Strutture dati e algoritmi spiegati da zero: notazione Big O, algoritmi di ordinamento e ricerca, liste, stack, code, hash table, alberi, grafi, cammino minimo e programmazione dinamica. Con esempi in Python ed esercizi.
Introduzione a Strutture Dati e Algoritmi
DSA sta per Data Structures and Algorithms (Strutture Dati e Algoritmi). È uno dei pilastri dell'informatica: insegna come organizzare i dati (strutture dati) e come risolvere problemi in modo efficiente (algoritmi).
Concetto
Definizione
Struttura dati
Un modo di organizzare e memorizzare i dati per usarli efficientemente (array, liste, alberi, grafi...)
Algoritmo
Una sequenza precisa di passi per risolvere un problema (ordinare, cercare, calcolare...)
Perché studiare DSA? Perché lo stesso problema può essere risolto in modi molto diversi: alcuni impiegano un secondo, altri ore. Conoscere le strutture dati e gli algoritmi giusti è ciò che distingue un programmatore esperto.
💡 DSA è anche l'argomento più richiesto nei colloqui tecnici delle grandi aziende tech (Google, Amazon, Meta...). Gli esempi di questo corso sono in Python, ma i concetti valgono per qualsiasi linguaggio.
Cos'è un algoritmo
Un algoritmo è un insieme finito di istruzioni ben definite per risolvere un problema. Deve essere: corretto (produce il risultato giusto), finito (termina) e idealmente efficiente.
Un algoritmo semplice: trovare il massimo
def trova_massimo(numeri):
massimo = numeri[0] # ipotesi: il primo è il max
for n in numeri[1:]: # scorri gli altri
if n > massimo: # se ne trovi uno maggiore
massimo = n # aggiornalo
return massimo
print(trova_massimo([3, 7, 2, 9, 4])) # 9
Questo algoritmo segue uno schema chiaro: inizializza, itera, confronta, aggiorna, restituisci. Analizzarne il costo (quante operazioni esegue al crescere dei dati) è il prossimo passo fondamentale.
Complessità: la notazione Big O
La complessità temporale misura come cresce il numero di operazioni di un algoritmo al crescere della dimensione dell'input n. Si esprime con la notazione Big O, che descrive il caso peggiore.
Big O
Nome
Esempio
O(1)
Costante
Accesso a un elemento di un array
O(log n)
Logaritmica
Ricerca binaria
O(n)
Lineare
Ricerca lineare
O(n log n)
Linearitmica
Merge Sort, Quick Sort
O(n²)
Quadratica
Bubble Sort, Selection Sort
O(2ⁿ)
Esponenziale
Fibonacci ricorsivo ingenuo
Confronto pratico di complessità
# O(1) — costante: una sola operazione
def primo(arr):
return arr[0]
# O(n) — lineare: un ciclo
def somma(arr):
tot = 0
for x in arr: # n iterazioni
tot += x
return tot
# O(n^2) — quadratica: ciclo dentro ciclo
def coppie(arr):
for i in arr: # n
for j in arr: # n → n*n
print(i, j)
🎯 Regola pratica: per input grandi, O(n log n) è enormemente più veloce di O(n²). Con n = 1.000.000, il primo fa ~20 milioni di operazioni, il secondo mille miliardi.
Array
L'array è la struttura dati più fondamentale: una collezione di elementi memorizzati in posizioni di memoria contigue, accessibili tramite un indice numerico.
Operazioni di base sugli array
# In Python gli array sono le liste
arr = [10, 20, 30, 40, 50]
# Accesso per indice → O(1)
print(arr[2]) # 30
# Aggiornamento → O(1)
arr[2] = 99
# Inserimento in fondo → O(1) ammortizzato
arr.append(60)
# Inserimento all'inizio → O(n) (sposta tutti)
arr.insert(0, 5)
# Ricerca di un valore → O(n)
print(30 in arr) # True
# Lunghezza → O(1)
print(len(arr))
Operazione
Complessità
Accesso per indice
O(1)
Aggiornamento
O(1)
Inserimento/rimozione in fondo
O(1)
Inserimento/rimozione all'inizio
O(n)
Ricerca
O(n)
Bubble Sort
Il Bubble Sort confronta coppie di elementi adiacenti e li scambia se sono nell'ordine sbagliato. Ad ogni passata, l'elemento più grande "risale" come una bolla fino alla sua posizione finale.
Bubble Sort — O(n²)
def bubble_sort(arr):
n = len(arr)
for i in range(n - 1):
scambiato = False
for j in range(n - 1 - i):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j]
scambiato = True
if not scambiato: # già ordinato → esci
break
return arr
print(bubble_sort([5, 2, 8, 1, 9, 3]))
# [1, 2, 3, 5, 8, 9]
💡 Il Bubble Sort è semplice da capire ma lento: O(n²). Si usa solo a scopo didattico o su liste piccolissime. L'ottimizzazione con scambiato lo rende O(n) se l'array è già ordinato.
Selection Sort
Il Selection Sort trova ripetutamente l'elemento minimo della parte non ordinata e lo sposta all'inizio.
Selection Sort — O(n²)
def selection_sort(arr):
n = len(arr)
for i in range(n):
# trova l'indice del minimo nella parte non ordinata
min_idx = i
for j in range(i + 1, n):
if arr[j] < arr[min_idx]:
min_idx = j
# scambia il minimo con l'elemento in posizione i
arr[i], arr[min_idx] = arr[min_idx], arr[i]
return arr
print(selection_sort([64, 25, 12, 22, 11]))
# [11, 12, 22, 25, 64]
A differenza del Bubble Sort, esegue al massimo n scambi, ma il numero di confronti resta O(n²).
Insertion Sort
L'Insertion Sort costruisce l'array ordinato un elemento alla volta, inserendo ogni nuovo elemento nella posizione corretta tra quelli già ordinati — come quando si ordinano le carte in mano.
Insertion Sort — O(n²)
def insertion_sort(arr):
for i in range(1, len(arr)):
chiave = arr[i]
j = i - 1
# sposta a destra gli elementi maggiori della chiave
while j >= 0 and arr[j] > chiave:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = chiave # inserisci la chiave
return arr
print(insertion_sort([12, 11, 13, 5, 6]))
# [5, 6, 11, 12, 13]
🎯 L'Insertion Sort è molto efficiente su array quasi ordinati (vicino a O(n)) ed è usato internamente dagli algoritmi ibridi su piccoli sotto-array.
Quick Sort
Il Quick Sort usa la strategia divide et impera: sceglie un elemento pivot, partiziona l'array in elementi minori e maggiori del pivot, poi ordina ricorsivamente le due parti.
Quick Sort — O(n log n) medio
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2]
minori = [x for x in arr if x < pivot]
uguali = [x for x in arr if x == pivot]
maggiori = [x for x in arr if x > pivot]
return quick_sort(minori) + uguali + quick_sort(maggiori)
print(quick_sort([3, 6, 8, 10, 1, 2, 1]))
# [1, 1, 2, 3, 6, 8, 10]
Caso
Complessità
Migliore / medio
O(n log n)
Peggiore (pivot pessimo)
O(n²)
💡 Il Quick Sort è uno degli algoritmi di ordinamento più usati nella pratica grazie alla sua velocità media e all'ordinamento "in-place" (poca memoria extra).
Merge Sort
Il Merge Sort divide ricorsivamente l'array a metà finché ogni parte ha un solo elemento, poi fonde (merge) le parti in ordine. Garantisce sempre O(n log n).
Merge Sort — O(n log n) garantito
def merge_sort(arr):
if len(arr) <= 1:
return arr
meta = len(arr) // 2
sinistra = merge_sort(arr[:meta])
destra = merge_sort(arr[meta:])
return merge(sinistra, destra)
def merge(a, b):
risultato = []
i = j = 0
while i < len(a) and j < len(b):
if a[i] <= b[j]:
risultato.append(a[i]); i += 1
else:
risultato.append(b[j]); j += 1
risultato.extend(a[i:])
risultato.extend(b[j:])
return risultato
print(merge_sort([38, 27, 43, 3, 9, 82, 10]))
# [3, 9, 10, 27, 38, 43, 82]
🎯 A differenza del Quick Sort, il Merge Sort è stabile (mantiene l'ordine relativo degli elementi uguali) e ha complessità garantita, ma richiede memoria extra O(n).
Counting Sort
Il Counting Sort non confronta gli elementi: conta quante volte appare ogni valore e ricostruisce l'array ordinato. Funziona solo su interi in un intervallo limitato, ma è velocissimo: O(n + k).
Counting Sort — O(n + k)
def counting_sort(arr):
if not arr:
return arr
massimo = max(arr)
conteggi = [0] * (massimo + 1)
# conta le occorrenze di ogni valore
for x in arr:
conteggi[x] += 1
# ricostruisci l'array ordinato
risultato = []
for valore, n in enumerate(conteggi):
risultato.extend([valore] * n)
return risultato
print(counting_sort([4, 2, 2, 8, 3, 3, 1]))
# [1, 2, 2, 3, 3, 4, 8]
⚠️ Il Counting Sort è efficiente solo se l'intervallo dei valori (k) è paragonabile al numero di elementi (n). Con valori molto grandi e sparsi diventa inefficiente in memoria.
Ricerca lineare
La ricerca lineare scorre l'array elemento per elemento finché trova il valore cercato. Funziona su qualsiasi array, ordinato o no, ma è O(n).
Ricerca lineare — O(n)
def ricerca_lineare(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i # trovato: restituisci l'indice
return -1 # non trovato
numeri = [4, 2, 7, 1, 9, 3]
print(ricerca_lineare(numeri, 9)) # 4
print(ricerca_lineare(numeri, 5)) # -1
Ricerca binaria
La ricerca binaria trova un valore in un array ordinato dimezzando ripetutamente lo spazio di ricerca. È estremamente veloce: O(log n).
Ricerca binaria — O(log n)
def ricerca_binaria(arr, target):
sinistra, destra = 0, len(arr) - 1
while sinistra <= destra:
meta = (sinistra + destra) // 2
if arr[meta] == target:
return meta # trovato
elif arr[meta] < target:
sinistra = meta + 1 # cerca a destra
else:
destra = meta - 1 # cerca a sinistra
return -1 # non trovato
ordinato = [1, 3, 5, 7, 9, 11, 13, 15]
print(ricerca_binaria(ordinato, 11)) # 5
print(ricerca_binaria(ordinato, 4)) # -1
💡 Con 1 milione di elementi, la ricerca lineare può richiedere 1.000.000 di confronti, la ricerca binaria al massimo 20. Ma attenzione: l'array deve essere ordinato.
Liste concatenate (Linked List)
Una lista concatenata è una sequenza di nodi, dove ogni nodo contiene un valore e un riferimento (puntatore) al nodo successivo. A differenza degli array, i nodi non sono contigui in memoria.
Lista concatenata semplice
class Nodo:
def __init__(self, valore):
self.valore = valore
self.next = None # riferimento al prossimo nodo
class ListaConcatenata:
def __init__(self):
self.testa = None
def aggiungi(self, valore): # inserimento in testa O(1)
nuovo = Nodo(valore)
nuovo.next = self.testa
self.testa = nuovo
def stampa(self):
corrente = self.testa
while corrente:
print(corrente.valore, end=" -> ")
corrente = corrente.next
print("None")
lista = ListaConcatenata()
lista.aggiungi(3); lista.aggiungi(2); lista.aggiungi(1)
lista.stampa() # 1 -> 2 -> 3 -> None
Operazione
Array
Linked List
Accesso per indice
O(1)
O(n)
Inserimento in testa
O(n)
O(1)
Memoria contigua
Sì
No
Stack e Queue
Lo Stack (pila) segue il principio LIFO (Last In, First Out): l'ultimo elemento inserito è il primo a uscire. La Queue (coda) segue il principio FIFO (First In, First Out): il primo inserito è il primo a uscire.
Stack (LIFO)
# Uno stack si implementa con una lista
stack = []
stack.append(1) # push
stack.append(2)
stack.append(3)
print(stack) # [1, 2, 3]
cima = stack.pop() # pop → rimuove l'ultimo
print(cima) # 3
print(stack) # [1, 2]
# Casi d'uso: undo/redo, ricorsione, parsing espressioni
Queue (FIFO)
from collections import deque
# deque è ottimizzato per inserimenti/rimozioni alle estremità
queue = deque()
queue.append("a") # enqueue
queue.append("b")
queue.append("c")
primo = queue.popleft() # dequeue → rimuove il primo
print(primo) # a
print(queue) # deque(['b', 'c'])
# Casi d'uso: code di stampa, BFS, task scheduling
🎯 Usa sempre collections.deque per le code: rimuovere dall'inizio di una lista normale è O(n), mentre con deque è O(1).
Hash Table
Una Hash Table (tabella hash) memorizza coppie chiave-valore e usa una funzione hash per calcolare dove memorizzare ogni elemento. Questo permette accesso, inserimento e ricerca in tempo medio O(1).
Hash Table = dizionario Python
# In Python le hash table sono i dizionari
rubrica = {}
# Inserimento → O(1) medio
rubrica["Anna"] = "333-1234"
rubrica["Luca"] = "347-5678"
# Ricerca → O(1) medio
print(rubrica["Anna"]) # 333-1234
# Verifica presenza chiave → O(1)
print("Luca" in rubrica) # True
# Anche i set sono hash table (solo chiavi)
visti = set()
visti.add("x")
print("x" in visti) # True
Come funziona internamente (concetto)
def hash_semplice(chiave, dimensione):
# somma i codici dei caratteri, poi modulo
return sum(ord(c) for c in chiave) % dimensione
# Una tabella con 10 "secchielli" (bucket)
print(hash_semplice("Anna", 10)) # es. 8
print(hash_semplice("Luca", 10)) # es. 5
# Quando due chiavi finiscono nello stesso bucket
# si ha una COLLISIONE, gestita con liste concatenate
# (chaining) o indirizzamento aperto.
Operazione
Caso medio
Caso peggiore
Inserimento
O(1)
O(n)
Ricerca
O(1)
O(n)
Rimozione
O(1)
O(n)
Alberi (Trees)
Un albero è una struttura dati gerarchica composta da nodi collegati. Ha un nodo radice (root), e ogni nodo può avere nodi figli. Un albero binario è un albero in cui ogni nodo ha al massimo due figli.
Un Binary Search Tree è un albero binario con una proprietà speciale: per ogni nodo, tutti i valori nel sottoalbero sinistro sono minori, e tutti quelli nel sottoalbero destro sono maggiori. Questo rende la ricerca O(log n).
BST: inserimento e ricerca
class Nodo:
def __init__(self, valore):
self.valore = valore
self.sinistra = self.destra = None
def inserisci(radice, valore):
if radice is None:
return Nodo(valore)
if valore < radice.valore:
radice.sinistra = inserisci(radice.sinistra, valore)
else:
radice.destra = inserisci(radice.destra, valore)
return radice
def cerca(radice, valore):
if radice is None or radice.valore == valore:
return radice
if valore < radice.valore:
return cerca(radice.sinistra, valore)
return cerca(radice.destra, valore)
radice = None
for v in [50, 30, 70, 20, 40, 60, 80]:
radice = inserisci(radice, v)
print(cerca(radice, 40) is not None) # True
print(cerca(radice, 45) is not None) # False
💡 Un BST sbilanciato (es. inserendo valori già ordinati) degenera in una lista e perde l'efficienza. Gli alberi AVL e i Red-Black Tree si ribilanciano automaticamente per garantire O(log n).
Grafi (Graphs)
Un grafo è un insieme di nodi (vertici) connessi da archi (edge). Modella relazioni: social network, mappe stradali, reti di computer. Può essere orientato o non orientato, pesato o no.
Grafo con lista di adiacenza + BFS
from collections import deque
# Lista di adiacenza
grafo = {
"A": ["B", "C"],
"B": ["A", "D", "E"],
"C": ["A", "F"],
"D": ["B"],
"E": ["B", "F"],
"F": ["C", "E"]
}
def bfs(grafo, partenza):
visitati = set([partenza])
coda = deque([partenza])
ordine = []
while coda:
nodo = coda.popleft()
ordine.append(nodo)
for vicino in grafo[nodo]:
if vicino not in visitati:
visitati.add(vicino)
coda.append(vicino)
return ordine
print(bfs(grafo, "A")) # ['A', 'B', 'C', 'D', 'E', 'F']
DFS (ricerca in profondità)
def dfs(grafo, nodo, visitati=None):
if visitati is None:
visitati = set()
visitati.add(nodo)
print(nodo, end=" ")
for vicino in grafo[nodo]:
if vicino not in visitati:
dfs(grafo, vicino, visitati)
dfs(grafo, "A") # A B D E F C
Visita
Strategia
Struttura
BFS (ampiezza)
Esplora per livelli
Coda (queue)
DFS (profondità)
Va a fondo poi torna
Stack / ricorsione
Cammino minimo (Shortest Path)
Il problema del cammino minimo cerca il percorso più breve tra due nodi di un grafo pesato. L'algoritmo più famoso è quello di Dijkstra.
Algoritmo di Dijkstra
import heapq
def dijkstra(grafo, partenza):
# distanza iniziale: infinito per tutti tranne partenza
dist = {nodo: float("inf") for nodo in grafo}
dist[partenza] = 0
coda = [(0, partenza)] # (distanza, nodo)
while coda:
d, nodo = heapq.heappop(coda)
if d > dist[nodo]:
continue
for vicino, peso in grafo[nodo].items():
nuova = d + peso
if nuova < dist[vicino]:
dist[vicino] = nuova
heapq.heappush(coda, (nuova, vicino))
return dist
grafo = {
"A": {"B": 1, "C": 4},
"B": {"C": 2, "D": 5},
"C": {"D": 1},
"D": {}
}
print(dijkstra(grafo, "A"))
# {'A': 0, 'B': 1, 'C': 3, 'D': 4}
⚠️ Dijkstra non funziona con archi di peso negativo. In quel caso si usa l'algoritmo di Bellman-Ford, che è O(V·E) ma gestisce i pesi negativi.
Albero ricoprente minimo (MST)
Un Minimum Spanning Tree è il sottoinsieme di archi che collega tutti i nodi di un grafo con il peso totale minimo, senza formare cicli. Utile per progettare reti (cavi, strade) al costo minimo.
Algoritmo di Kruskal
def kruskal(nodi, archi):
# archi = lista di (peso, nodo1, nodo2)
archi.sort() # ordina per peso crescente
padre = {n: n for n in nodi}
def trova(x): # find con compressione
while padre[x] != x:
padre[x] = padre[padre[x]]
x = padre[x]
return x
mst = []
for peso, a, b in archi:
ra, rb = trova(a), trova(b)
if ra != rb: # non crea ciclo
padre[ra] = rb
mst.append((a, b, peso))
return mst
nodi = ["A", "B", "C", "D"]
archi = [(1,"A","B"), (3,"A","C"), (4,"B","C"),
(2,"C","D"), (5,"B","D")]
print(kruskal(nodi, archi))
# [('A','B',1), ('C','D',2), ('A','C',3)]
💡 Esistono due algoritmi classici per l'MST: Kruskal (ordina gli archi e usa Union-Find) e Prim (cresce l'albero da un nodo usando una coda di priorità).
Memoization e Programmazione Dinamica
La programmazione dinamica (DP) risolve problemi complessi suddividendoli in sottoproblemi più semplici, e memorizzando i risultati per non ricalcolarli. Trasforma algoritmi esponenziali in polinomiali.
Fibonacci: ingenuo vs memoization
# Versione ingenua → O(2^n), lentissima
def fib_lento(n):
if n < 2:
return n
return fib_lento(n-1) + fib_lento(n-2)
# Versione con memoization → O(n)
def fib_veloce(n, memo={}):
if n < 2:
return n
if n not in memo:
memo[n] = fib_veloce(n-1, memo) + fib_veloce(n-2, memo)
return memo[n]
print(fib_veloce(50)) # 12586269025 (istantaneo)
# fib_lento(50) impiegherebbe molti minuti!
DP bottom-up (tabulation)
def fib_tabulation(n):
if n < 2:
return n
tabella = [0] * (n + 1)
tabella[1] = 1
for i in range(2, n + 1):
tabella[i] = tabella[i-1] + tabella[i-2]
return tabella[n]
print(fib_tabulation(10)) # 55
Approccio
Descrizione
Memoization (top-down)
Ricorsione + cache dei risultati
Tabulation (bottom-up)
Riempie una tabella iterativamente
🎯 La DP si applica quando un problema ha sottoproblemi sovrapposti e sottostruttura ottima: knapsack, cammini minimi, distanza di edit, e molti problemi da colloquio.
Esercizi pratici
🏋️ Esercizio 1 — Ricerca binaria
Implementa una funzione che, dato un array ordinato e un target, restituisca True se il target è presente, usando la ricerca binaria. Testala con valori presenti e assenti.
Soluzione Esercizio 1
def contiene(arr, target):
lo, hi = 0, len(arr) - 1
while lo <= hi:
mid = (lo + hi) // 2
if arr[mid] == target:
return True
elif arr[mid] < target:
lo = mid + 1
else:
hi = mid - 1
return False
dati = [2, 4, 6, 8, 10, 12]
print(contiene(dati, 8)) # True
print(contiene(dati, 7)) # False
🏋️ Esercizio 2 — Bilanciamento parentesi (Stack)
Usa uno stack per verificare se una stringa di parentesi è bilanciata: ogni parentesi aperta deve essere chiusa nell'ordine corretto. Es: "({[]})" → valido.
Soluzione Esercizio 2
def bilanciata(s):
stack = []
coppie = {")": "(", "]": "[", "}": "{"}
for c in s:
if c in "([{":
stack.append(c)
elif c in ")]}":
if not stack or stack.pop() != coppie[c]:
return False
return len(stack) == 0
print(bilanciata("({[]})")) # True
print(bilanciata("([)]")) # False
print(bilanciata("((")) # False
🏋️ Esercizio 3 — Fibonacci efficiente
Scrivi una funzione che calcoli l'n-esimo numero di Fibonacci in tempo O(n) usando solo due variabili (senza ricorsione né tabella).
Soluzione Esercizio 3
def fibonacci(n):
a, b = 0, 1
for _ in range(n):
a, b = b, a + b
return a
print(fibonacci(10)) # 55
print(fibonacci(20)) # 6765
print([fibonacci(i) for i in range(10)])
# [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
E ora?
Complimenti! Hai completato il corso di Strutture Dati e Algoritmi. Ora conosci gli strumenti fondamentali per scrivere codice efficiente e affrontare con sicurezza i colloqui tecnici.
Cosa hai imparato:
Analisi della complessità con la notazione Big O
Algoritmi di ordinamento: Bubble, Selection, Insertion, Quick, Merge, Counting
Ricerca lineare e binaria
Strutture dati: array, liste concatenate, stack, queue, hash table
Alberi, BST e grafi (BFS/DFS)
Cammino minimo (Dijkstra), MST (Kruskal) e programmazione dinamica
Il passo successivo? Mettere in pratica questi concetti risolvendo problemi reali e approfondendo con il corso di Python o quello di Data Science.