Files

217 lines
6.8 KiB
Markdown
Raw Permalink Normal View History

2026-05-16 18:23:13 +08:00
---
tags: [贪心算法, 双指针, 字符串, 哈希表, LeetCode]
create time: 2026-05-16 14:30
---
# 80. 划分字母区间
## 题面
给你一个字符串 `s` 。你要把这个字符串划分为尽可能多的片段,满足:**同一字母最多出现在一个片段中**。
返回一个表示每个字符串片段的长度的列表。
**示例 1:**
```
输入:s = "ababcbacadefegdehijhklij"
输出:[9,7,8]
解释:划分结果为 "ababcbaca"、"defegde"、"hijhklij"。每个字母最多出现在一个片段中。
```
**示例 2:**
```
输入:s = "eccbbbbdec"
输出:[10]
```
> [!info] 核心约束
> - 划分结果按顺序连接后必须等于原字符串(不能重排)
> - 同一字母的所有出现必须落在同一个片段内
> - 目标是使片段数量最大化(等价于每个片段尽量短)
---
## 思路
### 关键问题思考
先问自己一个问题:如果字母 `'a'` 在位置 0 和位置 7 都出现了,那第一个片段至少要覆盖到位置 7,对吗?
> [!tip] 核心观察
> **如果字符 c 的最后出现位置是 last[c],那么包含该字符的任意片段的右边界至少是 last[c]。**
但这个条件还不够——当第一个片段被迫延伸到位置 7 时,位置 3~7 之间出现的**其他字符**也可能有自己的最后出现位置比 7 更远。所以片段的右边界需要被进一步"推远"。
这引出了贪心策略的核心:**我们不知道第一个片段在哪里结束,但我们可以维护一个"最远能达到的位置"**。
### 解题步骤
| 步骤 | 操作 | 目的 |
|------|------|------|
| **第一步** | 遍历字符串,记录每个字符最后一次出现的位置 | 知道每个字符的"活动范围"右端点 |
| **第二步** | 再次遍历字符串,维护当前片段的起始位置 start 和最远右边界 far | 逐步推进,找到分割点 |
| **第三步** | 对于当前位置 i 的字符 s[i],将 far 更新为 max(far, last[s[i]]) | 扩展当前片段的覆盖范围 |
| **第四步** | 当 i == far 时,说明当前位置就是当前片段的终点 | 此时可以安全切分 |
### 流程图
```mermaid
flowchart TD
A[开始] --> B[第一步: 统计每个字符的最后出现位置 last]
B --> C[初始化 start = 0, far = 0]
C --> D{遍历字符串 i = 0 → n-1}
D --> E["far = max(far, last[s[i]])"]
E --> F{i == far ?}
F -- 是 --> G[记录片段长度 far - start + 1]
G --> H[start = far + 1]
H --> I{i == n-1 ?}
F -- 否 --> I
I -- 否 --> D
I -- 是 --> J[返回结果数组]
```
### 图解示例
以 `s = "ababcbacadefegdehijhklij"` 为例:
```
last 映射: a→8 b→5 c→7 d→14 e→15 f→11 g→13
h→19 i→22 j→23 k→20 l→21
位置索引: 0 1 2 3 4 5 6 7 8 | 9 1011121314 | 15 16 17 18 19 20 21 22 23
┌─┬─┬─┬─┬─┬─┬─┬─┬─┤ ├──┼──┼──┼──┼──┼─┤ ├──┼──┼──┼──┼──┼──┼──┼──┤
字符串: a b a b c b a c a | d e f e g d | e h i j h k l i j
片段划分: │ 片段1(长9) │ 片段2(长7) │ 片段3(长8) │
```
**模拟推进过程:**
```
i=0, s[0]='a', far = max(0, last['a']=8) = 8
i=1, s[1]='b', far = max(8, last['b']=5) = 8
i=2, s[2]='a', far = max(8, 8) = 8
i=3, s[3]='b', far = max(8, 5) = 8
i=4, s[4]='c', far = max(8, last['c']=7) = 8
i=5, s[5]='b', far = max(8, 5) = 8
i=6, s[6]='a', far = max(8, 8) = 8
i=7, s[7]='c', far = max(8, 7) = 8
i=8, s[8]='a', far = max(8, 8) = 8 ← i==far,切割!记录 9
i=9, s[9]='d', far = max(9, last['d']=14) = 14
i=10,s[10]='e', far = max(14, last['e']=15) = 15
i=11,s[11]='f', far = max(15, last['f']=11) = 15
i=12,s[12]='e', far = max(15, 15) = 15
i=13,s[13]='g', far = max(15, last['g']=13) = 15
i=14,s[14]='d', far = max(15, 14) = 15
i=15,s[15]='e', far = max(15, 15) = 15 ← i==far,切割!记录 7
i=16...最终 i==far=23,切割!记录 8
```
### 正确性为什么成立?
> [!question] 为什么当 i == far 时可以安全切割?
>
> **回答思路**:考虑反证法。假设我们在某个位置切割后,后面还有一个片段包含了与前面片段相同的字符 —— 但我们在推进过程中已经把所有已访问字符的 last 值都纳入了 far 的计算,far 就是所有已访问字符的最后出现位置的**最大值**。所以 far 之后的不可能出现前面的任何字符。因此切割是安全的。
### 复杂度分析
| 指标 | 复杂度 | 说明 |
|------|--------|------|
| **时间** | O(n) | 两次线性扫描,n 为字符串长度 |
| **空间** | O(Σ) | Σ 为字符集大小(小写字母 = 26),视为常数 |
---
## 代码提示
### 伪代码
```
函数 partitionLabels(s):
// Step 1: 建立 char -> last_position 的映射
创建数组 last[26], 初始化为 0
for i from 0 to s.length-1:
last[s[i] - 'a'] = i
// Step 2: 贪心划分
创建空结果数组 ans
start = 0, far = 0
for i from 0 to s.length-1:
far = max(far, last[s[i] - 'a'])
if i == far:
ans.append(i - start + 1)
start = i + 1
return ans
```
---
## 技巧
> [!abstract] 💡 面试技巧
>
> 1. **"最后一次出现"模式**:这类"元素必须同组"的问题,第一步通常是找每个元素的最后出现位置。类似的题目还有 `[45.跳跃游戏II](./45-跳跃游戏II.md)`、`[56.合并区间](./56-合并区间.md)`。
> 2. **双指针模板**:start 标记片段起点,far 标记片段终点。当到达 far 时就切一刀。这种模板在区间类问题中非常通用。
> 3. **不要提前切割**:即使觉得当前字符在前面都没出现过,也不能轻易在这里切分——因为中间可能有别的字符把它拉得更远。far 变量就是防止过早切割的关键。
---
## 代码
```go
// PartitionLabels 将字符串划分为尽可能多的片段,同一字母最多出现在一个片段中
// 参数 s: 仅由小写英文字母组成的字符串
// 返回值: 每个片段的长度列表
func partitionLabels(s string) []int {
// 记录每个字符最后一次出现的位置
last := [26]int{}
for i, ch := range s {
last[ch-'a'] = i
}
var ans []int
start, far := 0, 0
for i := range s {
// 扩展当前片段的最远边界
if p := last[s[i]-'a']; p > far {
far = p
}
// 到达当前片段终点,切割
if i == far {
ans = append(ans, i-start+1)
start = i + 1
}
}
return ans
}
```
> [!example] 简洁版写法(利用 len 预分配)
>
> ```go
> func partitionLabels(s string) []int {
> last := [26]int{}
> for i, ch := range s {
> last[ch-'a'] = i
> }
>
> var ans []int
> start := 0
> for i, far := 0, 0; i < len(s); i++ {
> if p := last[s[i]-'a']; p > far {
> far = p
> }
> if i == far {
> ans = append(ans, i-start+1)
> start = i + 1
> }
> }
> return ans
> }
> ```