回溯算法实战练习(3)

18 阅读9分钟

回溯算法实战练习(3)

前言

本篇专门整理回溯算法中最经典、面试最高频的 4 道实战题目,涵盖括号删除、石子移动、工作分配、饼干分发四大题型。


1. 301. 删除无效的括号

题目链接leetcode.cn/problems/re…

题目描述

给你一个由若干括号和字母组成的字符串 s,删除最小数量的无效括号,使得输入的字符串有效。

返回所有可能的结果。答案可以按任意顺序返回。

示例

  • 输入:s = "()())()"

  • 输出:["(())()","()()()"]

  • 输入:s = ")("

  • 输出:[""]

解题思路

  • 先算最少要删几个左、右括号

    • 遍历一遍字符串,用计数器算出:

    • delLeftCount:最终必须删掉的左括号数量

    • delRightCount:最终必须删掉的右括号数量

    • 这一步保证了:我们只删最少数量的括号,不会多删。

  • DFS 枚举删 / 不删

    • 从左到右遍历每个字符

    • 如果当前是 ( 且还没删够:可以选择删掉它

    • 如果当前是 ) 且还没删够:可以选择删掉它

    • 任何情况都可以选择不删,直接拼接

    • 用 start 控制位置,标准回溯切割思路

  • 终止条件

    • 遍历完整个字符串

    • 刚好删掉了应该删的左、右括号数量

    • 最终字符串括号合法

    • 满足以上三点才加入结果集

  • 去重

    • 使用 Set 存储结果,自动去掉重复字符串

代码


/**
 * 301. 删除无效的括号
 * 难度:困难
 * 解法:DFS 回溯 + 最小删除计数 + 合法性验证 + Set 去重
 * 思路:
 * 1. 先计算左括号的数量和右括号的数量,得出必须删除的左右括号数
 * 2. dfs(start, 已删左, 已删右, 当前串) 枚举删或不删
 * 3. 是可删括号且没删够:可以删
 * 4. 任何情况:可以不删
 * 5. 遍历结束后验证:删够数量 + 括号合法,才加入结果
 * 6. 使用 Set 自动去重
 */
var removeInvalidParentheses = function (s) {
  const n = s.length;
  let delLeftCount = 0;
  let delRightCount = 0;

  // 计算需要删除的左、右括号数量
  for (let char of s) {
    if (char === '(') delLeftCount++;
    if (char === ')') {
      if (delLeftCount > 0) {
        delLeftCount--;
      } else {
        delRightCount++;
      }
    }
  }

  // 本身合法,直接返回
  if (delLeftCount === 0 && delRightCount === 0) return [s];

  const res = new Set();
  dfs(0, 0, 0, '');
  return [...res];

  function dfs(start, deledLeft, deledRight, curStr) {
    // 终止条件:遍历完 + 删够数量 + 合法
    if (start === n) {
      if (deledLeft === delLeftCount && deledRight === delRightCount && isValid(curStr)) {
        res.add(curStr);
      }
      return;
    }

    const char = s[start];

    // 选择1:删除左括号
    if (char === '(' && deledLeft < delLeftCount) {
      dfs(start + 1, deledLeft + 1, deledRight, curStr);
    }

    // 选择2:删除右括号
    if (char === ')' && deledRight < delRightCount) {
      dfs(start + 1, deledLeft, deledRight + 1, curStr);
    }

    // 选择3:不删
    dfs(start + 1, deledLeft, deledRight, curStr + char);
  }

  // 验证括号是否有效
  function isValid(s) {
    const stack = [];
    for (let char of s) {
      if (char === '(') {
        stack.push('(');
      } else if (char === ')') {
        if (stack.length === 0) return false;
        stack.pop();
      }
    }
    return stack.length === 0;
  }
};

2. 2850. 将石头分散到网格图的最少移动次数

题目链接leetcode.cn/problems/mi…

题目描述

给你一个 3x3 的网格 grid,格子中的值代表石头数量。

每次可以移动一颗石头到相邻格子,求让所有格子都恰好为 1 的最少总移动步数

示例

  • 输入:[[1,1,0],[1,1,1],[1,2,1]]

  • 输出:1

解题思路

  • 先扫一遍网格,找出哪些石头要搬、哪些格子是空。

  • 回溯枚举所有搬法:每颗石头都可以放到任意空位。

  • 用曼哈顿距离算最少步数,找到总步数最小的方案。

