樹-BST基本實現

致于数据科学家的小陈發表於2024-08-12

之前的陣列, 棧, 連結串列, 佇列等都是順序資料結構, 這裡來介紹一個非順序資料結構, 樹. 樹在處理有層級相關的資料時非常有用, 還有在儲存資料如資料庫查詢實現等場景也是高頻使用.

作為一種分層資料的抽象模型, 在現實中最常見的例子是族譜, 公司組織架構圖等.

我個人覺得樹, 圖等非順序的資料結構是不太好直觀理解的, 它的實現都需要用到函式的遞迴. 就關於函式遞迴呼叫棧搞不清楚的話, 那也就很難理解程式碼了.

樹的基本內容包括:

  • 樹相關的術語
  • 建立二叉搜尋樹
  • 樹的三種遍歷
  • 新增和移除節點
  • AVL平衡樹

樹的核心術語

  • 節點: 樹的每個元素都稱為 "節點". 節點的祖先包括父節點, 祖父節點等; 節點的後代包括子節點, 孫節點等
  • 根節點: 一顆樹頂部最上層有個唯一的根節點元素, 稱為根節點
  • 內/外部節點: 至少有一個子節點的稱為 "內部節點", 否則稱 "外部節點 或 葉節點"
  • 邊: 節點和節點之間的 "連線" 成為邊, 類似連結串列中的指標
  • 子樹: 節點和其子節點的集合, 構成一顆子樹
  • 節點深度: 節點深度指其祖先節點的數量
  • 樹高度: 所有節點深度的最大值

搜尋二叉樹

二叉樹中的節點最多隻能有兩個子節點: 左子節點 和 右子節點.

這種只有2個路徑的結構可以讓我們寫出更高效的資料插入, 查詢, 刪除節點的演算法. 比如二分法, 關係型資料庫的查詢設計等, 就使用非常高頻.

二叉搜尋樹 (BST) 是二叉樹的一種, 強制要求:

  • 比父節點小的值, 存左側子節點
  • 比父節點大的值, 存右側子節點
// 節點類
class Node {
    constructor(key) {
        this.key = key     // 節點值
        this.left = null   // 左節點指標
        this.right = null  // 右節點指標
    }
}

// 二叉搜尋樹類
class BSTtree {
    constructor() {
        this.root = null  // 根節點物件
    }
}

主要實現的方法是有:

  • insert(key): 向樹中插入一個新的鍵
  • search(key): 搜尋 key, 如果存在返回 true, 否則返回 false
  • inOrderTraverse(): 中序遍歷所有節點
  • preOrderTraverse(): 先序遍歷所有節點
  • postOrderTraverse(): 後序遍歷所有節點
  • min() : 返回樹中最小值
  • max(): 返回樹中最大值
  • remove(key): 從樹中移除某個鍵

BST - 插入鍵

這裡我們先來實現一個輔助的方法 insertNode(node, key) .

它的作用是幫我們找到新節點應該插入到哪個最合適的層級位置, 因此它是一個 遞迴函式.

**當插入的 key 小於 node.key 時: **

  • 如果 node.left 是葉子節點時, 則直接插入
  • 否則就遞迴往左下層子樹查詢

當插入的 key 大於 node 時:

  • 如果 node.right 是葉子節點時, 則直接插入
  • 否則就遞迴往右下層子樹查詢
// 從某節點開始, 插入新的 key
insertNode(node, key) {
    if (key < node.key) {
        // 左子樹處理
        if (node.left == null) {
            node.left = new Node(key)
        } else {
            this.insertNOde(node.left, key)
        }
    } else {
        // 右子樹處理
        if (node.right == null) {
            node.right = new Node(key)
        } else {
            this.insertNode(node.right, key)
        }
    }
}

這樣插入的邏輯就是變成從根節點開始進行判斷:

  • 根節點無值, 則新增的 key 節點就是根節點
  • 根節點有值, 則將根節點開始進行 insertNode(root, key) 找到合適位置插入
// 插入一個 key
insert(key) {
    if this.root == null {
        // 空樹的話那就是根節點
        this.root = new Node(key)
    } else {
        // 有值則找到合適位置插入
        this.insertNode(this.root, key)
    }
}

二叉樹遍歷

二叉樹的遍歷方式基本就3種, 先序遍歷, 後序遍歷, 中序遍歷.

先序步驟: 根 => 左 => 右

應用: 複製樹, 序列化樹, 字首表示式解析.

中序步驟:左 => 根 => 右

應用: 排序, 中綴表示式解析 (a + b * c)

後序步驟:左 => 右 => 根

應用: 釋放記憶體, 字尾表示式解析 (棧), 修改樹.

      1
     / \
    2   3
   /   / \
  4   5   6

