前言
记得有一次面试的时候,做了一道递归算法题,面试官有意无意来了一句说所有的递归都可以用迭代来实现,当然他没让我写。最近重新学习二叉树,LeetCode上对二叉树前序、中序、后序遍历要求用递归和迭代两种算法实现,这勾起来了之前的回忆,决定深究下这其中的关系。
分析
理解迭代、递归
递归和递归,乍一看好像写法不大一样,递归是调用自己,迭代是循环调用。但仔细一想,它们做的都是同样的事——反复执行一段核心代码,举个栗子,打印100以内的计数,例如:1,2,3...98,99:
递归实现:
void printNum(int i) {
if (i < 100) {
System.out.println(i);
++i;
printNum(i);
}
}
迭代实现:
void printNum(int i) {
while (i < 100) {
System.out.println(i);
++i;
}
}
尽可能用简单相似的代码实现,控制无关变量更利于分析。对比观察上述代码,分离出核心代码:
i < 100;
System.out.println(i);
++i;
不同的只是递归在最后又拿着新的i去重复执行System.out.println(i); i++;了,而迭代是while语句拿着新的i去重复执行System.out.println(i); i++;。由此可见,递归和迭代原来是穿着不同风格外衣的同一个人啊。
迭代与递归的关系
通过上述的例子,我们找到了递归和迭代的相似性,由于上述例子过于简单,无法形象地描述递归的细节,咱们再通过二叉树前序遍历来分析一下
给出一个最简单的二叉树,虽然简单也足以说明问题了:
递归详解
二叉树前序遍历的递归算法大家都很了解,类似于这样:
List<Integer> list = new ArrayList<>();
public void preorderTraversal(TreeNode root,List<Integer> list>) {
if (root != null) { // 第1步
list.add(root.val); // 第2步
preorderTraversal(root.left); // 第3步
preorderTraversal(root.right); // 第4步
}
}
接下来咱们根据上述二叉树图片结合代码推理演绎,初始root = A:
- 执行第1,2步:,list.add(A.value);。
- 执行第3步:root = B了;重新调用递归方法。
- 执行1,2步:list.add(B.value)。
- 执行第3步:因为B没有左节点,所以root = null;重新调用递归方法。
- 执行第1步:由于root == null,直接退出递归方法。但是,程序并没有就此退出,而是回到了
序列4(不是注释中的第4步)执行结束的状态,而root也重新被重新赋值,root = B。 - 执行第4步:由于B没有右节点,所以root再次被赋值,root = null; 重新调用递归方法。
- 与
序列5类似, root == null,直接退出递归方法,程序依然没有就此退出,而是回到了序列2执行结束的状态,此时root被重新赋值,root = A,而对应的步骤则是在第3步。 - 执行第4:由于root == A;root.right == C, 重新调用递归方法,root被赋值,root = C。
- 接下来的步骤与对B节点做递归类似,C节点值会被放入list集合,由于C节点左右字节点均为空,所以最终又回到了
序列8执行完成的状态,此时root又被重新赋值,root = A。 - 此时再没有上级方法可以回去了,程序退出,List == [A.value, B.value, C.value]。
通过图片看看调用过程和root变化:
在整个递归过程中值得注意的就两个地方,一个是上文所诉的反复执行,另一个则是回到上级方法,局部变量被重新赋值。学Java的朋友都知道,Java运行时内存区域有一个称之为栈的区域,记录线程执行方法的数据和状态。如果进入一个方法,则会向当前线程栈中push一个栈桢,而退出一个方法时pop一个栈桢,每个栈桢内部有一张局部变量表,记录当前方法运行时的局部变量。耐心看到这里的同学是否恍然大悟,哇,这不就是递归方法执行的步骤吗,先不断入栈,局部变量不断被赋值,然后不断出栈,局部变量又逐步恢复到曾经的值。最终栈中没有方法,程序结束。
到这就清楚递归的原理了:通过方法调用栈记录局部变量,反复执行一段代码处理数据。
递归转化迭代
既然递归只是通过方法栈记录局部变量执行核心代码,那么可以用迭代去实现类似的效果嘛?当然可以啦,迭代没有方法栈,咱可以模拟一个方法栈记录局部变量。
使用迭代前序遍历上述二叉树:
// 伪代码:
public void preorderTraversal(TreeNode root,List<Integer> list>) {
if (root != null) {
list.add(root.val);
if (root.left != null) {
list.add(root.left.val);
... // 重复整个核心逻辑
}
if (root.right != null) {
list.add(root.left.val);
... // 重复整个核心逻辑
}
}
}
如果二叉树节点无穷多,上述代码也将是一个无法完成的算法。分析上述代码,对于每一个节点处理方式都是相同的,先判空,然后写入集合,再对左子树判空写入集合,接着对右子树判空写入集合。进一步简化逻辑,对节点判空,写入集合。但是需要去考虑变量root:
- 它在进入左子节点时,root = root.left;
- 在完成左子节点判空写入或判空结束之后,root又重新被赋值,root = root;
- 在进入右子节点时,root = root.right,
- 在完成右子节点判空写入或判空结束之后,root又重新被赋值,root = root。
- 最后退出程序。
类似这种写法
// 伪代码
public void preorderTraversal(TreeNode root,List<Integer> list>) {
while (root != null) {
while (root != null) {
list.add(root.val);
root = root.left;
}
root = root.right; // tip1:执行出错
}
}
上述伪代码在注释tip1中出错,原因很简单,出现NullPointException。root在遍历完左节点之后,没能重新被赋值为父节点以进行右节点的遍历。由上文二叉树递归分析可知,递归在进行下一次递归调用之前整个方法入栈,保存了局部变量,在调用结束后方法出栈,局部变量也随之恢复了入栈前的数据。那么我们完全可以创建一个栈去纪录root的变化,代码如下。
Stack<TreeNode> stack = new Stack<>();
public void preorderTraversal(TreeNode root,List<Integer> list>) {
while (root != null || !stack.isEmpty()) {
// 遍历左子节点
while (root != null) {
list.add(root.val);
stack.push(root); // 父节点遍历完成,入栈
root = root.left;
}
// 左子节点为null,左子节点遍历完成
if (!stack.isEmpty()) {
TreeNode root = stack.pop(); // 出栈,root被重新赋值为右节点
root = root.right; //
}
}
// 栈空,退出程序
}
总结感悟
总的来说,迭代实现其实借鉴了递归的方法栈思想。编程之所以难,难在逻辑细节处理。每一个被忽视的逻辑细节,将来都可能栽倒在上面。务实基础,打好地基,将来不论是建怎样的高楼大厦,都底气十足。