Tecniche algoritmiche per dev Java

Riassunto IA

L'articolo analizza l'elaborazione di collezioni Java 17+ confrontando approcci imperativi e dichiarativi tramite Stream API per operazioni comuni di aggregazione. Vengono fornite analisi di complessità computazionale e best practice, enfatizzando l'uso dei record per il modeling dei dati e l'ottimizzazione tramite tipi primitivi.

Immagine generata con IA
Immagine generata con IA

Introduzione

L'obiettivo di questa guida non è una trattazione accademica, ma offrire strumenti pratici per risolvere problemi ricorrenti quando si lavora con collezioni di dati.

Versione di riferimento: Java 17 LTS (o superiore). Gli esempi sfruttano feature moderne come record, var, Stream API e Optional. Alcuni snippet sono compatibili anche con Java 11+, dove indicato.

Gli esempi si concentrano sull'uso delle strutture dati della libreria standard: List, Set e Map.

Per ogni tecnica sono presentati:

  • il problema da risolvere
  • la complessità computazionale (notazione Big-O)
  • un'implementazione imperativa
  • un'alternativa funzionale con Stream API
  • note sui casi limite e le best practice

La classe Product usata negli esempi

Per mantenere gli esempi concisi e moderni, utilizziamo un Java Record — introdotto in Java 16 e stabile da Java 17. I record sono ideali per modellare dati immutabili (Value Object, DTO).

public record Product(
    String codice,
    String nome,
    double prezzo,
    boolean disponibile
) {}

Perché un record? Rispetto a una classe tradizionale, il compilatore genera automaticamente costruttore, getter, equals(), hashCode() e toString(). Perfetto per oggetti di sola lettura come i prodotti di un catalogo.


Iterazione e Aggregazione

Molte operazioni software richiedono di attraversare una collezione per produrre un risultato aggregato: una somma, una media, un conteggio o una verifica logica.

Queste operazioni condividono lo stesso schema:

  1. Attraversamento sequenziale della collezione
  2. Accumulazione di uno stato
  3. Restituzione di un risultato

La complessità è generalmente O(n), dove n è il numero di elementi.

Somma di una collezione

Problema

Calcolare la somma di una lista di valori numerici.

Implementazione imperativa

public static double calcolaTotale(List<Double> importi) {
    Objects.requireNonNull(importi, "La lista degli importi non può essere null");

    double totale = 0.0;
    for (double importo : importi) {
        totale += importo;
    }
    return totale;
}

Complessità: O(n)

Versione con Stream API

public static double calcolaTotale(List<Double> importi) {
    Objects.requireNonNull(importi, "La lista degli importi non può essere null");

    return importi.stream()
            .mapToDouble(Double::doubleValue)
            .sum();
}

Nota: mapToDouble() restituisce un DoubleStream primitivo, evitando il boxing/ unboxing di Doubledouble. Preferirlo sempre per operazioni numeriche intensive.

Calcolo della media

Problema

Calcolare la media dei valori presenti in una lista.

Implementazione imperativa

public static double calcolaMedia(List<Double> valori) {
    Objects.requireNonNull(valori, "La lista non può essere null");
    if (valori.isEmpty()) {
        throw new IllegalArgumentException("La lista non può essere vuota");
    }

    double somma = 0.0;
    for (double valore : valori) {
        somma += valore;
    }
    return somma / valori.size();
}

Versione con Stream API

public static double calcolaMedia(List<Double> valori) {
    Objects.requireNonNull(valori, "La lista non può essere null");

    return valori.stream()
            .mapToDouble(Double::doubleValue)
            .average()
            .orElseThrow(() -> new IllegalArgumentException("Lista vuota"));
}

Conteggio condizionale

Problema

Contare quanti elementi soddisfano una certa condizione. Esempio: quanti importi superano una soglia.

Implementazione imperativa

public static long contaImportiMaggioriDi(List<Double> importi, double soglia) {
    Objects.requireNonNull(importi, "La lista non può essere null");

    long conteggio = 0;
    for (double importo : importi) {
        if (importo > soglia) {
            conteggio++;
        }
    }
    return conteggio;
}

