LoginSignup
1
1

More than 3 years have passed since last update.

Pythonで2分探索木の動作を可視化

Last updated at Posted at 2020-06-18

探索木の動作

平衡2分探索木の考えですが、以下の関係を壊さないように、気に対してノードの追加削除進めていきます。
左の子ノードの値 ≦ ノードの値 ≦ 右の子ノードの値
加えて、木の平衡状態、つまり
各ノードの左右の部分木の高さの差が1以下にする
という状態を保つために
ノードの追加削除が行われた後に回転が行われます。

このロジックの理解を深めるため、どのように木が変化していくかを可視化してみます。
こんな感じです。
スクリーンショット 2020-06-18 23.44.00.jpg
スクリーンショット 2020-06-18 23.49.34.jpg
数がデータ最大値までの乱数を出力しながら
2分探索木のルールを守りながらアニメーションしていきます。

ソースコードですが、クラスをいくつか作成します。

  • Controller クラス
  • View クラス
  • Node クラス
  • Tree クラス

あとは関数をいくつも作成しています。

以下にサンプルプログラムを掲載します。

2TreeSamplePython.py
import tkinter
import time
import math
import random

# キャンバスのサイズ
CANVAS_WIDTH = 1200
CANVAS_HEIGHT = 700

# データの最大値
DATA_NUM = 120

# 描画時のウェイト(s)
WAIT = 0.1

# 円の半径
RADIUS = 30

class Node():
    def __init__(self, number):
        self.left = None
        self.right = None
        self.number = number

