题目描述

✅ 1047. 删除字符串中的所有相邻重复项

image-20260928202942681

题意分析

每次删除一对相邻且相同的字符,删除后其左右两侧会重新相邻,可能继续触发删除。要一直处理到不存在这样的字符对,返回最终字符串,而不是只删除原串中最初相邻的重复项。

这里需要区分两种输入约定:官方题目使用字符串输入和返回值,没有要求原地修改;上方题面图额外写了“原地、O(1) 额外空间”。Java 的 String 和 Go 的 string 都不能原地改写,普通字符串接口使用字符缓冲区需要 O(n) 空间。若输入本来就是可写字符数组,则可以复用原数组,并返回有效结果长度,满足原地的空间要求。

解法:栈模拟相邻抵消

核心思路

[!blue]

从左到右读取字符,栈始终保存“已读前缀完成全部抵消后的结果”。这份结果内部没有相邻重复项,因此加入新字符时,唯一可能出现的新重复对就是栈顶与当前字符。

如果二者相同,就弹出栈顶,同时丢弃当前字符,正好删除一对;如果不同,或栈为空,就把当前字符追加到栈顶。这样处理后,栈内仍然没有相邻重复项,不变量继续成立。

相同分支只需要一次判断,不需要拿当前字符反复弹栈:当前字符已经与栈顶一起被删除,没有机会再与下一个栈顶匹配。删除后留下的栈本来就是合法前缀,后续读入的字符会继续与新的栈顶比较,从而自然完成连锁消除。

Java 使用 StringBuilder,Go 使用字节切片,直接把缓冲区末尾作为栈顶。最终答案从栈底到栈顶已按原顺序存放,不需要反转,也不需要从原字符串中间反复删除和搬移后缀。

解题步骤

  1. 创建空字符缓冲区,作为保存已化简前缀的栈。
  2. 从左到右读入当前字符,先确认栈是否非空。
  3. 栈顶与当前字符相同时,弹出栈顶,当前字符不入栈。
  4. 否则将当前字符追加到栈顶。
  5. 扫描结束后,直接把缓冲区转换为字符串返回。

代码实现

class Solution {
    public String removeDuplicates(String s) {
        StringBuilder stack = new StringBuilder();

        for (int i = 0; i < s.length(); i++) {
            char ch = s.charAt(i);
            int top = stack.length() - 1;

            // 栈内前缀已完成消除,新字符只需与当前栈顶比较。
            if (top >= 0 && stack.charAt(top) == ch) {
                stack.deleteCharAt(top);
            } else {
                stack.append(ch);
            }
        }

        return stack.toString();
    }
}
func removeDuplicates(s string) string {
    stack := make([]byte, 0, len(s))
    for i := 0; i < len(s); i++ {
        // 栈内前缀已完成消除,新字符只需与当前栈顶比较。
        if len(stack) > 0 && stack[len(stack)-1] == s[i] {
            stack = stack[:len(stack)-1]
        } else {
            stack = append(stack, s[i])
        }
    }
    return string(stack)
}

复杂度分析

  • 时间复杂度:O(n)。每个字符只读一次,至多入栈、出栈各一次;删除缓冲区末尾不需要移动其他字符。
  • 空间复杂度:O(n)。如果没有任何字符抵消,缓冲区需要保留整个字符串。

关键点总结

[!green]

  • 栈保存的是已读前缀的最终结果,栈顶才是当前字符真正的左邻居。
  • 匹配时两个字符同时消失,当前字符不能再参与本轮比较。
  • 普通字符串接口需要独立的可写缓冲区,不能把这份空间说成常数。

补充解法:可变字符数组原地消除

核心思路

[!blue]

当输入是可写的 char[] 或 []byte 时,不再创建另一份栈,而是把原数组前缀当作栈。用 read 指向下一个待读字符,用 size 表示栈中有效字符数量,答案始终放在区间 [0, size),栈顶就是 chars[size - 1]。

先保存 chars[read]。若它与栈顶相同,将 size 减一,等价于弹栈;否则把它写到 chars[size],再将 size 加一,等价于入栈。不需要清空被删除的槽位,因为 size 之外的内容已经不属于有效结果。

每轮开始时都有 size <= read:读过的字符不可能留下更多结果。因此写入位置只会是当前读取位置或它左边,绝不会覆盖尚未读取的后缀。每轮保存当前字符后再改写前缀,能够安全复用同一数组。

返回 size 后,调用方读取原数组的前 size 个字符即可。下面的接口用于可变数组这一补充要求;若为了适配官方字符串接口而先复制成数组、最后构造字符串,整体仍然需要 O(n) 空间。

解题步骤

  1. 将有效长度 size 初始化为零。
  2. 用 read 顺序读取原数组,并保存当前字符。
  3. 若有效前缀非空且末尾字符相同,将 size 减一。
  4. 否则把当前字符写入下标 size,再增加有效长度。
  5. 返回 size,结果保存在原数组的 [0, size) 区间。

代码实现

class Solution {
    public int removeDuplicates(char[] chars) {
        int size = 0;

        for (int read = 0; read < chars.length; read++) {
            char ch = chars[read];

            if (size > 0 && chars[size - 1] == ch) {
                size--;
            } else {
                chars[size] = ch;
                size++;
            }
        }

        return size;
    }
}
func removeDuplicates(chars []byte) int {
    size := 0
    for read := 0; read < len(chars); read++ {
        ch := chars[read]
        if size > 0 && chars[size-1] == ch {
            size--
        } else {
            chars[size] = ch
            size++
        }
    }
    return size
}

复杂度分析

  • 时间复杂度:O(n)。每个输入字符读取一次,每轮只调整有效长度或写入一个位置。
  • 空间复杂度:O(1)。输入已经是可写数组时,只额外保存读指针、有效长度和当前字符,输出也保留在原数组中。

关键点总结

[!green]

  • 原地写入成立的原因是有效长度始终不超过已读取长度,写指针不会追上未读区间。
  • 删除只需缩短有效前缀,不需要搬动后缀或清理残留字符。
  • 返回有效长度保留了原地约定;额外创建结果字符串的内存不属于这个数组接口。

易错点总结

[!yellow]

  • 只跳过原串中的相邻对:删除会改变相邻关系,应与化简后前缀的末尾比较。
  • 未检查空栈就访问末尾:前面的字符可能已经全部抵消,下一轮必须重新检查长度。
  • 弹栈后又把当前字符入栈:相同分支需要删除两个字符,不能把其中一个放回去。
  • 按出栈顺序拼接结果:答案已经正向保存在缓冲区中,倒着取会颠倒字符顺序。
  • 把转数组后的做法写成整体 O(1) 空间:从不可变字符串创建数组本身就需要 O(n),只有调用方已经提供可写数组时才是原地处理。
  • 原地处理后返回整段旧数组:只有有效长度之前的前缀属于答案,后面的残留值应当忽略。

相似题目

题目 难度 关联与区别
1209. 删除字符串中的所有相邻重复项 II 中等 从删除两个相邻相同字符推广为删除k个,栈中需要同时保存字符与连续次数。
2390. 从字符串中移除星号 中等 同样用栈记录尚未消除的前缀,原题由星号触发,本题由相邻相同字符触发。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2023/58637969
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!