跳转至

第2章 顺序表与数组算法

旧版讲义 · 校订增强 · 专题阅读 · 保留代码与校订注 · 与版本锁一致

第2章 顺序表与数组算法

方法总路线:先保底,再榨干条件

面对数组算法题,考场上怎样按步骤落笔?

何时用:所有“设计一个时间上尽可能高效的算法”题。尤其适合一时看不出最优解、但能写出枚举或排序解时。

思路步骤:

  1. 先写正确的暴力解,明确输入、输出和循环边界,拿到基本分。
  2. 再列出尚未使用的条件:有序、值域小、只求第 k 小、只查边界、元素非负、空间限制等。
  3. 检查是否做了“超额工作”:只要中位数却全排序,只要交集却两重枚举,只要区间和却反复求和。
  4. 选用对应工具:有序性→折半/双指针;有限值域→直接寻址;重复区间查询→预处理;只定位秩→Quickselect。
  5. 最后写正确性依据、复杂度与边界,不用“感觉能过”代替证明。

伪代码/关键模板:

暴力解 → 找出浪费的条件/多做的工作 → 选优化工具
       → 写循环不变式或区间定义 → 处理空表、重复值、越界

复杂度:本卡是答题流程,本身无固定复杂度;复杂度取决于最终算法。

易错边界:不要在暴力解尚未闭环时孤注一掷追最优;题目若禁止改变原序、要求稳定或只读,不能直接原地快排。

回源 · PDF 第 5 页 · b_5_0;PDF 第 5 页 · b_5_2;PDF 第 5 页 · b_5_11

写出保底解后,怎样判断还能不能优化?

何时用:暴力解已经正确,但时间复杂度为 O(n²)、O(n³) 或 O(n log n),题目又要求“尽可能高效”时。

思路步骤:

  1. 做“条件浪费”检查:升序是否还在顺扫?取值范围是否没有映射?非负性是否没有用于滑窗?
  2. 做“目标超额”检查:只找极值却全排序?只划分一侧却递归两侧?每次区间查询都重新累计?
  3. 把目标改写为单调判定、局部推进或可复用状态。
  4. 优化后重新证明:每次舍弃的区间是否一定无解;每个指针是否只单向移动;辅助数组下标是否覆盖完整值域。

伪代码/关键模板:

unused_condition = {有序, 值域, 非负, 重复查询, 只求一个秩}
extra_work       = {全排序, 重复扫描, 重复求和, 搜索无效状态}
逐项匹配工具,并用不变式验证“舍弃部分不含答案”

复杂度:常见优化是 O(n²)→O(n)、O(n log n)→期望 O(n)、多次 O(n) 查询→预处理后 O(1) 查询。

易错边界:优化不是看到关键词就套模板;例如链表“有序”通常也不适合折半,滑窗计数依赖元素非负,直接寻址的空间是 O(M) 而不总是 O(n)。

回源 · PDF 第 9 页 · b_9_3;PDF 第 9 页 · b_9_5;PDF 第 9 页 · b_9_7;PDF 第 9 页 · b_9_8

分治选择:快排、划分与 Quickselect

快速排序什么时候能用,最坏界怎样写才准确?

何时用:允许改变数组顺序、不要求稳定,且“先排序再处理”能显著简化问题时。

思路步骤:

  1. 在闭区间 [lo, hi] 选枢轴。
  2. 划分后让枢轴进入最终位置 p,左侧不大于它,右侧不小于它。
  3. 递归处理 [lo, p-1] 与 [p+1, hi]。
  4. 用随机枢轴或三数取中降低持续极端划分的概率。

伪代码/关键模板:

static void ac_swap(int *x, int *y) {
    int t = *x;
    *x = *y;
    *y = t;
}

static int ac_partition(int a[], int lo, int hi) {
    int pivot = a[lo];
    int i = lo, j = hi;
    while (i < j) {
        while (i < j && a[j] >= pivot) --j;
        while (i < j && a[i] <= pivot) ++i;
        if (i < j) ac_swap(&a[i], &a[j]);
    }
    ac_swap(&a[lo], &a[i]);
    return i;
}

static void ac_quick_sort(int a[], int lo, int hi) {
    if (lo >= hi) return;
    int p = ac_partition(a, lo, hi);
    ac_quick_sort(a, lo, p - 1);
    ac_quick_sort(a, p + 1, hi);
}

