Laboratorul 6: Colecții, Tipuri de Date Speciale și Utilitare

Obiective

Pe parcursul laboratoarelor și temelor ați folosit structuri de date oferite de API-ul Java. În cadrul acestui laborator le vom aprofunda.

Aspectele urmărite sunt:

  • tipuri Wrapper peste primitive.
  • lucrul cu cele trei tipuri principale de colecții din Java: List, Set, Queue, Map.
  • cunoașterea diferențelor dintre implementările colecțiilor (eficiență, sortare, ordonare etc).
  • compararea elementelor unor colecții.
  • contractul equals-hashCode.

Aspectele bonus urmărite sunt:

  • utilitare din biblioteca Math.
  • lucrul cu date și ore.
  • folosirea tipurilor BigInteger și BigDecimal.

  • În acest laborator există mai multe secțiuni marcate [Optional]. Aceste secțiuni cuprind informații bonus care vă pot fi prezentate în timpul laboratorului sau pe care le puteți aprofunda în afara acestuia, ele nefiind necesare pentru laboratoarele viitoare sau pentru teme.
  • De asemenea, veți întâlni câteva secțiuni marcate [Nice to know]. Vă recomandăm ca acestea să aibă prioritate în parcurgerea secțiunilor de tip [Optional], deoarece vă pot oferi informații bonus care să fie și foarte probabil utile pentru teme sau laboratoare viitoare.

🌯 Wrappers pentru tipuri primitive

În Java există o separare fundamentală între:

  • Tipurile primitive, utilizate pentru operații rapide și eficiente (int, double, boolean, char etc.);
  • Tipurile de clasă (obiecte), care oferă funcționalități suplimentare și fac parte din ierarhia java.lang.Object.

Obiectele de tip wrapper oferă funcționalități suplimentare sub forma unor metode (ex. rotateLeft(), toHex() etc.), însă, spre deosebire de tipurile primitive, acestea sunt gestionate de Garbage Collector, ceea ce poate face execuția mai lentă.

Din aceste motive, Java a ales să păstreze tipurile primitive pentru a evita costurile suplimentare ale obiectelor, mai ales în calculele numerice intensive.

  • Este recomandat să folosiți tipuri primitive ori de câte ori puteți.
  • De asemenea, dacă doriți să evitați folosirea unor valori magice pentru control (ex. numNodes == -1) puteți folosi clasele de tip wrapper care permit și starea null (ex. numNodes == null).

Clase Wrapper

Pentru a permite utilizarea valorilor primitive în contexte ce necesită obiecte (de exemplu, în colecții despre care vom vorbi mai jos), Java oferă clase wrapper dedicate fiecărui tip primitiv.

Tip primitivClasă wrapper corespunzătoare
voidjava.lang.Void
booleanjava.lang.Boolean
charjava.lang.Character
bytejava.lang.Byte
shortjava.lang.Short
intjava.lang.Integer
longjava.lang.Long
floatjava.lang.Float
doublejava.lang.Double

Wrapper-ele tipurilor primitive sunt immutable.

Crearea instanțelor Wrapper

Un obiect wrapper poate fi construit:

  • dintr-o valoare primitivă
  • dintr-un șir de caractere (String) care reprezintă valoarea numerică.
Float pi = new Float(3.14);
Float pi2 = new Float("3.14");

Dacă șirul nu poate fi convertit într-o valoare numerică validă, se afișează o eroare la run-time de tip NumberFormatException.

Conversia între tipuri primitive

Toate clasele numerice (Integer, Double, Float etc.) au la dispoziție metode pentru conversia valorii interne în alte forme primitive:

Double size = new Double(32.76);
double d = size.doubleValue();  // 32.76
float f = size.floatValue();    // 32.76f
long l = size.longValue();      // 32L
int i = size.intValue();        // 32

Aceste metode sunt echivalente cu operațiile de conversie explicită (cast) între tipurile primitive.

Autoboxing și Unboxing

Începând cu Java 5, conversia între tipurile primitive și wrapper se face automat:

  • Autoboxing: conversie automată de la valoare primitivă la obiect wrapper;
  • Unboxing: conversie inversă, de la wrapper la valoare primitivă.
int primitiveValue = 42;
Integer objectValue = primitiveValue; // autoboxing automat de la int → Integer
 
Integer anotherObject = new Integer(99);
int anotherPrimitive = anotherObject; // unboxing automat de la Integer → int
 
Integer a = 10;
Integer b = 20;
int sum = a + b; // a și b sunt unboxed automat, apoi rezultatul e autoboxed dacă e atribuit unui Integer

Compilatorul inserează conversiile în mod implicit, făcând codul mai concis și mai lizibil.

Autoboxing poate genera costuri ascunse, mai ales în bucle mari, din cauza creării frecvente de obiecte.

🧺 Colecții

Colecțiile sunt structuri de date esențiale care permit gruparea, organizarea și manipularea eficientă a obiectelor. În Java, colecțiile oferă o modalitate standardizată de a lucra cu seturi, liste, cozi și mapări, reprezentând una dintre cele mai puternice părți ale limbajului.

De ce avem nevoie de colecții?

La nivel de bază, Java oferă tablouri (arrays), despre care am învățat în laboratoarele trecute că sunt structuri fixe, rapide, dar inflexibile. Ele au o dimensiune fixă și nu pot crește sau micșora dinamic, ceea ce devine o limitare serioasă în aplicațiile reale.

Colecțiile au fost introduse pentru a răspunde acestor limitări, oferind structuri dinamice capabile să se redimensioneze automat și să ofere operații avansate de căutare, filtrare și sortare.

Evoluția colecțiilor în Java

Etapă Soluție oferită Limitări
Java 1.0 Vector și Hashtable Lipsă de tipizare, design inconsistent
Java 1.2 Collections Framework Introduce interfețele Collection și Map
Java 5+ Generics, autoboxing, unboxing Colecții tipizate și mai sigure

