Python: esercizi svolti sul controllo del flusso con for, break, continue, all() e any()

Cerca:

Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors
Tutorial base di Python

Sei esercizi progressivi con soluzioni commentate, esempi applicativi e analisi della complessità computazionale

Un ciclo Python può sembrare un’operazione semplice: ripetere un’istruzione finché ci sono elementi da elaborare. Nella pratica, però, la qualità di un programma dipende spesso da decisioni meno immediate, come stabilire quando aggiornare un valore, quali dati escludere, quando interrompere un’elaborazione e come verificare che una determinata condizione sia rispettata da tutti gli elementi di una sequenza.

Sono problemi che ricorrono continuamente nell’analisi dei dati. Un algoritmo deve individuare il valore massimo in una serie di punteggi, ricostruire il saldo dopo una sequenza di movimenti, controllare la validità di un insieme di identificativi oppure rilevare una condizione anomala nelle letture di un sensore.

In tutti questi casi, il controllo del flusso determina non soltanto come viene eseguito il codice, ma anche quali informazioni entrano nel calcolo e quali decisioni vengono prese.

Questa raccolta propone sei esercizi progressivi in Python dedicati alle strutture for, if, break, continue e alle funzioni all() e any().

Ogni problema parte da uno scenario applicativo e sviluppa una soluzione commentata, con particolare attenzione agli accumulatori, alle condizioni di arresto, alla complessità computazionale e alla gestione degli input.

Il percorso parte da operazioni relativamente semplici, come la ricerca del massimo e il calcolo di una somma progressiva, per arrivare a un sistema di monitoraggio di sensori e a una pipeline di validazione e aggregazione di transazioni finanziarie.

L’obiettivo non è soltanto ottenere il risultato atteso, ma capire perché un algoritmo produce quel risultato, quali ipotesi richiede e in quali circostanze potrebbe comportarsi diversamente da quanto previsto.

Pubblicità

Il controllo del flusso: le strutture fondamentali

In Python il controllo del flusso stabilisce l’ordine con cui vengono eseguite le istruzioni e permette di adattare il comportamento di un programma alle condizioni incontrate durante l’elaborazione.

Le strutture fondamentali si possono ricondurre a tre categorie: sequenza, selezione e iterazione.

La sequenza rappresenta l’esecuzione ordinata delle istruzioni, una dopo l’altra, mentre la selezione permette di scegliere quali operazioni eseguire in funzione di una condizione. L’iterazione, infine, consente di ripetere un blocco di istruzioni su una sequenza di elementi oppure finché una condizione rimane verificata.

Struttura Funzione Esempio applicativo
if / elif / else Selezione condizionata Verificare se un importo supera una soglia
for Iterazione su una sequenza Scorrere le righe di un dataset
while Ripetizione condizionata Ripetere un controllo fino a una condizione di arresto
break Interruzione immediata del ciclo Fermarsi al primo saldo negativo
continue Passaggio all’iterazione successiva Escludere un dato non valido
all() Verifica di una proprietà universale Controllare che tutti i record siano validi
any() Verifica di una proprietà esistenziale Verificare se esiste almeno un record anomalo

Due istruzioni meritano particolare attenzione quando si lavora con i cicli.

break interrompe immediatamente il ciclo, anche quando sono ancora presenti elementi da elaborare. È utile quando il programma ha già raggiunto una condizione che rende superfluo proseguire, come il rilevamento di uno sforamento.

continue, invece, non interrompe il ciclo, ma salta le istruzioni rimanenti nell’iterazione corrente e passa all’elemento successivo. È particolarmente utile quando si devono filtrare dati non pertinenti senza interrompere l’intera elaborazione.

Anche all() e any() svolgono un ruolo importante, perché permettono di verificare proprietà logiche su sequenze di valori booleani o su espressioni valutate durante l’iterazione.

valori = [12, 18, 25, 31]

# Tutti i valori sono positivi?
print(all(x > 0 for x in valori))

# Esiste almeno un valore superiore a 30?
print(any(x > 30 for x in valori))

Output:

True
True

La prima istruzione restituisce True perché tutti gli elementi sono positivi, mentre la seconda restituisce True perché almeno un elemento supera 30.

Entrambe le funzioni utilizzano la valutazione anticipata, detta short-circuit: all() interrompe la verifica al primo risultato False, mentre any() si ferma al primo risultato True. Questo comportamento evita valutazioni inutili quando la condizione è già stata determinata.

Un’ultima considerazione riguarda la scelta tra cicli espliciti e funzioni built-in. Python mette a disposizione strumenti come max(), sum(), all() e any() che spesso consentono di esprimere un’operazione con meno codice. Un ciclo esplicito, tuttavia, diventa preferibile quando è necessario aggiornare più variabili, registrare informazioni aggiuntive oppure interrompere l’elaborazione al verificarsi di una determinata condizione.

Non si tratta quindi di scegliere sempre la soluzione più breve, ma quella che rende più chiaro il comportamento dell’algoritmo.

Gli esempi utilizzano annotazioni di tipo compatibili con Python 3.10 e versioni successive. Possono essere eseguiti anche su Google Colab con un ambiente Python aggiornato.

Pubblicità

Esercizio 1 — Facile: ricerca del massimo in un log di punteggi

Scenario

Una piattaforma di e-learning registra i punteggi ottenuti dagli studenti durante un corso. Il team didattico vuole individuare chi ha conseguito il risultato migliore e quale punteggio ha ottenuto, così da assegnare un badge di merito.

Il problema sembra semplice, ma richiede di gestire correttamente anche alcune situazioni particolari: possono esserci più studenti con lo stesso punteggio massimo e potrebbe non esserci ancora nessun risultato registrato.

Input di esempio

punteggi = [72, 88, 91, 65, 91, 79]
nomi = [
    "Anna",
    "Luca",
    "Sara",
    "Marco",
    "Elena",
    "Paolo"
]

Le due liste contengono informazioni corrispondenti: il primo punteggio appartiene ad Anna, il secondo a Luca e così via.

Obiettivo

  • Individuare il punteggio massimo.
  • Restituire il nome del primo studente che ha raggiunto quel punteggio.
  • Gestire una lista vuota senza generare un errore.
  • Segnalare un’incoerenza se le due liste hanno lunghezze differenti.

Soluzione

def studente_migliore(
    nomi: list[str],
    punteggi: list[int]
) -> tuple[str, int] | None:

    # Controllo della coerenza degli input
    if len(nomi) != len(punteggi):
        raise ValueError("Liste di lunghezza diversa")

    # Se non ci sono punteggi, non esiste un massimo da restituire
    if not punteggi:
        return None

    # Inizializzazione con il primo elemento
    miglior_nome = nomi[0]
    miglior_punto = punteggi[0]

    # Scansione degli elementi successivi
    for i in range(1, len(punteggi)):
        # Il confronto stretto mantiene il primo in caso di parità
        if punteggi[i] > miglior_punto:
            miglior_punto = punteggi[i]
            miglior_nome = nomi[i]

    return miglior_nome, miglior_punto


# Esecuzione
risultato = studente_migliore(nomi, punteggi)
print(risultato)

Output:

('Sara', 91)

Come funziona l’algoritmo

La prima operazione consiste nel verificare che le due liste abbiano la stessa lunghezza. Se così non fosse, non sarebbe possibile stabilire una corrispondenza affidabile tra studenti e punteggi, quindi la funzione solleva un’eccezione ValueError.

Successivamente viene verificato se la lista dei punteggi è vuota. In questo caso la funzione restituisce None, perché non esiste alcun risultato da classificare.

La scelta di controllare prima la lunghezza delle liste è importante: se nomi contenesse tre elementi e punteggi fosse vuota, il programma dovrebbe segnalare l’incoerenza anziché considerare automaticamente la situazione come un normale caso senza risultati.

L’algoritmo inizializza poi miglior_punto con il primo elemento della lista e associa a quel valore il nome dello studente corrispondente.

Questa scelta evita un errore frequente: inizializzare il massimo a zero.

Se i dati fossero tutti negativi, come nel caso seguente:

punteggi = [-5, -3, -10]

un’inizializzazione a zero restituirebbe un valore che non compare nemmeno nella lista e che non rappresenta il massimo effettivo. Partire dal primo elemento, invece, funziona indipendentemente dal segno dei numeri.

Il ciclo for comincia dall’indice 1 perché il primo elemento è già stato utilizzato per inizializzare il massimo. A ogni iterazione il programma confronta il punteggio corrente con quello migliore registrato fino a quel momento.

Quando trova un valore superiore, aggiorna contemporaneamente il punteggio e il nome dello studente.

