導言
我們先來看個例子,假設我連續拋一毛、五毛、一塊錢的硬幣各一個,那么這 3 枚硬幣呈現出的狀態有多少種可能呢?我們知道拋一枚硬幣只有兩種可能——證明或反面,也就是說拋硬幣這個事件可能會產生兩種可能性,所以我們來看:

如果我們把這個過程模擬成一個樹,整個樹有 8 個葉結點,那么這個事件的 8 種可能性我們就能說明白了。
二叉樹的定義
二叉樹 (Binary Tree) 是 n(n ≥ 0) 個結點的有限集合,該集合為空集時稱為空二叉樹,由一個根結點和兩棵互不相交的、分別稱為根結點的左子樹和右子樹的二叉樹組成。例如上文作為例子的樹結構,由於出現了一個結點有 3 個子樹的情況,所以不屬於二叉樹,而如圖所示結構就是二叉樹。

對於二叉樹來說有以下特點:
- 二叉樹的每個結點至多有兩個子樹,也就是說二叉樹不允許存在度大於 2 的結點;
- 二叉樹有左右子樹之分,次序不允許顛倒,即使是只有一棵子樹也要有左右之分。
因此對於一棵有 3 個結點的二叉樹來說,由於需要區分左右,會有以下五種情況。

特殊的二叉樹
斜樹
所有結點都只有左(右)子樹的二叉樹被稱為左(右)斜樹,同時這個樹結構就是一個線性表,如圖所示。

滿二叉樹
滿二叉樹要求所有的分支結點都存在左右子樹,並且所有的葉結點都在同一層上,若滿二叉樹的層數為 n,則結點數量為 2n-1 個結點,子葉只能出現在最后一層,內部結點的度都為 2,如圖所示。

完全二叉樹
從定義上來說,完全二叉樹是滿足若對一棵具有 n 個結點的二叉樹按層序編號,如果編號為 i 的結點 (1 ≤ i ≤ n)於同樣深度的滿二叉樹中編號為 i 的結點在二叉樹的位置相同的二叉樹。這樣講有些繁瑣,可以理解為完全二叉樹生成結點的順序必須嚴格按照從上到下,從左往右的順序來生成結點,如圖所示。

因此我們就不難觀察出完全二叉樹的特點,完全二叉樹的葉結點只能存在於最下兩層,其中最下層的葉結點只集中在樹結構的左側,而倒數第二層的葉結點集中於樹結構的右側。當結點的度為 1 時,該結點只能擁有左子樹。
二叉樹的性質
| 性質 | 內容 |
|---|---|
| 性質一 | 在二叉樹的 i 層上至多有 2i-1 個結點(i>=1) |
| 性質二 | 深度為 k 的二叉樹至多有 2k-1 個結點(i>=1) |
| 性質三 | 對任何一棵二叉樹 T,如果其終端結點樹為 n0,度為 2 的結點為 n2,則 n0 = n2 + n1 |
| 性質四 | 具有 n 個結點的完全二叉樹的深度為 [log2n] + 1 向下取整 |
| 性質五 | 如果有一棵有 n 個結點的完全二叉樹(其深度為 [log2n] + 1,向下取整)的結點按層次序編號(從第 1 層到第 [log2n] + 1,向下取整層,每層從左到右),則對任一結點 i(1 <= i <= n)有 1.如果 i = 1,則結點 i 是二叉樹的根,無雙親;如果 i > 1,則其雙親是結點 [i / 2],向下取整 2.如果 2i > n 則結點 i 無左孩子,否則其左孩子是結點 2i 3.如果 2i + 1 > n 則結點無右孩子,否則其右孩子是結點 2i + 1 |
二叉樹的存儲結構
順序存儲
由於二叉樹的結點至多為 2,因此這種性質使得二叉樹可以使用順序存儲結構來描述,在使用順序存儲結構時我們需要令數組的下標體現結點之間的邏輯關系。我們先來看完全二叉樹,如果我們按照從上到下,從左到右的順序遍歷完全二叉樹時,順序是這樣的:

