7.3.5 Verzamelingen
classDiagram Iterable <|-- Collection Collection <|-- List Collection <|-- Queue Collection <|-- Set class Iterable["Iterable#lt;E>"] { <<interface>> } class Collection["Collection#lt;E>"] { <<interface>> } class List["List#lt;E>"] { <<interface>> } class Queue["Queue#lt;E>"] { <<interface>> } class Set["Set#lt;E>"] { <<interface>> } style Set fill:#cdf,stroke:#99f
Set
Alle collecties die we totnogtoe gezien hebben, kunnen dubbels bevatten.
Bij een Set
is dat niet het geval. Het is een abstractie voor een wiskundige verzameling: elk element komt hoogstens één keer voor.
In een wiskundige verzameling is ook de volgorde van de elementen niet van belang.
De Set
interface legt geen volgorde van elementen vast, maar er bestaan sub-interfaces van Set
(bijvoorbeeld SequencedSet
en SortedSet
) die wél toelaten dat de elementen een bepaalde volgorde hebben.
De Set
interface voegt in feite geen nieuwe operaties toe aan de Collection-interface. Je kan elementen toevoegen, verwijderen, en nagaan of een element in de verzameling zit.
Het is leerrijk om even stil te staan bij hoe een set efficiënt geïmplementeerd kan worden. Immers, verzekeren dat er geen duplicaten inzitten vereist dat we gaan zoeken tussen de huidige elementen, en dat wordt al gauw een \(\mathcal{O}(n)\) operatie. We bekijken twee implementaties van hoe dat efficiënter kan: HashSet en TreeSet.
HashSet
Een HashSet kan gebruikt worden om willekeurige objecten in een set bij te houden. De objecten worden bijgehouden in een hashtable (in essentie een gewone array). Om te voorkomen dat we een reeds bestaand element een tweede keer toevoegen, moeten we echter snel kunnen nagaan of het toe te voegen element al in de set voorkomt. De hele hashtable overlopen kost teveel tijd (\(\mathcal{O}(n)\) met \(n\) het aantal objecten in de set), dus dat moeten we verbeteren.
Een HashSet
kan nagaan of een element bestaat, alsook een element toevoegen en verwijderen, in \(\mathcal{O}(1)\).
De sleutel om dat te doen is de hashCode()
methode die ieder object in Java heeft.
Die methode moet, voor elk object, een hashCode (een int) teruggeven, zodanig dat als twee objecten gelijk zijn volgens hun equals
-methode, ook hun hashcodes gelijk zijn.
Gewoonlijk zal je, als je equals
zelf implementeert, ook hashCode
moeten implementeren en omgekeerd.
De hashCode moet niet uniek zijn: meerdere objecten mogen dezelfde hashCode hebben, ook al zijn ze niet gelijk (al kan dat tot een tragere werking van een HashSet leiden; zie verder). Hoe uniformer de hashCode verdeeld is over alle objecten, hoe beter.
Note
Java records voorzien standaard een zinvolle equals- en hashCode-methode die afhangt van de attributen van het record. Deze hoef je dus normaliter niet zelf te voorzien.
De hashCode wordt gebruikt om een index te bepalen in de onderliggende hashtable (array). De plaats in die hashtable is een bucket. Het element wordt opgeslagen in de bucket op die index. Als we later willen nagaan of een element al voorkomt in de hashtable, berekenen we opnieuw de index aan de hand van de hashCode en kijken we of het element zich effectief in de overeenkomstige bucket bevindt.
Idealiter geeft elk object dus een unieke hashCode, en zorgen die voor perfecte spreiding van alle objecten in de hashtable. Er zijn echter twee problemen in de praktijk:
- twee verschillende objecten kunnen dezelfde hashCode hebben. Dat is een collision. Hiermee moeten we kunnen omgaan.
- als er teveel elementen toegevoegd worden, moet de onderliggende hashtable dynamisch kunnen uitbreiden. Dat maakt dat elementen plots op een andere plaats (index) terecht kunnen komen als voorheen. Uitbreiden vraagt vaak rehashing, oftwel het opnieuw berekenen van de index (nu in een grotere hashtable) aan de hand van de hashcodes. De load factor van de hash table geeft aan hoe vol de hashtable mag zijn voor ze uitgebreid wordt. Bijvoorbeeld, een load factor van 0.75 betekent dat het aantal elementen in de hashtable tot 75% van het aantal buckets mag gaan.
Beide problemen zijn al goed onderzocht in de computerwetenschappen. We overlopen twee technieken voor het eerste probleem (collisions): chaining en probing.
Chaining
Bij chaining houden we in de hashtable niet rechtstreeks de elementen bij, maar wijzen we in elke bucket naar een afzonderlijke gelinkte lijst. Elke keer wanneer we een element toevoegen, voegen we een knoop toe aan de gelinkte lijst in de bucket. Wanneer we een element opvragen, doorlopen we de gelinkte lijst om na te gaan of het element daarin voorkomt. Als er veel collisions zijn, verliezen we zo natuurlijk het performantie-voordeel van de hashtable. Inderdaad, in extremis hebben alle objecten dezelfde hashcode, en bevat de hashtable slechts één gelinkte lijst met daarin alle elementen. Een goede hashfunctie, die elementen goed verspreidt over de verschillende buckets, is dus essentieel voor de performantie.
block-beta columns 5 block:tab columns 1 i0["0"] i1["1"] i2["2"] end space block:lls:3 columns 1 block:ll0 columns 3 block:ll00 columns 2 ll00v["obj1"] ll00p[" "] end space block:ll01 columns 2 ll01v["obj2"] ll01p[" "] end end space block:ll2 columns 3 block:ll20 columns 2 ll20v["obj3"] ll20p[" "] end space:2 end end i0 --> ll00 ll00p --> ll01 i2 --> ll20 classDef none fill:none,stroke:none class lls none
Probing (open addressing)
Een andere techniek om om te gaan met collisions is probing. Als we een element willen toevoegen op een index, en er al een (ander) element op die index staat, berekenen we (volgens een deterministische formule) een volgende index, en proberen we daar opnieuw. Dat doen we tot we een lege plaats tegenkomen, waar we het element kunnen bijhouden. Die volgende index kan bijvoorbeeld (heel eenvoudig) index+1 zijn, maar we kunnen ook complexere formules bedenken waarmee we naar een heel andere plaats in de lijst springen. Bij het opzoeken volgen we hetzelfde stramien: blijven zoeken tot we het element terugvinden, of een lege plaats tegenkomen. Een element verwijderen wordt nu wel wat complexer: we moeten ervoor zorgen dat we geen lege plaats veroorzaken in de sequentie van indices.
SortedSet en TreeSet
Naast Set
bestaat ook de interface SortedSet
.
In tegenstelling tot een Set, kan een SortedSet geen willekeurige objecten bevatten.
De objecten moeten een volgorde hebben (hetzij door Comparable te implementeren, hetzij door een Comparator-object mee te geven).
De elementen worden steeds in gesorteerde volgorde opgeslagen en teruggegeven.
De TreeSet
klasse is een implementatie van SortedSet die gebruik maakt van een gebalanceerde boomstructuur (een red-black tree — de werking daarvan is hier niet van belang).
Alle basisoperaties (add, remove, contains) hebben worst-case tijdscomplexiteit \(\mathcal{O}(\log n)\); invoegen en verwijderen zijn best-case \(\mathcal{O}(1)\). De oorsprong van het logaritme wordt duidelijk aan de hand van deze oefening.