上次的文章介紹了樹和二叉樹的轉換,這次的文章介紹線索二叉樹的實現。
還是老規矩:
程序在碼雲上可以下載。
地址:https://git.oschina.net/601345138/DataStructureCLanguage.git
對於二叉樹遍歷的遞歸和非遞歸算法,在《數據結構編程筆記十四:第六章 樹和二叉樹 二叉樹基本操作及四種遍歷算法的實現》一文中已經有所介紹,通過閱讀文章,基本可以得出以下結論:
1.遞歸算法雖然結構簡單,容易閱讀,但是卻因爲其伴隨着多次的函數調用過程致使其存在效率低下的弊端(無論是時間上還是空間上皆是如此)。非遞歸算法較好的改進了遞歸算法存在的效率低的問題。
2.由於二叉樹的遍歷過程中存在試探和回溯的過程,無法通過遞推手段轉換爲非遞歸算法,需要藉助棧來記憶回溯點在可以實現遞歸算法非遞歸算法的轉換。
3.二叉樹的先序、中序、後序遍歷的區別在於訪問根結點的時機不同。先序遍歷是按照“根左右”方式遍歷。中序遍歷是按照“左根右”順序遍歷。後序遍歷則是按照“左右根”順序遍歷。
4.層序遍歷需要藉助隊列實現。
非遞歸算法相對於遞歸算法執行效率已經是提升不少了,但是它必須帶棧操作,離了棧它玩不轉,每次遍歷前先開闢一個棧,用完了棧還得銷燬,棧的各項操作本身就需要時間,棧自己還佔着內存空間。那麼我們是不是可以考慮再大膽一點,把棧也給摘掉,對非遞歸算法瘦身減肥,行不行呢?
如果直接摘掉棧肯定是不行的——回溯點沒法保存了。那我們必須要想辦法保存這些回溯的信息,使得非遞歸遍歷操作不借用棧也能順利進行。容易想到的一種方式就是在遍歷的時候把結點的前驅和後繼信息記下來,以後遍歷的時候直接讀取這些信息,這樣不就OK了嗎?
記錄前驅和後繼有兩種方案:
1.另外定義一個單獨的數據結構,另外開闢內存空間記錄前驅和後繼信息。
2.利用現有的存儲空間,直接就地保存前驅和後繼信息。
顯然,第二種想法更好,因爲第一種想法實現起來就和棧差不多了——也需要時間去操作這個數據結構,也需要內存去存儲這個新的數據結構。第二種做法不需要開闢把內存空間去存儲新的數據結構的信息,也不需要花時間執行額外操作。但是這樣美好的願景能實現嗎?
我們發現,二叉樹的指針域有很多是空的。空指針域並沒有存儲有效的信息,在二叉鏈表中屬於被浪費的存儲空間。那麼這些空間有多少呢?
一棵使用二叉鏈表存儲的二叉樹中,若二叉樹有n個結點,則必定存在n+1個空指針域。所以被浪費的指針域個數就是n+1了。
把這n+1個空指針域拿出來存儲前驅和後繼信息是一個不錯的想法,廢物利用嘛。
但是我們很快發現:沒辦法區分指針域存的是指針還是線索。所以想要正確表示線索我們還得給二叉鏈表“加點料”——加標誌位,標誌位就是起個標記作用,說明這個指針域存的是指針還是線索,如果存的是指針,標誌位就是0,要是存的是線索,標誌位就是1。
所以書上的線索二叉樹這樣定義:
//------------------- 二叉樹的二叉線索存儲表示 --------------------
//採用枚舉類型定義指針標誌位
typedef enum{
Link, //Link(0): 指針 0
Thread //Thread(1):線索 1
}PointerTag;
typedef struct BiThrNode{
TElemType data; //數據域,存儲節點數據
struct BiThrNode *lchild, *rchild; //左右孩子指針
PointerTag LTag, RTag; //左右標誌
}BiThrNode, *BiThrTree;
這個過程就像是在二叉樹的空指針域上“穿針引線”,將遍歷過程中將二叉樹的前驅和後繼穿了起來,並且保存了這種前驅和後繼的關係,使得以後的遍歷更快速——終於甩掉“棧”這個包袱了。這樣的二叉樹我們稱之爲線索二叉樹。
我們還可以考慮在二叉樹的根結點前面加頭結點,把頭結點和線索二叉樹的根連接在一起,就像一個雙向鏈表一樣,可以從前向後遍歷,也可以從後向前遍歷。
但是我認爲書上的存儲結構並不是最佳的,我們不妨算筆賬:
標誌位本身是要佔用內存空間的,加標誌位勢必會使每個結點的大小增加,使得二叉鏈表的整體大小增加。那我們就得想辦法,既要保證線索信息和指針信息的正確存儲和表示,還得想辦法減少這些附加的標誌位佔用的內存空間。我們必須要保證標誌位佔用的空間足夠小,因爲二叉鏈表中的指針變量和基本數據類型變量已經沒有壓榨空間了,只能壓榨標誌位。
以下這段程序會測試出指針變量的大小,還會給我們算筆賬:
#include <stdio.h>
#include <iostream>
using namespace std;
typedef int TElemType;
//----------------原始二叉鏈表------------------------
struct BiNode{
TElemType data;
struct BiNode *lchild,*rchild; //孩子結點指針
};
//--------------------線索二叉樹標誌位---------------
typedef enum{
Link, //Link(0): 指針 0
Thread //Thread(1):線索 1
}PointerTag;
//----------------線索二叉樹(不加左右標誌)---------
struct BiThrNode1{
TElemType data; //數據域,存儲節點數據
struct BiThrNode *lchild, *rchild; //左右孩子指針
};
//-----------------線索二叉樹(加左右標誌)---------
struct BiThrNode{
TElemType data; //數據域,存儲節點數據
struct BiThrNode *lchild, *rchild; //左右孩子指針
PointerTag LTag, RTag; //左右標誌
};
int main() {
int a = 1;
char b = 'a';
float c = 1.0;
void *p;
printf("int型變量所佔用的大小(字節):%d\n", sizeof(int));
printf("float型變量所佔用的大小(字節):%d\n", sizeof(float));
printf("char型變量所佔用的大小(字節):%d\n", sizeof(char));
printf("C++支持(C語言不支持):boolean型變量所佔用的大小(字節):%d\n", sizeof(bool));
printf("unsigned int型變量所佔用的大小(字節):%d\n", sizeof(unsigned int));
printf("short型變量所佔用的大小(字節):%d\n", sizeof(short));
p = &a;
printf("指向int型變量的指針變量所佔用的大小(字節):%d\n", sizeof(p));
p = &b;
printf("指向char型變量的指針變量所佔用的大小(字節):%d\n", sizeof(p));
p = &c;
printf("指向float型變量的指針變量所佔用的大小(字節):%d\n", sizeof(p));
printf("枚舉類型所佔用的大小(字節):%d\n", sizeof(PointerTag));
printf("原來的二叉鏈表結點大小(字節):%d\n", sizeof(struct BiNode));
printf("線索二叉樹結點(不加標誌位)大小(字節):%d\n", sizeof(struct BiThrNode1));
printf("線索二叉樹結點(加標誌位)大小(字節):%d\n", sizeof(struct BiThrNode));
int tagsize = (sizeof(struct BiThrNode) - sizeof(struct BiThrNode1)) / 2;
printf("標誌位在結構體中的大小(字節):%d\n", tagsize);
PointerTag pt1 = Link;
PointerTag pt2 = Link;
printf("&pt1= %d, &pt2= %d\n", &pt1, &pt2);
return 0;
} //main
我的操作系統是64位的,但用的是32位的gcc編譯器,從運行結果看出指針變量大小爲4字節。
同時我們也看到一個有趣的現象——指針變量大小和枚舉類型的變量所佔大小一樣,都是4個字節。
通過對結構體大小的計算,有左右標誌位結構體大小爲20字節,沒有左右標誌位的結構體大小爲12字節,因此我們可以得出標誌位佔的內存大小與int型一樣大——4個字節。左右標誌位各佔4字節。
而且通過打印兩個接收枚舉類型數值的變量pt1和pt2的地址時,發現即便枚舉值相同的兩個變量內存空間也不共享——地址不一樣。所以可以得出結論——C語言把標誌位當成了一個int型整數變量去處理了。
布爾型(C不支持)只佔一個字節,short類型(c支持)只佔2個字節。這兩種類型表示一個只有0和1兩種取值的變量是沒問題的,並且佔空間都比枚舉變量小。
書上用枚舉變量來存儲標誌位有其好處——限制了標誌位的取值範圍,並且可以用能讓人看懂的符號代替具體的值,增強了程序的可讀性。但是也有明顯的弊端——浪費空間多。存儲0和1其實只需要1個二進制位就夠了。一個字節=8個二進制位,表示0和1綽綽有餘。書上卻要浪費4個字節去存儲0和1。如果不考慮C語言的兼容性,C++中的布爾類型就能很好的滿足這樣的需求,且只需要枚舉變量1/4的內存空間。如果考慮C語言的兼容性,那就用short,佔用內存空間大小是枚舉類型的一半。
通過上面的一連串計算,發現書上的標誌位內存空間還沒有壓榨到極致,還有壓榨空間。我們爲了後續操作方便,就不去改了,和書上保持一致,如果大家有興趣,改了也無妨。
接下來一起看看線索二叉樹中序線索化的實現:
//>>>>>>>>>>>>>>>>>>>>>>>>>引入頭文件<<<<<<<<<<<<<<<<<<<<<<<<<<<<
#include <stdio.h> //使用了標準庫函數
#include <stdlib.h> //使用了動態內存分配函數
//>>>>>>>>>>>>>>>>>>>>>>>自定義符號常量<<<<<<<<<<<<<<<<<<<<<<<<<<
#define OVERFLOW -2 //內存溢出錯誤常量
#define OK 1 //表示操作正確的常量
#define ERROR 0 //表示操作錯誤的常量
#define TRUE 1 //表示邏輯真的常量
#define FALSE 0 //表示邏輯假的常量
//>>>>>>>>>>>>>>>>>>>>>>>自定義數據類型<<<<<<<<<<<<<<<<<<<<<<<<<<
typedef char TElemType;
typedef int Status;
//定義NIL爲空格
TElemType NIL = ' ';
//------------------- 二叉樹的二叉線索存儲表示 --------------------
//採用枚舉類型定義指針標誌位
typedef enum{
Link, //Link(0): 指針 0
Thread //Thread(1):線索 1
}PointerTag;
typedef struct BiThrNode{
TElemType data; //數據域,存儲節點數據
struct BiThrNode *lchild, *rchild; //左右孩子指針
PointerTag LTag, RTag; //左右標誌
}BiThrNode, *BiThrTree;
//---------------------------線索二叉樹操作----------------------------
/*
函數:CreateBiThrTree
參數:BiThrTree &T 線索二叉樹的引用
返回值:狀態碼,操作成功返回OK,否則返回ERROR
作用:按先序輸入線索二叉樹中結點的值,構造線索二叉樹T。
空格表示空結點
*/
Status CreateBiThrTree(BiThrTree &T) {
//保存從鍵盤接收的字符
TElemType ch;
scanf("%c", &ch);
//若輸入了空格,則此節點爲空
if(ch == NIL) {
T = NULL;
}//if
else{ //若輸入的不是空格則創建新結點並添加到線索二叉樹的合適位置
//申請根結點存儲空間
T = (BiThrTree)malloc(sizeof(BiThrNode));
//檢查空間分配是否成功
if(!T) {
exit(OVERFLOW);
}//if
//給根結點賦植
T->data = ch;
//遞歸地構造左子樹
CreateBiThrTree(T->lchild);
//若有左孩子則將左標誌設爲指針
if(T->lchild) {
T->LTag = Link;
}//if
//遞歸地構造右子樹
CreateBiThrTree(T->rchild);
//若有右孩子則將右標誌設爲指針
if(T->rchild) {
T->RTag = Link;
}//if
}//else
//操作成功
return OK;
}//CreateBiThrTree
//---------------------------- 中序線索化 ---------------------------
//全局變量,始終指向剛剛訪問過的結點
BiThrTree pre;
/*
函數:InThreading
參數:BiThrTree p 指向線索二叉樹結點的指針p
返回值:無
作用:通過中序遍歷遞歸地對頭結點外的其他結點進行中序線索化,
線索化之後pre指向最後一個結點。
此函數會被InOrderThreading函數調用。
*/
void InThreading(BiThrTree p) {
//線索二叉樹不空
if(p) {
//遞歸左子樹線索化
InThreading(p->lchild);
//由於已經訪問過前驅結點,此時就可以完成前驅結點的線索化了。
//當前結點的前驅就是pre指向的結點。如果當前結點沒有左孩子
//那麼左孩子的指針域就可以拿來存放前驅結點的線索信息。
//若當前結點沒有左孩子,則左指針域可以存放線索
if(!p->lchild) {
//左標誌爲前驅線索
p->LTag = Thread;
//左孩子指針指向前驅
p->lchild = pre;
}//if
//此時還未訪問後繼結點,但可以確定當前結點p一定是前驅結點pre
//的後繼,所以要把前驅結點的後繼指針域填上線索信息
//前驅沒有右孩子
if(!pre->rchild) {
//前驅的右標誌爲線索(後繼)
pre->RTag = Thread;
//前驅右孩子指針指向其後繼(當前結點p)
pre->rchild = p;
}//if
//使pre指向的結點p的新前驅
pre = p;
//遞歸右子樹線索化
InThreading(p->rchild);
}//if
}//InThreading
/*
函數:InOrderThreading
參數:BiThrTree &Thrt 頭結點的引用
BiThrTree T 指向線索二叉樹根結點的指針
返回值:狀態碼,操作成功返回OK,否則返回ERROR
作用:中序遍歷二叉樹T,並將其中序線索化。
*/
Status InOrderThreading(BiThrTree &Thrt, BiThrTree T) {
//申請頭結點內存空間
//if(!(Thrt = (BiThrTree)malloc(sizeof(BiThrNode))))
//相當於以下兩行代碼:
//Thrt = (BiThrTree)malloc(sizeof(BiThrNode));
//if(!Thrt) <=> if(Thrt == NULL)
if(!(Thrt = (BiThrTree)malloc(sizeof(BiThrNode)))) {
//內存分配失敗
exit(OVERFLOW);
}//if
//建頭結點,左標誌爲指針
Thrt->LTag = Link;
//右標誌爲線索
Thrt->RTag = Thread;
//右指針指向結點自身
Thrt->rchild = Thrt;
//若二叉樹空,則左指針指向結點自身
if(!T) { //if(!T) <=> if(T == NULL)
Thrt->lchild = Thrt;
}//if
else{
//頭結點的左指針指向根結點
Thrt->lchild = T;
//pre(前驅)的初值指向頭結點
pre = Thrt;
//中序遍歷進行中序線索化,pre指向中序遍歷的最後一個結點
//InThreading(T)函數遞歸地完成了除頭結點外其他結點的線索化。
InThreading(T);
//最後一個結點的右指針指向頭結點
pre->rchild = Thrt;
//最後一個結點的右標誌爲線索
pre->RTag = Thread;
//頭結點的右指針指向中序遍歷的最後一個結點
Thrt->rchild = pre;
}//else
//操作成功
return OK;
}//InOrderThreading
/*
函數:Print
參數:TElemType c 被訪問的元素
返回值:狀態碼,操作成功返回OK,操作失敗返回ERROR
作用:訪問元素e的函數,通過修改該函數可以修改元素訪問方式,
該函數使用時需要配合遍歷函數一起使用。
*/
Status Print(TElemType c){
//以控制檯輸出的方式訪問元素
printf(" %c ", c);
//操作成功
return OK;
}//Print
/*
函數:InOrderTraverse_Thr
參數:BiThrTree T 指向線索二叉樹根結點的指針
Status(*Visit)(TElemType) 函數指針,指向元素訪問函數
返回值:狀態碼,操作成功返回OK,否則返回ERROR
作用:中序遍歷線索二叉樹T(頭結點)的非遞歸算法。
*/
Status InOrderTraverse_Thr(BiThrTree T, Status(*Visit)(TElemType)) {
//工作指針p
BiThrTree p;
//p指向線索二叉樹的根結點
p = T->lchild;
//空樹或遍歷結束時,p==T
while(p != T) {
//由根結點一直找到二叉樹的最左結點
while(p->LTag == Link) {
p = p->lchild;
}//while
//調用訪問函數訪問此結點
Visit(p->data);
//p->rchild是線索(後繼),且不是遍歷的最後一個結點
while(p->RTag == Thread && p->rchild != T) {
p = p->rchild;
//訪問後繼結點
Visit(p->data);
}//while
//若p->rchild不是線索(是右孩子),p指向右孩子,返回循環,
//找這棵子樹中序遍歷的第1個結點
p = p->rchild;
}//while
//操作成功
return OK;
}//InOrderTraverse_Thr
/*
函數:DestroyBiTree
參數:BiThrTree &T T指向線索二叉樹根結點
返回值:狀態碼,操作成功返回OK,否則返回ERROR
作用:遞歸地銷燬線索二叉樹,被DestroyBiThrTree函數調用。
*/
Status DestroyBiTree(BiThrTree &T) {
//非空樹
if(T) { //if(T) <=> if(T != NULL)
//如果有左孩子則遞歸地銷燬左子樹
if(T->LTag == Link) {
DestroyBiTree(T->lchild);
}//if
//如果有右孩子則遞歸地銷燬右子樹
if(T->RTag == Link) {
DestroyBiTree(T->rchild);
}//if
//釋放根結點
free(T);
//指針置空
T = NULL;
}//if
//操作成功
return OK;
}//DestroyBiTree
/*
函數:DestroyBiTree
參數:BiThrTree &Thrt Thrt指向線索二叉樹頭結點
返回值:狀態碼,操作成功返回OK,否則返回ERROR
作用:若線索二叉樹Thrt存在,遞歸地銷燬線索二叉樹Thrt。
*/
Status DestroyBiThrTree(BiThrTree &Thrt) {
//頭結點存在
if(Thrt) {
//若根結點存在,則遞歸銷燬頭結點lchild所指的線索二叉樹
if(Thrt->lchild) { //if(Thrt->lchild) <=> if(Thrt->lchild != NULL)
DestroyBiTree(Thrt->lchild);
}//if
//釋放頭結點
free(Thrt);
//線索二叉樹Thrt指針賦0
Thrt = NULL;
}//if
//操作成功
return OK;
}//DestroyBiThrTree
int main(int argc, char** argv) {
printf("---------------------線索二叉樹測試程序-----------------------\n");
BiThrTree H, T;
//使用用戶輸入的先序遍歷序列生成一棵沒有被線索化的二叉樹
printf("請按先序遍歷順序輸入二叉樹,空格表示空子樹,輸入完成後按回車確認\n");
CreateBiThrTree(T);
//在中序遍歷過程中線索化二叉樹
InOrderThreading(H, T);
//按中序遍歷序列輸出線索二叉樹
printf("中序遍歷(輸出)線索二叉樹:\n");
InOrderTraverse_Thr(H, Print);
printf("\n");
//銷燬線索二叉樹
DestroyBiThrTree(H);
}//main
測試過程中的輸入和程序的輸出:
---------------------線索二叉樹測試程序-----------------------
請按先序遍歷順序輸入二叉樹,空格表示空子樹,輸入完成後按回車確認
ABE*F**C*DGHI*J*K******↙
//說明:此處的*是空格,爲方便確認輸入了幾個空格將空格替換成*,測試輸入時請將*改回空格
↙表示回車確認 輸入(可直接複製,不要複製↙):ABE F C DGHI J K ↙
中序遍歷(輸出)線索二叉樹:
E F B C I J K H G D A
--------------------------------
Process exited with return value 0
Press any key to continue . . .
總結:
線索二叉樹利用了二叉樹中廢棄的空指針域來保存結點前驅和後繼的信息,爲了區分指針域保存的是線索還是指針,引入了左右標誌位加以區分。雖然左右標誌位佔用了一些內存空間,但是這種空間上的犧牲是值得的——我們不僅去掉了棧,減少了很多入棧和出棧的操作,還做到了一勞永逸——一次遍歷生成的線索信息可以多次使用,大大提高了二叉樹遍歷操作的速度。在給線索二叉樹添加頭結點之後,我們可以將其改造爲雙向線索鏈表,這樣做的好處是線索二叉樹既可以正向遍歷也可以反向遍歷。
下次的文章將會介紹赫夫曼樹的實現。感謝大家一直以來的支持,希望大家繼續關注我的博客。再見!