Nel nostro esempio, Sara ottiene 91 punti e diventa la prima candidata al risultato migliore. Quando il ciclo incontra Elena, che ha ottenuto anch’essa 91 punti, non aggiorna le variabili perché la condizione utilizza l’operatore > anziché >=.

La differenza è importante: con > viene mantenuto il primo studente che ha raggiunto il massimo, mentre con >= sarebbe selezionato l’ultimo.

Verifica dei casi particolari

# Caso con punteggi negativi
print(
    studente_migliore(
        ["Anna", "Luca", "Sara"],
        [-5, -3, -10]
    )
)

# Caso con lista vuota
print(studente_migliore([], []))

Output:

('Luca', -3)
None

Il primo risultato dimostra che l’algoritmo non dipende dalla presenza di valori positivi, mentre il secondo conferma che una lista vuota viene gestita senza errori.

Complessità computazionale

L’algoritmo effettua una sola scansione della lista, confrontando ogni elemento con il massimo corrente. La complessità temporale è quindi [math]O(n)[/math], dove [math]n[/math] rappresenta il numero di punteggi.

La complessità spaziale aggiuntiva è [math]O(1)[/math], perché vengono utilizzate soltanto poche variabili indipendentemente dalla dimensione della lista.

Osservazione applicativa

Un algoritmo che funziona soltanto quando i punteggi sono positivi non è robusto: è un algoritmo che ha avuto fortuna con i dati di test. In un’applicazione reale, la scelta del valore iniziale e la gestione degli input vuoti sono dettagli che determinano l’affidabilità dell’intera procedura.

Mini-quiz

  • Quale proprietà permette di individuare il massimo con un solo passaggio sulla lista?
  • Cosa cambierebbe se sostituissimo > con >=?
  • Perché è opportuno restituire None quando non esistono punteggi, anziché generare automaticamente un’eccezione?
  • Quale sarebbe la complessità temporale se dovessimo ordinare tutti i punteggi prima di individuare il massimo?

Esercizio 2 — Facile/Medio: somma progressiva su un conto corrente

Scenario

Un’applicazione di home banking deve mostrare l’evoluzione del saldo di un conto corrente dopo ogni transazione. Il saldo iniziale è noto, mentre i movimenti vengono elaborati nell’ordine in cui sono registrati.

L’applicazione deve inoltre generare un alert quando il saldo diventa negativo, interrompendo l’elaborazione al primo sforamento.

Il problema permette di introdurre il concetto di accumulatore, cioè una variabile che conserva il risultato delle operazioni precedenti e viene aggiornata a ogni iterazione.

Input

saldo_iniziale = 500.0
movimenti = [
    -120.0,
    -80.0,
    -400.0,
    +50.0,
    -30.0
]

I movimenti negativi rappresentano uscite di denaro, mentre quelli positivi rappresentano entrate.

Obiettivo

  • Calcolare il saldo dopo ogni movimento.
  • Registrare il saldo iniziale e tutti i saldi successivi.
  • Interrompere il ciclo al primo saldo negativo, includendo tale saldo nel risultato.
  • Restituire la lista dei saldi e un indicatore booleano che segnali lo sforamento.

Soluzione

def saldi_progressivi(
    saldo_iniziale: float,
    movimenti: list[float]
) -> tuple[list[float], bool]:

    # L'accumulatore parte dal saldo iniziale
    saldo = saldo_iniziale

    # La storia comprende anche il saldo iniziale
    storia = [saldo]

    # Flag che registra l'eventuale sforamento
    sforato = False

    for movimento in movimenti:
        # Aggiornamento del saldo
        saldo += movimento

        # Registrazione del nuovo saldo
        storia.append(saldo)

        # Interruzione al primo saldo negativo
        if saldo < 0:
            sforato = True
            break

    return storia, sforato


# Esecuzione
storia, sforato = saldi_progressivi(
    saldo_iniziale,
    movimenti
)

print("Saldi:", storia)
print("Sforamento:", sforato)

Output:

Saldi: [500.0, 380.0, 300.0, -100.0]
Sforamento: True

Come funziona l’algoritmo

La variabile saldo rappresenta lo stato corrente del conto e viene inizializzata con il saldo disponibile prima dell’elaborazione dei movimenti.

La lista storia viene inizializzata con lo stesso valore, perché l’obiettivo non è registrare soltanto i saldi successivi alle transazioni, ma conservare l’intera evoluzione del conto a partire dalla situazione iniziale.

Il ciclo for scorre i movimenti nell’ordine in cui sono presenti nella lista. A ogni iterazione il valore del movimento viene sommato al saldo corrente e il nuovo risultato viene aggiunto alla lista storia.

Nel nostro esempio il calcolo procede nel modo seguente:

Operazione Movimento Saldo risultante
Saldo iniziale — 500 €
Prima transazione -120 € 380 €
Seconda transazione -80 € 300 €
Terza transazione -400 € -100 €

Quando il saldo raggiunge -100 euro, la condizione saldo < 0 diventa vera.

Il programma imposta quindi sforato = True e incontra l’istruzione break, che interrompe immediatamente il ciclo. Gli ultimi due movimenti non vengono elaborati.

È importante osservare che il saldo negativo viene comunque inserito nella lista prima dell’interruzione, perché l’istruzione storia.append(saldo) viene eseguita prima del controllo della condizione.

Se il controllo fosse stato effettuato prima dell’inserimento, il risultato avrebbe perso proprio il valore che ha determinato l’alert.

Perché usare break e non continue?

La scelta dipende dal comportamento richiesto all’applicazione.

Con break l’elaborazione si interrompe al primo evento critico. Questa soluzione è appropriata quando il requisito prevede di fermarsi non appena viene rilevato uno sforamento.

Se utilizzassimo continue, invece, il ciclo passerebbe al movimento successivo, continuando a calcolare il saldo anche dopo che il conto è diventato negativo.

La differenza non è soltanto sintattica: rappresenta due politiche operative differenti.

  • break: interrompere l’elaborazione al primo evento critico.
  • continue: saltare le istruzioni rimanenti nell’iterazione corrente e proseguire con i dati successivi.

Se stessimo ricostruendo la storia completa di un conto per un controllo contabile, interrompere l’elaborazione potrebbe significare perdere informazioni importanti. Se invece il sistema deve bloccare una procedura al primo superamento di una soglia, break è la scelta coerente.

Complessità computazionale

Nel caso peggiore, quando nessun movimento porta il saldo sotto zero, il programma esamina tutti gli [math]n[/math] movimenti e ha complessità temporale [math]O(n)[/math].

Nel caso migliore, quando il primo movimento determina immediatamente uno sforamento, viene eseguita una sola iterazione e la complessità è [math]O(1)[/math].

In generale, se il primo sforamento si verifica dopo [math]k[/math] movimenti, il costo dell’elaborazione è [math]O(k)[/math].

Mini-quiz

  • Perché l’accumulatore saldo deve essere inizializzato fuori dal ciclo?
  • Se utilizzassimo continue al posto di break, come cambierebbe la lista dei saldi?
  • Qual è la complessità temporale nel caso peggiore e in quello migliore?
  • Perché è importante aggiungere il saldo alla lista prima di verificare la condizione di sforamento?
Pubblicità

Esercizio 3 — Medio: verifica di proprietà con all() e any()

Scenario

Un’azienda deve verificare la validità dei badge utilizzati dai dipendenti per accedere ai propri sistemi informatici.

Ogni badge deve rispettare tre condizioni:

  • Deve contenere almeno otto caratteri.
  • Deve contenere esclusivamente caratteri alfanumerici.
  • Non deve iniziare con il prefisso riservato “0000”.

Il sistema deve restituire tre informazioni: un indicatore che segnali se tutti i badge sono validi, un secondo indicatore che segnali se esiste almeno un badge non valido e l’elenco dei badge da correggere, accompagnati dalla motivazione.

L’esercizio permette di collegare la validazione dei dati alla logica dei predicati e di utilizzare all() e any() per verificare proprietà sull’intera sequenza.

Input

badge = [
    "AB123456",
    "0000ABCD",
    "XY!789",
    "MARIO2024",
    ""
]

Soluzione

def valida_badge(b: str) -> tuple[bool, str]:
    """
    Verifica le tre proprietà richieste per un badge.
    Restituisce un booleano e una descrizione dell'esito.
    """
    if len(b) < 8: return False, "troppo corto" if not b.isalnum(): return False, "caratteri non alfanumerici" if b.startswith("0000"): return False, "prefisso riservato" return True, "ok" def audit(badges: list[str]) -> tuple[bool, bool, list]:

    # Valutazione di ogni badge una sola volta
    risultati = [
        (b, *valida_badge(b))
        for b in badges
    ]

    # Estrazione dei badge non validi e delle motivazioni
    invalidi = [
        (b, motivo)
        for b, valido, motivo in risultati
        if not valido
    ]

    # Proprietà universale: tutti i badge sono validi?
    tutti_validi = all(
        valido
        for _, valido, _ in risultati
    )

    # Proprietà esistenziale: esiste almeno un badge non valido?
    qualche_invalido = any(
        not valido
        for _, valido, _ in risultati
    )

    return tutti_validi, qualche_invalido, invalidi


