邻值查找

GitHub跳转原题关系图返回列表

把前缀查询倒过来做:先排序建成有序表,再从 i=n 倒推,每步用双向链表 O(1) 摘掉 A_i 并查询它的左右邻居,用三元组比较顺带处理平局规则,总复杂度 O(n log n)。

OJ: roj

题目 ID: 3036

难度:提高

标签:排序链表双向链表python

创建: 2026-10-01 11:28

更新: 2026-10-07 13:50

形式化题目

给定互不相同的整数 A1,A2,…,AnA_1, A_2, \dots, A_n。对每个 i∈[2,n]i \in [2, n],记前缀集合 Si={A1,…,Ai−1}S_i = \{A_1, \dots, A_{i-1}\},求

Di=min⁡1⩽j<i∣Ai−Aj∣ D_i = \min_{1 \leqslant j < i} |A_i - A_j|

以及取到这个最小值的下标 PiP_i。若最小值点不唯一(左右两侧距离相等),则选使 AjA_j 较小的那个 jj。

其中 n⩽105n \leqslant 10^5,∣Ai∣⩽109|A_i| \leqslant 10^9,序列中的数两两不同。

一个例子:A=(1,5,3)A = (1, 5, 3),则 S2={1}S_2 = \{1\} 给出 D2=4,P2=1D_2 = 4, P_2 = 1;S3={1,5}S_3 = \{1, 5\} 中 ∣3−1∣=∣3−5∣=2|3-1| = |3-5| = 2 打成平手,按规则取 AjA_j 较小的 A1A_1,得 D3=2,P3=1D_3 = 2, P_3 = 1。

正解

思路

朴素做法与瓶颈。 直接翻译题意:对每个 ii,把 A1…Ai−1A_1 \dots A_{i-1} 全扫一遍取最小差值。这是 O(n2)O(n^2),n=105n = 10^5 时约 5×1095 \times 10^9 次内层操作,必然超时。瓶颈不是「比较」这个动作,而是比较的对象太多——前缀里绝大多数元素根本不可能成为答案,必须找到一个「只留常数个候选」的刻画。

关键观察一:答案只需看左右邻居。

设 SiS_i 排序后形如 x1<x2<⋯<xmx_1 < x_2 < \dots < x_m,并令

l=max⁡{x∈Si:x<Ai},r=min⁡{x∈Si:x>Ai} l = \max\{x \in S_i : x < A_i\}, \qquad r = \min\{x \in S_i : x > A_i\}

(不存在时视作 ±∞\pm\infty)。那么对任意 x∈Six \in S_i:若 x⩽lx \leqslant l,有 ∣Ai−x∣=Ai−x⩾Ai−l=∣Ai−l∣|A_i - x| = A_i - x \geqslant A_i - l = |A_i - l|;若 x⩾rx \geqslant r,有 ∣Ai−x∣=x−Ai⩾r−Ai=∣Ai−r∣|A_i - x| = x - A_i \geqslant r - A_i = |A_i - r|。SiS_i 被 AiA_i 分成这两部分,所以

Di=min⁡(∣Ai−l∣, ∣Ai−r∣) D_i = \min\bigl(|A_i - l|,\ |A_i - r|\bigr)

最小值一定在 AiA_i 的两个有序邻居中取到,其余元素一律不用看。于是问题变成:维护一个有序集合,支持「插入一个数」和「查某个数的前驱/后继」。

关键观察二:把插入倒过来变成删除。

在 C++ 里这个有序集合就是 std::set(平衡树),O(nlog⁡n)O(n \log n) 直接解决。但 Python 标准库没有平衡树:bisect.insort 往 list 里插入是 O(n)O(n) 的搬移,整体会退化成 O(n2)O(n^2)。

换一个方向看时间轴。正向第 ii 步查询的集合是 Si={A1,…,Ai−1}S_i = \{A_1, \dots, A_{i-1}\};如果令 T=sort⁡(A)T = \operatorname{sort}(A) 是全体元素的有序表,那么

Si=T∖{Ai,Ai+1,…,An} S_i = T \setminus \{A_i, A_{i+1}, \dots, A_n\}

