Una lista non va bene per tutto. Cercare un numero in una lista di 100.000 elementi significa confrontarlo, nel caso peggiore, con tutti; in un insieme significa calcolare un indirizzo e guardare lì. Più sotto un listato misura la differenza. Prima conviene capire come sono fatte le quattro strutture di Python (list, tuple, dict, set), che cosa vuol dire «mutabile» e quali bug nascono da questa distinzione. È la seconda lezione del modulo «Python per i dati» del corso Machine Learning Engineer; servono solo Python 3 e la libreria standard (collections, copy, timeit), senza installare nulla.
Le quattro strutture, la loro analogia, il loro uso
| Struttura | Analogia | Ordine | Duplicati | Mutabile | Si cerca per | Quando |
|---|---|---|---|---|---|---|
| list | Il vagone di un treno: posti numerati | Sì | Sì | Sì | posizione | Sequenza di elementi che cresce e cambia |
| tuple | Una riga di un modulo già compilato e firmata | Sì | Sì | No | posizione | Record di dimensione fissa, chiavi composte |
| dict | La rubrica: cerchi per nome, non per pagina | Sì (ordine di inserimento, garantito da Python 3.7) | Chiavi uniche | Sì | chiave | Associazioni chiave → valore, conteggi, indici |
| set | Il sacchetto di biglie tutte diverse: puoi chiedere «c'è la rossa?» | No | No | Sì | appartenenza | Unicità, test di appartenenza, operazioni insiemistiche |
Quando NON usarle. Una lista come «database» da interrogare molte volte per valore (usa dict o set); un dict dove l'ordine posizionale conta e le chiavi sono 0, 1, 2… (usa una lista); un set quando ti serve l'ordine o i duplicati.
Installazione
Solo libreria standard: collections, copy, timeit. Niente da installare.
Primo esempio: lista, tuple, slicing
l = [10, 20, 30, 40, 50]
print(l[0], l[-1], l[1:3], l[::-1], l[::2])
t = (1, 2, 3)
try:
t[0] = 9
except TypeError as e:
print("TypeError:", e)
Output: 10 50 [20, 30] [50, 40, 30, 20, 10] [10, 30, 50], poi TypeError: 'tuple' object does not support item assignment.
Lo slicing l[inizio:fine:passo] include l'inizio ed esclude la fine. Motivo del «fine escluso»: l[:k] + l[k:] == l per ogni k, e l[a:b] ha esattamente b − a elementi. È una convenzione che evita errori di «uno in più, uno in meno» (la stessa di range). Indici negativi contano dalla fine; passo negativo percorre al contrario.
Perché esistono le tuple, se sono liste che non si modificano? Per tre motivi: (1) comunicano l'intenzione («questo record non cambia»); (2) possono essere usate come chiavi di dizionario o elementi di insieme, perché sono hashable (sezione sulla tabella hash); (3) occupano meno memoria: su CPython 3.14.4 sys.getsizeof((1, 2, 3)) dà 72 byte contro gli 88 di sys.getsizeof([1, 2, 3]) (misura dell'autore, cambia tra versioni).
Teoria: mutabile contro immutabile
Analogia. Una lista è una lavagna: puoi cancellare e riscrivere, e chiunque la guardi vede il cambiamento. Una tuple è una fotografia: può mostrare cose, non può cambiare.
La definizione è sul comportamento dell'oggetto: un oggetto è mutabile se il suo valore può cambiare rimanendo lo stesso oggetto (la stessa identità). Mutabili: list, dict, set. Immutabili: int, float, str, tuple, frozenset, bool, None.
Trappola 1: la matrice con righe uguali
righe = [[0] * 3] * 3 # SBAGLIATO: tre riferimenti alla stessa lista
righe[0][0] = 1
print(righe)
ok = [[0] * 3 for _ in range(3)] # CORRETTO: tre liste distinte
ok[0][0] = 1
print(ok)
Output: [[1, 0, 0], [1, 0, 0], [1, 0, 0]] contro [[1, 0, 0], [0, 0, 0], [0, 0, 0]].
[0] * 3 crea una lista nuova; poi * 3 ripete lo stesso oggetto-lista tre volte (non lo copia). Le tre righe sono un solo oggetto. La comprehension valuta [0] * 3 a ogni giro, creando tre liste.
Esercizio 1
Tre scaffali che sono lo stesso scaffale
Il magazziniere di un'officina prepara una griglia delle giacenze, tre scaffali per quattro codici di ricambio, con griglia = [[0] * 4] * 3. Poi registra sette pezzi nel terzo codice del primo scaffale: griglia[0][2] = 7.
- Che cosa contiene
grigliadopo quella riga? - Perché? Quanti oggetti-lista sono stati creati davvero da
[[0] * 4] * 3? - Scrivi la riga corretta e di' quanti oggetti-lista crea.
Le tre domande vanno in ordine: prima prevedi il risultato, poi lo spieghi contando gli oggetti-lista, infine lo correggi. Se sai rispondere solo alla prima hai riconosciuto il bug; se sai rispondere alla seconda hai capito perché esiste. Prova su carta prima di aprire la soluzione.
Mostra la soluzione
1. [[0, 0, 7, 0], [0, 0, 7, 0], [0, 0, 7, 0]]: i sette pezzi compaiono su tutti e tre gli scaffali.
2. [0] * 4 crea una lista di quattro zeri. Il secondo * 3 non la copia: ripete lo stesso riferimento tre volte. Gli oggetti-lista creati sono due: la riga interna e il contenitore esterno. Le tre «righe» sono tre etichette sullo stesso oggetto, quindi scrivere in una scrive in tutte.
3. griglia = [[0] * 4 for _ in range(3)]. La comprehension rivaluta [0] * 4 a ogni giro, quindi crea tre liste interne distinte più il contenitore: quattro oggetti-lista. Ora griglia[0][2] = 7 tocca solo il primo scaffale. È la stessa radice della trappola della matrice e della copia superficiale: in Python l'assegnazione copia etichette, non oggetti.
Trappola 2: copia superficiale e profonda
import copy
x = [1, [2, 3]]
superficiale = list(x) # copia il contenitore, NON gli oggetti interni
profonda = copy.deepcopy(x) # copia ricorsivamente tutto
x[1].append(99)
print(superficiale, profonda)
Output: [1, [2, 3, 99]] [1, [2, 3]]. La copia superficiale condivide con l'originale la lista interna [2, 3]. Con strutture annidate (liste di liste, dizionari di liste) usa copy.deepcopy, oppure ricostruisci con una comprehension.
Trappola 2 bis: modificare una lista mentre la si scorre
Un'altra trappola legata alla mutabilità: rimuovere elementi da una lista mentre la scorri con un ciclo. L'iteratore tiene una posizione numerica; se togli un elemento, quelli successivi scalano di una posizione e il ciclo ne salta uno.
numeri = [2, 4, 6, 8, 1]
for n in numeri:
if n % 2 == 0:
numeri.remove(n)
print("modificando durante il ciclo:", numeri)
numeri = [2, 4, 6, 8, 1]
dispari = [n for n in numeri if n % 2 != 0]
print("costruendo una lista nuova :", dispari)
Output ottenuto:
modificando durante il ciclo: [4, 8, 1]
costruendo una lista nuova : [1]
Come leggere il codice. Nel primo ciclo, quando si rimuove il 2 (posizione 0), il 4 scorre in posizione 0, ma l'iteratore passa alla posizione 1, che ora contiene il 6: il 4 viene saltato e resta nella lista. Alla fine restano [4, 8, 1], un risultato sbagliato senza alcun messaggio d'errore. Il secondo modo costruisce una lista nuova, filtrando: è corretto, leggibile e di solito più veloce. La regola: non modificare una collezione mentre la scorri; costruisci una nuova collezione, oppure itera su una copia.
Trappola 3: l'argomento di default mutabile
def aggiungi(v, lst=[]): # BUG: il default viene creato UNA volta, alla definizione
lst.append(v)
return lst
print(aggiungi(1), aggiungi(2)) # [1, 2] [1, 2]
def aggiungi_ok(v, lst=None):
lst = [] if lst is None else lst
lst.append(v)
return lst
print(aggiungi_ok(1), aggiungi_ok(2)) # [1] [2]
Output: [1, 2] [1, 2] contro [1] [2]. Nella versione con bug, entrambe le chiamate restituiscono lo stesso oggetto, la lista di default; print valuta le due chiamate prima di stampare, quindi mostra due volte la lista già contenente entrambi gli elementi. Python valuta i valori di default una sola volta, quando la funzione viene definita: il default [] è un unico oggetto che sopravvive tra le chiamate. Il rimedio idiomatico è None come valore sentinella, con creazione della lista dentro la funzione.
Teoria: la tabella hash, e perché x in insieme è istantaneo
Il problema
Hai 100.000 elementi e vuoi sapere se 99999 è tra essi. In una lista devi confrontarlo con gli elementi uno a uno finché lo trovi: in media ti serve scandire una frazione consistente della lista, nel caso peggiore tutta: tempo proporzionale a n, scritto O(n).
L'analogia: l'archivio con i cassetti etichettati
Immagina un archivio con 1000 cassetti numerati. Una funzione (la funzione hash) trasforma ogni oggetto in un numero, e quel numero (modulo 1000) dice in quale cassetto metterlo. Per cercare un oggetto non apri tutti i cassetti: calcoli il numero e vai direttamente al cassetto giusto. Il costo è indipendente dal numero totale di oggetti, quando i cassetti non si riempiono troppo. È O(1) in media.
Funzionamento, passo per passo
- Si calcola
h = hash(oggetto), un intero. - Si prende
i = h mod m, dovemè il numero di cassetti (la dimensione della tabella). - Si guarda il cassetto
i. Se è vuoto, l'oggetto non c'è. Se contiene un oggetto, si confronta con==. Se gli oggetti sono diversi ma hanno lo stessoi(una collisione), si prosegue con una strategia di risoluzione (Python usa l'indirizzamento aperto con sondaggio). - Se la tabella si riempie oltre una soglia, si ridimensiona (più cassetti) e si rimettono dentro gli elementi.
I passi hanno un ordine obbligato: l'indice si ricava dall'hash, e solo dopo si guarda il cassetto. I due punti in cui una tabella reale si allontana dal modello ideale sono il passo 3, dove le collisioni allungano la strada, e il passo 4, dove il ridimensionamento costa una volta per evitare di pagare a ogni ricerca. L'esperimento che segue misura quanto pesano entrambi.
Costruiamone una: una tabella hash giocattolo
Il modo migliore per capire un meccanismo è costruirlo. Ecco una tabella hash minima con m cassetti; ciascun cassetto è una lista di coppie (chiave, valore), così le collisioni si gestiscono semplicemente allungando la lista (si chiama concatenamento). Per avere risultati riproducibili usiamo come funzione hash un CRC deterministico (zlib.crc32): la funzione hash integrata delle stringhe cambia a ogni esecuzione per motivi di sicurezza, e i numeri dell'esperimento non sarebbero ripetibili.
import math
import zlib
class TabellaHash:
def __init__(self, m=8):
self.m = m
self.cassetti = [[] for _ in range(m)]
self.n = 0
def _indice(self, chiave):
return zlib.crc32(str(chiave).encode()) % self.m # funzione hash -> numero di cassetto
def inserisci(self, chiave, valore):
cassetto = self.cassetti[self._indice(chiave)]
for i, (k, _) in enumerate(cassetto):
if k == chiave:
cassetto[i] = (chiave, valore) # chiave già presente: sovrascrivi
return
cassetto.append((chiave, valore))
self.n += 1
def cerca(self, chiave, default=None):
for k, v in self.cassetti[self._indice(chiave)]:
if k == chiave:
return v
return default
def rimuovi(self, chiave):
cassetto = self.cassetti[self._indice(chiave)]
for i, (k, _) in enumerate(cassetto):
if k == chiave:
del cassetto[i]
self.n -= 1
return True
return False
t = TabellaHash(m=8)
for nome, eta in (("Anna", 31), ("Luca", 25), ("Marta", 40)):
t.inserisci(nome, eta)
print("cerca Anna:", t.cerca("Anna"), "| cerca Zoe:", t.cerca("Zoe", "assente"))
t.rimuovi("Luca")
print("dopo rimuovi Luca ->", t.cerca("Luca", "assente"), "| elementi:", t.n)
# esperimento: m fisso, n crescente
m = 1000
print(f"{'n':>6} {'n/m':>6} {'catena media':>13} {'catena max':>11} {'cassetti vuoti':>15} {'attesi e^-n/m':>14}")
for n in (500, 1000, 2000, 4000, 8000):
tab = TabellaHash(m)
for i in range(n):
tab.inserisci(f"utente-{i}", i)
lunghezze = [len(c) for c in tab.cassetti]
vuoti = sum(1 for x in lunghezze if x == 0) / m
print(f"{n:>6} {n / m:>6.1f} {sum(lunghezze) / m:>13.2f} {max(lunghezze):>11} {vuoti:>15.3f} {math.exp(-n / m):>14.3f}")
Output ottenuto:
cerca Anna: 31 | cerca Zoe: assente
dopo rimuovi Luca -> assente | elementi: 2
n n/m catena media catena max cassetti vuoti attesi e^-n/m
500 0.5 0.50 5 0.615 0.607
1000 1.0 1.00 5 0.365 0.368
2000 2.0 2.00 8 0.139 0.135
4000 4.0 4.00 11 0.020 0.018
8000 8.0 8.00 19 0.001 0.000
Come leggere il codice. _indice trasforma qualsiasi chiave in un intero (il CRC del suo testo) e lo riduce al numero di cassetti con l'operatore modulo: è la funzione hash seguita dal passo 2 della procedura descritta sopra. inserisci guarda il cassetto: se trova la stessa chiave la sovrascrive, altrimenti aggiunge in coda (è così che le collisioni convivono). cerca e rimuovi percorrono solo il cassetto indicato dall'hash, mai l'intera tabella: è il cuore dell'efficienza. L'esperimento fissa 1000 cassetti e inserisce da 500 a 8000 elementi. Il fattore di carico n/m è la lunghezza media di una catena (per costruzione: n elementi in m cassetti): con n/m = 1 ogni ricerca guarda in media circa un elemento; con n/m = 8 ne guarda circa otto, comunque pochissimi rispetto agli 8000 di una scansione.
La quota di cassetti vuoti segue da vicino e^{-n/m}, la probabilità che un dato cassetto non riceva nessuno di n elementi distribuiti a caso (è la probabilità di zero eventi in una distribuzione di Poisson, che incontrerai nella lezione di probabilità). La catena più lunga è però molto più lunga della media: un'ottima funzione hash non elimina le collisioni, le distribuisce.
Perché le implementazioni ridimensionano. Se n cresce e m resta fisso, il fattore di carico cresce, le catene si allungano e la ricerca torna lineare in n. Per questo le tabelle hash reali, compresa quella di Python, allargano m quando il carico supera una soglia stabilita dall'implementazione, e reinseriscono gli elementi: il costo di questa operazione, distribuito su tutte le inserzioni, è piccolo e il carico resta limitato, quindi la ricerca resta in media a tempo costante.
Perché le chiavi devono essere hashable
Il valore hash decide in quale cassetto sta l'oggetto. Se l'oggetto fosse mutabile e cambiasse dopo l'inserimento, il suo hash cambierebbe, e lo cercheresti nel cassetto sbagliato: l'oggetto sarebbe «perso» nella tabella. Per questo Python richiede che le chiavi (e gli elementi di un insieme) siano hashable, cioè con un hash che non cambia durante la vita dell'oggetto. In pratica: gli immutabili sì (con tuple hashable solo se contengono elementi hashable), le liste no.
try:
{[1, 2]: 1}
except TypeError as e:
print("TypeError:", e) # il testo esatto varia tra versioni di Python
d = {(1, 2): "punto"} # una tuple può essere chiave
print(d[(1, 2)])
Output: un TypeError che dice che il tipo list non è hashable, e punto.
Il significato di O(n) e O(1). La notazione O(n) (si legge «ordine di n») dice come cresce il lavoro al crescere della dimensione n dei dati: lineare significa che raddoppiando i dati raddoppia il lavoro; O(1) (costante) significa che il lavoro non dipende da n. Non dice quanti secondi servono, dice come scala. Contare i confronti è un modo indipendente dalla macchina per vederlo:
def confronti_lista(lista, x):
n = 0
for e in lista:
n += 1
if e == x:
break
return n
for N in (1_000, 10_000, 100_000):
print(f"N = {N:>7}: confronti per trovare l'ultimo elemento in una lista = {confronti_lista(list(range(N)), N - 1):>7}")
Output ottenuto:
N = 1000: confronti per trovare l'ultimo elemento in una lista = 1000
N = 10000: confronti per trovare l'ultimo elemento in una lista = 10000
N = 100000: confronti per trovare l'ultimo elemento in una lista = 100000
Come leggere il codice. confronti_lista percorre la lista contando i confronti fatti prima di trovare x; nel caso peggiore (l'ultimo elemento) sono N. Moltiplicare per dieci la lunghezza moltiplica per dieci i confronti: è il comportamento lineare. Con la tabella hash dell'esperimento precedente, ricercare un elemento guarda in media qualche elemento, qualunque sia N finché il carico resta limitato. Questa differenza di scala (non di velocità assoluta) è ciò che rende i set e i dizionari indispensabili sui dati grandi.
Misura: la differenza tra O(n) e O(1)
import timeit
N = 100_000
lista = list(range(N))
insieme = set(lista)
t_lista = timeit.timeit(lambda: (N - 1) in lista, number=200) # caso peggiore: ultimo
t_insieme = timeit.timeit(lambda: (N - 1) in insieme, number=200)
print("il set è almeno 100 volte più veloce:", t_lista / t_insieme > 100)
Output: True. Non stampo il rapporto perché dipende dal computer e dalla versione di Python; su qualunque macchina il valore è di ordini di grandezza. Esegui il codice stampando t_lista / t_insieme per vedere il tuo.
Trade-off. L'insieme occupa più memoria (la tabella ha spazi vuoti) e costa tempo per costruirlo (O(n)). Conviene quando interroghi più volte; se devi cercare una volta sola, scandire la lista costa meno di costruire l'insieme.
Esercizio 2
Il cassetto si calcola, non si cerca
Sei codici di ricambio — 2050, 2061, 2078, 2083, 2104, 2117 — finiscono in una tabella hash con m = 4 cassetti. Per gli interi non negativi e abbastanza piccoli CPython usa come valore hash l'intero stesso, quindi il cassetto di un codice è semplicemente codice % 4.
- Calcola il cassetto di ciascun codice.
- Quanti cassetti risultano occupati, e quante collisioni ci sono (elementi che trovano il cassetto già occupato)?
- Quanto vale il fattore di carico
n / m? E che cosa diventano le risposte dei punti 1 e 2 se raddoppi i cassetti am = 8?
Non serve eseguire nulla: il resto della divisione per 4 si calcola a mente, ed è proprio il punto della lezione, perché il cassetto non si cerca ma si calcola. Scrivi i sei resti prima di rispondere su collisioni e carico, poi ripeti il conto con otto cassetti per vedere che cosa cambia.
Mostra la soluzione
1. 2050 % 4 = 2, 2061 % 4 = 1, 2078 % 4 = 2, 2083 % 4 = 3, 2104 % 4 = 0, 2117 % 4 = 1. Cassetto 0: 2104. Cassetto 1: 2061, 2117. Cassetto 2: 2050, 2078. Cassetto 3: 2083.
2. Tutti e 4 i cassetti sono occupati, e le collisioni sono 6 - 4 = 2: due codici hanno trovato posto in un cassetto che ne conteneva già uno.
3. Il fattore di carico è 6 / 4 = 1.5, cioè una catena lunga in media un elemento e mezzo. Con m = 8 i resti diventano 2, 5, 6, 3, 0, 5: i cassetti occupati salgono a 5 e la collisione resta una sola (2061 e 2117 cadono entrambi nel cassetto 5), con fattore di carico 6 / 8 = 0.75. È lo stesso principio con cui un dict si allarga quando si riempie: più cassetti, e si reinserisce tutto, perché una catena lunga riporta la ricerca da O(1) a O(n). Il prezzo è la memoria: metà dei cassetti resta vuota.
Codice: dizionari e insiemi al lavoro
Contare con un dizionario
from collections import Counter, defaultdict
testo = "il gatto e il cane e il topo"
conteggio = {}
for parola in testo.split():
conteggio[parola] = conteggio.get(parola, 0) + 1
print(conteggio)
print(Counter(testo.split()).most_common(2))
gruppi = defaultdict(list) # per ogni lunghezza, la lista delle parole
for p in testo.split():
gruppi[len(p)].append(p)
print(dict(gruppi))
Output ottenuto (due esecuzioni identiche): {'il': 3, 'gatto': 1, 'e': 2, 'cane': 1, 'topo': 1}, poi [('il', 3), ('e', 2)], poi {2: ['il', 'il', 'il'], 5: ['gatto'], 1: ['e', 'e'], 4: ['cane', 'topo']}. Le chiavi del dizionario seguono l'ordine di inserimento, garantito dal linguaggio dalla versione 3.7 (Built-in Types, mapping types).
dict.get(chiave, default)evita ilKeyErrorper chiavi assenti.Counterè un dizionario specializzato nel contare;most_common(k)restituisce ikpiù frequenti.defaultdict(list)crea automaticamente la lista vuota alla prima chiave nuova: il pattern «raggruppa per» in una riga.
Le tre voci rispondono alla stessa domanda, «quante volte e dove?», con un costo di codice decrescente. get è la versione esplicita, utile per vedere il meccanismo; Counter è quella da usare quando devi solo contare; defaultdict serve quando devi raggruppare, non solo contare. Sono tutti dizionari, quindi la ricerca per chiave resta a costo costante in media anche con milioni di parole. Contare e raggruppare così è la versione a mano di groupby, che ritroverai nell'articolo su Pandas.
Operazioni sugli insiemi
a, b = {1, 2, 3, 4}, {3, 4, 5}
print(a | b, a & b, a - b, a ^ b)
Output: {1, 2, 3, 4, 5} {3, 4} {1, 2} {1, 2, 5} (unione, intersezione, differenza, differenza simmetrica). L'ordine di stampa di un insieme non è garantito: non contarci. L'articolo Insiemi e logica formalizza queste operazioni come insiemistica e le collega alla probabilità.
Comprehension
Una comprehension costruisce una collezione descrivendo come ottenere ciascun elemento, in analogia con la notazione insiemistica { x² | x ∈ [0,10), x pari }.
testo = "il gatto e il cane e il topo"
quadrati = [x * x for x in range(10) if x % 2 == 0]
mappa = {c: ord(c) for c in "abc"}
lunghezze = {len(p) for p in testo.split()}
print(quadrati, mappa, lunghezze)
Output: [0, 4, 16, 36, 64] {'a': 97, 'b': 98, 'c': 99} {1, 2, 4, 5}.
Quando NO: non annidare più di due livelli; se non riesci a leggerla in un respiro, scrivi un ciclo normale. Per grandi sequenze usate una sola volta, una generator expression (x*x for x in ...) non crea la lista in memoria.
Ordinare con una chiave
sorted(iterabile, key=funzione) ordina senza modificare l'originale; lista.sort() modifica sul posto e restituisce None. L'ordinamento è stabile: elementi con la stessa chiave mantengono l'ordine reciproco originale. Per ordinare per più criteri usa una tupla come chiave; il segno − inverte l'ordine di un campo numerico.
studenti = [("Anna", 28), ("Luca", 21), ("Gio", 28)]
print(sorted(studenti, key=lambda s: (-s[1], s[0]))) # voto decrescente, poi nome
Output: [('Anna', 28), ('Gio', 28), ('Luca', 21)].
Trappola classica: y = lista.sort() assegna None a y. Se vuoi una copia ordinata usa sorted.
La soluzione commentata del mini-progetto: l'indice invertito
L'indice invertito è la struttura alla base di ogni motore di ricerca: invece di associare a ogni riga le sue parole, associa a ogni parola l'insieme delle righe in cui compare. Rispondere a «quali righe contengono tutte queste parole?» diventa l'intersezione di insiemi (se vuoi la formalizzazione, Insiemi e logica).
from collections import Counter, defaultdict
import string
testo = """Il gatto dorme sul divano
Il cane corre nel giardino
Il gatto e il cane giocano nel giardino
La sera il gatto dorme"""
traduzione = str.maketrans("", "", string.punctuation)
righe = [r.lower().translate(traduzione) for r in testo.splitlines()]
frequenze = Counter(p for riga in righe for p in riga.split())
print("prime 5 parole:", frequenze.most_common(5))
indice = defaultdict(set)
for numero, riga in enumerate(righe):
for parola in riga.split():
indice[parola].add(numero)
def righe_con_tutte(indice, parole):
insiemi = [indice.get(p, set()) for p in parole]
return sorted(set.intersection(*insiemi)) if insiemi else []
print("righe con 'gatto' e 'dorme':", righe_con_tutte(indice, ["gatto", "dorme"]))
print("righe con 'cane' e 'giardino':", righe_con_tutte(indice, ["cane", "giardino"]))
print("righe con 'gatto' e 'giardino':", righe_con_tutte(indice, ["gatto", "giardino"]))
print("parola inesistente:", righe_con_tutte(indice, ["gatto", "elefante"]))
Output ottenuto:
prime 5 parole: [('il', 5), ('gatto', 3), ('dorme', 2), ('cane', 2), ('nel', 2)]
righe con 'gatto' e 'dorme': [0, 3]
righe con 'cane' e 'giardino': [1, 2]
righe con 'gatto' e 'giardino': [2]
parola inesistente: []
Come leggere la soluzione. str.maketrans("", "", string.punctuation) costruisce una tabella di traduzione che elimina tutti i segni di punteggiatura; translate la applica. Counter(... for ... in ...) conta le parole di tutte le righe con una generator expression annidata (prima le righe, poi le parole di ciascuna). L'indice è un defaultdict(set): alla prima occorrenza di una parola crea da solo l'insieme vuoto, e add aggiunge il numero di riga (essendo un insieme, un secondo add dello stesso numero non crea duplicati). righe_con_tutte prende per ciascuna parola l'insieme dei numeri di riga (indice.get(p, set()) restituisce l'insieme vuoto per una parola mai vista, evitando il KeyError) e li intersecca con set.intersection(*insiemi), dove * scompatta la lista in argomenti separati. Ogni ricerca costa un'intersezione di insiemi, non una scansione del testo.
Scegliere la struttura: cinque domande e una tabella di costi
Davanti a un compito nuovo, cinque domande bastano quasi sempre a scegliere la struttura giusta, e conviene farle in questo ordine.
- I dati hanno un ordine che conta? Se sì (una serie temporale, una coda di lavori) serve una sequenza: lista o tuple. Se no, un insieme o un dizionario possono essere più adatti.
- Devo cercare per valore o per chiave? Se la domanda tipica è «c'è questo elemento?» o «quanto vale per questa chiave?», cerca in modo diretto con set o dict. Una lista obbliga a scandire.
- Posso avere duplicati, e mi interessano? Se i duplicati sono un errore o vanno ignorati, un set li elimina da solo. Se vanno contati, serve un
Counter. - Il contenuto cambia dopo la creazione? Se un record non deve cambiare, tuple (o
dataclassconfrozen=True) lo garantisce e lo rende utilizzabile come chiave. - Quanti dati sono, e quante volte li interrogo? Con pochi elementi qualunque scelta va bene; con molti elementi e molte interrogazioni, il costo per operazione diventa decisivo.
Per l'ultima domanda serve conoscere i costi tipici. La tabella seguente dà l'ordine di grandezza medio di ciascuna operazione, con n il numero di elementi. Le righe su lista e deque alle estremità sono quelle che il tutorial ufficiale descrive; la riga sull'appartenenza è quella che i listati sopra contano e misurano; l'ordinamento è n · log n perché è un confronto di coppie su n elementi:
| Operazione | list |
set / dict |
collections.deque |
|---|---|---|---|
| Aggiungere in fondo | costante (ammortizzato) | costante in media | costante |
| Aggiungere o togliere all'inizio | proporzionale a n |
non applicabile | costante |
Test di appartenenza (x in ...) |
proporzionale a n |
costante in media | proporzionale a n |
Accesso per posizione (l[i]) |
costante | non applicabile | costante agli estremi |
Accesso per chiave (d[k]) |
non applicabile | costante in media | non applicabile |
Ordinare (sorted) |
n · log n |
non applicabile | non applicabile |
Due letture pratiche. La prima: se togli elementi dalla testa di una lista (lista.pop(0)), ogni rimozione sposta tutti gli altri di una posizione: in un ciclo su milioni di elementi diventa lentissimo; per le code si usa collections.deque, progettata per aggiungere e togliere a entrambi gli estremi in tempo costante. La seconda: «costante in media» significa che, in condizioni normali, la ricerca in un set o in un dizionario non dipende da n; nel caso peggiore (molte collisioni) può degradare, ma con una buona funzione hash è un caso che non si incontra nell'uso comune.
Un criterio di buon senso. Non ottimizzare prima di misurare: se la lista ha cento elementi, nessuna scelta di struttura farà differenza percepibile. Ma conoscere la tabella ti evita di scrivere, per abitudine, un test di appartenenza su una lista dentro un ciclo che gira milioni di volte: è la classica fonte di programmi che «funzionano in prova e non finiscono mai in produzione».
Errori tipici
| Errore | Sintomo | Causa | Rimedio |
|---|---|---|---|
| Righe della matrice tutte uguali | Modifichi una riga e cambiano tutte | [[0]*3]*3 ripete lo stesso oggetto |
Comprehension |
| Originale che cambia dopo la «copia» | Dati alterati altrove | Copia superficiale con oggetti annidati | copy.deepcopy |
| Lista che «ricorda» le chiamate | Risultati che si accumulano | Default mutabile | None come sentinella |
KeyError |
Programma che si ferma su una chiave assente | Accesso diretto d[k] |
d.get(k, default) o defaultdict |
unhashable type |
Errore usando una lista come chiave | Mutabile non hashable | Converti in tuple (o frozenset) |
| Modificare una collezione mentre la scorri | Elementi saltati o errori | Cambia la struttura durante l'iterazione | Itera su una copia o costruisci una nuova collezione |
| Ordine di un set dato per scontato | Risultati diversi tra esecuzioni | L'ordine non è garantito | Ordina esplicitamente con sorted |
lista.sort() assegnato |
Variabile None |
sort ritorna None |
sorted(lista) |
Best practice
- Scegli la struttura dal compito: sequenza (list), record fisso (tuple), ricerca per chiave (dict), appartenenza/unicità (set).
- Per chiavi composte usa tuple, non stringhe concatenate.
- Preferisci comprehension a cicli con
appendquando restano leggibili. - Non mutare gli argomenti passati a una funzione senza dichiararlo nel nome o nella documentazione.
- Per record con campi nominati considera
dataclassoNamedTupleinvece di tuple anonime.
Le cinque regole discendono da una sola: scegli la struttura guardando che cosa farai più spesso con i dati, non quella che ti viene in mente per prima. La più economica da applicare in revisione è la seconda: una chiave composta come stringa ("2026-09-24|Milano") va spezzata a ogni lettura, mentre una tupla (data, città) è già pronta e confrontabile.
Mini-progetto
Dato un testo (anche di tuo gusto, incollato in una stringa):
- Normalizza (minuscolo, togli la punteggiatura con
str.translateo una comprehension). - Conta la frequenza di ogni parola con
Countere stampa le prime 5. - Costruisci un indice invertito: un dizionario
parola → insieme dei numeri di rigain cui compare. - Scrivi una funzione
righe_con_tutte(indice, parole)che restituisce le righe che contengono tutte le parole (intersezione di insiemi).
Il mini-progetto segue l'ordine di un motore di ricerca in miniatura: pulire, contare, indicizzare, interrogare. Scrivilo prima con un testo tuo e solo dopo confrontalo con la soluzione commentata sopra; controlla che la quarta funzione dia una lista vuota quando una delle parole non esiste, perché è il caso che rompe le prime versioni.
Challenge
- Easy. Elimina i duplicati da una lista mantenendo l'ordine originale (suggerimento:
dict.fromkeys). - Medium. Scrivi una funzione che trasponga una matrice (lista di liste) con
zip(*m)e spiega cosa fa*. - Hard. Implementa una tabella hash giocattolo con
mcassetti e liste per le collisioni, coninserisci,cerca,rimuovi. Misura la lunghezza media delle catene al variare del numero di elementi. - Expert. Dimostra con un esperimento che una tabella hash con
mcassetti enelementi ha lunghezza media di catenan/m(fattore di carico), e spiega perché le implementazioni ridimensionano quando questo supera una soglia. - Enterprise. Scrivi una guida di stile di una pagina per il tuo team su quali strutture usare per quali scopi e quali trappole controllare in revisione del codice.
Le cinque sfide crescono per ambizione, non per sintassi. Easy e Medium usano ciò che hai già visto; Hard riscrive la tabella hash dell'articolo con le tue mani; Expert chiede di misurare il fattore di carico e spiegarlo; Enterprise sposta il problema dal codice alle regole che un team può far rispettare in revisione. Se ne fai una sola, scegli Hard, perché è quella che ti fa capire davvero i dizionari.
Che cosa sai fare adesso
Sai scegliere tra lista, tuple, dizionario e insieme guardando la domanda che farai ai dati, e sai spiegare perché x in insieme non scandisce nulla: l'hash dice in quale cassetto guardare. Riconosci le tre trappole della mutabilità (righe della matrice condivise, copia superficiale, default mutabile) e quella dell'iterazione con rimozione, e sai quale rimedio usare per ciascuna. Il prossimo passo è organizzare il codice: funzioni, file, eccezioni e oggetti.
Nella stessa serie trovi anche: 08 - Python per i dati 1: ambiente e fondamenti (la lezione precedente), 11 - Numeri in virgola mobile: perché 0.1 + 0.2 non fa 0.3, 14 - Visualizzare i dati con Matplotlib, 15 - scikit-learn: stimatori, pipeline e validazione (che richiamano questa lezione).
Fonti
- Python Tutorial, Data Structures - uso delle liste come code e perché togliere dalla testa è lento,
deque; consultata il 2026-11-12 - Python Glossary, voce «hashable» - definizione di oggetto hashable e legame con le chiavi di dizionario; consultata il 2026-11-12
- Library Reference, Built-in Types - ordine dei dizionari garantito dalla 3.7; consultata il 2026-11-12
- Programming FAQ, «Why are default values shared between objects?» - il default mutabile è creato una volta sola; consultata il 2026-11-12
- Sorting Techniques - l'ordinamento è garantito stabile; consultata il 2026-11-12
- Library Reference, copy - copia superficiale e profonda; consultata il 2026-11-12
Commenti
Caricamento commenti...
Accedi per commentare