回溯①--排列组合问题

在之前的二叉树章节,我们就了解了回溯,回溯和递归往往成对出现,回溯不仅可以解决二叉树等相关问题,还可以解决排列组合相关问题,是因为这类问题可以转化为n叉树遍历问题;

具体是如何转化和思考的,让我们从题目中寻找灵感吧!🧑‍💻

组合

LeetCode-77.组合

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

示例:

ini 复制代码
输入: n = 4, k = 2
输出:
[
  [2,4],
  [3,4],
  [2,3],
  [1,2],
  [1,3],
  [1,4],
]

前面我有说到排列组合问题可以将其转化为n叉树的遍历问题,对于这个最基础的组合题,我们应做如下转换(以示例为例):

如图所示,我们在每个叶子几点可以得到结果,每一层表示当前可选取的项。还记得二叉树中的求所有路径吗?当时就使用到了回溯,我们利用回溯来在一个节点处收集结果,然后在递归返回上一层时将当前路径信息回溯!在这里组合问题中我们同样是在一条路径尾收集结果,然后递归返回时回溯,从而实现遍历所有路径!

同样进行递归分析:

  • 参数和返回值:我们需要把所有结果收集起来并且不存在中断递归的情况,这里不需要返回值,直接使用全局遍历存储结果;
    • 参数:
      • candidates 候选项集合
      • curCombine 当前递归(层)收集的组合
      • length 递归的深度,题目要求k个数的所有组合,因此length为k
  • 终止条件:当前收集的组合长度为curCombin.length===length时收集该组合并结束
  • 单层循环逻辑:在该层我们需要取一项然后递归收集下一层,并当递归返回时进行回溯
    • 因此我们需要循环遍历当前层的候选项集合,对于每一项进行递归并在返回时回溯
    • 由于组合是不包括重复集合的,即[1,2][2,1]只能出现一个,我们可以构造一个本身有序的候选项集合,这样每一层就可以轻松的排除不可选项了

先来看看代码,然后对其进行总结:

js 复制代码
let res = []; //全局变量res,收集所有组合
/**
 * 
 * @param {number[]} candidates 候选项集合
 * @param {number[]} curCombine 当前递归(层)收集的组合
 * @param {number} length 递归的深度,题目要求k个数的所有组合,因此length为k
 */
function backtracking(candidates, curCombine, length) {
  //终止条件 当前收集的组合长度为`curCombin.length===length`时收集该组合并结束
  if (curCombine.length === length) {
    //curCombine为引用数据类型,其中各项为原始类型,需要浅拷贝
    res.push([...curCombine]);
    return;
  }

  //单层循环逻辑 循环当前递归层的所有可选项
  for (let i = 0; i < candidates.length; i++) {
    //选取一项
    curCombine.push(candidates[i]);
    //更新candidates用于下一层选择,即将当前选择的元素摘除
    //递归下一层
    backtracking(candidates.slice(i + 1), curCombine, length);
    //回溯 将curCombine 和 candidates 还原
    curCombine.pop();
    //我们在传参时使用了slice方法,因此这里不需要回溯candidates
  }
  return;
}

var combine = function (n, k) {
  res = [];//每次调用combine时重置res
  //构造出候选集和
  let candidates = new Array(n).fill(0).map((_, index) => index + 1);
  backtracking(candidates, [], k);
  return res;
};

小结

在上面这道题解决后,你应该知道:

  • 组合问题中,我们的for循环对应的是树的每一层,而for循环中的递归对应的是树的每一条选路

模板

除此之外,实际上对于回溯问题:组合、排列、分割等问题都是这样的逻辑,因此我们可以总结出一套模板

js 复制代码
function backtracing(...args){
    //终止条件
    if(...){
        //收集一个结果
        ...
        return;
    }
    
    //遍历每一层
    for(...){
        //剪枝,(如果可以的话,在算法完成后思考)
        //选取一项
        ...
        //更新下一层所需参数
        ...
        //递归
        backtracing(...);
        //回溯 还原更新
        ... 
    }
    
    return;
}

