欢迎光临
我们一直在努力

从「无重叠区间」到 LeetCode 1520:一次完整的算法踩坑与优化记录

本文记录我从 LeetCode 435(无重叠区间)出发,一路做到 LeetCode 1520(最多的不重叠子字符串)的完整过程:从贪心的两种解法,到套错框架、TLE、修超时、再优化,最后对比官解。适合想理清「区间调度 + 字符串扩展」这类题的读者。

一、先聊「无重叠区间」的两种解法

LeetCode 435 是这类问题的起点:给一堆区间,问最少删掉几个,使剩下的互不重叠。

它有两个经典解法。

解法 1:动态规划(LIS 式)

按左端点排序,定义:

f[i] = 以第 i 个区间结尾的最长不重叠区间链长度

转移:

f[i] = max(f[i], f[j] + 1) 当 intervals[j][1] <= intervals[i][0]

答案:

n – max(f)

class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
if (intervals.empty()) {
return 0;
}

sort(intervals.begin(), intervals.end(), [](const auto& u, const auto& v) {
return u[0] < v[0];
});

int n = intervals.size();
vector<int> f(n, 1);//dpf[i] = 在排序后的前 i+1 个区间中,必须包含第 i 个区间,且这些区间两两不重叠的情况下,最多能选多少个区间
for (int i = 1; i < n; ++i) {
for (int j = 0; j < i; ++j) {
if (intervals[j][1] <= intervals[i][0]) {
f[i] = max(f[i], f[j] + 1);
}
}
}
return n – *max_element(f.begin(), f.end());
}
};

这个 DP 结构就是「区间版的最长上升子序列」。正确,但 O(n²)。

解法 2:贪心(按右端点排序)

按右端点升序排序;
从左到右扫,若当前区间左端点 > 已选区间右端点,就选它。

class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
if(intervals.size()==1)return 0;
sort(intervals.begin(),intervals.end(),[](const vector<int>& a,const vector<int>&b){
return a[0]<b[0];
});
int r=INT_MIN,sum=0;
for(auto a:intervals){
if(a[0]>=r){
r=a[1];
}else{
if(a[1]<r){
r=a[1];
}
sum++;
}

}
return sum;
}
};

这是区间调度的经典贪心,O(n log n),最优。

两者对比

维度DP贪心
复杂度 O(n²) O(n log n)
适用 加权区间调度 无权区间调度
435 推荐 会 TLE

435 是无权版本,所以正解是贪心。这一点,是我后面踩坑的伏笔。


二、引入 LeetCode 1520:最多的不重叠子字符串

题目大意:

给你一个字符串 s,找出最多的不重叠子串,要求每个子串都包含其内部所有字符的全部出现。

例如 s = "adefaddaccc",答案是 ["e","f","ccc"]。

这题和 435 的关系:

  • 435 是"给区间,选最多不重叠";

  • 1520 是"先构造合法区间,再选最多不重叠,最后返回子串"。

也就是说,1520 = 区间构造(扩展)+ 435 的无权贪心。


三、我的第一版:套用了 435 的"最少删除"框架

一开始,我写出来的代码长这样(简化):

class Solution { //没有考虑到扩展情况"abab"
public:

vector<string> maxNumOfSubstrings(string s) {
auto cut = [&](int l, int r) {
return s.substr(l, r – l + 1);
};
unordered_map<char,vector<int>>mp;
vector<vector<int>>mmap;
for(auto a:s){
if(!mp.count(a)){
int b=s.find(a);
int c=s.rfind(a);
mp[a]={b,c};
mmap.push_back({b,c});//!!!!这里!没有扩展!
}
}
sort(mmap.begin(),mmap.end(),[](const vector<int>& a,const vector<int>&b){
return a[0]<b[0];
});
int r=INT_MIN,sum=0;int l;
for(auto a:mmap){
if(a[0]>=r){
r=a[1];
l=a[0];
}else{
if(a[1]<r){
r=a[1];
mp.erase(s[l]);
l=a[0];
}else{
mp.erase(s[a[0]]);
}
}
}
vector<string>result;
for(auto a:mp){
result.push_back(cut(a.second[0],a.second[1]));
}
return result;
}
};

