數據結構與算法分析(十)--- 二叉樹的本質與實現 + 遞歸樹與決策樹應用

一、爲何要有二叉樹?

在我們真實的世界裏,到底是具體的數值重要,還是數值之間相對的大小關係更重要,或者說相對的次序更重要?我想絕大部分人會說:“這得看具體情況”,如果必須讓你在上述問題中做出二選一的回答,你會怎麼辦?

具體到上面這個問題,在計算機科學中其實是有明確答案的,那就是相對的大小要比絕對的數值更重要。這一點,計算機科學和數、理、化都不相同,在數學上,數字是準確的;在物理中,水的冰點有一個確切的溫度,水不到0°是無法結冰的。但在計算機科學裏並不是這樣的,AlphaGo在和柯潔下棋時,講解棋局的幾位高手有時會覺得AlphaGo下得莫名其妙,因爲明明有一步棋可以贏20目,它卻要走一步穩妥的,原因其實很簡單,計算機只看重相對的輸贏。

世界上有什麼樣的問題,就有什麼樣的工具,或者說工具的發明是針對問題來的。在數學上要計算數字,人類就發明了算盤;在物理學上,要測量絕對的數值,人們就發明了各種度量長度的尺子、計時的鐘、稱重量的天平等;在化學上要測量化學反應的當量,人類就發明了各種有刻度的量器。在計算機中,由於經常要做的事情是判斷真假、比較大小、排序、挑選最大值這類的操作,而它們在計算機的世界裏又如此重要,當然也就值得爲這些事情專門設計一種數據結構這種數據結構被稱爲二叉樹

你可以看一眼下面二叉樹的圖示,有一個感性的認識:
二叉樹概念圖示
爲了便於理解二叉樹,我們可以將它和自然界的樹木對應起來:

  • 首先,它們都有一個根,二叉樹的根就是上圖最頂端的結點。爲了將來把二叉樹一層層擴展,二叉樹的根畫在了最頂端而不是下面,你把自然界的大樹旋轉180°就可以了;
  • 其次,從根開始,它們都有分叉,只不過二叉樹爲了簡單起見,只能有兩個分叉,不能更多,這一點和自然界的樹不同。每一個分叉也有一個自己的根結點,你可以把它們想象成大數各級主幹分叉前的部分。你可能會問,遇到特殊問題需要三個或更多分叉怎麼辦?在數學上,兩個分支與N個分支是等價的,或者說N個分支的情況都可以通過兩個分支的來實現,爲了簡單起見,也爲了更好地通用性,我們只研究二叉樹就可以了;
  • 最後,我們知道一棵樹的樹杈還能再分叉,二叉樹也是如此,任何一個樹杈都可以再分出兩枝,這個過程可以無限持續下去。

我們在生活中,一些組織結構其實就是樹狀結構,比如一個公司的大老闆就是根節點,每一個部門的老闆是主幹的根,下面職能部門的總監是枝幹的根,再下面小組的經理是更小枝子的根,最後基層員工是葉子結點。

接下來你可能會問,這種怪怪的數據結構在計算機科學中有什麼用處呢?它的用途很多,比如說可以用於排序。我們在前面介紹的排序算法,無論是高效率的快速排序還是最直觀的插入排序,使用的都是數組或者說線性的列表。其實,二叉樹可以直接完成排序,因爲它有一個很好的性質,就是它左右兩個分叉可以和比較大小後的兩種結果自然對應起來。具體講,用二叉樹排序的過程中只要遵循下面兩條規則就可以了:

  1. 先來的佔據根部,以及靠近頂部層級比較高的位置,後來的放在相對靠下的位置;
  2. 每當一個分支的根部被佔據之後,接下來的數字,是和根部的數字進行比較,小的放在左邊分叉中,大的放到右邊分叉中。

這樣安排得出來的二叉樹,裏面的數字從左到右自然是從小到大排好序的。假設共N個數字,每個數字按照一定的規則放到二叉樹中的時間爲logN(類似二分查找的時間複雜度分析),所有數字放進二叉樹使其有序的總時間是NlogN,這個時間和快速排序是同一個量級的。

二叉樹這種數據結構的用途遠不止排序,前面舉了排序的例子只是爲了說明它有用,在現實的工程中,二叉樹可以做很多事情,比如快速查找到某一個數值(二分查找,時間複雜度logN)。另外,由於網站的目錄結構也是樹狀的(當然它們有N個分叉),因此針對二叉樹的各種算法,稍加改變就可以用於互聯網。比如我們找到一個網站後,要下載一個網站裏面所有的網頁,就會用到二叉樹中的一種遍歷算法。

二叉樹雖然是一種抽象的東西,在自然界中並不存在,但是它卻濃縮了自然界很多事物的共性,那就是分叉、層層遞進和有序。而針對這些共性,科學家們又總結出一些具有普遍性的算法,能夠回過頭來,應用到各種實際問題中。

