【1】
這些天參與了CSDN論壇的討論,改變了我以前的一些看法。回頭看我以前的東西,我雖對這本書很不滿,但我還是按照它的安排在一點點的寫;這樣就導致了,我過多的在意書中的偏漏,我寫的更多是說“這本書怎樣”,而偏離了我寫這些的初衷——給正在學習數據結構的人一些幫助。正像我在前面所說的,雖然現有的教科書都不是很合理,但如果僅僅是抱怨這點,那無異于潑婦罵街。雖然本人的水平連初級都夠不上,但至少先從我做一點嘗試,以后這門課的教授方法必將一點點趨于合理。<?xml:namespace prefix = o ns = "urn:schemas-microsoft-com:office:office" />
因此,后面不在按照書上的次序,將本著“實際應用(算法)決定數據結構”的思想來講解,常見教科書上有的,基本不再重點敘述(除了重點,例如AVL樹的平衡旋轉),——因此,在看本文的同時,一定要有一本教科書。這只是一個嘗試,希望大家多提寶貴意見。
樹
因為現實世界中存在這“樹”這種結構——族譜、等級制度、目錄分類等等,而為了研究這類問題,必須能夠將樹儲存,而如何儲存將取決于所需要的操作。這里有個問題,是否允許存在空樹。有些書認為樹都是非空的,因為樹表示的是一種現實結構,而0不是自然數;我用過的教科書都是說可以有空樹,當然是為了和二叉樹統一。這個沒有什么原則上的差別,反正就是一種習慣。
二叉樹
二叉樹可以說是人們假想的一個模型,因此,允許有空的二叉樹是無爭議的。二叉樹是有序的,左邊有一個孩子和右邊有一個的二叉樹是不同的兩棵樹。做這個規定,是因為人們賦予了左孩子和右孩子不同的意義,在二叉樹的各種應用中,你將會清楚的看到。下面只講解鏈式結構。看各種講數據結構的書,你會發現一個有趣的現象:在二叉樹這里,基本操作有計算樹高、各種遍歷,就是沒有插入、刪除——那樹是怎么建立起來的?其實這很好理解,對于非線性的樹結構,插入刪除操作不在一定的法則規定下,是毫無意義的。因此,只有在具體的應用中,才會有插入刪除操作。
節點結構
數據域、左指針、右指針肯定是必須的。除非很少用到節點的雙親,或者是資源緊張,建議附加一個雙親指針,這將會給很多算法帶來方便,尤其是在這個“空間換時間”的時代。
template <class T>
struct BTNode
{
BTNode(T data = T(), BTNode<T>* left = NULL, BTNode<T>* right = NULL, BTNode<T>* parent = NULL)
: data(data), left(left), right(right), parent(parent) {}
BTNode<T> *left, *right, *parent;
T data;
};
基本的二叉樹類
template <class T>
class BTree
{
public:
BTree(BTNode<T> *root = NULL) : root(root) {}
~BTree() { MakeEmpty(); }
void MakeEmpty() { destroy(root); root = NULL; }
protected:
BTNode<T> *root;
private:
void destroy(BTNode<T>* p)
{
if (p)
{
destroy(p->left);
destroy(p->right);
delete p;
}
}
}
二叉樹的遍歷
基本上有4種遍歷方法,先、中、后根,逐層。當初我對這個很迷惑,搞這么多干什么?到了后面才明白,這是不同的應用需要的。例如,判斷兩個二叉樹是否相等,只要子樹根節點不同,那么就不等,顯然這時要用先序遍歷;而刪除二叉樹,必須先刪除左右子樹,然后才能刪除根節點,這時就要用后序遍歷。
實際上,搞這么多遍歷方法,根本原因是在內存中儲存的樹是非線性結構。對于用數組儲存的二叉樹,這些名目繁多的方法都是沒有必要的。利用C++的封裝和重載特性,這些遍歷方法能很清晰的表達。
1. 前序遍歷
public:
void PreOrder(void (*visit)(T &data) = print) { PreOrder(root, visit); }
private:
void PreOrder(BTNode<T>* p, void (*visit)(T &data))
{
if (p){ visit(p->data); PreOrder(p->left, visit); PreOrder(p->right, visit); }
}
2. 中序遍歷
public:
void InOrder(void (*visit)(T &data) = print) { InOrder(root, visit); }
private:
void InOrder(BTNode<T>* p, void (*visit)(T &data))
{
if (p){ InOrder(p->left, visit); visit(p->data); InOrder(p->right, visit); }
}
3. 后序遍歷
public:
void PostOrder(void (*visit)(T &data) = print) { PostOrder(root, visit); }
private:
void PostOrder(BTNode<T>* p, void (*visit)(T &data))
{
if (p){ PostOrder(p->left, visit); PostOrder(p->right, visit); visit(p->data); }
}
4. 層次遍歷
void LevelOrder(void (*visit)(T &data) = print)
{
queue< BTNode<T>* > a; BTNode<T>* p = root;//記得#include<queue>
while (p)
{
visit(p->data);
if (p->left) a.push(p->left); if (p->right) a.push(p->right);
if (a.empty()) break; p = a.front(); a.pop();
}
}
附注:缺省的visit函數print如下
private:
static void print(T &data) { cout << data << ' '; }
5. 不用棧的非遞歸中序遍歷
當有parent指針時,可以不用棧實現非遞歸的中序遍歷,書上提到了有這種方法,但沒給出例程。
public:
BTNode<T>* next()
{
if(!current) return NULL;
if (current->right) { current = current->right; while (current->left) current = current->left; }
else
{
BTNode<T>* y = current->parent;
while (y && current == y->right) {current = y; y = y->parent; }
current = y;
}
return current;
}
private:
BTNode<T>* current;
上面的函數能使current指針向前移動一個位置,如果要遍歷整棵二叉樹,需要使current指向中序序列的第一個節點,例如下面的成員函數:
public:
void first() { current = root; while (current->left) current = current->left; }
【2】
線索化二叉樹
這是數據結構課程里第一個碰到的難點,不知道你是不是這樣看,反正我當初是費了不少腦細胞——當然,惱人的矩陣壓縮和相關的加法乘法運算不在考慮之列。我費了不少腦細胞是因為思考:他們干什么呢?很欣喜的看到在這本黃皮書上,這章被打了*號,雖然我不確定作者是不是跟我一個想法——線索化二叉樹在現在的PC上是毫無用處的!——不知我做了這個結論是不是會被人罵死,^_^。
為了證明這個結論,我們來看看線索化二叉樹提出的緣由:第一,我們想用比較少的時間,尋找二叉樹某一個遍歷線性序列的前驅或者后繼。當然,這樣的操作很頻繁的時候,做這方面的改善才是有意義的。第二,二叉樹的葉子節點還有兩個指針域沒有用,可以節省內存。說真的,提出線索化二叉樹這樣的構思真的很精巧,完全做到了“廢物利用”——這個人真應該投身環保事業。但在計算機這個死板的東西身上,人們的精巧構思往往都是不能實現的——為了速度,計算機的各個部件都是整齊劃一的,而構思的精巧往往都是建立在組成的復雜上的。
我們來看看線索化二叉樹究竟能不能達到上面的兩個目標。
求遍歷后的線性序列的前驅和后繼。前序線索化能依次找到后繼,但是前驅需要求雙親;中序線索化前驅和后繼都不需要求雙親,但是都不很直接;后序線索化能依次找到前驅,但是后繼需要求雙親。可以看出,線索化成中序是最佳的選擇,基本上算是達到了要求。
節省內存。添加了兩個標志位,問題是這兩個位怎么儲存?即使是在支持位存儲的CPU上,也是不能拿位存儲器來存的,第一是因為結構體成員的地址是在一起的,第二是位存儲器的數目是有限的。因此,最少需要1個字節來儲存這兩個標志位。而為了速度和移植,一般來說,內存是要對齊的,實際上根本就沒節省內存!然而,當這個空間用來儲存雙親指針時,帶來的方便絕對不是線索化所能比擬的,前面已經給出了無棧的非遞歸遍歷。并且,在線索化二叉樹上插入刪除操作附加的代價太大。
綜上,線索化最好是中序線索化(前序后序線索化后還得用棧,何必要線索化呢),附加的標志域空間至少1個字節,在32位的CPU會要求對齊到4字節,還不如存儲一個雙親指針,同樣能達到中序線索化的目的,并且能帶來其他的好處。所以,線索化二叉樹在現在的PC上是毫無用處的!
由于對其他體系不太了解,以下觀點姑妄聽之。在內存空間非常充裕的現在,一個節點省2~3個字節實在是沒什么意思(實際上由于對齊還省不出來);而在內存非常寶貴的地方(比如單片機),會盡量避免使用樹結構——利用其他的方法。所以,現在看來,線索化二叉樹真的是毫無用處了。
二叉搜索樹
這恐怕是二叉樹最重要的一個應用了。它的構想實際是個很自然的事情——查找值比當前節點小轉左,大轉右,等則查到,到頭了就是沒找著。越自然的東西越好理解,也就越不需要我廢話。在給出BST的實現之前,我們要在二叉樹的類中添加一個打印樹狀結構的成員函數,這樣,就能清楚的看出插入和刪除過程。
public:
void print()
{
queue< BTNode<T>* > a; queue<bool> flag; ofstream outfile("out.txt");
BTNode<T>* p = root; BTNode<T> zero; bool v = true;
int i = 1, level = 0, h = height();
while (i < 2<<h)
{
if (i == 1<<level)
{
cout << endl << setw(2 <<(h - level)); level++;
if (v) cout << p->data;
else cout << ' ';
}
else
{
cout << setw(4 <<(h - level + 1));
if (v) cout << p->data;
else cout << " ";
}
if (p->left) { a.push(p->left); flag.push(true); }
else { a.push(&zero); flag.push(false); }
if (p->right) { a.push(p->right); flag.push(true); }
else { a.push(&zero); flag.push(false); }
p = a.front(); a.pop(); v = flag.front(); flag.pop(); i++;
}
cout << endl;
}
打印樹狀結構的核心是按層次遍歷二叉樹,但是,二叉樹有許多節點缺左或右子樹,連帶的越到下面空隙越大。為了按照樹的結構打印,必須把二叉樹補成完全二叉樹,這樣下面的節點就知道放在什么位置了——a.push(&zero);但是這樣的節點不能讓它打印出來,所以對應每個節點,有一個是否打印的標志,按理說pair結構很合適,為了簡單我用了并列的兩個隊列,一個放節點指針——a,一個放打印標志——flag。這樣一來,循環結束的標志就不能是隊列空——永遠都不可能空,碰到NULL就補一個節點——而是變成了到了滿二叉樹的最后一個節點2^(height+1)-1。——黃皮書對于樹高的定義是,空樹為的高度為-1。
對于輸出格式,注意的是到了第1、2、4、8號節點要換行,并且在同一行中,第一個節點的域寬是后序節點的一半。上面的函數在樹的層次少于等于5(height<=4)的時候能正常顯示,再多的話就必須輸出到文件中去ofstream outfile("out.txt");——如果層次再多的話,打印出來也沒什么意義了。
二叉搜索樹的實現
實際上就是在二叉樹的基礎上增加了插入、刪除、查找。
#include "BaseTree.h"
template <class T>
class BSTree : public BTree<T>
{
public:
BTNode<T>* &find(const T &data)
{
BTNode<T>** p = &root; current = NULL;
while(*p)
{
if ((*p)->data == data) break;
if ((*p)->data < data) { current = *p; p = &((*p)->right); }
else { current = *p; p = &((*p)->left); }
}
return *p;
}
bool insert(const T &data)
{
BTNode<T>* &p = find(data); if (p) return false;
p = new BTNode<T>(data, NULL, NULL, current); return true;
}
bool remove(const T &data)
{
return remove(find(data));
}
private:
bool remove(BTNode<T>* &p)
{
if (!p) return false; BTNode<T>* t = p;
if (!p->left || !p->right)
{
if (!p->left) p = p->right; else p = p->left;
if (p) p->parent = current;
delete t; return true;
}
t=p->right;while(t->left) t=t->left;p->data=t->data;current=t->parent;
return remove(current->left==t?current->left:current->right);
}
};
以上代碼有點費解,有必要說明一下——非線性鏈式結構操作的實現都是很讓人費神。insert和remove都是以find為基礎的,因此必須讓find能最大限度的被這兩個操作利用。
l 對于insert,需要修改查找失敗時的指針內容,顯然這是個內部指針(在雙親節點的內部,而不是象root和current那樣在節點外面指向節點),這就要求find返回一個內部指針的引用。但是C++的引用綁定到一個對象之后,就不能再改變了,因此在find內部的實現是一個二重指針。insert操作還需要修改插入的新節點的parent指針域,因此在find中要產生一個能被insert訪問的指向find返回值所在節點的指針,這里用的是current。實際上find返回的指針引用不是current->left就是current->right。這樣一來,insert的實現就非常簡單了。
l 對于remove,需要修改查找成功時的指針內容,同樣是個內部指針。在find的基礎上,很容易就能得到這個內部指針的引用(BTNode<T>* &p = find(data)。
? 在p->left和p->right中至少有一個為NULL
數據結構學習(C++)二叉樹