[ 回溯算法——子集、组合、排列 | 青训营笔记 ]

141 阅读5分钟

回溯算法——子集、组合、排列

参考代码随想录 (programmercarl.com),对子集、组合、排列这三种很类似的回溯算法问题进行了一个整理。

1. 组合

📚 组合: 从n个不同元素中每次取出m个不同元素(0≤m≤n),不管其顺序合成一组,称为从n个元素中不重复地选取m个元素的一个组合。

1.1 77. 组合 - 力扣(LeetCode)

image.png

  • 定义两个全局变量: res 存放所有可能的组合,path 存放没个可能的路径,当 path 的长度等于 k 时,将其浅拷贝到 res 中

  • 回溯函数:

    1. 回溯函数终止条件: path 的长度等于 k,if(path.length === k),将 path 加到 res 中,并返回
    2. 回溯搜索的遍历过程:for 循环每次从 startIndex 开始遍历,然后用 path 保存取到的节点 i 。backtracking(i + 1),下一层搜索从 i+1 开始,去重;回溯,撤销本次处理的结果, path.pop()
var combine = function(n, k) {
    let res = []
    let path = []
    const backtracking = (startIndex) => {
        if(path.length === k) {
            res.push([...path])
        }
        for(let i = startIndex; i <= n; i++) {
            path.push(i)
            backtracking(i + 1)
            path.pop()
        }
    }
    backtracking(1)
    return res
};

20210130194335207.png (1588×1054) (myqcloud.com)

💡 剪枝: 如果 for 循环选择的起始位置之后的元素个数已经不足我们需要的元素个数了,那么就没有必要搜索了。

👇 优化过程:

  1. 已经选择的元素个数:path.length;
  2. 所需需要的元素个数为: k - path.length;
  3. 列表中剩余元素(n - i + 1) >= 所需需要的元素个数(k - path.length)
  4. 在集合n中至多要从该起始位置 : i <= n - (k - path.length) + 1,开始遍历
var combine = function(n, k) {
    let res = []
    let path = []
    const backtracking = (startIndex) => {
        let len = path.length
        if(len === k) {
            res.push([...path])
            return
        }
        for(let i = startIndex; i <= n - k + len + 1; i++) {
            path.push(i)
            backtracking(i + 1)
            path.pop()
        }
    }
    backtracking(1)
    return res
};

1.2 216. 组合总和 III - 力扣(LeetCode)

image.png

  • 还需要增加一个变量 sum: sum 为当前 path 的元素总和

  • 回溯函数:

    1. 回溯函数终止条件:sum > n 直接 return; path 的长度等于 k 且 sum 的和为 n,if(path.length === k && sum === n),将 path 加到 res 中,并返回
    2. 回溯搜索的遍历过程:for 循环每次从 startIndex 开始遍历,然后用 path 保存取到的节点 i ,sum 的值加 i。backtracking(i + 1),下一层搜索从 i+1 开始,去重;回溯,撤销本次处理的结果,sum -= i path.pop()
var combinationSum3 = function(k, n) {
    let res = []
    let path = []
    let sum = 0
    const backtracking = (index) => {
        if(sum > n) return
        if(path.length === k && sum === n) {
            res.push([...path])
            return
        }
        for(let i = index; i <= 9 - k + path.length + 1; i++) {
            path.push(i)
            sum += i
            backtracking(i + 1)
            sum -= i
            path.pop()
        }
    }
    backtracking(1)
    return res
};

1.3 39. 组合总和 - 力扣(LeetCode)](leetcode.cn/problems/co…)

image.png

回溯函数:

  1. 回溯函数终止条件:if(sum === target),将 path 加到 res 中,并返回
  2. 回溯搜索的遍历过程:for 循环每次从 startIndex 开始遍历,然后用 path 保存取到的节点 i ,sum 的值加 i。由于这里允许组合中有重复元素,所以下一层搜索从 i 开始,backtracking(i);回溯,撤销本次处理的结果,sum -= candidates[i] path.pop()

💡 剪枝: 对总集合排序之后,如果下一层的 sum(就是本层的 sum + candidates[i])已经大于 target,就可以结束本轮 for 循环的遍历。

20201223170809182.png (1590×870) (myqcloud.com)

var combinationSum = function(candidates, target) {
    let res = []
    let path = []
    let sum = 0
    // 对总集合排序
    candidates.sort((a, b) => a - b)
    const backtracking = (index) => {
        if(sum === target) {
            res.push([...path])
            return
        }
        for(let i = index; i < candidates.length && sum + candidates[i] <= target; i++) {
            path.push(candidates[i])
            sum += candidates[i]
            // 允许重复元素,所以这里是 i
            backtracking(i)
            sum -= candidates[i]
            path.pop()
        }
    }
    backtracking(0)
    return res
};

1.4 40. 组合总和 II - 力扣(LeetCode)

image.png

这里跟上一题的区别主要是 candidates 里面可能包含重复的元素,要做去重处理

若当前元素和前一个元素相等,则本次循环结束,防止出现重复组合,if(i > index && candidates[i] === candidates[i - 1]) continue

