programmazione dinamica

La programmazione dinamica è una tecnica di progettazione di algoritmi basata sulla divisione del problema in sottoproblemi e sull’utilizzo di sottostrutture ottimali (la soluzione ottimale al sottoproblema può essere usata per trovare la soluzione ottimale all’intero problema).

programmazione dinamica vs divide et impera

  • il divide et impera suddivide il problema in sottoproblemi indipendenti e li risolve separatamente; nel caso in cui i sottoproblemi risultino uguali, risolve lo stesso problema più volte svolgendo lavoro inutile
  • la programmazione dinamica ottimizza evitando ricalcoli inutili tramite memoization, ed è adatta a problemi i cui sottoproblemi si ripetono e/o sono dipendenti tra loro (overlapping di sottoproblemi)

memoization

La memoization (memoizzazione) è una tecnica che consiste nel salvare in memoria i valori restituiti da una funzione, in modo da averli a disposizione senza doverli ricalcolare.

  • una funzione può essere “memoizzata” solo se non ha effetti collaterali e restituisce sempre lo stesso valore quando riceve in input gli stessi parametri
    • (si dice che “soddisfa la trasparenza referenziale”)

(definizioni da wikipedia, thx wikipedia)

caratteristiche della programmazione dinamica

Gli algoritmi di programmazione dinamica si basano quindi spesso sulla costruzione di una tabella (lista o matrice) che memorizza soluzioni intermedie ai sottoproblemi, che verranno poi usate per arrivare a quella finale.

  • in una prima fase, ci si può concentrare sul calcolo del valore ottimo della soluzione; successivamente, la stessa tabella può essere utilizzata per risalire alla soluzione vera e propria cui quel valore corrisponde.
  • l’idea è partire dall’elemento che rappresenta la soluzione ottima e, seguendo a ritroso le decisioni registrate nella tabella, ricostruire il percorso che ha portato a quel risultato.

numeri di Fibonacci

La sequenza dei numeri di Fibonacci è definita dall’equazione di ricorrenza:

con .

soluzione divide et impera

Una soluzione naïf è quella che utilizza il metodo divide et impera sfruttando la definizione stessa di numero di Fibonacci:

def Fib(n):
	if n <= 1: return 1
	a = Fib(n-1)
	b = Fib(n-2)
	return a + b

L’equazione di ricorrenza di questo algoritmo è . Si ha , che può essere risolta ottenendo .

Quindi, .

Il problema sta nel fatto che la funzione Fib viene chiamata sullo stesso input molte volte - i sottoproblemi in cui viene scomposto il problema non sono disgiunti, mentre l’algoritmo si comporta come se lo fossero.

Per rendere l’algoritmo più efficiente, basta quindi usare la memoizzazione e salvare in una lista i valori quando li si calcola la prima volta.

def memFib(n, F):
	if n <= 1: return 1
	if F[n] == -1:
		a = memFib(n-1, F)
		b = memFib(n-2, F)
		F[n] = a + b
	return F[n]
 
F = [-1]*(n+1)
  • in questo modo, l’algoritmo effettuerà esattamente chiamate ricorsive
  • tenendo conto che ogni chiamata ricorsiva costa , la complessità di memFib è

ulteriori ottimizzazioni

Questo algoritmo può essere ulteriormente ottimizzato:

  • sostituendo la ricorsione con l’iterazione, si risparmia lo spazio occupato dalla gestione della ricorsion
  • la complessità spaziale della lista () può essere ridotta a tenendo conto che basta conservare in memoria solo gli ultimi due valori calcolati
def FibOtt(n):
	if n <= 1:
		return n
	a = b = 1
	for i in range(2, n+1):
		a, b = b, a + b
	return b

top-down, bottom-up

  • nella versione ricorsiva dell’algoritmo, si parte dal problema scomponendolo in sottoproblemi sempre più piccoli fino ad arrivare a problemi facilmente risolvibili ⟶ si parla di approccio top-down
  • nella versione iterativa, si comincia dai sottoproblemi di dimensione piccola per poi passare a quelli di dimensione via via crescente fino ad arrivare alla soluzione del problema originario ⟶ si parla di approccio bottom-up