Versione con Stream API

public static long contaImportiMaggioriDi(List<Double> importi, double soglia) {
    return importi.stream()
            .filter(i -> i > soglia)
            .count();
}

Verifiche logiche su collezioni

Esiste almeno un elemento sotto soglia? (anyMatch)

// Imperativa
public boolean esisteImportoSottoSoglia(List<Double> importi, double soglia) {
    for (double importo : importi) {
        if (importo < soglia) return true;
    }
    return false;
}

// Stream API
public boolean esisteImportoSottoSoglia(List<Double> importi, double soglia) {
    return importi.stream().anyMatch(importo -> importo < soglia);
}

Tutti gli elementi sono sopra soglia? (allMatch)

// Imperativa
public boolean tuttiSopraSoglia(List<Double> importi, double soglia) {
    for (double importo : importi) {
        if (importo <= soglia) return false;
    }
    return true;
}

// Stream API
public boolean tuttiSopraSoglia(List<Double> importi, double soglia) {
    return importi.stream().allMatch(importo -> importo > soglia);
}

Short-circuit evaluation: Sia la versione imperativa che anyMatch/allMatch interrompono l'iterazione non appena la condizione è soddisfatta (o falsificata). Su liste grandi questo può fare una differenza significativa di performance.

Ricerca e Filtraggio

La ricerca di elementi in una collezione è tra le operazioni più frequenti. La scelta dell'algoritmo e della struttura dati ha un impatto diretto sulle prestazioni.

Ricerca lineare

Problema

Trovare un elemento in una lista tramite un campo chiave.

Implementazione imperativa (con Optional)

public static Optional<Product> trovaPerCodice(List<Product> prodotti, String codice) {
    Objects.requireNonNull(codice, "Il codice non può essere null");

    for (Product prodotto : prodotti) {
        if (prodotto.codice().equals(codice)) {
            return Optional.of(prodotto);
        }
    }
    return Optional.empty();
}

Evita null come valore di ritorno. Restituire null da un metodo di ricerca è una fonte comune di NullPointerException. Usa sempre Optional<T> per segnalare esplicitamente la possibile assenza di un risultato.

Complessità: O(n)

Versione con Stream API

public static Optional<Product> trovaPerCodice(List<Product> prodotti, String codice) {
    return prodotti.stream()
            .filter(p -> p.codice().equals(codice))
            .findFirst();
}

Filtraggio multiplo

Problema

Estrarre tutti i prodotti che soddisfano più condizioni contemporaneamente.

Implementazione imperativa

public static List<Product> filtraProdotti(List<Product> prodotti, double prezzoMinimo) {
    List<Product> risultato = new ArrayList<>();

    for (Product prodotto : prodotti) {
        if (prodotto.prezzo() > prezzoMinimo && prodotto.disponibile()) {
            risultato.add(prodotto);
        }
    }
    return risultato;
}

Versione con Stream API

public static List<Product> filtraProdotti(List<Product> prodotti, double prezzoMinimo) {
    return prodotti.stream()
            .filter(p -> p.prezzo() > prezzoMinimo)
            .filter(Product::disponibile)
            .toList(); // Restituisce una lista immutabile (Java 16+)
}

toList() vs collect(Collectors.toList()): Da Java 16, Stream.toList() è il metodo preferito. Restituisce una lista immutabile, il che rende il codice più sicuro. Se hai bisogno di una lista mutabile, usa collect(Collectors.toList()).

Ricerca efficiente con Map

Quando si effettuano ricerche frequenti per chiave univoca, indicizzare i dati in una Map è molto più efficiente di una ricerca lineare su List.

// Costruzione dell'indice (eseguita una sola volta)
Map<String, Product> prodottiPerCodice = new HashMap<>();
for (Product p : prodotti) {
    prodottiPerCodice.put(p.codice(), p);
}

