问题
给定两个整数 n
和 k
,返回 1 ... n
中所有可能的 k
个数的组合
示例:
输入: n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
思路
本题是回溯法的经典题目,直接的解法可以用for
循环暴力搜索,但是当循环嵌套次数过多时,for
循环嵌套连暴力都写不出来
此时就该使用回溯搜索法,虽然回溯法也是暴力,但至少能写出来,不像for
循环嵌套k
层让人绝望
那么回溯法怎么暴力搜呢?
如果要解决n
为100
,k
为50
的情况,暴力写法需要嵌套50
层for
循环,那么回溯法就用递归来解决嵌套层数的问题
递归来做层叠嵌套(可以理解是开k
层for
循环),每一次的递归中嵌套一个for
循环,那么递归就可以用于解决多层嵌套循环的问题了
此时递归的层数大家应该知道了,例如:n
为100
,k
为50
的情况下,就是递归50层
如果脑洞模拟回溯搜索的过程,绝对可以让人窒息,所以需要抽象图形结构来进一步理解
我们之前说到,回溯法解决的问题都可以抽象为树形结构(N叉树),用树形结构来理解回溯就容易多了
那么我把组合问题抽象为如下树形结构:
可以看出这个棵树,一开始集合是 1,2,3,4
, 从左向右取数,取过的数,不再重复取
第一次取1
,集合变为2,3,4
,因为k
为2
,我们只需要再取一个数就可以了,分别取2,3,4
,得到集合[1,2] [1,3] [1,4]
,以此类推
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围
图中可以发现n相当于树的宽度,k相当于树的深度
那么如何在这个树上遍历,然后收集到我们要的结果集呢
图中每次搜索到了叶子节点,我们就找到了一个结果
相当于只需要把达到叶子节点的结果收集起来,就可以求得n
个数中k
个数的组合集合
回溯法
递归函数的返回值以及参数
这里要定义两个全局变量,一个用来存放符合条件的单一结果,一个用来存放符合条件结果的集合
LinkedList<Integer> path = new LinkedList<Integer>();
List<List<Integer>> result = new ArrayList<List<Integer>>();
其实不定义这两个全局遍历也是可以的,把这两个变量放进递归函数的参数里,但函数里参数太多影响可读性,所以定义称为全局变量
- 函数里一定有两个参数,既然是集合
n
里面取k
的数,那么n
和k
是两个int
型的参数 - 然后还需要一个参数,为
int
型变量startIndex
,这个参数用来记录本层递归中,集合从哪里开始遍历(集合就是[1,…,n] )- 为什么要有这个
startIndex
呢?每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围,就是要靠**startIndex**
- 为什么要有这个
从下图中红线部分可以看出,在集合[1,2,3,4]
取1
之后,下一层递归,就要在[2,3,4]
中取数了,那么下一层递归如何知道从[2,3,4]
中取数呢,靠的就是startIndex
,所以需要startIndex
来记录下一层递归,搜索的起始位置
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
存的就是根节点到叶子节点的路径
- 什么时候到达所谓的叶子节点了呢?
如图红色部分:
此时用result
二维数组,把path
保存起来,并终止本层递归
if (path.size() == k) {
result.add(new LinkedList<Integer>(path));
return;
}
- 单层搜索的过程
- 回溯法的搜索过程就是一个树型结构的遍历过程,在如下图中,可以看出for循环用来横向遍历,递归的过程是纵向遍历。如此我们才遍历完图中的这棵树
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);
}
}