Salta al contenuto principale

09 - Python per i dati 2: liste, tuple, dizionari, insiemi

Lista, tuple, dizionario o insieme? Come scegliere, perché x in insieme è istantaneo grazie alla tabella hash e le tre trappole della mutabilità.

Cosa ne pensi di questo articolo?

09 - Python per i dati 2: liste, tuple, dizionari, insiemi
Machine Learning Engineerfedericocalo.dev

In Breve

Lista, tuple, dizionario o insieme? Come scegliere, perché x in insieme è istantaneo grazie alla tabella hash e le tre trappole della mutabilità.

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.

  1. Che cosa contiene griglia dopo quella riga?
  2. Perché? Quanti oggetti-lista sono stati creati davvero da [[0] * 4] * 3?
  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).

Come funziona una tabella hashLa chiave passa per una funzione hash che dà l'indice della casella."gatto"funzione hashh = 401234 → "gatto"567si va drittialla casella 4nessuna scansione: il costo non dipende da quanti elementi ci sono (in media)
Figura: perché `x in insieme` è istantaneo.

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

  1. Si calcola h = hash(oggetto), un intero.
  2. Si prende i = h mod m, dove m è il numero di cassetti (la dimensione della tabella).
  3. 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 stesso i (una collisione), si prosegue con una strategia di risoluzione (Python usa l'indirizzamento aperto con sondaggio).
  4. 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.

  1. Calcola il cassetto di ciascun codice.
  2. Quanti cassetti risultano occupati, e quante collisioni ci sono (elementi che trovano il cassetto già occupato)?
  3. Quanto vale il fattore di carico n / m? E che cosa diventano le risposte dei punti 1 e 2 se raddoppi i cassetti a m = 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 il KeyError per chiavi assenti.
  • Counter è un dizionario specializzato nel contare; most_common(k) restituisce i k più 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.

  1. 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.
  2. 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.
  3. 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.
  4. Il contenuto cambia dopo la creazione? Se un record non deve cambiare, tuple (o dataclass con frozen=True) lo garantisce e lo rende utilizzabile come chiave.
  5. 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 append quando restano leggibili.
  • Non mutare gli argomenti passati a una funzione senza dichiararlo nel nome o nella documentazione.
  • Per record con campi nominati considera dataclass o NamedTuple invece 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):

  1. Normalizza (minuscolo, togli la punteggiatura con str.translate o una comprehension).
  2. Conta la frequenza di ogni parola con Counter e stampa le prime 5.
  3. Costruisci un indice invertito: un dizionario parola → insieme dei numeri di riga in cui compare.
  4. 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 m cassetti e liste per le collisioni, con inserisci, cerca, rimuovi. Misura la lunghezza media delle catene al variare del numero di elementi.
  • Expert. Dimostra con un esperimento che una tabella hash con m cassetti e n elementi ha lunghezza media di catena n/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

Commenti

Caricamento commenti...

Accedi per commentare

Discuti questo articolo nella community

Unisciti al gruppo Telegram per discutere con altri sviluppatori, fare domande e condividere le tue esperienze.

Ti è piaciuto questo articolo?

Esplora altri contenuti sul blog o scopri i miei progetti