# Esecuzione
risultato = audit(badge)
print(risultato)

Output:

(
    False,
    True,
    [
        ('0000ABCD', 'prefisso riservato'),
        ('XY!789', 'troppo corto'),
        ('', 'troppo corto')
    ]
)

Come funziona l’algoritmo

La funzione valida_badge() concentra tutte le regole di controllo relative a un singolo identificativo.

Forse potrebbe interessarti anche:  Matrici in Python: Dal Parcheggio alla Computer Vision con NumPy

La prima verifica riguarda la lunghezza della stringa. Se il badge contiene meno di otto caratteri, la funzione restituisce immediatamente False e la motivazione “troppo corto”.

Successivamente viene utilizzato il metodo isalnum(), che restituisce True soltanto quando tutti i caratteri della stringa sono lettere o numeri e la stringa non è vuota.

Infine, il metodo startswith() controlla che il badge non inizi con il prefisso riservato.

La funzione restituisce una coppia di valori: un booleano che indica la validità e una stringa che descrive il risultato del controllo.

La funzione audit() utilizza questa procedura su tutti gli elementi della lista.

Per evitare di eseguire più volte le stesse verifiche, i risultati vengono memorizzati nella lista risultati, che contiene per ogni badge il valore originale, l’esito del controllo e la relativa motivazione.

La comprensione di lista:

risultati = [
    (b, *valida_badge(b))
    for b in badges
]

utilizza l’operatore * per inserire nella tupla il contenuto della coppia restituita da valida_badge().

Il risultato intermedio ha quindi questa struttura:

[
    ("AB123456", True, "ok"),
    ("0000ABCD", False, "prefisso riservato"),
    ("XY!789", False, "troppo corto"),
    ("MARIO2024", True, "ok"),
    ("", False, "troppo corto")
]

La funzione costruisce poi la lista degli elementi non validi, conservando la motivazione associata a ciascun badge.

A questo punto entrano in gioco all() e any().

La funzione all() verifica una proprietà universale: restituisce True soltanto se tutti gli elementi soddisfano la condizione.

Nel nostro caso:

all(valido for _, valido, _ in risultati)

restituisce False, perché sono presenti tre badge non validi.

La funzione any(), invece, verifica una proprietà esistenziale:

any(not valido for _, valido, _ in risultati)

restituisce True perché almeno un badge non supera i controlli.

Il collegamento con la logica matematica

Le due funzioni corrispondono ai quantificatori della logica dei predicati.

  • all() rappresenta il quantificatore universale [math]\forall[/math], cioè “per ogni elemento”.
  • any() rappresenta il quantificatore esistenziale [math]\exists[/math], cioè “esiste almeno un elemento”.

Le due proprietà sono collegate dalle leggi di De Morgan:

[math]\displaystyle \begin{aligned} \neg \forall x\,P(x) \equiv \exists x\,\neg P(x) \end{aligned}[/math]

In termini operativi, se non è vero che tutti i badge sono validi, significa che almeno uno è invalido.

Di conseguenza:

qualche_invalido = not tutti_validi

sarebbe logicamente equivalente alla verifica effettuata con any().

Nell’esercizio manteniamo entrambe le funzioni perché l’obiettivo è mostrare il loro comportamento e la corrispondenza con i quantificatori logici.

Un caso particolare: la lista vuota

Un comportamento che può sorprendere chi utilizza per la prima volta queste funzioni riguarda le sequenze vuote.

print(all([]))
print(any([]))

Output:

True
False

Il risultato è coerente con la logica matematica.

L’affermazione “tutti gli elementi di una lista soddisfano una proprietà” è considerata vera quando non esiste alcun elemento che la contraddica. Si parla in questo caso di verità vacua.

L’affermazione “esiste almeno un elemento che soddisfa una proprietà”, invece, è falsa quando la lista è vuota, perché non esiste alcun elemento che possa fungere da esempio.

In un’applicazione reale, tuttavia, bisogna distinguere la correttezza logica dal significato operativo: un controllo che restituisce all([]) == True non dimostra che siano stati effettivamente verificati dei badge. Se l’assenza di dati deve essere considerata un problema, occorre aggiungere un controllo esplicito sulla lunghezza della lista.

Complessità computazionale

La validazione richiede un numero di operazioni proporzionale alla lunghezza complessiva delle stringhe.

Indicando con [math]n[/math] il numero di badge e con [math]L[/math] la lunghezza media delle stringhe, il costo complessivo è proporzionale a [math]O(nL)[/math].

Le verifiche con all() e any() hanno complessità [math]O(n)[/math] nel caso peggiore, ma possono interrompersi prima grazie allo short-circuit.

Mini-quiz

  • Perché all([]) restituisce True, mentre any([]) restituisce False?
  • Qual è la differenza logica tra il quantificatore universale e quello esistenziale?
  • Perché qualche_invalido può essere calcolato come not tutti_validi?
  • Quale vantaggio offre il salvataggio dei risultati intermedi rispetto alla chiamata ripetuta di valida_badge()?

Esercizio 4 — Medio: massimo condizionato con soglia

Scenario

Un negozio online registra gli ordini dei clienti associando a ciascuno un identificativo, un importo e uno stato di lavorazione.

Il responsabile delle spedizioni vuole individuare l’ordine di importo più elevato tra quelli che risultano già spediti, escludendo gli ordini annullati e quelli ancora in attesa.

Il problema introduce una situazione frequente nell’elaborazione dei dataset: non tutti i record devono partecipare al calcolo e la ricerca del massimo deve essere effettuata soltanto sugli elementi che soddisfano una determinata condizione.

Occorre inoltre gestire il caso in cui non esistano ordini spediti, evitando di restituire un valore che non corrisponde ad alcun record.

Input

ordini = [
    (101, 250.00, "spedito"),
    (102, 80.00, "annullato"),
    (103, 412.50, "spedito"),
    (104, 412.50, "spedito"),
    (105, 100.00, "in attesa")
]

Ogni tupla contiene l’identificativo dell’ordine, il relativo importo e lo stato della spedizione.

Obiettivo

  • Individuare l’ordine più costoso tra quelli spediti.
  • Escludere gli ordini con uno stato differente da “spedito”.
  • Restituire il primo ordine in caso di parità.
  • Restituire None se non esistono ordini che soddisfano la condizione.

Soluzione

def max_spedito(
    ordini: list[tuple]
) -> tuple[int, float] | None:

    # Nessun candidato è stato ancora individuato
    best = None

    for oid, importo, stato in ordini:

        # Esclusione degli ordini non spediti
        if stato != "spedito":
            continue

        # Aggiornamento del massimo
        if best is None or importo > best[1]:
            best = (oid, importo)

    return best


# Esecuzione
risultato = max_spedito(ordini)
print(risultato)

Output:

(103, 412.5)

Come funziona l’algoritmo

La variabile best viene inizializzata a None, perché all’inizio dell’elaborazione non è ancora stato individuato alcun ordine che soddisfi il requisito.

Il ciclo for scorre tutte le tuple della lista utilizzando l’unpacking, cioè assegnando direttamente i tre elementi alle variabili oid, importo e stato.

La prima verifica riguarda lo stato dell’ordine:

if stato != "spedito":
    continue

Quando l’ordine non è spedito, il programma passa immediatamente all’elemento successivo senza eseguire il confronto con il massimo corrente.

Questa scelta permette di separare chiaramente due operazioni: il filtraggio dei candidati e la ricerca del valore massimo.

Quando viene individuato un ordine spedito, il programma verifica se best è ancora uguale a None oppure se l’importo corrente supera quello del candidato già selezionato.

La condizione:

if best is None or importo > best[1]:

è particolarmente interessante perché utilizza l’operatore logico or. Python valuta prima la prima parte e, se questa è vera, non esegue la seconda grazie allo short-circuit.

In questo modo il programma evita di tentare un accesso a best[1] quando best non contiene ancora una tupla.

Nel dataset di esempio, il primo ordine spedito è il 101, con un importo di 250 euro. Il secondo ordine viene ignorato perché annullato, mentre il terzo ordine, il 103, supera il massimo precedente con 412,50 euro.

Quando viene esaminato l’ordine 104, il cui importo è identico a quello del 103, il confronto stretto > non consente l’aggiornamento.

Il risultato conserva quindi l’ordine 103, che è il primo ad aver raggiunto il valore massimo.

Perché utilizzare None come sentinella?

