Amazing tree —— 二叉查詢樹

Max發表於2018-04-20

二分查詢很好的解決了查詢問題,將時間複雜度從 O(n)降到了O(logn)。
但是二分查詢的前提條件是資料必須是有序的,並且具有線性的下標。
對於線性表,可以很好的應用二分查詢,但是在插入和刪除操作時則可能會造成整個線性表的動盪,時間複雜度達到了O(n)
連結串列更是沒法應用二分查詢。

於是有了下面將要介紹的演算法,其在查詢、插入、刪除都能夠達到O(logn)的時間複雜度 —— 二叉查詢樹

見名知意,其資料結構基礎為二叉樹,初次接觸到二叉樹時並沒有感覺到其有什麼突出之處。但看到通過二叉樹構建出的二叉查詢樹方案時,確被深深的震撼了。

定義

二叉查詢樹(英語:Binary Search Tree),也稱二叉搜尋樹、有序二叉樹(英語:ordered binary tree),排序二叉樹(英語:sorted binary tree),是指一棵空樹或者具有下列性質的二叉樹:

若任意結點的左子樹不空,則左子樹上所有結點的值均小於它的根結點的值;
若任意結點的右子樹不空,則右子樹上所有結點的值均大於它的根結點的值;
任意結點的左、右子樹也分別為二叉查詢樹;
沒有鍵值相等的結點。

根據上面的規則我們先來定義一顆二叉樹

這裡可以很容易看出其規律,不需要過多的解釋。

插入

現在再插入一個元素13。 13>12所以往右邊走來到14,13 < 14則左走,發現14沒有左孩子,所以將13插入之,得到下面這張圖

查詢

按照上面插入的思路,可以很容易實現搜尋操作。並且發現其查詢的時間複雜度就為這顆樹的深度。

根據完全二叉樹的性質,具有n個結點的完全二叉樹的深度為 [logn] + 1

忽略掉+1得到二叉查詢樹的查詢時間複雜度為 O(logn),但是實際上並非如此,後面我們分析。

遍歷

二叉樹的遍歷有前序、中序、後序遍歷三種方式,這裡著重介紹後序遍歷。
對二差查詢樹進行中序遍歷時,可以得到一個asc的排序結果。如上面的樹中序遍歷的結果是 3, 8, 9, 12, 13, 14
中序遍歷從一顆子樹最左的節點開始輸出,既該樹的最小值。實現中序遍歷只需要將資料收集點置於左遞迴點與右遞迴點之間,這樣說還是有些含糊了,看程式碼吧

/**
 * 中序遍歷
 * @param $root
 * @return array
 */
public function inorder($root)
{
    $data = [];

    if ($root->left) {
        $data = array_merge($data, $this->inorder($root->left)); //左孩子遞迴點
    }

    $data[] = $root->data; // 這裡是中序遍歷的資料收集點

    if ($root->right) {
        $data = array_merge($data, $this->inorder($root->right)); // 右孩子遞迴點
    }

    return $data;
}

前驅與後繼, 以9節點為例, 12屬於9的後繼,8屬於9的前驅。

刪除

我們給這顆樹多加幾個結點

刪除樹中的結點分為很多種情況,如被刪除的結點不存在子結點,只存在左子樹/右子樹,左右子樹都存在,這裡已覆蓋率最廣的左右子樹都存在為例。

分析一個需求時要並不是需求存在多少中情況我們就寫多少種情況。而應該分析情況之間的關係,是否存在重複,或者屬於關係等,程式設計師應該做的就是提取需求的本質,力求於最簡潔的實現

現在我們打算刪除25這個結點,你會怎麼做?
如果只是簡單把18來頂替原來25的位置,則需要對18這顆子樹的孩子們進行重新調整。18只有三個孩子還好,但是當孩子成千上萬時,顯然會造成大面積的調整。
所以我希望能夠找到一個更好的節點來代替25,按照演算法導論中的描述,我們應該尋找該結點的前驅或者後繼來代替,比如圖中的24和27分別是25的前驅和後繼。

為什麼要使用前驅或者字尾來代替?這點我十分不確定,我給自己的理由是

  1. 該結點是一個特殊值,屬於某顆子樹的最大值或者最小值,具有確定性,可以被比較好的定義且查詢出來。
  2. 由於該結點屬於被刪除節點的前驅或者後繼,則刪除該結點對資料結構造成的影響最小。我並不確定是對什麼的資料結構造成的影響最小

上面描述的情況的圖解如下 ↓

刪除還存在一些其他的情況,比如下面這種情況↓

對於這種情況直接將30提升到25即可,接下來看一下看php的程式碼實現:

public function delete($root, $data)
{
        if (!$root) {
                return null;
        }

        if ($root->data === $data) {
                if ($root->left) {
                        // 左轉
                        $node = $root->left;

                        $parent = $root;
                        $toward = 'left';

                        while ($node->right) {

                                $parent = $node;
                                $toward = 'right';

                                $node = $node->right;
                        }

                        $root->data = $node->data;

                        $parent->{$toward} = $this->delete($node, $node->data);

                } else {
                        return $root->right;
                }
        } elseif ($root->data > $data) {
                // 如果root的左孩子沒有被刪除,那就原樣返回回來, 如果被刪除了,那就找個孩子代替
                $root->left = $this->delete($root->left, $data);
        } else {
                $root->right = $this->delete($root->right, $data);
        }

        return $root;
}

由於php有記憶體回收機制,因此我們沒有辦法像c一樣直接去修改記憶體,所以這裡藉助遞迴的特性來解決這個問題 $root->left = $this->delete($root->left, $data); 做類似這樣一個處理,這可能會有些理解上的困難。但總歸還是能夠明白的~

除了遞迴解決外,也可以用下面這種辦法。
即定義一個parent和toward來做一個導向,這在上面的程式碼中也有體現。該方法更加適用於迭代處理

$parent = $root;
$toward = 'left';

while ($node->right) {

    $parent = $node;
    $toward = 'right';

    $node = $node->right;
}

更詳細的實習細節和呼叫示例請參考單元測試。

https://github.com/weiwenhao/algorithm/blo...

演算法實現

https://github.com/weiwenhao/algorithm/blo...

補充

由於php沒有像js一樣的字面量物件或者c一樣的struct。因此直接使用物件來表示樹中的結點

class BiTNode
{
    public $data;
    public $left;
    public $right;

    public function __construct($data, $left = null, $right = null)
    {
        $this->data = $data;
        $this->left = $left;
        $this->right = $right;
    }
}

在查詢的時候指出了,二叉查詢樹的查詢的時間複雜度並不是嚴格意義上的O(logn) 是因為有這樣的情況發生, 假設需要插入 12, 10, 9, 5, 4, 1這幾個資料,那麼我們會得到這樣一顆歪脖子樹


此時的時間複雜度儼然已經變成了O(n),不過對於這樣的問題自然已經有解決方案。下一節將會在AVL樹紅黑樹這兩種解決方案中選一種來BB~

當然二叉查詢樹依舊是各種樹的根基,還請認真理解。

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章