那么我們就會發現,設父結點的序號為 k,則子結點的序號會分別為 2k 和 2k + 1,子結點的序號和父結點都是相互對應的,因此我們可以用順序存儲結構來描述,例如如圖大頂堆:

用順序存儲結構描述如圖所示:

那么對於一般的二叉樹呢?我們可以利用完全二叉樹的編號來實現,如果在完全二叉樹對應的結點是空結點,修改其值為 NULL 即可,例如:

再看個例子,左斜樹:

但是我們可以很明顯地看到,對於一個斜樹,我開辟的空間數遠超過實際使用的空間,這樣空間就被浪費了,因此順序存儲結構雖然可行,但不合適。
鏈式存儲
由於二叉樹的每個結點最多只能有 2 個子樹,因此我們就不需要使用上述的 3 種表示法來做,可以直接設置一個結點具有兩個指針域和一個數據域,那么這樣建好的鏈表成為二叉鏈表。例如:


再看個例子,上述我描述孩子兄弟表示法的樹結構,稍加改動就可以把圖示改成二叉樹:


結構體定義
typedef struct BiTNode
{
ElemType data; //數據域
ChildPtr *lchild,*rchild; //左右孩子的指針域
//可以開個指針域指向雙親,變為三叉鏈表
}BiTNode, *BiTree;

二叉樹的遍歷
遞歸遍歷法
從斐波那契數列說起
我們先不急着開始談二叉樹的遍歷,而是先回憶一下我們是怎么利用斐波那契數列實現遞歸的:

代碼實現:
int f(int n)
{
if (n == 0)
return 0;
else
if (n == 1)
return 1;
else
return f(n - 2) + f(n - 1);
}
代碼很好讀,已經不是什么難題了,但是我們並不是一開始就懂得遞歸是個什么玩意,我們也是通過模擬來深刻理解的。因此下面我們用圖示法進行模擬,假設我需要獲取第 4 個斐波那契數:

仔細看,我們模擬遞歸函數調用的過程,和二叉樹長得是一模一樣啊,那么對於二叉樹的操作,我們能否用遞歸來作些文章?
遍歷算法
由於二叉樹的結點使用了遞歸定義,也就是結點的擁有自己本身作為成員的成員,這就使得遍歷算法可以使用遞歸實現,而且思路很清晰。
void PreOrderTraverse (BiTree T)
{
if(T == NULL)
return;
//cout << T->data << " " ; //前序遍歷
PreOrderTraverse (T->lchild);
//cout << T->data << " " ; //中序遍歷
PreOrderTraverse (T->rchild);
//cout << T->data << " " ; //后序遍歷
}
可以看到,根據輸出語句的位置不同,輸出的數據順序是不一樣的,例如如圖所示二叉樹,3 種順序的輸出順序為:
前序:先訪問根結點,然后先進入左子樹前序遍歷,再進入右子樹前序遍歷。

中序:從根結點出發,先進入根結點的左子樹中序遍歷,然后訪問根結點,最后進入右子樹中序遍歷。

后序:從左到右先葉子后結點的方式進入左、右子樹遍歷,最后訪問根結點。

· 需要注意的是,無論是什么樣的遍歷順序,訪問結點都是從根結點開始訪問,按照從上到下,從左到右的順序向下挖掘,分為 3 中順序主要因為我們需要有一些方式來描述遞歸遍歷的結果,讓我們可以抽象二叉樹的結構,因此我們就按照輸出語句放的位置不同而決定是什么序遍歷,所以我這邊就將 3 中遍歷順序放在一起談。
層序遍歷法
顧名思義,層序遍歷法就是從第一層(根結點)開始,按照從上到下,從左到右的順序進行遍歷,如圖所示。

