Quando usare la ricorsione?


26

Quando ci sono alcuni casi (relativamente) di base (pensa al primo anno di college CS student) in cui uno userebbe la ricorsione invece di un semplice ciclo?


2
puoi trasformare qualsiasi ricorsione in un ciclo (con una pila).
Kaveh,

Risposte:


19

Ho insegnato C ++ agli studenti universitari per circa due anni e ho coperto la ricorsione. Dalla mia esperienza, la tua domanda e i tuoi sentimenti sono molto comuni. All'estremo, alcuni studenti vedono la ricorsione come difficile da capire mentre altri vogliono usarla praticamente per tutto.

Penso che Dave lo riassuma bene: usalo dove è appropriato. Cioè, usalo quando ti sembra naturale. Quando affronti un problema che si adatta bene, molto probabilmente lo riconoscerai: sembrerà che non riesci nemmeno a trovare una soluzione iterativa. Inoltre, la chiarezza è un aspetto importante della programmazione. Altre persone (e anche tu!) Dovrebbero essere in grado di leggere e comprendere il codice che produci. Penso che sia sicuro dire che i cicli iterativi sono più facili da capire a prima vista rispetto alla ricorsione.

Non so quanto conosci bene la programmazione o l'informatica in generale, ma sento fermamente che non ha senso parlare di funzioni virtuali, eredità o concetti avanzati qui. Ho spesso iniziato con il classico esempio di calcolo dei numeri di Fibonacci. Si adatta bene qui, poiché i numeri di Fibonacci sono definiti in modo ricorsivo . Questo è facile da capire e non richiede alcuna caratteristica di fantasia della lingua. Dopo che gli studenti hanno acquisito una conoscenza di base della ricorsione, abbiamo dato un'altra occhiata ad alcune semplici funzioni che abbiamo costruito in precedenza. Ecco un esempio:

Una stringa contiene un carattere ?X

Ecco come l'abbiamo fatto prima: iterare la stringa e vedere se un indice contiene .X

bool find(const std::string& s, char x)
{
   for(int i = 0; i < s.size(); ++i)
   {
      if(s[i] == x)
         return true;
   }

   return false;
}

La domanda è allora, possiamo lo facciamo in modo ricorsivo? Certo che possiamo, ecco un modo:

bool find(const std::string& s, int idx, char x)
{
   if(idx == s.size())
      return false;

   return s[idx] == x || find(s, ++idx);
}

La prossima domanda naturale è quindi, dovremmo farlo in questo modo? Probabilmente no. Perché? È più difficile da capire ed è più difficile da inventare. Quindi è anche più incline all'errore.


2
L'ultimo paragrafo non è sbagliato; voglio solo menzionare che spesso lo stesso ragionamento favorisce la ricorsività rispetto alle soluzioni iterative (Quicksort!).
Raffaello

1
@Raphael Concordato, esattamente. Alcune cose sono più naturali da esprimere in modo iterativo, altre in modo ricorsivo. Quello era il punto che stavo cercando di chiarire :)
Juho,

Umm, perdonami se sbaglio, ma non sarebbe meglio se hai separato la linea di ritorno in una condizione if nel codice di esempio, che restituisce vero se si trova x, altrimenti la parte ricorsiva? Non so se 'o' continui ad essere eseguito anche se trova vero, ma in tal caso, questo codice è altamente inefficiente.
MindlessRanger,

@MindlessRanger Forse un esempio perfetto che la versione ricorsiva è più difficile da capire e da scrivere? :-)
Juho

Sì, e il mio commento precedente era sbagliato, "o" o "||" non controlla le condizioni successive se la prima condizione è vera, quindi non c'è inefficienza
MindlessRanger

24

Le soluzioni ad alcuni problemi sono espresse in modo più naturale usando la ricorsione.

Ad esempio, supponiamo di avere una struttura di dati ad albero con due tipi di nodi: foglie, che memorizzano un valore intero; e rami, che hanno una sottostruttura sinistra e destra nei loro campi. Supponiamo che le foglie siano ordinate, in modo che il valore più basso sia nella foglia più a sinistra.

Supponiamo che l'attività sia di stampare i valori dell'albero in ordine. Un algoritmo ricorsivo per farlo è abbastanza naturale:

class Node { abstract void traverse(); }
class Leaf extends Node { 
  int val; 
  void traverse() { print(val); }
} 
class Branch extends Node {
  Node left, right;
  void traverse() { left.traverse(); right.traverse(); }
}

