1. 程式人生 > >為什麼我要放棄javaScript資料結構與演算法(第八章)—— 樹

為什麼我要放棄javaScript資料結構與演算法(第八章)—— 樹

之前介紹了一些順序資料結構,介紹的第一個非順序資料結構是散列表。本章才會學習另一種非順序資料結構——樹,它對於儲存需要快速尋找的資料非常有用。

本章內容

  • 樹的相關術語
  • 建立樹資料結構
  • 樹的遍歷
  • 新增和移除書的節點
  • AVL 樹

第八章 樹

樹資料結構

樹是一種分層資料的抽象模型。現實生活中最常見的樹的典型例子就是家譜,或是公司的組織架構。如下圖所示。

樹的資料結構

樹的相關術語

一個樹結構包含一系列存在父子關係的節點。每個節點都有一個父節點(除了頂部的第一個節點)已經零個或者多個子節點:

樹的資料結構

位於樹頂部的節點叫做根節點(11)。它沒有父節點。樹中的每個元素都叫做節點,節點分成內部節點和外部節點。至少有一個子節點的節點稱為內部節點(7/5/9/12/13/20是內部節點)。沒有子元素的節點被稱為外部節點或者是葉節點(3/6/8/10/12/14/18/25 是葉節點)、

一個節點可以有祖先和後代,一個節點(除了根節點)的祖先包括父節點、祖父節點、增祖父節點等。一個節點的後代包括子節點、孫子節點、增孫子節點等。例如,節點5的祖先有節點7和節點11,後代有節點3和節點6.

有關樹的另外一個術語是子樹。子樹由節點和它的後代構成。例如,節點13/12和14 構成了上圖中的樹的一顆子樹。

節點的一個屬性是深度,節點的深度取決於它的祖先節點的數量。比如,節點3有三個祖先節點(5/7/11),它的深度為3

樹的高度取決於所有節點的深度的最大值。一顆樹也可以被分解成層級。根節點在第0層,它的子節點在第1層,以此類推。上圖中的樹的高度為3

二叉樹和二叉搜尋樹

二叉樹的節點最多隻能有兩個子節點:一個是左側子節點,另外一個右側子節點。這些定義有助於我們寫出更高效的從樹中插入、查詢和刪除節點的演算法。二叉樹在電腦科學中的應用非常廣泛。

二叉搜尋樹(BST)是二叉樹的一種,但是它只允許你在左側節點儲存(比父節點)小的值,在右側節點儲存(比父節點)大(或者等於)的值。

二叉搜尋樹將是我們在本章要研究的資料結構。

建立BinarySearchTree 類

首先,宣告它的結構:

function BinarySearchTree(){
    var Node = function(key){
        this.key = key;
        this.left = null;
        this.right = null;
    }
    var root = null;
}

下面展現了二叉搜尋樹結構的組織方式:

二叉搜尋樹組織方式

和連結串列一樣,將通過指標來表示節點之間的關係(術語稱其為邊)。在雙向連結串列中,每個節點包含兩個指標,一個指向下一個節點,一個指向上一個節點。對於樹,使用同樣的方式(也是使用兩個指標)。但是,一個指向左側子節點,另一個指向右側子節點。因此,將宣告一個 Node 類來表示的書的每個節點。不同於前面的章節,我們會將節點本身稱為鍵而不是節點或者是項。鍵是樹相關的術語種對節點的稱呼。

我們將會遵循第五章 LinkedList 類相同的模式,這表示也將宣告一個變數以控制此資料結構的第一個節點。在樹中,它不再是頭節點,而是根元素。

下面是將要在樹類中實現的方法

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

向樹中插入一個鍵

本章要實現的方法會比之前的要複雜很多,要用到遞迴。

this.insert = function(key){
    var newNode = new Node(key);
    if(root === null){
        root = newNode;
    }else{
        insertNode(root,newNode)
    }
}

要向樹中插入一個新的節點,要經歷三個步驟

  1. 是建立用來表示新節點的Node 類例項,只需要向建構函式傳遞我們想用來插入樹的節點值,它的左指標和右指標會由建構函式與自動設定為 null
  2. 驗證這個插入操作是否為一種特殊情況。這個特殊情況就是我們要插入的節點是樹的第一個節點。如果是,就將節點指向新節點。
  3. 如果不是,就要將節點加在非根節點的其他位置。這種情況下,需要一個私有的輔助函式
var insertNode = function(node,newNode){
    if(newNode.key < node.key){
        if(node.left === null){
            node.left = newNode;
        }else{
            insertNode(node.left,newNode)
        }
    }else{
        if(node.right === null){
            node.right = newNode;
        }else{
            insertNode(node.right,newNode)
        }
    }
}