二、何爲二叉樹?

2.1 樹的基本概念

前面已經給出了二叉樹的圖示,二叉樹是最常用的樹結構,有二叉樹自然就有三叉樹、四叉樹等其它數量分叉的樹,那什麼是“樹”呢?再完備的定義,都沒有圖直觀,下面給出幾棵“樹”的圖示,這些“樹”都有什麼特徵呢?
幾棵樹的示例
“樹”這種數據結構真的很像我們現實生活中的“樹”,這裏面每個元素我們叫作“結點”;用連線來表示相鄰結點之間的關係,我們叫作“父子關係”。比如下面這幅圖,A 結點就是 B 結點的父結點,B 結點是 A 結點的子結點。B、C、D 這三個結點的父結點是同一個結點,所以它們之間互稱爲兄弟結點。我們把沒有父結點的結點叫作根結點,也就是圖中的結點 E。我們把沒有子結點的結點叫作葉子結點或者葉結點,比如圖中的 G、H、I、J、K、L 都是葉子結點。
樹的圖示
除此之外,關於“樹”,還有三個比較相似的概念:高度(Height)、深度(Depth)、層(Level)。它們的定義是這樣的:

  • 結點的高度 = 結點到葉子結點的最長路徑或邊數;
  • 結點的深度 = 根結點到這個結點所經歷的邊的個數;
  • 結點的層數 = 結點的深度 + 1;
  • 樹的高度 = 根結點的高度。

這三個概念的定義比較容易混淆,描述起來也比較空洞,下面舉個例子說明一下:
樹的高度、深度和層
在我們的生活中,“高度”這個概念,其實就是從下往上度量,比如我們要度量第 10 層樓的高度、第 13 層樓的高度,起點都是地面。所以,樹這種數據結構的高度也是一樣,從最底層開始計數,並且計數的起點是 0。

“深度”這個概念在生活中是從上往下度量的,比如水中魚的深度,是從水平面開始度量的。所以,樹這種數據結構的深度也是類似的,從根結點開始度量,並且計數起點也是 0。

“層數”跟深度的計算類似,不過,計數起點是 1,也就是說根結點的位於第 1 層。

2.2 二叉樹的定義與存儲

前面我們也說了,樹結構多種多樣,我們最常用卻是二叉樹。二叉樹,顧名思義,每個結點最多有兩個“叉”,也就是兩個子結點,分別是左子結點右子結點。不過,二叉樹並不要求每個結點都有兩個子結點,有的結點只有左子結點,有的結點只有右子結點。
二叉樹圖示
上圖中有兩個比較特殊的二叉樹,分別是編號 2 和編號 3 這兩個。其中,編號 2 的二叉樹中,葉子結點全都在最底層,除了葉子結點之外,每個結點都有左右兩個子結點,這種二叉樹就叫作滿二叉樹。編號 3 的二叉樹中,葉子結點都在最底下兩層,最後一層的葉子結點都靠左排列,並且除了最後一層,其他層的結點個數都要達到最大,這種二叉樹叫作完全二叉樹

滿二叉樹的特徵非常明顯,我們把它單獨拎出來,這個可以理解。但是完全二叉樹的特徵不怎麼明顯,單從長相上來看,完全二叉樹並沒有特別特殊的地方,更像是“芸芸衆樹”中的一種。那我們爲什麼還要特意把它拎出來呢?爲什麼偏偏把最後一層的葉子結點靠左排列的叫完全二叉樹?如果靠右排列就不能叫完全二叉樹了嗎?這個定義的由來或者說目的在哪裏?

要理解完全二叉樹定義的由來,我們需要先了解,如何表示(或者存儲)一棵二叉樹?想要存儲一棵二叉樹,我們有兩種物理存儲結構,一種是基於數組的順序存儲法,另一種是基於指針或者引用的鏈式存儲法。

我們先來看比較簡單、直觀的鏈式存儲法,從圖中你應該可以很清楚地看到,每個結點有三個字段,其中一個存儲數據,另外兩個是指向左右子結點的指針,我們只要拎住根結點,就可以通過左右子結點的指針,把整棵樹都串起來。這種存儲方式我們比較常用,大部分二叉樹代碼都是通過這種結構來實現的。
二叉樹鏈式存儲法

struct BinaryTree_Node{
    DataType                data;
    struct BinaryTree_Node *LeftChild;
    struct BinaryTree_Node *RightChild;
};

typedef struct BinaryTree_Node   *pTreeNode;

我們再來看,基於數組的順序存儲法,我們把根結點存儲在下標 i = 1 的位置,那左子結點存儲在下標 2 * i = 2 的位置,右子結點存儲在 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 的存儲位置。如果是非完全二叉樹,其實會浪費比較多的數組存儲空間,你可以看下面幾個例子。
非完全二叉樹順序存儲法
如果使用遊標數組來存儲二叉樹,二叉樹結點可以定義如下:

struct BinaryTree_Node{
    DataType    data;
    int			LeftChild;		//指向左子結點下標
    int			RightChild;		//指向右子結點下標
}TreeNode[maxn];				//結點數組,maxn爲結點上限個數

所以,如果某棵二叉樹是一棵完全二叉樹,那用數組存儲無疑是最節省內存的一種方式。因爲數組的存儲方式並不需要像鏈式存儲法那樣,要存儲額外的左右子結點的指針,這也是爲什麼完全二叉樹會單獨拎出來的原因,也是爲什麼完全二叉樹要求最後一層的子結點都靠左的原因。我們後面要介紹的堆其實就是一種完全二叉樹,最常用的存儲方式就是數組

2.3 二叉樹的遍歷

一個數據結構的遍歷操作是比較重要的,前面介紹的線性表遍歷操作比較簡單,需要注意的就是明確遍歷起點與終點,防止遺漏或訪問越界。二叉樹結構比線性表複雜,如何將二叉樹中所有結點都遍歷打印出來呢?經典的方法有三種,前序遍歷、中序遍歷和後序遍歷。其中,前、中、後序,表示的是結點與它的左右子樹結點遍歷打印的先後順序。

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

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

遞歸代碼的關鍵,就是看能不能寫出遞推公式,而寫遞推公式的關鍵就是,如果要解決問題 A,就假設子問題 B、C 已經解決,然後再來看如何利用 B、C 來解決 A。你可能已經發現了,二叉樹的結構很適合使用前面介紹的分治算法來處理,一棵二叉樹有兩個子叉,每個子叉也是一棵二叉樹,因此二叉樹可以使用遞歸不斷分解爲左右兩個子二叉樹去處理,遞歸的邊界條件就是根結點爲空。按照上面的邏輯,給出二叉樹的前、中、後序遍歷實現代碼如下:

void preOrder(pTreeNode T)
{  
	if (T == NULL) 
		return;
		  
	printf("%d ", T->data);
	preOrder(T->LeftChild);  
	preOrder(T->RightChild);
}

void inOrder(pTreeNode T) 
{  
	if (T == NULL) 
		return;
		  
	inOrder(T->LeftChild);  
	printf("%d ", T->data);
	inOrder(T->RightChild);
}

void postOrder(pTreeNode T) 
{  
	if (T == NULL) 
		return;
	
	postOrder(T->LeftChild);  
	postOrder(T->RightChild);  
	printf("%d ", T->data);
}

二叉樹的前、中、後序遍歷的遞歸實現很簡單吧,你知道二叉樹遍歷的時間複雜度是多少嗎?從上面二叉樹的前、中、後序遍歷順序圖可以看出,每個結點最多會被訪問兩次,所以遍歷操作的時間複雜度,跟結點的個數 n 成正比,也就是說二叉樹遍歷的時間複雜度是 O(n)。

對於查找二叉樹,使用中序遍歷可以按大小順序打印出所有元素,而且中序遍歷可以再配合前序遍歷或後序遍歷中的任何一種來構建唯一的一棵二叉樹,但前序遍歷與後序遍歷配合並不能構建唯一的一棵二叉樹。原因是前序與後序遍歷都是提供根結點,作用是相同的,必須由中序遍歷來區分左右子樹。

三、二叉查找樹

前面介紹了二分查找,對已排序數組使用二分查找的時間複雜度是O(logN),但數組的插入、刪除時間複雜度爲O(N),效率比較低,因此不適合作爲動態數據結構使用。要在頻繁插入、刪除元素的場景中發揮二分查找的效率,就要擺脫順序表的鄰接關係,改用指針式的指向關係。在介紹二分查找時,也介紹了跳躍鏈表(簡稱跳錶),通過構建多級索引層,可以實現O(logN)的查找、插入、刪除時間複雜度,但需要額外使用O(N)的內存地址空間。有沒有更好的實現二分查找算法的數據結構呢?

本章最開頭就已經介紹過,二叉樹有一個很好的性質,就是它左右兩個分叉可以和比較大小後的兩種結果自然對應起來。如果目標值比當前元素值小,就到其左子樹上繼續查找,如果目標值比當前元素值大,則到其右子樹上繼續查找,這種二叉樹就是二叉查找樹,也叫二叉搜索樹,它不僅僅支持O(logN)時間查找一個數據,還支持O(logN)時間插入、刪除一個數據,而且不需要額外佔用內存地址空間,它是怎麼做到這些的呢?

這些都依賴於二叉查找樹的特殊結構,二叉查找樹要求,在樹中的任意一個結點,其左子樹中的每個結點的值,都要小於這個結點的值,而右子樹結點的值都大於這個結點的值
二叉查找樹

3.1 二叉查找樹的實現

