Ricorsione o iterazione?


227

C'è un impatto sulle prestazioni se utilizziamo un loop invece della ricorsione o viceversa negli algoritmi in cui entrambi possono servire allo stesso scopo? Ad esempio: controlla se la stringa specificata è un palindromo. Ho visto molti programmatori usare la ricorsione come mezzo per mostrare quando un semplice algoritmo di iterazione può adattarsi al conto. Il compilatore gioca un ruolo vitale nel decidere cosa usare?


4
@Warrior Non sempre. Con i programmi di scacchi, ad esempio, è più facile leggere la ricorsione. Una versione "iterativa" del codice di scacchi non aiuterebbe davvero la velocità e potrebbe renderlo più complicato.
Mateen Ulhaq,

12
Perché un martello dovrebbe essere favorito rispetto a una sega? Un cacciavite su un punteruolo? Uno scalpello sopra una coclea?
Wayne Conrad,

3
Non ci sono preferiti Sono tutti solo strumenti, ognuno con il proprio scopo. Vorrei chiedere: "in quale tipo di problemi è meglio l'iterazione rispetto alla ricorsione, e viceversa?"
Wayne Conrad,

9
"Cosa c'è di così bello nella ricorsione?" ... È ricorsivo, ecco cosa. ; o)
Keng,

9
Falsa premessa. La ricorsione non è buona; in effetti è molto male. Chiunque scriva software robusto tenterà di eliminare ogni ricorsione poiché, a meno che non possa essere ottimizzato per la coda o il numero di livelli limitati logaritmicamente o simili, la ricorsione porta quasi sempre allo stack di overflow di tipo cattivo.
R .. GitHub smette di aiutare ICE il

Risposte:


181

È possibile che la ricorsione sia più costosa, a seconda che la funzione ricorsiva sia ricorsiva di coda (l'ultima linea è chiamata ricorsiva). La ricorsione della coda dovrebbe essere riconosciuta dal compilatore e ottimizzata per la sua controparte iterativa (mantenendo l'implementazione concisa e chiara che hai nel tuo codice).

Scriverei l'algoritmo nel modo più sensato ed è il più chiaro per il povero succhiatore (che sia te stesso o qualcun altro) che deve mantenere il codice in pochi mesi o anni. Se si verificano problemi di prestazioni, quindi profilare il codice e quindi e solo successivamente cercare l'ottimizzazione passando a un'implementazione iterativa. Potresti voler esaminare la memoizzazione e la programmazione dinamica .


12
Gli algoritmi la cui correttezza può essere dimostrata dall'induzione tendono a scrivere se stessi naturalmente in forma ricorsiva. Insieme al fatto che la ricorsione della coda è ottimizzata dai compilatori, si finisce per vedere più algoritmi espressi in modo ricorsivo.
Binil Thomas,

15
ri: tail recursion is optimized by compilersMa non tutti i compilatori supportano la ricorsione della coda ..
Kevin Meredith

348

I loop possono ottenere un miglioramento delle prestazioni per il tuo programma. La ricorsione può ottenere un miglioramento delle prestazioni per il programmatore. Scegli quale è più importante nella tua situazione!


3
@LeighCaldwell: penso che riassuma esattamente il mio pensiero. Peccato non onnipotente. Certamente. :)
Ande Turner,

35
Sapevi che sei stato citato in un libro a causa della tua frase di risposta? LOL amazon.com/Grokking-Algorithms-illustrated-programmers-curious/…
Aipi,

4
Mi piace questa risposta .. e mi piace il libro "Grokking Algorithms")
Max

così, almeno io e 341 umani abbiamo letto il libro Algoritmi di Grokking!
zzfima,

78

Confrontare la ricorsione con l'iterazione è come confrontare un cacciavite a croce con un cacciavite a testa piatta. Per la maggior parte si potrebbe rimuovere qualsiasi vite a croce testa con una testa piatta, ma sarebbe solo essere più facile se si è utilizzato il cacciavite progettata per tale diritto vite?

Alcuni algoritmi si prestano semplicemente alla ricorsione a causa del modo in cui sono progettati (sequenze di Fibonacci, attraversamento di un albero come una struttura, ecc.). La ricorsione rende l'algoritmo più succinto e più comprensibile (quindi condivisibile e riutilizzabile).

Inoltre, alcuni algoritmi ricorsivi usano "Lazy Evaluation" che li rende più efficienti dei loro fratelli iterativi. Ciò significa che eseguono i calcoli costosi solo nel momento in cui sono necessari anziché ogni volta che il ciclo viene eseguito.

