前情提要
事情是这样的:本人近日跟着邓俊辉的网课学习数据结构(不知道有没有听过的,讲得真的很好),在学习到二叉树的三种遍历,也就是先序、中序、后序这三种时,对课堂上提供的算法有些不满:三种算法全都采用引入一个辅助栈的方法来确定节点的遍历顺序。
这样的算法在我的眼中,不够优雅。老师在二叉树遍历的第一节课就说过,随着二叉树的规模、深度增大,递归遍历版本会出现"函数调用栈溢出"的问题。现在优化为了迭代版本,却依然没有解决这个问题。
于是我就带着这个问题,走向了马克思主义的教室(本人开学以来第一次见到这老师,其他时间都是在寝室自学,今天听说有期中考试才来的)。课上实在无聊,我口袋里又正好有一张草稿纸。闲来无事,在上面画了一棵树。
历史上也不乏这样的时刻--灵感总是在不经意间涌现。我把目光从整体的递归,或者说迭代策略,转向了局部,节点与节点之间的逻辑联系。于是发现了一种基于父指针的,不需要辅助栈的迭代遍历策略。这样的发现不能说不令人欣喜,于是写下我的第一篇博客,以作纪念。
算法思路与实现
我的算法基于父指针来维系节点之间的逻辑联系。这大概是其唯一的缺点。但是,耗费与栈相当的空间(对于已经提供父指针的情景,额外的空间是O(1)),不仅能够解决栈溢出的问题,而且在形式上,三种遍历算法高度统一,我自认为算是个优雅且优秀的算法。
先序遍历:V->L->R
先从先序遍历开始吧,我们有一棵二叉树,如图:

思路是这样的,从根节点开始。在任意时刻,我们先遍历当前节点,然后面临的无非这几种情况:
当前节点有左孩子,那就继续去遍历左孩子。
当前节点没有左孩子(左孩子为空),但是有右孩子,那就去遍历右孩子。
当前节点既没有左孩子,也没有右孩子(叶子节点),那就要回溯了。
回溯到什么时候呢?回溯时也无非两种情况,我们不妨讨论一下,使得你的思路更加清晰:
一、回溯时,自己是父节点的"非独生"左孩子。这很好理解,也就是这个父节点的左子树遍历完了嘛,那就接着去遍历他的右子树
二、回溯时,自己是父节点的"独生"左孩子,或是右孩子。也很好理解吧,那这个父节点的左右子树就全部遍历完了!我们就跳过这个父节点,继续向上回溯,直到它是父节点的"非独生"左孩子。
现在我们已经讨论完了遍历中所有的情况,那循环什么时候退出呢?答案是"根节点的左右子树被遍历完"。也就是说,当前节点重新回到根节点(父节点为空)时,退出循环,遍历结束。
下面给出代码(自认为完美,欢迎大佬来找bug):
scss
template<typename T, typename VST>
void traverse(bin_node<T>* node, VST& visit)
{
if (node == nullptr) return;//边界情况考虑
while (true)
{
visit(node);//先序遍历
if (node->get_left_child() != nullptr) node = node->get_left_child();//如果左孩子存在,则继续深入
else if (node->get_right_child() != nullptr) node = node->get_right_child();//如果没有左孩子,但是有右孩子,则转向右孩子
//如果为叶节点,开始回溯
else
{
while (true)
{
if (node->get_parent() == nullptr)break;//如果追溯到根节点,说明整棵树遍历完了,退出循环
if (node == node->get_parent()->get_left_child())//如果是父节点的左孩子,讨论是否"独生"
{
if (node->get_parent()->get_right_child() == nullptr);//如果独生,那就继续回溯
else break;//如果非独生,就退出循环,去遍历父节点的右子树
}
else;//如果是父节点的右孩子,继续回溯
node = node->get_parent();
}
if (node->get_parent() == nullptr) return;//如果追溯到根节点的上方,说明已经遍历完了
node = node->get_parent()->get_right_child();//如果不是根节点,转向右孩子
}
}
}
中序遍历
思路是一样的,只不过这里要等到节点的左子树遍历完再遍历该节点。
我们也分情况讨论。从根节点开始。在任意时刻,我们先不遍历当前节点,面临的无非这几种情况:
当前节点有左孩子,那就转向左孩子
当前节点没有左孩子,但是有右孩子。那就到了这个节点被遍历的时候了。它被遍历之后,要转向他的右孩子。
当前节点为叶节点,那就开始回溯
回溯的时候呢,也无非下面几种情况:
一、当前节点为左孩子,那就说明他的父节点的左子树已经遍历完了,遍历他的父节点。如果这个父节点有右孩子呢,就去遍历,如果没有那就继续向上回溯。
二、当前节点为右孩子,那说明它的父节点是不用遍历的,向上回溯。
(写到这里,不得不感叹一句:这写文章和在脑子里想真不是一回事啊。脑子里的思路透明如水晶,一到想要写下来时却找不着东南西北。)
scss
template<typename T, typename VST>
void my_traverse_middle(bin_node<T>* node, VST& visit)
{
if (node == nullptr) return;//边界情况
while (true)
{
if (node->get_left_child() != nullptr) node = node->get_left_child();//如果左孩子存在,继续深入
else//如果没有左孩子,那就遍历当前节点
{
visit(node);
if (node->get_right_child() != nullptr) node = node->get_right_child();//如果有右孩子,那就转向右孩子
else//没有的话,也就是叶节点,向上回溯,直到当前节点为"非独生"左孩子
{
while (true)
{
if (node->get_parent() == nullptr)break;//如果追溯到根节点,说明整棵树遍历完了,退出循环
if (node == node->get_parent()->get_left_child())//如果是父节点的左孩子,就遍历父节点,再讨论是否"独生"
{
visit(node->get_parent());
if (node->get_parent()->get_right_child() != nullptr) break;//如果非独生,就去遍历父节点的右子树
else;//如果独生,继续回溯
}
else;//如果是父节点的右孩子,继续回溯
node = node->get_parent();
}
if (node->get_parent() == nullptr) return;//如果追溯到根节点的上方,说明遍历完了
node = node->get_parent()->get_right_child();
}
}
}
}
后序遍历