前面談到,二叉查找樹支持快速查找、插入、刪除操作,這三個操作是如何實現的呢?

  • 二叉查找樹的查找操作

首先,我們看如何在二叉查找樹中查找一個結點。我們先取根結點,如果它等於我們要查找的數據,那就返回。如果要查找的數據比根結點的值小,那就在左子樹中遞歸查找;如果要查找的數據比根結點的值大,那就在右子樹中遞歸查找。
二叉查找樹查找示例
按照上述邏輯,二叉查找樹的查找操作實現代碼如下(爲更直觀展示邏輯,依然使用了遞歸實現方式):

// datastruct\binary_tree.c

pTreeNode Find(pTreeNode T, DataType x)
{
    if(T == NULL)
        return NULL;

    if(x < T->data)
        return Find(T->LeftChild, x);
    else if(x > T->data)
        return Find(T->RightChild, x);
    else
        return T;
}

有時候我們想快速找到最大元素或最小元素,但查找前我們並不知道最大或最小元素值,二叉查找樹依然可以在O(logN)時間找到並返回最大或最小元素指針。

查找最小元素的邏輯很簡單,只需要不斷往左子樹查找,根據二叉查找樹的性質,只要其有左子樹,則左子結點的值小於當前結點的值,直到當前結點沒有左子結點爲止,此時當前結點就是該二叉查找樹中元素值最小的結點。查找最大元素的邏輯與此類似,按照這個邏輯給出二叉查找樹查找最小與最大元素值結點操作的實現函數如下(分別以遞歸和非遞歸的實現方式給出,尾遞歸藉助棧結構轉換爲迭代循環的方法在前面介紹過了):

// datastruct\binary_tree.c

pTreeNode FindMin(pTreeNode T)
{
    if(T == NULL)
        return NULL;
    
    if(T->LeftChild == NULL)
        return T;
    else
        return FindMin(T->LeftChild);
}

pTreeNode FindMax(pTreeNode T)
{
    if(T != NULL)
        while(T->RightChild != NULL)
            T = T->RightChild;

    return T;
}
  • 二叉查找樹的插入操作

二叉查找樹的插入過程有點類似查找操作,新插入的數據一般都是在葉子結點上,所以我們只需要從根結點開始,依次比較要插入的數據和結點的大小關係。

如果要插入的數據比結點的數據大,並且結點的右子樹爲空,就將新數據直接插到右子結點的位置;如果不爲空,就再遞歸遍歷右子樹,查找插入位置。同理,如果要插入的數據比結點數值小,並且節點的左子樹爲空,就將新數據插入到左子結點的位置;如果不爲空,就再遞歸遍歷左子樹,查找插入位置。
二叉查找樹插入示例
按照上述邏輯,二叉查找樹的插入操作實現代碼如下(使用了遞歸實現方式):

// datastruct\binary_tree.c

pTreeNode Insert(pTreeNode T, DataType x)
{
    if(T == NULL)
    {
        // create and return a one-node tree
        T = malloc(sizeof(struct BinaryTree_Node));
        if(T == NULL){
            printf("Out of space!");
        }else{
            T->data = x;
            T->LeftChild = T->RightChild = NULL;
        }
        return T;
    }

    if(x < T->data)
        T->LeftChild = Insert(T->LeftChild, x);
    else if(x > T->data)
        T->RightChild = Insert(T->RightChild, x);    
    // else x is in the tree already, we will do nothing

    return T;
}

插入元素一般並不需要返回值,上面的實現函數中爲何需要返回值呢?我們知道以值傳遞形式傳給函數的參數,在函數體內其實只是一份拷貝,對其修改並不能反映到參數自身。這裏雖然傳進去的是指向某結點的指針,對其指向對象的修改有效,對指針本身的修改無效,如果我們想修改指針值,需要將指向指針的指針傳遞進去(C語言不支持引用傳參,C++支持引用傳參),或者將修改後的指針值返回,在函數調用中以賦值形式更新指針值。上面的實現代碼選擇了第二種方式,下面的刪除操作實現代碼也是類似的考慮。

重複元素的插入可以通過在結點記錄中保留一個附加域以指示發生的頻率來處理,這使整棵樹增加了部分附加空間,但卻比將重複信息放到樹中有更小的深度。當然,如果關鍵字只是一個更大結構的一部分,那麼這種方法就行不通了。

  • 二叉查找樹的刪除操作