Dovrebbe essere abbastanza per iniziare. Prenderò alcuni articoli ed esempi anche per te.

Link 1: Haskel vs PHP (Recursion vs Iteration)

Ecco un esempio in cui il programmatore ha dovuto elaborare un set di dati di grandi dimensioni utilizzando PHP. Dimostra quanto sarebbe stato facile affrontare Haskel usando la ricorsione, ma poiché PHP non aveva un modo semplice per ottenere lo stesso metodo, è stato costretto a usare l'iterazione per ottenere il risultato.

http://blog.webspecies.co.uk/2011-05-31/lazy-evaluation-with-php.html

Link 2: Mastering Recursion

Gran parte della cattiva reputazione della ricorsione deriva dagli alti costi e dall'inefficienza delle lingue imperative. L'autore di questo articolo parla di come ottimizzare gli algoritmi ricorsivi per renderli più veloci ed efficienti. Viene inoltre illustrato come convertire un loop tradizionale in una funzione ricorsiva e i vantaggi dell'utilizzo della ricorsione di coda. Le sue parole conclusive hanno davvero riassunto alcuni dei miei punti chiave che penso:

"la programmazione ricorsiva offre al programmatore un modo migliore di organizzare il codice in modo che sia sostenibile e logicamente coerente."

https://developer.ibm.com/articles/l-recurs/

Link 3: la ricorsione è sempre più veloce del looping? (Risposta)

Ecco un link a una risposta per una domanda StackOverflow simile alla tua. L'autore sottolinea che molti dei parametri di riferimento associati alla ricorrenza o al ciclo sono molto specifici del linguaggio. Le lingue imperative sono in genere più veloci utilizzando un ciclo e più lente con la ricorsione e viceversa per le lingue funzionali. Immagino che il punto principale da trarre da questo collegamento sia che è molto difficile rispondere alla domanda in un linguaggio cieco agnostico / situazione.

La ricorsione è mai più veloce del looping?


4
Mi è


16

La ricorsione è più costosa in memoria, poiché ogni chiamata ricorsiva richiede generalmente che un indirizzo di memoria venga inserito nello stack, in modo che in seguito il programma possa tornare a quel punto.

Tuttavia, ci sono molti casi in cui la ricorsione è molto più naturale e leggibile rispetto ai loop, come quando si lavora con gli alberi. In questi casi, consiglierei di attenersi alla ricorsione.


5
A meno che ovviamente il compilatore non ottimizzi le chiamate di coda come Scala.
Ben Hardy,

11

In genere, ci si aspetterebbe che la penalità prestazionale risieda nella direzione opposta. Le chiamate ricorsive possono portare alla costruzione di frame stack aggiuntivi; la penalità per questo varia. Inoltre, in alcune lingue come Python (più correttamente, in alcune implementazioni di alcune lingue ...), è possibile imbattersi in limiti di stack piuttosto facilmente per attività che è possibile specificare in modo ricorsivo, come trovare il valore massimo in una struttura di dati ad albero. In questi casi, vuoi davvero restare con i loop.

Scrivere buone funzioni ricorsive può ridurre un po 'la penalità delle prestazioni, supponendo che tu abbia un compilatore che ottimizza le ricorsioni di coda, ecc. sopra.)

A parte i casi "marginali" (elaborazione ad alte prestazioni, profondità di ricorsione molto ampia, ecc.), È preferibile adottare l'approccio che esprime più chiaramente le tue intenzioni, è ben progettato ed è mantenibile. Ottimizza solo dopo aver identificato un'esigenza.


8

La ricorsione è migliore dell'iterazione per problemi che possono essere suddivisi in più pezzi più piccoli.

Ad esempio, per creare un algoritmo ricorsivo di Fibonnaci, si scompone fib (n) in fib (n-1) e fib (n-2) e si calcolano entrambe le parti. L'iterazione consente solo di ripetere più volte una singola funzione.

Tuttavia, Fibonacci è in realtà un esempio non corretto e penso che l'iterazione sia effettivamente più efficiente. Si noti che fib (n) = fib (n-1) + fib (n-2) e fib (n-1) = fib (n-2) + fib (n-3). fib (n-1) viene calcolato due volte!

Un esempio migliore è un algoritmo ricorsivo per un albero. Il problema dell'analisi del nodo padre può essere suddiviso in più problemi minori dell'analisi di ciascun nodo figlio. A differenza dell'esempio di Fibonacci, i problemi più piccoli sono indipendenti l'uno dall'altro.

Quindi sì: la ricorsione è migliore dell'iterazione per problemi che possono essere suddivisi in problemi multipli, più piccoli, indipendenti e simili.


