Combinaciones complementarias
Las combinaciones complementarias
Ya vimos en uno de los primeros temas que el número de combinaciones sin repetición de n elementos distintos en subconjuntos de k elementos son las posibles muestras de k elementos distintos y sin orden que pueden extraerse de los n elementos. El número de subconjuntos S de orden k que se obtienen son las combinaciones C(n, k).
Si partimos del conjunto A con n elementos y Si,k designa uno de esos subconjuntos con 1 ≤ i ≤ C(n, k), entonces A - Si,k es el subconjunto complementario de combinaciones. Todos los subconjuntos complementarios conforman entonces las combinaciones complementarias de orden k del conjunto A con n elementos.
Las vamos a denotar como Cc(n, k), siendo obvio que el número de subconjuntos es el mismo, es decir, C(n, k) = Cc(n, k), puesto que las combinaciones responden a C(n, k) y las complementarias a C(n, n-k), pero son iguales puesto que:
C(n, n-k) = | n! | = | n! | = C(n, k) |
(n-k)! (n-(n-k))! | (n-k)! k! |
Ya vimos en uno de los primeros temas el algoritmo combine2(list, k) que nos permitía generar las combinaciones. Por ejemplo, si partimos del conjunto A = {a, b, c} obtenemos estos subconjuntos de combinaciones:
Las combinaciones complementarias se forman incluyendo en cada subconjunto los elementos restantes de A:
Podríamos implementar un algoritmo para generar complement(list, k)
. Pero si partimos del algoritmo de combine2(list, k)
no parece díficil obtener un conjunto de resultados que contenga las combinaciones y sus complementarias, algo como:
[[[a, b], [c]],
[[a, c], [b]],
[[b, c], [a]]]
Cada subresultado es un Array, como el primero [[a, b], [c]]
, donde la primera posición [a, b]
es una combinación y la segunda [c]
es su complementaria. Recuerde que, aunque se usan Arrays, no dejan de ser conjuntos, así que [a, b]
es en el fondo el subconjunto {a, b}
, donde el orden no importa. Generar combinaciones y complementarias en una misma estructura nos facilitará las cosas cuando veamos algoritmos que implementan las particiones.
Primer algoritmo para implementar combinaciones complementarias
Partimos del algoritmo combine2(list, k) exactamente igual, agregando en el caso base let complement = list.filter(v => !res.includes(v))
para incluir las combinaciones complementarias en cada subresultado:
// C(n, k) = ∑j=k..n C(j-1, k-1) function combineComplement1(list=[], k=0, n=list.length, res=Array.from({length:k}, ()==>""), result=[], start=true, n0=list.length, k0=res.length){ //if (start) { // iter += k; // start = false; //} if (k===0){ //iter += 1 + n0 * k0; let complement = list.filter(v => !res.includes(v)); //if (withCopyCost) iter += res.length; result.push([copy(res), complement]); } else { //iter += 1; for (let j=n; j=>=k; j--){ //iter += 1; res[k0-k] = list[n0-j]; combineComplement1(list, k-1, j-1, res, result, start); } } return result; }
Para analizar el coste simplemente recordamos el coste del algoritmo de partida combine2(list, k)
:
n≥k ⇒ T(n, k) = 2 C(n+1, k) - 1 + k
Como es el mismo algoritmo, sólo tenemos que añadir el coste de la sentencia let complement = list.filter(v => !res.includes(v))
. Si n0 es la longitud de la lista inicial list
y k0 es la de la sublista inicial res
, entonces el coste de esa sentencia es simplemente n0 × k0. Como hay C(n, k) subresultados, entonces la fórmula final será la siguiente, generalizado n0, k0 como n, k:
n≥k ⇒ T(n, k) = 2 C(n+1, k) - 1 + n k C(n, k) + k
En caso de considerar el coste de copia, hay que añadir el coste de copiar los C(n, k) subresultados res
cada uno con una longitud k:
n≥k ⇒ T(n, k) = 2 C(n+1, k) - 1 + n k C(n, k) + k + k C(n, k)
Segundo algoritmo para implementar combinaciones complementarias
En esta segunda implementación intentaremos evitar el uso de la sentencia let complement = list.filter(v => !res.includes(v))
.
// C(n, k) = ∑j=k..n C(j-1, k-1) function combineComplement2(list=[], k=0, n=list.length, res=Array.from({length:k}, ()=>""), resc=[...list], result=[], start=true, n0=list.length, k0=res.length){ //if (start) { // iter += n+k; // start = false; //} if (k===0){ //iter += 1; /* res.length + resc.length = k + (n-k) = n */ //if (withCopyCost) iter += res.length + resc.length; result.push([copy(res), copy(resc)]); } else { //iter += 1; for (let j=n; j>=k; j--){ //iter += 1; let pivot = list[n0-j]; res[k0-k] = pivot; //iter += resc.length-1; /* = k+n0-k0-1 */; resc = resc.slice(1); combineComplement2(list, k-1, j-1, res, resc, result, start); resc.push(pivot) } } return result; }
Aparte del subresultado res
, un Array de longitud inicial k0 para generar cada combinación, hemos de incluir otro Array resc
para generar cada combinación complementaria, con longitud inicial n0 pues es una copia de la lista list
, donde en cada caso n0 y k0 son los valores de la llamada inicial.
En el algoritmo vemos que extraemos el pivote let pivot = list[n0-j]
, lo mismo que hacíamos con el algoritmo de combine2(list, k)
. Pero ahora antes de cada llamada reducimos con resc.slice(1)
una unidad el subresultado complementario resc
y, tras la llamada volvemos a incorporar el pivote con resc.push(pivot)
. Más abajo veremos algunas consideraciones sobre el uso de métodos como slice()
o push()
en recursivos.
En cada llamada res
se rellena una posición que, con el caso base k===0
, alcanza la longitud k0 posiciones llenas, mientras que resc
se decrementa hasta alcanzar la longitud n0-k0, pues ambas longitudes sumaran n0 que es el tamaño de la lista inicial.
Por eso vemos que el coste de reducir resc.slice(1)
es resc.length-1
que es lo mismo que k+n0-k0-1. Si definimos b = n0-k0 que viene a ser constante en el desarrollo de la recurrencia, entonces ese coste es k+b-1.
Observe que no es necesario considerar el caso cuando n<k, pues entonces no se ejecuta el bucle for (let j=n; j>=k; j--)
, siendo el coste unitario correspondiendo al iter += 1
previo al bucle.
En el siguiente esquema se expone la ejecución del ejemplo combineComplement2([a,b,c], 2)
que genera tres subresultados [[a, b], [c]]
, [[a, c], [b]]
y [[b, c], [a]]]
:
list=[a,b,c] res=[,] resc=[a,b,c] call(n=3, k=2) loop j=3 pivot=a res=[a,] slice(1) resc=[b,c] call(n=2, k=1) loop j=2 pivot=b res=[a,b] slice(1) resc=[c] call(n=1, k=0) => [[a,b], [c]] push(b) resc=[c,b] j=1 pivot=c res=[a,c] slice(1) resc=[b] call(n=0, k=0) => [[a,c], [b]] push(c) resc=[b,c] push(a) resc=[b,c,a] j=2 pivot=b res=[b,c] slice(1) resc=[c,a] call(n=1, k=1) loop j=1 pivot=c res=[b,c] slice(1) resc=[a] call(n=0, k=0) => [[b,c], [a]] push(c) resc=[a,c] push(b) resc=[c,a,b]
Coste del segundo algoritmo para generar combinaciones complementarias
Vimos la definición de la recurrencia del algoritmo combine2(list, k):
T(n, k) = { | 1 | if k=0 |
1 + ∑j=k..n (1 + T(j-1, k-1)) | if k>0 |
La definición es ahora similar, incoporando k+b-1 que es el coste de reducir una unidad el subresultado complementario resc
:
t(n, k) = { | 1 | if k=0 |
1 + ∑j=k..n (1 + (k+b-1) + t(j-1, k-1)) | if k>0 |
En esta recurrencia diferenciamos t(n, k) de T(n, k), por la necesidad de incorporar el coste inicial n+k que sólo se suma cuando entramos por primera vez en el recursivo. Esto es el coste de componer los arrays res
y resc
, el primero con longitud k y el segundo con longitud n.
Adelantamos ya que no he podido obtener una fórmula cerrada para calcular el coste. Por lo tanto realizando una aplicación directa de la definición de la recurrencia, tenemos una de las tres soluciones recurrentes finales alternativas que vamos a ofrecer en este tema para el algoritmo combineComplement2(list, k)
:
k=0 ⇒ t(n, k, b) = 1,
t(n, k, b) = ∑j=k..n k + b + t(j-1, k-1, b)
T(n, k, b) = t(n, k, b) + n + k
Se calcula con este algoritmo:
function t(n, k, b=n-k) { if (k===0) { return 1; } else { let sum = 1; for (let j=k; j<=n; j++) { sum += k + b + t(j-1, k-1, b); } return sum; } }
Segunda solución alternativa
Intentaremos ver si podemos eliminar el sumatario, algo parecido a lo que hicimos con la recurrencia de combine2(list, k), partiendo de la recurrencia t(n, k)
Sumamos uno a n y k:
Por lo tanto la recurrencia equivale a esta si volvemos a reducir en una unidad los índices
La recurrencia del algoritmo combine2()
era similar sin el término k+b. Allí la resolví usando la técnica de la función generadora. Pero con esta no lo he logrado por ahora. Así que la fórmula final para el algoritmo combineComplement2(list, k)
queda, por ahora, así:
k=0 ∨ n<k ⇒ t(n, k) = 1
t(n, k) = k + b + t(n-1, k-1) + t(n-1, k)
T(n, k) = t(n, k) + n + k
En la herramienta Combine Tools usaré el siguiente recursivo para obtener el valor numérico de t(n, k, b), implementando directamente la recurrencia:
function t(n, k, b=n-k) { if (k===0 || n<k) { return 1; } else { return k + b + t(n-1, k-1, b) + t(n-1, k, b); } }
Vea que hemos de considerar el caso n<k.
Tercera solución alternativa
Intentemos eliminar el argumento b de la solución recurrente anterior. En primer lugar veamos los primeros valores que se obtienen con esa fórmula:
n↓k→ | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
0 | 1 | 2 | 3 | 4 | 5 |
1 | 2 | 5 | 4 | 5 | 6 |
2 | 3 | 10 | 10 | 6 | 7 |
3 | 4 | 17 | 23 | 16 | 8 |
4 | 5 | 26 | 46 | 42 | 23 |
Si nos centramos en t(n, k):
k=0 ∨ n < k ⇒ t(n, k, b) = 1
t(n, k, b) = k + b + t(n-1, k-1, b) + t(n-1, k, b)
Entonces los valores de la tabla anterior serían los siguientes, donde en cada celda restamos n+k:
n↓k→ | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
0 | 1 | 1 | 1 | 1 | 1 |
1 | 1 | 3 | 1 | 1 | 1 |
2 | 1 | 7 | 6 | 1 | 1 |
3 | 1 | 13 | 18 | 10 | 1 |
4 | 1 | 21 | 40 | 35 | 15 |
Supongamos ahora que eliminamos el argumento b, que antes era una constante en la evolución de la recurrencia y que tomaba el valor al inicio b = n0 - k0. Ahora será variable con valor b = n - k. Denominaremos a esta recurrencia q(n, k):
con la misma definición:
q(n, k) = n + q(n-1, k-1) + q(n-1, k)
Pero los términos recurrentes los tomaremos de t(n, k):
q(n, k) = n + t(n-1, k-1) + t(n-1, k)
Usando la tabla de valores que genera la recurrencia [1] desplegaremos valores para esta segunda recurrencia [2]:
n | k | q(n, k) = n + t(n-1, k-1) + t(n-1, k) | t(n,k) | t(n,k)- q(n,k) | C(n,k)-1 |
---|---|---|---|---|---|
0 | 0 | q(0, 0) = 1 | 1 | 0 | C(0, 0)-1 |
1 | 0 | q(1, 0) = 1 | 1 | 0 | C(1, 0)-1 |
1 | 1 | q(1, 1) = 1 + t(0, 0) + t(0, 1) = 1 + 1 + 1 = 3 | 3 | 0 | C(1, 1)-1 |
2 | 0 | q(2, 0) = 1 | 1 | 0 | C(2, 0)-1 |
2 | 1 | q(2, 1) = 2 + t(1, 0) + t(1, 1) = 2 + 1 + 3 = 6 | 7 | 1 | C(2, 1)-1 |
2 | 2 | q(2, 2) = 2 + t(1, 1) + t(1, 2) = 2 + 3 + 1 = 6 | 6 | 0 | C(2, 2)-1 |
3 | 0 | q(3, 0) = 1 | 1 | 0 | C(3, 0)-1 |
3 | 1 | q(3, 1) = 3 + t(2, 0) + t(2, 1) = 3 + 1 + 7 = 11 | 13 | 2 | C(3, 1)-1 |
3 | 2 | q(3, 2) = 3 + t(2, 1) + t(2, 2) = 3 + 7 + 6 = 16 | 18 | 2 | C(3, 2)-1 |
3 | 3 | q(3, 3) = 3 + t(2, 2) + t(2, 3) = 3 + 6 + 1 = 10 | 10 | 0 | C(3, 3)-1 |
4 | 0 | q(4, 0) = 1 | 1 | 0 | C(4, 0)-1 |
4 | 1 | q(4, 1) = 4 + t(3, 0) + t(3, 1) = 4 + 1 + 13 = 18 | 21 | 3 | C(4, 1)-1 |
4 | 2 | q(4, 2) = 4 + t(3, 1) + t(3, 2) = 4 + 13 + 18 = 35 | 40 | 5 | C(4, 2)-1 |
4 | 3 | q(4, 3) = 4 + t(3, 2) + t(3, 3) = 4 + 18 + 10 = 32 | 35 | 3 | C(4, 3)-1 |
4 | 4 | q(4, 4) = 4 + t(3, 3) + t(3, 4) = 4 + 10 + 1 = 15 | 15 | 0 | C(4, 4)-1 |
Obtenemos cada valor q(n, k) a partir de valores anteriores de t(n, k) que obtenemos de la tabla que se genera a partir de la fórmula [1]. Se observa que las diferencias en cada fila t(n, k) - q(n, k) obedecen al término C(n, k) -1. Entonces, aunque sin demostrar de forma genérica, podemos definir esta solución:
t(n, k) = n + C(n, k) - 1 + t(n-1, k-1) + t(n-1, k)
Con lo que tenemos otra solución alternativa para el coste de nuestro algoritmo combineComplement2(n, k)
t(n, k) = n + C(n, k) - 1 + t(n-1, k-1) + t(n-1, k)
T(n, k) = t(n, k) + n + k
Usaremos este algoritmo para obtener el valor de t(n, k) en la recurrencia anterior:
function t(n, k) { if (k===0 || n<k) { return 1; } else { return n + binomial(n, k) - 1 + t(n-1, k-1) + t(n-1, k); } }
Coste de copiar subresultados
Como vemos en el código del algoritmo cuando llega al caso base
... if (k===0){ iter += 1; /* res.length + resc.length = k + (n-k) = n */ if (withCopyCost) iter += res.length + resc.length; result.push([copy(res), copy(resc)]); } else { ...
en caso de computar el coste de copiar los subresultados, hemos de agregar por cada combinación y su complementaria res.length + resc.length
, lo que equivale a k + (n-k) = n, multiplicado por C(n, k) subresultados que se generan. Así que en caso de copia las tres fórmulas quedan así agregando n C(n, k)
k=0 ⇒ t(n, k, b) = 1,
t(n, k, b) = ∑j=k..n k + b + t(j-1, k-1, b)
T(n, k, b) = t(n, k, b) + n C(n, k) + n + k
k=0 ∨ n<k ⇒ t(n, k) = 1
t(n, k) = k + b + t(n-1, k-1) + t(n-1, k)
T(n, k) = t(n, k) + n C(n, k) + n + k
t(n, k) = n + C(n, k) - 1 + t(n-1, k-1) + t(n-1, k)
T(n, k) = t(n, k) + n C(n, k) + n + k
Comparando costes de combineComplement1() y combineComplement2()
En la siguiente tabla vemos el coste de combineComplement1(list, 5)
, combineComplement2(list, 5)
así como combine2(list, 5)
, algoritmo en el que nos basamos para desarrollar los otros dos, usando k = 5 para los primeros valores de n:
- T(n, 5) parar el algoritmo
combine2(n, 5)
- T1(n, 5) parar el algoritmo
combineComplement1(n, 5)
- T2(n, 5) parar el algoritmo
combineComplement2(n, 5)
Por supuesto que no alcanzan el mejor coste de combine2
, pero a medida que n crece, mayor es el porcentaje de reducción de combineComplement2
respecto a combineComplement1
.
n | T(n, 5) | T1(n, 5) | T2(n, 5) | % T2-T1 |
---|---|---|---|---|
5 | 16 | 41 | 31 | -24.4 |
6 | 19 | 55 | 40 | -27.3 |
7 | 61 | 355 | 145 | -59.2 |
8 | 173 | 1517 | 467 | -69.2 |
9 | 425 | 4961 | 1307 | -73.7 |
10 | 929 | 13529 | 3239 | -76.1 |
11 | 1853 | 32345 | 7265 | -77.5 |
12 | 3437 | 69965 | 15020 | -78.5 |
13 | 6011 | 139859 | 29034 | -79.2 |
14 | 10015 | 262267 | 53058 | -79.8 |
15 | 16021 | 466471 | 92461 | -80.2 |
Uso de métodos de Array en recursivos
En este apartado exponemos detalles que vimos en un apartado más arriba relacionado con el uso del método slice()
de un Array en JavaScript. En el tema métodos para modificar el propio array vimos que eran, entre otros, los siguientes que podríamos encontrar en algoritmos recursivos que manejan arrays:
let x = arr.push("a")
para agregar un elemento al final del array, devolviendo la longitud del array.let x = arr.pop()
para extrer elemento al final del array, devolviéndolo.let x = arr.unshift("a")
para agregar un elemento al principio del array, devolviendo su longitud.let x = arr.shift("a")
para extraer un elemento al principio del array, devolviéndolo.let x = arr.splice(start, num, "a", "b", ...)
para eliminarnum
elementos desde la posiciónstart
, pudiendo opcionalmente insertarse en esa posición los elementos"a", "b", ...
Devuelve un array con los elementos eliminados.- Hay otros que modifican el propio array, como
reverse()
,sort()
,copyWithin()
yfill()
.
Todos estos métodos modifican el propio array, así que si arr
es un array, la sentencia arr.pop()
elimina el último elemento, pero lo que devuelve no es el array modificado, sino el elemento eliminado. Por otro lado hay otros métodos para crear nuevos arrays como los siguientes:
let arr2 = arr.concat("a", "b")
concatena al final del arrayarr
los elementos que se le pasan como argumentos. Si se le pasan arrays se concatenarán sus elementos. Devuelve un nuevo array, que podríamos asignar al mismo u a otro nuevo, no modificando el arrayarr
sobre el que se aplica el método. En el fondo está haciendo una copia dearr
e insertando al final los nuevos elementos. Es como si utilizáramos el operador de propagación en un nuevo arraylet arr2 = [...arr, "a", "b"]
, pues...arr
copia los elementos.let arr2 = arr.map(v => v+1)
parar iterar por un array que podríamos suponer comoarr = [1, 2, 3]
, devolviendo un nuevo arrayarr2 = [2, 3, 4]
pues suma uno a cada elemento. El array de origen (sobre el que se aplica el método) no se modifica.let arr2 = arr.slice(start, end)
para recortar un array desde la posiciónstart
hasta la posiciónend
, sin incluir esta. Si no se pasa el segundo argumento se entiende que esarr.length
, es decir, el final del array. Si por ejemploarr = [1, 2, 3]
, entonceslet arr2 = arr.slice(1)
recortará desde la posición uno devolviendoarr2 = [2, 3]
. No modifica el array de origen.let arr2 = arr.filter(v => v>1)
para filtrar un array, de tal forma que siarr = [1, 2, 3]
nos devolverá un nuevo arrayarr2 = [2, 3]
. No modifica el array de origen.
En nuestro algoritmo combineComplement(list, k)
teníamos que eliminar el primer elemento del array resc
antes de hacer una nueva llamada recursiva, donde pasábamos resc
como argumento. Tras la llamada teníamos que insertar el pivote al final de ese array:
resc = resc.slice(1); combineComplement2(list, k-1, j-1, res, resc, result, start); resc.push(pivot)
Para eliminar el primer elemento, en lugar de usar resc = resc.slice(1)
, podríamos haber usado resc.shift()
o incluso resc.splice(0, 1)
. Pero no funcionará el recursivo dado que necesitamos crear un nuevo array para pasarlo a la llamada recursiva. Si no lo hacemos así, estaremos pasando la misma referencia en cada llamada. Así que hemos de usar métodos que creen nuevos arrays, como el que usamos resc = resc.slice(1)
o incluso resc = resc.filter(v => v!==pivot)
, pues es precisamente el pivote pivot
el que tenemos que eliminar de resc
.
Sin embargo la sentencia posterior a la llamada resc.push(pivot)
es del tipo que modifica el propio array, pero podemos utilizarlo pues es posterior a la llamada y su estado queda circunscrito a la ejecución en ese momento.
En un tema del año 2016 hice una funcionalidad para mostrar los Métodos de Array existentes en el navegador. En la Figura se observan algunos en color azul con vínculos que llevan a su explicación en otros temas. Pero hay otros nuevos sin vínculos que se han incorporado a JavaScript posteriormente a la publicación de ese tema. Vemos toReversed()
, toSorted()
y toSpliced()
.
Precisamente esos que se incorporan son versiones que van a funcionar como el segundo grupo que vimos antes. Así toSpliced()
hace lo mismo que splice()
pero no modificando el array de origen y devolviendo un nuevo array. En el ejemplo de nuestro algoritmo podríamos también haber usado resc = resc.toSpliced(0, 1)
.