1. 程式人生 > 實用技巧 >資料結構裡面每個元素叫做節點, 都是葉子節點

資料結構裡面每個元素叫做節點, 都是葉子節點

二叉樹基礎

樹(Tree)

樹是一種非線性表結構,比線性表的資料結構要複雜的多:

樹的種類
樹、二叉樹
二叉查詢樹
平衡二叉查詢樹、紅黑樹
遞迴樹

“樹”的特徵

“樹”這種資料結構裡面每個元素叫做“節點”;用來連線相鄰節點之間的關係叫做“父子關係”。

比如下面這幅圖,A節點就是B結點的父節點,B節點是A結點的子節點。B、C、D這三個結點的父節點是同一個節點,所以他們之間互稱為兄弟節點。沒有父節點的節點叫根節點,也就是圖中的節點E。沒有子節點的節點叫做葉子節點

或者葉節點,比如如中的
G、H、I、J、K、L 都是葉子節點。

高度(Height)深度(depth)層(Level)的定義:

節點的高度 = 節點到葉子節點的最長路徑(邊數)
節點的深度 = 根節點到這個節點所經歷的邊的個數
節點的層數 = 節點的深度 + 1
樹的高度 = 根節點的高度

“高度”是從下往上度量,從最底層開始計數,計量的起點是0。

“深度”是從上往下度量,從根節點開始度量計數起點也是0。

“層數”跟深度的計算類似,不過計數起點是1。

二叉樹(Binary Tree)

二叉樹的每個節點最多有兩個子節點,分別是左子節點和右子節點。二叉樹中,有兩種比較特殊的樹,分別是滿二叉樹和完全二叉樹。滿二叉樹又是完全二叉樹的一種特殊情況。

二叉樹可以用鏈式儲存,也可以用陣列順序儲存。陣列順序儲存的方式比較適合完全二叉樹,其他型別的二叉樹用陣列儲存會比較浪費儲存空間。除此之外,二叉樹裡非常重要的操作就是前、中、後序遍歷操作,遍歷的時間複雜度是O(n),需要用遞迴程式碼來實現。

二叉樹是樹的一種,特點是每個節點最多有兩個子節點,分別是左子節點右子節點
不過,二叉樹有的節點只有左子節點,有的節點只有右子節點:

上圖編號2的二叉樹中,葉子節點全都在最底層,除了葉子節點之外,每個節點都有左右兩個子節點,這種二叉樹就叫做滿二叉樹

編號3的二叉樹中,葉子節點都在最底下兩層,最後一層的葉子節點都靠左排列,並且除了最後一層,其他層的節點個數都要達到最大,這種二叉樹叫做完全二叉樹

完全二叉樹

完全二叉樹和非完全二叉樹的區別:

最後一層的葉子節點靠左排列的才叫完全二叉樹,如果靠右排列就不能叫完全二叉樹了。

儲存一棵樹有兩種方式,一種是基於指標或者引用的二叉鏈式儲存法,一種是基於陣列的順序儲存法。

鏈式儲存法中每個節點有三個欄位,其中一個儲存資料,另外兩個是指向左右子節點的指標。從根節點開始可以通過左右子節點的指標,把整棵樹都串起來。這種儲存方式我們比較常用。大部分二叉樹都是通過這種結構來實現的。

基於陣列的順序儲存法:把根節點儲存在下標i = 1的位置,那左子節點儲存在下標2 * i的位置,右子節點儲存在2 * i + 1 = 3的位置。以此類推,B節點的左子節點儲存在2 * i = 2 * 2 = 4的位置,右子節點儲存在2 * i + 1 = 2 * 2 + 1 = 5的位置。

如果節點X儲存在陣列中下標為i的位置,左子節點的下標為2 * i,右子節點為2 * i + 1.反過來,下標為 i / 2的位置儲存就是它的父節點。通過這種方式,只要知道根節點儲存的位置(一般情況下,為了方便計運算元節點,根節點會儲存在下標為1的位置),就可以通過下標計算,把整棵樹都串起來。

一顆完全二叉樹僅僅“浪費”了一個下標為0的儲存位置,如果是非完全二叉樹,會浪費比較多的陣列儲存空間。

如果某棵二叉樹是一棵完全二叉樹,用陣列儲存無疑是最節省記憶體的一種方式。因為陣列儲存方式並不需要儲存額外的左右子節點的指標。

堆其實就是一種完全二叉樹,最常用的儲存方式是陣列。

二叉樹的遍歷

將二叉樹所有節點遍歷打印出來有三種方式,前序遍歷、中序遍歷後序遍歷。其中前、中、後序,表示的是節點與它的左右子樹節點遍歷列印的先後順序。

  • 前序遍歷是指,對於樹中的任意節點來說,先列印這個節點,然後再列印它的左子樹,最後列印它的右子樹。
  • 中序遍歷是指,對於書中的任意節點,先列印它的左子樹,然後再列印它本身,最後列印它的右子樹。
  • 後序遍歷是指,對於樹中的任意節點來說,先列印它的左子樹,然後再列印它的右子樹,最後列印這個節點本身。

實際上,二叉樹的前、中、後序遍歷就是一個遞迴的過程。比如,前序遍歷,其實就是先列印根節點,然後再遞迴列印左子樹,最後遞迴地列印右子樹。

寫遞推公式的關鍵就是,如果要解決問題A,就假設子問題B、C已經解決,然後再來看如何利用B、C來解決A。前、中、後序遍歷的遞推公式:

前序遍歷的遞推公式
preOrder(r) = print r -> preOrder(r -> left) -> preOrder(r -> right)

中序遍歷的遞推公式
inOrder(r) = inOrder(r -> left) -> print r -> inOrder(r -> right)

後序遍歷的遞推公式
postOrder(r) = postOrder(p -> left) -> postOrder(r -> right) -> r

java虛擬碼

void preOrder(Node* root){
    if (root == null) return;
    print root 
    preOrder(root -> left);
    preOrder(root -> right)
}

void inOrder(Node* root){
    if (root == null) return;    
    inOrder(root -> left);
    print root;
    inOrder(root -> right);
}

void postOrder(Node* root){
    if (root == null) return;
    postOrder(root -> left);
    psotOrder(root -> right);
    print root;
} 

二叉樹遍歷的時間複雜度
遍歷過程中每個節點最多會被訪問兩次,所以遍歷操作的時間複雜度跟節點的個數n成正比,二叉樹遍歷的時間複雜度是O(n)。

按層次遍歷二叉樹

除了前、中、後序三種二叉樹遍歷方式外還有按層遍歷這種遍歷方式。

實現思路:
按照廣度優先的遍歷演算法的思路,引入一個佇列,根節點現如佇列,然後開始從佇列頭部取元素,每取一個元素則先列印當前元素,然後依次將左右子節點加入佇列,若左子節點或右子節點為空則跳過此步。

python實現程式碼:

from collections import deque

# 層級遍歷
def layer_order(root: TreeNode):
    if not root: return
    queue = deque([root])
    while queue:
        e: TreeNode = queue.popleft()
        yield e.val
        if e.left: queue.append(e.left)
        if e.right: queue.append(e.right)

給定一個二叉樹,返回其按層次遍歷的節點值。(即逐層地,從左到右訪問所有節點)

例如:給定二叉樹:[3, 20, null, null, 15, 7]

    3
   / \
  9  20
    /  \
   15   7

返回其層次遍歷結果:

[
  [3],
  [9, 20]
  [15, 7]
]

python程式碼實現

def level_order(root: TreeNode):
    levels = []
    if not root:
        return levels
    level = 0
    queue = deque([root])
    while queue:
    levels.append([])
    for i in range(len(queue)):
        node = queue.popleft()
        levels[level].append(node.val)
        if node.left:
            queue.append(node.left)
        if node.right:
            queue.append(node.right)
    level += 1
return levels

二叉查詢樹(Binary Search Tree)

二叉查詢樹是二叉樹中最常用的一種型別,也叫二叉搜尋樹。二叉查詢樹支援動態資料集合的快速插入、刪除、查詢操作。

二叉查詢樹要求,在樹中任意一個節點,其左子樹中的每個節點的值都小於這個節點的值,而右子樹每個節點的值都大於這個節點的值:

1.二叉查詢樹的查詢操作

先取根節點,如果它等於要查詢的資料就返回。如果要查詢的資料比根節點的值小,那就在左子樹中遞迴查詢,如果要查詢的資料比根節點的值大,那就在右子樹中遞迴查詢。

java程式碼實現:

public class BinarySearchTree{
    private Node tree;

    public Node find(int data){
        Node p = tree;
        while(p != null){
            if (data < p.data){
                p = p.left;
            } else if (data > p.data){
                p = p.right;
            } else {
                return p;
            }
        }
        return null;
    }
    public static class Node{
        private int data;
        private Node left;
        private Node right
    
        public Node(int data){
            this.data data;
        }
     }
}

2.二叉查詢樹的插入操作

二叉查詢樹的插入過程需要從根節點開始,依次比較要插入的資料和節點的大小關係。

如果要插入的資料比節點的資料大,並且節點的右子樹為空,就將資料直接插到右子節點的位置;如果不為空,就再遞迴遍歷右子樹,查詢插入位置。同理,如果要插入的資料比節點數值小,並且節點的左子樹為空,就將新資料插入到左子節點的位置;如果不為空,就再遞迴遍歷左子樹,查詢插入位置。

java程式碼實現:

public void insert(int data){
    if (tree == null){
        tree = new Node(data);
        return;
    }
    
    Node p = tree;
    while(p != null){
        if (data > p.data){
            if (p.right == null){
                p.right = new Node(data);
                return;
            }
            p = p.right;
        } else {
            if (p.left == null){
                p.left = new Node(data);
                return;
            }
            p = p.left;
        }
    }
}

3.二叉查詢樹的刪除操作

針對要刪除節點的子節點個數的不同需要分2中情況來處理。

如果要刪除的節點只有一個子節點(只有左子節點或者右子節點)或沒有子節點(左右子節點均為null),只需要將要刪除的父節點的指標指向要刪除節點的子節點。比如下圖中刪除節點55、13。

如果要刪除的節點有兩個子節點。需要找到這個節點的右子樹的最小節點,把它替換到要刪除的節點上。然後再按照上面方法刪除掉這個最小節點。比如下圖中的刪除節點18。(用左子樹的最大節點進行替換也可以)

java實現程式碼:

public void delete(int data){
    // p指向要刪除的節點,初始化指向根節點
    Node p = tree;
    // pp記錄的是p的父節點
    Node pp = null;
    while (p != null && p.data != data){
        pp = p;
        if (data > p.data) {
            p = p.right;
        } else {
            p = p.left;
        }
    }
    // 沒有找到
    if (p == null){
        return;
    }
    
    //要刪除的節點有兩個子節點
    if (p.left != null && p.right != null){
        //查詢右子樹中最小的節點
        Node minP = p.right;
        // minPP表示minP的父節點
        Node minPP = p;
        
        while (minP.left != null){
            minPP = minP;
            minP = p.left;
        }
        //將minP的資料替換到p中
        p.data = minP.data;
        //下面就變成了刪除minP了
        p = minP;
        pp = minPP;
    }
    
    // 刪除節點是葉子節點或者僅有一個子節點
    // p的子節點
    Node child;
    if (p.left != null){
        child = p.left;
    } else if (p.right != null){
        child = p.right;
    } else {
        child = null;
    }
    
    //刪除的是根節點
    if (pp == null) {
        tree = child;
    } else if (pp.left = p){
        pp.left = child;
    } else {
        pp.right = child;
    }
}

關於二叉查詢樹的刪除操作,最簡單的方法是單純將要刪除的節點標記為“已刪除”並不真正從樹中將這個節點去掉。這樣原本刪除的節點還需要儲存在記憶體中,缺點是比較浪費記憶體空間。

4.二叉查詢樹的其他操作

二叉查詢樹中還可以支援快速地查詢最大節點和最小節點、前驅結點和後繼結點

