Le funzioni Ricorsive


Home Page | Comments | Articles | Faq | Documents | Search | Archive | Tales from the Machine Room | Contribute | Login/Register
A cura di Davide Bianchi In molti casi una funzione ricorsive (che richiama se' stessa), e' la soluzione piu' rapida ad un problema. Ma usare una funzione non ricorsiva rende molto piu' efficiente lo stesso procedimento.
Funzioni Ricorsive Le funzioni ricorsive hanno fatto la loro comparsa sulla scena molto tempo fa'. Praticamente con il primo linguaggio che era dotato di funzioni.

Cosa e' una funzione ricorsiva? In sostanza si tratta di una funzione che, per svolgere il proprio lavoro, richiama se' stessa. Ad ogni richiamo la "profondita'" dell'elaborazione aumenta, finche' ad un certo punto, lo scopo viene raggiunto e la funzione ritorna.

Il tipico esempio di una funzione Ricorsiva e' la visualizzazione di dati in strutture gerarchiche, per esempio la visualizzazione dei file contenuti in una directory.

Invece di scervellarsi per trovare un sistema che 'visiti' ogni sotto-directory presente, si crea una funzione 'generica' che legge una directory e visualizza ogni file. Se il file e' una directory, la funzione richiama se' stessa usando la directory trovata come parametro.

Lo pseudo codice qui' sotto esemplifica questo funzionamento:


Function leggi_directory( directory_iniziale )
{
  for each File in [directory_iniziale] {
    if (file_normale(File)) {
      stampa_nome_file(File);
    } else {
      leggi_directory( File ); // ricorsione
    }
  Next;
}
Se File e' riconosciuto come una directory, la funzione richiama se' stessa per analizzare il contenuto della directory stessa.

Questa logica ovviamente non tiene conto di possibili problemi (la presenza di link per esempio).

Sicuramente il codice in questo caso e' molto semplice, ma la domanda e': questo codice e' efficiente?

Progettare funzioni ricorsive Per cominciare a progettare una funzione ricorsiva, occorre pensare se il "lavoro" da svolgere puo' essere spezzato in una sequenza di mini-operazioni identiche. Inoltre occorre pensare a quando la singola operazione dovra' concludersi e ritornare.

La regola base di una funzione ricorsiva e' che nessuno stato, valore o condizione che non sia controllato dalla funzione stessa deve essere modificato. In caso contrario la ricorsione non funzionera' come ci si aspetta.

Che significa questo? Riprendiamo l'esempio della lettura directory di poco fa'. Nel nostro caso abbiamo un'elenco di file/directory e leggiamo un elemento alla volta. Se troviamo una directory, richiamiamo la funzione che riceve un nuovo elenco di file/directory da leggere.

Tutto questo e' OK perche' l'elenco precedente non viene toccato, ma se (per struttura interna del sistema), la lettura del nuovo elenco distrugge (o modifica) l'elenco precedente, la ricorsione non funziona piu'. Dopo il primo richiamo la funzione che ritorna si ritrova con l'elenco che stava leggendo modificato o (peggio) distruttor. Probabilmente riceveremo un errore.

Perche' il nostro algoritmo possa funzionare in queste condizioni e' necessario salvare lo stato della funzione prima del richiamo e ripristinarlo dopo. Questo complica il funzionamento della nostra routine e ci obbliga a scrivere del codice aggiuntivo.

Ricorsione contro non-ricorsione Una funzione ricorsiva "salva" il suo stato nel momento in cui richiama se' stessa, solitamente nello stack. Ogni volta che la ricorsione viene invocata, tutte le variabili presenti vengono inserite nello stack ed una nuova serie di variabili viene creata (sempre dallo stack).

Questo significa un elevato consumo dello stack del sistema, se stiamo lavorando con uno stack limitato (come e' il caso con certi sistemi o con certe architetture di programma), rischiamo di superare i limiti dello stack e di avere un bel crash del programma.

Aggiungiamo poi che, solitamente, il numero di chiamate ricorsive della funzione non e' ipotizzabile a priori ed abbiamo un possibile problema.

Un altro possibile problema e' se la nostra funzione utilizza altre risorse oltre alla memoria del sistema, e tali risorse sono in quantita' limitata (connessioni a database per esempio). In questo caso, se il numero di richiami e' superiore ad un certo livello, possiamo avere un fallimento di chiamata.

Trasformare un algoritmo ricorsivo in uno non-ricorsivo Non sempre le funzioni ricorsive sono una risposta efficiente. Consideriamo che ogni chiamata ricorsiva consuma memoria ed utilizza un salto ad una funzione. Ridisegnando la funzione come non-ricorsiva, si risparmia un salto e (forse) un po' di quella preziosa memoria.

Sfortunatamente, non tutte le funzioni sono facilmente 'trasformabili' da ricorsive a non-ricorsive, in alcuni casi la funzione non puo' essere trasformata senza una completa riscrittura, e non sempre ne vale la pena.

Ricerca binaria L'algoritmo di ricerca in un albero binario e' il primo tipo di funzione ricorsiva che solitamente si analizza. Un albero binario e' una struttura logica in cui ogni singolo elemento (nodo) ha due collegamenti ad altrettanti nodi. Un albero e' detto "bilanciato", quando tutti gli elementi dell'ultima riga non hanno nessun collegamento.

Gli alberi binari sono utilizzati per implementare indici e funzioni di ricerca/ordinamento.

Nel nostro caso si assume che l'albero e' costruito in modo tale che il nodo a sinistra e' minore della 'radice', mentre il nodo a destra e' maggiore.

La funzione che analizza l'albero in cerca di un valore puo' essere scritta con la ricorsione in modo molto semplice. Ci sono due casi in cui la funzione deve ritornare: il primo caso e' quando il nodo e' identico a quello cercato, il secondo caso e' quando il nodo non ha nessun 'figlio', cioe' quello che cerchiamo non e' qui'.

La funzione ricorsiva puo' essere scritta come segue:


cerca_nodo( nodo, valore )
{
  if( nodo = -1 || valore == albero[nodo] )
    return nodo;
  if( valore < albero[nodo] )
    return cerca_nodo( albero[nodo].sinistro, valore );
  else
    return cerca_nodo( albero[nodo].destro, valore );
}
Se il nodo-figlio (destro o sinistro) non ha valore (il nodo-radice non ha figli), il valore ritornato e' -1.

Questo tipo di codifica gestisce efficientemente la ricerca in alberi binari anche complessi, il problema e' appunto l'utilizzo di risorse per gestire la ricorsione.

Il consumo di stack dell'algoritmo dipende pesantemente da quanto bilanciato e' l'albero. Se l'abero e' perfettamente bilanciato, per localizzare un qualunque nodo sono richiesti meno di log2(n) ricerche, dove n e' il numero di elementi dell'albero. Per un albero di 65.000 elementi sono necessarie solo 16 iterazioni. L'impatto sullo stack in questo caso e' minimo.

Nel caso di un albero molto sbilanciato, il nostro algoritmo non si comporta altrettanto bene, potrebbe essere necessario in questo caso il controllo di ogni singolo nodo dell'albero.

Trasformare l'algoritmo Trasformare la ricerca binaria in un algoritmo non-ricorsivo e' un lavoro abbastanza semplice. Il nostro algoritmo infatti non richiede espressamente la ricorsione. La funzione ritorna in due soli casi: ho ha trovato il nodo o non lo ha trovato. Non c'e' motivo di "ritornare all'inizio".

L'uso di una funzione non-ricorsiva in questo caso rimuove il consumo extra di memoria e rende la funziona anche piu' veloce.

Una chiamata ricorsiva effettua due operazioni: (1) prepara le variabili per la chiamata successiva e (2) ritorna all'inizio.

Nel nostro caso (1) puo' essere facilmente fatto modificando il valori di node con il nuovo nodo da esaminare, mentre (2) puo' essere effettuato usando un salto incondizionato (goto) o un qualche tipo di ciclo.

Il codice dell'algoritmo non-ricorsivo e' il seguente:


cerca_nodo( nodo, valore )
{
  /* ciclo infinito */
  while(1) {
    if( nodo = -1 || valore == albero[nodo] )
      break;
    if( valore < albero[nodo] )
      nodo = albero[nodo].sinistro;
    else
      nodo = albero[nodo].destro;
  }
  return nodo;
}

Algoritmi piu' complessi Algoritmi piu' complessi possono essere un problema da convertire. Prendiamo ad esempio la nostra funzione di 'scorrimento' delle directory. Il problema e' che dobbiamo salvare da qualche parte l'elenco dei file che stiamo gestendo e la posizione all'interno prima di ritornare all'inizio e processare il nuovo elenco, perche' al termine di questo dovremo ritornare indietro e procedere come se niente fosse successo.

La domanda e' dove accidenti salvo questa roba?. La risposta e' bisogna implementare un sistema di stack.

Ma se usiamo un altro stack, dove sta' il vantaggio di eliminare la ricorsione?

Un primo vantaggio e' che lo stack puo' essere molto piu' grande di quello di sistema, un secondo vantaggio e' che siamo in grado di controllarne la dimensione e la crescita, ed eventualmente di bloccare il processo se questo cresce eccessivamente.

Il nostro algoritmo di scorrimento directory non-ricorsivo, potrebbe essere qualche cosa come il seguente:


Function leggi_directory( directory_iniziale )
{

:ciclo

  for each File in [directory_iniziale] {

:riprendi
  
    if (file_normale(File)) {
      stampa_nome_file(File);
    } else {
      /* inserisco gli elementi nello stack */
      inserisci_stack( directory_iniziale );
      inserisci_stack( File );
      directory_iniziale = File;
      goto ciclo;
    }
  }

  /* verifico di non aver esaurito lo stack */
  if( stack_esaurito() ) {
    return;
  } else {
    /* prelevo gli elementi precedenti dallo stack */
    File = estrai_stack();
    directory_iniziale = estrai_stack();
    goto riprendi;
  }

}
N.B. L'uso dei goto in questo codice, benche' eliminabile, consente di avere un codice piu' comprensibile. Una stesura del codice senza i goto richiede la scrittura di piu' subroutine o l'uso di cicli infiniti piuttosto intricati.

Conclusioni Gli algoritmi ricorsivi sono veloci da scrivere, ma non sempre cosi' efficienti, in moltissimi casi l'uso di un algoritmo equivalente ma non-ricorsivo permette una maggiore efficienza al prezzo di un po' di codice in piu' da scrivere. Soprattutto se il codice stesso richiede l'uso di uno Stack per tenere traccia dello stato precedente dell'iterazione.


Commenti

I commenti sono aggiunti quando e soprattutto se ho il tempo di guardarli e dopo aver eliminato le cagate, spam, tentativi di phishing et similia. Quindi non trattenete il respiro.

No messages post new

Previous Next


The Author Davide Bianchi, works as Unix/Linux administrator for a "network security" company of Haarlem.
Contacts: mail: davide AT onlyforfun.net , ICQ: 268751033, Jabber: davideyeahsure AT gmail.com Skype: davideyahsure

Contribute Do you want to contribute? read how.

Copyright This site is made by me with blood, sweat and gunpowder, if you want to republish or redistribute any part of it, please drop me (or the author of the article if is not me) a mail.

This site was composed with VIM, now is composed with VIM and the (in)famous CMS FdT.

This site isn't optimized for vision with any specific browser, nor it requires special fonts or resolution.
You're free to see it as you wish.

This e-mail is here in order to send spam to an honeypot: davide@walterisookeensufferukker.nl

Web Interoperability Pleadge is this a valid html document? Support This Project

Updated : 21/12/2000 00:00