Combinaciones sin repetición con algoritmo mejorado

Figura
Figura. Identidad Hockey-stick

Hay varias identidades relacionadas con los coeficientes binomiales. Una de ellas es la que vemos en la Figura, obtenida a partir de la denominada identidad Hockey-stick. Vemos que C(6, 3) = C(5, 2)+C(4, 2)+C(3, 2)+C(2, 2) = 10+6+3+1 = ∑j=2..5 C(j, 2) = 20. Ese último sumatorio también lo podemos expresar como j=3..6 C(j-1, 3-1) = 20, de lo que obtenemos la fórmula general C(n, k) = ∑j=k..n C(j-1, k-1). La denominación hockey stick proviene de la forma palo de hockey que se visualiza en el Triángulo de Pascal.

Se observa que es una expresión recurrente que podría servirnos para buscar una versión mejorada del problema de obtener los subconjuntos combinaciones de un conjunto que vimos en el tema anterior. Es importante entender que no buscamos el valor numérico de C(n, k), sino construir ese conjunto de combinaciones. Por ejemplo, las combinaciones sin repetición de la lista de letras {a, b, c, d} tomados en subconjuntos de dos elementos sería una lista con los subconjuntos {{a, b}, {a, c}, {a, d}, {b, c}, {b, d}, {c, d}}.

En la primera versión de este algoritmo del tema anterior habíamos usado la identidad básica del Triángulo de Pascal C(n, k) = C(n-1, k-1) + C(n-1, k), obteniendo el coste T(n, k) = 2 ( ∑j=0..k C(n, j) ) - 1 en la versión básica que no tenía en cuenta el coste de la copia. Tomemos ahora la identidad siguiente para ver si nos conduce a una mejora de ese coste:

C(n, 0) = 1,
C(n, k) = ∑j=k..n C(j-1, k-1)

Para demostrar que esta expresión es cierta desplegamos la serie:

j=k..n C(j-1, k-1) = C(k-1, k-1) + C(k, k-1) + C(k+1, k-1) + ... + C(n-1, k-1)

Tomando el principio matemático C(n, k) = C(n-1, k-1) + C(n-1, k) que ya hemos usado repetidas veces antes, que podemos expresar como C(n-1, k-1) = C(n, k) - C(n-1, k), vemos que cada termino de la suma anterior empezando por el final es como cada una de las líneas siguientes:

C(n-1, k-1) = C(n, k) - C(n-1, k)
C(n-2, k-1) = C(n-1, k) - C(n-2, k)
C(n-3, k-1) = C(n-2, k) - C(n-3, k)
C(n-4, k-1) = C(n-3, k) - C(n-4, k)
...
C(k+1, k-1) = C(k+2, k) - C(k+1, k)
C(k, k-1) = C(k+1, k) - C(k, k)
C(k-1, k-1) = C(k, k) - C(k-1, k)

Observe que al sumar los términos en rojo en la primera y segunda línea se anulan. Y así todos los demás quedando sólo el primero y el último. Dado que cuando n < k ⇒ C(n, k) = 0 entonces como k-1 < k ⇒ C(k-1, k) = 0 obteniendo finalmente la demostración:

j=k..n C(j-1, k-1) = C(n, k) - C(k-1, k) = C(n, k) - 0 = C(n, k)

Demostrando la fórmula del sumatorio con función generadora

Aunque en el apartado anterior demostramos la recurrencia vista para n≥k

C(n, 0) = 1,
C(n, k) = ∑j=k..n C(j-1, k-1)

también podríamos generalizarla para cualquier caso así:

C(0, 0) = 1,
C(0, k) = 0 si k> 0
C(n, 0) = 1
C(n, k) = ∑j=k..n C(j-1, k-1)

En este apartado vamos a obtener lo mismo usando la técnica de la función generadora. Esto nos servirá para aplicarlo al resto del tema y verlo desde otra perspectiva. Para ello partiremos de la siguiente recurrencia, donde X(n, k) es, supuestamente, una función combinatoria desconocida que al final hemos de identificar con C(n, k):

X(0, 0) = 1,
X(0, k) = 0 si k> 0
X(n, 0) = 1
X(n, k) = ∑j=k..n X(j-1, k-1)

Usaremos la siguiente función generadora:

G(x, y) = ∑n,k≥0 X(n, k) xn yk

Veamos las condiciones iniciales

G0,0(x, y) = X(0, 0) = 1
G0,k(x, y) = ∑k≥0 X(0, k) yk = X(0, 0) + ∑k≥1 X(0, k) yk = 1
Gn,0(x, y) = ∑n≥0 X(n, 0) xn =1
1-x