層序遍歷法不僅直觀,而且好理解,但是我們要思考,處於同一層的結點存在於不同子樹,按照剛才的遞歸遍歷法我們無法和其他子樹產生溝通,那該怎么實現?仔細觀察,層序遍歷就好像從根結點開始,一層一層向下擴散搜索,這就跟我們隊列實現迷宮算法非常類似,因為迷宮算法的不同路徑也是無關聯的,但是我們是用廣度優先搜索的思想可以找到最短路徑。
算法實現
void levelOrder(BiTree t)
{
BiNnode ptr;
queue<BiTree> que_level; //層序結點隊列
if (t == NULL) //空樹處理
{
cout << "NULL";
}
que_level.push(t); //根結點入隊列
while (!que_level.empty() && que_level.front()) //直至空隊列,結束循環
{
cout << que_level.front()->data << ' ';
if (que_level.front()->left != NULL) //隊列頭結點是否有左結點
{
que_level.push(que_level.front()->left); //左結點入隊列
}
if (que_level.front()->right != NULL) //隊列頭結點是否有左結點
{
que_level.push(que_level.front()->right); //右結點入隊列
}
que_level.pop(); //隊列頭出隊列
}
}
加深印象
某二叉樹的前序和后序遍歷序列正好相反,則該二叉樹一定是(B)
A、空或只有一個結點
B、高度等於其結點數
C、任一結點無左孩子
D、任一結點無右孩子
建立二叉樹
拓展二叉樹
例如要確定一個二叉樹,我們肯定不能只是把結點說明白,還需要把每個結點是否有左右孩子說明白。例如如圖所示樹結構,我們可以向其中填充結點,使其的所有結點填充完后均具有左右結點,為了表示該結點其實是不存在的,我們需要設置一個標志來表示,例如是“#”,那么這種描述就是拓展二叉樹如圖所示。

按照前序遍歷,輸出的結果為“ABD#GE##C#F##”。
建樹算法
對於樹來說,遍歷是各種操作的基礎,我們剛剛是通過遞歸的方式實現了二叉樹的遍歷讀取,現在我們可以再次搬出遞歸,使其按照前序遍歷的順序建立二叉樹。假設樹結構的每一個結點的數據域都是一個字符,先序遍歷的順序已知,算法要求將一個字符序列的元素依次讀入建立二叉樹。
由於對一個樹結構來說,每個結點的左右分支都可以被理解為是一個樹結構,例如根結點就擁有左右子樹,葉結點可以理解為左右子樹都是空樹的根結點。因此我們可以通過分治思想,每一次只構建一棵子樹的根結點,然后遞歸建立左右子樹,直至讀取到“#”終止遞歸。
void CreatBiTree(BiTree &T)
{
char ch;
cin >> ch;
if(ch == '#') //讀取到 NULL 結點
T = NULL; //建立空樹,結束遞歸
else
{
T = new BiTNode; //生成樹的根結點
T->data = ch;
CreatBiTree(T->lchild); //創建根結點的左子樹
CreatBiTree(T->rchild); //創建根結點的右子樹
}
}
已知前序、中序遍歷建樹法
樣例模擬
假設我有如下遍歷序列:
ABDFGHIEC //前序遍歷
FDHGIBEAC //中序遍歷
我們來嘗試一下用這兩個遍歷結果反向建立一棵二叉樹。首先根據前序遍歷的特點,對於一棵樹來說,在前序遍歷時根結點會被先輸出,在中序遍歷時根結點會在左子樹結點輸出完畢之后輸出,因此我們可以知道這棵二叉樹的根結點的值為 “A”,而在中序遍歷中“A”結點又把序列分為了左右子樹,分別是“FDHGIBE”和“C”,如圖所示。

我的根結點安排明白了,這個時候在我眼里,前序遍歷只剩下了“BDFGHIEC”,而對於左子樹的中序遍歷是“FDHGIBE”,右子樹的中序遍歷是“C”。
對於二叉樹來說,可以看做由兩顆子樹構成的森林重新組合的樹結構,因此在我眼里根據前序遍歷的結構,左子樹的根結點是“B”,該結點把二叉樹分成了左右子樹分別是“FDHGI”和“E”,如圖所示。

