1. 程式人生 > >資料結構與演算法:二叉樹

資料結構與演算法:二叉樹

二叉樹是一種非常常見並且實用的資料結構,它結合了有序陣列與連結串列的優點。在二叉樹中查詢資料與在陣列中查詢資料一樣快,在二叉樹中新增、刪除資料的速度也和在連結串列中一樣高效,所以有關二叉樹的相關技術一直是程式設計師面試筆試中必考的知識點。

基礎知識

二叉樹(Binary Tree)也稱為二分樹、二元樹、對分樹等,它是n(n>=0)個有限元素的集合。該集合或者為空,或者由一個稱為根(root)的元素及兩個不想交的、被分別稱為左子樹和右子樹的二叉樹組成。當集合為空時,稱該二叉樹為空二叉樹。

在二叉樹中,一個元素也稱為一個結點。二叉樹的遞迴定義:二叉樹或者是一棵空樹,或者是一棵由一個根結點和兩棵互不相交的分別稱做根結點的左子樹和右子樹所組成的非空樹,左子樹和右子樹又同樣都是一棵二叉樹。

基本概念

以下是一些常見的二叉樹的基本概念: 
(1)結點的度。結點所擁有的子樹的個數稱為該結點的度 
(2)葉結點。度為0的結點稱為葉結點,或者稱為終端結點 
(3)分枝結點。度不為0的結點稱為分支節點,或者稱為非終端結點。一棵樹的結點除葉結點以外,其餘的都是分支結點。 
(4)左孩子、右孩子、雙親。樹中一個結點的子樹的根結點稱為這個結點的孩子。這個結點稱為它孩子結點的雙親。具有同一個雙親的孩子結點互稱為兄弟。 
(5)路徑、路徑長度。如果一棵樹的一串結點n1,n2,…,nk有如下關係:結點ni時ni+1的父節點(1<=i<k),就把n1,n2,…,nk稱為一條由n1~nk的路徑。這條路徑的長度是k-1 
(6)祖先、子孫。在樹中,如果有一條路徑從結點M~結點N,那麼M就稱為N的祖先,而N稱為M的子孫 
(7)結點的層數。規定樹的根結點的層數為1,其餘結點的層數等於它的雙親節點的層數加1。 
(8)樹的深度。樹中所有結點的最大層數稱為樹的深度。 
(9)樹的度。樹中各結點度的最大值稱為該樹的度,葉子結點的度為0。 
(10)滿二叉樹。在一棵二叉樹中,如果所有分支節點都存在左子樹和右子樹,並且所有葉子結點都在同一層上,這樣的一棵二叉樹稱為滿二叉樹 
(11)完全二叉樹。一棵深度為k的有n個結點的二叉樹,對樹中的結點按從上至下、從左到右的順序進行編號,如果編號為i(1<=i<=n

)的結點與滿二叉樹中編號為i的結點在二叉樹中的位置相同,則這棵二叉樹稱為完全二叉樹。完全二叉樹的特點是:葉子結點只能出現在最下層和次下層,且最下層的葉子結點集中在樹的左部。需要注意的是,滿二叉樹肯定是完全二叉樹,而完全二叉樹不一定是滿二叉樹。

性質

二叉樹的基本性質如下 
性質1:一棵非空二叉樹的第i層上最多有2^(i-1)個結點(i>=1)

性質2:一棵深度為k的二叉樹中,最多具有 2^k - 1個結點,最少有k個結點

性質3:對於一棵非空的二叉樹,度為0的結點(即葉子結點)總是比度為2的結點多一個,即如果葉子結點數為n0,度為2的結點數為n2,則有 n0 = n2 + 1.

證明:用n0表示度為0(葉子結點)的結點總數,用n1表示度為1的結點總數,n2表示度為2的結點總數,n表示整個完全二叉樹的結點總數,則n=n0+n1+n2.根據二叉樹和樹的性質,可知 n=n1+2*n2+1 (所有結點的度數之和 +1 = 結點總數),根據兩個等式可知 n0 + n1 +n2 = n1+2*n2 +1 ,所以, n2 = n0-1,即 n0 = n2 + 1. 所以 n = n0 + n1 + n2。