Como la recurrencia tiene un sumatorio, intentaremos evitarlo haciendo esto:

X(n+1, k+1) = ∑j=k+1..n+1 X(j-1, k) =
= ∑j=n+1..n+1 X(j-1, k) + ∑j=k+1..n X(j-1, k) =
= X(n, k) + X(n, k+1)

Entonces obtenemos X(n+1, k+1) = X(n, k) + X(n, k+1) que es lo mismo que la identidad del Triángulo de Pascal C(n, k) = C(n-1, k-1) + C(n-1, k) si incrementamos un índice C(n+1, k+1) = C(n, k) + C(n, k+1) como era de esperar. Apliquemos la función generadora a la identidad obtenida:

n,k≥0 X(n+1, k+1) xn yk = ∑n,k≥0 X(n, k) xn yk + ∑n,k≥0 X(n, k+1) xn yk

Expresemos X(n+1, k+1) en función de X(n, k)

G(x, y) = ∑n,k≥0 X(n, k) xn yk =
= ∑n≥0 X(n, 0) xn + ∑k≥0 X(0, k) yk - X(0, 0) + ∑n,k≥0 X(n+1, k+1) xn+1 yk+1 =
= Gn,0(x, y) + G0,k(x, y) - G0,0(x, y) + ∑n,k≥0 X(n+1, k+1) xn+1 yk+1 =
=1+ 1 - 1 + ∑n,k≥0 X(n+1, k+1) xn+1 yk+1 =
1-x
=1+ xy ∑n,k≥0 X(n+1, k+1) xn yk
1-x

De donde

n,k≥0 X(n+1, k+1) xn yk =1( G(x, y) -1)
xy1-x

Expresemos X(n, k+1) en función de X(n, k)

G(x, y) = ∑n,k≥0 X(n, k) xn yk =
= ∑n≥0 X(n, 0) xn + ∑n,k≥0 X(n, k+1) xn yk+1 =
= Gn,0(x, y) + ∑n,k≥0 X(n, k+1) xn yk+1 =
=1+ ∑n,k≥0 X(n, k+1) xn yk+1 =
1-x
=1+ y ∑n,k≥0 X(n, k+1) xn yk
1-x

De donde

n,k≥0 X(n, k+1) xn yk =1( G(x, y) -1)
y1-x

Sustituyendo tenemos

1( G(x, y) -1) = G(x, y) +1( G(x, y) -1)
xy1-xy1-x

Despejando obtenemos la función generadora:

G(x, y) =1
1-x-xy

Multiplicamos y dividimos por 1-y:

G(x, y) =1-y=
(1-y)(1-x-xy)
=1-y
(1-y)(1-x-xy)(1-y)(1-x-xy)

El primer término ya lo conocimos en el tema anterior al ver los coeficientes binomiales con 1/(1-x-xy) = ∑n≥0 (1+y)n xn que con 1/(1-y) = ∑k≥0 yk nos queda:

1= ∑n,k≥0 (1+y)n xn yk
(1-y)(1-x-xy)

Para el segundo término hacemos lo mismo

y= ∑n,k≥0 y (1+y)n xn yk
(1-y)(1-x-xy)

Obviamente conocemos el Binomio de Newton (1+y)n = ∑j≥0 C(n, j) y j con lo que tenemos:

G(x, y) = ∑n,k≥0 (1+y)n xn yk - ∑n,k≥0 y (1+y)n xn yk =
= ∑n,k≥0 (j≥0 C(n, j) y j ) xn yk - ∑n,k≥0 ( y ∑j≥0 C(n, j) y j ) xn yk =
= ∑n,k≥0 (j≥0 C(n, j) y j - y ∑j≥0 C(n, j) y j ) xn yk =
= ∑n,k≥0 (j≥0 C(n, j) y j - ∑j≥0 C(n, j) y j+1 ) xn yk =
= ∑n,k≥0 (j≥0 C(n, j) y j - ∑j≥1 C(n, j-1) y j ) xn yk

Nuestro término general es entonces:

X(n, k) = ∑j≥0 C(n, j) - ∑j≥1 C(n, j-1)

Como j≤k podemos ajustar los índices, teniendo en cuenta que

j=1..k C(n, j-1) = ∑j=1..k C(n, j-1) = C(n, 0) + C(n, 1) + ... + C(n, k-1) =
= ∑j=0..k-1 C(n, j)

por lo que finalmente obtenemos:

X(n, k) = ∑j=0..k C(n, j) - ∑j=1..k C(n, j-1) =
= ∑j=0..k C(n, j) - ∑j=0..k-1 C(n, j) =
= C(n, k)

con lo que obtenemos el resultado esperado X(n, k) = C(n, k).

Implementando algoritmo para generar combinaciones

Implementemos eso en un algoritmo:

// C(n, k) = ∑j=k..n C(j-1, k-1)
function combine2(list=[], k=0, n=list.length, res=Array.from({length:k}, ()=>""), result=[], n0=list.length, k0=res.length){
    if (k===0){
        result.push(copy(res));
    } else {
        for (let j=n; j>=k; j--){
            res[k0-k] = list[n0-j];
            combine2(list, k-1, j-1, res, result);
        }
    }
    return result;
}

En este algoritmo n0 y k0 son los valores en la llamada inicial combine2(n0, k0). En el bucle he invertido el rango [k, n] con objeto de que el conjunto resultado salga con el mismo orden que el algoritmo que vimos en el tema anterior, pero a efectos de la definición de la recurrencia se considera ese rango [k, n] con la llamada interna combine2(list, k-1, j-1).

Antes de estudiar el coste haremos una prueba experimental. Consideremos los costes con el algoritmo del tema anterior que denominaremos T1(n, k) y con este mejorado T2(n, k), obteniendo los valores para n=6 con 0 ≤ k ≤ 6:

n=6
k    C(n,k)  T1(n,k) T2(n,k)  T1(n,k)-T2(n,k)
---------------------------------------------
0    1       1        1        0
1    6       13       13       0
2    15      43       41       2
3    20      83       69       14
4    15      113      69       44
5    6       125      41       84
6    1       127      13       114

T1(n, k) - T2(n, k) = T1(n, k-2) + 1 =>
T2(n, k) = T1(n, k) - T1(n, k-2) - 1

Se observa que en esta prueba se cumple:

T2(n, k) = T1(n, k) - T1(n, k-2) - 1

Recordando el coste del algoritmo del tema anterior:

T1(n, k) = 2 ( ∑j=0..k C(n, j) ) - 1

Entonces

T2(n, k) = T1(n, k) - T1(n, k-2) - 1 =
= 2 ( ∑j=0..k C(n, j) ) -1 - ( 2 ( ∑j=0..k-2 C(n, j) ) - 1 ) - 1 =
= 2 ( ∑j=0..k C(n, j) ) -1 - ( 2 ( ∑j=0..k-2 C(n, j) ) - 1 ) - 1 =
= 2 ( ∑j=0..k C(n, j) - (∑j=0..k C(n, j) - C(n, k) - C(n, k-1) ) ) - 1 =
= 2 ( C(n, k) + C(n, k-1) ) - 1 =
= 2 C(n+1, k) - 1

De forma experimental hemos llegado a obtener el coste para este algoritmo mejorado. La función generadora G(x, y) con ese término general es (wolframalpha):

G(x, y) = ∑n,k≥0 T(n, k) xnyk =
= ∑n,k≥0 ( 2 C(n+1, k) - 1) xnyk =
=2xy2-2y2+1-x+xy
(1-x)(1-y)(1-x-xy)

Si F(x, y) es la función generadora de T1(n, k) del tema anterior que recordamos nuevamente:

F(x, y) = ∑n,k≥0 (2 (1+y)n - 1) xn yk =
= ∑n,k≥0 (2 ( ∑j≥0 C(n, j) yj ) - 1) xn yk =
=1-x+xy
(1-x)(1-y)(1-x-xy)

Vemos que relación hay entre G(x, y) y F(x, y)

G(x,y) =2xy2-2y2+1-x+xy=
(1-x)(1-y)(1-x-xy)
=1-x+xy-2y2(1-x)=
(1-x)(1-y)(1-x-xy)(1-x)(1-y)(1-x-xy)
= F(x, y) -2y2
(1-y)(1-x-xy)

Denominemos el término de la derecha como H(x, y), ecuación que tiene el siguiente desarrollo en series (wolframalpha):

H(x, y) =2y2= ∑n,k≥0 2y2(1+y)n xnyk
(1-y)(1-x-xy)

La resta F(x, y) - H(x, y) nos devuelve la serie que sustenta G(x, y) (wolframalpha):

G(x, y) = F(x, y) - H(x, y) =
= ∑n,k≥0 (2(1+y)n-1) xnyk - ∑n,k≥0 2y2(1+y)n xnyk =
= ∑n,k≥0 (2 (1-y2) (1+y)n -1) xnyk =
=2xy2-2y2+1-x+xy
(1-x)(1-y)(1-x-xy)

