Ejemplos 4

Particiones de enteros

ALGORÍTMICA Y COMPLEJIDAD
Grados en Ing. Informática y Matemáticas
Universidad de Cantabria
Camilo Palazuelos Calderón

Definimos la función Particiones1, que devuelve el número de particiones $p_k(n)$ de un entero no negativo $n$ en a lo sumo $k$ partes, recibidos $n$ y $k$ como parámetros; como caso base, $p_0(0) = 1$. Esta función generaliza el número de particiones $p(n)$ de un entero no negativo $n$, ya que $p(n) = p_n(n)$. Nótese que estamos interesados en calcular $p(n)$, por lo que asumimos que la primera llamada a Particiones1 se hace con $k = n$.

In [ ]:
def Particiones1(n, k):
    if n == 0 and k == 0:
        return 1
    elif n >= 0 and k > 0:
        return Particiones1(n, k - 1) + Particiones1(n - k, k)
    else:
        return 0

[[Particiones1(n, k) for k in range(6)] for n in range(6)]
Out[ ]:
[[1, 1, 1, 1, 1, 1],
 [0, 1, 1, 1, 1, 1],
 [0, 1, 2, 2, 2, 2],
 [0, 1, 2, 3, 3, 3],
 [0, 1, 3, 4, 5, 5],
 [0, 1, 3, 5, 6, 7]]

Evaluamos el coste temporal empírico de Particiones1 con $n$ desde $7$ hasta $70$ (de $7$ en $7$).

In [ ]:
import time

m = 70
T = [0]

