多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

CSP-J P7910:利用稳定排序与局部调整优化动态排序查询

CSP-J P7910:利用稳定排序与局部调整优化动态排序查询 拿到 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 只有 8000q 达到 2×10^5 时总运算量也在十亿量级以上C 在竞赛时限内很难跑完。还有一种更“贴近题意”的暴力每次查询真的用插入排序过程去排一遍。插入排序最坏是 O(n^2)8000 的平方乘以 2×10^5这个数字更大没有任何通过的希望。所以朴素的思路必须抛弃。2.2 数据范围里藏着解题信号再仔细读题n ≤ 8000q ≤ 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) 返回。做法是维护一个始终有序的数组 bb 里存结构体每个结构体有两个字段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 数组的同步更新上。只要把握住“交换数组元素的同时必须交换它对应的位置标记”这一条铁律这类题目基本不会再出大问题。
返回列表