也就是「从全集中挖掉 AiA_i 及其后面所有元素」。于是从 i=ni = n 倒着处理:一开始有序表里放着全部 nn 个数;处理到第 ii 步时,Ai+1,…,AnA_{i+1}, \dots, A_n 已经在前面几步被挖掉了,表里剩下 Si∪{Ai}S_i \cup \{A_i\}。而 AiA_i 自己在表里的位置正是它的左右邻居夹着的那个空档,「查邻居」和「删自己」可以在同一步完成。

这一步把「插入」换成了「删除」,而删除在有序表上是很便宜的操作——前提是用对结构。

关键观察三:有序表用双向链表实现。

在有序表上删掉一个元素,只需让它左边一格指向右边一格、右边一格指向左边一格,O(1)O(1),不必搬移任何元素。链表节点用「在有序表里的位置」编号 0…n−10 \dots n-1:

数组 含义
order[k] 有序表第 kk 个位置上放的是原序列的哪个下标
rank[i] AiA_i 在有序表里的位置,满足 order[rank[i]] == i
prev[k] 位置 kk 左边一格的位置;−1-1 表示没有(哨兵)
nxt[k] 位置 kk 右边一格的位置;−1-1 表示没有(哨兵)

一步操作就是下面四件事:

操作 做法
定位 AiA_i k = rank[i]
取两个候选 位置 prev[k] 与 nxt[k] 对应的原始下标,-1 过滤掉
比较并记录 取三元组 $(
摘掉 AiA_i nxt[prev[k]] = nxt[k]、prev[nxt[k]] = prev[k]

比较键为什么写成三元组?因为题面的规则是「差值最小;差值相同时取 AjA_j 较小的那个」,而这两个条件恰好就是 (∣Ai−Aj∣,Aj)(|A_i - A_j|, A_j) 的字典序;再补上原始下标 jj 作为第三关键字,可以让比较键成为全序,代码不必再写任何平局分支。

推演一:样例 1 5 3。 排序后有序表是 1,3,51, 3, 5(下标 1,3,21, 3, 2),倒序从 i=3i = 3 开始:

步骤 ii 表内元素(数值(下标)) 左邻居候选 右邻居候选 记录 摘掉后剩下
1 3(A=3A=3) 1(1) 3(3) 5(2) (2,1,1)(2, 1, 1) (2,5,2)(2, 5, 2) 2 1 1(1) 5(2)
2 2(A=5A=5) 1(1) 5(2) (4,1,1)(4, 1, 1) 无 4 1 1(1)

最终按 i=2,3i = 2, 3 的顺序输出 4 1 与 2 1,与题面样例一致。第 1 步正是平局:差值都是 2,三元组比较的第二位把 AjA_j 较小的下标 1 选了出来。

推演二:10 3 7 8 2(即 A1=10,A2=3,A3=7,A4=8,A5=2A_1=10, A_2=3, A_3=7, A_4=8, A_5=2)。 排序后有序表为 2,3,7,8,102, 3, 7, 8, 10(下标 5,2,3,4,15, 2, 3, 4, 1),每行是一次倒推,候选写成三元组 (∣Ai−Aj∣, Aj, j)\bigl(|A_i - A_j|,\ A_j,\ j\bigr):

步骤 ii 表内元素(数值(下标)) 左邻居候选 右邻居候选 记录 摘掉后剩下
1 5(A=2A=2) 2(5) 3(2) 7(3) 8(4) 10(1) 无 (1,3,2)(1, 3, 2) 1 2 3(2) 7(3) 8(4) 10(1)
2 4(A=8A=8) 3(2) 7(3) 8(4) 10(1) (1,7,3)(1, 7, 3) (2,10,1)(2, 10, 1) 1 3 3(2) 7(3) 10(1)
3 3(A=7A=7) 3(2) 7(3) 10(1) (4,3,2)(4, 3, 2) (3,10,1)(3, 10, 1) 3 1 3(2) 10(1)
4 2(A=3A=3) 3(2) 10(1) 无 (7,10,1)(7, 10, 1) 7 1 10(1)

读者可重点看第 2 步:A4=8A_4 = 8 的左邻居是 A3=7A_3 = 7(差值 11)、右邻居是 A1=10A_1 = 10(差值 22),取小者得 1 3。第 3 步则是差值不等但都为正的常规情形。倒序算出的这四行答案按 i=2,3,4,5i = 2, 3, 4, 5 重新排列,正好是程序输出的 7 1、3 1、1 3、1 2。

综上所述,算法是:

  1. 排序得到有序表,建立 order / rank / prev / nxt;
  2. ii 从 nn 递减到 22:取 prev[rank[i]] 与 nxt[rank[i]] 两个候选,比较后写答案,再把 rank[i] 从链表里摘掉;
  3. 把答案按 i=2…ni = 2 \dots n 的顺序输出。

代码

main.py 里的变量名与上文一一对应:order 和 rank 合起来表示有序表,prev / nxt 是双向链表,ans 是按原下标预分配的输出槽位——倒序算出的答案写进 ans[i],最后一次性 '\n'.join(ans[1:]) 输出。prev = list(range(-1, n - 1)) 与 nxt = list(range(1, n + 1)) 再补一个 nxt[n - 1] = -1,就得到两端为哨兵的双向链表;min((abs(...), a[order[l]], order[l]) for l in (prev[k], nxt[k]) if l >= 0) 一行同时完成「过滤哨兵」「比差值」「平局取较小 AjA_j」三件事。

Python 版:

python
#!/usr/bin/env python3
# Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
# rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
# rainboy的学习导航网站: https://idx.roj.ac.cn
# create_at: 2026-10-01 11:28
# update_at: 2026-10-01 11:28

import sys


def solve() -> None:
    data = iter(map(int, sys.stdin.buffer.read().split()))
    n: int = next(data)
    a: list[int] = [next(data) for _ in range(n)]

    # 有序表:order 是按数值升序排好的原始下标,rank[i] 是下标 i 在表里的位置。
    order: list[int] = sorted(range(n), key=a.__getitem__)
    rank: list[int] = [0] * n
    for k, i in enumerate(order):
        rank[i] = k

    # 双向链表:在有序表上删掉一个数只改两条边,不需要搬移元素。
    prev: list[int] = list(range(-1, n - 1))  # prev[k] = k-1,-1 表示没有前驱
    nxt: list[int] = list(range(1, n + 1))    # nxt[k] = k+1
    nxt[n - 1] = -1                           # 末尾哨兵:最后一个位置没有后继

    ans: list[str] = [""] * n
    for i in range(n - 1, 0, -1):
        k = rank[i]
        # 只需比较有序表上的前驱与后继:任何更远的数,差值都不会更小。
        # 三元组按字典序比较,正好先比差值,再比 A_j 较小者,最后比下标。
        diff, value, pos = min(
            (abs(a[order[l]] - a[i]), a[order[l]], order[l])
            for l in (prev[k], nxt[k])
            if l >= 0
        )
        ans[i] = f"{diff} {pos + 1}"  # 题面要求输出 1 起始的下标

        if prev[k] >= 0:
            nxt[prev[k]] = nxt[k]  # 摘掉 i 之后,左右邻居直接相连
        if nxt[k] >= 0:
            prev[nxt[k]] = prev[k]

    # 倒序算出的答案按 (i = 2..n) 的下标顺序输出,n = 1 时没有任何一行
    sys.stdout.write('\n'.join(ans[1:]) + ('\n' if n > 1 else ''))


if __name__ == "__main__":
    solve()

C++ 版(同一算法):

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2026-10-06 15:25
 * update_at: 2026-10-06 15:25
 */
#include <cstdio>
#include <algorithm>
using namespace std;

typedef long long ll;

const int MAXN = 100005; // n <= 1e5

int n;
ll a[MAXN];   // a[1..n]:原序列,数值两两不同
int order[MAXN]; // order[k]:有序表第 k 个位置上放的是原序列的哪个下标
int rank_of[MAXN]; // rank_of[i]:A_i 在有序表里的位置
int pre[MAXN]; // pre[k]:有序表里位置 k 的前一个位置,0 表示无前驱
int nxt[MAXN]; // nxt[k]:有序表里位置 k 的后一个位置,0 表示无后继
ll ans_diff[MAXN]; // ans_diff[i]:第 i 个数的最近差值
int ans_pos[MAXN]; // ans_pos[i]:取到最近差值的原始下标

// 按数值升序排列原始下标,供 sort 使用
bool cmp_value(int x, int y) {
    return a[x] < a[y];
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) {
        scanf("%lld", &a[i]);
        order[i] = i;
    }

    // 排序得到有序表,并记录每个下标在表中的位置
    sort(order + 1, order + n + 1, cmp_value);
    for (int k = 1; k <= n; k++) {
        rank_of[order[k]] = k;
    }

    // 建双向链表,删除一个位置只需改两条边
    for (int k = 1; k <= n; k++) {
        pre[k] = k - 1;
        nxt[k] = k + 1;
    }
    pre[1] = 0;
    nxt[n] = 0;

    // 从 i=n 倒推:此时表里是 S_i 并上 A_i,取它的左右邻居即为候选
    for (int i = n; i >= 2; i--) {
        int k = rank_of[i];
        ll best_diff = -1;
        ll best_val = 0;
        int best_pos = 0;
        for (int t = 0; t < 2; t++) {
            int p = (t == 0 ? pre[k] : nxt[k]);
            if (p == 0) continue;
            int j = order[p];
            ll diff = a[i] - a[j];
            if (diff < 0) diff = -diff;
            // 三元组 (差值, A_j, 下标) 字典序最小者
            bool better = false;
            if (best_diff == -1) better = true;
            else if (diff < best_diff) better = true;
            else if (diff == best_diff && a[j] < best_val) better = true;
            else if (diff == best_diff && a[j] == best_val && j < best_pos) better = true;
            if (better) {
                best_diff = diff;
                best_val = a[j];
                best_pos = j;
            }
        }
        ans_diff[i] = best_diff;
        ans_pos[i] = best_pos;

        // 摘掉 A_i:它的左右邻居直接相连
        if (pre[k] != 0) nxt[pre[k]] = nxt[k];
        if (nxt[k] != 0) pre[nxt[k]] = pre[k];
    }

    for (int i = 2; i <= n; i++) {
        printf("%lld %d\n", ans_diff[i], ans_pos[i]);
    }
    return 0;
}