复杂度:平均时间 O(n log n)、平均递归栈 O(log n);若每次只分出一个元素,最坏时间 O(n²)、最坏栈 O(n)。新版补充/校订:旧版只写了平均界,这里补齐最坏界。

易错边界:区间参数必须统一为闭区间;两侧扫描与比较符号必须配套,否则重复元素会卡死;禁止改序、要求稳定时不能直接用本模板。

回源 · PDF 第 8 页 · b_8_0;PDF 第 8 页 · b_8_3;PDF 第 8 页 · b_8_4;PDF 第 8 页 · b_8_5;PDF 第 9 页 · b_9_2

一次 partition 之后,真正保持了什么不变式?

何时用:需要手写快排、Quickselect,或只要求把“小于 k”和“大于等于 k”的元素分到两侧时。

思路步骤:

  1. 保存枢轴值,避免枢轴所在单元在交换中丢失。
  2. 在挖坑/交换过程中维护:已处理左段均不大于 pivot,已处理右段均不小于 pivot。
  3. i 与 j 相遇时,该位置是枢轴的最终位置。
  4. 若题目只要求二分集合,到此即可,不要继续排序两侧。

伪代码/关键模板:

循环前:pivot 暂存,未知区间为 [i,j]
每轮后:[lo+1,i) ≤ pivot;(j,hi] ≥ pivot
退出时:i=j,把 pivot 放入 i,返回 i

复杂度:一次划分时间 O(hi-lo+1),额外空间 O(1)。

易错边界:不同 partition 写法的“等号归哪侧”不可混搭;返回位置是枢轴最终秩,不代表左右内部已有序;只划分时不要误写成完整快排。

回源 · PDF 第 20 页 · b_20_6;PDF 第 20 页 · b_20_9;PDF 第 21 页 · b_21_0;PDF 第 21 页 · b_21_3

只求第 k 小时,为什么 Quickselect 不必排序两边?

何时用:只求第 k 小、中位数、或只需把较小的一半与较大的一半分开,且允许改变数组。

思路步骤:

  1. 把第 k 小换成目标下标 target=k-1。
  2. 对当前区间做 partition,得到枢轴最终下标 p。
  3. p=target 时直接结束;target<p 只保留左区间;否则只保留右区间。
  4. 迭代写法避免递归栈;随机化枢轴可降低退化概率。

伪代码/关键模板:

static int ac_quick_select(int a[], int n, int k) {
    if (n <= 0 || k < 1 || k > n) return 0;
    int lo = 0, hi = n - 1, target = k - 1;
    while (lo <= hi) {
        int p = ac_partition(a, lo, hi);
        if (p == target) return a[p];
        if (target < p) hi = p - 1;
        else lo = p + 1;
    }
    return 0;
}

复杂度:随机或平均情况下期望 O(n);固定坏枢轴时最坏 O(n²)。迭代版额外空间 O(1);递归版平均栈 O(log n)、最坏 O(n)。新版补充/校订:不能把期望 O(n) 写成无条件最坏 O(n)。

易错边界:k 是从 1 计数还是下标必须先换算;Quickselect 只保证目标位置与两侧大小关系,不保证两侧内部有序;返回 0 不能表达所有错误场景,工程代码应另设状态值。

回源 · PDF 第 21 页 · b_21_4;PDF 第 21 页 · b_21_7;PDF 第 21 页 · b_21_11;PDF 第 22 页 · b_22_1

折半边界:区间定义先于代码

精确查值为什么推荐闭区间模板?

何时用:升序数组中查找某个确切值,找到任一位置即可。

思路步骤:

  1. 明确搜索区间始终是闭区间 [lo, hi]。
  2. 当 lo≤hi 时取中点;相等直接返回。
  3. 目标更小就令 hi=mid-1,更大就令 lo=mid+1。
  4. 区间为空时返回未找到。

伪代码/关键模板:

static int ac_binary_find(const int a[], int n, int x) {
    int lo = 0, hi = n - 1;
    while (lo <= hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] == x) return mid;
        if (a[mid] < x) lo = mid + 1;
        else hi = mid - 1;
    }
    return -1;
}

复杂度:时间 O(log n),额外空间 O(1)。

易错边界:闭区间必须搭配 lo≤hi 和 mid±1;用 lo+(hi-lo)/2 避免 lo+hi 溢出;空数组时 hi=-1,循环自然不进入。

回源 · PDF 第 10 页 · b_10_3;PDF 第 10 页 · b_10_8;PDF 第 11 页 · b_11_0