二叉查找樹的查找、插入操作都比較簡單易懂,但是它的刪除操作就比較複雜了 。針對要刪除結點的子結點個數的不同,我們需要分三種情況來處理:

  1. 第一種情況是,如果要刪除的結點沒有子結點,我們只需要直接將父結點中,指向要刪除結點的指針置爲 null,比如下圖中刪除結點 55;
  2. 第二種情況是,如果要刪除的結點只有一個子結點(只有左子結點或者右子結點),我們只需要更新父結點中,指向要刪除結點的指針,讓它指向要刪除結點的子結點就可以了,比如下圖中刪除結點 13;
  3. 第三種情況是,如果要刪除的結點有兩個子結點,這就比較複雜了。我們需要找到這個結點的右子樹中的最小結點,把它替換到要刪除的結點上。然後再刪除掉這個最小結點,因爲最小結點肯定沒有左子結點(如果有左子結點,那就不是最小結點了),所以,我們可以應用上面兩條規則來刪除這個最小結點,比如下圖中刪除節點 18。
    二叉查找樹刪除示例
    按照上述邏輯,二叉查找樹的刪除操作實現代碼如下(使用了遞歸實現方式):
// datastruct\binary_tree.c

pTreeNode Delete(pTreeNode T, DataType x)
{
    if(T == NULL)
    {
        printf("Element not found");
        return NULL;
    }

    if(x < T->data)
        T->LeftChild = Delete(T->LeftChild, x);
    else if(x > T->data)
        T->RightChild = Delete(T->RightChild, x);
    // found target element to delete
    else
    {
        pTreeNode tmp;
        // have two children
        if(T->LeftChild != NULL && T->RightChild != NULL)
        {
            // replace with samllest in right subtree
            tmp = FindMin(T->RightChild);
            T->data = tmp->data;
            T->RightChild = Delete(T->RightChild, T->data);
        }else{
            // one or zero children
            tmp = T;
            T = (T->LeftChild != NULL) ? T->LeftChild : T->RightChild;
            free(tmp);
        }
    }
    
    return T;
}

實際上,關於二叉查找樹的刪除操作,還有個非常簡單、取巧的方法 — 懶惰刪除,就是單純將要刪除的節點標記爲“已刪除”,但是並不真正從樹中將這個節點去掉。這樣原本刪除的節點還需要存儲在內存中,比較浪費內存空間,但是刪除操作就變得簡單了很多。而且,這種處理方法也並沒有增加插入、查找操作代碼實現的難度,如果被刪除的元素再次重新插入,還能節省分配一個新元素的開銷。

二叉查找樹除了支持上面幾個操作之外,還有一個重要的特性,就是中序遍歷二叉查找樹,可以輸出有序的數據序列,時間複雜度是 O(n),非常高效。因此,二叉查找樹也叫作二叉排序樹。

我們使用上面實現的二叉查找樹操作函數完成一個示例,以驗證我們實現的操作函數是否存在bug,二叉查找樹的示例程序如下:

#include <stdio.h>
#include <stdlib.h>

#define DataType int

struct BinaryTree_Node{
    DataType                data;
    struct BinaryTree_Node *LeftChild;
    struct BinaryTree_Node *RightChild;
};
typedef struct BinaryTree_Node   *pTreeNode;

pTreeNode Find(pTreeNode T, DataType x);
pTreeNode FindMin(pTreeNode T);
pTreeNode FindMax(pTreeNode T);
pTreeNode Insert(pTreeNode T, DataType x);
pTreeNode Delete(pTreeNode T, DataType x);
void preOrder(pTreeNode T);
void inOrder(pTreeNode T);
void postOrder(pTreeNode T);

int main(void)
{
    pTreeNode T = NULL, tmp = NULL;

    T = Insert(T, 16);
    T = Insert(T, 18);  
    T = Insert(T, 13);
    T = Insert(T, 17);
    T = Insert(T, 15);
    T = Insert(T, 25);
    T = Insert(T, 27);

    printf("preOrder: ");
    preOrder(T);
    printf("\n");
    printf("inOrder: "); 
    printf("\n");
    printf("postOrder: ");
    postOrder(T);
    printf("\n");

    tmp = Find(T, 18);
    printf("find target result: %d\n", tmp->data);
    tmp = FindMin(T);
    printf("find min result: %d\n", tmp->data);
    tmp = FindMax(T);
    printf("find max result: %d\n", tmp->data);

    T = Delete(T, 27);
    T = Delete(T, 13);
    T = Delete(T, 18);

    printf("preOrder: ");
    preOrder(T);
    printf("\n");
    printf("inOrder: "); 
    inOrder(T);
    printf("\n");
    printf("postOrder: ");
    postOrder(T);
    printf("\n");

    return 0;
}

上面二叉查找樹的示例程序運行結果如下:
二叉查找樹示例程序運行結果

3.2 支持重複數據的二叉查找樹

前面介紹二叉查找樹的時候,我們默認樹中結點存儲的都是數字。很多時候,在實際的軟件開發中,我們在二叉查找樹中存儲的,是一個包含很多字段的對象。我們利用對象的某個字段作爲鍵值(key)來構建二叉查找樹,我們把對象中的其它字段叫作衛星數據

前面介紹的二叉查找樹的操作,針對的都是不存在鍵值相同的情況。那如果存儲的兩個對象鍵值相同,除了增加記錄頻率信息的附加字段外,還有哪些更好的解決方法呢?

