只出现一次还不够!字典序最小的“去重”到底怎么玩?

在这里插入图片描述
在这里插入图片描述

摘要

这题看起来像是处理字符串的常规操作:“把重复的字母删掉就好了嘛。”——但实际上,一旦你加上“字典序最小”这个条件,事情就不简单了。

LeetCode 316《去除重复字母》是一道结合了贪心算法单调栈思想的经典题,不仅考察你对字符串的操作熟练度,还考验你对“相对顺序 + 字典序”的理解。

本文将用 Swift 带你一行行拆解这题,并配合完整 Demo 和现实场景分析,带你掌握这类“按规则选字符”的高频技巧。

描述

题目原文如下:

给你一个字符串 s,去除其中重复的字母,使得每个字母只出现一次,并且返回结果的字典序最小。你必须保证字符的相对位置不能打乱

举几个例子:

  • 输入:"bcabc"
    输出:"abc"

  • 输入:"cbacdcbc"
    输出:"acdb"

注意点:

  • 要去重。
  • 相对顺序不能乱。
  • 最终结果字典序要最小(也就是“越靠前越小”的字母尽量放前面)。

题解答案

如果不考虑顺序和字典序,我们可以直接用 Set<Character> 来去重。但这题麻烦在于:

  • 要“只出现一次”
  • 要保证结果的字典序最小(比如 “cbacdcbc” 的最优解是 “acdb”)
  • 字母相对顺序不能乱

这时候,我们就得引入一种经典策略:单调栈 + 贪心算法

基本思路是:

  1. 从左到右扫描字符串;
  2. 每次处理一个字符,决定是否放进结果栈中;
  3. 如果当前字符比栈顶字符字典序小,并且栈顶字符后面还会出现,那就把栈顶弹出;
  4. 最终栈中的字符就是去重后、字典序最小、相对顺序合理的字符串。

题解代码分析(Swift 实现)

func removeDuplicateLetters(_ s: String) -> String {
    var stack: [Character] = []
    var seen: Set<Character> = []
    var lastIndex: [Character: Int] = [:]
    let chars = Array(s)
    
    // 记录每个字符最后出现的位置
    for (i, ch) in chars.enumerated() {
        lastIndex[ch] = i
    }
    
    for (i, ch) in chars.enumerated() {
        // 如果当前字符已经在结果中,就跳过
        if seen.contains(ch) {
            continue
        }
        
        // 如果当前字符比栈顶更小,并且栈顶后面还会再出现,就弹出栈顶
        while let last = stack.last,
              ch < last,
              lastIndex[last]! > i {
            stack.removeLast()
            seen.remove(last)
        }
        
        stack.append(ch)
        seen.insert(ch)
    }
    
    return String(stack)
}

题解代码分析

让我们一步步来拆解上面这段代码在干什么:

记录字符的最后出现位置

for (i, ch) in chars.enumerated() {
    lastIndex[ch] = i
}

这一段的目的是为了让我们在后续操作中判断:当前字符后面还会不会再出现,如果会,我们可以“大胆删”。

主循环逻辑(贪心 + 栈)

if seen.contains(ch) {
    continue // 已处理过,不重复加入
}

这是保证每个字符只出现一次的关键。

接下来是“贪心删栈顶”的逻辑:

while let last = stack.last,
      ch < last,
      lastIndex[last]! > i {
    stack.removeLast()
    seen.remove(last)
}

意思是:

  • 如果当前字符 ch 字典序更小,
  • 栈顶的字符还会在后面出现,
  • 那么我们可以把栈顶删掉,为字典序让路。

比如 “cbacdcbc”,我们走到 a 时,栈顶是 c,但 c 还会再出现,所以 a 可以把 c 替掉。

加入栈和记录

stack.append(ch)
seen.insert(ch)

这就是把当前合法的字符加入结果栈,同时标记它已经被用了。

示例测试及结果

我们写几个测试例子,来看下效果:

示例 1

let result1 = removeDuplicateLetters("bcabc")
print(result1)  // 输出:"abc"

解释:

  • b → 入栈
  • c → 入栈
  • a → 比 c 小,而且 c 后面还会出现,所以把 c 弹出,再把 a 入栈
  • 后面遇到重复的就跳过

最终是 "abc"

示例 2

let result2 = removeDuplicateLetters("cbacdcbc")
print(result2)  // 输出:"acdb"

解释过程如下:

  • c 入栈
  • b 入栈
  • ab 小,b 后面还有,弹出;c 后面还有,也弹出;然后 a 入栈
  • 后面依次加入 cdb,但跳过重复字符

最终得到 "acdb"

时间复杂度

  • 遍历一次字符串:O(n)
  • 每个字符最多只会被压栈和出栈一次:O(n)
  • 所以总体时间复杂度是 O(n)

空间复杂度

  • lastIndexseen 使用了常数空间(最多 26 个字母):O(1)
  • 栈最多也放 26 个字母:O(1)

所以虽然用了多个数据结构,但总体空间复杂度也是 O(1)(和输入字符集大小有关,输入字符集为小写英文字母)。

总结

这道题是典型的“处理字符串顺序 + 优化字典序”的组合题,在面试中非常常见。

你不仅需要理解“字典序”的含义,还需要:

  • 学会用贪心策略找“更优顺序”;
  • 能熟练使用 Set 组合去控制结果;
  • 处理“重复元素”时保证只选一次。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

网罗开发

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值