使字符串平衡的最少删除次数 图解题解
这道题到底在问什么
- 输入
- s="aababbab"
- 输出
- 2 (删 2 个字符即可平衡)
- 输入
- s="bbaaaaabb"
- 输出
- 2 (删掉最前面的两个 b)
- 输入
- s="aaaa"
- 输出
- 0 (已经平衡,无需删)
最优解:为什么这么做
一句话答案: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 前面时才需要删。
▶ 动画逐步走查(共 21 步)——想跟着动画一帧帧对照就展开
- 3记住这套「见 b 就 b+1、见 a 就 dp = min(dp+1, b)」,下面每一帧都在套它。这里 b 是「已扫描到的 b 总数」,不是最终保留多少 b。
- 4开局:还没扫任何字符,已扫描到的 b 总数 b=0,最少删除数 dp=0。指针停在第 0 个字符 b 上准备处理。
- 5看第 0 个字符,是 b。先把已扫描的 b 总数计上一笔,后面 a 要不要删它再说。
- 6这一步只把已扫描的 b 总数加 1,记到 1 个,本步不产生删除代价。dp 不变,仍是 0。是否要删掉这些 b,留到后面遇到 a 时再权衡。
- 7看第 1 个字符,是 b。先把已扫描的 b 总数计上一笔,后面 a 要不要删它再说。
- 8这一步只把已扫描的 b 总数加 1,记到 2 个,本步不产生删除代价。dp 不变,仍是 0。是否要删掉这些 b,留到后面遇到 a 时再权衡。
- 9看第 2 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
- 10比较两条路:删掉这个 a 花 1,删掉已扫描的 2 个 b 花 2。这一帧「删这个 a」更省(1 < 2),临时标红示意。dp 只记录最少代价 1,并不锁定最终一定删它,后面若有更优方案仍会改写。
- 11看第 3 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
- 12比较两条路:删掉这个 a 花 2,删掉已扫描的 2 个 b 花 2。两条路打平(2 = 2),任选一种都行;这里临时按删这个 a 标红示意。dp 取这个最少代价 2,并不锁定最终一定删它,后面若有更优方案仍会改写。
- 13看第 4 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
- 14比较两条路:删掉这个 a 花 3,删掉已扫描的 2 个 b 花 2。这一帧「删前面的 b」更省(2 < 3),当前 a 暂按保留标绿示意。dp 只记录最少代价 2,不锁定最终删除路径。
- 15看第 5 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
- 16比较两条路:删掉这个 a 花 3,删掉已扫描的 2 个 b 花 2。这一帧「删前面的 b」更省(2 < 3),当前 a 暂按保留标绿示意。dp 只记录最少代价 2,不锁定最终删除路径。
- 17看第 6 个字符,是 a。a 想合法留在前段,就得删掉已扫描到的所有 b;要么干脆删掉这个 a。
- 18比较两条路:删掉这个 a 花 3,删掉已扫描的 2 个 b 花 2。这一帧「删前面的 b」更省(2 < 3),当前 a 暂按保留标绿示意。dp 只记录最少代价 2,不锁定最终删除路径。
- 19看第 7 个字符,是 b。先把已扫描的 b 总数计上一笔,后面 a 要不要删它再说。
- 20这一步只把已扫描的 b 总数加 1,记到 3 个,本步不产生删除代价。dp 不变,仍是 2。是否要删掉这些 b,留到后面遇到 a 时再权衡。
- 21看第 8 个字符,是 b。先把已扫描的 b 总数计上一笔,后面 a 要不要删它再说。
- 22这一步只把已扫描的 b 总数加 1,记到 4 个,本步不产生删除代价。dp 不变,仍是 2。是否要删掉这些 b,留到后面遇到 a 时再权衡。
- 23全程扫完,dp 收在 2,这就是最少删除次数。它只给出代价数字,不指定唯一删法;对 "bbaaaaabb" 来说,删掉开头那两个 b 得到 "aaaaabb" 是其中一种达到 2 次的平衡方案。
⚠️ 容易写错的地方
✗ 错:遇到 b 时也去更新 dp
✓ 对:只让已扫描的 b 计数自增,dp 不动
这一步本身不删任何字符,代价为 0,是否删这些 b 等遇到 a 再算
✗ 错:遇到 a 只想着删这个 a
✓ 对:还要和「删掉已扫描的所有 b」比,取较小
已扫描的 b 很少时,删光那几个 b 比一路删 a 更省
✗ 错:把平衡理解成 a、b 个数相等
✓ 对:平衡是「a 全在 b 左边」,与个数无关
"aaab" 已平衡,答案是 0,跟数量是否相等无关
完整代码(Python / C++ / Java)
Python
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 dpC++
#include <algorithm>
#include <string>
using namespace std;
class Solution {
public:
int minimumDeletions(string s) {
int b = 0, dp = 0;
for (char c : s) {
if (c == 'b') b++;
else dp = min(dp + 1, b);
}
return dp;
}
};Java
import java.util.*;
class Solution {
public int minimumDeletions(String s) {
int b = 0, dp = 0;
for (char c : s.toCharArray()) {
if (c == 'b') b++;
else dp = Math.min(dp + 1, b);
}
return dp;
}
}复杂度
时间
O(n)
从头到尾扫一遍字符串
空间
O(1)
只用 b、dp 两个整数变量
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 使字符串平衡的最少删除次数 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
为什么「删前面所有 b」的代价就等于当前的 b,而删这个 a 只加 1?+
b 一路如实累计「已经扫过的 b 有多少个」,所以当前这个 a 前面正好积了 b 个 b,把它们全删掉代价就是 b。而删掉这个 a 只加 1,是因为它之前那段前缀该删多少已经全部结算进 dp,这一步只是在旧账 dp 上多删一个字符。两条路一个删前面的 b、一个删当前这个 a,互不重叠,取 min 就是到此为止的最少删除数。
这道题和求「最长的 a…b 平衡子序列」有什么关系?+
是一体两面。让串平衡最少删 k 个字符,等价于保留最多 n-k 个字符使其平衡,而保留下来的最长「若干 a 接若干 b」正是一个平衡子序列。所以最少删除数 = n 减去最长平衡子序列长度。本题的 dp 递推直接算删除数,省去了先求最长再相减那一步,一遍扫描就出结果。
为什么遇到 b 时 dp 一定不变,就算这个 b 最后要被删也不在这一步扣代价?+
因为 b 到底删不删,要等它右边出现 a 时才知道。一个 b 只有在「它右边还想保留 a」时才碍事、才需要删;扫到它的当下右边什么都还没看到,无法判断。所以遇到 b 只做一件事——把它记进 b 的计数,把「要不要为它付删除代价」的决定推迟到后面遇到 a 时,由 dp=min(dp+1, b) 一次性权衡。这也是为什么 b 记的是「路过的 b 总数」而非最终保留数。LeetCode 926 将字符串翻转到单调递增:把 a/b 换成 0/1 就是同一条递推。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 使字符串平衡的最少删除次数 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。