第一種方法比較容易:二叉查找樹中每一個結點不僅會存儲一個數據,因此我們可以通過鏈表、支持動態擴容的數組、甚至另一棵二叉查找樹等數據結構,把值相同的數據都存儲在同一個結點上。

第二種方法比較不好理解,不過更加優雅:每個節點仍然只存儲一個數據,在查找插入位置的過程中,如果碰到一個節點的值,與要插入數據的值相同,我們就將這個要插入的數據放到這個節點的右子樹,也就是說,把這個新插入的數據當作大於這個節點的值來處理。
插入值相等的元素
當要查找數據的時候,遇到值相同的節點,我們並不停止查找操作,而是繼續在右子樹中查找,直到遇到葉子節點,才停止,這樣就可以把鍵值等於要查找值的所有節點都找出來。
查找值相同的元素
對於刪除操作,我們也需要先查找到每個要刪除的節點,然後再按前面講的刪除操作的方法,依次刪除。
刪除值相同的元素

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

實際上,二叉查找樹的形態各式各樣。比如下圖中,對於同一組數據,我們構造了三種二叉查找樹,它們的查找、插入、刪除操作的執行效率都是不一樣的。圖中第一種二叉查找樹,根節點的左右子樹極度不平衡,已經退化成了鏈表,所以查找的時間複雜度就變成了 O(n)。
幾種二叉查找樹
前面分析的其實是一種最糟糕的情況,我們現在來分析一個最理想的情況,二叉查找樹是一棵完全二叉樹(或滿二叉樹)。這個時候,插入、刪除、查找的時間複雜度是多少呢?

我前面的圖示和實現代碼來看,不管操作是插入、刪除還是查找,時間複雜度其實都跟樹的高度成正比,也就是 O(height)。既然這樣,現在問題就轉變成另外一個了,也就是,如何求一棵包含 n 個結點的完全二叉樹的高度?

樹的高度就等於最大層數減一,爲了方便計算,我們轉換成層來表示。從圖中可以看出,包含 n 個結點的完全二叉樹中,第一層包含 1 個結點,第二層包含 21 個結點,第三層包含 22 個結點,依次類推,下面一層結點個數是上一層的 2 倍,第 K 層包含的結點個數就是 2(K-1)

不過,對於完全二叉樹來說,最後一層的結點個數有點兒不遵守上面的規律了,它包含的結點個數在 1 個到 2(L-1) 個之間(我們假設最大層數是 L)。如果我們把每一層的結點個數加起來就是總的結點個數 n,也就是說,如果結點的個數是 n,那麼 n 滿足這樣一個關係:

n >= 1+2+4+8+…+2(L-2)+1
n <= 1+2+4+8+…+2(L-2)+2(L-1)

藉助等比數列的求和公式,我們可以計算出,L 的範圍是 [log2(n+1), log2n +1]。完全二叉樹的層數小於等於 log2n +1,也就是說,完全二叉樹的高度小於等於 log2n。

顯然,極度不平衡的二叉查找樹,它的查找性能肯定不能滿足我們的需求。我們需要構建一種不管怎麼刪除、插入數據,在任何時候,都能保持任意結點左右子樹都比較平衡的二叉查找樹,這就是我們下一篇博客要介紹的一種特殊的二叉查找樹,平衡二叉查找樹。平衡二叉查找樹的高度接近 logn,所以插入、刪除、查找操作的時間複雜度也比較穩定,是 O(logn)。

3.4 二叉查找樹與散列表優劣對比

我們在散列表那篇博客中介紹過,散列表的插入、刪除、查找操作的時間複雜度可以做到常量級的 O(1),非常高效。而二叉查找樹在比較平衡的情況下,插入、刪除、查找操作時間複雜度纔是 O(logn),相對散列表,好像並沒有什麼優勢,那我們爲什麼還要用二叉查找樹呢?

我認爲有下面幾個原因:

  1. 散列表中的數據是無序存儲的,如果要輸出有序的數據,需要先進行排序。而對於二叉查找樹來說,我們只需要中序遍歷,就可以在 O(n) 的時間複雜度內,輸出有序的數據序列;
  2. 散列表擴容耗時很多,而且當遇到散列衝突時,性能不穩定,儘管二叉查找樹的性能不穩定,但是在工程中,我們最常用的平衡二叉查找樹的性能非常穩定,時間複雜度穩定在 O(logn);
  3. 籠統地來說,儘管散列表的查找等操作的時間複雜度是常量級的,但因爲哈希衝突的存在,這個常量不一定比 logn 小,所以實際的查找速度可能不一定比 O(logn) 快。加上哈希函數的耗時,也不一定就比平衡二叉查找樹的效率高;
  4. 散列表的構造比二叉查找樹要複雜,需要考慮的東西很多。比如散列函數的設計、衝突解決辦法、擴容、縮容等。平衡二叉查找樹只需要考慮平衡性這一個問題,而且這個問題的解決方案比較成熟、固定;
  5. 爲了避免過多的散列衝突,散列表裝載因子不能太大,特別是基於開放尋址法解決衝突的散列表,不然會浪費一定的存儲空間。