class Tree():
    # 枝の方向を指す定数
    LEFT = 0
    RIGHT = 1

    # 処理を表す定数
    ADD = 0
    LEFT_ROTATE = 1
    RIGHT_ROTATE = 2
    DELETE = 3

    def __init__(self, view):
        # 木構造を制御するオブジェクト生成

        self.view = view

        # 木の根をNoneに初期化
        self.root = None

    def add_node(self, number):
        # ノードを追加

        # まだノードがない場合
        if not self.root:
            # 根ノードとしてノードを追加
            self.root = Node(number)
            self.draw(Tree.ADD)

            return True

        # 根ノードから順に追加する場所を探索
        node = self.root

        # 根ノードからどう辿ったかを覚えておくリスト
        branch = []

        while True:
            # 追加する値がノードの値よりも小さい場合
            if number < node.number:

                # 左の枝を辿った情報を格納
                branch.append(Tree.LEFT)
                # そのノードの左の子が無い場合
                #  (もう辿るべきノードが無い場合)
                if not node.left:
                    # その左の子の位置にノードを追加
                    node.left = Node(number)

                    # 追加完了し処理終了
                    break

                # 左の子がある場合
                #  左の子を新たな注目ノードに設定
                node = node.left

            # 追加する値がノードの値よりも大きい場合
            elif number > node.number:
                # 右の枝を辿った情報を格納
                branch.append(Tree.RIGHT)

                # そのノードの右の子が無い場合
                #  (もう辿るべきノードが無い場合)
                if not node.right:
                    # その右の子の位置にノードを追加
                    node.right = Node(number)

                    # 追加完了し処理終了
                    break

                # 右の子がある場合
                #  右の子を新たな注目ノードに設定
                node = node.right

            # 追加する値とノードの値が同じ場合
            else:
                print(str(number) + " Already Exist")
                return False

        # 一旦追加した状態で描画
        self.draw(Tree.ADD)

        # 平衡に保つための回転
        self.balancing(self.root, None, None, branch)

        return True

    # numberの値を持つノードを削除
    def delete_node(self, number):
        if not self.root:
            return False

        # 削除するノードを探索
        node = self.root

        # 親ノードはNone
        parent = None

        # 根ノードからどう辿ったか情報を格納するリスト
        branch = []

        while node:
            # 探索する値よりもnodeの値が大きい場合
            if number < node.number:
                # 左の枝を辿った情報を格納
                branch.append(Tree.LEFT)

                # 左の子ノードを辿る
                parent = node
                node = node.left
            # 探索する値よりもnodeの値が小さい場合
            elif (number > node.number):
                # 右の枝を辿った情報を格納
                branch.append(Tree.RIGHT)
                # 右の子ノードを辿る
                parent = node
                node = node.right
            # 見つかった場合
            else:
                # 処理終了
                break
        # 見つからなかった場合
        else:
            print(str(number) + "を持つノードはありません")
            return False

        # 子がいないノードの削除
        if not node.left and not node.right:
            self.delete_no_child_node(node, parent)
        # 左の子のみいるノードの削除
        elif node.left and not node.right:
            self.delete_one_child_node(node, node.left)
        # 右の子のみいるノードの削除
        elif not node.left and node.right:
            self.delete_one_child_node(node, node.right)
        # 左の子と右の子両方がいるノードの削除
        else:
            self.delete_two_child_node(node, branch)

        # キャンバスから削除したノードの情報を除去
        self.remove(number)
        # 除去後の木を描画
        self.draw(Tree.DELETE)
        # 平衡にするため回転
        self.balancing(self.root, None, None, branch)

        return True

    # 子のいないノード(node)を削除
    def delete_no_child_node(self, node, parent):
        # 親がいる場合
        if parent:
            # nodeへの参照を外す
            if parent.left == node:
                parent.left = None
            else:
                parent.right = None
        # 親がいない場合
        else:
            self.root = None
    # 子が一方にだけいるノード(node)の削除
    def delete_one_child_node(self, node, child):
        # child のデータを node にコピー
        node.number = child.number
        node.left = child.left
        node.right = child.right

    # 子が2つあるノード(node)の削除
    def delete_two_child_node(self, node, branch):
        # nodeの左の子孫ノードの中から最大値を持つノードを探索
        max_node = node.left
        max_parent = node
        # 左の枝を辿った情報を格納
        branch.append(Tree.LEFT)

        # 必ず右の子ノードの方が値が大きい
        while max_node.right:
            max_parent = max_node
            max_node = max_node.right

            # 右の枝を辿ったことを覚えておく
            branch.append(Tree.RIGHT)

        # 最大ノードのデータ(number)をnodeにコピー
        node.number = max_node.number

        # 最大ノード(max_node)を削除
        #  最大ノードなので右の子ノードはいない
        if not max_node.left:
            # max_nodeに子がいない場合
            self.delete_no_child_node(max_node, max_parent)
        else:
            # 子が1つある場合
            self.delete_one_child_node(max_node, max_node.left)

    # 各ノードの平衡係数を±1以下にする
    def balancing(self, node, parent, direction, branch):
        if not node:
            return
        # 辿れる場合はまず目的のノードまで辿る
        if len(branch) > 0:
            # 辿る子ノードを設定
            if branch[0] == Tree.LEFT:
                next = node.left
            else:
                next = node.right

            # 子ノードを辿る
            self.balancing(next, node, branch[0], branch[1:])

        # 平衡係数を計算
        left_height = self.get_height(node.left)
        right_height = self.get_height(node.right)
        balance = right_height - left_height

        # 右の部分木が高くて並行状態でない場合
        if balance > 1:
            # 2重回転が必要かどうかを判断
            if self.get_height(node.right.left) > self.get_height(node.right.right):
                # 2重回転(Right Left Case)
                self.right_left_rotate(node, parent, direction)
            else:
                # 1重回転(左回転)
                self.left_rotate(node, parent, direction)

        # 左の部分木が高くて並行状態でない場合
        elif balance < -1:
            # 2重回転が必要かどうかを判断
            if self.get_height(node.left.right) > self.get_height(node.left.left):
                # 2重回転(Left Right Case)
                self.left_right_rotate(node, parent, direction)
            else:
                # 1重回転(右回転)
                self.right_rotate(node, parent, direction)

    # 2重回転(Right Left Case)を行う
    def right_left_rotate(self, node, parent, direction):
        print("right_left_rotate:" + str(node.number))
        # nodeの右の子ノードを根として右回転
        self.right_rotate(node.right, node, Tree.RIGHT)
        # nodeを根として左回転
        self.left_rotate(node, parent, direction)

    # 2重回転(Left Right Case)を行う
    def left_right_rotate(self, node, parent, direction):
        print("left_right_rotate:" + str(node.number))
        # nodeの左の子ノードを根として左回転
        self.left_rotate(node.left, node, Tree.LEFT)
        # nodeを根として右回転
        self.right_rotate(node, parent, direction)

    # nodeを根として左回転を行う
    def left_rotate(self, node, parent, direction):
        print("left_rotate:" + str(node.number))
        # 新しい根とするノードをpivotとして設定
        pivot = node.right

        # 左回転
        if pivot:
            node.right = pivot.left
            pivot.left = node

        # parentもしくはrootに新しい根ノードを参照させる
        if not parent:
            self.root = pivot
            self.draw(Tree.LEFT_ROTATE)
            return

        # どちらの子に設定するかはdirectionから判断
        if direction == Tree.LEFT:
            parent.left = pivot
        else:
            parent.right = pivot

        self.draw(Tree.LEFT_ROTATE)

    # nodeを根として右回転を行う
    def right_rotate(self, node, parent, direction):
        print("right_rotate:" + str(node.number))
        # 新しい根とするノードをpivotとして設定
        pivot = node.left

        # 右回転
        if pivot:
            node.left = pivot.right
            pivot.right = node

        # parentもしくはrootに新しい根ノードを参照させる
        if not parent:
            self.root = pivot
            self.draw(Tree.RIGHT_ROTATE)
            return

        # どちらの子に設定するかはdirectionから判断
        if direction == Tree.LEFT:
            parent.left = pivot
        else:
            parent.right = pivot

        self.draw(Tree.RIGHT_ROTATE)

    # nodeを根とした木の高さを取得する
    def get_height(self, node):
        # nodeが無いなら高さは0
        if not node:
            return 0

        # 左右の子を根とした木の高さを取得
        left_height = self.get_height(node.left)
        right_height = self.get_height(node.right)

        # 大きい方に+1したものを木の高さとして返却
        if left_height > right_height:
            tree_height = left_height
        else:
            tree_height = right_height

        return tree_height + 1

    # 木の描画
    def draw(self, process):
        # rootを根とする木を描画
        self.draw_node(self.root, [], process)
        # キャンバスのアップデート依頼
        self.view.update()
        # スリープ
        time.sleep(WAIT)

    # 枝を辿りながらnodeを根とする木を描画
    def draw_node(self, node, branch, process):
        if not node:
            return
        # 処理によって円の色を変える
        if process == Tree.LEFT_ROTATE:
            diff_color = "#FFDDDD"
        elif process == Tree.RIGHT_ROTATE:
            diff_color = "#DDDDFF"
        elif process == Tree.ADD:
            diff_color = "#DDFFDD"
        elif process == Tree.DELETE:
            diff_color = "#FFFFDD"

        # そのノードと親ノードを繋ぐ線を描画
        self.view.draw_line(node, branch)
        # そのノードを描画
        self.view.draw_oval(node, branch, diff_color)
        # そのノードの数字を描画
        self.view.draw_text(node, branch)
        # 左の子孫ノードを描画
        branch.append(0)
        self.draw_node(node.left, branch, process)
        del branch[-1]
        # 右の子孫ノードを描画
        branch.append(1)
        self.draw_node(node.right, branch, process)
        del branch[-1]

    # numberをデータに持つノードの描画を除去
    def remove(self, number):
        self.view.remove_oval(number)
        self.view.remove_line(number)
        self.view.remove_text(number)


