martedì 19 dicembre 2023

Corso ALGORITMI PER LA PROGRAMMAZIONE: Lezione 5 Ricorsione


5- Ricorsione:
La ricorsione è un concetto in cui una funzione chiama se stessa. Gli algoritmi ricorsivi possono risolvere problemi suddividendoli in sotto-problemi più piccoli e risolvendo ricorsivamente questi sotto-problemi.

La ricorsione è un concetto fondamentale nella programmazione e nell'informatica in generale. Si tratta di un approccio in cui una funzione si richiama (o chiama se stessa) per risolvere un problema. Questo può avvenire quando un problema può essere suddiviso in istanze più piccole dello stesso problema.

Un algoritmo ricorsivo risolve un problema dividendo progressivamente il problema in sotto-problemi più semplici. Questi sotto-problemi vengono risolti richiamando la stessa funzione all'interno della funzione stessa, fino a quando si raggiunge un caso base che può essere risolto direttamente senza ulteriori chiamate ricorsive.

La ricorsione è un concetto fondamentale nella programmazione in cui una funzione si richiama direttamente o indirettamente se stessa per risolvere un problema più grande. Viene ampiamente utilizzata per risolvere problemi complessi suddividendoli in sotto-problemi più piccoli.


5.1-Caratteristiche della ricorsione:

La chiamata ricorsiva è un concetto interessante in programmazione in cui una funzione si richiama da sola direttamente o indirettamente all'interno del proprio corpo. Questo tipo di tecnica è comunemente usato per risolvere problemi che possono essere suddivisi in sottoproblemi simili o identici. Una delle forme più comuni di ricorsione è la ricorsione diretta, in cui una funzione chiama se stessa direttamente.


Ecco un esempio di una funzione ricorsiva in Python che calcola il fattoriale di un numero:

python

Copy code

def fattoriale(n):

    if n == 0 or n == 1:

        return 1

    else:

        return n * fattoriale(n - 1)

Questa funzione fattoriale calcola il fattoriale di un numero intero non negativo n. Se n è 0 o 1, restituisce 1, altrimenti si chiama ricorsivamente moltiplicando n per il fattoriale di n - 1. Ad esempio, fattoriale(5) restituirà 5 * 4 * 3 * 2 * 1 = 120.


La ricorsione può essere difficile da capire e gestire poiché potrebbe generare chiamate multiple alla stessa funzione fino a quando non viene soddisfatta una condizione base che interrompe le chiamate successive. In mancanza di questa condizione, si potrebbe incorrere in un caso di ricorsione infinita, consumando tutti i ricorsi disponibili e causando un errore di esaurimento della memoria o un crash del programma.

La ricorsione può anche essere indiretta, dove una funzione richiama un'altra funzione che, a sua volta, alla fine richiama la funzione originale.

Ad esempio:

python

Copy code

def funzione1(n):

    if n > 0:

        print(n)

        funzione2(n - 1)

