Skip to content

经典递归过程解析


预备内容

路径与递归

带路径的递归 vs 不带路径的递归(大部分 dp,状态压缩 dp 认为是路径简化了结构,dp 专题后续讲述)

任何递归都是 dfs 且非常灵活。回溯这个术语并不重要

传值方式

值传递:在递归中可以理解为值得副本拷贝,并不会改变值

引用传递:引用传递传递的是地址,指向的是同一份空间,会影响值

字符串的全部子序列

题目链接

https://www.nowcoder.com/practice/92e6247998294f2c933906fdedbc6e6a

思路分析

每个位置都分要和不要两种情况,子序列的总个数:2n

沿途收集路径,如果要回到上一个状态,需要恢复现场(回溯),再走下一个状态


写法一

经典递归写法,递归沿途收集路径,如果需要回到上一个状态,恢复现场(回溯)

利用 HashSet 存储路径,同时实现去重

java
import java.util.*;

public class Solution {
    // 题目是 String s,这里改成 str
    public String[] generatePermutation(String str) {
        char[] s = str.toCharArray();
        HashSet<String> set = new HashSet<>();
        // 将容器作为递归参数传递,可以复用空间
        traversal(s, 0, new StringBuilder(), set);
        String[] ans = new String[set.size()];
        int i = 0;
        for (String cur : set) {
            ans[i++] = cur;
        }
        return ans;
    }

    public void traversal(char[] s, int i, StringBuilder path, HashSet<String> set) {
        if (i == s.length) {
            set.add(path.toString());
        } else {
            path.append(s[i]);
            traversal(s, i + 1, path, set);
            // 恢复现场
            path.deleteCharAt(path.length() - 1);
            traversal(s, i + 1, path, set);
        }
    }
}

写法二

更优雅的实现,将数组作为递归参数传递,不断复用同一份空间,节省了空间开销

变量 i 表示当前遍历到的位置, size 表示 path 中填了多少个,即控制有效元素的个数

通过数组的复用和 i 变量,以及 size 控制有效元素的个数,在递归的过程中可以实现元素擦除,后面的元素会覆盖前面的元素,间接实现了恢复现场,无需手动控制



java
import java.util.*;

public class Solution {
    // 题目是 String s,这里改成 str
    public String[] generatePermutation(String str) {
        char[] s = str.toCharArray();
        HashSet<String> set = new HashSet<>();
        // 将容器作为递归参数传递,可以复用空间
        traversal(s, 0, new char[s.length], 0, set);
        String[] ans = new String[set.size()];
        int i = 0;
        for (String cur : set) {
            ans[i++] = cur;
        }
        return ans;
    }

    public void traversal(char[] s, int i, char[] path, int size, HashSet<String> set) {
        if (i == s.length) {
            set.add(String.valueOf(path, 0, size));
        } else {
            // 通过 size 控制来有效的元素
            path[size] = s[i];
            traversal(s, i + 1, path, size + 1, set);
            traversal(s, i + 1, path, size, set);
        }
    }
}

复杂度分析

时间复杂度:O(2n * n),子序列的个数:2n,每个子序列都要生成出来,平均长度算为 n

数组的全部组合(去重)

题目链接

https://leetcode.cn/problems/subsets-ii/

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的组合

答案不能包含重复的组合。返回的答案中,组合可以按 任意顺序排列

注意其实要求返回的不是子集,因为子集一定是不包含相同元素的,要返回的其实是不重复的组合,比如输入:nums = [1,2,2],输出:[[],[1],[1,2],[1,2,2],[2],[2,2]]

思路分析

如何区分不同的组合?组合中的每个数出现的个数不同,就是不同的组合

可以先对数组排序,这个操作并不会影响组合的个数


代码实现

java
class Solution {
    public List<List<Integer>> subsetsWithDup(int[] nums) {
        List<List<Integer>> ans = new ArrayList<>();
        Arrays.sort(nums);
        // 作为参数递归传递可以实现空间复用
        traversal(nums, 0, new int[nums.length], 0, ans);
        return ans;
    }