Colecțiile moderne permit:

  • Creștere și micșorare dinamică a dimensiunii
  • Ordine și unicități controlabile
  • Abstractizarea relațiilor dintre obiecte (prin tipuri diferite de colecții)
  • Eficiență în căutare și sortare datorită algoritmilor integrați

Structura Frameworkului

Frameworkul de colecții se bazează pe două ierarhii paralele, definite în pachetul java.util:

Ierarhie Interfață rădăcină Ce reprezintă Exemple
Colecții de elemente Collection Grup de obiecte independente List, Set, Queue
Mapări cheie–valoare Map Asocieri între chei unice și valori HashMap, TreeMap

Deși Map face parte din framework, nu extinde Collection deoarece logica sa (chei și valori separate) diferă conceptual de cea a colecțiilor simple.

Diferențe conceptuale între colecții

Fiecare colecție are un scop diferit:

  • List — păstrează ordinea de inserare și permite duplicate.
  • Set — elimină duplicatele, accent pe unicitate.
  • Queue— gestionează elemente în ordinea procesării (FIFO sau LIFO).
  • Map — asociază chei unice cu valori, oferind acces rapid după cheie.

Alegerea colecției potrivite depinde de cerințele aplicației (viteză, ordonare, unicitate, asociere logică etc.).

Beneficii generale

Prin folosirea ierarhiilor de mai sus, avem următoarele beneficii:

  • Interfețe standard și implementări interschimbabile
  • Cod mai scurt și expresiv
  • Implementări multiple optimizate pentru scopuri diferite
  • Tipizare sigură prin genericitate
  • Integrare nativă cu Stream API

Vom vorbi despre genericitate în laboratoarele următoare.

Interfața Collection

Interfața Collection<E> definește un comportament comun pentru toate colecțiile care gestionează elemente individuale. Ea nu dictează cum sunt stocate datele, ci ce operații sunt posibile.

Metode de bază

Metodă Descriere Observații
boolean add(E e) Adaugă un element Returnează false dacă e duplicat (în cazul Set)
boolean remove(E e) Elimină un element existent Poate arunca UnsupportedOperationException
boolean contains(E e) Verifică dacă elementul există Compară prin equals()
int size() Numărul elementelor
boolean isEmpty() Verifică dacă e goală
Iterator<E> iterator() Permite parcurgerea elementelor Folosit pentru bucle controlate

  • Iteratorul este o modalitate sigură de a parcurge colecțiile fără a expune detalii interne de implementare.
  • Pentru modificări în timpul parcurgerii, este recomandat să folosim iteratorul pentru a evita ConcurrentModificationException.

Alte operații utile

MetodăDescriere
addAll(Collection c)adaugă toate elementele unei alte colecții
removeAll(Collection c)elimină toate elementele dintr-o altă colecție
containsAll(Collection c)verifică dacă toate elementele există

Iteratori

Un iterator este un obiect care permite traversarea unei colecţii şi modificarea acesteia (ex: ştergere de elemente) în mod selectiv. Puteţi obţine un iterator pentru o colecţie, apelând metoda sa iterator(). Interfaţa Iterator este următoarea:

public interface Iterator<E> {
    boolean hasNext();
    E next();
    void remove(); // optional
}

Exemplu de folosire a unui iterator:

Collection<Double> col  = new ArrayList<Double>();
Iterator<Double> it = col.iterator();
 
while (it.hasNext()) {
    Double backup = it.next();
    // apelul it.next() trebuie realizat înainte de apelul it.remove()
    if (backup < 5.0) {
        it.remove();
    }
}

Apelul metodei remove() a unui iterator face posibilă eliminarea elementului din colecţie care a fost întors la ultimul apel al metodei next() din acelaşi iterator.

În exemplul anterior, toate elementele din colecţie mai mici decât 5 for fi şterse la ieşirea din bucla while.

For-each

Această construcţie permite (într-o manieră expeditivă) traversarea unei colecţii.

Collection collection = new ArrayList();
for (Object o : collection)
    System.out.println(o);

Construcţia for-each se bazează, în spate, pe un iterator, pe care îl ascunde. Prin urmare nu putem şterge elemente în timpul iterării.

În această manieră pot fi parcurşi şi vectori oarecare. De exemplu, collection ar fi putut fi definit ca Object[].

Exemplu practic

import java.util.*;
 
public class CollectionExample {
    public static void main(String[] args) {
        // Folosim Collection cu ArrayList ca implementare
        Collection<String> fruits = new ArrayList<>(); // Diamond operator <>
 
        // Adăugăm elemente
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Cherry");
 
        // Verificăm dimensiunea și dacă este goală
        System.out.println("Dimensiune: " + fruits.size());
        System.out.println("Este goală? " + fruits.isEmpty());
 
        // Verificăm dacă un element există
        System.out.println("Conține 'Banana'? " + fruits.contains("Banana"));
 
        // Iterăm prin elemente folosind Iterator
        System.out.println("Elemente în colecție:");
        Iterator<String> it = fruits.iterator();
        while (it.hasNext()) {
            System.out.println(it.next());
        }
 
        // Eliminăm un element
        fruits.remove("Apple");
        System.out.println("Colecție după eliminare: " + fruits);
    }
}

Despre diamond operator <>:

  • este folosit pentru a specifica tipul omogen al colecției (ex. String, Integer etc.).
  • tipul specificat trebuie să fie de tip obiect, deci vom folosi mereu clase Wrapper sau alte obiecte.
  • operatorul trebuie inclus și în stânga și în dreapta, dar este nevoie să specificăm tipul doar în stânga
    Collection<String> fruits = new ArrayList<>(); // corect, specificat doar în stânga
    Collection<String> fruits = new ArrayList<String>(); // corect, dar nepreferat