性質4:具有n個結點的完全二叉樹的深度為 log2 n + 1(log以2為底,n的對數,向下取整)

證明:根據性質2,深度為k的二叉樹最多隻有 2^k -1 個結點,且完全二叉樹的定義是與同深度的滿二叉樹前面編號相同,即它的總結點數n位於k層和k-1層滿二叉樹容量之間,即2^(k-1)-1 < n <= 2^(k-1) - 1 或 2^(k-1) <= n < 2^k,三邊同時取對數,於是有 k-1 <= log2n < k因為k是整數,所以深度為 性質4所述。

性質5:對於具有n個結點的完全二叉樹,如果按照從上至下和從左到右的順序對二叉樹中的所有結點從1開始順序編號,則對於任意的序號為i的結點,有: 
(1)如果i>1,則序號為i的結點的雙親節點的序號為 i/2;如果 i=1,則序號為i的結點 是根結點,無雙親結點。 
(2)如果2i<=n,則序號為i的結點的左孩子結點的序號為2i;如果2i>n,則序號為i的結點無左孩子。 
(3)如果2i+1 <= n,則序號為i的結點的右孩子結點的序號為2i+1;如果2i+1>n,則序號為i的結點無右孩子

此外,若對二叉樹的根結點從0開始編號,則相應的i號結點的雙親結點的編號為(i-1)/2,左孩子的編號為 2i+1,右孩子的編號為 2i+2。

有關二叉樹的例題

題目

例題1: 一棵完全二叉樹上有1001個結點,其中葉子結點的個數是多少?

例題2:如果根的層次為1,具有 61個結點的完全二叉樹的高度為多少?

例題3:在具有100個結點的樹中,其邊的數目為多少?

解析

例題1:二叉樹的公式: n = n0 + n1 + n2 = n0+n1+(n0-1) = 2*n0 + n1 -1.而在完全二叉樹中,n1只能取0或1.若n1 = 1,則 2*n0 = 1001 , 可推出n0為小數,不符合題意;若 n1 =0, 則 2*n0-1 = 1001, 則 n0 = 501.所以答案為501.

例題2:如果根的層次為1,具有61個結點的完全二叉樹的高度為多少? 
根據二叉樹的性質,具有n個結點的完全二叉樹的深度為 log2n + 1 (log以2為底n的對數),因此含有61個結點的完全二叉樹的高度為 log2n + 1 (log以2為底n的對數),即應該為6層。所以答案為6.

例題3:在具有100個結點的樹中,其邊的數目為多少? 
在一棵樹中,除了根結點之外,每一個節點都有一條入邊,因此總邊數應該是 100-1,即99條。所以答案為 99

遞迴實現二叉樹的遍歷

二叉樹的先序遍歷的思想是從根結點開始,沿左子樹一直走到沒有左孩子的結點為止,依次訪問所經過的結點,同時所經結點的地址進棧,當找到沒有左孩子的結點時,從棧頂退出該結點的雙親的右孩子。此時,此結點的左子樹已訪問完畢,再用上述方法遍歷該結點的右子樹,如此重複到棧空為止。

二叉樹中序遍歷的思想是從根結點開始,沿左子樹一直走到沒有左孩子的結點為止,並將所經結點的地址進棧,當找到沒有左孩子的結點時,從棧頂退出該結點並訪問它。此時,此結點的左子樹已訪問完畢,再用上述方法遍歷該結點的右子樹,如此重複到棧空為止。

二叉樹後序遍歷的思想是從根結點開始,沿左子樹一直走到沒有左孩子的結點為止,並將所經結點的地址第一次進棧,當找到沒有左孩子的結點時,此結點的左子樹已訪問完畢,從棧頂退出該結點,判斷該結點是否為第一次進棧。如果是,再將所經結點的地址第二次進棧,並沿該結點的右子樹一直走到沒有右孩子的結點為止;如果不是,則訪問該結點。此時,該結點的左右子樹都已完全遍歷,且令指標p=NULL,如此重複直到棧空為止。

