Colecții

Video introductiv: link

Obiective

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

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

Collections Framework

În pachetul java.util (pachet standard din JRE) există o serie de clase pe care le veti găsi folositoare. Collections Framework este o arhitectură unificată pentru reprezentarea şi manipularea colecţiilor. Ea conţine:

  • interfeţe: permit colecţiilor să fie folosite independent de implementările lor
  • implementări
  • algoritmi metode de prelucrare (căutare, sortare) pe colecţii de obiecte oarecare. Algoritmii sunt polimorfici: un astfel de algoritm poate fi folosit pe implementări diferite de colecţii, deoarece le abordează la nivel de interfaţă.

Colecţiile oferă implementări pentru următoarele tipuri:

  • Set (elemente neduplicate)
  • List (o mulțime de elemente)
  • Map (perechi cheie-valoare)

Există o interfaţă, numită Collection, pe care o implementează majoritatea claselor ce desemnează colecţii din java.util. Explicaţii suplimentare găsiţi pe Java Tutorials - Collection

Exemplul de mai jos construieşte o listă populată cu nume de studenţi:

Collection names = new ArrayList();
names.add("Andrei");
names.add("Matei");

Parcurgerea colecţiilor

Colecţiile pot fi parcurse (element cu element) folosind:

  • iteratori
  • o construcţie for specială (cunoscută sub numele de for-each)

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. for-each este foarte similar cu for. Următorul exemplu parcurge elementele unei colecţii şi le afişează.

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[].

Genericitate

Fie următoarea porţiune de cod:

Collection c = new ArrayList();
c.add("Test");
 
Iterator it = c.iterator();
 
while (it.hasNext()) {   
    String s = it.next();         // ERROR: next() returns an Object and it's needed an explicit cast to String
    String s = (String)it.next(); // OK
}

Am definit o colecţie c, de tipul ArrayList (pe care îl vom examina într-o secţiune următoare). Apoi, am adăugat în colecţie un element de tipul String. Am realizat o parcurgere folosind un iterator, şi am încercat obţinerea elementului nostru folosind apelul: String s = it.next();. Funcţia next însă întoarce un obiect de tip Object. Prin urmare apelul va eşua. Varianta corectă este String s = (String)it.next();. Am fi putut preciza, din start, ce tipuri de date dorim într-o colecţie:

Collection<String> c = new ArrayList<String>();
c.add("Test");
c.add(2);      // ERROR!
Iterator<String> it = c.iterator();
 
while (it.hasNext()) {   
     String s = it.next();
}

Mai multe detalii despre acest subiect găsiți in laboratorul următor: Genericitate

Interfaţa List

O listă este o colecţie care poate fi ordonată. Listele pot conţine elemente duplicate. Pe lângă operaţiile moştenite de la Collection, interfaţa List conţine operaţii bazate pe poziţie (index), de exemplu: set, get, add la un index, remove de la un index.

List<String> fruits = new ArrayList<>(Arrays.asList("Apple", "Orange", "Grape"));
fruits.add("Apple");               // metodă moștenită din Collection 
fruits.add(2, "Pear");             // [Apple, Orange, Pear, Grape, Apple]
System.out.println(fruits.get(3)); // Grape
fruits.set(1, "Cherry");           // [Apple, Cherry, Pear, Grape, Apple]
fruits.remove(2);
System.out.println(fruits);        // [Apple, Cherry, Grape, Apple]

Alături de List, este definită interfaţa ListIterator, ce extinde interfaţa Iterator cu metode de parcurgere în ordine inversă. List posedă două implementări standard:

  • ArrayList - implementare sub formă de vector. Accesul la elemente se face în timp constant: O(1)
  • LinkedList - implementare sub formă de listă dublu înlănţuită. Prin urmare, accesul la un element nu se face în timp constant, fiind necesară o parcurgere a listei: O(n).

Printre algoritmii implementaţi se numără:

  • sort - realizează sortarea unei liste
  • binarySearch - realizeaază o căutare binară a unei valori într-o listă sortată

În general, algoritmii pe colecţii sunt implementaţi ca metode statice în clasa Collections.

Atenţie: Nu confundaţi interfaţa Collection cu clasa Collections. Spre deosebire de prima, a doua este o clasă ce conţine exclusiv metode statice. Aici sunt implementate diverse operaţii asupra colecţiilor.

Iată un exemplu de folosire a sortării:

List<Integer> l = new ArrayList<Integer>();
l.add(5);
l.add(7);
l.add(9);
l.add(2);
l.add(4);
 
Collections.sort(l);
System.out.println(l);

Mai multe detalii despre algoritmi pe colecţii găsiţi pe Java Tutorials - Algoritmi pe liste

Compararea elementelor

Rularea exemplului de sortare ilustrat mai sus arată că elementele din ArrayList se sortează crescator. Ce se întâmplă când dorim să realizăm o sortare particulară pentru un tip de date complex? Spre exemplu, dorim să sortăm o listă ArrayList<Student> după media anilor. Să presupunem că Student este o clasă ce conţine printre membrii săi o variabilă ce reţine media anilor. Acest lucru poate fi realizat folosind interfeţele:

Comparable Comparator
Logica de sortare Logica de sortare trebuie să fie în clasa ale cărei obiecte sunt sortate. Din acest motiv, această metodă se numeşte sortare naturală. Logica de sortare se află într-o clasă separată. Astfel, putem defini mai multe metode de sortare, bazate pe diverse câmpuri ale obiectelor de sortat.
Implementare Clasa ale cărei instanţe se doresc a fi sortate trebuie să implementeze această interfaţă şi, evident, să suprascrie metoda compareTo(). Clasa ale cărei instanţe se doresc a fi sortate nu trebuie să implementeze această interfaţă. Este nevoie de o alta clasă (poate fi şi internă) care să implementeze interfaţa Comparator.
Metoda de comparare int compareTo(Object o1)
Această metodă compară obiectul curent (this) cu obiectul o1 şi întoarce un întreg. Valoarea întoarsă este interpretată astfel:
1. pozitiv – obiectul este mai mare decât o1
2. zero – obiectul este egal cu o1
3. negativ – obiectul este mai mic decât o1
int compare(Object o1,Object o2)
Această metodă compară obiectele o1 and o2 şi întoarce un întreg. Valoarea întoarsă este interpretată astfel:
1. pozitiv – o2 este mai mare decât o1
2. zero – o2 este egal cu o1
3. negativ – o2 este mai mic decât o1
Metoda de sortare Collections.sort(List)
Aici obiectele sunt sortate pe baza metodei compareTo().
Collections.sort(List, Comparator)
Aici obiectele sunt sortate pe baza metodei compare() din Comparator.
Pachet Java.lang.Comparable  Java.util.Comparator

Exemplu Comparable:

  • implementare:
public class Student implements Comparable<Student> {
    private String name;
    private String surname;
 
    public Student(String name, String surname) {
        this.name = name;
        this.surname = surname;
    }
 
    public String getName() {
        return name;
    }
 
    public void setName(String name) {
        this.name = name;
    }
 
    public String getSurname() {
        return surname;
    }
 
    public void setSurname(String surname) {
        this.surname = surname;
    }
 
    @Override
    public int compareTo(Student o) {
        if (surname.equals(o.surname)) {
            return name.compareTo(o.name);
        } else {
            return surname.compareTo(o.surname);
        }
    }
}
  • folosire:
ArrayList<Student> students = new ArrayList<>();
// populate ArrayList with Student objects
Collections.sort(students);

Exemplu implementare și folosire Comparator:

ArrayList<Integer> numbers = new ArrayList<>();
numbers.add(5);
numbers.add(1);
numbers.add(3623);
numbers.add(13);
numbers.add(7);
Collections.sort(numbers, new Comparator<Integer>() {
     @Override
     public int compare(Integer o1, Integer o2) {
          return o2 - o1;
     }
});
System.out.println(numbers); // se afișează [3623, 13, 7, 5, 1]
 
// alternativ, putem sorta o colecție, folosind metoda sort() din interfața List, în acest mod:
numbers.sort(new Comparator<Integer>() {
      @Override
      public int compare(Integer o1, Integer o2) {
           return o2 - o1;
      }
});

Interfaţa Set

Un Set (mulţime) este o colecţie ce nu poate conţine elemente duplicate. Interfaţa Set conţine doar metodele moştenite din Collection, la care adaugă restricţii astfel încât elementele duplicate să nu poată fi adăugate. Avem trei implementări utile pentru Set:

  • HashSet: memorează elementele sale într-o tabelă de dispersie (hash table); este implementarea cea mai performantă, însă nu avem garanţii asupra ordinii de parcurgere. Doi iteratori diferiţi pot parcurge elementele mulţimii în ordine diferită.
  • TreeSet: memorează elementele sale sub formă de arbore roşu-negru; elementele sunt ordonate pe baza valorilor sale. Implementarea este mai lentă decat HashSet.
  • LinkedHashSet: este implementat ca o tabelă de dispersie. Diferenţa faţă de HashSet este că LinkedHashSet menţine o listă dublu-înlănţuită peste toate elementele sale. Prin urmare (şi spre deosebire de HashSet), elementele rămân în ordinea în care au fost inserate. O parcurgere a LinkedHashSet va găsi elementele mereu în această ordine.

Atenţie: Implementarea HashSet, care se bazează pe o tabelă de dispersie, calculează codul de dispersie al elementelor pe baza metodei hashCode, definită în clasa Object. De aceea, două obiecte egale, conform funcţiei equals, trebuie să întoarcă acelaşi rezultat din hashCode.

Explicaţii suplimentare găsiti pe Java Tutorials - Set.

Interfaţa Map

Un Map este un obiect care mapează chei pe valori. Într-o astfel de structură nu pot exista chei duplicate. Fiecare cheie este mapată la exact o valoare. Map reprezintă o modelare a conceptului de funcţie: primeşte o entitate ca parametru (cheia), şi întoarce o altă entitate (valoarea). Trei implementări pentru Map sunt:

Particularităţile de implementare corespund celor de la Set. Exemplu de folosire:

class Student {
    String name;
    float avg;
 
    public Student(String name, float avg) {
        this.name = name;
        this.avg  = avg;
    }
 
    public String toString() {
        return "[" + name + ", " + avg + "]";
    }
}
 
public class Test {
    public static void main(String[] args) {
 
        Map<String,Student> students = new HashMap<String, Student>();
 
        students.put("Matei",  new Student("Matei",  4.90F));
        students.put("Andrei", new Student("Andrei", 6.80F));
        students.put("Mihai",  new Student("Mihai",  9.90F));
 
        System.out.println(students.get("Mihai"));
 
        // adaugăm un element cu aceeași cheie
        System.out.println(students.put("Andrei", new Student("", 0.0F))); 
        // put(...) întoarce elementul vechi
 
        // si îl suprascrie
        System.out.println(students.get("Andrei"));
 
        // remove(...) returnează elementul șters
        System.out.println(students.remove("Matei"));
 
        // afișăm structura de date
        System.out.println(students);
    }
}

Interfaţa Map.Entry desemnează o pereche (cheie, valoare) din map. Metodele caracteristice sunt:

  • getKey: întoarce cheia
  • getValue: întoarce valoarea
  • setValue: permite stabilirea valorii asociată cu această cheie

O iterare obişnuită pe un map se va face în felul următor:

for (Map.Entry<String, Student> entry : students.entrySet())
    System.out.println(entry.getKey() + " has the following average grade: " + entry.getValue().getAverage());

În bucla for-each de mai sus se ascunde, de fapt, iteratorul mulţimii de perechi, întoarse de entrySet. Explicaţii suplimentare găsiţi pe Java Tutorials - Map.

Alte interfeţe

Queue este o interfaţă ce defineşte operaţii specifice pentru cozi:

  • inserţia unui element
  • ştergerea unui element
  • operaţii de “inspecţie” a cozii

Implementări utilizate frecvente pentru Queue:

  • PriorityQueue: coadă cu priorităţi / heap

Deque este o interfaţă, care extinde interfața Queue, ce defineşte operaţii specifice pentru cozi cu două capete, unul la început și celălalt la final. Având operații pentru ambele capete, rezultă faptul că o colecție de tip Deque poate fi folosită atât ca stivă, cât și drept coadă.

Operații specifice:

  • inserţia unui element
  • ştergerea unui element
  • operaţii de “inspecţie” a cozii / a stivei

Implementări utilizate frecvente pentru Deque:

  • LinkedList: pe lângă List, LinkedList implementează şi Deque (deci şi Queue)
  • ArrayDeque: este mai rapidă decât LinkedList, în caz ca este folosită drept coadă

Explicaţii suplimentare găsiţi pe Java Tutorials - Queue, Deque

În Java, există colecții care sunt marcate ca fiind obsolete, adică nu mai sunt recomandate să fie folosite. Exemple de astfel de colecții:

  • Vector - operațiile prin care colecția este modificată (adăugare, ștergere) sunt sincronizate (detalii legate de sincronizări veți studia la APD, în anul 3), în timp ce operațiile la ArrayList (care este recomandat în locul lui Vector) nu sunt sincronizate, permițând astfel programatorului să aibă mai mult control asupra operațiilor în cod
  • Hashtable - operațiile prin care colecția este modificată (adăugare, ștergere) sunt sincronizate, în timp aceste operatiile la HashMap (care este recomandat în locul lui Hashtable) nu sunt sincronizate
  • Stack - acesta reprezintă implementarea de operații specifice pentru stivă și extinde clasa Vector, despre care am vorbit anterior. Colecția recomandată în locul acesteia este ArrayDeque.

Funcții lambda

În cadrul laboratorului de clase interne, am vorbit despre funcții anonime (funcții lambda) și despre cum le putem folosi în Java.

Putem folosi funcții anonime pentru a executa diverse operații pe liste (de exemplu removeIf, care filtrează elementele unei colecții pe baza unui predicat, și replaceAll, care aplică o operație pe toate elementele unei colecții).

Exemple:

List<Integer> list = new ArrayList<>(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10));
 
// incrementează toate numerele din colecție cu 1
list.replaceAll((x) -> x + 1);
 
// șterge din colecție numerele impare
list.removeIf((x) -> x % 2 == 1);

O altă utilitate a funcțiilor anonime reprezintă în implementarea comparatorilor folosiți la sortare sau la crearea de colecții sortate (TreeSet, TreeMap).

Exemple:

// o variantă
Collections.sort(list, (o1, o2) -> o2 - o1);
 
// alta variantă, prin care se folosim de metoda sort() din interfața List
list.sort((o1, o2) -> o2 - o1);
 
// colecții sortate
TreeSet<Integer> sortedSet = new TreeSet<>((o1, o2) -> o1 - o2);
TreeMap<Integer, Integer> sortedMap = new TreeMap<>((o1, o2) -> o1 - o2);

TL;DR

  • 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

  1. (2p) Î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, implementând metoda compareTo. În metoda compareTo, studenții vor fi comparați mai întâi după medie, apoi după numele de familie, apoi dupa prenume (adică dacă doi studenți au aceeași medie, ei vor fi comparați după numele de familie și dacă au același nume de familie, atunci vor fi comparați după prenume). Recomandăm să suprascrieți metoda toString, pentru a putea afișa datele despre un student.
  2. (1p) Creați 5 obiecte de tip Student și adăugați-le într-un ArrayList, pe care să îl sortați (hint: Collections.sort), apoi afisați conținutul din ArrayList.
  3. (1p) Sortați ArrayList-ul de la punctul anterior cu metoda sort() din interfața List sau cu Collections.sort(), în care să folosiți o funcție lambda, în care se compară descrescător după medie.
  4. (2p) Adăugați ArrayList-ul definit la subpunctul anterior într-un PriorityQueue (hint: Collection.addAll), care folosește un Comparator (hint: constructor PriorityQueue) sau o funcție anonimă, unde elementele sunt sortate crescător după id (aici puteti folosi Long.compare ca să comparați două numere de tip long).
  5. (1p) Suprascrieți metodele equals și hashCode în clasa Student (hint: puteți folosi generatorul de cod din IntelliJ).
  6. (1p) Folosiți un HashMap<Student, LinkedList<String», în care se vor adăuga perechi de tipul (Student, lista de materii pe care le are studentul respectiv), iar apoi afisați conținutul colecției (hint: Map.Entry și entrySet()).
  7. (2p) Extindeți clasa LinkedHashSet<Integer>, cu o clasă în care se vor putea adăuga doar numere pare. Va fi suprascrisă metoda add, în așa fel încât să nu fie permise adăugarea de numere impare în colecție. Pentru testare, adăugați numere pare și impare, iar după aceea iterați prin colecție, folosind Iterator (tipizat cu Integer) sau folosind forEach, afișând elementele din colecție. Înlocuiți LinkedHashSet cu HashSet - ce observați cu privire la ordinea de inserare a elementelor? Dar dacă ați înlocui cu TreeSet?

Resurse

Linkuri utile

poo-ca-cd/laboratoare/colectii.txt · Last modified: 2020/11/30 16:40 by florin.mihalache
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