class View():
    # UI関連のオブジェクト生成
    def __init__(self, master):
        self.master = master

        # 各種設定
        self.drawn_oval = []
        self.drawn_line = []
        self.drawn_text = []

        # キャンバスのサイズを決定
        self.canvas_width = CANVAS_WIDTH
        self.canvas_height = CANVAS_HEIGHT
        # 情報表示用のフレームを作成
        self.canvas_frame = tkinter.Frame(
            master,
        )
        self.canvas_frame.grid(column=1, row=1)
        # 操作用ウィジェットのフレームを作成
        self.operation_frame = tkinter.Frame(
            master,
        )
        self.operation_frame.grid(column=2, row=1, padx=10)
        # キャンバスの生成と配置
        self.canvas = tkinter.Canvas(
            self.canvas_frame,
            width=self.canvas_width,
            height=self.canvas_height,
            bg="#F0F0F0"
        )
        self.canvas.pack()
        # 開始ボタンの生成と配置
        self.button = tkinter.Button(
            self.operation_frame,
            text="開始",
        )
        self.button.pack()

    # キャンバス上の円の中心座標を取得
    def get_oval_coord(self, branch):
        # まずは木のどの位置の円を描画するかをbranchから計算
        #  branchの長さよりこのノードの縦方向の位置を計算
        tree_y = len(branch)
        # branchの要素より左から何番目のデータかを計算
        tree_x = 0
        i = 0
        for n in branch[::-1]:
            tree_x += n * 2 ** i
            i += 1

        # tree_xとtree_yをキャンバス上の座標に変換していく
        #  データ数より木の最大高さを計算
        max_tree_height = math.ceil(math.log2(DATA_NUM + 0.5)) + 1
        # マスの数を計算
        #  平衡
        num_horizontal = 2 ** (max_tree_height - 1) * 2
        #  垂直
        num_vertical = max_tree_height * 2

        # 1マスの幅を計算(キャンバスの幅とマス数より)
        one_width = self.canvas.winfo_width() / num_horizontal
        # 1マスの高さを計算(キャンバスの高さとマス数より)
        one_height = self.canvas.winfo_height() / num_vertical

        # 円の中心座標を計算
        x_space = num_horizontal / (2 ** tree_y * 2)
        x = x_space * (tree_x * 2 + 1) * one_width
        y = (tree_y * 2 + 1) * one_height

        return x, y

    # 2つの座標が同じである角かを判断
    def judge_same_coord(self, coord1, coord2):
        # どちらか一方が None
        if not coord1 or not coord2:
            return False
        # 長さが違う
        if len(coord1) != len(coord2):
            return False

        # 座標が同じかを1つ1つチェック
        for i in range(len(coord1)):
            if not math.isclose(coord1[i], coord2[i]):
                # 異なればFalse
                return False

        # 全部同じならTrue
        return True

    # ノードを表す円をdiff_color色で描画
    def draw_oval(self, node, branch, diff_color):
        # 円の中心座標を取得
        x, y = self.get_oval_coord(branch)

        # 円の左上(x1, y1)と円の右下(x2, y2)を計算
        coord = (
            x - RADIUS,
            y - RADIUS,
            x + RADIUS,
            y + RADIUS,
        )

        # タグを作成
        tag = "oval_" + str(node.number)

        # 前回描画時の円の座標を取得
        bcoord = self.canvas.coords(tag)

        # 前回と座標差分のあるノードだけ色を付ける
        if self.judge_same_coord(coord, bcoord):
            color = "#FFFFFF"
        else:
            color = diff_color

        # 前回描画した円を削除
        if tag in self.drawn_oval:
            self.drawn_oval.remove(tag)
            self.canvas.delete(tag)

        # 円を新しく描画
        self.canvas.create_oval(
            coord[0], coord[1],
            coord[2], coord[3],
            fill=color,
            tag=tag
        )

        # 描画した円のタグを覚えておく
        self.drawn_oval.append(tag)

    # ノードと親ノードを繋ぐ線を描画
    def draw_line(self, node, branch):
        if len(branch) == 0:
            # タグを作成
            tag = "line_" + str(node.number)

            # 前回描画時した線を削除
            if tag in self.drawn_line:
                self.drawn_line.remove(tag)
                self.canvas.delete(tag)

            # 親がないので線は描画しない
            return

        # ノードの中心座標取得
        x, y = self.get_oval_coord(branch)

        # 親ノードの中心座標取得
        px, py = self.get_oval_coord(branch[:-1])

        width = px - x
        height = py - y
        rad = math.atan2(height, width)

        # タグを作成
        tag = "line_" + str(node.number)

        # 前回描画時した線を削除
        if tag in self.drawn_line:
            self.drawn_line.remove(tag)
            self.canvas.delete(tag)

        # 新しく線を描画
        self.canvas.create_line(
            x + RADIUS * math.cos(rad),
            y + RADIUS * math.sin(rad),
            px + RADIUS * math.cos(math.pi + rad),
            py + RADIUS * math.sin(math.pi + rad),
            tag=tag
        )

        # 描画した線のタグを覚えておく
        self.drawn_line.append(tag)

    # ノードの数字を描画
    def draw_text(self, node, branch):
        # ノードの中心座標取得
        x, y = self.get_oval_coord(branch)

        # タグを作成
        tag = "text_" + str(node.number)

        # 前回描画時した数字を削除
        if tag in self.drawn_text:
            self.drawn_text.remove(tag)
            self.canvas.delete(tag)

        # 新しくテキストを描画
        self.canvas.create_text(
            x, y,
            text=str(node.number),
            tag=tag,
            font=("", RADIUS, "bold")
        )

        # 描画したテキストのタグを覚えておく
        self.drawn_text.append(tag)

    # numberを持つノードの円を除去'
    def remove_oval(self, number):
        # タグを作成
        tag = "oval_" + str(number)

        # 前回描画時した線を削除
        if tag in self.drawn_oval:
            self.drawn_oval.remove(tag)
            self.canvas.delete(tag)

    # numberを持つノードの線を除去
    def remove_line(self, number):
        # タグを作成
        tag = "line_" + str(number)

        # 前回描画時した線を削除
        if tag in self.drawn_line:
            self.drawn_line.remove(tag)
            self.canvas.delete(tag)

    # numberを持つノードの数字を除去
    def remove_text(self, number):
        # タグを作成
        tag = "text_" + str(number)

        # 前回描画時した円を削除
        if tag in self.drawn_text:
            self.drawn_text.remove(tag)
            self.canvas.delete(tag)

    # キャンバスのアップデート
    def update(self):
        self.canvas.update()


