【LeetCode Hot100 刷题日记 (80/100)】763. 划分字母区间 —— 贪心 + 哈希表🧠

4 阅读5分钟

📌 题目链接:763. 划分字母区间 - 力扣(LeetCode)

🔍 难度:中等 | 🏷️ 标签:贪心、哈希表、双指针、字符串

⏱️ 目标时间复杂度:O(n)

💾 空间复杂度:O(1) (字符集固定为 26 个小写字母)


🧠在 LeetCode Hot100 的刷题旅程中,「划分字母区间」是一道非常典型的贪心思想区间合并结合的字符串处理题。它不仅考察你对贪心策略的理解,还隐含了“跳跃游戏”、“合并区间”等经典模型的影子。面试中若能迅速识别出这类问题的本质,往往能脱颖而出。


🔍 题目分析

题目要求将字符串 s 划分为尽可能多的片段,使得同一个字母只出现在一个片段中。这意味着:

  • 如果字母 'a' 出现在位置 08,那么包含 'a' 的片段必须覆盖 [0, 8]
  • 所有出现在该片段中的其他字母,也必须全部落在这个区间内。
  • 最终目标是最大化片段数量 → 即每个片段要尽可能短,但又满足“字母不跨段”的约束。

这本质上是一个区间覆盖问题:每个字符定义了一个区间 [first, last],我们要将这些区间合并成若干互不重叠的最大独立段,并返回每段长度。

关键洞察
不需要记录每个字符的首次出现位置!因为从左到右扫描时,我们天然知道“当前已遇到的字符”,只需知道它们最远能延伸到哪里即可。


🎯 核心算法及代码讲解

本题的核心是 贪心 + 哈希预处理

步骤拆解:

  1. 预处理:遍历一次字符串,用数组 last[26] 记录每个小写字母最后一次出现的下标。

    • 由于只有 26 个字母,可用 s[i] - 'a' 作为索引,空间 O(1)。
  2. 贪心扫描

    • 维护两个指针:start(当前段起点)、end(当前段终点)。
    • 遍历每个字符 s[i],更新 end = max(end, last[s[i]])
    • i == end 时,说明从 startend 的所有字符,其最远出现位置都不超过 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

  • 对每个 i0n-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!💪

📌 记住:当你在刷题时,不要只看答案,要像写这篇文章一样,深入思考每一步背后的原理、优化空间和面试价值。这才是真正提升算法能力的方式!