对于后序遍历,我们理解这样一个事实:对于任意一棵(子)树,我们总是先去遍历最左边的叶子
从根节点开始,我们向下遍历,无非这几种情况:
当前节点有左孩子,那就继续向左
如果当前孩子没有左孩子,那就尝试向右
如果当前节点为叶子节点,那就遍历,并开始回溯
我们接着讨论回溯时的情况
一、当前孩子为"非独生"左孩子,那就去遍历他的父节点的右子树
二、当前节点为"独生"左孩子,或者当前节点为右孩子,说明到了它的父节点被遍历的时候了
下面给出这个算法的实现:
scss
template<typename T, typename VST>
void traverse(bin_node<T>* node, VST& visit)
{
if (node == nullptr) return;//边界情况
while (true)
{
if (node->get_left_child() != nullptr)node = node->get_left_child();//如果有左孩子,就向左
else if (node->get_right_child() != nullptr)node = node->get_right_child();//如果没有左孩子,但是有右孩子,就向右
else //都没有,说明是叶节点,开始回溯
{
visit(node);
while (true)
{
if (node->get_parent() == nullptr)break;//如果追溯到根节点,说明整棵树遍历完了,退出循环
if (node == node->get_parent()->get_left_child())//如果是父节点的左孩子,讨论是否"独生"
{
if (node->get_parent()->get_right_child() == nullptr)visit(node->get_parent());//如果独生,说明父节点左右子树均遍历完,到了他被遍历的时候
else break;//如果非独生,就退出循环,去遍历父节点的右子树
}
else//如果是父节点的右孩子,那就遍历父节点,继续回溯
{
visit(node->get_parent());
}
node = node->get_parent();
}
if (node->get_parent() == nullptr) return;//如果追溯到根节点的上方,说明遍历完了
node = node->get_parent()->get_right_child();
}
}
}
总结
我认为这个算法是有价值的
首先,空间上:对于自带父指针的树,他的额外空间复杂度是O(1),就算是算上父指针也不过O(n),还能避免栈溢出的问题。
形式上,不难看出,三种遍历算法的逻辑和代码实现都是高度统一的。
时间上,省去了入栈出栈的操作,代价是每个非叶子节点都要被node"经过"两次。总体上,时间复杂度对比传统的辅助栈方法是小优,仍然是O(n)
当然也有缺点,首先是这个父指针的事吧,我跟着课堂上实现的二叉树版本是自带父指针的,我也不知道这个父指针的泛用性咋样。还有就是代码逻辑对比传统的辅助栈方法确实不那么简明,但我觉得还好。(不知道看到这里的你看懂了吗?)
事实上,对于层次遍历,我也自己推导出了"无队列版本",但为了保证这篇博客内容的统一性,就不放在这里了。有兴趣的朋友可以私信交流。