对于上述题目,我们在解决后想想它是否能够进行剪枝? 假如此时候选集合为[1,2,3,4],我们需要的组合长度为k=4,那么考虑一下在第一层时,递归2,3,4能否得到结果?显然是不能的,因为已经没有足够的元素可以供我们选取了,那么就可以将这些不可用的递归分支剪掉:

js 复制代码
...
  for (let i = 0; i < candidates.length; i++) {
    //剪枝  当前层数=已经选择的个数:curCombine.length + 可选项个数:candidates.length < 所需组合个数:length 则没必要递归下去了
    if(curCombine.length + candidates.length < length){
        return;
    }
    ...
  }
...

LeetCode-216.组合总和 III

找出所有相加之和为 nk 个数的组合,且满足下列条件:

  • 只使用数字1到9
  • 每个数字 最多使用一次

示例:

ini 复制代码
输入: k = 3, n = 7
输出: [[1,2,4]]
解释:
1 + 2 + 4 = 7
没有其他符合的组合了。

与上一题十分相似,我想我不需要多做解释了,直接上代码,你可以自己画一下树状图能够帮助你更好地理解:

js 复制代码
let res = [];
/**
 * @param {number[]} candidates 候选项集合
 * @param {number[]} curCombin 当前递归(层)收集的组合
 * @param {number} curSum 当前递归的总和
 * @param {number} targetSum 目标总和
 * @param {number} length 目标组合的长度
 */
function backtracking(candidates, curCombin, curSum, targetSum, length) {
  //终止条件1 如果当前总和大于目标综合或当前组合的长度大于目标长度,则退出,不需要继续收集后续了
  if (curSum > targetSum || curCombin.length > length) {
    return;
  }
  //终止条件2 满足条件,收集结果并返回
  if (curSum === targetSum && curCombin.length === length) {
    res.push([...curCombin]);
    return;
  }

  for (let i = 0; i < candidates.length; i++) {
    //剪枝1 加上当前选择的值如果大于目标值那么该层后续的都没必要选择了
    if (curSum + candidates[i] > targetSum) {
      return;
    }
    //剪枝2 如果待选个数不满足需求,则也没必要继续了
    if (candidates.length + curCombin.length < length) {
      return;
    }
    //收集一项,并更新参数
    curCombin.push(candidates[i]);
    curSum += candidates[i];
    backtracking(candidates.slice(i + 1), curCombin, curSum, targetSum, length);
    //回溯 还原更新
    curCombin.pop();
    curSum -= candidates[i];
  }
  return;
}
/**
 * @param {number} k
 * @param {number} n
 * @return {number[][]}
 */
var combinationSum3 = function (k, n) {
  res = [];
  //构造候选集合
  const candidates = new Array(9).fill(0).map((_, index) => index + 1);
  backtracking(candidates, [], 0, n, k);
  return res;
};

LeetCode-17.电话号码的字母组合

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。答案可以按 任意顺序 返回。给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例:

arduino 复制代码
输入: digits = "23"
输出: ["ad","ae","af","bd","be","bf","cd","ce","cf"]

对于本题我们同样是需要先构造出候选集合,然后考虑每一层的逻辑:我的办法是构造出类似如下的候选集合:

css 复制代码
输入: digits = "23"
候选集和:[['a','b','c'],['d','e','f']]

对于这样一个候选集合,我们要清楚的知道每一层都有哪些项,我们应该遍历哪个集合来进行递归;(前面的题目中候选集合都是剩余的项,而在本题中不同)

  • 每次进入递归后,我们应该选择当前候选集合中的第一项作为该层的可选项,因此对于第一层我们的遍历目标就是['a','b','c']
  • 因此我们可以总结出:回溯问题中每一层的候选项可能不同,我们应当根据我们的数据结构来选定

分析了这么多,你可以先尝试一下,实现如下:

js 复制代码
/**
 * 获取数字对应的字母,你当然可以使用一个固定的对象来获取,不需要一个函数
 * @param {number} num 2-9 
 * @returns num对应的手机按键字母数组 ['a','b','c']
 */
function getBtnAlphabet(num) {
  if (num > 9) {
    throw new Error();
  };
  const alphabet = 'abcdefghijklmnopqrstuvwxyz';
  if (num === 7) return Array.from('pqrs');
  if (num === 8) return Array.from('tuv');
  if (num === 9) return Array.from('wxyz');
  return Array.from(alphabet.slice((num - 2) * 3, (num - 1) * 3));
}
/**
 * @param {string[][]} candidates 表示可进行组合的组 e.g.[[a,b,c],[d,e,f]]
 * @param {string} curCombine 当前组合
 * @param {number} length 每个组合的长度
 * @returns 返回所有可能的组合
 */
let res = []
function backtraking(candidates, curCombin, length) {
  //终止条件
  if (curCombin.length === length) {
    res.push(curCombin.join(""));
    return;
  }

  //在遍历这一层之前先确定这一层的候选集合
  const [curLayer] = candidates.slice(0, 1);
  for (let i = 0; i < curLayer.length; i++) {
    //收集
    curCombin.push(curLayer[i]);
    //递归
    backtraking(candidates.slice(1), curCombin, length);
    //回溯
    curCombin.pop();
  }
  return;
}
/**
 * @param {string} digits
 * @return {string[]}
 */
var letterCombinations = function (digits) {
  res = [];
  if (!digits.length) return res;
  //构造出候选集合
  const input = Array.from(digits); //['2','3']
  const candidates = input.map((num) => {
    return getBtnAlphabet(parseInt(num));
  })
  backtraking(candidates, [], digits.length);
  return res;
};

小结

  • 针对不同的场景,每一层候选集合需要在for循环(遍历这一层)之前确定。
    • for循环之外的代码应该用于解决即将遍历层的所需信息(后面题目中还会使用到)
    • for循环内的代码是针对当前层在进入下一次递归和退出递归时做的操作(递归可以理解为进入树的下一层入口)
  • 以上所述可以抽象为:

LeetCode-39.组合总和

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

示例

ini 复制代码
输入: candidates = [2,3,5], target = 8
输出: [[2,2,2,2],[2,3,3],[3,5]]

本题与之前的组合总和Ⅲ的不同点就是,一个元素可以重复利用,也就是说,当我们选择了一个元素后进入下一层之前,并不需要删除当前已选元素,但是之前选过的元素还需要删除

js 复制代码
let res = [];
/**
 * @param {number[]} candidates 候选数集合 
 * @param {number[]} curCombine 当前组合
 * @param {number} sum 当前总和
 * @param {number} target 目标值
 * @returns 
 */
function backtracking(candidates, curCombine, sum, target) {
  //终止条件
  if (sum === target) {
    res.push([...curCombine]);
    return;
  }
  if (sum > target) {
    return;
  }

  //当前循环
  for (let i = 0; i < candidates.length; i++) {
    //剪枝 如果当前总和加上要选项 大于 目标值 则跳过本次循环
    if (sum + candidates[i] > target) {
      continue;
    }
    curCombine.push(candidates[i]);
    sum += candidates[i];
    //下一层的候选项集合中,仍然需要包括这一层已选元素,但是不包括这一层之上的其他层选的
    backtracking(candidates.slice(i), curCombine, sum, target);
    curCombine.pop();
    sum -= candidates[i];
  }

  return;
}
/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum = function (candidates, target) {
  res = [];
  backtracking(candidates, [], 0, target);
  return res;
};

关注这一行backtracking(candidates.slice(i), curCombine, sum, target);,下一层的候选集合应该包括这一层下选择的元素,而之一层之前选择的则不需要了:e.g. [2,3,1,4] 选1 => 下一层候选集合[1,4]

LeetCode-40.组合总和 II

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。