Vemos que este algoritmo mejorado resta el coste H(x, y) al primer algoritmo. Pero todo esto es una suposición experimental. Intentaré en los siguientes apartados que se sustente matemáticamente.

Desarrollo para resolver el coste del algoritmo mejorado (sin coste de copia)

Este algoritmo mejorado se basa en el principio matemático siguiente que ya demostramos en el apartado anterior:

C(n, k) = ∑j=k..n C(j-1, k-1)

Con este principio se construye nuestro algoritmo, donde la serie equivale a un bucle for. Ya comentamos que es un bucle invertido, es decir, en lugar de ser [k, n] es [n, k]. Lo hacemos así con el único objetivo de que el resultado salga ordenado, pero realmente es indiferente para la ejecución del algoritmo. Sin embargo hay que tener cuidado cuando equiparamos un bucle de un algoritmo con una serie matemática, pues una serie con rango invertido tiene como resultado cero, el elemento neutro de la suma. Es decir, si en general j > k ⇒ ∑i=j..k ai = 0. Volveremos a incidir sobre esto porque es un aspecto clave.

Volvemos a repetir aquí el algoritmo que vamos a analizar:

function combine2(list=[], k=0, n=list.length, res=Array.from({length:k}, ()=>""), result=[], n0=list.length, k0=res.length){
    if (k===0){
        iter += 1;
        result.push(copy(res));
    } else {
        iter += 1;
        for (let j=n; j>=k; j--){
            iter += 1;
            res[k0-k] = list[n0-j];
            combine2(list, k-1, j-1, res, result);
        }
    }
    return result;
}

Esta es la definición planteada, donde ahora no contemplamos el coste de copiar cada resultado parcial:

T(n, k) = {1if k=0
1 + ∑j=k..n (1 + T(j-1, k-1))if k>0

Lo llamamos básico pues en el desarrollo a continuación descubriremos las dudas con las condiciones iniciales. En las definiciones de algoritmo del tema anterior teníamos claro las condiciones iniciales:

T(n, 0) = T(0, k) = T(0, 0) = 1
T(n, k) = 1 + T(n-1, k-1) + T(n-1, k)

Pero en este segundo caso no está tan claro, pues no tienen porque ser las mismas:

T(n, 0) = ?, T(0, k) = ?, T(0, 0) = ?
T(n, k) = 1 + ∑j=k..n 1+T(j-1, k-1)

Parece obvio que T(n, 0) = 1 puesto que vemos en el algoritmo que si k=0 se produce una única iteración, entrando en el condicional if (k===0) y por lo tanto T(n, 0) = 1. Obtenemos Gn,0(x, y):

Gn,0(x, y) = ∑n≥0 T(n, 0) xn = ∑n≥0 xn =1
1-x

Si queremos comprobar que T(n, 0) = 1 consideremos la solución general obtenida por experimentación T(n, k) = 2 C(n+1, k) - 1 y que luego demostraremos que es así, usándola de esta forma:

T(n, 0) = 2 C(n+1, 0) - 1 = 2(n+1)!- 1 = 2-1 = 1
0! (n+1-0)!

Por otro lado también es obvio que T(0, 0) =1 pues es un caso particular con k=0, con lo que tenemos:

G0,0(x, y) = T(0, 0) = 1

Veamos ahora T(0, k), que en el tema anterior era 1 y ahora no es exactamente eso. En el algoritmo vemos que si k>0 entra en el condicional donde está el bucle que itera en el rango [k, 0], pero es un rango inverso que supone que no se ejecute el bucle con k>0. El coste parece ser 1 para cualquier valor de k, pero en matemáticas este bucle con rango invertido si tiene sentido cuando intentamos expresar la recurrencia como una serie. Si el bucle equivale a una serie, entonces con m>n ⇒ ∑j=m..n aj = 0 resultando el elemento neutro de la suma.

Para aclarnos apliquemos la solución experimental T(n, k) = 2 C(n+1, k) - 1 a los primeros valores de k con n=0:

T(0, 0) = 2 C(1, 0) - 1 = 21!- 1 = 2×1 - 1 = 1
0! (1-0)!
T(0, 1) = 2 C(1, 1) - 1 = 21!- 1 = 2×1 - 1 = 1
1! (1-1)!
T(0, 2) = 2 C(1, 2) - 1 = 21!- 1 = 2×0 - 1 = -1
2! (1-2)!
T(0, 3) = 2 C(1, 3) - 1 = 21!- 1 = 2×0 - 1 = -1
3! (1-3)!
...
T(0, k) = 2 C(1, k) - 1 = 21!- 1 = 2×0 - 1 = -1
k! (1-k)!

En general si k≤1 ⇒ C(1, k) = 1 y si k>1 ⇒ C(1, k) = 0. Puede comprobarlo en WolframAlpha. Vease que tenemos un factorial negativo en el denominador con k>1. El factorial negativo no está definido, pero en series de potencias entendemos que el término (1-k)! → ∞ cuando k→∞, y dado que el numerador es uno entonces el cociente 1 / (k! ∞) → 0.

Entonces tenemos que si k=0 ∨ k=1 ⇒ T(0, k) = 1 y si k≥2 ⇒ T(0, k) = -1. Vease además que con T(0, k) y T(n, 0) tenemos que si n=0 ∧ k=0 en ambos casos T(0, 0) =1. Con esto las condiciones iniciales son

n ≥ 0 ⇒ T(n, 0) = 1
0 ≤ k ≤ 1 ⇒ T(0, k) = 1
k > 1 ⇒ T(0, k) = -1

En un apartado más abajo incidiremos sobre estas condiciones iniciales y su implementación en el algoritmo. Veamos ahora G0,k(x, y) usando estas condiciones iniciales:

G0,k(x, y) = ∑k≥0 T(0, k) yk = T(0, 0) y0 + T(0, 1) y1 + ∑k≥2 T(0, k) yk =
= 1 + y + ∑k≥2 (-1) yk = 1 + y - ∑k≥0 yk+2 = 1 + y - y2k≥0 yk =
= 1 + y - y21=1-2y2
1-y1-y

Expresemos T(n, k) = 1 + ∑j=k..n 1+T(j-1, k-1) en función de n+1 y k+1 como hicimos en el primer apartado

T(n+1, k+1) = 1 + ∑j=k+1..n+1 1+T(j-1, k) = 1 + (1+T(n, k)) + ∑j=k+1..n 1+T(j-1, k) =
= 1 + T(n, k) + 1 + ∑j=k+1..n 1+T(j-1, k) = 1 + T(n, k) + T(n, k+1)

Por lo tanto nuestra recurrencia en términos de n+1 y k+1 queda así:

T(n+1, k+1) = 1 + T(n, k) + T(n, k+1)

Como hicimos en el primer apartado apliquemos series a esta expresión

n,k≥0 T(n+1, k+1) xnyk =
= ∑n,k≥0 xnyk + ∑n,k≥0 T(n, k) xnyk + ∑n,k≥0 T(n, k+1) xnyk

donde hemos de obtener G(x, y) = ∑n,k≥0 T(n, k) xnyk. Empezaremos por obtener el término general para T(n, k+1):

n,k≥0 T(n, k) xnyk = ∑n≥0 T(n, 0) xn + ∑n,k≥0 T(n, k+1) xnyk+1 =
n≥0 T(n, 0) xn + y ∑n,k≥0 T(n, k+1) xnyk

de donde

n,k≥0 T(n, k+1) xnyk =1( G(x, y) -1)
y1-x

Y ahora el término general para T(n+1, k+1):

n,k≥0 T(n, k) xnyk =
= ∑n≥0 T(n, 0) xn + ∑k≥0 T(0, k) yk - T(0, 0) + ∑n,k≥0 T(n+1, k+1) xn+1yk+1 =
= ∑n≥0 T(n, 0) xn + ∑k≥0 T(0, k) yk - T(0, 0) + xy ∑n,k≥0 T(n+1, k+1) xnyk

de donde

n,k≥0 T(n+1, k+1) xnyk =1( G(x, y) -1-1-2y2+ 1 )
xy1-x1-y

Sustituimos en la expresión de series:

1( G(x, y) -1-1-2y2+ 1 ) =
xy1−x1-y
1+ G(x, y) +1( G(x, y) -1)
(1-x)(1-y)y1-x

De donde se deduce la función generadora de nuestro algoritmo mejorado:

G(x, y) =2xy2-2y2+1-x+xy
(1-x)(1-y)(1-x-xy)

Es la misma que obtuvimos de forma experimental y que puede comprobarse en wolframalpha. Teniendo la expresión G(x, y) hemos de buscar el término general. Para ello separemos en dos partes como hicimos en el apartado experimental:

G(x, y) = F(x, y) - H(x, y)

Entonces el término general es la siguiente resta, usando el término general de F(x, y) que obtuvimos en el primer apartado:

TG (n, k) = TF (n, k) - TH (n, k) = 2 ( ∑j=0..k C(n, j) ) - 1 - TH (n, k)

El coste TH resta y, por tanto, mejora al de TF. Estas son las dos funciones generadoras:

F(x, y) =1-x+xy
(1-x)(1-y)(1-x-xy)
H(x, y) =2y2-2xy2=2y2(1-x)=2y2
(1-x)(1-y)(1-x-xy)(1-x)(1-y)(1-x-xy)(1-y)(1-x-xy)

Puede ver en wolframalpha la expresión en series de H(x, y), aunque en el desplegable siguiente puede ver el cálculo formal usando desarrollo de Taylor:

H(x, y) =2y2= ∑n,k≥0 2y2(1+y)nxnyk
(1-y)(1-x-xy)

Por un lado ya conocemos la siguiente identidad binomial:

(1+y)n = ∑j≥0 C(n, j) y j

Y por otro haremos esto

H(x, y) = ∑n,k≥0 2y2(1+y)nxnyk =
= 2 ∑n,k≥0 ( y2j≥0 C(n, j) y j ) xnyk =
= 2 ∑n,k≥0 (j≥0 C(n, j) y j+2 ) xnyk =
= 2 ∑n,k≥0 (j≥2 C(n, j-2) y j ) xnyk

Como 2 ≤ j ≤ k entonces

j≥2 C(n, j-2) = ∑j=2..k C(n, j-2) = C(n, 0) + C(n, 1) + ... + C(n, k-2) =
= ∑j=0..k-2 C(n, j)

entonces

H(x, y) = 2 ∑n,k≥0 (j=0..k-2 C(n, j) y j ) xnyk

siendo el término general el siguiente:

TH (n, k) = 2 ∑j=0..k-2 C(n, j)

Usando la igualdad C(n, k) + C(n, k-1) = C(n+1, k) tenemos finalmente la solución buscada:

TG (n, k) = 2 (j=0..k C(n, j) ) - 1 - 2 ∑j=0..k-2 C(n, j) =
= 2 (j=0..k C(n, j) - ( - C(n, k-1) - C(n, k) + ∑j=0..k C(n, j) )) - 1 =
= 2 ( C(n, k) + C(n, k-1) ) - 1 = 2 C(n+1, k) - 1

En esta forma se visualiza que el coste final se reduce con respecto a la solución del primer apartado en k-2+1 términos de la serie:

T(n, k) = 2 (j=0..k C(n, j) - ∑j=0..k-2 C(n, j) ) - 1

En cualquier caso la solución es más cómoda de usar de esta forma:

T(n, k) = 2 C(n+1, k) - 1

Estas soluciones hay que ajustarlas como veremos en el siguiente apartado. Veáse que en la solución se observa el principio en que se basa este algoritmo mejorado:

C(n+1, k) = ∑j=0..k C(n, j) - ∑j=0..k-2 C(n, j)

Ajustar algoritmo mejorado para C(n, k) con k>n+1 e incoporar coste de argumentos

En un apartado anterior descubrimos las condiciones iniciales necesarias para representar la recurrencia como series:

n ≥ 0 ⇒ T(n, 0) = 1
0 ≤ k ≤ 1 ⇒ T(0, k) = 1
k > 1 ⇒ T(0, k) = -1

Hay que tener en cuenta que estas condiciones iniciales contemplan que pueda darse el caso k > n, cuando sabemos que las combinaciones C(n, k) en este caso no existen. O dicho de otra forma, existen pero supone un conjunto vacío, pues no se pueden combinar n elementos en subconjuntos de k elementos cuando k>n. Pero como necesitábamos expresar como series la posibilidad de que k>n, adaptaremos también el código del algoritmo en la implementación para contemplar esto.

Vease que con cualquier n>0 ∧ k>0 cuando n = k tenemos T(n, n+1) = 1, T(n, n+2) = -1, T(n, n+3) = -1, ... siendo -1 todos los valores a partir de k > n+1. Por ejemplo, consideremos n=3 y veamos los valores del coste calculado T(n, k) = 2 C(n+1, k) - 1 para valores de k=0, 1, 2, 3, ...

T(n, k) = 2 C(n+1, k) - 1
T(3, 0) = 2 C(4, 0) - 1 = 1
T(3, 1) = 2 C(4, 1) - 1 = 7
T(3, 2) = 2 C(4, 1) - 1 = 11
T(3, 3) = 2 C(4, 3) - 1 = 7
T(3, 4) = 2 C(4, 4) - 1 = 1
T(3, 5) = 2 C(4, 5) - 1 = -1
T(3, 6) = 2 C(4, 6) - 1 = -1
...
j>4 ⇒ T(3, j) = 2 C(4, j) - 1 = -1

Si vemos el algoritmo, cuando k>n+1 entra en la parte del else sumando un iter += 1 de la cabecera del bucle, aunque no entra en ese bucle y, por tanto, no produce nueva llamada recursiva, con lo que el coste final será 1.

function combine2(list=[], k=0, n=list.length, res=Array.from({length:k}, ()=>""),
result=[], n0=list.length, k0=res.length){
    if (k===0){
        iter += 1;
        result.push(copy(res));
    } else {
        iter += 1;
        for (let j=n; j>=k; j--){
            iter += 1;
            res[k0-k] = list[n0-j];
            combine2(list, k-1, j-1, res, result);
        }
    }
    return result;
}

Sin embargo en el coste calculado 2 C(n+1, k) - 1 = -1, por lo que limitamos el coste total con Max(1, -1) = 1 para contemplar el caso de k>n+1:

T(n, k) = Max(1, 2 C(n+1, k) - 1)

La expresión anterior se deducía de la siguiente que también nos conduce al mismo resultado:

T(n, k) = Max(1, 2 (j=0..k C(n, j) - ∑j=0..k-2 C(n, j) ) - 1 )

Estas dos expresiones son iguales, usando la primera en la aplicación.

Lo siguiente es una actualización de lo anterior detectada en marzo 2024.

Posteriormente a publicar este tema, detecto que a la hora de definir el coste del algoritmo no tuve en cuenta el coste de montar los argumentos de la llamada inicial que son directamente proporcionales al tamaño del problema. En este algoritmo vemos que tenemos que crear el Array res con k posiciones vacías. Para ello dotaremos de otro argumento start para, en la primera llamada, sumar k al coste total:

function combine2(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;
        result.push(copy(res));
    } else {
        iter += 1;
        for (let j=n; j>=k; j--){
            iter += 1;
            res[k0-k] = list[n0-j];
            combine2(list, k-1, j-1, res, result, start);
        }
    }
    return result;
}

