Дерева. Бінарні дерева Лекція № 4
Якщо у списку кожний елемент має покажчик лише на один наступний за ним елемент, то в дереві ― на декілька. Таку структуру так само можна організувати лише за допомогою покажчиків. Деревом називається структура даних, кожний елемент якої зв’язаний з декількома наступними за ним елементами за допомогою вказівників.
Схематично дерево, кожний елемент якого зв’язаний з двома наступними елементами, можна зобразити таким чином:
• Розглянемо декілька прикладів представлення дерев, де елементами є множина літер. 1. Вкладені множини: A B D і O k E j L G M C N F H P
2. Вкладені дужки: 3. Відступи: A (A(B(D(i), E(j, k, L)), C(F(O), G(M, N), H(P)))) B D E C F G H i j k L O M N P
З наведеними прикладами представлення дерев досить часто маємо справу. Перший приклад ― схематичне представлення структури деякого підприємства або організації із зображенням підпорядкування різних підрозділів, другий ― математичне представлення арифметичного виразу, третій ― ієрархічна структура систематизованого запису: план твору, зміст книжки, код мовою програмування тощо. Розглянемо деякі означення дерева, як одного із способів представлення інформації. Упорядковане дерево ― це дерево, у якого ребра, тобто гілки, що виходять з кожної вершини, упорядковані.
Наприклад, два упорядкованих дерева на малюнку різні: A A B C C B
Вершина y, що знаходиться безпосередньо нижче вершини x, називається нащадком х. Якщо вершина х знаходиться на рівні i, то кажуть, що вершина y знаходиться на рівні і+1. І навпаки, вершину х називають безпосереднім предком у. Вважається, що корінь дерева (найвища вершина) знаходиться на рівні 0. Максимальний рівень деякої з вершин дерева називається його глибиною або висотою. Якщо елемент не має нащадків, то його називають термінальною вершиною або листом, а не термінальну вершину називають внутрішньою.
Кількість безпосередніх нащадків внутрішньої вершини називають її степінню. Максимальний степінь всіх вершин дерева називається степінню дерева. Кількість ребер, які необхідно пройти від кореня дерева до вершини х, називається довжиною шляху до вершини х. Корінь дерева має шлях 0, його безпосередні нащадки мають довжину шляху 1 і т. д. Вершина на рівні і має довжину шляху рівну і. Довжина шляху всього дерева визначається як сума довжин шляхів всіх його компонентів. Її також називають довжиною внутрішнього шляху. Наприклад, довжина внутрішнього шляху на малюнку нижче рівна 36.
Середня довжина шляху обраховується за формулою: ni*i/n, де ni ― кількість вершин на рівні і. Для дерев існує поняття зовнішнього шляху. Для його визначення дерево необхідно доповнити спеціальними вершинами у тих місцях, де у заданому дереві відсутні піддерева. Додаткові вершини добавляються за правилом: всі вершини повинні стати максимального степеня, рівного степеню дерева.
Розширене дерево матиме такий вигляд: А В С D i E j F k L O G M H N P
Середній зовнішній шлях обраховується за формулою: mi*i/m, де mi ― кількість спеціальних вершин на рівні і. Наприклад, довжина зовнішнього шляху на малюнку дорівнює 120. Особливе місце у структурі даних «дерево» займають упорядковані дерева другого ступеня. Їх ще називають двійковими або бінарними. Визначимо упорядковане бінарне дерево як кінцеву множину елементів, тобто вершин, яка або порожня, або складається з кореня з двома окремими двійковими деревами ― лівим та правим піддеревом.
Приклади бінарних дерев: • генеалогічне дерево, де у кожної людини в термінах поняття «дерево» «нащадками» є батько та мати; • арифметичний вираз з бінарними операціями. Наприклад, для арифметичного виразу (a + b/c) * (d e * f) деревовидна схема виглядатиме так: * + а / b * d c e f
Як програмно представити дерево? Кожну вершину можна розглядати як вхід у нове дерево. Це наводить на думку використання рекурсії в описові дерева. Посилання на порожні дерева, тобто термінальні вершини, будемо позначати значенням null ― порожнє посилання.
У термінах посилань представимо попереднє дерево таким чином: root * - + / a null b null e c null * d null f null
Розглянемо бінарне дерево як структуру даних. За аналогією зі списком опишемо її таким чином: Pascal С++ Ttree=^tree; struct Ttree=record { int data; data: <тип>; Ttree* left; left, right: Ttree; Ttree* rigth; end; };
Отож, тепер можна дати ще одне означення структури даних «дерево» : Дерево з базовим типом Т ― це • порожнє дерево або • деяка вершина типу Т з кінцевою кількістю зв’язаних з нею окремих дерев з базовим типом Т, що називаються піддеревами.
З’ясуємо, яким чином структура даних «дерево» відображається на пам’ять комп’ютера. Оскільки пам’ять має лінійну дискретну структуру, то і елементи розташовуються там один за одним. Лише значення покажчиків вказуватиме на слідування одного елемента за іншим. Зобразимо схематично розташування у пам’яті комп’ютера структуру дерева, що відповідає арифметичному виразу (a + b/c) * (d e * f):
Розглянемо конкретну задачу, в якій застосуємо дерево: сформуємо бінарне дерево для цілих чисел, що вводяться. Будемо будувати дерево мінімальної глибини, розміщуючи вершини порівну зліва і справа від кожної вершини. Правило формування такого дерева визначимо таким чином: 1) перше число розмістимо у першій вершині в якості кореня дерева; 2) побудуємо таким самим чином ліве піддерево з nl вершинами (int nl=n / 2); 3) побудуємо таким самим чином праве піддерево з nr вершинами (nr=n nl 1).
Побудоване за цим правилом дерево називається ідеально збалансованим, тому що кількість вершин в його правих та лівих піддеревах відрізняється не більше ніж на 1. Почнемо зі створення дерева. Для цього можна запропонувати використання такої рекурсивної функції:
Pascal С++ function tree_balans(n: integer): Ptr; Ttree* tree_balance(int n) var q: Ptr; { x, nl, nr: integer; Ttree* q; begin int nl, nr; if n=0 then q: =nil if (n==0) q=0; else { begin nl=n/2; nr=n-nl-1; nl: =n div 2; nr: =n nl 1 q=new Ttree; read(x); q->data=rand()%100 -50; new(q); q->left=tree_balance(nl); with q^ do q->rigth=tree_balance(nr); begin }; data: =x; return q; left: =tree_balans(nl); } right: =tree_balans(nr); end; tree_balans: =q; end;
Використати функцію створення можна такими операціями: Pascal read (n); root: =tree_balans (n); дерева С++ cout<< "Input N "; int n; cin>> n; srand(time(0)); Ttree* root=tree_balance(n);
У результаті змінна root буде містити адресу його кореневого елемента. Цікаво було би подивитися вміст створеного дерева, тобто побачити, яким чином елементи збалансованого дерева наслідують один одного. Назвемо цю процедуру Print. Tree: Pascal procedure Print. Tree(t: Ptr; h: integer); var i: integer; begin if t<>nil then with t^ do begin Print. Tree(rigth, h+1); for i: =1 to h do write(’ ’); write (data: 6); writeln; Print. Tree (left, h+1); end; С++ void Print. Tree(Ttree* t, int h) { if (t!=0) { Print. Tree(t->left, h+1); for (int i=1; i<=h; i++) { cout << " "; }; cout << t->data<<"n"; cout <<"n"; Print. Tree(t->rigth, h+1); } return; }
Звернення в основній програмі до цієї процедури для виведення на екран монітора побудованого дерева буде таким: Print. Tree(root, 0); Якщо ми виконаємо наведену процедуру виведення вмісту побудованого збалансованого дерева, наприклад, для послідовності 2 5 1 7 6 3 10 9 4, то отримаємо такий вигляд дерева: 2 5 1 7 3 10 6 9 4
Перш ніж визначити операції читання та запису елементів дерева необхідно розібратися з операцією пошуку заданого елемента в дереві. Адже перш ніж вставляти елемент в дерево або вилучати його необхідно знайти елемент, перед яким або після якого треба виконати визначену операцію. Зрозуміло, що дерево будується завжди за певною ознакою. На відміну від списку, у кожної вершини дерева є два нащадка. Тобто, будуючи дерево ми спочатку обумовлюємо ознаку, за якою один елемент є лівим, а другий ― правим нащадком для даного. У збалансованому дереві такою ознакою є порядковий номер числа у заданій послідовності: перші числа займають ліві «позиції» у лівому піддереві, а наступні числа― праві.
Можна також умовою побудови дерева визначити якісну характеристику чисел, тобто їх значення: лівим нащадком для кожного числа у вершині дерева буде менше за нього число, а правим ― більше. Дерево, побудоване таким чином, називається деревом пошуку. Алгоритм створення такого дерева виглядає так:
Pascal С++ procedure tree_find(x: integer; var Ttree* seach(Ttree* &q, int x) p: Ptr); { begin if (q!=0) if p=nil if (x<q->data) seach(q->left, x); then else seach(q->rigth, x); begin else { new(p); q=new Ttree; with p^ do q->data=x; begin q->left=0; data: =x; q->rigth=0; left: =nil; right: =nil; }; end; return q; end } else if x<p^. data then tree_find(x, p^. left) else tree_find(x, p^. right); end;
Нехай нам відомо значення x елемента дерева, яке необхідно знайти. Найпростіший алгоритм опису цієї процедури є рекурсивним. Якщо елемент не знайдено, пройдемо по самій лівій межі дерева, поки не вийдемо на термінальну вершину, у якої покажчик на ліве піддерево дорівнює nil. Потім повернемося у найближчу батьківську вершину і здійснимо перехід до правого піддерева. Наступні дії будемо виконувати аналогічно. Завершенням обходу дерева буде знаходження шуканого елемента або повернення назад у корінь дерева, що означатиме відсутність у дереві шуканого елемента.
Процедура обходу дерева і одночасного пошуку заданого елемента виглядає так: Pascal procedure searсh(t: Ptr); begin if (t<>nil) and (t^. data<>x) then with t^ do begin searсh (t^. left); searсh (t^. rigth); end; С++ void find_element(Ttree* &r, Ttree* &q) { if (r->rigth!=0) find_element(r->rigth, q); else { q->data=r->data; q=r; r=r->left; }; return; };
Аналогічно можна знайти елемент, для якого лівий або правий нащадок є шуканим елементом: ((t^. left<>nil) and (t^. left^. data<>x)) or ((t^. rigth <>nil) and (t^. rigth^. data<>x)) або ((r->left!=0) && (r->left->data!=x)) || ((r->rigth!=0) && (r->rigth->data!=x)). Тобто можна знайти предка або батьківську вершину для шуканого елемента. Подібні операції ми визначали на структурі даних «список» .
Запис нового елемента в побудоване дерево є не такою вже й простою задачею. Якщо побудоване дерево є ідеально збалансованим, то запис нового елемента у будь-яке місце порушить цю умову. Тому для збереження структури дерева запис повинен супроводжуватись його переформуванням. Якщо ж дерево є деревом пошуку, то запис логічно здійснювати лише після відповідних термінальних вершин. Розглянемо запис нового елемента зі значенням х саме у дерево пошуку. Послідовність дій повинна бути такою: • «спуститися» гілками дерева до термінальної вершини за виконання умови x<p->data або x>p>data; • дописати елемент зі значенням х у визначене місце. (створення дерева пошуку tree_find)
Читання заданого елемента з дерева є значно складнішою операцією. Необхідно вилучити елемент із побудованої структури, не порушивши при цьому закону її будови. Розглянемо такі три випадки: • елемент, що вилучається є термінальною вершиною; • елемент, що вилучається, має одного нащадка; • елемент, що вилучається, має два нащадки.
Найпростішим є випадок, коли шуканий елемент є термінальною вершиною, або вершиною з одним нащадком. Складніше, коли необхідно вилучити елемент, що має обох нащадків, оскільки в цьому випадку його необхідно замінити на самий правий елемент його лівого піддерева або ж на самий лівий елемент його правого піддерева. Розглянемо створення повної версії процедури вилучення елемента з дерева пошуку поетапно. Якщо шуканий елемент, значення якого знаходилося за адресою р, виявився термінальною вершиною, то досить цю адресу замінити на null. Тобто до заміни p->data=x; p->left=0; p->right=0 (p^. data=x; p^. left=nil; p^. rigth=nil), а після заміни ― p=0 (р=nil).
Якщо ж шуканий елемент, значення якого знаходилося за адресою р, має лише одного нащадка, то він має бути замінений на нього. Це означає, що якщо відсутній правий нащадок (малюнок зліва), то шуканий елемент, що розташований за адресою р має набути значення елемента, що розташований зліва за адресою p ->left: p=p->left (p^. left: p: = p^. left). Якщо ж відсутній лівий нащадок (малюнок справа), то шуканий елемент має набути значення елемента, що розташований справа за адресою p->right^ p=p>rigth (p^. rigth: p: = p^. rigth). 30 25 30 35
Коли ж шуканий елемент, що розташований за адресою р, має два нащадки, то його необхідно замінити самим правим нащадком у лівому піддереві. Чим це мотивовано? По-перше, це повинен бути елемент, що є термінальною вершиною, оскільки після його вилучення дерево пошуку не потребує перебудови і ми вже вміємо таку вершину вилучати. По-друге, цей елемент буде точно більший за лівого нащадка елемента, що вилучається, і менший за правого нащадка. Це слідує з умови побудови дерева пошуку.
Розглянемо конкретний приклад дерева і пересвідчитися в логіці попередніх тлумачень: 6 2 5 1 0 20 10 4 22 16 8 7 9 25 13 11 18 14 17 30 19 45
Твердження: для будь-якого елемента дерева пошуку, що не є термінальною вершиною, всі елементи лівого піддерева менші за нього, а правого ― більші. Вилучимо елемент дерева пошуку зі значенням 20: всі елементи лівого піддерева менші за цей елемент, а правого ― більші за нього. На місце числа 20 треба поставити елемент, менший за нього, щоб не порушити структуру правого піддерева. Такі елементи є лише у лівому піддереві. Але одночасно цей елемент повинен бути більший за його лівого нащадка, тобто більший за 10. Ця умова повинна бути збережена після переміщення, щоб не порушити структуру лівого піддерева. Такий елемент є у правому піддереві для вершини 10, оскільки саме тут розміщені всі елементи, більші за значення його вершини.
На перший погляд претендентом на переміщення може бути будь-яка з термінальних вершин 11, 14, 17, 19: для всіх цих значень лівий нащадок менше, а правий більше за значенням. Однак, насправді лише термінальна вершина 19 може бути переміщеною на місце вершини зі значенням 20. Саме її значення на новому місці не порушить структури дерева пошуку. Адже якщо на місце вершини 20 помістити вершину зі значенням 11, то у її лівому піддереві знайдуться елементи, більші за її значення, наприклад, числа 13, 18, 17, 16, і таким чином структура дерева буде порушена. Для пошуку вершини, яка є претендентом на переміщення, необхідно спускатися правим піддеревом вершини 10 до термінальної вершини. На цьому шляху ми дійдемо саме до шуканої термінальної вершини 19.
Pascal procedure (var p: Ptr; x: integer); var q: prt; procedure d(var r: Ptr); begin if r^. right<>nil then d(r^. right) else begin q^. data: =r^. data; q: =r; r: =r^. left end; begin if p=nil then writeln(’Not found’) else if x<p^. data then (p^. left, x) else if x>p^. data then del(p^. right, x) else begin q: =p; if q^. right=nil then p: =q^. left else if q^. left=nil then p: =q^. right else d(q^. left); dispose(q); end; С++ // Пошук самого правого найбільшого елемента void find_element(Ttree* &r, Ttree* &q) { if (r->rigth!=0) find_element(r->rigth, q); else { q->data=r->data; q=r; r=r->left; }; return; }; // Видалення елемента у бінарному дереві пошуку void del_tree(Ttree* &p, int x) { Ttree* q; if (p==0)cout << "Element not foundn"; else if (x<p->data) del_tree(p->left, x); else if (x>p->data) del_tree(p->rigth, x); else { q=p; if (q->rigth==0) p=q->left; else if (q->left==0) p=q->rigth; else find_element(q->left, q); delete(q); }; return; };
Структура даних «дерево» так само, як і список, є структурою послідовного доступу: можна знайти будь-який визначений елемент дерева тільки починаючи з кореня дерева і рухаючись далі його гілками.