Vai al contenuto
Topos Uranos

Riepilogo

Una funzione può chiamare sé stessa: la ricorsione risolve un problema riducendolo a un’istanza più piccola dello stesso problema. Questa lezione traduce in C++ definizioni ricorsive della matematica (il fattoriale, la potenza, la somma delle cifre), ciascuna con il suo caso base e il suo caso ricorsivo; mostra che l’induzione ne garantisce la correttezza e che una misura che decresce ne garantisce la terminazione; segue con lo stack delle chiamate i frame che si impilano e si disfano, e spiega quando lo stack si esaurisce. Esamina il limite del tipo del risultato, che va in overflow molto prima dello stack; confronta la versione ricorsiva e quella iterativa di Fibonacci contando le chiamate, e si chiude con un criterio per scegliere tra la ricorsione e un ciclo.

Obiettivi di apprendimento

  1. Scrivere una funzione ricorsiva, con il suo caso base e il suo caso ricorsivo, a partire da una definizione ricorsiva.
  2. Giustificare la correttezza e la terminazione di una funzione ricorsiva per induzione sulla dimensione del problema.
  3. Seguire con lo stack delle chiamate l’esecuzione di una funzione ricorsiva e spiegare l’overflow dello stack.
  4. Confrontare il costo della versione ricorsiva e di quella iterativa di Fibonacci contando le loro chiamate e le loro operazioni.

Definizioni ricorsive

Il fattoriale

Il fattoriale di un naturale nn è il prodotto dei naturali da 1 a nn: n!=1⋅2⋯nn! = 1 \cdot 2 \cdots n, con la convenzione 0!=10! = 1. I puntini di sospensione di quella formula nascondono un ciclo, e la lezione Cicli ha insegnato a scriverlo. Ora, la stessa definizione ammette un’altra forma, che non menziona alcun ciclo: il fattoriale di nn è nn volte il fattoriale di n−1n - 1, e quello di 0 è 1.

0!=1,n!=n⋅(n−1)!(n≥1).0! = 1, \qquad n! = n \cdot (n - 1)! \quad (n \geq 1).

Questa è una definizione ricorsiva: definisce il fattoriale di nn per mezzo del fattoriale di un numero minore. Non è circolare, perché ogni applicazione della seconda regola scende di un gradino e la scala termina nella prima: 3!=3⋅2!=3⋅2⋅1!=3⋅2⋅1⋅0!=63! = 3 \cdot 2! = 3 \cdot 2 \cdot 1! = 3 \cdot 2 \cdot 1 \cdot 0! = 6. Le due regole hanno un nome. La prima è il caso base, che dà il risultato senza riferirsi ad altro; la seconda è il caso ricorsivo, che riduce il problema a un’istanza più piccola dello stesso problema.

C++ permette a una funzione di chiamare sé stessa, e con ciò la definizione si traduce quasi parola per parola.

EsempioIl fattoriale ricorsivo
Programma in C++
#include <print>​// Fattoriale di n.// Precondizione: 0 <= n <= 12 (il limite si giustifica più avanti).// Postcondizione: restituisce n! = 1 · 2 · ... · n, con 0! = 1.int factorial(int n){    if (n == 0) {        return 1;                   // caso base    }    return n * factorial(n - 1);    // caso ricorsivo}​int main(){    for (int n{0}; n <= 6; ++n) {        std::println("{}! = {}", n, factorial(n));    }}
Output
0! = 11! = 12! = 23! = 64! = 245! = 1206! = 720

La funzione ha esattamente la forma della definizione: un if che tratta il caso base e un return che applica il caso ricorsivo. La chiamata factorial(n - 1) non è diversa da qualsiasi altra chiamata della lezione Funzioni: si valuta il suo argomento, si crea un nuovo frame con il suo n e, al termine, il valore restituito viene moltiplicato per l’n di chi ha chiamato.

Il contratto non è decorativo: con n=−1n = -1 il caso ricorsivo chiamerebbe con −2-2, poi con −3-3, e non raggiungerebbe mai lo 0; e 13!13! non sta in un int. Molti testi introduttivi scrivono questo fattoriale con il caso base if (n == 0 || n == 1), ridondante ma innocuo, e senza dire nulla dei negativi né della grandezza del risultato: il loro codice non termina con n<0n < 0 e dà un risultato privo di senso a partire da 13!13!. Entrambi i difetti saranno esaminati nella loro sezione.

Caso base e caso ricorsivo

Ogni funzione ricorsiva ha uno o più casi base, che si risolvono direttamente, e uno o più casi ricorsivi, che chiamano la stessa funzione con un argomento minore in un senso preciso e combinano ciò che essa restituisce.

La potenza xnx^n, con nn naturale, si definisce con x0=1x^0 = 1 e xn=x⋅xn−1x^n = x \cdot x^{n - 1}: la dimensione è l’esponente. La somma delle cifre di un naturale nn (quella di 2026 è 2+0+2+6=102 + 0 + 2 + 6 = 10) si definisce così: se n<10n < 10, la somma è nn, perché ha una sola cifra; altrimenti, è la somma delle cifre di ⌊n/10⌋\lfloor n / 10 \rfloor, che è nn senza la sua ultima cifra, più quest’ultima cifra, n mod 10n \bmod 10. Qui la dimensione è il numero di cifre.

EsempioLa potenza e la somma delle cifre
Programma in C++
#include <print>​// x elevato alla n.// Precondizione: n >= 0.// Postcondizione: restituisce x^n, con x^0 = 1.double power(double x, int n){    if (n == 0) {        return 1.0;    }    return x * power(x, n - 1);}​// Somma delle cifre decimali di n.// Precondizione: n >= 0.// Postcondizione: restituisce la somma delle cifre di n.int digitSum(int n){    if (n < 10) {        return n;    }    return digitSum(n / 10) + n % 10;}​int main(){    std::println("2^10 = {}", power(2.0, 10));    std::println("1.5^3 = {}", power(1.5, 3));    std::println("somma delle cifre di 2026: {}", digitSum(2026));}
Output
2^10 = 10241.5^3 = 3.375somma delle cifre di 2026: 10

In digitSum, la divisione intera n / 10 toglie l’ultima cifra e n % 10 la estrae, come nel cambiamento di base della lezione Algoritmi numerici. Per n=2026n = 2026, la funzione chiede la somma delle cifre di 202, che chiede quella di 20, che chiede quella di 2; questa è un caso base e restituisce 2, e i risultati si compongono al ritorno: 2+0=22 + 0 = 2, 2+2=42 + 2 = 4, 4+6=104 + 6 = 10.

Scrivere una funzione ricorsiva consiste, dunque, nel rispondere a due domande: quali casi si risolvono senza aiuto, e come si ottiene la soluzione di un caso a partire da quella di uno minore. Se entrambe le risposte sono corrette, lo è anche la funzione, e la sezione seguente lo dimostra.