题目
给你一个字符串 s ,它仅包含字符 'a' 和 'b' 。
你可以删除 s 中任意数目的字符,使得 s 平衡 。当不存在下标对 (i,j) 满足 i < j ,且 s[i] = 'b' 的同时 s[j]= 'a' ,此时认为 s 是 平衡 的。
请你返回使 s 平衡 的 最少 删除次数。
示例 1:
输入:s = "aababbab" 输出:2 解释:你可以选择以下任意一种方案: 下标从 0 开始,删除第 2 和第 6 个字符("aababbab" -> "aaabbb"), 下标从 0 开始,删除第 3 和第 6 个字符("aababbab" -> "aabbbb")。
示例 2:
输入:s = "bbaaaaabb" 输出:2 解释:唯一的最优解是删除最前面两个字符。
提示:
1 <= s.length <= 10^5s[i]要么是'a'要么是'b' 。
解题思路
题目要求字符串变得“平衡”。“平衡”的定义是不存在 b 在 a 之前。
这意味着,处理后的字符串必须长成这个样子:
$$A...AB...B$$
即:前面全是 'a'(也可以没有),后面全是 'b'(也可以没有)。
我们有两种主要的解题思路:
思路一:枚举分割点(前缀和/后缀和思想)
这是最直观的思路。既然最终结果一定是 aaaa...abbbb... 的形式,我们可以想象在字符串中画一条竖线,竖线左边必须全是 'a',竖线右边必须全是 'b'。
逻辑步骤:
- 分割线位置: 我们遍历字符串的每一个缝隙(包括最左边和最右边),假设这个缝隙就是
a和b的分界线。 - 计算代价: 对于每一个分割点:
- 左边部分: 需要删掉所有的 'b'。
- 右边部分: 需要删掉所有的 'a'。
- 当前代价 = (左边的 'b' 个数) + (右边的 'a' 个数)。
- 优化: 如果暴力计算,每次都要遍历左右,复杂度是 $O(N^2)$。但我们可以预处理:
- 先遍历一遍统计出总共有多少个 'a' (记为
total_a)。 - 再遍历一遍字符串,维护一个变量
count_b(当前左边遇到的 'b' 个数)。 - 此时,右边的 'a' 个数 =
total_a-count_a(当前遇到的 'a' 个数)。 - 或者更简单:右边的 'a' 个数 =
total_a- (当前位置下标 - 当前count_b)? 不如直接维护右边剩余的 'a'。
- 先遍历一遍统计出总共有多少个 'a' (记为
算法流程 ($O(N)$):
- 统计整个字符串中 'a' 的总数,赋值给
right_a。 - 初始化
left_b = 0。 - 初始化
min_deletions = right_a(相当于分割线在最左边,把所有 'a' 全删了,全保留 'b')。 - 遍历字符串
s中的每个字符c:- 如果
c == 'a':说明这个 'a' 从分割线右边跑到了左边(被划入左半区),那么right_a减 1。 - 如果
c == 'b':说明这个 'b' 留在了分割线左边,需要被删除,left_b加 1。 - 每次迭代更新:
min_deletions = min(min_deletions, left_b + right_a)。
- 如果
- 返回
min_deletions。
思路二:动态规划 / 贪心 (最优解法)
这个思路更符合算法直觉,通常代码更简洁。我们可以把问题看作:“当我们遍历到第 i 个字符时,如果要使 s[0...i] 平衡,最少需要删几次?”
定义两个变量:
count_b:目前为止遇到的 'b' 的数量。dp:目前为止使字符串平衡的最少删除次数。
状态转移逻辑: 当我们遍历到一个新字符 c 时:
- 如果
c == 'b':- 在这个 'b' 之前如果已经是平衡的,加上这个 'b' 依然平衡(因为 'b' 可以接在 'a' 或 'b' 后面)。
- 我们不需要删除它。
- 操作:
count_b加 1,dp不变。
- 如果
c == 'a':- 这就出问题了。如果前面有 'b',这个 'a' 就破坏了平衡。我们有两个选择:
- 选项 A(删当前): 把这个刚进来的 'a' 删掉。代价是之前的最小代价
dp+ 1。 - 选项 B(保留当前): 如果我们要保留这个 'a',那意味着它前面不能有任何 'b'。所以我们要把前面所有的 'b' 都删掉。代价是
count_b。
- 选项 A(删当前): 把这个刚进来的 'a' 删掉。代价是之前的最小代价
- 决策: 我们取两者的最小值。
- 操作:
dp = min(dp + 1, count_b)。
- 这就出问题了。如果前面有 'b',这个 'a' 就破坏了平衡。我们有两个选择:
举例 bbaaaaabb:
b: count_b=1, dp=0b: count_b=2, dp=0a: 遇到 'a'。删它(dp=1) vs 删前面的b(count_b=2)。取小 -> dp=1。a: 遇到 'a'。删它(dp=1+1=2) vs 删前面的b(count_b=2)。取小 -> dp=2。a: 遇到 'a'。删它(dp=2+1=3) vs 删前面的b(count_b=2)。取小 -> dp=2。- ... 后面都是最优解保持 2。
这个思路相当于在比较:是把当前这个 'a' 视为“必须要删掉的噪音”,还是发现前面的 'b' 才是“噪音”。
具体代码
解法一
func minimumDeletions(s string) int {
// 1. 先统计整个字符串中 'a' 的总数
rightA := 0
for _, char := range s {
if char == 'a' {
rightA++
}
}
// 初始状态:分割点在字符串最左侧
// 需要删除的只有右边的所有 'a'(左边为空,没有 'b' 需要删)
minDel := rightA
leftB := 0
// 2. 遍历字符串,模拟分割点向右移动
for _, char := range s {
if char == 'a' {
// 如果当前字符是 'a',它从分割线右边移到了左边
// 它是合法的(左边允许有 'a'),所以不需要删除了
// 右边剩余的 'a' 减少一个
rightA--
} else {
// 如果当前字符是 'b',它从分割线右边移到了左边
// 它是非法的(左边不能有 'b'),所以必须删除
// 左边需要删除的 'b' 增加一个
leftB++
}
// 计算当前分割点的总删除次数 = 左边删掉的 'b' + 右边删掉的 'a'
// 并更新全局最小值
if cost := leftB + rightA; cost < minDel {
minDel = cost
}
}
return minDel
}
解法二
func minimumDeletions(s string) int {
bCount := 0
dp := 0 // dp[i] 的空间优化版,表示处理到当前字符时的最小删除次数
for _, char := range s {
if char == 'b' {
// 遇到 'b',对平衡性没有直接破坏('b' 可以放在最后)
// 只是增加了潜在的“如果后面出现 'a' 需要删除前面所有 'b'”的代价
bCount++
} else {
// 遇到 'a',出现冲突
// 决策 1: 删除当前的 'a' -> 代价是 dp + 1
// 决策 2: 保留当前的 'a' -> 意味着前面所有的 'b' 都得删掉,代价是 bCount
// 取两者的最小值
if dp + 1 < bCount {
dp = dp + 1
} else {
dp = bCount
}
}
}
return dp
}