Vom învăța mai multe despre acesta în laboratorul despre genericitate.

Pe parcursul laboratorului veți vedea notații de tipul <E>, <T>, <K, V>. Vom înțelege în următoarele laboratoare la ce se referă exact, însă pentru acest laborator este suficient să știți că sunt niște notații pentru a semnala că o colecție folosește un tip omogen (ex. Collection<E>Collection poate folosi doar un tip omogen denumit E care poate fi un obiect).

Tipurile principale de colecții

Set

Un Set este o colecție fără duplicate. Elementele sunt comparate folosind metodele equals() și hashCode().

Caracteristici:

  • Nu garantează ordinea elementelor (în HashSet)
  • Permite elemente null (în unele implementări)
  • Implementări comune:
    • HashSet – rapid, dar neordonat
    • LinkedHashSet – păstrează ordinea de inserare
    • TreeSet – menține ordinea naturală sau definită de un Comparator
Implementări
Implementare Structură internă Ordine elemente Permite duplicate? Complexitate adăugare / căutare / ștergere Avantaje Dezavantaje Când se folosește
HashSet Bazat pe HashMap intern (folosește hashing pentru distribuție rapidă a elementelor) Ordine imprevizibilă ❌ Nu Aproximativ O(1) (constantă amortizată) Foarte rapid pentru operații de bază (add, remove, contains) Nu păstrează ordinea inserării; performanța depinde de funcția hashCode() Când performanța este prioritară și ordinea nu contează (ex: filtrarea duplicatelor, verificări rapide de apartenență)
LinkedHashSet Extinde HashSet dar menține o listă dublu înlănțuită a elementelor pentru ordinea inserării Ordinea inserării ❌ Nu Aproximativ O(1) Păstrează ordinea de inserare, performanță foarte bună Ușor mai lent decât HashSet (suplimentar pentru menținerea listei) Când e nevoie de viteză dar și de o ordine previzibilă (ex: cache, istorice, afisări ordonate)
TreeSet Implementat prin TreeMap intern (arbore roșu-negru) Ordine naturală (sau definită de un Comparator) ❌ Nu O(log n) Menține elementele sortate, permite căutări bazate pe ordine (headSet, tailSet, etc.) Mai lent decât HashSet și LinkedHashSet Când ai nevoie ca elementele să fie sortate sau să poți naviga în intervale (ex: rapoarte, topuri, ordonări lexicografice)

Dacă modificați hashCode() sau equals() după inserarea elementului, comportamentul colecției devine imprevizibil. Vom vorbi despre aceste pe larg în secțiunile de mai jos.

Subinterfețe
Subinterfață Descriere Metode specifice Implementări principale
SortedSet Elemente sortate subSet(), headSet(), tailSet() TreeSet, ConcurrentSkipListSet
NavigableSet Căutare a elementelor apropiate higher(), lower(), ceiling(), floor() TreeSet, ConcurrentSkipListSet

ConcurrentSkipListSet oferă o variantă thread-safe, bazată pe o skip list, potrivită pentru medii concurente, dar care depășește scopul acestui laborator.

Exemplu practic
import java.util.*;
 
public class HashSetExample {
    public static void main(String[] args) {
        // HashSet
        Set<String> cities = new HashSet<>();
        cities.add("Bucharest");
        cities.add("Cluj");
        cities.add("Bucharest"); // ignorat (duplicat)
 
        System.out.println(cities); // ordinea nu este garantată
        System.out.println("Conține Cluj? " + cities.contains("Cluj"));
 
        // LinkedHashSet
        Set<Integer> numbers = new LinkedHashSet<>();
        numbers.add(3);
        numbers.add(1);
        numbers.add(2);
 
        System.out.println(numbers); // păstrează ordinea de inserare → [3, 1, 2]
 
        // TreeSet
        Set<String> names = new TreeSet<>();
        names.add("Ana");
        names.add("Ion");
        names.add("Maria");
 
        System.out.println(names); // sortat natural → [Ana, Ion, Maria]
    }
}

List

List este o colecție ordonată ce permite elemente duplicate și acces prin index.

Caracteristici:

  • Elementele sunt stocate în ordinea de inserare.
  • Poți accesa, adăuga sau elimina elemente la poziții specifice.
  • Implementări comune:
    • ArrayList – acces rapid, inserări lente în mijloc
    • LinkedList – inserări rapide, acces lent la elemente
    • Vector – versiune veche, sincronizată (de obicei evitată)
Metode proprii
Metodă Descriere Exemple
void add(int index, E e) Inserează la poziția indicată list.add(2, “Hello”)
E get(int index) Returnează elementul list.get(0)
E set(int index, E e) Înlocuiește un element
void remove(int index) Elimină elementul la index

Pentru parcurgere, se recomandă folosirea buclelor de tip enhanced for sau folosirea Iterator. Evitați modificarea listei în timpul iterării, deoarece poate genera ConcurrentModificationException.

Implementări
Implementare Structură internă Ordine elemente Permite duplicate? Acces prin index Complexitate (add / get / remove) Avantaje Dezavantaje Când se folosește
ArrayList Bazat pe array dinamic (redimensionat automat) Ordinea inserării ✅ Da ✅ Da add → amortizat O(1)
get → O(1)
remove → O(n)
Acces foarte rapid prin index, eficient la citire Inserările/ștergerile în mijloc sunt lente; cost mare la redimensionare Când ai multe citiri și parcurgeri, dar puține inserări în mijloc
LinkedList listă dublu înlănțuită Ordinea inserării ✅ Da ⚠️ Lent (traversare secvențială) add/remove → O(1) la capete
get → O(n)
Inserare/ștergere rapidă oriunde în listă Acces lent la elemente; overhead de memorie mai mare Când ai multe inserări/ștergeri, dar nu acces frecvent prin index

