Files
leetcode-master/problems/0077.组合.md
2024-05-10 10:13:34 +08:00

883 lines
25 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<p align="center">
<a href="https://www.programmercarl.com/xunlian/xunlianying.html" target="_blank">
<img src="../pics/训练营.png" width="1000"/>
</a>
<p align="center"><strong><a href="./qita/join.md">参与本项目</a>,贡献其他语言版本的代码,拥抱开源,让更多学习算法的小伙伴们受益!</strong></p>
# 第77题. 组合
[力扣题目链接](https://leetcode.cn/problems/combinations/ )
给定两个整数 n 和 k返回 1 ... n 中所有可能的 k 个数的组合。
示例:
输入: n = 4, k = 2
输出:
[
[2,4],
[3,4],
[2,3],
[1,2],
[1,3],
[1,4],
]
## 算法公开课
**[《代码随想录》算法视频公开课](https://programmercarl.com/other/gongkaike.html)[带你学透回溯算法-组合问题对应力扣题目77.组合)](https://www.bilibili.com/video/BV1ti4y1L7cv)[组合问题的剪枝操作](https://www.bilibili.com/video/BV1wi4y157er),相信结合视频在看本篇题解,更有助于大家对本题的理解**。
## 思路
本题是回溯法的经典题目。
直接的解法当然是使用for循环例如示例中k为2很容易想到 用两个for循环这样就可以输出 和示例中一样的结果。
代码如下:
```CPP
int n = 4;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
cout << i << " " << j << endl;
}
}
```
输入n = 100, k = 3
那么就三层for循环代码如下
```CPP
int n = 100;
for (int i = 1; i <= n; i++) {
for (int j = i + 1; j <= n; j++) {
for (int u = j + 1; u <= n; n++) {
cout << i << " " << j << " " << u << endl;
}
}
}
```
**如果n为100k为50呢那就50层for循环是不是开始窒息**
**此时就会发现虽然想暴力搜索但是用for循环嵌套连暴力都写不出来**
咋整?
回溯搜索法来了虽然回溯法也是暴力但至少能写出来不像for循环嵌套k层让人绝望。
那么回溯法怎么暴力搜呢?
上面我们说了**要解决 n为100k为50的情况暴力写法需要嵌套50层for循环那么回溯法就用递归来解决嵌套层数的问题**。
递归来做层叠嵌套可以理解是开k层for循环**每一次的递归中嵌套一个for循环那么递归就可以用于解决多层嵌套循环的问题了**。
此时递归的层数大家应该知道了例如n为100k为50的情况下就是递归50层。
一些同学本来对递归就懵回溯法中递归还要嵌套for循环可能就直接晕倒了
如果脑洞模拟回溯搜索的过程,绝对可以让人窒息,所以需要抽象图形结构来进一步理解。
**我们在[关于回溯算法,你该了解这些!](https://programmercarl.com/回溯算法理论基础.html)中说到回溯法解决的问题都可以抽象为树形结构N叉树用树形结构来理解回溯就容易多了**
那么我把组合问题抽象为如下树形结构:
![77.组合](https://code-thinking-1253855093.file.myqcloud.com/pics/20201123195223940.png)
可以看出这棵树,一开始集合是 1234 从左向右取数,取过的数,不再重复取。
第一次取1集合变为234 因为k为2我们只需要再取一个数就可以了分别取234得到集合[1,2] [1,3] [1,4],以此类推。
**每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围**
**图中可以发现n相当于树的宽度k相当于树的深度**
那么如何在这个树上遍历,然后收集到我们要的结果集呢?
**图中每次搜索到了叶子节点,我们就找到了一个结果**
相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
在[关于回溯算法,你该了解这些!](https://programmercarl.com/回溯算法理论基础.html)中我们提到了回溯法三部曲,那么我们按照回溯法三部曲开始正式讲解代码了。
### 回溯法三部曲
* 递归函数的返回值以及参数
在这里要定义两个全局变量,一个用来存放符合条件单一结果,一个用来存放符合条件结果的集合。
代码如下:
```cpp
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件结果
```
其实不定义这两个全局变量也是可以的,把这两个变量放进递归函数的参数里,但函数里参数太多影响可读性,所以我定义全局变量了。
函数里一定有两个参数既然是集合n里面取k个数那么n和k是两个int型的参数。
然后还需要一个参数为int型变量startIndex这个参数用来记录本层递归的中集合从哪里开始遍历集合就是[1,...,n] )。
为什么要有这个startIndex呢
**建议在[77.组合视频讲解](https://www.bilibili.com/video/BV1ti4y1L7cv)中07:36的时候开始听startIndex 就是防止出现重复的组合**
从下图中红线部分可以看出,在集合[1,2,3,4]取1之后下一层递归就要在[2,3,4]中取数了,那么下一层递归如何知道从[2,3,4]中取数呢靠的就是startIndex。
![77.组合2](https://code-thinking-1253855093.file.myqcloud.com/pics/20201123195328976.png)
所以需要startIndex来记录下一层递归搜索的起始位置。
那么整体代码如下:
```cpp
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件单一结果
void backtracking(int n, int k, int startIndex)
```
* 回溯函数终止条件
什么时候到达所谓的叶子节点了呢?
path这个数组的大小如果达到k说明我们找到了一个子集大小为k的组合了在图中path存的就是根节点到叶子节点的路径。
如图红色部分:
![77.组合3](https://code-thinking-1253855093.file.myqcloud.com/pics/20201123195407907.png)
此时用result二维数组把path保存起来并终止本层递归。
所以终止条件代码如下:
```cpp
if (path.size() == k) {
result.push_back(path);
return;
}
```
* 单层搜索的过程
回溯法的搜索过程就是一个树型结构的遍历过程在如下图中可以看出for循环用来横向遍历递归的过程是纵向遍历。
![77.组合1](https://code-thinking-1253855093.file.myqcloud.com/pics/20201123195242899.png)
如此我们才遍历完图中的这棵树。
for循环每次从startIndex开始遍历然后用path保存取到的节点i。
代码如下:
```CPP
for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归控制树的纵向遍历注意下一层搜索要从i+1开始
path.pop_back(); // 回溯,撤销处理的节点
}
```
可以看出backtracking递归函数通过不断调用自己一直往深处遍历总会遇到叶子节点遇到了叶子节点就要返回。
backtracking的下面部分就是回溯的操作了撤销本次处理的结果。
关键地方都讲完了组合问题C++完整代码如下:
```CPP
class Solution {
private:
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件结果
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = startIndex; i <= n; i++) {
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归
path.pop_back(); // 回溯,撤销处理的节点
}
}
public:
vector<vector<int>> combine(int n, int k) {
result.clear(); // 可以不写
path.clear(); // 可以不写
backtracking(n, k, 1);
return result;
}
};
```
* 时间复杂度: O(n * 2^n)
* 空间复杂度: O(n)
还记得我们在[关于回溯算法,你该了解这些!](https://programmercarl.com/回溯算法理论基础.html)中给出的回溯法模板么?
如下:
```
void backtracking(参数) {
if (终止条件) {
存放结果;
return;
}
for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
处理节点;
backtracking(路径,选择列表); // 递归
回溯,撤销处理结果
}
}
```
**对比一下本题的代码,是不是发现有点像!** 所以有了这个模板,就有解题的大体方向,不至于毫无头绪。
## 总结
组合问题是回溯法解决的经典问题我们开始的时候给大家列举一个很形象的例子就是n为100k为50的话直接想法就需要50层for循环。
从而引出了回溯法就是解决这种k层for循环嵌套的问题。
然后进一步把回溯法的搜索过程抽象为树形结构,可以直观的看出搜索的过程。
接着用回溯法三部曲,逐步分析了函数参数、终止条件和单层搜索的过程。
## 剪枝优化
我们说过,回溯法虽然是暴力搜索,但也有时候可以有点剪枝优化一下的。
在遍历的过程中有如下代码:
```cpp
for (int i = startIndex; i <= n; i++) {
path.push_back(i);
backtracking(n, k, i + 1);
path.pop_back();
}
```
这个遍历的范围是可以剪枝优化的,怎么优化呢?
来举一个例子n = 4k = 4的话那么第一层for循环的时候从元素2开始的遍历都没有意义了。 在第二层for循环从元素3开始的遍历都没有意义了。
这么说有点抽象,如图所示:
![77.组合4](https://code-thinking-1253855093.file.myqcloud.com/pics/20210130194335207-20230310134409532.png)
图中每一个节点图中为矩形就代表本层的一个for循环那么每一层的for循环从第二个数开始遍历的话都没有意义都是无效遍历。
**所以可以剪枝的地方就在递归中每一层的for循环所选择的起始位置**
**如果for循环选择的起始位置之后的元素个数 已经不足 我们需要的元素个数了,那么就没有必要搜索了**
注意代码中i就是for循环里选择的起始位置。
```
for (int i = startIndex; i <= n; i++) {
```
接下来看一下优化过程如下:
1. 已经选择的元素个数path.size();
2. 还需要的元素个数为: k - path.size();
3. 在集合n中至多要从该起始位置 : n - (k - path.size()) + 1开始遍历
为什么有个+1呢因为包括起始位置我们要是一个左闭的集合。
举个例子n = 4k = 3 目前已经选取的元素为0path.size为0n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。
从2开始搜索都是合理的可以是组合[2, 3, 4]。
这里大家想不懂的话,建议也举一个例子,就知道是不是要+1了。
所以优化之后的for循环是
```
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
```
优化后整体代码如下:
```CPP
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(int n, int k, int startIndex) {
if (path.size() == k) {
result.push_back(path);
return;
}
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) { // 优化的地方
path.push_back(i); // 处理节点
backtracking(n, k, i + 1);
path.pop_back(); // 回溯,撤销处理的节点
}
}
public:
vector<vector<int>> combine(int n, int k) {
backtracking(n, k, 1);
return result;
}
};
```
## 剪枝总结
本篇我们准对求组合问题的回溯法代码做了剪枝优化,这个优化如果不画图的话,其实不好理解,也不好讲清楚。
所以我依然是把整个回溯过程抽象为一棵树形结构,然后可以直观的看出,剪枝究竟是剪的哪里。
## 其他语言版本
### Java
未剪枝优化
```java
class Solution {
List<List<Integer>> result= new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
backtracking(n,k,1);
return result;
}
public void backtracking(int n,int k,int startIndex){
if (path.size() == k){
result.add(new ArrayList<>(path));
return;
}
for (int i =startIndex;i<=n;i++){
path.add(i);
backtracking(n,k,i+1);
path.removeLast();
}
}
}
```
剪枝优化:
```java
class Solution {
List<List<Integer>> result = new ArrayList<>();
LinkedList<Integer> path = new LinkedList<>();
public List<List<Integer>> combine(int n, int k) {
combineHelper(n, k, 1);
return result;
}
/**
* 每次从集合中选取元素可选择的范围随着选择的进行而收缩调整可选择的范围就是要靠startIndex
* @param startIndex 用来记录本层递归的中,集合从哪里开始遍历(集合就是[1,...,n] )。
*/
private void combineHelper(int n, int k, int startIndex){
//终止条件
if (path.size() == k){
result.add(new ArrayList<>(path));
return;
}
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++){
path.add(i);
combineHelper(n, k, i + 1);
path.removeLast();
}
}
}
```
### Python
未剪枝优化
```python
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result = [] # 存放结果集
self.backtracking(n, k, 1, [], result)
return result
def backtracking(self, n, k, startIndex, path, result):
if len(path) == k:
result.append(path[:])
return
for i in range(startIndex, n + 1): # 需要优化的地方
path.append(i) # 处理节点
self.backtracking(n, k, i + 1, path, result)
path.pop() # 回溯,撤销处理的节点
```
剪枝优化:
```python
class Solution:
def combine(self, n: int, k: int) -> List[List[int]]:
result = [] # 存放结果集
self.backtracking(n, k, 1, [], result)
return result
def backtracking(self, n, k, startIndex, path, result):
if len(path) == k:
result.append(path[:])
return
for i in range(startIndex, n - (k - len(path)) + 2): # 优化的地方
path.append(i) # 处理节点
self.backtracking(n, k, i + 1, path, result)
path.pop() # 回溯,撤销处理的节点
```
### Go
```Go
var (
path []int
res [][]int
)
func combine(n int, k int) [][]int {
path, res = make([]int, 0, k), make([][]int, 0)
dfs(n, k, 1)
return res
}
func dfs(n int, k int, start int) {
if len(path) == k { // 说明已经满足了k个数的要求
tmp := make([]int, k)
copy(tmp, path)
res = append(res, tmp)
return
}
for i := start; i <= n; i++ { // 从start开始不往回走避免出现重复组合
if n - i + 1 < k - len(path) { // 剪枝
break
}
path = append(path, i)
dfs(n, k, i+1)
path = path[:len(path)-1]
}
}
```
### Javascript
未剪枝:
```js
var combine = function (n, k) {
// 回溯法
let result = [],
path = [];
let backtracking = (_n, _k, startIndex) => {
// 终止条件
if (path.length === _k) {
result.push(path.slice());
return;
}
// 循环本层集合元素
for (let i = startIndex; i <= _n; i++) {
path.push(i);
// 递归
backtracking(_n, _k, i + 1);
// 回溯操作
path.pop();
}
};
backtracking(n, k, 1);
return result;
};
```
剪枝:
```javascript
var combine = function (n, k) {
// 回溯法
let result = [],
path = [];
let backtracking = (_n, _k, startIndex) => {
// 终止条件
if (path.length === _k) {
result.push(path.slice());
return;
}
// 循环本层集合元素
for (let i = startIndex; i <= _n - (_k - path.length) + 1; i++) {
path.push(i);
// 递归
backtracking(_n, _k, i + 1);
// 回溯操作
path.pop();
}
};
backtracking(n, k, 1);
return result;
};
```
### TypeScript
```typescript
function combine(n: number, k: number): number[][] {
let resArr: number[][] = [];
function backTracking(n: number, k: number, startIndex: number, tempArr: number[]): void {
if (tempArr.length === k) {
resArr.push(tempArr.slice());
return;
}
for (let i = startIndex; i <= n - k + 1 + tempArr.length; i++) {
tempArr.push(i);
backTracking(n, k, i + 1, tempArr);
tempArr.pop();
}
}
backTracking(n, k, 1, []);
return resArr;
};
```
### Rust
```Rust
impl Solution {
fn backtracking(result: &mut Vec<Vec<i32>>, path: &mut Vec<i32>, n: i32, k: i32, start_index: i32) {
let len= path.len() as i32;
if len == k{
result.push(path.to_vec());
return;
}
for i in start_index..= n {
path.push(i);
Self::backtracking(result, path, n, k, i+1);
path.pop();
}
}
pub fn combine(n: i32, k: i32) -> Vec<Vec<i32>> {
let mut result = vec![];
let mut path = vec![];
Self::backtracking(&mut result, &mut path, n, k, 1);
result
}
}
```
剪枝
```Rust
impl Solution {
fn backtracking(result: &mut Vec<Vec<i32>>, path: &mut Vec<i32>, n: i32, k: i32, start_index: i32) {
let len= path.len() as i32;
if len == k{
result.push(path.to_vec());
return;
}
// 此处剪枝
for i in start_index..= n - (k - len) + 1 {
path.push(i);
Self::backtracking(result, path, n, k, i+1);
path.pop();
}
}
pub fn combine(n: i32, k: i32) -> Vec<Vec<i32>> {
let mut result = vec![];
let mut path = vec![];
Self::backtracking(&mut result, &mut path, n, k, 1);
result
}
}
```
### C
```c
int* path;
int pathTop;
int** ans;
int ansTop;
void backtracking(int n, int k,int startIndex) {
//当path中元素个数为k个时我们需要将path数组放入ans二维数组中
if(pathTop == k) {
//path数组为我们动态申请若直接将其地址放入二维数组path数组中的值会随着我们回溯而逐渐变化
//因此创建新的数组存储path中的值
int* temp = (int*)malloc(sizeof(int) * k);
int i;
for(i = 0; i < k; i++) {
temp[i] = path[i];
}
ans[ansTop++] = temp;
return ;
}
int j;
for(j = startIndex; j <=n ;j++) {
//将当前结点放入path数组
path[pathTop++] = j;
//进行递归
backtracking(n, k, j + 1);
//进行回溯,将数组最上层结点弹出
pathTop--;
}
}
int** combine(int n, int k, int* returnSize, int** returnColumnSizes){
//path数组存储符合条件的结果
path = (int*)malloc(sizeof(int) * k);
//ans二维数组存储符合条件的结果数组的集合。数组足够大避免极端情况
ans = (int**)malloc(sizeof(int*) * 10000);
pathTop = ansTop = 0;
//回溯算法
backtracking(n, k, 1);
//最后的返回大小为ans数组大小
*returnSize = ansTop;
//returnColumnSizes数组存储ans二维数组对应下标中一维数组的长度都为k
*returnColumnSizes = (int*)malloc(sizeof(int) *(*returnSize));
int i;
for(i = 0; i < *returnSize; i++) {
(*returnColumnSizes)[i] = k;
}
//返回ans二维数组
return ans;
}
```
剪枝:
```c
int* path;
int pathTop;
int** ans;
int ansTop;
void backtracking(int n, int k,int startIndex) {
//当path中元素个数为k个时我们需要将path数组放入ans二维数组中
if(pathTop == k) {
//path数组为我们动态申请若直接将其地址放入二维数组path数组中的值会随着我们回溯而逐渐变化
//因此创建新的数组存储path中的值
int* temp = (int*)malloc(sizeof(int) * k);
int i;
for(i = 0; i < k; i++) {
temp[i] = path[i];
}
ans[ansTop++] = temp;
return ;
}
int j;
for(j = startIndex; j <= n- (k - pathTop) + 1;j++) {
//将当前结点放入path数组
path[pathTop++] = j;
//进行递归
backtracking(n, k, j + 1);
//进行回溯,将数组最上层结点弹出
pathTop--;
}
}
int** combine(int n, int k, int* returnSize, int** returnColumnSizes){
//path数组存储符合条件的结果
path = (int*)malloc(sizeof(int) * k);
//ans二维数组存储符合条件的结果数组的集合。数组足够大避免极端情况
ans = (int**)malloc(sizeof(int*) * 10000);
pathTop = ansTop = 0;
//回溯算法
backtracking(n, k, 1);
//最后的返回大小为ans数组大小
*returnSize = ansTop;
//returnColumnSizes数组存储ans二维数组对应下标中一维数组的长度都为k
*returnColumnSizes = (int*)malloc(sizeof(int) *(*returnSize));
int i;
for(i = 0; i < *returnSize; i++) {
(*returnColumnSizes)[i] = k;
}
//返回ans二维数组
return ans;
}
```
### Swift
```swift
func combine(_ n: Int, _ k: Int) -> [[Int]] {
var path = [Int]()
var result = [[Int]]()
func backtracking(start: Int) {
// 结束条件,并收集结果
if path.count == k {
result.append(path)
return
}
// 单层逻辑
// let end = n
// 剪枝优化
let end = n - (k - path.count) + 1
guard start <= end else { return }
for i in start ... end {
path.append(i) // 处理结点
backtracking(start: i + 1) // 递归
path.removeLast() // 回溯
}
}
backtracking(start: 1)
return result
}
```
### Scala
暴力:
```scala
object Solution {
import scala.collection.mutable // 导包
def combine(n: Int, k: Int): List[List[Int]] = {
var result = mutable.ListBuffer[List[Int]]() // 存放结果集
var path = mutable.ListBuffer[Int]() //存放符合条件的结果
def backtracking(n: Int, k: Int, startIndex: Int): Unit = {
if (path.size == k) {
// 如果path的size == k就达到题目要求添加到结果集并返回
result.append(path.toList)
return
}
for (i <- startIndex to n) { // 遍历从startIndex到n
path.append(i) // 先把数字添加进去
backtracking(n, k, i + 1) // 进行下一步回溯
path = path.take(path.size - 1) // 回溯完再删除掉刚刚添加的数字
}
}
backtracking(n, k, 1) // 执行回溯
result.toList // 最终返回result的List形式return关键字可以省略
}
}
```
剪枝:
```scala
object Solution {
import scala.collection.mutable // 导包
def combine(n: Int, k: Int): List[List[Int]] = {
var result = mutable.ListBuffer[List[Int]]() // 存放结果集
var path = mutable.ListBuffer[Int]() //存放符合条件的结果
def backtracking(n: Int, k: Int, startIndex: Int): Unit = {
if (path.size == k) {
// 如果path的size == k就达到题目要求添加到结果集并返回
result.append(path.toList)
return
}
// 剪枝优化
for (i <- startIndex to (n - (k - path.size) + 1)) {
path.append(i) // 先把数字添加进去
backtracking(n, k, i + 1) // 进行下一步回溯
path = path.take(path.size - 1) // 回溯完再删除掉刚刚添加的数字
}
}
backtracking(n, k, 1) // 执行回溯
result.toList // 最终返回result的List形式return关键字可以省略
}
}
```
### Ruby
```ruby
def combine(n, k)
result = []
path = []
backtracking(result, path, n, 1, k)
return result
end
#剪枝优化
def backtracking(result, path, n, j, k)
if path.size == k
result << path.map {|item| item}
return
end
for i in j..(n-(k - path.size)) + 1
#处理节点
path << i
backtracking(result, path, n, i + 1, k)
#回溯,撤销处理过的节点
path.pop
end
end
```
### C#
```csharp
// 暴力
public class Solution
{
public IList<IList<int>> res = new List<IList<int>>();
public IList<int> path = new List<int>();
public IList<IList<int>> Combine(int n, int k)
{
BackTracking(n, k, 1);
return res;
}
public void BackTracking(int n, int k, int start)
{
if (path.Count == k)
{
res.Add(new List<int>(path));
return;
}
for (int i = start; i <= n; i++)
{
path.Add(i);
BackTracking(n, k, i + 1);
path.RemoveAt(path.Count - 1);
}
}
}
// 剪枝
public class Solution
{
public IList<IList<int>> res = new List<IList<int>>();
public IList<int> path = new List<int>();
public IList<IList<int>> Combine(int n, int k)
{
BackTracking(n, k, 1);
return res;
}
public void BackTracking(int n, int k, int start)
{
if (path.Count == k)
{
res.Add(new List<int>(path));
return;
}
for (int i = start; i <= n - (k - path.Count) + 1; i++)
{
path.Add(i);
BackTracking(n, k, i + 1);
path.RemoveAt(path.Count - 1);
}
}
}
```
<p align="center">
<a href="https://programmercarl.com/other/kstar.html" target="_blank">
<img src="../pics/网站星球宣传海报.jpg" width="1000"/>
</a>