problema dei file su disco

testo

Abbiamo un disco di capacità e file di varie dimensioni (ciascuna inferiore a ). Vogliamo trovare il sottoinsieme di file che può essere memorizzato su disco che massimizzi lo spazio occupato.

  • per semplicità di esposizione, ci limiteremo a calcolare il valore della soluzione ottima, ovvero il massimo spazio del disco che può essere occupato grazie agli file

Attraverso la memoizzazione, possiamo ottenere una soluzione pseudopolinomiale.

algoritmo pseudopolinomiale

In teoria della complessità computazionale, un algoritmo è detto pseudopolinomiale se la sua complessità temporale è polinomiale nel valore numerico del suo input e non necessariamente nella sua dimensione (risolve un problema in tempo polinomiale quando i numeri presenti nell’input sono codificati in unario).

Creiamo una tabella di dimensione , in cui è il valore ottenuto dalla soluzione del sottoproblema in cui si hanno i primi file e la dimensione del disco è (quindi il massimo spazio che si può occupare in un blocco grande con i primi file).

Per compilarla, usiamo questo criterio:

  • (primo caso) se non aggiungo il file , la capienza rimane quella calcolata per i primi file
  • (secondo caso) se invece aggiungo il file , sto occupando unità di spazio e mi resterà una capacità di
    • quindi, il valore totale sarà dato dalla dimensione di sommata al massimo valore ottenibile con i file precedenti e lo spazio residuo (ovvero )

Ogni cella deve contenere il massimo spazio occupato con i dati delle coordinate, quindi l’equazione generale per una cella sarà:

con

implementazione top-down

def es(A, C):
	T = [[-1]*(C+1) for i in range(len(A)+1)]
	return disco(A, len(A), C, T)
 
def disco(A, i, c, T):
	if T[i][c] == -1:
		if i == 0 or c == 0: T[i][c] = 0 
		else:
			lascio = disco(A, i-1, c, T) # opzione senza i
			T[i][c] = lascio
			if A[i-1] <= c: # se non c'entra devo sicuramente lasciare
				prendo = A[i-1] + disco(A, i-1, c-A[i-1], T)
				T[i][c] = max(lascio, prendo) # max spazio occupato
	return T[i][c]

Il tempo di calcolo è limitato dalla dimensione della tabella: le operazioni di ogni chiamata di disco() costano infatti , e il numero totale di chiamate non può superare la dimensione della tabella.

La complessità è quindi

  • in realtà, a causa dell’inizializzazione della tabella

questo algoritmo è quindi più efficiente della versione divide et impera?

  • se è molto grande (ad esempio se supera ), la versione memoizzata si comporta peggio; altrimenti, può risultare anche molto più efficiente.

Spesso però (visto che si parla di capienza di dischi ed è comune che si lavori con numeri grandi) la profondità dell’albero delle chiamate ricorsive è molto grande e può portare ad uno stack overflow. Per evitare questo problema, ci si può concentrare direttamente sul calcolo della tabella eliminando la ricorsione.

implementazione bottom-up:

def discoI(A, C):
	n = len(A)
	T = [ [0]*(C+1) for i in range(n+1) ]
	for i in range(1, n+1):
		for c in range(C+1):
			if c < A[i-1]:
				T[i][c] = T[i-1][c]
			else:
				T[i][c] = max(T[i-1][c], A[i-1]+T[i-1][c-A[i-1]])
	return T[n][C]
  • anche questa versione ha una complessità di

pseudopolinomiale

Questo algoritmo è pseudopolinomiale perché la capacità dell’input è codificata in bit. Considerando il numero di bit come misura, infatti, la complessità dell’algoritmo sarà , ovvero esponenziale nella dimensione binaria dell’output.

Usiamo ora la tabella appena calcolata per ricavare la soluzione ottima.

Prendiamo come esempio la tabella relativa all’istanza e con 6 file di dimensioni .

center

  • a partire da possiamo disegnare, per ogni elemento toccato della tabella, una freccia verso l’elemento in base al quale è stato calcolato (se l’elemento è , può essere o o ).
  • quindi, se la freccia è verticale, vuol dire che il -esimo file non è stato scelto; se invece è obliqua, vuol dire che è stato scelto.