Scrivere un codice equivalente senza ricorsione sarebbe molto più difficile. Provalo!

Più in generale, la ricorsione funziona bene con algoritmi su strutture di dati ricorsive come alberi o per problemi che possono essere naturalmente suddivisi in sotto-problemi. Dai un'occhiata, ad esempio, algoritmi di divisione e conquista .

Se vuoi davvero vedere la ricorsione nel suo ambiente più naturale, allora dovresti guardare un linguaggio di programmazione funzionale come Haskell. In un linguaggio del genere, non esiste un costrutto a ciclo continuo, quindi tutto viene espresso usando la ricorsione (o funzioni di ordine superiore, ma questa è un'altra storia, che vale la pena conoscere anche).

Si noti inoltre che i linguaggi di programmazione funzionale eseguono la ricorsione della coda ottimizzata. Ciò significa che non posano un frame stack a meno che non debbano --- essenzialmente, la ricorsione può essere convertita in un loop. Da un punto di vista pratico, è possibile scrivere il codice in modo naturale, ma ottenere le prestazioni del codice iterativo. Per la cronaca, sembra che i compilatori C ++ ottimizzino anche le chiamate di coda , quindi non ci sono costi aggiuntivi di utilizzo della ricorsione in C ++.


1
Il C ++ ha una ricorsione della coda? Potrebbe valere la pena sottolineare che i linguaggi funzionali in genere lo fanno.
Louis,

3
Grazie Louis. Alcuni compilatori C ++ ottimizzano le chiamate di coda. (La ricorsione della coda è una proprietà di un programma, non una lingua.) Ho aggiornato la mia risposta.
Dave Clarke,

Almeno GCC ottimizza le chiamate di coda (e anche alcune forme di chiamate non di coda) di distanza.
vonbrand,

11

Da qualcuno che praticamente vive in ricorsione proverò a far luce sull'argomento.

Quando viene introdotto per la prima volta alla ricorsione, apprendi che si tratta di una funzione che si chiama da sola e che viene sostanzialmente dimostrata con algoritmi come la traversata di alberi. Successivamente scopri che è molto usato nella programmazione funzionale per linguaggi come LISP e F #. Con l'F # che scrivo, la maggior parte di ciò che scrivo è ricorsivo e corrisponde al modello.

Se impari di più sulla programmazione funzionale come F #, imparerai gli elenchi F # sono implementati come elenchi collegati singolarmente, il che significa che le operazioni che accedono solo al capo dell'elenco sono O (1) e l'accesso agli elementi è O (n). Una volta appreso questo, si tende a attraversare i dati come elenco, creando un nuovo elenco in ordine inverso e quindi invertendo l'elenco prima di tornare dalla funzione che è molto efficace.

Ora, se inizi a pensarci, ti rendi presto conto che le funzioni ricorsive invieranno un frame di stack ogni volta che viene effettuata una chiamata di funzione e possono causare un overflow dello stack. Tuttavia, se costruisci la tua funzione ricorsiva in modo che possa eseguire una chiamata di coda e il compilatore supporti la possibilità di ottimizzare il codice per la chiamata di coda. vale a dire .NET OpCodes.Tailcall Field non causerà un overflow dello stack. A questo punto inizi a scrivere qualsiasi loop come funzione ricorsiva e qualsiasi decisione come corrispondenza; i giorni di ife whilesono ormai storia.

Una volta passati all'intelligenza artificiale utilizzando il backtracking in lingue come PROLOG, tutto è ricorsivo. Sebbene ciò richieda di pensare in un modo completamente diverso dal codice imperativo, se PROLOG è lo strumento giusto per il problema, ti libera dall'onere di dover scrivere molte righe di codice e può ridurre drasticamente il numero di errori. Vedi: cliente Amzi eoTek

Per tornare alla tua domanda su quando usare la ricorsione; un modo in cui guardo la programmazione è con l'hardware da un lato e concetti astratti dall'altro. Più il problema è vicino all'hardware, più penso nei linguaggi imperativi ife while, più il problema è astratto, più penso nei linguaggi di alto livello con la ricorsione. Tuttavia, se si inizia a scrivere codice di sistema di basso livello e simili e si desidera verificare che sia valido, si trovano utili soluzioni come i dimostratori di teoremi , che si basano fortemente sulla ricorsione.

Se guardi Jane Street vedrai che usano il linguaggio funzionale OCaml . Anche se non ho visto nessuno dei loro codici, dalla lettura di ciò che menzionano sul loro codice, stanno sicuramente pensando in modo ricorsivo.

MODIFICARE

Dal momento che stai cercando un elenco di usi, ti darò un'idea di base di cosa cercare nel codice e un elenco di usi di base che si basano principalmente sul concetto di Catamorfismo che va oltre le basi.

Per C ++: se si definisce una struttura o una classe che ha un puntatore alla stessa struttura o classe, la ricorsione dovrebbe essere considerata per i metodi di attraversamento che usano i puntatori.

Il caso semplice è un elenco collegato a una via. Elaboreresti l'elenco a partire dalla testa o dalla coda e poi attraverserai ricorsivamente l'elenco usando i puntatori.

Un albero è un altro caso in cui viene spesso utilizzata la ricorsione; così tanto che se vedi attraversare un albero senza ricorsione, dovresti iniziare a chiederti perché? Non è sbagliato, ma qualcosa che dovrebbe essere notato nei commenti.

Gli usi comuni della ricorsione sono:


2
Sembra una risposta davvero eccezionale, anche se è un po 'al di sopra di tutto ciò che viene insegnato nelle mie lezioni ogni volta che presto ci credo.
Taylor Huston,

1
@TaylorHuston Ricorda che sei il cliente; chiedi all'insegnante i concetti che vuoi capire. Probabilmente non risponderà loro in classe, ma lo catturerà durante le ore d'ufficio e potrebbe pagare molti dividendi in futuro.
Guy Coder,

Bella risposta, ma sembra troppo avanzata per qualcuno che non conosce la programmazione funzionale :).
pad

