Drzewa binarne

WPROWADZENIE

Drzewo binarne to drzewo uporządkowane z korzeniem, w którym każdy węzeł ma co najwyżej dwóch potomków, lewego i prawego. Drzewa mają strukturę rekurencyjną i dlatego wiele operacji na nich można wygodnie zapisać za pomocą funkcji rekurencyjnych.

Drzewo binarne to pojęcie abstrakcyjne, więc przy przejściu do konkretnej realizacji można wybrać jedną z wielu możliwości.

IMPLEMENTACJA 1

Najczęściej występująca implementacja drzewa binarnego oparta jest na strukturach/klasach zawierających dwa łącza (lewe i prawe). Łącze może być puste, jeżeli węzeł nie ma w tym miejscu potomka. Najpierw zdefiniujemy klasę Node, która będzie reprezentować węzeł. Puste drzewo będzie reprezentowane przez puste łącze do korzenia. Aby dostać się do konkretnego węzła, musimy zacząć od wyróżnionego węzła, zwanego korzeniem (root), a następnie poruszać się w dół łączem lewym lub prawym.


class Node:
    """Klasa reprezentująca węzeł drzewa binarnego."""

    def __init__(self, data=None, left=None, right=None):
        self.data = data
        self.left = left
        self.right = right

    def __str__(self):
        return str(self.data)

root = None           # puste drzewo
root = Node("alone")  # drzewo z jednym węzłem
# Ręczne budowanie większego drzewa.
root = Node(1)
root.left = Node(2)
root.right = Node(3)
root.left.left = Node(4)
root.left.right = Node(5)
root.right.left = Node(6)
root.right.right = Node(7)

PRZECHODZENIE PRZEZ DRZEWO


|       1       |
|     /   \     |
|   2       3   |
|  / \     / \  |
| 4   5   6   7 |

Najbardziej podstawową operacją związaną z przetwarzaniem drzew jest przechodzenie przez drzewo. Operacja polega na tym, aby mając łącze do korzenia, odwiedzić systematycznie każdy węzeł drzewa. Sposoby przechodzenia przez drzewo binarne:

Pierwsze trzy sposoby przechodzenia przez drzewo można łatwo zaimplementować rekurencyjnie. Funkcja visit() oznacza operację na węźle, np. wyświetlenie zawartości.


def traverse_preorder(top, visit):
    if top is None:
        return
    visit(top)
    traverse_preorder(top.left, visit)
    traverse_preorder(top.right, visit)

def traverse_inorder(top, visit):
    if top is None:
        return
    traverse_inorder(top.left, visit)
    visit(top)
    traverse_inorder(top.right, visit)

def traverse_postorder(top, visit):
    if top is None:
        return
    traverse_postorder(top.left, visit)
    traverse_postorder(top.right, visit)
    visit(top)

Przechodzenie przez drzewo w kolejności preorder możemy zaimplementować nierekurencyjnie za pomocą stosu.


def traverse_stack(top, visit):
    if top is None:
        return
    stack = list()   # stos symulujemy przez listę Pythona
    stack.append(top)
    while stack:
        node = stack.pop()
        visit(node)   # przetworzenie node po zdjęciu ze stosu
        if node.right:
            stack.append(node.right)
        if node.left:
            stack.append(node.left)

Nierekurencyjne wersje inorder i postorder są bardziej skomplikowane [Drozdek].

Warto zauważyć, że zamieniając stos na kolejkę uzyskamy procedurę realizującą przechodzenie przez drzewo poziomami. Odpowiada to przechodzeniu przez graf wszerz (BFS), zaczynając od korzenia.


#from Queue import Queue   # Py2
from queue import Queue   # Py3

def traverse_queue(top, visit):
    if top is None:
        return
    Q = Queue()
    Q.put(top)
    while not Q.empty():
        node = Q.get()
        visit(node)   # przetworzenie node po wyjęciu z kolejki
        if node.left:
            Q.put(node.left)
        if node.right:
            Q.put(node.right)

PARAMETRY DRZEWA

Rozważymy funkcje, które będą obliczały pewne interesujące parametry drzewa binarnego. Szukamy funkcji podającej ilość węzłów w drzewie binarnym. Funkcja ma prostą strukturę rekurencyjną, a argumentem jest łącze do węzła.


def btree_count(top):
    if top is None:
        return 0
    return btree_count(top.left) + 1 + btree_count(top.right)

def btree_count_iteratively(top):
    if top is None:
        return 0
    counter = 0
    stack = list()
    stack.append(top)
    while stack:
        node = stack.pop()
        counter += 1   # przetworzenie node po zdjęciu ze stosu
        if node.left:
            stack.append(node.left)
        if node.right:
            stack.append(node.right)
    return counter

Następna funkcja będzie obliczała wysokość drzewa binarnego, rozumianą jako liczbę poziomów (samotny korzeń to wysokość 1). Tu również pojawia się rekurencja.


def btree_height(top):
    if top is None:
        return 0   # czasem przyjmuje się -1
    left = btree_height(top.left)
    right = btree_height(top.right)
    return 1 + max(left, right)

