Menu

Ricorsione in C: caso base, fattoriale e profondità dello stack

Come una funzione C chiama se stessa: il caso base che la ferma, fattoriale e Fibonacci svolti passo per passo, perché il Fibonacci ingenuo è disastrosamente lento, cos'è davvero uno stack overflow e quando un ciclo è la risposta migliore.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

Una funzione che chiama se stessa

Niente impedisce a una funzione C di chiamare se stessa. Il suo nome è visibile all'interno del corpo, quindi questo è lecito:

void countdown(int n) {
    printf("%d\n", n);
    countdown(n - 1);      /* chiama se stessa, ma non si ferma mai! */
}

Ed è anche sbagliato. Stampa all'infinito, anche numeri negativi, finché il programma non va in crash. Gli manca un caso base: una condizione in cui la funzione restituisce il controllo senza chiamare se stessa.

Ogni funzione ricorsiva ha esattamente queste due parti:

  • Un caso base: l'input più piccolo, a cui si risponde direttamente, senza ulteriori chiamate.
  • Un caso ricorsivo: risolve il problema tramite una versione strettamente più piccola di se stesso.

"Strettamente più piccola" è la parte che si sbaglia. countdown(n - 1) si avvicina a 0 a ogni chiamata. countdown(n) non lo farebbe, e nemmeno countdown(n / 2) se n potesse restare 1 per sempre. Ogni percorso deve ridurre il problema, altrimenti il caso base non viene mai raggiunto.

Fattoriale

Il primo esempio classico. n! è n × (n-1) × ... × 1, e 0! è definito come 1. Quella definizione è già ricorsiva: n! = n × (n-1)!.

Segui factorial(4) per vedere come si costruisce la risposta. Le chiamate scendono e le moltiplicazioni avvengono durante la risalita:

factorial(4)  -> 4 * factorial(3)
                      factorial(3) -> 3 * factorial(2)
                                           factorial(2) -> 2 * factorial(1)
                                                                factorial(1) -> 1   (caso base)
                                           factorial(2) = 2 * 1  = 2
                      factorial(3) = 3 * 2  = 6
factorial(4)  = 4 * 6  = 24

Non si moltiplica nulla finché il caso base non restituisce il valore. Ogni chiamata in sospeso resta in attesa tenendo il proprio n, ed è questo il punto da interiorizzare: quelle chiamate in attesa occupano memoria.

Nota il tipo di ritorno. int va in overflow intorno a 13!, producendo in silenzio un numero sbagliato: il C non controlla. unsigned long long ti porta fino a 20! e non oltre, perché 21! supera i 64 bit. Qui il limite non è la ricorsione, è il tipo.

Il caso base usa n <= 1 invece di n == 1 di proposito: factorial(0) deve valere 1, e <= lo gestisce. Con n == 1, chiamare factorial(0) scenderebbe a -1, -2 e non terminerebbe mai: un buon esempio di come un caso base "ovviamente corretto" possa dimenticare un input.

Fibonacci, e perché la versione ingenua è una trappola

Fibonacci è l'altro classico: ogni numero è la somma dei due precedenti, partendo da 0 e 1. La definizione ricorsiva si scrive da sola.

Guarda il numero di chiamate. fib(10) richiede 177 chiamate; fib(35) quasi 30 milioni. Ogni passo di 5 moltiplica il lavoro per circa undici.

Il motivo si vede nell'albero delle chiamate. fib(5) chiama fib(4) e fib(3); fib(4) chiama di nuovo fib(3); e ognuna di queste ricalcola fib(2) da zero. Nulla viene ricordato, quindi gli stessi sottoproblemi vengono risolti più e più volte e il numero di chiamate cresce all'incirca come 1,6ⁿ. fib(50) in questo modo girerebbe per giorni; fib(100) durerebbe più dell'universo.

La versione con il ciclo tiene gli ultimi due valori ed è lineare:

fib(90) risponde all'istante. La lezione non è "la ricorsione è lenta": è che la ricorsione con sottoproblemi sovrapposti è lenta, a meno che tu non ricordi le risposte. Salva i risultati in un array mentre li calcoli (memoizzazione) e anche la versione ricorsiva diventa lineare.

Il call stack e lo stack overflow

Ogni chiamata di funzione ha bisogno di un posto dove tenere i parametri, le variabili locali e l'indirizzo a cui tornare. Quello spazio è uno stack frame, che viene aggiunto quando la chiamata inizia e rimosso quando restituisce. La ricorsione impila i frame uno sopra l'altro: factorial(1000) ha mille frame attivi contemporaneamente, ognuno con il proprio n.

Lo stack non è grande. Un valore predefinito tipico è 1-8 MB, quindi qualche decina di migliaia di frame è il limite realistico, e molto meno se ogni frame contiene un grosso array locale. Superalo e il programma muore:

Segmentation fault (core dumped)

Quello è uno stack overflow, e ci si arriva in due modi:

Ricorsione infinita: un caso base mancante o irraggiungibile. È un bug, e il crash è immediato:

int bad(int n) {
    return bad(n - 1);      /* nessun caso base: crash in una frazione di secondo */
}