    public static void traversal(int[] nums, int i, int[] path, int size, List<List<Integer>> ans) {
        if (i == nums.length) {
            ArrayList<Integer> cur = new ArrayList<>();
            for (int j = 0; j < size; j++) {
                cur.add(path[j]);
            }
            ans.add(cur);
        } else {
            // 退出循环时,j 来到下一组的第一个数的位置
            int j = i + 1;
            while (j < nums.length && nums[i] == nums[j]) {
                j++;
            }
            // 通过 size 控制要几个当前组的数
            // 当前组的数要 0 个,从 j 位置开始继续递归求出组合
            traversal(nums, j, path, size, ans);
            // 当前组的数要 1 个,2 个,3 个... 每个情况都尝试一遍
            for (; i < j; i++) {
                path[size++] = nums[i];
                traversal(nums, j, path, size, ans);
            }
        }
    }
}

复杂度分析

为什么分组可以达到剪枝的效果?对当前组每次选 0 个数,1 个数......

这个过程就是人为的控制当前组递归的过程,进而达到了剪枝的目的,而不是每遇到一个数都去展开,分情况讨论要和不要去求组合

例如:1 1 1 1 2 3 4 这个序列,前面有四个 1,若不剪枝,都要讨论要和不要的情况,相当于 24 的展开,而人为的控制分组,四个 1 这一组就只有五种情况,要 0 个 1,要 1 个 1......大大减少了不必要的递归,优化了时间

如果每一组的数出现多次,通过人为的分组控制递归的过程,是有利于优化实践复杂度的

最差的情况,每个数都出现了一次,那每个数都是选和不选两种情况,类似递归过程的二叉树那样展开

时间复杂度:O(2n * n),每个数都是选和不选两种情况,意思就是等价于求子序列的过程,子序列的个数:2n,每个子序列都要生成出来,平均长度算为 n

数组的全部排列

题目链接

https://leetcode.cn/problems/permutations

思路分析

区别于组合,组合是每个位置的数要和不要两种情况

对于排列,该位置之后包括当前位置的数字都要在当前位置都要出现一次,如下图解


以上图解过程

java
for (int j = i; j < nums.length; j++) {
    swap(nums, i, j);
    traverse(nums, i + 1, ans);
    swap(nums, i, j);
}

代码实现

java
public class Solution {
    public List<List<Integer>> permuteUnique(int[] nums) {
        List<List<Integer>> ans = new ArrayList<>();
        traverse(nums, 0, ans);
        return ans;
    }

    public void traverse(int[] nums, int i, List<List<Integer>> ans) {
        if (i == nums.length) {
            List<Integer> cur = new ArrayList<>();
            for (int num : nums) {
                cur.add(num);
            }
            ans.add(cur);
        } else {
            for (int j = i; j < nums.length; j++) {
                swap(nums, i, j);
                traverse(nums, i + 1, ans);
                swap(nums, i, j);
            }
        }
    }

    public void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

复杂度分析

时间复杂度:O(n! * n),排列的个数:n!,每个排列都要生成出来,平均长度算为 n

数组的全部排列(去重)

题目链接

https://leetcode.cn/problems/permutations-ii/

思路分析

思路和上体类似,需要保证每次换到 i 位置的数都是不同的,这里可以使用 HashSet 去重,递归前判断一下

代码实现

java
class Solution {
    public List<List<Integer>> permuteUnique(int[] nums) {
        List<List<Integer>> ans = new ArrayList<>();
        traverse(nums, 0, ans);
        return ans;
    }

    public void traverse(int[] nums, int i, List<List<Integer>> ans) {
        if (i == nums.length) {
            List<Integer> cur = new ArrayList<>();
            for (int num : nums) {
                cur.add(num);
            }
            ans.add(cur);
        } else {
            HashSet<Integer> hashSet = new HashSet<>();
            for (int j = i; j < nums.length; j++) {
                // 每次换到 i 位置的数都是不同的,实现去重
                // 每遍历一个数就放到 set 中,后续判断是否出现过
                // 如果当前遍历的数 set 中已经存在,说明是重复的数值
                if (!hashSet.contains(nums[j])) {
                    hashSet.add(nums[j]);
                    swap(nums, i, j);
                    traverse(nums, i + 1, ans);
                    swap(nums, i, j);
                }
            }
        }
    }

