回溯算法实战练习(3)

回溯算法实战练习(3)

前言

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


1. 301. 删除无效的括号

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

题目描述

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

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

示例

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

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

  • 输入:s = ")("

  • 输出:[""]

解题思路

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

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

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

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

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

  • DFS 枚举删 / 不删

    • 从左到右遍历每个字符

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

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

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

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

  • 终止条件

    • 遍历完整个字符串

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

    • 最终字符串括号合法

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

  • 去重

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

代码

JavaScript 复制代码
/**
 * 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

解题思路

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

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

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

代码

JavaScript 复制代码
/**
 * 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

解题思路

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

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

  • 记录当前最大耗时

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

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

代码

JavaScript 复制代码
/**
 * 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 完成工作的最短时间完全同模板

代码

JavaScript 复制代码
/**
 * 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 道题覆盖了回溯算法最核心的分配与枚举思想

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

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

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

相关推荐
HZ·湘怡17 分钟前
二叉树 2 堆
算法
用户新2 小时前
JS事件深度解析四 事件的循环和异步
前端·javascript·事件·event loop
wabs6668 小时前
关于贪心算法的思考
算法·贪心算法
社交怪人8 小时前
【判断大小】信息学奥赛一本通C语言解法(题号1043)
算法
Snasph8 小时前
GNU Make 用户手册(中文版)
服务器·算法·gnu
江澎涌8 小时前
拆解与 AI 的一次对话
人工智能·算法·程序员
sheeta19989 小时前
LeetCode 每日一题笔记 日期:2026.06.02 题目:3635. 最早完成陆地和水上游乐设施的时间 II
笔记·算法·leetcode
Lsk_Smion9 小时前
力扣实训 _ [102].层序遍历--前序--后续_递归与非递归的实现
数据结构·算法·leetcode
大鸡腿同学9 小时前
AI 知识库搜索不准?问题出在分块
后端
夕颜11110 小时前
Multica 使用心得介绍
后端