1
Il calcolo due volte potrebbe effettivamente essere evitato attraverso la memoizzazione.
Siddhartha,

7

Le tue prestazioni peggiorano quando usi la ricorsione perché chiamare un metodo, in qualsiasi lingua, implica molta preparazione: il codice chiamante pubblica un indirizzo di ritorno, parametri di chiamata, alcune altre informazioni di contesto come i registri del processore potrebbero essere salvate da qualche parte, e al momento del ritorno il chiamato metodo invia un valore restituito che viene quindi recuperato dal chiamante e tutte le informazioni di contesto salvate in precedenza verranno ripristinate. la differenza di prestazioni tra un approccio iterativo e un approccio ricorsivo risiede nel tempo impiegato da queste operazioni.

Da un punto di vista dell'implementazione, inizi davvero a notare la differenza quando il tempo necessario per gestire il contesto chiamante è paragonabile al tempo impiegato dal tuo metodo per essere eseguito. Se l'esecuzione del metodo ricorsivo richiede più tempo rispetto alla parte di gestione del contesto chiamante, procedere in modo ricorsivo poiché il codice è generalmente più leggibile e di facile comprensione e non si noterà la perdita di prestazioni. Altrimenti diventa iterativo per motivi di efficienza.


Questo non è sempre vero. La ricorsione può essere efficace quanto l'iterazione in alcuni casi in cui è possibile eseguire l'ottimizzazione delle chiamate di coda. stackoverflow.com/questions/310974/…
Sid Kshatriya

6

Credo che la ricorsione della coda in Java non sia attualmente ottimizzata. I dettagli sono cosparsi in questa discussione su LtU e sui collegamenti associati. Essa può essere una caratteristica nella prossima versione 7, ma a quanto pare presenta alcune difficoltà quando combinato con Stack di ispezione in quanto determinati fotogrammi sarebbero scomparsi. Stack Inspection è stato utilizzato per implementare il loro modello di sicurezza a grana fine da Java 2.

http://lambda-the-ultimate.org/node/1333


Esistono JVM per Java che ottimizzano la ricorsione della coda. ibm.com/developerworks/java/library/j-diag8.html
Liran Orevi

5

Ci sono molti casi in cui fornisce una soluzione molto più elegante rispetto al metodo iterativo, l'esempio comune è l'attraversamento di un albero binario, quindi non è necessariamente più difficile da mantenere. In generale, le versioni iterative sono in genere un po 'più veloci (e durante l'ottimizzazione può sostituire una versione ricorsiva), ma le versioni ricorsive sono più semplici da comprendere e implementare correttamente.


5

La ricorsione è molto utile in alcune situazioni. Ad esempio, considera il codice per trovare il fattoriale

int factorial ( int input )
{
  int x, fact = 1;
  for ( x = input; x > 1; x--)
     fact *= x;
  return fact;
}

Ora consideralo usando la funzione ricorsiva

int factorial ( int input )
{
  if (input == 0)
  {
     return 1;
  }
  return input * factorial(input - 1);
}

Osservando questi due, possiamo vedere che la ricorsione è facile da capire. Ma se non viene usato con cura, può anche essere soggetto a molti errori. Supponiamo che se perdiamo if (input == 0), il codice verrà eseguito per un po 'di tempo e termina di solito con uno stack overflow.


6
In realtà trovo la versione iterativa più facile da capire. A ciascuno il suo, suppongo.
Max

@Maxpm, una soluzione ricorsiva di alto ordine è molto meglio: foldl (*) 1 [1..n]tutto qui.
SK-logic,

5

In molti casi la ricorsione è più veloce a causa della memorizzazione nella cache, che migliora le prestazioni. Ad esempio, ecco una versione iterativa dell'ordinamento di tipo merge che utilizza la tradizionale routine di tipo merge. Funzionerà più lentamente dell'implementazione ricorsiva a causa della memorizzazione nella cache di prestazioni migliorate.

Implementazione iterativa

public static void sort(Comparable[] a)
{
    int N = a.length;
    aux = new Comparable[N];
    for (int sz = 1; sz < N; sz = sz+sz)
        for (int lo = 0; lo < N-sz; lo += sz+sz)
            merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
}

Implementazione ricorsiva

private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi)
{
    if (hi <= lo) return;
    int mid = lo + (hi - lo) / 2;
    sort(a, aux, lo, mid);
    sort(a, aux, mid+1, hi);
    merge(a, aux, lo, mid, hi);
}

PS: è quanto ha detto il professor Kevin Wayne (Università di Princeton) nel corso sugli algoritmi presentato su Coursera.


4

