I dizionari sono tra le strutture dati più utilizzate in Python, soprattutto quando occorre associare informazioni, organizzare dati provenienti da fonti diverse, effettuare aggregazioni o costruire strutture più articolate. La loro flessibilità li rende particolarmente adatti alle applicazioni aziendali, all’elaborazione dei dati e allo sviluppo di programmi che devono gestire informazioni organizzate secondo una relazione chiave-valore.
Conoscere la sintassi di un dizionario è relativamente semplice, ma utilizzarlo in modo efficace richiede di comprendere alcune proprietà fondamentali: il funzionamento delle chiavi, le operazioni di ricerca, la gestione dei valori mancanti, l’ordine degli elementi e le differenze tra una copia superficiale e una copia profonda.
In questa raccolta affrontiamo sei esercizi progressivi, partendo dal conteggio delle occorrenze e arrivando alla realizzazione di una cache LRU (Least Recently Used). Gli esempi utilizzano situazioni concrete, come la registrazione degli accessi a un sistema aziendale, la gestione delle pagelle, l’organizzazione del magazzino e l’aggregazione degli ordini di un negozio online.
Ogni esercizio comprende il problema, una soluzione commentata, l’output atteso e alcune osservazioni sulle scelte implementative. Le domande finali permettono di verificare non soltanto la conoscenza dei metodi del dizionario, ma anche la capacità di valutare le implicazioni delle diverse soluzioni.
Prerequisiti: come funzionano i dizionari Python
Un dizionario (dict) è una struttura dati che associa chiavi univoche a valori. Internamente utilizza una tabella hash, che consente di individuare rapidamente il valore associato a una determinata chiave.
La struttura generale è:
dizionario = {
"nome": "Anna",
"età": 28,
"città": "Milano"
}
In questo esempio le chiavi sono stringhe, mentre i valori appartengono a tipi differenti.
Prima di affrontare gli esercizi è utile ricordare alcune proprietà.
Chiavi univoche e hashable
Ogni chiave deve essere univoca e hashable, cioè deve poter essere utilizzata per calcolare un valore hash stabile durante la sua permanenza nel dizionario.
Stringhe, numeri e tuple contenenti esclusivamente elementi hashable possono essere utilizzati come chiavi. Liste e dizionari, invece, non possono esserlo perché sono oggetti mutabili.
Se si assegna un nuovo valore a una chiave già presente, il valore precedente viene sovrascritto:
utente = {
"nome": "Anna",
"ruolo": "analista"
}
utente["ruolo"] = "data scientist"
print(utente)
# {'nome': 'Anna', 'ruolo': 'data scientist'}
# Il valore precedente associato alla chiave "ruolo" è stato sostituito.
Ordine di inserimento
A partire da Python 3.7, il mantenimento dell’ordine di inserimento è una garanzia del linguaggio. Quando si itera su un dizionario, gli elementi vengono quindi restituiti nell’ordine in cui sono stati inseriti.
Accesso e complessità
Le operazioni di ricerca, inserimento e cancellazione hanno complessità media O(1), grazie all’utilizzo dell’hashing. Nel caso peggiore, dovuto a collisioni, il costo può essere maggiore.
Iterazione
Python mette a disposizione tre modalità principali per scorrere gli elementi:
d = {"a": 10, "b": 20, "c": 30}
for chiave in d:
print(chiave)
# a, b, c
# L'iterazione diretta restituisce le chiavi.
for valore in d.values():
print(valore)
# 10, 20, 30
# values() restituisce i valori.
for chiave, valore in d.items():
print(chiave, valore)
# a 10
# b 20
# c 30
# items() restituisce le coppie chiave-valore.
Metodi da conoscere
get(k, default): Restituisce il valore associato a k oppure un valore predefinitosetdefault(k, default): Restituisce il valore esistente oppure inserisce la chiave con il valore predefinitopop(k): Rimuove una chiave e restituisce il relativo valoreupdate(d): Aggiorna il dizionario con gli elementi di un altro dizionariokeys(): Restituisce una vista delle chiavivalues(): Restituisce una vista dei valoriitems(): Restituisce una vista delle coppie chiave-valore
Le viste restituite da keys(), values() e items() sono dinamiche: riflettono le modifiche apportate al dizionario dopo la loro creazione. Se serve una copia stabile degli elementi, si può utilizzare list(d.items()).
Gli esercizi richiedono Python 3.10 o superiore, così da poter utilizzare le annotazioni dei tipi come list[str] e l’operatore | per indicare tipi alternativi.
Esercizio 1 — Contatore di accessi (facile)
Il problema
Una piccola intranet aziendale registra in un file di log i nomi degli utenti che effettuano l’accesso. Il sistema dispone di una lista log_accessi nella quale alcuni nomi compaiono più volte.
Occorre realizzare una funzione che produca un dizionario nella forma {utente: numero_accessi} e successivamente calcoli:
- gli utenti con il maggior numero di accessi;
- il totale complessivo degli accessi;
- gli utenti registrati che non hanno mai effettuato un accesso.
Soluzione
def conta_accessi(log_accessi: list[str]) -> dict[str, int]:
"""Restituisce un dizionario {utente: numero_accessi}."""
conteggio: dict[str, int] = {}
for utente in log_accessi:
# Se l'utente non esiste, get() restituisce 0.
# A ogni iterazione il contatore viene incrementato di 1.
conteggio[utente] = conteggio.get(utente, 0) + 1
return conteggio
def riepilogo(
log_accessi: list[str],
utenti_registrati: list[str]
) -> dict:
conteggio = conta_accessi(log_accessi)
# Individua il numero massimo di accessi.
# default=0 evita ValueError quando il dizionario è vuoto.
massimo = max(conteggio.values(), default=0)
# Seleziona tutti gli utenti che hanno raggiunto il massimo.
top = [
utente
for utente, numero in conteggio.items()
if numero == massimo and massimo > 0
]
# Somma tutti gli accessi registrati.
totale = sum(conteggio.values())
# Individua gli utenti registrati che non compaiono nei log.
inattivi = [
utente
for utente in utenti_registrati
if conteggio.get(utente, 0) == 0
]
return {
"top": top,
"totale": totale,
"inattivi": inattivi,
"dettaglio": conteggio
}
# --- Esempio completo ed eseguibile ---
if __name__ == "__main__":
log = [
"anna", "marco", "anna",
"luca", "anna", "marco"
]
registrati = ["anna", "marco", "luca", "giulia"]
risultato = riepilogo(log, registrati)
print("Utenti più attivi:", risultato["top"])
print("Totale accessi:", risultato["totale"])
print("Utenti inattivi:", risultato["inattivi"])
print("Dettaglio accessi:", risultato["dettaglio"])
# Output:
# Utenti più attivi: ['anna']
# Totale accessi: 6
# Utenti inattivi: ['giulia']
# Dettaglio accessi: {'anna': 3, 'marco': 2, 'luca': 1}
Come funziona la soluzione
Il primo passaggio consiste nell’inizializzare un dizionario vuoto e utilizzare il metodo get() per aggiornare il contatore.
L’istruzione:
conteggio[utente] = conteggio.get(utente, 0) + 1
restituisce il valore già associato all’utente oppure zero se la chiave non esiste. Il risultato viene incrementato e assegnato nuovamente alla stessa chiave.
Questo approccio evita di scrivere un controllo esplicito con if utente in conteggio e rende il codice più leggibile.
Una volta costruito il dizionario, max() individua il numero massimo di accessi, mentre sum() calcola il totale utilizzando direttamente la vista dei valori.
La ricerca degli utenti inattivi sfrutta nuovamente get(): se un utente registrato non compare nei log, il metodo restituisce zero e il nome viene inserito nella lista.
La condizione massimo > 0 evita che, con un log vuoto, tutti gli utenti registrati vengano erroneamente classificati come utenti più attivi.
Osservazioni tecniche
Il ruolo di get()
Il metodo get() è particolarmente utile quando un valore potrebbe non essere ancora presente nel dizionario. Rispetto alla sequenza if chiave in d seguita da d[chiave], evita una ricerca separata per verificare la presenza e una seconda ricerca per recuperare il valore.
È importante, tuttavia, non confondere questa caratteristica con la sicurezza concorrente: l’intera operazione di lettura, incremento e scrittura non è atomica. In un programma multithread occorre proteggere l’aggiornamento con un meccanismo di sincronizzazione appropriato.
Counter come alternativa
La libreria standard Python mette a disposizione collections.Counter, che consente di ottenere lo stesso conteggio in modo molto sintetico:
from collections import Counter
log = ["anna", "marco", "anna", "luca", "anna", "marco"]
conteggio = Counter(log)
print(conteggio)
# Counter({'anna': 3, 'marco': 2, 'luca': 1})
# Counter costruisce automaticamente il conteggio delle occorrenze.
Il contatore manuale rimane comunque utile per comprendere il funzionamento dei dizionari e quando occorre integrare il conteggio con altre operazioni, come l’applicazione di pesi o regole specifiche.
Domande di riflessione
Q1.1 Perché conteggio.get(utente, 0) + 1 è preferibile, in questo caso, alla sequenza if utente in conteggio seguita dall’aggiornamento del valore?
Q1.2 Cosa restituisce riepilogo([], [])? Che cosa succede, invece, se utenti_registrati è None?
Esercizio 2 — Pagella: medie e miglior studente (facile-intermedio)
Il problema
Il registro elettronico di un professore contiene, per ogni studente, una lista di voti in decimi. Alcuni studenti hanno una lista vuota perché non hanno ancora sostenuto verifiche.
Occorre calcolare la media di ogni studente, individuare quello con la media più alta e, in caso di parità, preferire chi ha ricevuto il maggior numero di voti.
Infine, bisogna restituire soltanto gli studenti promossi, considerando sufficiente una media pari o superiore a 6.
Per questo esercizio assumiamo che i voti siano numerici e che non siano ammessi valori non finiti come NaN e infinito.
Soluzione
import math
from statistics import mean
def analizza_pagella(
voti: dict[str, list[float]]
) -> dict:
medie: dict[str, float] = {}
# Calcola la media degli studenti che hanno almeno un voto.
for studente, lista in voti.items():
if not lista:
continue
# Controlla che i voti siano numerici e finiti.
if any(not math.isfinite(voto) for voto in lista):
raise ValueError(
f"Voti non validi per lo studente {studente}"
)
# Calcola la media aritmetica.
medie[studente] = mean(lista)
# Gestisce il caso in cui nessuno abbia ricevuto voti.
if not medie:
return {
"medie": {},
"migliore": None,
"promossi": {}
}
# Prima confronta la media, poi il numero di voti.
migliore = max(
medie,
key=lambda studente: (
medie[studente],
len(voti[studente])
)
)
# Seleziona gli studenti con media almeno pari a 6.
promossi = {
studente: media
for studente, media in medie.items()
if media >= 6.0
}
return {
"medie": medie,
"migliore": migliore,
"promossi": promossi
}
# --- Esempio completo ed eseguibile ---
if __name__ == "__main__":
pagella = {
"anna": [8, 7, 9],
"marco": [5, 6, 5],
"luca": [],
"giulia": [7, 7, 7, 7]
}
risultato = analizza_pagella(pagella)
print("Medie:", risultato["medie"])
print("Migliore studente:", risultato["migliore"])
print("Promossi:", risultato["promossi"])
# Output:
# Medie: {'anna': 8.0, 'marco': 5.333333333333333,
# 'giulia': 7}
# Migliore studente: anna
# Promossi: {'anna': 8.0, 'giulia': 7}
Come funziona la soluzione
La prima operazione consiste nel calcolare la media di ciascuno studente, utilizzando statistics.mean().
Il controllo:
if not lista:
continue
esclude gli studenti senza voti, evitando una divisione per zero e impedendo che vengano inseriti nel risultato valori privi di significato.
Prima del calcolo viene effettuata una verifica con math.isfinite(), che intercetta valori come NaN e infinito. Questi valori possono compromettere i confronti numerici e rendere poco affidabile l’individuazione dello studente migliore.
La scelta dello studente migliore utilizza una funzione lambda che restituisce una tupla:
lambda studente: (
medie[studente],
len(voti[studente])
)
Python confronta le tuple in modo lessicografico, considerando prima il primo elemento e successivamente il secondo. La media rappresenta quindi il criterio principale, mentre il numero di voti viene utilizzato soltanto quando le medie coincidono.
La comprehension finale costruisce un nuovo dizionario contenente esclusivamente gli studenti con media sufficiente.
Osservazioni tecniche
Le tuple come criteri di ordinamento
L’utilizzo di una tupla permette di rendere esplicite le regole di selezione. Lo stesso principio può essere applicato quando occorre ordinare prodotti per prezzo e disponibilità, clienti per fatturato e numero di ordini oppure candidati per punteggio e anzianità.
La gestione dei pareggi
Se due studenti hanno la stessa media e lo stesso numero di voti, max() restituisce il primo elemento che raggiunge il massimo secondo l’ordine di iterazione.
Poiché i dizionari preservano l’ordine di inserimento, il risultato è deterministico quando l’ordine con cui vengono caricati i dati è stabile.
Attenzione ai numeri floating point
Le medie sono rappresentate come numeri in virgola mobile e possono presentare piccoli scostamenti dovuti alla rappresentazione binaria dei decimali.
Non è opportuno applicare automaticamente round(media, 2) prima di verificare la promozione, perché l’arrotondamento potrebbe modificare il criterio di valutazione. Se il regolamento scolastico prevede un arrotondamento, questo deve essere applicato esplicitamente secondo le regole stabilite.
Domande di riflessione
Q2.1 Perché la funzione lambda utilizzata da max() restituisce una tupla invece della sola media?
Q2.2 Che cosa potrebbe accadere se una lista contenesse float("nan")? Perché è importante intercettare questo valore prima di calcolare la media?
Esercizio 3 — Inversione di un dizionario con collisioni (intermedio)
Il problema
Un magazzino utilizza un dizionario che associa a ogni prodotto il codice della sua ubicazione:
{
"SKU1": "A1",
"SKU2": "A1",
"SKU3": "B2"
}
La direzione della relazione è prodotto → ubicazione, ma per alcune operazioni gestionali potrebbe essere più utile conoscere tutti i prodotti presenti in una determinata ubicazione.
Occorre quindi costruire un nuovo dizionario con la relazione inversa:
{
"A1": ["SKU1", "SKU2"],
"B2": ["SKU3"]
}
Il problema presenta una particolarità: più prodotti possono condividere la stessa ubicazione, quindi l’inversione deve conservare tutte le informazioni senza sovrascrivere i valori precedenti.
Soluzione
def inverti_mappa(
mappa: dict[str, str]
) -> dict[str, list[str]]:
"""Inverte SKU -> codice in codice -> lista di SKU."""
inverso: dict[str, list[str]] = {}
for sku, codice in mappa.items():
# Se il codice non esiste, crea una lista vuota.
# Successivamente aggiunge lo SKU alla lista.
inverso.setdefault(codice, []).append(sku)
return inverso
def inverti_se_univoco(
mappa: dict[str, str]
) -> dict[str, str] | None:
"""Inverte la mappa solo se tutti i valori sono distinti."""
# Se il numero dei valori distinti è minore
# del numero delle coppie, esistono collisioni.
if len(set(mappa.values())) != len(mappa):
return None
# Inversione classica tramite dictionary comprehension.
return {
codice: sku
for sku, codice in mappa.items()
}
# --- Esempio completo ed eseguibile ---
if __name__ == "__main__":
mappa = {
"SKU1": "A1",
"SKU2": "A1",
"SKU3": "B2"
}
inverso = inverti_mappa(mappa)
print("Mappa originale:", mappa)
print("Mappa inversa:", inverso)
univoca = inverti_se_univoco(mappa)
print("Inversione uno-a-uno:", univoca)
# Output:
# Mappa originale: {'SKU1': 'A1', 'SKU2': 'A1', 'SKU3': 'B2'}
# Mappa inversa: {'A1': ['SKU1', 'SKU2'], 'B2': ['SKU3']}
# Inversione uno-a-uno: None
#
# Il codice A1 è associato a due prodotti:
# l'inversione classica non è quindi possibile senza perdere dati.
Come funziona la soluzione
La prima funzione utilizza setdefault() per creare automaticamente una lista associata a ogni codice di ubicazione.
L’istruzione:
inverso.setdefault(codice, []).append(sku)
restituisce la lista già presente oppure inserisce una nuova lista vuota quando la chiave non esiste. Successivamente, append() aggiunge il codice SKU alla lista.
La seconda funzione affronta un problema differente: l’inversione classica di un dizionario è possibile soltanto quando tutti i valori originali sono distinti.
Il controllo:
len(set(mappa.values())) != len(mappa)
confronta il numero dei valori distinti con il numero delle coppie presenti nel dizionario. Se i due valori sono differenti, esistono collisioni e la funzione restituisce None.
Osservazioni tecniche
setdefault() e defaultdict
Il metodo setdefault() è particolarmente utile quando si vogliono inizializzare valori associati a chiavi che potrebbero non esistere.
Un’alternativa è collections.defaultdict, che crea automaticamente il valore predefinito al primo accesso:
from collections import defaultdict
mappa = {
"SKU1": "A1",
"SKU2": "A1",
"SKU3": "B2"
}
inverso = defaultdict(list)
for sku, codice in mappa.items():
inverso[codice].append(sku)
print(dict(inverso))
# {'A1': ['SKU1', 'SKU2'], 'B2': ['SKU3']}
# defaultdict ha creato automaticamente le liste mancanti.
La scelta tra le due soluzioni dipende dalle preferenze progettuali e dal comportamento desiderato. Non è corretto considerare una delle due sempre più veloce: le prestazioni dipendono dal numero di elementi, dalle frequenze di accesso e dal contesto.
Hashability dei valori
Quando si inverte un dizionario, i valori originali diventano le nuove chiavi. Devono quindi essere hashable.
Nel nostro esempio i codici sono stringhe e rispettano questo requisito. Se i valori fossero liste, l’operazione set(mappa.values()) e la costruzione del dizionario inverso non sarebbero direttamente possibili.
Conservazione dell’ordine
Le liste costruite dalla funzione rispettano l’ordine con cui gli SKU compaiono nel dizionario originale. Questo comportamento è utile quando si desiderano risultati riproducibili.
Domande di riflessione
Q3.1 Perché {valore: chiave for chiave, valore in mappa.items()} può provocare una perdita di informazioni quando esistono valori duplicati?
Q3.2 Perché i valori del dizionario originale devono essere hashable quando si costruisce il dizionario inverso?
Esercizio 4 — Raggruppamento degli ordini e-commerce (intermedio-avanzato)
Il problema
Un negozio online riceve una lista di ordini rappresentati da dizionari. Ogni ordine contiene quattro informazioni:
id: identificativo dell’ordine;categoria: categoria del prodotto acquistato;importo: valore economico dell’ordine;cliente: identificativo del cliente.
L’obiettivo è costruire un riepilogo che raggruppi gli ordini per categoria, calcoli il fatturato di ciascuna categoria e individui il cliente con la spesa complessiva più alta all’interno di ogni gruppo.
La struttura finale dovrà avere questa forma:
{
"libri": {
"ordini": [...],
"totale": ...,
"top_cliente": ...
}
}
Per i calcoli monetari utilizzeremo Decimal, invece dei normali numeri float. La rappresentazione binaria dei floating point può infatti introdurre piccoli errori nei calcoli decimali, mentre Decimal permette di gestire importi monetari con maggiore controllo.
Soluzione
from collections import defaultdict
from decimal import Decimal
from typing import Iterable
def analizza_ordini(
ordini: Iterable[dict]
) -> dict:
gruppi: dict = {}
# Totale degli importi per categoria.
totali = defaultdict(Decimal)
# Spesa complessiva per cliente e categoria.
spesa_cliente = defaultdict(
lambda: defaultdict(Decimal)
)
for ordine in ordini:
# Estrae le informazioni necessarie.
categoria = ordine["categoria"]
cliente = ordine["cliente"]
# Converte l'importo in Decimal.
# str() evita di trasferire direttamente
# eventuali approssimazioni di un float.
importo = Decimal(str(ordine["importo"]))
# Crea il gruppo se non esiste.
gruppi.setdefault(
categoria,
{
"ordini": [],
"totale": Decimal("0"),
"top_cliente": None
}
)
# Inserisce l'ordine nel gruppo corrispondente.
gruppi[categoria]["ordini"].append(ordine)
# Aggiorna il totale della categoria.
totali[categoria] += importo
# Aggiorna la spesa del cliente nella categoria.
spesa_cliente[categoria][cliente] += importo
# Completa i risultati dopo aver elaborato tutti gli ordini.
for categoria, dati in gruppi.items():
dati["totale"] = totali[categoria]
# Individua il cliente con la spesa maggiore.
# In caso di parità viene mantenuto il primo inserito.
dati["top_cliente"] = max(
spesa_cliente[categoria],
key=spesa_cliente[categoria].get
)
return gruppi
# --- Esempio completo ed eseguibile ---
if __name__ == "__main__":
ordini = [
{
"id": 1,
"categoria": "libri",
"importo": "20.00",
"cliente": "anna"
},
{
"id": 2,
"categoria": "libri",
"importo": "15.00",
"cliente": "marco"
},
{
"id": 3,
"categoria": "libri",
"importo": "30.00",
"cliente": "anna"
},
{
"id": 4,
"categoria": "tech",
"importo": "200.00",
"cliente": "giulia"
}
]
risultato = analizza_ordini(ordini)
for categoria, dati in risultato.items():
print(f"\nCategoria: {categoria}")
print("Ordini:", dati["ordini"])
print("Totale:", dati["totale"])
print("Cliente principale:", dati["top_cliente"])
# Output:
#
# Categoria: libri
# Ordini: [ordine 1, ordine 2, ordine 3]
# Totale: 65.00
# Cliente principale: anna
#
# Categoria: tech
# Ordini: [ordine 4]
# Totale: 200.00
# Cliente principale: giulia
#
# Anna ha speso complessivamente 50.00 euro
# nella categoria libri, superando Marco (15.00).
Come funziona la soluzione
L’esercizio utilizza due strutture di accumulo basate su defaultdict.
La prima:
totali = defaultdict(Decimal)
crea automaticamente un valore iniziale pari a zero quando incontra una nuova categoria. In questo modo è possibile sommare direttamente gli importi senza inizializzare manualmente ogni chiave.
La seconda struttura:
spesa_cliente = defaultdict(
lambda: defaultdict(Decimal)
)
è un dizionario annidato. Il primo livello identifica la categoria, mentre il secondo associa a ogni cliente il totale dei suoi acquisti.
Durante il ciclo vengono eseguite tre operazioni:
- inserimento dell’ordine nel gruppo corrispondente;
- aggiornamento del totale della categoria;
- aggiornamento della spesa del cliente.
Una volta elaborati tutti gli ordini, un secondo ciclo completa i risultati e individua il cliente con la spesa maggiore.
L’istruzione:
max(
spesa_cliente[categoria],
key=spesa_cliente[categoria].get
)
restituisce la chiave associata al valore massimo, senza dover ordinare l’intero dizionario.
Osservazioni tecniche
Perché Decimal invece di float
Consideriamo un esempio:
print(0.1 + 0.2)
# 0.30000000000000004
# Il risultato presenta una piccola approssimazione binaria.
from decimal import Decimal
totale = Decimal("0.1") + Decimal("0.2")
print(totale)
# 0.3
# Decimal conserva esattamente la rappresentazione decimale
# dei valori utilizzati in questo esempio.
Per gli importi monetari è quindi preferibile utilizzare Decimal, costruendo i valori a partire da stringhe o da numeri interi.
Se si deve applicare una regola di arrotondamento a due decimali, è opportuno farlo esplicitamente con quantize() e una modalità di arrotondamento coerente con le regole del dominio applicativo.
La complessità di max()
La funzione max() effettua un solo passaggio sugli elementi, quindi ha complessità O(k), dove k è il numero dei clienti presenti nella categoria.
Ordinare tutto il dizionario con sorted() richiederebbe invece O(k log k), un costo non necessario quando interessa soltanto individuare il valore massimo.
La validazione degli ordini
L’esempio assume che ogni ordine contenga tutte le chiavi richieste e che gli importi siano validi. Se il codice deve essere utilizzato in produzione, occorre validare i dati in ingresso, verificando la presenza dei campi, il formato degli importi e l’eventuale presenza di valori negativi o non ammessi.
È generalmente preferibile centralizzare questa validazione, anziché distribuire controlli differenti in ogni punto del programma.
Domande di riflessione
Q4.1 Perché max(dizionario, key=dizionario.get) richiede O(k), mentre sorted(dizionario.items()) richiede O(k log k)?
Q4.2 Che cosa succede se l’importo è la stringa "20"? E se manca la chiave "cliente"?
Q4.3 Perché in un sistema commerciale è preferibile utilizzare Decimal rispetto a float?
Esercizio 5 — Merge di configurazioni annidate con override (avanzato)
Il problema
Un sistema software utilizza due file di configurazione: uno contiene le impostazioni di base, mentre il secondo contiene le modifiche specifiche di un ambiente, per esempio quello di produzione.
Occorre realizzare una funzione che unisca le due configurazioni rispettando alcune regole:
- le chiavi presenti nella configurazione di override sostituiscono quelle corrispondenti nella configurazione base;
- quando il valore associato a una chiave è un dizionario in entrambe le configurazioni, il merge deve essere ricorsivo;
- quando il valore è una lista in entrambe le configurazioni, la lista di override sostituisce interamente quella di base;
- la configurazione originale non deve essere modificata.
L’ultimo requisito è particolarmente importante perché la funzione potrebbe essere utilizzata più volte sullo stesso insieme di configurazioni.
Soluzione
from copy import deepcopy
from typing import Any
def deep_merge(
base: dict[str, Any],
override: dict[str, Any]
) -> dict[str, Any]:
"""
Unisce ricorsivamente due dizionari.
I dizionari vengono uniti ricorsivamente.
Gli altri tipi vengono sostituiti dall'override.
Le liste vengono sostituite, non concatenate.
Gli input originali non vengono modificati.
"""
# Crea una copia profonda della configurazione di base.
risultato = deepcopy(base)
for chiave, nuovo in override.items():
# Recupera il valore esistente, se presente.
vecchio = risultato.get(chiave)
# Se entrambi i valori sono dizionari,
# applica ricorsivamente lo stesso procedimento.
if isinstance(vecchio, dict) and isinstance(nuovo, dict):
risultato[chiave] = deep_merge(vecchio, nuovo)
else:
# Per liste, numeri, stringhe e altri tipi,
# il valore di override sostituisce quello precedente.
risultato[chiave] = deepcopy(nuovo)
return risultato
# --- Esempio completo ed eseguibile ---
if __name__ == "__main__":
base = {
"db": {
"host": "localhost",
"port": 5432,
"opts": {
"ssl": True
}
},
"features": ["a", "b"],
"debug": False
}
override = {
"db": {
"host": "prod-db",
"opts": {
"ssl": False,
"timeout": 30
}
},
"features": ["c"]
}
merged = deep_merge(base, override)
print("Configurazione risultante:")
print(merged)
# Output:
# {
# 'db': {
# 'host': 'prod-db',
# 'port': 5432,
# 'opts': {
# 'ssl': False,
# 'timeout': 30
# }
# },
# 'features': ['c'],
# 'debug': False
# }
#
# Il valore host è stato sostituito.
# La porta è stata conservata.
# Il dizionario opts è stato unito ricorsivamente.
# La lista features è stata sostituita interamente.
# Verifica che la configurazione originale sia intatta.
assert base["db"]["host"] == "localhost"
assert base["db"]["opts"]["ssl"] is True
assert base["features"] == ["a", "b"]
print("\nConfigurazione originale preservata.")
# Output:
# Configurazione originale preservata.
# Verifica che anche le liste annidate siano indipendenti.
merged["features"].append("d")
print("Lista originale:", base["features"])
print("Lista modificata:", merged["features"])
# Output:
# Lista originale: ['a', 'b']
# Lista modificata: ['c', 'd']
#
# La modifica del risultato non altera la lista originale.
Come funziona la soluzione
La funzione inizia creando una copia profonda della configurazione base:
risultato = deepcopy(base)
Questa scelta evita che il nuovo dizionario condivida riferimenti con gli oggetti annidati della configurazione originale.
Successivamente, la funzione scorre le coppie chiave-valore dell’override. Quando incontra una chiave presente in entrambe le configurazioni e associata a due dizionari, richiama se stessa per effettuare il merge anche al livello successivo.
Negli altri casi, il nuovo valore sostituisce quello precedente.
La ricorsione permette di applicare la stessa regola a qualsiasi livello di annidamento, senza dover conoscere in anticipo la struttura completa delle configurazioni.
Osservazioni tecniche
Copia superficiale e copia profonda
La differenza tra copy() e deepcopy() è fondamentale quando si lavora con strutture annidate.
Una copia superficiale crea un nuovo dizionario esterno, ma mantiene i riferimenti agli oggetti contenuti al suo interno.
Consideriamo questo esempio:
base = {
"db": {
"host": "localhost"
},
"features": ["a", "b"]
}
# Copia superficiale.
superficiale = dict(base)
# Modifica una lista annidata.
superficiale["features"].append("c")
print(base["features"])
# ['a', 'b', 'c']
# La lista originale è stata modificata perché i due
# dizionari condividono lo stesso oggetto lista.
Il problema si presenta anche quando il merge non modifica direttamente una determinata struttura annidata: se il risultato mantiene un riferimento condiviso, una successiva modifica può propagarsi all’oggetto originale.
Con deepcopy() vengono invece copiati ricorsivamente anche gli oggetti contenuti nei livelli interni.
Il significato di None
Il metodo get() restituisce None sia quando una chiave è assente sia quando è presente con valore None.
Nel nostro esercizio questa distinzione non cambia il comportamento desiderato, perché None non viene trattato come un dizionario da unire. In una funzione generica, però, potrebbe essere importante distinguere i due casi:
if chiave in risultato:
vecchio = risultato[chiave]
else:
vecchio = None
In questo modo si verifica esplicitamente la presenza della chiave, indipendentemente dal valore associato.
Le regole di merge dipendono dal dominio
Non esiste una regola universale per unire liste e dizionari annidati.
In questo esercizio le liste vengono sostituite completamente, ma altri sistemi possono prevedere concatenazioni oppure unioni basate su identificativi. La semantica del merge deve quindi essere documentata e condivisa con chi utilizza la funzione.
Complessità
La copia profonda richiede un tempo proporzionale alla dimensione della struttura da copiare. Il merge aggiunge il costo necessario per attraversare gli elementi dell’override.
Per configurazioni di dimensioni contenute, il costo è generalmente accettabile. Per strutture molto grandi o utilizzate frequentemente, è opportuno valutare anche soluzioni basate su copie selettive o strutture immutabili.
Domande di riflessione
Q5.1 Se sostituissimo deepcopy(base) con dict(base), quale problema potrebbe emergere quando il risultato contiene liste o altri oggetti annidati non modificati durante il merge?
Q5.2 Qual è la differenza tra chiave in dizionario e dizionario.get(chiave) quando None è un valore legittimo?
Q5.3 Perché la regola di sostituzione delle liste deve essere documentata esplicitamente?
Esercizio 6 — LRU Cache con OrderedDict (difficile)
Il problema
Una cache è una struttura utilizzata per conservare temporaneamente informazioni che potrebbero essere richieste più volte, evitando di ripetere operazioni costose.
Una cache LRU (Least Recently Used) elimina l’elemento che non viene utilizzato da più tempo quando deve liberare spazio.
Supponiamo di avere una cache con capacità pari a due elementi:
- Inseriamo
aeb. - Accediamo ad
a, rendendolo l’elemento più recente. - Inseriamo
c. - Poiché la capacità è stata superata, viene eliminato
b, che rappresenta l’elemento meno recentemente utilizzato.
Dobbiamo implementare una classe che esponga due operazioni:
get(key): restituisce il valore associato alla chiave e aggiorna il suo stato di utilizzo; se la chiave non esiste, restituisce-1;put(key, value): inserisce o aggiorna un elemento e, se necessario, elimina quello meno recentemente utilizzato.
La convenzione adottata riserva -1 come valore indicativo di una chiave assente, quindi in questa implementazione -1 non deve essere utilizzato come valore valido della cache.
Soluzione
from collections import OrderedDict
from typing import Any
class LRUCache:
"""
Cache Least Recently Used basata su OrderedDict.
L'elemento meno recente si trova all'inizio,
mentre quello più recente si trova alla fine.
"""
def __init__(self, capacity: int) -> None:
if capacity <= 0:
raise ValueError("capacity deve essere maggiore di zero")
self._capacity = capacity
self._cache: OrderedDict[Any, Any] = OrderedDict()
def get(self, key: Any) -> Any:
# Se la chiave non esiste, restituisce -1.
if key not in self._cache:
return -1
# Sposta la chiave alla fine:
# è diventata l'elemento più recentemente utilizzato.
self._cache.move_to_end(key)
return self._cache[key]
def put(self, key: Any, value: Any) -> None:
# Se la chiave esiste già, la rende la più recente.
if key in self._cache:
self._cache.move_to_end(key)
# Inserisce il valore oppure aggiorna quello esistente.
self._cache[key] = value
# Se la capacità è stata superata,
# elimina il primo elemento, cioè il meno recente.
if len(self._cache) > self._capacity:
self._cache.popitem(last=False)
def __len__(self) -> int:
return len(self._cache)
def __repr__(self) -> str:
return f"LRUCache({dict(self._cache)})"
# --- Esempio completo ed eseguibile ---
if __name__ == "__main__":
cache = LRUCache(2)
cache.put("a", 1)
cache.put("b", 2)
print("Cache iniziale:", cache)
# LRUCache({'a': 1, 'b': 2})
# L'ordine va dal meno recente al più recente.
print("Accesso ad a:", cache.get("a"))
# 1
# L'accesso ad a lo sposta in fondo alla struttura.
print("Cache dopo get:", cache)
# LRUCache({'b': 2, 'a': 1})
# b è ora il meno recente, mentre a è il più recente.
cache.put("c", 3)
print("Cache dopo inserimento di c:", cache)
# LRUCache({'a': 1, 'c': 3})
# La capacità è stata superata:
# b è stato eliminato perché era il meno recente.
print("Ricerca di b:", cache.get("b"))
# -1
# b non è più presente nella cache.
print("Ricerca di a:", cache.get("a"))
# 1
# a è ancora presente.
print("Ricerca di c:", cache.get("c"))
# 3
# Anche c è presente.
print("Numero di elementi:", len(cache))
# 2
# La capacità massima è rispettata.
Come funziona la soluzione
La classe utilizza OrderedDict, una struttura che mantiene l’ordine degli elementi e mette a disposizione metodi specifici per modificarlo.
L’operazione:
self._cache.move_to_end(key)
sposta una chiave alla fine del dizionario, identificandola come l’elemento più recentemente utilizzato.
Quando la cache supera la capacità massima, viene eseguita:
self._cache.popitem(last=False)
Il parametro last=False indica che deve essere rimosso il primo elemento, cioè quello meno recente.
La distinzione tra ordine di inserimento e ordine di utilizzo è fondamentale: una cache LRU non elimina semplicemente l’elemento inserito da più tempo, ma quello che non viene utilizzato da più tempo.
Osservazioni tecniche
Perché OrderedDict e non un normale dict?
A partire da Python 3.7 anche un dizionario normale conserva l’ordine di inserimento, ma non offre il metodo move_to_end().
Una LRU può essere implementata anche con un normale dict, sfruttando la possibilità di cancellare e reinserire una chiave:
d = {"a": 1, "b": 2}
# Simula lo spostamento di a alla fine.
valore = d.pop("a")
d["a"] = valore
print(d)
# {'b': 2, 'a': 1}
# La chiave a è stata reinserita in fondo.
Anche questa soluzione può essere efficiente nelle normali condizioni di utilizzo, ma OrderedDict esprime più chiaramente l’intenzione e mette a disposizione operazioni dedicate.
Complessità delle operazioni
Le operazioni principali della cache hanno complessità media O(1):
- Ricerca della chiave: O(1)
- Inserimento: O(1)
- Aggiornamento: O(1)
- Spostamento alla fine: O(1)
- Eliminazione del meno recente: O(1)
La complessità attesa dipende dal funzionamento delle strutture hash utilizzate internamente.
La gestione della concorrenza
La classe non è thread-safe: se più thread modificano contemporaneamente la cache, le operazioni composte possono interferire tra loro.
In un’applicazione concorrente è opportuno proteggere le operazioni con un threading.Lock, in modo che la lettura, l’aggiornamento dell’ordine e l’eventuale eliminazione siano gestiti in modo coerente.
Il significato della capacità zero
In questo esercizio abbiamo scelto di sollevare un’eccezione quando la capacità è zero o negativa.
Una cache con capacità zero potrebbe anche essere progettata come un componente disabilitato, nel quale gli inserimenti non producono effetti e le ricerche restituiscono sempre il valore di assenza. La scelta dipende dal comportamento richiesto dall’applicazione.
È importante che questa regola sia definita nel contratto della classe e verificata attraverso i test.
Domande di riflessione
Q6.1 Perché il metodo get() deve chiamare move_to_end() e non limitarsi a restituire il valore?
Q6.2 Che cosa accadrebbe se la capacità fosse pari a zero? Perché abbiamo scelto di sollevare un’eccezione?
Q6.3 Perché la classe non può essere considerata automaticamente thread-safe?
Risposte alle domande di riflessione
Le domande precedenti permettono di verificare alcuni aspetti che vanno oltre la semplice conoscenza dei metodi del dizionario. Le risposte aiutano a comprendere perché determinate scelte implementative siano preferibili in uno specifico contesto.
Esercizio 1 — Contatore di accessi
Q1.1 — Perché utilizzare get() invece di if?
L’espressione:
conteggio[utente] = conteggio.get(utente, 0) + 1
permette di recuperare il valore associato alla chiave e di utilizzare zero quando questa non è ancora presente.
La soluzione alternativa:
if utente in conteggio:
conteggio[utente] += 1
else:
conteggio[utente] = 1
richiede un controllo di presenza e successivamente un accesso al valore. La prima soluzione è più compatta e, in questo caso, evita una ricerca separata per verificare l’esistenza della chiave.
Non bisogna però interpretare questa caratteristica come una garanzia di atomicità: anche get() seguito dall’incremento e dall’assegnazione rappresenta una sequenza di operazioni distinte.
Q1.2 — Che cosa succede con liste vuote e con None?
Con:
riepilogo([], [])
il dizionario dei conteggi rimane vuoto. max() restituisce zero grazie al parametro default=0, la lista degli utenti più attivi è vuota, il totale degli accessi è zero e non ci sono utenti inattivi da segnalare.
Il risultato è quindi:
{
"top": [],
"totale": 0,
"inattivi": [],
"dettaglio": {}
}
Se invece utenti_registrati è None, il ciclo for genera un TypeError, perché None non è iterabile.
In un’applicazione reale gli input devono essere validati prima di raggiungere la funzione. Se il contratto prevede che l’assenza di una lista equivalga a una lista vuota, si può gestire esplicitamente questa eventualità.
Esercizio 2 — Pagella
Q2.1 — Perché la lambda restituisce una tupla?
La tupla:
(medie[studente], len(voti[studente]))
definisce due criteri di confronto.
Python confronta prima la media e, soltanto quando due studenti hanno la stessa media, confronta il numero di voti.
Questo permette di esprimere direttamente la regola richiesta senza dover costruire un ordinamento separato o scrivere ulteriori condizioni.
Se anche il numero di voti coincide, viene scelto il primo studente nell’ordine di inserimento del dizionario.
Q2.2 — Che cosa succede con NaN?
Il valore NaN (Not a Number) presenta alcune proprietà particolari:
import math
nan = float("nan")
print(nan == nan)
# False
# Un NaN non è uguale nemmeno a se stesso.
print(nan > 5)
# False
print(nan < 5)
# False
# I confronti con NaN non seguono le normali regole numeriche.
Se NaN entra in una lista di voti, statistics.mean() può propagare il valore nella media. Inoltre, i confronti utilizzati da max() possono produrre risultati dipendenti dall’ordine degli elementi.
Per questo motivo la soluzione controlla i valori con math.isfinite(), intercettando sia NaN sia infinito prima di effettuare i calcoli.
Esercizio 3 — Inversione dei dizionari
Q3.1 — Perché l’inversione classica può perdere informazioni?
Consideriamo:
mappa = {
"SKU1": "A1",
"SKU2": "A1"
}
inversa = {
valore: chiave
for chiave, valore in mappa.items()
}
print(inversa)
# {'A1': 'SKU2'}
# SKU1 è stato perso perché la seconda assegnazione
# ha sovrascritto il valore associato alla chiave A1.
Un dizionario non può contenere due volte la stessa chiave. Quando più prodotti condividono lo stesso codice di ubicazione, occorre quindi utilizzare una lista come valore oppure rifiutare l’inversione se il requisito è quello di ottenere una relazione uno-a-uno.
Q3.2 — Perché i valori originali devono essere hashable?
Quando si inverte una mappa, i valori diventano le nuove chiavi. Devono quindi rispettare lo stesso requisito imposto alle chiavi di qualsiasi dizionario Python.
Stringhe, numeri e tuple di elementi hashable sono utilizzabili, mentre una lista non può essere inserita direttamente come chiave.
Anche l’operazione:
set(mappa.values())
richiede valori hashable, perché il set utilizza una struttura basata sull’hashing per individuare gli elementi distinti.
Esercizio 4 — Raggruppamento degli ordini
Q4.1 — Perché max() è O(k)?
La funzione max() attraversa gli elementi una sola volta, mantenendo in memoria il massimo individuato fino a quel momento.
Se ci sono k clienti, vengono eseguiti k confronti al massimo e il costo è O(k), assumendo che la funzione utilizzata come criterio di confronto abbia costo medio O(1).
Un ordinamento completo, invece, richiederebbe O(k log k). Quando serve soltanto il valore massimo, ordinare tutti gli elementi comporta un lavoro aggiuntivo non necessario.
Q4.2 — Che cosa succede con importi stringa e chiavi mancanti?
Una stringa numerica come "20" può essere convertita in Decimal:
from decimal import Decimal
importo = Decimal("20")
print(importo)
# 20
# La stringa numerica viene convertita correttamente.
Una stringa non numerica, come "venti", genera invece un’eccezione decimal.InvalidOperation.
Se manca la chiave "cliente", l’accesso:
cliente = ordine["cliente"]
genera un KeyError.
In un sistema reale occorre validare i dati ricevuti prima di procedere con le aggregazioni, stabilendo quali campi siano obbligatori e come gestire le informazioni non valide.
Q4.3 — Perché Decimal è preferibile a float?
I numeri float rappresentano i valori in virgola mobile attraverso una codifica binaria. Alcuni decimali, come 0.1, non possono essere rappresentati esattamente e questo può generare piccoli errori nelle somme.
Decimal consente invece di gestire la rappresentazione decimale in modo controllato, risultando più adatto ai calcoli monetari, purché venga utilizzato correttamente e con regole di arrotondamento esplicite.
Esercizio 5 — Merge ricorsivo
Q5.1 — Quale problema può emergere con dict(base)?
Una copia superficiale crea un nuovo dizionario esterno, ma mantiene i riferimenti agli oggetti annidati.
Nel nostro esempio, anche una lista che non viene coinvolta direttamente nel merge rimane condivisa tra il dizionario originale e quello risultante.
Una modifica successiva al risultato potrebbe quindi alterare anche la configurazione di base.
La copia profonda evita questo problema duplicando ricorsivamente gli oggetti contenuti nella struttura.
Q5.2 — Qual è la differenza tra in e get()?
L’operatore in verifica esclusivamente la presenza di una chiave, indipendentemente dal valore associato.
Il metodo get() restituisce invece il valore oppure un valore predefinito. Se il valore presente è None, una semplice chiamata a get() senza un secondo argomento non permette di distinguere questo caso dall’assenza della chiave.
Per esempio:
d = {
"timeout": None
}
print("timeout" in d)
# True
# La chiave esiste.
print(d.get("timeout"))
# None
# Il valore associato è None.
print(d.get("missing"))
# None
# Anche una chiave assente restituisce None.
Se la distinzione è importante, occorre verificare esplicitamente la presenza della chiave.
Q5.3 — Perché documentare le regole di merge?
Perché la combinazione di strutture annidate può seguire regole differenti a seconda del problema applicativo.
Una lista potrebbe essere sostituita, concatenata oppure unita utilizzando un identificativo comune. Senza una regola esplicita, due sviluppatori potrebbero aspettarsi comportamenti differenti dalla stessa funzione.
Il contratto del metodo deve quindi specificare chiaramente come vengono trattati i diversi tipi di dato.
Esercizio 6 — LRU Cache
Q6.1 — Perché get() deve aggiornare l’ordine?
Una cache LRU elimina l’elemento il cui ultimo utilizzo è più lontano nel tempo, non semplicemente quello inserito per primo.
Quando una chiave viene cercata con successo, diventa un elemento recentemente utilizzato e deve quindi essere spostata in fondo alla struttura.
Se get() si limitasse a restituire il valore senza aggiornare l’ordine, la cache seguirebbe una logica simile a FIFO (First In, First Out), eliminando gli elementi secondo l’ordine di inserimento e non secondo la frequenza temporale degli accessi.
Q6.2 — Che cosa accade con capacity uguale a zero?
Una capacità pari a zero significa che la cache non può conservare alcun elemento.
Si potrebbero adottare due comportamenti:
- considerare la cache disabilitata, ignorando gli inserimenti;
- sollevare un’eccezione durante la costruzione.
La seconda soluzione è stata scelta nell’esercizio perché rende esplicito il vincolo della classe e impedisce di creare un oggetto con una configurazione non ammessa.
Una capacità negativa è invece sempre incompatibile con l’invariante che stabilisce che il numero degli elementi non può superare la capacità massima.
Q6.3 — Perché la classe non è thread-safe?
Le operazioni di una cache LRU non consistono soltanto nell’accesso a un dizionario: una ricerca modifica anche l’ordine degli elementi, mentre un inserimento può richiedere l’eliminazione di un’altra chiave.
Se più thread eseguono queste operazioni contemporaneamente, possono verificarsi interferenze tra gli aggiornamenti.
Un threading.Lock permette di proteggere le operazioni composte e garantire che ciascuna venga eseguita in modo coerente rispetto alle altre.
Quando un dizionario non è la struttura dati migliore
I dizionari sono molto versatili, ma non rappresentano sempre la scelta più adatta. La struttura da utilizzare dipende dal tipo di problema e dalle operazioni che devono essere eseguite con maggiore frequenza.
- Conservare elementi ordinati per posizione: Lista (
list) — Accesso tramite indice e sequenza ordinata - Rappresentare dati immutabili: Tupla (
tuple) — Struttura non modificabile - Conservare elementi unici senza associazioni: Set (
set) — Eliminazione dei duplicati e verifiche di appartenenza - Elaborare dati tabellari: Pandas DataFrame — Filtri, aggregazioni e trasformazioni vettoriali
- Gestire elementi secondo l’ordine di utilizzo: OrderedDict — Spostamento e rimozione degli elementi in base all’accesso
- Contare frequenze: Counter — Conteggio automatico delle occorrenze
La scelta non dipende soltanto dalla comodità della sintassi. È necessario considerare la natura dei dati, le operazioni richieste, la complessità computazionale e le modalità con cui la struttura verrà utilizzata nel programma.
Per esempio, un dizionario è particolarmente efficace quando occorre recuperare rapidamente un valore attraverso una chiave, mentre una lista è più naturale quando l’ordine posizionale degli elementi rappresenta una caratteristica fondamentale dei dati.
Quando si lavora con grandi quantità di dati tabellari, invece, un DataFrame di Pandas può risultare molto più adatto, perché mette a disposizione operazioni di selezione, aggregazione e trasformazione progettate specificamente per questo tipo di elaborazione.
Sintesi: quale strumento utilizzare?
I sei esercizi hanno mostrato come un dizionario possa essere impiegato in situazioni molto differenti, dalla gestione di semplici contatori fino alla realizzazione di strutture più complesse.
La tabella seguente riassume le proprietà incontrate e gli esercizi nei quali sono state utilizzate.
get()— Recupero con valore predefinito — Esercizi 1, 4items()evalues()— Iterazione e aggregazione — Esercizi 1, 2, 3max()conkey— Selezione basata su criteri multipli — Esercizi 2, 4- Tuple come chiavi di ordinamento — Gestione dei pareggi — Esercizio 2
setdefault()— Creazione di gruppi — Esercizi 3, 4defaultdict— Accumulo e raggruppamento — Esercizio 4Decimal— Calcoli monetari — Esercizio 4deepcopy()— Copia di strutture annidate — Esercizio 5- Ricorsione — Merge di configurazioni — Esercizio 5
OrderedDict— Gestione di una cache LRU — Esercizio 6
Una regola pratica da ricordare: un dizionario è la struttura più naturale quando occorre associare una chiave a un valore e recuperare rapidamente quest’ultimo. Quando si devono accumulare dati, Counter e defaultdict possono semplificare il codice; quando si lavora con strutture annidate, è necessario prestare attenzione ai riferimenti condivisi e alla mutabilità; quando l’ordine di utilizzo degli elementi diventa parte del problema, OrderedDict offre operazioni specifiche che rendono l’implementazione più leggibile.
La conoscenza delle strutture dati non consiste soltanto nel ricordare i metodi disponibili, ma nel riconoscere quale struttura risponde meglio alle esigenze di un problema concreto. È questa capacità che permette di scrivere programmi più chiari, efficienti e semplici da mantenere.




