ΠΑΝΕΠΙΣΤΗΜΙΟ ΚΥΠΡΟΥ ΤΜΗΜΑ ΠΛΗΡΟΦΟΡΙΚΗΣ ΕΠΛ 231: Δομές Δεδομένων και Αλγόριθμοι Εαρινό Εξάμηνο 2013 ΑΣΚΗΣΗ 3 Δέντρα Διδάσκων Καθηγητής: Παναγιώτης Ανδρέου Ημερομηνία Υποβολής: 19/03/2013 Ημερομηνία Παράδοσης: 02/04/2013 ΠΕΡΙΓΡΑΦΗ Σε αυτή την άσκηση καλείστε να αναλύσετε και να δημιουργήσετε δομές δεδομένων και αλγόριθμους για κάποια προβλήματα. Επιπλέον καλείστε να υπολογίσετε το χρόνο εκτέλεσης των αλγορίθμων που θα δημιουργήσετε. Άσκηση 1 (15 μονάδες) Δείξτε το αποτέλεσμα εισαγωγής των αριθμών: 5, 8, 15, 3, 2, 4, 9, 7, 6 σε ένα άδειο AVL δέντρο αναφέροντας και το είδος της περιστροφής που απαιτείται σε κάθε βήμα (για τις περιπτώσεις που απαιτείται κάποια περιστροφή).
Άσκηση 2 (15 μονάδες) Δύο δυαδικά δέντρα είναι όμοια μεταξύ τους αν είτε είναι και τα δύο κενά ή και τα δύο δεν είναι κενά και έχουν όμοια αριστερά και δεξιά υπόδενδρα. Να δώσετε ένα παράδειγμα ζεύγους όμοιων δένδρων. Να ορίσετε μια μηαναδρομική διαδικασία η οποία αποφασίζει κατά πόσο δύο δένδρα είναι όμοια μεταξύ τους. Τέλος, να αναλύσετε τη χρονική πολυπλοκότητα της διαδικασίας. Η μη αναδρομική εκδοχή της διαδικασίας απαιτεί τη χρήση βοηθητικής δομής στην οποία θα αποθηκεύουμε ζεύγη κατευθύνσεων προς τα οποία θα πρέπει να κινηθούμε σε μεταγενέστερο στάδιο. Ο τύπος της βοηθητικής δομής δεν έχει σημασία αφού η επεξεργασία των ζευγών μπορεί να γίνει σε τυχαία σειρά. Στη λύση αυτή χρησιμοποιείται στοίβα. Η βασική ιδέα του αλγόριθμου είναι η εξής: 1. Δημιουργούμε μια κενή στοίβα. 2. Εφόσον τα δύο δένδρα που μελετούμε δεν είναι κενά και η στοίβα δεν είναι άδεια (δηλ. υπάρχουν ακόμη ζεύγη προς επεξεργασία) προχωρούμε στο βήμα 3. Διαφορετικά πάμε στο βήμα 5. 3. Αν το ένα από τα δύο δένδρα είναι κενά, επιστρέφουμε 0 δηλώνοντας έτσι ότι τα δένδρα είναι ανόμοια και τερματίζουμε. Διαφορετικά τοποθετούμε δείκτες προς τα δεξιά παιδιά των δύο κόμβων και προχωρούμε στα αριστερά παιδιά, επιστρέφοντας στο βήμα 2. 4. Αν και τα δύο δένδρα είναι κενά (null δείκτες) συμπεραίνουμε ότι μέχρι στιγμής δεν έχουμε αντιμετωπίσει ανόμοια δένδρα και ανασύρουμε από τη στοίβα κάποιο από τα ζεύγη που αναμένει επεξεργασία. Επαναλαμβάνουμε από το βήμα 2. 5. Επιστρέφουμε την τιμή 1 που υπονοεί ότι τα αρχικά δένδρα είναι όμοια και τερματίζουμε. int SimilarNR(node p, node q){ Stack S = new Stack(); while (p!= null OR q!= null OR
!S.IsEmpty()){ if (p!= null AND q == null) (p == null AND q!= null) return 0; if (p!= null AND q!= null) S.Push((p.right, q.right)); p = p.left; q = q.left; (p,q) = S.Pop(); return 1; Η διαδικασία επισκέπτεται και πάλι κάθε κόμβο ακριβώς μια φορά, επομένως ο χρόνος εκτέλεσής της είναι Θ(n) όπου n είναι ο αριθμός των κόμβων του δένδρου. Άσκηση 3 (40 μονάδες) Ένα δυαδικό δένδρο αναζήτησης με n κόμβους έχει n+1 null δείκτες. Αυτό σημαίνει ότι η μισή μνήμη που χρησιμοποιείται για την αποθήκευση του δένδρου σπαταλείται άσκοπα. Η άσκηση αυτή σας προτείνει να χρησιμοποιήσετε τη μνήμη των null δεικτών ως εξής: Αν κάποιος κόμβος του δένδρου δεν έχει αριστερό παιδί, να φυλάγεται στο πεδίο left του κόμβου δείκτης προς τον κόμβο του δένδρου με το αμέσως μικρότερο κλειδί. Αντίστοιχα, αν κάποιος κόμβος του δένδρου δεν έχει δεξιό παιδί, να φυλάγεται στο πεδίο right του κόμβου δείκτης προς τον κόμβο του δένδρου με το αμέσως μεγαλύτερο κλειδί. Ένα τέτοιο δένδρο ονομάζεται νηματώδες δένδρο και οι επιπλέον δείκτες ονομάζονται νήματα. Α. Να επιδείξετε τη νηματώδη μορφή του πιο κάτω ΔΔΑ.
5 2 7 6 9 23 Β. Πως μπορούμε να διαχωρίζουμε ανάμεσα στους δείκτες νήματα και στους πραγματικούς δείκτες προς τα παιδιά ενός κόμβου σε ένα νηματώδες δένδρο; Γ. Να γράψετε διαδικασίες εισαγωγής και διαγραφής κόμβων σε ένα νηματώδες δένδρο που να διατηρούν τις προδιαγραφές του. Δ. Ποια τα πλεονεκτήματα ενός νηματώδους δένδρου; 3.A. Να επιδείξετε τη νηματώδη μορφή του πιο κάτω ΔΔΑ. 5 2 7 null 6 9 23 null 3.B. Πως μπορούμε να διαχωρίζουμε ανάμεσα στους δείκτες νήματα και στους πραγματικούς δείκτες προς τα παιδιά ενός κόμβου σε ένα νηματώδες δένδρο; Για διαχωρισμό ανάμεσα στους δείκτες παιδιά και τους δείκτες νήματα ενός κόμβου χρησιμοποιούμε ένα χαρακτήρα σε κάθε κόμβο ο οποίος παίρνει την
τιμή n αν κανένα από τα παιδιά του κόμβου δεν είναι νήμα, l αν το αριστερό παιδί είναι νήμα, r αν το δεξί παιδί είναι νήμα και b αν και τα δύο παιδιά είναι νήματα. Χρησιμοποιούμε τις δομές: class ΤΝode{ int key; TNode left; TNode right; char threads; class Τree{ TNode root; και υποθέτουμε πως ένα νηματώδες δένδρο είναι υλοποιημένο ως δείκτης σε κόμβο τύπου *tree. 3.Γ. Να γράψετε διαδικασίες εισαγωγής και διαγραφής κόμβων σε ένα νηματώδες δένδρο που να διατηρούν τις προδιαγραφές του. Εισαγωγή σε νηματώδες δένδρο γίνεται παρόμοια με ένα ΔΔΑ. Η επέκταση η οποία καλούμαστε να κάνουμε είναι η απόδοση νημάτων στους κόμβους/φύλλα του δένδρου που δημιουργούνται λόγω των εισαγωγών. Η απόδοση γίνεται ως εξής. Αν ο νέος κόμβος δεν έχει πατέρα, τότε τα παιδιά/νήματα έχουν και τα δύο την τιμή null. Αν ο νέος κόμβος είναι αριστερό παιδί του πατέρα του, τότε κληρονομεί από αυτόν το αριστερό του νήμα, ενώ το δεξί του νήμα δείχνει στον πατέρα. Αν ο νέος κόμβος είναι δεξί παιδί του πατέρα του, τότε κληρονομεί από αυτόν το δεξί του νήμα, ενώ το αριστερό του νήμα δείχνει στον πατέρα. Και στις δύο τελευταίες περιπτώσεις, ο πατέρας πρέπει να ενημερωθεί κατάλληλα τόσο για την απόκτηση νέου παιδιού όσο και για την απώλεια κάποιου νήματος. TNode InsertNode(TNode *root, int n){ TNode p=new TNode(); p.val = n; p.left = p.right = null; p.threads = b ; TNode r = root; if (r == null) { p.threads = b return p;
while (1) { if (n == r.val) report n already in tree and exit if (n<r.val && r.threads!= b, l') r = r.left; if (n>r.val && r.threads!= b, r') r = r.right; break; if (n < r.val){ p.left = r.left; p.right = r; r.left = p; if r.threads == l r.threads == n ; if r.threads == b r.threads == r ; if (n > r.val)){ p.right = r.right; p.left = r; r.right = p; if r.threads == r r.threads == n if r.threads == b r.threads == l return root; Για τη διαδικασία εξαγωγής χρησιμοποιούμε βοηθητικά τις διαδικασίες DeleteRightMin(r) η οποία εξάγει το ελάχιστο στοιχείο στο δεξί υπόδενδρο του κόμβου r και DeleteLeftMax(r) η οποία εξάγει το μέγιστο στοιχείο στο αριστερό υπόδενδρο του κόμβου r. Πιο κάτω παρουσιάζεται η διαδικασία DeleteRightMin(r). Η διαδικασία αυτή είναι όμοια με την ανάλογη διαδικασία σε ΔΔΑ με κατάλληλες προσθήκες για ενημέρωση των νημάτων. Η σημαντικότερη αλλαγή αφορά το γεγονός ότι ο κόμβος με το ελάχιστο στοιχείο, έστω p, πιθανόν να αποτελεί αριστερό νήμα κάποιου άλλου κόμβου. Ο κόμβος αυτός, αν υπάρχει, είναι ο κόμβος/φύλλο με το αμέσως μεγαλύτερο στοιχείο. Υπάρχει, αν ο p έχει δεξιά υπόδενδρο. (Αν όχι, ο κόμβος με το αμέσως μεγαλύτερο στοιχείο είναι ο πατέρας του p ο οποίος δεν έχει αριστερό νήμα πριν από την εξαγωγή).
int deleterightmin(tnode r){ father = r; p = r = r.right; while (r.threads!= b, l ) { father = r; r = r.left; if (p == r) { father.right = r.right; if (r.threads == b ) if (father.threads == n ) r.threads == r if (father.threads == l ) father.threads == b father.left = r.left; if (r.threads == b ) if (father.threads == n ) r.threads == l ; if (father.threads == r ) if (r.right!= null) for(q=r.right; q.left!=null; q=q.left); q.left = r.left; val = r.val; return val; H διαδικασία εξαγωγής περιέχει εφαρμόζει κλήση της DeleteMin όπως και στα ΔΔΑ. Κά κάποια μικρή επιπρόσθετη πολυπλοκότητα έναντι αυτής σε ΔΔΑ εφόσον οι όποιες αναπροσαρμογές των νημάτων γίνονται κατά την κλήση της. void Delete(Tree t, int n){ r = t.root; if (r == null) return null; father = null; while (r!=null) if (n < r.val) father = r; r = r.left; if (n > r.val) father = r; r = r.right; if (n == r.val)
break; if r == null return; if (father == null) tree.root = null; if (r.threads = b ); if (r == father.left) father.left = r.left; if (father.threads = n ) father.threads = l if (father.threads = r ) father.threads = b if (r == father.right) father.right = r.right; if (father.threads = n ) father.threads = r if (father.threads = l ) father.threads = b if (r.right!= null) val = DeleteRightMin(r); val = DeleteLeftMax(r); r.val = val; 3.Δ. Το κύριο πλεονέκτημα ενός νηματώδους δένδρου είναι η ευκολότερη μετακίνηση μέσα στο δένδρο. Συγκεκριμένα, η ενδο διατεταγμένη και η μεταδιατεταγμένη διάσχιση δένδρων μπορεί να υλοποιηθεί εύκολα χωρίς αναδρομή και χωρίς τη χρήση στοίβας. Το ίδιο ισχύει (δηλαδή, εύκολη μετατροπή σε μη αναδρομικές διαδικασίες χωρίς τη χρήση βοηθητικών δομών) για αναδρομικές διαδικασίες που μιμούνται τις διασχίσεις αυτές. Μειονέκτημα είναι η χρήση περισσότερης μνήμης σε κάθε κόμβο του δένδρου και η αύξηση της πολυπλοκότητας των διαδικασιών εισαγωγής και διαγραφής. Άσκηση 4 (10 μονάδες) Θέλουμε να εισάγουμε τους αριθμούς 1, 4, 5, 7, 8, 28, 29, 72, σε ένα 2 3 δένδρο. Να βρείτε κάποια σειρά εισαγωγής των στοιχείων που να δημιουργεί 2 3 δένδρο με τον μικρότερο δυνατό αριθμό κόμβων και κάποια σειρά εισαγωγής που να δημιουργεί 2 3 δένδρο με τον μεγαλύτερο δυνατό αριθμό κόμβων. Να εφαρμόσετε τις δύο σειρές εισαγωγών των στοιχείων δείχνοντας όλα τα ενδιάμεσα αποτελέσματα.
Μία σειρά εισαγωγής των στοιχείων που έχει ως αποτέλεσμα την δημιουργία του δένδρου με τον μέγιστο αριθμό κόμβων είναι η 1, 4, 5, 7, 8, 28, 29, 72. (Υπάρχουν και άλλες σειρές εισαγωγής που δίνουν αυτό τον αριθμό κόμβων.) 7 4 28 1 5 8 29 72 Μια σειρά που παράγει δένδρο με τον μικρότερο αριθμό κόμβων είναι η 72, 4, 28, 29, 5, 8, 1, 7. Η σειρά αυτή δημιουργεί το πιο κάτω δένδρο με 4 κόμβους. (Υπάρχουν και άλλες σειρές εισαγωγής που δίνουν αυτό τον αριθμό κόμβων.) 5 28 1 4 7 8 29 72 Άσκηση 5 (20 μονάδες) Να εισηγηθείτε δενδρική δομή δεδομένων η οποία να υλοποιεί το γενεαλογικό δένδρο μιας οικογένειας. Συγκεκριμένα, για κάθε άτομο θέλουμε να αποθηκεύουμε τα εξής στοιχεία (α) ονοματεπώνυμο (β) χρονολογίες γεννήσεως και θανάτου (αν υπάρχει), (γ) όνομα συζύγου (αν υπάρχει) και (δ) δείκτες στα παιδιά του, αν έχει παιδιά. Η δομή θα πρέπει να υποστηρίζει τις πιο κάτω πράξεις: i. Children(name): η διαδικασία αυτή θα πρέπει να τυπώνει τα παιδιά του ατόμου με ονοματεπώνυμο name. ii. NewChild(child, year, parent) : η διαδικασία αυτή θα πρέπει εισάγει την πληροφορία της γέννησης του παιδιού child από το άτομο parent τη χρονολογία year. iii. Living(name): η διαδικασία αυτή θα πρέπει να τυπώνει τα ονοματεπώνυμα όλων των εν ζωή απογόνων του ατόμου name. Να υπολογίσετε τον χρόνο εκτέλεσης των διαδικασιών σας και να συζητήσετε την αποδοτικότητα της υλοποίησής σας από άποψης χρόνου και χώρου.
Προφανώς τα δένδρα τα οποία θα υλοποιήσουμε δεν έχουν κάποιο προκαθορισμένο βαθμό (δεν υπάρχει καθορισμένος μέγιστος βαθμός ενός κόμβου). Έτσι για εξοικονόμηση χώρου και για υλοποίηση λύσης χωρίς την επιβολή οποιωνδήποτε περιορισμών/υποθέσεων επιλέγουμε να υλοποιήσουμε το δένδρο φυλάγοντας για κάθε κόμβο το πρώτο του παιδί και τον επί δεξιά του αδελφό. Αυτό υλοποιείται μέσω των δομών: class person{ class ftnode { String name; person pers; int birth; ftnode firstchild; int death; ftnode rightsibling; String spouse; και υποθέτουμε πως ένα γενεαλογικό δένδρο είναι υλοποιημένο ως δείκτης στη ρίζα του δένδρου, δηλαδή, έχει τύπο ftnode. Αρχικά υλοποιούμε τη βοηθητική διαδικασία Find (name, ftree) η οποία εντοπίζει και επιστρέφει δείκτη στον κόμβο που αφορά το άτομο με όνομα name στο δένδρο που δείχνεται από το δείκτη p, αν υπάρχει. ftnode Find(String name, ftnode p) { if (p == null) return null; { if ((p.pers.name) == name) return p; { q = Find(name, p.firstchild); if (q!= null) return q; return Find(name,p.rightsibling); (i) Για να τυπώσουμε τα ονόματα των παιδιών ενός ατόμου με κάποιο όνομα, πρώτα εντοπίζουμε τον κόμβο που αντιστοιχεί στο συγκεκριμένο άτομο και μετά επισκεπτόμαστε τα παιδιά του μέσω του δείκτη firstchild του κόμβου και των δεικτών rightsibling των παιδιών: Children(String name, ftnode p) { q = Find(name, p); if (q!= null) {
q = p.firstchild; while (q!= null) { print q.pers.name; q = q.rightsibling; (ii) Για να εισαγάγουμε ένα νέο παιδί κάποιου ατόμου θα πρέπει πρώτα να εντοπίσουμε το άτομο αυτό, και στη συνέχεια να τοποθετήσουμε το νέο παιδί στη λίστα παιδιών που δείχνεται από το δείκτη firstchild: Newchild(String child, int year, String parent, ftnode p){ q = Find(parent, p); if (q!= null) { r = new ftnode(); r.pers.name = child; r.pers.birth = year; r.pers.death = 1; r.firstchild = null; r.rightsibling = q.firstchild; q.firstchild = r; (iii) H διαδικασία που προτείνεται αρχικά εντοπίζει τον ζητούμενο κόμβο και στη συνέχεια καλεί μια δεύτερη, αναδρομική διαδικασία στο πρώτο παιδί του κόμβου αυτού. Η αναδρομική διαδικασία δουλεύει ως εξής: αν το παιδί δεν είναι κενό, τότε: αν το άτομο στη ρίζα του δένδρου βρίσκεται εν ζωή, τότε το όνομα του ατόμου τυπώνεται στην οθόνη και η διαδικασία καλείται (1) στο πρώτο παιδί του κόμβου και (2) στον επί δεξιά αδελφό του κόμβου. Living(String name, ftnode p) { q = Find(name, p); if (q == null) return; if (q.firstchild!= null) RecLiving(q.firstchild); RecLiving (ftnode p){ if (p.death == 1) print(p.pers.name); if (p.firstchild!= null) RecLiving (p.firstchild);
if (p.rightsibling!= null) RecLive(p.righsibling); Ο χρόνος εκτέλεσης των διαδικασιών είναι Ο(n) όπου n είναι ο αριθμός των κόμβων του δένδρου.