二叉查詢樹也叫做二叉排序樹,中序遍歷二叉查詢樹,可以輸出有序的資料序列,時間複雜度是O(n)

Python程式碼實現:

def find_min(self) -> Optional[TreeNode]:
    if self.tree is Node: return None
    p = self.tree
    while p.left:
        p = p.left
    return p
    
def find_max(self) -> Optional[TreeNode]:
    if self.tree is None: return None
    p = self.tree
    while p.right:
        p = p .right
    return p
    
def _in_order(self, root: Optional[TreeNode]):
    if root:
        yield from self._in_order(root.left)
        yield root.data
        yield from self._in_order(root.right)
        
def in_order(self) -> list:
    if self.tree is Node:
        return []
    return list(self._in_order(self.tree))

二叉查詢樹的Python實現程式碼

from collections import deque
from typing import Optional, List


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


class BinarySearchTree:
    def __init__(self, val_list=None):
        if val_list is None:
            val_list = []
        self.tree: Optional[TreeNode] = None
        for n in val_list:
            self.insert(n)

    def find(self, data):
        p = self.tree
        while p and p.data != data:
            p = p.left if data < p.data else p.right
        return p

    def insert(self, data):
        if not self.tree:
            self.tree = TreeNode(data)
            return
        pp = None
        p = self.tree
        while p:
            pp = p
            p = p.left if p.data > data else p.right
        if pp.data > data:
            pp.left = TreeNode(data)
        else:
            pp.right = TreeNode(data)

    def delete(self, data):
        p: Optional[TreeNode] = self.tree  # p指向要刪除的節點,初始化指向根節點
        pp: Optional[TreeNode] = None  # pp記錄的是p的父節點
        while p and p.data != data:
            pp = p
            p = p.right if p.data < data else p.left
        if not p: return  # 沒有找到

        if p.left and p.right:  # 查詢右子樹中最小節點
            min_p = p.right  # 記錄右子樹的最小節點
            min_pp = p  # minPP表示minP的父節點
            while min_p.left:
                min_pp = min_p
                min_p = min_p.left
            p.data = min_p.data  # 替換資料
            p, pp = min_p, min_pp  # p指向要刪除的min_p
        #  刪除節點是葉子節點或者僅有一個子節點
        child: Optional[TreeNode] = p.left if p.left else p.right
        if not pp:
            self.tree = child  # 刪除的是根節點
        elif pp.left == p:
            pp.left = child
        else:
            pp.right = child

    def find_min(self) -> Optional[TreeNode]:
        if self.tree is None: return None
        p = self.tree
        while p.left:
            p = p.left
        return p

    def find_max(self) -> Optional[TreeNode]:
        if self.tree is None: return None
        p = self.tree
        while p.right:
            p = p.right
        return p

    def _in_order(self, root: Optional[TreeNode]):
        if root:
            yield from self._in_order(root.left)
            yield root.data
            yield from self._in_order(root.right)

    def in_order(self) -> list:
        if self.tree is None:
            return []
        return list(self._in_order(self.tree))

    def __repr__(self):
        return str(self.in_order())

    def __iter__(self):
        return self._in_order(self.tree)

    def draw_tree(self):
        if not self.tree:
            return
        # level = 0
        queue = deque([self.tree])
        while queue:
            length = len(queue)
            if set(queue) == {None}: return
            for i in range(length):
                node = queue.popleft()
                if node:
                    print(node.data, end="")
                    queue.append(node.left)
                    queue.append(node.right)
                else:
                    print(None, end="")
                    queue.append(None)
                    queue.append(None)
                if i != length - 1:
                    print(",", end="")
                else:
                    print()


if __name__ == '__main__':
    nums = [4, 2, 10, 6, 1, 7, 3]
    bst = BinarySearchTree(nums)
    print(bst)
    bst.draw_tree()
    # 插入
    bst.insert(5)
    print(bst)

    print(bst.find(2).data)

    # 刪除
    bst.delete(6)
    print(bst)
    bst.delete(4)
    print(bst)
    bst.draw_tree()
    # min max
    print(bst.find_max().data)
    print(bst.find_min().data)