center

  • seguendo le frecce, è possibile vedere che sono stati scelti i file di dimensioni e
  • quindi, se , il -esimo file non è stato scelto; altrimenti, e il -esimo file è stato scelto

implementazione:

def discoI(A, C):
	n = len(A)
	T = [ [0]*(C+1) for i in range(n+1) ]
	for i in range(1, n+1):
		for c in range(C+1):
			if c < A[i-1]:
				T[i][c] = T[i-1][c]
			else:
				T[i][c] = max(T[i-1][c], A[i-1]+T[i-1][c-A[i-1]])
	
	valore = T[n][C]
	sol = []
	i = n
	while i > 0:
		if T[i][valore]	 != T[i-1][valore]
			sol.append(i-1)
			valore -= A[i-1]
		i -= 1
	return T[n][C], sol
  • il calcolo della tabella costa
  • nella ricerca dei file, la tabella viene visitata a partire dall’ultima cella , una riga per volta; il costo è

La complessità di questo algoritmo è quindi .

problema dello zaino (knapsack problem)

traccia

Abbiamo uno zaino di capacità e oggetti, ognuno con peso e valore . Vogliamo sapere, dati la capacità , i vettori dei pesi e dei valori, in il valore massimo che si può inserire nello zaino.

Il problema del disco (visto sopra) è considerabile un caso particolare del problema dello zaino in cui . È un noto problema NP-completo.

esempio

center

ragionamento

Si utilizza una matrice di dimensione in cui:

  • massimo valore ottenibile dai primi oggetti per uno zaino di capacità

indici di tabella e vettori

(teniamo a mente che peso e valore di un oggetto sono “sfasati” di rispetto agli indici della tabella: infatti, nella tabella, rappresenta l’uso dei primi oggetti, ma, poiché questi si indicizzano da , l’oggetto -esimo è rappresentato da e

  • vuol dire “sto considerando il primo oggetto”, ovvero quello con peso e valore )

Per compilare la tabella, sappiamo che:

  • se non si hanno oggetti o la capacità dello zaino è nulla, la soluzione varrà
  • se l’-esimo oggetto ha peso superiore alla capacità dello zaino, non potrà essere inserito e il valore sarà dato dagli altri oggetti (con la stessa capacità), ovvero da
  • per ogni oggetto da inserire nello zaino, ci sono due possibilità:
    • “lascio” ⟶ non inserisco l’oggetto nello zaino: il valore della soluzione è dato da , ovvero il valore massimo ottenibile dai primi oggetti con la stessa capacità
    • “prendo” ⟶ inserisco l’oggetto nello zaino: in questo caso si guadagna in valore e bisogna sottrarre la capacità dell’oggetto inserito - rimane, per gli altri oggetti, una capacità di . Quindi, il valore della soluzione sarà dato da

Il valore da scegliere è quindi il massimo tra “prendo” e “lascio”:

L’equazione di ricorrenza è quindi:

implementazione:

def knapsack(P, V, c):
	n = len(P)
	T = [[0]*(c+1) for _ in range(n+1)]
	for i in range(1, n+1):
		for j in range(1, c+1):
			if j < P[i-1]
				T[i][j] = T[i-1][j]
			else:
				T[i][j] = max(T[i-1][j], V[i-1]+T[i-1][j-P[i-1]])
	return T, T[n][c]

altri esercizi

contare il numero di stringhe binarie lunghe senza 2 zeri consecutivi

  • per questo tipo di esercizi, è utile calcolare i primi valori e utilizzarli per dedurre il pattern generale (à la metodi matematici)

Qui abbiamo

  • ⟶ 1 stringa: stringa vuota ""
  • ⟶ 2 stringhe: 0 e 1
  • ⟶ 3 stringhe: 01, 10, 11
  • ⟶ 5 stringhe: 010, 011, 101, 111, 110

Utilizzeremo una tabella monodimensionale di dimensione , il cui contenuto è definito come segue:

numero di stringhe binarie lunghe dove non compaiono 2 zeri consecutivi

  • una volta riempita la tabella, la soluzione si troverà nella locazione