已知先序遍歷和中序遍歷,如何求後序遍歷

一般資料結構都有遍歷操作,根據需求的不同,二叉樹一般有以下幾種遍歷方式:先序遍歷、中序遍歷、後序遍歷和層序遍歷

(1)先序遍歷:如果二叉樹為空,遍歷結束。否則,第一步,訪問根結點;第二步,先序遍歷根結點的左子樹;第三步,先序遍歷根結點的右子樹。 
(2)中序遍歷:如果二叉樹為空,遍歷結束。否則,第一步,中序遍歷根結點的左子樹;第二步,訪問根結點;第三步,中序遍歷根結點的右子樹。 
(3)後序遍歷:如果二叉樹為空,遍歷結束。否則,第一步,後序遍歷根結點的左子樹;第二步,後續遍歷根結點的右子樹;第三步,訪問根結點 
(4)層次遍歷:從二叉樹的第一層(根結點)開始,從上至下逐層遍歷,在同一層中,則按從左到右的順序對結點逐個訪問

這裡寫圖片描述

圖13-15 的各種遍歷結果如下: 
先序遍歷 ABDHIEJCFG 
中序遍歷 HDIBJEAFCG 
後序遍歷 HIDJEBFGCA 
層次遍歷 ABCDEFGHIJ

例如,先序序列為 ABDECF, 中序序列為 DBEAFC。求後序序列。

首先先序遍歷樹的規則為根左右,可以看到先序遍歷序列的第一個元素必為樹的根結點,則A就為根結點。再看中序遍歷為左根右,再根據根結點A,可知左子樹包含元素為 DBE,右子樹包含元素為FC。然後遞迴求解左子樹(左子樹的先序為 BDE,中序為 DBE),遞迴求解右子樹(即右子樹的先序為 CF,中序為 FC)。如此遞迴到沒有左右子樹為止。所以,樹結構如圖 13-16所示。

通過上面的例子可以總結出用先序遍歷和中序遍歷來求解二叉樹的過程,步驟如下: 
(1)確定樹的根結點。樹根是當前樹中所有元素在先序遍歷中最先出現的元素,即先序遍歷的第一個節點就是二叉樹的根。

(2)求解樹的子樹。找到根在中序遍歷的位置,位置左邊是二叉樹的左孩子,位置右邊是二叉樹的右孩子,若根結點左邊或右邊為空,則該方向子樹為空;若根結點左邊和右邊都為空,則根結點已經為葉子結點。

(3)對二叉樹的左、右孩子分別進行步驟(1)(2),直到求出二叉樹結構為止。

這裡寫圖片描述

引申,已知中序遍歷和後序遍歷,求先序遍歷

第一步確定樹的跟,樹根是當前樹中所有元素在後序遍歷中最後出現的元素。 
第二步求解樹的子樹,找出根結點在中序遍歷中的位置,根左邊的所有元素就是左子樹,根右邊的所有元素就是右子樹,如果根結點左邊或右邊為空,則該方向子樹為空;若根結點左邊和右邊都為空,則根結點已經為葉子結點。 
第三步遞迴求解樹,將左子樹和右子樹分別看成一棵二叉樹。重複以上步驟,直到所有的結點完成定位。該過程 與根據先序序列和中序序列求解樹的過程類似,略有不同。

需要注意的是,如果知道先序和後序遍歷序列,是無法構建二叉樹的。例如,先序序列為 ABDECF,後序序列為 DEBFCA,此時只能確定根結點,而對於左右子樹的組成不確定。

非遞迴實現二叉樹的後序遍歷

後序遍歷可以用遞迴實現,程式中遞迴的呼叫就是儲存函式的資訊在棧中。一般情況下,能用遞迴解決的問題都可以用棧解決,知識遞迴更符合人們的思維方式,程式碼相對而言也更簡單,但不能說明遞迴比棧的方式更快、更節省空間,因為在遞迴過程中都是作業系統來幫助用棧實現儲存資訊。下面用棧來實現二叉樹的後序遍歷。