insertNode 函式會幫助我們找到新節點應該插入的正確位置,下面是這個函式實現的步驟。

  1. 如果樹非空,需要找到插入新節點的位置,因此在呼叫 insertNode 方法時要通過引數傳入根書的根節點和要插入的節點。
  2. 如果新節點的鍵小於當前節點的鍵(當前節點就是根節點),那麼需要檢查當前節點的左側節點,需要通過遞迴呼叫 insertNode 方法繼續需要樹的下一層,在這裡,下次將要比較的節點就會是當前節點的左側節點。
  3. 如果節點的鍵大於當前節點的鍵,同時當前節點沒有右側子節點,就在那裡插入洗的節點。如果有右側子節點,同樣需要遞迴呼叫 insertNode 方法,但是要用來和新節點比較的節點將會是右側子節點。

樹的遍歷

遍歷一顆樹是指訪問樹的每個節點並對它們進行某種操作的過程。訪問樹的所有節點有三種方法:中序、先序和後序。

中序遍歷

中序遍歷是一種以上行順序訪問BST 所有節點的遍歷方式,也就是以最小和最大的順序訪問所有節點。中序遍歷的一種應用就是對數進行排序操作。實現:

this.inOrderTraverse = function(callback){
    inOrderTraverseNode(root,callback);
}

inOrderTraverse 方法接受一個回撥函式作為引數。回撥函式用來定義我們對遍歷到的每個節點進行的操作(這也叫做訪問者模式)。由於我們在BST 中最常見實現的演算法是遞迴,這裡使用一個私有的輔助函式,來接受一個節點和對應的回撥函式作為引數。

var inOrderTraverseNode = function(node, callback){
    if(node !== null){
        inOrderTraverseNode(node.left, callback);
        callback(node.key)
        inOrderTraverseNode(node.left, callback);
    }
}

要通過中序遍歷的方法遍歷一棵樹,首先要檢查以引數形式傳入的節點是否為 null (這就是停止遞迴繼續執行的判斷條件)

然後遞迴呼叫相同的函式來訪問左側子節點,或者對這個節點進行一些操作(callback),然後再訪問右側子節點。

測試。

const tree = new BinarySearchTree();
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);
tree.inOrderTraverse(printNode);
// 3 5 6 7 8 9 10 11 12 13 14 15 18 20 25

下面的圖描述了 inOrderTraverse 方法的訪問路徑

中序遍歷

先序遍歷

先序遍歷是以優於後代節點的順序訪問每個節點的。先序遍歷的一種應用就是列印一個結構化的文件。

實現:

this.preOrderTraverse = function(callback){
    preOrderTraverseNode(root,callback);            
}

preOrderTraverseNode 方法的實現

var preOrderTraverseNode = function(node, callback){
    if(node !== null){
        callback(node.key)          
        preOrderTraverseNode(node.left, callback);
        preOrderTraverseNode(node.right, callback);
    }
}

先序遍歷和中序遍歷的不同點是,先序遍歷會先訪問節點的本身,然後再訪問它的左側子節點,最後是右側子節點,而中序遍歷的執行順序是:先訪問左側子節點、接著節點本身,最後是右側子節點。

下面是測試結果

tree.preOrderTraverse(printNode);
// 11 7 5 3 6 9 8 10 15 13 12 14 20 18 25

下面的描述了 preOrderTraverse 方法的訪問路徑

先序遍歷

後序遍歷

後序遍歷是先訪問節點的後代節點,再訪問節點本身。後序遍歷的一種應用是計算一個目錄和它的子目錄中所有檔案所佔空間的大小。

實現:

this.postOrderTraverse = function(callback){
    postOrderTraverseNode(root,callback);
}

postOrderTraverseNode 方法的實現:

var postOrderTraverseNode = function(node, callback){
    if(node !== null){      
        postOrderTraverseNode(node.left, callback);
        postOrderTraverseNode(node.right, callback);
        callback(node.key)  
    }
}

這個例子中,後序遍歷會先訪問左側子節點,然後是右側子節點,最後是父節點。

下面是測試結果

tree.postOrderTraverse(printNode);
// 3 6 5 8 10 9 7 12 14 13 18 25 20 15 11

下圖描述了 postOrderTraverse 方法的訪問路徑

後序遍歷

搜尋樹中的值

在樹中,有三種經常執行的搜尋型別

  • 搜尋最大值
  • 搜尋最小值
  • 搜尋特定的值

搜尋最小值和最大值

我們使用下面的樹作為例項

例項

可以一眼發現樹最後一層最左側的節點的值為3,這是這棵樹最小的鍵,如果你再看一眼最右端的樹的節點,會發現是25,這是這棵樹中最大的鍵。

首先,我們來尋找樹的最小鍵的方法

this.min = function(){
    return minNode(root);
}

min 方法將會暴露給使用者,這個方法呼叫了 minNode方法。

