數據結構與算法(C++)學習筆記:二叉樹(更新完畢)

部分圖片非原創,侵刪

基本概念

樹在C++裏是一種非常重要的數據結構,應用頻率僅次於圖。
樹在生活中的應用主要有:

  • 編譯系統:表示源程序的語法結構
  • 數據庫系統:組織信息,高效查找或檢索
  • 計算機系統:XML、DOM樹、JSON數據、磁盤路徑結構
  • 文件壓縮:Huffman(文章末尾會講到)

樹的基本概念
三種易混淆二叉樹的辨析

存儲結構

樹的存儲結構

雙親表示法

[數組]

#define MaxSize 100
template<class T>//模板類,可提高代碼重用性
struct pNode
{
   T data;//此結點存儲的數據
   int parent;//存儲父結點的序號
};
pNode<T>Tree[MaxSize];//此處在使用時應把T換成具體數據類型
int size;

雙親表示法
時間複雜度:

  • 由葉結點向上搜索根節點:
    最好的情況:O(1)
    最壞的情況:O(n) 左、右斜樹
  • 由子結點向上搜索父結點:O(1)
  • 從根節點搜索葉結點:O(n)
  • 搜索兄弟結點:O(n)
    結論:此方法只適用於搜索雙親或根節點

帶右兄弟的雙親表示法

[數組]

template<class T>//模板類,可提高代碼重用性
struct pNode
{
    T data;//此結點存儲的數據
    int parent, rightbrt;//存儲父結點和右兄弟的序號
};
pNode<T>Tree[MaxSize];//此處在使用時應把T換成具體數據類型
int size;

帶右兄弟的雙親表示法
時間複雜度:

  • 搜索右兄弟:O(1)
  • 搜索孩子結點:O(n)
    結論:此方法僅適用於搜索雙親和右兄弟

孩子表示法

//孩子結點
struct CNode
{
    int child;//存儲數據
    CNode *next;//指向下一個孩子結點的指針
};
//表頭結點,只與第一個孩子有直接聯繫
template<class T>
struct CBNode
{
    T data;
    CNode *firstchild;
};

孩子結點表示法
優勢:便於搜索孩子結點
弊端:沒有孩子的表頭結點處,浪費了大量的指針空間;不易從下到上搜索雙親

孩子雙親表示法

孩子雙親表示法
優勢:便於尋找雙親和孩子
弊端:利用了順序表,規模較大
感悟:二叉樹更結構平衡,不會有很長的孩子鏈

多重鏈表法

多重鏈表表示法
優勢:便於查找孩子,邏輯上非常簡單粗暴
弊端:首先,效率非常低;其次,每個結點的指針域要選擇與樹的度已知(結點最大的度),會造成空間的大量浪費

孩子兄弟表示法

template<class T>
struct TNode
{
    T data;
    TNode<T> *firstchild,*rightbrt;//兩個指針分別指向雙親與右兄弟
};

孩子兄弟表示法
優勢:方便搜索孩子與兄弟,是比較好用的一種方法
感悟:上邊的圖片是不是有點眼熟?沒錯,這不就是把一般的樹轉化成了二叉樹嘛!

二叉樹的存儲結構

順序存儲結構

方法:

  1. 將二叉樹按照完全二叉樹編號
  2. 用一維數組存儲該二叉樹
  3. 無結點的位置也要編號,賦值爲NULL
    二叉樹的順序存儲結構
    優勢:在二叉樹結構已知的情況下可以快速搜索

二叉鏈表

template<class T>
struct Node
{
    T data;
    Node<T> *lch;
    Node<T> *rch;
};

二叉鏈表
優勢:邏輯簡單
弊端:很難向上搜索

三叉鏈表

template<class T>
struct Node
{
    T data;
    Node<T> *parent,*lchild,*rchild;
};

三叉鏈表
在二叉鏈表的基礎上,加了一個指向雙親的指針,方便向上搜索

基本操作簡介

遍歷的定義:從根節點出發,按照某種次序依次訪問所有結點,且每個結點僅訪問一次。
注意體會這裏隱含的遞歸思想。

二叉樹的遍歷

前序遍歷

  1. 訪問根節點 D
  2. 前序遍歷左子樹 L
  3. 前序遍歷右子樹 R

中序遍歷

  1. 中序遍歷左子樹 L
  2. 訪問根節點 D
  3. 中序遍歷右子樹 R

後序遍歷

  1. 後序遍歷左子樹 L
  2. 後序遍歷右子樹 R
  3. 訪問根節點 D

層序遍歷

從上到下,從左到右依次遍歷。

下面來從實例來理解樹的遍歷方法
樹的遍歷例題
前序遍歷 D-L-R: ABDEFGC
中序遍歷 L-D-R: DBFEGAC
後序遍歷 L-R-D: DFGEBCA
層序遍歷(最簡單): ABCDEFG
個人經驗:牢記每種遍歷方式對應的字母口訣,再套用即可。

