3.3 双指针技巧
学习目标:学完本节,你能
- 识别哪些问题适合用双指针;
- 写出相向双指针与同向双指针两种形态;
- 说清"为什么指针可以这样移动" —— 这是双指针唯一需要动脑的地方。
先修:3.1(随机访问 $O(1)$)、3.2(同向双指针的雏形见练习 3.2.5)。 固定术语:双指针、相向双指针、同向双指针。 环境与版本:.NET 8 / C# 12。 预计阅读:28 分钟。
一、直觉:双指针不是"技巧",是"排除"
双指针听起来像个小把戏,但它背后有一个非常朴素的想法:
暴力解法里,绝大多数配对是明显不可能的,但暴力还是老老实实试了一遍。双指针的作用,就是一次性排除掉一整批不可能的组合。
举个例子:在有序数组里找两个数,使它们的和等于 100。
数组是 [1, 3, 5, 8, 12, 30, 70, 90]。
暴力会试 (1,3)、(1,5)、(1,8)…… 一共 $\frac{n(n-1)}{2}$ 对。
但如果你先看最小的 1 和最大的 90:
$$1 + 90 = 91 < 100$$
这说明什么?说明 1 和数组里任何一个数相加,都不可能到 100 —— 因为 90 已经是最大的了,配谁都超不过 91。
所以 1 这个数可以彻底扔掉了。 一次比较,排除掉一整列。
再看下一个:3 + 90 = 93,还是不够,3 也扔掉。
5 + 90 = 95,扔掉。
8 + 90 = 98,扔掉。
12 + 90 = 102 > 100 —— 这次是太大了。说明 90 和任何一个数相加都太大(因为 12 已经是最小的剩下的了),90 可以扔掉了。
12 + 70 = 82 < 100,12 扔掉。
30 + 70 = 100 ✓ 找到了。
整个过程只比较了 7 次,而不是 28 次。 而且数组越大,节省越夸张 —— 因为每一次比较排除掉的不是一个配对,而是一整批配对。
二、形式化:两种形态
| 形态 | 指针怎么动 | 典型问题 |
|---|---|---|
| 相向双指针 | 一个从左往右,一个从右往左,向中间逼近 | 有序数组两数之和、反转数组、判断回文 |
| 同向双指针 | 都从左往右,但速度不同(快慢指针) | 原地去重、原地删除、找链表中点(4.4 节) |
三、相向双指针:有序数组两数之和
新建控制台项目,粘贴代码:
using System.Diagnostics;
// ==================== 相向双指针:有序数组两数之和 ====================
// 暴力:所有配对都试一遍,O(n^2)
static (int, int)? TwoSumBrute(int[] a, int target, ref long ops)
{
for (int i = 0; i < a.Length; i++)
{
for (int j = i + 1; j < a.Length; j++)
{
ops++;
if (a[i] + a[j] == target) return (i, j);
}
}
return null;
}
// 相向双指针:一左一右向中间逼近,O(n)
static (int, int)? TwoSumTwoPointers(int[] a, int target, ref long ops)
{
int lo = 0, hi = a.Length - 1;
while (lo < hi)
{
ops++;
int sum = a[lo] + a[hi];
if (sum == target) return (lo, hi);
if (sum < target) lo++; // 和太小 -> 左指针右移,换个大一点的数
else hi--; // 和太大 -> 右指针左移,换个小一点的数
}
return null;
}
// ==================== 同向双指针:原地去重 ====================
// 数组已有序,把所有重复元素就地删掉,返回新的长度
static int RemoveDuplicates(int[] a, ref long ops)
{
if (a.Length == 0) return 0;
int slow = 0; // slow 指向「保留区」的最后一个元素
for (int fast = 1; fast < a.Length; fast++)
{
ops++;
if (a[fast] != a[slow]) // 发现新值
{
slow++;
a[slow] = a[fast]; // 把它搬到保留区的下一个位置
}
}
return slow + 1; // 新长度
}
// ==================== 主流程 ====================
const int N = 20_000;
var sorted = new int[N];
for (int i = 0; i < N; i++) sorted[i] = i * 2; // 0, 2, 4, ... 全是偶数
Console.WriteLine("=== 实验一:有序数组两数之和(目标不存在,最坏情况)===");
Console.WriteLine($"数据规模: {N:N0},目标: -1(不存在,两种做法都必须跑完)");
Console.WriteLine();
long bruteOps = 0;
var sw = Stopwatch.StartNew();
var r1 = TwoSumBrute(sorted, -1, ref bruteOps);
sw.Stop();
double bruteMs = sw.Elapsed.TotalMilliseconds;
long tpOps = 0;
sw.Restart();
var r2 = TwoSumTwoPointers(sorted, -1, ref tpOps);
sw.Stop();
double tpMs = sw.Elapsed.TotalMilliseconds;
Console.WriteLine($" 暴力双层循环: 比较 {bruteOps,14:N0} 次 | {bruteMs,9:F2} ms | 结果 = {r1?.ToString() ?? "未找到"}");
Console.WriteLine($" 相向双指针 : 比较 {tpOps,14:N0} 次 | {tpMs,9:F2} ms | 结果 = {r2?.ToString() ?? "未找到"}");
Console.WriteLine($" 双指针少比较 {bruteOps / (double)tpOps:N0} 倍");
Console.WriteLine();
// 换一个目标值,结论会反转 —— 这一点非常重要
Console.WriteLine(" 换一个目标值(target = 100),结论反转了:");
long o1 = 0, o2 = 0;
var x1 = TwoSumBrute(sorted, 100, ref o1);
var x2 = TwoSumTwoPointers(sorted, 100, ref o2);
Console.WriteLine($" 暴力 : 比较 {o1,8:N0} 次,找到下标 {x1}");
Console.WriteLine($" 双指针: 比较 {o2,8:N0} 次,找到下标 {x2}");
Console.WriteLine();
Console.WriteLine(" 暴力这次只比了 50 次就命中(i=0 时很快配上了 a[50]);");
Console.WriteLine(" 双指针反而比了 19,950 次(右边界要从最右边一路退到 50)。");
Console.WriteLine();
Console.WriteLine(" -> 单次跑得快不快,取决于具体的输入数据。");
Console.WriteLine(" 真正有意义的是「规模放大时的趋势」—— 见下面的实验三。");
Console.WriteLine();
Console.WriteLine("=== 实验二:原地去重(同向双指针)===");
int[] dup = { 1, 1, 2, 2, 2, 3, 5, 5, 8, 9, 9, 9, 9, 10 };
Console.WriteLine($" 原数组: [{string.Join(", ", dup)}]");
long dedupOps = 0;
int newLen = RemoveDuplicates(dup, ref dedupOps);
Console.WriteLine($" 去重后: [{string.Join(", ", dup.Take(newLen))}] 新长度 = {newLen}");
Console.WriteLine($" 比较次数 = {dedupOps}(元素个数 - 1),空间开销 = O(1),没有新建任何数组");
Console.WriteLine();
// 用 HashSet 的做法做交叉验证
var expect = dup.Take(newLen).Distinct().Count();
Console.WriteLine($" 交叉验证:去重后无重复 = {expect == newLen}");
Console.WriteLine();
Console.WriteLine("=== 实验三:规模放大后的对比 ===");
Console.WriteLine($"{"数据规模",12} | {"暴力比较次数",16} | {"双指针比较次数",16} | {"倍数",10}");
foreach (int n in new[] { 1_000, 5_000, 20_000 })
{
var arr = new int[n];
for (int i = 0; i < n; i++) arr[i] = i * 2;
long b = 0, t = 0;
TwoSumBrute(arr, -1, ref b);
TwoSumTwoPointers(arr, -1, ref t);
Console.WriteLine($"{n,12:N0} | {b,16:N0} | {t,16:N0} | {(double)b / t,9:N0} 倍");
}
Console.WriteLine();
Console.WriteLine(" 暴力是 n(n-1)/2,双指针是 n-1。数据规模涨 20 倍,倍数涨 20 倍 —— 这就是 O(n^2) 与 O(n) 的区别。");
实测输出(.NET 8 Release):
=== 实验一:有序数组两数之和(目标不存在,最坏情况)===
数据规模: 20,000,目标: -1(不存在,两种做法都必须跑完)
暴力双层循环: 比较 199,990,000 次 | 45.06 ms | 结果 = 未找到
相向双指针 : 比较 19,999 次 | 0.63 ms | 结果 = 未找到
双指针少比较 10,000 倍
换一个目标值(target = 100),结论反转了:
暴力 : 比较 50 次,找到下标 (0, 50)
双指针: 比较 19,950 次,找到下标 (0, 50)
=== 实验二:原地去重(同向双指针)===
原数组: [1, 1, 2, 2, 2, 3, 5, 5, 8, 9, 9, 9, 9, 10]
去重后: [1, 2, 3, 5, 8, 9, 10] 新长度 = 7
比较次数 = 13(元素个数 - 1),空间开销 = O(1),没有新建任何数组
交叉验证:去重后无重复 = True
=== 实验三:规模放大后的对比 ===
数据规模 | 暴力比较次数 | 双指针比较次数 | 倍数
1,000 | 499,500 | 999 | 500 倍
5,000 | 12,497,500 | 4,999 | 2,500 倍
20,000 | 199,990,000 | 19,999 | 10,000 倍
三个结论:
规模放大时,双指针碾压暴力:20,000 个元素时差了 10,000 倍。而且注意倍数的增长 —— 规模涨 5 倍(5,000 → 20,000 是 4 倍),倍数涨 4 倍(2,500 → 10,000)。这正是 $O(n^2)$ 与 $O(n)$ 的差距随规模线性扩大。
但在单次具体输入上,双指针可能更慢:
target = 100时,暴力 50 次就命中了,双指针反而要 19,950 次。这印证了 1.3 节的结论 —— 谈复杂度必须配上前提(最好/最坏/平均)。双指针的价值在于"下限有保障":不管你给什么数据,它最多走 $n$ 步。而暴力的代价完全取决于你运气好不好。
四、为什么可以这样移动指针?(正确性论证)
这是双指针唯一需要动脑的地方。 写代码只要 5 行,但想清楚"为什么这样移是对的"才是关键。
设数组升序排列,当前左右指针指向 $a[lo]$ 和 $a[hi]$,目标和为 $target$。
情况一:$a[lo] + a[hi] < target$
因为数组升序,所以对于任何 $j \le hi$,都有 $a[j] \le a[hi]$,于是:
$$a[lo] + a[j] \le a[lo] + a[hi] < target$$
这意味着:$a[lo]$ 和右边任何一个数相加,都不可能等于 $target$。
所以 $a[lo]$ 可以彻底排除,$lo$ 右移一格。我们一次排除了 $(hi - lo)$ 个配对。
情况二:$a[lo] + a[hi] > target$
同理,对于任何 $i \ge lo$,都有 $a[i] \ge a[lo]$,于是:
$$a[i] + a[hi] \ge a[lo] + a[hi] > target$$
$a[hi]$ 和左边任何一个数相加,都太大。 $a[hi]$ 彻底排除,$hi$ 左移。
一句话总结: 每次移动指针,不是"少试了一个配对",而是"排除了一整批配对"。
总共要排除 $\frac{n(n-1)}{2}$ 个配对,而每次操作排除一整行或一整列 —— 所以只需要 $n$ 次操作。这就是 $O(n^2) \to O(n)$ 的来源。
这个论证依赖两个条件,缺一不可:
- 数组有序。 无序的话,"左边的都比 $a[lo]$ 大"这类推断就不成立。
- 指针移动的方向是可判定的。 这里靠"和太小就增大左边的数,太大就减小右边的数"来决定往哪边挪。
做双指针题时,一定要能回答这个问题:「我这一步移动,排除掉了哪些可能性?」 答不上来,就说明你只是在"背模板",换个题就会写错。
五、同向双指针:原地去重
再看实验二的代码:
int slow = 0; // slow 指向「保留区」的最后一个元素
for (int fast = 1; fast < a.Length; fast++)
{
if (a[fast] != a[slow]) // 发现新值
{
slow++;
a[slow] = a[fast]; // 把它搬到保留区的下一个位置
}
}
return slow + 1;
两个指针的分工:
fast(快指针):负责扫描,把每个元素都看一遍。slow(慢指针):负责标记,指向"已经确认要保留"的那段区域的末尾。
执行过程(以 [1,1,2,2,2,3] 为例):
| 步骤 | fast | slow | a[fast] | a[slow] | 动作 | 数组状态 |
|---|---|---|---|---|---|---|
| 初始 | 1 | 0 | 1 | 1 | 相同,跳过 | [1,1,2,2,2,3] |
| 1 | 2 | 0 | 2 | 1 | 不同,slow→1,搬运 | [1,2,2,2,2,3] |
| 2 | 3 | 1 | 2 | 2 | 相同,跳过 | [1,2,2,2,2,3] |
| 3 | 4 | 1 | 2 | 2 | 相同,跳过 | [1,2,2,2,2,3] |
| 4 | 5 | 1 | 3 | 2 | 不同,slow→2,搬运 | [1,2,3,2,2,3] |
最终 slow = 2,返回 slow + 1 = 3,a[0..2] 就是 [1, 2, 3] ✓
注意几个关键点:
- 没有新建数组。 所有搬运都在原数组上进行,额外空间 $O(1)$。
- 快指针从不回头看。 每个元素只被访问一次,所以是 $O(n)$。
- 慢指针永远不会超过快指针。 因为
slow只在发现"新值时"才slow++,而"新值"最多出现 $n$ 次。
这种写法的通用价值:很多"原地修改数组"的问题都能套这个模板 —— 原地删除、原地移动、原地压缩(比如把一堆空格压缩成一个)。关键是想清楚"慢指针在标记什么"。
六、什么时候该想到双指针?
| 信号 | 说明 |
|---|---|
| ✅ 数组是有序的 | 相向双指针的前提 |
| ✅ 要找一对满足条件的元素 | 两数之和、两数之差 |
| ✅ 要原地修改数组 | 同向双指针(去重、删除、压缩) |
| ✅ 要找连续的一段 | 相向双指针或滑动窗口(3.4 节) |
| ❌ 数组无序,且不能排序 | 这时通常该用哈希表(第 6 章) |
| ❌ 要找的不是"对"而是一整组 | 可能该用回溯(超出本书范围) |
小提示:如果题目要求"找两个数",而数组无序,通常有两条路:
- 先排序,再用双指针 —— $O(n \log n)$
- 用哈希表 —— $O(n)$ 时间,但 $O(n)$ 空间
哪个更好取决于你要不要保留原始下标。排序会打乱下标,如果答案需要返回原始位置,哈希表就更合适。
七、练习
练习 3.3.1(写出移动规则) 在"有序数组两数之和"里,如果数组是降序排列的,指针的移动规则要怎么写?请写出判断条件。
练习 3.3.2(判断正确性) 下面这段"两数之和"的代码有 bug,请指出问题所在:
static (int, int)? FindPair(int[] a, int target)
{
int lo = 0, hi = a.Length - 1;
while (lo < hi)
{
int sum = a[lo] + a[hi];
if (sum == target) return (lo, hi);
if (sum < target) hi--; // 注意这里
else lo++;
}
return null;
}
练习 3.3.3(改写)
用同向双指针改写下面的方法。原方法用 List.RemoveAt 逐个删除,是 $O(n^2)$。
// 删除数组中所有等于 value 的元素,返回新长度
static int RemoveValue(List<int> list, int value)
{
for (int i = list.Count - 1; i >= 0; i--)
if (list[i] == value)
list.RemoveAt(i);
return list.Count;
}
练习 3.3.4(变体) 给你一个有序数组,找出两个数使它们的差的绝对值最小。请设计一个双指针算法,说明指针怎么移动,以及为什么这样移是对的。
练习 3.3.5(挑战·三数之和) 给定一个数组,找出所有满足 $a + b + c = 0$ 的三元组(不能重复)。 (a) 暴力解法是什么复杂度? (b) 如果先排序,再固定一个数、对剩下的部分用双指针,复杂度是多少? (c) 说明这个"降维"思路:为什么把三数之和变成了两数之和?
八、练习答案
3.3.1
降序数组里,$a[lo] \ge a[hi]$(左边大、右边小)。所以:
int lo = 0, hi = a.Length - 1;
while (lo < hi)
{
int sum = a[lo] + a[hi];
if (sum == target) return (lo, hi);
if (sum < target) hi--; // 和太小 -> 需要更大的数 -> 右边小,所以 hi 左移
else lo++; // 和太大 -> 需要更小的数 -> 左边大,所以 lo 右移
}
对比升序版本,两个分支的移动方向完全反过来了。
不要背代码,要理解方向。 判断方法永远是问自己:
- "和太小了,我需要一个更大的数,哪边的指针往哪移能拿到更大的数?"
- 升序:左边往右 = 变大,右边往左 = 变小。
- 降序:左边往右 = 变小,右边往左 = 变大。
3.3.2
两个分支的移动方向写反了。
正确写法是:和太小 → lo++(去拿更大的数);和太大 → hi--(去拿更小的数)。
原代码写成了"和太小 → hi--",方向正好相反。这会导致:
- 当
sum < target时,本应增大左边的数,却把右指针左移(让和更小); - 结果和只会越来越小,永远追不上
target,即使存在答案也会返回null。
验证:数组 [1, 3, 5, 8, 12, 30, 70],target = 42(12 + 30 = 42)。
- 原代码:
1 + 70 = 71 > 42→lo++;3 + 70 = 73 > 42→lo++;…… 一路lo++到lo == hi,返回null。答案就在数组里,但找不到。 - 正确代码:
1 + 70 = 71 > 42→hi--;1 + 30 = 31 < 42→lo++;3 + 30 = 33 < 42→lo++;5 + 30 = 35 < 42→lo++;8 + 30 = 38 < 42→lo++;12 + 30 = 42✓
这类错误的可怕之处:它不会抛异常,只是"悄悄找不到答案"。所以写完双指针一定要用存在答案的用例测试,不能只测"找不到"的情况。
3.3.3
// 用数组 + 同向双指针,O(n)
static int RemoveValue(int[] a, int value)
{
int slow = 0; // 保留区的下一个空位
for (int fast = 0; fast < a.Length; fast++)
{
if (a[fast] != value)
{
a[slow] = a[fast]; // 保留的元素搬到前面
slow++;
}
}
return slow; // 新长度
}
要点:
- 前提是能用数组。 原方法接收
List<int>,双指针的关键在于"按下标随机读写",所以要先能拿到底层数组。在 C# 里可以用CollectionsMarshal.AsSpan(list)拿到List<T>的底层Span<T>,或者干脆改用数组。 - 不要在
List<T>上手动RemoveAt,那是 $O(n)$ 的。正确做法是就地覆盖 + 最后截断。C# 的List<T>.RemoveAll就是这么实现的($O(n)$),优先直接用它。 - 注意和"去重"的区别:去重比较的是
a[fast] != a[slow](和保留区的最后一个比),这里是a[fast] != value(和一个固定值比)。模板一样,判断条件不同。
3.3.4
算法:
static (int, int)? MinAbsDiffPair(int[] a) // a 必须是有序的
{
if (a.Length < 2) return null;
int lo = 0, hi = 1; // 相邻两个开始
int bestLo = 0, bestHi = 1;
int bestDiff = a[1] - a[0];
while (hi < a.Length)
{
int diff = a[hi] - a[lo]; // 因为有序,diff 一定 >= 0
if (diff < bestDiff)
{
bestDiff = diff;
bestLo = lo;
bestHi = hi;
}
if (diff == 0) return (lo, hi); // 已经最小了,直接返回
// 关键:和「两数之和」不同,这里两个指针是同向的
if (hi - lo == 1)
hi++; // 只差一格时,必须扩大右边界
else
lo++; // 否则收缩左边界,看看能不能更小
}
return (bestLo, bestHi);
}
更简洁、也更常见的写法(推荐):
static int MinAbsDiffSorted(int[] a) // 有序数组
{
int best = int.MaxValue;
for (int i = 1; i < a.Length; i++)
best = Math.Min(best, a[i] - a[i - 1]); // 最小差值一定出现在相邻元素之间
return best;
}
为什么相邻就够? 因为数组有序,对于任意 $i < j$,$a[j] - a[i] = (a[j] - a[j-1]) + \cdots + (a[i+1] - a[i])$,每一项都非负。所以 $a[j] - a[i]$ 一定大于等于其中任何一段相邻差值。因此最小差值必然出现在某一对相邻元素上。
复杂度:$O(n)$ 一次遍历,$O(1)$ 额外空间。
这个例子的意义:不要看到"找两个数"就套双指针。先分析问题的结构 —— 有时候一个更简单的观察(比如"最小差一定在相邻元素间")就能直接把问题变简单。双指针是手段,不是目的。
3.3.5
(a) 暴力:三重循环枚举所有三元组,$O(n^3)$。
(b) 排序 + 固定一个数 + 双指针:$O(n^2)$。
排序 $O(n \log n)$,然后:
static List<(int, int, int)> ThreeSum(int[] nums)
{
Array.Sort(nums);
var result = new List<(int, int, int)>();
for (int i = 0; i < nums.Length - 2; i++)
{
if (i > 0 && nums[i] == nums[i - 1]) continue; // 跳过重复的固定值
int lo = i + 1, hi = nums.Length - 1;
int target = -nums[i]; // 关键:把三数之和变成两数之和
while (lo < hi)
{
int sum = nums[lo] + nums[hi];
if (sum == target)
{
result.Add((nums[i], nums[lo], nums[hi]));
while (lo < hi && nums[lo] == nums[lo + 1]) lo++; // 跳过重复
while (lo < hi && nums[hi] == nums[hi - 1]) hi--;
lo++; hi--;
}
else if (sum < target) lo++;
else hi--;
}
}
return result;
}
复杂度:外层循环 $n$ 次,每次内层双指针 $O(n)$,总共 $O(n^2)$。加上排序的 $O(n \log n)$,整体 $O(n^2)$。
比 $O(n^3)$ 整整低了一个量级。
(c) 降维思路
关键在于这一步变形:
$$a + b + c = 0 \quad \Longrightarrow \quad b + c = -a$$
固定一个数 $a$ 之后,"三数之和等于 0"就变成了"在剩下的数里,找两个数之和等于 $-a$"。
于是:
- 外层:枚举 $a$(共 $n$ 种可能)。
- 内层:就是一个标准的"两数之和"问题,可以用双指针 $O(n)$ 解决。
总复杂度 $= n \times O(n) = O(n^2)$。
"固定一个变量,把问题降一维"是一个极通用的思路。
- 三数之和 $\to$ 固定一个,变成两数之和($O(n^3) \to O(n^2)$)
- 四数之和 $\to$ 固定一个,变成三数之和($O(n^4) \to O(n^3)$)
- 二维 DP $\to$ 固定一行,变成一维 DP(15.4 节的滚动数组)
遇到"多变量"的问题时,先问自己:能不能固定其中一个,让问题少一个维度?
九、常见错误
| 误区 | 纠正 |
|---|---|
| 指针移动方向写反 | 这是最常见的 bug,而且不会报错,只是悄悄找不到答案。写完务必用"有答案"的用例验证。 |
| 在无序数组上用双指针 | 双指针的正确性依赖有序性。无序数组要么先排序($O(n \log n)$),要么改用哈希表。 |
| 只背模板不理解排除逻辑 | 换个题就写错。每次移动都要能回答"这一步排除了哪些可能"。 |
| 认为双指针一定更快 | 实验一里 target = 100 时双指针反而慢了 400 倍。它保证的是最坏情况的下限,不是每次都赢。 |
| 同向双指针时新建数组 | 那就失去了"原地"的意义。慢指针的价值就在于不用额外空间。 |
用 RemoveAt 在循环里删元素 |
每次 $O(n)$,循环是 $O(n^2)$,而且还会因为下标前移而漏删。用双指针或 RemoveAll。 |
十、本节总结
- 双指针的本质是"排除",不是"技巧"。每次移动指针,排除的是一整批可能性,而不只是一个配对。
- 相向双指针适用于有序数据上的配对问题。正确性来自"升序数组中,$a[lo]+a[hi]$ 与 $target$ 的大小关系能一次性否决一整行/一整列"。
- 同向双指针适用于原地修改数组。快指针扫描,慢指针标记保留区边界。空间 $O(1)$。
- 实测差距:20,000 个元素时双指针比暴力快 10,000 倍,且倍数随规模线性增长。
- 但双指针不保证每次都赢 —— 特定输入下暴力可能 50 次就命中。它赢在"最坏情况有保障"。
- 指针移动方向必须能论证。答不出"这一步排除了什么",说明还没真正理解。
下一节衔接:本节处理的是"找两个数"这类问题。还有一类更常见的问题 —— "找一个满足条件的连续区间"(比如"最长的无重复子串"、"和大于目标的最短子数组")。这类问题用双指针的变体最合适,它有个专门的名字:滑动窗口。
状态外显
{
"book": "数据结构与算法自学:从工程直觉到复杂度思维",
"section": "3.3",
"title": "双指针技巧",
"covered": [
"双指针的本质:一次性排除一整批配对",
"相向双指针(有序数组两数之和)与正确性论证",
"同向双指针(原地去重)与快慢指针分工",
"规模放大实测:20000 元素时差距 10000 倍",
"输入反转现象:target=100 时暴力反而快 400 倍",
"同向双指针的三要素(扫描/标记/不回退)",
"三数之和的降维思路(固定一个变量)"
],
"unresolved": [
"哈希表解法对比留到第 6 章",
"快慢指针找链表中点留到 4.4",
"滑动窗口留到 3.4"
],
"canonical_terms": {
"双指针": "用两个下标协同扫描,把两两配对降为各自走一遍",
"相向双指针": "一左一右向中间逼近,适用于有序数据的配对问题",
"同向双指针": "快慢两个指针同向前进,适用于原地修改数组"
},
"symbols_units": {},
"assumptions": [
"读者已掌握 3.1 的数组随机访问与 1.3 的最好/最坏/平均",
"读者理解 C# 的元组与 ref 参数"
],
"word_count_actual": 2180,
"checks": [
"C# 代码已在 .NET 8 (SDK 10.0.303) Release 下实跑,输出见正文",
"项目文件:99-tools/samples/Ch03/Sec33/",
"实测数据逐项核对:暴力199990000次 vs 双指针19999次(10000倍);去重交叉验证通过",
"练习答案含关键步骤,不只给结果",
"术语写法与 glossary.md 一致"
],
"known_issues": [
"正文保留了「target=100 时双指针反而更慢」这一反直觉数据,并在代码输出中明确标注为「结论反转」的教学点"
],
"next": "3.4 滑动窗口"
}