代码


/**
 * LeetCode 1769. 移动石子直到所有格子都为 1
 * 题目:3x3 的网格,有的格子石头数量 > 1(多了),有的 = 0(空了)
 * 每次可以移动一颗石头到相邻格子,一步算 1
 * 求让所有格子都变成 1 的【最少总移动步数】
 *
 * 解法:回溯 DFS + 曼哈顿距离
 * 核心思想:不模拟一步步移动,直接计算【每颗石头】到【每个空位】的最短距离
 *          回溯枚举所有分配方案,找到总距离最小的
 */
var minimumMoves = function (grid) {
  // 获取网格行列数(固定 3x3)
  const rows = grid.length;
  const cols = grid[0].length;

  // ======================================
  // 第一步:收集两个关键数组
  // ======================================

  // from:存放【需要往外搬石头】的位置
  // 重点:多几颗石头,就存几次!
  // 例:grid[i][j] = 3 → 多 2 颗 → push 2 次 [i,j]
  const from = [];

  // to:存放【空位置】,需要填入石头
  const to = [];

  // 遍历整个网格,收集数据
  for (let row = 0; row < rows; row++) {
    for (let col = 0; col < cols; col++) {
      // 如果当前格子是空的(0),加入 to 列表
      if (grid[row][col] === 0) {
        to.push([row, col]);
      }

      // 如果当前格子石头 > 1,说明有多余石头要搬走
      while (grid[row][col] > 1) {
        // 把这个位置加入 from
        from.push([row, col]);
        // 拿走一颗石头,避免死循环,同时记录多出来的数量
        grid[row][col]--;
      }
    }
  }

  // ======================================
  // 第二步:回溯准备
  // ======================================

  // used 数组:标记 to 中的空位是否已经被分配了石头
  // 作用:避免多个石头搬到同一个空位
  let used = new Array(to.length).fill(false);

  // 记录最终答案:最小总步数
  let res = Infinity;

  // ======================================
  // 第三步:开始回溯
  // ======================================

  // dfs 参数说明:
  // start:当前正在处理 from 中的第几个石头
  // step:当前已经累计的移动总步数
  dfs(0, 0);

  // 遍历完所有方案,返回最小步数
  return res;

  // ====================
  // 回溯核心函数
  // ====================
  function dfs(start, step) {
    // 递归终止条件:
    // 所有要搬的石头(from)都已经分配完了
    if (start === from.length) {
      // 更新最小步数:把当前方案的总步数和历史最小值比较
      res = Math.min(res, step);
      return;
    }

    // 取出【当前要搬运】的这颗石头的出发点坐标
    const [row1, col1] = from[start];

    // 枚举所有空位,尝试把这颗石头放到【每一个空位】上
    for (let i = 0; i < to.length; i++) {
      // 如果这个空位已经被占用,跳过
      if (used[i]) continue;

      // 标记:这个空位已经被当前石头占用
      used[i] = true;

      // 取出目标空位的坐标
      let [row2, col2] = to[i];

      // ====================
      // 曼哈顿距离:
      // 从出发点到目标点,最少需要走几步
      // 公式:横向距离 + 纵向距离
      // ====================
      const distance = Math.abs(row2 - row1) + Math.abs(col2 - col1);

      // 递归处理下一颗石头
      // 石头编号 +1,总步数 + 当前距离
      dfs(start + 1, step + distance);

      // ====================
      // 回溯核心:撤销选择!
      // 把当前空位释放,让下一颗石头也可以选择这个位置
      // ====================
      used[i] = false;
    }
  }
};

3. 1723. 完成所有工作的最短时间

题目链接leetcode.cn/problems/fi…

题目描述

给定一个整数数组 jobs 和一个整数 k

jobs[i] 是第 i 份工作的耗时。

将所有工作分配给 k 个工人,求完成所有工作的最短时间(即工人中最大耗时的最小值)。

示例

  • 输入:jobs = [3,2,3], k = 3

  • 输出:3

解题思路

  • 大任务优先分配,快速触发剪枝

  • 回溯枚举每一份工作分给哪个工人

  • 记录当前最大耗时

  • 剪枝:当前最大耗时 ≥ 已找到最优解,直接返回

  • 剪枝:相同工作量工人跳过重复递归

代码