复杂度

  • 时间复杂度:O(nlog⁡n)O(n \log n)。排序是唯一的超线性步骤(sorted 由 C 实现);建表 O(n)O(n),倒序 n−1n-1 步里每步只做常数次下标访问和比较,删除只改两条边,O(1)O(1)。
  • 空间复杂度:O(n)O(n)。a、order、rank、prev、nxt、ans 各占 O(n)O(n) 个 Python 对象。
  • 与 C++ 正解(std::set + lower_bound,或先建有序表再倒删的链表写法)同阶,瓶颈都是排序。实测最大用例 n=105n = 10^5 约 0.08 s、峰值内存约 50 MB,相对 1000 ms / 128 MB 余量充足。

总结

  • 朴素做法 O(n2)O(n^2) 的瓶颈在于比较对象太多;先证明答案只可能来自有序邻居,就能把候选压到常数个。
  • 证明的写法是两侧夹逼:AiA_i 左边越远越小、右边越远越大,所以极值必在紧邻的两个数上取得。
  • 时间轴反转是关键一步:正向需要「插入」而 Python 的 list 插入是 O(n)O(n);把 SiS_i 看成「全集中挖掉 AiA_i 及之后所有元素」,就能改成从 nn 倒推的「删除」,每步 O(1)O(1)。
  • 有序表用双向链表表达:删除只改两条边;节点编号用有序表位置,rank 提供从原下标到位置的映射。
  • 平局规则不必写分支,把候选编码成 (∣Ai−Aj∣,Aj)(|A_i - A_j|, A_j) 的字典序比较即可。

图示解析

下面这张 ASCII 路线图串起从朴素做法到最终做法的三步降复杂度:

text
题意:对每个 i,在 {A_1..A_{i-1}} 中找最近的数
`- 朴素:每个 i 扫完前缀                        O(n^2)
   `- 瓶颈:候选太多,前缀里绝大多数数不可能成为答案
      `- 观察一:答案只在有序邻居 l < A_i < r 中取到    (两侧夹逼)
         `- 问题变成:维护有序集合,支持 插入 + 查前驱/后继
            `- 观察二:S_i = 全集 挖掉 {A_i..A_n}
               `- 从 i=n 倒推:查询 + 删除,都是 O(1) 的局部操作
                  `- 观察三:有序表用双向链表表示,删除只改两条边
                     `- 排序 O(n log n) + 线性倒推 = 总复杂度 O(n log n)

先看每一层解决了什么:第一层把 O(n2)O(n^2) 个候选换成 2 个;第二层把「插入」换成「删除」,绕开 Python 没有平衡树的问题;第三层给有序表配上 O(1)O(1) 删除的实现。顺着 `- 的分支往下读,每一步都只依赖上一层已经证明的结论,最后的总复杂度由排序决定。