1. 二叉树
二叉树是树形结构的一个重要类型。许多实际问题抽象出来的数据结构往往是二叉树的形式,即使是一般的树也能简单地转换为二叉树,而且二叉树的存储结构及其算法都较为简单,因此二叉树显得特别重要。
1.1. 二叉树的定义
1.1.1. 二叉树的递归定义
二叉树(BinaryTree)是n(n≥0)个结点的有限集,它或者是空集(n=0),或者由一个根结点及两棵互不相交的、分别称作这个根的左子树和右子树的二叉树组成。
1.1.2. 二叉树的五种基本形态
二叉树可以是空集;根可以有空的左子树或右子树;或者左、右子树皆为空。
二叉树的五种基本形态如下图所示。
1.1.3. 二叉树不是树的特例
1) 二叉树与无序树不同
二叉树中,每个结点最多只能有两棵子树,并且有左右之分。
二叉树并非是树的特殊情形,它们是两种不同的数据结构。
2) 二叉树与度数为2的有序树不同
在有序树中,虽然一个结点的孩子之间是有左右次序的,但是若该结点只有一个孩子,就无须区分其左右次序。而在二叉树中,即使是一个孩子也有左右之分。
【例】下图中(a)和(b)是两棵不同的二叉树,它们同右图中的普通树(作为有序树或无序树)很相似,但却不等同于这棵普通树。若将这三棵树均看做普通树,则它们就是相同的了。
|
二叉树并非是树的特殊情形,它们是两种不同的数据结构。
1.2. 二叉树的性质
二叉树具有以下重要性质:
性质1: 在二叉树的第i层上至多有2i-1个结点(i>=1) (i≥1)。
证明:用数学归纳法证明:
- 归纳基础:i=1时,有2i-1=20=1。因为第1层上只有一个根结点,所以命题成立。
- 归纳假设:假设对所有的j(1≤j<i)命题成立,即第j层上至多有2j-1个结点,证明j=i时命题亦成立。
- 归纳步骤:根据归纳假设,第i-1层上至多有2i-2个结点。由于二叉树的每个结点至多有两个孩子,故第i层上的结点数至多是第i-1层上的最大结点数的2倍。即j=i时,该层上至多有2×2i-2=2i-1个结点,故命题成立。
性质2: 深度为k的二叉树至多有2k-1个结点(k≥1)。
证明:在具有相同深度的二叉树中,仅当每一层都含有最大结点数时,其树中结点数最多。因此利用性质1可得,深度为k的二叉树的结点数至多为:
20+21+…+2k-1=2k-1(等比公式)
故命题正确。
性质3: 在任意-棵二叉树中,若终端结点的个数为n0,度为2的结点数为n2,则n0=n2+1。
证明:因为二叉树中所有结点的度数均不大于2,所以结点总数(记为n)应等于0度结点数、1度结点(记为n1)和2度结点数之和:
n=n0+n1+n2 (式子1)
另一方面,1度结点有一个孩子,2度结点有两个孩子,故二叉树中孩子结点总数是:
nl+2n2
树中只有根结点不是任何结点的孩子,故二叉树中的结点总数又可表示为:
n=n1+2n2+1 (式子2)
由式子1和式子2得到:
n0=n2+1
性质1、2、3是二叉树的通用特性。
在介绍其它性质之前,先了解另两种特殊的二叉树,即满二叉树和完全二叉树,满二叉树和完全二叉树是二叉树的两种特殊情形。
1、满二叉树(FullBinaryTree)
一棵深度为k且有2k-1个结点的二又树称为满二叉树。
满二叉树的特点:
a) 每一层上的结点数都达到最大值。即对给定的高度,它是具有最多结点数的二叉树。
b) 满二叉树中不存在度数为1的结点,每个分支结点均有两棵高度相同的子树,且树叶都在最下一层上。
c) 非叶子结点的度一定是2。
【例】图(a)是一个深度为4的满二叉树。
2、完全二叉树(Complete BinaryTree)
若一棵二叉树至多只有最下面的两层上结点的度数可以小于2,并且最下一层上的结点都集中在该层最左边的若干位置上,则此二叉树称为完全二叉树。
特点:
a) 满二叉树是完全二叉树,完全二叉树不一定是满二叉树。
b) 在满二叉树的最下一层上,从最右边开始连续删去若干结点后得到的二叉树仍然是一棵完全二叉树。
c) 在完全二叉树中,若某个结点没有左孩子,则它一定没有右孩子,即该结点必是叶结点。
【例】如图(c)中,结点F没有左孩子而有右孩子L,故它不是一棵完全二叉树。
【例】图(b)是一棵完全二叉树。
性质4: 具有n个结点的完全二叉树的深度为
证明:设所求完全二叉树的深度为k。由完全二叉树定义可得:
深度为k得完全二叉树的前k-1层是深度为k-1的满二叉树,一共有2k-1-1个结点。
由于完全二叉树深度为k,故第k层上还有若干个结点,因此该完全二叉树的结点个数:
n>2k-1-1。
另一方面,由性质2可得:
n≤2k-1,
即:2k-1-l<n≤2k-1
由此可推出:2k-1≤n<2k,取对数后有(注意:由于在计算机领域常用是2进制,根据对数换底公式,采用2为底和采用10为底,只相差一个常数,而算法分析中的大O表示法公式前面的常数是等价的,lg以2为底,仅仅是方便计算。算法导论中lgN默认都是以2为底)
k-1≤lgn<k
又因k-1和k是相邻的两个整数,故有
k-1=⌊lgn⌋,
由此即得:
k=⌊lgn⌋+1
注意:
k=⌈lg(n+1)⌉的证明【参见参考书目】
性质5:在按层序编号的n个结点的完全二叉树中,任意一个结点i(1 )有:
- i = 1时,结点i是树的根,否则(i> 1),结点i的双亲为i/2(向下取整),如:
2≤i=2.5≤3, 取i= 2.
- 2i> n时,结点i无左孩子,为叶结点,否则结点i的左孩子为结点2i
- 2i+1> n时,结点i无右孩子,否则结点i的右孩子为结点2i +1.
性质4与性质5是针对完全二叉树而言的。性质6是针对二叉树的链式存储结构而言。
性质6: 含有n个结点的二叉链表中,有n + 1个空链域。
证明:空链域数 = 2n0+1*n1
因为:n= n0 + n1 + n2 -----------(1)
又有:n0 = n2 + 1 (根据性质3)-----------(2)
(2) – (1):2n0 = n - n1 + 1
即 2n0 + n1 = n + 1 = 空链域数。
1.3. 二叉树的三种遍历方式(递归、非递归和Morris遍历)
二叉树遍历是二叉树的最基本的操作,其实现方式主要有三种:
1) 递归遍历
2) 非递归遍历
3) Morris遍历
递归遍历的实现非常容易,非递归实现需要用到栈。而Morris算法可能很多人都不太熟悉,其强大之处在于只需要使用O(1)的空间就能实现对二叉树O(n)时间的遍历。
1.3.1.二叉树结点的定义
每个二叉树结点包括一个值以及左孩子和右孩子结点,其定义如下:
class TreeNode {
public:
int val;
TreeNode *left, *right;
TreeNode(int val) {
this->val = val;
this->left = this->right = NULL;
}
};
1.3.2.二叉树的遍历
二叉树的遍历,就是按照某条搜索路径访问树中的每一个结点,使得每个结点均被访问一次,而且仅被访问一次。常见的遍历次序有:
- 先序遍历:先访问根结点,再访问左子树,最后访问右子树
- 中序遍历:先访问左子树,再访问根结点,最后访问右子树
- 后序遍历:先访问左子树,再访问右子树,最后访问根结点
下面介绍,二叉树3种遍历方式的实现。
1.3.2.1递归遍历
递归实现非常简单,按照遍历的次序,对当前结点分别调用左子树和右子树即可。
A)前序遍历
void preOrder(TreeNode *root) {
if(root == NULL)
return;
cout << root->val << endl;
preOrder(root->left);
preOrder(root->right);
}
B)中序遍历
void inOrder(TreeNode *root) {
if(root == NULL)
return;
inOrder(root->left);
cout << root->val << endl;
inOrder(root->right);
}
C)后序遍历
void postOrder(TreeNode *root) {
if(root == NULL)
return;
postOrder(root->left);
postOrder(root->right);
cout << root->val << endl;
}
D)复杂度分析
二叉树遍历的递归实现,每个结点只需遍历一次,故时间复杂度为O(n)。而使用了递归,最差情况下递归调用的深度为O(n),所以空间复杂度为O(n)。
1.3.2.2非递归遍历
二叉树遍历的非递归实现,可以借助栈。
A)前序遍历
- 将根结点入栈;
- 每次从栈顶弹出一个结点,访问该结点;
- 把当前结点的右孩子入栈;
- 把当前结点的左孩子入栈。
按照以上顺序入栈,这样出栈顺序就与先序遍历一样:先根结点,再左子树,最后右子树。
void preOrder2(TreeNode *root) {
if(root == NULL)
return;
stack<TreeNode *> stk;
stk.push(root);
while(!stk.empty()) {
TreeNode *pNode = stk.top();
stk.pop();
cout << pNode->val << endl;
if(pNode->right != NULL)
stk.push(pNode->right);
if(pNode->left != NULL)
stk.push(pNode->left);
}
}
B)中序遍历
- 初始化一个二叉树结点pNode指向根结点;
- 若pNode非空,那么就把pNode入栈,并把pNode变为其左孩子;(直到最左边的结点)
- 若pNode为空,弹出栈顶的结点,并访问该结点,将pNode指向其右孩子(访问最左边的结点,并遍历其右子树)
void inOrder2(TreeNode *root) {
if(root == NULL)
return;
stack<TreeNode *> stk;
TreeNode *pNode = root;
while(pNode != NULL || !stk.empty()) {
if(pNode != NULL) {
stk.push(pNode);
pNode = pNode->left;
}
else {
pNode = stk.top();
stk.pop();
cout << pNode->val << endl;
pNode = pNode->right;
}
}
}
C)后序遍历
- 设置两个栈stk, stk2;
- 将根结点压入第一个栈stk;
- 弹出stk栈顶的结点,并把该结点压入第二个栈stk2;
- 将当前结点的左孩子和右孩子先后分别入栈stk;
- 当所有元素都压入stk2后,依次弹出stk2的栈顶结点,并访问之。
第一个栈的入栈顺序是:根结点,左孩子和右孩子;于是,压入第二个栈的顺序是:根结点,右孩子和左孩子。因此,弹出的顺序就是:左孩子,右孩子和根结点。
void postOrder2(TreeNode *root) {
if(root == NULL)
return;
stack<TreeNode *> stk, stk2;
stk.push(root);
while(!stk.empty()) {
TreeNode *pNode = stk.top();
stk.pop();
stk2.push(pNode);
if(pNode->left != NULL)
stk.push(pNode->left);
if(pNode->right != NULL)
stk.push(pNode->right);
}
while(!stk2.empty()) {
cout << stk2.top()->val << endl;
stk2.pop();
}
}
另外,二叉树的后序遍历的非递归实现,也可以只使用一个栈来实现。
void postOrder2(TreeNode *root) {
if(root == NULL)
return;
stack<TreeNode *> stk;
stk.push(root);
TreeNode *prev = NULL;
while(!stk.empty()) {
TreeNode *pNode = stk.top();
if(!prev || prev->left == pNode || prev->right == pNode) { // traverse down
if(pNode->left)
stk.push(pNode->left);
else if(pNode->right)
stk.push(pNode->right);
/* else {
cout << pNode->val << endl;
stk.pop();
}
*/
}
else if(pNode->left == prev) { // traverse up from left
if(pNode->right)
stk.push(pNode->right);
}
/* else if(pNode->right == prev) { // traverse up from right
cout << pNode->val << endl;
stk.pop();
}
*/
else {
cout << pNode->val << endl;
stk.pop();
}
prev = pNode;
}
}
D)复杂度分析
二叉树遍历的非递归实现,每个结点只需遍历一次,故时间复杂度为O(n)。而使用了栈,空间复杂度为二叉树的高度,故空间复杂度为O(n)。
1.3.2.3.Morris遍历
Morris遍历算法最神奇的地方就是,只需要常数的空间即可在O(n)时间内完成二叉树的遍历。O(1)空间进行遍历困难之处在于在遍历的子结点的时候如何重新返回其父节点?在Morris遍历算法中,通过修改叶子结点的左右空指针来指向其前驱或者后继结点来实现的。
A)中序遍历
- 如果当前结点pNode的左孩子为空,那么输出该结点,并把该结点的右孩子作为当前结点;
- 如果当前结点pNode的左孩子非空,那么就找出该结点在中序遍历中的前驱结点pPre
- 当第一次访问该前驱结点pPre时,其右孩子必定为空,那么就将其右孩子设置为当前结点,以便根据这个指针返回到当前结点pNode中,并将当前结点pNode设置为其左孩子;
- 当该前驱结点pPre的右孩子为当前结点,那么就输出当前结点,并把前驱结点的右孩子设置为空(恢复树的结构),将当前结点更新为当前结点的右孩子
- 重复以上两步,直到当前结点为空。
void inOrder3(TreeNode *root) {
if(root == NULL)
return;
TreeNode *pNode = root;
while(pNode != NULL) {
if(pNode->left == NULL) {
cout << pNode->val << endl;
pNode = pNode->right;
}
else {
TreeNode *pPre = pNode->left;
while(pPre->right != NULL && pPre->right != pNode) {
pPre = pPre->right;
}
if(pPre->right == NULL) {
pPre->right = pNode;
pNode = pNode->left;
}
else {
pPre->right = NULL;
cout << pNode->val << endl;
pNode = pNode->right;
}
}
}
}
因为只使用了两个辅助指针,所以空间复杂度为O(1)。对于时间复杂度,每次遍历都需要找到其前驱的结点,而寻找前驱结点与树的高度相关,那么直觉 上总的时间复杂度为O(nlogn)。其实,并不是每个结点都需要寻找其前驱结点,只有左子树非空的结点才需要寻找其前驱,所有结点寻找前驱走过的路的总和至多为一棵树的结点个数。因此,整个过程每条边最多走两次,一次使定位到该结点,另一次是寻找某个结点的前驱,所以时间复杂度为O(n)。
如以下一棵二叉树。首先,访问的是根结点F,其左孩子非空,所以需要先找到它的前驱结点(寻找路径为B->D->E),将E的右指针指 向F,然后当前结点为B。依然需要找到B的前驱结点A,将A的右指针指向B,并将当前结点设置为A。下一步,输出A,并把当前结点设置为A的右孩子B。之 后,会访问到B的前驱结点A指向B,那么令A的右指针为空,继续遍历B的右孩子。依次类推。
B)前序遍历
与中序遍历类似,区别仅仅是输出的顺序不同。
void preOrder3(TreeNode *root) {
if(root == NULL)
return;
TreeNode *pNode = root;
while(pNode) {
if(pNode->left == NULL) {
cout << pNode->val << endl;
pNode = pNode->right;
}
else {
TreeNode *pPre = pNode->left;
while(pPre->right != NULL && pPre->right != pNode)
pPre = pPre->right;
if(pPre->right == NULL) {
pPre->right = pNode;
cout << pNode->val << endl;
pNode = pNode->left;
}
else {
pPre->right = NULL;
pNode = pNode->right;
}
}
}
}
C)后序遍历
- 先建立一个临时结点dummy,并令其左孩子为根结点root,将当前结点设置为dummy;
- 如果当前结点的左孩子为空,则将其右孩子作为当前结点;
- 如果当前结点的左孩子不为空,则找到其在中序遍历中的前驱结点
- 如果前驱结点的右孩子为空,将它的右孩子设置为当前结点,将当前结点更新为当前结点的左孩子;
- 如果前驱结点的右孩子为当前结点,倒序输出从当前结点的左孩子到该前驱结点这条路径上所有的结点。将前驱结点的右孩子设置为空,将当前结点更新为当前结点的右孩子。
- 重复以上过程,直到当前结点为空。
void reverse(TreeNode *p1, TreeNode *p2) {
if(p1 == p2)
return;
TreeNode *x = p1;
TreeNode *y = p1->right;
while(true) {
TreeNode *temp = y->right;
y->right = x;
x = y;
y = temp;
if(x == p2)
break;
}
}
void printReverse(TreeNode *p1, TreeNode *p2) {
reverse(p1, p2);
TreeNode *pNode = p2;
while(true) {
cout << pNode->val << endl;
if(pNode == p1)
break;
pNode = pNode->right;
}
reverse(p2, p1);
}
void postOrder3(TreeNode *root) {
if(root == NULL)
return;
TreeNode *dummy = new TreeNode(-1);
dummy->left = root;
TreeNode *pNode = dummy;
while(pNode != NULL) {
if(pNode->left == NULL)
pNode = pNode->right;
else {
TreeNode *pPrev = pNode->left;
while(pPrev->right != NULL && pPrev->right != pNode)
pPrev = pPrev->right;
if(pPrev->right == NULL) {
pPrev->right = pNode;
pNode = pNode->left;
}
else {
printReverse(pNode->left, pPrev);
pPrev->right = NULL;
pNode = pNode->right;
}
}
}
}
References
http://noalgo.info/832.html
http://www.cnblogs.com/AnnieKim/archive/2013/06/15/morristraversal.html