先序, 遍歷的結果是: 1, 2, 4, 3, 5, 6

後序, 遍歷的結果是: 4, 2, 5, 6, 3, 1

中序, 遍歷的結果是: 4, 2, 1, 5, 3, 6

先序遍歷

即按照 根 -> 左 -> 右 的順序, 先會訪問節點本身(父節點), 然後再訪問左側節點, 最後右側節點.

這裡對於節點訪問處理可以傳遞一個通用的 回撥函式 callback , 同時用一個輔助方法 preOrderTraverseNode(node, callback) 來接受一個節點和回撥函式.

// 輔助方法: 節點遍歷
preOrderTraverseNode(node, callback) {
    // 基本情況
    if (node == null) return 
    
    // 遞迴情況: 先訪問自身
    callback(node.key)
    // 再訪問左樹, 再是右樹
    this.preOrderTraverseNode(node.left, callback)
    this.preOrderTraverseNode(node.right, callback)
}

// 先序遍歷: 從根節點開始輸入
preOrderTraverse(callback) {
    this.preOrderTraver(this.root, callback)
}

中序遍歷

即按照 左 -> 根 -> 右 的順序, 先訪問子節點(葉子), 然後再訪問根節點, 最後右側節點.

同樣節點訪問處理可以傳遞一個通用的 回撥函式 callback , 同時用一個輔助方法 inOrderTraverseNode(node, callback) 來接受一個節點和回撥函式.

// 輔助方法: 節點遍歷
inOrderTraverseNode(node, callback) {
    // 基本情況
    if (node == null) return 
    
    // 遞迴情況:  先左側到葉子節點
    this.inOrderTraverse(node.left, callback)
    // 訪問節點
    callback(node.key)
    // 再是右側節點
    this.inOrderTraverse(node.right, callback)
}

// 中序遍歷
inOrderTraverse(callback) {
    this.inOrderTraverse(this.root, callback)
}

後序遍歷

即按照 左 -> 右 -> 根 的順序, 先訪問子節點(葉子), 然後再訪問根節點, 最後右側節點.

同樣節點訪問處理可以傳遞一個通用的 回撥函式 callback , 同時用一個輔助方法 postOrderTraverseNode(node, callback) 來接受一個節點和回撥函式.

// 輔助方法: 節點遍歷
postOrderTraverseNode(node, callback) {
    // 基本情況
    if (node == null) return 
    
    // 遞迴情況:
    this.postOrderTraverse(node.left, callback)
    this.postOrderTraverse(node.right, callback)
    // 訪問節點
    callback(node.key)
    
}

// 後序遍歷
postOrderTraverse(callback) {
    this.postOrderTraverse(this.root, callback)
}

最大最小值

// BST 樹的最小值
min() {
    return this.minNode(this.root)
}

minNode(node) {
    let current = node 
    // 因為是 BST 樹, 小的值一定在左側, 從樹一層層往下到底就行
    while (current != null && current.left != null) {
        current = current.left
    }
    return current
}


// BST 樹的最大值
max() {
    return this.maxNode(this.root)
}

maxNode(node) {
    let current = node 
    while (current != null && current.right != null) {
        current = current.right
    }
    return current.key
}

樹中查詢值

因為是 BST 樹, 左側深度遞迴查詢就是越來越小, 右側深度遞迴查詢的值就越來越大, 否則就基本條件, 沒有找到嘍.

// 查詢值
searchNode(node, key) {
    // 基本情況, 沒有找到就返回 false
    if (node == null) return false 
    
    // 若 key 小於當前節點, 則繼續左側深度遞迴搜尋
    if (key < node.key) return this.searchNode(node.left, key)
    
    // 若 key 大於當前節點, 則繼續右側深度遞迴搜尋
    else if (key > node.key) return this.searchNode(node.right, key)
    
    // 找到就返回
    else return false 
}

樹中移除值

這個算是非常複雜的邏輯了, 既要分析不同場景, 不同場景下又要進行遞迴, 就很難搞.

首先要做的就是要透過對子樹分析, 來確定將被刪節點的位置,

然後根據將要被刪的節點位置, 分情況進行討論處理.

* 如果不為 null,我們需要在樹中找到要移除的鍵

* 如果要找的鍵比當前節點的值小, 就沿著樹的左邊找到下一個節點

* 如果要找的鍵比當前節點的值大, 就那麼就沿著樹的右邊找到下一個節點

也就是說我們要分析它的子樹。如果我們找到了要找的鍵(鍵和 node.key 相等),就需要處理三種不同的情況:

  • 移除一個葉節點 (無腿)
  • 移除一個有左側 或者 右側子節點的節點 (一條腿)
  • 移除有2個子節點的節點 (2條腿)