重要題型:由兩種給出的遍歷方式來推理一棵二叉樹的結構。
例:已知二叉樹的前序序列{ABCDEFGH} 和中序序列 {CDBAFEHG} ,試確定該二叉樹的結構。
例題解答
TIP:注意中序遍歷左孩子在最前面。(對於各種遍歷的組合,解題方法類似)

樹的遍歷

前序遍歷

  1. 訪問根節點
  2. 從左到右遍歷各個子樹

後序遍歷

  1. 從左到右遍歷每一棵子樹
  2. 訪問根節點

層序遍歷

從上到下,從左到右訪問每個結點。
樹的遍歷練習

森林的遍歷

前序遍歷

從左到右前序遍歷每個子樹。

後序遍歷

從左到右後序遍歷每個子樹。

樹、森林與二叉樹的轉換

樹->二叉樹

【前面挖的坑這裏就填上啦】
利用樹的孩子兄弟表示法,即可將一般樹轉化爲二叉樹。
樹轉化爲二叉樹

森林->二叉樹

  1. 分別將森林中的每棵樹轉化爲二叉樹
  2. 從左到右連接新的二叉樹:後一棵樹的根節點是前一棵樹根節點的右孩子
    森林轉化爲二叉樹

二叉樹的實現(重難點)

二叉樹的聲明

template<class T>
struct BiNode//這裏採用的是二叉鏈表法
{
    T data;
    BiNode<T> *lchild;
    BiNode<T> *rchild;
}template<class T>
class BiTree
{
private:
    void Create(BiNode<T> *&R,T data[],int i,int n)//創建   思考:這裏爲什麼要用二級指針捏
    void Release(BiNode<T> *R);//釋放
public:
    BiNode<T> *root;//思考:根節點爲什麼是公有的?
    BiTree():root(NULL){} //空樹
    BiTree(T data[],int n);
    void PreOrder(BiNode<T>*R);//前序
	void InOrder(BiNode<T>*R);//中序
	void PostOrder(BiNode<T>*R);//後序
	void LevelOrder(BiNode<T>*R);//層序
    ~BiTree();
}

二叉樹的關鍵算法

二叉樹的創建

利用順序存儲結構建立二叉鏈表
由性質5 若當前結點爲i,則左孩子爲2i,右孩子爲2i+1(此處不考慮值是否爲零)
基本思想:

  1. 建立根節點
  2. 建立左子樹
  3. 建立右子樹
    順序結構存儲二叉樹
template <class T>
void BiTree<T>::Create(BiNode<T>* &R, T data[], int i, int n) //創建二叉樹
{ //R當前要建立的根節點指針,i當前根節點編號  n表示最後一個葉子結點的編號
	//R的類型:指針的引用
	if ((i <= n) && (data[i - 1] != '0'))
	{
		R = new BiNode<T>;					//1、建立根結點
		R->data = data[i - 1];
		R->lchild = NULL;
		R->rchild = NULL;

		Create(R->lchild, data, 2 * i, n);		//2、建立左子樹
		Create(R->rchild, data, 2 * i + 1, n);//3、建立右子樹
	}
}
template<class T>
BiTree<T>::BiTree(T data[],int n)
{
  Create(root,data,1,int n);
}

將上述過程可視化
創建
反思:這種方法有一個明顯的弊端–會佔用大量的空間,這就是爲什麼我們更願意使用二叉樹。

二叉樹的析構

易錯點:容易產生內存泄漏
基本思路:採用後序遍歷的方法從下到上釋放結點空間

template<class T>
void BiTree<T>::Release(BiNode<T> *R)//由於析構函數是不可以有形參的,只能單獨寫一個函數來釋放空間
{
  if(R != NULL)
    {
       Release(R->lchild);
       Release(R->rchild);
       delete R;
    }
}
template<class T>
BiTree<T>::~BiTree()
{
  Release(root);
}

PS:由於析構函數對於一棵樹只運行一次,所以在這裏使用遞歸並不會對程序的效率產生太大影響。但是遞歸的使用,對下面要介紹的遍歷操作來說,影響就十分嚴重了。

二叉樹的關鍵操作

二叉樹的遍歷(二叉鏈表)

前序遍歷

遞歸
template<class T>
void BiTree<T>::PreOrder(BiNode<T> *Root)
{
    if(Root != NULL)
    {
      cout<<Root->data;//結點
      PreOrder(Root->lchild);//左子樹
      PreOrder(Root->rchild);//右子樹
    }
}

遞歸過程可視化:
(在這裏結合了棧的思想)
前序遍歷
反思:在遍歷的整個過程中,會涉及大量的入棧、出棧操作,再加上這一操作是用遞歸來實現的,回使程序的運行非常低效。
思考:對於樹而言,遍歷是經常使用的操作,不可能只用遞歸來實現。那麼有沒有什麼更好的方法呢?

