Drzewa i ich reprezentacje - WSB-NLU

advertisement
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;
}
}
Download