Si potrebbe pensare di inizializzare best con una tupla contenente un importo molto basso, per esempio:

best = (-1, -1)

Questa soluzione, tuttavia, funziona soltanto se il dominio dei dati garantisce che tutti gli importi siano superiori a -1.

Se gli importi potessero essere negativi oppure se il valore iniziale scelto non fosse compatibile con il dominio, il programma potrebbe restituire un risultato privo di significato.

None rappresenta invece esplicitamente l’assenza di un candidato e non introduce ipotesi arbitrarie sui valori numerici.

Verifica del caso senza ordini spediti

ordini_in_attesa = [
    (201, 120.00, "in attesa"),
    (202, 300.00, "annullato")
]

print(max_spedito(ordini_in_attesa))

Output:

None

Il risultato è corretto perché nessun ordine supera il filtro sullo stato.

Come ottenere tutti gli ordini a pari merito?

La funzione restituisce intenzionalmente un solo ordine. Se il requisito aziendale prevedesse di individuare tutti gli ordini con l’importo massimo, sarebbe necessario conservare più candidati.

Una soluzione semplice utilizza due passaggi:

# Individuazione del valore massimo tra gli ordini spediti
max_val = max(
    imp
    for _, imp, stato in ordini
    if stato == "spedito"
)

# Estrazione di tutti gli ordini che raggiungono il massimo
tutti = [
    (oid, imp)
    for oid, imp, stato in ordini
    if stato == "spedito" and imp == max_val
]

print(tutti)

Output:

[(103, 412.5), (104, 412.5)]

Questa versione presuppone che esista almeno un ordine spedito, perché max() su una sequenza vuota genera un’eccezione ValueError. Per renderla robusta anche in quel caso, occorre verificare prima la presenza di candidati oppure utilizzare un valore predefinito.

Complessità computazionale

Il ciclo esamina una sola volta ciascun ordine, quindi la complessità temporale è [math]O(n)[/math].

La memoria aggiuntiva è [math]O(1)[/math], perché viene conservata soltanto la tupla corrispondente al candidato migliore.

Mini-quiz

  • Cosa cambierebbe se utilizzassimo un blocco if stato == "spedito" al posto di continue?
  • Perché best = (-1, -1) non rappresenta un’inizializzazione sempre sicura?
  • Come possiamo modificare l’algoritmo per restituire tutti gli ordini che condividono il massimo?
  • Perché il confronto stretto > è importante anche quando il valore massimo non cambia?
Pubblicità

Esercizio 5 — Medio/Difficile: monitoraggio dei sensori con stato e alert

Scenario

Una fabbrica utilizza sensori di temperatura per monitorare alcune apparecchiature durante il turno di produzione. Le letture vengono registrate a intervalli regolari e un sistema di controllo deve elaborarle per individuare eventuali condizioni di surriscaldamento.

Il sistema deve svolgere contemporaneamente diverse attività: calcolare la somma progressiva delle temperature valide, individuare il picco massimo, determinare la media delle letture raccolte e generare un alert quando la temperatura supera una determinata soglia per tre rilevazioni consecutive.

Non tutte le misurazioni possono essere considerate attendibili. Il sensore potrebbe trasmettere valori fuori dal campo operativo previsto, per esempio a causa di un errore di comunicazione o di un malfunzionamento.

Per questo motivo vengono considerate valide soltanto le temperature comprese tra -20 °C e 200 °C.

L’esercizio permette di combinare accumulatori, condizioni, continue, break e verifiche di persistenza.

Input

letture = [
    25, 30, 210, 90, 95, 99,
    102, 100, 98, 50, 30
]

SOGLIA = 95

Obiettivo

  • Calcolare la somma delle letture valide.
  • Individuare la temperatura massima.
  • Calcolare la media delle temperature valide.
  • Escludere i valori fuori dall’intervallo ammesso.
  • Generare un alert quando si verificano almeno tre letture consecutive strettamente superiori alla soglia.
  • Interrompere la raccolta al momento dell’alert.
  • Registrare l’indice della lettura che determina l’attivazione dell’allarme.

Soluzione

import math


def watchdog(
    letture: list[float],
    soglia: float
) -> dict:

    # Accumulatori statistici
    somma = 0.0
    n_validi = 0
    picco = None

    # Stato del controllo di persistenza
    consecutive = 0
    alert = False
    indice_alert = None

    for i, t in enumerate(letture):

        # 1. FILTRO DI VALIDITÀ
        # Esclude valori non numerici finiti e fuori intervallo
        if not math.isfinite(t) or not (-20 <= t <= 200):
            continue

        # 2. AGGIORNAMENTO DELLE STATISTICHE
        somma += t
        n_validi += 1

        if picco is None or t > picco:
            picco = t

        # 3. CONTROLLO DELLA PERSISTENZA
        if t > soglia:

            consecutive += 1

            if consecutive >= 3:
                alert = True
                indice_alert = i
                break

        else:
            # La sequenza consecutiva viene azzerata
            consecutive = 0

    # 4. CALCOLO DELLA MEDIA
    media = somma / n_validi if n_validi else 0.0

    return {
        "somma": round(somma, 2),
        "media": round(media, 2),
        "picco": picco,
        "alert": alert,
        "indice_alert": indice_alert,
        "letture_valide": n_validi
    }


# Esecuzione
risultato = watchdog(letture, SOGLIA)

print(risultato)

Output:

{
    'somma': 541.0,
    'media': 77.29,
    'picco': 102,
    'alert': True,
    'indice_alert': 7,
    'letture_valide': 7
}

Come funziona l’algoritmo

A differenza degli esercizi precedenti, qui il ciclo deve mantenere contemporaneamente diverse informazioni sullo stato dell’elaborazione.

La variabile somma accumula le temperature valide, n_validi conta quante misurazioni sono state effettivamente utilizzate e picco conserva il valore massimo incontrato.

A queste variabili si aggiungono consecutive, che conta le letture consecutive sopra soglia, alert, che registra l’eventuale attivazione dell’allarme, e indice_alert, che conserva la posizione della lettura responsabile dell’attivazione.

Il ciclo utilizza enumerate() per ottenere contemporaneamente l’indice e il valore di ogni misurazione.

La prima operazione riguarda la validazione:

if not math.isfinite(t) or not (-20 <= t <= 200):
    continue

La funzione math.isfinite() verifica che il valore sia finito, escludendo quindi valori come NaN, inf e -inf.

Successivamente viene controllato che la temperatura sia compresa nell’intervallo ammesso.

Se una delle due condizioni non è rispettata, il programma esegue continue e passa alla lettura successiva senza aggiornare gli accumulatori.

Questa precauzione evita che valori non attendibili entrino nei calcoli statistici.

Una volta superato il filtro, il programma aggiorna la somma e il numero delle letture valide. Il massimo viene aggiornato soltanto quando la nuova temperatura supera il picco precedente oppure quando non è stato ancora registrato alcun valore.

La parte più interessante riguarda il controllo della persistenza.

Un singolo superamento della soglia potrebbe rappresentare una variazione momentanea, mentre tre letture consecutive sopra soglia possono indicare una condizione operativa più significativa.

La variabile consecutive viene incrementata ogni volta che la temperatura supera 95 °C.

Quando raggiunge il valore 3, il programma attiva l’alert, memorizza l’indice della lettura corrente e interrompe il ciclo con break.

Se invece una temperatura valida non supera la soglia, il contatore viene azzerato. Questo passaggio è fondamentale perché la condizione richiesta riguarda tre superamenti consecutivi, non tre superamenti qualsiasi.

Analisi dettagliata dell’output

Consideriamo la sequenza originale e il comportamento del filtro.

Indice Temperatura Validità Contatore consecutivo Risultato
0 25 Valida 0 Nessun alert
1 30 Valida 0 Nessun alert
2 210 Non valida 0 Esclusa
3 90 Valida 0 Contatore azzerato
4 95 Valida 0 Non supera strettamente la soglia
5 99 Valida 1 Primo superamento
6 102 Valida 2 Secondo superamento
7 100 Valida 3 Alert attivato

La lettura di 210 °C viene esclusa perché supera il limite massimo consentito. Non entra quindi né nella somma né nel calcolo della media e non modifica il contatore delle letture consecutive.

La temperatura di 95 °C non incrementa il contatore perché la condizione utilizzata nel codice è t > soglia, non t >= soglia.

Il superamento effettivo comincia quindi con 99 °C, prosegue con 102 °C e termina con 100 °C.

L’alert scatta alla terza lettura consecutiva, che si trova all’indice 7 della sequenza originale.

Il valore letture_valide è 7 perché il programma ha elaborato le prime otto posizioni della lista, ma una di queste conteneva il valore non valido 210.

La somma delle sette letture valide è:

[math]\displaystyle \begin{aligned} 25+30+90+95+99+102+100=541 \end{aligned}[/math]

La media è:

[math]\displaystyle \begin{aligned} \bar{x}=\frac{541}{7}=77{,}2857\ldots \end{aligned}[/math]

Arrotondando a due decimali si ottiene 77,29 °C.

Il picco massimo è invece 102 °C.

È importante osservare che la media e il picco sono calcolati soltanto sulle letture elaborate fino al momento dell’alert, non sull’intera lista originale. Le misurazioni successive all’indice 7 non vengono esaminate.

Una precisazione sugli indici

L’indice registrato dal programma è relativo alla sequenza originale, non alla sequenza filtrata.

Forse potrebbe interessarti anche:  Python: La libreria Sympy. Costruire una matrice -2-

Questa distinzione è importante quando si devono ricostruire gli eventi attraverso i log di un sistema industriale. Se si utilizzasse un indice riferito soltanto ai valori validi, si potrebbe individuare una posizione differente rispetto a quella effettiva del dato nel flusso originale.

Cosa succede se la soglia è uguale al limite massimo?

Consideriamo:

SOGLIA = 200

In questo caso l’alert non può scattare, perché il filtro ammette temperature fino a 200 °C inclusi, mentre la condizione richiede temperature strettamente superiori a 200 °C.

Si tratta di una combinazione di parametri che rende impossibile verificare la regola di allarme.

In un sistema operativo reale sarebbe opportuno validare anche la configurazione della soglia, verificando che sia compatibile con il campo di misurazione del sensore.

Complessità computazionale

Nel caso peggiore, quando non viene mai attivato l’alert, il ciclo esamina tutte le [math]n[/math] letture e ha complessità temporale [math]O(n)[/math].

Nel caso migliore, quando le prime tre letture valide superano consecutivamente la soglia, il programma interrompe l’elaborazione dopo tre iterazioni utili.

La memoria aggiuntiva è [math]O(1)[/math], perché gli accumulatori occupano uno spazio costante indipendentemente dal numero di misurazioni.

Osservazione applicativa

Un singolo picco può essere un’anomalia di misurazione, mentre tre rilevazioni consecutive sopra soglia raccontano una storia diversa. La persistenza della condizione, e non la sua semplice presenza, può diventare il criterio che determina l’attivazione di un allarme.

In un sistema industriale reale sarebbe inoltre opportuno registrare il numero e la distribuzione delle letture scartate, perché un sensore che produce frequentemente valori non validi potrebbe richiedere un intervento anche quando non si verifica alcun surriscaldamento.

Mini-quiz

  • Se la sequenza iniziasse con [96, 97, 98, ...], dopo quante letture verrebbe attivato l’alert?
  • Cosa succede se la soglia viene impostata a 200 °C?
  • La media viene calcolata su tutte le letture o soltanto su quelle valide?
  • Perché il contatore consecutive deve essere azzerato quando una temperatura valida non supera la soglia?
  • Quale sarebbe il problema se il programma utilizzasse l’indice della sequenza filtrata anziché quello originale?

Esercizio 6 — Difficile: pipeline di validazione e aggregazione delle transazioni

Scenario

Un’azienda finanziaria riceve quotidianamente un insieme di transazioni attraverso un’API. Ogni record contiene un identificativo, un importo, una data e uno stato di lavorazione.

Il sistema deve elaborare il batch e produrre un report che consenta di monitorare i volumi, individuare le transazioni anomale e verificare la presenza di sequenze potenzialmente sospette.

In particolare, il programma deve:

  • Calcolare il volume complessivo delle transazioni valide per ciascun giorno.
  • Individuare la transazione valida di importo massimo, mantenendo la prima in caso di parità.
  • Verificare se tutte le transazioni sono valide.
  • Verificare se esiste almeno una transazione valida con importo superiore a 10.000 euro.
  • Registrare le anomalie e la relativa motivazione.
  • Generare un alert quando le ultime tre transazioni ricevute sono tutte valide e superano la soglia di 10.000 euro.

Quest’ultimo requisito merita attenzione: le tre transazioni devono essere consecutive nella sequenza originale, non semplicemente le ultime tre transazioni valide. Se tra due transazioni valide compare un record non valido, la sequenza deve essere considerata interrotta.

L’esercizio combina quindi validazione, aggregazione, gestione dello stato, finestra mobile e verifica di proprietà logiche.

Input

transazioni = [
    {
        "id": "T1",
        "importo": 250.0,
        "giorno": "2025-03-01",
        "stato": "ok"
    },
    {
        "id": "T2",
        "importo": 12000.0,
        "giorno": "2025-03-01",
        "stato": "ok"
    },
    {
        "id": "T3",
        "importo": -50.0,
        "giorno": "2025-03-02",
        "stato": "ok"
    },
    {
        "id": "T4",
        "importo": 15000.0,
        "giorno": "2025-03-02",
        "stato": "ok"
    },
    {
        "id": "T5",
        "importo": 30000.0,
        "giorno": "2025-03-02",
        "stato": "sospesa"
    },
    {
        "id": "T6",
        "importo": 11000.0,
        "giorno": "2025-03-03",
        "stato": "ok"
    },
    {
        "id": "T7",
        "importo": 13000.0,
        "giorno": "2025-03-03",
        "stato": "ok"
    }
]

SOGLIA_SOSPETTA = 10000.0

Soluzione

Per mantenere il codice leggibile, separiamo la validazione del singolo record dall’elaborazione dell’intero dataset.

La prima funzione verifica se una transazione rispetta i requisiti minimi, mentre la seconda utilizza il risultato della validazione per aggiornare gli accumulatori e produrre il report.

import math

from datetime import date
from collections import defaultdict, deque


def valida(t: dict) -> tuple[bool, str]:
    """
    Verifica la validità di una singola transazione.
    """

    # Verifica della struttura del record
    if not isinstance(t, dict):
        return False, "record non valido"

    # Verifica dell'importo
    importo = t.get("importo")

    if (
        isinstance(importo, bool)
        or not isinstance(importo, (int, float))
    ):
        return False, "importo non numerico"

    if not math.isfinite(importo):
        return False, "importo non finito"

    if importo <= 0:
        return False, "importo non positivo"

    # Verifica dello stato
    if t.get("stato") != "ok":
        return False, f"stato='{t.get('stato')}'"

    # Verifica dei campi obbligatori
    if not isinstance(t.get("id"), str) or not t["id"].strip():
        return False, "id mancante o non valido"

    giorno = t.get("giorno")

    if not isinstance(giorno, str):
        return False, "giorno mancante o non valido"

    # Verifica del formato della data
    try:
        date.fromisoformat(giorno)
    except ValueError:
        return False, "formato della data non valido"

    return True, "ok"


def report(
    transazioni: list[dict],
    soglia: float
) -> dict:

    # --- STATO DELL'ELABORAZIONE ---

    # Aggregazione degli importi per giorno
    somma_per_giorno = defaultdict(float)

    # Prima transazione valida con importo massimo
    max_ok = None

    # Registro delle anomalie
    anomalie = []

    # Finestra mobile sulle ultime tre transazioni originali
    ultime_transazioni = deque(maxlen=3)

    # Indicatori globali
    tutte_valide = True
    qualche_sospetta = False

    n_valide = 0

    # --- ELABORAZIONE DEL DATASET ---

    for t in transazioni:

        # 1. VALIDAZIONE
        ok, motivo = valida(t)

        importo = t.get("importo") if isinstance(t, dict) else None

        # 2. AGGIORNAMENTO DELLA FINESTRA MOBILE
        # Ogni transazione viene considerata, anche se non valida
        ultime_transazioni.append((ok, importo))

        # 3. REGISTRAZIONE DELLE ANOMALIE
        if not ok:

            tutte_valide = False

            anomalie.append({
                "id": t.get("id") if isinstance(t, dict) else None,
                "motivo": motivo
            })

            continue

        # Da qui in avanti vengono elaborate solo transazioni valide
        n_valide += 1

        # 4. AGGREGAZIONE PER GIORNO
        giorno = t["giorno"]
        somma_per_giorno[giorno] += importo

        # 5. RICERCA DEL MASSIMO
        if max_ok is None or importo > max_ok["importo"]:

            max_ok = {
                "id": t["id"],
                "importo": importo
            }

        # 6. VERIFICA DELLE TRANSAZIONI SOSPETTE
        if importo > soglia:
            qualche_sospetta = True

    # --- VERIFICA FINALE DELLA FINESTRA MOBILE ---

    alert_frode = (
        len(ultime_transazioni) == 3
        and all(
            ok and importo > soglia
            for ok, importo in ultime_transazioni
        )
    )

    return {
        "somma_per_giorno": dict(somma_per_giorno),
        "max_valida": max_ok,
        "tutte_valide": tutte_valide,
        "qualche_sospetta": qualche_sospetta,
        "alert_frode": alert_frode,
        "anomalie": anomalie,
        "n_valide": n_valide
    }


