跟着卡哥学算法Day 22:回溯算法理论 & 题目一
正在加载今日诗词....
2025-03-05

回溯算法

  • 回溯法也叫做回溯搜索法
  • 回溯是递归算法的一种:本质是穷举,通过深度优先搜索遍历所有可能的解空间,并在发现当前路径无法得到有效解时,回退到上一步尝试其他分支

理解:类似于走迷宫,一条路走到底发现没有出口,继续返回到前一步的另一个路径,直到找到出口。

回溯法的效率

回溯法暴力穷举时,可以通过剪枝来优化,即剪掉不需要处理的路径,从而减少不必要的搜索。

回溯法核心理解

回溯法可以抽象为一个 n 叉树结构,树的宽度表示处理的集合的大小,树的高度表示递归的深度。

  1. 解空间树模型

    • 将问题转化为树形结构的决策过程,每个节点代表一个决策步骤
    • 叶子节点对应问题的候选解,路径代表决策序列
  2. 三要素

    • 路径:记录做出的选择
    • 选择列表:当前可做的选择,通常而言,用数组存储可以选择的操作
    • 结束条件:到达决策树底层,就是递归的结束点,也就是搜索的结束点

回溯法模板

回溯三部曲

  1. 回溯函数返回值以及参数

    • 回溯算法返回值一般为 void
    void backtracking(参数)
    
  2. 回溯算法终止条件

    既然是树形结构,那么搜到叶子节点,也就找到了满足条件的一条答案,收集结果,结束本层递归

    if (满足结束条件) {
      收集结果
      return
    }
    
  3. 单层搜索的过程

    回溯法一般是在集合中递归搜索,集合的大小构成了树的宽度,递归的深度构成的树的深度。

    for 循环可以理解是横向遍历每个元素,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了

    for (let i in 选择列表) {
      // 对一个选择列表做相应的选择
    
      做选择
      路径.push(选择)
    
      backtrack(新路径, 新选择列表)
    
      // 既然是回溯算法,那么在一次分岔路做完选择后
      // 需要回退我们之前做的操作
      // 保持路径状态一致性,确保不同分支间不互相干扰
    
      撤销选择
      路径.pop()
    }
    

回溯模板

result = []

function backtrack(路径, 选择列表) {
  if ('满足结束条件') {
    // 记录结果
    result.push(路径)
    return
  } else {
    for (let i in 选择列表) {
      // 对一个选择列表做相应的选择

      做选择
      路径.push(选择)

      backtrack(新路径, 新选择列表)

      // 既然是回溯算法,那么在一次分岔路做完选择后
      // 需要回退我们之前做的操作
      // 保持路径状态一致性,确保不同分支间不互相干扰

      撤销选择
      路径.pop()
    }
  }
}

关键优化技巧

  1. 剪枝策略

    • 可行性剪枝:提前终止不可能产生解的分支

      if 当前选择导致不可行: continue
      
    • 最优性剪枝:放弃非最优路径

      if 当前路径已劣于已知最优解: continue
      
  2. 记忆化搜索

    • 使用哈希表记录已访问状态,避免重复计算
    • 适用于存在重叠子问题的情况
  3. 决策顺序优化

    • 优先选择约束强的决策,减少搜索空间
    • 例如数独求解时优先填充候选数少的格子

77. 组合 🌟🌟

  • 组合:[1, 2, 3]、[1, 3, 2]、[2, 3, 1]等都是同一个组合
  • 排列:[1, 2, 3]、[1, 3, 2]、[2, 3, 1]等都是不同的排列

力扣链接 🌟🌟

题目描述

给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。

示例: 输入: n = 4, k = 2 输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]

解题思路

暴力法:两层 for 循环

for (let i = 0; i < n; i++) {
  for (let j = i + 1; j < n; j++) {
    result.push([i + 1, j + 1])
  }
}

但如果有 k>2 时,就需要多层 for 循环,暴力解法多层 for 循环嵌套很难写出来。

此时就需要使用回溯法来解决,用递归来解决多层嵌套循环的问题

回溯法解题步骤

画图,将需要解决的问题抽象为 n 叉树

back-tracking1

可以看出:n 相当于树的宽度,k 相当于树的高度

一维数组 path 来存放符合条件的结果,二维数组 result 来存放结果集

回溯三部曲:

  1. 回溯函数返回值以及参数

    • 参数 1:n

    • 参数 2:k

    • 参数 3:startIndex,用于记录当前递归的起始位置

      void backtracking(int n, int k, int startIndex)
      
  2. 回溯函数终止条件

    到达叶子节点时结果递归函数,那么何时到底叶子节点呢?

    收集结果的数组大小等于 k 时,说明已经找到了子集大小为 k 的组合,结果中保存的就是根节点到叶子结点的路径。

    if (path.length == k) {
      result.push(path)
      return
    }
    
  3. 单层搜索的过程

    for 循环可以理解是横向遍历每个元素,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了

    
    for (int i = startIndex; i <= n; i++) { // 控制树的横向遍历
        path.push(i); // 处理节点
        backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
        path.pop(); // 回溯,撤销处理的节点
    }
    

代码

function combine(n, k) {
  let result = []
  let path = []

  const backtracking = (n, k, startIndex) => {
    if (path.length == k) {
      result.push([...path])
      return
    }

    for (let i = startIndex; i <= n; i++) {
      path.push(i)
      backtracking(n, k, i + 1)
      path.pop()
    }
  }
  backtracking(n, k, 1)
  return result
}

剪枝优化

以 n=5,k=3 来详细拆解剪枝优化的逻辑