var minNode = function(node){
    if(node){
        while(node && node.left !== null){
            node = node.left
        }
        return node.key
    }
    return null;
}

minNode 方法允許我們從樹中任意一個節點開始尋找最小的鍵。我們可以使用它來知道一顆樹或者它的子樹中最小的鍵。因為,我們呼叫 minNode 方法的時候傳入樹的根節點,因為我們想找到這棵樹的最小鍵。

在 minNode 內部,我們會遍歷樹的左邊,直到找到樹的最下層(最左端)

相似的方式,可以實現 max 方法

this.max = function(){
    return maxNode(root);           
}
var maxNode = function(node){
    if(node){
        while(node && node.right !== null){
            node = node.right
        }
        return node.key
    }
    return null;
}

測試

console.log('這棵樹的最大值:'+tree.max()); // 25
console.log('這棵樹的最小值:'+tree.min()); // 3

搜尋一個特定的值

實現

this.search = function(key){
    return searchNode(root,key)
}

var searchNode = function(node,key){
    if(node === null){
        return false;
    }
    if(key < node.key){
        return searchNode(node.left, key)
    }else if(key > node.key){
        return searchNode(node.right, key)
    }else{
        return true
    }
}

我們要做的第一件事就是宣告 search 方法。和 BST 中宣告的其他方法的模式相同,我們將會使用一個輔助函式。

searchNode 方法可以用來尋找一棵樹或者它的任意子樹中的一個特定的值。

在開始演算法前,先驗證作為傳入引數的node 是否為 null.是則證明要找的鍵沒有找到,返回 false.

如果傳入的不是 null,需要繼續驗證,如果要找的鍵比當前的節點小,那麼繼續在左側的子樹上搜索,反之在右側子節點開始繼續搜尋,否則就說明要找的鍵和當前節點的鍵相等,就返回 true來表示找到了這個鍵。

測試

console.log(tree.search(3)); // true
console.log(tree.search(28)); // false

移除一個節點

移除方法是最複雜的,實現

this.remove = function(key){
    root = removeNode(root,key);
}

這個方法接受要移除並且它呼叫了 removeNode 方法,傳入 root 和要移除的鍵作為引數。

removeNode 複製在於我們處理不同的執行場景,還是通過遞迴來實現。

var removeNode = function(node,key){
    if(node === null){
        return null;
    }
    if(key < node.key){
        node.left = removeNode(node.left,key)
        return node;
    }else if(key > node.key){
        node.right = removeNode(node.right,key)
        return node;
    }else{
        // 一個葉節點
        if(node.left === null && node.right === null){
            node = null;
            return node;
        }
        // 只有一個子節點的節點
        if(node.left === null){ 
            node = node.right
            return node;
        }else if(node.right === null){
            node = node.right;
            return node;
        }
        // 一個有兩個子節點的節點
        var aux = findMinNode(node.right);
        node.key = aux.key;
        node.right = removeNode(node.right,aux.key);
        return node;
    }
}

移除一個葉節點

第一種情況是該節點是一個沒有左側或者右側子節點的葉節點。在這種情況下,我們要做的就是給這個節點賦予 null 來移除它。但是學習了連結串列的實現後,我們知道僅僅賦予一個 null 值是不夠的,還需要處理指標。在這裡,這個節點沒有任何的子節點,但是它有一個父節點,需要通過返回 null 來將對應的父節點指標賦值 null (return node)。

現在節點的值已經是 null ,父節點指向它的指標也會接收這個值,這也是我們在函式中返回節點的值的原因。父節點總是會接受到函式的返回值、另外一個可行的方法就是將父節點和節點本身都作為引數傳入方法內部。

移除有一個左側或右側子節點的節點

移除有一個左側子節點或右側子節點的節點。在這種情況下,需要跳過這個節點,直接將父節點指向它的指標指向子節點。

如果這個節點沒有左側子節點,也就是說它有一個右側子節點。因為我們把對它的引用改為對它右側子節點的引用,並返回更新後的節點。如果這個節點沒有右側子節點,也是一樣——把對它的引用改為對它左側子節點的引用並返回更新後的值。

移除有兩個子節點的節點

最複雜的情況就是要移除的節點有兩個子節點——左側子節點和右側子節點。要移除有兩個子節點的節點,需要執行四個步驟

  1. 當找到了需要移除的節點後,需要找到它右邊子樹中最小的節點(它的繼承者 var aux = finMinNode(node.right))
  2. 然後,用它右側子樹中最小節點的鍵去更新這個節點的值(node.key = zux.key)。通過這一步,我們改變了這個節點的鍵,也就是它被移除了。
  3. 但是,這樣在樹中就有兩個擁有相同鍵的節點了,需要繼續把右側子樹中的最小節點移除,畢竟它已經被移至要移除的節點的位置了。
  4. 最後,向它的父節點返回更新後節點的引用。