Usando la ricorsione, stai sostenendo il costo di una chiamata di funzione con ogni "iterazione", mentre con un ciclo, l'unica cosa che di solito paghi è un incremento / decremento. Quindi, se il codice per il ciclo non è molto più complicato del codice per la soluzione ricorsiva, il ciclo di solito sarà superiore alla ricorsione.


1
In realtà, la funzione compilata ricorsiva della coda di Scala si riduce a un ciclo nel bytecode, se ti interessa guardarli (consigliato). Nessun overhead di chiamata di funzione. In secondo luogo, le funzioni ricorsive della coda hanno il vantaggio di non richiedere variabili / effetti collaterali mutabili o cicli espliciti, rendendo la correttezza molto più facile da dimostrare.
Ben Hardy,

4

La ricorsione e l'iterazione dipendono dalla logica aziendale che si desidera implementare, sebbene nella maggior parte dei casi possa essere utilizzata in modo intercambiabile. La maggior parte degli sviluppatori ricorre alla ricorsione perché è più facile da capire.


4

Dipende dalla lingua. In Java dovresti usare i loop. I linguaggi funzionali ottimizzano la ricorsione.


3

Se stai solo iterando su un elenco, allora sicuramente, iterare via.

Un paio di altre risposte hanno menzionato l'attraversamento degli alberi (in profondità). È davvero un ottimo esempio, perché è una cosa molto comune fare una struttura di dati molto comune. La ricorsione è estremamente intuitiva per questo problema.

Dai un'occhiata ai metodi "trova" qui: http://penguin.ewu.edu/cscd300/Topic/BSTintro/index.html


3

La ricorsione è più semplice (e quindi - più fondamentale) di ogni possibile definizione di iterazione. È possibile definire un sistema completo di Turing con solo una coppia di combinatori (sì, anche una ricorsione stessa è una nozione derivata in tale sistema). Il calcolo lambda è un sistema fondamentale altrettanto potente, con funzioni ricorsive. Ma se vuoi definire correttamente un'iterazione, avresti bisogno di molte più primitive per iniziare.

Per quanto riguarda il codice, no, il codice ricorsivo è in effetti molto più facile da capire e da mantenere rispetto a quello puramente iterativo, poiché la maggior parte delle strutture di dati sono ricorsive. Ovviamente, per farlo bene uno avrebbe bisogno di una lingua con un supporto per funzioni e chiusure di alto ordine, almeno - per ottenere tutti i combinatori e iteratori standard in modo ordinato. In C ++, ovviamente, le complicate soluzioni ricorsive possono sembrare un po 'brutte, a meno che tu non sia un utente hardcore di FC ++ e simili.