// Con Stream API (più compatto)
Map<String, Product> prodottiPerCodice = prodotti.stream()
        .collect(Collectors.toMap(Product::codice, p -> p));

// Ricerca O(1)
Optional<Product> trovato = Optional.ofNullable(prodottiPerCodice.get(codice));
Approccio Complessità ricerca Note
List + loop O(n) Semplice, nessun pre-processing
HashMap O(1) media Richiede indicizzazione iniziale
TreeMap O(log n) Mantiene l'ordine delle chiavi

Selezione e Ordinamento

Ordinare una collezione consente di confrontare, visualizzare e cercare dati in modo prevedibile. Java utilizza TimSort — un algoritmo ibrido (MergeSort + InsertionSort) ottimizzato per dati parzialmente ordinati, molto comune in scenari reali.

Complessità: O(n log n) nel caso medio e peggiore.

Ordinamento crescente con Comparable

Se la classe implementa Comparable<T>, è possibile ordinare direttamente:

public record Product(String codice, String nome, double prezzo, boolean disponibile)
        implements Comparable<Product> {

    @Override
    public int compareTo(Product altro) {
        return Double.compare(this.prezzo, altro.prezzo);
    }
}

// Utilizzo
prodotti.sort(null); // usa l'ordinamento naturale definito da compareTo

Ordinamento con Comparator (approccio preferito)

Comparator è più flessibile perché non richiede di modificare la classe e permette criteri multipli di ordinamento.

// Per prezzo crescente
prodotti.sort(Comparator.comparingDouble(Product::prezzo));

// Per prezzo decrescente
prodotti.sort(Comparator.comparingDouble(Product::prezzo).reversed());

// Ordinamento multi-livello: prima per disponibilità, poi per prezzo crescente
prodotti.sort(
    Comparator.comparing(Product::disponibile).reversed()
              .thenComparingDouble(Product::prezzo)
);

comparingDouble() vs comparing(): Per campi di tipo double, usa sempre Comparator.comparingDouble(). comparing() usa il boxing a Double, introducendo overhead non necessario in collezioni grandi.

Gestione dei Duplicati

Rilevare e gestire duplicati è un problema comune, ad esempio nella validazione di input o nel processamento di batch di dati.

Verificare la presenza di duplicati

public boolean contieneDuplicati(List<Integer> valori) {
    Set<Integer> valoriUnici = new HashSet<>();

    for (Integer valore : valori) {
        if (!valoriUnici.add(valore)) {
            return true; // add() restituisce false se l'elemento era già presente
        }
    }
    return false;
}

Complessità: O(n) — ogni add() su HashSet è O(1) in media.

Rimuovere i duplicati da una lista

// Metodo 1: tramite Set (non preserva l'ordine)
List<String> senzaDuplicati = new ArrayList<>(new HashSet<>(listaConDuplicati));

// Metodo 2: tramite LinkedHashSet (preserva l'ordine di inserimento)
List<String> senzaDuplicatiOrdinati = new ArrayList<>(
        new LinkedHashSet<>(listaConDuplicati)
);

// Metodo 3: Stream API
List<String> senzaDuplicatiStream = listaConDuplicati.stream()
        .distinct()
        .toList();

Deduplicazione per campo specifico

Spesso si vogliono rimuovere oggetti "duplicati" in base a un campo, non per uguaglianza totale. Un pattern comune usa Collectors.toMap():

// Mantiene il primo prodotto trovato per ogni codice
List<Product> prodottiUnici = new ArrayList<>(
    prodotti.stream()
            .collect(Collectors.toMap(
                Product::codice,
                p -> p,
                (esistente, nuovo) -> esistente // in caso di chiave duplicata, mantieni il primo
            ))
            .values()
);

Trasformazione dei Dati (Mapping)

Trasformare una collezione di oggetti in un'altra è un'operazione fondamentale, spesso chiamata mapping. Con la Stream API, questo processo diventa conciso ed espressivo.

Trasformazione semplice: estrarre un campo

