一、什么是递归程序?
这里引用维基百科中关于递归程序的定义:
递归(英语:Recursion),又译为递回,在数学与计算机科学中,是指在函数的定义中使用函数自身的方法。递归一词还较常用于描述以自相似方法重复事物的过程。例如,当两面镜子相互之间近似平行时,镜中嵌套的图像是以无限递归的形式出现的。也可以理解为自我复制的过程。
简而言之,在计算机中,递归就是指在一个函数中出现了自己调用自己的行为。
下面是一个简单的递归程序的示例:
public static int f(int n) {
if (n <= 1) {
return 1;
}
return n * f(n - 1);
}
上面的例子用来求解一个n的阶乘,在f函数中,出现了f自己调用自己的行为,因此f就是一个递归函数。
递归函数中有几个比较重要点:
- 所有的递归函数,一定有递归的出口,这里n == 1就是递归的出口,如果一个递归函数没有出口,那么该递归函数将会无休无止的递归调用下去,最终导致栈溢出。
- 函数可以递归,说明当前函数解决的问题可以被分解为规模更小的问题,且这些更小的问题的解决方法和原问题一样,比如这里的f函数,它解决的问题就是求解n的阶乘,n的阶乘问题可以被分解为规模更小的问题,比如n的阶乘可以转换为n乘以n-1的阶乘,n-1相较于n,问题规模缩小了1,而求n-1的阶乘的方法和求n的阶乘的方法是一样的,因此求解子问题可以使用原问题的解决方法,所以这里就可以继续调用f求解n-1的阶乘。
- 所有的递归函数中的递归调用一定是朝着问题规模越来越小的步调持续迈进,直到达到了一个特别小的规模,在该规模下,可以直接得到问题的答案。比如这里的f函数,内部的f(n-1)调用相较于原来的f(n)调用,问题规模由n缩减到了n-1。当问题规模不断缩小,n==1的时候,1的阶乘是可以直接得到的,因此直接返回。
二、三个例子入门递归转非递归
首先要明确一个问题,递归函数在系统中是怎么执行的,在执行函数的过程中,操作系统会维护一个系统栈,栈中的元素是一个个的栈帧,每一个栈帧中,保存着当前函数内部的所有局部变量等信息。下面演示了求解3的阶乘的函数的递归执行情况:
2.1 基本思路
根据前面描述的,任何一个递归程序在执行的时候,都是操作系统底层开辟了一个栈,每执行一个函数调用,那么就将当前函数的栈帧压栈,如果当前函数内部存在递归调用,那么就继续压入新的该函数代表的栈帧,直到某个函数内部没有再进行递归调用,该函数计算完毕后,将其出栈,并将计算结果返回给下一层栈帧。
既然操作系统执行递归调用是借助栈完成的,那么理论上,我们只需要在应用层面自己造一个栈,然后模拟操作系统的这种调用也能将一个递归程序转换为非递归程序。
下面是一个二叉树的先序遍历的程序:
public static void preOrder(TreeNode root) {
// 0,第一次来到该栈帧
if (root == null) {
return;
}
System.out.println(root.val);
preOrder(root.left);
// 1,第二次来到该栈帧
preOrder(root.right);
// 2,第三次来到该栈帧
}
转换的过程中,需要注意几个点:
- 什么时候需要将当前栈帧出栈,即递归函数的出口在哪?以上面的函数为例,root == null的时候,不再进行递归调用,对应栈中,也就是要将本次调用对应的栈帧从栈中弹出。
- 一个递归函数内部可能存在多个地方进行了递归调用,那么当本栈帧从栈中弹出的时候,应该从下一个栈帧的什么地方开始执行呢?
- 比如在preOrder程序中,有两个位置进行了递归调用,那么当前栈帧弹出的时候,应该从下一个栈帧的1位置开始执行还是2位置开始执行呢?
- 操作系统解决这个问题是通过在当前栈帧中记录返回地址实现的,即当前栈帧运行结束后,根据返回地址,就知道应该从下一个栈帧的什么位置来运行了
- 那么我们如何在应用层模拟实现返回地址呢?答案也很简单,只需要在模拟的栈帧中,记录一个状态变量即可,如果当前栈帧的状态变量为0,表示当前栈帧是第一次被执行,那么直接从函数的第一条语句处开始执行,如果当前栈帧的状态变量为1,那么就从第二次回到该栈帧的位置处执行,如果当前栈帧状态变量为2,那么就从第三次回到该栈帧的位置处执行,依次类推
- 任何一个栈帧刚创建的时候,state一定是0,那么什么时候更新该state的值呢?答案是压入下一个栈帧的时候,如果你是在状态为0的时候压入的下一个栈帧,那么说明下一个栈帧弹出的时候,你是第二次回到当前栈帧,那么你的状态在压入下一个栈帧之前,应该更新为1,以便下一个栈帧弹出的时候,你可以运行到第二次回到栈帧的代码处。同理如果你是在状态为1的时候押入的下一个栈帧,说明下一个栈帧弹出的时候,你是第三次回到当前栈帧,那么你的状态在压入下一个栈帧之前,应该更新为2,以便下一个栈帧弹出的时候,你可以运行到第三次回到栈帧的代码处。
- 这里需要注意的是,我们在2中提出的思路,并不是在压入下一个栈帧的时候,在下一个栈帧中记录下一个栈帧弹出的时候,应该返回到当前栈帧的什么位置。我们采用的方法是,在压入下一个栈帧的时候,主动变更当前栈帧的状态,根据状态,当下一个栈帧返回的时候,我们就知道运行哪些代码了。
2.2 将快速排序转化为非递归
2.2.1 快速排序的递归实现
import java.util.Arrays;
import java.util.Random;
import java.util.Stack;
/**
* @author 西城风雨楼
*/
public class QuickSort {
public static void sort(int[] arr, int left, int right) {
// state = 0
// 表示第一次来到当前栈帧
if (left >= right) {
// 如果只有一个元素,那么直接返回
return;
}
// 先选择枢纽元
pivot(arr, left, right);
int[] p = partition(arr, left, right);
sort(arr, left, p[0]);
// state = 1
// 表示第二次来到当前栈帧
sort(arr, p[1], right);
// state = 2
// 表示第三次来到当前栈帧
}
private static int[] partition(int[] arr, int left, int right) {
int l = left - 1;
int r = right;
int c = left;
while (c < r) {
if (arr[c] < arr[right]) {
// 如果当前元素比枢纽元小
swap(arr, ++l, c++);
} else if (arr[c] > arr[right]) {
// 如果当前元素比枢纽元大
swap(arr, --r, c);
} else {
// 如果相等,那么什么也不做
c++;
}
}
// 执行到这里的时候,c == r,r指向的是大于枢纽元的最后一个元素
swap(arr, r++, right);
return new int[]{l, r};
}
/**
* 最简单暴力的方式求解枢纽元的方式就是一直选择第一个元素作为枢纽元
* 枢纽元素永远放在最后一个位置
*/
private static void pivot(int[] arr, int left, int right) {
swap(arr, left, right);
}
private static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
2.2.2 快速排序非递归实现
import java.util.Arrays;
import java.util.Random;
import java.util.Stack;
/**
* @author 西城风雨楼
*/
public class QuickSort {
public static void main(String[] args) {
for (int i = 0; i < 1000000; i++) {
int[] arr = randomArray(200, 1000);
int[] copy0 = copy(arr);
int[] copy1 = copy(arr);
int[] copy2 = copy(arr);
sortWithStack(copy0, 0, arr.length - 1);
sort(copy1, 0, copy1.length - 1);
Arrays.sort(copy2);
if (test(arr, copy0, copy1)) {
break;
}
if (test(arr, copy1, copy2)) {
break;
}
}
System.out.println("排序算法测试通过");
}
private static boolean test(int[] arr, int[] copy0, int[] copy1) {
if (!Arrays.equals(copy0, copy1)) {
System.out.println("排序算法错误");
System.out.println("origin: " + Arrays.toString(arr));
System.out.println("copy0: " + Arrays.toString(copy0));
System.out.println("copy1: " + Arrays.toString(copy1));
return true;
}
return false;
}
private static int[] randomArray(int maxLen, int maxValue) {
Random random = new Random();
int len = random.nextInt(maxLen);
if (len == 0) {
return new int[]{};
}
int[] arr = new int[len];
for (int i = 0; i < len; i++) {
arr[i] = random.nextInt(maxValue);
}
return arr;
}
private static int[] copy(int[] arr) {
return Arrays.copyOf(arr, arr.length);
}
/**
* 栈帧中存放的数据,本质上也就是函数中的所有局部变量
*/
private static class StackFrame {
int left;
int right;
int[] arr;
int[] p;
// 标记当前是第几次回到该栈帧中
int state;
}
public static void sortWithStack(int[] arr, int left, int right) {
// 创建一个栈结构模拟系统的递归栈
Stack<StackFrame> stack = new Stack<>();
// 创建一个当前栈帧,且初始的时候,该栈帧的state=-0
StackFrame frame = new StackFrame();
frame.arr = arr;
frame.p = null;
frame.left = left;
frame.right = right;
frame.state = 0;
// 将该栈帧压入栈中
stack.push(frame);
// 当系统栈为空的时候,说明程序运行结束,此时可以获得结果了
while (!stack.isEmpty()) {
// 判断当前栈顶栈帧的状态
StackFrame top = stack.peek();
if (top.state == 0) {
// 如果是第一次进入该栈帧
// 第一次进入栈帧,首先需要判断是否需要弹出该栈帧直接
// 返回,如果left >= right满足的话
if (top.left >= top.right) {
// 弹出当前栈帧的时候,需要修改下一个栈帧的状态
// 因为当前栈帧被弹出的时候,那么下一个栈帧就是第二次被访问了
stack.pop();
if (stack.isEmpty()) {
break;
}
} else {
// 如果不满足的话,那么需要执行pivot
// partition等操作,同时构建下一个栈帧,将其入栈
pivot(top.arr, top.left, top.right);
top.p = partition(top.arr, top.left, top.right);
// 创建下一个栈帧
StackFrame nextFrame = new StackFrame();
nextFrame.arr = top.arr;
nextFrame.p = null;
nextFrame.left = top.left;
nextFrame.right = top.p[0];
nextFrame.state = 0;
stack.push(nextFrame);
// 将当前栈帧的状态修改为1,因为下一个栈帧被访问完
// 当前栈帧就是第二次被访问
top.state = 1;
}
} else if (top.state == 1) {
// 如果是第二次进入该栈帧
// 那么需要创建一个新的栈帧
StackFrame nextFrame = new StackFrame();
nextFrame.arr = top.arr;
nextFrame.p = null;
nextFrame.left = top.p[1];
nextFrame.right = top.right;
nextFrame.state = 0;
// 将这个新的栈帧入栈
stack.push(nextFrame);
// 因为是第二次回到当前栈帧了,如果nextFrame
// 被弹出,那么当前栈帧的状态应该是2
top.state = 2;
} else if (top.state == 2) {
// 如果是第三次进入该栈帧
// 第三次进入该栈帧,说明需要弹出当前栈帧了
stack.pop();
if (stack.isEmpty()) {
break;
}
}
}
}
public static void sort(int[] arr, int left, int right) {
// state = 0
// 表示第一次来到当前栈帧
if (left >= right) {
// 如果只有一个元素,那么直接返回
return;
}
// 先选择枢纽元
pivot(arr, left, right);
int[] p = partition(arr, left, right);
sort(arr, left, p[0]);
// state = 1
// 表示第二次来到当前栈帧
sort(arr, p[1], right);
// state = 2
// 表示第三次来到当前栈帧
}
private static int[] partition(int[] arr, int left, int right) {
int l = left - 1;
int r = right;
int c = left;
while (c < r) {
if (arr[c] < arr[right]) {
// 如果当前元素比枢纽元小
swap(arr, ++l, c++);
} else if (arr[c] > arr[right]) {
// 如果当前元素比枢纽元大
swap(arr, --r, c);
} else {
// 如果相等,那么什么也不做
c++;
}
}
// 执行到这里的时候,c == r,r指向的是大于枢纽元的最后一个元素
swap(arr, r++, right);
return new int[]{l, r};
}
/**
* 最简单暴力的方式求解枢纽元的方式就是一直选择第一个元素作为枢纽元
* 枢纽元素永远放在最后一个位置
*/
private static void pivot(int[] arr, int left, int right) {
swap(arr, left, right);
}
private static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
2.3 将汉洛塔问题转为非递归实现
这里使用的是leetcode的原题:
https://leetcode.cn/problems/hanota-lcci/
2.3.1 汉洛塔问题的递归实现
import java.util.List;
import java.util.Stack;
class Solution {
// 采用递归的方式解决汉洛塔问题
public void hanota(List<Integer> A, List<Integer> B, List<Integer> C) {
solve(A, B, C, A.size());
}
// 本质上可以理解为树的中序遍历
// 只是遍历的叶子结点是n == 1的时候,然后进行一些特殊的处理
private void solve(List<Integer> A, List<Integer> B, List<Integer> C, int n) {
if (n == 1) {
C.add(0, A.remove(0));
return;
}
solve(A, C, B, n - 1);
C.add(0, A.remove(0));
solve(B, A, C, n - 1);
}
}
2.3.2 汉洛塔问题的非递归实现
import java.util.List;
import java.util.Stack;
class Solution {
// 采用递归的方式解决汉洛塔问题
public void hanota(List<Integer> A, List<Integer> B, List<Integer> C) {
solveWithStack(A, B, C, A.size());
}
private void solveWithStack(List<Integer> A, List<Integer> B, List<Integer> C, int n) {
// 构造一个当前栈帧
StackFrame curFrame = new StackFrame();
curFrame.A = A;
curFrame.B = B;
curFrame.C = C;
curFrame.n = n;
curFrame.state = 0;
Stack<StackFrame> stack = new Stack<>();
stack.push(curFrame);
while (!stack.isEmpty()) {
StackFrame top = stack.peek();
if (top.state == 0) {
if (top.n == 1) {
top.C.add(0, top.A.remove(0));
stack.pop();
if (stack.isEmpty()) {
break;
}
} else {
// 构造一个新的StackFrame
StackFrame nextFrame = new StackFrame();
nextFrame.A = top.A;
nextFrame.B = top.C;
nextFrame.C = top.B;
nextFrame.n = top.n - 1;
nextFrame.state = 0;
stack.push(nextFrame);
top.state = 1;
}
} else if (top.state == 1) {
top.C.add(0, top.A.remove(0));
// 创建一个新的栈帧,将其入栈
StackFrame nextFrame = new StackFrame();
nextFrame.A = top.B;
nextFrame.B = top.A;
nextFrame.C = top.C;
nextFrame.n = top.n - 1;
nextFrame.state = 0;
stack.push(nextFrame);
top.state = 2;
} else if (top.state == 2) {
stack.pop();
if (stack.isEmpty()) {
break;
}
}
}
}
private static class StackFrame {
List<Integer> A;
List<Integer> B;
List<Integer> C;
int n;
int state;
}
}
2.4 二叉树的先序遍历转非递归
2.4.1 先序遍历的递归实现
public void preOrder(TreeNode root) {
if (root == null) {
return;
}
System.out.println(root.val);
preOrder(root.left);
// 0
preOrder(root.right);
// 1
}
2.4.2 先序遍历的非递归实现
private static class StackFrame {
TreeNode root;
// 记录当前栈帧应该从什么地方开始执行
int pc;
public StackFrame(TreeNode root, int retAddress) {
this.root = root;
this.pc = retAddress;
}
}
private List<Integer> solve(TreeNode root) {
Stack<StackFrame> stack = new Stack<>();
stack.push(new StackFrame(root, 0));
List<Integer> res = new ArrayList<>();
while (!stack.isEmpty()) {
StackFrame curFrame = stack.peek();
if (curFrame.retAddress == 0) {
if (curFrame.root == null) {
stack.pop();
if (stack.isEmpty()) {
break;
}
} else {
res.add(curFrame.root.val);
stack.push(new StackFrame(curFrame.root.left, 0));
curFrame.retAddress = 1;
}
} else if (curFrame.retAddress == 1) {
stack.push(new StackFrame(curFrame.root.right, 0));
curFrame.retAddress = 2;
} else if (curFrame.retAddress == 2) {
stack.pop();
if (stack.isEmpty()) {
break;
}
}
}
return res;
}