【LeetCode刷题日记】1047:双栈法与双指针法巧妙消除相邻重复字符

🔥个人主页:北极的代码(欢迎来访)

🎬作者简介:java后端学习者

❄️个人专栏:苍穹外卖日记SSM框架深入JavaWeb

命运的结局尽可永在,不屈的挑战却不可须臾或缺!

摘要:

本文针对LeetCode 1047题"删除字符串中的所有相邻重复项"提出了两种解法。

第一种使用Deque栈结构,通过压栈和弹栈操作消除相邻重复字符,最后反转剩余字符得到结果。

第二种采用双指针法,将原数组模拟为栈,通过快慢指针实现原地修改,空间效率更高。两种方法的时间复杂度均为O(n),但双指针法将空间复杂度优化至O(1)。文章详细分析了两种实现的核心逻辑、性能差异和适用场景,并提供了完整的Java代码实现。

题目背景:LeetCode1047

给出由小写字母组成的字符串 S,重复项删除操作会选择两个相邻且相同的字母,并删除它们。

在 S 上反复执行重复项删除操作,直到无法继续删除。

在完成所有重复项删除操作后返回最终的字符串。答案保证唯一。

示例:

  • 输入:"abbaca"
  • 输出:"ca"
  • 解释:例如,在 "abbaca" 中,我们可以删除 "bb" 由于两字母相邻且相同,这是此时唯一可以执行删除操作的重复项。之后我们得到字符串 "aaca",其中又只有 "aa" 可以执行重复项删除操作,所以最后的字符串为 "ca"。

提示:

  • 1 <= S.length <= 20000
  • S 仅由小写英文字母组成。

题目解析:

第一种解法:Deque栈实现

本题要删除相邻相同元素,相对于20. 有效的括号来说其实也是匹配问题,20. 有效的括号 是匹配左右括号,本题是匹配相邻元素,最后都是做消除的操作。

本题也是用栈来解决的经典题目。那么栈里应该放的是什么元素呢

我们在删除相邻重复项的时候,其实就是要知道当前遍历的这个元素,我们在前一位是不是遍历过一样数值的元素,那么如何记录前面遍历过的元素呢?

所以就是用栈来存放,那么栈的目的,就是存放遍历过的元素,当遍历当前的这个元素的时候,去栈里看一下我们是不是遍历过相同数值的相邻元素。

然后再去做对应的消除操作。 如动画所示:

然后从栈中弹出剩余元素,此时是字符串ac,因为从栈里弹出的元素是倒序的,所以再对字符串进行反转一下,就得到了最终的结果。

text

复制代码
遍历字符串 "abbaca":

步骤1: 栈空 → 压入 'a'         栈: [a]
步骤2: 'b' ≠ 栈顶'a' → 压入 'b'  栈: [a, b]
步骤3: 'b' = 栈顶'b' → 弹出 'b'  栈: [a]     ← 发现重复,消除
步骤4: 'a' = 栈顶'a' → 弹出 'a'  栈: []      ← 发现重复,消除
步骤5: 'c' ≠ 栈顶(空) → 压入 'c' 栈: [c]
步骤6: 'a' ≠ 栈顶'c' → 压入 'a' 栈: [c, a]

输出: 栈中剩余 [c, a] → 反转得到 "ca"
代码实操:

我们先要实现栈,我们这里使用Deque来实现栈,

复制代码
ArrayDeque会比LinkedList在除了删除元素这一点外会快一点

为什么用 ArrayDeque 而不是 LinkedList

特性 ArrayDeque LinkedList
底层结构 循环数组 双向链表
内存占用 更小 更大(每个节点有前后指针)
随机访问 O(1) O(n)
插入/删除 O(1)(均摊) O(1)
CPU 缓存友好 ✅ 是 ❌ 否

结论: 对于栈操作(只在一端插入删除),ArrayDeque 性能更好。

接下来的判断,当栈是空的时候或者栈顶的元素跟我们遍历的元素不相等时,入栈,如果栈顶跟我们遍历的元素相等时 ,出栈,则消除了重复的元素。

逻辑分支表:

条件 动作 含义
deque.isEmpty() push(ch) 第一个字符,无物可消
deque.peek() != ch push(ch) 不同字符,暂存等待
deque.peek() == ch pop() 相同字符,互相抵消

之后把结果进行反转:

text

复制代码
栈底 → 栈顶
[c, a]   (a 是栈顶)

pop() 顺序:先取 a,再取 c
如果直接拼接:str = a + c = "ac"  ❌ 错误

正确做法:str = pop() + str
- 第一次:str = "a" + "" = "a"
- 第二次:str = "c" + "a" = "ca"  ✅
题目答案:

使用 Deque 作为堆栈

java 复制代码
class Solution {
    public String removeDuplicates(String S) {
        //ArrayDeque会比LinkedList在除了删除元素这一点外会快一点
        //参考:https://stackoverflow.com/questions/6163166/why-is-arraydeque-better-than-linkedlist
        ArrayDeque<Character> deque = new ArrayDeque<>();
        char ch;
        for (int i = 0; i < S.length(); i++) {
            ch = S.charAt(i);
            if (deque.isEmpty() || deque.peek() != ch) {
                deque.push(ch);
            } else {
                deque.pop();
            }
        }
        String str = "";
        //剩余的元素即为不重复的元素
        while (!deque.isEmpty()) {
            str = deque.pop() + str;
        }
        return str;
    }
}

