递归的缺点 二叉树的主要遍历方式有以下几种:
前序遍历:根- 左子树-右子树(前序遍历可以唯一确定一棵二叉树 )
中序遍历:左子树-根-右子树(二叉搜索树使用此遍历可以从小大到大输出所有元素。 )
后序遍历:左子树-右子树-根(结合中序遍历可以唯一确定一棵二叉树 )
二叉树遍历最常见的,也是最简单的思路是使用递归算法。既然有了最简答的写法,为什么还有学习更复杂的迭代实现呢?主要是递归有以下缺点:
递归的本质是函数调用,对于大多数操作系统,每一次函数调用都是一个代价较高的过程,主要因为以下几个方面:
性能上考虑:函数每一次调用都得往栈中压入函数返回地址,函数参数,以及为函数内的所有局部变量分配空间。每个进程的栈空间是有限的,递归层次太多,会导致往栈中存在太多数据,易导致栈溢出,进而程序崩溃。
效率上考虑:当系统进行函数调用时,会向栈中压入函数返回地址,函数形参,为局部变量分配空间,当函数执行完毕时,需要进行弹栈(也称为清栈)操作使之与函数调用前一模一样,往栈中压入数据和弹出数据都需要时间,从某种程度上降低了程序的性能。
说了递归的缺点,现在说下迭代的好处:迭代没有函数调用所产生的额外开销,也没有压栈和弹栈操作,通常空间利用率更高,程序运行效率更好。 系统 : ubuntu 18.04 64位
编程语言: c++
g++ 版本: g++ 8.4.0
节点定义:为了简化代码,省略了父节点。
1 2 3 4 5 6 7 8 9 struct TreeNode { int key; TreeNode *left; TreeNode *right; TreeNode *parent; TreeNode () : val (0 ), left (nullptr ), right (nullptr ){} TreeNode (int x) : val (x), left (nullptr ), right (nullptr ) {} };
为了封装二叉树的各种操作,我们创建一个二叉树类:
1 2 3 4 5 6 7 8 class BinaryTree {public : TreeNode *root; void insert (TreeNode *node) ; TreeNode* treeBuild (vector<int > &tab, int &index, TreeNode *parent) ; void preOrder (TreeNode *root) ; void preIteration (TreeNode *root) ; };
二叉树构造 为了测试遍历代码的正确性,往往需要构建一棵二叉树。二叉树的构建方法主要有两种:
前序遍历法 使用前序遍历可以唯一确定一棵二叉树,二叉树的前序遍历顺序:根-左子树-右子树,可以使用 vector 来存储二叉树的前序遍历,遍历序列中必须包含空子树标记。例如:现在给定一个前序遍历序列:vector<int> pre{4,2,1,NULL,NULL,3,NULL,NULL,5,NULL,NULL};
可以根据前序遍历的步骤来构建二叉树。
1 2 3 4 5 6 7 8 9 10 11 12 13 TreeNode* BinaryTree::treeBuild (vector<int > &tab, int &index, TreeNode *parent) { TreeNode *root = nullptr ; if (index < tab.size () && tab[index] != 0 ) { root = new TreeNode (tab[index]); root->parent = parent; root->left = treeBuild (tab, ++index, root); root->right = treeBuild (tab, ++index, root); } return root; }
随机插入法 此方法适用于二叉搜索树,一棵二叉搜索树的定义如下,若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值; 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值。二叉搜索树的所有子树都为二叉搜索树。
给定一组序列,使用二叉搜索树的插入方法,可以构建一棵有序的二叉树。次方法不必知道二叉树的结构。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 void BinaryTree::insert (TreeNode *node) { TreeNode *x = root; TreeNode *y = nullptr ; while (x != nullptr ) { y = x; if (node->val < x->val) { x = x->left; } else { x = x->right; } } if (y == nullptr ) { root = node; } else if (node->val < y->val) { y->left = node; } else { y->right = node; } }
前序遍历 递归法
递归的本质是自己调用自己,每一次调用都是输出当前子树的根节点值。
函数第一次调用时,root 为根节点,首先输出根节点值。
然后再递归调用左子树,右子树。这样就依次输出了左节点值,右节点。和前序遍历的要求一致:根-左-右
递归本质是基于栈的回溯,即它在递归的时候,会在栈中记录所走的路径,回溯时输出节点值。可以看到递归法代码更加简洁,只需4行代码即可。
1 2 3 4 5 6 7 8 9 10 void BinaryTree::preOrder (TreeNode *root) { if (root != nullptr ) { cout << root->val << " " ; preOrder (root->left); preOrder (root->right); } }
迭代法 迭代的原理就是利用一个栈来记录路径,模拟函数调用的回溯。迭代法避免了函数调用产生的代价,具有更高的运行效率,但代码会更复杂一些。
循环开始前,将根节点入栈。
每一次循环都先输出当前子树根节点值,弹出当前子树根节点 cur,然后再 cur 的右子树,左子树入栈。因为栈本身的特性,后到先服务。
再下一轮循环中,首先处理左子树,然后处理右子树。这样就满足前序遍历:根-左子树-右子树的要求。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 void BinaryTree::preIteration (TreeNode *root) { stack<TreeNode *> s; s.push (root); while (!s.empty ()){ TreeNode *p = s.top (); s.pop (); cout<<p->val<<" " ; if (p->right!=nullptr ){ s.push (p->right); } if (p->left!=nullptr ){ s.push (p->left); } } }
中序遍历 递归法 只需改变输出根节点值的位置即可。
1 2 3 4 5 6 7 8 9 10 void BinaryTree::inOrder (TreeNode *root) { if (root != nullptr ) { inOrder (root->left); cout << root->val << " " ; inOrder (root->right); } }
迭代法 中序遍历是;左-根-右。
使用 cur 作为当前节点,循环开始前 cur 的值为根节点。
因为要先输出左子树,在循环开始,使用一个子循环将 当前节点 cur 的左孩子节点,左孩子的左孩子入栈,直到 cur 为空为止。
此时栈顶就是输出的第一个节点。弹出栈顶到 cur 中,并输出,将 cur 设置为它的右孩子,如果其右孩子存在的话,对其右子树执行相同的操作,就是找到最左边节点;如果 其右孩子为空,则 cur 也为空,在循环开始处不会有节点在进栈,此时弹出的当前节点 cur 就是最先输出节点的父节点,将该节点输出,然后将 cur 设置为其右孩子,对其右孩子执行相同的操作。这样就实现了回溯的功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void BinaryTree::inIteration (TreeNode *root) { stack<TreeNode *> s; TreeNode* cur = root; while (cur!=nullptr || !s.empty ()){ while (cur!=nullptr ){ s.push (cur); cur = cur->left; } cur = s.top (); s.pop (); cout<< cur->val<<" " ; cur = cur->right; } }
后序遍历 递归版本 1 2 3 4 5 6 7 8 9 void BinaryTree::postOrder (TreeNode *root) { if (root!=nullptr ){ postOrder (root->left); postOrder (root->right); cout<<root->val<<" " ; } }
迭代版本 后续遍历的迭代版本会更加复杂一些。
后序遍历: 左-右-根。输出左孩子简单,使用一个子循环就可以找到最左边孩子,重点是从右节点到根节点之后,需要判断它是否从右孩子回溯而来,如果是,则表示它的右孩子已经处理,可以处理根节点,如果不是回溯而来,则需要先处理它的右子树。
使用一个遍历 pre 来记录上一次访问的节点,这样就可以容易的判断当前节点是不是由右孩子回溯而来。
第一次循环:当前节点是根节点,pre 为空节点。使用一个子循环将当前节点 cur 的左孩子节点,左孩子的左孩子入栈,知道 cur 为空。
弹出栈顶元素到 cur,此时判断 cur 是否为回溯而来,只需判断 cur 的右孩子是否是 pre,不是则将 cur 再次入栈,并将 cur 的右孩子入栈,执行相同的操作。如果 cur 的右孩子为 pre,则表示 cur 是由 pre 回溯而来,当前 cur 作为当前子树的根节点,将 pre 设置为 cur,将 cur 设置为 空。在下一次循环中,就来到了上一层节点,实现了回溯的功能。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 void BinaryTree::postIteration (TreeNode *root) { stack<TreeNode *> s; TreeNode *cur = root; TreeNode *pre = nullptr ; while (cur || !s.empty ()){ while (cur){ s.push (cur); cur = cur->left; } if (!s.empty ()){ cur = s.top (); s.pop (); if (cur->right==nullptr || pre == cur->right){ cout<<cur->val<<" " ; pre = cur; cur = nullptr ; }else { s.push (cur); cur = cur->right; } } } }
测试代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 int main () { vector<int > pre{4 ,2 ,1 ,NULL ,NULL ,3 ,NULL ,NULL ,5 ,NULL ,NULL }; vector<int >tab {1 ,3 ,2 ,6 ,4 ,5 }; BinaryTree b; for (auto x:tab){ TreeNode *node = new TreeNode (x); b.insert (node); } cout<<"前序遍历(递归): " ; b.preOrder (b.root); cout<<endl; cout<<"前序遍历(迭代): " ; b.preIteration (b.root); cout<<endl; cout<<"中序遍历(递归): " ; b.inOrder (b.root); cout<<endl; cout<<"中序遍历(迭代): " ; b.inIteration (b.root); cout<<endl; cout<<"后序遍历(递归): " ; b.postOrder (b.root); cout<<endl; cout<<"后续遍历(迭代): " ; b.postIteration (b.root); cout<<endl; return 0 ; }
1 2 3 4 5 6 前序遍历(递归): 1 3 2 6 4 5 前序遍历(迭代): 1 3 2 6 4 5 中序遍历(递归): 1 2 3 4 5 6 中序遍历(迭代): 1 2 3 4 5 6 后序遍历(递归): 2 5 4 6 3 1 后续遍历(迭代): 2 5 4 6 3 1
编程练习 学习完二叉树遍历方法,可以做题巩固一下,建议把递归和迭代都实现一遍。
参考 http://icejoywoo.github.io/2020/03/31/binary-tree-traversal.html
https://zhuanlan.zhihu.com/p/80578741