问题

给定两个整数 nk,返回 1 ... n 中所有可能的 k 个数的组合
示例:

输入: n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]

思路

本题是回溯法的经典题目,直接的解法可以用for循环暴力搜索,但是当循环嵌套次数过多时,for循环嵌套连暴力都写不出来

此时就该使用回溯搜索法,虽然回溯法也是暴力,但至少能写出来,不像for循环嵌套k层让人绝望

那么回溯法怎么暴力搜呢?

如果要解决n100k50的情况,暴力写法需要嵌套50for循环,那么回溯法就用递归来解决嵌套层数的问题

递归来做层叠嵌套(可以理解是开kfor循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了

此时递归的层数大家应该知道了,例如:n100k50的情况下,就是递归50层

如果脑洞模拟回溯搜索的过程,绝对可以让人窒息,所以需要抽象图形结构来进一步理解

我们之前说到,回溯法解决的问题都可以抽象为树形结构(N叉树),用树形结构来理解回溯就容易多了

那么我把组合问题抽象为如下树形结构:
leetcode-77:组合 - 图1
可以看出这个棵树,一开始集合是 1,2,3,4, 从左向右取数,取过的数,不再重复取

第一次取1,集合变为2,3,4 ,因为k2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合[1,2] [1,3] [1,4],以此类推

每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围

图中可以发现n相当于树的宽度,k相当于树的深度

那么如何在这个树上遍历,然后收集到我们要的结果集呢

图中每次搜索到了叶子节点,我们就找到了一个结果

相当于只需要把达到叶子节点的结果收集起来,就可以求得n个数中k个数的组合集合

回溯法

  • 递归函数的返回值以及参数

    • 这里要定义两个全局变量,一个用来存放符合条件的单一结果,一个用来存放符合条件结果的集合

      1. LinkedList<Integer> path = new LinkedList<Integer>();
      2. List<List<Integer>> result = new ArrayList<List<Integer>>();
    • 其实不定义这两个全局遍历也是可以的,把这两个变量放进递归函数的参数里,但函数里参数太多影响可读性,所以定义称为全局变量

    • 函数里一定有两个参数,既然是集合n里面取k的数,那么nk是两个int型的参数
    • 然后还需要一个参数,为int型变量startIndex,这个参数用来记录本层递归中,集合从哪里开始遍历(集合就是[1,…,n] )
      • 为什么要有这个startIndex呢?每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠**startIndex**

从下图中红线部分可以看出,在集合[1,2,3,4]1之后,下一层递归,就要在[2,3,4]中取数了,那么下一层递归如何知道从[2,3,4]中取数呢,靠的就是startIndex,所以需要startIndex来记录下一层递归,搜索的起始位置
leetcode-77:组合 - 图2

LinkedList<Integer> path = new LinkedList<Integer>(); // 用来存放符合条件单一结果
List<List<Integer>> result = new ArrayList<List<Integer>>(); // 存放符合条件结果的集合
void backtracking(int n, int k, int startIndex)
  • 回溯函数终止条件
    • 什么时候到达所谓的叶子节点了呢?
      • path这个数组的大小如果达到k,说明我们找到了一个子集大小为k的组合了,在图中path存的就是根节点到叶子节点的路径

如图红色部分:
leetcode-77:组合 - 图3
此时用result二维数组,把path保存起来,并终止本层递归

if (path.size() == k) {
    result.add(new LinkedList<Integer>(path));
    return;
}
  • 单层搜索的过程
    • 回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。如此我们才遍历完图中的这棵树

leetcode-77:组合 - 图4
for循环每次从startIndex开始遍历,然后用path保存取到的节点i

for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
    // 向路径变量里添加一个数 
    path.addLast(i); 
    // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始,组合数里不允许出现重复的元素
    backtracking(n, k, i + 1); 
    // 回溯,撤销处理的节点(深度优先有回头的过程,因此递归之前做了什么,递归后做逆向操作)
    path.removeLast(); 
}

可以看出backtracking(递归函数)通过不断调用自己一直往深处遍历,总会遇到叶子节点,遇到了叶子节点就要返回
backtracking的下面部分就是回溯的操作了,撤销本次处理的结果

public class Solution {
    LinkedList<Integer> path = new LinkedList<Integer>(); // 用来存放符合条件单一结果
    List<List<Integer>> result = new ArrayList<List<Integer>>(); // 存放符合条件结果的集合

    public List<List<Integer>> combine(int n, int k) {
        if (k <= 0 || n < k) {
            return result;
        }
        // 从 1 开始是题目的设定
        backtracking(n, k, 1);
        return result;
    }

    public void backtracking(int n, int k, int startIndex) {
        // 递归终止条件是:path 的长度等于 k
        if (path.size() == k) {
                result.add(new LinkedList<Integer>(path));
            return;
        }

        // 遍历可能的搜索起点
        for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
        // 向路径变量里添加一个数 
        path.addLast(i); 
        // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始,组合数里不允许出现重复的元素
        backtracking(n, k, i + 1); 
        // 回溯,撤销处理的节点(深度优先有回头的过程,因此递归之前做了什么,递归后做逆向操作)
        path.removeLast(); 
        }
    }

    public static void main(String[] args) {
        Solution solution = new Solution();
        int n = 5;
        int k = 3;
        List<List<Integer>> res = solution.combine(n, k);
        System.out.println(res);
    }
}