Il codice ricorsivo può essere estremamente difficile da seguire, specialmente se l'ordine dei parametri cambia o i tipi ad ogni ricorsione. Il codice iterativo può essere molto semplice e descrittivo. L'importante è codificare prima la leggibilità (e quindi l'affidabilità), sia iterativa che ricorsiva, quindi ottimizzare se necessario.
Marcus Clements,

2

Penserei che nella ricorsione (non di coda) si verificherebbe un impatto sulle prestazioni per l'allocazione di un nuovo stack ecc. Ogni volta che viene chiamata la funzione (dipende ovviamente dalla lingua).


2

dipende dalla "profondità di ricorsione". dipende da quanto l'overhead della chiamata di funzione influenzerà il tempo totale di esecuzione.

Ad esempio, il calcolo del fattoriale classico in modo ricorsivo è molto inefficiente a causa di: - rischio di overflow dei dati - rischio di overflow dello stack - l'overhead della chiamata di funzione occupa l'80% dei tempi di esecuzione

mentre si sviluppa un algoritmo min-max per l'analisi della posizione nel gioco degli scacchi che analizzerà le successive mosse N può essere implementato in ricorsione sulla "profondità dell'analisi" (come sto facendo ^ _ ^)


sono completamente d'accordo con ugasoft qui ... dipende dalla profondità della ricorsione ... e dalla complessità della sua implementazione iterativa ... è necessario confrontare entrambi e vedere quale è più efficiente ... Non esiste una regola del pollice in quanto tale. ..
rajya vardhan,

2

Ricorsione? Da dove comincio, wiki ti dirà "è il processo di ripetere gli oggetti in modo auto-simile"

Ai tempi in cui stavo facendo C, la ricorsione in C ++ era una mossa di Dio, cose come "Ricorsione in coda". Troverai anche molti algoritmi di ordinamento che utilizzano la ricorsione. Esempio di ordinamento rapido: http://alienryderflex.com/quicksort/

La ricorsione è come qualsiasi altro algoritmo utile per un problema specifico. Forse non potresti trovare un uso subito o spesso, ma ci saranno problemi, sarai contento che sia disponibile.


Penso che tu abbia l'ottimizzazione del compilatore al contrario. I compilatori ottimizzeranno le funzioni ricorsive in un ciclo iterativo quando possibile per evitare la crescita dello stack.
CoderDennis,

Punto giusto, era al contrario. Tuttavia non sono sicuro che sia ancora applicabile per la ricorsione della coda.
Nickz,

2

In C ++ se la funzione ricorsiva è templata, il compilatore ha maggiori possibilità di ottimizzarla, poiché tutte le deduzioni di tipo e le istanze di funzione si verificheranno in fase di compilazione. I compilatori moderni possono anche incorporare la funzione, se possibile. Quindi, se si usano flag di ottimizzazione come -O3o -O2dentro g++, le ricorsioni possono avere la possibilità di essere più veloci delle iterazioni. Nei codici iterativi, il compilatore ha meno possibilità di ottimizzarlo, poiché è già nello stato più o meno ottimale (se scritto abbastanza bene).

Nel mio caso, stavo cercando di implementare l'espiazione della matrice quadrando usando gli oggetti matrice Armadillo, sia in modo ricorsivo che iterativo. L'algoritmo può essere trovato qui ... https://en.wikipedia.org/wiki/Exponentiation_by_squaring . Le mie funzioni erano basate su modelli e ho calcolato 1,000,000 12x12matrici elevate al potere 10. Ho ottenuto il seguente risultato:

iterative + optimisation flag -O3 -> 2.79.. sec
recursive + optimisation flag -O3 -> 1.32.. sec

iterative + No-optimisation flag  -> 2.83.. sec
recursive + No-optimisation flag  -> 4.15.. sec

Questi risultati sono stati ottenuti usando gcc-4.8 con c ++ 11 flag ( -std=c++11) e Armadillo 6.1 con Intel mkl. Anche il compilatore Intel mostra risultati simili.


1

Mike ha ragione. La ricorsione della coda non è ottimizzata dal compilatore Java o dalla JVM. Otterrai sempre un overflow dello stack con qualcosa del genere:

int count(int i) {
  return i >= 100000000 ? i : count(i+1);
}

3
A meno che tu non lo scriva alla Scala ;-)
Ben Hardy,

1

Devi tenere presente che utilizzando una ricorsione troppo profonda ti imbatterai in Stack Overflow, a seconda delle dimensioni dello stack consentite. Per evitare ciò, assicurati di fornire un caso base che termina la ricorsione.


1

La ricorsione ha uno svantaggio che l'algoritmo che scrivi usando la ricorsione ha una complessità spaziale O (n). Mentre l'approccio iterativo ha una complessità spaziale di O (1). Questo è il vantaggio di usare l'iterazione rispetto alla ricorsione. Allora perché usiamo la ricorsione?

Vedi sotto.

A volte è più facile scrivere un algoritmo usando la ricorsione mentre è leggermente più difficile scrivere lo stesso algoritmo usando l'iterazione. In questo caso, se si sceglie di seguire l'approccio di iterazione, è necessario gestire lo stack da soli.


1

Se le iterazioni sono atomiche e gli ordini di grandezza sono più costosi rispetto alla spinta di un nuovo stack frame e alla creazione di un nuovo thread e hai più core e il tuo ambiente di runtime può usarli tutti, un approccio ricorsivo potrebbe produrre un enorme aumento delle prestazioni quando combinato con multithreading. Se il numero medio di iterazioni non è prevedibile, potrebbe essere una buona idea utilizzare un pool di thread che controllerà l'allocazione dei thread e impedirà al processo di creare troppi thread e controllare il sistema.

Ad esempio, in alcune lingue, esistono implementazioni ricorsive di ordinamento di tipo merge multithread.

Ma ancora una volta, il multithreading può essere utilizzato con il loop piuttosto che con la ricorsione, quindi quanto bene funzionerà questa combinazione dipende da più fattori tra cui il sistema operativo e il suo meccanismo di allocazione dei thread.


0

Per quanto ne so, Perl non ottimizza le chiamate ricorsive della coda, ma puoi fingere.

sub f{
  my($l,$r) = @_;

  if( $l >= $r ){
    return $l;
  } else {

    # return f( $l+1, $r );

    @_ = ( $l+1, $r );
    goto &f;

  }
}

Quando viene chiamato per la prima volta, alloca spazio nello stack. Quindi cambierà i suoi argomenti e riavvierà la subroutine, senza aggiungere altro allo stack. Farà quindi finta di non chiamarsi mai, trasformandolo in un processo iterativo.