第二种解法:双指针法

代码逻辑:

定义快慢指针这里就不用说了,快指针负责读,慢指针负责写,主要的逻辑就是模拟栈的思想,而不是实现栈,我们可能会想,重复的都是两个相同的元素,而覆盖操作不是只能覆盖一个元素吗。其实这是对覆盖进行了误解。这就是我们用双指针方法的精髓:

我们用的是栈的思想 ,但没有 new 任何栈对象。我们把数组本身当成了栈来用

组件 角色 说明
fast 指针 读指针 遍历原字符串,读取每个字符
slow 指针 写指针/栈顶指针 指向栈的下一个空位
ch[slow] = ch[fast] 入栈操作 把读到的字符写入栈中
ch 数组 栈的存储空间 模拟栈的物理内存

ch[slow] = ch[fast]; // 这就是入栈操作

这里的覆盖操作,其实就是入栈操作,通过fast指针来读,写进slow指针的位置,其实就是我们模拟的栈

复制代码
char[] ch = s.toCharArray();  // ← 这个数组就是栈!
int slow = 0;                 // ← slow 就是栈顶指针!
java 复制代码
java

// 查看栈顶元素
if (slow > 0 && ch[slow] == ch[slow - 1]) {
//                     ↑          ↑
//                  当前字符    栈顶元素(slow-1)
//                  要入栈      最后一个有效元素
}

// 为什么是 slow-1?
// 因为有效元素在 [0, slow) 区间
// 区间是左闭右开,最后一个有效索引就是 slow-1
概念 含义 位置
slow 下一个可用位置 / 栈的大小 指向空位
slow - 1 栈顶元素的位置 指向最后一个有效元素

然后就是我们前面提到的,是怎么消除两个元素的,如果元素不相等时,就相当于是入栈,没什么好说的

然后就是遇到相同元素的时候:如果慢指针的元素和慢指针前一个的元素相等,也就是出现了重复元素,这里我们进行slow--,模拟的就是出栈的操作,slow就是栈顶的指针的下一个元素,我们把这个slow指针后移了,从而栈顶的指针也就移动了(slow-1)。同时另一个元素也没有入栈,因此两个重复元素都没有了。满足了我们的需求。

其实这不是真正意义上的移除操作,我们只是通过指针来规定边界,只看指针范围的内部,因此我们是忽视了外界的元素。

栈是一种「思想」,不一定是「对象」

概念 说明
逻辑上的栈 一种数据结构思想:后进先出(LIFO)
物理上的栈 代码中 new 出来的 Stack、ArrayDeque 对象

这道题的关键: 我们用的是栈的思想 ,但没有 new 任何栈对象。我们把数组本身当成了栈来用!

题目答案:
java 复制代码
class Solution {
    public String removeDuplicates(String s) {
        char[] ch = s.toCharArray();
        int fast = 0;
        int slow = 0;
        while(fast < s.length()){
            // 直接用fast指针覆盖slow指针的值
            ch[slow] = ch[fast];
            // 遇到前后相同值的,就跳过,即slow指针后退一步,下次循环就可以直接被覆盖掉了
            if(slow > 0 && ch[slow] == ch[slow - 1]){
                slow--;
            }else{
                slow++;
            }
            fast++;
        }
        return new String(ch,0,slow);
    }
}

核心差异对比表

对比维度 显式栈(Deque) 双指针模拟栈
空间复杂度 O(n) O(1)
是否需要额外容器 需要 new ArrayDeque<>() 不需要,原数组当栈用
最终结果处理 需要反转(栈是倒序) 直接截取,无需反转
代码可读性 高,逻辑清晰 中,需要理解栈模拟
是否修改原数据 是(修改原字符数组)
内存分配 堆上分配新对象 无额外分配
适用场景 通用,不介意额外空间 追求极致性能/空间
指标 显式栈 双指针
时间复杂度 O(n) O(n)
空间复杂度 O(n) O(1)
内存分配次数 2次(栈+StringBuilder) 0次(只用了原数组)
CPU缓存友好 一般 (连续内存访问)
最后反转 需要 O(n) 不需要

结语:如果对你有帮助,请点赞,关注,收藏,你的支持就是我最大的鼓励!

相关推荐
切糕师学AI1 小时前
布隆过滤器(Bloom Filter)技术详解
数学·算法
礼拜天没时间.2 小时前
力扣热题100实战 | 第33期:搜索旋转排序数组——二分查找的变体艺术
算法·leetcode·职场和发展·旋转数组·搜索旋转排序数组
Jenlybein2 小时前
用 uv 替代 conda,速度飙升(从 0 到 1 开始使用 uv)
后端·python·算法
400分2 小时前
LangChain 与大模型技术全链路详解
算法·架构
电科一班林耿超2 小时前
第 14 课:动态规划(DP)—— 算法思想的巅峰,面试的终极分水岭
数据结构·算法·动态规划
Java成神之路-2 小时前
面试题:@Controller 与 @RestController 区别
java·spring boot
用户298698530142 小时前
Java 提取 HTML 文本内容:两种轻量级实现方案对比
java·后端
lihao lihao2 小时前
Linux文件与fd
java·linux·算法
Navigator_Z2 小时前
LeetCode //C - 1026. Maximum Difference Between Node and Ancestor
c语言·算法·leetcode