La ricorrenza si può dedurre separando il conteggio delle stringhe lunghe che terminano con 1 da quelle che terminano con 0:

  • le stringhe lunghe che terminano con 1 si ottengono dalle stringhe lunghe senza vincoli (se in ci sono solo stringhe senza due zeri consecutivi, sicuramente aggiungendo un 1 questa proprietà sarà mantenuta) ⟶ esse sono quindi
  • le stringhe lunghe che terminano con uno 0 hanno invece un vincolo: il carattere precedente () deve necessariamente essere un 1
    • quindi (vista in modo “combinatorio”) visto che abbiamo “fissato” i caratteri e , le stringhe sono tutte le stringhe lunghe , a cui viene aggiunto “10”
    • sono quindi

La ricorrenza è quindi:

notiamo che questo problema è analogo ai numeri di Fibonacci !

implementazione:

def duezeri(n):
	T = [0]*(n+1)
	T[0], T[1] = 1, 2
	for i in range(2, n+1):
		T[i] = T[i-1] + T[i-2]
	return T[n]

contare il numero di stringhe binarie lunghe senza 3 zeri consecutivi

Calcoliamo i primi valori:

  • ⟶ 1 stringa: stringa vuota ""
  • ⟶ 2 stringhe: 0 e 1
  • ⟶ 4 stringhe: 00, 01, 10, 11
  • ⟶ 7 stringhe: 001, 010, 011, 100, 101, 110, 111

Il problema è quindi molto simile a quello precedente. Come prima, consideriamo i due casi: stringa lunga che termina con un 1 e stringa lunga che termina con uno 0.

  • le stringhe lunghe che terminano con un 1 non hanno vincoli rispetto alle stringhe del passo precedente e sono quindi
  • per le stringhe lunghe che terminano con uno 0, bisogna fare una distinzione
    • se il carattere è uno 0, allora deve necessariamente essere un 1 ⟶ le stringhe sono quindi
    • se è un 1, non ci sono vincoli su ⟶ sono

La ricorrenza è quindi:

numero di sequenze decimali non decrescenti

testo

Dato un intero , vogliamo sapere, in , quante sono le sequenze di cifre decimali non decrescenti lunghe .

  • per , le cifre sono
  • per cifre

Infatti, alla prima cifra possono seguire cifre diverse. Si ha quindi

ragionamento

Visto che dobbiamo considerare il constraint della non-decrescenza, dobbiamo tenere a mente non solo la lunghezza delle sequenze, ma anche con che numero terminano (per poter determinare se un numero possa essere aggiunto alla sequenza o no).

Sappiamo infatti che le stringhe lunghe che terminano con saranno formate da stringhe lunghe che terminano con qualunque cifra (a cui verrà aggiunto ).

Utilizziamo quindi una matrice definita così:

  • numero di sequenze decimali non decrescenti lunghe che terminano con la cifra

La soluzione al problema sarà data quindi dalla somma degli elementi dell’ultima riga (sequenze non decrescenti lunghe che terminano con tutte le cifre possibili)

  • il caso base è dato dalle sequenze lunghe - ogni sequenza lunga è infatti decrescente

La regola ricorsiva è quindi:

implementazione:

def es(n):
	T = [[0]*10 for _ in range(n+1)]
	for j in range(10):
		T[1][j] = 1
	for i in range(2, n+1):
		for j in range(10):
			for k in range(j+1):
				T[i][j] += T[i-1][k]
	return sum(T[n])
  • inizializzare la tabella costa (ha righe e colonne)
  • dei 3 for annidati:
    • il primo viene iterato volte
    • il secondo viene iterato volte
    • il terzo viene iterato al più volte
  • il costo totale dei for è

raggiungibilità in una matrice

testo

Data una matrice binaria , si vuole verificare, in , se è possibile raggiungere la cella in basso a destra partendo da quella in alto a sinistra senza mai toccare celle che contengono il numero .

  • ad ogni passo ci si può spostare solo di un passo verso destra o un passo verso il basso

ragionamento

  • la cella è raggiungibile
  • se , non è raggiungibile