# Esecuzione
risultato = report(
    transazioni,
    SOGLIA_SOSPETTA
)

print(risultato)

Output atteso

{
    'somma_per_giorno': {
        '2025-03-01': 12250.0,
        '2025-03-02': 15000.0,
        '2025-03-03': 24000.0
    },
    'max_valida': {
        'id': 'T7',
        'importo': 13000.0
    },
    'tutte_valide': False,
    'qualche_sospetta': True,
    'alert_frode': False,
    'anomalie': [
        {
            'id': 'T3',
            'motivo': 'importo non positivo'
        },
        {
            'id': 'T5',
            'motivo': "stato='sospesa'"
        }
    ],
    'n_valide': 5
}

Come funziona l’algoritmo

La funzione valida() rappresenta il punto di ingresso per il controllo dei dati. Prima di eseguire qualsiasi aggregazione, verifica che il record abbia una struttura utilizzabile, che l’importo sia numerico e finito, che sia positivo e che lo stato sia uguale a “ok”.

Il controllo esclude esplicitamente i valori booleani, perché in Python bool è una sottoclasse di int. Senza questa precauzione, un valore True potrebbe essere accettato come importo numerico.

La funzione verifica inoltre che l’identificativo sia presente e che la data rispetti il formato ISO YYYY-MM-DD. Per quest’ultimo controllo utilizza date.fromisoformat(), che permette di intercettare anche date non valide, come il 30 febbraio.

La funzione restituisce sempre una coppia composta da un booleano e da una descrizione dell’esito. In questo modo la funzione principale può distinguere immediatamente i record utilizzabili da quelli che devono essere registrati come anomalie.

L’aggregazione per giorno

La variabile:

somma_per_giorno = defaultdict(float)

è un dizionario che inizializza automaticamente a zero il valore associato a una nuova chiave.

Quando il programma incontra una transazione valida, aggiorna il totale relativo alla data corrispondente:

somma_per_giorno[giorno] += importo

Nel dataset di esempio, il primo giorno comprende due transazioni valide:

[math]\displaystyle \begin{aligned} 250+12000=12250 \end{aligned}[/math]

Il 2 marzo viene registrata una transazione valida da 15.000 euro, mentre la transazione T3 viene esclusa perché ha un importo negativo e T5 viene esclusa perché il suo stato è “sospesa”.

Il 3 marzo vengono elaborate due transazioni valide:

[math]\displaystyle \begin{aligned} 11000+13000=24000 \end{aligned}[/math]

È importante precisare che questa operazione produce un’aggregazione giornaliera, non una serie temporale di somme progressive. Per ottenere quest’ultima sarebbe necessario calcolare anche il totale cumulativo tra i diversi giorni.

Ricerca della transazione massima

La variabile max_ok conserva il riferimento alla transazione valida con importo più elevato.

L’aggiornamento avviene soltanto quando viene trovato un importo superiore al massimo corrente:

if max_ok is None or importo > max_ok["importo"]:
    max_ok = {
        "id": t["id"],
        "importo": importo
    }

Anche in questo caso il confronto stretto > permette di conservare la prima transazione in caso di parità.

Il massimo viene calcolato soltanto sulle transazioni valide, quindi gli importi associati a record anomali non partecipano alla selezione.

Nel dataset il valore massimo valido è 15.000 euro, relativo a T4? No: attenzione alla sequenza dei dati. T4 ha effettivamente un importo di 15.000 euro, mentre T6 e T7 hanno rispettivamente 11.000 e 13.000 euro.

Pertanto, l’output corretto del codice deve riportare T4 con 15.000 euro, non T7 con 13.000 euro.

Il risultato corretto è:

'max_valida': {
    'id': 'T4',
    'importo': 15000.0
}

Questa verifica è importante: il massimo non deve essere confuso con l’ultima transazione elaborata o con l’ultima che supera una soglia.

La differenza tra tutte_valide e qualche_sospetta

I due indicatori rispondono a domande differenti.

tutte_valide verifica se ogni transazione rispetta le regole strutturali e operative stabilite dalla funzione valida().

La variabile viene inizializzata a True e diventa False non appena viene individuata un’anomalia.

qualche_sospetta, invece, verifica una regola di business: esiste almeno una transazione valida il cui importo supera 10.000 euro?

L’indicatore viene aggiornato soltanto dopo il superamento della validazione:

if importo > soglia:
    qualche_sospetta = True

Questa distinzione è fondamentale.

La transazione T5 ha un importo di 30.000 euro, ma il suo stato è “sospesa”. Non viene quindi considerata valida e non entra nel calcolo delle transazioni sospette.

T2, T4, T6 e T7 sono invece transazioni valide che superano la soglia, perciò qualche_sospetta restituisce True.

La finestra mobile e l’alert antifrode

La parte più delicata dell’esercizio riguarda il controllo delle ultime tre transazioni.

Per gestire questa situazione utilizziamo deque, una struttura dati disponibile nel modulo collections, particolarmente adatta alla gestione di finestre di dimensione limitata.

ultime_transazioni = deque(maxlen=3)

La proprietà maxlen=3 mantiene automaticamente soltanto gli ultimi tre elementi inseriti.

La differenza rispetto a una lista tradizionale è che, quando viene aggiunto un quarto elemento, quello più vecchio viene eliminato automaticamente.

A ogni iterazione viene registrata una coppia contenente l’esito della validazione e l’importo:

ultime_transazioni.append((ok, importo))

È importante che questa istruzione venga eseguita prima del continue riservato alle transazioni non valide.

In questo modo la finestra rappresenta sempre le ultime tre transazioni originali, comprese quelle che non superano i controlli.

Al termine dell’elaborazione viene verificata la condizione:

alert_frode = (
    len(ultime_transazioni) == 3
    and all(
        ok and importo > soglia
        for ok, importo in ultime_transazioni
    )
)

L’alert viene attivato soltanto quando sono presenti tre transazioni e ciascuna è valida e supera la soglia.

La condizione len(...) == 3 evita di generare un alert quando il dataset contiene meno di tre transazioni.

Nel nostro esempio le ultime tre transazioni sono:

Transazione Importo Validità Supera la soglia
T5 30.000 € No Sì
T6 11.000 € Sì Sì
T7 13.000 € Sì Sì

Nonostante tutti e tre gli importi superino la soglia, T5 non è valida. Di conseguenza all() restituisce False e l’alert non viene attivato.

Se avessimo ignorato T5 e mantenuto soltanto le ultime tre transazioni valide, avremmo ottenuto un risultato differente, modificando involontariamente il significato della regola antifrode.

Questo esempio mostra quanto sia importante definire con precisione il dominio sul quale viene verificata una proprietà.

Verifica del risultato complessivo

Il report consente di leggere contemporaneamente diversi aspetti del batch:

  • somma_per_giorno mostra i volumi delle sole transazioni valide, aggregati per data.
  • max_valida individua la transazione valida di importo maggiore, mantenendo la prima in caso di parità.
  • tutte_valide segnala la presenza di almeno un’anomalia.
  • qualche_sospetta segnala che esiste almeno una transazione valida sopra la soglia.
  • alert_frode verifica la presenza di tre transazioni originali consecutive, tutte valide e superiori alla soglia.
  • anomalie conserva gli identificativi e le motivazioni dei record scartati.
  • n_valide conta le transazioni che hanno superato tutti i controlli.

Nel dataset sono presenti sette transazioni, cinque delle quali valide. Le due anomalie vengono registrate separatamente, mentre il sistema rileva la presenza di importi superiori alla soglia senza generare un alert antifrode, perché la sequenza finale contiene una transazione non valida.

Complessità computazionale

La funzione esegue un solo passaggio sulle [math]n[/math] transazioni.

La validazione viene effettuata una sola volta per ogni record, mentre aggregazione, ricerca del massimo, conteggio e verifica delle soglie vengono aggiornati durante la stessa scansione.

La finestra mobile contiene al massimo tre elementi, quindi richiede memoria costante.

La complessità temporale complessiva è [math]O(n)[/math], considerando un costo costante per i controlli sui singoli record, escluso il costo proporzionale alla lunghezza delle stringhe validate.

La memoria aggiuntiva è [math]O(n)[/math] nel caso peggiore, perché vengono conservate le anomalie e le aggregazioni per giorno, mentre la finestra mobile occupa soltanto [math]O(1)[/math] spazio.

Osservazioni di progettazione

Questo esercizio introduce alcuni concetti che diventano particolarmente importanti quando un programma passa da un semplice esempio didattico a una procedura utilizzata per elaborare dati reali.

