Professional Documents
Culture Documents
ΠΑΡΑΡΤΗΜΑ ΝΑΥΠΑΚΤΟΥ
ΤΜΗΜΑ ΤΗΛΕΠΙΚΟΙΝΩΝΙΑΚΩΝ ΣΥΣΤΗΜΑΤΩΝ & ΔΙΚΤΥΩΝ
ΣΗΜΕΙΩΣΕΙΣ ΜΑΘΗΜΑΤΟΣ
Β’ ΕΞΑΜΗΝΟ ΣΠΟΥΔΩΝ
Επιμέλεια:
Δημήτρης Σοφοτάσιος
Ιωάννης Τσακνάκης
ΠΕΡΙΕΧΟΜΕΝΑ
ΠΡΟΛΟΓΟΣ ........................................................................................................... 6
Β Ι Β Λ Ι Ο Γ Ρ Α Φ Ι Α ...................................................................................... 175
ΠΡΟΛΟΓΟΣ
Ο προγραμματισμός επικεντρώνεται στην μετατροπή της ακολουθίας βημάτων επίλυσης
ενός προβλήματος σε μία ακολουθία εντολών άμεσα αναγνωρίσιμη από τον υπολογιστή.
Γενικά θα μπορούσε να θεωρηθεί ότι η επίλυση ενός προβλήματος απαιτεί 3 στάδια, τον
σαφή ορισμό του προβλήματος, την ανάπτυξη της λύσης του προβλήματος μέσω μίας
ακριβούς ακολουθίας βημάτων και την κωδικοποίηση της μεθόδου επίλυσης του προβλήματος
(βημάτων) και την υλοποίησή της στον υπολογιστή (πρόγραμμα). Παρά το γεγονός ότι ο
προγραμματισμός αναφέρεται στο τρίτο στάδιο πρέπει να κατανοηθεί ότι τα πρώτα δύο
στάδια είναι εξίσου σημαντικά. Η μεθοδολογία ανάπτυξης της μεθόδου επίλυσης του
προβλήματος αποτελεί τον Αλγόριθμο (Algorithm) ενώ ο τρόπος με τον οποίο οργανώνονται
τα δεδομένα σε ομάδες ονομάζονται Δομές Δεδομένων (Data Structures). Οι αλγόριθμοι και
οι δομές δεδομένων αποτελούν τα βασικά συστατικά ενός προγράμματος. Οι τεχνικές που
χρησιμοποιούνται στο σχεδιασμό ενός αλγόριθμου και η επιλογή των κατάλληλων δομών
δεδομένων προδιαγράφουν ουσιαστικά την ποιότητα και την απόδοση του προγράμματος
πριν ακόμα αυτό υλοποιηθεί.
Αντικείμενο των σημειώσεων είναι να εισάγει τους σπουδαστές στις βασικές αρχές των
Αλγόριθμων και των Δομών Δεδομένων και να παρουσιάσει τις πιο κυριότερες δομές που
χρησιμοποιούνται τόσο στην κύρια όσο και στην δευτερεύουσα μνήμη. Οι σημειώσεις
εστιάζουν ιδιαίτερα στις βασικές πράξεις που υποστηρίζει κάθε δομή δεδομένων αναλύοντας
τους αλγόριθμους υλοποίησής τους.
Πιο συγκεκριμένα, στο Κεφάλαιο 1 γίνεται μία σύντομη παρουσίαση των βασικών
εννοιών των αλγόριθμων και των δομών δεδομένων. Αρχικά παρουσιάζονται οι βασικές αρχές
ορισμού ενός προβλήματος. Στην συνέχεια ορίζεται η μεθοδολογία ανάπτυξης της λύσης ενός
προβλήματος εισάγοντας την έννοια του αλγόριθμου. Δίνεται ως παράδειγμα ο αλγόριθμος
επίλυσης του προβλήματος εύρεσης του μέγιστου κοινού διαιρέτη δύο θετικών ακεραίων.
Παράλληλα περιγράφεται ο τρόπος ανάλυσης των αλγόριθμων και μέτρησης της
απόδοσης/πολυπλοκότητάς τους ενώ παρουσιάζεται και η μορφή των ακολουθιών εντολών
της αλγοριθμικής γλώσσας που χρησιμοποιούμε παντού στις σημειώσεις. Επίσης,
περιγράφεται ο τρόπος με τον οποίο οργανώνονται τα δεδομένα σε ομάδες που ονομάζονται
δομές δεδομένων καθώς και η χρήση τους στην ανάπτυξη των αλγόριθμων αλλά και στην
διαδικασία του προγραμματισμού στη συνέχεια. Αρχικά περιγράφονται οι συνηθισμένες
πράξεις πάνω στις δομές δεδομένων και στη συνέχεια γίνεται μία εκτενή παρουσίαση των
σημαντικότερων από αυτές.
Στο Κεφάλαιο 2 παρουσιάζονται οι πιο βασικές γραμμικές δομές δεδομένων που
χρησιμοποιούνται τόσο στην κύρια όσο και στην δευτερεύουσα μνήμη. Πιο συγκεκριμένα,
αρχικά περιγράφεται η πιο απλή και συχνά χρησιμοποιούμενη δομή, ο πίνακας. Στη συνέχεια
αναλύεται η στοίβα, οι βασικές αρχές λειτουργίας της και οι βασικές πράξεις push και pop.
Παράλληλα, περιγράφονται μερικές εφαρμογές της στοίβας όπως είναι η υλοποίηση της
αναδρομής και η μετατροπή παραστάσεων στον πολωνικό συμβολισμό. Αμέσως μετά
περιγράφεται η ουρά, οι διαφορές της με την στοίβα και οι υποστηριζόμενες πράξεις Dequeue
και Enqueue σε ουρά pipeline και ουρά δακτυλίου. Τέλος, αναλύεται η δομή της λίστας,
δίνοντας έμφαση στις πράξεις εισαγωγής, αναζήτησης και διαγραφής σε απλά συνδεδεμένη
λίστα. Σε όλες τις παραπάνω δομές δίνονται σε μορφή ψευδοκώδικα οι αλγόριθμοι
υλοποίησης των βασικών πράξεων.
Στο Κεφάλαιο 3 αρχικά περιγράφεται το πρόβλημα της ταξινόμησης και οι μέθοδοι που
χρησιμοποιούνται για την επίλυσή του και οι οποίες διακρίνονται στις βασιζόμενες σε
συγκρίσεις και σε εκείνες που βασίζονται στην αναπαράσταση των στοιχείων εισόδου.
Εξετάζονται οι βασικότεροι αλγόριθμοι που εφαρμόζονται τόσο στην κύρια όσο και τη
βοηθητική μνήμη και γίνεται ανάλυση της απόδοσής τους. Στο δεύτερο μέρος περιγράφονται
οι πιο συνηθισμένες μέθοδοι αναζήτησης σε έναν ταξινομημένο πίνακα και στο τρίτο μέρος
παρουσιάζονται δύο αλγόριθμοι επιλογής του i-oστού μεγαλύτερου στοιχείου ενός τυχαίου
πίνακα.
Στο Κεφάλαιο 4 περιγράφονται τρεις βασικές μη γραμμικές δομές δεδομένων, τα δένδρα,
οι γράφοι και οι δομές UNION-FIND. Αρχικά παρουσιάζονται τα δένδρα δεδομένου ότι
αποτελούν τις πλέον αποδοτικές δομές στην οργάνωση και επεξεργασία μεγάλου όγκου
δεδομένων. Στη συνέχεια περιγράφονται και αναλύονται οι βασικές πράξεις σε ένα δυαδικό
κομβοπροσανατολισμένο δένδρο αναζήτησης και επιχειρείται μία σύντομη εισαγωγή στα
ισοζυγισμένα δένδρα καθώς και σε δένδρα που απαντώνται σε ειδικές εφαρμογές. Στο
δεύτερο μέρος του κεφαλαίου ορίζεται η δομή του γράφου, περιγράφονται οι βασικές
κατηγορίες γράφων καθώς και θεμελιώδεις έννοιες όπως η διαπερασιμότητα και η
συνεκτικότητα και στο τέλος παρατίθενται τα σημαντικότερα γραφοθεωρητικά προβλήματα της
επιστήμης των υπολογιστών τα οποία απαντώνται σε πλήθος εφαρμογών. Το κεφάλαιο
κλείνει με την περιγραφή και ανάλυση της δομής UNION-FIND η οποία διαχειρίζεται
αποδοτικά ξένα μεταξύ τους σύνολα στοιχείων.
ΚΑΤΑΛΟΓΟΣ ΣΧΗΜΑΤΩΝ
Σχήμα 1: Διάγραμμα ροής για τον αλγόριθμο του Ευκλείδη .................................................. 16
Σχήμα 2: Γραφικές παραστάσεις διαφορετικών τάξεων ασυμπτωτικής πολυπλοκότητας ..... 22
Σχήμα 3: Παραδείγματα πινάκων........................................................................................... 29
Σχήμα 4: Η δομή της στοίβας ................................................................................................. 35
Σχήμα 5: Η στοίβα μετά την ένθεση του στοιχείου P ............................................................. 35
Σχήμα 6: Η στοίβα μετά την απώθηση του στοιχείου της κορυφής........................................ 36
Σχήμα 7: Η δομή της ουράς.................................................................................................... 44
Σχήμα 8: Η ουρά μετά την ένθεση του στοιχείου P ............................................................... 44
Σχήμα 9: Η ουρά μετά την απώθηση του πρώτου της στοιχείου (Χ)..................................... 44
Σχήμα 10: Υλοποίηση ουράς pipeline με χρήση πίνακα ........................................................ 46
Σχήμα 11: Υλοποίηση ουράς δακτυλίου με χρήση πίνακα .................................................... 49
Σχήμα 12: Παράδειγμα απλά συνδεδεμένης λίστας............................................................... 52
Σχήμα 13: Παράδειγμα διπλά συνδεδεμένης λίστας.............................................................. 52
Σχήμα 14: Κυκλική λίστα........................................................................................................ 52
Σχήμα 15: Εισαγωγή του στοιχείου Χ σε μια απλά συνδεδεμένη λίστα ................................ 55
Σχήμα 16: Δυναμική δέσμευση μνήμης για τη δομή του δείκτη node με τη χρήση της
malloc()........................................................................................................................... 56
Σχήμα 17: Διαγραφή του στοιχείου Β από μια απλά συνδεδεμένη λίστα .............................. 57
Σχήμα 18: Αποδέσμευση μνήμης που καταλάμβανε η δομή του δείκτη node μετά την
εκτέλεση της free(node).................................................................................................. 57
Σχήμα 19: Αντιστροφή των στοιχείων μιας λίστας................................................................. 60
Σχήμα 20: Ένας δυαδικός σωρός 10 στοιχείων..................................................................... 74
Σχήμα 21: Το δένδρο μετά την απομάκρυνση του μεγαλύτερου στοιχείου ............................ 77
Σχήμα 22: Διαδικασία βύθισης του στοιχείου 2 και επανακατασκευή του σωρού ................ 77
Σχήμα 23: Λειτουργία του βήματος διαχωρισμού του quicksort............................................. 79
Σχήμα 24: Λειτουργία του αλγόριθμου mergesort.................................................................. 84
Σχήμα 25: Λειτουργία του αλγόριθμου countingsort .............................................................. 87
Σχήμα 26: Το δένδρο αποφάσεων παράγει ο insertionsort για 3 στοιχεία............................. 91
Σχήμα 27: Δένδρο αναζήτησης του αλγόριθμου binary search.............................................. 95
Σχήμα 28: Λειτουργία μεθόδου αναζήτησης με δυαδική παρεμβολή ..................................... 97
Σχήμα 29: Διαχωρισμός σε πεντάδες. Τα στοιχεία του S1 είναι μικρότερα ή ίσα του m και
τα στοιχεία του S2 μεγαλύτερα του m ......................................................................... 100
Σχήμα 30: Επεξήγηση των βασικότερων εννοιών σε ένα (δυαδικό) δένδρο ........................ 104
Σχήμα 31: Παράδειγμα προγονικού δένδρου....................................................................... 105
Σχήμα 32: Δένδρο εκφράσεων για την παράσταση c^d/a+(e+f)*b....................................... 105
Σχήμα 33: Δήλωση ενός δυαδικού δένδρου στη C .............................................................. 108
Σχήμα 34: Δυαδικό δένδρο αναζήτησης .............................................................................. 108
Σχήμα 35: Κομβοπροσανατολισμένο και φυλλοπροσανατολισμένο δυαδικό δένδρο
αναζήτησης για το ίδιο σύνολο στοιχείων.................................................................... 109
Σχήμα 36: Αλγόριθμος preorder διαπέρασης....................................................................... 110
Σχήμα 37: Αλγόριθμος inorder διαπέρασης ......................................................................... 110
Σχήμα 38: Αλγόριθμος postorder διαπέρασης ..................................................................... 110
Σχήμα 39: Η έξοδος της Print_tree() για ένα κομβοπροσανατολισμένο και το αντίστοιχο
φυλλοπροσανατολισμένο δυαδικό δένδρο αναζήτησης............................................... 117
Σχήμα 40: Παράδειγμα δεξιού ενδονηματοειδούς δυαδικού δένδρου .................................. 117
Σχήμα 41: Εύρεση του successor(p) .................................................................................... 119
Σχήμα 42: Εύρεση του predecessor(p) ................................................................................ 120
Σχήμα 43: Λειτουργία αλγόριθμου Search()......................................................................... 122
1.1 Εισαγωγή
Ο προγραμματισμός στους υπολογιστές είναι η διαδικασία με την οποία ο χρήστης ορίζει
στον υπολογιστή τον τρόπο με τον οποίο θέλει να επιλύσει ένα συγκεκριμένο πρόβλημα ή
κατάσταση. Επομένως βασικότερη έννοια είναι η επίλυση ενός προβλήματος από τον χρήστη
με τέτοιο τρόπο ώστε να μπορεί στη συνέχεια να εκτελέσει τα βήματα επίλυσης ο
υπολογιστής.
Γενικά θα μπορούσε να θεωρηθεί ότι η επίλυση ενός προβλήματος απαιτεί 3 στάδια:
1. Τον σαφή ορισμό του προβλήματος.
2. Την ανάπτυξη της λύσης του προβλήματος μέσω μίας ακριβούς ακολουθίας βημάτων
3. Την κωδικοποίηση της μεθόδου επίλυσης του προβλήματος (βημάτων) και την υλοποίησή
του στον υπολογιστή
Ο προγραμματισμός επικεντρώνεται στο 3ο στάδιο, δηλαδή στην μετατροπή της
ακολουθίας βημάτων επίλυσης του προβλήματος είτε σε μία ακολουθία εντολών άμεσα
αναγνωρίσιμη από τον υπολογιστή είτε σε μία ενδιάμεση γλώσσα εύχρηστη και φιλική από
τον χρήστη η οποία μπορεί στη συνέχεια να μεταγλωττισθεί σε εντολές που αναγνωρίζει ο
υπολογιστής και ανήκουν στην γλώσσα μηχανής του.
Παρά την πρακτική αξία του 3ου σταδίου, θα πρέπει να γίνει κατανοητό ότι και τα πρώτα
δύο στάδια είναι εξίσου σημαντικά και για το λόγο αυτό αποτελούν το κύριο αντικείμενο
συζήτησης στο παρόν κεφάλαιο.
1
O όρος αλγόριθμος αποδίδεται στον Πέρση μαθηματικό Abu Ja’far Mohammed ibn Musa
al Khowarizmi, που έζησε περί το 825 μ.Χ. και συνέγραψε μία μελέτη η οποία θεωρείται ως η
πρώτη πλήρης πραγματεία άλγεβρας.
Αρκετές φορές η έννοια του αλγόριθμου συγχέεται με την έννοια του προγράμματος. Ένα
πρόγραμμα δεν είναι τίποτα περισσότερο από μία «μετάφραση» ή «κωδικοποίηση» ενός
αλγόριθμου στη γλώσσα που καταλαβαίνει ο υπολογιστής. Αυτό σημαίνει ότι είναι σχεδόν
αδύνατο να γράψει κανείς ένα πρόγραμμα που να λύνει ένα υπολογιστικό πρόβλημα αν
προηγουμένως δεν έχει αναπτύξει έναν αλγόριθμο για την επίλυσή του. Δυστυχώς, δεν
υπάρχει αλγόριθμος για την εύρεση αλγόριθμων, υπάρχουν όμως μερικές τεχνικές χρήσιμες
στην ανάπτυξή τους. Γεγονός όμως είναι ότι τις περισσότερες φορές η ανάπτυξη ενός
αλγόριθμου για την επίλυση ενός προβλήματος είναι διαδικασία σαφώς δυσκολότερη από τον
προγραμματισμό του αλγόριθμου.
ή Επανέλαβε
Α
Αν Συνθήκη Έξοδος
ή Επέλεξε (έκφραση)
Περίπτωση 1: A
Περίπτωση 2: B
……
Αλλιώς: X
Τέλος Επιλογής
Είναι γεγονός ότι η εκτέλεση όλων των παραπάνω βημάτων βρίσκεται στην ευχέρεια του
προγραμματιστή και είναι σύνηθες κάποια να παραλαείπονται. Όμως πρέπει να τονιστεί η
σπουδαιότητα τουλάχιστον του τελευταίου βήματος και της σύνταξης ψευδοκώδικα πριν την
μετατροπή του σε μια γλώσσα προγραμματισμού. Η διαδικασία αυτή είναι τόσο σημαντική
που τα τελευταία χρόνια έχει οριστεί ως γνωστικό αντικείμενο με θέμα τη “μηχανική των
αλγόριθμων” (algorithm engineering).
παραδείγματα ώστε να δούμε αν αυτές είναι σωστές. Τέλος επιλέγουμε την λύση που
μαθηματικά ή έστω διαισθητικά καταλαβαίνουμε ότι είναι η αποδοτικότερη.
Στο παράδειγμα επομένως μπορούμε να θεωρήσουμε τις εξής λύσεις:
1. Από τους δύο αριθμούς μπορούμε να επιλέξουμε τον μικρότερο έστω x και να
εξετάσουμε από το 1 έως το x, όλους τους αριθμούς κρατώντας αυτούς που
διαιρούν και το x και το y. O μεγαλύτερος από αυτούς είναι και ο ζητούμενος.
Είναι σαφές ότι σε κάθε περίπτωση θα εκτελεστεί μία εντολή επανάληψης για x
φορές όπου κάθε φορά θα εξετάζεται αν ο τρέχον αριθμός διαιρεί τον y, και αν ναι
τον κρατούμε και πάμε στον επόμενο αριθμό.
2. Μία άλλη λύση είναι μία παραλλαγή της παραπάνω μόνο που μπορούμε να
ξεκινήσουμε την επανάληψη από τον αριθμό x κατεβαίνοντας μέχρι το 1. Στην
περίπτωση αυτή έχουμε καλύτερη λύση όμως ο χρόνος της χειρότερης
περίπτωσης όπως θα δούμε παρακάτω είναι ίδιος.
3. Μία τρίτη λύση είναι η εξής: “Διαιρούμε το x με το y, και έστω z το υπόλοιπο. Αν z
= 0 τότε ο ΜΚΔ είναι ο y. Αν z ≠ 0 τότε επανάλαβε το βήμα με τους ακεραίους y
και z αντί για x και y”. Η λύση αυτή είναι γνωστή ως αλγόριθμος του Ευκλείδη2.
Για να κατανοήσουμε καλύτερα τη λύση αυτή και να ορίσουμε τα πεπερασμένα
βήματα του αλγόριθμου που προκύπτουν από αυτήν μπορούμε να εκτελέσουμε
βήμα - βήμα ένα συγκεκριμένο παράδειγμα.
Παράδειγμα
Έστω έχουμε τους αριθμούς 26 και 65 και ψάχνουμε τον ΜΚΔ τους.
Ο πίνακας που προκύπτει από τη διαδοχική εφαρμογή των βημάτων του
αλγόριθμου είναι ο εξής:
Επανάληψη Εκτέλεση Βημάτων x y z
Αρχή 1. z = y άρα z = 65 26 65 65
1η 1. z = x mod y άρα z = 26 65 26 26
2. x = y άρα x = 65
3. y = z άρα y = 26
2η 1. z = x mod y άρα z = 13 26 13 13
2. x = y άρα x = 26
3. y = z άρα y = 13
3η 1. z = x mod y άρα z = 0 13 0 0
2. x = y άρα x = 13
3. y = z άρα y = 0
2
Ο συγκεκριμένος αλγόριθμος θεωρείται ως ο πρώτος διάσημος αλγόριθμος και δόθηκε από
τον Έλληνα μαθηματικό Ευκλείδη (~ 325 π.Χ. – 265 μ.Χ.). Ο αρχικός αλγόριθμος, όπως
περιγράφηκε από τον Ευκλείδη στο έργο του «Στοιχεία» Βιβλίο VII, αντιμετώπιζε το
πρόβλημα γεωμετρικά, χρησιμοποιώντας επαναλαμβανόμενες αφαιρέσεις αντί για το
υπόλοιπο της διαίρεσης.
Αποτέλεσμα
ΜΚΔ = x = 13
Στην αρχή θέτουμε z = y γεγονός που σημαίνει ότι αν εισέλθουμε στην επανάληψη
για όσο το z ≠ 0 θα έχουμε y ≠ 0 και άρα μπορεί να εκτελεστεί η πράξη x mod y.
Είναι σαφές ότι η παραπάνω λύση είναι αποδοτικότερη από τις δύο
προηγούμενες.
Β. Σύμφωνα με την παράγραφο 1.1, το επόμενο βήμα είναι η αναπαράσταση των
πεπερασμένων βημάτων επίλυσης του αλγόριθμου. Έστω ότι για το σκοπό αυτό
χρησιμοποιούμε ένα διάγραμμα ροής. Για τη λύση του συγκεκριμένου προβλήματος
εύρεσης του ΜΚΔ κατά Ευκλείδη μπορεί να χρησιμοποιηθεί το παρακάτω διάγραμμα:
ΑΡΧΗ
ΔΙΑΒΑΣΕ
Χ,Υ
Z=Y
ΝΑΙ
Z=0
ΟΧΙ ΤΥΠΩΣΕ
Χ
Z = X MOD Y
X=Y
ΤΕΛΟΣ
Y=Z
Αλγόριθμος Α1
Βήμα 1: Διάβασε τα n, x
Βήμα 2: Έστω i=n και y=1
Bήμα 3: Εκτέλεσε τα παρακάτω βήματα μέχρι το i να γίνει 0
3.1 y = y*x
3.2 i = i-1
Bήμα 4: Eκτύπωσε την τιμή του y
Αλγόριθμος Α2
Βήμα 1: Διάβασε τα n, x
Βήμα 2: Υπολόγισε τον αριθμό a = xn/2 με βάση τον Α1
Bήμα 3: Yπολόγισε τον αριθμό y = a*a
Bήμα 4: Eκτύπωσε την τιμή του y
Ποιος από τους παραπάνω αλγόριθμους είναι καλύτερος (προτιμότερος) για τον
υπολογισμό της n-οστής δύναμης ενός ακεραίου;
Για να απαντήσουμε στο ερώτημα αυτό πρέπει να ορίσουμε κάποιο κριτήριο: ένας καλός
αλγόριθμος κάνει αυτό ακριβώς που σχεδιάστηκε να κάνει με το λιγότερο δυνατό κόστος.
Ας μετρήσουμε τον αριθμό των πολλαπλασιασμών που χρειάζονται οι δύο αλγόριθμοι του
παραδείγματος:
▪ Για τον Α1 έχουμε n πολ/μούς
▪ Για τον Α2 έχουμε n/2+1 πολ/μούς
Επομένως, με δεδομένο ότι ο πολλαπλασιασμός είναι χρονοβόρα πράξη για τον υπολογιστή,
συμφέρει να διαλέξουμε τον δεύτερο αλγόριθμο προκειμένου να γράψουμε ένα πρόγραμμα για
τον υπολογισμό της δύναμης ακεραίου.
Στο προηγούμενο παράδειγμα είναι προφανές ότι το κριτήριο για την επιλογή αλγόριθμου
είναι η ταχύτητα (ο χρόνος δηλαδή για την εκτέλεση του αλγόριθμου). Είναι όμως ο
αλγόριθμος Α2 ο καλύτερος δυνατός; Ποια κριτήρια μπορούν να το εξασφαλίσουν;
Η υπολογιστική πολυπλοκότητα (computational complexity) είναι το βασικό μέτρο
αξιολόγησης ενός αλγόριθμου. Καθορίζει την απόδοση του αλγόριθμου η οποία εξαρτάται από
τους πόρους που απαιτεί ο αλγόριθμος από το υπολογιστικό σύστημα όταν αυτός υλοποιηθεί
σε πρόγραμμα. Οι πόροι αυτοί μπορεί να είναι το μέγεθος της μνήμης που χρησιμοποιείται
για την αποθήκευση των δεδομένων καθώς και ο χρόνος που χρειάζεται για την εκτέλεση των
εντολών του αλγόριθμου. Από αυτές τις μονάδες μέτρησης ορίζονται η πολυπλοκότητα
χρόνου (time complexity) και χώρου (space complexity) ως δείκτες απόδοσης του
αλγόριθμου και χαρακτηρίζονται συχνά με τον όρο δυναμική πολυπλοκότητα.
Ο πιο απλός και εύκολος τρόπος για να μετρήσουμε την πολυπλοκότητα ενός
αλγόριθμου είναι ο εμπειρικός ή εκ των υστέρων (a posteriori) τρόπος: ο αλγόριθμος
εφαρμόζεται σε ένα σύνολο δεδομένων εισόδου και μετράμε τον χρόνο επεξεργασίας και την
απαιτούμενη μνήμη. Προφανώς, ο τρόπος αυτός δεν είναι πρακτικός γιατί:
• Η συμπεριφορά του αλγορίθμου μπορεί να αλλάξει, αν αλλάξουν τα δεδομένα εισόδου
και,
• Ο χρόνος επεξεργασίας εξαρτάται από το υλικό (hardware), τη γλώσσα
προγραμματισμού στην οποία έχει κωδικοποιηθεί ο αλγόριθμος, τις δεξιότητες και την
εμπειρία του προγραμματιστή κλπ.
Ένας άλλος τρόπος υπολογισμού της πολυπλοκότητας ενός αλγορίθμου είναι ο
θεωρητικός ή εκ των προτέρων (a priori) τρόπος όπου μας ενδιαφέρει να εκφράσουμε την
πολυπλοκότητα ως συνάρτηση του μεγέθους της εισόδου του αλγόριθμου. Το τί σημαίνει
μέγεθος εισόδου εξαρτάται από το εκάστοτε πρόβλημα που επιλύει ο αλγόριθμος. Για
παράδειγμα, σε έναν αλγόριθμο που χρησιμοποιείται για την ταξινόμηση στοιχείων, το
μέγεθος εισόδου είναι το πλήθος των προς διάταξη αντικειμένων.
Η μηχανή RAM αποτελείται από ένα πεπερασμένο πλήθος καταχωρητών και άπειρες
θέσεις μνήμης που αριθμούνται 0, 1, 2, … Οι καταχωρητές και οι θέσεις μνήμης μπορούν να
αποθηκεύσουν έναν ακέραιο ή πραγματικό αριθμό. Οι εντολές είναι μονοδιευθυντικές και
εκτελούνται ακολουθιακά, η μία μετά την άλλη, μία σε κάθε βήμα. Μία εντολή είναι μία απλή
αριθμητική ή λογική πράξη στα περιεχόμενα κάποιων καταχωρητών ή η άμεση προσπέλαση
για εγγραφή σε ή ανάγνωση από μια θέση μνήμης. Πρακτικά, η RAM αντιστοιχεί σε
υλοποιήσεις αλγόριθμων με πίνακες σε αντίθεση με την μηχανή δείκτη (pointer machine,
PM) που είναι ασθενέστερη της RAM δεδομένου ότι η προσπέλαση στη μνήμη γίνεται μέσω
δεικτών, δηλ. αντιστοιχεί σε υλοποιήσεις αλγόριθμων με λίστες.
Στο μοντέλο RAM ο χρόνος και ο χώρος ενός αλγόριθμου επί συγκεκριμένης εισόδου είναι
ανάλογος των στοιχειωδών πράξεων ή βημάτων που εκτελούνται και του πλήθους των θέσεων
μνήμης που απαιτούνται αντίστοιχα. Μπορούμε εύκολα να εκτιμήσουμε την πολυπλοκότητα
ενός αλγόριθμου αφού:
1. Κάθε εντολή ανάθεσης τιμής, δήλωσης απλής μεταβλητής, η λογική/αριθμητική πράξη
κοστίζει σταθερό χρόνο.
2. Σε κάθε βρόγχο επιλογών σε κάθε επανάληψη έχουμε ανεξάρτητα το κόστος όλων των
πράξεων σ’ αυτές.
3. Η δέσμευση μεταβλητής απλού τύπου κοστίζει σταθερό χώρο ενώ η δέσμευση ενός
πίνακα k θέσεων κοστίζει χρόνο και χώρο ανάλογο του k.
Το μέγεθος του προγράμματος στη μνήμη της μηχανής RAM που υλοποιεί έναν αλγόριθμο
καλείται στατική πολυπλοκότητα του αλγόριθμου.
Παράδειγμα 1
f(n) = 3n2+8n = 3n2+ O(n) = O(n2) (το ‘=’ σημαίνει περιέχεται).
Παράδειγμα 2
Έστω οι συναρτήσεις:
f(n) = n
g(n) = n2
Όταν 0 < n < 1 ισχύει f(n) > g(n).
Όταν n ≥ 1 ισχύει f(n) ≤ g(n).
Άρα, μπορούμε να ισχυριστούμε ότι f = O(g).
Παράδειγμα 3
Έστω οι συναρτήσεις:
f(n) = 3n + 5√n
g(n) = 2n
Aπό τον ορισμό ισχύει f(n) = O(n), g(n) = O(n) παρά το γεγονός ότι η f είναι πάντα
μεγαλύτερη της g.
Παράδειγμα
Θεωρούμε τους αλγόριθμους Α1, Α2, Α3, Α4, Α5 με αντίστοιχες ασυμπτωτικές
πολυπλοκότητες (για μέγεθος εισόδου n):
Θ(n) [γραμμική]
Θ(nlogn)3
Θ(n2) [τετραγωνική]
Θ(n3) [κυβική]
Θ(2n) [εκθετική]
Έτσω ότι δίνουμε σε κάθε αλγόριθμο 1 sec να εργαστεί. Αν υποθέσουμε ότι η μονάδα
χρόνου είναι 1 msec (η εκτέλεση κάθε στοιχειώδους απαιτεί ένα 1 msec), τότε το πλήθος των
στοιχείων που προλαβαίνει να επεξεργαστεί κάθε αλγόριθμος φαίνεται στον επόμενο πίνακα:
Αλγόριθμος Πολυπλοκότητα* Πλήθος στοιχείων (κατά προσέγγιση)
Α1 n 1.000 = S1
A2 nlogn 140 = S2
A3 n2 31 = S3
A4 n3 10 = S4
A5 2n 9 = S5
Έστω τώρα ότι η ταχύτητα του υπολογιστή που εκτελεί τους αλγόριθμους
δεκαπλασιάζεται. Τότε το μέγιστο πλήθος στοιχείων που προλαβαίνει να επεξεργαστεί κάθε
αλγόριθμος μεταβάλλεται ως εξής:
Αλγόριθμος Πολυπλοκότητα Πλήθος στοιχείων (κατά προσέγγιση)
Α1 n 10S1
A2 nlogn 9S2
A3 n2 3,16S3
A4 n3 2,15S4
A5 2n S5 + 3,3
3
Όπου στις σημειώσεις χρησιμοποιεται ο λογάριθμος logn θεωρούμε ότι έχει βάση 2 εκτός κι αν
αναγράφεται διαφορετική βάση.
f(n)
O(2n)
O(n3)
O(n2)
O(nlogn)
O(n)
O(logn)
O(1)
Τέλος, όπως ήδη έχει αναφερθεί στην αρχή του κεφαλαίου, υπάρχουν και προβλήματα μη
επιλύσιμα ή ανοικτά, δηλ. δεν έχει βρεθεί μέχρι σήμερα αλγόριθμος που να τα επιλύει.
Χαρακτηριστικό παράδειγμα είναι το παρακάτω (πρόβλημα που τέθηκε από τον L. Collatz to
1937):
Υποθέστε ότι έχετε το παρακάτω πρόγραμμα σε C:
while (n != 1)
{
if (n%2 == 0) n = n/2; /* n άρτιος */
else n = 3n+1; /* n περιττός */
}
Αν το n έχει πχ. αρχική τιμή 7 τότε διαδοχικά παίρνει τις τιμές 22 -> 11 -> 34 -> 17 -> 52 -> 26
-> 13 -> 40 -> 20 -> 10 -> 5 -> 16 -> 8 -> 4 -> 2 -> 1, δηλ. το πρόγραμμα τερματίζει. Το γενικό
όμως πρόβλημα αν το πρόγραμμα τερματίζει για κάθε φυσικό αριθμό n είναι ανοικτό, δηλ. δεν
ξέρουμε την απάντηση!
Σε σχέση με τον τρόπο υλοποίησής του, ένας αλγόριθμος μπορεί να είναι αναδρομικός
(recursive). Στην περίπτωση αυτή ορίζεται μία αναδρομική σχέση και μία βάση (αρχική
συνθήκη). Πχ. το παραγοντικό ενός αριθμού n δίνεται από την αναδρομική σχέση n! = n(n-1)!
για n≥1 ενώ η βάση είναι 0! = 1.
Ο αλγόριθμος του Ευκλείδη για τον υπολογισμό του ΜΚΔ 2 θετικών ακεραίων όπως
παρουσιάστηκε παραπάνω μπορεί να μετατραπεί σε αναδρομικό ως εξής:
Αλγόριθμος ΜΚΔ_Ευκλείδη (x, y)
Δεδομένα //x, y: ακέραιοι //
Αρχή
Aν y>0 Τότε κάλεσε ΜΚΔ(y, x mod y)
Αποτελέσματα // τύπωσε x: O x είναι ο ΜΚΔ//
Τέλος ΜΚΔ_Ευκλείδη
Mπορεί να αποδειχθεί ότι η χρονική πολυπλοκότητα του αλγόριθμου του Ευκλείδη (είτε
της επαναληπτικής ή της αναδρομικής του υλοποίησης) είναι Ο(log(min(x,y))) (υποθέστε ότι τα
x, y είναι διαδοχικοί όροι της ακολουθίας Fibonacci η οποία ορίζεται ως εξής: F0 = 0, F1 = 1, Fn
= Fn-1 + Fn-2 για n >1).
Πολλές φορές οι αλγόριθμοι μπορούν να εκτελεστούν σε ένα υπολογιστή ο οποίος έχει
περισσότερες από μία ΚΜΕ αλλά και σε ένα σύνολο υπολογιστών όπου ο καθένας έχει 1
ΚΜΕ. Στις περιπτώσεις αυτές υλοποιούνται παράλληλοι αλγόριθμοι, όμως δεν μπορούν να
παραλληλοποιηθούν αποδοτικά όλοι οι αλγόριθμοι.
Επίσης, συχνά οι αλγόριθμοι κατηγοριοποιούνται ανάλογα με την περιοχή των
προβλημάτων που επιλύουν. Έτσι υπάρχουν οι αριθμητικοί αλγόριθμοι που εφαρμόζονται σε
προβλήματα αριθμητικής ανάλυσης, οι συνδυαστικοί για προβλήματα βελτιστοποίησης, οι
στοχαστικοί για προβλήματα θεωρίας ουρών κ.ά.
• Διαγραφή (deletion), για την διαγραφή ενός ή περισσοτέρων δεδομένων από την
δομή.
• Αντιγραφή (copying), για την αντιγραφή ενός ή περισσοτέρων δεδομένων από την
δομή σε μία άλλη δομή.
• Αναζήτηση (searching), για την εύρεση ενός ή περισσοτέρων δεδομένων της δομής.
• Ταξινόμηση (sorting), για την ταξινόμηση των δεδομένων της δομής με βάση κάποιο
κριτήριο από τον χρήστη
• Συγχώνευση (merging), για την συγχώνευση των δεδομένων δύο ή περισσότερων
δομών.
Οι δομές δεδομένων χωρίζονται σε δύο μεγάλες κατηγορίες ανάλογα με τον τρόπο
δέσμευσης της μνήμης που απαιτείται για την αποθήκευση των δεδομένων τους, τις στατικές
και τις δυναμικές. Οι στατικές δομές δεδομένων γνωρίζουν το ακριβές μέγεθος των δεδομένων
τους και δεσμεύουν εξ αρχής τον απαιτούμενο χώρο μνήμης γι’ αυτά. Τα δεδομένα
αποθηκεύονται με τον τρόπο αυτό σε συνεχόμενες θέσεις μνήμης.
Αντίθετα, οι δυναμικές δομές δεδομένων δεν έχουν σταθερό μέγεθος. Έτσι, κάθε φορά
που υπάρχει νέα εγγραφή – δεδομένο δεσμεύεται στην μνήμη ο απαραίτητος χώρος. Η
δέσμευση της μνήμης γίνεται με την τεχνικής της δυναμικής παραχώρησης μνήμης και
προφανώς τα δεδομένα δεν είναι αποθηκευμένα σε συνεχόμενες θέσεις μνήμης.
Απλές Σύνθετες
Γραμμικές Μη γραμμικές
Πίνακας Στοίβα Ουρά προτεραιότητας
Εγγραφή Ουρά Δένδρο
Γραμμική λίστα Γράφος
Δομή UNION-FIND
Πίνακας (Array)
Ο πίνακας είναι η πιο απλή και κοινά χρησιμοποιούμενη δομή δεδομένων. Είναι συνήθως
στατική δομή αφού κατά τον ορισμό του σε ένα πρόγραμμα ορίζεται και το μέγεθος του. Ένας
πίνακας μπορεί να είναι μιας διάστασης ή περισσοτέρων και περιέχει δεδομένα του ίδιου
τύπου.
Εγγραφή (Structure)
Απαρτίζεται από ένα σταθερό πλήθος στοιχείων πληροφορίας, όχι απαραίτητα του ίδιου
τύπου, που ονομάζονται πεδία. Αποτελεί τη μονάδα δόμησης των αρχείων.
Στοίβα (Stack)
Η στοίβα είναι μία δομή δεδομένων στην οποία τα δεδομένα εισάγονται το ένα μετά το
άλλο ενώ ο χρήστης έχει τη δυνατότητα να προσπελάσει μόνο το τελευταίο που έχει εισαχθεί
ενώ το πρώτο στοιχείο που εισήχθη θα προσπελαστεί τελευταίο (δομή LIFO - Last In First
Out).
Η υλοποίηση μίας στοίβας μπορεί να γίνει με απλό τρόπο χρησιμοποιώντας έναν
μονοδιάστατο πίνακα χρησιμοποιώντας πάντοτε έναν δείκτη που να δείχνει το πρώτο στοιχείο
της στοίβας που είναι και το τελευταίο που εισήχθη σ’ αυτήν.
Οι βασικές πράξεις σε μία στοίβα είναι:
• Empty() η οποία επιστρέφει 1 αν η στοίβα είναι άδεια και 0 στην αντίθετη περίπτωση.
• Push(x) η οποία τοποθετεί το στοιχείο x στην κορυφή της στοίβας
• Top() η οποία προσπελαύνει το στοιχείο που βρίσκεται στην κορυφή της στοίβας.
• Pop() η οποία προσπελαύνει και παράλληλα διαγράφει το στοιχείο που βρίσκεται
στην κορυφή της στοίβας.
Ουρά (Queue)
Η ουρά είναι μία δομή δεδομένων στην οποία τα δεδομένα εισάγονται το ένα μετά το άλλο
ενώ ο χρήστης έχει τη δυνατότητα να προσπελάσει τα δεδομένα με την αντίστοιχη φορά που
τα έχει εισάγει (δομή FIFO – First In First Out). Αποτελεί τυπικό παράδειγμα μιας ουράς
ανθρώπων σε μια τράπεζα.
Η υλοποίηση μίας ουράς μπορεί να γίνει με απλό τρόπο χρησιμοποιώντας έναν
μονοδιάστατο πίνακα και 2 δείκτες: έναν δείκτη που να δείχνει στο πρώτο στοιχείο της ουράς
και έναν στο τελευταίο.
Οι βασικές πράξεις σε μία ουρά είναι:
• Enqueue(x) η οποία τοποθετεί το στοιχείο x στο τέλος της ουράς
• Dequeue() η οποία προσπελαύνει και παράλληλα διαγράφει το στοιχείο που
βρίσκεται στην κορυφή της ουράς.
Δένδρο (Tree)
Τα δένδρα χρησιμοποιούνται για την αναπαράσταση ιεραρχικών σχέσεων μεταξύ των
στοιχείων. Αντίθετα με τις λίστες, σε ένα δένδρο από κάθε κόμβο δεν ξεκινά ένας μόνο δείκτης
που να δείχνει μόνο σε ένα κόμβο αλλά περισσότεροι οι οποίοι δείχνουν σε κόμβους που
ονομάζονται απόγονοι του αρχικού. Ο πρώτος κόμβος στο δένδρο ονομάζεται ρίζα ενώ οι
κόμβοι που δεν δείχνουν σε απoγόνους ονομάζονται φύλλα.
Οι βασικές πράξεις που υποστηρίζει ένα δένδρο, γνωστές στη βιβλιογραφία ως πράξεις
του λεξικού (dictionary), είναι οι ακόλουθες:
• Search(x) η οποία αναζητά και επιστρέφει το στοιχείο x
• Insert(x) η οποία εισάγει το στοιχείο x στο δένδρο
• Delete(x) η οποία διαγράφει το στoιχείο x από το δένδρο.
Γράφος (Graph)
Η δομή αυτή αποτελείται από κόμβους δεδομένων (κορυφές) στους οποίους όμως δεν
υπάρχει κάποια συγκεκριμένη ιεραρχική οργάνωση. Οι κόμβοι ενώνονται μεταξύ τους μέσω
ενός συνόλου ακμών.
Δομή UNION-FIND
Η δομή αυτή χρησιμοποιείται για τη διαχείριση ξένων μεταξύ τους υποσυνόλων από
στοιχεία τα οποία ανήκουν σε ένα σταθερό και γνωστό εκ των προτέρων σύνολο που καλείται
σύμπαν. Υποστηρίζει τις πράξεις:
• Μakeset(x) η οποία δημιουργεί ένα καινούργιο μονοσύνολο με το στοιχείο x.
• Union(x, y) η οποία ενώνει τα δυναμικά σύνολα που περιέχουν τα x και y σε ένα νέο
σύνολο καταστρέφοντας έτσι τα προηγούμενα.
• Find(x) η οποία επιστρέφει το (ένα δείκτη στο) όνομα του συνόλου που περιέχει το x.
Ερωτήσεις Κεφαλαίου
1. Τι είναι ο αλγόριθμος; Ποια κριτήρια πρέπει να ικανοποιεί;
2. Με ποιους τρόπους αναπαρίστανται οι αλγόριθμοι;
3. Ποιοι είναι οι βασικοί τύποι εντολών ενός αλγόριθμου;
4. Περιγράψτε τον αλγόριθμο του Ευκλείδη.
5. Περιγράψτε τα χαρακτηριστικά της μηχανής RAM.
6. Δώστε τα 2 μέτρα της απόδοσης ενός αλγόριθμου.
7. Δώστε τους βασικούς ασυμπτωτικούς συμβολισμούς που χρησιμοποιούνται στην
εκτίμηση της πολυπλοκότητας ενός αλγόριθμου.
8. Περιγράψτε τους βασικούς τύπους πολυπλοκότητας ενός αλγόριθμου.
9. Τι είναι η δομή δεδομένων; Ποια η διαφορά της στατικής με τη δυναμική δομή δεδομένων.
10. Δώστε τις βασικές πράξεις πάνω στα στοιχεία μιας δομής δεδομένων.
Σε ένα πίνακα περιέχονται δεδομένα του ίδιου τύπου. Είναι σημαντικό ο προγραμματιστής
να κατανοεί κάθε φορά το χώρο μνήμης που καταλαμβάνουν οι δομές που είναι
αποθηκευμένες σε κάθε θέση του πίνακα. Τα στοιχεία ενός πίνακα αποθηκεύονται σε
γειτονικές θέσεις μνήμης και κάθε στοιχείο καταλαμβάνει το ίδιο χώρο μνήμης. Αν έχουμε ένα
μονοδιάστατο πίνακα Ν στοιχείων και κάθε στοιχείο καταλαμβάνει μία λέξη μνήμης τότε, όταν
δηλώνεται ο πίνακας σε μία γλώσσα προγραμματισμού όπως η C, δεσμεύονται εξαρχής Ν
διαδοχικές θέσεις μνήμης για τον πίνακα.
Στη γενική περίπτωση κάθε στοιχείο ενός πίνακα καταλαμβάνει τόσες θέσεις μνήμης
ανάλογα με τον τύπο του και με την αρχιτεκτονική της κεντρικής μνήμης του υπολογιστή. Έτσι
σε υπολογιστές όπου η λέξη μνήμης είναι ισοδύναμη με ένα byte:
Επειδή ακριβώς πρέπει να γνωρίζουμε εξαρχής το μέγεθος του πίνακα ώστε να είναι
δυνατή η δέσμευση του απαιτούμενου χώρου μνήμης, ο πίνακας είναι μία αυστηρά στατική
δομή δεδομένων. Το προκαθορισμένο όμως μέγεθος μνήμης επιτρέπει να προσπελάσουμε
οποιαδήποτε θέση του πίνακα σε Ο(1) χρόνο κι αυτή ακριβώς η ιδιότητα κάνει τους πίνακες
σημαντικούς απ΄ τη σκοπιά της υπολογιστικής πολυπλοκότητας (βλ. μοντέλο υπολογισμού
RAM).
Για ένα συμμετρικό πίνακα αρκεί να αποθηκεύσουμε, πχ. σε ένα νέο μονοδιάστατο
πίνακα, μόνο τα στοιχεία της διαγωνίου του κι αυτά που βρίσκονται πάνω (ή κάτω)
απ΄ αυτήν. Με τον τρόπο αυτό μειώνουμε σχεδόν στο μισό το χώρο που χρειαζόμαστε
αφού, αν ο συμμετρικός πίνακας περιέχει Ν2 στοιχεία, τότε ο νέος πίνακας θα περιέχει
Ν(Ν+1)/2 στοιχεία.
2. Τριγωνικοί πίνακες. Ένα πίνακας είναι τριγωνικός όταν είναι δύο διαστάσεων και τα
στοιχεία του Αij ικανοποιούν την σχέση Αij = 0 για i > j, οπότε έχουμε τον κάτω
τριγωνικό πίνακα, ή Αij = 0 για i < j, οπότε ο πίνακας είναι πάνω τριγωνικός.
Πχ. ο παρακάτω πίνακας είναι κάτω τριγωνικός:
7 0 0 0 0
9 7 0 0 0
2 2 6 0 0
5 8 3 5 0
6 1 2 3 4
Και εδώ, αντί για ολόκληρο του πίνακα, μπορούμε να αποθηκεύσουμε μόνο τα μη
μηδενικά του στοιχεία χρησιμοποιώντας πχ. τρεις μονοδιάστατους πίνακες, έναν για
τα στοιχεία της διαγωνίου, έναν για αυτά που είναι πάνω απ΄ αυτήν και έναν για τα
στοιχεία που είναι κάτω από τη διαγώνιο. Με τον τρόπο αυτό προκύπτουν συνολικά
3Ν-2 θέσεις.
4. Αραιοί πίνακες. Ένας πίνακας είναι αραιός όταν πολλά στοιχεία του (συνήθως πάνω
από το 70%) είναι 0.
Παράδειγμα αραιού πίνακα:
0 0 5 0 0 0
0 7 0 9 0 0
2 0 0 0 0 6
4 0 3 0 0 0
Ένας τρόπος για να αποθηκεύσουμε έναν αραιό πίνακα είναι για κάθε μη μηδενικό
στοιχείο του να κρατάμε σε ένα νέο μονοδιάστατο πίνακα μία τριάδα στοιχείων που
δείχνουν τη γραμμή, τη στήλη και την τιμή του στοιχείου αντίστοιχα. Με τον τρόπο
αυτό, για k μη μηδενικά στοιχεία, χρειαζόμαστε μόνο 3k θέσεις.
Ένας άλλος τρόπος είναι να μετατρέψουμε τον αραιό πίνακα σε δυαδικό όπου κάθε
στοιχείο του έχει τιμή 1 αν στην αντίστοιχη θέση του αραιού πίνακα υπάρχει μη
μηδενική τιμή και 0 διαφορετικά και να αποθηκεύσουμε τα μη μηδενικά στοιχεία σε
έναν μονοδιάστατο πίνακα με τη σειρά από πάνω προς τα κάτω και σε κάθε γραμμή
του αραιού πίνακα από αριστερά προς τα δεξιά. Για τον αραιό πίνακα του
παραδείγματος οι πίνακες που προκύπτουν είναι:
0 0 1 0 0 0
0 1 0 1 0 0
1 0 0 0 0 1
1 0 1 0 0 0
και
5 7 9 2 6 4 3
Στον τρόπο αυτό οι οικονομία χώρου οφείλεται στο γεγονός ότι ο δυαδικός πίνακας
αποθηκεύει σε κάθε θέση του την τιμή ενός bit μόνο.
2.1.2 Συμβολοσειρές
Μία σημαντική κατηγορία μονοδιάστατων πινάκων είναι οι πίνακες χαρακτήρων όπου
κάθε στοιχείο τους είναι ένας χαρακτήρας. Οι πίνακες αυτοί ονομάζονται συμβολοσειρές
(strings) και είναι αλφαριθμητικοί. Λόγω της ευρείας τους χρήσης σε πλήθος εφαρμογών,
κάθε γλώσσα προγραμματισμού προσφέρει ένα σύνολο συναρτήσεων οι οποίοι έχουν σκοπό
να εκτελέσουν συγκεκριμένες λειτουργίες πάνω σε συμβολοσειρές όπως συνένωση
συμβολοσειρών, αποκοπή στοιχείων ή και τμημάτων της συμβολοσειράς κ.α.
Είναι σημαντικό να σημειώσουμε ότι κάθε στοιχείο του πίνακα μπορεί να είναι ένας
χαρακτήρας αλλά και από μόνο του μία συμβολοσειρά. Στην περίπτωση αυτή μπορεί η
συμβολοσειρά να είναι σταθερού ή μεταβλητού μεγέθους. Στην πρώτη περίπτωση κατά τον
ορισμό του πίνακα δεσμεύεται ο συνολικός απαραίτητος χώρος μνήμης για την αποθήκευση
των στοιχείων του. Δηλαδή αν ο πίνακας είναι ΝxΝ τότε στην κάθε γραμμή δεσμεύεται χώρος
για μια συμβολοσειρά Ν χαρακτήρων ανεξάρτητα από το πραγματικό μέγεθός της. Αυτό έχει
σαν αποτέλεσμα να σπαταλάται αρκετός χώρος.
Στην δεύτερη περίπτωση συμβολοσειρών μεταβλητού μεγέθους σε κάθε γραμμή δεν
δεσμεύεται χώρος Ν χαρακτήρων αλλά μόνο 2 στοιχείων. Το πρώτο περιέχει το πλήθος των
χαρακτήρων της συμβολοσειράς της συγκεκριμένης γραμμής (δηλ. το μήκος της) ενώ το
δεύτερο στοιχείο είναι ένας δείκτης που δείχνει στην διεύθυνση μνήμης που περιέχει τον
πρώτο χαρακτήρα της συμβολοσειράς. Οι χαρακτήρες της συμβολοσειράς αποθηκεύονται σε
συνεχόμενες θέσεις μνήμης. Έτσι το πρόγραμμα κατά την εκτέλεσή του, όταν θέλει να
επεξεργαστεί, έστω την πρώτη συμβολοσειρά του πίνακα, προσπελαύνει τη διεύθυνση που
μνήμης όπου είναι καταχωρημένος ο πρώτος χαρακτήρας και ανακτά στη συνέχεια το
περιεχόμενο των συνεχόμενων θέσεων μνήμης τις οποίες δείχνει ο αριθμός που δίνει το μήκος
της συμβολοσειράς.
Mπορούμε να τροποποιήσουμε τον παραπάνω αλγόριθμο ώστε στην έξοδο να δίνει και τη
θέση του μεγαλύτερου στοιχείου. Χρησιμοποιούμε μια μεταβλητή επιπλέον η οποία
αρχικοποιείται στην τιμή 1 πριν την είσοδο στο loop και σε κάθε επανάληψη ενημερώνεται με
τη θέση του μεγαλύτερου στοιχείου μέχρι εκείνη τη στιγμή.
Είναι εύκολο να δείτε ότι ο προηγούμενος αλγόριθμος εκτελεί ακριβώς N-1 συγκρίσεις και
είναι βέλτιστος: κάθε αλγόριθμος για την εύρεση της μεγαλύτερης από Ν τιμές σε τυχαία
σειρά θα πρέπει να εκτελεί τουλάχιστον N-1 συγκρίσεις αφού για να διασφαλίσει ότι η τιμή
που δίνει στην έξοδο είναι όντως η μεγαλύτερη θα πρέπει όλα τα υπόλοιπα Ν-1 στοιχεία να
έχουν συγκριθεί τουλάχιστον μία φορά με αυτήν και να έχουν χάσει.
αποθηκεύεται στο τέλος της στοίβας. Όταν όμως θέλει να εξάγει ένα στοιχείο από τη στοίβα
τότε έχει τη δυνατότητα να προσπελάσει μόνο το στοιχείο που έχει εισαχθεί τελευταίο. Αυτό
σημαίνει ότι το πρώτο στοιχείο που εισήχθη στη στοίβα θα προσπελαστεί τελευταίο. Η
λειτουργία αυτή κατατάσσει τη στοίβα στις δομές LIFO (Last In First Out).
Συχνά στην καθημερινή μας ζωή συναντάμε περιπτώσεις ή προβλήματα που απαιτούν
την εφαρμογή της δομής της στοίβας για την επίλυσή τους. Μία στοίβα πιάτων ή ένας
κερματοδέκτης είναι χαρακτηριστικά παραδείγματα. Πρέπει να τονιστεί παράλληλα ότι η
στοίβα αποτελεί βασική δομή δεδομένων και για την επιστήμη των υπολογιστών. Έτσι, όταν
στον προγραμματισμό καλείται μία συνάρτηση και στην συνέχεια μία άλλη κ.ο.κ. τότε αυτή η
διαδρομή των κλήσεων «κρατείται» σε μία στοίβα κατά την εκτέλεση του προγράμματος.
Ποιες λειτουργίες όμως εφαρμόζονται σε μία στοίβα;
Έστω έχουμε μία στοίβα στην οποία έχουμε αποθηκευμένα τα στοιχεία X, Y, Z και V
όπως φαίνεται στο παρακάτω σχήμα.
Εισαγωγή Εξαγωγή
στοιχείων στοιχείων
Επίσης ένας χρήστης μπορεί να απωθήσει (εξάγει) ένα στοιχείο από τη στοίβα με την
εκτέλεση της πράξης pop(). Ουσιαστικά η πράξη pop προσπελαύνει το τελευταίο στοιχείο της
στοίβας (το στοιχείο της κορυφής) και στη συνέχεια το σβήνει από τη στοίβα. Τότε η στοίβα
αποκτά την ακόλουθη μορφή:
Υπάρχουν βέβαια και άλλες πράξεις που εφαρμόζονται σε μία στοίβα, λιγότερο
σημαντικές από τις προηγούμενες δύο. Ειδικότερα, οι βασικές πράξεις που ορίζονται σε μία
στοίβα είναι:
• Push (x) η οποία τοποθετεί το στοιχείο x στην κορυφή της στοίβας
• Pop(), η οποία προσπελαύνει και παράλληλα διαγράφει το στοιχείο που βρίσκεται
στην κορυφή της στοίβας.
• Top() η οποία προσπελαύνει το στοιχείο που βρίσκεται στην κορυφή της στοίβας.
• Empty() η οποία επιστρέφει 1 (ή ΤRUE) αν η στοίβα είναι άδεια και 0 (ή FALSΕ) στην
αντίθετη περίπτωση.
Η πράξη Push()
Αν θεωρήσουμε ότι στοίβα περιέχει ακεραίους αριθμούς (ο πίνακας Stack[N] δηλώνεται
ως ένας πίνακας ακεραίων) τότε η πράξη ένθεσης καλείται όταν θέλουμε να εισάγουμε ένα
στοιχείο στη στοίβα έστω Num (η μεταβλητή Num είναι επίσης τύπου ακεραίου). Η push είναι
μία ανεξάρτητη διαδικασία η οποία καλείται από το κύριο πρόγραμμα όταν θέλουμε να
εισάγουμε ένα στοιχείο στη στοίβα. Συντάσσεται επομένως ως μία συνάρτηση στην οποία
μεταφέρεται ως αρχικό δεδομένο και η τιμή του στοιχείου Num (η συνάρτηση καλείται με
παράμετρο τη μεταβλητή Num). Όταν καλείται η push επομένως έχει την μορφή Push(Num).
Όταν γίνει αίτηση για ένθεση (Push(Num)) πρέπει να εξεταστεί αρχικά το γεγονός αν
υπάρχει διαθέσιμος χώρος στη στοίβα για να εισαχθεί το στοιχείο Νum. Τονίσαμε ότι η
μεταβλητή Head δείχνει στο τελευταίο στοιχείο της στοίβας (στην κεφαλή της). Αν επομένως
στο παράδειγμά μας, η κεφαλή δείχνει στο στοιχείο της θέσης N τότε δεν υπάρχει χώρος στη
στοίβα. Άρα δεν εκτελείται η πράξη και αυτό δηλώνεται θέτοντας την μεταβλητή Check ως
ψευδή (FALSE). Στην αντίθετη περίπτωση υπάρχει χώρος για το στοιχείο Νum και τότε η
μεταβλητή Ηead αυξάνεται κατά ένα ώστε να δείχνει στην επόμενη θέση της στοίβας που είναι
κενή. Στη συνέχεια το στοιχείο Num εισάγεται στη θέση που δείχνει η Ηead. Η ένθεση
εκτελείται επιτυχώς και η μεταβλητή Check τίθεται αληθής (TRUE).
Παρακάτω δίνεται ο αλγόριθμος υλοποίησης της πράξης push σε ψευδοκώδικα:
Αλγόριθμος Push(Num)
Δεδομένα // Stack[N], Head, Num: ακέραιοι; Check: δυαδική //
Αρχή
Aν Head < N
Tότε
Ηead = Head+1
Stack[Head] = Num
Check = TRUE
Αλλιώς Check = FALSE
Τέλος Αν
Αποτελέσματα // Check //
Τέλος Push
Η πράξη Pop()
H πράξη απώθησης καλείται όταν θέλουμε να διαβάσουμε και να σβήσουμε παράλληλα
το στοιχείο-κεφαλή της στοίβας έστω Num. Η Pop είναι μία ανεξάρτητη διαδικασία και
συντάσσεται ως μία συνάρτηση μια τιμή τύπου ακεραίου. Όταν καλείται η pop επομένως έχει
την μορφή Pop() και επιστρέφει την κεφαλή της στοίβας εφόσον εκτελεστεί επιτυχώς.
Όταν γίνει αίτηση για απώθηση (Pop()) πρέπει να εξεταστεί αρχικά αν υπάρχουν στοιχεία
στη στοίβα. Eίπαμε ότι η μεταβλητή Head δείχνει στο τελευταίο στοιχείο της στοίβας. Αν
επομένως η Ηead έχει την τιμή 0 σημαίνει ότι η στοίβα είναι κενή και δεν εκτελείται η πράξη.
Τότε στη μεταβλητή Check τίθεται η τιμή FALSE. Στην αντίθετη περίπτωση υπάρχουν στοιχεία
στη στοίβα και η pop επιστρέφει την κεφαλή της που είναι το στοιχείο Stack[Head]. Η
μεταβλητή Ηead μειώνεται κατά ένα ώστε να δείχνει στο προηγούμενο στοιχείο της στοίβας
που θα αποτελεί στη συνέχεια και τη νέα κεφαλή. Η απώθηση εκτελείται επιτυχώς και η
μεταβλητή Check παίρνει την τιμή TRUE.
Παρακάτω δίνεται ο αλγόριθμος υλοποίησης της πράξης pop σε ψευδοκώδικα:
Αλγόριθμος Pop()
Είναι προφανές ότι και οι δύο πράξεις Push και Pop εκτελούνται σε σταθερό Ο(1) χρόνο.
Οι αλγόριθμοι των πράξεων Top() και Εmpty() είναι πιο απλοί. Η Τop() είναι ειδική
περίπτωση της Pop() όπου δεν ενημερώνουμε το δείκτη Head ενώ η πράξη Empty() μπορεί να
υλοποιηθεί με τη χρήση μιας μεταβλητής Check η οποία τίθεται ΤRUE αν Ηead = 0 και FALSE
διαφορετικά. Και αυτές οι πράξεις εκτελούνται σε χρόνο Ο(1).
int fact(int n)
{
if (n == 0) /* συνθήκη τερματισμού της αναδρομής */
return(1);
else
return(n*fact(n-1)); /* αναδρομική κλήση */
Μια σύντομη ανάλυση του προγράμματoς δείχνει ότι εκτελούνται συνολικά n κλήσεις
(δημιουργούνται n αντίγραφα) της fact(). H εξυπηρέτηση (επιστροφή της τιμής) των κλήσεων
ακολουθεί τη σειρά LIFO όπως φαίνεται στον πίνακα που ακολουθεί για n = 4:
Kλήση (αντίγραφο) Τιμή που επιστρέφεται
fact(0) 1
fact(1) 1*fact(0) = 1*1
fact(2) 2*fact(1) = 2*1*1
fact(3) 3*fact(2) = 3*2*1*1
fact(4) 4*fact(3) = 4*3*2*1*1
αναδρομή εξωτερικά με τη χρήση μιας στοίβας (εφόσον φυσικά είναι επιθυμητή μια
αναδρομική υλοποίηση). Πολλές φορές η προσέγγιση αυτή ακολουθείται και σε γλώσσες που
είναι αναδρομικές για λόγους απόδοσης επειδή η υλοποίηση των αναδρομικών κλήσεων από
το σύστημα είναι γενικά μια αρκετά χρονοβόρα διαδικασία λόγω των πολλών αλλαγών
περιβάλλοντος. Όταν δε οι κλήσεις είναι πολλές, η στοίβα του συστήματος μπορεί να
υπερχειλίσει.
Πολωνικός συμβολισμός
Σε πολλές περιπτώσεις ιδιαίτερα στην επιστήμη των υπολογιστών απαιτείται η χρήση
ενός συμβολισμού για τις αριθμητικές και λογικές παραστάσεις στον οποίο δεν
χρησιμοποιούνται παρενθέσεις. Για το λόγο αυτό ο πολωνός μαθηματικός, το 1951, Jan
Lukasiewicz παρουσίασε έναν συμβολισμό ο οποίος προς τιμή του ονομάστηκε πολωνικός.
Οι παραστάσεις τις οποίες χρησιμοποιεί ο άνθρωπος αποτελούνται από τους τελεστέους,
τους τελεστές καθώς και από παρενθέσεις. Η συνηθισμένη τους μορφή όπως την γνωρίζουμε
είναι οι τελεστές να παρεμβάλλονται ανάμεσα στους τελεστέους. Η μορφή αυτή των
παραστάσεων ονομάζεται ένθετη (infix). Σύμφωνα με τον πολωνικό συμβολισμό κάθε ένθετη
μορφή μπορεί να μετατραπεί στην προθεματική (prefix) στην οποία οι τελεστές προηγούνται
από τους τελεστέους αλλά και στην μεταθετική (postfix) στην οποία οι τελεστές ακολουθούν
τους τελεστέους.
Έτσι για παράδειγμα έχουμε:
Ένθετη Προθεματική Μεταθετική
1 a+b +ab ab+
2 a+b*c +a*bc abc*+
3 a*b*c **abc ab*c*
4 a*b+c*d +*ab*cd ab*cd*+
Ένα πρόβλημα που παρουσιάστηκε στον πολωνικό συμβολισμό αφορά στην πράξη της
αφαίρεσης. Το σύμβολο – μπορεί να θεωρηθεί σε μία παράσταση είτε ως πρόσημο είτε ως το
σύμβολο της πράξης αφαίρεσης. Στην περίπτωση του πολωνικού συμβολισμού δεν έχουμε
πράξη αφαίρεσης. Αυτή μετατρέπεται σε πράξη πρόσθεσης του αντίθετου του αριθμού.
Συγκεκριμένα, όταν ζητείται η εκτέλεση της πράξης a-b τότε αυτή μετατρέπεται στην πράξη a+(-
b). Στην συνέχεια στον πολωνικό συμβολισμό αφαιρούνται και οι παρενθέσεις όπως θα δούμε
παρακάτω.
Επίσης πρέπει να τονιστεί ότι βασικό στοιχείο αποτελεί ο ορισμός του πλήθους των
τελεστέων που αντιστοιχούν σε ένα τελεστή.
Παράλληλα είναι σημαντικό να γνωρίζουμε ότι τόσο στην ένθετη μορφή των
παραστάσεων όσο και στη μεταθετική και προθεματική ισχύει μία ιεραρχία ή προτεραιότητα
στις αριθμητικές πράξεις. Αυτή η ιεραρχία στις βασικές πράξεις είναι:
1. Ύψωση σε δύναμη.
2. Πολλαπλασιασμός και διαίρεση.
3. Πρόσθεση και αφαίρεση.
Οι πράξεις που ανήκουν στο ίδιο επίπεδο ιεραρχίας (έχουν την ίδια προτεραιότητα)
εκτελούνται από αριστερά προς τα δεξιά. Αυτή ακριβώς η σειρά προτεραιότητας τηρείται και
κατά τον υπολογισμό των τιμών των αριθμητικών παραστάσεων σε μια γλώσσα
προγραμματισμού.
Οι εφαρμογές που έχει ο πολωνικός συμβολισμός είναι αρκετές. Στις περισσότερες
γλώσσες προγραμματισμού μπορεί ο προγραμματιστής να εισάγει εντολές για την εκτέλεση
παραστάσεων στην ένθετη μορφή, όμως ο μεταγλωττιστής της γλώσσας αρχικά μετατρέπει
την παράσταση στην προθεματική ή την μεταθετική μορφή και στη συνέχεια εκτελεί τις
πράξεις. Επίσης, ανάλογη διαδικασία εκτελείται και σε διάφορους τύπους
προγραμματιζόμενων αριθμομηχανών κ.α.
Ο πιο συνηθισμένος συμβολισμός παραστάσεων στις γλώσσες προγραμματισμού είναι
της μεταθετικής μορφής. Παρακάτω θα εξεταστεί ο τρόπος με τον οποίο μετατρέπεται η ένθετη
μορφή μιας παράστασης είτε με παρενθέσεις είτε όχι στην αντίστοιχη μεταθετική. Επίσης θα
εξεταστεί ο τρόπος εκτέλεσης πράξεων με δοσμένη την παράσταση στην μεταθετική μορφή. Σε
όλες τις παραπάνω περιπτώσεις χρησιμοποιούνται στοίβες.
c *+ abc
^ ^*+ abc
e ^*+ abce
+ + abce^*+
d + abce^*+d
/ /+ abce^*+d
f /+ abce^*+df
- abce^*+df/+
Υποθέστε ότι έχουμε την παράσταση (a+b)*(c-d)^e*f στην ένθετη μορφή. Αρχικά
μετατρέπουμε την πράξη αφαίρεσης σε πρόσθεση και παίρνουμε (a+b)*(c+-d)^e*f. Εξετάζουμε
τα σύμβολα της παράστασης διαδοχικά ξεκινώντας από τα αριστερά και έχουμε:
Σύμβολα στην ένθετη μορφή Στοίβα Μεταθετική μορφή
( (
a ( a
+ +( a
b +( ab
) - ab+
* * ab+
( (* ab+
c (* ab+c
+ +(* ab+c
-d +(* ab+c-d
) * ab+c-d+
^ ^* ab+c-d+
e ^* ab+c-d+e
* * ab+c-d+e^*
f * ab+c-d+e^*f
- - ab+c-d+e^*f*
Στην ουρά όμως δεν έχουμε μόνο ένα σημείο εισόδου και εξόδου. Στοιχεία εισέρχονται
από το ένα άκρο της ουράς, το πίσω έστω το τέλος της ουράς ενώ εξέρχονται (απωθούνται)
από το εμπρός μέρος της ουράς έστω την αρχή της. Μπορούμε επομένως να φανταστούμε την
ουρά σαν ένα σωλήνα στον οποίο εισέρχονται στοιχεία από το ένα άκρο του (πίσω-τέλος) και
εξέρχονται από το άλλο άκρο (εμπρός-αρχή).
Έστω επομένως έχουμε μία ουρά στην οποία έχουμε αποθηκευμένα τα στοιχεία X, Y, Z
και V όπως φαίνεται στο παρακάτω σχήμα.
X Y Z V
Στην ουρά αυτή υπάρχουν δύο βασικές επιλογές λειτουργιών. Ένας χρήστης μπορεί να
ενθέσει ένα στοιχείο στην ουρά, έστω P με την εκτέλεση της πράξης Enqueue(P). Τότε η ουρά
αποκτά την ακόλουθη μορφή:
X Y Z V P
Επίσης, ένας χρήστης μπορεί να απωθήσει ένα στοιχείο από την ουρά με την πράξη
Dequeue(). Συγκεκριμένα, η πράξη dequeue προσπελαύνει το πρώτο στοιχείο της ουράς και
στη συνέχεια το σβήνει από αυτή. Τότε η ουρά αποκτά την ακόλουθη μορφή:
Σχήμα 9: Η ουρά μετά την απώθηση του πρώτου της στοιχείου (Χ)
Bεβαίως υπάρχουν και άλλες πράξεις που εφαρμόζονται σε μία ουρά, όχι βέβαια τόσο
σημαντικές όσο οι προηγούμενες δύο που αναφέρθηκαν και για το λόγο αυτό δεν θα
ασχοληθούμε στη συνέχεια μ’ αυτές.
X Back
V Head
Αρχικά η ουρά είναι άδεια και ισχύει Head=0 και Back=0 (η ίδια συνθήκη ισχύει και κάθε
φορά που η ουρά αδειάζει).
Όταν εισάγεται για πρώτη φορά σε μια άδεια ουρά ένα στοιχείο τότε αυξάνεται κατά ένα η
Back (Back=1) και το στοιχείο καταλαμβάνει τη θέση Queue[Back]=Queue[1]. Παράλληλα η
head (που είναι μηδέν εφόσον ήταν άδεια η ουρά) αυξάνεται κατά ένα και δείχνει στη θέση
Queue[1]. Το επόμενο στοιχείο πηγαίνει στη θέση Queue[2] και η Back=2 (η head τώρα δεν
αλλάζει).
Αν θέλουμε να εξάγουμε ένα στοιχείο τότε παίρνουμε το Queue[Head] = Queue[1] και
αυξάνουμε κατά ένα την Head και γίνεται 2 δείχνοντας στο επόμενο στοιχείο της κεφαλής της
ουράς.
Από το παραπάνω σχήμα καταλαβαίνουμε ότι εισήχθησαν στην ουρά 8 στοιχεία και
απωθήθηκαν 4. Το 5o στοιχείο που εισήχθη είναι το V το οποίο είναι και στην κεφαλή της
ουράς για να απωθηθεί.
Πράξη Enqueue()
Αν θεωρήσουμε ότι ουρά θα περιέχει ακεραίους αριθμούς (ο πίνακας Queue[N] δηλώνεται
ως ένας πίνακας ακεραίων) τότε η πράξη ένθεσης καλείται όταν θέλουμε να εισάγουμε ένα
στοιχείο στην ουρά έστω Num (η μεταβλητή Νum είναι τύπου ακεραίου). Η Enqueue είναι μία
ανεξάρτητη διαδικασία η οποία καλείται από το κύριο πρόγραμμα όταν θέλουμε να εισάγουμε
ένα στοιχείο στην ουρά. Συντάσσεται επομένως ως μία συνάρτηση στην περίπτωση μας
τύπου ακεραίου στην οποία μεταφέρεται μαζί ως αρχικά δεδομένα και η τιμή του στοιχείου
num. Όταν καλείται η Enqueue επομένως έχει την μορφή Enqueue(Νum).
Όταν γίνει αίτηση για εισαγωγή πρέπει να εξεταστεί αρχικά το γεγονός αν υπάρχει
διαθέσιμος χώρος στην ουρά για να εισαχθεί το στοιχείο num. Τονίσαμε ότι η μεταβλητή Back
δείχνει στο τελευταίο στοιχείο της ουράς (που εισήχθη και τελευταίο) και έχει στο παράδειγμα
μας την τιμή Ν. Αν επομένως στο παράδειγμα μας, η Back δείχνει στο στοιχείο N τότε δεν
υπάρχει χώρος στην ουρά. Άρα δεν εκτελείται η πράξη και αυτό δηλώνεται ορίζοντας την
μεταβλητή Check ως FALSE. Στην αντίθετη περίπτωση υπάρχει χώρος για το στοιχείο num
και τότε η μεταβλητή Back αυξάνεται κατά ένα ώστε να δείχνει στην επόμενη θέση της ουράς
που είναι κενή. Στη συνέχεια το στοιχείο Num εισάγεται στη θέση που δείχνει η Back. Η
ένθεση εκτελείται επιτυχώς και τίθεται η μεταβλητή Check ως TRUE.
Παράλληλα εξετάζουμε την περίπτωση που ένα στοιχείο εισάγεται για πρώτη φορά σε μια
άδεια ουρά. Τότε, όπως αναφέραμε ήδη, αυξάνεται κατά ένα η Back (Back=1) και το στοιχείο
καταλαμβάνει τη θέση Queue[Back]=Queue[1]. Παράλληλα η Head (που έχει τιμή 0 εφόσον η
ουρά ήταν άδεια) αυξάνεται κατά ένα και δείχνει στο στοιχείο Queue[1].
Παρακάτω δίνεται ο αλγόριθμος υλοποίησης της πράξης Enqueue σε ψευδοκώδικα:
Πράξη Dequeue()
Η πράξη απώθησης καλείται όταν θέλουμε να διαβάσουμε και να σβήσουμε παράλληλα
το στοιχείο στην κεφαλή της ουράς, έστω Νum. Η Dequeue είναι μία ανεξάρτητη διαδικασία
και υλοποιείται ως μία συνάρτηση που στην περίπτωσή μας επιστρέφει μία ακέραια τιμή. Η
κλήση της επομένως έχει την μορφή Dequeue() και επιστρέφει το στοιχείο-κεφαλή της ουράς
(αν εκτελεστεί επιτυχώς).
Όταν γίνει αίτηση για απώθηση (κλήση Dequeue()), πρέπει να εξεταστεί αρχικά αν
υπάρχουν στοιχεία στην ουρά.. Είπαμε ότι η μεταβλητή Head δείχνει στο πρώτο στοιχείο της
ουράς (κεφαλή). Αν επομένως η Head έχει την τιμή 0 σημαίνει ότι η ουρά είναι κενή και δεν
εκτελείται η πράξη. Επομένως επιστρέφεται η μεταβλητή Check ως FALSE. Στην αντίθετη
περίπτωση υπάρχουν στοιχεία στην ουρά και η Dequeue επιστρέφει το στοιχείο της κεφαλής
που είναι το Queue[Head]. Η μεταβλητή Ηead αυξάνεται κατά ένα ώστε να δείχνει στο
επόμενο στοιχείο της ουράς που θα αποτελεί στη συνέχεια και την κεφαλή της. Η απώθηση
εκτελείται επιτυχώς και η μεταβλητή Check τίθεται TRUE.
Τέλος, πρέπει να εξεταστεί η περίπτωση αν μετά από κάθε απώθηση άδειασε η ουρά.
Αυτό συμβαίνει αν η Head, αυξανόμενη κατά 1, πάρει τιμή μεγαλύτερη της Back. Τότε έχει
απωθηθεί το τελευταίο στοιχείο της ουράς. Στην περίπτωση αυτή η ουρά είναι άδεια και
επομένως μπορούμε να την αρχικοποιήσουμε θέτοντας στις μεταβλητές Head και Back την
τιμή 0.
Παρακάτω δίνεται ο αλγόριθμος υλοποίησης της πράξης Dequeue σε μορφή
ψευδοκώδικα:
Head = 0
Back = 0
Τέλος Αν
Αλλιώς Check=FALSE
Τέλος Αν
Αποτελέσματα // Check, Νum //
Τέλος Dequeue Pipeline
Από την υλοποίηση των πράξεων Enqueue και Dequeue καταλαβαίνουμε ότι μπορεί η
μεταβλητή Back να δείχνει στην τιμή N χωρίς αυτό να σημαίνει ότι η ουρά είναι γεμάτη. Είναι
γεμάτη μόνο αν Back = N και Head = 1. Αν επομένως κάποια στιγμή η Back πάρει την τιμή N
τότε ελέγχουμε την τιμή της Head. Αν είναι 1 τότε η ουρά είναι γεμάτη, διαφορετικά υπάρχουν
κενές θέσεις στην ουρά που προέκυψαν στην αρχή της από τις συνεχόμενες απωθήσεις
στοιχείων. Στην περίπτωση αυτή πρέπει να εκτελεστεί μία ρουτίνα η οποία να μετακινεί όλα τα
στοιχεία της ουράς της προς την αρχή της ώστε η Head να γίνει 1 και η Back να πάρει μία τιμή
ανάλογα με το πλήθος των στοιχείων της ουράς και μικρότερη από την τιμή N. Aυτή τη ρουτίνα
μετακίνησης στοιχείων ονομάζεται Fragm() και δίνεται στη συνέχεια σε μορφή ψευδοκώδικα:
Αλγόριθμος Fragm()
Δεδομένα // Queue[N], Head, Back, i: ακέραιοι //
Αρχή
Για i = Head Μέχρι i = Back με Βήμα 1 Εκτέλεσε
Queue[i-Head+1] = Queue[i]
Tέλος Eπανάληψης
Back = Back-Head+1
Head = 1
Αποτελέσματα // Head, Back //
Τέλος Fragm
Στη συνέχεια δίνεται ο ψευδοκώδικας για την πράξη Enqueue η οποία κάνει χρήση της
ρουτίνας Fragm() . Τώρα στη χειρότερη περίπτωση η Enqueue έχει πολυπλοκότητα O(N).
Queue[Back] = num
Check = TRUE
Αλλιώς
Αν Head = 1 Τότε Check = FALSE
Αλλιώς κάλεσε τη Fragm() και κάνε μετά την ένθεση
Tέλος Αν
Τέλος Αν
Αποτελέσματα // Check //
Τέλος Εnqueue Pipeline with Fragm
N W
Y Head
Z Back
1 V
Για να υλοποιήσουμε τις πράξεις Enqueue και Dequeue σε μια ουρά δακτυλίου θα
χρησιμοποιήσουμε μία μεταβλητή counter η οποία θα κρατά το πλήθος των στοιχείων της
ουράς. Όταν counter = 0, τότε η ουρά είναι άδεια ενώ όταν Counter = N η ουρά είναι γεμάτη.
Οι αλγόριθμοι για τις πράξεις Enqueue και Dequeue ελέγχουν κάθε φορά την τιμή της
μεταβλητής Counter και εκτελούν την πράξη αναπροσαρμόζοντας τις τιμές Back και Ηead
κατάλληλα εφόσον χρειάζεται. Στη συνέχεια δίνονται οι αλγόριθμοι σε μορφή ψευδοκώδικα:
2.4.1 Εφαρμογές
Οι λίστες χρησιμοποιούνται ευρύτατα στον προγραμματισμό εφαρμογών λόγω του
αποδοτικού τρόπου διαχείρισης της μνήμης. Ενδεικτικές περιπτώσεις είναι οι κάτωθι:
1. Δημιουργία πινάκων με μη προκαθορισμένο μέγεθος μνήμης. Δηλαδή, όταν γνωρίζουμε
το μέγεθος μνήμης που θα χρησιμοποιήσουμε μπορούμε να χρησιμοποιήσουμε ένα
πίνακα συγκεκριμένου μεγέθους ενώ σε αντίθετη περίπτωση μία λίστα.
2. Αποθήκευση πληροφοριών σε βάσεις δεδομένων. Στην περίπτωση αυτή τα δεδομένα
αποθηκεύονται σε αρχεία του δίσκου (δευτερεύουσα μνήμη). Οι λίστες στην περίπτωση
αυτή μας βοηθούν στην εγγραφή και διαγραφή στοιχείων από τα αρχεία του δίσκου
γρήγορα και εύκολα, χωρίς να απαιτείται η αναδιάταξη ολόκληρου του αρχείου. Τα αρχεία
συνήθως αποτελούνται από πολλά δεδομένα ενώ στην πλειοψηφία τους οι εφαρμογές
διαχείρισης βάσεων δεδομένων είναι δυναμικές εφαρμογές με συνεχείς λειτουργίες
ανάκτησης, εισαγωγής και διαγραφής.
μπορούμε να “διατρέξουμε” όλους τους κόμβους μέχρι τον τελευταίο. Αυτός αναγνωρίζεται από
το γεγονός ότι ο δείκτης στο επόμενο στοιχείο του έχει την τιμή ΝULL (κενός δείκτης).
Παρακάτω δίνεται ένα παράδειγμα απλά συνδεδεμένης λίστας όπου η τιμή NULL
απεικονίζεται με το 0.
Α Β C N
0
Επίσης, ένα άλλο είδος λίστας είναι η διπλά συνδεδεμένη λίστα ή συμμετρική
(symmetric list). Στις λίστες αυτές περιέχονται σε κάθε κόμβο σύνδεσμοι όχι μόνο για το
επόμενο στοιχείο – κόμβο αλλά και για το προηγούμενο. Παρακάτω δίνεται ένα παράδειγμα
διπλής συνδεδεμένης λίστας.
Α Β C N
0 0
Ένα ακόμα είδος λίστας είναι η κυκλική (circular list) που φαίνεται στο επόμενο σχήμα.
Ουσιαστικά είναι μία απλά συνδεδεμένη λίστα με την διαφορά ότι ο δείκτης επόμενου
στοιχείου του τελευταίου της κόμβου δεν περιέχει την τιμή 0 (NULL) αλλά δείχνει στο πρώτο
στοιχείο της λίστας. Η συγκεκριμένη δομή εισάγει αρκετές διαφοροποιήσεις στον τρόπο
υλοποίησης των βασικών πράξεων. Παρακάτω δίνεται ένα παράδειγμα κυκλικής λίστας.
Α Β C N
• Οι λίστες είναι είτε στατικές είτε δυναμικές δομές δεδομένων. Όταν το μέγεθος της λίστας
δηλαδή το σύνολο των κόμβων είναι προκαθορισμένο τότε η λίστα είναι στατική. Όταν
όμως δεν το γνωρίζουμε το μέγεθός της και κάθε φορά που δημιουργείται ένας κόμβος
(εισαγωγή ενός στοιχείου) δεσμεύουμε τον χώρο αποθήκευσης στην μνήμη για αυτόν, τότε
η λίστα είναι δυναμική.
• Για την υλοποίηση μίας λίστας μπορούμε να χρησιμοποιήσουμε είτε έναν αριθμητικό
πίνακα στον οποίο θα αποθηκεύονται όλοι οι σύνδεσμοι των κόμβων δηλαδή οι θέσεις
των επόμενων στοιχείων είτε, αν και η γλώσσα προγραμματισμού το επιτρέπει, δείκτες. Οι
δείκτες είναι παρέχουν εύκολη δημιουργία, διαχείριση, και διαγραφή στοιχείων από μία
λίστα. Μερικές γλώσσες προγραμματισμού διευκολύνουν το χειρισμό λιστών όπως είναι η
LISP (List Processing Language) και η Prolog. Οι περισσότερες όμως παρέχουν τη
δυνατότητα χρήσης δεικτών και έτσι η επεξεργασία μία λίστας γίνεται εύκολα και
αποδοτικά.
Οι βασικές πράξεις που υποστηρίζει μια λίστα είναι:
1. Εισαγωγή (insertion) στοιχείου. Για να γίνει η εισαγωγή ενός στοιχείου πρέπει να
δημιουργηθεί ένας νέος κόμβος στην λίστα στον οποίο να αποθηκευτεί. Η θέση του
κόμβου ορίζεται με βάση τις απαιτήσεις του προγράμματος. Σε κάθε περίπτωση
ανάλογα με το είδος της λίστας (απλή, διπλή ή κυκλική) γίνεται μόνο τροποποίηση
των περιεχομένων των συνδέσμων επόμενου στοιχείου των κατάλληλων κόμβων.
2. Διαγραφή (deletion) ενός στοιχείου. Για να γίνει η διαγραφή ενός στοιχείου από τη
λίστα αρκεί να τροποποιήσουμε το περιεχόμενο του συνδέσμου του επόμενου
στοιχείου του προηγούμενου κόμβου.
3. Σάρωση (traversal) ή διαπέραση λίστας. Με την πράξη αυτή προσπελαύνονται όλα
τα στοιχεία της λίστας ξεκινώντας από το πρώτο και ακολουθώντας τους κόμβους
μέσω των συνδέσμων τους μέχρι το τελευταίο στοιχείο που ο σύνδεσμος του
επόμενου στοιχείου του έχει την τιμή NULL (0).
4. Αναζήτηση (search) στοιχείου. Με την πράξη αυτή μπορεί να γίνει η αναζήτηση ενός
στοιχείου που επιθυμούμε. Για την υλοποίηση της πράξης αυτής χρησιμοποιείται η
πράξη σάρωσης της λίστας. Η λίστα είναι μία γραμμική δομή άρα και η διαπέραση
της μέσω των συνδέσμων είναι γραμμική.
5. Συνένωση (concatenation) λιστών. Με την πράξη αυτή είναι δυνατή η συνένωση
δύο λιστών σε μία λίστα διατηρώντας αρχικές προϋποθέσεις που δίνονται. Δηλαδή η
συνένωση μπορεί να γίνει απλά τοποθετώντας τη μία λίστα μετά την άλλη είτε με
συγκεκριμένο αλγόριθμο στην περίπτωση π.χ. που οι αρχικές λίστες είναι
διατεταγμένες και επιθυμούμε η νέα λίστα να είναι και αυτή διατεταγμένη.
6. Αντιστροφή (reversal) λίστας. Με την πράξη αυτή αλλάζει η φορά διάταξης της
λίστας ορίζοντας τον τελευταίο κόμβο ως πρώτο και αυτόν τελευταίο τροποποιώντας
κατάλληλα τους συνδέσμους όλων των κόμβων της λίστας.
Για τις πράξεις που θα δούμε στη συνέχεια θεωρούμε ότι έχουμε μια απλά συνδεδεμένη
λίστα ακεραίων και χρησιμοποιούμε τη μεταβλητή δείκτη node που δείχνει σε έναν κόμβο της
λίστας. Στη C o συμβολισμός node->num αναφέρεται στον ίδιο τον ακέραιο αριθμό του
κόμβου που δείχνει ο node ενώ ο συμβολισμός node->next αναφέρεται στο δείκτη του
κόμβου προς τον επόμενό του. Τέλος, για την επεξεργασία μίας λίστας ορίζουμε και δύο
δείκτες, έστω head και last, που δείχνουν στον πρώτο και στον τελευταίο κόμβο της
λίστας.
Οι τυπικές δηλώσεις στη C για τα παραπάνω έχουν ως εξής:
Struct listnode {
int num;
Struct listnode *next;
};
Struct listnode *head, *last, *node;
Εισαγωγή στοιχείου
Για να γίνει η εισαγωγή ενός στοιχείου πρέπει να δημιουργηθεί ένας νέος κόμβος στην
λίστα στον οποίο να αποθηκευτεί. Η θέση του κόμβου ορίζεται με βάση τις απαιτήσεις του
προγράμματος. Σε κάθε περίπτωση, ανάλογα με το είδος της λίστας (απλή, διπλή ή κυκλική),
γίνεται μόνο τροποποίηση των περιεχομένων των συνδέσμων επόμενου στοιχείου των
κατάλληλων κόμβων.
Πιο συγκεκριμένα, μπορεί ο κόμβος να εισαχθεί στο μέσο της λίστας όπου ο σύνδεσμος
επόμενου στοιχείου του προηγούμενου κόμβου θα δείχνει στον νέο κόμβο. ενώ ο σύνδεσμος
του νέου κόμβου θα πάρει την τιμή του είχε ο σύνδεσμος του προηγούμενου. Στην περίπτωση
που ο κόμβος – στοιχείο εισάγεται στο τέλος της λίστας τότε ο σύνδεσμος επόμενου στοιχείου
του τελευταίου κόμβου που είναι NULL πλέον δείχνει στο νέο στοιχείο ο σύνδεσμος του
οποίου παίρνει την τιμή NULL και είναι ο τελευταίος κόμβος. Οι πιο συνηθισμένες
περιπτώσεις εισαγωγής στοιχείων σε λίστες είναι είτε εισαγωγή στοιχείου σε ταξινομημένη
λίστα είτε στον τέλος μιας απλά συνδεδεμένης λίστας.
Στο παρακάτω σχήμα, φαίνεται η εισαγωγή ενός στοιχείου σε μία απλά συνδεδεμένη
λίστα.
Α Β C N
0
Α Β C N
0
Αλγόριθμος Εισαγωγής
Δεδομένα // Ο δείκτης pro_node που δείχνει τον κόμβο μετά τον οποίο θα εισαχθεί το
στοιχείο, ο δείκτης node που δείχνει τη θέση (διεύθυνση) του νέου κόμβου και
το στοιχείο x που είναι ο ακέραιος που θα εισαχθεί //
Αρχή
node->num = x
node->next = pro_node->next
pro_node->next = node
Τέλος Εισαγωγής
Δεδομένου ότι έχουμε τη θέση στην οποία εισάγεται το στοιχείο x (δείκτης pro_node), ο
παραπάνω αλγόριθμος εκτελείται σε χρόνο Ο(1).
Πρέπει να σημειώσουμε ότι ο παραπάνω αλγόριθμος εισαγωγής προϋποθέτει ότι έχει
γίνει μία αίτηση δέσμευσης χώρου από τη μνήμη του συστήματος για τον κόμβο node. Μία
τέτοια αίτηση στις γλώσσες προγραμματισμού είναι μία ρουτίνα ή συνάρτηση. Τέτοια
συνάρτηση στη γλώσσα C είναι η malloc() η οποία συντάσσεται στην περίπτωσή μας ως
εξής:
node = (struct listnode *)malloc(sizeof(struct listnode));
Η malloc() είτε επιστρέφει μία διεύθυνση μνήμης στην οποία μπορεί να αποθηκευτεί ο
κόμβος είτε επιστρέφει την τιμή NULL που σημαίνει ότι δεν μπορεί να ικανοποιηθεί το αίτημα.
Επίσης, τα bytes του χώρου της μνήμης δεν παίρνουν αρχική τιμή. Η λειτουργία της malloc()
απεικονίζεται σχηματικά παρακάτω:
Σχήμα 16: Δυναμική δέσμευση μνήμης για τη δομή του δείκτη node με τη χρήση της
malloc()
Διαγραφή στοιχείου
Για να γίνει η διαγραφή ενός στοιχείου από τη λίστα αρκεί να τροποποιήσουμε το
περιεχόμενο του συνδέσμου επόμενου στοιχείου του προηγούμενου κόμβου. Αν αυτός δείχνει
στον κόμβο που δείχνει ο σύνδεσμος του προς διαγραφή κόμβου τότε ουσιαστικά δεν υπάρχει
κόμβος που να δείχνει σ’ αυτόν ενώ η αλυσίδα στοιχείων της λίστας δεν “κόβεται”.
Στο παρακάτω σχήμα, φαίνεται η διαγραφή ενός στοιχείου από μία απλά συνδεδεμένη
λίστα.
Α Β C N
0
Α Β C N
0
Σχήμα 17: Διαγραφή του στοιχείου Β από μια απλά συνδεδεμένη λίστα
Στην περίπτωση της διαγραφής ενός κόμβου θα πρέπει να επιστρέψουμε στο σύστημα το
χώρο που καταλαμβάνει ο κόμβος αυτός ώστε να μπορεί να χρησιμοποιηθεί από άλλη
εφαρμογή. Αν node είναι ο δείκτης στον κόμβο, τότε στη C η αποδέσμευση χώρου γίνεται με
την εκτέλεση της τύπου void συνάρτησης:
free(node);
Η λειτουργία της free() μπορεί να παρασταθεί με το ακόλουθο σχήμα:
Σχήμα 18: Αποδέσμευση μνήμης που καταλάμβανε η δομή του δείκτη node μετά την
εκτέλεση της free(node)
Αλγόριθμος Διαγραφής
Δεδομένα // Ο δείκτης pro_node που δείχνει τον προηγούμενο κόμβο από αυτόν που
θέλουμε να διαγράψουμε //
Αρχή
node = pro_node->next
pro_node->next = node->next
Aπελευθέρωσε το χώρο που καταλαμβάνει ο node
Τέλος Διαγραφής
Η πράξη της σάρωσης εκτελείται σε χρόνο Θ(μέγεθος λίστας) δεδομένου ότι ο αλγόριθμος
επισκέπτεται όλους τους κόμβους της λίστας και σε κάθε κόμβο ξοδεύει Ο(1) χρόνο.
Αναζήτηση στοιχείου
Με την πράξη αυτή μπορεί να γίνει η αναζήτηση ενός στοιχείου που επιθυμούμε. Για την
υλοποίηση της πράξης αυτής χρησιμοποιείται η πράξη της σάρωσης ελαφρά παραλλαγμένη.
Ο αλγόριθμος αναζήτησης του στοιχείου x στη λίστα παρουσιάζεται παρακάτω:
Συνένωση λιστών
Με την πράξη αυτή είναι δυνατή η συνένωση δύο λιστών σε μία διατηρώντας τις αρχικές
προϋποθέσεις που δίνονται. Δηλαδή η συνένωση μπορεί να γίνει απλά τοποθετώντας τη μία
λίστα μετά την άλλη είτε με συγκεκριμένο αλγόριθμο στην περίπτωση πχ. που οι αρχικές
λίστες είναι ταξινομημένες και επιθυμούμε η νέα λίστα να είναι και αυτή ταξινομημένη.
Παρακάτω δίνουμε τον αλγόριθμο συνένωσης για την απλή περίπτωση:
Αντιστροφή λίστας
Με την πράξη αυτή αλλάζει η φορά διάταξης της λίστας ορίζοντας τον τελευταίο κόμβο ως
πρώτο και τον πρώτο τελευταίο τροποποιώντας κατάλληλα τους συνδέσμους όλων των
κόμβων της λίστας. Η διαδικασία αυτή φαίνεται στο επόμενο σχήμα:
Ο αλγόριθμος της αντιστροφής χρησιμοποιεί δύο βοηθητικές μεταβλητές που ελέγχουν την
προσπέλαση στους κόμβους της λίστας:
Εδώ ο χρόνος εκτέλεσης του αλγόριθμου είναι Ο(μέγεθος λίστας) αφού επισκέπτεται
όλους τους κόμβους για να ενημερώσει κατάλληλα το δείκτη next του κόμβου.
Oι αλγόριθμοι για τις πράξεις push(x) και pop() θα είχαν τώρα την εξής μορφή:
Αλγόριθμος Push(x)
Δεδομένα // Δείκτης head που δείχνει στο πρώτο στοιχείο (κορυφή) της στοίβας; x:
ακέραια; p βοηθητικός δείκτης σε κόμβο της στοίβας //
Αρχή
Δέσμευσε χώρο για το δείκτη p
Αν δεν είναι δυνατή η δέσμευση χώρου Τότε Επέστρεψε FALSE
Αλλιώς
p->num = x
p->next = head
head = p
Επέστρεψε ΤRUE
Tέλος Αν
Τέλος Push
Αλγόριθμος Pop()
Δεδομένα // Δείκτης head που δείχνει στο πρώτο στοιχείο (κορυφή) της στοίβας; x:
ακέραια; p βοηθητικός δείκτης σε κόμβο της στοίβας //
Αρχή
Αν (head = NULL) Τότε Επέστρεψε FALSE
Αλλιώς
p = head
x = p->num /* Στη σφαιρική μεταβλητή x επιστρέφεται το αποτέλεσμα της Pop */
head = head->next
Απελευθέρωσε το χώρο του p
Επέστρεψε ΤRUE
Tέλος Αν
Τέλος Pop
Για τη δυναμική υλοποίηση μιας ουράς αρκεί επίσης μια απλά συνδεδεμένη λίστα με τους
δείκτες head και last να δείχνουν στο πρώτο και στο τελευταίο στοιχείο της λίστας αντίστοιχα.
H πράξη enque(x) εισάγει το στοιχείο x πάντα μετά τον κόμβο που δείχνει ο δείκτης last και
παράλληλα ενημερώνει τον last, ενώ η πράξη Dequeue() επιστρέφει στη σφαιρική μεταβλητή x
την τιμή του κόμβου head ενημερώνοντας επίσης τον head.
Για να δούμε τη διαχείριση και μιας διπλά συνδεδεμένης λίστας, στη συνέχεια υλοποιούμε
την ουρά με μία τέτοια λίστα μέσω των ακόλουθων C δηλώσεων:
Struct queuenode {
int num;
Struct queuenode *next, *previous;
};
Αλγόριθμος Εnqueue(x)
Δεδομένα // Δείκτες head, last που δείχνoυν στο πρώτο και τελευταίο στοιχείο της ουράς
αντίστοιχα; x: ακέραια; p βοηθητικός δείκτης σε κόμβο της oυράς //
Αρχή
Δέσμευσε χώρο για το δείκτη p
Αν δεν είναι δυνατή η δέσμευση χώρου Τότε Επέστρεψε FALSE
Αλλιώς
p->num = x
p->next = NULL
p->previous = last
Αν (last = ΝULL) Τότε head = p // Η ουρά είναι άδεια //
Αλλιώς last->next = p
Τέλος Αν
last = p
Επέστρεψε ΤRUE
Tέλος Αν
Τέλος Εnqueue
Αλγόριθμος Dequeue()
Δεδομένα // Δείκτες head, last που δείχνoυν στο πρώτο και τελευταίο στοιχείο της ουράς
αντίστοιχα; x: ακέραια; p βοηθητικός δείκτης σε κόμβο της oυράς //
Αρχή
Αν (head = NULL) Τότε Επέστρεψε FALSE
Αλλιώς
p = head
x = p->num
head = head->next
Αν (head = ΝULL) Τότε last = NULL // Η ουρά είχε ένα μόνο στοιχείο //
Αλλιώς head->previous = NULL
Τέλος Αν
Απελευθέρωσε το χώρο του p
Επέστρεψε ΤRUE
Tέλος Αν
Τέλος Dequeue
Ερωτήσεις Κεφαλαίου
1. Τι είναι οι γραμμικές δομές δεδομένων; Αναφέρατε τις σημαντικότερες από αυτές.
2. Αναφέρατε μερικά παραδείγματα μορφών πινάκων.
3. Τι είναι οι συμβολοσειρές; Ποια είναι η κύρια διαφορά όταν σε πίνακα κάθε στοιχείο του
που είναι συμβολοσειρά είναι σταθερού ή μεταβλητού μεγέθους;
4. Εξηγείστε γιατί ο αλγόριθμος εύρεσης του μεγαλύτερου στοιχείο ενός πίνακα που είδαμε
είναι βέλτιστος σε σχέση με το πλήθος συγκρίσεων.
5. Περιγράψτε τη δομή της στοίβας και τις πράξεις που υποστηρίζει.
6. Δώστε τους αλγόριθμους των πράξεων push και pop σε μία στοίβα που υλοποιείται με
πίνακα.
7. Eξηγείστε τον τρόπο υλοποίησης της αναδρομής σε μια γλώσσα προγραμματισμού.
8. Τι είναι ο πολωνικός συμβολισμός. Πως υλοποιείται η μετατροπή μιας παράστασης από
την ένθετη στη μεταθετική μορφή;
9. Τι είναι η ουρά. Ποιες οι βασικές της πράξεις;
10. Δώστε τους αλγόριθμους των πράξεων enqueue και dequeue σε μία ουρά pipeline και σε
μια ουρά δακτυλίου.
11. Ποια είναι τα δύο κοινά χαρακτηριστικά της ουράς και της στοίβας;
12. Τι είναι λίστα. Ποιες οι βασικές της εφαρμογές; Ποιες οι βασικές πράξεις που υποστηρίζει;
13. Δώστε τους αλγόριθμους εισαγωγής και διαγραφής σε μία απλά συνδεδεμένη λίστα.
14. Δώστε τον αλγόριθμο αντιστροφής μιας απλά συνδεδεμένης λίστας.
15. Δώστε τους αλγόριθμους των πράξεων push και pop σε μία στοίβα που υλοποιείται
δυναμικά ως μία απλά συνδεδεμένη λίστα.
16. Δώστε τους αλγόριθμους των πράξεων enqueue και dequeue σε μία ουρά που
υλοποιείται δυναμικά: (α) ως μία απλά συνδεδεμένη λίστα και (β) ως μία διπλά
συνδεδεμένη λίστα.
Στη συνέχεια εξετάζουμε κάθε έναν από τους παραπάνω αλγόριθμους και αναλύουμε την
πολυπλοκότητά του στη μέση και χειρότερη περίπτωση. Στο τέλος του κεφαλαίου
αποδεικνύουμε ένα κάτω φράγμα στη χρονική πολυπλοκότητα των συγκριτικών αλγόριθμων
ταξινόμησης.
Αλγόριθμος Bubblesort
Είσοδος (S, n)
Δεδομένα // i, j: ακέραιοι //
Αρχή
Για i = 2 Μέχρι n με Βήμα 1
Για j = n Μέχρι i με Βήμα -1
Αν S[j-1] > S[j] Τότε
Αντιμετάθεσε S[j-1] , S[j]
Τέλος Αν
Τέλος Επανάληψης
Τέλος Επανάληψης
Αποτελέσματα // Ταξινομημένος πίνακας S[1..n] //
Τέλος Bubblesort
Από τον τρόπο λειτουργίας του βασικού αλγόριθμου προκύπτει ότι για το πλήθος των
συγκρίσεων T(n) που εκτελούνται ισχύει:
1
Τ(n) =
∑i = (n-1) + (n-2) +…+ 1= n (n-1) / 2,
i=n−1
και το πλήθος αυτό είναι ανεξάρτητο από τη μορφή της εισόδου, αν δηλ. ο πίνακας είναι ήδη
ταξινομημένος ή όχι. Γι’ αυτόν ακριβώς το λόγο για τον bubblesort έχουν αναπτυχθεί αρκετές
παραλλαγές που βελτιώνουν την απόδοση της βασικής έκδοσης σε πολλές πρακτικές
περιπτώσεις.
Μια πρώτη βελτίωση μπορούμε να έχουμε όταν σε κάθε διαπέραση του πίνακα
ελέγχουμε αν έχει ήδη ταξινομηθεί. Αυτό είναι εύκολο: ο πίνακας έχει ταξινομηθεί πλήρως
μετά τα πρώτα i περάσματα αν στο επόμενο πέρασμα i+1 δεν συμβεί καμία αντιμετάθεση.
Χρησιμοποιώντας λοιπόν μια έξτρα δυαδική μεταβλητή sorted που θα μας λέει αν έγινε ή όχι
τουλάχιστον μία αντιμετάθεση στην τρέχουσα διαπέραση, παίρνουμε την παρακάτω
παραλλαγή του βασικού αλγόριθμου:
Αλγόριθμος Bubblesort_παραλλαγή 1
Είσοδος (S, n)
Δεδομένα // i, j: ακέραιοι; sorted: δυαδική //
Aρχή
sorted = FALSE
i=2
Με την παραλλαγή αυτή, όταν ο αρχικός πίνακας είναι ήδη ταξινομημένος, τότε εκτελείται
1 μόνο διαπέραση και n-1 συγκρίσεις και ο αλγόριθμος τερματίζει χωρίς να γίνει καμία
αντιμετάθεση. Φυσικά, στην περίπτωση που ο αλγόριθμος είναι ταξινομημένος με την
αντίστροφη (φθίνουσα) σειρά εκτελούνται n-1 διαπεράσεις με το μέγιστο πλήθος συγκρίσεων
όπως και στο βασικό αλγόριθμο που είναι n(n-1)/2. Στην περίπτωση αυτή γίνονται ακριβώς
και n(n-1)/2 αντιμεταθέσεις.
Η συγκεκριμένη παραλλαγή αποτελεί μια βελτίωση αλλά δεν είναι η καλύτερη. Στην
πράξη, η απόδοση του αλγόριθμου μπορεί να βελτιωθεί ακόμα περισσότερο αν σε κάθε
διαπέραση προχωρούμε μέχρι τη θέση όπου έγινε η τελευταία αντιμετάθεση. Αν ξέρουμε ότι σε
κάποιο πέρασμα i η τελευταία αντιμετάθεση έγινε στην θέση k (δηλ. μεταξύ των S[k] και S[k-
1]), τότε σίγουρα όλα τα στοιχεία του υποπίνακα S[1..k-1] είναι ταξινομημένα. Επομένως, στο
επόμενο πέρασμα k+1 αρκεί να επισκεφτούμε μόνο τα στοιχεία του S[k..n].
H υλοποίηση αυτής της δεύτερης παραλλαγής είναι επίσης εύκολη. Χρησιμοποιούμε μία
μεταβλητή που κρατά σε κάθε πέρασμα του αλγόριθμου τη θέση της τελευταίας
(αριστερότερης) αντιμετάθεσης. Όταν η τιμή της μεταβλητής αυτής στο τέλος του περάσματος
είναι 0, αυτό σημαίνει ότι ο πίνακας έχει ταξινομηθεί και ο αλγόριθμος τερματίζει. Ο
αλγόριθμος δίνεται παρακάτω:
Αλγόριθμος Bubblesort_παραλλαγή 2
Είσοδος (S, n)
Δεδομένα // i, k, l: ακέραιοι //
Aρχή
l = 1 /* θέση αριστερότερης αντιμετάθεσης */
Όσο (l <> 0) Εκτέλεσε
k=0
Για i = n Μέχρι l+1 με Βήμα -1
Αν S[i-1] > S[i] Τότε
Αλγόριθμος Shakersort
Είσοδος (S, n)
Δεδομένα // i, k, l, r: ακέραιοι //
Αρχή
l = 1; r = n; k = 0
Όσο (l < r) Εκτέλεσε
Για i = l Μέχρι r-1 με Βήμα 1 /* κίνηση → */
Αν S[i] > S[i+1] Τότε
Αντιμετάθεσε S[i], S[i+1]
k=i
Τέλος Αν
Τέλος Επανάληψης
r=k
Για i = r Μέχρι l+1 με Βήμα -1 /* κίνηση ← */
Αν S[i-1] > S[i] Τότε
Αντιμετάθεσε S[i-1], S[i]
k=i
Τέλος Αν
Τέλος Επανάληψης
l=k
Τέλος Όσο
Αποτελέσματα // Ταξινομημένος πίνακας S[1..n] //
Τέλος Shakersort
Αλγόριθμος Insertionsort
Είσοδος (S, n)
Δεδομένα // i, j, t: ακέραιοι //
Αρχή
Για i = 1 Μέχρι n-1 με Βήμα 1
t = S[i+1]; j = i
Eπανέλαβε
Αν S[j] <= t Τότε έξοδος
S[j+1] = S[j]; j = j-1
Eνόσω j > 0
S[j+1] = t
Τέλος Επανάληψης
Αποτελέσματα // Ταξινομημένος πίνακας S[1..n] //
Τέλος Insertionsort
Το μέγιστο πλήθος συγκρίσεων του αλγόριθμου δίνεται από την παρακάτω σχέση:
n−1
T(n) =
∑i = n(n-1)/2 = Ο(n )
2
i =1
Στη χειρότερη περίπτωση οι μετακινήσεις στοιχείων σε κάθε διαπέραση είναι κατά 1
περισσότερες από το πλήθος των συγκρίσεων, επομένως το μέγιστο πλήθος μετακινήσεων
n−1
είναι
∑ (i + 1) = (n-1)(n+2)/2 = O(n ). 2
i=1
Η ίδια ασυμπτωτική πολυπλοκότητα ισχύει και στη μέση περίπτωση.
σωστή τους θέση ενώ στην επόμενη διαπέραση ψάχνουμε να βρούμε το (i+2)-οστό μικρότερο
στοιχείο. Η διαδικασία επαναλαμβάνεται για n-1 ακριβώς διαπεράσεις.
Παράδειγμα
Έστω έχουμε τον πίνακα S=[6, 5, 4, 7].
Ο αλγόριθμος εξετάζει για τη θέση 1 όλα τα στοιχεία. Στην πρώτη διαπέραση συγκρίνει το
S[1]=6 με το S[2]=5. To S[2] < S[1], κρατά ως μικρότερο το S[2] = 5 και συνεχίζει συγκρίνοντάς
το με το επόμενο στοιχείο του πίνακα που είναι το S[3] = 4. To S[3] < S[2], ο αλγόριθμος κρατά
ως μικρότερο το S[3] = 4 και συνεχίζει συγκρίνοντάς το με το επόμενο στοιχείο του πίνακα
που είναι το S[4] = 7. To S[4] > S[3], άρα το S[3] είναι το μικρότερο στοιχείο του πίνακα και
γίνεται αντιμετάθεση του S[1] με το S[3]. Η διαδικασία συνεχίζεται με τα υπόλοιπα n-1
στοιχεία [5, 6, 7] κ.ο.κ.
Ακολουθεί ο αλγόριθμος Selectionsort σε μορφή ψευδοκώδικα:
Αλγόριθμος Selectionsort
Είσοδος (S, n)
Δεδομένα // i, j, k: ακέραιοι //
Αρχή
Για i=1 Μέχρι n-1 με Βήμα 1
k = i /* θέση min στοιχείου στον S[i..n] */
Για j=i+1 Μέχρι n με Βήμα 1
Αν S[j] < S[k] Τότε k=j Tέλος Αν
Τέλος Επανάληψης
Αντιμετάθεσε τα S[i], S[k]
Τέλος Επανάληψης
Αποτελέσματα // Ταξινομημένος πίνακας S[1..n] //
Τέλος Selectionsort
Για τη εύρεση του μικρότερου στοιχείου στον υποπίνακα S[i..n] στην i-οστή διαπέραση
εκτελούνται n-i συγκρίσεις. Άρα, ο αλγόριθμος με επιλογή κάνει πάντα
n −1 n −1
∑ i =1
(n − i ) =
∑
j =1
j = n(n-1)/2 συγκρίσεις ανεξάρτητα από το στιγμιότυπο εισόδου. Οι
αντιμεταθέσεις είναι μόνο n-1 αφού σε κάθε πέρασμα γίνεται μία μόνο αντιμετάθεση του
μικρότερου στοιχείου κι αυτό κάνει τον αλγόριθμο κατάλληλο σε εφαρμογές όπου οι
αντιμεταθέσεις κοστίζουν ακριβά, όταν πχ. επεξεργαζόμαστε σύνθετες εγγραφές.
Ουρές προτεραιότητας
Οι ουρές προτεραιότητας χρησιμοποιούνται για τη διαχείριση μη διατεταγμένων συνόλων
από στοιχεία. Υποστηρίζουν τις ακόλουθες βασικές πράξεις:
o Ιnsert(x): εισάγει το στοιχείο με την τιμή x στην ουρά,
o Find_max(): επιστρέφει το στοιχείο με τη μεγαλύτερη τιμή,
o Delete_max(): διαγράφει και επιστρέφει στοιχείο με τη μεγαλύτερη τιμή,
o Ιncrease(x, k): αλλάζει την τιμή του στοιχείου x με τη νέα τιμή k > x,
o Delete(x): διαγράφει το στοιχείο με τιμή x.
Για τις τελευταίες δύο πράξεις θεωρούμε ότι η θέση του στοιχείου με την τιμή x είναι γνωστή
πχ. μέσω ενός δείκτη που αποτελεί μέρος της εισόδου, απαίτηση που δεν δημιουργεί
πρόβλημα σε πολλές εφαρμογές.
Στη βιβλιογραφία έχουν καταγραφεί αρκετές υλοποιήσεις των ουρών προτεραιότητας.
Κάποιες εξασφαλίζουν ότι όλες οι παραπάνω πράξεις σε μια ουρά n στοιχείων εκτελούνται
στη χειρότερη περίπτωση σε χρόνο Ο(logn). Άλλες πιο έξυπνες υλοποιήσεις υποστηρίζουν τις
πράξεις Delete_max() και Delete() σε κατανεμημένο χρόνο Ο(logn) και όλες τις υπόλοιπες σε
χρόνο Ο(1).
Μια βασική εφαρμογή των ουρών προτεραιότητας είναι η δρομολόγηση εργασιών (job
scheduling) σε κάποιον εξυπηρετητή. Η ουρά αποθηκεύει τις εργασίες που πρέπει να
εκτελεστούν και τις σχετικές τους προτεραιότητες οι οποίες καθορίζονται εφαρμόζοντας
κάποιο συγκεκριμένο κανόνα (πχ. μία εργασία που πρέπει να ολοκληρωθεί πιο σύντομα από
τις υπόλοιπες έχει μεγαλύτερη προτεραιότητα). Όταν μια εργασία ολοκληρώνεται ή
διακόπτεται, επιλέγεται για ανάθεση αυτή με τη μεγαλύτερη προτεραιότητα χρησιμοποιώντας
τη Delete_max(). Μια νέα εργασία μπορεί να προστεθεί στην ουρά οποιαδήποτε στιγμή
εκτελώντας την Insert(). Οι προτεραιότητες μπορούν να αλλάζουν δυναμικά (πχ. για εργασίες
με μεγάλο χρόνο αναμονής) με την Ιncrease() ενώ η ακύρωση μιας εργασίας που δεν έχει
εξυπηρετηθεί ακόμα γίνεται με την Delete().
Ο δυαδικός σωρός
Ο δυαδικός σωρός μπορεί να παρασταθεί με ένα δυαδικό ισοζυγισμένο δένδρο από
στοιχεία όπου η τιμή κάθε στοιχείου ικανοποιεί την ιδιότητα του σωρού (heap property)
όπως στο σχήμα που ακολουθεί:
πατέρα αν αυτό είναι μεγαλύτερο από την τιμή του πατέρα αποθηκεύοντας ταυτόχρονα την
τιμή του πατέρα στο παιδί.
Για την πράξη Delete_max() πρώτα μεταφέρουμε την τιμή του τελευταίου φύλλου στη ρίζα του
δένδρου και στη συνέχεια κατεβαίνουμε το μονοπάτι από τη ρίζα μεταφέροντας στον πατέρα
τη μεγαλύτερη από τις τιμές των δύο παιδιών του μέχρι όλες οι τιμές να μπουν στη σωστή τους
θέση ή να φτάσουμε σε κάποιο φύλλο. Στη συνέχεια θα δούμε ότι την τεχνική αυτή ακριβώς
χρησιμοποιεί και ο αλγόριθμος Heapsort για να ταξινομήσει τα στοιχεία ενός πίνακα.
Και η πράξη Delete(x) εκτελείται παρόμοια με την Delete_max() μόνο που τώρα μεταφέρουμε
την τιμή του τελευταίου φύλλου στον κόμβο v με την τιμή x, σβήνουμε το τελευταίο φύλλο και
ξεκινούμε την κάθοδο στο δένδρο από τον κόμβο v. Aν η πρόσβαση στο τέλος του σωρού
καθώς και στον κόμβο v με την τιμή που επιθυμούμε να σβήσουμε μπορεί να γίνει σε Ο(1)
χρόνο, τότε ο χρόνος εκτέλεσης των προηγούμενων πράξεων είναι Ο(ύψος δένδρου) και άρα
για ένα σωρό n στοιχείων Ο(logn). Τέλος, η πράξη Ιncrease(x, k) αντιστοιχεί σε μια Delete(x)
ακολουθούμενη από μια Ιnsert(k), άρα και αυτή παίρνει χρόνο Ο(logn).
Για τις ανάγκες του Heapsort o δυαδικός ισοζυγισμένος σωρός υλοποιείται με έναν
πίνακα S. Για το σωρό του προηγούμενου σχήματος ο πίνακας αυτός είναι ο εξής:
Δηλ. το στοιχείο της πρώτης θέσης του πίνακα S[1] είναι το μεγαλύτερο και αντιστοιχεί στη
ρίζα του δένδρου. Τα παιδιά του κόμβου S[i] του δένδρου βρίσκονται στις θέσεις S[2i]
(αριστερό παιδί) και S[2i+1] (δεξί παιδί) του πίνακα, ενώ ο κόμβος S[i] έχει πατέρα τον
S[ ⎣i / 2⎦ ].
Ο αλγόριθμος Heapsort
Έστω τώρα ότι θέλουμε να ταξινομήσουμε τον πίνακα S n ακέραιων στοιχείων. Ο
αλγόριθμος Ηeapsort εκτελείται σε δύο φάσεις:
Φάση Δόμησης: Ο πίνακας S[1..n] με τα μη ταξινομημένα στοιχεία μετατρέπεται σε σωρό.
Φάση Διαλογής: To μεγαλύτερο στοιχείο του πίνακα, που βρίσκεται στη ρίζα του σωρού
(στοιχείο S[1]), αλλάζει αμοιβαία θέση με το δεξιότερο στοιχείο του πίνακα
(στοιχείο S[n]). Στη συνέχεια επαναλαμβάνεται η φάση δόμησης και πάλι η
φάση διαλογής για το δεύτερο μεγαλύτερο στοιχείο κ.ο.κ. μέχρι να
φτάσουμε στο τελευταίο στοιχείο.
Στη φάση δόμησης χτίζουμε σωρούς σε διαφορετικά υπόδενδρα κάθε φορά. Ξεκινούμε
από το στοιχείο στη θέση ⎣n / 2⎦ , δηλ. το S[ ⎣n / 2⎦ ] και προχωράμε στα στοιχεία S[ ⎣n / 2⎦ -1],
S[ ⎣n / 2⎦ – 2], … μέχρι να φτάσουμε στη ρίζα S[1] του δένδρου. Σε κάθε υποδένδρο με ρίζα
S[k] χτίζουμε το σωρό για τα στοιχεία S[k..n] του πίνακα.
Στη φάση διαλογής αρχικά αντιμεταθέτουμε το S[1] με το S[n] ώστε το μεγαλύτερο
στοιχείο του πίνακα να μπει στην τελευταία θέση που είναι και η σωστή. Στη συνέχεια
ξαναχτίζουμε το σωρό αλλά για τα n-1 στοιχεία S[1..n-1]. Προχωρούμε με αυτόν τον τρόπο
αυτό μέχρι να μείνουν 2 στοιχεία, το μικρότερο και το δεύτερο μικρότερο στοιχείο του πίνακα.
Βάζουμε το δεύτερο μικρότερο στοιχείο στη σωστή του θέση και ο αλγόριθμος τερματίζει.
Μπορούμε τώρα να δώσουμε τον αλγόριθμο σε μορφή ψευδοκώδικα:
Aλγόριθμος Heapsort
Είσοδος (S, n)
Δεδομένα // i: temp: ακέραιοι //
Aρχή
/* Φάση Δόμησης */
Για i = n/2 Μέχρι 1 με Βήμα -1
1.1 <Mετέτρεψε τον υποπίνακα S[i..n] σε έναν σωρό>
Τέλος Επανάληψης
/* Φάση Διαλογής */
Για i = n Μέχρι 2 με Βήμα -1
/* Το S[1] μπαίνει στη σωστή του θέση */
temp = S[1]
S[1] = S[i]
S[i] = temp
1.2 <Μετέτρεψε τον υποπίνακα S[1..i-1] σε έναν σωρό>
/* Στο σημείο αυτό ο υποπίνακας S[1..i-1] είναι ένας σωρός και ο S[i..n]
περιέχει τα n-i+1 μεγαλύτερα στοιχεία του αρχικού πίνακα ταξινομημένα */
Τέλος Επανάληψης
Αποτελέσματα // Tαξινομημένος πίνακας S[1..n] //
Τέλος Ηeapsort
Σχήμα 22: Διαδικασία βύθισης του στοιχείου 2 και επανακατασκευή του σωρού
Aλγόριθμος Shift_down
Είσοδος (S, root, last)
Δεδομένα // j, k, v: ακέραιοι //
Aρχή
v = S[root]
k = root
Όσο (k <= last/2) Εκτέλεσε /* Δεν έχουμε φτάσει σε φύλλο */
Τώρα μπορούμε να αντικαταστήσουμε τις εντολές 1.1 και 1.2 στο βασικό αλγόριθμο
Heapsort με τις Shift_down(i, n) και Shift_down(1, i-1) αντίστοιχα.
Μπορεί να αποδειχθεί σχετικά εύκολα ότι η φάση δόμησης απαιτεί συνολικό χρόνο O(n).
Η διαίσθηση είναι ότι η φάση δόμησης περιλαμβάνει πολλά υπόδενδρα που έχουν μικρό
μέγεθος κι έτσι προκύπτει γραμμικός χρόνος. Πράγματι, ο χρόνος αυτός είναι ανάλογος της
ποσότητας:
Ισχύει τώρα:
Η φάση διαλογής απαιτεί χρόνο O(nlogn) αφού με κάθε απομάκρυνση της ρίζας ξαναχτίζουμε
σε O(logn) χρόνο το σωρό. Επομένως, ο συνολικός χρόνος εκτέλεσης του αλγόριθμου είναι
O(nlogn) στη χειρότερη περίπτωση για την ταξινόμηση n στοιχείων.
Καλούμε τη διαδικασία διαχωρισμού partition. Παίρνει ως είσοδο τον υποπίνακα S[l..r] και
επιστρέφει τη θέση j του πίνακα όπου γίνεται ο διαχωρισμός. Ο αλγόριθμος της partition σε
μορφή ψευδοκώδικα είναι ο εξής:
Aλγόριθμος Partition
Είσοδος (S, l, r)
Δεδομένα // i, j, x, temp: ακέραιοι //
Aρχή
x = S[l]
i=l
j = r+1
Όσο (i< j) Εκτέλεσε
Εκτέλεσε i = i+1 όσο (S[i] < x)
Εκτέλεσε j = j-1 όσο (S[j] > x)
Αν (i<j) Τότε
temp = S[i]
S[i] = S[j]
S[j] = temp
Tέλος Αν
Τέλος Όσο
/* Στο σημείο αυτό οι δείκτες έχουν διασταυρωθεί και το στοιχείο διαχωρισμού πρέπει
να μπει στη σωστή του θέση */
S[l] = S[j]
S[j] = x
Aποτελέσματα // Δείκτης j //
Τέλος Partition
Tώρα μπορούμε να δώσουμε τον ψευδοκώδικα για τον αλγόριθμο quicksort o oποίος είναι
εξαιρετικά απλός λόγω της αναδρομής.
Aλγόριθμος Quicksort
Είσοδος (S, l, r)
Δεδομένα // l, r, k: ακέραιοι //
Aρχή
Αν (l < r) Τότε
/* Βήμα Διαμέρισης */
k = θέση του στοιχείου διαχωρισμού που επιστρέφει η Partition με είσοδο τον
υποπίνακα S[l ..r]
/* Aναδρομικό βήμα */
Κάλεσε αναδρομικά τον Quicksort με είσοδο τον υποπίνακα S[l..k-1]
Για την ταξινόμηση του S[1..n] η αρχική κλήση του quicksort γίνεται με παραμέτρους 1 και n
δηλ. έχει τη μορφή Quicksort(S, 1, n).
Παρατήρηση:
Αν το στοιχείο S[1] είναι το μεγαλύτερο στοιχείο του πίνακα και είναι μοναδικό, τότε κατά την
υλοποίηση του αλγόριθμου της Partition ο αριστερός δείκτης i θα πάρει διαδοχικά όλες τις
τιμές 1, 2, 3, …n, n+1 δίχως να σταματήσει και θα προκαλέσει ένα run time λάθος, αφού το
στοιχείο S[n+1] δεν υπάρχει. Το πρόβλημα μπορεί να λυθεί με έναν επιπλέον έλεγχο (μια Αν
… Τότε … εντολή) ή χρησιμοποιώντας μια επιπλέον θέση στον πίνακα που έχει το ρόλο του
«φρουρού». Στη θέση αυτή αποθηκεύουμε μια τιμή τέτοια ώστε να υπάρχει η εγγύηση ότι το i
δεν θα βγει έξω από τα όρια του πίνακα. Η μεγαλύτερη τιμή του πίνακα (τη βρίσκουμε με n-1
συγκρίσεις) ή ο μεγαλύτερος ακέραιος στο σύστημα που δουλεύουμε είναι κατάλληλες για το
σκοπό αυτό. Η λύση του φρουρού είναι και η πιο πρακτική και δεν αλλάζει τον αλγόριθμο της
Partition, αρκεί να θυμάται ο χρήστης ότι για να ταξινομήσει n στοιχεία σ΄ έναν πίνακα με τον
quicksort, ο πίνακας πρέπει να έχει μέγεθος n+1, με το τελευταίο στοιχείο να είναι ο φρουρός.
Παρατηρήστε ότι για το δεξιό δείκτη j δεν χρειαζόμαστε αντίστοιχο φρουρό αφού, μόλις ο j
φτάσει στο αριστερό όριο του πίνακα, ο έλεγχος S[j] > x για j = l αποτυγχάνει και ο δείκτης
σταματά.
∑
__
T (n) = n + 1 + 1/n * [Τ(k - 1) + Τ(n − k)] = Ο(nlogn), Τ(0) = Τ(1) = 0
k=1
δηλ. η πολυπλοκότητα μέσης περίπτωσης του quicksort είναι O(nlogn). H O(nlogn)
πολυπλοκότητα ερμηνεύεται φυσικά από το γεγονός ότι για τυχαίες εισόδους το στοιχείο
διαχωρισμού κόβει τον πίνακα κάθε φορά περίπου στη μέση που είναι και το καλύτερο που
θα μπορούσαμε να περιμένουμε (στην περίπτωση αυτή Τ(n) = n+1 + 2T(n/2) που έχει λύση
Ο(nlogn)).
Εξαιτίας των Ο(n) αναδρομικών κλήσεων στη χειρότερη περίπτωση, για μεγάλα n η
στοίβα του συστήματος μπορεί να υπερχειλίσει. Μία λύση στο πρόβλημα αυτό είναι στο
αναδρομικό βήμα καλούμε τον quicksort μόνο για τον μικρότερο υποπίνακα και ταξινομούμε
τον μεγαλύτερο επαναληπτικά. Έτσι έχουμε μόνο Ο(logn) ενεργές αναδρομικές κλήσεις, αφού
κάθε νέο περιβάλλον που αποθηκεύεται στη στοίβα αντιπροσωπεύει ένα υποπίνακα με
μέγεθος ≤ μισό του υποπίνακα για το προηγούμενο περιβάλλον.
Παρακάτω δίνεται ο αλγόριθμος για την παραλλαγή αυτή:
Aλγόριθμος Quicksort_stack_limited
Είσοδος (S, l, r)
Δεδομένα // k: ακέραιoς //
Αρχή
Όσο (l < r) Εκτέλεσε
k = θέση του στοιχείου διαχωρισμού που επιστρέφει η Partition με είσοδο τον
υποπίνακα S[l ..r]
Αν (k-l < r-k) Τότε
<Κάλεσε αναδρομικά τον αλγόριθμο για τον S[l..k-1]>
l=k+1 /* ενημέρωση αριστερού ορίου του πίνακα για το */
/* επαναληπτικό βήμα */
Αλλιώς
<Κάλεσε αναδρομικά τον αλγόριθμο για τον S[k+1..r]>
r=k-1 /* ενημέρωση δεξιού ορίου του πίνακα */
Tέλος Αν
Τέλος Όσο
Αποτελέσματα // Ταξινομημένος πίνακας S[l..r] //
Tέλος Quicksort_stack_limited
Μια άλλη βελτίωση που μπορεί να γίνει στον quicksort είναι να τερματίζουμε την αναδρομή
όταν φτάσουμε σε υποπίνακες μεγέθους < Μ για κάποια σταθερά Μ < n και να ταξινομούμε
τους υποπίνακες αυτούς με έναν επαναληπτικό αλγόριθμο όπως για παράδειγμα τον
selectionsort. Η τιμή της Μ εξαρτάται από τι λεπτομέρειες της υλοποίησης, όμως δεν απαιτείται
να είναι η καλύτερη δυνατή: ο αλγόριθμος λειτουργεί εξίσου καλά για τις τιμές της Μ στην
περιοχή από 5 έως 25, η δε μείωση στο χρόνο εκτέλεσης είναι της τάξης του 20% όπως
αποδεικνύουν σχετικές μετρήσεις που έχουν γίνει.
Για να βελτιώσουμε τώρα την κακή συμπεριφορά του αλγόριθμου για ταξινομημένες ή περίπου
ταξινομημένες εισόδους, μπορούμε να επιλέγουμε τυχαία ένα στοιχείο του πίνακα ως
στοιχείο διαχωρισμού, χρησιμοποιώντας για παράδειγμα μία γεννήτρια ψευδοτυχαίων. Με
την αλλαγή αυτή η χειρότερη περίπτωση πλέον συμβαίνει με πολύ μικρή πιθανότητα και δεν
υπάρχουν πια κακές είσοδοι: ο τυχαίος quicksort συμπεριφέρεται το ίδιο σε όλες τις εισόδους.
Η παραλλαγή αυτή είναι ένα απλοϊκό παράδειγμα πιθανοτικού (probabilistic) αλγόριθμου,
όπου η τυχαιότητα χρησιμοποιείται για να έχουμε σχεδόν πάντα καλή απόδοση. Γενικά η
τυχαιότητα αποτελεί μια ισχυρή τεχνική για τον αποδοτικό σχεδιασμό αλγορίθμων , ειδικά
όταν υπάρχει από πριν κάποια γνώση για την κατανομή των εισόδων.
Μεγαλύτερη βελτίωση προκύπτει αν πάρουμε 3 στοιχεία από τον πίνακα, πχ. το αριστερότερο
S[l], το δεξιότερο S[r], και το S[(l+r)/2] που βρίσκεται στη μέση και χρησιμοποιήσουμε το
μεσαίο σε μέγεθος από τα τρία ως στοιχείο διαχωρισμού. Η μέθοδος αυτή είναι γνωστή ως
μέσος-από-τρεις μέθοδος διαχωρισμού. Είναι εύκολο να δείτε ότι με την παραλλαγή αυτή
αποφεύγουμε τη χρήση του στοιχείου φρουρού.
Κλείνοντας την παρουσίαση του quicksort επισημαίνουμε ότι είναι δυνατές κι άλλες
αλγοριθμικές βελτιώσεις, (πχ. θα μπορούσε να χρησιμοποιηθεί ο μεσαίος από 5 στοιχεία)
αλλά το κέρδος πλέον είναι οριακό. Αξίζει δεν αναφερθεί ότι σε σύγκριση δε με τον αλγόριθμο
heapsort που έχει πολυπλοκότητα χειρότερης περίπτωσης Ο(nlogn), o quicksort στην πράξη,
αν υλοποιηθεί σωστά, είναι περίπου 2 με 3 φορές γρηγορότερος επειδή οι πράξεις της
Partition() είναι απλούστερες!
διαχωρισμού του πίνακα και στη δεξιά η διαδικασία συγχώνευσης που παράγει
ταξινομημένους υποπίνακες.
Aλγόριθμος Mergesort
Είσοδος (S, l, r)
Δεδομένα // m: ακέραιος //
Aρχή
Aν (l < r) Τότε
m=(r-l+1)/2
/* Αναδρομικό βήμα */
<Kάλεσε τον Μergesort με είσοδο τον υποπίνακα S[l..m]>
<Kάλεσε τον Μergesort με είσοδο τον υποπίνακα S[m+1..r]>
/* Βήμα συγχώνευσης */
<Kάλεσε τον αλγόριθμο συγχώνευσης Merge των δύο υποπινάκων>
Tέλος Αν
Aποτελέσματα // Tαξινομημένος πίνακας S[l..r] //
Τέλος Mergesort
συγκρίνονται ανά δύο τα στοιχεία των υποπινάκων και ανάλογα με τη σειρά που έχουν
γράφονται στον C.
O αλγόριθμος συγχώνευσης με τη μορφή ψευδοκώδικα δίνεται παρακάτω:
Aλγόριθμος Merge
Είσοδος (S, l1, r1, l2, r2) /* Tαξινομημένοι υποπίνακες S[l1..r1] και S[l2..r2] */
Δεδομένα // C, i, j, k: ακέραιοι // /* Ο C χρησιμοποιείται ως βοηθητικός πίνακας */
Aρχή
i = l1; j = l2; k = 1
Όσο (i<= r1) AND (j<=r2) Εκτέλεσε
Aν (S[i] < S[j]) Τότε
C[k] = S[i]; k=k+1; i=i+1
Aλλιώς
C[k] = S[j]; k=k+1; j=j+1
Tέλος Αν
Τέλος Όσο
Όσο (i <= r1) Εκτέλεσε
C[k] = S[i]; k=k+1; i=i+1
Τέλος Όσο
Όσο (j <= r2) Εκτέλεσε
C[k] = S[j]; k=k+1; j=j+1
Τέλος Όσο
k = 1;
Για i=l1 Μέχρι r1 Εκτέλεσε
S[i] = C[k]; k=k+1 /* Τα ταξινομημένα στοιχεία του C αποθηκεύoνται
ξανά στους υποπίνακες του S */
Τέλος επανάληψης
Για j=l2 Μέχρι r2 Εκτέλεσε
S[j] = C[k]; k=k+1
Τέλος επανάληψης
Aποτελέσματα // Tαξινομημένοι υποπίνακες S[l1..r1] και S[l2..r2], όπου S[r1] ≤ S[l2] //
Τέλος Merge
Για την ανάλυση του αλγόριθμου Ταξινόμησης με Συγχώνευση παρατηρούμε ότι σε κάθε
βήμα, διαιρείται το μέγεθος του προβλήματος κατά δύο, γεγονός που σημαίνει ότι θα
εκτελέσουμε το πολύ k = logn + 1 βήματα. Σε κάθε βήμα εκτελούνται O(n) συγκρίσεις οπότε
αποδεικνύεται ότι ο αλγόριθμος Ταξινόμησης με Συγχώνευση ταξινομεί μία ακολουθία μήκους
Υποθέτουμε ότι η είσοδος στον αλγόριθμο είναι ο πίνακας S[1..n] και κάθε στοιχείο του
είναι ένας ακέραιος στο διάστημα [1..k]. Θεωρούμε επίσης ότι έχουμε στη διάθεσή μας τους
βοηθητικούς πίνακες B[1..n] και C[1..k] που αποθηκεύουν την ταξινομημένη έξοδο του
αλγόριθμου και το πλήθος των τιμών που είναι μικρότερες ή ίσες από ένα δεδομένο ακέραιο
αντίστοιχα (δηλ. και ο συγκεκριμένος αλγόριθμος δεν εκτελείται in-place). Ο αλγόριθμος
countingsort έχει τις εξής τρεις φάσεις:
Φάση 1: Αρχικοποιούμε τα στοιχεία του C με την τιμή 0. Σαρώνουμε τον S με φορά → και
για κάθε θέση i αυξάνουμε την τιμή C[S[i]] κατά 1.
Φάση 2: Σαρώνουμε τον C με φορά → και για κάθε θέση i προσθέτουμε τα στοιχεία C[i] και
C[i-1]. Έτσι κάθε C[j] αποθηκεύει το πλήθος των στοιχείων του S που είναι ≤ j.
Φάση 3: Σαρώνουμε τον S με φορά ← Έστω ότι είμαστε στη θέση i και S[i] = j. Το C[j]
δείχνει πόσα στοιχεία είναι ≤ j. Toποθετούμε την τιμή j στη θέση C[j] του Β και
μειώνουμε το C[j] κατά 1.
Ο αλγόριθμος σε μορφή ψευδοκώδικα παρουσιάζεται στη συνέχεια:
Aλγόριθμος Countingsort
Είσοδος (S, n, k)
Δεδομένα // B[1..n], C[1..k], i, j: ακέραιοι //
Aρχή
/* Φάση 1 */
Για i = 1 Μέχρι k με Βήμα 1
C[i] = 0
Τέλος Επανάληψης
Για j = 1 Μέχρι n με Βήμα 1
C[S[j]] = C[S[j]]+1
Τέλος Επανάληψης
/* Στο σημείο αυτό το C[i] περιέχει το πλήθος των στοιχείων S[j] με S[j] = i */
/* Φάση 2 */
/* Φάση 3 */
Για j = n Μέχρι 1 με Βήμα -1
B[C[S[j]]] = S[j]
C[S[j]] = C[S[j]]-1
Τέλος Επανάληψης
Αποτελέσματα // Tαξινομημένος πίνακας B[1..n] //
Τέλος Countingsort
αλφαριθμητικά στοιχεία) παρά εκτελούν συγκρίσεις για να για να βρουν τη σειρά μεταξύ των
στοιχείων εισόδου.
Αν k = O(n2) ή ακόμα χειρότερα k = O(n3), ο αλγόριθμος απαιτεί υπερβολικό χρόνο.
3.1.8 Radixsort
Ο αλγόριθμος ταξινόμησης Radixsort χρησιμοποιείται και αυτός σε περιπτώσεις που
γνωρίζουμε συγκεκριμένες πληροφορίες για την μορφή εισόδου.
Έτσι, στην περίπτωση ταξινόμησης δεκαδικών αριθμών δημιουργούμε δέκα κάδους (όσα
και τα στοιχεία του δεκαδικού συστήματος) και διαβάζουμε τα ψηφία των αριθμών της
εισόδου από αριστερά προς τα δεξιά. Όσοι αρχίζουν πχ. από 3 τοποθετούνται στον τρίτο
κάδο, όσοι αρχίζουν από 8 στον όγδοο κάδο κ.ο.κ. Mε τον τρόπο αυτό τοποθετούμε σε κάδους
όλα τα στοιχεία του πίνακα. Στη συνέχεια χωρίζουμε κάθε κάδο σε 10 υποκάδους και
ακολουθούμε την ίδια διαδικασία για το δεύτερο ψηφίο.
Ο αλγόριθμος είναι αναδρομικός ενώ είναι μη αποδοτικός αφού για να ταξινομήσει n
αριθμούς με d ψηφία, όπου κάθε ψηφίο μπορεί να πάρει k διαφορετικές τιμές από 0 μέχρι k-
1, απαιτεί O(ndk) χρόνο και χώρο. Υπάρχει μία διαφορετική τεχνική που κοστίζει Ο(d(n+k))
χρόνο στην οποία διατρέχουμε τους αριθμούς από δεξιά προς τ’ αριστερά και τους
ταξινομούμε προχωρώντας από το λιγότερο σημαντικό προς το περισσότερο σημαντικό
ψηφίο τους. Υποθέτοντας ότι το ψηφίο 1 είναι το λιγότερο σημαντικό και το ψηφίο d το
περισσότερο σημαντικό (δηλ. ο αριθμός γράφεται ως adad-1ad-2a2a1), ο αλγόριθμος μπορεί να
περιγραφεί εύκολα με τον παρακάτω ψευδοκώδικα:
Aλγόριθμος Radixsort
Είσοδος (S, n, d, k)
Δεδομένα // i: ακέραιος //
Aρχή
Για i = 1 Μέχρι d με Βήμα 1
<Ταξινόμησε τους αριθμούς του πίνακα S με βάση το ψηφίο i>
Τέλος Επανάληψης
Αποτελέσματα // Tαξινομημένος πίνακας S[1..n] //
Τέλος Radixsort
Aν το k δεν είναι μεγάλο, τότε η προφανής επιλογή για την ταξινόμηση είναι ο αλγόριθμος
countingsort και άρα κάθε επανάληψη του παραπάνω loop κοστίζει Ο(n+k) χρόνο. Επειδή
γίνονται d επαναλήψεις, ο συνολικός χρόνος εκτέλεσης γίνεται Ο(d(n+k)). Αν το d είναι
σταθερό και k = O(n), τότε ο radixsort τρέχει σε γραμμικό χρόνο. Το μειονέκτημα φυσικά της
χρήσης του countingsort είναι ότι δεν εκτελείται in-place σε αντίθεση με τους περισσότερους
συγκριτικούς αλγόριθμους που είδαμε.
Φάση 1
To i-oστό μπλοκ (1 ≤ i ≤ n/M) που διαβάζουμε το γράφουμε στην ταινία (i-1) mod p + 1.
Το περιεχόμενο των ταινιών θα είναι:
Ταινία 1: AOS ■ DMN ■ AEX ■
Ταινία 2: IRT ■ EGR ■ LMP ■
Ταινία 3: AGN ■ GIN ■ E ■
Φάση 2
Βήμα 1: Συγχωνεύουμε στην κύρια μνήμη τα στοιχεία της i-οστής ομάδας μπλοκ (1 ≤ i ≤
(n/M)/p)) από τις p ταινίες εισόδου και τα γράφουμε στην ταινία (i-1) mod p + p+1. Εδώ
χρησιμοποιούμε τον αλγόριθμο Merge p ταξινομημένων ακολουθιών. Οι ταινίες θα είναι τώρα:
Ταινία 4: ΑΑGINORST ■
Ταινία 5: DEGGIMNNR ■
Ταινία 6: AEELMPX ■
Βήμα 2: Οι ταινίες εισόδου γίνονται ταινίες εισόδου και αντίστροφα. Προχωρούμε όπως στο
βήμα 1 μέχρι να πάρουμε μια ταξινομημένη ακολουθία και άρα έχουμε:
Έξοδος στην Ταινία 1: ΑΑΑDEEEGGGIILMNNNOPRRSTX ■
Η χρονική συμπεριφορά του αλγόριθμου εξαρτάται από το μέγεθος του μπλοκ που παράγεται
στη Φάση 1 αφού επηρεάζει το πλήθος των βημάτων συγχώνευσης που απαιτούνται μέχρι
την τελική ταξινόμηση της ακολουθίας εισόδου. Μετά από κάθε συγχώνευση το πλήθος των
ταξινομημένων μπλοκ μειώνεται κατά ένα παράγοντα p, πχ. στο προηγούμενο παράδειγμα
αρχικά έχουμε 9 ταξινομημένα μπλοκ (τα 8 έχουν 3 στοιχεία και το τελευταίο μόνο 1), στη
συνέχεια 3 ταξινομημένα μπλοκ (τα 2 πρώτα έχουν 9 στοιχεία και το τρίτο 7) και στην έξοδο 1
μπλοκ με 25 στοιχεία. Επομένως, το πλήθος βημάτων συγχώνευσης είναι logpn/M.
Υπάρχει παραλλαγή του προηγούμενου αλγόριθμου που ονομάζεται Replacement Selection
η οποία χρησιμοποιεί ως βασική δομή στην κύρια μνήμη μία ουρά προτεραιότητας (πχ. ένα
δυαδικό σωρό) και κάνει κατά μέσο όρο logpn/2M βήματα συγχώνευσης (το μέγεθος των
μπλοκ που παράγονται στη Φάση 1 είναι κατά μέσο όρο το διπλάσιο σε σύγκριση με τον
προηγούμενο αλγόριθμο).
Στο δένδρο αποφάσεων κάθε εσωτερικός κόμβος (που στο σχήμα παριστάνεται με έλλειψη
και αντιστοιχεί σε μία εντολή σύγκρισης) έχει ακριβώς 2 παιδιά ενώ τα φύλλα (που στο σχήμα
παριστάνονται με παραλληλόγραμμα και αντιστοιχούν σε εντολές εκτύπωσης της
ταξινομημένης ακολουθίας των n στοιχείων) έχουν 0 παιδιά. Για κάθε στιγμιότυπο εισόδου ο
αλγόριθμος sort ακολουθεί στο δένδρο αποφάσεων ένα μοναδικό μονοπάτι από τη ρίζα σε
κάποιο φύλλου του δένδρου που εξαρτάται μόνο από τη σχετική θέση που έχουν τα στοιχεία
μεταξύ τους και όχι από τις πραγματικές τους τιμές. Το πλήθος των εσωτερικών κόμβων πάνω
στο μακρύτερο μονοπάτι του δένδρου αποφάσεων, ή αλλιώς το ύψος του δένδρου, καθορίζει
το μέγιστο πλήθος συγκρίσεων που εκτελεί ο sort. Για να πάρουμε ένα κάτω φράγμα για τη
χειρότερη περίπτωση πρέπει να βρούμε ένα κάτω φράγμα στο ύψος του δένδρου αποφάσεων.
To δένδρο αποφάσεων πρέπει να έχει τουλάχιστον n! διαφορετικά φύλλα εφόσον υπάρχουν
n! διαφορετικές μεταθέσεις n στοιχείων. Αν h είναι το ύψος του δένδρου, τότε ισχύει:
Mία πιο ακριβής εκτίμηση για το n! είναι (n/e)n, όπου e=2,717… η βάση των νεπέρειων
λογάριθμων οπότε h ≥ nlogn-1,44n.
Το προηγούμενο φράγμα δεν μας λέει ότι η ταξινόμηση n στοιχείων μπορεί να γίνει με
Θ(nlogn) συγκρίσεις παρά μόνο ότι Ω(nlogn) συγκρίσεις είναι απαραίτητες. Oι heapsort και
mergesort που είδαμε νωρίτερα κάνουν Θ(nlogn) συγκρίσεις, δηλ. πιάνουν το κάτω φράγμα
και άρα είναι ασυμπτωτικά βέλτιστοι.
Το φράγμα Ω(nlogn) ισχύει και για τη μέση συμπεριφορά των συγκριτικών αλγόριθμων
ταξινόμησης. Για την απόδειξη εισάγουμε την παρακάτω έννοια:
External path length (epl): το άθροισμα των μηκών όλων των μονοπατιών από τη ρίζα σε
ένα φύλλο του δένδρου αποφάσεων
Σε ένα δυαδικό δένδρο όπου κάθε κόμβος έχει 2 ή 0 παιδιά ισχύει eplmin(n!) ≥ n!log(n!).
Υποθέτοντας ότι όλες οι n! μεταθέσεις n στοιχείων έχουν την ίδια πιθανότητα να εμφανιστούν
ως είσοδοι στον αλγόριθμο, τότε έχουμε:
Μέσο πλήθος συγκρίσεων ≥ 1/n! eplmin(n!)
≥ 1/n!(n!log(n!)) = log(n!) = Ω(nlogn)
Στον παραπάνω αλγόριθμο left και right είναι το αριστερό και δεξί όριο του υποπίνακα
στον οποίο ψάχνουμε κάθε φορά για το x. Η αναζήτηση γίνεται στη θέση next και αν το x
βρεθεί ο αλγόριθμος τερματίζει επιτυχώς (η μεταβλητή found γίνεται TRUE), διαφορετικά
ενημερώνονται οι δείκτες left και right ώστε την επόμενη φορά η αναζήτηση να περιοριστεί σε
μικρότερο υποπίνακα.
Ο αλγόριθμος τερματίζει γιατί η τιμή right-left σε κάθε διέλευση του επαναληπτικού loop
Όσο … Τέλος όσο μειώνεται τουλάχιστον κατά 1 και πάντα συγκρίνεται right ≥ left. Αν στο
τέλος η found είναι FALSE τότε η αναζήτηση ήταν ανεπιτυχής (το x δεν βρέθηκε στον πίνακα),
διαφορετικά επιστρέφεται στη μεταβλητή next η θέση του x στον πίνακα.
Στον γενικό αλγόριθμο αναζήτησης, επιλέγοντας κάθε φορά με διαφορετικό τρόπο τη θέση
next στην οποία ψάχνουμε το x, έχουμε και διαφορετική μέθοδο αναζήτησης όπως
περιγράφεται στα επόμενα.
Δεδομένου ότι τα στοιχεία του πίνακα είναι ταξινομημένα κατ΄ αύξουσα σειρά, την πρώτη
φορά που θα βρεθεί το S[next] > x τότε θα γίνει right = left - 1 και ο αλγόριθμος θα
τερματιστεί ανεπιτυχώς.
Πρέπει να τονιστεί ο απλός και άμεσος τρόπος αναζήτησης της γραμμικής μεθόδου. Όμως
η απόδοση της μεθόδου είναι η χειρότερη σε σύγκριση με τις υπόλοιπες που θα εξετάσουμε
παρακάτω γιατί ακριβώς δεν εκμεταλλεύεται το γεγονός ότι ο πίνακας είναι ταξινομημένος.
Πιο συγκεκριμένα, εφόσον έχουμε n στοιχεία, ο χρόνος της χειρότερης περίπτωσης προκύπτει
όταν το στοιχείο που αναζητούμε στην δομή μας είναι ≥ S[n]. Στην περίπτωση αυτή η
γραμμική μέθοδος θα απαιτήσει n συγκρίσεις ενώ το μέσο πλήθος συγκρίσεων είναι n/2, ό,τι
δηλ. ισχύει με τη γραμμική αναζήτηση σε έναν τυχαίο (μη ταξινομημένο) πίνακα.
Στην περίπτωση που το στοιχείο x που ψάχνουμε υπάρχει στον πίνακα, με το τέλος του
αλγόριθμου, η μεταβλητή next δείχνει τη θέση του μέσα σ’ αυτόν.
Η διαδικασία της δυαδικής αναζήτησης μπορεί να παρασταθεί με ένα δυαδικό δένδρο
αναζήτησης n στοιχείων. Υποθέτουμε για ευκολία ότι n = 2k -1 για κάποιο k ≥ 1 που σημαίνει
ότι το δένδρο είναι πλήρες και έχει k = log(n+1) επίπεδα. Πχ. για 15 στοιχεία το δένδρο θα έχει
την παρακάτω μορφή:
ίδιο τρόπο στον υποπίνακα που δημιουργείται. Επειδή η θέση του στοιχείου που συγκρίνεται
κάθε φορά με το στοιχείο x που ψάχνουμε εξαρτάται και από το x, θα πρέπει να ελέγχουμε σε
κάθε επανάληψη αν το x είναι εντός των ορίων του υποπίνακα που εξετάζουμε.
Παρακάτω δίνεται ο αλγόριθμος της αναζήτησης με παρεμβολή σε μορφή ψευδοκώδικα:
Aλγόριθμος Find(S, l, r, i)
Δεδομένα // l, r, i, k, s: ακέραιοι //
Aρχή
Αν (l=r) Τότε επέστρεψε S[l]
k = θέση του στοιχείου διαχωρισμού που επιστρέφει η Partition με είσοδο τον
υποπίνακα S[l ..r]
s= k-l+1 /* Μέγεθος υποπίνακα S1 */
Aν (s = i) Τότε επέστρεψε (S[k])
Αλλιώς Αν (s > i) Τότε επέστρεψε (Find(S, l, k-1, i))
Για τον πίνακα S[1..n] η αρχική κλήση στον αλγόριθμο θα έχει τη μορφή Find(S, 1, n, i). Η
συνάρτηση Partition() είναι ίδια με αυτήν που έχουμε δει στον quicksort όπου σαν στοιχείο
διαχωρισμού επιλέγεται το αριστερότερο στοιχείο του πίνακα (πρέπει κι εδώ να προβλεφθεί
το στοιχείο-φρουρός στη θέση n+1 του πίνακα). Φυσικά, μπορούμε να χρησιμοποιήσουμε
και οποιαδήποτε παραλλαγή της Partition() όπου το στοιχείου διαχωρισμού επιλέγεται τυχαία
ή είναι ο μέσος από τρεις που στην πράξη δίνει καλύτερα αποτελέσματα.
Η ανάλυση του αλγόριθμου Find είναι ίδια με αυτήν του quicksort. Υπενθυμίζουμε ότι ο
διαχωρισμός του πίνακα απαιτεί χρόνο Ο(n). H χειρότερη περίπτωση για τον Find για
οποιοδήποτε i προκύπτει όταν σε κάθε αναδρομική κλήση το στοιχείο διαχωρισμού είναι κακό
και χωρίζει τον πίνακα με μη ισορροπημένο τρόπο πχ. όταν συμβαίνει το στοιχείο
διαχωρισμού να είναι το μεγαλύτερο στοιχείο κάθε φορά. Στην περίπτωση αυτή, όπως είδαμε
και στον quicksort, ο αλγόριθμος Find απαιτεί Ο(n2) χρόνο. Ευτυχώς όμως στη μέση
περίπτωση ο αλγόριθμος είναι κατά πολύ καλύτερος. Χρησιμοποιώντας τις ίδιες υποθέσεις
που κάναμε και στην ανάλυση του quicksosrt προκύπτει ότι η πιθανότητα το στοιχείο
διαχωρισμού για έναν πίνακα μεγέθους n να είναι το i-oστό μεγαλύτερο στοιχείο του είναι 1/n.
__
Έστω T (n, i) o μέσος χρόνος που κάνει ο Find για να βρει το i-oστό μεγαλύτερο στοιχείο
__ __
από n στοιχεία και T (n) = maxi T (n, i) ο μέγιστος χρόνος που χρειάζεται στη μέση
περίπτωση για οποιοδήποτε i, 1 ≤ i ≤ n. Ισχύουν οι παρακάτω σχέσεις:
i -1 n
∑ ∑
__ __ __
T (n,i) = cn + 1/n* [ T (n - k,i - k) + T (k - 1,i) ] για κάποια σταθερά c > 0
k =1 k =i +1
i-1 n-1
∑ ∑
__ __ __ __
T (n) = max T (n,i) ≤ Ο(n)+ 1/n * max[ T (n - k) + T (k) ]
i i
k=1 k=i
__
και μπορεί να αποδειχθεί με επαγωγή ότι T (n) ≤ 4cn = O(n).
Ο γραμμικός χρόνος στη μέση περίπτωση προκύπτει επειδή το μέσο μέγεθος των υποπινάκων
όπου εφαρμόζεται αναδρομικά ο Find είναι κλάσμα του n ενώ ο τετραγωνικός χρόνος
χειρότερης περίπτωσης όταν το μέγεθος των υποπινάκων είναι μεγάλο. Με καλύτερο
διαχωρισμό μπορούμε να πάρουμε γραμμικό χρόνο και στη χειρότερη περίπτωση.
Σχήμα 29: Διαχωρισμός σε πεντάδες. Τα στοιχεία του S1 είναι μικρότερα ή ίσα του m και τα
στοιχεία του S2 μεγαλύτερα του m
Τα στοιχεία που βρίσκονται μέσα στο λευκό ορθογώνιο είναι σίγουρα μεγαλύτερα από τον
m , άρα ανήκουν στο υποσύνολο S2 (τα στοιχεία αυτά είναι μεγαλύτερα από τον ενδιάμεσο
της στήλης όπου ανήκουν και ο ενδιάμεσος κάθε στήλης είναι μεγαλύτερος από τον m ).
Ομοίως, τα στοιχεία του γκρι ορθογωνίου είναι σίγουρα μικρότερα ή ίσα από τον m και
ανήκουν στο υποσύνολο S1. Tα S1, S2 περιέχουν πιθανώς και άλλα στοιχεία αλλά αυτά που
περικλείονται από τα ορθογώνια ξέρουμε με βεβαιότητα ότι ανήκουν στα δύο υποσύνολα.
Φράσσοντας το μέγιστο πλήθος στοιχείων που μπορεί να έχει κάθε ένα από τα S1, S2
μπορούμε να φράξουμε το μέγιστο χρόνο κάθε αναδρομικής κλήσης του αλγόριθμου Select.
Eπειδή |S1| + |S2| = n κάθε υποσύνολο θα περιέχει θα περιέχει τουλάχιστον 3n/10
στοιχεία αν το n διαιρείται ακριβώς με το 10 ή τουλάχιστον 3n/11 στοιχεία αν δεν διαιρείται
(τόσα είναι τα στοιχεία κάθε ορθογωνίου στο προηγούμενο σχήμα). Άρα, κάθε υποσύνολο θα
περιέχει το πολύ 8n/11 στοιχεία. Το πλήθος επίσης των πεντάδων που δημιουργούνται κατά
το διαχωρισμό είναι n/5 αν το n διαιρείται με το 5 ή το πολύ 21n/100 αν το n δεν διαιρείται. Αν
Τ(n) είναι ο μέγιστος χρόνος του αλγόριθμου Select για κάθε πίνακα μεγέθους n και κάθε i,
τότε υπάρχουν θετικές σταθερές a, b ώστε:
Mπορεί τώρα να αποδειχθεί με επαγωγή ότι Τ(n) ≤ cn = O(n) για c = max(a, 1100b/69).
Ερωτήσεις Κεφαλαίου
1. Σε ποιες κατηγορίες ταξινομούνται οι μέθοδοι ταξινόμησης; Ποια είναι η βασική τους
διαφορά;
2. Περιγράψτε τον αλγόριθμο Ταξινόμησης Φυσαλίδας. Δώστε τους χρόνους μέσης και
χειρότερης περίπτωσης.
3. Περιγράψτε τον αλγόριθμο Ταξινόμησης με Εισαγωγή. Δώστε τους χρόνους μέσης και
χειρότερης περίπτωσης.
4. Περιγράψτε τον αλγόριθμο Ταξινόμησης με Επιλογή. Δώστε τους χρόνους μέσης και
χειρότερης περίπτωσης.
5. Περιγράψτε τον αλγόριθμο Ταξινόμησης Σωρού. Δώστε τους χρόνους μέσης και
χειρότερης περίπτωσης.
6. Περιγράψτε τον αλγόριθμο Γρήγορης Ταξινόμησης. Δώστε τους χρόνους μέσης και
χειρότερης περίπτωσης.
7. Περιγράψτε τον αλγόριθμο Ταξινόμησης με Συγχώνευση. Δώστε τους χρόνους μέσης και
χειρότερης περίπτωσης.
8. Δώστε τα κάτω όρια στις πολυπλοκότητες μέσης και χειρότερης περίπτωσης των
συγκριτικών αλγόριθμων ταξινόμησης.
9. Περιγράψτε τον αλγόριθμο Ταξινόμησης με Μέτρηση. Δώστε την χρονική πολυπλοκότητα
του αλγόριθμου.
10. Περιγράψτε τον αλγόριθμο Radix Sort.
11. Περιγράψτε τον αλγόριθμο External Sorting. Δώστε την πολυπλοκότητα χρόνου του
αλγόριθμου.
12. Σε ποιες δύο κατηγορίες ταξινομούνται οι μέθοδοι αναζήτησης; Ποια η βασική τους
διαφορά;
13. Περιγράψτε τον αλγόριθμο γραμμικής αναζήτησης. Δώστε τους χρόνους μέσης και
χειρότερης περίπτωσης.
14. Περιγράψτε τον αλγόριθμο δυαδικής αναζήτησης. Δώστε τους χρόνους μέσης και
χειρότερης περίπτωσης.
15. Περιγράψτε τον αλγόριθμο αναζήτησης παρεμβολής. Δώστε τους χρόνους μέσης και
χειρότερης περίπτωσης. Κάντε μία σύγκριση με την μέθοδο δυαδικής αναζήτησης.
16. Περιγράψτε τον αλγόριθμο αναζήτησης κατά ομάδες. Δώστε τους χρόνους χειρότερης και
μέσης περίπτωσης. Εξηγείστε πώς μπορούν να βελτιωθούν αυτοί οι χρόνοι.
17. Περιγράψτε τον αλγόριθμο Find και δώστε τους χρόνους μέσης και χειρότερης
περίπτωσης.
18. Περιγράψτε τον γραμμικό αλγόριθμο Select.
4.1 Δένδρα
4.1.1 Γενική περιγραφή - Ορισμοί
Αντίθετα με τις λίστες,, σε ένα δένδρο από κάθε κόμβο δεν ξεκινά ένας μόνο δείκτης που
να δείχνει σε ένα κόμβο αλλά περισσότεροι οι οποίοι δείχνουν σε πολλούς κόμβους. Δηλαδή
τα δένδρα υλοποιούν σχέσεις ιεραρχικές μεταξύ των στοιχείων και άρα είναι δομές μη
γραμμικές. Οι σχέσεις που συνδέουν τα στοιχεία μεταξύ τους προσδιορίζουν κάθε φορά το
είδος του δένδρου και τις πράξεις που εκτελούνται σ’ αυτό. Επειδή ακριβώς η ιεραρχική
οργάνωση επιτρέπει ταχύτερη πρόσβαση στα δεδομένα, τα δένδρα είναι μία από τις πιο
αποτελεσματικές δομές για αποθήκευση και επεξεργασία δεδομένων και χρησιμοποιούνται
ευρύτατα σε κάθε είδος εφαρμογής διαχείρισης δεδομένων.
Ένα δένδρο μπορεί να παρασταθεί με ένα σύνολο κόμβων που συνδέονται με ακμές. Άρα
για οποιουσδήποτε δύο κόμβους υπάρχει ένα μοναδικό μονοπάτι που τους ενώνει. Η δομή
του ουσιαστικά έχει την μορφή ενός πραγματικού δέντρου και για το λόγο αυτό κάποια από τα
στοιχεία του ακολουθούν την γενική ονοματολογία δένδρων. Έτσι ο αρχικός κόμβος από τον
οποίο ξεκινούν ακμές αλλά δεν υπάρχει ακμή που να φτάνει σ’ αυτόν ονομάζεται ρίζα (root)
του δένδρου. Σε κάθε κόμβο καταλήγει μία ακμή ενώ από αυτόν ξεκινούν περισσότερες που
κάθε μία δείχνει σε έναν κόμβο. Οι κόμβοι στους οποίους καταλήγουν μόνο ακμές
ονομάζονται φύλλα (leaves) του δένδρου ενώ οι υπόλοιποι εσωτερικοί κόμβοι (internal
nodes) του δένδρου.
Παρακάτω δίνονται συνοπτικά οι ορισμοί των πιο συνηθισμένων εννοιών που
χρησιμοποιούνται στα δένδρα (κάποιους απ΄ αυτούς τους έχουμε δει ήδη στην παρουσίαση
του αλγόριθμου ταξινόμησης heapsort).
1. Πρόγονοι (ancestors) ενός κόμβου ή φύλλου είναι οι κόμβοι που βρίσκονται πάνω στο
μονοπάτι από τον κόμβο αυτό προς τη ρίζα.
2. Υπόδενδρο (subtree) ονομάζεται το δένδρο που σχηματίζεται, αν ως ρίζα ληφθεί ένας
οποιασδήποτε κόμβος.
3. Απόγονοι (ενός κόμβου είναι οι κόμβοι και τα φύλλα που βρίσκονται στο υπόδενδρο
κάτω από αυτόν.
4. Πατέρας (parent) ενός κόμβου ή ενός φύλλου είναι ο κοντινότερος πρόγονός του.
5. Παιδιά (childs) ενός κόμβου είναι οι απόγονοι του για τους οποίους είναι πατέρας.
6. Δύο κόμβοι με τον ίδιο πατέρα καλούνται αδέλφια (brothers).
7. Ο βαθμός (degree) ενός κόμβου είναι ίσος με το πλήθος των παιδιών του.
8. Μονοπάτι (path) δένδρου είναι η διαδρομή από τη ρίζα του δένδρου προς κάποιο φύλλο.
9. Το βάθος (depth) ενός κόμβου ή ενός φύλλου είναι ίσο με το μήκος (πλήθος ακμών) της
διαδρομής από αυτόν προς την ρίζα του δένδρου.
10. Το ύψος (height) ενός δένδρου είναι το μήκος του μακρύτερου μονοπατιού του δένδρου.
Ισούται με το βάθος του φύλλου με το μεγαλύτερο βάθος.
11. Επίπεδο (level) ενός κόμβου είναι ο αριθμός των προγόνων του μέχρι τη ρίζα συν 1.
12. Φυλλοπροσανατολιζόμενα (leaf-oriented) δένδρα καλούνται τα δένδρα στα οποία τα
δεδομένα είναι αποθηκευμένα μόνο στα φύλλα ενώ στους κόμβους αποθηκεύεται μόνο
βοηθητική πληροφορία.
13. Κομβοπροσανατολισμένα (node-oriented) δένδρα καλούνται τα δένδρα στα οποία τα
δεδομένα είναι αποθηκευμένα στα φύλλα και στους κόμβους.
14. κ-δικό καλείται το δένδρο όπου κάθε κόμβος έχει βαθμό το πολύ κ.
▪ Αν το δένδρο έχει n συνολικά κόμβους (εσωτερικοί κόμβοι και φύλλα) τότε h = log(n+1) –
1.
Σε κάθε δυαδικό δένδρο όπου κάθε κόμβος έχει ακριβώς 2 παιδιά (εσωτερικός κόμβος) ή 0
παιδιά (φύλλο) ισχύει:
Πλήθος φύλλων = Πλήθος εσωτερικών κόμβων +1
B C
D E F G
Για τη σειρά αποθήκευσης των κόμβων του δένδρου στον πίνακα, αρχικά αυτοί
αριθμούνται από αριστερά προς τα δεξιά και από το επίπεδο 1 της ρίζας μέχρι το τελευταίο
επίπεδο των φύλλων του δένδρου. Έτσι, σε ένα πλήρες δυαδικό δένδρο ύψους h με πλήθος
κόμβων 2h+1-1 ισχύει για κάθε κόμβο i:
Η υλοποίηση με του δείκτες πλεονεκτεί από την αντίστοιχη με πίνακες αφού η δέσμευση
της απαραίτητης μνήμης γίνεται δυναμικά και ανάλογα με τις τρέχουσες απαιτήσεις. Στην
περίπτωση του πίνακα, όπως είδαμε και στην περίπτωση της λίστας, είναι δυνατό να γεμίσει
και να μην “χωράει” άλλα στοιχεία ή και να έχει λίγα στοιχεία ενώ έχει δεσμευτεί το σύνολο
του αποθηκευτικού χώρου (σπατάλη μνήμης).
Παραδείγματα διαπεράσεων
Παράδειγμα 1: Εφαρμογή αλγόριθμων διαπέρασης
δηλ. η διαπέραση του δένδρου εκφράσεων με την μέθοδο της προδιάταξης δίνει τον
πολωνικό συμβολισμό της έκφρασης στην προθεματική (prefix) μορφή, η διαπέραση με τη
μέθοδο της μεταδιάταξης δίνει την παράσταση γραμμένη σε μεταθετική (postfix) μορφή και η
συμμετρική διαπέραση την παράσταση στην κανονική ένθετη (infix) μορφή.
Στο δένδρο του παραπάνω σχήματος, η συμμετρική διαπέραση δίνει την ταξινομημένη
(κατ΄ αύξουσα σειρά) ακολουθία στοιχείων που αποθηκεύει.
Preorder(r->left);
Preorder(r->right);
}
}
Και οι τρεις αλγόριθμοι διαπέρασης απαιτούν γραμμικό χρόνο αφού επισκέπτονται κάθε
κόμβο του δένδρου μόνο μία φορά (υποθέτουμε ότι η επίσκεψη ενός κόμβου κοστίζει Ο(1)
χρόνο). Πιο φορμαλιστικά, αν n είναι το μέγεθος του δένδρου (= πλήθος των κόμβων του) και
k το μέγεθος του αριστερού υποδένδρου της ρίζας, τότε ο χρόνος εκτέλεσης T(n)
οποιουδήποτε αλγόριθμου διαπέρασης είναι:
T(n) = O(1) + T(k) + T(n-k-1)
που έχει λύση Ο(n).
Ένα ενδιαφέρον σημείο για τη διαπέραση inorder είναι ότι μπορεί να χρησιμοποιηθεί για
να απαντήσει ερωτήσεις περιοχής (range queries) του τύπου «βρες τις τιμές του δένδρου που
ανήκουν σε ένα συγκεκριμένο διάστημα [x1..x2], είναι δηλ. ≥ x1 και ≤ x2 για δοσμένα x1, x2».
Δεδομένου ότι η inorder επιστρέφει τις τιμές που αποθηκεύει το δένδρο κατ’ αύξουσα σειρά,
βρίσκουμε αρχικά το μικρότερο στοιχείο που είναι ≥ x1 και στη συνέχεια με τη διαπέραση
τυπώνουμε ένα – ένα όλα τα στοιχεία του δένδρου μέχρι να συναντήσουμε το πρώτο
στοιχείο που είναι > x2. Οι αλλαγές που απαιτούνται στον παραπάνω κώδικα της συνάρτησης
Inorder() είναι απλές (θα πρέπει να προστεθούν οι κατάλληλες if εντολές) και αφήνονται ως
άσκηση. Αν Α είναι το σύνολο των στοιχείων της απάντησης, τότε η διαδικασία αυτή εκτελείται
σε χρόνο Ο(h + |A|), όπου h το ύψος του δένδρου και |Α| το μέγεθος του συνόλου Α.
Παρατήρηση:
Οι μέθοδοι διαπέρασης preorder και postorder μπορούν να οριστούν και σε δένδρα
οποιουδήποτε βαθμού d > 2 ενώ η συμμετρική διαπέραση έχει εφαρμογή μόνο σε δυαδικά
δένδρα.
Ύψος δένδρου
Το ύψος (Height) ενός δυαδικού δένδρου T μπορεί να οριστεί αναδρομικά ως εξής:
Height(T) = -1, αν το Τ είναι άδειο
Height(T) = 1+max(Height(αριστερό υποδένδρο Τ), Height(δεξί υποδένδρο Τ))
Η παρακάτω συνάρτηση σε C επιστρέφει το ύψος του T:
Αν root είναι ο δείκτης στη ρίζα του Τ, τότε η Ηeight(root) επιστρέφει το ύψος του T. Eίναι
εύκολο να δει κανείς ότι, αν το Τ έχει n συνολικά κόμβους, τότε ο χρόνος εκτέλεσης της
Height() είναι Ο(n) αφού ο αλγόριθμος ξοδεύει σε κάθε κόμβο Ο(1) χρόνο για να υπολογίσει το
μέγιστο από τα ύψη των δύο υποδένδρων του.
Aν h είναι το ύψος του T τότε ο χρόνος εκτέλεσης κάθε συνάρτησης είναι O(h) αφού η
αναζήτηση γίνεται κατά μήκος ενός μονοπατιού.
Οι δύο παράμετροι της συνάρτησης είναι το αριστερό και δεξί όριο του πίνακα S
αντίστοιχα ενώ η αρχική κλήση είναι root = Balanced_tree(1, n) (υποθέτουμε ότι ο πίνακας S
έχει δηλωθεί ως σφαιρική – global – μεταβλητή στο πρόγραμμα).
To δένδρο Τ μπορεί να κατασκευαστεί σε γραμμικό χρόνο. Πράγματι, αν T(n) είναι ο
χρόνος εκτέλεσης της Balanced_tree(), τότε:
T(n) = O(1) + 2Τ(n/2) = O(n)
Απεικόνιση δένδρου
Η επόμενη συνάρτηση «ζωγραφίζει» ένα δυαδικό δένδρο Τ τυπώνοντας τα στοιχεία που
αποθηκεύει στους κόμβους του κατά γραμμές ξεκινώντας από το δεξιότερο κόμβο: τα στοιχεία
των κόμβων που βρίσκονται πιο βαθιά στο δένδρο τυπώνονται δεξιότερα στην οθόνη.
if (r)
{
Print_tree (r->right, k+1);
for (i=1; i<=k; i++)
printf(“ “);
printf(“ %d \n“, r->num);
Print_tree(r->left, k+1);
}
}
Στο επόμενο σχήμα φαίνεται η έξοδος της συνάρτησης για ένα κομβοπροσανατολισμένο
και ένα φυλλοπροσανατολισμένο δυαδικό δένδρο αναζήτησης αντίστοιχα που αποθηκεύει τα
στοιχεία 1, 2, 3, 5, 6, 8, 12 (η συνάρτηση τυπώνει μόνο τους αριθμούς, οι γκρι γραμμές έχουν
προστεθεί για να δείξουν καλύτερα την τοπολογία του δένδρου).
Σχήμα 39: Η έξοδος της Print_tree() για ένα κομβοπροσανατολισμένο και το αντίστοιχο
φυλλοπροσανατολισμένο δυαδικό δένδρο αναζήτησης
Το πρόβλημα στα νηματοειδή δένδρα είναι πώς θα γνωρίζουμε αν ένας δείκτης είναι
κανονικός δείκτης ή δείκτης-νήμα. Η επίλυσή του είναι απλή αν στη δήλωση του κόμβου ενός
δένδρου χρησιμοποιήσουμε για κάθε δείκτη ένα ακόμη πεδίο που κρατάει το τύπο του
(κανονικός ή νήμα).
Το τελευταίο ερώτημα που θα μας απασχολήσει είναι πώς βρίσκουμε τον κόμβο που
προηγείται ή έπεται ενός δοσμένου κόμβου p σύμφωνα με τη συμμετρική διάταξη. Όπως θα
δούμε σε λίγο, η αναζήτηση του κόμβου αυτού είναι πιθανόν να απαιτήσει την επίσκεψη
κόμβων που βρίσκονται ψηλότερα από τον p στο δένδρο. Αυτό σημαίνει ότι από κάθε κόμβο
πρέπει να έχουμε πρόσβαση στον πατέρα του. Για το σκοπό αυτό επεκτείνουμε τον ορισμό
του δένδρου προσθέτοντας σε κάθε κόμβο του κι ένα πεδίο parent που είναι ο δείκτης στον
πατέρα του. Οι αντίστοιχες δηλώσεις στη C είναι οι εξής:
struct btnode {
int num;
struct btnode *left, *right, *parent;
};
Προσέξτε ότι στην περίπτωση β) είναι δυνατόν, καθώς ανεβαίνουμε το μονοπάτι, να φτάσουμε
στη ρίζα του Τ διαμέσου μιας διαδρομής δεξιών μόνο παιδιών. Στην περίπτωση αυτή ο p
αποθηκεύει τη μεγαλύτερη τιμή του T κι επομένως δεν υπάρχει ο successor(p) και η αντίστοιχη
συνάρτηση επιστρέφει τιμή NULL.
Στη συνέχεια δίνεται η συνάρτηση successor (p) σε C:
if (p->right)
{
p = p->right;
while (p->left)
p = p->left;
return (p);
}
q = p->parent;
while (q && p == q->right)
{
p = q;
q = p->parent;
}
return (q);
}
Και εδώ είναι δυνατόν ανεβαίνοντας το μονοπάτι να φτάσουμε στη ρίζα του Τ διαμέσου μιας
διαδρομής αριστερών μόνο παιδιών, δηλ. ο p αποθηκεύει τη μικρότερη τιμή του T και άρα
predecessor(p) = NULL.
Ακολουθεί η συνάρτηση predecessor (p) σε C η οποία ομοίως εκτελείται σε χρόνο Ο(h):
if (p->left)
{
p = p->left;
while (p->right)
p = p->right;
return (p);
}
q = p->parent;
while (q && p == q->left)
{
p = q;
q = p->parent;
}
return (q);
}
Η πράξη Search()
Αρχικά θα μελετήσουμε την πράξη της αναζήτησης. Για να βρούμε το στοιχείο x στο
δένδρο ακολουθούμε την ιδέα της δυαδικής αναζήτησης ως εξής (υποθέτουμε ότι όλα τα
στοιχεία που αποθηκεύει το δένδρο είναι διαφορετικά μεταξύ τους):
Αρχικά προσπελαύνουμε το πρώτο στοιχείο του δένδρου που είναι η ρίζα και συγκρίνουμε
το x με το περιεχόμενο της ρίζας. Αν το x είναι μικρότερο συνεχίζουμε την αναζήτηση στο
αριστερό υπόδενδρο της ρίζας. Αν είναι μεγαλύτερο συνεχίζουμε την αναζήτηση στο δεξί
υπόδενδρο. Η διαδικασία τερματίζεται είτε μόλις βρούμε τον κόμβο με το στοιχείο x ή
φτάσουμε σε άδειο δένδρο (δηλ. βρισκόμαστε σε κάποιον κόμβο και προσπελαύνουμε το
δείκτη ενός παιδιού του που είναι NULL).
Aλγόριθμος Search(r, x)
/* r είναι o δείκτης στη ρίζα του τρέχοντος δένδρου */
Δεδομένα // r, current_node: δείκτης σε struct btnode; x ακέραιος //
Aρχή
current_node = r
Όσο (current_node <> NULL) Εκτέλεσε
Αν (x = current_node->num) Τότε
επέστρεψε (current_node)
Αλλιώς Αν (x < current_node->num) Τότε
current_node = current_node->left
Αλλιώς current_node = current_node->right
Tέλος Αν
Τέλος Αν
Τέλος Όσο
επέστρεψε (NULL)
Αποτελέσματα // Ο κόμβος που δείχνει η current_node αν το x υπάρχει στο δένδρο
και ΝULL διαφορετικά //
Τέλος Search
Η πράξη Ιnsert()
Η εισαγωγή του στοιχείου x στο δένδρο γίνεται ακολουθώντας τον παρακάτω αναδρομικό
αλγόριθμο:
Συγκρίνουμε το x με το περιεχόμενο της ρίζας. Αν το x είναι μικρότερο εκτελούμε αναδρομικά
την εισαγωγή στο αριστερό υπόδενδρο της ρίζας ενώ αν είναι μεγαλύτερο στο δεξί. Αν
φτάσουμε σε άδειο δένδρο τότε δημιουργούμε ένα νέο φύλλο με τιμή το x και ο αλγόριθμος
τερματίζεται.
Ο αλγόριθμος καλείται με ορίσματα ένα δείκτη στη ρίζα r του τρέχοντος δένδρου και το
στοιχείο εισαγωγής x. Ως έξοδο επιστρέφει τον δείκτη στο κόμβο-ρίζα του νέου δένδρου μετά
την εισαγωγή.
Aλγόριθμος Insert(r, x)
/* r είναι o δείκτης στη ρίζα του τρέχοντος δένδρου */
Δεδομένα // r: δείκτης σε struct btnode; x: ακέραιος //
Aρχή
Aν (r = NULL) Τότε /* Άδειο δένδρο */
Δέσμευσε χώρο για τον κόμβο r
r->num = x
r->left = NULL
r->right = NULL
Aλλιώς Αν (x < r ->num) Τότε /* Eισαγωγή του x στο αριστερό υπόδενδρο */
r->left = Insert(r->left, x)
Αλλιώς Aν (x > r ->num) Τότε /* Εισαγωγή του x στο δεξί υπόδενδρο */
r->right = Insert(r->right, x)
Tέλος Αν
Tέλος Αν
Τέλος Αν
επέστρεψε (r)
Αποτελέσματα // Δείκτης στη ρίζα του νέου δένδρου μετά την εισαγωγή του x //
Τέλος Insert
Ο αλγόριθμος εξετάζει καταρχήν αν ο κόμβος r είναι NULL ή όχι. Αν ναι, τότε δεσμεύεται
δυναμικά χώρος στη μνήμη για το δείκτη r για τη δημιουργία του νέου κόμβου στον οποίο θα
αποθηκευτεί η τιμή x ενώ οι δείκτες r->left και r->right τίθενται NULL.
Στην περίπτωση που r <> NULL τότε η εισαγωγή του x θα γίνει είτε στο αριστερό υπόδενδρο
του r αν x < r->num (αναδρομική κλήση Insert(r->left, x)) ή στο δεξί υπόδενδρο αν x > r->num
(αναδρομική κλήση Insert(r ->right, x)) ενώ ενημερώνεται ταυτόχρονα ο δείκτης r->left ή r-
>right αντίστοιχα.
Σε κάθε περίπτωση ο αλγόριθμος επιστρέφει το δείκτη στη ρίζα του νέου δένδρου που
δημιουργείται μετά την εισαγωγή.
Η κλήση της Insert() έχει τη μορφή
root = insert(root, x)
ενώ αρχικά το δένδρο είναι άδειο και root = NULL. Προσέξτε ότι αν η τιμή x υπάρχει ήδη στο
δένδρο ο αλγόριθμος δεν το ξαναεισάγει.
Η πράξη Delete()
Η πράξη της διαγραφής ενός στοιχείου x από το δένδρο είναι λίγο πιο πολύπλοκη.
Υποθέτουμε ότι το x υπάρχει στο δένδρο. Ο αλγόριθμος είναι επίσης αναδρομικός και σε
πρώτο επίπεδο μπορεί να περιγραφεί ως εξής:
Συγκρίνουμε το x με το περιεχόμενο της ρίζας. Αν το x είναι μικρότερο τότε το διαγράφουμε
αναδρομικά από το αριστερό υπόδενδρο της ρίζας. Αν το x είναι μεγαλύτερο, τότε το
διαγράφουμε αναδρομικά από το δεξί υπόδενδρο. Αν το x είναι ίσο με το περιεχόμενο της
ρίζας, τότε διακρίνουμε τις εξής περιπτώσεις:
Έστω v ο κόμβος που περιέχει την τιμή x.
α) Aν ο v είναι φύλλο, το διαγράφουμε και ο αλγόριθμος τερματίζεται.
β) Αν ο v έχει ένα μόνο παιδί, έστω w, αντικαθιστούμε τον v με τον w και ο αλγόριθμος
τερματίζεται.
γ) Αν ο v έχει δύο παιδιά, έστω u το δεξί παιδί του. Bρίσκουμε τη μικρότερη τιμή στο
υπόδενδρο με ρίζα τον u (Tu), έστω στον κόμβο z και τη βάζουμε στον v. Στη συνέχεια
διαγράφουμε αναδρομικά τον z όπως στις περιπτώσεις α) και β).
Στο επόμενο σχήμα απεικονίζονται οι περιπτώσεις τρεις περιπτώσεις α), β), γ):
Τέλος Αν
Τέλος Αν
Αλλιώς Αν (x < r->num) Τότε r->left = Delete(r->left, x)
Aλλιώς r->right = Delete(r->right, x)
Τέλος Αν
επέστρεψε (r)
Τέλος Αν
Αποτελέσματα // Δείκτης στο νέο δένδρο //
Τέλος Delete
Όπως φαίνεται παραπάνω, μόλις βρούμε τον κόμβο που περιέχει το x εξετάζουμε ποια
από τις περιπτώσεις α), β) και γ) που είδαμε νωρίτερα ισχύει:
Περίπτωση α): Ο κόμβος r που επιθυμούμε να διαγράψουμε είναι φύλλο, γεγονός που
σημαίνει ότι και οι δύο δείκτες του r->left και r->right είναι NULL. Τότε σβήνεται ο r και
επιστρέφεται η τιμή NULL στον πατέρα του για να ενημερωθεί κατάλληλα ο αντίστοιχος
αριστερός ή δεξιός δείκτης του.
Περίπτωση β): Ο κόμβος διαγραφής r είναι εσωτερικός κόμβος και έχει ένα μόνο παιδί. Τότε ο
r σβήνεται αποδεσμεύοντας το χώρο που κατείχε και επιστρέφεται στον πατέρα του ο δείκτης
προς το παιδί του (στον αλγόριθμο είναι ο p) για την ενημέρωση.
Περίπτωση γ): Ο κόμβος διαγραφής r έχει δύο παιδιά. Tότε ο αλγόριθμος βρίσκει πρώτα την
μικρότερη τιμή στο δεξί υπόδενδρο του r προχωρώντας προς τα αριστερά (εντολή p = p->left
στο while loop του ψευδοκώδικα), στη συνέχεια αποθηκεύει την τιμή αυτή (p->num) στον
κόμβο r και σβήνει αναδρομικά από το δεξί υπόδενδρο του r την τιμή (αναδρομική κλήση
Delete(r->right, r->num)) ενημερώνοντας παράλληλα το δεξί δείκτη του r να δείχνει στο
υπόδενδρο που προέκυψε από τη διαγραφή.
Έτσι η πολυπλοκότητα χειρότερης περίπτωσης για την αναζήτηση σε ένα δυαδικό δένδρο
δεν είναι καλύτερη από την αντίστοιχη για την αναζήτηση σε έναν πίνακα ή μία λίστα όπου η
σειρά των στοιχείων είναι τυχαία.
Αν ξέρουμε τα στοιχεία από την αρχή, τότε μπορούμε να λύσουμε το προηγούμενο
πρόβλημα κτίζοντας ένα ισοζυγισμένο δένδρο με τη βοήθεια της συνάρτησης Balanced_tree(l,
r) που περιγράψαμε στο κεφ. 4.1.9 όπου το δένδρο έχει ύψος ≈ logn αν n=r-l+1 και η
κατασκευή του κοστίζει χρόνο Ο(n).
Όμως και πάλι δεν λύσαμε το πρόβλημα πλήρως. Η συνάρτηση Balanced_tree() απαιτεί
να ξέρουμε από την αρχή όλα τα στοιχεία και είναι καθαρά στατικό. Κάθε εκτέλεση μιας
πράξης Insert() ή Delete() μπορεί να χαλάσει τη ζύγιση του δένδρου και να κάνει ακριβές τις
Πόσος είναι ο μέσος χρόνος για μια επιτυχή αναζήτηση στο δένδρο T; Για να
εκτιμήσουμε αυτό το χρόνο αρκεί να υπολογίσουμε το μέσο πλήθος των ανεπιτυχών
συγκρίσεων που γίνονται για μία επιτυχή αναζήτηση. Για το σκοπό αυτό θα
χρησιμοποιήσουμε την έννοια του:
Ιnternal path length (ipl): το άθροισμα των μηκών όλων των μονοπατιών από τη ρίζα σε έναν
κόμβο του δένδρου
Το ipl μας λέει ακριβώς πόσες ανεπιτυχείς συγκρίσεις γίνονται για όλες τις δυνατές επιτυχείς
αναζητήσεις αν το περιεχόμενο κάθε κόμβου του δένδρου συγκρίνεται μία φορά με το στοιχείο
που ψάχνουμε. Θέλουμε λοιπόν να υπολογίσουμε το μέσο ipl (n), το οποίο συμβολίζουμε με
ipl (n) για το προηγούμενο τυχαίο δένδρο των n στοιχείων. Χωρίς οποιαδήποτε άλλη
πληροφορία, μπορούμε επίσης να υποθέσουμε ότι κάθε μια από τις n! δυνατές μεταθέσεις των
n στοιχείων είναι ισοπίθανη για τη σειρά εισαγωγής.
Για να μπορέσουμε να βρούμε μια αναδρομική σχέση για το ipl (n) αρκεί να
παρατηρήσουμε ότι αν τα n στοιχεία είναι σε τυχαία σειρά, τότε η πιθανότητα ένα
οποιοδήποτε να βρίσκεται στη ρίζα του T είναι 1/n και τα υπόλοιπα n-1 είναι πάλι σε τυχαία
σειρά. Επιπλέον, αν η ρίζα του Τ είναι το στοιχείο xi = i-oστό μεγαλύτερο στοιχείο, τότε τα
στοιχεία που είναι μικρότερα του xi (δηλ. τα x1, x2, …, xi-1) είναι και αυτά σε τυχαία σειρά όπως
και τα μεγαλύτερα του xi (δηλ. τα xi+1, xi+2, …, xn). Άρα και το αριστερό και δεξί υποδένδρο της
ρίζας του Τ είναι τυχαία δένδρα, οπότε η αναδρομική σχέση που προκύπτει για το ipl (n) θα
έχει τη μορφή:
n 1
ipl (n) = ∑ n [n − 1 + ipl (k − 1) + ipl (n − k)] = O(nlogn), ipl (0) = ipl (1) = 0
k =1
όπου ο όρος n-1 προκύπτει απ΄το γεγονός ότι η ρίζα του δένδρου συνεισφέρει κατά 1 στο ipl
των υπόλοιπων κόμβων του. Η προηγούμενη σχέση θυμίζει αυτήν που είδαμε στην ανάλυση
της μέσης συμπεριφοράς του quicksort και έχει λύση Θ(nlogn) που σημαίνει ότι το πλήθο
πλήθος συγκρίσεων για μία επιτυχή αναζήτηση θα είναι clogn για κάποια μικρή σταθερά c (το
c για την ακρίβεια αποδεικνύεται ότι είναι γύρω στο 1,39, δηλ. ο μέσος χρόνος μιας επιτυχούς
αναζήτησης είναι περίπου 40% χειρότερος από το βέλτιστο χρόνο logn που έχουμε σε ένα
πλήρες ισοζυγισμένο δένδρο). Η ανάλυση του μέσου χρόνου μιας ανεπιτυχούς αναζήτησης
είναι περίπου η ίδια μόνο που αντί του ipl πρέπει να χρησιμοποιήσουμε το epl το οποίο
έχουμε δει ήδη στον υπολογισμό του κάτω φράγματος στην πολυπλοκότητα των συγκριτικών
αλγόριθμων ταξινόμησης, κεφ. 3.1.9.
Η προηγούμενη ανάλυση αναφέρεται σε ένα ημιδυναμικό τυχαίο κομβοπροσανατολισμένο
δυαδικό δένδρο αναζήτησης. Τί ισχύει με τις διαγραφές στοιχείων; Μέχρι τώρα δεν υπάρχει
κάποια γνωστή ανάλυση για το μέσο χρόνο αναζήτησης σε ένα δένδρο που έχει
κατασκευαστεί με μια τυχαία ακολουθία πράξεων insert και delete. Εξάλλου, σε πρακτικές
εφαρμογές οι ακολουθίες αυτές συνήθως δεν είναι τυχαίες και το γεγονός αυτό κάνει τα
τυχαία δυναμικά δένδρα αναξιόπιστα. Πάντως υπάρχει γενικά η πεποίθηση ότι μάλλον
αποκλείεται να ισχύει κάποιο αποτέλεσμα καλύτερο απ΄ αυτό της προηγούμενης
παραγράφου. Αυτό δικαιολογείται από την ασυμμετρία που υπάρχει στη λειτουργία του
αλγόριθμου Delete(): για να σβήσουμε ένα στοιχείο x απ΄ το δένδρο, το αντικαθιστούμε είτε
με το προηγούμενο ή με το επόμενό του στη συμμετρική διάταξη και κατόπιν σβήνουμε το
στοιχείο αυτό. Το μόνο γνωστό αποτέλεσμα που έχει αποδειχθεί είναι το εξής: Αν σε κάθε
διαγραφή αντικαθιστούμε το στοιχείο που σβήνουμε με το προηγούμενό του στη συμμετρική
διάταξη, τότε n τυχαίες εισαγωγές ακολουθούμενες από μία σειρά τυχαίων εισαγωγών και
διαγραφών εναλλάξ παράγουν ένα δένδρο με ipl (n) = Θ(n3/2) που δίνει μέσο χρόνο
αναζήτησης Θ(√n). Για να φανεί όμως μια τέτοια συμπεριφορά, η ακολουθία των εισαγωγών
και διαγραφών πρέπει να είναι αρκετά μεγάλη.
Mια τελευταία παρατήρηση για την πράξη της διαγραφής είναι η παρακάτω. Είναι γενικά
σύνηθες φαινόμενο στους αλγόριθμους αναζήτησης να έχουμε πιο πολύπλοκες υλοποιήσεις
για την πράξη της διαγραφής: τα στοιχεία τείνουν να είναι ενσωματωμένα στη δομή με ένα
αρκετά ‘σφικτό’ τρόπο και η απομάκρυνση κάποιου απ΄ αυτά μπορεί να επιφέρει σύνθετες
ανακατασκευές. Γι’ αυτό ένας διαφορετικός τρόπος υλοποίησης, που μπορεί για κάποιες
εφαρμογές να είναι πιο κατάλληλος, είναι να χρησιμοποιήσουμε τη σταδιακή διαγραφή (lazy
deletion). Σύμφωνα με την τεχνική αυτή ένας κόμβος δεν απομακρύνεται από τη δομή αλλά
μόνο μαρκάρεται ως «διαγραμμένος» για τις ανάγκες της αναζήτησης. Ο κώδικας για μια
τέτοια διαγραφή πρέπει να περιλαμβάνει έναν επιπλέον έλεγχο για την ύπαρξη τέτοιων
κόμβων ώστε να μπορεί να σταματήσει η αναζήτηση. Φυσικά, η προσέγγιση αυτή επιβάλει να
έχουμε πάντα υπόψη μας ότι ένα μεγάλο πλήθος από μαρκαρισμένους κόμβους σημαίνει και
σπατάλη χώρου. Ένας πιο αποδοτικός τρόπος είναι να ξανακτίζουμε περιοδικά τη δομή μας
από την αρχή εξαιρώντας όλους τους μαρκαρισμένους κόμβους.
εδώ αυτή η κακή συμπεριφορά μπορεί να συμβαίνει αρκετά συχνά στην πράξη αν ο χρήστης
δεν επιχειρεί να την προλάβει. Ακολουθίες στοιχείων που είναι ήδη ταξινομημένα κατ΄
αύξουσα ή φθίνουσα σειρά ή ακολουθίες με στοιχεία μικρά και μεγάλα εναλλάξ τοποθετημένα
έχουν ως αποτέλεσμα τον εκφυλισμό των δυαδικών δένδρων σε γραμμικές λίστες με φτωχή
απόδοση.
Με τον quicksort ο μόνος τρόπος για να βελτιώσουμε την κατάσταση ήταν να
εφαρμόσουμε την τυχαιότητα: επιλέγοντας το στοιχείο διαχωρισμού τυχαία, οι νόμοι των
πιθανοτήτων μπορούν να εγγυηθούν ότι ο αλγόριθμος δεν έχει σχεδόν ποτέ κακή
συμπεριφορά. Ευτυχώς, για τα δυαδικά δένδρα αναζήτησης μπορούμε να κάνουμε κάτι
καλύτερο: υπάρχει μια τεχνική που εξασφαλίσει με βεβαιότητα ότι η κακή περίπτωση δεν θα
συμβεί ποτέ. Η τεχνική αυτή είναι γνωστή με το όνομα ζύγιση (balancing) και
χρησιμοποιείται ως βάση στους περισσότερους αλγόριθμους που εφαρμόζονται σε
ισοζυγισμένα δένδρα. Δυστυχώς όμως, λόγω περιορισμού της ύλης, θα συζητήσουμε αυτούς
τους αλγόριθμους πολύ συνοπτικά.
Τα ισοζυγισμένα δένδρα (balanced trees) είναι δένδρα αναζήτησης (είτε
κομβοπροσανατολισμένα ή φυλλοπροσανατολισμένα) τα οποία κτίζονται έτσι ώστε να
ικανοποιούν συγκεκριμένα κριτήρια ζύγισης τα οποία μπορούν να εγγυηθούν ότι το ύψους του
δένδρου για n στοιχεία θα είναι πάντα Θ(logn) και άρα όλες οι βασικές πράξεις μπορούν να
γίνουν σε Ο(logn) χρόνο. Γενικά τα ισοζυγισμένα δένδρα διακρίνονται σε τρεις μεγάλες
κατηγορίες:
(i) Tα υψοζυγισμένα δένδρα (heigh balanced trees)
(ii) Tα βαροζυγισμένα δένδρα (weight balanced trees)
(iii) Tα πολυδιακλαδισμένα τέλεια ισοζυγισμένα δένδρα (perfectly balanced multiway
trees)
Oι δύο πρώτες κατηγορίες περιλαμβάνουν δυαδικά δένδρα ενώ στην τρίτη ο αριθμός των
παιδιών κάθε κόμβου ποικίλει ανάλογα με το είδος του δένδρου. Ας δούμε στα γρήγορα κάθε
κατηγορία χωριστά.
Υψοζυγισμένα δένδρα
Στα υψοζυγισμένα δένδρα, κάθε κόμβος πληρεί μία συνθήκη ζύγισης που ααναφέρεται
στο ύψος του υποδένδρου που κρέμεται από τον κόμβο αυτό. Το δένδρο είναι ζυγισμένο αν τα
ύψη των υποδένδρων κάθε κόμβου δεν διαφέρουν πολύ μεταξύ τους. Η ζύγιση όμως αυτή
μπορεί να χαλάσει με την εκτέλεση μιας δυναμικής πράξης, γι΄ αυτό σε κάθε λειτουργία insert
ή delete μετά την εισαγωγή ή διαγραφή ενός στοιχείου ξεκινάει μια διαδικασία επαναζύγισης
(rebalancing) του δένδρου που περιλαμβάνει τις εξής επαναζυγιστικές πράξεις:
(α) απλές αλλαγές ζύγισης που ενημερώνουν την πληροφορία ζύγισης ενός κόμβου και οι
οποίες χαρακτηρίζονται ως φτηνές πράξεις και,
(β) δύο άλλες δομικές επαναζυγιστικές πράξεις που είναι η απλή περιστροφή (single
rotation) και η διπλή περιστροφή (double rotation) η οποία αποτελείται από δύο απλές
που εκτελούνται με διαφορετική φορά.
Η πράξη της απλής και διπλής περιστροφής απεικονίζεται παρακάτω. Χαρακτηρίζονται
ως δομικές και ακριβές πράξεις επειδή ακριβώς μεταβάλλουν τη δομή του δένδρου. Οι
περιστροφές είναι οι μόνες πράξεις που διορθώνουν τη ζύγιση στο δένδρο και συνάμα δεν
επηρεάζουν τη συμμετρική διάταξη των στοιχείων του.
α β
Δεξιά απλή
β περιστροφή στον α α
Αριστερή απλή
περιστροφή στον β
α γ
β β α
Δεξιά διπλή περιστροφή στον α:
γ αριστερή απλή στον β και δεξιά
απλή στον α
Οι περιστροφές επιφέρουν τοπικές αλλαγές στο δένδρο οι οποίες μεταδίδονται κατά μήκος
του μονοπατιού αναζήτησης από κάτω προς τα πάνω (bottom up). Η περιστροφή, απλή ή
διπλή, που αποκαθιστά τη ζύγιση καλείται τερματική. Mας ενδιαφέρει σε κάθε λειτουργία
insert και delete οι περιστροφές να είναι όσο το δυνατόν λιγότερες.
Κύριοι αντιπρόσωποι της κατηγορίας των υψοζυγισμένων δένδρων είναι τα ΑVL δένδρα
(Αdel’son-Velskιi & Landis, 1962) και τα κόκκινα-μαύρα (red-black) δένδρα (Βayer, 1972 και
Γκίμπας & Sedgewick,1978).
AVL δένδρα
Σε ένα AVL δένδρο (που ιστορικά είναι και το παλαιότερο ισοζυγισμένο δένδρο) η
πληροφορία ζύγισης που αποθηκεύεται σε κάθε κόμβο είναι η διαφορά των υψών μεταξύ
δεξιού και αριστερού υποδένδρου του κόμβου. Η διαφορά αυτή απαιτούμε να είναι -1, 0 ή 1
(είναι δυνατή η γενίκευση από –c έως c για κάποια μικρή σταθερά c). Παράδειγμα ενός
κομβοπροσανατολισμένου AVL δένδρου δίνεται παρακάτω. Στο εν λόγω δένδρο η διαφορά
των υψών των υποδένδρων κάθε κόμβου είναι 1.
Παρατηρείστε ότι το AVL δένδρο του προηγούμενου παραδείγματος είναι το F4. Έστω Nh το
πλήθος των στοιχείων του Fh δένδρου. Tότε από την κατασκευή του δένδρου ισχύει Nh = Nh -1
+ Nh-2 + 1 με Νο = 1, Ν1 = 2, Ν3 = 4 και άρα Nh = Fib(h+3)-1, όπου Fib(h+3) είναι ο (h+3)-οστός
Σημειώνουμε εδώ ότι και για τα φυλλοπροσανατολισμένα δένδρα o υπολογισμός του ύψους
γίνεται με παρόμοιο τρόπο και δίνει αντίστοιχα όρια.
Σε σχέση με τις πράξεις insert και delete αναφέρουμε ότι απαιτούν στη χειρότερη
περίπτωση Ο(logn) αλλαγές στην πληροφορία ζύγισης (μη δομικές πράξεις) στους κόμβους
κατά μήκος του μονοπατιού από τη θέση εισαγωγής μέχρι τη ρίζα. Επιπλέον, η insert
χρειάζεται το πολύ μία απλή ή διπλή περιστροφή (ακριβή δομική πράξη) η οποία εκτελείται
τελευταία ενώ η delete Ο(logn) περιστροφές (για την ακρίβεια h/2 περιστροφές αν το ύψος του
δένδρου είναι h) στο μονοπάτι από τον κόμβο διαγραφής μέχρι τη ρίζα του δένδρου.
Kόκκινα-μαύρα δένδρα
Είναι τα πλέον δημοφιλή δένδρα. Σ΄ ένα κόκκινο-μαύρο δένδρο η πληροφορία ζύγισης
είναι ένα bit που παριστάνει ένα χρώμα, κόκκινο ή μαύρο. Για τους κόμβους του δένδρου
ισχύουν τα εξής:
- Η ρίζα και τα φύλλα του δένδρου βάφονται εξ’ ορισμού μαύρα,
- Τα παιδιά κάθε κόκκινου κόμβου είναι μαύρα,
- Όλα τα μονοπάτια από τη ρίζα στα φύλλα του δένδρου έχουν τον ίδιο αριθμό μαύρων
κόμβων.
Στο επόμενο σχήμα δίνεται παράδειγμα ενός κόκκινου-μαύρου δένδρου αναζήτησης:
Μπορούμε εύκολα να δούμε από τον παραπάνω ορισμό ότι ο λόγος του μακρύτερου προς
το συντομότερο μονοπάτι του δένδρου είναι το πολύ 2 (το μακρύτερο μονοπάτι περιέχει
μαύρους και κόκκινους κόμβους εναλλάξ και το συντομότερο μόνο μαύρους).
4Η ακολουθία αυτή είναι διπλά αναδρομική και ορίζεται ως εξής: Fib(0) = 0, Fib(1) = 1 και Fib(k) = Fib(k-1) +
Fib(k-2) για k ≥ 2. Η λύση της είναι Fib(k) = (ak+1 – bk+1)/√5 όπου a = (1+√5)/2 και b = (1-√5)/2
To κόκκινο-μαύρο δένδρο δεν έχει την αυστηρή μορφή ζύγισης των AVL δένδρων, γι΄ αυτό
και το ύψος στη χειρότερη περίπτωση είναι μεγαλύτερο. Έτσι, μια πράξη search κοστίζει
περισσότερο, όμως εξαιτίας ακριβώς της ασθενέστερης συνθήκης ζύγισης τα κόκκινα-μαύρα
δένδρα επιτρέπουν ταχύτερη εκτέλεση των πράξεων insert και delete. Συγκεκριμένα, και οι δύο
δυναμικές πράξεις απαιτούν Ο(logn) αλλαγές χρώματος στους κόμβους αλλά μόνο Ο(1)
περιστροφές. Επιπλέον, για τα κόκκινα-μαύρα δένδρα έχουν αποδειχθεί και οι παρακάτω
ιδιότητες:
• Για m πράξεις insert και delete το πλήθος των επαναζυγίσεων είναι Ο(m),
• Ένας κόμβος σε ύψος k θα επαναζυγιστεί (με αλλαγή χρώματος ή μια περιστροφή) με
πιθανότητα Ο(1/ck) για κάποια σταθερά c>1.
Τέλος, τα κόκκινα-μαύρα δένδρα επιτρέπουν η επαναζύγιση να γίνεται από πάνω προς τα
κάτω (top down) όπου απαιτούνται Ο(logn) περιστροφές και άρα είναι ιδανικά σε
παράλληλες εφαρμογές όπου πολλοί χρήστες θέλουν να προσπελάσουν το ίδιο κομμάτι του
δένδρου ταυτόχρονα.
Βαροζυγισμένα δένδρα
Τα βαροζυγισμένα δένδρα χρησιμοποιούν για τη ζύγιση πληροφορία σχετικά με το
πλήθος των κόμβων ή των φύλλων των υποδένδρων και χρησιμοποιούν τις ίδιες
επαναζυγιστικές πράξεις όπως και τα υψοζυγισμένα για να επιτύχουν ζύγιση. Κυριότερος
αντιπρόσωπος των βαροζυγισμένων δένδρων είναι τα BB[α] δένδρα (Nievergelt και Reingold,
1973).
μπορεί να γίνει καταμερισμός (amortization) των εξόδων στις εισαγωγές και διαγραφές που
πέρασαν από τον κόμβο αυτό. Για τον λόγο αυτό είναι πολύ σημαντική σε παράλληλες
εφαρμογές καθώς και εφαρμογές πολυδιάστατων δομών δεδομένων.
Παρατηρείστε ότι όσο μεγαλύτερος είναι ο βαθμός διακλάδωσης b, τόσο πιο γεμάτοι είναι
οι κόμβοι του δένδρου και άρα έχουμε μικρότερο ύψος για το ίδιο πλήθος στοιχείων που
αποθηκεύει στα φύλλα του το δένδρο. Για παράδειγμα, χρησιμοποιώντας βαθμό
διακλάδωσης b = 103 μπορούμε να αποθηκεύσουμε 109 στοιχεία στα φύλλα ενός δένδρου με
ύψος μόλις 3. Το πλεονέκτημα αυτό κάνει τα (a, b) δένδρα κατάλληλα για εφαρμογές όπου το
πλήθος των κόμβων που επισκεπτόμαστε κατά την εκτέλεση μιας πράξης (search, insert,
delete κλπ.) πρέπει να είναι πολύ μικρός. Χαρακτηριστικό παράδειγμα αποτελούν οι
εφαρμογές διαχείρισης μεγάλου όγκου δεδομένων όπου μεγάλο μέρος των στοιχείων είναι
αποθηκευμένο στο σκληρό δίσκο του υπολογιστή (πχ. σε συστήματα διαχείρισης βάσεων
δεδομένων).
Αναφορικά με το ύψος ενός (a, b) δένδρου ισχύουν τα παρακάτω.
Έστω h το ύψος του δένδρου το οποίο περιέχει n στοιχεία Το ελάχιστο ύψος συμβαίνει
όταν κάθε εσωτερικός κόμβος έχει ακριβώς b παιδιά. Tότε το πλήθος των φύλλων του δένδρου
είναι bh = n, oπότε ελάχιστο ύψος h = logn/logb. To μεγαλύτερο ύψος συμβαίνει όταν η ρίζα
έχει 2 παιδιά και κάθε άλλος εσωτερικός κόμβος a παιδιά. Τότε το πλήθος των φύλλων του
δένδρου είναι 2ah-1, oπότε μέγιστο ύψος h = log(n/2)/loga+1. Δηλαδή, για το ύψος h ενός (a, b)
δένδρου ισχύει:
logn / logb ≤ h ≤ log(n/2) / loga + 1
και άρα είναι Θ(logn) για σταθερά a, b των οποίων οι ακριβείς τιμές εξαρτώνται από την
εφαρμογή όπου χρησιμοποιείται το δένδρο. Άμεση απόρροια αυτού είναι ότι κάθε πράξη
insert εκτελείται σε χρόνο Θ(logn).
Μια σημαντική διαφορά των (a, b) δένδρων με τα ισοζυγισμένα δένδρα των
προηγούμενων κατηγοριών είναι ότι η πληροφορία ζύγισης είναι σφαιρική και όχι τοπική σε
κάθε κόμβο: όλα τα φύλλα απέχουν το ίδιο από τη ρίζα και η μόνη ιδιότητα που πρέπει να
εξασφαλίζεται είναι ο αριθμός των παιδιών κάθε κόμβου να είναι από a μέχρι b. Αυτό σημαίνει
ότι τώρα αλλάζει και η φύση των επαναζυγιστικών πράξεων.
Μια πράξη insert μπορεί να οδηγήσει σε ένα κόμβο με b+1 παιδιά (λέμε ότι ο κόμβος
αυτός υπερχείλισε), οπότε ο ορισμός παραβιάζεται. Τότε ο κόμβος με τα b+1 παιδιά δίνει
κάποια σε έναν γειτονικό του αδερφό, αν υπάρχει ένας που δεν είναι γεμάτος και η insert
τερματίζεται (η πράξη αυτή καλείται διαμοιρασμός – sharing). Διαφορετικά, δημιουργείται
ένας νέος κόμβος που παίρνει τα μισά παιδιά αυτού που υπερχείλισε (πράξη διαχωρισμού –
split) και η διαδικασία συνεχίζεται προς τα πάνω.
Στη λειτουργία delete τώρα, αν σβήσουμε κάποιο φύλλο που κρέμεται από έναν κόμβο με
a παιδιά, τότε αυτός είτε δανείζεται από κάποιον γειτονικό αδερφό του, αν υπάρχει κάποιος με
> a παιδιά και η πράξη τερματίζεται. Διαφορετικά, τα δύο αδέρφια συγχωνεύονται σε έναν
κόμβο με 2a-1 παιδιά (πράξη συγχώνευσης – fusion ή merging) και η διαδικασία
συνεχίζεται προς τα πάνω.
Και στις δύο δυναμικές πράξεις, στη χειρότερη περίπτωση η διαταραχή θα φτάσει μέχρι
τη ρίζα του δένδρου και μόνε τότε μπορεί να αυξηθεί κατά 1 το ύψος του δένδρου σε μια insert
ή να μειωθεί κατά 1 σε μια delete. Δηλ. κάθε πράξη στη χειρότερη περίπτωση απαιτεί Ο(logn)
επαναζυγιστικές πράξεις που περιλαμβάνουν διαχωρισμούς ή συγχωνεύσεις κόμβων
αντίστοιχα μέχρι να γίνει η τερματική πράξη διαμοιρασμού.
Μια τελευταία ιδιότητα των (a, b) δένδρων είναι η εξής: το συνολικό κόστος επαναζύγισης
n εισαγωγών και διαγραφών στοιχείων σε ένα αρχικά άδειο (a, b) δένδρο είναι Ο(n) όταν b >
2a-1 ενώ για b = 2a-1 αυτό δεν ισχύει.
Στις περισσότερες εφαρμογές τα (a, b) δένδρα εμφανίζονται με τη μορφή (2, 4) δένδρων
τα οποία είναι εύκολα και πρακτικά στην υλοποίησή τους και απαντώνται σχεδόν σε όλα τα
συστήματα διαχείρισης βάσεων δεδομένων. Επιπλέον, έχουν αποδειχθεί ισοδύναμα με τα
κόκκινα-μαύρα δένδρα.
Το δένδρο περιοχής
Το δένδρο περιοχής προτάθηκε από τον Willard το 1985 για την επίλυση του
προβλήματος του παραθύρου (windowing ή orthogonal range searching) που διατυπώνεται
ως εξής:
Έστω P είναι το σύνολο των n σημείων στο επίπεδο και Q = [xQ1..xQ2] x [yQ1..yQ2] η
ορθογώνια περιοχή (παράθυρο) της ερώτησης. Για να απαντήσουμε το πρόβλημα: α) πρώτα
εστιάζουμε στα σημεία με x-συντεταγμένη που ανήκει στο διάστημα [xQ1..xQ2] και β) από τα
σημεία αυτά βρίσκουμε εκείνα με y-συντεταγμένη εντός του [yQ1..yQ2].
Για το α) η απάντηση είναι εύκολη. Αρχικά αποθηκεύουμε τα σημεία με βάση τη x-
συντεταγμένη τους στα φύλλα ενός ισοζυγισμένου φυλλοπροσανατολισμένου δυαδικού
δένδρου αναζήτησης Τ το οποίο μπορεί να υλοποιηθεί πχ. ως ένα AVL ή ένα κόκκινο-μαύρο
δένδρο. Στη συνέχεια ψάχνουμε στο Τ με βάση τα xQ1, xQ2. Έστω PxQ1, PxQ2 τα δύο
μονοπάτια αναζήτησης προς τα xQ1 και xQ2 αντίστοιχα τα οποία διαχωρίζονται στον κόμβο
vsplit όπως απεικονίζεται στο επόμενο σχήμα:
Σχήμα 50: Εύρεση των σημείων με x-συντεταγμένη μεταξύ των xQ1 και xQ2
Ως απάντηση αναφέρουμε τα σημεία που αποθηκεύονται στα φύλλα των υπόδενδρων Τv,
όπου v είναι είτε δεξιό παιδί κάποιου κόμβου στο μονοπάτι PxQ1 κάτω από τον vsplit ή
αριστερό παιδί κάποιου κόμβου στο μονοπάτι PxQ2 κάτω από τον vsplit και δεν ανήκει στα
μονοπάτια PxQ1 και PxQ2 (στο παραπάνω σχήμα είναι τα φύλλα των γκρι υπόδενδρων).
Δεδομένου ότι επισκεπτόμαστε Ο(logn) ξένα μεταξύ τους υπόδενδρα, ο συνολικός χρόνος
απάντησης είναι Ο(logn + |Α|), όπου Α είναι το σύνολο των σημείων της απάντησης.
Για το σημείο β) επεκτείνουμε την παραπάνω δομή ως εξής. Για κάθε κόμβο v του
δένδρου Τ κατασκευάζουμε ένα ισοζυγισμένο φυλλοπροσανατολισμένο δυαδικό δένδρο
αναζήτησης για τα σημεία που αποθηκεύονται στα φύλλα του Tv και με βάση την y-
συντεταγμένη. Πρακτικά ο κόμβος v περιέχει ένα δείκτη σ’ αυτό το δευτερεύον δένδρο και με
τον τρόπο αυτό έχουμε δημιουργήσει ένα δένδρο δύο επιπέδων όπου το κύριο δένδρο Τ είναι
το δένδρο 1ου επιπέδου και τα δευτερεύοντα δένδρα των κόμβων του Τ είναι τα δένδρα 2ου
επιπέδου (τα δένδρα αυτά φωλιάζουν μέσα στους κόμβους του Τ). Τα συγκεκριμένα δένδρα
απεικονίζονται στο σχήμα που ακολουθεί που δείχνει το δένδρο περιοχής για ένα επιλεγμένο
σύνολο σημείων του επιπέδου:
Σχήμα 51: Παράδειγμα δένδρου περιοχής για ένα συγκεκριμένο σύνολο σημείων
την εκτέλεση μιας ερώτησης παραθύρου είναι ∑ Ο(logn +|Av|) = O(log2n + |A|), όπου Α το
v
σύνολο των σημείων της απάντησης (ο χρόνος αυτός μπορεί να μειωθεί σε O(logn + |A|)
εφαρμόζοντας μια έξυπνη τεχνική που ονομάζεται fractional cascading και την οποία δεν θα
αναφέρουμε εδώ).
Ο χώρος τώρα που καταλαμβάνει το δένδρο παύει να είναι γραμμικός. Kάθε σημείο p
αποθηκεύεται σε όλα τα δένδρα 2ου επιπέδου των κόμβων του μονοπατιού εύρεσης προς το
φύλλο που περιέχει το p στο δένδρο 1ου επιπέδου T. Άρα, για όλους τους κόμβους του Τ που
βρίσκονται σε ένα συγκεκριμένο ύψος, το p αποθηκεύεται μόνο σε ένα δένδρο 2ου επιπέδου το
οποίο απαιτεί χώρο Ο(n). Επειδή το ύψος του Τ είναι Ο(logn), o συνολικός χώρος γίνεται
Ο(nlogn).
δυναμικό ισοζυγισμένο δυαδικό δένδρο αναζήτησης. Στην περίπτωση που το δένδρο είναι το
AVL ή το ΒΒ[α], τότε στη χειρότερη περίπτωση κάθε πράξη εισαγωγής/διαγραφής σε ένα
δένδρο περιοχής d διαστάσεων κοστίζει Ο(logdn) χρόνο.
Ψηφιακά δένδρα
Ένα ψηφιακό δένδρο (digital tree) αποθηκεύει τα στοιχεία ενός συνόλου ιεραρχικά
εκμεταλλευόμενο την αναπαράσταση των τιμών των στοιχείων.
Υποθέτουμε ότι η τιμή κάθε στοιχείου είναι μία ακολουθία ψηφίων ή χαρακτήρων οι οποίοι
ανήκουν σε ένα συγκεκριμένο αλφάβητο, πχ. τα δεκαδικά ψηφία 0-9, τα γράμματα a-z κλπ.
Μπορούμε να υλοποιήσουμε ένα ψηφιακό δένδρο αποθηκεύοντας σε κάθε κόμβο του ένα
μόνο ψηφίο και με τρόπο ώστε η τιμή κάθε στοιχείου που έχει εισαχθεί στο δένδρο να
σχηματίζεται ακολουθώντας ένα μονοπάτι από τη ρίζα του δένδρου σε κάποιο φύλλο του.
Έτσι, κάθε κόμβος του δένδρου έχει το πολύ τόσα παιδιά όσα είναι τα ψηφία του
αλφάβητου. Στο επόμενο σχήμα δίνεται μία τέτοια υλοποίηση για τους αριθμούς 123, 129,
140, 143, 148, 151, 155, 167 που θεωρείστε ότι αντιστοιχούν πχ. σε κωδικούς των φοιτητών
ενός τμήματος:
Σχήμα 52: Υλοποίηση ψηφιακού δένδρου για τους αριθμούς 123, 129, 140, 143, 148, 151,
155, 167
δείχνει πώς έχει τροποποιηθεί το προηγούμενο δένδρο για να συμπεριλάβει και τον αριθμό
1234.
Σχήμα 53: Το δένδρο που προκύπτει μετά την εισαγωγή του αριθμού 1234
Ένας άλλος τρόπος για να υλοποιήσουμε ένα ψηφιακό δένδρο είναι να αποθηκεύσουμε
τις τιμές των στοιχείων στα φύλλα του δένδρου και σε κάθε κόμβο του να χρησιμοποιήσουμε
πίνακες που καθοδηγούν την αναζήτηση. Η συγκεκριμένη δομή είναι γνωστή με το όνομα
δένδρο ανάκτησης ή στα αγγλικά trie και προκύπτει από τη λέξη re-trie-val που σημαίνει
ανάκτηση.
Η δομή trie χρησιμοποιείται συνήθως για την αποθήκευση συμβολοσειρών σταθερού
μήκους. Έστω ότι κάθε σύμβολο επιλέγεται από ένα αλφάβητο k συμβόλων (ψηφίων). Αν το
μήκος κάθε συμβολοσειράς είναι λ, τότε το trie μπορεί να αποθηκεύσει συνολικά Ν = kλ
στοιχεία. Η προφανής υλοποίηση είναι σε κάθε κόμβο του trie να διατηρούμε έναν πίνακα από
k δείκτες. Κάθε θέση του πίνακα αντιστοιχεί σε ένα σύμβολο του αλφάβητου. Για έναν κόμβο
στο επίπεδο i, μία θέση που αντιστοιχεί στο σύμβολο d θα δείχνει σε έναν κόμβο του
επόμενου επιπέδου αν κάποια συμβολοσειρά περιέχει στην i-οστή της θέση από αριστερά το
σύμβολο d. Το ύψος του trie αυτού είναι ίσο με λ.
Ως παράδειγμα θεωρείστε ότι το αλφάβητό μας είναι το σύνολο {0, 1, 2} και θέλουμε να
αποθηκεύσουμε τα στοιχεία 102, 120, 121, 210, 211 και 212. To trie στην περίπτωσή μας θα
έχει την παρακάτω μορφή:
Σχήμα 54: Η δομή trie για τα στοιχεία 102, 120, 121, 210, 211 και 212
Τετραδικά δένδρα
Τα τετραδικά δένδρα (quad trees) έχουν ευρεία χρήση σε εφαρμογές γραφικών και
επεξεργασίας εικόνας.
Υποθέστε ότι στην οθόνη του υπολογιστή μας έχουμε την παρακάτω εικόνα, που για
λόγους ευκολίας θεωρούμε ότι είναι ασπρόμαυρη:
Η οθόνη περιλαμβάνει ένα πλήθος από μικρά τετράγωνα τα οποία καλούνται εικονοστοιχεία
(pixels) κι έστω το πλήθος των εικονοστοιχείων είναι 2n x 2n για κάποιο καλώς ορισμένο n>1.
Σε κάθε εικονοστοιχείο αντιστοιχεί μία τιμή η οποία δίνει το χρώμα του εικονοστοιχείου. Για
μια ασπρόμαυρη εικόνα, η τιμή αυτή παριστάνεται συνήθως με ένα byte (8 bits) κι επομένως
το χρώμα μπορεί να έχει τιμή από 1=20 έως 256=28 που αντιστοιχούν σε διαφορετικές
διαβαθμίσεις του γκρι , από το άσπρο έως το τελείως μαύρο (στις έγχρωμες εικόνες συνήθως
χρησιμοποιούνται 3 bytes = 24 bits για την κωδικοποίηση του χρώματος κι έχουμε επομένως
224 ≈ 16,7 εκατομμύρια χρώματα). Για το παράδειγμά μας υποθέτουμε ότι η τιμή κάθε
εικονοστοιχείου είναι μόνο ένα bit με τo 0 να αντιστοιχεί στο λευκό και το 1 στο μαύρο. Έτσι η
αρχική εικόνα παίρνει τώρα την παρακάτω μορφή:
0 0 0 0 0 0 0 0
0 0 0 0 1 1 0 0
0 0 0 0 1 1 1 1
0 0 0 0 1 1 1 0
1 1 1 0 1 1 1 1
1 0 0 1 1 1 1 1
1 1 0 0 1 1 1 1
1 1 0 0 1 1 1 1
Ο στόχος μας είναι να βρούμε μία δομή κατάλληλη για την αποθήκευση της παραπάνω
εικόνας στην μνήμη του υπολογιστή.
Για το σκοπό αυτό μπορούμε να χρησιμοποιήσουμε ένα διδιάστατο δυαδικό πίνακα με 2n
x 2n bits, ανεξάρτητα από το πλήθος των 1 (πόσο ‘γεμάτη’ δηλ. είναι η εικόνα). Όταν το n είναι
μεγάλο, αυτός ο τρόπος αποθήκευσης οδηγεί σε σπατάλη χώρου. Για παράδειγμα, για μια
οθόνη με ανάλυση 1.024x768 pixels απαιτούνται συνολικά 96 KB (φανταστείτε μία εφαρμογή
γραφικών όπου κανείς πρέπει να επεξεργαστεί ένα σύνολο τέτοιων εικόνων).
Η δομή δεδομένων που εφαρμόζεται συνήθως στο πρόβλημα αυτό είναι το τετραδικό
δένδρο κάθε κόμβος του οποίου εκτός των φύλλων έχει τέσσερα ακριβώς παιδιά και μπορεί
να οριστεί αναδρομικά σύμφωνα με την παρακάτω διαδικασία:
• Παριστάνουμε την εικόνα με τον αντίστοιχο δυαδικό πίνακα. Η ρίζα του τετραδικού
δένδρου αντιστοιχεί στον αρχικό αυτό πίνακα.
• Στη συνέχεια ο πίνακας χωρίζεται σε τέσσερις ισομεγέθεις υποπίνακες και κάθε τέτοιος
υποπίνακας αντιστοιχίζεται στον κόμβο ενός τετραδικού δένδρου. Αν ο υποπίνακας είναι
ομοιογενής, έχει δηλ. το ίδιο περιεχόμενο σε όλες τις θέσεις του (μόνο 0 ή μόνο 1), τότε ο
κόμβος είναι φύλλο του δένδρου. Αν δεν έχει το ίδιο περιεχόμενο σε όλες του τις θέσεις,
τότε ο υποπίνακας χωρίζεται εκ νέου σε τέσσερις υποπίνακες και η διαδικασία
επαναλαμβάνεται μέχρι να καταλήξουμε σε μία μόνο θέση – εικονοστοιχείο της αρχικής
μας εικόνας.
• Σε κάθε φύλλο αποθηκεύουμε κι ένα bit που μας δείχνει αν ο αντίστοιχος υποπίνακας
έχει σε όλα τα εικονοστοιχεία του την τιμή 0 ή 1.
0 0 0 0 0 0 0 0
0 0 0 0 1 1 0 0
0 0 0 0 1 1 1 1
0 0 0 0 1 1 1 0
1 1 1 0 1 1 1 1
1 0 0 1 1 1 1 1
1 1 0 0 1 1 1 1
1 1 0 0 1 1 1 1
Στο επόμενο σχήμα φαίνεται το τετραδικό δένδρο που προκύπτει για τον πίνακα του
παραδείγματος όπου κάθε φύλλο βάφεται λευκό ή μαύρο ανάλογα αν η πληροφορία για το
αντίστοιχο κομμάτι της εικόνας είναι 0 (άδεια) ή 1 (γεμάτη).
Σχήμα 55: Το τετραδικό δένδρο που αντιστοιχεί στον εικόνα του παραδείγματος
Η αναζήτηση σε ένα τετραδικό δένδρο γίνεται με τον ίδιο τρόπο όπως και στα δυαδικά
δένδρα μόνο που τώρα σε κάθε κόμβο θα πρέπει να αποφασίζουμε ποιο υπόδενδρο να
ακολουθήσουμε.
Ένα τετραδικό δένδρο που χρησιμοποιείται για την αποθήκευση μιας εικόνας μεγέθους
2n x 2n έχει ύψος το πολύ n, δηλ. λογαριθμικό στο μέγεθος της εικόνας. Το μέγιστο πλήθος
κόμβων N του δένδρου δίνεται από τον τύπο:
n
Ν=
∑
k=0
4k ≈
4 n
3
4
δηλ. οι απαιτήσεις σε χώρο είναι περίπου τα 4/3 του μεγέθους της εικόνας.
Τέλος, επισημαίνεται ότι ο παραπάνω ορισμός του τετραδικού δένδρου που ισχύει για το
επίπεδο μπορεί να γενικευθεί και στις d διαστάσεις όπου κάθε κόμβος του δένδρου έχει τώρα
2d παιδιά.
4.2 Γράφοι
4.2.1 Τι είναι γράφος
Παραπάνω περιγράψαμε δομές δεδομένων οι οποίες αποτελούνται από κόμβους και
συνδέσεις ή αλλιώς ακμές. Τόσο η διάταξη των κόμβων όσο και το πλήθος και είδος των
ακμών καθόριζαν τον τρόπο οργάνωσης των δεδομένων και τη λειτουργία της δομής. Έτσι,
στις λίστες είχαμε ένα σύνολο από κόμβους που κάθε ένας συνδέονταν μέσω ενός συνδέσμου
– ακμής με τον επόμενο κόμβο σχηματίζοντας μία απλά συνδεδεμένη λίστα. Παράλληλα, στα
δένδρα σε κάθε κόμβο συνδέονταν περισσότερες ακμές που, ανάλογα με το πλήθος τους,
σχημάτιζαν διαφορετικά είδη δένδρων.
Στη γενική περίπτωση, όταν η δομή αποτελείται από κορυφές (ή κόμβους) και μερικά
ζεύγη αυτών συνδέονται με ακμές τότε αυτή καλείται γράφος (graph). Πιο αυστηρά, ένας
γράφος είναι ένα διατεταγμένο ζεύγος G = (V, E), όπου το V = {v1, v2,…, vn} είναι το σύνολο
των κορυφών ή κόμβων (vertices) ενώ Ε = {e1, e2, …, em} είναι ένα σύνολο από διμελή
σύνολα κορυφών (vi, vk) ∈ VxV που ορίζουν τις ακμές (edges) του γράφου. Ο γράφος που
ορίζεται με τον παραπάνω τρόπο καλείται μη κατευθυνόμενος (undirected), επειδή οι ακμές
του δεν έχουν συγκεκριμένη κατεύθυνση, σε αντίθεση με τον όπου κάθε ακμή έχει
συγκεκριμένο προσανατολισμό. Τα όσα ακολουθούν μέχρι και την παρ. 4.2.8 αναφέρονται σε
μη κατευθυνόμενους γράφους.
Παράδειγμα
Έστω έχουμε τον γράφο G=(V, E) με V = {v1, v2, v3} και Ε = {( v1, v2), ( v2, v3), (v1, v3)}. Στην
περίπτωση αυτή ο γράφος αποτελείται από 3 κορυφές και 3 ακμές όπως φαίνεται και στο
παρακάτω σχήμα.
Είναι προφανές ότι σε ένα γράφο G= (V, E) με n≥1 κορυφές (|V| = n), το πλήθος των
ακμών του |Ε| μπορεί να είναι μέχρι n(n-1)/2 (κάθε κορυφή συνδέεται με τις υπόλοιπες n-1).
Όταν το |Ε| είναι γραμμικό στο πλήθος των κορυφών, δηλ. |Ε| = Ο(n), τότε ο γράφος καλείται
αραιός (sparse) ενώ στην περίπτωση που το |Ε| = Θ(n2), τότε ο γράφος ονομάζεται πυκνός
(dense).
Σε ένα γράφο, οι ακμές μπορούν να φέρουν και κάποια πληροφορία που καλείται βάρος
(κόστος) και δίνεται από μια συνάρτηση βάρους w: E →R η οποία εξαρτάται από την
εφαρμογή. Για παράδειγμα, το βάρος μιας ακμής w(x, y) μπορεί να αντιστοιχεί στην
απόσταση ή στο κόστος μεταφοράς μεταξύ των κορυφών x, y (πχ. σε ένα γράφο που
αναπαριστά το οδικό δίκτυο μεταξύ κάποιων πόλεων) ή σε χωρητικότητα επικοινωνίας (πχ. σε
ένα γράφο που αναπαριστά ένα δίκτυο δεδομένων) κλπ. Στην περίπτωση αυτή ο γράφος
ονομάζεται βεβαρημένος ή βαροζυγισμένος (weighted).
∑ d(v) = 2|Ε|
v∈V
Η απόδειξη γι’ αυτό βασίζεται στην παρατήρηση ότι κάθε ακμή έχει δύο άκρα.
6. Ένας γράφος G’=(V’, E’) είναι υπογράφος (subgraph) του γράφου G=(V, E) όταν όλοι οι
κόμβοι και οι ακμές του γράφου G’ περιέχονται στον γράφο G δηλαδή ισχύουν: V’ ⊆ V
και E’ ⊆ E.
Παράδειγμα:
Ο γράφος:
Για κάθε υποσύνολο κορυφών V’ του V μπορούμε να παράγουμε έναν υπογράφο ο οποίος
έχει σύνολο κορυφών το V’ και σύνολο ακμών όλες τις ακμές του G με άκρα στο V’. Επίσης,
μπορούμε να παράγουμε έναν υπογράφο G’ του γράφου G αν αφαιρέσουμε από τον G
(σύνολο Ε) μία ακμή. Αντιστοίχως, να παράγουμε έναν υπογράφο G’ του γράφου G αν
αφαιρέσουμε από τον G (σύνολο V) μία κορυφή έστω x και παράλληλα όλες τις ακμές που
έχουν ως άκρο την κορυφή x.
Παράδειγμα
Παρακάτω φαίνονται οι δύο δομές δεδομένων για τον γράφο του σχήματος:
Σχήμα 56: Πίνακας γειτνίασης και λίστες γειτνίασης για την αποθήκευση ενός γράφου
Παρατηρείστε ότι ο πίνακας γειτνίασης είναι συμμετρικός αφού τα ζεύγη (i, j) και (j, i)
παριστάνουν την ίδια ακμή (i, j) ∈ Ε και άρα aij = aji = 1.
Ο πίνακας γειτνίασης μπορεί να χρησιμοποιηθεί και για την αποθήκευση ενός βεβαρημένου
γράφου. Στην περίπτωση αυτή το στοιχείο aij του πίνακα γειτνίασης έχει ως τιμή το βάρος w(i,
j) αν (i, j) ∈ Ε. Αν (i, j) ∉ Ε τότε το aij παίρνει την ειδική τιμή NULL (αν και σε πολλά
γραφοθεωρητικά προβλήματα βολεύει να χρησιμοποιήσουμε την τιμή 0 ή ∞).
Οι λίστες γειτνίασης μπορούν επίσης να προσαρμοστούν για να αποθηκεύσουν έναν
βεβαρημένο γράφο, αν σε κάθε κόμβο της λίστας Adj[v] αποθηκεύουμε και το βάρος της
ακμής (v, u). Mπορούμε επιπλέον να διατηρούμε τη λίστα διατεταγμένη ως προς τα βάρη των
ακμών, κάτι που βολεύει σε αρκετά προβλήματα.
Αναφορικά τώρα με την αποδοτικότητα των δύο δομών, ισχύουν τα εξής.
Ο χώρος καταρχήν του πίνακα γειτνίασης είναι πάντα n2 ανεξάρτητα από το |Ε|. Σε
κάποιες εφαρμογές αρκεί να αποθηκεύσουμε μόνο τα στοιχεία που είναι πάνω ή κάτω από
την κύρια διαγώνιο και να μειώσουμε με τον τρόπο αυτό τον απαιτούμενο χώρο παραπάνω
από το μισό. Στις λίστς γειτνίασης, το συνολικό μέγεθος όλων των λιστών (συνολικό πλήθος
κόμβων) είναι μόνο 2|Ε| και επομένως ο συνολικός χώρος που καταλαμβάνει η δομή μαζί με
τον πίνακα Adj είναι Ο(n+|E|), δηλ. οιλίστες γειτνίασης είναι πιο αποδοτική σε χώρο για
αραιούς γράφους.
Σε σχέση με την υλοποίηση βασικών πράξεων σε ένα γράφο, κάθε δομή μπορεί να
υποστηρίξει αποδοτικά συγκεκριμένες μόνο πράξεις. Ο έλεγχος για παράδειγμα αν ο γράφος
περιέχει την ακμή (x, y) ή η εισαγωγή/διαγραφή μιας ακμής στον/από το γράφο εκτελούνται
όλες σε χρόνο Ο(1) αν χρησιμοποιήσουμε τον πίνακα γειτνίασης (ελέγχουμε ή αλλάζουμε
μόνο την τιμή aij). Αντιθέτως, με λίστες γειτνίασης και οι τρεις πράξεις απαιτούν χρόνο Ο(d(x))
αφού στη χειρότερη περίπτωση πρέπει να σαρώσουμε ολόκληρη τη λίστα Adj[x] για να
βρούμε αν η (x, y) υπάρχει, κι αν ναι να τη διαγράψουμε ή διαφορετικά να την εισάγουμε. Κι
επειδή o γράφος είναι μη κατευθυνόμενος, το ίδιο θα πρέπει να κάνουμε και στη λίστα Adj[y].
Οι λίστες γειτνίασης είναι σαφώς πιο αποδοτικές αν κανείς θέλει να υπολογίσει το βαθμό μιας
κορυφής x ή να βρει όλες τις ακμές του γράφου. Για την πρώτη πράξη απαιτείται σάρωση της
λίστας Adj[x] που γίνεται σε χρόνο Ο(d(x)) και για τη δεύτερη σάρωση όλων των λιστών σε
χρόνο Ο(|E|). Στην περίπτωση του πίνακα γειτνίασης απαιτείται χρόνος Ο(n) και Ο(n2)
αντίστοιχα.
Ο ακόλουθος πίνακας συνοψίζει τα αποτελέσματα της σύγκρισης μεταξύ των δύο δομών:
Γενικά η λίστα γειτνίασης είναι μία αρκετά εύρωστη και αποδοτική δομή η οποία μπορεί να
προσαρμόζεται κατάλληλα ανάλογα με την εφαρμογή.
Πολλαπλοί γράφοι
Πολλαπλοί (multiple) είναι οι γράφοι στους οποίους επιτρέπονται ανακυκλώσεις, δηλ.
ακμές με άκρα την ίδια κορυφή. Τυπικά, ένας πολλαπλός είναι ένας γράφος G=(V, E) όπου το
Ε περιέχει ακμές της μορφής (e, k) όπου:
• e ένα ζεύγος διαφορετικών κορυφών ή μόνο μία κορυφή ως άκρα
• k είναι ένας φυσικός αριθμός που χρησιμοποιείται για τον ορισμό του πλήθους των
ανακυκλώσεων σε μία κορυφή.
Παράδειγμα
Έστω ότι έχουμε τον γράφο G=(V, E), με V = {v1, v2, v3} και Ε={{(v1, v2), 0}, {( v1, v2), 1},
{(v1, v3), 0}, {(v3), 0},{(v2, v3), 0}}. Στην περίπτωση αυτή ο γράφος αποτελείται από 3 κορυφές
και 5 ακμές όπως φαίνεται και στο παρακάτω σχήμα:
Ισομορφικοί γράφοι
Δύο γράφοι G’= (V’, E’) και G=(V, E) καλούνται ισομορφικοί (isomorphic) αν υπάρχει
αμφιμονοσύμαντη αντιστοιχία μεταξύ των συνόλων V’ και V τέτοια ώστε να διατηρούνται οι
σχέσεις γειτνίασης.
Στο παρακάτω σχήμα δίνονται δύο ισομορφικοί γράφοι.
Επίπεδος γράφος
Ένας επίπεδος (planar) γράφος μπορεί να απεικονιστεί στο επίπεδο με τέτοιο τρόπο
ώστε οι ακμές του να τέμνονται μόνο στις κορυφές του. Η απεικόνιση αυτή φαίνεται στο δεξιό
γράφο του προηγούμενου σχήματος
Πλήρης γράφος
Ένας γράφος καλείται πλήρης (complete) όταν για κάθε ζεύγος κορυφών του περιέχει
μια ακμή. Ένας πλήρης γράφος με n κορυφές συμβολίζεται με Kn και έχει n(n-1)/2 ακμές. Οι
ισομορφικοί γράφοι του προηγούμενου σχήματος είναι πλήρεις (γράφοι K4)
Διμερής γράφος
Διμερής (bipartite) ονομάζεται ένας γράφος όταν οι κορυφές του μπορούν να χωριστούν
σε δύο ξένα μεταξύ τους σύνολα και όλες οι ακμές του έχουν το ένα άκρο τους στο πρώτο
σύνολο και το άλλο στο δεύτερο σύνολο.
Ένας διμερής γράφος είναι πλήρης όταν υπάρχουν ακμές που ενώνουν κάθε ζεύγος
κορυφών (από τα δύο ξένα σύνολα κορυφών). Ένας πλήρης διμερής γράφος των οποίων τα
δύο σύνολα κορυφών έχουν n και m κορυφές αντίστοιχα συμβολίζεται με Kn,m και έχει n*m
ακμές.
Παράδειγμα διμερή γράφου δίνεται στο επόμενο σχήμα:
Συμπληρωματικοί γράφοι
Δύο γράφοι G’= (V’, E’) και G= (V, E) καλούνται συμπληρωματικοί όταν έχουν το ίδιο
σύνολο κορυφών (V’ = V), E ∩ E’ = Ø και για οποιεσδήποτε δύο κορυφές v και u η ακμή {v,
u} ∈ E U E’.
Στο παρακάτω σχήμα δίνονται δύο συμπληρωματικοί γράφοι:
4.2.5 Διαπερασιμότητα
Έστω έχουμε έναν γράφο G= (V, E) με n κορυφές v1, v2,….,vn. Μονοπάτι (path) του G είναι
μία ακολουθία γειτονικών κορυφών. Το μονοπάτι τότε συνδέει την πρώτη και την τελευταία
κορυφή. Αν αυτές είναι ίδιες, το μονοπάτι καλείται κλειστό, διαφορετικά ανοικτό.
Το μήκος (length) ενός μονοπατιού είναι το σύνολο (πλήθος) των ακμών που περιέχει. Στην
περίπτωση που όλες οι κορυφές ενός μονοπατιού είναι μοναδικές (απαντώνται μόνο μία
φορά), τότε το μονοπάτι καλείται απλό (simple). Στην περίπτωση που όλες οι ακμές ενός
μονοπατιού είναι μοναδικές, τότε το μονοπάτι καλείται ίχνος (trace).
Ένα κλειστό απλό μονοπάτι με περισσότερες από δύο κορυφές καλείται κύκλος (cycle). Ένας
γράφος που δεν περιέχει κύκλους λέγεται άκυκλος (acyclic).
Η απόσταση (distance) μεταξύ δύο κορυφών είναι το μήκος του συντομότερου μονοπατιού
από τη μία στην άλλη.
Το επόμενο σχήμα διασαφηνίζει τις παραπάνω έννοιες:
Για να είναι ένας γράφος γράφος Euler, θα πρέπει κάθε κορυφή του να έχει άρτιο βαθμό
(κάθε φορά που φτάνουμε μέσω κάποιας ακμής στην κορυφή v, θα πρέπει μέσω κάποιας
άλλης ακμής να μπορούμε να φύγουμε από την v). Είναι εύκολο επίσης να δείτε ότι κάθε
πλήρης γράφος είναι γράφος Hamilton (έχει πάντοτε κύκλο Hamilton). Eιδικότερα για τους
γράφους Hamilton ισχύουν τα εξής:
Έστω ένας γράφος G με n κορυφές. Ο G είναι γράφος Hamilton σε οποιαδήποτε από τις
ακόλουθες περιπτώσεις:
1. Αν όλες οι κορυφές του G έχουν βαθμό n-1.
2. Αν όλες οι κορυφές του G έχουν βαθμό ≥ n/2.
3. Αν το άθροισμα των βαθμών κάθε ζεύγους μη γειτονικών κορυφών είναι ≥ n (n ≥ 3).
4.2.6 Συνεκτικότητα
Ένας γράφος λέγεται συνεκτικός (connected) όταν δύο οποιεσδήποτε κορυφές του
συνδέονται με ένα μονοπάτι.
Συνεκτική συνιστώσα (connected component) ενός γράφου G=(V, E) καλείται ένας
υπογράφος G’ που είναι συνεκτικός και για τον οποίο δεν υπάρχει άλλος συνεκτικός
υπογράφος του G που να έχει υπογράφο τον G’.
Στο παρακάτω σχήμα φαίνεται ένας συνεκτικός γράφος:
Παράδειγμα
Παρακάτω απεικονίζεται ένας γράφος στον οποίο κ(G)=2, λ(G)=3 και d(G)=3.
Επίσης, ένας γράφος G καλείται k-συνεκτικός ως προς τις κορυφές όταν κ(G) ≥k και
αντιστοίχως k-συνεκτικός ως προς τις ακμές όταν λ(G) ≥k.
Ο ορισμός του βαθμού μιας κορυφής v επεκτείνεται και στους κατευθυνόμενους γράφους.
Έχουμε στην περίπτωση αυτή τον βαθμό εισόδου (in-degree) din(v) που αποτελεί το πλήθος
των ακμών που καταλήγουν σε μια κορυφή και τον βαθμό εξόδου (out-degree) dout(v) που
αποτελεί το πλήθος των ακμών που ξεκινούν από την κορυφή. Ο βαθμός της κορυφής είναι το
άθροισμα του βαθμού εισόδου και εξόδου της. Είναι εύκολο να δείτε ότι σε κάθε
κατευθυνόμενο γράφο ισχύει:
∑ d (v)= ∑ d
v∈V
in
v∈V
out(v) = |Ε|
Στο γράφο του επόμενου σχήματος υπάρχουν τρεις ισχυρά συνεκτικές συνιστώσες: {Α, Β, Γ,
Δ}, {Χ} και {Υ}.
Α B X
Δ Γ Y
Ο παραπάνω αλγόριθμος παράγει ένα δένδρο αναζήτησης με ρίζα την κορυφή s και κόμβους
όλες τις προσεγγίσιμες από την s κορυφές του G. Αν ο G είναι συνεκτικός, τότε το δένδρο αυτό
ονομάζεται γεννητικό (spanning tree) επειδή καλύπτει ολόκληρο το γράφο (δηλ. μπορεί να
‘γεννήσει’ το γράφο).
Η αναζήτηση των κορυφών ενός γράφου μπορεί να γίνει με δύο τρόπους ανάλογα με την
σειρά που επιλέγουμε να επισκεφθούμε τις κορυφές:
(α) Aναζήτηση πρώτα κατά πλάτος (Breadth-first search, ΒFS)
(β) Aναζήτηση πρώτα κατά βάθος (Depth-first search, DFS)
Aλγόριθμος BFS(G, s)
Στον αλγόριθμο BFS η σειρά επίσκεψης των κορυφών καθορίζεται από τις αποστάσεις τους
από την s: ο αλγόριθμος πρώτα επισκέπτεται όλες τις κορυφές σε απόσταση k από την s
πριν προχωρήσει στις κορυφές σε απόσταση k+1.
Στην υλοποίηση του αλγόριθμου χρησιμοποιείται η δομή της ουράς στην οποία
αποθηκεύονται οι γειτονικές κορυφές της τρέχουσας τις οποίες δεν έχουμε επισκεφθεί ακόμα.
Το δένδρο αναζήτησης του BFS περιέχει αρχικά μόνο τη ρίζα που είναι η κορυφή s. Στη
συνέχεια στο δένδρο προστίθενται μία – μία όλες οι κορυφές v που ο αλγόριθμος ανακαλύπτει
(επισκέπτεται για πρώτη φορά) πχ. από την κορυφή u μαζί με την αντίστοιχη ακμή (v, u). Στο
δένδρο αυτό η v είναι ο πατέρας (parent) της u. Δεδομένου ότι ο αλγόριθμος ανακαλύπτει
κάθε κορυφή του G μόνο μία φορά, ο πατέρας οποιασδήποτε κορυφής πλην της s είναι
μοναδικός. Στο δένδρο μπορούν να οριστούν επίσης και οι σχέσεις προγόνου – απογόνου
μιας κορυφής σε σχέση με τη ρίζα s: αν η x ανήκει στο μονοπάτι από τη ρίζα s προς την
κορυφή y, τότε η x καλείται πρόγονος της y και η y απόγονος της x.
Στο επόμενο σχήμα απεικονίζεται ένας γράφος και το δένδρο αναζήτησης που παράγει ο
αλγόριθμος BFS(G, s):
Επειδή ακριβώς ο BFS(G, s) βρίσκει την απόσταση κάθε κορυφής από την κορυφή s,
μπορεί να χρησιμοποιηθεί για την εύρεση της ελάχιστης απόστασης δ(s, v) από την s στη v
η οποία είναι το ελάχιστο πλήθος ακμών στο μονοπάτι s → v.
Αν για την αναπαράσταση του γράφου χρησιμοποιήσουμε λίστες γειτνίασης, τότε
αποδεικνύεται ότι ο χρόνος εκτέλεσης του BFS είναι Ο(|V|+|E|).
Aλγόριθμος DFS(G, s)
Στον αλγόριθμο αυτό ψάχνουμε όσο γίνεται ‘βαθύτερα’ στον G. Δηλ. η σειρά επίσκεψης των
κορυφών καθορίζεται από το ποια επισκέφτηκε ο αλγόριθμος τελευταία: αν αυτή ήταν η v, τότε
η επόμενη θα είναι κάποια γειτονική της v την οποία ακόμα δεν έχει επισκεφτεί.
Ο DFS αλγόριθμος είναι αναδρομικός και αρκετά πιο κομψός σε σχέση με τον BFS. Μας
δίνει δε πιο αναλυτική πληροφορία για τη δομή του γράφου όπως πχ. η παρουσία κύκλων.
Το αντίστοιχο δένδρο αναζήτησης ονομάζεται DFS δένδρο. Στο επόμενο σχήμα φαίνεται ένα
τέτοιο δένδρο για τον ίδιο γράφο που είχαμε και στο σχήμα 58:
Και εδώ μπορεί να αποδειχθεί εύκολα ότι με τη χρήση λιστών γειτνίασης ο χρόνος
εκτέλεσης του DFS είναι επίσης Ο(|V|+|E|).
Συντομότερα μονοπάτια
Στο επόμενο σχήμα φαίνονται η είσοδος και η έξοδος του αντίστοιχου αλγόριθμου επίλυσης:
Β. Συντομότερα μονοπάτια για όλα τα ζεύγη κορυφών (all pairs shortest paths)
Στην παραλλαγή αυτή το ζητούμενο είναι η εύρεση των συντομότερων μονοπατιών μεταξύ
όλων των ζευγών κορυφών (x, y) ∈ VxV.
Τα μη αρνητικά βάρη ακμών εξασφαλίζουν ότι ο γράφος εισόδου δεν έχει κύκλους με
αρνητικό βάρος (διαφορετικά το συντομότερο μονοπάτι μεταξύ δύο κορυφών του κύκλου θα
περιελάμβανε ένα άπειρο πλήθος επαναλήψεων του αρνητικού αυτού κύκλου). Για την
επίλυση του προβλήματος ο πλέον κλασικός αλγόριθμος είναι αυτός του Dijkstra, γνωστός
από το 1959!
Η φυσική σημασία των βαρών των ακμών εξαρτάται από τη πρόβλημα που καλούμαστε
να επιλύσουμε στην πράξη: κάθε βάρος μπορεί να αντιστοιχεί στην απόσταση μεταξύ δύο
κορυφών ή στο χρόνο μετάβασης από τη μία κορυφή στην άλλη για παράδειγμα. Γι΄ αυτόν
ακριβώς το λόγο το συγκεκριμένο πρόβλημα έχει ένα εντυπωσιακά μεγάλο πλήθος
εφαρμογών. Οι πιο προφανείς αφορούν σε μεταφορικά ή επικοινωνιακά δίκτυα, όπως πχ.
υπολογισμός των βέλτιστων διαδρομών για την διανομή ενός προϊόντος από μια αποθήκη σε
ένα δίκτυο πόλεων ή τη δρομολόγηση πακέτων σε έναν προορισμό σε ένα δίκτυο δεδομένων.
Μια άλλη ειδική εφαρμογή είναι η εξής: Υποθέστε ότι θέλουμε να ζωγραφίσουμε ένα
γράφο. Το κέντρο της σελίδας θα πρέπει να αντιστοιχεί στο ‘κέντρο’ του γράφου με ό,τι
σημαίνει αυτό. Ένας καλός ορισμός του κέντρου είναι η κορυφή του γράφου που
ελαχιστοποιεί τη μέγιστη απόσταση προς οποιαδήποτε άλλη κορυφή του γράφου. Η εύρεση
αυτού του κέντρου απαιτεί γνώση της απόστασης (συντομότερου μονοπατιού) μεταξύ όλων
των ζευγών κορυφών του γράφου.
Το εν λόγω πρόβλημα έχει μεγάλη ιστορία: ο πρώτος αλγόριθμος για την επίλυσή του
δόθηκε το 1926!
Το ελάχιστο γεννητικό δένδρο έχει εφαρμογές σε όλα σχεδόν τα προβλήματα σχεδιασμού
δικτύων. Υποθέστε για παράδειγμα ότι μια τηλεφωνική εταιρία θέλει να συνδέσει όλα τα
τηλεφωνικά της κέντρα σε μια πόλη. Τότε το ελάχιστο γεννητικό δένδρο καθορίζει το πιο
οικονομικό σχήμα διασύνδεσης, δηλ. αυτό με τα λιγότερα μέτρα καλωδίου.
Γενικά, τα ελάχιστα γεννητικά δένδρα είναι σημαντικά για πολλούς λόγους:
- Μπορούν να υπολογιστούν εύκολα και γρήγορα και παράγουν ένα αραιό υπογράφο
που δίνει αρκετή γνώση για την τοπολογία του αρχικού γράφου.
- Παρέχουν έναν τρόπο για τον εντοπισμό clusters σε ένα σύνολο σημείων. Η διαγραφή
των ακμών μεγάλου βάρους από το ελάχιστο γεννητικό δένδρο δίνει τις συνεκτικές
συνιστώσες οι οποίες ορίζουν φυσικά clusters μεταξύ των σημείων.
- Μπορούν να χρησιμοποιηθούν ώστε να παράγουν προσεγγιστικές λύσεις σε
υπολογιστικά δύσκολα προβλήματα όπως πχ. το πρόβλημα του πλανόδιου πωλητή το
οποίο παρουσιάζεται στα επόμενα.
Στο επόμενο σχήμα παρουσιάζεται το πρόβλημα υπολογισμού της συνεκτικότητας των ακμών
ενός γράφου.
Όπως είδαμε στην παρ. 4.2.6 συνεκτικότητα ακμών και κορυφών είναι δύο ποσότητες που
συνδέονται και ο ελάχιστος βαθμός των κορυφών του γράφου αποτελεί ένα άνω φράγμα και
για τις δύο.
Μια ενδιαφέρουσα παραλλαγή του γενικού προβλήματος είναι η εύρεση του μικρότερου
υποσυνόλου ακμών (κορυφών) η απομάκρυνση των οποίων για δεδομένα s, t ∈ V θα
διαχωρίσει την κορυφή s από την κορυφή t, πρόβλημα που συνδέεται άμεσα με την
αξιοπιστία δικτύων.
Κύκλος Euler
Eίσοδος: Ένας μη κατευθυνόμενος γράφος G=(V, E).
Έξοδος: Ένας κύκλος Euler.
Το πρόβλημα αυτό διατυπώθηκε από τον μεγάλο μαθηματικό Leonard Euler ο οποίος το
1736 έλυσε το πρόβλημα με τις επτά γέφυρες της πόλης Κönigsberg της Ρωσίας (σημερινό
Καλίνιγκραντ). Ο Euler παρατήρησε ότι μια αναγκαία συνθήκη για να έχει ένας γράφος G
κύκλο Euler είναι κάθε κορυφή του G να έχει άρτιο βαθμό.
Η σημασία του προβλήματος είναι προφανής σε προβλήματα διαδρομών όταν κανείς
καλείται να σχεδιάσει πάνω στο χάρτη μιας πόλης βέλτιστες διαδρομές απορριμματοφόρων,
εκχιονιστικών μηχανημάτων, ταχυδρομικών μέσων κλπ. Σε όλες τις εφαρμογές πρέπει να
επισκεφτούμε κάθε δρόμο της πόλης τουλάχιστον μία φορά. Για λόγους αποδοτικότητας
θέλουμε να ελαχιστοποιείται ο συνολικός χρόνος οδήγησης ή ισοδύναμα η συνολική
απόσταση που διανύουμε, το οποίο επιτυγχάνεται αν περάσουμε από κάθε δρόμο μία φορά,
δηλ. κινηθούμε κατά μήκος ενός κύκλου Euler.
Παρατηρήστε ότι τα μέγιστα ταιριάσματα μπορεί να είναι περισσότερα από ένα. Όταν
έχουμε βεβαρημένο γράφο θέλουμε το άθροισμα των βαρών των ακμών του ταιριάσματος να
είναι μέγιστο.
Χαρακτηριστική εφαρμογή του προβλήματος έχουμε στο εξής παράδειγμα: Υποθέστε ότι
έχουμε ένα σύνολο υπαλλήλων καθένας εκ των οποίων μπορεί να διεκπεραιώσει κάποιες από
τις εργασίες ενός συνόλου εργασιών που πρέπει να εκτελεστούν. Αναζητούμε μια ανάθεση
εργασιών τέτοια ώστε κάθε εργασία να ανατεθεί σε ένα μοναδικό υπάλληλο. Κάθε
αντιστοίχηση μεταξύ ενός υπαλλήλου και μιας εργασίας που μπορεί να κάνει ορίζει μια ακμή
και αυτό που μας ενδιαφέρει είναι το σύνολο των ακμών οι οποίες δεν μοιράζονται τον ίδιο
υπάλληλο ή την ίδια εργασία με κάποιες άλλες, δηλ. ένα ταίριασμα.
Η εύρεση του πιο αποδοτικού σε κόστος τρόπου για τη δρομολόγηση προϊόντων από ένα
σύνολο εργοστασίων σε ένα σύνολο αποθηκών ορίζει ένα πρόβλημα ροής. Το πρόβλημα
απαντάται ακόμη σε εφαρμογές ανάθεσης πόρων σε δίκτυα επικοινωνίας, δρομολόγησης
εργασιών κλπ.
Η πραγματική αξία του προβλήματος συνίσταται στο γεγονός ότι πολλές εφαρμογές
δυναμικού προγραμματισμού είναι δυνατόν να μοντελοποιηθούν ως προβλήματα ροής
δικτύου και μπορούν να χρησιμοποιηθούν ειδικοί γραφοαλγόριθμοι για την αποδοτική τους
επίλυση. Σε προβλήματα ροής δικτύου μπορούν να αναχθούν για παράδειγμα βασικά
γραφοθεωρητικά προβλήματα όπως διμερή ταιριάσματα, συντομότερα μονοπάτια και
συνεκτικότητα ακμών / κορυφών.
Η ένθεση ενός γράφου βοηθάει να κατανοήσουμε καλύτερη τη δομή του. Οι γράφοι που
χρησιμοποιούνται για να αναπαραστήσουν οδικά δίκτυα ή ολοκληρωμένα κυκλώματα είναι
επίπεδοι επειδή ορίζονται από επίπεδες δομές.
Η πιο σημαντική ιδιότητα ενός επίπεδου γράφου είναι ότι είναι αραιός. Συγκεκριμένα
ισχύει |Ε| ≤ 3|V|-6 (φόρμουλα του Euler), δηλ. κάθε επίπεδος γράφος έχει γραμμικό πλήθος
ακμών και επιπλέον πρέπει να περιέχει μία κορυφή βαθμού το πολύ 5. Τόσο το πρόβλημα του
ελέγχου αν ένας γράφος είναι επίπεδος όσο και αυτό της ένθεσης (εύρεσης μιας επίπεδης
απεικόνισης) μπορούν να επιλυθούν σε γραμμικό χρόνο.
Κύκλος Hamilton
Πρόκειται για ένα από τα γνωστότερα δύσκολα προβλήματα. Δείτε για παράδειγμα ότι για
τον πλήρη γράφο Kn με n>2 έχουμε (n-1)!/2 δυνατές λύσεις.
Το πρόβλημα του πλανόδιου πωλητή είναι ίσως το πιο διάσημο δύσκολο πρόβλημα το
οποίο όμως μπορεί να προσεγγιστεί με υποβέλτιστους ευριστικούς αλγόριθμους. Το
προηγούμενο πρόβλημα της εύρεσης ενός κύκλoυ Hamilton είναι ειδική περίπτωση αυτού του
προβλήματος όπου κάθε ακμή του γράφου έχει κόστος 1 ενώ για κάθε ζεύγος μη γειτονικών
κορυφών θέτουμε το κόστος ίσο με ∞.
Το ελάχιστο πλήθος χρωμάτων για το χρωματισμό των ακμών του G καλείται χρωματικός
δείκτης (chromatic index) και συμβολίζεται με χ(G). Παρατηρήστε ότι σε ένα κύκλο G άρτιου
μήκους έχουμε χ(G)=2, ενώ σε ένα κύκλο περιττού μήκους χ(G)=3. Σε ένα ταίριασμα G το
χ(G)=1.
Το πρόβλημα του χρωματισμού ακμών απαντάται σε εφαρμογές δρομολόγησης όπου
κανείς θέλει να ελαχιστοποιήσει το πλήθος των συγκρούσεων κατά την εκτέλεση ενός
συνόλου εργασιών. Για παράδειγμα, υποθέστε ότι θέλουμε να παράγουμε ένα πρόγραμμα
συναντήσεων μιας ομάδας ατόμων, όπου τα άτομα συναντώνται ανά δύο και κάθε συνάντηση
κρατά μία ώρα. Όλες οι συναντήσεις μπορούν να δρομολογηθούν σε διαφορετικές ώρες για ν’
αποφύγουμε τις συγκρούσεις αλλά θα κερδίσουμε χρόνο αν κάποιες ανεξάρτητες συναντήσεις
δρομολογηθούν ταυτόχρονα. Κατασκευάζουμε λοιπόν έναν γράφο G του οποίου κάθε κορυφή
αντιστοιχεί σε ένα άτομο και κάθε ακμή συνδέει δύο άτομα που θέλουν να συναντηθούν. Τότε
ο χρωματισμός των ακμών του G μας δίνει τη ζητούμενη δρομολόγηση. Τα διαφορετικά
χρώματα παριστάνουν διαφορετικές χρονικές περιόδους στη δρομολόγηση και όλες οι
συναντήσεις του ίδιου χρώματος λαμβάνουν χώρα ταυτόχρονα.
Η κάλυψη κορυφών είναι ειδική περίπτωση του γενικού προβλήματος κάλυψης συνόλων
(set cover problem) το οποίο παίρνει ως είσοδο μία αυθαίρετη συλλογή C από σύνολα C1, C2,
…, Cn που περιέχουν στοιχεία από ένα σύμπαν U = {1, 2, …, m} και παράγει στην έξοδο το
μικρότερο υποσύνολο από σύνολα της C των οποίων η ένωση δίνει το U. Η αναγωγή του
προβλήματος κάλυψης κορυφών στο πρόβλημα της κάλυψης συνόλων γίνεται ως εξής: Το U
θα περιέχει όλες τις ακμές του G και κάθε Ci θα περιέχει τις ακμές που έχουν άκρο την κορυφή
i. Tότε το υποσύνολο κορυφών S ορίζει μία κάλυψη κορυφών στο γράφο G αν και μόνον αν τα
αντίστοιχα σύνολα Ci που έχουν οριστεί για τις κορυφές του S ορίζουν μία κάλυψη συνόλων
για το U. Παρόλα αυτά, επειδή κάθε ακμή του G μπορεί να ανήκει μόνο σε δύο διαφορετικά
σύνολα Ci, κάθε στιγμιότυπο του προβλήματος της κάλυψης κορυφών είναι πιο απλό από το
γενικό πρόβλημα της κάλυψης συνόλων.
Για διμερείς γράφους το πρόβλημα της κάλυψης κορυφών είναι ισοδύναμο με το
πρόβλημα του μέγιστου ταιριάσματος και μπορεί να επιλυθεί σε πολυωνυμικό χρόνο.
Κλίκα (Clique)
Eίσοδος: Ένας μη κατευθυνόμενος γράφος G= (V, Ε).
Έξοδος: Το μεγαλύτερο υποσύνολο S του V έτσι για κάθε ζεύγος κορυφών x, y ∈ S η ακμή
(x, y) ∈ E.
Παρατηρήστε ότι μια κλίκα με m κορυφές είναι ο πλήρης γράφος Κm. Ο εντοπισμός
clusters από ομοειδή αντικείμενα ανάγεται συχνά στο πρόβλημα της εύρεσης μεγάλων κλικών
σε γράφους. Σκεφτείτε ένα γράφο του οποίου οι κορυφές αντιστοιχούν σε άτομα, πχ.
συμμαθητές μιας τάξης, και οι ακμές του συνδέουν ζευγάρια από άτομα που είναι φίλοι. Μια
κλίκα μεταξύ των μαθητών της τάξης με την πραγματική της σημασία αντιστοιχεί τώρα σε μια
κλίκα με γραφοθεωρητικούς όρους στο γράφο που αναπαριστά τις σχέσεις φιλίας μεταξύ των
μαθητών.
Ο ισομορφισμός γράφων ελέγχει αν δύο γράφοι είναι πραγματικά ίδιοι. Υποθέστε ότι μας
δίνεται μία συλλογή από γράφους και πρέπει να εκτελέσουμε μια πράξη σε κάθε έναν από
αυτούς. Αν βρούμε τους γράφους που είναι ισομορφικοί, τότε μπορούμε να αγνοήσουμε τα
αντίγραφα για ν΄ αποφύγουμε περιττή εργασία.
Παράδειγμα
Στη συνέχεια παρουσιάζουμε έναν αλγόριθμο για την εύρεση των συνεκτικών
συνιστωσών του μη κατευθυνόμενου γράφου G = (V, E) o οποίος κάνει χρήση των τριών
πράξεων Makeset(), Union() και Find() oι οποίες υλοποιούνται πάνω σε κάποια δομή UNION-
FIND. Υπενθυμίζουμε ότι σε μια συνεκτική συνιστώσα κάθε ζεύγος κορυφών συνδέεται μέσω
ενός μονοπατιού.
Έστω ότι το σύνολο των κορυφών του G είναι το V = {1, 2, 3, …, n} για κάποιο ορισμένο n.
Για την αποθήκευση του G στον υπολογιστή μπορούμε να χρησιμοποιήσουμε είτε έναν
πίνακα ή λίστες γειτνίασης αλλά στον αλγόριθμο που ακολουθεί δεν γίνεται οποιαδήποτε
υπόθεση για τη δομή αποθήκευσης.
Ο αλγόριθμος αρχικά κατασκευάζει για κάθε κορυφή v του G το μονοσύνολο {v}. Στη
συνέχεια, για κάθε ακμή (x, y) του G για την οποία οι κορυφές x, y ανήκουν σε διαφορετικά
σύνολα της UNION-FIND δομής, ενώνει τα δύο αυτά σύνολα αφού οι x, y ανήκουν στην ίδια
συνεκτική συνιστώσα του G. Μετά την επεξεργασία όλων των ακμών του γράφου, δύο
οποιεσδήποτε κορυφές θα ανήκουν στην ίδια συνεκτική συνιστώσα αν και μόνο αν ανήκουν
στο ίδιο σύνολο της UNION-FIND δομής. Ο επόμενος αλγόριθμος ελέγχει αν οι κορυφές v, u
ανήκουν στην ίδια συνεκτική συνιστώσα του G:
Υποθέτοντας ότι το όνομα κάθε συνόλου αντιστοιχεί στο όνομα του πρώτου στοιχείου της
λίστας (αντιπρόσωπος του συνόλου), τότε κάθε στοιχείο της λίστας περιέχει δύο δείκτες: ο
ένας δείχνει στο επόμενό του και ο άλλος στον αντιπρόσωπο.
Στην παραπάνω δομή η πράξη Find(x) εκτελείται σε Ο(1) χρόνο ακολουθώντας το δείκτη
από τον κόμβο που περιέχει το x στον αντιπρόσωπο της λίστας. Για μια πράξη Union(x, y)
όμως θα πρέπει να προσθέσουμε τη λίστα που περιέχει το y στο τέλος αυτής με το x και να
ενημερώσουμε τους δείκτες της δεύτερης λίστας να δείχνουν το νέο αντιπρόσωπο που παίρνει
χρόνο O(μήκος της λίστας).
Έστω ότι έχουμε n στοιχεία x1, x2, …, xn στη δομή και εκτελούμε m συνολικά πράξεις.
Έστω επίσης ότι n = m/2+1 και q = m – n = m/2-1. Eκτελούμε τώρα n Μakeset(xi) πράξεις για
κάθε ένα από τα n στοιχεία και στη συνέχεια q Union(xi, xi+1) πράξεις για i = 1, 2, …, q-1. Ο
συνολικός χρόνος για τις n Μakeset() πράξεις είναι O(n). Η i-οστή Union() ενημερώνει τους
δείκτες i στοιχείων και επομένως για τις q Union() ξοδεύουμε συνολικό χρόνο O(1 + 2 + … + q-
1) = O(q2). Eπομένως, ο χρόνος για τις m πράξεις είναι O(n+q2) = O(m2) αφού n = O(m) και q
= O(m). Άρα, κάθε μία από τις m πράξεις έχει κατανεμημένο χρόνο Ο(m). Παρατηρείστε ότι
κάθε πράξη Union() μειώνει το πλήθος των συνόλων κατά 1 και άρα μπορούμε να έχουμε το
πολύ n-1 Union() από τις οποίες προκύπτει ένα μόνο σύνολο n στοιχείων.
Οι παραπάνω χρόνοι μπορούν να βελτιωθούν αν κατά την πράξη Union() προσθέτουμε
κάθε φορά τη μικρότερη λίστα στο τέλος της μεγαλύτερης (αυτό απαιτεί να αποθηκεύουμε
στον κόμβο του αντιπροσώπου και το μήκος της λίστας του συνόλου). Ο ευριστικός αυτός
κανόνας λέγεται weighted union rule και για μια ακολουθία από m Makeset(), Union() και
Find() πράξεις από τις οποίες οι n είναι Μakeset() απαιτεί χρόνο Ο(m + nlogn) αφού κάθε
Makeset() ή Find() κοστίζει Ο(1) χρόνο και η ενημέρωση των δεικτών για ένα στοιχείο σε όλες
τις ≤ n-1 Union() γίνεται logn το πολύ φορές (σε κάθε Union() το στοιχείο αυτό τοποθετείται σε
μια λίστα με τουλάχιστον διπλάσιο μέγεθος).
Προφανώς και η δομή αυτή από μόνη της δεν είναι αποδοτική αφού τα μονοπάτια των
δένδρων μπορούν να γίνουν γραμμικά σε μήκος. Έτσι, μια Find(x) πράξη που εκτελείται
ανεβαίνοντας το μονοπάτι από τον κόμβο του x προς τη ρίζα του δένδρου κοστίζει χρόνο Ο(n).
H απόδοση της δομής μπορεί όμως να βελτιωθεί σημαντικά αν χρησιμοποιήσουμε δύο
ευριστικούς κανόνες, τον union by rank rule και το path compression.
O union by rank κανόνας είναι παρόμοιος με τον weighted union που είδαμε νωρίτερα
αλλά αντί για τα μεγέθη των δένδρων χρησιμοποιούμε ως κριτήριο τα ύψη τους. Η ιδέα είναι
στις πράξεις Union() να κάνουμε τη ρίζα του δένδρου με το μικρότερο ύψος να δείχνει στη
ρίζα αυτού με το μεγαλύτερο. Για το λόγο αυτό σε κάθε κόμβο με την τιμή x αποθηκεύουμε την
ακέραια ποσότητα rank που είναι μια προσεγγιστική εκτίμηση του ύψους του δένδρου με ρίζα
τον κόμβο αυτό και αποτελεί ένα άνω φράγμα στο ύψος του δένδρου. Αρχικά, όταν με τη
Makeset() δημιουργείται ένα μονοσύνολο, η τιμή του rank τίθεται 0. Κάθε Find() δεν αλλάζει
τις ποσότητες rank. Όταν τώρα εκτελείται μία πράξη Union() σε δύο δένδρα που οι ρίζες τους
έχουν το ίδιο rank, τότε επιλέγουμε αυθαίρετα μία ρίζα, έστω v, και την κάνουμε παιδί της
άλλης, έστω w, και αυξάνουμε κατά 1 το rank της w (μόνο τότε αυξάνει το rank του δένδρου με
ρίζα την τιμή w).
Στη συνέχεια δίνουμε τους αλγόριθμους για κάθε πράξη της UNION-FIND δομής στην
οποία εφαρμόζουμε τον κανόνα union by rank. Λόγω της αρχικής υπόθεσης ότι τα στοιχεία
μας είναι οι φυσικοί αριθμοί 1, 2, …, n, θα χρειαστούμε δύο πίνακες parent[1..n] και rank[1..n]
όπου θα αποθηκεύουμε τον πατέρα κάθε κόμβου και την ποσότητα rank αντίστοιχα. (Στην
περίπτωση που τα στοιχεία μας δεν ήταν φυσικοί αλλά μπορούσαν να έχουν οποιοδήποτε
τύπο όπως χαρακτήρες ή συμβολοσειρές, τότε η υλοποίηση απαιτεί τη χρήση εγγραφών και
δεικτών όπως είδαμε στα δένδρα αναζήτησης αλλά η λογική των αλγόριθμων δεν αλλάζει).
Αλγόριθμος Makeset(x)
Δεδομένα // parent [1..n], rank[1..n], x: ακέραιοι //
Αρχή
parent[x] = x
rank[x] = 0
Aποτελέσματα // Μονοσύνολο {x} //
Tέλος Makeset
Αλγόριθμος Union(x, y)
Δεδομένα // parent [1..n], rank[1..n], x, y, v, w: ακέραιοι //
Αρχή
v = Find(x)
w = Find(y)
Αν (rank[v] > rank[w]) Τότε parent[w] = v
Aλλιώς
parent[v] = w
Αν (rank[v] = rank[w]) Τότε rank[w] = rank[w]+1 Τέλος Αν
Τέλος Αν
Aποτελέσματα // Νέο σύνολο που προκύπτει από την ένωση των ξένων
υποσυνόλων που περιέχουν τα x και y αντίστοιχα //
Tέλος Union
Ο αλγόριθμος της Union(x, y) καλεί δύο φορές την πράξη Find() που επιστρέφει τη ρίζα
του δένδρου στο οποίο ανήκει το x και y αντίστοιχα.
Αλγόριθμος Find(x)
Δεδομένα // parent [1..n], x, v: ακέραιοι //
Αρχή
v=x
Όσο (v ≠ parent[v]) /* Δεν έχουμε φτάσει ακόμα στη ρίζα του δένδρου που περιέχει
το x */
Τότε v = parent[v]
Τέλος Όσο
Επέστρεψε parent[v]
Aποτελέσματα // Όνομα του συνόλου που περιέχει το x //
Tέλος Find
H πράξη Find() θα μπορούσε να υλοποιηθεί και αναδρομικά, παρόλα αυτά επιλέξαμε την
επαναληπτική υλοποίηση που είναι και πιο εύληπτη.
Ο ευριστικός κανόνας του path compression είναι επίσης απλός και αρκετά αποδοτικός:
κάθε φορά που εκτελούμε μια πράξη Find(x) ενημερώνουμε τους δείκτες των κόμβων που
επισκεπτόμαστε να δείχνουν στη ρίζα του δένδρου όπου ανήκει το x. Για να γίνει αυτό
κάνουμε δύο περάσματα. Στο πρώτο ανεβαίνουμε το μονοπάτι από τον κόμβο με το στοιχείο x
προς τη ρίζα και στο δεύτερο κατεβαίνουμε το ίδιο μονοπάτι αλλάζοντας κατάλληλα τους
δείκτες των κόμβων. Η εφαρμογή του path compression απεικονίζεται παρακάτω:
Σχήμα 71: Εφαρμογή του κανόνα path compression στην εκτέλεση μιας Find()
Χρησιμοποιώντας μόνο τον κανόνα path compression, n Makeset(), n-1 το πολύ Union()
και f Find() πράξεις έχει αποδειχθεί ότι παίρνουν συνολικό χρόνο Ο(n + flogn) αν f < n και
Ο(flog(1+f/n)n) αν f ≥ n.
Αντί του πλήρους path compression μπορούν να χρησιμοποιηθούν και άλλα υβριδικά
σχήματα που μειώνουν το πλήθος των ενημερώσεων των δεικτών χωρίς να αλλάζουν τις
παραπάνω πολυπλοκότητες. Ένα τέτοιο σχήμα είναι το path splitting όπου κατά τη διάρκεια
της Find() βάζουμε κάθε κόμβο του μονοπατιού να δείχνει στον παππού του οπότε
προκύπτουν δύο μονοπάτια με μήκος περίπου το μισό του αρχικού.
Αν στη δομή UNION-FIND εφαρμόσουμε και τους δύο ευριστικούς κανόνες union by rank
και path compression, oι αλγόριθμοι των πράξεων Makeset() και Union() είναι ίδιοι με αυτούς
που είδαμε παραπάνω, αλλάζει όμως ο αλγόριθμος της Find():
Αλγόριθμος Find(x)
Δεδομένα // parent [1..n], x: ακέραιοι //
Αρχή
Αν (x ≠ parent[x]) /* Δεν έχουμε φτάσει ακόμα στη ρίζα του δένδρου που περιέχει το
x */
Τότε parent[x] = Find(parent[x])
Τέλος Αν
Επέστρεψε parent[x]
Aποτελέσματα // Όνομα του συνόλου που περιέχει το x //
Tέλος Find
Η αναδρομή στον νέο αλγόριθμο της Find() βοηθά στην υλοποίηση των δύο περασμάτων
που απαιτούνται λόγω του κανόνα path compression. Στο πρώτο πέρασμα ανεβαίνουμε το
μονοπάτι από τον κόμβο με το x στη ρίζα του δένδρου και επιστρέφουμε την τιμή της ρίζας ως
αποτέλεσμα της Find(x) ενώ με κάθε επιστροφή από μια αναδρομική κλήση ενημερώνεται ο
αντίστοιχος κόμβος του μονοπατιού ώστε να δείχνει στη ρίζα. Επειδή η επιστροφή από κάθε
αναδρομική κλήση γίνεται με σειρά LIFO, οι κόμβοι ενημερώνονται με σειρά από πάνω προς
τα κάτω κατά μήκος του μονοπατιού. Σε μια επαναληπτική υλοποίηση θα έπρεπε να ανεβούμε
το μονοπάτι από τον κόμβο με το x δύο φορές: μία για να βρούμε τη ρίζα του δένδρου που
περιέχει το x και μία δεύτερη για να ενημερώσουμε την τιμή parent κάθε κόμβου του
μονοπατιού.
Η από κοινού εφαρμογή του union by rank και του path compression κανόνα, δίνει
συνολικό χρόνο σχεδόν γραμμικό στο πλήθος των πράξεων. Συγκεκριμένα, για m συνολικά
πράξεις ο χρόνος χειρότερης περίπτωσης είναι Ο(mα(m, n)), όπου α(m, n) είναι η αντίστροφη
συνάρτηση του Αckermann η οποία αυξάνει με πολύ αργό ρυθμό: ισχύει α(m, n) ≤ 3 για n <
216 = 65.536, δηλ. σε όλες τις πρακτικές εφαρμογές η α(m, n) είναι μια σταθερά όχι μεγαλύτερη
του 3. Έχει αποδειχθεί ότι ο χρόνος αυτός είναι ασυμπτωτικά βέλτιστος.
Ερωτήσεις Κεφαλαίου
1. Δώστε τους ορισμούς των παρακάτω εννοιών:
• Ρίζα και φύλλα ενός δένδρου.
• Πρόγονοι ενός κόμβου.
• Υπόδενδρο ενός κόμβου.
• Απόγονοι ενός κόμβου.
• Πατέρας ενός κόμβου.
• Παιδιά ενός κόμβου.
• Βαθμός ενός κόμβου.
ΒΙΒΛΙΟΓΡΑΦΙΑ
[1] Χ. Κοίλιας, “Δομές Δεδομένων και Οργανώσεις Αρχείων”, Εκδόσεις Νέων Τεχνολογιών,
1998.
[2] Λ. Κυρούσης, Χ. Μπούρας, Π. Σπυράκης, Γ. Σταματίου, “Εισαγωγή στους Γράφους:
Θεωρία, Προβλήματα και Λύσεις”, Eκδόσεις Gutenberg, 1999.
[3] Θ. Παπαθεοδώρου, “Αλγόριθμοι: Εισαγωγικά Θέματα και Παραδείγματα”, Εκδόσεις
Πανεπιστημίου Πατρών, 1999.
[4] Π.Δ. Μποζάνης, “Αλγόριθμοι, Σχεδιασμός και Ανάλυση”, Εκδόσεις Τζιόλα, 2003.
[5] Π.Δ. Μποζάνης, “Δομές Δεδομένων - Ταξινόμηση και Αναζήτηση με Java”, Εκδόσεις
Τζιόλα, 2003.
[6] Α. Τσακαλίδης, “Δομές Δεδομένων”, Εκδόσεις Πανεπιστημίου Πατρών, 2004.
[7] Δ. Φωτάκης, “Tαξινόμηση – Αναζήτηση – Επιλογή”, Σημειώσεις μαθήματος,
Πανεπιστήμιο Αιγαίου, Τμήμα Μηχανικών Πληροφοριακών και Επικοινωνιακών
Συστημάτων, Νοέμβριος 2006.
[8] T. Cormen, C. Leiserson, R. Rivest, C. Stein, “Introduction to Algorithms (2nd Edition)”,
MIT Press, 2001.
[9] S. Sahni, “Δομές Δεδομένων, Αλγόριθμοι και Εφαρμογές στην C++”, Εκδόσεις Τζιόλα,
2004.
[10] R. Sedgewick, “Αλγόριθμοι σε C” (3η αμερικάνικη έκδοση), Εκδόσεις Κλειδάριθμος,
2005.
[11] N. Wirth, “Αλγόριθμοι και Δομές Δεδομένων”, Εκδόσεις Κλειδάριθμος, 1990.
[7] http://ocw.mit.edu/OcwWeb/Electrical-Engineering-and-Computer-Science/6-046JFall-
2005/Video Lectures/, Οpen courses σε αλγόριθμους από το Massachusetts Institute of
Technology.
[8] http://www.csse.monash.edu.au/~dwa/MELB/, Animation αλγόριθμων από το Melburne
University.
[9] http://www.cs.pitt.edu/~kirk/algorithmcourses/, Κατάλογος με ιστοσελίδες που
ασχολούνται με αλγόριθμους.