candidates 中的每个数字在每个组合中只能使用 一次

示例:

ini 复制代码
输入: candidates = [10,1,2,7,6,1,5], target = 8,
输出:
[
[1,1,6],
[1,2,5],
[1,7],
[2,6]
]

这道题与普通组合总和的不同点就是:候选集合中包含重复元素,这会造成什么问题呢?

例如[1,3,1,4],目标值为5

  • 那么我们会在第一个1处得到结果:[1,3,1] [1,4]
  • 在第二个1处又得到[1,4]

我们应该如何排除这种情况?

  • 将候选集合进行排序,这样重复项将成群出现,我们只需要判断当前所选的元素是否与上一个相同,如果相同就不需要进行这一层了,因为递归从第一个1处退出时,已经得出了所有以1开头的可能组合了。
js 复制代码
let res = []
/**
 * @param {number[]} candidates 候选集合
 * @param {number} sum 当前组合总和
 * @param {number} target 目标值
 * @param {number[]} curCombine 当前组合
 * @returns 
 */
function backtracking(candidates, sum, target, curCombine) {
  //终止条件
  if (sum === target) {
    res.push([...curCombine]);
    return;
  }

  for (let i = 0; i < candidates.length; i++) {
    //如果当前数字以及寻找过了,那不用再寻找了,跳过循环
    if (i && candidates[i] === candidates[i - 1]) continue;
    //剪枝
    if (sum + candidates[i] > target) {
      return;
    }
    sum += candidates[i];
    curCombine.push(candidates[i]);
    backtracking(candidates.slice(i + 1), sum, target, curCombine);
    curCombine.pop();
    sum -= candidates[i];
  }
  return;
}
/**
 * @param {number[]} candidates
 * @param {number} target
 * @return {number[][]}
 */
var combinationSum2 = function (candidates, target) {
  res = [];
  //将候选集合排序
  candidates.sort((a, b) => a - b);
  backtracking(candidates, 0, target, []);
  return res;
};

排列

搞懂组合问题,排列问题就十分简单了,在解决组合时:[1,2,3]这个集合的组合为[1,2] [1,3] [2,3]。而排列则不同,不同的顺序被视为不同的结果;

在组合时,我们写了如下代码:backtracking(candidates.slice(i + 1), ...args); 我们将已使用过的所有元素删除了,在下一层不会出现。

而在排列时,我们只希望删除这一层选择的那一个;因此我们的代码就应该变成:backtracking([].concat(candidates.slice(0,i), candidates.slice(i + 1)), ...args);

看懂这个,下面的题目就十分简单了;

LeetCode-46.全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

js 复制代码
let res = [];
/**
 * @param {number[]} candidates 候选集和
 * @param {number[]} curCombin 当前所选组合
 * @param {number} targetLength 目标组合长度
 * @returns {void}
 */
function backtracing(candidates, curCombin, targetLength) {
  //终止条件 已选组合长度满足目标组合长度即可,全排列的每一个结果都是包含所有元素的
  if (curCombin.length === targetLength) {
    res.push([...curCombin]);
    return;
  }

  for (let i = 0; i < candidates.length; i++) {
    curCombin.push(candidates[i]);
    //下一层的候选集合中仅不包括candidates[i]
    backtracing([].concat(candidates.slice(0, i), candidates.slice(i + 1)), curCombin, targetLength);
    curCombin.pop();
  }

  return;
}
/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permute = function (nums) {
  res = [];
  backtracing(nums, [], nums.length);
  return res;
};

LeetCode-50.全排列 II

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。

例如这个示例:[1,1,2]:

当前递归从第一个1处开始,逐步向下递归获得:1,1,2 ,1,2,1

然后到第二个1处,此时如果还像上述代码一样执行则会继续得到1,1,2,1,2,1

也就是说我们不希望一样的元素进行重复递归; 那不就和组合总和Ⅱ一样了吗,排序加判断就可以了;

