部分圖片非原創,侵刪
數據結構與算法(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;//兩個指針分別指向雙親與右兄弟
};
優勢:方便搜索孩子與兄弟,是比較好用的一種方法
感悟:上邊的圖片是不是有點眼熟?沒錯,這不就是把一般的樹轉化成了二叉樹嘛!
二叉樹的存儲結構
順序存儲結構
方法:
- 將二叉樹按照完全二叉樹編號
- 用一維數組存儲該二叉樹
- 無結點的位置也要編號,賦值爲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;
};
在二叉鏈表的基礎上,加了一個指向雙親的指針,方便向上搜索
基本操作簡介
遍歷的定義:從根節點出發,按照某種次序依次訪問所有結點,且每個結點僅訪問一次。
注意體會這裏隱含的遞歸思想。
二叉樹的遍歷
前序遍歷
- 訪問根節點 D
- 前序遍歷左子樹 L
- 前序遍歷右子樹 R
中序遍歷
- 中序遍歷左子樹 L
- 訪問根節點 D
- 中序遍歷右子樹 R
後序遍歷
- 後序遍歷左子樹 L
- 後序遍歷右子樹 R
- 訪問根節點 D
層序遍歷
從上到下,從左到右依次遍歷。
下面來從實例來理解樹的遍歷方法
前序遍歷 D-L-R: ABDEFGC
中序遍歷 L-D-R: DBFEGAC
後序遍歷 L-R-D: DFGEBCA
層序遍歷(最簡單): ABCDEFG
個人經驗:牢記每種遍歷方式對應的字母口訣,再套用即可。
重要題型:由兩種給出的遍歷方式來推理一棵二叉樹的結構。
例:已知二叉樹的前序序列{ABCDEFGH} 和中序序列 {CDBAFEHG} ,試確定該二叉樹的結構。
TIP:注意中序遍歷左孩子在最前面。(對於各種遍歷的組合,解題方法類似)
樹的遍歷
前序遍歷
- 訪問根節點
- 從左到右遍歷各個子樹
後序遍歷
- 從左到右遍歷每一棵子樹
- 訪問根節點
層序遍歷
從上到下,從左到右訪問每個結點。
森林的遍歷
前序遍歷
從左到右前序遍歷每個子樹。
後序遍歷
從左到右後序遍歷每個子樹。
樹、森林與二叉樹的轉換
樹->二叉樹
【前面挖的坑這裏就填上啦】
利用樹的孩子兄弟表示法,即可將一般樹轉化爲二叉樹。
森林->二叉樹
- 分別將森林中的每棵樹轉化爲二叉樹
- 從左到右連接新的二叉樹:後一棵樹的根節點是前一棵樹根節點的右孩子
二叉樹的實現(重難點)
二叉樹的聲明
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(此處不考慮值是否爲零)
基本思想:
- 建立根節點
- 建立左子樹
- 建立右子樹
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-右子樹。
第一次迭代優化:遞歸->非遞歸
僞代碼:(棧頂元素永遠爲當前結點的父結點)
(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);
}
思考:是否可以進一步優化呢?
**分析:**對於當前結點,當訪問玩它的左子樹以後,需要依靠它找到它的右子樹,此後當前結點就可以不再提供任何信息了,只需要靜靜的等待出棧。所以,讓當前結點提前出棧,就可以簡化非遞歸過程。
這就需要棧:
- 保存當前結點
- 訪問當前結點的左子樹
- 訪問當前節點左子樹,當前結點出棧
- 訪問其右子樹
第二次迭代優化:
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);
}
思考:後序遍歷的非遞歸算法還能優化嗎?
答案是不能,因爲對於後序排列來說,雙親結點會在孩子結點後被訪問,無法提前出棧。
層序遍歷
層序遍歷的基本思想是先入先出,顯然,我們用隊列來實現這一操作。
僞代碼:
- 若根節點非空,入隊;
- 若隊列不空:
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
哈夫曼樹
這部分內容比較多,於是我新寫了一篇文章。歡迎大家閱讀。
作爲初學者,我個人的編程能力還不夠強,大家如果上述內容有疑問,可以直接評論或私信我哦。
祝同學們學習進步哈哈