leetcode77.组合:回溯算法中for循环与状态回退的逻辑艺术
一、题目深度解析与组合问题本质
题目描述
给定两个整数n
和k
,要求从1到n的整数中选取k个不同的数,返回所有可能的组合。例如,当n=4,k=2时,所有组合为[[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]]。题目要求:
- 组合中的数字按升序排列
- 不同组合之间按字典序排列
- 不能有重复组合
组合问题的核心特性
组合问题的本质是在n个元素中选取k个元素的子集问题,具有以下特点:
- 无序性:组合不考虑元素顺序(如[1,2]和[2,1]视为同一组合)
- 唯一性:每个元素只能使用一次
- 有序输出:结果需按升序排列,便于比较和处理
回溯算法是解决组合问题的经典方法,其核心在于通过"选择-回退"的递归逻辑,系统地枚举所有可能的组合。
二、回溯解法的核心实现与逻辑框架
完整回溯代码实现
class Solution {List<Integer> temp = new LinkedList<>(); // 存储当前组合List<List<Integer>> res = new ArrayList<>(); // 存储所有结果组合public List<List<Integer>> combine(int n, int k) {backTracking(n, k, 1); // 从1开始回溯return res;}public void backTracking(int n, int k, int s) {// 终止条件:当前组合长度等于kif (temp.size() == k) {res.add(new ArrayList<>(temp)); // 保存当前组合的副本return;}// 核心循环:从s到n-(k-temp.size())+1选择数字for (int i = s; i <= n - (k - temp.size()) + 1; i++) {temp.add(i); // 选择当前数字backTracking(n, k, i + 1); // 递归选择下一个数字(i+1保证升序)temp.removeLast(); // 回溯:撤销当前选择}return;}
}
核心设计解析:
-
数据结构设计:
temp
:使用LinkedList
存储当前正在构建的组合,支持高效的尾部添加和删除res
:使用ArrayList
存储所有结果组合,便于批量操作
-
回溯函数参数:
n
:总数字范围(1到n)k
:需要选取的数字个数s
:当前选择的起始位置(避免重复组合)
-
终止条件:
- 当
temp.size() == k
时,说明已选满k个数字,将当前组合添加到结果集
- 当
-
循环边界优化:
i <= n - (k - temp.size()) + 1
:动态计算循环上界,避免无效搜索
三、核心问题解析:回溯逻辑与循环边界
1. 回溯算法的核心流程
回溯三部曲:
- 选择:在当前可选范围内选择一个数字加入组合
- 递归:继续在剩余数字中选择下一个数字
- 回退:撤销当前选择,尝试其他可能
代码中的体现:
temp.add(i); // 选择
backTracking(..., i+1); // 递归
temp.removeLast(); // 回退
2. for循环边界的数学推导
常规循环写法:
for (int i = s; i <= n; i++) { ... }
这种写法会导致无效搜索(如剩余需要选m个数字时,当前数字之后必须至少有m个数字)。
优化后的边界条件:
i <= n - (k - temp.size()) + 1
- 设当前已选
t
个数字(t = temp.size()
),还需选m = k - t
个数字 - 为保证后续能选够m个数字,当前数字
i
必须满足:i + m - 1 <= n
- 推导得:
i <= n - m + 1 = n - (k - t) + 1
示例说明:
当n=4, k=2, temp.size()=1
时,还需选1个数字:
- 边界条件:
i <= 4 - 1 + 1 = 4
- 可选数字:i从当前s到4,符合实际需求
四、回溯流程深度模拟:以n=4,k=2为例
递归调用树:
backTracking(4,2,1)
├─ i=1: temp=[1]
│ └─ backTracking(4,2,2)
│ ├─ i=2: temp=[1,2] → 加入res
│ ├─ i=3: temp=[1,3] → 加入res
│ └─ i=4: temp=[1,4] → 加入res
├─ i=2: temp=[2]
│ └─ backTracking(4,2,3)
│ ├─ i=3: temp=[2,3] → 加入res
│ └─ i=4: temp=[2,4] → 加入res
└─ i=3: temp=[3]└─ backTracking(4,2,4)└─ i=4: temp=[3,4] → 加入res
详细步骤:
-
初始调用:
backTracking(4,2,1)
temp
为空,进入循环,i从1开始
-
i=1时:
temp.add(1)
→ temp=[1]- 递归调用
backTracking(4,2,2)
- 在该递归中,i从2开始,依次选择2、3、4,形成[1,2]、[1,3]、[1,4]
-
i=2时:
temp.add(2)
→ temp=[2]- 递归调用
backTracking(4,2,3)
- 形成[2,3]、[2,4]
-
i=3时:
temp.add(3)
→ temp=[3]- 递归调用
backTracking(4,2,4)
- 形成[3,4]
五、算法复杂度分析
1. 时间复杂度
- O(C(n,k)×k):
- 组合数:C(n,k)为最终结果数量
- 每个组合需要O(k)时间复制到结果集
- 优化后的循环减少了无效搜索,但最坏情况下仍需遍历所有可能组合
2. 空间复杂度
- O(k):递归栈深度最大为k(每层递归代表一个数字选择)
temp
列表长度最多为k,res
空间为O(C(n,k)×k)
六、核心技术点总结:回溯算法的三大要素
1. 状态定义
- 当前组合:用
temp
列表记录正在构建的组合 - 结果集:用
res
列表存储所有有效组合 - 选择起点:用
s
参数避免重复组合
2. 递归逻辑
- 终止条件:当组合长度达到k时保存结果
- 递归方向:每次选择后,下一次选择从i+1开始
- 回退操作:用
removeLast()
撤销选择
3. 剪枝优化
- 循环边界计算:通过数学推导减少无效搜索
- 顺序控制:从s开始选择,保证组合升序且唯一
七、常见误区与优化建议
1. 重复组合问题
- 误区:未使用
s
参数控制选择起点for (int i = 1; i <= n; i++) { ... } // 错误,会产生[1,2]和[2,1]等重复组合
- 正确做法:每次从s开始选择,且s=i+1
2. 结果集复制错误
- 误区:直接添加temp到res
res.add(temp); // 错误,后续修改会影响已保存的组合
- 正确做法:添加副本
new ArrayList<>(temp)
3. 优化建议:位运算实现
// 位运算解法(仅作示意)
List<List<Integer>> res = new ArrayList<>();
for (int mask = 1; mask < (1 << n); mask++) {if (Integer.bitCount(mask) == k) {List<Integer> combo = new ArrayList<>();for (int i = 0; i < n; i++) {if ((mask & (1 << i)) != 0) {combo.add(i + 1);}}res.add(combo);}
}
- 优势:代码更简洁,时间复杂度相同
- 劣势:无法处理n较大的情况(如n=30时,1<<30超出整数范围)
八、总结:回溯算法是组合问题的系统枚举之道
本算法通过回溯法系统地枚举所有可能的组合,核心在于:
- 状态管理:用temp和res维护当前组合和结果集
- 递归控制:通过s参数避免重复,用循环边界优化搜索
- 回退机制:通过removeLast()实现状态回退,尝试所有可能
理解回溯算法的关键在于把握"选择-递归-回退"的循环逻辑,以及如何通过参数设计避免重复计算。这种方法不仅适用于组合问题,还可扩展到排列、子集等多种组合优化问题,是算法设计中处理枚举类问题的核心技术之一。