Implementarea Vector nu a fost inclusă, deoarece nu mai este folosită în proiectele moderne.

Exemplu practic
import java.util.*;
 
public class ArrayListExample {
    public static void main(String[] args) {
        // ArrayList
        List<String> fruits = new ArrayList<>();
        fruits.add("Apple");
        fruits.add("Banana");
        fruits.add("Apple"); // duplicatele sunt permise
 
        System.out.println(fruits); // [Apple, Banana, Apple]
        System.out.println("Primul element: " + fruits.get(0));
 
        // LinkedList
        List<String> tasks = new LinkedList<>();
        tasks.add("Wake up");
        tasks.add("Make coffee");
        tasks.addFirst("Stretch");
 
        System.out.println(tasks); // [Stretch, Wake up, Make coffee]
        tasks.removeLast();
        System.out.println(tasks); // [Stretch, Wake up]
    }
}

Queue

Queue modelează o coadă de așteptare. Elementele sunt procesate în ordinea în care sunt adăugate (FIFO).

Caracteristici:

  • Ideală pentru procese asincrone sau bufferizare
  • Implementări:
    • PriorityQueue - permite inserarea într-o coadă păstrând o ordine specifică
    • ArrayDeque - permite inserarea și ștergerea din ambele capete

  • Varianta LIFO (stack) poate fi implementată cu ArrayDeque.
  • Chiar și LinkedList implementează interfața Deque care extinde Queue, deoarece o listă înlănțuită permite inserarea și ștergerea de la ambele capete, similar cu un ArrayDeque.

Metode proprii
Metodă Descriere Comportament la gol
boolean offer(E e) Încearcă să adauge element Returnează `false` dacă nu e loc
E poll() Scoate și returnează elementul din față null dacă e goală
E peek() Returnează elementul din față fără a-l elimina null dacă e goală
Implementări
Implementare Structură internă Ordine elemente Permite duplicate? Acceptă null? Complexitate (add / poll / peek) Avantaje Dezavantaje Când se folosește
LinkedList Listă dublu înlănțuită Ordinea inserării (FIFO) sau LIFO (prin Deque) ✅ Da ✅ Da add → O(1)
poll → O(1)
peek → O(1)
Flexibilă — poate fi folosită și ca List, Queue sau Stack Acces aleator lent (O(n)); overhead de memorie mai mare Când ai nevoie de o coadă simplă, ușor de convertit în listă sau stivă
ArrayDeque Buffer circular dinamic Ordinea inserării (FIFO) sau LIFO ✅ Da ❌ Nu add → amortizat O(1)
poll → O(1)
peek → O(1)
Mai rapidă decât LinkedList; foarte eficientă pentru stive și cozi Nu este thread-safe; nu acceptă null Când vrei o coadă sau stivă performantă într-un singur fir de execuție
PriorityQueue Heap binar (min-heap) ⚠️ Ordine bazată pe prioritate, nu pe inserare ✅ Da ❌ Nu add → O(log n)
poll → O(log n)
peek → O(1)
Menține automat ordinea elementelor prin comparator Nu păstrează ordinea de inserare; nu este thread-safe Când vrei procesarea elementelor în funcție de prioritate

Există mai multe implementări ale interfeței Queue cum ar fi: ArrayBlockingQueue, LinkedBlockingQueue, ConcurrentLinkedQueue, DelayQueue, SynchronousQueue, LinkedTransferQueue, doar că cele mai folosite implementări sunt cele din tabelul de mai sus.

Exemplu practic
import java.util.*;
 
public class Main {
    public static void main(String[] args) {
        // PriorityQueue
        Queue<Integer> pq = new PriorityQueue<>();
        pq.add(5);
        pq.add(1);
        pq.add(3);
 
        while (!pq.isEmpty()) {
            System.out.println(pq.poll()); // scoate în ordine: 1, 3, 5
        }
 
        // ArrayDeque (Stack)
        Deque<String> stack = new ArrayDeque<>();
        stack.push("A");
        stack.push("B");
        stack.push("C");
 
        System.out.println(stack.pop()); // C (comportament de stivă)
        System.out.println(stack);       // [B, A]
    }
}

Interfața Map

Map<K, V> asociază chei unice cu valori. Este baza pentru implementarea tabelelor de asociere, cache-urilor și bazelor de date simple în memorie.

Operații de bază

Metodă Descriere
V put(K key, V value) Adaugă o pereche cheie–valoare
V get(K key) Returnează valoarea asociată cheii
V remove(K key) Elimină o pereche din map
int size() Numărul perechilor

  • Dacă o cheie există deja, put() o suprascrie și returnează vechea valoare.
  • La fel ca HashSet, implementările de tip hash (ex: HashMap, LinkedHashMap) necesită ca obiectele să implementeze metodele equals() și hashCode(). Alte implementări, cum ar fi TreeMap, folosesc ordinea naturală sau un Comparator și nu se bazează pe hashCode().

Metode specifice pentru utilizarea cheilor sau valorilor din Map

Metodă Returnează Conținut
keySet() Set<K> Toate cheile unice
values() Collection<V> Toate valorile (pot fi duplicate)
entrySet() Set<Map.Entry<K,V» Perechi cheie–valoare

Implementări