Assumendo che ,

  • una cella della prima riga può essere raggiunta solo dalla cella che la precede (non ha una cella sopra di sé)
  • una cella della prima colonna può essere raggiunta solo dalla cella che sopra di sé sulla colonna (non ha una cella alla sua sinistra)
  • una cella “interna” è raggiungibile se può essere raggiunta dalla cella che la precede o dalla cella sopra di sé

Possiamo quindi utilizzare una matrice di booleani, in cui è raggiungibile.

La ricorrenza sarà quindi:

implementazione:

def perc(M):
	n = len(M)
	T = [[0]*n for _ in range(n)]
	
	for i in range(n):
		for j in range(n):
			if i == j == 0: 
				T[i][j] = 1
				continue
				
			sopra = T[i-1][j] if i != 0 else 0
			sx = T[i][j-1] if j != 0 else 0
			T[i][j] = sopra or sx
			 
	return T[n][n]

sottomatrici di soli uni

testo

Data una matrice quadrata binaria di dimensione , si vuole sapere, in , qual è la dimensione massima per le sottomatrici quadrate di soli uni contenute in .

Si può utilizzare una tabella bidimensionale dove:

  • il lato della matrice quadrata più grande contenente tutti uni e con cella in basso a destra

ragionamento

Il ragionamento è questo:

  • se ,
  • altrimenti, per avere un quadrato di dimensione , gli elementi della matrice , e (sopra, diagonale, sinistra) dovranno a loro volta essere gli angoli di un quadrato di dimensione almeno

Si può osservare in questo caso:

  • per il primo quadrato (giallo, che termina in ), è abbastanza evidente: le celle che lo circondano formano quadrati
  • ma si nota anche per (rosso): infatti, ai tre quadrati formati dalle celle circostanti manca esattamente l’ultima cella per diventare un unico quadrato

La ricorrenza è quindi:### others

(si prende il minimo lato perché, per poter “fondere” i tre quadrati che terminano in quelle tre celle, essi devono essere della stessa dimensione: se si prendesse il massimo, uno degli altri due quadrati potrebbe essere più piccolo e la figura risultante non sarebbe un quadrato - il minimo, invece, li “copre” sicuramente tutti e tre)

implementazione:

def sottomatrice(M):
	T = [[0]*n for _ in range(n)]
	T[0][0] = M[0][0]
	for i in range(n):
		for j in range(n):
			if M[i][j] == 0:
				T[i][j] = 0
			else:
				if i == 0 or j == 0:
					T[i][j] = 1
				else:
					T[i][j] = min(T[i][j-1], T[i-1][j-1], T[i-1][j]) + 1
	return max(T)

numeri di Strirling di seconda specie

testo

Dati due interi non negativi e , vogliamo sapere, in in quanti modi è possibile partizionare l’insieme dei numeri da da a in sottoinsiemi non vuoti.

ragionamento

Possiamo usare una tabella bidimensionale di dimensioni , in cui:

  • numero di modi di partizionare elementi in sottoinsiemi non vuoti

Sappiamo che:

  • non c’è modo di partizionare elementi in parti oppure elementi in parti, quindi si ha
  • negli altri casi, si possono contare i modi di partizionare pensando all’-esimo elemento:
    • se si sceglie di tenere l’-esimo elemento da solo, si avranno modi (i modi di creare i sottoinsiemi considerando tutti gli altri elementi e un sottoinsieme in meno)
    • se si sceglie di inserire l’-esimo elemento in un sottoinsieme con almeno un altro elemento, si avranno modi di farlo
      • sono i modi per partizionare elementi in sottoinsiemi

      • il nuovo elemento () può essere messo in uno qualsiasi dei sottoinsiemi creati, quindi bisogna moltiplicare i modi per il numero di sottoinsiemi creati, ovvero

esempio

center

L’equazione di ricorrenza sarà quindi:

implementazione:

def es(n, k):
	T = [[0]*(k+1) for _ in range(n+1)]
	T[0][0] = 1
	for i in range(1, n+1);
		for j in range(1, k+1):
			T[i][j] = T[i-1][j-1] + j*T[i-1][j]
	return T[n][k]