重復上述切片操作,就能夠建立一棵二叉樹。


我們發現了,反向建樹的方式還是滲透了分治法的思想,通過分治把一個序列不斷分支成左右子樹,知道分治到葉結點。因此我們可以總結出建樹的算法思路:在遞歸過程中,如果當前先序序列的區間為 [idx_f1,idx_f2],中序序列的區間為 [idx_m1,idx_m2],設前序序列的第一個元素在中序序列中的下標為 k,那么左子樹的結點個數為 num = (k − idx_m1) 。這樣左子樹的先序序列區間就是 [idx_f1 + 1, idx_f1 + num],左子樹的中序序列區間是 [idx_m1,k − 1];右子樹的先序序列區間是 [idx_f1 + num + 1,idx_f1],右子樹的中序序列區間是 [k + 1,idx_m2],由於我按照先序序列的順序安排結點,因此當先序序列的 idx_f1 > idx_f2 時,就是遞歸的結束條件。
代碼實現
void createTree(tree& t, int idx_f1, int idx_f2, int idx_m1,int idx_m2)
{ //front 和 middle 是存儲輸入的前序、中序序列的數組,為全局變量
int i;
t = new treenode;
if (idx_f1 > idx_f2) //前序序列已經安排完畢,結束
{
t = NULL;
return;
}
t->data = front[idx_f1]; //構建子樹的根結點
for (i = idx_m1; i <= idx_m2; i++)
{
if (middle[i] == front[idx_f1]) //查找到在中序序列中的對應位置
{
break;
}
} //遞歸分治,將子樹對應的前序、中序序列傳入遞歸函數
createTree(t->left, idx_f1 + 1, idx_f1 + i - idx_m1, idx_m1, i - 1);
createTree(t->right, idx_f1 + i - idx_m1 + 1, idx_f2, i + 1, idx_m2);
}
已知后序、中序遍歷建樹法
樣例模擬
假設我有如下遍歷序列:
2 3 1 5 7 6 4 //后序遍歷
1 2 3 4 5 6 7 //中序遍歷
后續遍歷的最后一個元素是根結點,因此通過根結點“4”在中序序列分成了左、右子樹。對於后續遍歷也被分為兩個序列“2 3 1”和“5 7 6”。

對於左子樹來說,根據后序遍歷“2 3 1”,他的根結點是“1”,這個結點將中序序列分成了左右子樹,分別為右子樹“2 3”和一個空左樹。

重復上述操作即可還原出二叉樹。