Nota che non c'è " my @_;" o " local @_;", se lo facessi non funzionerebbe più.


0

Utilizzando solo Chrome 45.0.2454.85 m, la ricorsione sembra essere molto più veloce.

Ecco il codice:

(function recursionVsForLoop(global) {
    "use strict";

    // Perf test
    function perfTest() {}

    perfTest.prototype.do = function(ns, fn) {
        console.time(ns);
        fn();
        console.timeEnd(ns);
    };

    // Recursion method
    (function recur() {
        var count = 0;
        global.recurFn = function recurFn(fn, cycles) {
            fn();
            count = count + 1;
            if (count !== cycles) recurFn(fn, cycles);
        };
    })();

    // Looped method
    function loopFn(fn, cycles) {
        for (var i = 0; i < cycles; i++) {
            fn();
        }
    }

    // Tests
    var curTest = new perfTest(),
        testsToRun = 100;

    curTest.do('recursion', function() {
        recurFn(function() {
            console.log('a recur run.');
        }, testsToRun);
    });

    curTest.do('loop', function() {
        loopFn(function() {
            console.log('a loop run.');
        }, testsToRun);
    });

})(window);

RISULTATI

// 100 esegue utilizzando lo standard per il ciclo

100x per loop. Tempo per il completamento: 7,683 ms

// 100 corse con approccio ricorsivo funzionale con ricorsione della coda

Esecuzione di ricorsione 100x. Tempo per il completamento: 4.841ms

Nello screenshot seguente, la ricorsione vince ancora con un margine maggiore quando viene eseguita a 300 cicli per test

La ricorsione vince ancora!


Il test non è valido perché stai chiamando la funzione all'interno della funzione loop - questo invalida uno dei vantaggi prestazionali più importanti del loop che è la mancanza di salti di istruzioni (incluso, per chiamate di funzione, assegnazione di stack, popping di stack, ecc.). Se si eseguisse un'attività all'interno di un ciclo (non si chiamasse semplicemente una funzione) anziché eseguire un'attività all'interno di una funzione ricorsiva, si otterrebbero risultati diversi. (Le prestazioni di PS sono una questione dell'algoritmo del compito reale, in cui a volte i salti di istruzione sono più economici dei calcoli necessari per evitarli).
Myst,

0

Ho trovato un'altra differenza tra questi approcci. Sembra semplice e non importante, ma ha un ruolo molto importante mentre ti prepari per le interviste e questo argomento si pone, quindi guarda da vicino.

In breve: 1) l'attraversamento iterativo post-ordine non è facile - questo rende DFT più complesso 2) i cicli controllano più facilmente con la ricorsione

Dettagli:

Nel caso ricorsivo, è facile creare attraversamenti pre e post:

Immagina una domanda piuttosto standard: "stampa tutte le attività che dovrebbero essere eseguite per eseguire l'attività 5, quando le attività dipendono da altre attività"

Esempio:

    //key-task, value-list of tasks the key task depends on
    //"adjacency map":
    Map<Integer, List<Integer>> tasksMap = new HashMap<>();
    tasksMap.put(0, new ArrayList<>());
    tasksMap.put(1, new ArrayList<>());

    List<Integer> t2 = new ArrayList<>();
    t2.add(0);
    t2.add(1);
    tasksMap.put(2, t2);

    List<Integer> t3 = new ArrayList<>();
    t3.add(2);
    t3.add(10);
    tasksMap.put(3, t3);

    List<Integer> t4 = new ArrayList<>();
    t4.add(3);
    tasksMap.put(4, t4);

    List<Integer> t5 = new ArrayList<>();
    t5.add(3);
    tasksMap.put(5, t5);

    tasksMap.put(6, new ArrayList<>());
    tasksMap.put(7, new ArrayList<>());

    List<Integer> t8 = new ArrayList<>();
    t8.add(5);
    tasksMap.put(8, t8);

    List<Integer> t9 = new ArrayList<>();
    t9.add(4);
    tasksMap.put(9, t9);

    tasksMap.put(10, new ArrayList<>());

    //task to analyze:
    int task = 5;


    List<Integer> res11 = getTasksInOrderDftReqPostOrder(tasksMap, task);
    System.out.println(res11);**//note, no reverse required**

    List<Integer> res12 = getTasksInOrderDftReqPreOrder(tasksMap, task);
    Collections.reverse(res12);//note reverse!
    System.out.println(res12);

    private static List<Integer> getTasksInOrderDftReqPreOrder(Map<Integer, List<Integer>> tasksMap, int task) {
         List<Integer> result = new ArrayList<>();
         Set<Integer> visited = new HashSet<>();
         reqPreOrder(tasksMap,task,result, visited);
         return result;
    }