Bardzo pożyteczną funkcją jest funkcja drukująca lub rysująca drzewo. Funkcja rejestruje wysokość drzewa i korzysta z tej informacji do tworzenia wcięć w obrazie reprezentacji drzewa.


def btree_print_indented(top, level=0):
    if top is None:
        return
    btree_print_indented(top.right, level+1)
    print ( "{}* {}".format('   '*level, top) )
    btree_print_indented(top.left, level+1)

Łatwo można stworzyć drzewo binarne do którego nowe węzły dodawane są w przypadkowy sposób. Wyszukiwanie elementów w drzewie wymaga przeglądania całego drzewa.


import random

# Wersja rekurencyjna wstawiania.
def btree_random_insert(top, node):   # zwraca nowy korzeń
    if top is None:
        return node
    if random.random() < 0.5:
        top.left = btree_random_insert(top.left, node)
    else:
        top.right = btree_random_insert(top.right, node)
    return top

# Wersja rekurencyjna wyszukiwania.
def btree_random_search(top, data):   # zwraca węzeł lub None
    if top is None or top.data == data:
        return top
    node = btree_random_search(top.left, data)
    if node:
        return node
    else:
        return btree_random_search(top.right, data)

PRZETWARZANIE DRZEW BINARNYCH

Chcemy stworzyć funkcję, która będzie umieszczała elementy w drzewie binarnym w pewien uporządkowany sposób. Możemy zażądać, aby przy przeglądaniu drzewa w kolejności inorder elementy były posortowane. W ten sposób powstanie drzewo poszukiwań binarnych (binary search tree, BST). Funkcja będzie miała strukturę rekurencyjną, a argumentami funkcji będą: łącze do węzła i wstawiany element. Dodawanie elementu powoduje przebudowę drzewa, dlatego funkcja zwraca nową referencję do węzła. Przepis na taką funkcję może być następujący:

Musimy jeszcze doprecyzować przypadek, w którym wstawiany element jest równy elementowi przechowywanemu w węźle (duplikat). Mamy m.in. następujące możliwości:

Napiszemy funkcję ignorującą duplikaty.


# Wersja rekurencyjna wstawiania.
def bst_insert(top, node):   # zwraca nowy korzeń
    if top is None:
        return node
    if node.data < top.data:
        top.left = bst_insert(top.left, node)
    elif node.data > top.data:
        top.right = bst_insert(top.right, node)
    else:
        pass          # ignorujemy duplikaty
    return top            # bez zmian

Potrzebujemy również funkcji sprawdzającej, czy w takim posortowanym drzewie znajduje się dany element. Zwracany jest cały znaleziony wierzchołek lub None.


# Wersja rekurencyjna wyszukiwania.
def bst_search(top, data):   # zwraca węzeł lub None
    if top is None or data == top.data:
        return top
    elif data < top.data:
        return bst_search(top.left, data)
    else:   # data > top.data
        return bst_search(top.right, data) 

# Wersja iteracyjna wyszukiwania.
def bst_search_iteratively(top, data):   # zwraca węzeł lub None
    while top is not None:
        if data == top.data:
            return top
        elif data < top.data:
            top = top.left
        else:   # data > top.data
            top = top.right
    return None

TESTOWANIE DRZEWA BST

Załóżmy, że mamy dane drzewo binarne i chcemy sprawdzić, czy jest ono drzewem BST. Nie wystarczy sprawdzić dla każdego węzła czy zachodzi node.left.data < node.data oraz node.data < node.right.data. Musimy do węzła przekazać informację od rodzica o ograniczeniach na wartość przechowywaną w węźle. Przyjmujemy, że klucze w drzewie mogą się powtarzać.


def is_bst(top, min_key, max_key):
    """Test if a binary tree is BST."""
    if top is None:
        return True
    if top.data < min_key or top.data > max_key:
        return False
    return (is_bst(top.left, min_key, top.data) and
            is_bst(top.right, top.data, max_key))

# Wywołanie.
print ( is_bst(tree, float("-inf"), float("inf")) )

ROTACJE DRZEWA BST

Rotacja drzewa (ang. tree rotation) jest operacją na drzewie BST, która zmienia jego lokalną strukturę bez zmiany kolejności elementów, przy przechodzeniu przez drzewo BST metodą inorder. Wyróżnia się dwie symetryczne operacje: rotacja w prawo i rotacja w lewo.


|     X     --[w prawo]-o    Y     |
|    / \                    / \    |
|   Y   C   o-[w lewo]--   A   X   |
|  / \                        / \  |
| A   B                      B   C |

def rotate_right(top):
    if top.left is None:
        return top       # nie ma rotacji
    node = top.left
    top.left = node.right   # poddrzewo B
    node.right = top
    return node   # jest wyżej

def rotate_left(top):
    if top.right is None:
        return top           # nie ma rotacji
    node = top.right
    top.right = node.left   # poddrzewo B
    node.left = top
    return node   # jest wyżej