Algorytmy i struktury danych WSB-NLU w Nowym Sączu Ćwiczenia 12 Drzewa i ich reprezentacje Nasze rozważania rozpoczniemy od najpopularniejszych i najczęściej używanych drzew, czyli od drzew binarnych. Podstawowa komórka służąca do konstrukcji drzewa binarnego ma postać: struct Node { Elem info; struct Node *left, *right; } Jak widać mamy tu dwa wskaźniki, które wskazują na lewą i prawą gałąź drzewa binarnego. Zmienna info służy oczywiście do przechowywania danych w węźle. Może to być dowolny typ danych. Drzewa binarne i wyrażenia arytmetyczne Jedno z zastosowań drzew binarnych to reprezentacja wyrażeń arytmetycznych. Dowolne wyrażenie arytmetyczne może być zapisane w kilku różnych postaciach zależnie od tego jak będziemy umieszczali argumenty (zwane też operandami) względem operatorów. Operatory dwuargumentowe można umieszczać przed swoimi argumentami, po nich oraz pomiędzy nimi. Przykład 1 Rozważmy tradycyjne wyrażenie wrostkowej: ((a+b)+x*9)*12. Jego reprezentacja w postaci drzewa binarnego może wyglądać tak: * + 12 + a * b x 9 Jeżeli napiszemy odpowiednie funkcje obsługujące powyższą strukturę danych, to możemy przy pomocą takiej reprezentacji wyrazić dowolnie skomplikowane wyrażenia arytmetyczne i wykonywać na nich operacje. Ćwiczenie 1 Narysować binarne drzewa reprezentujące poniższe wyrażenia. W których przypadkach drzewo takie możemy zbudować na więcej niż jeden sposób? a) (a + b)*(a - b) b) a + b + c*3 c) (2*(3 + 4*(5 + 1/6)) + a)*(2 + x) d) (3 + 7*5)/(8 - 1/x) Dalej będziemy używali następującej struktury danych do przechowywania wyrażeń arytmetycznych w postaci binarnego drzewa wyrażenia: struct nodeExpr { double val; char op; nodeExpr *left, *right; } W zależności od wartości przechowywanej w polu op struktury nodeExpr wiemy czy dana komórka (węzeł) przechowuje argument liczbowy czy operator! Przyjmujemy następującą konwencję: Jeżeli op == ‘0’, to komórka przechowuje wartość w polu val Zajmiemy się teraz konstrukcją binarnego drzewa wyrażenia, gdy mamy daną tablicę tabOnp przechowującą poprawnie zapisane wyrażenie w ONP. Aby było wygodniej, elementami tej tablicy są struktury nodeExpr. Algorytm wykorzystuje stos i podstawowe operacje na stosie: pop() i push(). Ponadto używamy pomocniczej funkcji isOp(), która zwraca prawdę, gdy argument jest operatorem (np. +, - ). Dane wejściowe: tabOnp[] Dane wyjściowe: binarne drzewo wyrażenia z tablicy tabOnp[], o korzeniu wskazywanym przez wskaźnik e. nodeExpr *e; for (i=0; i < length(tabOnp); i++) { e = new nodeExpr; if (isOp(tabOnp[i]) == TRUE) e->op = tabOnp[i].op; else { e->val = tabOnp[i].val; e-> op = ‘0’; } e->left = NULL; e->right = NULL; if (isOp(tabOnp[i]) == TRUE) { nodeExpr *left, *right; pop(left); pop(right); e->left = left; e->right = right; } push(e); } Ćwiczenie 2 Wykonać powyższy algorytm budowania drzewa wyrażenia dla następujących wyrażeń (zapisanych już w ONP): a) a b + b) a b + x y - * c) 3 7 + 9 * d) x y + x y - * e) 2 4 6 8 + * + Wypisywanie drzewa wyrażenia Teraz chcielibyśmy obejrzeć drzewo, które zostało zbudowane przez powyższy algorytm. Co ciekawe okazuje się, że sposób interpretacji takiego drzewa zależy od sposobu przechodzenia przez jego gałęzie. Oto algorytm, który wypisuje drzewo w postaci klasycznej (wrostkowej): printInfix(e) { if e jest liczbą, to print(e); if e jest operatorem, to { printInfix(e->left) print(e->op) printInfix(e->right) } } Jak widać jest to algorytm rekurencyjny, w którym wykorzystujemy pomocniczą funkcję (print()), która wypisuje swoje argumenty (np. printf(), cout). Ćwiczenie 3 Wykonać powyższy algorytm, dla drzew zbudowanych w Ćwiczeniu 2. Ćwiczenie 4 Przetestować poniższą funkcję napisaną w języku C++, która realizuje algorytm wypisywania w postaci infiksowej, wyrażenia reprezentowanego w drzewie. Należy jeszcze dodać warunek zakończenia wywołań rekurencyjnych. void printInfix(struct nodeExpr *e) { if (e->op == ‘0’) cout << e->val; else { cout << “(“; printInfix(e->left); cout << e->op; printInfix(e->right); cout << “)”; } } Ćwiczenie 5 Teraz chcemy wypisać wyrażenie w formie beznawiasowej (ONP). Przeanalizować poniższy algorytm zapisany w języku C++. void printPrefix(struct *e) { if (e->op == ‘0’) cout << e->val << “ “; else { cout << e->op << “ “; printPrefix(e->left); printPrefix(e->right); } } Podsumujmy to jeszcze raz: W zależności od sposobu przechodzenia po drzewie możemy w różny sposób przedstawić jego zawartość bez wykonywania jakiejkolwiek zmiany w strukturze samego drzewa Obliczanie wartości wyrażenia Algorytm wartościujący drzewo wyrażenia jest niezwykle prosty w wersji rekurencyjnej. Podkreślmy, że sama definicja wyrażenia arytmetycznego ma charakter rekurencyjny. Idea algorytmu jest następująca: jeżeli w korzeniu drzewa jest liczba lub zmienna, to zwróć tę wartość (wtedy drzewo składa się tylko z tego jednego węzła); w przeciwnym razie oblicz wartość lewego podrzewa, oblicz wartość prawego podrzewa i wykonaj operację na tych wartościach wskazywana przez operator z korzenia. Można to zapisać w pseudokodzie tak: eval (e) { if (e jest liczbą lub zmienną) return e->val; else { op = e->op; return eval(e->left) op eval(e->right); } } Ćwiczenie 5 Zrealizować w postaci funkcji języka C++ i przetestować ten algorytm. Zakładamy, że na wejściu funkcja otrzymuje poprawne drzewo binarne wyrażenia poprzez wskaźnik do jego korzenia. Wskazówka: Zasadniczy kod funkcji może wyglądać tak: double eval(struct nodeExpr *e) { if (e->op == '0') return (e->val); else switch (e->op) { case '+' : return eval(e->left) + eval(e->right); case '-' : return eval(e->left) - eval(e->right); case '*' : return eval(e->left) * eval(e->right); case ':' : case '/' : if (eval (e->right) != 0) return (eval(e->left) / eval(e-right)); else EROOR!!! Dzielenie przez zero; stop; } } Powyższy algorytm działa poprawnie przy założeniu, że na wejściu znajduje się poprawne drzewo wyrażenia. W sytuacji, gdy algorytm będzie pracował na niepoprawnym drzewie, to wartościowanie się rozsypie. Poniżej znajduje się bardzo prosta funkcja rekurencyjna, która zwraca 1, gdy drzewo jest poprane i 0 gdy jest niepoprawne. Ćwiczenie 6 Przeanalizować i zaimplementować poniższą funkcją sprawdzającą poprawność drzewa wyrażenia: int valid (nodeExpr *e) { if (e->op == '0') return 1; else switch (e->op) { case '+' : case '-' : case '*' : case ':' : case '/' : return valid(e->op) * valid(e->op); default : return 0; } }