二叉树遍历是二叉树中最基本的问题,其实现的方法非常多,有简单粗暴但容易爆栈的递归算法,还有稍微高级的使用栈模拟递归的非递归算法,另外还有不用栈而且只需要常数空间和线性时间的神奇Morris遍历算法,本文将对这些算法进行讲解和实现。
递归算法
二叉树节点使用以下数据结构进行表示,包括关键字、左儿子、右儿子属性和一个带默认参数的构造函数。
struct成员的默认属性为public,于是可以直接访问。
struct Node
{
int val;
Node *left, *right;
Node(int v = 0, Node *l = NULL, Node *r = NULL) : val(v), left(l), right(r) {}
};
二叉树的递归算法非常简单,设置好递归出口之后,根据遍历的顺序,对当前节点的左右子树递归调用自身即可。其前序、中序、后序遍历的代码如下。
void preorder1(Node *root) //递归前序遍历
{
if (!root) return;
printf("%d ", root->val);
preorder1(root->left);
preorder1(root->right);
}
void inorder1(Node *root) //递归中序遍历
{
if (!root) return;
inorder1(root->left);
printf("%d ", root->val);
inorder1(root->right);
}
void postorder1(Node *root) //递归后序遍历
{
if (!root) return;
postorder1(root->left);
postorder1(root->right);
printf("%d ", root->val);
}
栈模拟非递归算法
递归算法的本质是利用了函数的调用栈,实际上我们可以自行使用栈来进行模拟,这样算法空间复杂度为O(h),h为二叉树的高度(这里的h指的是stack元素最多时含有的元素数目,h最坏情况下为n)。
前序遍历
首先把根节点入栈,然后在每次循环中执行以下操作:
- 此时栈顶元素即为当前的根节点,弹出并打印当前的根节点。
- 把当前根节点的右儿子和左儿子分别入栈(注意是右儿子先入栈左儿子后入栈,这样的话下次出栈的元素才是左儿子,这样才符合前序遍历的顺序要求:根节点->左儿子->右儿子)。
下面是代码实现。
void preorder2(Node *root)//非递归前序遍历
{
if (!root) return;
stack<Node *> stk;
stk.push(root);
while (!stk.empty())
{
Node *p = stk.top(); stk.pop();
printf("%d ", p->val);
if (p->right) stk.push(p->right);
if (p->left) stk.push(p->left);
}
}
后序遍历
因为后序遍历的顺序是:左子树->右子树->根节点,于是我们在前序遍历的代码中,当访问完当前节点后,先把当前节点的左子树入栈,再把右子树入栈,这样最终得到的顺序为:根节点->右子树->左子树,刚好是后序遍历倒过来的版本,于是把这个结果做一次翻转即为真正的后序遍历。而翻转可以通过使用另外一个栈简单完成,这样的代价是需要两个栈,就复杂度而言,空间复杂度为O(n)。
void postorder2(Node *root)//非递归后序遍历
{
if (root == NULL) return;
stack<Node *> stk, stk2;
stk.push(root);
while (!stk.empty())
{
Node *p = stk.top(); stk.pop();
stk2.push(p);
if (p->left) stk.push(p->left);
if (p->right) stk.push(p->right);
}
while(!stk2.empty())
{
printf("%d ", stk2.top()->val);
stk2.pop();
}
}
第二种思路:要保证根结点在左孩子和右孩子访问之后才能访问,因此对于任一结点P,先将其入栈。如果P不存在左孩子和右孩子,则可以直接访问它;或者P存在左孩子或者右孩子,但是其左孩子和右孩子都已被访问过了,则同样可以直接访问该结点。若非上述两种情况,则将P的右孩子和左孩子依次入栈,这样就保证了每次取栈顶元素的时候,左孩子在右孩子前面被访问,左孩子和右孩子都在根结点前面被访问。
void postOrder3(Node *root) //非递归后序遍历
{
if (!root) {
return;
}
stack<Node*> s;
Node *pre=NULL; //前一次访问的结点
s.push(root);
while(!s.empty())
{
Node* cur=s.top(); //当前节点
if((cur->left==NULL&&cur->right==NULL)||
(pre!=NULL&&(pre==cur->left||pre==cur->right)))
{
cout<<cur->val<<" "; //如果当前结点没有孩子结点或者孩子节点都已被访问过
s.pop();
pre=cur;
}
else
{
if(cur->right!=NULL)
s.push(cur->right);
if(cur->left!=NULL)
s.push(cur->left);
}
}
}
中序遍历
要注意到每次要先遍历当前节点最左节点
下面是代码实现。
void inorder2(Node *root)//非递归中序遍历
{
if(!root) return;
stack<Node*> mystack;
Node *p = root;
while(p || !mystack.empty())
{
while(p)
{
mystack.push(p);
p = p->left;
}
Node* top = mystack.top();
cout << top->val;
mystack.pop();
p = p->right;
}
}
Morris遍历
Morris遍历的神奇之处在于它是非递归的算法,但并不需要额外的O(h)的空间,而且复杂度仍然是线性的。这样的算法最关键的问题是当访问完一棵子树后,如何回到其对应的根节点再继续访问右子树呢?Morris是通过修改二叉树某些节点的指针来做到的。
中序遍历
按照定义,在中序遍历中,对于一棵以root为根的二叉树,当访问完root的前驱节点后,需要回到root节点进行访问,然后再到root的右儿子进行访问。于是,我们可以每次访问到一棵子树时,找到它的前驱节点,把前驱节点的右儿子变为当前的根节点root,这样当遍历完前驱节点后,可以顺着这个右儿子回到根节点root。
但问题是修改了该前驱节点的右儿子后什么时候再改回来呢?
- 当第一次访问以root为根的子树时,找到它的前驱pre,此时pre的右儿子必定为空,于是把这个右儿子设置为root,以便以后根据这个指针回到root节点。
- 当第二次回到以root为根的子树时,再找到它的前驱pre,此时pre的右儿子已经被设置成了当前的root,这时把该右儿子重新设置成NULL,然后继续进行root的右儿子的遍历。于是完成了指针的修改。
在这样的情景下,寻找当前节点的前驱节点时,不仅需要判断其是否有右儿子,而且还要判断右儿子是否为当前的root节点,跟普通情况下的寻址前驱节点稍微多了一个条件。
由于在每次遍历一个节点的时候都需要寻找其前驱节点,而寻找前驱节点的时间一般与树的高度相关,这样看上去算法的复杂度应该为O(nlogn)才对。但由于其只需要对有左儿子的节点才寻找前驱,于是所有寻找前驱时走过的路加起来至多为一棵树的节点数,例如在下文的例子中,只需要对以下节点寻找前驱:
- 节点4:寻找路径为:2-3
- 节点2:寻找路径为:1
- 节点6:寻找路径为:5
于是寻找前驱加上遍历的运算量之和至多为2*n,n为节点个数,于是算法的复杂度为仍然为O(n)。
复杂度详细分析:
空间复杂度:O(1),因为只用了两个辅助指针。
时间复杂度:O(n)。证明时间复杂度为O(n),最大的疑惑在于寻找中序遍历下二叉树中所有节点的前驱节点的时间复杂度是多少,即以下两行代码:
1 while (pre->right != NULL && pre->right != p)
2 pre = pre->right;
直觉上,认为它的复杂度是O(nlgn),因为找单个节点的前驱节点与树的高度有关。但事实上,寻找所有节点的前驱节点只需要O(n)时间。n个节点的二叉树中一共有n-1条边,整个过程中每条边最多只走2次,一次是为了定位到某个节点,另一次是为了寻找上面某个节点的前驱节点,如下图所示,其中红色是为了定位到某个节点,黑色线是为了找到前驱节点。所以复杂度为O(n)。
其实现代码如下:
void inorder3(Node *root)//Morris中序遍历
{
Node *p = root;
while (p != NULL)
{
if (p->left == NULL)
printf("%d ", p->val), p = p->right;
else
{
Node *pre = p->left;
while (pre->right != NULL && pre->right != p)
pre = pre->right;
if (pre->right == NULL) //第一次访问,修改pre的右儿子
pre->right = p, p = p->left;
else //第二次访问,改回pre的右儿子
pre->right = NULL, printf("%d ", p->val), p = p->right;
}
}
}
前序遍历
前序遍历和中序遍历类似,只是在遍历过程中访问节点的顺序稍有不同。即在第一次访问一棵子树时,就要先对根节点进行访问,于是printf输出语句被放到了if判断中第一次访问的分支中。
其代码如下:
void preorder3(Node *root)//Morris前序遍历
{
Node *p = root;
while (p != NULL)
{
if (p->left == NULL)
printf("%d ", p->val), p = p->right;
else
{
Node *pre = p->left;
while (pre->right != NULL && pre->right != p)
pre = pre->right;
if (pre->right == NULL) //第一次访问,修改pre的右儿子
pre->right = p, printf("%d ", p->val), p = p->left;
else //第二次访问,改回pre的右儿子
pre->right = NULL, p = p->right;
}
}
}
后序遍历
后序遍历稍微复杂,但其遍历的基本顺序也是和前/中序遍历类似,只是在打印的时候做了一个翻转。考虑下文例子中的后序遍历结果:1 3 2 5 7 6 4。其可以这样进行拆分并进行解释:
- 1:最左下角的结果节点
- 3 2:节点2、3的倒序
- 5:右儿子的最左下角的节点
- 7 6 4:右边一列节点4、6、7的倒序
于是我们可以在中序遍历过程中,当第二次访问到一个节点时,把它的左儿子到它的前驱节点的路径上的节点进行翻转打印,即可得到后序遍历的结果。但这样的话根节点到最右下角那一列会访问不到,增加一个辅助节点作为新的根节点,把原有根节点作为其左儿子即可。
注意!!morris后序遍历时,只有一种输出的情形,即第二次访问到某个节点的时候。
其实现代码如下:
void reverse(Node *p1, Node *p2)//使用right指针翻转p1到p2节点
{
if (p1 == p2) return;
Node *pre = p1, *p = p1->right;
while (true) //这个时候,不能用cur是否为NULL作为终止条件!!!因为会调用reverse两次,第二次调用的时候cur可能永远不能为NULL
{
Node *tem = p->right;
p->right = pre;
if (p == p2) break;
pre = p, p = tem;
}
}
void print(Node *p1, Node *p2)//逆序打印p1到p2节点
{
reverse(p1, p2);
for (Node *p = p2; ; p = p->right)
{
printf("%d ", p->val);
if (p == p1) break;
}
reverse(p2, p1);
}
void postorder3(Node *root)//Morris后序遍历
{
Node dummy(-1, root, NULL), *p = &dummy;
while (p != NULL)
{
if (p->left == NULL)
p = p->right;
else
{
Node *pre = p->left;
while (pre->right != NULL && pre->right != p)
pre = pre->right;
if (pre->right == NULL)
pre->right = p, p = p->left;
else
print(p->left, pre), pre->right = NULL, p = p->right;//这里pre->right一定要在print之后,一定要在print之后,一定要在print之后,因为print调用的reverse会修改pre->right的数值!!!!!!
}
}
}
代码测试
在下面的主函数中,我们对以下简单的二叉树进行测试。
4
/ \
2 6
/ \ / \
1 3 5 7
主函数代码如下:
#include <cstdio>
#include <stack>
using namespace std;
int main()
{
Node a1(1), a3(3), a5(5), a7(7);
Node a2(2, &a1, &a3), a6(6, &a5, &a7);
Node a4(4, &a2, &a6);
preorder1(&a4); printf("\n"); //4 2 1 3 6 5 7
preorder2(&a4); printf("\n"); //4 2 1 3 6 5 7
preorder3(&a4); printf("\n"); //4 2 1 3 6 5 7
printf("\n");
inorder1(&a4); printf("\n"); //1 2 3 4 5 6 7
inorder2(&a4); printf("\n"); //1 2 3 4 5 6 7
inorder3(&a4); printf("\n"); //1 2 3 4 5 6 7
printf("\n");
postorder1(&a4); printf("\n"); //1 3 2 5 7 6 4
postorder2(&a4); printf("\n"); //1 3 2 5 7 6 4
postorder3(&a4); printf("\n"); //1 3 2 5 7 6 4
}