第 04 站 · 线性结构
双指针与滑动窗口
可变长度滑动窗口 时间 O(n) · 空间 O(Σ)
先做这道
LC 125验证回文串卡住再看
跳过非字母数字字符并统一大小写,进行相向比较。
要点
- 右端每步前进一格,左端只在窗口不合法时收缩;两个指针都只前进,总共 O(n)。
- 求最长窗口:收缩完再更新答案;求最短窗口:在「仍然合法」的收缩循环里更新答案。
- 有序数组上求两数之和、去重、反转用相向双指针;判环、找中点用快慢指针。
易错点
- 收缩时忘了把移出窗口的元素计数减一,窗口状态就和实际内容对不上。
- 数组里有负数时,「和超过 k 就收缩」不再单调,滑动窗口失效,要改用前缀和。
- 「恰好 k 个」很难直接滑,用「至多 k 个」减去「至多 k-1 个」。
看动画
一步一步播放这个模式在小例子上的执行过程。可以暂停、单步前进或后退,拖动进度条跳到任意一步。
双指针 · 有序数组两数之和
找出和为 14 的两个数
换个输入
- 目标和
- 14
- left
- 0
- right
- 6
- 当前和
- 16
数组已排序。left 指向最小值,right 指向最大值。
始终成立答案如果存在,一定在 [left, right] 之内:被跳过的值不可能和任何剩下的值凑成目标。
滑动窗口 · 无重复字符子串
输入:abcabcbb
换个输入
- left
- 0
- right
- -1
- 窗口长度
- 0
- 最长长度
- 0
从空窗口开始,求不含重复字符的最长子串长度。
始终成立每次加入右端字符后,窗口内所有字符互不相同;left 只向右移动。
模板
选一种语言展开。四种写法实现的是同一个模板,每一份都编译运行过,并用测试数据核对了输出。看完用它重写热身题 LC 125。
Python
from collections import defaultdict
def longest_unique(s: str) -> int:
"""Length of the longest substring without repeated characters."""
cnt = defaultdict(int) # counts inside the window s[left:right+1]
left = best = 0
for right, c in enumerate(s):
cnt[c] += 1
while cnt[c] > 1: # shrink until the window is valid again
cnt[s[left]] -= 1
left += 1
best = max(best, right - left + 1)
return bestC++
// Length of the longest substring without repeated characters.
int longestUnique(const string& s) {
unordered_map<char, int> cnt; // counts inside the window s[left..right]
int left = 0, best = 0;
for (int right = 0; right < (int)s.size(); right++) {
char c = s[right];
cnt[c]++;
while (cnt[c] > 1) cnt[s[left++]]--; // shrink until the window is valid again
best = max(best, right - left + 1);
}
return best;
}Java
class Solution {
// Length of the longest substring without repeated characters.
int longestUnique(String s) {
Map<Character, Integer> cnt = new HashMap<>(); // counts inside the window s[left..right]
int left = 0, best = 0;
for (int right = 0; right < s.length(); right++) {
char c = s.charAt(right);
cnt.merge(c, 1, Integer::sum);
while (cnt.get(c) > 1) cnt.merge(s.charAt(left++), -1, Integer::sum); // shrink
best = Math.max(best, right - left + 1);
}
return best;
}
}Go
// longestUnique returns the length of the longest substring without repeated bytes.
func longestUnique(s string) int {
cnt := map[byte]int{} // counts inside the window s[left:right+1]
left, best := 0, 0
for right := 0; right < len(s); right++ {
c := s[right]
cnt[c]++
for cnt[c] > 1 { // shrink until the window is valid again
cnt[s[left]]--
left++
}
best = max(best, right-left+1)
}
return best
}常见变体
这一站的题还会用到下面 3 种写法。展开看什么时候换用它,代码同样四种语言都跑过测试。
定长滑动窗口时间 O(n) · 空间 O(1)
窗口长度 k 固定(长度为 k 的子串里元素最多、某个排列是否出现):右端每进一个,左端 i - k 就出一个,窗口满 k 之后才开始记答案。LC 1456、LC 438 找到字符串中所有字母异位词、LC 567 字符串的排列都是这个形状。
Python
def max_vowels(s: str, k: int) -> int:
"""Most vowels in any length-k substring (LC 1456)."""
vowels = set("aeiou")
cur = best = 0
for i, c in enumerate(s):
cur += c in vowels # c enters the window
if i >= k:
cur -= s[i - k] in vowels # s[i - k] leaves: the window is s[i-k+1..i]
if i >= k - 1: # only full windows count
best = max(best, cur)
return bestC++
// Most vowels in any length-k substring (LC 1456).
int maxVowels(const string& s, int k) {
auto vowel = [](char c) { return string("aeiou").find(c) != string::npos; };
int cur = 0, best = 0;
for (int i = 0; i < (int)s.size(); i++) {
cur += vowel(s[i]); // s[i] enters the window
if (i >= k) cur -= vowel(s[i - k]); // s[i - k] leaves: the window is s[i-k+1..i]
if (i >= k - 1) best = max(best, cur); // only full windows count
}
return best;
}Java
class Solution {
// Most vowels in any length-k substring (LC 1456).
int maxVowels(String s, int k) {
int cur = 0, best = 0;
for (int i = 0; i < s.length(); i++) {
if (vowel(s.charAt(i))) cur++; // s[i] enters the window
if (i >= k && vowel(s.charAt(i - k))) cur--; // s[i - k] leaves: the window is s[i-k+1..i]
if (i >= k - 1) best = Math.max(best, cur); // only full windows count
}
return best;
}
private boolean vowel(char c) {
return "aeiou".indexOf(c) >= 0;
}
}Go
import "strings"
// maxVowels returns the most vowels in any length-k substring (LC 1456).
func maxVowels(s string, k int) int {
vowel := func(c byte) int {
if strings.IndexByte("aeiou", c) >= 0 {
return 1
}
return 0
}
cur, best := 0, 0
for i := 0; i < len(s); i++ {
cur += vowel(s[i]) // s[i] enters the window
if i >= k {
cur -= vowel(s[i-k]) // s[i - k] leaves: the window is s[i-k+1..i]
}
if i >= k-1 { // only full windows count
best = max(best, cur)
}
}
return best
}相向双指针:每次移动较短的一边时间 O(n) · 空间 O(1)
答案由两端共同决定、而且可以证明「较差的那一端」不可能再参与更好的答案时,用两个指针从两头往中间收。LC 11 盛最多水的容器每次移动较矮的墙;LC 167、LC 15 三数之和在有序数组上按和的大小移动。
Python
def max_area(height: list[int]) -> int:
"""Most water two walls can hold (LC 11)."""
lo, hi = 0, len(height) - 1
best = 0
while lo < hi:
best = max(best, (hi - lo) * min(height[lo], height[hi]))
if height[lo] < height[hi]: # the shorter wall caps every narrower pair it is in
lo += 1
else:
hi -= 1
return bestC++
// Most water two walls can hold (LC 11).
long long maxArea(const vector<int>& height) {
int lo = 0, hi = (int)height.size() - 1;
long long best = 0;
while (lo < hi) {
best = max(best, (long long)(hi - lo) * min(height[lo], height[hi]));
if (height[lo] < height[hi]) lo++; // the shorter wall caps every narrower pair it is in
else hi--;
}
return best;
}Java
class Solution {
// Most water two walls can hold (LC 11).
long maxArea(int[] height) {
int lo = 0, hi = height.length - 1;
long best = 0;
while (lo < hi) {
best = Math.max(best, (long) (hi - lo) * Math.min(height[lo], height[hi]));
if (height[lo] < height[hi]) lo++; // the shorter wall caps every narrower pair it is in
else hi--;
}
return best;
}
}Go
// maxArea returns the most water two walls can hold (LC 11).
func maxArea(height []int) int {
lo, hi := 0, len(height)-1
best := 0
for lo < hi {
best = max(best, (hi-lo)*min(height[lo], height[hi]))
if height[lo] < height[hi] { // the shorter wall caps every narrower pair it is in
lo++
} else {
hi--
}
}
return best
}恰好 K 个 = 至多 K 个 − 至多 K−1 个时间 O(n) · 空间 O(n)
要数「恰好 K 个不同值 / 恰好 K 个奇数 / 和恰好为 K」的子数组,窗口不满足单调收缩;但「至多 K 个」满足:右端每到一个位置,以它结尾的合法窗口有 right - left + 1 个。两次相减得到恰好 K。LC 992、LC 930、LC 1248 都这么做。
Python
from collections import defaultdict
def subarrays_with_k_distinct(nums: list[int], k: int) -> int:
"""Subarrays with exactly k distinct values (LC 992)."""
def at_most(k: int) -> int: # subarrays with at most k distinct values
cnt = defaultdict(int)
left = total = 0
for right, x in enumerate(nums):
cnt[x] += 1
if cnt[x] == 1:
k -= 1
while k < 0: # one distinct value too many: shrink from the left
cnt[nums[left]] -= 1
if cnt[nums[left]] == 0:
k += 1
left += 1
total += right - left + 1 # windows that end at right and start at or after left
return total
return at_most(k) - at_most(k - 1)C++
// Subarrays with exactly k distinct values (LC 992).
long long subarraysWithKDistinct(const vector<int>& nums, int k) {
auto atMost = [&](int k) { // subarrays with at most k distinct values
unordered_map<int, int> cnt;
long long total = 0;
for (int left = 0, right = 0; right < (int)nums.size(); right++) {
if (++cnt[nums[right]] == 1) k--;
while (k < 0) // one distinct value too many: shrink from the left
if (--cnt[nums[left++]] == 0) k++;
total += right - left + 1; // windows that end at right and start at or after left
}
return total;
};
return atMost(k) - atMost(k - 1);
}Java
class Solution {
// Subarrays with exactly k distinct values (LC 992).
long subarraysWithKDistinct(int[] nums, int k) {
return atMost(nums, k) - atMost(nums, k - 1);
}
// Subarrays with at most k distinct values.
private long atMost(int[] nums, int k) {
Map<Integer, Integer> cnt = new HashMap<>();
long total = 0;
for (int left = 0, right = 0; right < nums.length; right++) {
if (cnt.merge(nums[right], 1, Integer::sum) == 1) k--;
while (k < 0) // one distinct value too many: shrink from the left
if (cnt.merge(nums[left++], -1, Integer::sum) == 0) k++;
total += right - left + 1; // windows that end at right and start at or after left
}
return total;
}
}Go
// subarraysWithKDistinct counts the subarrays with exactly k distinct values (LC 992).
func subarraysWithKDistinct(nums []int, k int) int {
atMost := func(k int) int { // subarrays with at most k distinct values
cnt := map[int]int{}
left, total := 0, 0
for right, x := range nums {
cnt[x]++
if cnt[x] == 1 {
k--
}
for k < 0 { // one distinct value too many: shrink from the left
cnt[nums[left]]--
if cnt[nums[left]] == 0 {
k++
}
left++
}
total += right - left + 1 // windows that end at right and start at or after left
}
return total
}
return atMost(k) - atMost(k-1)
}为什么对
- 收缩左端不会漏解:如果 [l, r] 已经不合法,那么左端还是 l、右端 ≥ r 的窗口也都不合法,所以 l 可以永久右移。
- 前提是合法性对窗口包含关系单调:一个窗口合法,它的每个子窗口也合法。含负数的子数组和不满足这一点,所以要改用前缀和。
- 复杂度:l 和 r 都只增不减,各自最多走 n 步,所以内层 while 的总次数是 O(n),不是 O(n²)。
30 秒口述稿
面试官让你讲思路时,按做法、为什么对、复杂度的顺序说。中英文各一版,可以对着读几遍。
我用可变滑动窗口:右端每一步都扩张,窗口一旦不合法就移动左端直到恢复合法,在合适的位置更新答案。正确性靠单调性:一个窗口不合法时,同一个左端配更远的右端也不会合法,所以左端可以放心右移、不会漏解。两个指针都只往前走,总共 O(n)。如果数组里有负数导致这种单调性不成立,我会换成前缀和加哈希表。I use a variable-size sliding window: the right end grows every step, and while the window is invalid the left end moves forward until it is valid again. It is correct because validity is monotone: if a window is invalid, keeping the same left end with a farther right end cannot fix it, so moving the left end never skips an answer. Both pointers only move forward, so the total work is O(n). If negative numbers break that monotonicity, I switch to prefix sums with a hash map.自评一下
对照上面的答案给自己打个分。没掌握的题会按 1、3、7、21 天的间隔回来,到期的题在「我的进度 · 到期复习」里。 只存在这台设备上;登录后会合并到你的账号。
小测
4 道概念题和复杂度题。答过的题会和 OA 选择题练习一起按间隔回来复习。 去 OA 选择题练习
作答记录只保存在这台设备的浏览器里;登录后打开OA 选择题练习页,会存进账号。
模板练习
用上面的模板和变体出题:把打乱的行排回原序,把挖掉的边界条件填回去,或者在空白编辑器里整段默写,跑本站的测试并逐行对比。每次「换一题」都会重新出题。
下面几行被打乱了。点一行选中,再点另一行和它交换,排回模板原来的顺序;缩进是提示。
分档练习
本站在 500 路里的 38 道题,按基础、核心、进阶分三档。每题附一句话思路,先自己想,想不出再看。
基础 5
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 125 | 验证回文串 | 跳过非字母数字字符并统一大小写,进行相向比较。 | |
| 167 | 两数之和 II - 输入有序数组 | 利用有序性移动左右指针,每次排除不可能的配对。 | |
| 977 | 有序数组的平方 | 有序数组的最大平方位于两端,从输出末尾填充。 | |
| 1456 | 定长子串中元音的最大数目 | 维护定长窗口计数,加入新字符并移除旧字符。 | |
| 2090 | 半径为 k 的子数组平均值 | 把半径平均值映射到固定长度窗口,明确两端不足长度的位置。 |
核心 26
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 11 | 盛最多水的容器 | 证明移动较短边才可能改进容器面积,避免枚举所有端点。 | |
| 42 | 接雨水 | 用左右最大高度确定蓄水量,比较前后缀预处理、双指针和单调栈,并解释为何移动较低边界。 | |
| 15 | 三数之和 | 固定一个元素后使用相向双指针,并在各层去重。 | |
| 16 | 最接近的三数之和 | 维护最接近目标的和,区分指针移动方向与最优值更新。 | |
| 680 | 验证回文串 II | 首次失配时只需尝试删除两端之一,避免遍历所有删除位置。 | |
| 844 | 比较含退格的字符串 | 从后向前计数退格,让跳过已删除字符的过程保持常数空间。 | |
| 3 | 无重复字符的最长子串 | 维护无重复字符窗口,正确推进左边界而不回退。 | |
| 209 | 长度最小的子数组 | 利用正数条件收缩满足目标的窗口,求最短长度。 | |
| 438 | 找到字符串中所有字母异位词 | 用频次差或匹配计数判断每个定长窗口是否为异位词。 | |
| 567 | 字符串的排列 | 把排列包含问题转为定长频次匹配,并在首次匹配时结束。 | |
| 1052 | 爱生气的书店老板 | 分开固定收益与可选窗口额外收益,避免重复计算满意顾客。 | |
| 159 | 至多包含两个不同字符的最长子串 🔒 | 维护最多两类字符的窗口,并在某类频次归零时删除它。 | |
| 340 | 至多包含 K 个不同字符的最长子串 🔒 | 把最多两类推广为最多k类,保持窗口频次与类别数一致。 | |
| 904 | 水果成篮 | 将两种水果约束建模为最多两类元素的最长连续窗口。 | |
| 424 | 替换后的最长重复字符 | 由窗口长度减去最大频次计算替换需求,理解历史最大频次优化。 | |
| 1004 | 最大连续1的个数 III | 把可翻转零的数量视为预算,维护预算内的最长窗口。 | |
| 1493 | 删掉一个元素以后全为 1 的最长子数组 | 将必须删除一个元素映射为允许一个零的窗口,并处理全一数组。 | |
| 1208 | 尽可能使字符串相等 | 把两字符串位置差转成非负成本,滑窗维护总预算。 | |
| 713 | 乘积小于 K 的子数组 | 利用正整数乘积的单调性计数窗口内所有合法后缀。 | |
| 1658 | 将 x 减到 0 的最小操作数 | 把两端删除转为保留中间最长目标和子数组。 | |
| 1838 | 最高频元素的频数 | 排序后用窗口长度和总和计算补齐成本,再寻找最大可行窗口。 | |
| 1234 | 替换子串得到平衡字符串 | 维护窗口外字符频次,把替换合法性转化为外部计数约束。 | |
| 1358 | 包含所有三种字符的子字符串数目 | 找到覆盖三类字符的最小窗口后,批量统计其合法扩展。 | |
| 2024 | 考试的最大困扰度 | 分别考虑统一为两种答案之一,把修改次数作为窗口预算。 | |
| 2516 | 每种字符至少取 K 个 | 把从两端取字符转换为中间可保留字符的频次上限。 | |
| 2302 | 统计得分小于 K 的子数组数目 | 维护窗口和乘长度的得分,并批量计数满足上界的后缀。 |
进阶 7
| # | 题目 | 一句话思路 | 难度分 |
|---|---|---|---|
| 18 | 四数之和 | 推广多层固定与双指针,处理重复值、剪枝及整数溢出。 | |
| 76 | 最小覆盖子串 | 区分字符种类满足与总频次满足,收缩覆盖目标的最小窗口。 | |
| 30 | 串联所有单词的子串 | 按单词长度分组滑动,维护单词多重集合而非字符频次。 | |
| 395 | 至少有 K 个重复字符的最长子串 | 枚举目标字符种类数或按低频字符分治,解决不直接单调的窗口约束。 | |
| 992 | K 个不同整数的子数组 | 用至多k类减去至多k−1类统计恰好k类的子数组。 | |
| 1438 | 绝对差不超过限制的最长连续子数组 | 用两个单调队列维护窗口极值,配合左边界满足差值限制。 | |
| 2537 | 统计好子数组的数目 | 增删一个元素时增量维护相等对数,统计达到阈值的窗口。 |
本站 OA 真题 145
- Longest Subarray With Value Frequency > ThresholdMeta
- Find Pair Closest to K (for E5 :)Meta
- Max Consecutive Ones III (for E5 :)Meta
- Merge Three Sorted ArraysMeta
- Sliding Window AverageMeta
- Combine Two Vectors of Intervals (MLE)Meta
- Enumerating Narrative SectionsMeta
- Self-Sufficient SubstringAmazon
- Find Similarities (Password Subsequence)Amazon
- Find Maximum PackagesAmazon