拿到 P7910 这道题的时候,我第一反应是:这不就是把插入排序模拟一遍吗?直到看见 q 的范围是 2×10^5,才意识到事情没那么简单。题目要求维护一个长度不超过 8000 的序列,支持修改某个位置的值,以及查询某个“原下标”对应的元素在稳定排序后出现在第几位。这个“稳定排序后”非常关键,它把插入排序的过程抽象成了一种确定的排序规则,而不是真的要你每次查询都去跑一遍插入排序。如果真按插入排序去完整重排,每次查询都是 O(n^2),肯定当场超时。
这道题的精髓在于:一次修改只会让一个元素的位置错乱,其余元素之间的相对顺序完全不受影响。只要抓住这一点,就能把修改操作的复杂度从 O(n log n) 或 O(n^2) 拉低到 O(n) 的局部调整。下面我会从题目模型、核心思路、代码实现到易错点,把这道 CSP-J 2021 第三题完整拆一遍,顺便聊聊这类“修改少、查询多”题目的通用处理套路。
1. 从题面到模型:P7910 到底让我们做什么
1.1 稳定排序在题目里的隐藏含义
题目说的是用“插入排序”对序列排序,但插入排序属于稳定排序,这意味着当两个元素值相同时,排序后它们的相对顺序必须保持原样。举个例子,原序列是:
a[1] = 2, a[2] = 1, a[3] = 2稳定排序后,两个值为 2 的元素谁在前?答案是原下标小的在前,也就是先出现 a[1] 再出现 a[3]。排序结果等价于:把每个元素看成二元组(值, 原下标),然后按“值从小到大,值相同则原下标从小到大”进行排序。
这一步理解到位了,题目就从“模拟插入排序过程”转化成了“维护一个二元组有序数组的动态问题”。很多同学一开始会忽略这个稳定性,直接把数组按值排序,导致相等元素之间的位置关系乱掉,查询结果自然就不对。
1.2 修改操作和查询操作的本质区别
题目里有两种操作:
1 x v:把当前数组的第 x 个位置的值修改为 v。2 x:查询如果将当前数组进行稳定排序,原本第 x 个位置的那个元素会排在第几位。
这里有个很隐蔽的坑:查询的 x 是“原始下标”,也就是最初输入时那个元素所在的位置。修改操作不会改变元素的身份,即使它排序后在数组中间,下一次查询仍然问的是“这个原下标 x 对应的元素”现在排到哪了。
所以我们在实现时,不能只维护值,还必须给每个元素一个永久的 id,也就是它最初的数组下标。所有排序、比较、交换,都要带着这个 id 一起走。排序后的结果数组里存的不是裸的数字,而是一个个带有身份信息的结构体。
从操作次数看,n 只有 8000,但 q 高达 2×10^5。如果每次查询都重新排序整个数组,哪怕用 O(n log n) 的 sort,总复杂度也是 2×10^5 × 8000 × log 8000,约等于 2×10^10 级别,完全不可行。这提醒我们:查询必须做到 O(1) 或者接近 O(1),而修改可以稍微慢一点。
2. 朴素做法为什么必然超时
2.1 每次查询重新排序的复杂度陷阱
先看看最直观的暴力方案:查询时把当前数组复制一份,用 sort 稳定排序,然后遍历找原下标 x 的位置。排序一次的时间是 O(n log n),q 次查询就是 O(q n log n)。即便 n 只有 8000,q 达到 2×10^5 时,总运算量也在十亿量级以上,C++ 在竞赛时限内很难跑完。
还有一种更“贴近题意”的暴力:每次查询真的用插入排序过程去排一遍。插入排序最坏是 O(n^2),8000 的平方乘以 2×10^5,这个数字更大,没有任何通过的希望。所以朴素的思路必须抛弃。
2.2 数据范围里藏着解题信号
再仔细读题:n ≤ 8000,q ≤ 2×10^5,但题目额外保证所有 1 操作(修改操作)的次数不超过 5000。这个限制不是随便给的,它直接把题目的性质暴露出来了:修改少,查询多。
为什么这个限制这么重要?因为如果修改操作很少,我们就可以忍受每次修改花费 O(n) 的时间去维护有序数组;而查询操作很多,就必须让查询做到 O(1)。这样一来总复杂度是:
排序预处理 O(n log n) + 修改 O(5000 × n) + 查询 O(q)代入 n = 8000,修改部分大约是 5000 × 8000 = 4×10^7,这在 1 到 2 秒的时限内是完全可以接受的。看到这种“某一种操作次数被限制”的数据范围说明,先别急着想高级数据结构,多想想能不能通过预处理和局部维护来降低高频操作的复杂度。
3. 核心思路:一个元素变了,有序序列剩下部分仍然是完整的
3.1 维护有序数组 b 和位置映射 pos
既然修改少、查询多,我们的目标就是让查询能直接 O(1) 返回。做法是维护一个始终有序的数组 b,b 里存结构体,每个结构体有两个字段:val表示当前值,id表示原下标。排序规则是:
按 val 从小到大排序; 如果 val 相同,按 id 从小到大排序。再开一个pos数组,其中pos[id]表示“原下标为 id 的元素”当前在 b 数组中的位置。这样查询操作就非常简单:
cin >> x; cout << pos[x] << '\n';因为 x 是原始下标,它对应的元素在 b 里的位置我们已经实时维护好了,直接查表输出即可。
3.2 为什么单点修改只影响一个元素的位置
假设当前 b 是一个已经排好序的数组。现在把某个位置 x 的值从旧值改成新值 v,其他所有元素的值都没变。那么在 b 数组中,除了这个元素之外,其他元素之间的相对顺序本来就满足排序要求,完全不需要重新排列。
我们可以想象成一排人按身高从矮到高站好了,突然其中一个人脚踩了增高鞋或者鞋底坏了,身高发生了变化。其他人都没有动,队伍整体仍然有序,只有这个身高改变的人需要往左或往右走几步,走到他应该在的位置停下来,队伍就又恢复有序了。
具体来说:
- 如果新值变大,这个元素应该往右移动,直到右边没有比自己小的元素。
- 如果新值变小,这个元素应该往左移动,直到左边没有比自己大的元素。
- 如果新值没变,那就什么都不用做。
每次移动只需要和相邻元素比较、交换,交换次数最多为 n-1,所以一次修改的时间是 O(n)。
3.3 移动时同步更新 pos 数组
这里是最容易写错的地方。交换数组里相邻两个元素时,它们各自的位置都发生了变化,pos数组必须跟着更新。比如当前元素在位置 p,它和位置 p-1 的元素交换后,原来 p-1 的元素跑到了 p,当前元素跑到了 p-1,那么:
swap(b[p], b[p-1]); pos[b[p].id] = p; pos[b[p-1].id] = p - 1; p--;很多同学写完交换后忘了更新 pos,或者只更新了其中一个,后面查询结果就会错得莫名其妙。每次 swap 之后必须立刻补上两行 pos 更新,这是整道题实现的核心细节。
4. 代码实现与细节处理
4.1 结构体定义与比较函数
我们需要一个结构体来同时保存值和原下标:
struct Node { int val; int id; };比较函数必须体现稳定排序规则:
bool isBefore(const Node &a, const Node &b) { return a.val < b.val || (a.val == b.val && a.id < b.id); }isBefore(a, b)表示“a 是否应该排在 b 前面”。这样写的好处是,无论排序还是移动时判断顺序,都可以复用一个函数,避免逻辑不一致。
4.2 完整的 C++ 参考实现
#include <bits/stdc++.h> using namespace std; struct Node { int val; int id; }; const int MAXN = 8005; Node b[MAXN]; int pos[MAXN]; int n, q; bool isBefore(const Node &a, const Node &b) { return a.val < b.val || (a.val == b.val && a.id < b.id); } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); cin >> n >> q; for (int i = 1; i <= n; i++) { cin >> b[i].val; b[i].id = i; } sort(b + 1, b + n + 1, isBefore); for (int i = 1; i <= n; i++) { pos[b[i].id] = i; } while (q--) { int op; cin >> op; if (op == 2) { int x; cin >> x; cout << pos[x] << '\n'; } else { int x, v; cin >> x >> v; int p = pos[x]; int oldVal = b[p].val; b[p].val = v; if (v > oldVal) { // 值变大,向右移动 while (p < n && isBefore(b[p + 1], b[p])) { swap(b[p], b[p + 1]); pos[b[p].id] = p; pos[b[p + 1].id] = p + 1; p++; } } else if (v < oldVal) { // 值变小,向左移动 while (p > 1 && isBefore(b[p], b[p - 1])) { swap(b[p], b[p - 1]); pos[b[p].id] = p; pos[b[p - 1].id] = p - 1; p--; } } // v == oldVal 时不需要移动 } } return 0; }4.3 移动方向的判断逻辑
我在代码里先用oldVal保存修改前的值,然后判断新值和旧值的大小关系来决定方向。这样做比不区分方向、直接跑两个 while 更清晰,也减少了不必要的比较。
向右移动的条件是isBefore(b[p + 1], b[p]),意思是右侧元素比当前元素小(或者值相等但 id 更小,也就是它应该排在当前元素前面)。只要满足这个条件,就说明当前元素还没到正确位置,继续交换。
向左移动的条件是isBefore(b[p], b[p - 1]),意思是当前元素比左侧元素小(或者值相等但 id 更小),当前元素应该继续往左挪。
判断时用严格的小于,避免了值相等时不必要交换。如果新值和旧值相同,两个 while 都不会进入,因为isBefore判断相等元素的顺序时,id 小的排前面,而同一个元素的 id 不可能比自己小,所以条件不成立。
5. 易错点与测试用例设计
5.1 排序必须带原下标作为第二关键字
这是最容易踩的坑。如果不带原下标,直接用sort(b + 1, b + n + 1, [](a, b){ return a.val < b.val; }),那么两个值相等的元素顺序是未定义的。C++ 的sort是不稳定排序,它可能会打乱相等元素的相对顺序。
题目要求的是插入排序,也就是稳定排序。我们必须保证相等元素按原下标升序排列,否则查询结果和手算结果对不上。我在上面定义的isBefore函数里显式处理了val相等的情况,这一步不能省。
5.2 修改时先记录旧值
代码里我先把oldVal = b[p].val存下来,再改b[p].val = v。这个顺序很重要。如果先修改值,再判断方向,你就已经不知道原来的值是什么了,方向判断只能靠写两个 while 自动判断,虽然也能写,但可读性会差一些。
另外,如果你在修改值之前没有保存旧值,代码里还有一个隐患:万一新值和旧值一样,你其实不需要做任何移动,但不清不楚的写法可能让元素在相邻位置来回交换,白白浪费时间。先保存旧值,三个分支一目了然。
5.3 手造数据验证代码逻辑
我自己做题时有一个习惯:造几组小数据,手动模拟一遍,再拿代码跑,对拍验证。这题我建议造一组包含相等元素的数据,专门测稳定排序。
测试数据:
5 6 3 1 2 2 1 2 2 1 2 4 2 2 2 5 1 5 0 2 5手动模拟:
- 初始数组 a = [3, 1, 2, 2, 1],五个元素按原下标编号 1~5。
- 稳定排序后是:
(1,2), (1,5), (2,3), (2,4), (3,1)。 - 查询
2 2:原下标 2 的元素值为 1,排在第 1 位,输出 1。 - 修改
1 2 4:把下标 2 的元素值改成 4,排序后变成(1,5), (2,3), (2,4), (3,1), (4,2)。 - 查询
2 2:原下标 2 的元素值为 4,排在第 5 位,输出 5。 - 查询
2 5:原下标 5 的元素值为 1,排在第 1 位,输出 1。 - 修改
1 5 0:把下标 5 的元素值改成 0,排序后变成(0,5), (1,2)?,但当前数组是 [3, 4, 2, 2, 0],下标 2 的值是 4,所以排序后是(0,5), (2,3), (2,4), (3,1), (4,2)。 - 查询
2 5:输出 1。
这个手算过程可以验证代码里的移动方向和 pos 更新是否正确。像我上面代码里那样,修改下标 2 的值为 4 时,它原本在排序数组的某个位置,向右移动的过程中每交换一次都要紧跟着更新 pos。你可以把代码跑一遍对比输出,确保每一步都和手算一致。
5.4 注意输入输出效率
q 最大是 2×10^5,输出量可能也很大。C++ 里如果直接用cin/cout不关同步,可能会因为 IO 开销卡常。建议在 main 开头加上:
ios::sync_with_stdio(false); cin.tie(nullptr);如果是在比赛环境中对性能不放心,也可以把输出先存到 string 或 vector 里,最后一次性输出。这题的输出量不算特别夸张,关掉同步后基本够用。
6. 从这题看竞赛中的“修改少查询多”套路
6.1 操作次数不对称性就是解题信号
很多数据结构的题,看起来是动态维护排序后的位置,第一反应可能是树状数组、平衡树、线段树一类高级结构。但这道题明确告诉你:n 只有 8000,修改次数不超过 5000。这种“不对称”的操作次数设定,其实是在提示你不需要上太高级的东西,直接用 O(n) 的局部调整就可以。
拿到题目先别急着写代码,先算一笔账:
- 如果修改 O(n),查询 O(1),总复杂度是 O(n log n + 5000n + q),约为 4×10^7。
- 如果修改 O(log n),查询 O(log n),总复杂度是 O((5000 + q) log n),约为 2×10^6,当然更优,但实现复杂度会高不少。
- 如果修改 O(1),查询 O(n),总复杂度是 O(5000 + qn),约为 1.6×10^9,必然超时。
所以最合理的平衡点是“修改慢一点,查询 O(1)”。这在实际比赛中是非常常见的思维:高频操作要快,低频操作可以慢。
6.2 类似的题还能怎么变
如果把 n 放大到 2×10^5,并且不限制修改次数,那 O(n) 的移动就不可行了。这时需要把元素按值离散化,用树状数组维护每个值出现的次数,查询某个原下标元素排序后的位置,等价于计算“值小于它的元素个数 + 值等于它且原下标小于它的元素个数 + 1”。修改操作则是从旧值中减一、新值中加一,复杂度 O(log n)。
不过 CSP-J 的难度不会要求到树状数组,P7910 考的就是对稳定排序的理解和局部调整的能力。但如果将来遇到类似题,比如洛谷上的 P3369 普通平衡树,或者各种带修改的排名查询,你可以往这个方向想:只要操作次数不对称,就优先尝试从高频操作的 O(1) 入手,看能不能用预处理和维护映射来解决。
回到这道题本身,它其实是想告诉我们一个很朴素的道理:排序不一定要每次都从头排。大部分时候数据只是发生了微小的变化,原本的有序结构仍然可以利用。维护好“元素身份”和“当前位置”的映射,一次修改只需要让那一个元素归位,剩下的秩序就还在。我在实际做题中最大的体会是:这类题写代码的时间往往不长,调试的时间却很长,而大部分 bug 都出在 pos 数组的同步更新上。只要把握住“交换数组元素的同时必须交换它对应的位置标记”这一条铁律,这类题目基本不会再出大问题。