非遞歸

所有的遞歸函數都可以改寫爲效率較高的非遞歸函數。
對於二叉樹的遍歷操作,只要找出遍歷的結點入棧、出棧
打印的規律,就可以瞭解遞歸遍歷的全過程。但是當函數調用結束以後,如何區分是左子樹還是右子樹呢?
基本思想:

  • 利用一個簡單的標記,規定:整型數據爲1-左子樹,2-右子樹。
    前序遍歷2

第一次迭代優化:遞歸->非遞歸
僞代碼:(棧頂元素永遠爲當前結點的父結點)
(1) 若R != NULL,訪問R併入棧,調用 R=R->lchild (設R標記爲1)返回(1)
(2) 若R==NULL,重新設 R=棧頂元素
(2.1) 若R標記爲2,說明右子樹返回,R出棧,重新設R=棧頂元素,返回(2.1)
(2.2) 若R標記爲1,說明左子樹返回,調用 R=R->rchild(設R標記爲2),返回(1)
(反覆執行至 棧空&&當前結點=NULL)
前序遍歷

在這裏我們先定義一個棧中的結點元素

template<class T>class SNode
{
   public:
        BiNode<T> *R;
        int tag;
}

PS:使用順序棧–結點數組
前序遍歷函數(這裏的形參又變成了一級指針)

template<class T>
void BiTree<T>::PreOrder(BiNode<T> *R)
{
    SNode<T> S[100]; //棧
    int top = -1; //棧頂指針
    do
    {
       while(R != NULL)   //入棧,訪問左子樹
       {
          S[++top].R = R;  
          S[top].tag = 1;  //標記爲1
          cout<<R->data;
          R = R->lchild;
       }
       while((top != -1)&&(S[top].tag == 2)) top--; //出棧
       if((top != -1)&&(S[top].tag == 1))  //訪問右子樹
       {
           R = S[top].R->rchild;
           S[top].tag = 2;//設置棧頂標記
       }
    }while(top != -1);
}

思考:是否可以進一步優化呢?
**分析:**對於當前結點,當訪問玩它的左子樹以後,需要依靠它找到它的右子樹,此後當前結點就可以不再提供任何信息了,只需要靜靜的等待出棧。所以,讓當前結點提前出棧,就可以簡化非遞歸過程。

這就需要棧:

  1. 保存當前結點
  2. 訪問當前結點的左子樹
  3. 訪問當前節點左子樹,當前結點出棧
  4. 訪問其右子樹

第二次迭代優化:
優化後

template<class T>
void BiTree<T>::PreOrder(BiNode<T> *R)
{
    BiNode<T>  S[100];
    int top = -1;
     while((top != -1)||(R != NULL))
    {
        if(R != NULL)
        {
            cout<<R->data;  //訪問
            S[++top] = R;   //入棧
            R = R->lchild;
        }
        else
        {
            R = S[top--];    //當前結點出棧
            R = R->rchild;  //訪問當前結點的右孩子
        }
    }
}

**總結:**優化以後的非遞歸算法在空間與時間上都提高了效率,對於一棵有n個結點的二叉樹,其前序遍歷的時間複雜度爲O(nlog n).

中序遍歷

遞歸
template<class T>
void BiTree<T>::InOrder(BiNode<T> *Root)
{
  if(Root != NULL)
  {
    InOrder(Root->lchild);//左子樹
    cout<<Root->data;//結點
    InOrder(Root->rchild);//右子樹
  }
}
非遞歸

此處的分析方法與前序遍歷相同,唯一的區別就是結點的訪問順序變成了L-D-R

第一次迭代優化:遞歸->非遞歸
僞代碼:(棧頂元素永遠爲當前結點的父結點)
(1)若R != NULL,R入棧,調用 R=R->lchild(設R標記爲1) 返回(1)
(2)若 R==NULL,重新設 R=棧頂元素
(2.1)若R標記爲2,說明右子樹返回,R出棧,重新設R=棧頂元素,返回(1)
(2.2)若R標記爲1,說明左子樹返回,訪問R,並調用 R=R->rchild(設R標記爲2),返回(1)
反覆執行上述操作直到棧空

template<class T>
void BiTree<T>::InOrder(BiNode<T> *R)
{
    SNode<T> S[100];  //棧
    int top = -1;    //棧頂指針
    do 
    {
        while(R != NULL)   //入棧,設置遍歷左子樹
        {
            S[++top].R = R;
            S[top].tag = 1;
            R = R->lchild;
        }
        while((top != -1)&&(S[top].tag == 2))  top--; //出棧
        if((top != 1)&&(S[top].tag == 1))   //訪問棧頂元素
        {                                   //遍歷右子樹
            cout<<S[top].R->data;
            R = S[top].R->rchild;
            S[top].tag = 2;
        }
    }while(top != -1);
}