def funzione2(n):

    if n > 1:

        print(n)

        funzione1(n // 2)

funzione1(5)

In questo esempio, funzione1 chiama funzione2 e viceversa. Quando si esegue funzione1(5), verrà stampato:

Copy code

5

4

2

Poi il programma terminerà poiché funzione2(0) non soddisfa la condizione n > 1 e non farà ulteriori chiamate.


La ricorsione può essere potente quando si tratta di problemi che possono essere risolti suddividendoli in sottoproblemi più piccoli e simili. Tuttavia, è importante comprendere e gestire accuratamente le condizioni di base per evitare il rischio di ricorsione infinita.








Il caso base è un concetto fondamentale nella progettazione di funzioni ricorsive. Ogni funzione ricorsiva deve avere almeno un caso base: una condizione che interrompe la ricorsione, in modo che la funzione non continui a chiamarsi infinitamente.

Il caso base rappresenta la condizione in cui la funzione ricorsiva restituisce un risultato senza ulteriori chiamate ricorsive. Senza un caso base, la funzione continuerebbe a chiamarsi senza mai raggiungere una soluzione, portando a un loop infinito o a un esaurimento delle risorse disponibili.


Un esempio classico è la funzione per il calcolo del fattoriale di un numero, in cui il caso base si verifica quando il numero è 0 o 1. Ecco come appare il caso base nella funzione di calcolo del fattoriale che ho mostrato precedentemente:

python

Copy code

def fattoriale(n):

    if n == 0 or n == 1:  # Caso base

        return 1

    else:

        return n * fattoriale(n - 1)

Nel codice sopra, il caso base è definito dalla condizione n == 0 or n == 1. Quando n è uguale a 0 o 1, la funzione restituirà immediatamente il valore 1 senza ulteriori chiamate ricorsive. Questo è essenziale per evitare che la funzione continui a chiamarsi all'infinito.


In generale, quando si progetta una funzione ricorsiva, è necessario identificare chiaramente il caso base in modo da garantire che la ricorsione termini in modo corretto. Senza un caso base, la funzione continuerà a richiamarsi indefinitamente, causando errori o risultati imprevisti.

L'assenza o la gestione errata del caso base è una delle principali fonti di errori nelle funzioni ricorsive. Quindi, la corretta identificazione e implementazione del caso base sono cruciali per garantire che la ricorsione si interrompa correttamente, fornendo il risultato desiderato.


La divisione in sotto-problemi è un concetto fondamentale nella ricorsione, in cui un problema complesso viene suddiviso in problemi più piccoli e simili fino a quando questi ultimi diventano sufficientemente semplici da risolvere. La risoluzione dei sotto-problemi consente quindi di combinare le loro soluzioni per ottenere la soluzione del problema complessivo.

La ricorsione è particolarmente efficace quando un problema può essere affrontato suddividendolo in sotto-problemi dello stesso tipo del problema originale, ma più piccoli. In altre parole, la soluzione di un problema più grande è basata sulla soluzione di problemi più piccoli dello stesso tipo.


Un esempio classico di divisione in sotto-problemi è l'algoritmo "Divide et Impera". Un esempio comune è l'algoritmo di ordinamento Merge Sort, che suddivide ricorsivamente un elenco in sotto-liste più piccole, le ordina e poi le combina per ottenere l'elenco ordinato.


Ecco un esempio di come funziona l'algoritmo Merge Sort:

python

Copy code

def merge_sort(arr):

    if len(arr) <= 1:  # Caso base: quando l'array è di lunghezza 0 o 1

        return arr

    # Divide l'array a metà

    middle = len(arr) // 2

    left_half = arr[:middle]

    right_half = arr[middle:]

    # Richiama ricorsivamente merge_sort per le due metà

    left_half = merge_sort(left_half)

    right_half = merge_sort(right_half)

    # Combina le due metà ordinate

    return merge(left_half, right_half)

def merge(left, right):

    result = []

    left_index, right_index = 0, 0

    # Combina le due liste ordinate

    while left_index < len(left) and right_index < len(right):

        if left[left_index] < right[right_index]:

            result.append(left[left_index])

            left_index += 1

        else:

            result.append(right[right_index])

            right_index += 1

    # Aggiungi gli elementi rimanenti

    result.extend(left[left_index:])

    result.extend(right[right_index:])

    return result

# Esempio di utilizzo di merge_sort

array = [12, 11, 13, 5, 6, 7]

sorted_array = merge_sort(array)

print("Array ordinato:", sorted_array)

In questo esempio, l'algoritmo Merge Sort suddivide ricorsivamente l'array in sotto-array fino a quando non raggiunge il caso base (array di lunghezza 0 o 1). Poi, ricombina i sotto-array ordinati usando la funzione merge per ottenere l'array completo ordinato.


Questo esempio dimostra come la divisione in sotto-problemi permetta di affrontare problemi complessi, come l'ordinamento di un array, suddividendoli in problemi più gestibili, risolvendo ciascun sotto-problema e combinando infine le soluzioni per ottenere la soluzione complessiva.



5.2-Approfondimenti sull'utilizzo della ricorsione:

Vantaggi:

La ricorsione offre diversi vantaggi nell'affrontare problemi complessi:

Espressione chiara e concisa: La ricorsione consente spesso di scrivere codice più leggibile e comprensibile. Questo perché segue un approccio basato su casi base e passaggi ricorsivi, riflettendo spesso la struttura logica del problema stesso. Ciò può rendere il codice più intuitivo, adatto per interpretare e risolvere problemi in modo chiaro e diretto.

Adattabilità ai problemi suddivisibili: La ricorsione è particolarmente adatta per risolvere problemi che possono essere scomposti in sotto-problemi simili o identici. Questo approccio si presta bene a situazioni in cui il problema complesso può essere affrontato più facilmente risolvendo iterativamente problemi più piccoli dello stesso tipo. Ad esempio, algoritmi di ordinamento, alberi di ricerca, percorso più breve, e molti altri possono beneficiare dell'approccio ricorsivo.


Ecco un esempio di come la ricorsione può rendere più chiara la soluzione di un problema:

python

Copy code

def stampa_numeri(n):

    if n > 0:

        print(n)

        stampa_numeri(n - 1)

stampa_numeri(5)

Questa funzione ricorsiva stampa i numeri da n a 1. La struttura del codice riflette direttamente l'idea logica di stampare numeri in ordine decrescente, rendendo il codice più chiaro e conciso.


Tuttavia, è importante notare che la ricorsione non è sempre la scelta migliore. In alcuni casi, può essere meno efficiente in termini di memoria e prestazioni rispetto alle soluzioni iterative. Inoltre, una ricorsione troppo profonda potrebbe portare a un superamento del limite di ricorsione del linguaggio o consumare troppa memoria.

Pertanto, mentre la ricorsione offre vantaggi in termini di chiarezza e concisione nella risoluzione di problemi, è importante valutare attentamente se è la scelta migliore in termini di efficienza e gestione delle risorse per il problema specifico che si sta affrontando.



Svantaggi:

uno dei principali svantaggi della ricorsione è il suo potenziale consumo di memoria rispetto all'iterazione. Questo consumo di memoria è principalmente dovuto alla creazione di frame di attivazione per ogni chiamata ricorsiva.

Ogni volta che una funzione viene chiamata, il programma alloca uno spazio di memoria chiamato "frame di attivazione" per mantenere informazioni come variabili locali, parametri della funzione e il punto di ripresa dopo la terminazione della chiamata. Quando si usano chiamate ricorsive, una nuova istanza del frame di attivazione viene creata ogni volta che la funzione viene chiamata, accumulando memoria.

Questo consumo di memoria può diventare problematico in presenza di ricorsione profonda o quando il numero di chiamate ricorsive è elevato, poiché ogni frame di attivazione deve essere memorizzato nella pila di esecuzione fino alla risoluzione dell'ultimo caso base.


Ad esempio, considera una funzione ricorsiva per calcolare il fattoriale di un numero:

python

Copy code

def fattoriale(n):

    if n == 0 or n == 1:

        return 1

    else:

        return n * fattoriale(n - 1)

Quando questa funzione viene chiamata con un grande numero, come fattoriale(1000), vengono create 1000 chiamate ricorsive, ciascuna con il proprio frame di attivazione, occupando un'ampia quantità di memoria nella pila di esecuzione. Questo potrebbe portare a un'eccezione di "OverflowError" o "RecursionError" a causa del superamento del limite massimo di ricorsione consentito dal linguaggio o dall'esaurimento della memoria disponibile.


Per mitigare il problema del consumo eccessivo di memoria, è possibile convertire l'implementazione ricorsiva in una soluzione iterativa, quando possibile, eliminando la necessità di mantenere frame di attivazione multipli. Questo approccio può ridurre significativamente il consumo di memoria senza compromettere la chiarezza o la comprensibilità del codice.

In generale, la ricorsione può essere potente e intuitiva per risolvere alcuni problemi, ma è importante essere consapevoli dei suoi limiti in termini di consumo di memoria, specialmente in situazioni in cui la profondità della ricorsione può diventare elevata.


Costi di prestazione

Le chiamate ricorsive possono comportare costi di prestazione maggiori rispetto alle iterazioni in alcuni contesti. Ciò è dovuto principalmente a diverse ragioni:

Overhead della gestione delle chiamate ricorsive: Ogni chiamata ricorsiva comporta un certo overhead aggiuntivo dovuto alla creazione dei frame di attivazione e alla gestione della pila delle chiamate. Questo può portare a un aumento dei tempi di esecuzione, specialmente se il numero di chiamate ricorsive è elevato.

Costi aggiuntivi legati alla gestione della pila: Quando si utilizza la ricorsione, le chiamate successive vengono impilate nella pila delle chiamate. Il sistema deve costantemente gestire l'aggiunta e la rimozione di elementi dalla pila, comportando costi di gestione aggiuntivi.

Limiti delle ottimizzazioni del compilatore: Alcuni compilatori potrebbero non ottimizzare efficacemente le chiamate ricorsive, rendendo più lenta l'esecuzione del codice ricorsivo rispetto a una soluzione iterativa equivalente.


Per illustrare questo concetto, consideriamo un esempio di una funzione ricorsiva e la sua controparte iterativa per calcolare la somma dei primi N numeri naturali:

Versione ricorsiva:

python

Copy code

def somma_ricorsiva(n):

    if n <= 1:

        return n

    else:

        return n + somma_ricorsiva(n - 1)

Versione iterativa equivalente:

python

Copy code

def somma_iterativa(n):

    result = 0

    for i in range(1, n + 1):

        result += i

    return result

In questo esempio, la versione ricorsiva della funzione calcola la somma dei primi N numeri naturali chiamando se stessa in modo ricorsivo. Tuttavia, questa implementazione potrebbe richiedere più tempo di esecuzione e consumare più risorse rispetto alla versione iterativa. La gestione delle chiamate ricorsive e la creazione di frame di attivazione potrebbero influenzare le prestazioni, specialmente per valori elevati di N.


In generale, quando si tratta di prestazioni, è importante considerare sia la chiarezza del codice sia la sua efficienza. Mentre la ricorsione può rendere il codice più chiaro in alcuni casi, potrebbe non essere la scelta migliore in termini di prestazioni per problemi in cui la ricorsione profonda o il consumo eccessivo di memoria potrebbero compromettere le prestazioni globali dell'applicazione. In queste situazioni, l'utilizzo di soluzioni iterative potrebbe essere preferibile per ottimizzare le prestazioni del programma.


5.3-Casi di utilizzo comuni della ricorsione:

Alberi e grafi: Percorsi in alberi o grafi spesso utilizzano l'approccio ricorsivo (ad es. attraversare un albero binario).

L'utilizzo dell'approccio ricorsivo è comune quando si lavora con strutture dati come alberi e grafi. Queste strutture dati sono intrinsecamente ricorsive nella loro natura e l'uso della ricorsione può semplificare notevolmente l'attraversamento o l'analisi di tali strutture.


Alberi Binari

Ad esempio, nell'attraversamento di un albero binario (inorder, preorder, postorder), è comune utilizzare la ricorsione per visitare i nodi:

python

Copy code

class TreeNode:

    def __init__(self, val=0, left=None, right=None):

        self.val = val

        self.left = left

        self.right = right

def inorder_traversal(node):

    if node:

        inorder_traversal(node.left)

        print(node.val)

        inorder_traversal(node.right)

# Esempio di utilizzo

root = TreeNode(1)

root.left = TreeNode(2)

root.right = TreeNode(3)

root.left.left = TreeNode(4)

root.left.right = TreeNode(5)

print("Inorder traversal:")

inorder_traversal(root)

Nell'esempio sopra, inorder_traversal è una funzione ricorsiva che visita un albero binario seguendo l'ordine: sinistra - radice - destra. La funzione si chiama ricorsivamente prima per il sottoalbero sinistro, poi stampa il valore del nodo corrente e infine si chiama ricorsivamente per il sottoalbero destro.


Grafi

Per quanto riguarda i grafi, la ricorsione può essere utilizzata per esplorare o per trovare percorsi tra i nodi.
Ad esempio, nell'attraversamento di un grafo usando la ricerca in profondità (DFS - Depth-First Search), si può utilizzare la ricorsione per esplorare i nodi e i loro vicini:

python

Copy code

from collections import defaultdict

class Graph:

    def __init__(self):

        self.graph = defaultdict(list)

    def add_edge(self, u, v):

        self.graph[u].append(v)

    def dfs_recursive(self, node, visited):

        visited.add(node)

        print(node)

        for neighbor in self.graph[node]:

            if neighbor not in visited:

                self.dfs_recursive(neighbor, visited)

# Esempio di utilizzo

g = Graph()

g.add_edge(0, 1)

g.add_edge(0, 2)

g.add_edge(1, 2)

g.add_edge(2, 0)

g.add_edge(2, 3)

g.add_edge(3, 3)

print("DFS traversal:")

g.dfs_recursive(2, set())

Nell'esempio sopra, dfs_recursive è una funzione che esegue la ricerca in profondità ricorsivamente. Visita un nodo, lo segna come visitato, stampa il suo valore e quindi si chiama ricorsivamente per esplorare i vicini non visitati.


La ricorsione rende l'implementazione delle procedure di attraversamento o di ricerca dei percorsi in queste strutture dati più concisa e intuitiva, riflettendo la natura ricorsiva intrinseca degli alberi e dei grafi stessi. Tuttavia, è importante considerare l'eventualità di una ricorsione troppo profonda in grafi o alberi molto grandi, che potrebbe portare a un superamento del limite di ricorsione o a un consumo eccessivo di memoria.


Strutture dati ricorsive: Come le liste concatenate, le pile o le code possono essere implementate utilizzando la ricorsione.

Le strutture dati come liste concatenate, pile e code possono essere implementate utilizzando la ricorsione per alcune operazioni, sebbene siano solitamente realizzate in modo iterativo. Tuttavia, l'approccio ricorsivo può essere usato per dimostrare concetti o operazioni specifiche in queste strutture dati.


Liste concatenate

Una lista concatenata può essere rappresentata utilizzando la ricorsione per operazioni come l'inserimento, l'eliminazione o la ricerca di elementi.

Ad esempio, consideriamo la rappresentazione ricorsiva di una lista concatenata:

python

Copy code

class Nodo:

    def __init__(self, valore):

        self.valore = valore

        self.successivo = None

def inserisci_ricorsivo(testa, valore):

    if not testa:

        return Nodo(valore)

    else:

        testa.successivo = inserisci_ricorsivo(testa.successivo, valore)

        return testa

def stampa_lista(testa):

    if not testa:

        return

    print(testa.valore, end=" -> ")

    stampa_lista(testa.successivo)

# Esempio di utilizzo

testa = None

testa = inserisci_ricorsivo(testa, 1)

testa = inserisci_ricorsivo(testa, 2)

testa = inserisci_ricorsivo(testa, 3)

print("Lista concatenata:")

stampa_lista(testa)

In questo esempio, la funzione inserisci_ricorsivo inserisce un nuovo nodo in modo ricorsivo all'interno della lista concatenata. La funzione stampa_lista stampa la lista ricorsivamente.


Pile (Stack) e Code (Queue)

Anche le pile e le code possono essere implementate utilizzando la ricorsione per alcune operazioni, come l'implementazione di un algoritmo ricorsivo per invertire una pila o una coda:


Esempio di inversione di una Pila in modo ricorsivo:

python

Copy code

class Pila:

    def __init__(self):

        self.items = []

    def push(self, elemento):

        self.items.append(elemento)

    def pop(self):

        return self.items.pop()

    def is_empty(self):

        return len(self.items) == 0

def inverti_pila(p):

    if not p.is_empty():

        elemento = p.pop()

        inverti_pila(p)

        inserisci_in_fondo(p, elemento)

def inserisci_in_fondo(p, elemento):

    if p.is_empty():

        p.push(elemento)

    else:

        temp = p.pop()

        inserisci_in_fondo(p, elemento)

        p.push(temp)

# Esempio di utilizzo

pila = Pila()

pila.push(1)

pila.push(2)

pila.push(3)

print("Pila originale:", pila.items)

inverti_pila(pila)

print("Pila invertita:", pila.items)

In questo esempio, la funzione inverti_pila utilizza la ricorsione per invertire una pila. Si basa sull'uso di un altro metodo ricorsivo inserisci_in_fondo, che inserisce un elemento alla base della pila.


Mentre queste implementazioni sono dimostrative, è importante notare che in pratica le operazioni su liste concatenate, pile o code vengono generalmente realizzate in modo iterativo poiché la ricorsione potrebbe comportare un utilizzo inefficiente delle risorse o problemi di gestione della memoria per strutture dati di grandi dimensioni.


Algoritmi di divisione e conquista: Algoritmi come il Merge Sort o il Quick Sort utilizzano la ricorsione per suddividere e ordinare una sequenza.

Gli algoritmi di divisione e conquista sfruttano la ricorsione per risolvere problemi suddividendoli in sottoproblemi più piccoli, risolvendo poi tali sottoproblemi e combinando le loro soluzioni per ottenere il risultato finale. Due degli esempi più noti di algoritmi di divisione e conquista sono il Merge Sort e il Quick Sort.


Merge Sort

Il Merge Sort è un algoritmo di ordinamento che utilizza l'approccio di divisione e conquista. Questo algoritmo suddivide ricorsivamente l'array da ordinare in due metà, ordina le due metà separatamente e infine unisce le metà ordinate per ottenere l'array ordinato.


python

Copy code

def merge_sort(arr):

    if len(arr) > 1:

        middle = len(arr) // 2

        left_half = arr[:middle]

        right_half = arr[middle:]

        merge_sort(left_half)

        merge_sort(right_half)

        i = j = k = 0

        while i < len(left_half) and j < len(right_half):

            if left_half[i] < right_half[j]:

                arr[k] = left_half[i]

                i += 1

            else:

                arr[k] = right_half[j]

                j += 1

            k += 1

        while i < len(left_half):

            arr[k] = left_half[i]

            i += 1

            k += 1

        while j < len(right_half):

            arr[k] = right_half[j]

            j += 1

            k += 1

# Esempio di utilizzo

arr = [12, 11, 13, 5, 6, 7]

print("Array non ordinato:", arr)

merge_sort(arr)

print("Array ordinato:", arr)

In questo esempio, la funzione merge_sort suddivide l'array ricorsivamente fino a quando le sotto-parti non sono più divisibili. Poi, combina e ordina le parti usando la funzione merge in un processo di "conquista".


Quick Sort

Il Quick Sort è un altro algoritmo di ordinamento che utilizza l'approccio di divisione e conquista. Suddivide l'array in base a un "pivot", spostando gli elementi più piccoli a sinistra del pivot e gli elementi più grandi a destra. Ricorsivamente ordina le due partizioni risultanti.


python

Copy code

def quick_sort(arr, low, high):

    if low < high:

        pi = partition(arr, low, high)

        quick_sort(arr, low, pi - 1)

        quick_sort(arr, pi + 1, high)

def partition(arr, low, high):

    pivot = arr[high]

    i = low - 1

    for j in range(low, high):

        if arr[j] < pivot:

            i += 1

            arr[i], arr[j] = arr[j], arr[i]

    arr[i + 1], arr[high] = arr[high], arr[i + 1]

    return i + 1

# Esempio di utilizzo

arr = [10, 7, 8, 9, 1, 5]

print("Array non ordinato:", arr)

quick_sort(arr, 0, len(arr) - 1)

print("Array ordinato:", arr)

In questo esempio, la funzione quick_sort suddivide l'array basandosi su un pivot e ricorsivamente ordina le parti destre e sinistre del pivot, seguendo l'approccio di divisione e conquista.


Entrambi questi algoritmi dimostrano come la ricorsione possa essere utilizzata in modo efficiente per suddividere e risolvere problemi, come l'ordinamento di un array, seguendo un approccio di "dividi e conquista".

La comprensione della ricorsione è fondamentale per i programmatori poiché consente di risolvere una vasta gamma di problemi in modo elegante ed efficiente, se utilizzata correttamente. Tuttavia, è essenziale prestare attenzione ai casi base e ai rischi di stack overflow (saturazione dello stack di chiamate) per evitare errori e problemi di prestazioni.


Nessun commento:

Posta un commento