for n in range(m // 10, m + 1, m // 10):
    t = time.time()
    Particiones1(n, n)
    T.append(time.time() - t)
    #print(f'Iteración {n // (m // 10)} de 10')

Dibujamos una gráfica con el tiempo de ejecución $T(n)$ de Particiones1 en función de $n$.

In [ ]:
import matplotlib.pyplot as plt
import numpy as np

with plt.style.context('ggplot'):
    plt.plot(np.array(T), label = '$T(n) \in O(2^n)$')
    plt.xticks(
        [x     for x in range(0, 11, 2)],
        [x * 7 for x in range(0, 11, 2)])
    plt.xlabel(        '$n$', fontsize = 12)
    plt.ylabel('$T(n)$ en s', fontsize = 12)
    plt.title('Complejidad temporal')
    plt.legend()

plt.show()
No description has been provided for this image

Definimos las funciones Particiones2 y Particiones3, las versiones memoizadas recursiva e iterativa, respectivamente, de Particiones1 (con $k = n$ en la primera llamada a la función).

In [ ]:
def Particiones2(n):
    A = [[0 for _ in range(n + 1)] for _ in range(n + 1)]
    TopDown(A, n, n)
    return A

def TopDown(A, n, k):
    if n == 0 and k == 0:
        A[n][k] = 1
        return A[n][k]
    elif n >= 0 and k > 0:
        if A[n][k] == 0:
            A[n][k] = TopDown(A, n, k - 1) + TopDown(A, n - k, k)
        return A[n][k]
    else:
        return 0

Particiones2(5)
Out[ ]:
[[1, 1, 1, 1, 1, 1],
 [0, 1, 1, 1, 1, 0],
 [0, 1, 2, 2, 0, 0],
 [0, 1, 2, 0, 0, 0],
 [0, 1, 0, 0, 0, 0],
 [0, 1, 3, 5, 6, 7]]
In [ ]:
def Particiones3(n):
    A = [[0 for _ in range(n + 1)] for _ in range(n + 1)]
    t = time.time()
    BottomUp(A, n)
    t = time.time() - t
    return A, t

def BottomUp(A, n):
    A[0][0] = 1
    for i in range(n + 1):
        for j in range(1, n + 1):
            A[i][j] = A[i][j - 1]
            if i >= j:
                A[i][j] += A[i - j][j]
    return A[n][n]

Particiones3(5)[0]
Out[ ]:
[[1, 1, 1, 1, 1, 1],
 [0, 1, 1, 1, 1, 1],
 [0, 1, 2, 2, 2, 2],
 [0, 1, 2, 3, 3, 3],
 [0, 1, 3, 4, 5, 5],
 [0, 1, 3, 5, 6, 7]]

Definimos la función Mostrar, que representa gráficamente el contenido del array $A[0..n, 0..n]$ que recibe como parámetro. Observamos que Particiones2, la versión memoizada recursiva de Particiones1, no rellena del array auxiliar $A[0..n, 0..n]$ las posiciones $(i, j)$ tales que, o bien $\color{red}{0 < i \le n}$ y $\color{red}{j = 0}$, o bien $\color{blue}{0 < i < n}$ y $\color{blue}{n - i < j \le n}$.

In [ ]:
def Mostrar(A):
    print('\n')
    for i in range(len(A)):
        s = ''
        for j in range(len(A)):
            if A[i][j] != 0:
                s += '\33[40m  \33[0m  '
            elif j == 0:
                s += '\33[41m  \33[0m  '
            elif len(A) - 1 - i < j:
                s += '\33[44m  \33[0m  '
            else:
                s += '\33[45m  \33[0m  '
        print(s + '\n')

Mostrar(Particiones2(10))

                                            

                                            

                                            

                                            

                                            

                                            

                                            

                                            

                                            

                                            

                                            

Vamos un paso más allá y definimos la función Particiones4, que reproduce iterativamente el patrón recursivo de rellenado del array $A[0..n, 0..n]$ y, además, aprovecha que $A[i, j] = A[i, i]$ para todo $j$ tal que $\color{purple}{0 \le i < j \le n}$. Así, Particiones4 solo rellena del array $A[0..n, 0..n]$ las posiciones mostradas en negro a continuación.

In [ ]:
def Particiones4(n):
    A = [[0 for _ in range(n + 1)] for _ in range(n + 1)]
    t = time.time()
    BottomUp2(A, n)
    t = time.time() - t
    return A, t

def BottomUp2(A, n):
    A[0][0] = 1
    for i in range(n + 1):
        for j in range(1, min(i, n - i * (i < n)) + 1):
            A[i][j] = A[i][j - 1]
            if i - j >= j:
                A[i][j] += A[i - j][j]
            else:
                A[i][j] += A[i - j][i - j]
    return A[n][n]

Mostrar(Particiones4(10)[0])

                                            

                                            

                                            

                                            

                                            

                                            

                                            

                                            

                                            

                                            

                                            

Evaluamos el coste temporal empírico de Particiones3 (bottom-up) y Particiones4 (bottom-up optimizado) con $n$ desde $1 \cdot 10^3$ hasta $10 \cdot 10^3$ (de $10^3$ en $10^3$).

In [ ]:
m = 10000
T = [[0], [0]]

for n in range(m // 10, m + 1, m // 10):
    T[0].append(Particiones3(n)[1])
    T[1].append(Particiones4(n)[1])
    #print(f'Iteración {n // (m // 10)} de 10')

Dibujamos una gráfica con los tiempos de ejecución $T_1(n)$ y $T_2(n)$ de Particiones3 y Particiones4, respectivamente, en función de $n$.

In [ ]:
with plt.style.context('ggplot'):
    plt.plot(np.array(T[0]), label = '$T_1(n) \in O(n^{2.5})$')
    plt.plot(np.array(T[1]), label = '$T_2(n) \in O(n^{2.5})$')
    plt.xticks(
        [    x               for x in range(0, 11, 2)],
        [f'${x} \cdot 10^3$' for x in range(0, 11, 2)])
    plt.xlabel(          '$n$', fontsize = 12)
    plt.ylabel('$T_i(n)$ en s', fontsize = 12)
    plt.title('Complejidad temporal')
    plt.legend()

plt.show()
No description has been provided for this image

Nota: Las funciones anteriores realizan $O(n^2)$ sumas de enteros no negativos, y el coste temporal de cada una de estas es $O(m)$, donde $m$ es el número de dígitos de los enteros no negativos que sumar. El número de dígitos de $p(n)$ es asintótico a $\sqrt{n}$, por lo que el coste temporal de las funciones anteriores es $O(n^2 \sqrt{n}) = O(n^{2.5})$.