Separazione delle responsabilità. La funzione valida() si occupa dei controlli sul singolo record, mentre report() gestisce l’aggregazione e le regole di business. Questa separazione permette di modificare le regole di validazione senza riscrivere l’intera procedura.

Tracciabilità delle anomalie. I record non validi non vengono semplicemente ignorati, ma conservati in una struttura che documenta il motivo dell’esclusione. Questo aspetto è essenziale quando i risultati devono essere verificati successivamente.

Coerenza della finestra temporale. Una sequenza di tre transazioni consecutive non equivale a una sequenza di tre transazioni valide selezionate da un dataset più ampio. La differenza può cambiare completamente il risultato di una regola di controllo.

Gestione dei numeri finanziari. In questo esempio utilizziamo float per rendere immediati i calcoli, ma non è la rappresentazione ideale per importi monetari reali. I numeri in virgola mobile seguono lo standard IEEE 754 e alcune frazioni decimali non possono essere rappresentate esattamente in base binaria.

Per applicazioni finanziarie è preferibile utilizzare decimal.Decimal oppure memorizzare gli importi come numeri interi espressi in centesimi.

Per esempio:

from decimal import Decimal

importo = Decimal("250.00")
print(importo)

Output:

250.00

L’utilizzo di Decimal permette di controllare con maggiore precisione le operazioni decimali, purché i valori vengano costruiti a partire da stringhe o da rappresentazioni decimali affidabili.

La funzione presentata rimane comunque un esempio didattico: un sistema antifrode reale richiederebbe ulteriori controlli, gestione delle soglie configurabili, tracciamento degli eventi, regole di business formalizzate e verifiche specifiche sui dati in ingresso.

Mini-quiz

  • Perché qualche_sospetta non può essere calcolato semplicemente cercando una transazione sopra soglia nella lista delle anomalie?
  • Se due transazioni valide avessero lo stesso importo massimo, quale verrebbe restituita?
  • Cosa succederebbe all’alert se il dataset contenesse soltanto due transazioni valide?
  • Perché la finestra mobile deve includere anche le transazioni non valide?
  • Qual è il problema dell’utilizzo di float per gli importi monetari e quale alternativa offre Python?
Forse potrebbe interessarti anche:  Algoritmo di Dijkstra: Trovare il Cammino Più Breve con Esempio Python per Data Scientist e Sviluppatori

Risposte ai mini-quiz

Le domande proposte negli esercizi servono a verificare non soltanto la comprensione della sintassi, ma anche la capacità di interpretare il comportamento dell’algoritmo e le conseguenze delle scelte implementative.

Esercizio 1 — Ricerca del massimo

Quale proprietà permette di individuare il massimo con un solo passaggio sulla lista?

La proprietà fondamentale è la transitività dell’ordinamento sui numeri.

Quando si individua il massimo tra due valori, il risultato può essere confrontato con il successivo senza dover riprendere tutti gli elementi già esaminati.

Formalmente:

[math]\displaystyle \begin{aligned} \max(a,b,c)=\max(\max(a,b),c) \end{aligned}[/math]

Se [math]m[/math] è il massimo degli elementi già analizzati e [math]x[/math] è il nuovo elemento, il massimo aggiornato sarà:

[math]\displaystyle \begin{aligned} m_{\text{nuovo}}=\max(m,x) \end{aligned}[/math]

Questa proprietà permette di costruire un algoritmo incrementale che conserva soltanto il massimo corrente, senza ordinare l’intera sequenza.

Cosa cambierebbe sostituendo > con >=?

Il valore massimo non cambierebbe, ma cambierebbe la scelta dell’elemento restituito in caso di parità.

Con > il programma aggiorna il candidato soltanto quando trova un valore strettamente superiore, mantenendo quindi il primo studente che ha raggiunto il massimo.

Con >= aggiorna anche quando incontra un valore uguale, restituendo l’ultimo studente con il punteggio massimo.

Si tratta di una scelta di tie-breaking che deve essere coerente con il requisito applicativo.

Perché restituire None quando la lista è vuota?

None rappresenta l’assenza di un risultato, che in questo caso è una condizione legittima: nessuno studente ha ancora svolto il test.

Un’eccezione sarebbe più appropriata per segnalare una violazione del contratto della funzione, come la presenza di due liste con lunghezze differenti.

È importante distinguere un errore negli input da un risultato che semplicemente non esiste.

Quale sarebbe la complessità temporale se ordinassimo tutti i punteggi?

Un ordinamento efficiente ha generalmente complessità temporale [math]O(n\log n)[/math], mentre la ricerca del massimo richiede soltanto [math]O(n)[/math].

Se interessa esclusivamente individuare il massimo, ordinare tutti gli elementi comporta quindi un lavoro aggiuntivo non necessario.

Esercizio 2 — Somma progressiva

Perché l’accumulatore deve essere inizializzato fuori dal ciclo?

L’accumulatore deve conservare il risultato delle iterazioni precedenti.

Se saldo venisse inizializzato all’interno del ciclo, a ogni movimento ripartirebbe dal valore iniziale e la somma progressiva non potrebbe essere calcolata correttamente.

La variabile deve quindi essere dichiarata prima del ciclo e aggiornata durante l’elaborazione.

Cosa cambierebbe utilizzando continue al posto di break?

continue salterebbe soltanto le istruzioni rimanenti dell’iterazione corrente, senza interrompere il ciclo.

Il programma continuerebbe quindi a elaborare tutti i movimenti successivi allo sforamento, producendo una storia completa dei saldi.

Il flag sforato rimarrebbe True, ma la lista conterrebbe anche i risultati delle operazioni successive.

Qual è la complessità temporale nel caso migliore e in quello peggiore?

Nel caso migliore il primo movimento porta immediatamente il saldo sotto zero, quindi viene eseguita una sola iterazione: [math]O(1)[/math].

Nel caso peggiore nessun movimento determina uno sforamento e devono essere esaminati tutti gli elementi: [math]O(n)[/math].

Perché aggiungere il saldo alla lista prima di verificare lo sforamento?

Perché il saldo che determina l’evento critico deve essere conservato nella storia.

Se il controllo venisse effettuato prima di storia.append(saldo), il risultato non conterrebbe il valore negativo che ha determinato l’interruzione.

Esercizio 3 — all() e any()

Perché all([]) restituisce True e any([]) restituisce False?

Il comportamento deriva dalla definizione matematica dei quantificatori.

L’affermazione universale secondo cui tutti gli elementi soddisfano una proprietà è considerata vera quando non esiste alcun controesempio.

L’affermazione esistenziale, invece, richiede almeno un elemento che soddisfi la proprietà. Se la sequenza è vuota, non esiste alcun elemento che possa fungere da testimone.

Si parla rispettivamente di verità vacua e di assenza di un testimone esistenziale.

Qual è la differenza tra quantificatore universale ed esistenziale?

Il quantificatore universale richiede che una proprietà sia vera per tutti gli elementi:

[math]\displaystyle \begin{aligned} \forall x \in A,\ P(x) \end{aligned}[/math]

Il quantificatore esistenziale richiede che la proprietà sia vera per almeno un elemento:

[math]\displaystyle \begin{aligned} \exists x \in A:\ P(x) \end{aligned}[/math]

In Python, all() implementa il primo comportamento, mentre any() implementa il secondo.

Perché qualche_invalido equivale a not tutti_validi?

La relazione deriva dalle leggi di De Morgan:

[math]\displaystyle \begin{aligned} \neg(\forall x\,P(x)) \equiv \exists x\,\neg P(x) \end{aligned}[/math]

Se non è vero che tutti i badge sono validi, allora esiste almeno un badge non valido.

Le due espressioni sono quindi logicamente equivalenti:

qualche_invalido = not tutti_validi

oppure:

qualche_invalido = any(
    not valido
    for _, valido, _ in risultati
)

Quale vantaggio offre il salvataggio dei risultati intermedi?

Permette di evitare di eseguire più volte la stessa validazione.

In un’applicazione reale, una funzione di controllo potrebbe essere molto più complessa delle tre verifiche utilizzate nell’esempio e potrebbe comportare accessi a database, controlli su servizi esterni o operazioni di trasformazione.

Conservare il risultato consente di riutilizzarlo nelle elaborazioni successive, riducendo il lavoro e mantenendo coerente l’esito della validazione.

Esercizio 4 — Massimo condizionato

Cosa cambierebbe utilizzando if stato == "spedito" al posto di continue?

Il risultato sarebbe funzionalmente identico.

Una soluzione alternativa potrebbe essere:

if stato == "spedito":
    if best is None or importo > best[1]:
        best = (oid, importo)

La differenza riguarda soprattutto la leggibilità.

