CHAPTER 19 / 数据结构与算法

数组、哈希与双指针:重复工作从哪里删掉

两数之和与最长无重复区间,能否只扫描一遍?

阅读与推演约 60 分钟练习时间另计

这一章要弄清楚

  • 用不变量证明单次扫描
  • 保留索引与重复值信息
  • 识别滑动窗口的单调条件

先备知识:拥有一组数据: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 的无重复窗口

windowableft0best201 / 03 · MEMORYwindowableft0best201 / 03 · MEMORY
读完 ab

当前窗口 [0,2),两个字节都不同。

1 / 3
阅读完整推演文字
  1. 读完 ab

    window:ab;left:0;best:2

    当前窗口 [0,2),两个字节都不同。

  2. 读入第二个 b

    window:b;left:2;best:2

    上一个 b 在位置 1,left 跳到 2。

  3. 读入最后的 a

    window:ba;left:2;best:2

    旧 a 在 0,已不在当前窗口;left 不退回 1。

跟着例子,走完一遍

例题 01C++20 · 本机可运行

哈希表只保存过去的位置

输入 [2,7,11],target=9;补充 [3,3]、单元素和无解检查。

  1. i=0 查 7 未命中,再保存 2→0。
  2. i=1 查 2 命中 0。
  3. 返回不同索引 0、1。
15-a.cpp
下载
#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';
}

如何编译和运行下载的 .cpp 文件 →

结果与解释

indices=0,1

先查询再插入避免使用自身;重复值仍能在之后位置匹配。

在本机运行这个例子

下载后,在文件所在目录执行。需要支持 C++20 的编译器;POSIX 示例还需要章节说明中的系统条件。

clang++ -std=c++20 -Wall -Wextra -Wpedantic -Werror -pthread 15-a.cpp -o example && ./example

预期标准输出:

indices=0,1
例题 02C++20 · 本机可运行

窗口左边界只能向右

字节串 abba,求最长无重复连续子串长度。

  1. 读 a、b,窗口长 2。
  2. 再读 b,left 跳到 2。
  3. 读末尾 a,旧 a 在窗口外,left 不后退。
15-b.cpp
下载
#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. 和太大移右端。
  2. 和太小移左端。
查看答案与推理

先 1+11=12 太大,右端到 7;1+7=8 太小,左端到 2;2+7=9 命中。移动依据有序性,不是凭经验猜哪边。

练习 2

数组允许负数,找和至少为 K 的最短子数组,直接用非负数窗口一定正确吗?

给我一点提示
  1. 加入负数可能使和变小。
  2. 移除负数反而使和变大。
查看答案与推理

不保证。非负窗口依赖扩张和不减、收缩和不增,负数破坏它。应重新建模,例如前缀和配单调队列;先用含负数反例检验简单窗口的排除步骤是否仍成立。

把理解说出来

先用中文讲清因果,再用英文回答。问题依据技能主题编写,并非公司内部题库。

解释

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.

继续查证

公开资料用于查证;本章图解和例题是独立教学内容。CPU 逻辑模型不能证明设备性能。

接着看已有的图解

这些资料按主题补充本章内容。