綜合這幾點,平衡二叉查找樹在某些方面還是優於散列表的,所以,這兩者的存在並不衝突。我們在實際的開發過程中,需要結合具體的需求來選擇使用哪一個。

四、二叉樹應用之遞歸樹與決策樹

我們都知道,遞歸代碼的時間複雜度分析起來很麻煩。我們在博客:排序算法分析 中介紹過,如何利用遞推公式,求解歸併排序、快速排序的時間複雜度,但是,有些情況,比如快排的平均時間複雜度的分析,用遞推公式的話,會涉及非常複雜的數學推導,藉助遞歸樹來分析遞歸算法的時間複雜度,則可以簡化數學推導過程。

4.1 用遞歸樹分析歸併排序時間複雜度

我們前面講過,遞歸的思想就是,將大問題分解爲小問題來求解,然後再將小問題分解爲小小問題,這樣一層一層地分解,直到問題的數據規模被分解得足夠小,不用繼續遞歸分解爲止。如果我們把這個一層一層的分解過程畫成圖,它其實就是一棵樹,我們給這棵樹起一個名字,叫作遞歸樹。下面給出一棵斐波那契數列的遞歸樹圖示,結點裏的數字表示數據的規模,一個結點的求解可以分解爲左右子結點兩個問題的求解。
斐波那契數列遞歸樹
通過這個例子,你對遞歸樹的樣子應該有個感性的認識了,看起來並不複雜。現在,我們就來看,如何用遞歸樹來求解時間複雜度?

歸併排序算法你還記得吧?它的遞歸實現代碼非常簡潔。現在我們就藉助歸併排序來看看,如何用遞歸樹,來分析遞歸代碼的時間複雜度。歸併排序每次會將數據規模一分爲二。我們把歸併排序畫成遞歸樹,就是下面這個樣子:
歸併排序遞歸樹
因爲每次分解都是一分爲二,所以代價很低,我們把時間上的消耗記作常量 1。歸併算法中比較耗時的是歸併操作,也就是把兩個子數組合併爲大數組。從圖中我們可以看出,每一層歸併操作消耗的時間總和是一樣的,跟要排序的數據規模有關,我們把每一層歸併操作消耗的時間記作 n。現在,我們只需要知道這棵樹的高度 h,用高度 h 乘以每一層的時間消耗 n,就可以得到總的時間複雜度 O(n∗h)。

從歸併排序的原理和遞歸樹,可以看出來,歸併排序遞歸樹是一棵滿二叉樹,滿二叉樹的高度大約是 log2​n,所以歸併排序遞歸實現的時間複雜度就是 O(nlogn)。我這裏的時間複雜度都是估算的,對樹的高度的計算也沒有那麼精確,但是這並不影響複雜度的計算結果。

4.2 用遞歸樹分析快速排序時間複雜度

快速排序在最好情況下,每次分區都能一分爲二,這個時候用遞推公式 T(n)=2T(n/2​)+n,很容易就能推導出時間複雜度是 O(nlogn)。但是,我們並不可能每次分區都這麼幸運,正好一分爲二。

我們假設平均情況下,每次分區之後,兩個分區的大小比例爲 1:k。當 k=9 時,如果用遞推公式的方法來求解時間複雜度的話,遞推公式就寫成 T(n)=T(n/10​)+T(9*n/10​)+n。這個公式可以推導出時間複雜度,但是推導過程非常複雜。那我們來看看,用遞歸樹來分析快速排序的平均情況時間複雜度,是不是比較簡單呢?

我們還是取 k 等於 9,也就是說,每次分區都很不平均,一個分區是另一個分區的 9 倍。如果我們把遞歸分解的過程畫成遞歸樹,就是下面這個樣子:
快速排序遞歸樹
快速排序的過程中,每次分區都要遍歷待分區區間的所有數據,所以,每一層分區操作所遍歷的數據的個數之和就是 n。我們現在只要求出遞歸樹的高度 h,這個快排過程遍歷的數據個數就是 h∗n ,也就是說,時間複雜度就是 O(h∗n)。

因爲每次分區並不是均勻地一分爲二,所以遞歸樹並不是滿二叉樹。這樣一個遞歸樹的高度是多少呢?我們知道,快速排序結束的條件就是待排序的小區間,大小爲 1,也就是說葉子節點裏的數據規模是 1。從根節點 n 到葉子節點 1,遞歸樹中最短的一個路徑每次都乘以 1/10​,最長的一個路徑每次都乘以 9/10​。通過計算,我們可以得到,從根節點到葉子節點的最短路徑是 log10​n,最長的路徑是 log10/9​​n。

