Programmazione dinamica e i numeri di Fibonacci

A cura di Mohamad Ali Mohamad Almgerbi, basato sulle lezioni di Cecilia Verri

Le soluzioni di molti problemi possono essere descritte in forma ricorsiva. In questo caso, il problema originale è suddiviso in alcuni sotto-problemi e viene risolto ripetutamente e quindi combinato in una soluzione finale. Tuttavia, se la descrizione del problema contiene sotto-problemi identici, tale implementazione è inefficace in quanto gli stessi sotto-problemi vengono risolti indipendentemente l’uno dall’altro più e più volte. Questo tipo di ricalcolo non necessario può spesso essere evitato usando tecniche di programmazione dinamica.

La programmazione dinamica (DP) è una tecnica per risolvere i problemi in modo più efficiente. La programmazione dinamica è efficace nel trovare soluzioni ottimali per casi con molti sotto-problemi sovrapposti. Nella programmazione dinamica memorizziamo la soluzione di questi sotto-problemi in modo da non doverli risolvere di nuovo, questo si chiama memoization. Onde evitare di ricalcolare più volte la soluzione di uno stesso sotto-problema, i sotto-problemi vengono risolti con una strategia bottom-up (vale a dire: dal sotto-problema più “piccolo” al sotto-problema più “grande”) e le soluzioni a questi sotto-problemi vengono memorizzate in apposite tabelle di modo che siano disponibili, se necessario, alla soluzione di altri sotto-problemi.

Di seguito mostreremo il calcolo dei numeri di Fibonacci mediante programmazione dinamica, risolvendo il problema con la proprietà dei sotto-problemi sovrapposti.

La sequenza di Fibonacci

La sequenza di Fibonacci è una sequenza infinita di numeri interi positivi, a partire da 0 e 1, dove ogni elemento successivo è uguale alla somma dei suoi due elementi precedenti. Se indichiamo il numero alla posizione n come Fib(n), possiamo definire formalmente la sequenza di Fibonacci come:

\(Fib(n) = 0\) if \(n = 0\)

\(Fib(n) = 1\) if \(n = 1\)

\(Fib(n) = Fib(n-1) + Fib(n-2)\) if \(n > 1\)

Pertanto, l’inizio della sequenza è: \(0, 1, 1, 2, 3, 5, 8, 13, …\)

Valore 0 1 1 2 3 5 8 13
Indice 0 1 2 3 4 5 6 7
def Fib(n):
    # il primo numero di Fibonacci
    if n==0:
        return 0

    # il secondo numero di Fibonacci
    elif n==1:
        return 1

    else:
        return Fib(n-1)+Fib(n-2)

n=5
print(Fib(n))

Nel codice sopra abbiamo definito una funzione chiamata Fib, che ha preso \(n\) come input e ha restituito il numero \(n\)-esimo nella sequenza di Fibonacci.

All’interno di questa funzione, per prima cosa controlliamo se l’input è uguale a 0 e restituiamo 0 in questo caso. Controlliamo anche se l’input è uguale a 1 e restituiamo 1 in questo caso. Altrimenti, ovvero se l’input è diverso da 0 e diverso da 1, usiamo la funzione ricorsiva, richiamando la funzione stessa. La funzione chiamata farà altrettanto fin quando l’input diventerà uguale a 1 o 0. Se supponiamo di voler calcolare Fib(5), dal momento che 5 è diverso da 0 e 1, abbiamo Fib (5) = Fib (4) + Fib (3). Le chiamate a Fib (4) e Fib (3), dal momento che 4 e 3 sono diversi da 0 e 1, useranno ancora la regola ricorsiva. Il processo andrà avanti fino a quando non troveremo i casi base. Dunque:

  • \(Fib (5) = Fib (4) + Fib (3)\)

Dove ciascuna delle due chiamate, internamente, procede come segue:

  • \(Fib (4) = Fib (3) + Fib (2)\)
  • \(Fib (3) = Fib (2) + Fib (1)\)

per cui,

  • \(Fib (5) = (Fib (3) + Fib (2)) + (Fib (2) + Fib (1))\)

Applicando ancora la ricorsione, usando:

  • \(Fib (3) = Fib (2) + Fib (1)\)
  • \(Fib (2) = Fib (1) + Fib (0)\)

si ottiene,

  • \(Fib (5) = (Fib (2) + Fib (1)) + (Fib (1) + Fib (0)) + (Fib (1) + Fib (0) + Fib (1))\)

La regola ricorsiva viene di nuovo applicata per Fib (2). Viene invece applicato il caso base per Fib (1) e Fib (0), perché il valore di \(n\) è in questo caso uguale a 1 e 0:

  • \(Fib (5) = ((Fib (1) + Fib (0)) + Fib (1)) + (Fib (1) + Fib (0)) + (Fib (1) + Fib (0) + Fib (1)) = 1 + 0 + 1 + 1 + 0 + 1 + 0 + 1 = 5\)