/**
 * LeetCode 1723. 完成所有工作的最短时间
 * 题意:把 jobs 分给 k 个工人,求【完成所有工作的最短时间】(即工人最大时间的最小值)
 * 解法:回溯 DFS + 三大剪枝优化
 */
var minimumTimeRequired = function (jobs, k) {
  // 🔥 优化1:大任务优先分配!让剪枝立刻生效,从根源减少递归
  jobs.sort((a, b) => b - a);

  const n = jobs.length; // 工作总数
  let minRes = Infinity; // 最终答案:最小的「最大工作时间」
  const perTime = new Array(k).fill(0); // 记录每个工人当前的工作时间

  // 开始回溯:从第0个工作开始,当前最大时间为0
  dfs(0, 0);

  return minRes;

  // ====================
  // 回溯核心函数
  // start: 当前分配第几个工作
  // maxTime: 当前【所有工人中的最大时间】
  // ====================
  function dfs(start, maxTime) {
    // 终止条件:所有工作分配完毕
    if (start === n) {
      minRes = Math.min(maxTime, minRes);
      return;
    }

    // 🔥 优化2:剪枝!当前已比最优解差,直接放弃这条路径
    if (maxTime >= minRes) return;

    // 遍历所有工人,尝试把当前工作分配给他们
    for (let i = 0; i < k; i++) {
      // 🔥 优化3:重复状态剪枝!
      // 工人时间相同,分配给谁都一样,跳过重复递归
      if (i > 0 && perTime[i] === perTime[i - 1]) continue;

      // 选择:把当前工作给工人i
      perTime[i] += jobs[start];
      // 递归:分配下一个工作,更新最大时间
      dfs(start + 1, Math.max(perTime[i], maxTime));
      // 回溯:撤销选择
      perTime[i] -= jobs[start];
    }
  }
};

4. 2305. 公平分发饼干

题目链接leetcode.cn/problems/fa…

题目描述

给定一个数组 cookies,其中 cookies[i] 是第 i 包饼干的数量。

将所有饼干分给 k 个孩子,求最小的最大值(即拿到最多饼干的孩子,饼干数尽可能小)。

示例

  • 输入:cookies = [8,15,10,20,8], k = 2

  • 输出:31

解题思路

分配问题 = 回溯 + 大的优先 + 剪枝 + 跳过重复

与 1723 完成工作的最短时间完全同模板

代码


/**
 * LeetCode 2305. 公平分发饼干
 * 题意:把饼干数组 cookies 分给 k 个孩子
 * 每个孩子可以分多块
 * 让「拿到最多饼干的孩子」尽可能少
 * 返回:最小的最大值
 *
 * 解法:回溯 DFS + 三大优化
 * 模板和 1723 完全通用!
 */
var distributeCookies = function (cookies, k) {
  // 🔥 优化1:大饼干优先分配!快速剪枝(必须降序!)
  // 你写的 x-y 是升序,改成 y-x 更稳!
  cookies.sort((a, b) => b - a);

  const n = cookies.length; // 饼干总数
  let minRes = Infinity; // 答案:最小的最大饼干数
  const children = new Array(k).fill(0); // 每个孩子当前拥有的饼干

  dfs(0, 0); // 从第0块饼干开始,当前最大值为0

  return minRes;

  // ====================
  // 回溯核心
  // cId: 当前分配第几块饼干
  // maxCookie: 当前孩子中的最大值
  // ====================
  function dfs(cId, maxCookie) {
    // 终止:所有饼干分完了
    if (cId === n) {
      minRes = Math.min(minRes, maxCookie);
      return;
    }

    // 🔥 优化2:剪枝!当前已经比最优差,直接返回
    if (maxCookie >= minRes) return;

    // 尝试分给每一个孩子
    for (let i = 0; i < k; i++) {
      // 🔥 优化3:重复状态剪枝!
      // 两个孩子饼干一样多,分给谁结果一样,跳过重复
      if (i > 0 && children[i] === children[i - 1]) continue;

      // 分配
      children[i] += cookies[cId];
      // 递归
      dfs(cId + 1, Math.max(maxCookie, children[i]));
      // 回溯
      children[i] -= cookies[cId];
    }
  }
};

总结

这 4 道题覆盖了回溯算法最核心的分配与枚举思想

  • 括号删除:枚举删/不删 + 合法性验证

  • 石子移动:曼哈顿距离 + 全排列分配

  • 工作/饼干分配:统一万能模板(排序+双剪枝)