Implementare Structură internă Ordine elemente Permite duplicate? Acceptă null? Complexitate (put / get / remove) Avantaje Dezavantaje Când se folosește
HashMap Table hash cu liste înlănțuite/bucket-uri sau red-black tree după coliziuni Nicio ordine garantată ❌ Nu (cheile sunt unice) ✅ Chei și valori put → O(1) amortizat
get → O(1)
remove → O(1)
Foarte rapidă; implementare standard Nu păstrează ordinea de inserare Când ai nevoie de un map rapid, fără grijă pentru ordine
LinkedHashMap HashMap + listă dublu înlănțuită pentru ordinea inserării Ordinea inserării sau LRU dacă este activată ❌ Nu ✅ Chei și valori put → O(1) amortizat
get → O(1)
remove → O(1)
Păstrează ordinea inserării sau accesului; utilă pentru cache LRU Ușor mai lentă și mai multă memorie decât HashMap Când ai nevoie de map rapid, dar ordonat după inserare sau acces
TreeMap Red-Black Tree (arbore echilibrat) Ordine naturală sau Comparator ❌ Nu ❌ Chei
✅ Valori
put → O(log n)
get → O(log n)
remove → O(log n)
Ordine sortată; suportă subseturi și navigare Mai lentă decât HashMap; nu permite chei null Când ai nevoie de un map sortat sau de intervale de chei

TreeMap implementează interfețele SortedMap și NavigableMap, ceea ce îi permite să mențină cheile într-o ordine sortată și să ofere metode pentru navigarea în arbore (de exemplu: subMap(), headMap(), tailMap(), higherKey(), lowerKey()).

Exemplu practic

import java.util.*;
 
public class MapExample {
    public static void main(String[] args) {
        // HashMap
        Map<String, Integer> map = new HashMap<>();
 
        map.put("Apple", 10);
        map.put("Banana", 20);
        map.put("Cherry", 30);
        map.put("Apple", 40); // suprascrie valoarea pentru cheie "Apple"
 
        System.out.println("HashMap: " + map);
 
        // Iterare
        for (Map.Entry<String, Integer> entry : map.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }
        // Output HashMap (aleator pe baza metodei hashCode():
        // Banana -> 20
        // Apple -> 40
        // Cherry -> 30
 
 
        // LinkedHashMap
        LinkedHashMap<String, Integer> linkedMap = new LinkedHashMap<>();
 
        linkedMap.put("Apple", 10);
        linkedMap.put("Banana", 20);
        linkedMap.put("Cherry", 30);
 
        System.out.println("LinkedHashMap: " + linkedMap);
 
        // Iterare
        for (Map.Entry<String, Integer> entry : linkedMap.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }
        // Output LinkedHashMap (ordonat pe baza inserării): 
        // Apple -> 10
        // Banana -> 20
        // Cherry -> 30 
 
 
        // TreeMap
        TreeMap<String, Integer> treeMap = new TreeMap<>();
 
        treeMap.put("Apple", 10);
        treeMap.put("Banana", 20);
        treeMap.put("Cherry", 30);
 
        System.out.println("TreeMap: " + treeMap);
 
        // Iterare
        for (Map.Entry<String, Integer> entry : treeMap.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }
        // Output TreeMap (ordonat lexicografic dupa cheia String): 
        // Apple -> 10, 
        // Banana -> 20
        // Cherry -> 30
 
        // SubMap
        System.out.println("SubMap (A-C): " + treeMap.subMap("Apple", "Cherry"));
        // Output SubMap: SubMap (A-C): {Apple=10, Banana=20}
    }
}

Metoda subMap(“Apple”, “Cherry”) returnează toate cheile între “Apple” (inclusiv) și “Cherry” (exclusiv).

Avantaje ale Collections Framework

  • Standardizare – toate colecțiile urmează același set de reguli
  • Performanță – implementări eficiente bazate pe algoritmi consacrați
  • Tipizare sigură – prin Generics
  • Extensibilitate – poți crea propriile colecții personalizate
  • Compatibilitate cu Stream API – colecțiile se pot procesa funcțional

Nu toate colecțiile pot fi modificate. Unele implementări (ex: cele create prin List.of()) sunt imutabile și aruncă UnsupportedOperationException la add() sau remove().

// Listă imutabilă
List<String> immutableList = List.of("Apple", "Banana", "Cherry");
immutableList.add("Orange"); // eroare
 
// Listă mutabilă
List<String> mutableList = new ArrayList<>(List.of("Apple", "Banana", "Cherry"));
mutableList.add("Orange"); // funcționează

Comparare, sortare și asignarea unui identificator pentru obiecte

Compararea și sortarea obiectelor

Sortarea elementelor simple (ex. String, Integer, Float etc.) într-un ArrayList este directă, dar pentru obiecte complexe, cum ar fi o listă ArrayList<Student> sortată după media anilor, avem nevoie de Comparable sau Comparator.

Comparable Comparator
Logica de sortare În clasa obiectului (sortare naturală) Într-o clasă separată, permițând mai multe strategii
Implementare Clasa implementează Comparable și suprascrie compareTo() O altă clasă (sau internă) implementează Comparator și metoda compare()
Metoda de comparare int compareTo(T o) – returnează negativ, zero sau pozitiv int compare(T o1, T o2) – returnează negativ, zero sau pozitiv
Metoda de sortare Collections.sort(List) Collections.sort(List, Comparator) sau List.sort(Comparator)
Pachet java.lang.Comparable java.util.Comparator
Exemplu Comparable
class Student implements Comparable<Student> {
    private String name;
    private String surname;
 
    public Student(String name, String surname) {
        this.name = name;
        this.surname = surname;
    }
 
    // Ordonare naturală
    @Override
    public int compareTo(Student o) {
        return surname.equals(o.surname) ? name.compareTo(o.name) : surname.compareTo(o.surname);
    }
}
 
public class Main {
    public static void main(String[] args) {
        ArrayList<Student> students = new ArrayList<>();
 
        // Sortare folosind metoda sort() din Collections
        Collections.sort(students);
    }
}
Exemplu Comparator
ArrayList<Integer> numbers = new ArrayList<>(Arrays.asList(5, 1, 3623, 13, 7));
 
// Sortare descrescătoare folosind Comparator anonim
Collections.sort(numbers, new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2 - o1;
    }
});
System.out.println(numbers); // [3623, 13, 7, 5, 1]
 
// Alternativ, folosind metoda sort() din List
numbers.sort(new Comparator<Integer>() {
    @Override
    public int compare(Integer o1, Integer o2) {
        return o2 - o1;
    }
});

  • După cum puteți observa puteți folosi metoda sort() din Collections sau metoda sort() din List pentru a ordona un tip de colecție.
  • Metoda sort() din List apelează metoda de sortare din Collections care folosește un algoritm foarte eficient numit TimSort.

Comparable vs. Comparator
Aspect Comparable Comparator
Când se folosește Când clasa are o ordine „naturală” clară și vrem o singură metodă de sortare implicită Când vrem sortări multiple sau dinamice sau clasa nu poate implementa Comparable
Exemplu Sortarea alfabetică a unui `Student` după `surname` și `name` Sortarea unei liste de `Student` după media anilor, apoi vârstă, apoi nume
Avantaje Simplu, nu necesită clase externe Flexibil, permite definirea mai multor strategii de sortare
Limitări Poate exista o singură metodă de sortare „standard” per clasă Necesită clasă separată sau comparator anonim

Pentru simplitate, putem urma aceste reguli:

  • Dacă există o ordine „naturală” clară → Comparable.
  • Dacă avem nevoie de sortări multiple sau dinamice → Comparator.

Asignarea unui identificator unic pentru un obiect

În Java, metodele hashCode() și equals() sunt fundamentale pentru colecțiile care folosesc hashing, cum ar fi HashSet, HashMap sau LinkedHashMap. Înțelegerea lor corectă este esențială pentru a evita comportamente neașteptate.

Ce face hashCode?

Metoda hashCode() returnează un număr întreg care reprezintă obiectul. Acest număr este folosit de colecțiile bazate pe hash pentru a determina bucket-ul în care va fi plasat obiectul. Astfel, accesul la elemente devine rapid, aproape constant în timp.

Dacă două obiecte sunt considerate egale prin equals(), hashCode-ul lor trebuie să fie identic. Dacă hashCode-ul este diferit, obiectele sunt sigur inegale.

Contractul dintre hashCode() și equals()
  1. Două obiecte egale prin equals() trebuie să aibă același hashCode().
  2. Două obiecte cu același hashCode() nu sunt neapărat egale.
  3. Obiectele cu hash diferit sunt sigur inegale.

  • Nerespectarea contractului poate duce la comportament neașteptat în colecții bazate pe hash.
  • Metoda hashCode() trebuie să fie consistentă: dacă obiectul nu se modifică, valoarea returnată trebuie să fie aceeași.
  • Modificarea câmpurilor folosite în hashCode() după ce obiectul a fost adăugat într-o colecție poate cauza pierdere de elemente sau comportament neașteptat.

Exemplu practic

Definirea metodelor pentru hash:

public class Student {
    private String name;
    private int age;
 
    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }
 
    @Override
    public boolean equals(Object obj) {
        if (this == obj) return true;
        if (obj == null || getClass() != obj.getClass()) return false;
        Student other = (Student) obj;
        return age == other.age && name.equals(other.name);
    }
 
    @Override
    public int hashCode() {
        return Objects.hash(name, age);
    }
}

Adăugarea în HashSet:

Set<Student> students = new HashSet<>();
students.add(new Student("Alice", 20));
students.add(new Student("Alice", 20)); // Nu se adaugă, equals() returnează true
System.out.println(students.size()); // Afișează 1

  • Deoarece știm că metoda de hash trebuie să returneze ID-uri cât mai unice ca să evite coliziuni putem folosi IntelliJ pentru generarea metodei apăsând click dreapta pe codGenerate…equals() and hashCode() de unde selectăm toate câmpurile care ne interesează.
  • Puteți oricând să suprascrieți metoda hashCode() cu un algoritm diferit față de cel din Objects.hash() recomandat de IntelliJ, însă alegeți algoritmul cu grijă, astfel încât să nu penalizați performanța aplicației pentru use-case-ul aplicației voastre.

🧮 [Nice to know] Math Utilities

Java oferă suport nativ pentru operații aritmetice pe tipuri primitive (int, long, float, double). Pentru operații mai complexe, cum ar fi funcții trigonometrice, radăcini pătrate sau generarea de numere aleatoare, clasa java.lang.Math oferă metode statice gata de folosit. Aceasta nu poate fi instanțiată, fiind un utilitar de tip static.

Pentru aritmetica întregilor, împărțirea la zero aruncă o excepție de tip ArithmeticException. În schimb, operațiile pe numere zecimale (double/float) nu aruncă excepții, ci pot returna valori speciale precum POSITIVE_INFINITY, NEGATIVE_INFINITY sau NaN.

Tabelul de mai jos sintetizează principalele metode din clasa Math:

Tabel metode clasa Math

Tabel metode clasa Math

Metodă Tip argument Funcționalitate Exemplu
Math.abs(a) int, long, float, double Valoarea absolută Math.abs(-5) → 5
Math.max(a,b) int, long, float, double Max dintre două valori Math.max(3,7) → 7
Math.min(a,b) int, long, float, double Min dintre două valori Math.min(3,7) → 3
Math.pow(a,b) double a ridicat la puterea b Math.pow(2,3) → 8.0
Math.sqrt(a) double Rădăcină pătrată Math.sqrt(16) → 4.0
Math.round(a) float/double Rotunjire la întreg Math.round(1.7) → 2
Math.ceil(a) double Rotunjire în sus Math.ceil(1.3) → 2.0
Math.floor(a) double Rotunjire în jos Math.floor(1.7) → 1.0
Math.sin(a) / Math.cos(a) / Math.tan(a) double Funcții trigonometrice Math.sin(Math.PI/2) → 1.0
Math.random() Generare număr aleator [0,1) Math.random() → 0.374
Math.toDegrees(a) / Math.toRadians(a) double Conversie radian ↔ grade Math.toDegrees(Math.PI) → 180.0


🐋 [Optional] BigInteger și BigDecimal

Tipurile primitive long și double au limite în ceea ce privește dimensiunea și precizia. Dacă ai nevoie de numere foarte mari sau de zecimale cu precizie arbitrară, poți folosi clasele BigInteger și BigDecimal din pachetul java.math.

  • BigInteger permite operații aritmetice pe numere întregi de orice dimensiune, fără să apară overflow.
  • BigDecimal permite operații pe numere zecimale cu precizie controlată și este ideal pentru calcule financiare sau științifice.
BigInteger b1 = new BigInteger("9223372036854775807");
BigInteger b2 = new BigInteger("2");
System.out.println(b1.add(b2)); // 9223372036854775809
 
BigDecimal bd1 = new BigDecimal("1.0");
BigDecimal bd2 = new BigDecimal("3");
BigDecimal result = bd1.divide(bd2, 100, BigDecimal.ROUND_UP);
System.out.println(result); // 0.3333...3334 (100 cifre)

Aceste clase oferă metode pentru adunare, scădere, înmulțire, împărțire și control precis al rotunjirii.

Nu există o limită bine definită pentru BigInteger sau BigDecimal, puteți stoca orice număr atâta timp cât există memorie RAM disponibilă.

🕒 [Nice to know] Lucrul cu date și ore

Java include mai multe clase pentru manipularea timpului și a datelor:

  • java.time.LocalDate – reprezintă doar data, fără timp.
  • java.time.LocalTime – reprezintă doar timpul, fără dată.
  • java.time.LocalDateTime – combină data și ora.
  • java.time.ZonedDateTime – reprezintă un moment exact într-un fus orar, luând în calcul ajustările de vară.

Crearea instanțelor se poate face fie cu valorile numerice folosind of(), fie prin parsarea stringurilor folosind parse(). De asemenea, metoda now() returnează momentul curent.

LocalDate

Reprezintă o dată fără timp (ex: 2023-03-31). Ideal pentru evenimente sau zile calendaristice.

Puteți crea un obiect folosind metoda now() pentru data curentă sau of() pentru o dată specifică.

Metodă Funcționalitate Exemplu
LocalDate.now() Data curentă LocalDate today = LocalDate.now();
LocalDate.of(year, month, day) Creează o dată specifică LocalDate piDay = LocalDate.of(2023, 3, 14);
LocalDate.parse(String, DateTimeFormatter) Parsează un string LocalDate valentine = LocalDate.parse(“02/14/23”, shortUS);
plus() / minus() Adaugă sau scade unități de timp today.plus(1, ChronoUnit.WEEKS);
LocalDate today = LocalDate.now();
LocalDate reminder = today.plus(1, ChronoUnit.WEEKS);
System.out.println(reminder); // o săptămână de la azi

LocalTime

Reprezintă o oră fără dată (ex: 07:15). Util pentru alarme sau ore de evenimente recurente.

Metodă Funcționalitate Exemplu
LocalTime.now() Ora curentă LocalTime now = LocalTime.now();
LocalTime.of(hour, minute) Creează o oră specifică LocalTime alarm = LocalTime.of(7, 15);
parse(String, DateTimeFormatter) Parsează un string LocalTime sunset = LocalTime.parse(“2020”, military);
LocalTime alarm = LocalTime.of(7, 15);
System.out.println(alarm); // 07:15

LocalDateTime

Reprezintă data și timpul împreună, fără fus orar. Este util pentru programări și timestamp-uri locale.

Metodă Funcționalitate Exemplu
LocalDateTime.now() Data și ora curentă LocalDateTime now = LocalDateTime.now();
LocalDateTime.of(year, month, day, hour, minute) Creează un obiect specific LocalDateTime appointment = LocalDateTime.of(2023,5,4,7,0);
plus() / minus() Manipulare date și timp appointment.plusDays(2);
LocalDateTime appointment = LocalDateTime.of(2023,5,4,7,0);
LocalDateTime nextAppointment = appointment.plusHours(3);
System.out.println(nextAppointment); // 2023-05-04T10:00

ZonedDateTime

Reprezintă un moment exact într-un fus orar, incluzând ajustările de vară. Este esențial când aplicația se folosește în mai multe zone.

Metodă Funcționalitate Exemplu
ZonedDateTime.now() Data și ora curentă cu fus orar ZonedDateTime now = ZonedDateTime.now();
atZone(ZoneId) Atașează un fus orar la LocalDateTime ZonedDateTime ny = appointment.atZone(ZoneId.of(“America/New_York”));
withZoneSameInstant(ZoneId) Convertire instantanee într-un alt fus ZonedDateTime paris = ny.withZoneSameInstant(ZoneId.of(“Europe/Paris”));
LocalDateTime piLocal = LocalDateTime.parse("2023-03-14T01:59");
ZonedDateTime piCentral = piLocal.atZone(ZoneId.of("America/Chicago"));
ZonedDateTime piParis = piCentral.withZoneSameInstant(ZoneId.of("Europe/Paris"));
System.out.println(piParis); // 2023-03-14T07:59+01:00[Europe/Paris]

DateTimeFormatter și formatarea datelor și orelor

Permite formatări și parsări personalizate pentru date și timp. Aceleași formate pot fi folosite și pentru parsarea string-urilor.

Caracteristică Funcționalitate Exemplu
ofPattern(String) Creează un format personalizat DateTimeFormatter shortUS = DateTimeFormatter.ofPattern(“MM/dd/yy”);
format(TemporalAccessor) Formatează o dată sau oră shortUS.format(today);
parse(String) Parsează un string conform formatului LocalDate valentine = LocalDate.parse(“02/14/23”, shortUS);
DateTimeFormatter military = DateTimeFormatter.ofPattern("HHmm");
LocalTime sunset = LocalTime.parse("2020", military);
System.out.println(sunset); // 20:20
 
DateTimeFormatter appointment = DateTimeFormatter.ofPattern("h:mm a MM/dd/yy z");
ZonedDateTime dentist = ZonedDateTime.parse("10:30 AM 11/01/23 EST", appointment);
System.out.println(dentist); // 2023-11-01T10:30-04:00[America/New_York]
 
LocalTime t = LocalTime.now();
System.out.println(withSeconds.format(t)); // ex: 09:17:34
System.out.println(military.format(t));    // ex: 0917

Puteți crea formate foarte variate pentru afișări personalizate folosind caracterele din DateTimeFormatter.

Tabel de formatare

Tabel de formatare

Caracter Descriere Exemplu
a AM/PM PM
d Ziua din lună 10
E Ziua din săptămână Tue, Tuesday
G Era BCE, CE
k Ora (1-24) 24
K Ora AM/PM (0-11) 0
L Luna din an Jul, July
h Ora AM/PM (1-12) 12
H Ora din zi (0-23) 0
m Minute 30
M Luna din an (numeric) 7, 07
s Secunde 55
S Fracțiune de secundă 033954
u An (fără era) 2004, 04
y An cu era 2004, 04
z Numele fusului orar Pacific Standard Time, PST
Z Offset fus orar +0000, -0800, -08:00

Timestamps

Clasele Instant și ChronoUnit permit manipularea timestamp-urilor precise, ideale pentru logging sau urmărirea evenimentelor:

Instant time1 = Instant.now();
Instant time2 = Instant.now();
 
System.out.println(time1.isAfter(time2)); // false
System.out.println(time1.plus(3, ChronoUnit.DAYS)); // 3 zile mai târziu

Instant este similar cu clasa veche Date, dar mult mai consistent și integrat cu java.time.

Recomandări

  • Folosiți LocalDate pentru date fără timp și LocalTime pentru timp fără dată.
  • LocalDateTime combină data și ora, dar nu include fusul orar.
  • ZonedDateTime este necesar când lucrați cu aplicații globale și fusuri orare.
  • DateTimeFormatter permite formatarea și parsarea flexibilă a datelor și orelor.
  • Instant reprezintă un timestamp precis pentru evenimente și log-uri.

Puteți să consultați pachetele java.util, java.text și java.time pentru mai multe utilitare care vă pot fi de folos. De exemplu, vă puteți uita la java.util.Random pentru generarea de numere aleatoare.

Summary

  • Pachetul java.util oferă implementări ale unor stucturi de date și algoritmi pentru manipularea lor: ierarhiile Collection și Map și clasa cu metode statice Collections.
  • Parcurgerea colecţiilor se face în două moduri:
    • folosind iteratori (obiecte ce permit traversarea unei colecţii şi modificarea acesteia)
    • folosind construcţia specială for each (care nu permite modificarea colecţiei în timpul parcurgerii sale)
  • Interfaţa List - colecţie ordonată ce poate conţine elemente duplicate.
  • Interfaţa Set - colecţie ce nu poate conţine elemente duplicate. Există trei implementări utile pentru Set: HashSet (neordonat, nesortat), TreeSet (set sortat) și LinkedHashSet (set ordonat)
  • Interfaţa Map - colecţie care mapează chei pe valori. Într-o astfel de structură nu pot exista chei duplicate. Cele trei implementări pentru Map sunt HashMap (neordonat, nesortat), TreeMap (map sortat) și LinkedHashMap (map ordonat)
  • Contractul equals - hashcode: dacă obj1 equals obj2 atunci hashcode obj1 == hashcode obj2. Dacă implementați equals, implementați și hashcode dacă doriți să folosiți acele obiecte în colecții bazate pe hash-uri (e.g. HashMap, HashSet).

Exerciţii

Task 1 (10p)

1. În cadrul acestui exercițiu, veți implementa o clasă numită Student, care are patru membri:

  1. name (String)
  2. surname (String)
  3. id (long)
  4. averageGrade (double) - media unui student.

Clasa Student va implementa interfața Comparable<Student>, folosită la sortări, prin implementarea metodei compareTo. În metoda compareTo, studenții vor fi comparați mai întâi după medie, apoi după numele de familie, apoi după prenume. După implementarea clasei, sortați elementele listei “students” din metoda main folosind metoda Collections.sort().

2. Adăugați lista “copyStudents” într-un PriorityQueue (cu ajutorul metodei Collection.addAll), care folosește un Comparator (utilizați constructorul PriorityQueue) sau o funcție anonimă. Elementele vor fi sortate crescător după id.

3. Suprascrieți metodele equals și hashCode în clasa Student (puteți folosi generatorul de cod din IntelliJ). După aceasta, adăugați în lista asociată studentilor din “studentMap” patru materii aleatorii. Pentru a obține materiile aleatorii, urmăriți indicațiile din codul din funcția main.

4. Extindeți clasa LinkedHashSet<Integer> cu o clasă în care se vor putea adăuga doar numere pare. Metoda add va fi suprascrisă astfel încât să nu permită adăugarea de numere impare în colecție. Efectuați aceeași operațiune și pentru clasele TreeSet și HashSet. Observați diferențele privind ordinea de inserare a elementelor între cele trei clase menționate.

Resurse și Linkuri utile

poo-ca-cd/laboratoare/colectii-tipuri-de-date-speciale-si-utilitare.txt · Last modified: 2025/11/17 02:11 by florian_luis.micu
CC Attribution-Share Alike 3.0 Unported
www.chimeric.de Valid CSS Driven by DokuWiki do yourself a favour and use a real browser - get firefox!! Recent changes RSS feed Valid XHTML 1.0