La complessità temporale di Fibonacci

Vogliamo analizzare quanto è veloce l’algoritmo. Come si vede dall’esempio sopra, Fib (3) viene ripetuto 2 volte, Fib (2) viene ripetuto 3 volte, Fib(1) viene ripetuto 5 volte e Fib (0) viene ripetuto 3 volte. A causa di molte chiamate identiche ripetute, l’algoritmo risulta inefficiente. Come mostrato sotto il numero di chiamate ricorsive è il seguente ad ogni livello.

\(2^0=1\) chiamata: Fib(n)

\(2^1=2\) chiamate: Fib(n-1), Fib(n-2)

\(2^2=4\) chiamate: Fib(n-2), Fib(n-3), Fib(n-3), Fib(n-4)

\(2^3=8\) chiamate: Fib(n-3), Fib(n-4), Fib(n-4), Fib(n-5), Fib(n-4), Fib(n-5), Fib(n-5), Fib(n-6)

\(...\)

\(2^n-1\) chiamate

Usando \(T(n)\) per indicare la complessità temporale di Fib(n) otteniamo \(T(n) = T(n - 1) + T(n -2) + 1 \leq O(2^n)\). Questo algoritmo è esponenziale, ovvero non riusciamo a vedere la sua fine per esempio passando \(n=64\) (https://en.wikipedia.org/wiki/Wheat_and_chessboard_problem). Ci sono molte altre soluzioni alternative per trovare l’n-esimo numero di Fibonacci, una delle quali applica la Programmazione Dinamica. L’idea di questo approccio è evitare il lavoro ripetuto, memorizzando il risultato dei sotto-problemi, per evitare di calcolarlo ancora.

La programmazione dinamica - Fibonacci

Per trovare il numero n-esimo di Fibonacci, applichiamo la programmazione dinamica, mediante due possibili approcci.

4.1. Top-down con Memoization

In questo approccio, cerchiamo di risolvere il problema più grande trovando ricorsivamente la soluzione a piccoli problemi secondari. Ogni volta che risolviamo un sotto-problema, memorizziamo nella cache il suo risultato in modo da non risolverlo ripetutamente se viene chiamato più volte e restituire semplicemente il risultato salvato in precedenza. Questa tecnica di memorizzazione dei risultati di sottoproblemi già risolti si chiama Memoization.

Esempio:

def calculateFibonacci(n):
        memoize = [-1 for x in range(n+1)]
        return calculateFibonacciRecur(memoize, n)

def calculateFibonacciRecur(memoize, n):
        if n < 2:
                return n
        if memoize[n] >= 0:
                return memoize[n]
        memoize[n] = calculateFibonacciRecur(memoize, n - 1) + calculateFibonacciRecur(memoize, n - 2)
        return memoize[n]

print(calculateFibonacci(5))

L’output è 5

4.2. Bottom-up con tabulazione

La tabulazione è l’opposto dell’approccio top-down. In questo approccio, risolviamo il problema “dal basso verso l’alto” (ovvero risolvendo prima tutti i sotto-problemi correlati). Questo viene generalmente eseguito compilando una tabella n-dimensionale. Sulla base dei risultati nella tabella, viene quindi calcolata la soluzione al problema principale/originale.

Esempio:

def calculateFibonacci(n):
        dp = [0, 1]
        for i in range(2, n + 1):
                dp.append(dp[i - 1] + dp[i - 2])
        return dp[n]
print(calculateFibonacci(5))

L’output è 5

Conclusione

Un esempio tipico di algoritmo ricorsivo è l’algoritmo per trovare il numero n-esimo di Fibonacci. La risoluzione del problema in modo ricorsivo è abbastanza naturale in quanto sfrutta la definizione della serie stessa. Tuttavia, un’applicazione diretta della definizione implica la risoluzione di uno stesso sotto-problema molte volte. Questa inefficienza porta ad avere una complessità esponenziale. La programmazione dinamica permette di evitare questo problema: suddivide il problema in problemi più piccoli e memorizza i valori dei sotto-problemi per un uso successivo. Per determinare se un problema può essere risolto con la programmazione dinamica, dovremmo capire se questo problema può essere risolto in modo ricorsivo e il risultato dei sotto-problemi può aiutarci a risolvere il problema originale. Abbiamo visto due tecniche di applicazione della programmazione dinamica, quali Bottom-up e Top-down, le quali entrambe usano tempo O(n), lineare anziché esponenziale.

Next Section - Ancora su programmazione dinamica: il problema del resto