    public void swap(int[] nums, int i, int j) {
        int temp = nums[i];
        nums[i] = nums[j];
        nums[j] = temp;
    }
}

递归函数逆序栈

思路分析

时间复杂度:O(n2



代码实现

java
import java.util.Stack;

public class Main {

    public static void reverse(Stack<Integer> stack) {
        if (stack.isEmpty()) {
            return;
        }
        // 依次取出栈底元素,栈空了,要返回
        // 返回的过程中将原来拿到的栈底元素压回去
        // 相当于把栈底元素放到了栈顶,实现了逆序
        int num = buttomOut(stack);
        reverse(stack);
        stack.push(num);
    }

    public static int buttomOut(Stack<Integer> stack) {
        // 栈顶到栈底元素 a b c 为例
        int ans = stack.pop();
        if (stack.isEmpty()) {
            // 拿完了 c,栈空,返回 c
            return ans;
        } else {
            int last = buttomOut(stack);
            // 把当时弹出的元素押回去
            // 同时接收到返回的 c,一直向上返回
            stack.push(ans);
            return last;
        }
    }

    public static void main(String[] args) {
        Stack<Integer> stack = new Stack<Integer>();
        stack.push(1);
        stack.push(2);
        stack.push(3);
        stack.push(4);
        stack.push(5);
        // 原先从栈顶到栈底   5 4 3 2 1
        // 逆序后从栈顶到栈底 1 2 3 4 5
        // 依次弹出栈顶元素   1 2 3 4 5
        reverse(stack);
        while (!stack.isEmpty()) {
            System.out.println(stack.pop());
        }
    }
}

递归函数排序栈

题目要求

请完成无序栈的排序,要求排完序之后,从栈顶到栈底从小到大

只能使用栈提供的 push、pop、isEmpty 三个方法、以及递归函数

除此之外不能使用任何的容器,数组也不行

思路分析

时间复杂度:O(n2

首先统计栈的深度,记为 deep,统计最大值出现的次数,记为 k

然后在 deep 层这个范围内找到最大值,将所有最大值沉底

由于最大值沉底了,这部分排好序了,下一轮可以不用管

下一轮在 deep - k 层这个范围内找最大值,沉底,如此往复,从栈底到栈顶就是逐渐减小的,即从栈顶到栈底是从小到大排列的,这样就可以实现排序

代码实现

java
import java.util.Stack;

public class Main {

    public static void sort(Stack<Integer> stack) {
        int deep = deep(stack);
        while (deep > 0) {
            // 找到最大值
            int max = max(stack, deep);
            // 最大值出现的次数
            int k = times(stack, deep, max);
            // 将所有最大值沉底
            down(stack, deep, max, k);
            // 最大值占用了 k 层
            // 下一轮在 deep - k 层继续找最大,沉底
            // 依次往复,这样就可以实现栈排序
            deep -= k;
        }
    }

    public static int deep(Stack<Integer> stack) {
        if (stack.isEmpty()) {
            return 0;
        }
        int num = stack.pop();
        int deep = deep(stack) + 1;
        stack.push(num);
        return deep;
    }

    // 从栈当前的顶部开始,往下数 deep 层
    // 返回这 deep 层里的最大值
    public static int max(Stack<Integer> stack, int deep) {
        if (deep == 0) {
            return Integer.MIN_VALUE;
        }
        int num = stack.pop();
        int restMax = max(stack, deep - 1);
        int max = Math.max(num, restMax);
        stack.push(num);
        return max;
    }

    // 从栈当前的顶部开始,往下数 deep 层,已知最大值 max
    // 返回 max 出现了几次,不改变栈中元素的相对次序
    public static int times(Stack<Integer> stack, int deep, int max) {
        if (deep == 0) {
            return 0;
        }
        int num = stack.pop();
        int restTimes = times(stack, deep - 1, max);
        // 总出现次数 = 底下层的出现次数 + 当前层出现次数
        int times = restTimes + (num == max ? 1 : 0);
        stack.push(num);
        return times;
    }

    // 从栈当前的顶部开始往下数 deep 层,已知最大值 max,出现了 k 次
    // 请把这 k 个最大值沉底,剩下的数保持相对次序不变放回栈中
    public static void down(Stack<Integer> stack, int deep, int max, int k) {
        if (deep == 0) {
            // 递归到最底层,把 max 压入
            // 返回的时候把原来的元素压回去
            // 实现了将最大元素沉底的操作
            for (int i = 0; i < k; i++) {
                stack.push(max);
            }
        } else {
            int num = stack.pop();
            down(stack, deep - 1, max, k);
            // 递归前取出来了,返回的时候压回去
            // 由于递归是一层一层往下,返回是从下往上返回
            // 类似栈的结构,后面取出的元素先压回,可以维持相对次序
            if (num != max) {
                stack.push(num);
            }
        }
    }

    // 为了测试
    // 生成随机栈
    public static Stack<Integer> randomStack(int n, int v) {
        Stack<Integer> ans = new Stack<Integer>();
        for (int i = 0; i < n; i++) {
            ans.add((int) (Math.random() * v));
        }
        return ans;
    }

    // 为了测试
    // 检测栈是不是从顶到底依次有序
    public static boolean isSorted(Stack<Integer> stack) {
        int step = Integer.MIN_VALUE;
        while (!stack.isEmpty()) {
            if (step > stack.peek()) {
                return false;
            }
            step = stack.pop();
        }
        return true;
    }

    // 为了测试
    public static void main(String[] args) {
        Stack<Integer> test = new Stack<Integer>();
        test.add(1);
        test.add(5);
        test.add(4);
        test.add(5);
        test.add(3);
        test.add(2);
        test.add(3);
        test.add(1);
        test.add(4);
        test.add(2);
        sort(test);
        while (!test.isEmpty()) {
            System.out.println(test.pop());
        }

        // 随机测试
        int N = 20;
        int V = 20;
        int testTimes = 20000;
        System.out.println("测试开始");
        for (int i = 0; i < testTimes; i++) {
            int n = (int) (Math.random() * N);
            Stack<Integer> stack = randomStack(n, V);
            sort(stack);
            if (!isSorted(stack)) {
                System.out.println("出错了!");
                break;
            }
        }
        System.out.println("测试结束");
    }
}

汉诺塔问题

问题背景

三根柱子 A、B、C,柱子上有大小不同的圆盘,初始时所有圆盘在 A 柱子上

需要将所欲圆盘从 A 柱子移动到 C 柱子,且移动过程中必须满足大圆盘在小圆盘的下面

给定圆盘 n,打印将所有圆盘从 A 柱子移动到 C 柱子的移动过程


思路分析

递归思想:一个问题可以拆分成一个简单的问题和一个复杂问题

设置 from,为起始柱,to 为目标柱,other 为中间柱三个参数

将上面的 n - 1 个圆盘看做整体,视为一个复杂的问题,最底层的圆盘看作一个简单的问题

移动思路:将上面的 n - 1 个圆盘从 from 柱移动到 other 柱,将最底层的圆盘从 from 柱移动到 to 柱,将 n - 1 个圆盘从 other 柱移动到 to 柱(可以类比两个圆盘的情况理解)

而上面的 n -1 个圆盘这个复杂的问题,如何从 from 柱移动到 other 柱子呢,又可以复用递归的思想

将上面的 n - 2 个圆盘从 from 柱移动到 other 柱,将第 n - 1 个圆盘从 from 柱移动到 to 柱,将 n - 2 个圆盘从 other 柱移动到 to 柱......

代码实现

java
// 打印n层汉诺塔问题的最优移动轨迹
public class Code07_TowerOfHanoi {

	public static void hanoi(int n) {
		if (n > 0) {
			f(n, "左", "右", "中");
		}
	}

	public static void f(int i, String from, String to, String other) {
        // 只有一个圆盘
		if (i == 1) {
			System.out.println("移动圆盘 1 从 " + from + " 到 " + to);
		} else {
            // from 左边的柱子,other 中间的柱子,to 右边的柱子
			f(i - 1, from, other, to);
			System.out.println("移动圆盘 " + i + " 从 " + from + " 到 " + to);
			f(i - 1, other, to, from);
		}
	}

	public static void main(String[] args) {
		int n = 3;
		hanoi(n);
	}

}

移动次数

给定 n 个圆盘,将所有圆盘从 A 柱子移动到 C 柱子所需要的移动次数:2 n - 1

将上面的 n - 1 个圆盘从 from 柱子移动到 other 柱子:f(n - 1)

将最底层的圆盘从 from 柱子移动到 to 柱子:1

将 n - 1 个圆盘从 other 柱子移动到 to 柱子:f(n - 1)

总过程:f(n) = f(n - 1) + 1 + f(n - 1) = 2 * f(n - 1) + 1

根据数学递推关系可以得到通项公式为 2 n - 1