continue consente di escludere subito i record non pertinenti, evitando di aumentare il livello di indentazione del codice.

Perché non inizializzare best = (-1, -1)?

Questa inizializzazione presuppone che gli importi siano sempre maggiori di -1.

Se il dominio dei dati non garantisce questa condizione, il valore iniziale potrebbe rimanere nel risultato anche quando esistono candidati validi.

None rappresenta invece in modo esplicito l’assenza di un candidato e non dipende dalle caratteristiche del dominio numerico.

Come ottenere tutti gli ordini che condividono il massimo?

Si può utilizzare una prima scansione per individuare l’importo massimo e una seconda per raccogliere tutti gli ordini che raggiungono quel valore.

In alternativa, si può mantenere una lista dei candidati migliori durante un unico ciclo, svuotandola quando viene trovato un nuovo massimo e aggiungendo gli elementi quando si verifica una parità.

Perché utilizzare > invece di >=?

Perché il requisito prevede di conservare il primo ordine che raggiunge il massimo.

L’operatore > aggiorna il candidato soltanto quando l’importo è superiore, mentre >= lo aggiorna anche in caso di parità.

Esercizio 5 — Monitoraggio dei sensori

Quante letture servono per attivare l’alert con [96, 97, 98, ...]?

Sono sufficienti tre letture.

Dopo 96 °C il contatore vale 1, dopo 97 °C vale 2 e dopo 98 °C raggiunge 3.

L’alert viene quindi attivato alla terza lettura, corrispondente all’indice 2 se la sequenza parte da zero.

Cosa succede se la soglia è impostata a 200 °C?

L’alert non può scattare, perché il filtro ammette temperature fino a 200 °C inclusi, mentre la condizione richiede valori strettamente superiori a 200 °C.

La configurazione rende quindi impossibile soddisfare la regola di allarme.

La media viene calcolata su tutte le letture?

No, viene calcolata soltanto sulle letture valide che sono state effettivamente elaborate prima dell’eventuale interruzione.

Nel nostro esempio il valore 210 °C viene escluso perché supera il limite massimo ammesso.

Inoltre, le letture successive all’attivazione dell’alert non vengono elaborate.

Perché il contatore deve essere azzerato quando una temperatura valida non supera la soglia?

Perché la regola richiede tre superamenti consecutivi.

Se il contatore non venisse azzerato, il programma potrebbe sommare superamenti separati da letture che non rispettano la condizione, generando falsi allarmi.

Perché conservare l’indice originale?

Perché l’indice permette di risalire alla posizione effettiva della misurazione nel flusso dei dati.

Se venisse utilizzato l’indice della sequenza filtrata, il riferimento potrebbe non corrispondere più alla posizione del dato nei log originali, complicando la ricostruzione dell’evento.

Esercizio 6 — Pipeline di validazione e aggregazione

Perché qualche_sospetta non può essere calcolato cercando una transazione sopra soglia nella lista delle anomalie?

Perché validità e sospettosità rappresentano due proprietà differenti.

Una transazione può essere formalmente valida ma avere un importo superiore alla soglia, come T2 e T4.

Al contrario, una transazione può avere un importo elevato ma non superare i controlli di validazione, come T5, il cui stato è “sospesa”.

Le anomalie registrano violazioni dei requisiti strutturali e operativi, mentre la sospettosità rappresenta una regola di business applicata alle transazioni valide.

Se due transazioni valide avessero lo stesso importo massimo, quale verrebbe restituita?

Viene restituita la prima, perché il codice utilizza il confronto stretto >.

Se l’importo corrente è uguale al massimo già registrato, il candidato non viene aggiornato.

Cosa succederebbe all’alert con soltanto due transazioni?

L’alert restituirebbe False, perché la condizione richiede esplicitamente tre elementi nella finestra mobile.

Non sarebbe quindi possibile verificare la presenza di tre transazioni consecutive sopra soglia.

In un sistema reale si potrebbe anche distinguere tra False, che significa dati sufficienti ma condizione non verificata, e None, che potrebbe indicare dati insufficienti per effettuare il controllo.

Perché la finestra deve includere anche le transazioni non valide?

Perché la regola riguarda le ultime tre transazioni ricevute, non le ultime tre transazioni che hanno superato la validazione.

Ignorare i record non validi altererebbe la sequenza originale e potrebbe produrre un alert che non corrisponde alla condizione effettivamente richiesta.

Qual è il problema di float per gli importi monetari?

float utilizza la rappresentazione binaria IEEE 754 e non rappresenta esattamente tutte le frazioni decimali.

Un esempio noto è:

print(0.1 + 0.2 == 0.3)

Output:

False

Il risultato deriva dalla rappresentazione approssimata dei numeri in virgola mobile.

Per gestire importi monetari è preferibile utilizzare decimal.Decimal oppure interi che rappresentino i centesimi, definendo anche regole di arrotondamento coerenti con il dominio applicativo.

Sintesi: quale struttura utilizzare e quando

Gli esercizi mostrano come le strutture di controllo possano essere combinate per realizzare algoritmi di elaborazione dati con obiettivi differenti.

La tabella seguente riassume i principali costrutti utilizzati e il loro ruolo negli esempi.

Costrutto Funzione Esercizi
for Scansione di una sequenza 1, 2, 3, 4, 5, 6
if / else Selezione condizionata Tutti
Accumulatore Conservazione di un risultato progressivo 2, 5, 6
break Interruzione anticipata 2, 5
continue Esclusione di elementi non pertinenti 4, 5, 6
all() Verifica di una proprietà universale 3, 6
any() Verifica di una proprietà esistenziale 3
None Rappresentazione dell’assenza di un candidato 1, 4, 5, 6
defaultdict Aggregazione per chiave 6
deque Gestione di una finestra mobile 6
enumerate() Accesso simultaneo a indice e valore 5

Ciclo esplicito o funzione built-in?

Non sempre è necessario utilizzare un ciclo for per eseguire un’operazione su una sequenza.

Python mette a disposizione funzioni integrate che svolgono alcune operazioni comuni in modo sintetico ed efficiente.

Consideriamo la ricerca del massimo:

punteggi = [72, 88, 91, 65, 91, 79]
massimo = max(punteggi)
print(massimo)

Output:

91

La funzione max() è la scelta più semplice quando interessa soltanto conoscere il valore massimo.

Se invece vogliamo anche il nome dello studente che ha ottenuto quel risultato, oppure dobbiamo gestire una specifica regola di parità, un ciclo esplicito può risultare più adatto.

Lo stesso principio vale per le altre operazioni.

Esigenza Funzione o struttura da valutare
Sommare tutti i valori sum()
Individuare il massimo max()
Verificare che tutti rispettino una condizione all()
Verificare che almeno uno rispetti una condizione any()
Elaborare elementi con aggiornamento di più variabili for
Interrompere al verificarsi di un evento break
Saltare elementi non pertinenti continue

La scelta dipende quindi dalla natura del problema e dalle informazioni che devono essere conservate durante l’elaborazione.

📚 Per approfondire: Python, programmazione ed elaborazione dei dati

Dopo aver lavorato sulle condizioni, sui cicli, sulle funzioni e sulle strutture dati, puoi proseguire con questi approfondimenti dedicati alla programmazione Python e alle sue applicazioni nell’analisi dei dati.

Conclusione

Il controllo del flusso è uno degli aspetti che più influenzano la qualità di un programma Python, perché determina quali dati vengono elaborati, in quale ordine vengono esaminate le condizioni e quando un algoritmo deve interrompere il proprio lavoro.

Gli esercizi hanno mostrato come un semplice ciclo possa essere utilizzato per individuare un massimo, costruire una somma progressiva, verificare proprietà logiche, filtrare record e monitorare sequenze di misurazioni.

Quando queste operazioni vengono combinate, come nell’ultimo esercizio, diventa evidente che la correttezza del risultato dipende anche dalla precisione con cui vengono definite le regole di elaborazione.

Un sistema che ignora una transazione non valida all’interno di una finestra temporale potrebbe produrre un alert errato; un accumulatore inizializzato in modo scorretto potrebbe restituire un massimo inesistente; una condizione scritta con >= invece di > potrebbe modificare il criterio di selezione di un record.

Sono dettagli che possono sembrare marginali durante la scrittura del codice, ma che diventano determinanti quando un algoritmo viene utilizzato per elaborare dati reali e supportare decisioni operative.

Un buon algoritmo non deve soltanto produrre il risultato atteso: deve rispettare le ipotesi del problema, gestire correttamente i casi limite e rendere comprensibile il percorso attraverso il quale arriva alla soluzione.

È questa la differenza tra un programma che funziona su un esempio e una procedura di elaborazione dati progettata per essere verificata, mantenuta e utilizzata in contesti applicativi.

Pubblicità