这段代码错在哪

  • 只取首末位置,没扩展区间。比如 s = "abab",a 的初始区间 [0,2],但区间内还有 b,而 b 最后出现在 3,必须扩到 [0,3];

  • 按左端点排序 + 重叠时 erase,这是"保留右端点更小者"的启发式,不是标准贪心,不保证最优;

  • 没有扩展区间,很多用例直接输出非法子串。


  • 四、第二版:加上扩展区间,但超时了

    为了修"没扩展"的问题,我加了一段:

    bool changed = true;
    while (changed) {
    changed = false;
    for (int i = b; i <= c; ++i) {
    char ch = s[i];
    int nf = s.find(ch); // O(n)
    int nl = s.rfind(ch); // O(n)
    if (nf < b) { b = nf; changed = true; }
    if (nl > c) { c = nl; changed = true; }
    }
    }

    逻辑是对的,但超时。

    class Solution {
    public:
    vector<string> maxNumOfSubstrings(string s) {
    auto cut = [&](int l, int r) {
    return s.substr(l, r – l + 1);
    };

    unordered_map<char, vector<int>> mp;
    vector<vector<int>> mmap;

    for (auto a : s) {
    if (!mp.count(a)) {
    int d=s.find(a);
    int b = s.find(a);
    int c = s.rfind(a);
    // ↓↓↓ 新增:扩展区间直到稳定 ↓↓↓
    bool changed = true;
    while (changed) {
    changed = false;
    for (int i = b; i <= c; ++i) {
    char ch = s[i];
    int nf = s.find(ch);
    int nl = s.rfind(ch);
    if (nf < b) { b = nf; changed = true; }
    if (nl > c) { c = nl; changed = true; }
    }
    }
    // ↑↑↑ 新增结束 ↑↑↑
    mp[a] = {b, c};
    mmap.push_back({b,c,d});//因为扩展了,导致扩展的头可能不是要删除的头,于是多加一个元素放真正的头!!!!!!!!!!!
    }
    }

    sort(mmap.begin(), mmap.end(), [](const vector<int>& a, const vector<int>& b) {
    return a[0] < b[0];
    });

    int r = INT_MIN, sum = 0;
    int l;
    for (auto a : mmap) {
    if (a[0] >= r) {
    r = a[1];
    l = a[2];
    } else {
    if (a[1] < r) {
    r = a[1];
    mp.erase(s[l]);
    l = a[2];
    } else {
    mp.erase(s[a[2]]);
    }
    }
    }

    vector<string> result;
    for (auto a : mp) {
    result.push_back(cut(a.second[0], a.second[1]));
    }
    return result;
    }
    };

    超时原因

    • 外层 while(changed) 最坏扩展 O(n) 次;

    • 内层 for 是 O(n);

    • 每次 s.find/rfind 又是 O(n)。

    单个字符最坏 O(n³),26 个字符总最坏 O(26·n³)。n = 10⁵ 时是 10¹⁵ 量级,必然 TLE。

    根因

    把 find/rfind 这种 O(n) 操作,嵌套在了双层循环里。


    五、第三版:修超时,把 find/rfind 换成数组

    思路很简单:预处理每个字符的首末位置,把 O(n) 查询变成 O(1)。

    int n = s.size();
    vector<int> first(26, n), last(26, -1);
    for (int i = 0; i < n; ++i) {
    int c = s[i] – 'a';
    first[c] = min(first[c], i);
    last[c] = max(last[c], i);
    }

    扩展时:

    bool changed = true;
    while (changed) {
    changed = false;
    for (int i = b; i <= c; ++i) {
    int ch = s[i] – 'a';
    if (first[ch] < b) { b = first[ch]; changed = true; }
    if (last[ch] > c) { c = last[ch]; changed = true; }
    }
    }

    这一版不超时了,"adefaddaccc" 也能输出 ["e","f","ccc"]

    class Solution {
    public:
    vector<string> maxNumOfSubstrings(string s) {
    int n = s.size();
    vector<int> first(26, n), last(26, -1);//修超时:把 find/rfind 换成数组
    for (int i = 0; i < n; ++i) {
    int c = s[i] – 'a';
    first[c] = min(first[c], i);
    last[c] = max(last[c], i);
    }

    auto cut = [&](int l, int r) {
    return s.substr(l, r – l + 1);
    };

    unordered_map<char, vector<int>> mp;
    vector<vector<int>> mmap;

    for (auto a : s) {
    if (!mp.count(a)) {
    int d = first[a – 'a']; // 原始首位置
    int b = first[a – 'a'];
    int c = last[a – 'a'];
    bool changed = true;
    while (changed) {
    changed = false;
    for (int i = b; i <= c; ++i) {
    int ch = s[i] – 'a';
    if (first[ch] < b) { b = first[ch]; changed = true; }
    if (last[ch] > c) { c = last[ch]; changed = true; }
    }
    }
    mp[a] = {b, c};
    mmap.push_back({b, c, d});
    }
    }

    sort(mmap.begin(), mmap.end(), [](const vector<int>& a, const vector<int>& b) {
    return a[0] < b[0];
    });

    int r = INT_MIN;
    int l = 0;
    for (auto a : mmap) {
    if (a[0] >= r) {
    r = a[1];
    l = a[2];
    } else {
    if (a[1] < r) {
    r = a[1];
    mp.erase(s[l]);
    l = a[2];
    } else {
    mp.erase(s[a[2]]);
    }
    }
    }

    vector<string> result;
    for (auto a : mp) {
    result.push_back(cut(a.second[0], a.second[1]));
    }
    return result;
    }
    };

    但仍有隐患

    • 扩展最坏还是 O(26·n²)(while 反复扫);

    • 贪心仍是"按左端点排序 + erase 反悔",不保证最优;

    • 结果从 unordered_map 输出,顺序随机。

    也就是说:复杂度勉强过,但正确性靠用例运气。


    六、第四版:单次扫描扩展 + 标准贪心

    真正的优化,要把两件事一起做对。

    优化 1:扩展从 while(changed) 改成单次扫描

    关键观察:右边界 c 只增不减,所以从左到右扫一遍即可,扫过的新区域会自动被覆盖。

    int b = first[c], e = last[c];
    bool valid = true;
    for (int i = b; i <= e; ++i) { // e 在循环中会增大
    int ch = s[i] – 'a';
    if (first[ch] < b) { valid = false; break; } // 区间外还有该字符 → 非法
    if (last[ch] > e) e = last[ch]; // 扩右边界
    }

    • 复杂度从 O(n²) 降到 O(n)(每个字符);

    • first[ch] < b 提前 break,剪掉大量非法区间。

    优化 2:贪心改成标准写法

    sort(intervals.begin(), intervals.end(),
    [](auto& a, auto& b) { return a.second < b.second; }); // 按右端点

    vector<string> res;
    int end = -1;
    for (auto& [b, e] : intervals) {
    if (b > end) { // 不重叠 → 选
    res.push_back(s.substr(b, e – b + 1));
    end = e;
    }
    }

    • 按右端点排序;

    • 不重叠就选,选中立即收集;

    • 不再 erase,不再遍历 unordered_map。

    第四版完整代码

    class Solution {
    public:
    vector<string> maxNumOfSubstrings(string s) {
    int n = s.size();
    vector<int> first(26, n), last(26, -1);
    for (int i = 0; i < n; ++i) {
    int c = s[i] – 'a';
    first[c] = min(first[c], i);
    last[c] = max(last[c], i);
    }

    vector<pair<int,int>> intervals;
    for (int c = 0; c < 26; ++c) {
    if (last[c] == -1) continue;
    int b = first[c], e = last[c];
    bool valid = true;
    for (int i = b; i <= e; ++i) {
    int ch = s[i] – 'a';
    if (first[ch] < b) { valid = false; break; }
    if (last[ch] > e) e = last[ch];
    }
    if (valid) intervals.push_back({b, e});
    }

    sort(intervals.begin(), intervals.end(),
    [](const auto& a, const auto& b) { return a.second < b.second; });

    vector<string> res;
    int end = -1;
    for (auto& [b, e] : intervals) {
    if (b > end) {
    res.push_back(s.substr(b, e – b + 1));
    end = e;
    }
    }
    return res;
    }
    };

    优化前后对比

    维度第三版第四版
    扩展 O(26·n²) O(26·n),实际 O(n) 级
    排序 左端点 右端点
    重叠处理 erase 反悔 跳过
    结果收集 unordered_map 随机 选中即收集,有序
    正确性 靠运气 可证明最优

    七、官解分析:双指针向两侧扩散

    整体思路:两步走

    官解的结构非常清晰,就两大块:

    1. 构造所有"合法区间" → pos + 双指针扩展
    2. 从合法区间里选最多不重叠 → 按右端点排序 + 贪心

    这正好对应题目里的两个难点:

    难点对应手段
    子串要"自洽" 扩展区间,直到包含内部所有字符的全部出现
    数量要最多且不重叠 区间调度贪心

    第一步:记录每个字符的首末位置

    unordered_map<char, pair<int, int>> pos;
    for (int i = 0; i < s.length(); i++) {
    char ch = s[i];
    if (pos.find(ch) == pos.end()) {
    pos[ch] = {i, i};
    } else {
    pos[ch].second = i;
    }
    }

    • pos[ch] = {第一次出现, 最后一次出现};

    • 遍历一次 O(n);

    • 用 unordered_map 而不是 26 数组,字符集不限于小写字母时也通用。

    这一步是后面所有扩展的查询表。

    第二步(核心):双指针向两侧扩散

    这是整份官解最精彩的部分。

    for (auto& [c, range] : pos) {
    int l = range.first, r = range.second;
    int nl = l, nr = l;

    while (nl >= l || nr <= r) {
    int i = (nl >= l) ? nl : nr;

    int l_t = pos[s[i]].first;
    int r_t = pos[s[i]].second;

    if (l_t < l) l = l_t; // 向左扩
    if (r_t > r) r = r_t; // 向右扩

    if (i == nl) nl–;
    if (i == nr) nr++;
    }

    valid.push_back({l, r});
    }

     它在干什么

    对每个字符 c,从它的初始区间 [l, r] 出发:

    • nl:左指针,从 l 开始往左走;

    • nr:右指针,从 l 开始往右走;

    • 每次取一个指针位置的字符 s[i],查它的首末 [l_t, r_t]:

      • 若 l_t < l,说明这个字符在更左边也出现过,左边界必须扩到 l_t;

      • 若 r_t > r,说明它在更右边也出现过,右边界必须扩到 r_t;

    • 扩完后继续检查新纳入的字符,直到两个指针都走出当前区间。

    为什么是"向两侧"而不是"扫内部"

    常见写法是扫区间内部:

    for (int i = b; i <= e; ++i) { … } // e 动态增大

    官解换了个视角:检查区间外的字符,发现漏掉就扩过去。

    两种视角等价,但官解更贴合题目的语义——"子串必须包含内部字符的全部出现",反过来说就是"不能有字符在区间外还有出现"。双指针就是去验证这一点。

     while 的条件为什么是 nl >= l || nr <= r

    • nl >= l:左指针还在区间内,还有字符没检查;

    • nr <= r:右指针还在区间内,还有字符没检查;

    • 只要任意一个还在区间内,就继续。

    注意 nl 和 nr 都从 l 出发,所以一开始两边都在区间内。

    指针移动的细节

    if (i == nl) nl–;
    if (i == nr) nr++;

    • 如果这次处理的是左指针位置,左指针就左移一格;

    • 如果是右指针位置,右指针就右移一格;

    • 两个 if 分开写,是因为 i 可能同时等于 nl 和 nr(初始时 nl = nr = l),此时两边都要动。

    为什么结束时区间一定合法

    • 区间被扩展后,新纳入的字符会被继续检查;

    • 直到所有在区间内的字符,其全部出现都落在区间内;

    • 所以最终区间必然满足"包含内部所有字符的全部出现"。

    第三步:按右端点排序 + 贪心

    sort(valid.begin(), valid.end(),
    [](const pair<int, int>& a, const pair<int, int>& b) {
    return a.second < b.second;
    });

    vector<string> ans;
    int end = -1;
    for (auto& [left, right] : valid) {
    if (left > end) {
    ans.push_back(s.substr(left, right – left + 1));
    end = right;
    }
    }

    这是经典的区间调度贪心:

    • 按右端点升序;

    • left > end 说明与已选区间不重叠,选它,更新 end = right;

    • 否则跳过;

    • 选中时立即收集子串,结果天然有序。

    为什么按右端点?

    右端点越小,留给后面的空间越大。按右端点排序后,每次选"最早结束且不重叠"的区间,能选出最多数量——这是区间调度问题的经典结论,可用交换论证证明。

    官解的精髓,可以浓缩成两句话:

    扩展区间:用双指针向两侧扩散,直到区间包含内部所有字符的全部出现。
    选择区间:按右端点排序,贪心选最多不重叠区间,选中即收集。

    它把一道看起来复杂的字符串题,干净地拆成了"构造 + 调度"两个经典子问题。这种把新问题归约到已知模型的能力,正是算法题最值得学习的地方。


    附:官解代码(带注释)

    class Solution {
    public:
    vector<string> maxNumOfSubstrings(string s) {
    // 1. 记录每个字符的第一次和最后一次出现位置
    unordered_map<char, pair<int, int>> pos;
    for (int i = 0; i < s.length(); i++) {
    char ch = s[i];
    if (pos.find(ch) == pos.end()) {
    pos[ch] = {i, i};
    } else {
    pos[ch].second = i;
    }
    }

    // 2. 双指针向两侧扩散,构造所有合法区间
    vector<pair<int, int>> valid;
    for (auto& [c, range] : pos) {
    int l = range.first, r = range.second;
    int nl = l, nr = l;

    while (nl >= l || nr <= r) {
    int i = (nl >= l) ? nl : nr;

    int l_t = pos[s[i]].first;
    int r_t = pos[s[i]].second;

    if (l_t < l) l = l_t; // 向左扩
    if (r_t > r) r = r_t; // 向右扩

    if (i == nl) nl–;
    if (i == nr) nr++;
    }

    valid.push_back({l, r});
    }

    // 3. 按右端点升序排序
    sort(valid.begin(), valid.end(),
    [](const pair<int, int>& a, const pair<int, int>& b) {
    return a.second < b.second;
    });

    // 4. 贪心选择互不重叠的区间
    vector<string> ans;
    int end = -1;
    for (auto& [left, right] : valid) {
    if (left > end) {
    ans.push_back(s.substr(left, right – left + 1));
    end = right;
    }
    }

    return ans;
    }
    };

    一句话:官解 = 双指针扩展合法区间 + 按右端点贪心选最多不重叠,O(n) 级,清晰且可证明最优。

    赞(0)
    未经允许不得转载:171主机测评 » 从「无重叠区间」到 LeetCode 1520:一次完整的算法踩坑与优化记录
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址