// 移除BST樹中的節點
removeNode(node, key) {
    if (node == null) return null 
    
    if (key < node.key) {
        // 從左子樹深度遞迴查詢
        node.left = this.removeNode(node.left, key)
        return node
        
    } else if (key > node.key) {
        // 從右子樹深度遞迴查詢
        node.right = this.removeNode(node.right, key)
        return node 
        
    } else {
        // 找到了 key 的位置, 即 key == node.key 時候, 分3中情況討論
        
        // 情況-01: 移除一個葉節點
        if (node.left == null && node.right == null) {
            node = null
            // 透過返回 null 將父節點的指標指向 null
            return node  
        }
        
        // 情況-02: 移除一個有左或右節點的節點(1條腿)
        // 跳過子節點, 直接將父節點指向其子節點
        if (node.left == null) {
            node = node.right
            return node
            
        } else if (node.right == null) {
            node = node.left
            return node
        }
        
        // 情況03 移除有兩個子節點的節點 (2條腿)
        
        // a. 找到它右子樹中最小節點, 下下層元素 grandson
        // b. 用 grandson 去替換掉被刪節點的值 (改了鍵, 則移除了)
        // c. 繼續將右側子樹的最小節點移除 (刪掉重複鍵節點)
        // d. 向父節點返回更新的引用
        const grandson = this.minNode(node.right)
        node.key = grandson.key
        node.right = this.removeNode(node.right, grandson.key)
        return node 
    }
}

測試

// 測試
const tree = new BSTtree()
tree.insert(11)
tree.insert(7);
tree.insert(15);
tree.insert(5);
tree.insert(3);
tree.insert(9);
tree.insert(8);
tree.insert(10);
tree.insert(13);
tree.insert(12);
tree.insert(14);
tree.insert(20);
tree.insert(18);
tree.insert(25);
tree.insert(6);


// 中序遍歷
function callback(value) {
  console.log('callback-log: ', value);
}

// 中序遍歷
console.log('中序遍歷:');
tree.inOrderTraverse(callback)
console.log();

// 先序遍歷
console.log('先序遍歷:');
tree.preOrderTraverse(callback)
console.log();
// 後序遍歷
console.log('後序遍歷:');
tree.postOrderTraverse(callback)

// 樹的最小值
console.log();
console.log('樹的最小值是: ', tree.min());

// 樹的最大值
console.log();
console.log('樹的最大值是: ', tree.max());

// 查詢值
console.log();
console.log(tree.search(1));
console.log(tree.search(8));

// 刪除值
console.log('刪除值 25', tree.remove(25));
tree.inOrderTraverse(callback)

console.log('刪除值 15', tree.remove(15));
tree.inOrderTraverse(callback)

輸出:

PS F:\algorithms> node .\bst_tree.js
中序遍歷:
callback-log:  3
callback-log:  5
callback-log:  6
callback-log:  7
callback-log:  8
callback-log:  9
callback-log:  10
callback-log:  11
callback-log:  12
callback-log:  13
callback-log:  14
callback-log:  15
callback-log:  18
callback-log:  20
callback-log:  25

先序遍歷:
callback-log:  11
callback-log:  7
callback-log:  5
callback-log:  3
callback-log:  6
callback-log:  9
callback-log:  8
callback-log:  10
callback-log:  15
callback-log:  13
callback-log:  12
callback-log:  14
callback-log:  20
callback-log:  18
callback-log:  25

後序遍歷:
callback-log:  3
callback-log:  6
callback-log:  5
callback-log:  8
callback-log:  10
callback-log:  9
callback-log:  7
callback-log:  12
callback-log:  14
callback-log:  13
callback-log:  18
callback-log:  25
callback-log:  20
callback-log:  15
callback-log:  11

樹的最小值是:  Node { key: 3, left: null, right: null }

樹的最大值是:  25

false
true
刪除值 25 undefined
callback-log:  3
callback-log:  5
callback-log:  6
callback-log:  7
callback-log:  8
callback-log:  9
callback-log:  10
callback-log:  11
callback-log:  12
callback-log:  13
callback-log:  14
callback-log:  15
callback-log:  18
callback-log:  20

刪除值 15 
callback-log:  3
callback-log:  5
callback-log:  6
callback-log:  7
callback-log:  8
callback-log:  9
callback-log:  10
callback-log:  11
callback-log:  12
callback-log:  13
callback-log:  14
callback-log:  18
callback-log:  20

PS F:\algorithms>

至此, 關於樹, 二叉搜尋樹的基本實現就到這裡啦.

相關文章