js 复制代码
let res = [];
/**
 * @param {number[]} candidates 候选集和
 * @param {number[]} curCombin 当前所选组合
 * @param {number} targetLength 目标组合长度
 * @returns {void}
 */
function backtracing(candidates, curCombin, targetLength) {
  //终止条件 当前组合长度满足
  if (curCombin.length === targetLength) {
    res.push([...curCombin]);
    return;
  }

  for (let i = 0; i < candidates.length; i++) {
    if (i && candidates[i - 1] === candidates[i]) continue;
    //收集
    curCombin.push(candidates[i]);
    //递归向树枝移动,candidates为删除使用项外的其他项
    backtracing([].concat(candidates.slice(0, i), candidates.slice(i + 1)), curCombin, targetLength);
    //回溯
    curCombin.pop();
  }
  return;
}
/**
 * @param {number[]} nums
 * @return {number[][]}
 */
var permuteUnique = function (nums) {
  res = [];
  nums.sort((a, b) => a - b);
  backtracing(nums, [], nums.length);
  return res;
};

除了这个方法外,我们还可以在进入每层之前创建一个当前层使用的元素集合layerUsed,在当前层选择排列元素时,如果遇到已经使用过的元素则直接跳出即可;

js 复制代码
let res = [];
/**
 * @param {number[]} candidates 候选集和
 * @param {number[]} curCombin 当前所选组合
 * @param {number} targetLength 目标组合长度
 * @returns {void}
 */
function backtracing(candidates, curCombin, targetLength) {
   ...
  //当前层所使用的项
  let layerUsed = []
  for (let i = 0; i < candidates.length; i++) {
    //如果当前层已经使用过相同项,就跳过该次循环
    if (layerUsed.includes(candidates[i])) continue;
    //更新当前层使用的项
    layerUsed.push(candidates[i])

   ...
  }
  return;
}

var permuteUnique = function (nums) {
  res = [];
  backtracing(nums, [], nums.length);
  return res;
};

思考题:为什么组合Ⅱ中不能这样使用呢? 你可以试试这个示例:cadidates=[1,2,1,5] target=8;

总结

  • 回溯问题模板:
js 复制代码
function backtracing(...args){
    //终止条件
    if(...){
        //收集一个结果
        ...
        return;
    }
    
    //遍历每一层
    for(...){
        //剪枝,(如果可以的话,在算法完成后思考)
        //选取一项
        ...
        //更新下一层所需参数
        ...
        //递归
        backtracing(...);
        //回溯 还原更新
        ... 
    }
    
    return;
}
  • for循环对应的是树的每一层,而for循环中的递归对应的是树的每一条选路,整个过程就像深度优先遍历一样
  • 模板可以抽象为:
相关推荐
Mephisto.java26 分钟前
【力扣 | SQL题 | 每日四题】力扣2082, 2084, 2072, 2112, 180
sql·算法·leetcode
robin_suli27 分钟前
滑动窗口->dd爱框框
算法
丶Darling.28 分钟前
LeetCode Hot100 | Day1 | 二叉树:二叉树的直径
数据结构·c++·学习·算法·leetcode·二叉树
labuladuo52039 分钟前
Codeforces Round 977 (Div. 2) C2 Adjust The Presentation (Hard Version)(思维,set)
数据结构·c++·算法
安冬的码畜日常1 小时前
【D3.js in Action 3 精译_029】3.5 给 D3 条形图加注图表标签(上)
开发语言·前端·javascript·信息可视化·数据可视化·d3.js
jiyisuifeng19911 小时前
代码随想录训练营第54天|单调栈+双指针
数据结构·算法
太阳花ˉ1 小时前
html+css+js实现step进度条效果
javascript·css·html
꧁༺❀氯ྀൢ躅ྀൢ❀༻꧂1 小时前
实验4 循环结构
c语言·算法·基础题
新晓·故知1 小时前
<基于递归实现线索二叉树的构造及遍历算法探讨>
数据结构·经验分享·笔记·算法·链表