class Controller():
    # SortとViewを制御するオブジェクトを生成
    def __init__(self, view, tree):
        # 制御するViewとTreeのオブジェクト設定
        self.view = view
        self.tree = tree

        self.num_list = []

        # ボタンクリック時のイベントを受け付け
        self.view.button["command"] = self.button_click

    # ボタンクリック時の処理
    def button_click(self):
        for i in range(DATA_NUM):
            self.add(i)

        for i in range(DATA_NUM):
            self.delete(i)

    # 木へのノードの追加を行う
    def add(self, i):
        num = random.randint(0, DATA_NUM)
        # 木へのnumを持つノード追加
        self.tree.add_node(num)
        print("add:" + str(num))

    # 木へのノードの削除を行う
    def delete(self, i):
        num = random.randint(0, DATA_NUM)
        # 木からnumを持つノードを削除
        self.tree.delete_node(num)
        print("delete:" + str(num))


# アプリ生成
app = tkinter.Tk()
app.title("二分木")

# Viewオブジェクト生成
view = View(app)

# Treeオブジェクト生成
tree = Tree(view)

# Controllerオブジェクト生成
controller = Controller(view, tree)

# mainloopでイベント受付を待機
app.mainloop()

2分探索木になっている2分木はどれか

まとめ

この際に2分探索木の復讐をなさってはいかがでしょうか。
そうそう、このPGを動かすときに
開始ボタンがあります。
「反応しねぇな。。。」と思わないで、ボタン押下お願いしますね。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1