题目描述
思路解析
一句话答案:LeetCode 1653 使字符串平衡的最少删除次数:扫一遍字符串,见 b 就累计 b 的个数、见 a 就取 dp=min(dp+1, b),在删这个 a 和删前面所有 b 之间取小。时间 O(n)、空间 O(1)。
「平衡」到底要求字符串长成什么样
给一个只含 a 和 b 的字符串 s,删最少的字符,让删完后不存在任何一个 b 排在某个 a 的左边,也就是整个串成「先一串 a,再一串 b」的形状(某段为空也算)。例如 s="bbaaaaabb",删掉开头两个 b 得 "aaaaabb" 恰好平衡,删了 2 个;s="aaaa" 本就平衡,一个都不用删。
为什么不能把「删哪些字符」逐种试过去
一个长度 n 的串,每个字符删或不删,删法共 2ⁿ 种,n 到几十就数不完,逐种试再检查显然不行。突破口是:平衡串被限定成「a 在前、b 在后」,所以从左往右扫时,每个字符要不要留,只取决于「到目前已付出多少删除代价」和「前面积攒了几个 b」这两个量,不必回头重算。
扫到一半时,只要记住哪两个数
顺着串从左扫到右,只要维护两个数。第一个是 dp,存的是「到当前字符为止、让已扫过的这段前缀变平衡,最少得删几个字符」;把这个前缀的答案一路记下来、下个字符接着用它推新值,正是动态规划。第二个是 b:已经扫过的 b 的总个数,不是最终保留几个 b。开局两个都是 0。
遇到一个 a,是删它自己还是删它前面所有 b
扫到的字符是 b 时好办:b 只可能待在后段,先记进「已扫描的 b 总数」,本步不产生删除代价,dp 原封不动。要做决定的是扫到 a 时。这个 a 想留在前段,就得让它左边一个 b 都不剩,于是只有两条路:删掉它前面已扫到的全部 b,代价是 b——选这条路等于留这个 a 和前面所有 a、只删所有 b,本身就是这段前缀的完整方案,代价恰是 b、与旧 dp 无关;或把这个 a 自己删掉,代价是原来 dp 再加 1,即 dp+1。哪条省走哪条,所以 dp=min(dp+1, b)。
这一步的 min 才是全题做决定的地方(min 取两者中较小):dp+1 是「留前面的 b、牺牲这个 a」,b 是「留这个 a、清掉前面所有 b」。
拿 s="bbaaaaabb" 把 dp 一步步逼出来
起手 b=0、dp=0。先是两个 b:b 依次记到 1、2,dp 一直是 0。接着五个 a:第一个 a,dp=min(dp+1, b)=min(0+1, 2)=1,删自己更省;第二个 a,dp=min(1+1, 2)=2,两条打平;第三个 a,dp=min(2+1, 2)=2,这回删前面两个 b 更省;第四、第五个 a 都是 min(3, 2)=2,dp 稳在 2。最后两个 b:b 记到 3、4,dp 不动。扫完 dp=2 即答案,对应删掉开头那两个 b。
全 a 或全 b 的串,答案为什么直接是 0
有人把「遇到 b」也当成付代价的一步,急着扫到 b 就删,dp 就被算大了;其实遇到 b 只是计数、零代价,dp 一个字都不改,删不删要留到后面遇到 a 才结算。复杂度很轻:从头到尾只扫一遍,时间 O(n)(大 O 记号,描述规模变大时操作数怎么涨,n 是串长);只用 b、dp 两个整数,空间 O(1)(占用和串长无关)。
边界也顺带想清:单个字符、全是 a、全是 b,这三种串本身就平衡,dp 没机会变大,答案都是 0;只有当某个 b 真挡在某个 a 前面时才需要删。
▶ 动画逐步走查(文字版)——想跟着上方动画一帧帧对照就展开
记住这套「见 b 就 b+1、见 a 就 dp = min(dp+1, b)」,下面每一帧都在套它。这里 b 是「已扫描到的 b 总数」,不是最终保留多少 b。
开局:还没扫任何字符,已扫描到的 b 总数 b=0,最少删除数 dp=0。指针停在第 0 个字符 b 上准备处理。
看第 0 个字符,是 b。先把已扫描的 b 总数计上一笔,后面 a 要不要删它再说。
这一步只把已扫描的 b 总数加 1,记到 1 个,本步不产生删除代价。dp 不变,仍是 0。是否要删掉这些 b,留到后面遇到 a 时再权衡。
看第 1 个字符,是 b。先把已扫描的 b 总数计上一笔,后面 a 要不要删它再说。
这一步只把已扫描的 b 总数加 1,记到 2 个,本步不产生删除代价。dp 不变,仍是 0。是否要删掉这些 b,留到后面遇到 a 时再权衡。
看第 2 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
比较两条路:删掉这个 a 花 1,删掉已扫描的 2 个 b 花 2。这一帧「删这个 a」更省(1 < 2),临时标红示意。dp 只记录最少代价 1,并不锁定最终一定删它,后面若有更优方案仍会改写。
看第 3 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
比较两条路:删掉这个 a 花 2,删掉已扫描的 2 个 b 花 2。两条路打平(2 = 2),任选一种都行;这里临时按删这个 a 标红示意。dp 取这个最少代价 2,并不锁定最终一定删它,后面若有更优方案仍会改写。
看第 4 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
比较两条路:删掉这个 a 花 3,删掉已扫描的 2 个 b 花 2。这一帧「删前面的 b」更省(2 < 3),当前 a 暂按保留标绿示意。dp 只记录最少代价 2,不锁定最终删除路径。
看第 5 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
比较两条路:删掉这个 a 花 3,删掉已扫描的 2 个 b 花 2。这一帧「删前面的 b」更省(2 < 3),当前 a 暂按保留标绿示意。dp 只记录最少代价 2,不锁定最终删除路径。
看第 6 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
比较两条路:删掉这个 a 花 3,删掉已扫描的 2 个 b 花 2。这一帧「删前面的 b」更省(2 < 3),当前 a 暂按保留标绿示意。dp 只记录最少代价 2,不锁定最终删除路径。
看第 7 个字符,是 b。先把已扫描的 b 总数计上一笔,后面 a 要不要删它再说。
这一步只把已扫描的 b 总数加 1,记到 3 个,本步不产生删除代价。dp 不变,仍是 2。是否要删掉这些 b,留到后面遇到 a 时再权衡。
看第 8 个字符,是 b。先把已扫描的 b 总数计上一笔,后面 a 要不要删它再说。
这一步只把已扫描的 b 总数加 1,记到 4 个,本步不产生删除代价。dp 不变,仍是 2。是否要删掉这些 b,留到后面遇到 a 时再权衡。
全程扫完,dp 收在 2,这就是最少删除次数。它只给出代价数字,不指定唯一删法;对 "bbaaaaabb" 来说,删掉开头那两个 b 得到 "aaaaabb" 是其中一种达到 2 次的平衡方案。
边界先想清:单字符、全 a、全 b 都是 0;只有 b 真正挡住了 a 才需删。
两个高频追问:为什么贪心最优、以及如何推广到多字符。
参考代码
class Solution: def minimumDeletions(self, s: str) -> int: b = dp = 0 for ch in s: if ch == 'b': b += 1 else: dp = min(dp + 1, b) return dp复杂度
- 时间:O(n),从头到尾扫一遍字符串
- 空间:O(1),只用 b、dp 两个整数变量
易错点
面试追问把动画讲成自己的话
追问这道题为什么贪心(直接 dp = min(dp+1, b))就一定最优,不会漏更好的方案?
追问如果字符不止 a、b 两种,思路还能用吗?
这道题到这就讲完了。动画和文字是同一套思路——别光看,关掉页面自己默写一遍。然后顺着主线继续:
经过一次操作后的最大子数组和
LeetCode 1746 · 中等 · 沿着 动态规划套路 继续往下推进
把这道题真正学会,再走
图解算法年卡 ¥99 /年
- ✓本题每步动画的吴师兄语音讲解(全站陆续覆盖)
- ✓小欧带 8 步通关训练——追问到不看答案也能写对、能 30 秒讲给面试官听
- ✓学习报告,记录每道题的掌握程度
76k+ GitHub Star · 吴师兄开源图解算法,几十万开发者在看的算法讲解
想成体系刷透这类套路?去图解算法专题