樹的概念與基本術語
樹是若干結點的集合,是由唯一的根和若干棵互不相交的子樹組成的。樹的概念是遞歸的,即在樹的定義中又用到了樹的定義。
結點的度:結點擁有的子樹個數或者分支的個數。
樹的度:樹中各結點度的最大值。
層次:從根開始,根為第一層,根的孩子為第二層。
樹的高度(或者深度):樹中結點的最大層次。
結點的深度:從根結點到該結點路徑上的結點個數
結點的高度:從某結點往下走可能到達多個葉子結點,對應了多條通往這些葉子結點的路徑,其中最長的那條路徑的長度即為該結點在樹中的高度。根結點的高度為樹的高度。
兄弟:同一個雙親的孩子之間互為兄弟。
堂兄弟:雙親在同一層的結點互為堂兄弟。
有序樹與無序樹:樹中的結點的子樹從左到右是有次序的,不能交換,這樣的樹叫做有序樹。可以隨便交換的叫做無序樹。
豐滿樹:豐滿樹即理想平衡樹,要求除最底層外,其他層都是滿的。
森林:若干棵互不相交的樹的集合。
樹的存儲結構:
1.順序存儲結構:
樹的順序存儲結構中最簡單直觀的是雙親存儲結構。用一個整數數組來存儲,下標就是該結點,數組元素的內容表示該結點的雙親結點。例如3的雙親是1,則tree[3]=1.
樹的雙親存儲結構在克魯斯卡爾算法中有重要應用。
2.鏈式存儲結構:
1)孩子存儲結構:孩子存儲結構實質上就是圖的鄰接表存儲結構。樹就是一種特殊的圖。
2)孩子兄弟存儲結構:孩子兄弟存儲結構與樹和森林與二叉樹的相互轉換關系密切。
二叉樹
二叉樹滿足以下定義:
- 每個結點最多只有兩棵子樹,即二叉樹中結點的度只能為0、1、2
- 子樹有左右順序之分,不能顛倒
滿二叉樹:如果所有的分支結點都有左孩子和右孩子結點,并且葉子結點都集中在二叉樹的最下一層,則這樣的二叉樹成為滿二叉樹。
完全二叉樹:如果對一棵深度為k,有n個結點的二叉樹進行編號后,各結點的編號與深度為k的滿二叉樹中相對位置上的結點的編號均相同,那么這棵樹就是一棵完全二叉樹。
一棵完全二叉樹一定是由一棵滿二叉樹從右到左,從下到上,挨個刪除結點所得到的。如果跳著刪除,則得到的不是完全二叉樹。
二叉樹的主要性質:
性質1. 非空二叉樹上葉子結點數等于雙分支結點數加1.
證明:假設葉子結點數為n0,單分支結點數為n1,雙分支結點數為n2,則總結點數為n0+n1+n2。總分支數為n1+2n2。由于除了根結點外,其它結點都有一個分支指向它,所以總分支數=總結點數-1。得到n1+2n2 = n0 + n1 + n2 - 1。得到:n2 = n0 - 1。
一個變形:問二叉樹中總的結點數為n,則樹中空指針數為多少?可以將空指針數看成是葉子結點,則每個結點都是雙分支結點。根據性質1,葉子結點數等于雙分支結點數加1,則空指針數=n+1.
性質2. 二叉樹的第i層最多有2^(i-1)個結點(i>=1).
結點最多的情況即為滿二叉樹的情況,此時二叉樹每層上的結點數構成了一個首項為1,公比為2的等比數列。通向為2^(i-1),i為層號。
性質3. 高度(或深度)為k的二叉樹最多有2^k - 1個結點.
最多的情況還是滿二叉樹,則根據等比公式求和可得(1-2k)/(1-2)=2k - 1.
在這里復習一下等比數列求和公式和等差數列求和公式:
性質4. 有n個結點的完全二叉樹,對各結點從上到下,從左到右依次編號,則結點之間有如下關系:(i為某結點a的編號)
- 如果i不等于1,則a的雙親結點的編號為i/2向下取整
- 如果2i<=n,則a的左孩子的編號為2i;如果2i > n,則a無左孩子
- 如果2i+1 <= n,則a的右孩子編號為2i+1;如果2i+1 > n,則a無右孩子
性質5. 函數catalan( ):給定n個結點,能構成h(n)種不同的二叉樹,h(n)=C(2n,n)/(n+1)
性質6. 具有n個結點的完全二叉樹的高度或深度為 (log2n向下取整+1),或者(log2(n+1)向上取整)
二叉樹的存儲結構
1. 順序存儲結構:
順序存儲結構即用一個數組來存儲一棵二叉樹,這種存儲方式最適合于完全二叉樹,用于存儲一般的二叉樹會浪費大量存儲空間。
將完全二叉樹中的結點值按編號依次存入一個一維數組中,即完成了一棵二叉樹的順序存儲。如果知道了一個結點的下標為i,則該結點的左孩子的下標為2i,右孩子為2i+1(當其存在時)。
2. 鏈式存儲結構(二叉鏈表存儲結構):
typedef struct BTNode {
char data; //數據域,可更改為其他類型
struct BTNode *lchild;
struct BTNode *rchild;
};
二叉樹的遍歷算法
二叉樹的遍歷方式有先序遍歷、中序遍歷、后序遍歷和層次遍歷。
1.先序遍歷preorder
根、左、右
void preorder(BTNode *p) {
if (p != NULL) {
visit(p); //對該結點的訪問操作,例如打印該結點的數值等信息
preorder(p->lchild);
preorder(p->rchild);
}
}
2.中序遍歷inorder
左、根、右
void inorder(BTNode *p) {
if (p != NULL) {
inorder(p->lchild);
visit(p);
inorder(p->rchild);
}
}
3.后序遍歷postorder
左、右、根
void postorder(BTNode *p) {
if (p != NULL) {
postorder(p->lchild);
postorder(p->rchild);
visit(p);
}
}
典型例題:寫一個算法求一棵二叉樹的深度,二叉樹以二叉鏈表為存儲方式。
int getDepth(BTNode *p) {
int LD, RD;
if (p == NULL)
return 0;
else {
LD = getDepth(p->lchild);
RD = getDepth(p->rchild);
return (LD>RD?LD:RD)+1;//返回左右子樹深度的最大值加1
}
}
一個結論:根據二叉樹的前、中、后3種遍歷序列中的前和中、中和后兩對遍歷序列都可以唯一確定這棵二叉樹,而根據前和后這對遍歷序列不能確定這棵二叉樹。
例題:假設二叉樹采用二叉鏈表存儲結構存儲,輸出先序遍歷序列中的第k個結點的值,假設k不大于總的結點數。
int n;
void print_k_of_preorder(BTNode *p, int k) {
if (p != NULL) {
++n;
if (k == n) {
cout<<p->data<<endl;
return;
}
print_k_of_preorder(p->lchild, k);
print_k_of_preorder(p->rchild, k);
}
}
4.層次遍歷level
對二叉樹的層次遍歷即從上到下,從左到右的遍歷結點。
對二叉樹的層次遍歷,需要建立一個循環隊列,先將二叉樹頭結點入隊列,然后出隊列,訪問該結點,如果它有左子樹,則將左子樹的根結點入隊;如果它有右子樹,則將右子樹的根結點入隊。然后出隊列,對出隊結點訪問,如此反復,直到隊列為空為止。
void level(BTNode *p) {
queue<BTNode *> queue;
BTNode *q;
if (p != NULL)
queue.push(p);
while (!queue.empty( )) {
q = queue.front( );
queue.pop( );
cout<<q->data<<endl;
if (q->lchild != NULL)
queue.push(q->lchild);
if (q->rchild != NULL)
queue.push(q->rchild);
}
}
二叉樹遍歷算法的改進
之前的幾個二叉樹的深度優先(DFS)遍歷算法,都是用遞歸實現的,這是很低效的。因為遞歸調用了系統本身的棧,會有很大開銷。用戶自己實現非遞歸的算法比較高效。
1. 先序遍歷的非遞歸實現preorder
根結點入棧。當棧不為空時,出棧棧頂元素,將其右結點入棧,左結點入棧...
void PreOrderNonRecursion(BTNode *root) {
stack<BTNode *> node;
BTNode *p = root;
if (p != NULL) {
node.push(p);
BTNode *q;
while (!node.empty()) {
q = node.top();
node.pop();
cout<<q->val<<endl;
if (q->rchild)
node.push(q->rchild);
if (q->lchild)
node.push(q->lchild);
}
}
}
2. 中序遍歷的非遞歸實現inorder
void InOrderNonRecursion(BTNode *root) {
stack<BTNode *> node;
BTNode *p, *q;
p = root;
while (!node.empty() || p != NULL) {
while(p != NULL) {
node.push(p);
p = p->lchild;
}
if (!node.empty()) {
p = node.top();
node.pop();
cout<<p->val<<endl;
p = p->rchild;
}
}
}
3. 后序遍歷的非遞歸實現postorder
vector<int> PostOrderNonCursivion(BTNode *root) {
vector<int> postSeq;
stack<BTNode *> s;
if (root == NULL)
return postSeq;
BTNode *cur;
s.push(root);
BTNode *pre = NULL;
while (!s.empty()) {
cur = s.top();
if ((cur->left == NULL && cur->right == NULL) || (pre != NULL) && (pre == cur->left || pre->cur->right)){
/* 如果這個結點是葉子結點,或該結點的左右孩子被訪問過了,則可訪問它 */
postSeq.push_back(cur->val);
pre = cur;
s.pop();
}
else {
if (cur->right != NULL) {
s.push(cur->right);
}
if (cur->left != NULL) {
s.push(cur->left);
}
}
}
}
線索二叉樹
二叉樹非遞歸遍歷算法避免了系統棧的調用,提高了一定的執行效率。線索二叉樹可以將用戶棧也省掉,把二叉樹的遍歷過程線性化,進一步提高效率。
n個結點的二叉樹有n+1個空鏈域,線索二叉樹將這些空鏈域利用起來。
二叉樹被線索化后近似于一個線性結構,分支結構的遍歷操作就轉化為了近似于線性結構的遍歷操作,通過線索的輔助使得尋找當前結點前驅或者后繼的平均效率大大提高。
線索二叉樹的結點定義如下:
typedef struct TBTNode {
char data;
int ltag, rtag; //線索標記
struct TBTNode *lchild;
struct TBTNode *rchild;
};
其中的兩個線索標記,如果ltag=0,則表示lchild為指針,指向結點的左孩子;如果ltag=1,則表示lchild為線索,指向結點的直接前驅。
如果rtag=1,則表示rchild為線索,指向結點的直接后繼。
線索二叉樹可以分為前序線索二叉樹、中序線索二叉樹、后序線索二叉樹。對一棵二叉樹中所有結點的空指針域按照某種遍歷方式加線索的過程叫做線索化,被線索化了的二叉樹稱為線索二叉樹。
中序線索二叉樹的考察最頻繁。中序線索化的規則是,左線索指針指向當前結點在中序遍歷序列中的前驅結點,右線索指針指向后繼結點。
void InThread(TBTNode *p, TBTNode *&pre) {
if (p != NULL) {
InThread(p->lchild, pre);
if (p->lchild == NULL) {
p->lchild = pre;
p->ltag = 1;
}
if (pre != NULL && pre->rchild == NULL) {
pre->rchild = p;
pre->rtag = 1;
}
pre = p;
InThread(p->rchild, pre);
}
}
通過中序遍歷建立中序線索二叉樹的主程序如下:
void createInThread(TBTNode *root) {
TBTNode *pre = NULL;
if (root != NULL) {
InThread(root, pre);
pre->rchild = NULL;
pre->rtag = 1;
}
}
二叉樹的應用
1. 二叉排序樹和平衡二叉樹
2. 赫夫曼樹和赫夫曼編碼
赫夫曼樹
赫夫曼樹又叫做最優二叉樹,它的特點是帶權路徑最短。
樹的帶權路徑長度WPL:所有葉子結點的帶權路徑長度(從該結點到根之間的路徑長度乘以結點的權值)之和。
赫夫曼樹的構造方法
1)將這n個權值分別看作只有根結點的n棵二叉樹,這些二叉樹構成的集合記為F
2)從F中選取兩棵根結點的權值最小的樹作為左右子樹,構造一棵新的二叉樹,新的二叉樹的根結點的權值為左右子樹根結點權值之和。
3)重復進行,直到構造成一棵二叉樹。
赫夫曼編碼
在存儲文件的時候,對于包含同一內容的文件有多種存儲方式,可以找出一種最節省空間的存儲技術。這就是赫夫曼編碼的用途。常見的.zip壓縮文件和.jpeg圖片文件的底層技術都用到了赫夫曼編碼。
按字符出現的次數為權值,構造赫夫曼樹,再進行前綴編碼。