lower_bound 怎样用半开区间稳定找左边界?

何时用:重复元素中找第一个不小于 x 的位置,也可作为“第一个等于 x”的候选位置。

思路步骤:

  1. 维护半开区间 [lo, hi),初始 [0,n)。
  2. 若 a[mid]≥x,答案仍可能是 mid,令 hi=mid。
  3. 若 a[mid]<x,mid 不可能是答案,令 lo=mid+1。
  4. 退出时 lo=hi,即第一个不小于 x 的插入位置;再检查是否小于 n 且等于 x。

伪代码/关键模板:

static int ac_lower_bound(const int a[], int n, int x) {
    int lo = 0, hi = n;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] >= x) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

复杂度:时间 O(log n),额外空间 O(1)。

易错边界:返回值可能等于 n,访问 a[pos] 前必须判界;保留 mid 时只能写 hi=mid,写成 mid-1 会漏掉真正边界;不要把闭区间和半开区间更新式混用。

回源 · PDF 第 11 页 · b_11_1;PDF 第 11 页 · b_11_3;PDF 第 11 页 · b_11_5;PDF 第 12 页 · b_12_3

统计 x 出现次数,怎样避免用 x+1 造成溢出?

何时用:有重复元素的升序数组中统计某值出现次数,或寻找等值段的左右边界。

思路步骤:

  1. lower_bound 找第一个 ≥x 的位置 left。
  2. upper_bound 直接找第一个 >x 的位置 right。
  3. 次数为 right-left;若不存在 x,两边会相等。
  4. 不用 lower_bound(x+1),因为 x=INT_MAX 时会整数溢出。

伪代码/关键模板:

static int ac_upper_bound(const int a[], int n, int x) {
    int lo = 0, hi = n;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] > x) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

static int ac_count_occurrences(const int a[], int n, int x) {
    return ac_upper_bound(a, n, x) - ac_lower_bound(a, n, x);
}

复杂度:两次折半,总时间 O(log n),额外空间 O(1)。

易错边界:upper_bound 的判定是严格大于;不要在 right=n 时访问 a[right]。新版补充/校订:旧版用 x+1 求右界,x=INT_MAX 时可能溢出,本卡改为直接 upper_bound(x)。

回源 · PDF 第 53 页 · b_53_2;PDF 第 54 页 · b_54_4;PDF 第 54 页 · b_54_6;PDF 第 55 页 · b_55_3;PDF 第 55 页 · b_55_5

无重复旋转升序数组,最小值为何能二分?

何时用:数组由严格升序数组旋转而来、元素无重复,要求最小值或旋转点。

思路步骤:

  1. 维护最小值必在 [lo,hi]。
  2. 比较 a[mid] 与右端 a[hi]。
  3. a[mid]≤a[hi] 时,mid 到 hi 有序,最小值在 [lo,mid],保留 mid。
  4. a[mid]>a[hi] 时,最小值在 [mid+1,hi]。

伪代码/关键模板:

static int ac_rotated_min_index(const int a[], int n) {
    if (n <= 0) return -1;
    int lo = 0, hi = n - 1;
    while (lo < hi) {
        int mid = lo + (hi - lo) / 2;
        if (a[mid] <= a[hi]) hi = mid;
        else lo = mid + 1;
    }
    return lo;
}

复杂度:无重复前提下时间 O(log n),额外空间 O(1)。

易错边界:若允许重复,a[mid]=a[hi] 时无法判断方向,通常只能 hi--,最坏退化为 O(n);本题无重复,所以不能把两种口径混写。

回源 · PDF 第 41 页 · b_41_10;PDF 第 43 页 · b_43_3;PDF 第 43 页 · b_43_5;PDF 第 43 页 · b_43_7

值域映射:空间换时间必须先算下标

什么时候能把散列表直接写成数组?

何时用:整数值域 [L,R] 有明确且可承受的范围,需要统计出现次数、判重或按值输出。

思路步骤:

  1. 先算值域大小 M=R-L+1,而不是看元素个数 n。
  2. 用下标 x-L 表示原值 x;访问前验证 L≤x≤R。
  3. 初始化 count/seen,再单次扫描更新。
  4. 若 M 远大于 n,改用真正的哈希表、排序或其他方法,避免稀疏巨数组。

伪代码/关键模板:

index(x) = x - L
0 ≤ index(x) < M
seen[index(x)] 记录 x 是否出现

复杂度:扫描时间 O(n),初始化/按值扫描通常 O(M),空间 O(M);只有 M=Θ(n) 时才可简写为空间 O(n)。

易错边界:负数不能直接当数组下标;绝对值映射要小心 INT_MIN;值域端点是否包含会决定数组长度是 R-L 还是 R-L+1。

回源 · PDF 第 13 页 · b_13_0;PDF 第 13 页 · b_13_2;PDF 第 13 页 · b_13_3;PDF 第 14 页 · b_14_0

2018 最小未出现正整数,如何做到 O(n) 且原地标记?

何时用:长度 n 的整数数组,求未出现的最小正整数,允许修改原数组。

思路步骤:

  1. 答案必在 [1,n+1];把不在 [1,n] 的值改成 n+1。
  2. 对每个值 v=|a[i]|,若 1≤v≤n,就把 a[v-1] 置负表示 v 出现。
  3. 第一个仍为正的位置 i 对应答案 i+1。
  4. 若 1..n 都出现,答案是 n+1。

伪代码/关键模板:

static int ac_smallest_missing_positive(int a[], int n) {
    for (int i = 0; i < n; ++i) {
        if (a[i] <= 0 || a[i] > n) a[i] = n + 1;
    }
    for (int i = 0; i < n; ++i) {
        int v = a[i] < 0 ? -a[i] : a[i];
        if (v >= 1 && v <= n && a[v - 1] > 0) a[v - 1] = -a[v - 1];
    }
    for (int i = 0; i < n; ++i) {
        if (a[i] > 0) return i + 1;
    }
    return n + 1;
}

复杂度:时间 O(n),额外空间 O(1)。新版补充/校订:旧版真题解使用 O(n) 辅助数组,这里补充原地标记版本。

易错边界:先清洗再取绝对值,避免直接对 INT_MIN 取绝对值;该算法会破坏原数组;标记下标是 v-1,不是 v。

回源 · PDF 第 65 页 · b_65_1;PDF 第 66 页 · b_66_6;PDF 第 66 页 · b_66_9;PDF 第 66 页 · b_66_13

值域 [2n,3n) 去重输出,偏移量到底加还是减?

何时用:n 个整数都落在连续长度 n 的区间 [2n,3n),要求去重、计数或按值序输出。

思路步骤:

  1. 值域下界 L=2n,大小 M=n。
  2. 原值 x 映射到 seen[x-2n],范围正好是 [0,n)。
  3. 扫描原数组标记;再按下标 i 从小到大输出 i+2n。
  4. 重复值只会重复写同一标记,因此自然去重。

伪代码/关键模板:

mark[A[i] - 2*n] = true
for i = 0 .. n-1:
    if mark[i]: 输出 i + 2*n

复杂度:时间 O(n),空间 O(n)。

易错边界:新版补充/校订:旧版文字处出现 A[i]+2n,是方向错误;其后代码使用 A[i]-2n 才是正确映射。还要防止先算 3n 产生整型溢出,工程实现宜用更宽整数。

回源 · PDF 第 47 页 · b_47_9;PDF 第 48 页 · b_48_4;PDF 第 48 页 · b_48_5;PDF 第 49 页 · b_49_0

预处理:把重复工作折叠为一次扫描

前缀和怎样把区间和查询降到 O(1)?

何时用:同一数组上多次查询连续区间和,或枚举大量区间但不想重复累加。

思路步骤:

  1. 定义 prefix[0]=0。
  2. 令 prefix[i+1]=prefix[i]+a[i],即前 i+1 个元素之和。
  3. 闭区间 [L,R] 之和为 prefix[R+1]-prefix[L]。
  4. 用 long long 保存累计和,避免多个 int 相加溢出。

伪代码/关键模板:

static void ac_prefix_sum(const int a[], int n, long long prefix[]) {
    prefix[0] = 0;
    for (int i = 0; i < n; ++i) prefix[i + 1] = prefix[i] + a[i];
}

static long long ac_range_sum(const long long prefix[], int left, int right) {
    return prefix[right + 1] - prefix[left];
}

复杂度:预处理 O(n)、每次查询 O(1)、空间 O(n)。

易错边界:prefix 长度必须是 n+1;[L,R] 要用 R+1;若数组会更新,静态前缀和不再适用,应换树状数组等动态结构。

回源 · PDF 第 14 页 · b_14_2;PDF 第 14 页 · b_14_6;PDF 第 14 页 · b_14_7;PDF 第 15 页 · b_15_2;PDF 第 15 页 · b_15_3

前后缀最值何时可以压成常数个变量?

何时用:每个位置只依赖“此前/此后的最大值或最小值”,且查询方向固定为一次线性扫描。

思路步骤:

  1. 先判断依赖方向:依赖 [0,i] 就从左向右;依赖 [i,n-1] 就从右向左。
  2. 若后续仍需随机访问每个位置的最值,建立 prefix/suffix 数组。
  3. 若扫描到 i 时只用当前累计最值,直接用一个 max_so_far 或 min_so_far。
  4. 同时涉及最大与最小时维护两个变量,不要错误假设元素全为正。

伪代码/关键模板:

best = A[start]
沿依赖方向扫描 i:
    best = max(best, A[i]) 或 min(best, A[i])
    res[i] = f(A[i], best)

复杂度:时间 O(n);数组版空间 O(n),状态压缩版额外空间 O(1)。

易错边界:必须先用合法元素初始化,不能无条件用 0;若 A[i] 可为负,最大乘积可能来自最小值;只有单向消费状态时才能压缩数组。

回源 · PDF 第 15 页 · b_15_6;PDF 第 15 页 · b_15_7;PDF 第 16 页 · b_16_5;PDF 第 16 页 · b_16_6;PDF 第 16 页 · b_16_7

2025 后缀最大乘积,为什么必须同时维护最大值与最小值?

何时用:对每个 i,在 j≥i 中最大化 A[i]×A[j],数组含正数、负数和零。

思路步骤:

  1. 从右向左维护后缀最大值 mx 和后缀最小值 mn。
  2. 更新到位置 i 后,候选只有 A[i]×mx 与 A[i]×mn。
  3. 取二者较大者写入 res[i]。
  4. 乘法用 long long 更稳妥;若题目接口固定 int,要说明可能溢出的前提。

伪代码/关键模板:

static void ac_suffix_mul_max(const int a[], long long res[], int n) {
    if (n <= 0) return;
    int mx = a[n - 1], mn = a[n - 1];
    for (int i = n - 1; i >= 0; --i) {
        if (a[i] > mx) mx = a[i];
        if (a[i] < mn) mn = a[i];
        long long p1 = (long long)a[i] * mx;
        long long p2 = (long long)a[i] * mn;
        res[i] = p1 > p2 ? p1 : p2;
    }
}

复杂度:时间 O(n),额外空间 O(1)(不计输出数组)。新版补充/校订:旧版暴力解只有两重循环,却误写 O(n³),正确应为 O(n²)。

易错边界:只维护后缀最大值会漏掉“负×更负”;从右向左时先把 a[i] 纳入 mx/mn,才允许 j=i;n=0 时不能访问 a[n-1]。

回源 · PDF 第 69 页 · b_69_5;PDF 第 70 页 · b_70_10;PDF 第 70 页 · b_70_12;PDF 第 70 页 · b_70_13;PDF 第 71 页 · b_71_2;PDF 第 71 页 · b_71_6;PDF 第 71 页 · b_71_7

双指针与滑动窗口:先写不变式,再决定谁移动

双指针题的移动规则怎样从不变式推出?

何时用:一个或两个有序数组同步扫描、左右夹逼,或维护连续区间时。

思路步骤:

  1. 先写“已排除部分为什么不可能含答案”,再写移动规则。
  2. 同向归并时,较小者一旦输出/排除就不会再回来。
  3. 对撞指针时,根据和偏小/偏大决定移动左端或右端。
  4. 每轮至少有一个指针严格前进,且每个指针至多走完整个数组。

伪代码/关键模板:

i = 起点A;j = 起点B或末端
while 两指针均有效:
    检查当前组合
    依据单调性只移动能改善答案的一方
循环不变式:指针之前的状态均已正确处理且无需回看

复杂度:若每个指针只单向移动,通常时间 O(n+m),额外空间 O(1)(不计输出)。

易错边界:双层语法不等于 O(n²),关键看指针是否回退;没有有序性/单调性时不能凭感觉移动;相等分支必须保证至少一个指针变化。

回源 · PDF 第 17 页 · b_17_0;PDF 第 17 页 · b_17_2;PDF 第 17 页 · b_17_6

两个升序数组怎样在线性时间归并?

何时用:两个有序数组合并为一个有序数组,或求并集/交集的基础扫描。

思路步骤:

  1. i、j 指向两个未处理区间的首元素,k 指向输出位置。
  2. 较小元素是全局剩余最小值,写入输出并移动对应指针。
  3. 一个数组结束后,把另一个数组剩余部分直接复制。
  4. 若题目要求去重,在写入前与上一个输出比较。

伪代码/关键模板:

static int ac_merge_sorted(const int a[], int n, const int b[], int m, int out[]) {
    int i = 0, j = 0, k = 0;
    while (i < n && j < m) {
        if (a[i] <= b[j]) out[k++] = a[i++];
        else out[k++] = b[j++];
    }
    while (i < n) out[k++] = a[i++];
    while (j < m) out[k++] = b[j++];
    return k;
}

复杂度:时间 O(n+m),输出数组之外额外空间 O(1);若把输出计入则空间 O(n+m)。

易错边界:主循环退出后不要漏复制尾部;相等时只移动一边会保留两份,相等时同时移动则是去重/交集口径,必须按题意选择。

回源 · PDF 第 17 页 · b_17_9;PDF 第 17 页 · b_17_15;PDF 第 17 页 · b_17_16;PDF 第 18 页 · b_18_1

升序数组两数之和,左右指针为何不会漏解?

何时用:升序数组中找两个不同位置,使两数之和等于目标 k。

思路步骤:

  1. left=0、right=n-1,始终只考虑 left<right。
  2. 当前和等于 k 即成功。
  3. 当前和小于 k 时,固定 left 的任何更小 right 都更小,故只能 left++。
  4. 当前和大于 k 时,固定 right 的任何更大 left 都更大,故只能 right--。

伪代码/关键模板:

static int ac_two_sum_sorted(const int a[], int n, int k) {
    int left = 0, right = n - 1;
    while (left < right) {
        long long sum = (long long)a[left] + a[right];
        if (sum == k) return 1;
        if (sum < k) ++left;
        else --right;
    }
    return 0;
}

复杂度:时间 O(n),额外空间 O(1)。

易错边界:必须是 left<right,避免同一元素使用两次;加法用更宽类型防溢出;若数组无序,移动规则失去依据。

回源 · PDF 第 38 页 · b_38_5;PDF 第 38 页 · b_38_12;PDF 第 40 页 · b_40_2;PDF 第 40 页 · b_40_3;PDF 第 40 页 · b_40_5

两个升序数组的最小绝对差,为什么移动较小者?

何时用:分别从两个有序数组各取一个数,使绝对差最小。

思路步骤:

  1. i、j 从各自起点出发,先更新当前差。
  2. 若 a[i]<b[j],保持 a[i] 而增大 b[j] 只会让差更大,因此移动 i。
  3. 若 a[i]>b[j],对称地移动 j。
  4. 相等时最优值为 0,可立即结束。

伪代码/关键模板:

static long long ac_min_abs_distance(const int a[], int n, const int b[], int m) {
    if (n <= 0 || m <= 0) return -1;
    int i = 0, j = 0;
    long long best = 9223372036854775807LL;
    while (i < n && j < m) {
        long long d = (long long)a[i] - b[j];
        if (d < 0) d = -d;
        if (d < best) best = d;
        if (a[i] < b[j]) ++i;
        else if (a[i] > b[j]) ++j;
        else return 0;
    }
    return best;
}

复杂度:时间 O(n+m),额外空间 O(1)。

易错边界:先转 long long 再做减法/取绝对值;任一数组为空时不存在合法数对;“移动较小者”依赖两个数组升序。

回源 · PDF 第 37 页 · b_37_2;PDF 第 37 页 · b_37_3;PDF 第 38 页 · b_38_0;PDF 第 38 页 · b_38_1;PDF 第 38 页 · b_38_3

非负数组统计和不超过 k 的连续子数组,窗口不变式是什么?

何时用:数组元素非负,统计或寻找满足区间和上界的连续子数组。

思路步骤:

  1. right 每次纳入一个新元素并更新 sum。
  2. 当 sum>k 时连续移动 left 并减去离窗元素,直到 sum≤k。
  3. 此时以 right 结尾、起点在 [left,right] 的区间都合法,共 right-left+1 个。
  4. 把该数加入答案,再扩展 right。

伪代码/关键模板:

static long long ac_count_nonnegative_windows(const int a[], int n, long long k) {
    int left = 0;
    long long sum = 0, count = 0;
    for (int right = 0; right < n; ++right) {
        sum += a[right];
        while (left <= right && sum > k) sum -= a[left++];
        if (sum <= k) count += right - left + 1;
    }
    return count;
}

复杂度:left、right 各至多移动 n 次,时间 O(n),额外空间 O(1)。

易错边界:非负性是关键;出现负数时,缩小窗口不再保证和单调下降。答案可能达到 n(n+1)/2,应用 long long。

回源 · PDF 第 43 页 · b_43_9;PDF 第 43 页 · b_43_10;PDF 第 44 页 · b_44_7;PDF 第 44 页 · b_44_8;PDF 第 44 页 · b_44_10;PDF 第 45 页 · b_45_0

统考真题路线:把方法识别落实到得分点

2010 数组循环左移,三次逆置怎样记?

何时用:将长度 n 的数组左移 p 位,要求时间 O(n)、额外空间 O(1)。

思路步骤:

  1. 把数组写成 XY,其中 |X|=p、|Y|=n-p,目标是 YX。
  2. 先逆置 X 得 XᴿY。
  3. 再逆置 Y 得 XᴿYᴿ。
  4. 最后逆置整体,得到 (XᴿYᴿ)ᴿ=YX。

伪代码/关键模板:

static void ac_reverse_range(int a[], int lo, int hi) {
    while (lo < hi) ac_swap(&a[lo++], &a[hi--]);
}

static void ac_rotate_left(int a[], int n, int p) {
    if (n <= 0) return;
    p %= n;
    if (p < 0) p += n;
    if (p == 0) return;
    ac_reverse_range(a, 0, p - 1);
    ac_reverse_range(a, p, n - 1);
    ac_reverse_range(a, 0, n - 1);
}

复杂度:时间 O(n),额外空间 O(1)。

易错边界:先把 p 归一化到 [0,n);p=0 时第一段会成为空区间;三次逆置的区间端点不能混用闭/开区间。

回源 · PDF 第 55 页 · b_55_8;PDF 第 55 页 · b_55_9;PDF 第 56 页 · b_56_0;PDF 第 56 页 · b_56_1;PDF 第 56 页 · b_56_3;PDF 第 56 页 · b_56_4

2011 两个等长升序数组的中位数,怎样每轮丢掉同样多的元素?

何时用:两个长度相等的升序数组,求合并后 2n 个元素的下中位数,要求 O(log n) 时间、O(1) 空间。

思路步骤:

  1. 分别取两个候选区间的中点 m1、m2。
  2. 中点值相等即可返回。
  3. a[m1]<b[m2] 时,丢弃 A 的较小前段和 B 的较大后段;反之对称。
  4. 奇偶长度不同会影响是否保留中点,必须保证两边每轮丢弃元素数相同。

伪代码/关键模板:

static int ac_two_array_lower_median(const int a[], const int b[], int n) {
    int s1 = 0, d1 = n - 1, s2 = 0, d2 = n - 1;
    while (s1 != d1 || s2 != d2) {
        int m1 = s1 + (d1 - s1) / 2;
        int m2 = s2 + (d2 - s2) / 2;
        if (a[m1] == b[m2]) return a[m1];
        int even = ((d1 - s1 + 1) % 2 == 0);
        if (a[m1] < b[m2]) {
            s1 = even ? m1 + 1 : m1;
            d2 = m2;
        } else {
            d1 = m1;
            s2 = even ? m2 + 1 : m2;
        }
    }
    return a[s1] < b[s2] ? a[s1] : b[s2];
}

复杂度:时间 O(log n),额外空间 O(1)。

易错边界:这里“中位数”按真题口径取第 n 小,不是两个中间数的平均值;n 必须大于 0;每轮两边删去的候选数量必须相等。

回源 · PDF 第 56 页 · b_56_6;PDF 第 56 页 · b_56_7;PDF 第 58 页 · b_58_8;PDF 第 58 页 · b_58_9;PDF 第 58 页 · b_58_10;PDF 第 59 页 · b_59_0

2013 主元素,摩尔投票为什么还必须二次验证?

何时用:寻找出现次数严格超过 n/2 的元素,要求 O(n) 时间、O(1) 空间。

思路步骤:

  1. count=0 时,把当前元素设为候选并令 count=1。
  2. 后续元素等于候选就加一,否则减一,表示异值成对抵消。
  3. 若主元素存在,最终候选必是它。
  4. 但不存在主元素时也会留下候选,所以必须第二遍计数并验证次数>n/2。

伪代码/关键模板:

static int ac_majority(const int a[], int n, int *value) {
    if (n <= 0 || value == NULL) return 0;
    int candidate = 0, count = 0;
    for (int i = 0; i < n; ++i) {
        if (count == 0) {
            candidate = a[i];
            count = 1;
        } else if (a[i] == candidate) {
            ++count;
        } else {
            --count;
        }
    }
    int occurrences = 0;
    for (int i = 0; i < n; ++i) {
        if (a[i] == candidate) ++occurrences;
    }
    if (occurrences <= n / 2) return 0;
    *value = candidate;
    return 1;
}

复杂度:两次线性扫描,时间 O(n),额外空间 O(1)。

易错边界:严格超过一半,不是大于等于。新版补充/校订:旧版的负计数重置写法改为规范的 count==0 重置;不能省略验证遍。

回源 · PDF 第 59 页 · b_59_2;PDF 第 59 页 · b_59_3;PDF 第 61 页 · b_61_9;PDF 第 61 页 · b_61_10;PDF 第 61 页 · b_61_11;PDF 第 62 页 · b_62_4

2016 集合等分,为什么只定位分界秩就够了?

何时用:正整数集合分成大小尽量接近的两组,并让两组和之差尽量大。

思路步骤:

  1. 个数差最小时,两组大小应为 floor(n/2) 与 ceil(n/2)。
  2. 为最大化和差,让较小的 floor(n/2) 个元素在一组,其余较大元素在另一组。
  3. 不需要两组内部有序,只需 Quickselect 把第 floor(n/2) 小的分界放到位。
  4. 扫描两段求和或直接输出分组。

伪代码/关键模板:

t = floor(n/2)
Quickselect 使 A[0..t-1] 均不大于 A[t..n-1]
A1 = A[0..t-1];A2 = A[t..n-1]

复杂度:随机/平均时间期望 O(n),固定坏枢轴最坏 O(n²);迭代版额外空间 O(1)。新版补充/校订:旧版题目问“平均复杂度”,应明确写期望/平均而非无条件 O(n)。

易错边界:n 为奇数时较大元素一组应多一个;集合若允许重复,划分位置仍有效但内部顺序不定;不要为输出两个集合而误做完整排序。

回源 · PDF 第 62 页 · b_62_5;PDF 第 62 页 · b_62_6;PDF 第 63 页 · b_63_2;PDF 第 63 页 · b_63_3;PDF 第 64 页 · b_64_10;PDF 第 64 页 · b_64_11;PDF 第 65 页 · b_65_0

2020 三数组最小距离,为什么只移动当前最小值?

何时用:三个升序数组各取一个数,最小化 |a-b|+|b-c|+|c-a|。

思路步骤:

  1. 利用恒等式 D=2×(当前最大值-当前最小值)。
  2. 三个指针从起点出发,更新当前 D。
  3. 若不移动最小值,仅增大中间值或最大值,当前跨度不可能缩小。
  4. 因此只移动指向当前最小值的指针;任一数组耗尽即结束。

伪代码/关键模板:

static long long ac_three_array_min_distance(
    const int a[], int n, const int b[], int m, const int c[], int k
) {
    if (n <= 0 || m <= 0 || k <= 0) return -1;
    int i = 0, j = 0, t = 0;
    long long best = 9223372036854775807LL;
    while (i < n && j < m && t < k) {
        int mn = a[i], mx = a[i];
        if (b[j] < mn) mn = b[j];
        if (c[t] < mn) mn = c[t];
        if (b[j] > mx) mx = b[j];
        if (c[t] > mx) mx = c[t];
        long long d = 2LL * ((long long)mx - mn);
        if (d < best) best = d;
        if (a[i] <= b[j] && a[i] <= c[t]) ++i;
        else if (b[j] <= a[i] && b[j] <= c[t]) ++j;
        else ++t;
    }
    return best;
}

复杂度:三个指针各至多走一遍,时间 O(n+m+k),额外空间 O(1)。

易错边界:三个集合必须非空且升序;相等最小值时移动任一最小指针都安全,但必须保证推进;用更宽整数计算两倍跨度。

回源 · PDF 第 67 页 · b_67_0;PDF 第 67 页 · b_67_1;PDF 第 68 页 · b_68_7;PDF 第 69 页 · b_69_0;PDF 第 69 页 · b_69_2;PDF 第 69 页 · b_69_3;PDF 第 69 页 · b_69_4