前言
回溯算法是一种试探性的算法,它在解决问题时尝试各种可能的解,并通过在每一步选择中进行回退(backtrack)来找到问题的解。在JavaScript中,回溯算法通常用于解决一些组合、排列、子集和搜索等问题。
回溯算法的基本思想:
- 选择: 在问题的每一步,算法尝试各种可能的选择。
- 判断: 对每个选择进行评估,确定是否满足问题的条件。
- 回退: 如果选择导致无解或不满足条件,算法回退到之前的状态,尝试其他选择。
下面我将用LeetCode中的例题进行讲解。
1. 全排列问题(Permutations)
问题描述: 给定一个不含重复数字的数组 nums,返回其所有可能的全排列。
js
var permute = function(nums) {
const result = [];
function backtrack(path, used) {
// 终止条件:当当前路径的长度等于数组长度时,说明找到一个全排列
if (path.length === nums.length) {
result.push([...path]); // 将当前路径拷贝一份加入结果集
return;
}
// 遍历数组元素
for (let i = 0; i < nums.length; i++) {
// 跳过已经使用过的元素
if (used[i]) continue;
// 做出选择:将当前元素加入路径,并标记为已使用
path.push(nums[i]);
used[i] = true;
// 递归调用,继续往下选择
backtrack(path, used);
// 撤销选择:回溯到上一层状态
path.pop();
used[i] = false;
}
}
// 初始调用回溯函数
backtrack([], []);
return result;
};
在上述代码中,我们构建了一个backtrack
的回调函数,它接受两个参数,path
表示当前的路径,used
用于标记数组元素是否被使用过。并加入了终止条件:当当前路径的长度等于数组长度时,说明找到一个全排列。然后做出选择。以此通过递归的方式,使用回溯算法在不同的选择路径上搜索全排列,确保每个元素都在每个位置上都有机会出现。
2. 子集问题(Subsets)
问题描述: 给定一个不含重复元素的整数数组 nums,返回该数组所有可能的子集。
js
var subsets = function(nums) {
const result = [];
function backtrack(start, path) {
// 将当前路径加入结果集
result.push([...path]);
// 遍历数组元素
for (let i = start; i < nums.length; i++) {
// 做出选择:将当前元素加入路径
path.push(nums[i]);
// 递归调用:继续往下选择,从下一个元素开始
backtrack(i + 1, path);
// 撤销选择:回溯到上一层状态
path.pop();
}
}
// 初始调用回溯函数,从第一个元素开始,初始路径为空
backtrack(0, []);
// 返回最终的结果集
return result;
};
现在逐步解释这段代码的运行过程:
- 初始调用: 最初调用
backtrack(0, [])
,表示从数组的第一个元素开始选择,当前路径为空。 - 遍历数组元素: 进入
for
循环,从start
开始遍历数组元素。 - 做出选择: 将当前元素加入路径
path
,这里是nums[i]
。 - 递归调用: 调用
backtrack(i + 1, path)
,继续往下选择,下一层的起始位置为i + 1
,表示从当前元素的下一个元素开始选择。 - 遍历和递归: 重复上述步骤,遍历数组元素,做出选择,递归调用,直到遍历完成。
- 撤销选择: 当递归返回后,回到上一层状态,撤销当前选择,即
path.pop()
,这样就回溯到上一层状态。 - 将当前路径加入结果集: 在每一层递归返回后,都将当前路径加入结果集,即
result.push([...path])
。 - 重复过程: 重复以上步骤,直到遍历完所有可能的选择,得到所有子集。
3. 组合求和问题(Combination Sum)
问题描述: 给定一个无重复元素的数组 candidates 和一个目标数 target,找出 candidates 中所有可以使数字和为 target 的组合。
js
var combinationSum = function(candidates, target) {
const result = [];
function backtrack(start, target, path) {
// 终止条件:当目标值为0时,表示找到一个组合,将当前路径加入结果集
if (target === 0) {
result.push([...path]);
return;
}
// 遍历数组元素
for (let i = start; i < candidates.length; i++) {
// 跳过不符合条件的元素
if (target - candidates[i] < 0) continue;
// 做出选择:将当前元素加入路径
path.push(candidates[i]);
// 递归调用:继续往下选择,更新目标值,起始位置不变
backtrack(i, target - candidates[i], path);
// 撤销选择:回溯到上一层状态
path.pop();
}
}
// 初始调用回溯函数,从数组的第一个元素开始,目标值为target,初始路径为空
backtrack(0, target, []);
// 返回最终的结果集
return result;
};
下面逐步解释代码的运行过程:
- 初始调用: 调用
backtrack(0, target, [])
,表示从数组的第一个元素开始选择,目标值为target
,初始路径为空。 - 终止条件: 在
backtrack
函数内部,首先检查终止条件,当target
等于 0 时,表示找到一个组合,将当前路径path
加入结果集result
。 - 遍历数组元素: 使用循环遍历数组元素,从
start
开始。 - 跳过不符合条件的元素: 如果
target - candidates[i] < 0
,说明当前元素加入路径后目标值将小于0,跳过当前元素。 - 做出选择: 将当前元素加入路径
path
。 - 递归调用: 调用
backtrack(i, target - candidates[i], path)
,继续往下选择,更新目标值为target - candidates[i]
,起始位置不变。 - 撤销选择: 在递归返回后,回溯到上一层状态,将当前元素从路径中弹出。
- 重复过程: 重复以上步骤,直到遍历完所有可能的选择,得到所有满足条件的组合。
总结
- 在每个问题中,都定义了一个
backtrack
函数,负责执行回溯算法的核心逻辑。 path
参数用于保存当前的路径,表示一种可能的解。- 对于全排列和子集问题,通过循环遍历数组元素,做出选择并递归,然后撤销选择。
- 对于组合求和问题,同样通过循环遍历数组元素,但在递归时传递的
target
值会递减,直到target
为0时,表示找到一个解。
注意事项:
- 回溯算法通常使用递归实现。
- 在每一步的选择中,需要进行选择、递归、回退三个步骤。
- 需要谨慎处理状态的保存和撤销,确保不影响算法的正确性。