De este forma el coste final de combine2(list, k) es el siguiente, donde ahora no usamos la función Max() pues la incorporación de k devolverá un valor no negativo, pero lo ajustaremos para cuando n<k devuelva k+1.

n<k ⇒ T(n, k) = k+1
n≥k ⇒ T(n, k) = 2 C(n+1, k) - 1 + k

Algoritmo mejorado con coste de copia

El coste con copia para el algoritmo anterior le afectaba el caso cuando k=0, que en lugar de valorar en 1 el coste teníamos que valorarlo en 1+k0, siendo k0 el valor de k en la llamada inicial. En el tema anterior, apartado simplificando el coste con copia, obteníamos una solución que ahora denominamos como TFc, para aclarar con c que contiene el coste de copia:

TFc (n, k) = 2 ( ∑j=0..k C(n, j) ) - 1 + k C(n, k)
29 Marzo 2023: Posteriormente a la publicación de este tema actualizo este apartado tras mejorar el apartado simplificando el coste con copia del tema anterior.

Por otro lado teníamos

TH (n, k) = 2 ∑j=0..k-2 C(n, j)

Vemos que cuando k=0 tenemos TH (n, 0) = 2 ∑j=0..-2 C(n, j) = 2×0 = 0, pues en el caso general en que m>n sucede que j=m..n ai = 0, el elemento neutro de la suma. Esto quiere decir que no le afecta la longitud del resultado a devolver, así que podemos determinar que THc = TH y por tanto:

TGc = TFc - THc = TFc - TH

De donde finalmente obtenemos la solución incluyendo el coste de copiar cada resultado parcial:

T(n, k) = 2 ( ∑j=0..k C(n, j) ) - 1 + k C(n, k) - 2 ∑j=0..k-2 C(n, j) =
= 2 C(n+1, k) - 1 + k C(n, k)

Observe que, al igual que hicimos en el apartado anterior, en las dos sumas con rangos 0..k y 0..k-2 se anulan términos quedando una expresión más sencilla. Entonces el resultado final con coste de copia e incluyendo la limitación al máximo como hicimos en el apartado anterior es:

T(n, k) = Max(1, 2 C(n+1, k) - 1 + k C(n, k))

En definitiva es el mismo coste sin copia T(n, k) =2 C(n+1, k) - 1 más el coste de copiar R = C(n, k) resultados, cada uno con una longitud de k elementos, de tal forma que se suma el coste de copia k × R.

Lo siguiente es una actualización de lo anterior detectada en marzo 2024.

Tal como comentamos al final del apartado anterior, al publicar este tema omití el coste de montar el argumento res que es proporcional al tamaño del problema con longitud k, por lo que la fórmula anterior queda así:

n<k ⇒ T(n, k) = k+1
n≥k ⇒ T(n, k) = 2 C(n+1, k) - 1 + k + k C(n, k)