過程和前、中序序列建樹是很相似的,雖然后序遍歷理解起來比前序要復雜一些,因為前序序列你只需要一個一個向下讀取。不過我們發現當我們找到根結點在中序序列中的位置之后,后序遍歷中左子樹與中序遍歷左子樹位置是對應的,后序遍歷中右子樹與中序遍歷中右子樹位置相差一個元素,也就是中序遍歷根節點的位置。因此我們可以利用這個特性使用指針來描述數組,就不需要傳遞那么多描述下標的參數了。
代碼實現
void createBiTree(BiTree& t, int* back, int* middle, int n)
{ //back 和 middle 分別是指向后續、中序序列的數組的指針
int num;
int* ptr;
if (n <= 0) //序列長度小於 0 時,遞歸結束
{
t = NULL;
return;
}
t = new BiNode;
ptr = middle; //ptr 指向 middle 的第一個元素
while (*ptr != back[n - 1])
{
ptr++; //查找中序序列的根結點
}
t->data = *ptr;
num = ptr - middle; //左子樹的結點數,可以通過這個變量退出右子樹結點數
createBiTree(t->left, back, middle, num); //通過指針運算限制傳入的數組
createBiTree(t->right, back + num, middle + num + 1, n - num - 1);
}
二叉樹的其他基操
復制二叉樹
還是用遞歸,與創建二叉樹類似,先申請一個新結點用於拷貝根結點,然后通過遞歸依次復制每一個子樹的根結點即可實現。
void CopyBiTree(BiTree &T,BiTree &NewT)
{
if(T == NULL) //根結點是空樹,結束復制
NewT = NULL;
return;
else
{
NewT = new BiTNode;
NewT->data = T->data; //拷貝根結點
CopyBiTree(T->lchild,NewT->lchild); //拷貝左子樹根結點
CopyBiTree(T->rchild,NewT->rchild); //拷貝右子樹根結點
}
}
獲取二叉樹的深度
還是用遞歸,與創建二叉樹類似,利用分治的思想,對於倒數第二層的子樹來說深度為 1 或 2,即左右子樹是否存在的問題,那么當我從最底層分治回根結點時,二叉樹的深度即為左右子樹深度較大的數值加 1,最后函數需要返回樹的深度。
int DepthBiTree(BiTree T)
{
int l_depth,r_depth;
if(T == NULL) //若樹為空樹則表示子樹的深度為 0
return 0;
else
{
l_depth = DepthBiTree(T->lchild); //向左子樹挖掘深度
r_depth = DepthBiTree(T->rchild); //向右子樹挖掘深度
if(l_depth > r_depth) //返回左右子樹中的較大層數
return l_depth + 1;
else
return r_depth + 1;
}
}
統計二叉樹的結點數
還是用遞歸,每一個子樹的結點數為其左子樹和右子樹的結點樹之和再加上它本身,也就是加 1。
int NodeCount(BiTree T)
{
if(T == Tree)
return 0; //若為空樹,則結點數為 0
else
return NodeCount(T->lchild) + NodeCount(T->rchild) + 1; //挖掘左右結點的節點個數
}
線索二叉樹
描述前驅與后繼
回顧一下雙向鏈表,為了能夠准確描述某個結點的前驅,我們給結點結構體引入了前驅指針域,通過前驅指針域我們就能夠清楚地知道一個結點的前驅,而不需要再次遍歷。那么再看一下二叉樹,雖然在樹結構中,結點間的關系是一對多的關系,但是當我們遍歷二叉樹時,無論是前序遍歷、中序遍歷還是后序遍歷,我們使用了某些規則使得我們能夠按照一定的順序來描述二叉樹的結點。也就是說,例如我們使用中序遍歷的時候,我們是可以按照中序遍歷的規則明白一個結點的前驅和后繼的,但是如果我們需要知曉這一點,就不得不進行一次遍歷操作。那么我們能不能像雙向鏈表那樣開辟一些指針域來描述前驅與后繼的關系呢?

別急,我們先來觀察一下,如圖所示的二叉樹使用二叉鏈表來組織結點,中序遍歷的結點順序為 GDBEACF,但是我們發現並不是所有的結點的指針域都得到了充分的應用。該二叉樹有 7 個結點,也就是說有 14 個指針域,可是我們只使用了 6 個指針域來描述邏輯關系。再接着看,當我們需要描述后繼關系時,也就是 G->D、D->B、E->A、F->NULL 這四個關系,描述清楚之后就能夠吧中序遍歷所得的后繼關系說明白;描述前驅關系時,需要把 G->NULL、E->B、C->A、F->C 這四個關系說明白。觀察一下,如圖二叉樹有 6 個分支,這些分支分別需要有 1 個指針域來存儲信息,總共有 14 個指針域,那也就是還有 8 個指針域是空閑的,然后我們就能發現,這個數字與我們要描述清前驅后繼所需要的指針域是一樣的,也就是說我們無需對結構體的定義進行操作,只需要將這些空閑的空間充分利用即可。
如圖所示,描述后繼關系:

描述前驅關系:

對於這類用於描述前驅和后繼的指針,我們稱之為線索,而將空閑的指針域利用起來的二叉鏈表,也就是引入線索的二叉鏈表成為線索鏈表,描述的二叉樹成為線索二叉樹。通過對線索的使用,我們把一棵二叉樹描述為一個雙向鏈表,我們很清楚雙線鏈表的插入、刪除和查找結點的操作都是很方便的,而我們以某種遍歷順序設置線索的過程成稱為線索化。線索二叉樹的結構即充分利用了二叉樹的空指針域,又使得一次遍歷就能獲取結點的前驅和后繼信息,既節省了空間也節省了時間。

線索二叉樹結點結構體定義
我們明白了可以利用空閑的指針域來描述前驅后繼,但是我們要如何確定這些指針域是用來描述左右子結點還是前驅后繼的關系的呢?也就是說,我們不僅需要一些機制來進行判斷,更要留下一些標志來為我們后續的訪問提供便利。我們的做法是,引入兩個 bool 性成員變量 ltag、rtag,當 ltag 的值為 0 時表示指針域指向該結點的左結點,值為 1 時指向該結點的前驅,rtag 的用法同理。
typedef enum {Link,Thread} PointerTag; //Link 表示指向子結點,Thread 表示指向前驅或后繼
typedef struct BiThrNode
{
ElemType data; //數據域
BiThrNode *lchild,*rchild; //左右孩子的指針域
PointerTag LTag; //判斷左指針域作用的 flag
PointerTag RTag; //判斷右指針域作用的 flag
}BiThrNode, *BiThrTree;

線索化
所謂線索化就是將二叉樹中沒有使用的空閑指針域進行修改,使其能夠描述前驅和后繼的過程,而前驅和后繼的信息我們在遍歷的時候比較關心,因此線索化本質上就是在中序遍歷的時候添加描述的過程,算法的實現也是基於遍歷算法的實現。
BiThrTree pre; //當前訪問結點的前驅指針
void InThreading(BiThrTree ptr)
{
if(ptr != NULL)
{
InThreading(ptr->lchild); //左子樹線索化
if(!ptr->lchild) //結點無左子樹
{
ptr->LTag = Thread; //修改 flag
ptr->lchild = pre; //左指針域指向前驅
}
if(!pre->rchild) //結點的前驅無右子樹
{
pre->RTag = Thread; //修改 flag
pre->rchild = ptr; //右指針域指向后繼
}
pre = ptr; //移動 pre,使其始終指向當前操作結點的前驅
InThreading(ptr->rchild); //左子樹線索化
}
}
遍歷線索二叉樹
由於線索二叉樹實現了近似於雙向鏈表的結構,因此我們可以添加一個頭結點,使其左指針域指向線索二叉樹的根結點,右指針域指向中序遍歷訪問的最后一個結點。同時我們可以運用一下循環鏈表的思想,使中序遍歷的第一個結點的左指針域和最后一個結點的右指針域指向頭結點,就能夠實現從任何結點出發都能夠完整遍歷線索二叉樹的功能了。該算法時間復雜度 O(n)。
bool InOederTraverse_Thr(BiThrTree T) //指針 T 指向頭結點
{
BiThrTree ptr = T->lchild; //ptr 初始化為根結點
while(ptr != T) //遇到空樹或遍歷結束時,ptr 會指向頭結點
{
while(ptr->LTag == Link) //結點指向左子樹時循環到中序序列的第一個結點
ptr = ptr->lchild;
cout << ptr->data;
while(ptr->RTag == Thread && ptr->rchild != T) //中序遍歷,並找到下一個右子樹
{
ptr = ptr->rchild;
cout << ptr->data;
}
ptr = ptr->rchild; //ptr 進入右子樹
}
return true;
}
哈夫曼樹
左轉我的另一篇博客——哈夫曼樹與哈夫曼編碼
參考資料
《大話數據結構》—— 程傑 著,清華大學出版社
《數據結構教程》—— 李春葆 主編,清華大學出版社
《數據結構(C語言版|第二版)》—— 嚴蔚敏 李冬梅 吳偉民 編著,人民郵電出版社