2
... portando l'ingenuo interrogatore a studiare la programmazione funzionale. Vincere!
JeffE,

8

Per darti un caso d'uso meno arcano di quelli forniti nelle altre risposte: la ricorsione si mescola molto bene con strutture di classe ad albero (orientate agli oggetti) derivanti da una fonte comune. Un esempio in C ++:

class Expression {
public:
    // The "= 0" means 'I don't implement this, I let my subclasses do that'
    virtual int ComputeValue() = 0;
}

class Plus : public Expression {
private:
    Expression* left
    Expression* right;
public:
    virtual int ComputeValue() { return left->ComputeValue() + right->ComputeValue(); }
}

class Times : public Expression {
private:
    Expression* left
    Expression* right;
public:
    virtual int ComputeValue() { return left->ComputeValue() * right->ComputeValue(); }
}

class Negate : public Expression {
private:
    Expression* expr;
public:
    virtual int ComputeValue() { return -(expr->ComputeValue()); }
}

class Constant : public Expression {
private:
    int value;
public:
    virtual int ComputeValue() { return value; }
}

L'esempio precedente utilizza la ricorsione: ComputeValue è implementato in modo ricorsivo. Per far funzionare l'esempio, usi le funzioni e l'ereditarietà virtuali. Non sai quali sono esattamente le parti sinistra e destra della classe Plus, ma non ti importa: è qualcosa che può calcolare il suo valore, che è tutto ciò che devi sapere.

Il vantaggio cruciale dell'approccio sopra è che ogni classe si prende cura dei propri calcoli . Separate completamente le diverse implementazioni di ogni possibile sottoespressione: non hanno alcuna conoscenza del reciproco funzionamento. Ciò semplifica il ragionamento sul programma e quindi rende il programma più semplice da comprendere, mantenere ed estendere.


1
Non sono sicuro a quali esempi "arcani" ti riferisci. Tuttavia, bella discussione sull'integrazione con OO.
Dave Clarke,

3

Il primo esempio usato per insegnare la ricorsione nella mia lezione di programmazione iniziale era una funzione per elencare tutte le cifre in un numero separatamente in ordine inverso.

void listDigits(int x){
     if (x <= 0)
        return;
     print x % 10;
     listDigits(x/10);
}

O qualcosa del genere (vado dalla memoria qui e non sto testando). Inoltre, quando entri in classi di livello superiore, userai la ricorsione MOLTO soprattutto negli algoritmi di ricerca, algoritmi di ordinamento, ecc.

Quindi ora può sembrare una funzione inutile nella lingua, ma a lungo termine è molto utile.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.