// Imperativa
public static List<String> estraiNomi(List<Product> prodotti) {
    List<String> nomi = new ArrayList<>();
    for (Product prodotto : prodotti) {
        nomi.add(prodotto.nome());
    }
    return nomi;
}

// Stream API
public static List<String> estraiNomi(List<Product> prodotti) {
    return prodotti.stream()
            .map(Product::nome)
            .toList();
}

Collectors avanzati

I Collector di Java permettono aggregazioni sofisticate in una sola passata sulla collezione. Sono uno strumento essenziale per chi lavora con pipeline di dati.

Raggruppamento (groupingBy)

Raggruppa i prodotti per disponibilità:

Map<Boolean, List<Product>> perDisponibilita = prodotti.stream()
        .collect(Collectors.groupingBy(Product::disponibile));

List<Product> disponibili   = perDisponibilita.get(true);
List<Product> nonDisponibili = perDisponibilita.get(false);

Partizione (partitioningBy)

Versione specializzata di groupingBy per predicati booleani — più efficiente e semanticamente più chiara:

Map<Boolean, List<Product>> partizione = prodotti.stream()
        .collect(Collectors.partitioningBy(p -> p.prezzo() > 100.0));

Costruzione di una Map (toMap)

// Map<codice, nomeProdotto>
Map<String, String> codiceToNome = prodotti.stream()
        .collect(Collectors.toMap(Product::codice, Product::nome));

Conteggio per gruppo (groupingBy + counting)

// Quanti prodotti per fascia di prezzo? (esempio semplificato)
Map<String, Long> conteggioPerFascia = prodotti.stream()
        .collect(Collectors.groupingBy(
            p -> p.prezzo() > 100.0 ? "premium" : "standard",
            Collectors.counting()
        ));

Parallel Streams: quando usarli (e quando no)

La Stream API supporta l'elaborazione parallela tramite parallelStream(). È una funzionalità potente, ma va usata con cautela.

// Esempio di parallel stream per somma su lista molto grande
double totale = importiMoltoGrandi.parallelStream()
        .mapToDouble(Double::doubleValue)
        .sum();

Quando conviene:

  • Collezioni con milioni di elementi
  • Operazioni CPU-intensive e stateless (senza effetti collaterali)

Quando non conviene:

  • Collezioni piccole (l'overhead di gestione dei thread supera il guadagno)
  • Operazioni con side-effect (es. scrittura su variabili condivise)
  • Operazioni di I/O (usa CompletableFuture in questi casi)

Regola pratica: Misura sempre con un benchmark reale (es. JMH) prima di passare a parallelStream(). In molti casi la versione sequenziale è più veloce.

Scelta delle Strutture Dati

La struttura dati corretta può fare la differenza tra un'applicazione performante e una che degrada sotto carico. La tabella seguente riassume le complessità principali.

Struttura Accesso Ricerca Inserimento (coda) Inserimento (posizione) Note
ArrayList O(1) O(n) O(1) ammortizzato O(n) Preferita per uso generale
LinkedList O(n) O(n) O(1) O(1) * *Con riferimento diretto al nodo
HashSet O(1) med. O(1) med. No duplicati, no ordine
LinkedHashSet O(1) med. O(1) med. No duplicati, ordine inserimento
TreeSet O(log n) O(log n) No duplicati, ordinato
HashMap O(1) med. O(1) med. Chiave → valore, no ordine
LinkedHashMap O(1) med. O(1) med. Ordine di inserimento
TreeMap O(log n) O(log n) Ordinato per chiave

Linee guida pratiche

  • Hai bisogno di accesso casuale per indice?ArrayList
  • Inserisci/rimuovi spesso in testa o in coda?ArrayDeque (non LinkedList)
  • Devi verificare l'appartenenza di un elemento?HashSet
  • Devi iterare in ordine di inserimento?LinkedHashSet / LinkedHashMap
  • Devi mantenere un ordinamento naturale?TreeSet / TreeMap
  • Ricerchi frequentemente per chiave univoca?HashMap

Commenti