当剩余元素不足以完成组合时,提前终止循环。例如,若还需选 k - path.length 个元素,则循环上限为 n - (k - path.length) + 1

  • 未剪枝的情况

    所有可能的第一个元素:1,2,3,4,5
    但选4或5会导致后续无法完成组合:
              root
            /  |  |  \  \
            1   2  3  4  5  → 4和5的分支无法生成有效组合
          /|\  ...
          ...(大量无效递归)
    
  • 剪枝后的情况

    只允许第一个元素选1,2,3:
            root
            /  |  \
          1   2   3  → 每个分支都能保证后续有足够元素
          /|\ /|\ /|\
        ...(有效路径)
    
function combine(n, k) {
  let result = []
  let path = []

  const backtracking = (n, k, startIndex) {
    if (path.length === k) {
      result.push([...path])
      return
    }
    const remaining = k - path.length
    for (let i = startIndex; i <= n - remaining + 1; i++) {
      path.push(i)
      backtracking(n, k, i + 1)
      path.pop()
    }
  }
  backtracking(n, k, 1)
  return result
}

216.组合总和 III 🌟🌟

力扣链接 🌟🌟

题目描述

找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。

说明:

  • 所有数字都是正整数。
  • 解集不能包含重复的组合。

示例 1: 输入: k = 3, n = 7 输出: [[1,2,4]]

示例 2: 输入: k = 3, n = 9 输出: [[1,2,6], [1,3,5], [2,3,4]]

解题思路

本题:k 相当于树的深度,9 就是树的宽度,即在组合[1,2,3,4,5,6,7,8,9]中求 k 个数和为 n 的组合。

此时依然使用一维数组 path 来存放符合条件的结果,二维数组 result 来存放结果集

回溯三部曲:

  1. 回溯函数返回值以及参数

    • 参数 1:n

    • 参数 2:k

    • 参数 3:startIndex,用于记录当前递归的起始位置

    • 参数 4:sum,存储当前路径的和,path 内元素的总和

      void backtracking(n, k, startIndex, sum)
      
  2. 回溯函数终止条件

    到达叶子节点时结果递归函数,那么何时到底叶子节点呢?

    收集结果的数组大小等于 k 时,并且 sum 等于 n,使用 result 收集。

    if (path.length == k) {
      if (sum === n) result.push(path)
      return
    }
    
  3. 单层搜索的过程

    for 循环可以理解是横向遍历每个元素,backtracking(递归)就是纵向遍历,这样就把这棵树全遍历完了

    
    for (int i = startIndex; i <= 9; i++) { // 控制树的横向遍历
        path.push(i); // 处理节点
        sum += i
        backtracking(n, k, i + 1, sum); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
        path.pop(); // 回溯,撤销处理的节点
        sum -= i
    }
    

代码

var combinationSum3 = function (k, n) {
  let result = []
  let path = []

  const backtracking = (startIndex, sum) => {
    if (path.length === k) {
      if (n === sum) result.push([...path])
      return
    }

    for (let i = startIndex; i <= 9; i++) {
      path.push(i)
      sum += i
      backtracking(i + 1, sum)

      sum -= i
      path.pop()
    }
  }
  backtracking(1, 0)

  return result
}

剪枝优化

  • 和超过目标值:若当前路径和超过目标值 sum > n,直接返回,减少递归次数
  • 剩余数字不足:若剩余可选数字不足以填满所需数量 for 循环,提前终止
var combinationSum3 = function (k, n) {
  let result = []
  let path = []

  const backtracking = (startIndex, sum) => {
    // 剪枝:和超过目标值
    if (sum > n) return
    if (path.length === k) {
      if (n === sum) result.push([...path])
      return
    }
    // 计算剩余需要的数字数量
    const need = k - path.length
    // 剪枝:i的上界为 9 - need + 1
    for (let i = startIndex; i <= 9 - need + 1; i++) {
      path.push(i)
      sum += i
      backtracking(i + 1, sum)

      sum -= i
      path.pop()
    }
  }
  backtracking(1, 0)

  return result
}

17.电话号码的字母组合 🌟🌟

力扣链接 🌟🌟

题目描述

给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。

给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母。

示例:

  • 输入:"23"
  • 输出:["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"].

说明:尽管上面的答案是按字典序排列的,但是你可以任意选择答案输出的顺序

解题思路

本题:输入数字的个数相当于树的深度,每个按键对应的字母就是树的宽度

此时依然使用字符串 s 来存放符合条件的结果,数组 result 来存放结果集

回溯三部曲:

  1. 回溯函数返回值以及参数

    • 参数 1:index 当前处理的数字位置
  2. 回溯函数终止条件

    当 index 等于数字个数,那么使用 result 收集结果

    if (index === digits.length) {
      result.push(currentStr)
      return
    }
    
  3. 单层搜索的过程

    for 循环可以理解是遍历当前数字对应的所有字母,backtracking(递归)就是纵向遍历,处理下一个数字

    for (let char in letters) {
    }
    

代码

function letterCombinations(digits) {
  if (!digits?.length) return []

  const result = []
  const path = []
  const map = {
    2: 'abc',
    3: 'def',
    4: 'ghi',
    5: 'jkl',
    6: 'mno',
    7: 'pqrs',
    8: 'tuv',
    9: 'wxyz',
  }

  const backtracking = (index) => {
    if (path.length === digits.length) {
      result.push(path.join(''))
      return
    }

    const letters = map[digits[index]]
    for (let char of letters) {
      path.push(char)
      backtracking(index + 1)
      path.pop()
    }
  }
  backtracking(0)
  return result
}

京ICP备2022027737号
Copyright © 2022 - present @wangxiang

  • ☀️
  • 🌑