Comparando el coste de ambos algoritmos

Figura
Figura. Comparando costes de dos algoritmos para combinar elementos

En este tema hemos encontrado las soluciones sin considerar el coste de copia ni la limitación Max(1, T(n,k)) para el caso k>n+1. Los costes del algoritmo del tema anterior y de este tema son, ambos sin considerar el coste de copia, son:

TF (n, k) = 2 ( ∑j=0..k C(n, j) ) - 1
TG (n, k) = 2 C(n+1, k) - 1

En la Figura vemos una representación gráfica del coste de TF en rojo y de TG en azul. El ejemplo presenta valores para n=6 y k tomando valores en el rango [0, 6]. Aunque las gráficas aparecen continuas, hemos de entender que son puntos sueltos en las intersecciones de los números enteros del eje k. He usado la aplicación Gráficas matemáticas usando esta entrada de texto con la definición de la gráfica.

Hasta la primera mitad de k la gráfica roja se comporta casi igual que la azul (tomando los enteros [0, 6] del eje k). A partir de ahí la azul aprovecha la ventaja de que las combinaciones hasta la mitad de n son las mismas en orden inverso que las del resto, como ya vimos en un apartado anterior con estos datos de ejemplo y que volvemos a replicar aquí. Se observa esa simetría en C(n, k) que se reproduce en TG.

n=6
k    C(n,k)  TF(n,k) TG(n,k)
---------------------------------------------
0    1       1        1
1    6       13       13
2    15      43       41
3    20      83       69
4    15      113      69
5    6       125      41
6    1       127      13

En resumen podríamos decir que si k<n/2 cualquiera de los dos algoritmos son igual de eficientes. En otro caso el mejorado TG se comporta mejor. Y más cuando k es o está muy cerca de n.

La cuestión que ahora se plantea es saber en que orden están estas funciones. Recordemos que una función f(n) está o se incluye en el orden de otra g(n) si se cumple lo siguiente:

limn→∞f(n)∈ ℝ+ ⇒ f(n) ∈ O(g(n)) ∧ g(n) ∈ O(f(n))
g(n)

Supongamos que buscamos el orden en función de n de TG, manteniendo k>0 constante. Vease que

C(n+1, k) =(n+1)!=(n+1)n(n-1)...(n+1-k+1) (n+1-k)!=
k! (n+1-k)!k! (n+1-k)!
=(n+1)n(n-1)...(n+1-k+1)=Pk(n)nk
k!k!k!

Pk(n) significa un polinomio de orden (n+1) - (n+1-k+1) + 1= k, donde el término de mayor potencia es nk, entonces asintóticamente equivale a nk/k!. Para TG tenemos que:

limn→∞2 C(n+1, k) - 1=
nk / k!
= limn→∞2 (nk / k!)- limn→∞k!= 2-0 = 2 ∈ ℝ+
nk / k!nk

El límite del término de la derecha k!/nk es cero, pues

k! = k×(k-1)×..k veces..×2×1
nk = n×n×..k veces..×n×n

Y claramente si n≥k entonces nk > k!.

Entonces TG (n, k) ∈ O(nk/k!) y nk/k! ∈ O(TG (n, k)) con lo que TG ∈ O(nk/k!).

Figura
Figura. Comparando costes de dos algoritmos T(n, n×0.8) para combinar elementos

En cuanto a TF (n, k) = 2 ( ∑j=0..k C(n, j) ) - 1 vemos que el peor de los casos es cuando k=n. En este caso tenemos que 2 ( ∑j=0..n C(n, j) ) - 1 = 2n+1 -1. Por lo tanto TF ∈ O(2n+1).

En la gráfica de la Figura se observa la representación de TF (n, k) y TG (n, k) en función de n y tomando k = n×0.8 para cada valor de n, observándose el mejor comportamiento del algoritmo mejorado. Observe como TF está muy cerca, pero por debajo, de 2n+1.

Puede verla en Gráficas matemáticas usando esta definición. Se puede probar con k = n×0.5 observándose que ambas gráficas siguen la misma tendencia, en cuyo caso estarán en el mismo orden. En cualquier caso 2n+1 es un cota superior de ambas.