支援重複資料的二叉查詢樹

在實際的軟體開發中,在二叉查詢樹中儲存的,是一個包含很多欄位的物件。利用物件的某個欄位作為鍵值來構建二叉查詢樹,物件中的其他欄位叫做衛星資料。

如果儲存的兩個物件鍵值相同的兩種解決方法:

  1. 二叉查詢樹中每一個結點儲存連結串列或支援動態擴容的陣列,把值相同的資料儲存在同一個節點上。

  2. 每個節點仍然只儲存一個數據。在查詢插入位置的過程中,如果碰到了一個節點的值,與要插入資料的值相同,就把這個新插入的資料當作大於這個節點的值來處理,放到這個節點的右子樹。

查詢資料的時候,遇到值相同的節點並不停止查詢操作,而是繼續在右子樹中查詢,直到遇到葉子節點,才停止。這樣就可以把鍵值等於要查詢的值的所有節點都找出來。

對於刪除操作也需要先查詢每個要刪除的節點,然後再一次刪除。

二叉查詢樹的時間複雜度分析

二叉查詢樹的形態各式各樣。下圖中同一組資料構造的三種二叉查詢樹,他們的查詢、插入、刪除操作的執行效率是不一樣的。

不管操作是插入、刪除還是查詢,時間複雜度其實都跟樹的高度成正比,也就是O(height)

上圖中,第一種二叉查詢樹,根節點的左右子樹極度不平衡,已經退化成了連結串列,所以查詢的時間複雜度就變成了O(n)。

最理想的情況下,二叉查詢樹是一顆完全二叉樹(或滿二叉樹),插入、刪除、查詢操作的時間複雜度是O(logn)。

樹的高度就等於最大層數減1,包含n個節點的滿二叉樹中,第一層包含1個節點,第二層包含2個節點,第三層包含4個節點,一次類推,下面一層節點個數是上一層的2倍,第k層包含的節點個數就是2^(k - 1)。

對於完全二叉樹來說,最後一層的節點個數在1到2^(k - 1)個之間(假設最大層數是K)。n滿足這樣一個關係:

1 + 2 + 4 + 8 + ... + 2^(k - 2) + 1 <= n <= 1 + 2 + 4 + 8 + ... + 2^(k - 2) + 2^(k - 1) => 2^(k - 1) <= n <= 2^k - 1 => log(n + 1) <= K <= logn + 1

K的範圍是[log(n + 1), logn + 1]。完全二叉樹的層數小於等於logn + 1,即高度小於等於logn。

散列表vs二叉查詢樹

散列表的優勢:

三獵豹的插入、刪除、查詢操作的時間複雜度可以做到常量級的O(1),而二叉查詢樹在比較平衡的情況下,插入、刪除、查詢操作的時間複雜度才是O(logn)。

散列表的劣勢:

第一,散列表要輸出有序的資料,需要先進行排序;二叉查詢樹只需要中序遍歷,就可以在O(n)的時間複雜度內,輸出有序的資料序列。

第二,散列表擴容耗時很多,而且當遇到雜湊衝突時,效能不穩定;最常用的平衡二叉查詢樹的效能非常穩定,時間複雜度穩定在O(logn)。

第三,因為雜湊衝突的存在,散列表的實際查詢速度可能不一定比O(logn)快。加上雜湊函式的耗時,也不一定就比平衡二叉查詢樹的效率高。

第四,散列表的構造比二叉查詢樹要複雜,需要考慮的東西很多。比如雜湊函式的設計、衝突解決辦法、擴容、縮容等。平衡二叉查詢樹只需要考慮平衡性這一個問題,而且這個問題的解決方案比較成熟、固定。

最後,為了避免過多的雜湊衝突,散列表裝載因子不能太大,特別是基於開放定址法解決衝突的散列表,不然會浪費一定的儲存空間。

綜合這幾點,平衡二叉查詢樹在某些方面還是優於散列表的。

求給定二叉樹的確切的高度

思路:
遞迴法,根節點高度 = max(左子樹高度,右子樹高度) + 1