先说点题外话。我第一眼看到《从字符串中移除星号》这个标题时,心想这不是送分题吗,把 * 全删了不就行了。结果仔细读题才发现完全不是那么回事——这道题在 LeetCode 上的编号是 2390,难度中等,考点不是“删除”,而是“最近”这两个字。我当时用最朴素的模拟法去写,遇到星号就往前找字符并标记删除,样例能过,提交直接超时,那一刻才意识到这题得换思路。
这篇文章我准备把这题讲透:读题时有哪些坑、为什么遇到“最近”就该想到栈、除了常规栈解法还有哪几种更漂亮的写法、以及它和同类题(退格键、相邻重复项删除)到底是什么关系。不管你是刚开始刷力扣的新手,还是正在按专题刷栈的老手,这篇应该都能给你一些值得抄走的经验。
1. 读题最容易踩的坑:“左边最近”到底是什么意思
1.1 我第一遍读题时的错误理解
题目描述大致是这样的:给你一个包含若干星号 * 的字符串,每次操作可以任意选择一个星号,删除它左边最近的非星号字符,同时删除这个星号本身,重复操作直到字符串中不存在星号,返回最终剩下的字符串。
我第一遍把“左边最近的非星号字符”看成了“左边的第一个字符”,心想那还不简单,遇到 * 就把前一个字符删掉。结果拿样例一跑就发现不对。字符串 leet**cod*e,如果照着“删前一个字符”的逻辑去写,删除顺序和结果是完全对不上的。所以读题时一定要咬文嚼字:“非星号字符”意味着星号左边可能紧挨着另一个星号,这时候你不能去删那个星号,得继续往左找,直到找到一个真正的普通字符。
这个细节决定了模拟代码的写法。如果你真的去模拟“往左找最近的非星号字符”,每删一次都要维护一堆“已删除标记”,还要处理连续星号的情况,代码写出来又长又容易错,时间复杂度还不理想。
1.2 用一个例子把操作顺序推演清楚
我们拿 leet**cod*e 完整走一遍。
初始字符串:leet**cod*e,一共有 6 个普通字符(l e e t c o d e 是 8 个?数一下:l、e、e、t、c、o、d、e,一共 8 个)和 3 个星号。
- 选择第一个星号(从左数第 5 个字符)。它左边紧挨着的是另一个星号,不是普通字符,继续往左,找到
t。删除t和这个星号,字符串变成lee*cod*e。 - 字符串现在是
lee*cod*e,还剩两个星号。选左边的那个星号,它左边最近的普通字符是e(原字符串第三个字符),删除它俩,变成lecod*e。 - 最后只剩一个星号,左边最近的普通字符是
d,删除后得到lecoe。
注意一个很容易被忽略的性质:每一步可以选择任意一个星号,但无论你怎么选,最终结果都一样。原因很简单,每次操作本质上是让一个星号和它左边当前仍然存活的最近普通字符配对抵消。星号只吃“还没被吃掉的”左边字符,不会影响其他星号的配对对象,所以操作顺序不改变结果。这个性质后面两类优化解法都用得上,建议先记住。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 栈解法:为什么“最近”这个词一出现就该想到栈
2.1 从“最近”到“栈顶”的翻译
栈这种数据结构最大的特点就是“后进先出”——你永远只能操作最近放进去的那个元素。题目要求的“左边最近的非星号字符”,翻译过来就是“我已经读过的、还没被删除的字符里,最靠右的那个”。从左往右遍历字符串,每读到一个普通字符就暂时放进一个容器;每遇到一个星号,就从容器里把最近放进去的那个字符拿出来删掉。先进去的垫底,后进去的先被删,这不就是标准的栈行为吗?
所以这题的核心洞察就一句话:星号是一个“弹出指令”,普通字符是“压栈元素”。把规则翻译到数据结构上,代码自然就出来了。
2.2 三种语言的实现与细节
C++ 最推荐直接用 string 当栈用。string 本身就是可变容器,push_back、pop_back 都是现成的,而且最后直接返回这个 string,连拼接都省了。
cpp复制string removeStars(string s) {
string st;
for (char c : s) {
if (c == '*') {
if (!st.empty()) st.pop_back();
} else {
st.push_back(c);
}
}
return st;
}
Python 用 list 实现,最后 join 一下:
python复制def removeStars(self, s: str) -> str:
st = []
for c in s:
if c == '*':
if st:
st.pop()
else:
st.append(c)
return ''.join(st)
Java 用 StringBuilder,因为 String 是不可变的,直接拼接删除会产生大量临时对象:
java复制public String removeStars(String s) {
StringBuilder st = new StringBuilder();
for (char c : s.toCharArray()) {
if (c == '*') {
st.deleteCharAt(st.length() - 1);
} else {
st.append(c);
}
}
return st.toString();
}
三个细节想提醒一下。第一,判空不是必须的,因为题目保证每个星号执行时左边都存在可删除字符,但写上 if (!st.empty()) 会更稳,防止你拿这题模板去改造成其他输入时崩掉。第二,C++ 里没必要用 vector<char> 再转 string,多此一举。第三,Java 里别用 StringBuffer,单线程场景 StringBuilder 更快,这是老生常谈但真有人不在意。
2.3 为什么栈解法是 O(n) 而不是 O(n²)
很多新手会写出这种代码:遇到 *,就往前线性扫描,找一个没被删过的字符,标记删除。每次查找最坏要扫 O(n) 个位置,总体复杂度退化到 O(n²),数据量一到 10^5 级别就肯定超时。
栈解法的高明之处在于:它把“查找左边最近存活字符”这个操作从 O(n) 降到了 O(1)。栈顶天然就是“最近存活”的那个字符,每个字符至多入栈一次、出栈一次,整体时间复杂度 O(n)。这道题的核心考点就在这里——不是考你会不会模拟,而是考你有没有能力把“最近”这个语义翻译成栈操作。
3. 不建栈也能做:逆序遍历加星号计数,空间复杂度直接归零
3.1 换个方向看问题
栈解法已经够简洁了,但如果面试官追问“能不能不用额外栈”,还有一个很漂亮的思路:从右往左遍历。
为什么从右往左?因为星号只会吃掉它左边的字符。从右往左看时,每遇到一个星号,它就像一个“欠账”:它欠左边一个普通字符,会吃掉左边遇到的第一个还没被吃掉的普通字符。我们只需要维护一个计数器 star,遇到 * 就加一,表示积压了一个删除需求;遇到普通字符时,如果 star > 0,说明这个字符已经被右边的某个星号预订了,直接跳过;否则说明它是安全的,保留下来。
3.2 正确性直觉与代码实现
python复制def removeStars(self, s: str) -> str:
star = 0
ans = []
for c in reversed(s):
if c == '*':
star += 1
elif star > 0:
star -= 1
else:
ans.append(c)
return ''.join(ans[::-1])
因为是从右往左收集答案,最后必须反转一次,这一步千万别漏。
为什么计数器够用,不需要记录具体星号位置?还是因为那个“操作顺序不影响结果”的性质。从右往左扫到某个普通字符时,它右边所有还没被抵消的星号都想吃它,它就是这些星号眼中“最近的候选”。所以只要计数器的值大于零,这个普通字符一定会被吃掉,至于被哪颗星号吃,根本不重要。这道题所以能用这么短的代码解决,本质上就是因为“配对关系”可以被压缩成一个数字。
3.3 空间复杂度到底怎么算
严格来说,这个解法还是要 O(n) 空间,因为答案字符串本身需要存储。但它的“额外空间”是 O(1),栈解法则是额外 O(n)。面试时这样说会显得你很严谨:栈解法需要 O(n) 的辅助栈空间,逆向计数法只需要 O(1) 辅助空间,两者时间都是 O(n)。如果题目要求原地修改输入字符串,那就得看下一节的写法了。
4. 面试加分项:原地双指针写法,把字符串当成栈来维护
4.1 核心思想:一个指针当栈顶,一个指针遍历
第三种写法是原地修改输入字符串,把字符串的前半部分当成栈来用。定义慢指针 j 表示当前“栈”的长度;快指针 i 遍历整个字符串。遇到普通字符,就把字符写到 s[j] 位置,然后 j++;遇到星号,就让 j--,相当于把栈顶弹出。遍历结束后,s[0..j-1] 就是最终答案。
cpp复制string removeStars(string s) {
int j = 0;
for (char c : s) {
if (c == '*') {
if (j > 0) --j;
} else {
s[j++] = c;
}
}
return s.substr(0, j);
}
这个写法的妙处在于:入栈变成了覆盖写,出栈变成了指针回退。因为栈里的元素本来就会保留在字符串前面,后面的旧字符不需要特意清理,最后 substr(0, j) 截断即可。整个算法只用了一个 int 变量,额外空间 O(1)。
4.2 边界条件:j 会不会变成负数
一个常见的担心是:如果字符串开头就是 *,j = 0 时还执行 --j,会不会越界?答案是不可能。题目保证输入合法,每个星号执行时左边都存在可删除的非星号字符,这意味着任意时刻“有效前缀”里普通字符的数量一定不少于已经遇到的需要被删除的字符数量。所以 j 永远不会小于 0。但代码里保留 if (j > 0) 有备无患,不影响效率。
4.3 三种写法怎么选,以及面试节奏
我建议面试时这样安排:
| 解法 | 思路 | 时间复杂度 | 额外空间 | 代码量 | 适合场景 |
|---|---|---|---|---|---|
| 栈解法 | 模拟压栈弹栈 | O(n) | O(n) | 少 | 笔试、讲清楚原理 |
| 逆序计数法 | 星号计数器抵消 | O(n) | O(1) | 最少 | 追求简洁、展示对题意的理解 |
| 原地双指针 | 快慢指针模拟栈 | O(n) | O(1) | 中等 | 面试追问“能否 O(1) 空间”时展示深度 |
不要一上来就写双指针。面试的常规节奏是:先给栈解法,讲清楚“为什么最近对应栈顶”,如果面试官追问空间能不能省,再拿出逆序计数法或者双指针。一上来就写最花哨的解法,反而容易把自己绕晕,还会让人怀疑你是不是背了模板但没吃透。
5. 同类题与变种:把“删星号”推广到“删k个”、“删相邻重复项”
5.1 变种一:把星号换成退格键
LeetCode 844《比较含退格的字符串》和这题几乎是一个妈生的:遇到 # 就删掉左边的字符,只不过退格键本身不删除自己,而且它可以连续出现(比如连按三次退格)。处理套路完全一致:从左往右用栈,或者从右往左用计数器。区别只在于触发删除的字符不同、是否删除触发字符本身。
如果你把这两题放在一起刷,会发现“最近删除”这类题的肌肉记忆很容易建立:遇到“左边最近”“退格”“撤销”这些词,第一反应就应该是栈或者计数器。
5.2 变种二:每次删除左边第 k 个字符
如果题目改成“每个星号删除它左边第 k 个未删除的字符”,栈解法立刻失效,因为要删的不是栈顶,而是栈中深度为 k 的那个节点。这时候就需要更高级的数据结构:可以拿链表维护存活字符,配合并查集快速找到“当前第 k 个存活位置”,或者用平衡树做删除。复杂度能做到 O(n log n),但代码量会明显上升。这种变种在真实面试里不太会出现,但了解它能帮你理解:为什么“删除最近”和“删除第 k 个”的难度完全不同——前者用栈天然契合,后者本质上是一个动态序列的随机删除问题。
5.3 和“删除所有相邻重复项”的对比
LeetCode 1047 是另一道经典的栈题:删除字符串中所有相邻重复项,一次消除一对相邻且相同的字符,直到不能再消。它和本题都只需要 O(n) 时间和栈,但具体规则不一样,放在一起对比效果很好:
| 对比项 | 从字符串中移除星号 | 删除所有相邻重复项 |
|---|---|---|
| 触发删除的条件 | 遇到 * |
当前字符与栈顶相同 |
| 被删除的对象 | 栈顶字符 + 星号本身 | 栈顶字符 + 当前字符 |
| 是否依赖其他信息 | 不依赖 | 不依赖 |
| 处理后的顺序 | 保留原相对顺序 | 保留原相对顺序 |
| 底层套路 | 把规则翻译成栈顶操作 | 把规则翻译成栈顶操作 |
这两题的共同点:规则都只涉及“最近”的元素,所以都只需要看一眼栈顶就能决定是入栈还是弹出。理解了这一层,你就不会觉得栈题是靠背套路,而是靠翻译规则。
5.4 如果输入是流式的怎么办
栈解法其实天然支持流式输入:读一个字符就处理一个字符,任何时候想要当前结果,栈里的内容就是答案。这比双指针更灵活,因为双指针需要随机访问整段字符串。所以“省空间”和“可流式”经常是二选一,真正上手时要根据场景选。如果字符串特别长,无法一次性读入,栈解法反而是更合理的选择。
6. 刷题复盘:怎么判断一道题该用栈,以及我的两道防线
6.1 遇到这些信号,优先想栈
刷题多了以后,我总结出下面这些“栈信号”:
- 题目里出现“最近”“相邻”“上一个”“最靠右”这类位置限定词;
- 操作是配对、抵消、撤销、回退;
- 需要逆序处理某个序列;
- 规则只跟当前末尾元素有关;
- 括号匹配、函数调用、浏览器后退、文本撤销这类场景。
如果一道题同时命中两条以上,基本可以放心往栈的方向想。这题命中了“最近”和“抵消删除”两条,所以信号非常强。
6.2 我见过的翻车代码,给你排排雷
这次刷题我看到评论区里常见的错误,汇总一下:
- 忘删星号本身。很多人处理完“星号吃掉左边字符”后,把星号留在了答案里。记住操作是“删除它们两个”,不是只删一个。
- Java 里用 String 反复拼接再删除。每次拼接生成新对象,再 substring 又是新对象,复杂度直接退化到 O(n²),数据一大就 TLE。正确做法是 StringBuilder。
- 逆序计数法忘记反转。从右往左收集的答案是反的,最后不
reverse一下,样例都过不了。 - 原地写法最后忘记截断。
substr(0, j)这步一漏,返回的字符串里会混着栈后面的脏数据。 - 被“任意顺序”吓住。其实题目给定任意顺序结果不变,不需要去枚举或者模拟某种特定顺序,直接按单趟遍历处理就行。
6.3 如果正在按专题刷力扣,建议的练习顺序
这道题在力扣热题里常被归到“栈”专题。如果你是按专题刷的,我建议把下面这组题按顺序做一遍,每道之间都有递进关系:
- 20 有效括号——最基础的入栈出栈,理解“配对”;
- 1047 删除字符串中的所有相邻重复项——第一次接触“栈顶比较”;
- 2390 从字符串中移除星号——本题,理解“最近”对应栈顶;
- 844 比较含退格的字符串——同类变种,巩固计数器思路;
- 71 简化路径——栈用于层级路径处理;
- 739 每日温度——从基础栈过渡到单调栈。
这组做完,你对“什么时候用栈”会有一个比较稳的判断。之后再去看“下一个更大元素”“接雨水”这些进阶栈题,也不会那么发怵。
我个人实际刷完这题后的体会是:它考的不是算法,而是你能不能把一句现实规则准确翻译成数据结构操作。“左边最近”这四个字一出来,答案其实已经写了一半;剩下的一半,就是管住自己别手痒去写模拟删除。最后再分享一个小技巧:遇到字符串处理题,先想想能不能把字符串本身当栈用,C++ 用 string、Java 用 StringBuilder、Python 用 list,很多题都能白赚一个免拼接的便利。