var combinationSum2 = function(candidates, target) {
    let res = []
    let path = []
    let sum = 0
    candidates.sort((a,b) => a - b)
    const backtracking = (index) => {
        if(sum === target) {
            res.push([...path])
            return
        }
        for(let i = index; i < candidates.length && sum + candidates[i] <= target; i++) {
            //若当前元素和前一个元素相等,则本次循环结束,防止出现重复组合
            if(i > index && candidates[i] === candidates[i - 1]) continue
            path.push(candidates[i])
            sum += candidates[i]
            backtracking(i + 1)
            sum -= candidates[i]
            path.pop()
        }
    }
    backtracking(0)
    return res
};

2. 子集

📚 子集: 如果集合A任意一个元素都是集合B的元素,那么集合A称为集合B子集

2.1 78. 子集 - 力扣(LeetCode)

image.png

77. 组合 - 力扣(LeetCode)类似,不同之处在于子集的长度没有限制为 k,所以不用进行终止条件判断,直接 res.push([...path])

var subsets = function(nums) {
    let res = []
    let path = []
    let n = nums.length
    const backtracking = (startIndex) => {
        // 这里由于 path 是引用型数据,要进行一下拷贝
        res.push(Array.from(path))        
        for(let i = startIndex; i < n; i++) {
            path.push(nums[i])
            backtracking(i + 1)
            path.pop()
        }
    }
    backtracking(0)
    return res
};

2. 2 90. 子集 II - 力扣(LeetCode)

image.png

可能包含重复的元素,所以先进行排序处理,解集不能包含重复的子集,所以当 i > startIndexnum[i] === num[i - 1],即,处于同一个树层要重复取一样的元素时,要去重,if(i > startIndex && nums[i] === nums[i - 1]) continue

var subsetsWithDup = function(nums) {
    let res = []
    let path = []
    const n = nums.length
    nums.sort((a, b) => a - b)
    const backtracking = (startIndex) => {
        res.push(Array.from(path))
        for(let i = startIndex; i < n; i++) {
            if(i > startIndex && nums[i] === nums[i - 1]) continue
            path.push(nums[i])
            backtracking(i + 1)
            path.pop()
        }
    }
    backtracking(0)
    return res
};

3. 排列

📚 排列: 从n个不同元素中取出m(m≤n)个元素,按照一定的顺序排成一列,叫做从n个元素中取出m个元素的一个排列(permutation)。特别地,当m=n时,这个排列被称作全排列(all permutation)。

3.1 46. 全排列 - 力扣(LeetCode)

image.png

排列问题: 因为跟顺序有关,所以排列问题在每次都要从头开始搜索,例如元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次。

🤔 那么如何知道元素是否已经使用过了呢?

used数组: 记录此时 path 里都有哪些元素使用了,一个排列里一个元素只能使用一次。回溯遍历时,如果元素已经出现过,则跳过当前循环, if(used[i]) continue

var permute = function(nums) {
    let res = [], path = [], len = nums.length
    let used = new Array(len)
    const backtracking = () => {
        if(path.length === len) {
            res.push([...path])
            return
        }
        for(let i = 0; i < len; i++) {
            if(used[i]) continue
            path.push(nums[i])
            used[i] = true
            backtracking()
            used[i] = false
            path.pop()
        }
    }
    backtracking()
    return res
};

3.2 47. 全排列 II - 力扣(LeetCode)

image.png

与上一题的不同之处在于 nums 可能包含重复数字,因此要先对 nums 进行排序处理,且进行去重

  • used[i - 1] == true,说明同一树枝nums[i - 1]使用过
  • used[i - 1] == false,说明同一树层nums[i - 1]使用过

如果同一树层 nums[i - 1] 使用过则直接跳过,if(i > 0 && nums[i] === nums[i - 1] && used[i - 1] === false) continue

var permuteUnique = function(nums) {
    let res = [], path = [], n = nums.length, used = new Array(n)
    nums.sort((a, b) => a - b)
    const backtracking = () => {
        if(path.length === n) {
            res.push([...path])
            return
        }
        for(let i = 0; i < n; i++) {
            if(used[i]) continue
            // 如果同一树层nums[i - 1]使用过则直接跳过
            if(i > 0 && nums[i] === nums[i - 1] && used[i - 1] === false) continue
            path.push(nums[i])
            used[i] = true
            backtracking()
            used[i] = false
            path.pop()
        }
    }
    backtracking()
    return res
};

4. 总结

  1. 组合和子集与数字的顺序无关,所以在遍历时,从 startIndex 开始

    //子集
    for(let i = startIndex; i <= n; i++) {
        path.push(i)
        backtracking(i + 1)
        path.pop()
    }
    // 组合可以进行一下剪枝
    for(let i = startIndex; i <= n - k + len + 1; i++) {
        path.push(i)
        backtracking(i + 1)
        path.pop()
    }
    
  2. 而排列与顺序有关,所以遍历时从 0 开始,并使用 used 数组记录数字是否已经出现在 path 中

    for(let i = 0; i < len; i++) {
        if(used[i]) continue
        path.push(nums[i])
        used[i] = true
        backtracking()
        used[i] = false
        path.pop()
    }
    
  3. 如果是有重复元素的,则要先进行排序,在同一树层上做剪枝处理

    nums.sort((a, b) => a - b)
    // 组合和子集
    if(i > startIndex && nums[i] === nums[i - 1]) continue
    // 排列
    if(i > 0 && nums[i] === nums[i - 1] && used[i - 1] === false) continue