Tecniche algoritmiche per dev Java
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.
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 APIeOptional. 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()etoString(). 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:
- Attraversamento sequenziale della collezione
- Accumulazione di uno stato
- 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 unDoubleStreamprimitivo, evitando il boxing/ unboxing diDouble→double. 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/allMatchinterrompono 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
nullcome valore di ritorno. Restituirenullda un metodo di ricerca è una fonte comune diNullPointerException. Usa sempreOptional<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()vscollect(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, usacollect(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()vscomparing(): Per campi di tipodouble, usa sempreComparator.comparingDouble().comparing()usa il boxing aDouble, 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
CompletableFuturein 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(nonLinkedList) - 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