findMinNode 方法的實現和 min 方法的實現方式是一樣的。唯一不同之處在於,在 min 方法中只返回鍵,而在 findMinNode 中返回了節點。

完整程式碼

var insertNode = function(node,newNode){
    if(newNode.key < node.key){
        if(node.left === null){
            node.left = newNode;
        }else{
            insertNode(node.left,newNode)
        }
    }else{
        if(node.right === null){
            node.right = newNode;
        }else{
            insertNode(node.right,newNode)
        }
    }
}

var inOrderTraverseNode = function(node, callback){
    if(node !== null){
        inOrderTraverseNode(node.left, callback);
        callback(node.key)
        inOrderTraverseNode(node.right, callback);
    }
}   

var preOrderTraverseNode = function(node, callback){
    if(node !== null){
        callback(node.key)          
        preOrderTraverseNode(node.left, callback);
        preOrderTraverseNode(node.right, callback);
    }
}   

var postOrderTraverseNode = function(node, callback){
    if(node !== null){      
        postOrderTraverseNode(node.left, callback);
        postOrderTraverseNode(node.right, callback);
        callback(node.key)  
    }
}
var minNode = function(node){
    if(node){
        while(node && node.left !== null){
            node = node.left
        }
        return node.key
    }
    return null;
}   
var maxNode = function(node){
    if(node){
        while(node && node.right !== null){
            node = node.right
        }
        return node.key
    }
    return null;
}
var searchNode = function(node,key){
    if(node === null){
        return false;
    }
    if(key < node.key){
        return searchNode(node.left, key)
    }else if(key > node.key){
        return searchNode(node.right, key)
    }else{
        return true
    }
}

var removeNode = function(node,key){
    if(node === null){
        return null;
    }
    if(key < node.key){
        node.left = removeNode(node.left,key)
        return node;
    }else if(key > node.key){
        node.right = removeNode(node.right,key)
        return node;
    }else{
        // 一個葉節點
        if(node.left === null && node.right === null){
            node = null;
            return node;
        }
        // 只有一個子節點的節點
        if(node.left === null){ 
            node = node.right
            return node;
        }else if(node.right === null){
            node = node.right;
            return node;
        }
        // 一個有兩個子節點的節點
        var aux = findMinNode(node.right);
        node.key = aux.key;
        node.right = removeNode(node.right,aux.key);
        return node;
    }
}

var findMinNode = function(node){
    while(node && node.left !== null){
        node = node.left;
    }
    return node;
}

var heightNode = function(node){
    if(node === null){
        return -1;
    }else{
        return Math.max(heightNode(node.left),heightNode(node.right))+ 1;
    }
}

var printNode = function(value){
    console.log(value);
}

function BinarySearchTree(){
    var Node = function(key){
        this.key = key;
        this.left = null;
        this.right = null;
    }
    var root = null;
    this.insert = function(key){
        var newNode = new Node(key);
        if(root === null){
            root = newNode;
        }else{
            insertNode(root,newNode)
        }
    }
    this.search = function(key){
        return searchNode(root,key)
    }

    this.inOrderTraverse = function(callback){
        inOrderTraverseNode(root,callback);
    }
    this.preOrderTraverse = function(callback){
        preOrderTraverseNode(root,callback);            
    }
    this.postOrderTraverse = function(callback){
        postOrderTraverseNode(root,callback);
    }
    this.min = function(){
        return minNode(root);
    }
    this.max = function(){
        return maxNode(root);           
    }
    this.remove = function(key){
        root = removeNode(root,key);
        console.log(root);
    }
    this.deep = function(){
        return heightNode(root);
    }
}

自平衡樹

BST 存在一個問題:取決於你新增的節點數,樹的一條邊可能會非常深,也就是說,樹的一條分支會有很多層,而其他的分支卻只有幾層。

為了解決這個問題,有一種樹叫做 Sdelson-Velskii-Land 樹(AVL 樹)。一種自平衡的二叉搜尋樹,意思是任何一個節點左右兩側子樹的高度之差最多為1,也就是說這種樹會在新增和移除節點時儘量試著成為一顆完全的樹。

這裡不做具體深入實現,儘管AVL樹是自平衡的,其插入和移除節點的效能並不總是最好的。更好的選擇是紅黑樹。紅黑樹可以高效有序的遍歷其節點。有興趣的請自行百度

小結

這章介紹了廣泛使用的基本樹資料結構——二叉搜尋樹中新增、移除和搜尋項的演算法。同樣介紹了訪問樹中每個節點的三種遍歷方式——中序、先序、後序遍歷

下一章,將學習圖的基本概念,也是一種非線性的資料結構。