棧的思想是“先進後出”,即首先把根結點入棧(這時棧中有一個元素),根結點出棧的時候再把它的右左孩子入棧(這時棧中有兩個元素,注意是“先進右後進左”,不是“先進左後進右”),再把棧頂出棧(也就是左孩子),再把棧頂元素的右左孩子入棧,此過程一直執行直到棧為空,出棧的元素按順序排列就是這個二叉樹的先序遍歷。

用棧來解決二叉樹的後序遍歷是最後輸出父親結點,先序遍歷是在結點出棧時入棧右左孩子。顯然,對於後序遍歷,不應該在父親結點出棧時,才把右左孩子入棧,應該在入棧時就把右左孩子一併入棧。在父親結點出棧時,應該判斷右左孩子是否已經遍歷過(是否執行過入棧),那麼就應該由一個標記來判斷還在是否遍歷過。

下面借用二叉樹的結構體來定義一個適用於這個演算法的新結構體

typedef struct stackTreeNode
{
    BTree treeNode;
    int flag;
} *pSTree;
  • 1
  • 2
  • 3
  • 4
  • 5

結構體中,flag為標誌位,0表示左右孩子沒有遍歷 2表示左右孩子遍歷完,具體實現程式碼如下:

int lastOrder( BTree root )
{
    stack< pSTree > stackTree;
    pSTree sTree = ( pSTree) malloc( sizeof(struct stackTreeNode) );
    sTree->treeNode = root;
    sTree->flag = 0;
    stackTree.push( sTree );
    while( !stackTree.empty() )
    {
        psTree tmptree = stackTree.top();
        if(tmptree->flag == 2)
        {
            cout << tmptree->treeNode->data << " ";
            stackTree.pop();
        }
        else
        {
            if(tmptree->treeNode->rchild)
            {
                pSTree sTree = (pSTree) malloc( sizeof(struct stackTreeNode) );
                sTree->treeNode = tmptree->treeNode->rchild;
                sTree->flag = 0;
                stackTree.push(sTree);
            }
            tmptree->flag++;
            if( tmptree->treeNode->lchild )
            {
                PSTree sTree = (pSTree)malloc(sizeof(struct stackTreeNode));
                sTree->treeNode = tmptree->treeNode->lchild;
                sTree->flag = 0;
                stackTree.push(sTree);
            }
            tmptree->flag++;
        }
    }
    return 1;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37

如何使用非遞迴方法實現二叉樹的先序遍歷與中序遍歷

將二叉樹的先序遍歷遞迴演算法轉化為非遞迴演算法的方法如下: 
(1)將二叉樹的根結點作為當前節點。 
(2)若當前結點非空,則先訪問該結點,並將該結點進棧,再將其左孩子結點作為當前結點,重複步驟(2),直到當前結點為空為止。 
(3)若棧非空,則棧頂結點出棧,並將當前結點的右孩子結點作為當前結點 
(4)重複步驟(2)(3),直到棧為空且當前結點為空為止。

將中序遍歷遞迴演算法轉化為非遞迴演算法的方法如下: 
(1)將二叉樹的根結點作為當前結點。 
(2)若當前結點非空,則該結點進棧並將其左孩子結點作為當前結點,重複步驟(2),直到當前結點為空為止。 
(3)若棧非空,則將棧頂結點出棧並作為當前結點,接著訪問當前結點,再將當前結點的右孩子結點作為當前結點。 
(4)重複步驟(2)(3),直到棧為空且當前為空為止。

使用非遞迴演算法求二叉樹的深度

計算二叉樹的深度,一般都是用後序遍歷,採用遞迴演算法,先計算出左子樹的深度,再算出右子樹的深度,最後取較大者加1即為二叉樹的深度

typedef struct Node
{
    char data;
    struct Node *LChild;
    struct Node *RChild;
    struct Node *Parent;
}BNode,*BTree;

//後序遍歷求二叉樹的深度遞迴演算法
int PostTreeDepth( BTree root )
{
    int left,right, max;
    if( root!=NULL )
    {
        left = PostTreeDepth( root->LChild );   
        right = PostTreeDepth( root->RChild );
        max = left > right ? left : right ;
        return (max+1);
    }
    else
        return 0;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

如果直接將該演算法改成非遞迴形式是非常繁瑣和複雜的。考慮到二叉樹深度與深度的關係,可以有下面兩種非遞迴演算法實現求解二叉樹深度。

方法一:先將演算法改成先序遍歷再改寫非遞迴形式。先序遍歷演算法:遍歷一個結點前,先算出當前結點時在哪一層,層數的最大值就等於二叉樹的深度。

int GetMax( int a,int b )
{
    return a>b?a:b;
}
int GetTreeTreeHeightPreorder( const BTree root )
{
    struct Info
    {
        const BTree TreeNode;
        int level;
    }
    deque<Info> dq; //雙端佇列,可以在兩端進行插入和刪除元素
    int level = -1;
    int TreeHeight = -1;
    while(1)
    {
        while(root)
        {
            ++level;
            if(root->RChild)
            { 
                Info info = { root->RChild, level };
                dq.push_back( info ); // 尾部插入一資料
            }// end if
            root = root->LChild;
        }//while(root)
        TreeHeight = GetMax( TreeHeight, level );
        if( dq.empty())
            break;
        const Info&info = dq.back();// 返回最後一個數據
        root = info.TreeNode;
        level = info.level;
        dq.pop_back(); // 刪除最後一個數據
    }
    return TreeHeight;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36

方法二:修改上面提到的迭代演算法。上例中,所用到輔助棧(或雙端佇列)的大小達到的最大值減去1就等於二叉樹的深度。因而只需記錄在往輔助棧放入元素後(或者在訪問結點資料時),輔助棧的棧大小達到的最大值

int GetTreeHeightPostorder( const BTree root )
{
    deque<const BTree> dq; // 雙端佇列
    int TreeHeight = -1;
    while(1)
    {
        //先序將左子樹入棧
        for( ;root!=NULL; root=root->LChild )
            dq.push_back( root );
        //dq.size()輔助棧的大小
        TreeHeight = GetMax( TreeHeight, (int)dq.size()-1 );
        while(1)
        {
            if(dq.empty()) return TreeHeight;
            const BTree parrent = dq.back();
            const BTree Rchild = parrent->RChild;
            if( RChild&& root!=RChild )
            {
                root = RChild;
                break;
            }
            root = parrent;
            dq.pop_back();
        }
    }
    return TreeHeight;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

霍夫曼編解碼

霍夫曼編碼用到一種叫做“字首編碼”的技術,即任意一個數據的編碼都不是另一個數據編碼的字首。而最優二叉樹,即霍夫曼樹(帶權路徑長度最小的二叉樹)就是一種實現霍夫曼編碼的方式。霍夫曼編碼的過程就是構造霍夫曼樹的過程,構造霍夫曼樹的相應演算法如下: 
(1)有一組需要編碼且帶有權值的字母,如a(4) b(8) c(1) d(2) e(11)。括號內分別為各字母相對應的權值。 
(2)選取字母中權值較小的兩個 c(1) d(2) 組成一個新二叉樹,其父節點的權值為這兩個字母權值之和,記為 f(3) ,然後將該結點加入到原字母序列中去(不包含已經選擇的權值最小的兩個字母),則剩下的字母為 a(4) b(8) e(11) f(3) 
(3)重複進行步驟(2),直到所有字母都加入到二叉樹中為止。(編碼一般是左0, 右1)

這裡寫圖片描述 
這裡寫圖片描述

霍夫曼樹的解碼過程與編碼過程正好相反,從根結點觸發,逐個讀入編碼內容;如果遇到0,則走左子樹的根結點,否則走向右子樹的根結點,一旦到達葉子結點,便譯出程式碼多對應的字元。然後又重新從根結點開始繼續譯碼,直到二進位制編碼結束。

這裡寫圖片描述