LeetCode 1003. 检查替换后的词是否有效
题目描述
题意分析
从空串出发,每次可以往当前串的任意位置插入一个完整的
abc,问给定的s能不能这样造出来。「往任意位置插入」是正向定义,直接顺着它搜索会爆炸:长度 n 的串需要 n/3 次插入,每次有若干个可选位置,方案数是阶乘量级。所以第一步必须把它反过来读——
s有效,当且仅当反复从s里删掉某个连续的abc子串,最终能把整个串删空。删除是插入的逆操作,两个方向的可达性完全等价。反向之后仍有一个疑问:
s里可能同时存在多个abc,先删哪一个会不会影响最终结果?答案是不会。删掉一个abc只会让它左右两侧变成相邻,不可能破坏别处已经成型的abc(那三个字符要么整体在左侧、要么整体在右侧,不会被拆开)。既然任意删除顺序殊途同归,就可以固定成「从左往右扫,一旦凑出abc立刻删」这种最方便实现的顺序,不需要回溯。约束给的信号:
s只含a、b、c三种字符,长度上限 $2 \times 10^4$,暗示线性或近线性做法即可,同时也提醒答案必须是「一次扫描 + 一个后进先出的暂存结构」这种形态——因为每次删除影响的是最近那几个字符。边界:空串按定义有效(不过题目保证
s非空);长度不是 3 的倍数一定无效,这一点会被主逻辑自然覆盖,不必单独判;开头字符不是a也一定无效,同样会被自然覆盖。
解法:栈模拟消除
核心思路
把构造过程反过来看:插入一个
abc的逆操作,就是删除一个连续的abc。因此,原串有效当且仅当它能通过反复删除abc变成空串。直接在字符串中反复查找、删除会产生重复扫描和搬移。更合适的做法是从左到右扫描,用栈保存当前前缀消除后的残留串。每压入一个字符,只检查栈顶三个字符;若为
abc,就立即弹出。不变量:处理完前
i个字符后,栈自底向上的内容,正是前缀s[0..i)充分消除abc后的残留串,并且栈内不再含有abc。不变量成立的原因是:加入新字符前,栈内已经无法继续消除;加入后若出现新的
abc,它只能以新字符结尾,所以只可能位于栈顶。删除这三个字符后,剩余部分是旧栈的一个前缀,仍然无法继续消除,一次检查就足够。正确性还要覆盖两个方向。若在
x与y之间插入一个完整的abc,栈处理完xabc后与处理完x的状态相同,继续处理y的结果也相同;所以从空串经过任意次插入得到的字符串最终一定栈空。反过来,栈空时每次弹栈都对应一次合法删除,反向执行这些删除就得到一条从空串构造原串的插入序列。因此,栈空与字符串有效等价。
解题步骤
- 准备一个最多容纳
s.length()个字符的栈,并用top表示栈中元素个数。- 依次把当前字符压栈。
- 若栈中至少有 3 个字符,且栈顶向前的三个字符依次为
a、b、c,令top -= 3完成消除。- 扫描结束后返回
top == 0。例如
aabcbc的栈变化为:a -> aa -> aab -> aabc -> a -> ab -> abc -> 空最后栈空,因此有效。反例
abccba在消除开头的abc后还剩cba,无法继续消除,因此无效。长度不是 3 的倍数、首字符不是
a、末尾留下不完整片段等情况,都会自然表现为栈非空,无需额外分支。
代码实现
class Solution {
public boolean isValid(String s) {
char[] stack = new char[s.length()];
int top = 0;
for (int i = 0; i < s.length(); i++) {
stack[top++] = s.charAt(i);
if (top >= 3
&& stack[top - 3] == 'a'
&& stack[top - 2] == 'b'
&& stack[top - 1] == 'c') {
top -= 3;
}
}
return top == 0;
}
}
func isValid(s string) bool {
stack := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
stack = append(stack, s[i])
n := len(stack)
if n >= 3 && stack[n-3] == 'a' && stack[n-2] == 'b' && stack[n-1] == 'c' {
stack = stack[:n-3]
}
}
return len(stack) == 0
}
复杂度分析
- 时间复杂度: $O(n)$。每个字符入栈一次,匹配和弹栈都是常数操作。
- 空间复杂度: $O(n)$。无可消除子串时,栈最多保存全部字符。
关键点总结
- 将“反复插入”转化为等价的“反复删除”,避免枚举插入位置。
- 栈保存的是当前前缀的不可消除残留,这是理解算法和证明正确性的核心不变量。
- 新的可消除子串必然出现在栈顶,因此每次入栈后只需检查最后三个字符。
- 用字符数组模拟栈可避免 Java 中
Character装箱,逻辑也与 Go 切片版本一致。
易错点总结
- 把匹配方向写反: 栈顶是
c,次顶是b,第三个才是a。abccba可暴露把cba错当成可消除串的问题。- 只比较字符数量:
aaabbbccc中三种字符数量相同,但不存在可连续消除的abc,答案是false。- 在原字符串上循环
replace: 结果可能正确,但每轮都要重新扫描和构造字符串,最坏会退化为 $O(n^2)$。- 用消除次数作为答案:
abcab虽能消除一次,但仍残留ab;最终判据必须是栈是否为空。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 1047. 删除字符串中的所有相邻重复项 | 简单 | 消除的模式是「相邻两个相同字符」,只需比对栈顶一个元素,是本题的最简形态 |
| 1209. 删除字符串中的所有相邻重复项 II | 中等 | 消除长度变成任意 k,栈里要额外存计数,不能再靠固定长度比对 |
| 20. 有效的括号 | 简单 | 消除的是配对而非固定串,栈里存的是「等待被闭合的左括号」 |
| 1544. 整理字符串 | 简单 | 消除条件变为「相邻两字符同字母异大小写」,判据从等值改成位运算 |
| 735. 小行星碰撞 | 中等 | 消除是带方向和大小的三种结局,栈顶可能连续弹多个,且当前元素也可能被销毁 |
| 1190. 反转每对括号间的子串 | 中等 | 栈里保存的不是字符而是待拼接的片段,出栈时做的是反转与合并而非丢弃 |
| 394. 字符串解码 | 中等 | 双栈分别保存倍数与前缀,出栈时要按倍数展开,是消除思路的构造版 |