第二次迭代優化:

template<class T>
void BiTree<T>::InOrder(BiNode<T> *R)
{
    BiNode<T>  S[100];
    int top = -1;
     while((top != -1)||(R != NULL))
    {
        if(R != NULL)
        {
            S[++top] = R;   //入棧
            R = R->lchild;
            cout<<R->data;  //訪問
        }
        else
        {
            R = S[top--];    //當前結點出棧
            R = R->rchild;  //訪問當前結點的右孩子
        }
    }
}

後序遍歷

template<class T>
void BiTree<T>::PostOrder(BiNode<T> *Root)
{
  if(Root != NULL)
  {
    PostOrder(Root->lchild);//左子樹
    PostOrder(Root->rchild);//右子樹
    cout<<Root->data;//結點
  }
}
非遞歸

第一次迭代優化:遞歸->非遞歸
僞代碼:(棧頂元素永遠爲當前結點的父結點)
(1)若R != NULL,R入棧,調用 R=R->lchild(設R標記爲1) 返回(1)
(2)若 R==NULL,重新設 R=棧頂元素
(2.1)若R標記爲2,說明右子樹返回,R出棧,重新設R=棧頂元素,返回(1)
(2.2)若R標記爲1,說明左子樹返回,訪問R,並調用 R=R->rchild(設R標記爲2),返回(1)
反覆執行上述操作直到棧空

template<class T>
void BiTree<T>::PostOrder(BiNode<T> *R)
{
    SNode<T> S[100];
    int top= -1;
    do 
    {
        while(R != NULL)
        {
            S[++top].R = R;
            S[top].tag = 1;
            R = R->lchild;
        }
        while((top != -1)&&(S[top].tag == 2)) 
        { 
            cout<<S[top].R->data;
            top--;
        }
        if((top != 1)&&(S[top].tag == 1))
        {
            R = S[top].R->rchild;
            S[top].tag = 2;
        }
    }while(top != -1);
}

思考:後序遍歷的非遞歸算法還能優化嗎?
答案是不能,因爲對於後序排列來說,雙親結點會在孩子結點後被訪問,無法提前出棧。

層序遍歷

層序遍歷的基本思想是先入先出,顯然,我們用隊列來實現這一操作。
僞代碼:

  1. 若根節點非空,入隊;
  2. 若隊列不空:
    2.1 隊頭元素出隊
    2.2 訪問該元素
    2.3 若該結點的左孩子非空,則左孩子入隊
    2.4 若該結點的右孩子非空,則右孩子入隊

層序遍歷
PS:這裏同樣用的是順序隊列–結點數組

層序遍歷函數

template<class T>
void BiTree<T>::LevelOrder(BiNode<T> *R)
{
  BiNode<T> *queue[MAXSIZE];
  int f=0,r=0;    //初始化空隊列
  if(R != NULL)
    queue[++r] = R;//根結點入隊
  while(f != r)
  {
    BiNode<T> *p=queue[++f];//隊頭元素出隊
    cout<<p->data;//出隊打印
    if(p->lchild != NULL)
      queue[++r] = p->lchild;//左孩子入隊
    if(p->rchild != NULL)
      queue[++r] = p->rchild;//右孩子入隊
  }
}

(重點來啦)答疑+填坑

我們在上面的操作實現中,提出了以下三個問題
QUESTION1: 在聲明中爲什麼不用構造函數和析構函數實現樹的創建和刪除?
QUESTION2:爲什麼root結點要設置爲公有成分?
QUESTION3:[難]爲什麼構造樹時,形參要用二級指針?

QUESTION1

爲什麼不用構造函數直接創建樹?
我個人認爲是爲了操作更方便,邏輯上比較清晰。這樣在主函數調用的時候只用傳兩個參數,不用自己添加結點指針。
爲什麼不用析構函數刪除樹?
基本功,因爲析構函數是不能有參數的。

QUESTION2

爲什麼root結點要設置爲公有成分?
這個問題大家只需要認認真真自己敲一遍代碼就會懂啦(我就是),因爲調用遍歷函數的時候需要傳一個指向根結點的指針,如果root是私有的,就沒辦法找到樹的根結點了。

QUESTION3

爲什麼構造樹時,形參要用二級指針?
這裏請參考一位大佬的文章
涉及到變量的修改權限問題。

上述操作的完整代碼

遞歸版,一級優化版,二級優化版 請移步Github

哈夫曼樹

這部分內容比較多,於是我新寫了一篇文章。歡迎大家閱讀。

作爲初學者,我個人的編程能力還不夠強,大家如果上述內容有疑問,可以直接評論或私信我哦。
祝同學們學習進步哈哈

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