Corretta ma troppo profonda: una chiamata ricorsiva per ogni elemento di una lista da un milione di voci. La logica è giusta, ma l'approccio non entra nello stack. Riscrivila come un ciclo, oppure ristrutturala in modo che la profondità sia logaritmica (ricorrere sulle metà, come fanno la ricerca binaria e il merge sort, dà una profondità di circa 20 per un milione di elementi).

Alcuni compilatori riescono a trasformare la ricorsione in coda, in cui la chiamata ricorsiva è l'ultimissima cosa che la funzione fa, senza lavoro in sospeso dopo, in un ciclo che riusa un solo frame. countdown qui sopra è ricorsiva in coda; factorial no, perché la moltiplicazione deve ancora avvenire dopo che la chiamata restituisce. Ma il C non impone questa ottimizzazione, quindi può avvenire o meno a seconda del compilatore e delle opzioni. Non scrivere mai codice C che funziona solo perché l'ottimizzatore ha eliminato una chiamata in coda.

Dove la ricorsione vince davvero

Ogni funzione ricorsiva si può riscrivere come ciclo, e per un semplice conteggio il ciclo è chiaramente migliore. La ricorsione si guadagna il posto quando sono i dati stessi a essere ricorsivi, cioè quando una struttura contiene copie più piccole di se stessa.

La ricerca binaria è un esempio pulito: cerca in una metà, poi nella metà di quella.

Qui ci sono due casi base, ed è normale: uno per il successo e uno per l'esaurimento. La profondità è circa log₂(n), quindi anche un miliardo di elementi richiede solo trenta frame.

Altri casi in cui la ricorsione è la scelta naturale: attraversare un albero o una lista concatenata, percorrere le directory, analizzare espressioni annidate e gli ordinamenti divide et impera come quicksort e merge sort. In tutti questi il codice ricorsivo è più corto e più chiaro del ciclo con uno stack esplicito che lo sostituirebbe.

Ricorsione o ciclo?

Usa un ciclo quando          il problema e lineare: contare, sommare, scorrere
Usa la ricorsione quando     i dati sono annidati: alberi, strutture annidate, divide et impera
Riscrivi la ricorsione       se la profondita puo crescere senza limite con la dimensione dell'input
Non usare mai la ricorsione  quando i sottoproblemi si sovrappongono, a meno di memoizzare

Due note pratiche. Le chiamate ricorsive costano un po' più di un'iterazione di ciclo, perché ogni volta c'è un frame da aggiungere e rimuovere, quindi per cicli semplici e molto usati la versione iterativa vince in velocità oltre che in memoria. E il debug è diverso: uno stack trace di una ricorsione profonda è fatto di centinaia di frame tutti uguali, quindi stampa il parametro in ingresso (come fa il contatore calls qui sopra) quando qualcosa non termina.

Scrivere una funzione ricorsiva: una checklist

  1. Trova prima il caso base. Qual è l'input più piccolo e qual è la sua risposta? Se non sai dirlo, la funzione non si può scrivere.
  2. Dai per scontato che la chiamata ricorsiva funzioni. Non seguirla a mente: fidati che factorial(n - 1) restituisca (n-1)! e scrivi l'unico passo che lo trasforma nella risposta.
  3. Controlla che ogni percorso riduca il problema. Ogni chiamata ricorsiva deve avvicinarsi al caso base per ogni input possibile, compresi 0 e i negativi.
  4. Controlla la profondità. Più o meno quanti frame raggiungerà con dati reali? Migliaia vanno bene; milioni no.
  5. Controlla le sovrapposizioni. Se lo stesso sottoproblema viene calcolato due volte, ti serve la memoizzazione o un ciclo.

Domande frequenti

Cos'è la ricorsione in C?

Una funzione che chiama se stessa per risolvere una versione più piccola dello stesso problema. Ogni funzione ricorsiva ha bisogno di due cose: un caso base che restituisce un valore senza richiamarsi e un caso ricorsivo che si avvicina in modo misurabile al caso base. Senza caso base le chiamate non si fermano mai e il programma va in crash con uno stack overflow.

Come si scrive una funzione fattoriale in C?

int factorial(int n) { if (n <= 1) return 1; return n * factorial(n - 1); }. Il caso base gestisce 0 e 1, e ogni chiamata ricorsiva riduce n di uno finché non lo raggiunge. Tieni presente che un int va in overflow a 13!: usa unsigned long long per valori più grandi.

Perché il Fibonacci ricorsivo è così lento in C?

Perché fib(n) chiama fib(n-1) e fib(n-2), che ricalcolano gli stessi sottoproblemi di continuo: il numero di chiamate cresce in modo esponenziale, quindi fib(50) richiederebbe anni. Riscriverlo come un ciclo che tiene gli ultimi due valori lo rende lineare e istantaneo.

Cosa causa uno stack overflow nella ricorsione in C?

Ogni chiamata occupa un frame di memoria sullo stack per i suoi parametri e le sue variabili locali, e lo stack è di pochi megabyte. Un caso base mancante o irraggiungibile significa ricorsione infinita e un crash immediato; anche una ricorsione corretta che scende di centinaia di migliaia di livelli può esaurire lo stack.

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA