L10 · Δομές Δεδομένων — Σωροί & Ξένα Σύνολα
Τι θα δούμε
Στο L09 αφήσαμε δύο υποσχέσεις. Είπαμε ότι ο Dijkstra και ο Prim τρέχουν σε «με έναν δυαδικό σωρό», και ότι ο Kruskal χρειάζεται μια δομή που απαντά γρήγορα «είναι αυτές οι δύο κορυφές ήδη συνδεδεμένες;». Και στις δύο περιπτώσεις είπαμε «θα το δούμε αργότερα».
Αυτή είναι η διάλεξη του «αργότερα». Δύο δομές δεδομένων:
- Σωρός (heap) — υλοποιεί μια ουρά προτεραιότητας: «δώσε μου το μικρότερο στοιχείο» σε . Αυτό κάνει γρήγορους τον Dijkstra και τον Prim.
- Ξένα σύνολα (union-find) — απαντά «σε ποιο σύνολο ανήκει το ;» και «ένωσε δύο σύνολα» σχεδόν σε σταθερό χρόνο. Αυτό κάνει γρήγορο τον Kruskal.
Μέρος Α' — Σωροί (Heaps)
Το πρόβλημα που λύνουν
Φαντάσου μια συλλογή στοιχείων με κλειδιά (αριθμούς) όπου ρωτάς ξανά και ξανά: «ποιο είναι το μικρότερο;» — και μετά το βγάζεις από τη συλλογή. Αυτό ακριβώς κάνει ο Dijkstra: σε κάθε βήμα βγάζει την ανεξερεύνητη κορυφή με το μικρότερο .
Ποιες είναι οι επιλογές μας;
| Δομή | FindMin | Insert / Delete |
|---|---|---|
| Μη ταξινομημένος πίνακας | — σάρωσε όλα | |
| Ταξινομημένος πίνακας | — είναι στην άκρη | — μετατόπιση |
| Σωρός |
Ορισμός
Ο ορισμός έχει δύο ανεξάρτητα κομμάτια:
- «Ισοσταθμισμένο δυαδικό δέντρο» — αφορά το σχήμα. Κάθε επίπεδο γεμίζει πλήρως πριν αρχίσει το επόμενο, και το τελευταίο γεμίζει από αριστερά. Αυτό εγγυάται ύψος — εκεί κρύβεται το κάθε πράξης.
- «Ιδιότητα διάταξης» — αφορά τα κλειδιά: κάθε γονιός κάθε παιδί του.
Πρόσεξε τι δεν λέει η ιδιότητα: τίποτα για τη σχέση ανάμεσα σε αδέλφια, ούτε ανάμεσα σε κορυφές διαφορετικών κλάδων. Ο σωρός είναι «χαλαρά» ταξινομημένος — και ακριβώς γι' αυτό συντηρείται φθηνά. Άμεση συνέπεια: η ρίζα έχει πάντα το ελάχιστο κλειδί, οπότε FindMin .
Αναπαράσταση με πίνακα
Το ωραιότερο κόλπο: ο σωρός δεν χρειάζεται δείκτες. Αφού το σχήμα είναι αυστηρά ισοσταθμισμένο, αποθηκεύουμε τις κορυφές σε έναν πίνακα , επίπεδο-επίπεδο από αριστερά προς δεξιά. Τότε για κάθε θέση :
«Κατέβα στο παιδί» = πολλαπλασίασε με 2· «ανέβα στον γονέα» = διαίρεσε με 2. Καθαρή αριθμητική, μηδέν δείκτες.
Αυτό ακούγεται αφηρημένο μέχρι να το πιάσεις στα χέρια σου. Πάτησε οποιοδήποτε στοιχείο παρακάτω — δέντρο ή πίνακα — και δες τον γονέα και τα παιδιά του να βγαίνουν από τους τρεις τύπους, για το συγκεκριμένο που διάλεξες:
Πάτησε οποιονδήποτε κόμβο του δέντρου ή κελί του πίνακα — ο γονέας ⌊i/2⌋ και τα παιδιά 2i, 2i+1 ανάβουν ταυτόχρονα και στις δύο όψεις.
Δοκίμασε ένα φύλλο (π.χ. το ): το ξεπερνά το , άρα «δεν υπάρχει παιδί» — έτσι ακριβώς ξέρει ο αλγόριθμος ότι έφτασε στον πάτο. Και πρόσεξε ότι κάθε γονιός είναι μικρότερος από τα παιδιά του (· · …) — αλλά τα αδέλφια δεν είναι ταξινομημένα μεταξύ τους, και δεν χρειάζεται.
Heapify-up — επιδιόρθωση προς τη ρίζα
Όταν εισάγουμε ένα στοιχείο, το βάζουμε στο τέλος του πίνακα — στην πρώτη ελεύθερη θέση, ώστε το σχήμα να μείνει ισοσταθμισμένο. Αλλά το κλειδί του μπορεί να είναι πολύ μικρό και να παραβιάζει την ιδιότητα — να είναι μικρότερο από τον γονέα του.
Με λόγια. Το νέο, «ελαφρύ» στοιχείο αναδύεται σαν φυσαλίδα. Σε κάθε βήμα το συγκρίνουμε με τον γονέα του· αν είναι μικρότερο, τα ανταλλάσσουμε και συνεχίζουμε από τη νέα του θέση. Σταματάμε μόλις βρει γονέα μικρότερο από αυτό, ή φτάσει το ίδιο στη ρίζα.
Χρόνος. Το μονοπάτι προς τη ρίζα έχει μήκος , κάθε βήμα είναι → . Η εισαγωγή (Insert) = «βάλε στο τέλος» + Heapify-up, δηλαδή κι αυτή .
Heapify-down — επιδιόρθωση προς τα φύλλα
Το συμμετρικό πρόβλημα: μια κορυφή έχει κλειδί πολύ μεγάλο — μεγαλύτερο από κάποιο παιδί της. Τη σπρώχνουμε προς τα κάτω.
Με λόγια. Συγκρίνουμε την κορυφή με τα παιδιά της. Αν κάποιο παιδί είναι μικρότερο, ανταλλάσσουμε με το μικρότερο από τα δύο και συνεχίζουμε προς τα κάτω. Σταματάμε όταν και τα δύο παιδιά είναι από αυτήν, ή όταν φτάσει σε φύλλο.
Χρόνος: ίδιο επιχείρημα — μονοπάτι προς τα φύλλα μήκους → .
Δες και τις δύο επιδιορθώσεις να συμβαίνουν — εναλλάξου ανάμεσα στην Insert (που ανεβάζει) και στην ExtractMin (που κατεβάζει), και πρόσεξε πώς δέντρο και πίνακας κινούνται μαζί:
Ουρά προτεραιότητας
Συνδυάζοντας τα παραπάνω παίρνουμε την ουρά προτεραιότητας — μια δομή που πάντα ξέρει «τι έχει σειρά»:
| Πράξη | Τι κάνει | Χρόνος |
|---|---|---|
CreateHeap(H) | κενός σωρός για στοιχεία | |
Insert(H, v) | βάλε στο τέλος, Heapify-up | |
FindMin(H) | επίστρεψε τη ρίζα | |
Delete(H, i) | βγάλε τη θέση , Heapify-down (ή up) | |
ExtractMin(H) | FindMin + διαγραφή της ρίζας |
Η ουρά προτεραιότητας υποστηρίζει επίσης decreaseKey(H, v, k) — μειώνει το κλειδί ενός στοιχείου και καλεί Heapify-up. Αυτή ακριβώς είναι η πράξη που κάνουν ο Dijkstra και ο Prim όταν βρίσκουν φθηνότερο μονοπάτι ή ακμή προς μια κορυφή.
Δωρεάν μπόνους: Heapsort
Έχοντας την ουρά προτεραιότητας, ένας ολόκληρος αλγόριθμος ταξινόμησης μάς πέφτει στα χέρια χωρίς καμία νέα ιδέα. Σκέψου το: το ExtractMin επιστρέφει πάντα το τρέχον ελάχιστο. Αν το καλέσεις ξανά και ξανά, τα στοιχεία βγαίνουν από το μικρότερο προς το μεγαλύτερο — δηλαδή ταξινομημένα. Αυτός είναι όλος ο αλγόριθμος.
Ο Heapsort έχει λοιπόν δύο φάσεις:
- Χτίσιμο σωρού — βάλε και τα στοιχεία σε έναν σωρό. Με διαδοχικές
Insertαυτό κοστίζει το πολύ . - Άδειασμα — κάλεσε
ExtractMinφορές. Κάθε κλήση , άρα συνολικά — και τα στοιχεία βγαίνουν σε αύξουσα σειρά.
Δες το να συμβαίνει: ο σωρός πρώτα γεμίζει, μετά στραγγίζει στοιχείο-στοιχείο σε μια ταξινομημένη έξοδο.
Χρόνος: — το ίδιο φράγμα με τη συγχωνευτική ταξινόμηση του L03, αλλά από εντελώς διαφορετικό δρόμο: εκεί «διαίρει και κυρίευε», εδώ «μια δομή που ξέρει πάντα πού είναι το ελάχιστο».
Πώς ο σωρός επιταχύνει τον Dijkstra και τον Prim
Τώρα κλείνει η πρώτη υπόσχεση του L09. Και οι δύο αλγόριθμοι κρατούν μια ουρά προτεραιότητας από ανεξερεύνητες κορυφές:
ExtractMin— διάλεξε την επόμενη κορυφή. Καλείται φορές → .decreaseKey— όταν μια κορυφή μπει στο , ενημέρωσε τις προτεραιότητες των γειτόνων της. Συνολικά μέχρι φορές → .
Σύνολο — ακριβώς το φράγμα του L09. Χωρίς σωρό, το ExtractMin θα ήταν γραμμική σάρωση και θα κατέληγες σε .
Μέρος Β' — Ξένα Σύνολα (Union-Find)
Το πρόβλημα που λύνουν
Θυμήσου τον Kruskal από το L09: σαρώνει ακμές σε αύξουσα σειρά κόστους και προσθέτει την εκτός αν δημιουργεί κύκλο. Πότε δημιουργεί κύκλο; Ακριβώς όταν οι και είναι ήδη στην ίδια συνεκτική συνιστώσα του μέχρι τώρα δέντρου.
Χρειαζόμαστε λοιπόν μια δομή που κρατά μια διαμέριση — μια συλλογή από ανά δύο ξένα σύνολα — και απαντά γρήγορα: «το και το είναι στο ίδιο σύνολο;»
Εφαρμογή — συνεκτικές συνιστώσες
Με αυτές τις τρεις πράξεις, η εύρεση των συνεκτικών συνιστωσών ενός γραφήματος γίνεται μηχανική.
- Είσοδος:
- γράφημα G = (V, E)
- Έξοδος:
- μια διαμέριση των κορυφών στις συνεκτικές συνιστώσες
Με λόγια. Φτιάξε ένα ξεχωριστό σύνολο για κάθε κορυφή. Μετά, για κάθε ακμή, ένωσε τα σύνολα των δύο άκρων της (αν είναι ήδη μαζί, δεν χρειάζεται). Στο τέλος, δύο κορυφές είναι στην ίδια συνιστώσα ακριβώς όταν έχουν τον ίδιο Find.
Μια αφελής προσέγγιση — και γιατί δεν φτάνει
Η πιο απλή ιδέα: ένας πίνακας , όπου είναι το «όνομα» του συνόλου του .
Find(x)— επίστρεψε . — εξαιρετικό.Union(x, y)— διάλεξε ένα όνομα και σάρωσε όλον τον πίνακα αλλάζοντας κάθε εμφάνιση του άλλου ονόματος. — απαίσιο.
Με ενώσεις φτάνουμε . Πληρώνουμε υπερβολικά στο Union. Χρειαζόμαστε καλύτερη ισορροπία.
Κάθε σύνολο ένα δέντρο με ρίζα
Η σωστή ιδέα: αναπαριστούμε κάθε σύνολο σαν ένα κατευθυνόμενο δέντρο με ρίζα.
- Κάθε κορυφή έχει έναν δείκτη — προς τον γονέα της.
- Η ρίζα δείχνει στον εαυτό της και είναι ο αντιπρόσωπος του συνόλου.
- Πολλά ξένα σύνολα → ένα κατευθυνόμενο δάσος.
Με αυτή την αναπαράσταση, οι τρεις πράξεις γίνονται απλές:
MakeSet(x)— ένα δέντρο με μία μόνο κορυφή, που δείχνει στον εαυτό της. .Find(x)— ακολούθησε τους δείκτες-γονείς από το προς τα πάνω, ως τη ρίζα· επίστρεψέ την.Union(x, y)— βρες τις δύο ρίζες, και κάνε τη μία να δείχνει στην άλλη. — αφού έχεις τις ρίζες.
Τρέξε μια ακολουθία από αυτές τις πράξεις και δες το δάσος να σχηματίζεται. Πρόσεξε δύο πράγματα: το Find ανεβαίνει πάντα μέχρι τη ρίζα, και ο πίνακας γονέων από κάτω είναι το ίδιο ακριβώς δάσος, γραμμένο σαν απλοί αριθμοί.
Ίδιο χρώμα = ίδιο σύνολο · βέλος = δείκτης προς τον γονέα · 7 σύνολα τώρα
Πρώτη βελτίωση: union by size
Η προειδοποίηση πιο πάνω είπε ότι ο εχθρός είναι η αλυσίδα. Να ο φθηνός τρόπος να την αποφεύγουμε: για κάθε δέντρο αποθηκεύουμε το μέγεθός του, και στο Union κάνουμε πάντα τη ρίζα του μικρότερου δέντρου να δείχνει στη ρίζα του μεγαλύτερου — ποτέ ανάποδα.
Το παρακάτω εργαλείο τρέχει την ίδια ακολουθία ενώσεων με δύο στρατηγικές. Πάνω, η απλοϊκή ένωση που αγνοεί τα μεγέθη· κάτω, το union by size. Δες το βάθος του ενός να εκτοξεύεται και του άλλου να μένει καρφωμένο:
Η εικόνα γίνεται θεώρημα:
Δεύτερη βελτίωση: συμπίεση μονοπατιών
Σχεδόν δωρεάν — και κρύβεται μέσα σε μια πράξη που κάνουμε ήδη. Κάθε φορά που τρέχουμε Find(v), διασχίζουμε ολόκληρο το μονοπάτι από το ως τη ρίζα· το πληρώσαμε ήδη. Πριν επιστρέψουμε, λοιπόν, ξανασυνδέουμε κάθε κορυφή αυτού του μονοπατιού απευθείας στη ρίζα.
Δες το να συμβαίνει: μια Find σε ένα σχεδόν εκφυλισμένο δέντρο πληρώνει τη μακριά διαδρομή μία φορά — και την ισιώνει στο πέρασμά της, ώστε η επόμενη να είναι ακαριαία.
Αναπηδήσεις αυτής της Find: 0
Με union by size + συμπίεση μονοπατιών μαζί συμβαίνει κάτι σχεδόν μαγικό: μια ακολουθία πράξεων σε στοιχεία τρέχει σε .
Πώς το union-find επιταχύνει τον Kruskal
Κλείνει και η δεύτερη υπόσχεση του L09. Στον Kruskal, ο έλεγχος «κλείνει κύκλο;» γίνεται με ένα ζεύγος Find, και η προσθήκη ακμής με ένα Union:
- Ταξινόμηση: .
- ζεύγη
Find+ έωςUnion: — ουσιαστικά γραμμικό. - Σύνολο: — η ταξινόμηση κυριαρχεί. Ακριβώς το φράγμα του L09.
Κλείδωσε τη γνώση
Βάλε στη σειρά τα βήματα του ExtractMin:
Και συμπλήρωσε τα κενά για το union-find:
Μοτίβο σκέψης
Τι μάθαμε
Τι κρατάμε από αυτή τη διάλεξη
- Σωρός — ισοσταθμισμένο δυαδικό δέντρο με ιδιότητα διάταξης (γονιός παιδιά). Αποθηκεύεται σε πίνακα: , παιδιά και — μηδέν δείκτες.
- Heapify-up / Heapify-down — επιδιορθώνουν μία παραβίαση σε , σπρώχνοντας ένα στοιχείο προς τη ρίζα ή προς τα φύλλα (πάντα προς το μικρότερο παιδί).
- Ουρά προτεραιότητας —
FindMin,Insert/ExtractMin. Δίνει Heapsort και κάνει Dijkstra & Prim . - Union-Find —
MakeSet,Union,Findπάνω σε διαμέριση σε ξένα σύνολα. Κάθε σύνολο = δέντρο με ρίζα = αντιπρόσωπο. - Union by size κρατά το βάθος · η συμπίεση μονοπατιών το μικραίνει κι άλλο. Μαζί → ανά πράξη, πρακτικά σταθερό.
- Το union-find κάνει τον Kruskal — η ταξινόμηση των ακμών είναι πια το ακριβό κομμάτι.
Ασκήσεις από εξετάσεις
Από τη θεωρία στην εξεταστική
3 άσκησεις που χρησιμοποιούν ως τελευταίο εργαλείο αυτή τη διάλεξη. Οι πρόσφατες εξεταστικές (2024/2025) φέρουν badge προτεραιότητας.
Φροντιστηριακό Σετ #5 · Άσκηση 10 — Πυθαγόρεια τετράδα σε O(n²)
Μια Πυθαγόρεια τετράδα είναι ακέραιοι με , δηλαδή . Σχεδίασε αλγόριθμο που αποφασίζει αν υπάρχει Πυθαγόρεια τετράδα σε έναν πίνακα διακριτών θετικών ακεραίων (επιτρέπεται η πολλαπλή χρήση ενός στοιχείου).
Φροντιστηριακό Σετ #5 · Άσκηση 11 — Ζεύγη με δοσμένο άθροισμα σε O(n)
Σχεδίασε αλγόριθμο που, δοθέντος ενός πίνακα με διαφορετικούς ακεραίους στο εύρος και τιμή στόχο , εκτυπώνει όλα τα ζεύγη με . Ο αναμενόμενος χρόνος πρέπει να είναι .
Σεπτέμβριος 2023 · Θέμα 3 — Master Theorem & επιδιόρθωση σωρού
(Α) Να επιλυθεί η αναδρομική εξίσωση , όπου και μια σταθερά θετική, με χρήση του Θεωρήματος Κυριαρχίας (Master Theorem).
(Β) Η ακολουθία είναι αποθηκευμένη στον μονοδιάστατο πίνακα υπό δομή σωρού (max-heap). Κάποιος όρος αλλάζει και παίρνει μικρότερη τιμή. Ο νέος πίνακας ενδέχεται να μην είναι πλέον σωρός.
i. Να δοθεί σύντομα ένας αναδρομικός αλγόριθμος που διατηρεί στον τη δομή σωρού. ii. Να δοθεί η αναδρομική σχέση που περιγράφει την πολυπλοκότητα του αλγορίθμου στη χείριστη περίπτωση. iii. Να επιλυθεί η , με . iv. Εφαρμόστε τον αλγόριθμο για έναν εσωτερικό κόμβο που κρατούσε την τιμή , όταν αυτή αλλάζει σε και όταν αλλάζει σε (τα παιδιά του κόμβου κρατούν τις τιμές και ).