private static void reqPreOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {

    if(!visited.contains(task)) {
        visited.add(task);
        result.add(task);//pre order!
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPreOrder(tasksMap,child,result, visited);
            }
        }
    }
}

private static List<Integer> getTasksInOrderDftReqPostOrder(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    reqPostOrder(tasksMap,task,result, visited);
    return result;
}

private static void reqPostOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {
    if(!visited.contains(task)) {
        visited.add(task);
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPostOrder(tasksMap,child,result, visited);
            }
        }
        result.add(task);//post order!
    }
}

Si noti che l'attraversamento post ordine ricorsivo non richiede una successiva inversione del risultato. I bambini hanno stampato per primi e il tuo compito nella domanda è stata stampata per ultima. Va tutto bene. Puoi effettuare un attraversamento pre-ordine ricorsivo (mostrato anche sopra) e richiedere uno storno dell'elenco dei risultati.

Non è così semplice con un approccio iterativo! Nell'approccio iterativo (uno stack) puoi fare solo un pre-order-traversal, quindi hai l'obbligo di invertire l'array dei risultati alla fine:

    List<Integer> res1 = getTasksInOrderDftStack(tasksMap, task);
    Collections.reverse(res1);//note reverse!
    System.out.println(res1);

    private static List<Integer> getTasksInOrderDftStack(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    Stack<Integer> st = new Stack<>();


    st.add(task);
    visited.add(task);

    while(!st.isEmpty()){
        Integer node = st.pop();
        List<Integer> children = tasksMap.get(node);
        result.add(node);
        if(children!=null && children.size() > 0){
            for(Integer child:children){
                if(!visited.contains(child)){
                    st.add(child);
                    visited.add(child);
                }
            }
        }
        //If you put it here - it does not matter - it is anyway a pre-order
        //result.add(node);
    }
    return result;
}

Sembra semplice, no?

Ma è una trappola in alcune interviste.

Significa quanto segue: con l'approccio ricorsivo, è possibile implementare Depth First Traversal e quindi selezionare quale ordine è necessario pre o post (semplicemente modificando la posizione della "stampa", nel nostro caso della "aggiunta all'elenco dei risultati" ). Con l'approccio iterativo (una pila) puoi facilmente eseguire solo il pre-ordine di attraversamento e quindi nella situazione in cui i bambini devono essere stampati per primi (praticamente tutte le situazioni in cui è necessario iniziare a stampare dai nodi inferiori, andando verso l'alto). il problema. Se hai questo problema, puoi invertire in seguito, ma sarà un'aggiunta al tuo algoritmo. E se un intervistatore sta guardando l'orologio, potrebbe essere un problema per te. Esistono modi complessi per eseguire un attraversamento iterativo post-ordine, esistono, ma non sono semplici . Esempio:https://www.geeksforgeeks.org/iterative-postorder-traversal-using-stack/

Quindi, la linea di fondo: vorrei usare la ricorsione durante le interviste, è più semplice da gestire e da spiegare. Hai un modo semplice per passare dall'attraversamento pre al post ordine in qualsiasi caso urgente. Con iterativo non sei così flessibile.

Vorrei usare la ricorsione e poi dire: "Ok, ma l'iterativo può fornirmi un controllo più diretto sulla memoria utilizzata, posso facilmente misurare le dimensioni dello stack e impedire un pericoloso overflow ..."

Un altro vantaggio della ricorsione: è più semplice evitare / notare cicli in un grafico.

Esempio (preudocodice):

dft(n){
    mark(n)
    for(child: n.children){
        if(marked(child)) 
            explode - cycle found!!!
        dft(child)
    }
    unmark(n)
}

0

Può essere divertente scriverlo come ricorsione o come pratica.

Tuttavia, se il codice deve essere utilizzato in produzione, è necessario considerare la possibilità di overflow dello stack.

L'ottimizzazione della ricorsione della coda può eliminare l'overflow dello stack, ma vuoi affrontare il problema di renderlo tale e devi sapere che puoi contare su di esso con l'ottimizzazione nel tuo ambiente.

Ogni volta che ricorre l'algoritmo, di quanto vengono ridotte o nridotte le dimensioni dei dati ?