所以,遍歷數據的個數總和就介於 nlog10​n 和 nlog10/9n 之間。根據複雜度的大 O 表示法,對數複雜度的底數不管是多少,我們統一寫成 logn,所以,當分區大小比例是 1:9 時,快速排序的時間複雜度仍然是 O(nlogn)。

剛剛我們假設 k=9,那如果 k=99,也就是說,每次分區極其不平均,兩個區間大小是 1:99,這個時候的時間複雜度是多少呢?我們可以類比上面 k=9 的分析過程。當 k=99 的時候,樹的最短路徑就是 log100n,最長路徑是 log100/99n,所以總遍歷數據個數介於 nlog100n 和 nlog100/99​​n 之間。儘管底數變了,但是時間複雜度也仍然是 O(nlogn)。

也就是說,對於 k 等於 9,99,甚至是 999,9999……,只要 k 的值不隨 n 變化,是一個事先確定的常量,那快排的時間複雜度就是 O(nlogn)。所以,從概率論的角度來說,快排的平均時間複雜度就是 O(nlogn)。

4.3 用決策樹分析分治排序時間複雜度

二叉樹除了可以用作遞歸分解子問題的分析工具 — 遞歸樹外,還可以作爲判斷真假輔助決策的工具 — 決策樹。如果把決策樹用於分析排序算法的時間複雜度,這裏判斷真假的對象就是元素間的比較關係,如下圖所示,樹的根結點表示“元素的所有可能順序”,樹的每一條邊表示“一種可能的結果”,一條邊連接的孩子結點則是“父結點經過該邊所代表的比較結果後剩餘的可能順序”:
決策樹圖示
上圖是一棵三元素排序決策樹,根結點處表示所有可能的順序,而從根延伸下來的兩條邊分別表示了兩種“決策結果”,或者說“比較結果”,若符合該“決策結果”就可以得出剩餘的可能情況,比如根結點的左孩子是經歷決策“a<b”後剩餘的可能。顯然,葉子代表只剩一種可能順序。

決策樹並沒有代表任何排序算法,即沒有哪個排序算法是這樣工作的。但是決策樹可以給我們這樣一個信息:通過比較來排序的算法,本質上就是沿着該元素集合的決策樹從根到某個葉子的路徑比較下去。因此,分析這條“路徑”平均經過多少條邊,就相當於分析使用比較的排序算法平均需要多少次比較。歸併排序、快速排序,包括後面將要介紹的堆排序都是基於比較的排序算法,我們可以藉助決策樹來分析這類基於比較的排序算法的時間複雜度下界。

從決策樹的定義和圖示可以看出,決策樹算是滿二叉樹,所以決策樹的深度是logL(L爲葉子結點的個數)。決策樹葉子結點的數量是多少呢?假如N個元素排序,每個葉子結點就是這N個元素的一種可能排列結果,葉子結點的數量實際上就是這N個元素的全排列結果數量。從學過的高中數學可知,N個元素的全排列數量共有N!種,所以N個元素決策樹的葉子結點數量也有N!個,該決策樹的深度就是log(N!)。

基於比較的排序算法的比較次數,借用決策樹分析,就是從決策樹根結點出發,沿着某條路徑,到達一種有序的排列結果,也即其中一個葉子結點。中間經過的邊數,也即元素兩兩比較次數,就是該決策樹的深度log(N!),所以基於比較的排序算法,至少需要進行log(N!)次比較,也即基於比較的排序算法的時間複雜度是O(log(N!))。

你可能會問,歸併排序與快速排序的平均時間複雜度都是O(NlogN),這裏又說基於比較的排序算法的時間複雜度下界是O(log(N!)),這兩個時間複雜度哪個更高階呢?從下面的簡單轉換可以看出,當N有限時,NlogN比log(N!)更大。

logN!=log(N(N-1)(N-2)……x2x1)
   =logN+log(N-1)+log(N-1)+……+log2+log1
  <=N*logN

但是,大O表示法針對的是N趨近於無窮大時的情況,根據數學中的斯特拉公式可知:
斯特林公式
當N趨近於無窮大時,O(log(N!))與O(NlogN)是等價的(也即忽略上式中的低階項),從兩個函數曲線圖上也可以看出二者之間的關係:
nlogn與log(n!)曲線對比
由此可見,基於比較的排序算法的時間複雜度下界爲Ω(log(N!)),也等價於Ω(N*logN)。

將該決策樹的分析過程推廣到一般情形,如果存在P種不同的情況要區分,而問題是Yes/No的真假判斷形式,那麼通過任何算法求解該問題在某種情形下的解至少需要logP次判斷。

本章數據結構實現源碼下載地址:https://github.com/StreamAI/ADT-and-Algorithm-in-C/tree/master/datastruct

更多文章:

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章