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$.
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)]
[[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$).
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$.
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()
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).
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)
[[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]]
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]
[[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}$.
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.
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$).
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$.
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()
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})$.