Se stai riducendo la dimensione dei dati o ndella metà ogni volta che fai ricorso, in generale non devi preoccuparti dell'overflow dello stack. Ad esempio, se deve essere profondo 4.000 o profondo 10.000 affinché il programma possa impilare l'overflow, le dimensioni dei dati devono essere circa 2 4000 affinché il programma impili l'overflow. Per metterlo in prospettiva, un dispositivo di archiviazione più grande di recente può contenere 2 61 byte e se si dispone di 2 61 di tali dispositivi, si ha a che fare solo con 2 122 dimensioni dei dati. Se stai osservando tutti gli atomi nell'universo, si stima che possa essere inferiore a 2 84. Se hai bisogno di trattare tutti i dati nell'universo e i loro stati per ogni millisecondo dalla nascita dell'universo stimato in 14 miliardi di anni fa, potrebbe essere solo 2 153 . Pertanto, se il programma è in grado di gestire 2 4000 unità di dati o n, è possibile gestire tutti i dati nell'universo e il programma non impilerà l'overflow. Se non hai bisogno di gestire numeri grandi come 2 4000 (un numero intero di 4000 bit), in generale non devi preoccuparti di overflow dello stack.

Tuttavia, se riduci la dimensione dei dati o ndi un importo costante ogni volta che fai ricorso, puoi eseguire lo stack overflow quando il programma funziona bene quando nè 1000ma in alcune situazioni, quando ndiventa semplicemente 20000.

Quindi, se hai la possibilità di overflow dello stack, prova a renderlo una soluzione iterativa.


-1

Risponderò alla tua domanda progettando una struttura di dati di Haskell per "induzione", che è una sorta di "duplice" ricorsione. E poi mostrerò come questa dualità porta a cose belle.

Introduciamo un tipo per un albero semplice:

data Tree a = Branch (Tree a) (Tree a)
            | Leaf a
            deriving (Eq)

Possiamo leggere questa definizione dicendo "Un albero è un ramo (che contiene due alberi) o è una foglia (che contiene un valore di dati)". Quindi la foglia è una specie di caso minimo. Se un albero non è una foglia, deve essere un albero composto contenente due alberi. Questi sono gli unici casi.

Facciamo un albero:

example :: Tree Int
example = Branch (Leaf 1) 
                 (Branch (Leaf 2) 
                         (Leaf 3))

Supponiamo ora di aggiungere 1 a ciascun valore nella struttura. Possiamo farlo chiamando:

addOne :: Tree Int -> Tree Int
addOne (Branch a b) = Branch (addOne a) (addOne b)
addOne (Leaf a)     = Leaf (a + 1)

Innanzitutto, si noti che questa è in realtà una definizione ricorsiva. Prende i costruttori di dati Branch e Leaf come casi (e poiché Leaf è minimo e questi sono gli unici casi possibili), siamo sicuri che la funzione terminerà.

Cosa sarebbe necessario per scrivere addOne in uno stile iterativo? Come sarà il looping in un numero arbitrario di rami?

Inoltre, questo tipo di ricorsione può essere spesso preso in considerazione, in termini di "funzione". Possiamo trasformare gli alberi in Functor definendo:

instance Functor Tree where fmap f (Leaf a)     = Leaf (f a)
                            fmap f (Branch a b) = Branch (fmap f a) (fmap f b)

e definendo:

addOne' = fmap (+1)

Siamo in grado di escludere altri schemi di ricorsione, come il catamorfismo (o piega) per un tipo di dati algebrico. Usando un catamorfismo, possiamo scrivere:

addOne'' = cata go where
           go (Leaf a) = Leaf (a + 1)
           go (Branch a b) = Branch a b

-2

Lo overflow dello stack si verificherà solo se stai programmando in un linguaggio che non ha nella gestione della memoria integrata .... Altrimenti, assicurati di avere qualcosa nella tua funzione (o una chiamata di funzione, STDLbs, ecc.). Senza ricorsione non sarebbe semplicemente possibile avere cose come ... Google o SQL, o qualsiasi luogo in cui si debba ordinare in modo efficiente attraverso grandi strutture di dati (classi) o database.

La ricorsione è la strada da percorrere se si desidera scorrere i file, abbastanza sicuro che sia così 'trova * | ? grep * 'funziona. Kinda doppia ricorsione, specialmente con la pipa (ma non fare un mucchio di syscalls come molti piace fare se è qualcosa che farai uscire lì per gli altri da usare).

Lingue di livello superiore e persino clang / cpp possono implementarlo allo stesso modo in background.


1
"Lo overflow dello stack si verifica solo se stai programmando in un linguaggio che non ha una gestione della memoria integrata" - non ha senso. La maggior parte delle lingue usa stack di dimensioni limitate, quindi la ricorsione porterà a un fallimento abbastanza presto.
StaceyGirl,
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.