CHAPTER 19 / 数据结构与算法
数组、哈希与双指针:重复工作从哪里删掉
两数之和与最长无重复区间,能否只扫描一遍?
这一章要弄清楚
- 用不变量证明单次扫描
- 保留索引与重复值信息
- 识别滑动窗口的单调条件
先备知识:拥有一组数据:array、vector与string / 算法与lambda:表达处理意图 / 错误处理与输入解析 / 测试与调试:独立定位失败
ISO C++20;macOS 或 Linux;仅标准库;示例是独立教学程序,非学习者验收。
从朴素解找重复工作
两数之和要求在数组里找到不同位置的两个值,使和等于目标。最直接的办法枚举所有位置对,时间平方级。重复工作在于:对每个新值,都重新扫描过去的所有值。用哈希表保存已经出现的值和位置,就把“寻找补数”变成一次查询。处理当前位置之前,表里只含更早的位置,这个不变量同时保证不会把同一个元素用两次。
例如目标九,先读二,需要七但表为空,于是记下二在零号位置;再读七,需要二,表中找到零号,返回零和一。若输入是三、三,目标六,也能正确匹配两个不同位置,因为查询发生在插入当前元素之前。计算目标减当前值时还应考虑数值溢出,本章用 long long 保存来自 int 输入的补数运算。
排序以后,两个端点能排除整片候选
有序数组也可用左右双指针。若最小端与最大端之和太小,那么固定这个最小值再配任何更小的右值只会更小,所以可以安全丢掉左端;和太大则丢掉右端。关键不是“两个指针很快”,而是每次移动都有排除候选的证明。总共每个指针最多走 n 次,因此扫描阶段线性;若先排序,总成本仍包括排序。
题目若要求原始索引,排序必须把值与索引一起保存,否则顺序改变后无法直接返回原位置。若要求所有不重复值对,还需处理相同值的跳过规则;若要求所有位置对,输出数量本身可能平方级。先确认输出语义,不能把“找到任意一对”的实现直接当作所有变体答案。
滑动窗口维护一个连续区间
最长无重复字符子串是另一个重复工作问题。用窗口 [left,right) 表示当前无重复区间,记录每个字符最近出现的位置。读入新字符时,若它在当前窗口里已出现,left 跳到旧位置后一个;若旧位置已经在窗口外,left 不能倒退。更新最优长度后继续扩右端,便得到单次扫描。
在 abba 中,读第二个 b 时 left 从零跳到二,窗口变成 b;最后读 a 时,它旧位置零已在窗口外,因此 left 保持二,最终窗口 ba,最长长度仍是二。很多错误实现最后把 left 退回一,就错误地把 bba 当成无重复区间。图解逐帧展示的正是这条单调不变量。
线性扫描有适用前提
不是所有“连续子数组”问题都能用同一种滑动窗口。对于非负数求满足阈值的和,扩右端使和不减、缩左端使和不增,常能利用单调性;允许负数后这个关系失效,可能需要前缀和加哈希或其他结构。先确认单调性质,才能决定能否永久丢弃某个端点。
字符串例子还要说明字符单位。下载程序处理字节,不自动把 UTF-8 多字节字符或用户感知字素当成一个字符。对固定字节域可用数组保存最近位置;为了展示一般状态映射,本章采用哈希表。平均时间线性依赖哈希操作平均常数,最坏情况不能不加条件地承诺线性。
面试表达应从证明连接到实现
先说输入和输出,再给朴素解,指出重复工作,说明维护什么状态,解释为什么移动不会漏答案,最后讨论复杂度和测试。至少测试空、单个、重复数、无解及重复字符落在窗口外的情况。写完代码后用这些例子手动走一遍,比马上声称“这是标准模板”更能展示理解。
abba 的无重复窗口
当前窗口 [0,2),两个字节都不同。
上一个 b 在位置 1,left 跳到 2。
旧 a 在 0,已不在当前窗口;left 不退回 1。
阅读完整推演文字
- 读完 ab
window:ab;left:0;best:2
当前窗口 [0,2),两个字节都不同。
- 读入第二个 b
window:b;left:2;best:2
上一个 b 在位置 1,left 跳到 2。
- 读入最后的 a
window:ba;left:2;best:2
旧 a 在 0,已不在当前窗口;left 不退回 1。
跟着例子,走完一遍
哈希表只保存过去的位置
输入 [2,7,11],target=9;补充 [3,3]、单元素和无解检查。
- i=0 查 7 未命中,再保存 2→0。
- i=1 查 2 命中 0。
- 返回不同索引 0、1。
#include <iostream>
#include <optional>
#include <span>
#include <unordered_map>
#include <utility>
#include <vector>
std::optional<std::pair<std::size_t, std::size_t>> two_sum(std::span<const int> data, int target) {
std::unordered_map<long long, std::size_t> seen;
for (std::size_t i = 0; i < data.size(); ++i) {
const long long needed = static_cast<long long>(target) - data[i];
if (const auto it = seen.find(needed); it != seen.end()) return std::pair{it->second, i};
seen.try_emplace(data[i], i);
}
return std::nullopt;
}
int main() {
const std::vector<int> a{2, 7, 11}, duplicate{3, 3}, single{3}, none{1, 2};
const auto result = two_sum(a, 9);
if (!result || !two_sum(duplicate, 6) || two_sum(single, 6) || two_sum(none, 8)) return 1;
std::cout << "indices=" << result->first << ',' << result->second << '\n';
}indices=0,1
先查询再插入避免使用自身;重复值仍能在之后位置匹配。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 15-a.cpp -o example && ./example预期标准输出:
indices=0,1
窗口左边界只能向右
字节串 abba,求最长无重复连续子串长度。
- 读 a、b,窗口长 2。
- 再读 b,left 跳到 2。
- 读末尾 a,旧 a 在窗口外,left 不后退。
#include <algorithm>
#include <iostream>
#include <string_view>
#include <unordered_map>
std::size_t longest_unique(std::string_view text) {
std::unordered_map<char, std::size_t> last;
std::size_t left = 0, best = 0;
for (std::size_t right = 0; right < text.size(); ++right) {
if (const auto it = last.find(text[right]); it != last.end())
left = std::max(left, it->second + 1);
last[text[right]] = right;
best = std::max(best, right - left + 1);
}
return best;
}
int main() {
if (longest_unique("abba") != 2 || longest_unique("") != 0 ||
longest_unique("aaaa") != 1 || longest_unique("abc") != 3) return 1;
std::cout << "abba=" << longest_unique("abba") << " empty=" << longest_unique("") << '\n';
}abba=2 empty=0
取 max(left,last+1) 保留窗口不变量;算法按字节定义,不声称 Unicode 字符语义。
在本机运行这个例子
下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。
clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 15-b.cpp -o example && ./example预期标准输出:
abba=2 empty=0
左边界遇到旧字符就倒退
在 abba 最后读 a,把 left 设成旧位置+1=1,窗口包含重复 b。
修正思路:left=max(left,last+1),只排除当前窗口内的重复,不重新纳入已丢弃区间。
轮到你动手
先写预测或代码,再按需打开提示。完整答案用于对照自己的推理。
练习 1
排序数组 [1,2,4,7,11] 找和为 9 的一对,写出端点移动。
给我一点提示
- 和太大移右端。
- 和太小移左端。
查看答案与推理
先 1+11=12 太大,右端到 7;1+7=8 太小,左端到 2;2+7=9 命中。移动依据有序性,不是凭经验猜哪边。
练习 2
数组允许负数,找和至少为 K 的最短子数组,直接用非负数窗口一定正确吗?
给我一点提示
- 加入负数可能使和变小。
- 移除负数反而使和变大。
查看答案与推理
不保证。非负窗口依赖扩张和不减、收缩和不增,负数破坏它。应重新建模,例如前缀和配单调队列;先用含负数反例检验简单窗口的排除步骤是否仍成立。
把理解说出来
先用中文讲清因果,再用英文回答。问题依据技能主题编写,并非公司内部题库。
two sum 为什么先查询再插入?
参考回答 / English answer
表里只包含更早位置,从而命中必为不同元素,正确处理 [3,3] 而不把单个 3 使用两次。
The map contains only earlier indices. Looking up before inserting prevents the current element from matching itself.abba 最后遇到 a,left 应该变成多少?
参考回答 / English answer
保持 2,因为旧 a 在窗口外;不能回到 1。
Left stays at two because the previous a is outside the current window. Moving left backward would reintroduce the repeated b.排序后直接返回双指针下标为什么可能错?
参考回答 / English answer
题目可能要求原始索引,排序改变位置;需要保存 value,index 对。
Sorting changes positions. If the problem asks for original indices, carry the original index with each value.滑动窗口何时不能直接用?
参考回答 / English answer
没有保证能永久排除端点的单调关系时,例如允许负数的阈值和,需其他状态结构。
A sliding window needs a valid monotonic exclusion argument. Negative values can break the sum behavior that a simple two-pointer window relies on.哈希版 two sum 复杂度怎样准确表述?
参考回答 / English answer
平均 O(n) 时间和 O(n) 空间,最坏哈希行为可使时间退化;输出一个答案。
It uses expected linear time and linear auxiliary space under the usual hash-table assumptions. Worst-case hashing behavior can degrade the time bound.最长无重复字符串题还应确认什么输入语义?
参考回答 / English answer
是字节、Unicode 码点还是字素;窗口单位和索引返回必须一致,当前示例按字节。
I clarify whether a character means a byte, a Unicode code point, or a grapheme cluster. The state representation and returned indices must use the same unit.继续查证
- MIT 6.006 · Data Structures ↗
Lecture 2:序列接口、数组与操作成本
- MIT 6.006 · Hashing ↗
Lecture 4:字典接口、哈希与预期复杂度
公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。
接着看已有的图解
- 原有 C/C++ 编程题库 ↗
按当前缺口选择一题;基础练习仍使用标准 C++20,不按旧站点完成标记解锁 G0。
这些资料按主题补充本章内容。