📌 题目链接:763. 划分字母区间 - 力扣(LeetCode)
🔍 难度:中等 | 🏷️ 标签:贪心、哈希表、双指针、字符串
⏱️ 目标时间复杂度:O(n)
💾 空间复杂度:O(1) (字符集固定为 26 个小写字母)
🧠在 LeetCode Hot100 的刷题旅程中,「划分字母区间」是一道非常典型的贪心思想与区间合并结合的字符串处理题。它不仅考察你对贪心策略的理解,还隐含了“跳跃游戏”、“合并区间”等经典模型的影子。面试中若能迅速识别出这类问题的本质,往往能脱颖而出。
🔍 题目分析
题目要求将字符串 s 划分为尽可能多的片段,使得同一个字母只出现在一个片段中。这意味着:
- 如果字母
'a'出现在位置0和8,那么包含'a'的片段必须覆盖[0, 8]。 - 所有出现在该片段中的其他字母,也必须全部落在这个区间内。
- 最终目标是最大化片段数量 → 即每个片段要尽可能短,但又满足“字母不跨段”的约束。
这本质上是一个区间覆盖问题:每个字符定义了一个区间 [first, last],我们要将这些区间合并成若干互不重叠的最大独立段,并返回每段长度。
✅ 关键洞察:
不需要记录每个字符的首次出现位置!因为从左到右扫描时,我们天然知道“当前已遇到的字符”,只需知道它们最远能延伸到哪里即可。
🎯 核心算法及代码讲解
本题的核心是 贪心 + 哈希预处理。
步骤拆解:
-
预处理:遍历一次字符串,用数组
last[26]记录每个小写字母最后一次出现的下标。- 由于只有 26 个字母,可用
s[i] - 'a'作为索引,空间 O(1)。
- 由于只有 26 个字母,可用
-
贪心扫描:
- 维护两个指针:
start(当前段起点)、end(当前段终点)。 - 遍历每个字符
s[i],更新end = max(end, last[s[i]])。 - 当
i == end时,说明从start到end的所有字符,其最远出现位置都不超过end,可以安全切分! - 将长度
end - start + 1加入结果,start = end + 1开启下一段。
- 维护两个指针:
为什么这是贪心?
- 我们总是尝试在最早可能的位置结束当前段(即一旦
i == end就切),这样能保证片段数最多。 - 若延迟切割,虽然合法,但会减少总段数,不符合“尽可能多”的要求。
💡 面试加分点:
- 类比“跳跃游戏 II” :每个位置
i的“最远跳”就是last[s[i]],求最少跳跃次数?不,这里是求“自然停顿点”。 - 类比“合并区间” :把每个字符的
[first, last]视为区间,合并后得到的就是最终片段。但本题无需显式构建所有区间,更高效!
🧩 解题思路(分步详解)
✅ Step 1:记录每个字符最后出现的位置
遍历s,更新last[c - 'a'] = i。由于后出现的会覆盖前面的,最终last[x]就是字母'a'+x的最后位置。
✅ Step 2:双指针贪心扫描
初始化
start = 0,end = 0。对每个
i从0到n-1:
更新
end = max(end, last[s[i] - 'a'])如果
i == end,说明当前段[start, end]已“自包含”,可切分。
- 记录长度
end - start + 1- 更新
start = end + 1
✅ Step 3:返回结果列表
📊 算法分析
| 项目 | 分析 |
|---|---|
| 时间复杂度 | O(n) —— 两次遍历字符串,n 为长度 |
| 空间复杂度 | O(1) —— last 数组大小固定为 26 |
| 是否原地 | 否,但额外空间极小 |
| 稳定性 | 稳定,输出顺序与输入一致 |
| 面试高频度 | ⭐⭐⭐⭐☆(字节、腾讯、阿里常考) |
💬 面试官可能会问:
- 如果字符串包含 Unicode 字符怎么办?→ 改用
unordered_map<char, int>,空间变为 O(k),k 为不同字符数。- 能不能一次遍历完成?→ 不能,因为需要先知道“最后位置”才能决定何时切割。
- 如果要求输出具体子串而非长度?→ 只需在切分时
s.substr(start, len)即可。
💻 代码
C++
#include <bits/stdc++.h>
using namespace std;
using ll = long long;
class Solution {
public:
vector<int> partitionLabels(string s) {
// 📌 步骤1:记录每个字母最后出现的位置
int last[26]; // 因为只有小写字母,用数组代替哈希表更高效
int length = s.size();
for (int i = 0; i < length; i++) {
last[s[i] - 'a'] = i; // 覆盖式赋值,最终保留最后一次位置
}
// 📌 步骤2:贪心划分
vector<int> partition;
int start = 0, end = 0; // 当前片段的起止位置
for (int i = 0; i < length; i++) {
// 🔄 扩展当前片段的右边界:取当前字符最远位置与当前end的最大值
end = max(end, last[s[i] - 'a']);
// ✂️ 如果走到当前片段的最右端,说明可以安全切割
if (i == end) {
partition.push_back(end - start + 1); // 记录长度
start = end + 1; // 下一段从 end+1 开始
}
}
return partition;
}
};
// 测试
signed main(){
ios::sync_with_stdio(false);
cin.tie(nullptr);
cout.tie(nullptr);
Solution sol;
// 🧪 示例1
string s1 = "ababcbacadefegdehijhklij";
auto res1 = sol.partitionLabels(s1);
cout << "示例1: ";
for (int x : res1) cout << x << " "; // 输出: 9 7 8
cout << "\n";
// 🧪 示例2
string s2 = "eccbbbbdec";
auto res2 = sol.partitionLabels(s2);
cout << "示例2: ";
for (int x : res2) cout << x << " "; // 输出: 10
cout << "\n";
return 0;
}
JS
/**
* @param {string} s
* @return {number[]}
*/
var partitionLabels = function(s) {
// 📌 步骤1:记录每个字母最后出现的位置
const last = new Array(26);
const length = s.length;
const codePointA = 'a'.codePointAt(0);
for (let i = 0; i < length; i++) {
last[s.codePointAt(i) - codePointA] = i;
}
// 📌 步骤2:贪心划分
const partition = [];
let start = 0, end = 0;
for (let i = 0; i < length; i++) {
// 🔄 扩展当前片段的右边界
end = Math.max(end, last[s.codePointAt(i) - codePointA]);
// ✂️ 到达当前片段末尾,切割
if (i === end) {
partition.push(end - start + 1);
start = end + 1;
}
}
return partition;
};
// 🧪 测试
console.log(partitionLabels("ababcbacadefegdehijhklij")); // [9, 7, 8]
console.log(partitionLabels("eccbbbbdec")); // [10]
🌟 结语
🌟 本期完结,下期见!🔥
👉 点赞收藏加关注,新文更新不迷路。关注专栏【算法】LeetCode Hot100刷题日记,持续为你拆解每一道热题的底层逻辑与面试技巧!
💬 欢迎留言交流你的解法或疑问!一起进步,冲向 Offer!💪
📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!