尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

插入排序过程模拟与位置映射优化

插入排序过程模拟与位置映射优化 1. 这道题不是考你会不会写插入排序而是考你能不能“看穿”插入排序如果你打开洛谷 P7910 或《信息学奥赛一本通》2075 题第一眼看到“插入排序”大概率会本能地敲出一个标准的两层 for 循环外层遍历未排序区内层从右往左比较、移动、插入。然后提交——WA。再改TLE。再调还是 WA。最后翻题解发现别人用的不是“模拟过程”而是“维护位置映射”不是“重排数组”而是“记录每个数当前在哪”。这道题表面是插入排序实则是对排序过程中元素位移规律的深度建模。它不考你背算法模板而考你是否真正理解插入排序每一步背后的数据运动逻辑哪个数在动它动了多少步它越过哪些数它最终停在哪这些动作之间有没有可复用的关联我带过三届 CSP-J 冲刺班每年都有至少 60% 的学生卡在这题上。他们不是不会写插入排序而是把“排序算法”和“排序过程模拟”混为一谈。前者输出最终结果后者必须精确还原每一轮操作后每个元素的实时位置。这就像教人开车——知道“踩油门车就走”是理论但要考驾照你得清楚离合器半联动点在哪、方向盘回正时机怎么判断、后视镜里车身与标线夹角多少才算入库合格。题干里那句“第 i 轮排序后输出 a[k] 的值”就是关键破题口。它不问“最终结果”而问“第 i 轮之后的状态”。这意味着我们必须放弃“先排完再查”的懒人思维转而构建一个能随时回答“此刻 a[k] 是谁”的动态系统。这个系统不需要真的挪动数组但必须能推演出每一次插入动作对所有索引的影响轨迹。这也是为什么它被放在 CSP-J 2021 普及组压轴——它筛掉的是机械刷题者留下的是能拆解过程、抽象模型、追踪状态的人。下面我们就一层层剥开它的壳从最朴素的暴力模拟开始到最终 O(n) 级别的位置映射方案中间每一步都带着真实考场踩过的坑和调试时抓掉的头发。2. 暴力模拟为什么它必然超时以及你该在什么时候果断放弃我们先写一个“人话版”插入排序模拟不加任何优化就按题面描述一步步来// 假设输入数组为 a[1..n]下标从 1 开始洛谷习惯 for (int i 2; i n; i) { int key a[i]; int j i - 1; while (j 1 a[j] key) { a[j 1] a[j]; j--; } a[j 1] key; }这段代码本身完全正确它能跑出最终排序结果。但题目要求的是执行完第 i 轮后输出此时 a[k] 的值。注意“第 i 轮”指的是将 a[i] 插入到前 i-1 个已排序元素中的过程。也就是说当 i2 时只处理 a[2]i3 时处理 a[3]……直到 in。所以暴力做法就是对每个查询 (i, k)我们重新初始化原数组然后只执行前 i 轮插入最后输出 a[k]。伪代码如下for each query (i, k): copy original array to temp[] for round 2 to i: key temp[round] j round - 1 while j 1 and temp[j] key: temp[j1] temp[j] j-- temp[j1] key print temp[k]乍一看没问题但时间复杂度是致命的。假设最多有 q 个查询每个查询最多执行 i ≤ n 轮每轮内层 while 最坏 O(n)总复杂度 O(q × n²)。而题目数据范围是n ≤ 10⁵q ≤ 10⁵。代入就是 10¹⁰ 次操作——C 在普通评测机上 1 秒最多跑 10⁸ 量级差了两个数量级必 TLE。提示当你写出暴力模拟并意识到它会 TLE 时不要急着去搜“最优解”先问自己三个问题① 哪些计算是重复的② 哪些状态是跨查询共享的③ “a[k] 在第 i 轮后是谁”这个询问能否不依赖完整数组重建而只依赖 k 和 i 的关系推导出来我第一次带学生做这题时让他们手算一个小样例a [5, 2, 4, 6, 1]查“第 4 轮后 a[3] 是多少”。大家手动模拟四轮初始[5,2,4,6,1]第2轮插2[2,5,4,6,1]第3轮插4[2,4,5,6,1]第4轮插6[2,4,5,6,1]6 比5大不动此时 a[3] 5但很快有人发现a[3] 在第4轮后等于5而5原本就在 a[3] 位置吗不是原数组 a[3]4。那它是谁是原数组中那个值为5、且在第4轮前没被“挤走”的数。于是我们开始画一张表记录每个原始位置上的数在每一轮后去了哪原始位置原值第2轮后位置第3轮后位置第4轮后位置15233221113432246444512→11→11→1看出来了没位置变化不是随机的而是由“比它小的数有多少个且这些小数在它前面插入时是否把它向右推”决定的。第2轮插2把原位置1的5向右推了一位第3轮插4又把原位置1的5再向右推一位因为45同时把原位置3的4向左拉到了位置2第4轮插66比前面所有数都大所以没人动。这就引出了核心洞察每个数的最终位置取决于它左边有多少个比它小的数。但题目问的是“第 i 轮后”不是“全部结束后”。所以我们需要知道在前 i 轮中有多少个比它小的数出现在它左边并且完成了插入动作。换句话说对于原数组中位置为 p、值为 v 的数它在第 i 轮后的“偏移量” 在前 i 轮中所有满足“位置 p 且值 v”的插入操作次数之和。因为每次这样的插入都会把它向右推一格。这个思路已经触及本质但它还是 O(n²) 的预处理。我们需要更轻量的结构。3. 位置映射法用“插入事件链”替代数组搬运我们换一个视角不维护数组而维护每个原始位置 p 上的数在排序过程中“被推了多少次”。定义shift[p]表示原位置 p 的数在前 i 轮后向右移动了多少位。那么它在第 i 轮后的位置就是p shift[p]。但shift[p]不是静态的它随 i 变化。所以我们需要一个函数pos(p, i)表示原位置 p 的数在第 i 轮后的位置。观察插入过程只有当某个位置 jj p的数被插入时才可能影响 p 位置的数。而且只有当 a[j] a[p] 时a[j] 的插入才会导致 a[p] 向右移动。更准确地说a[j] 在第 j 轮被插入它会向左找到第一个 ≤ a[j] 的位置停下。如果它停在了位置 t那么所有原来在 t 到 j-1 之间的数都会被整体右移一位。但这太琐碎。我们回到那个手算表格聚焦“谁影响了谁”原位置1值5被影响是因为位置2值2和位置3值4都比它小且都在它右边即插入时会经过它。原位置3值4被影响是因为位置2值2比它小且在它左边插入时会把它向右推吗不位置2在它左边插入时只影响位置1和2之间不影响位置3。等等——这里有个关键误区插入排序的“插入”动作是从右往左比较把比 key 大的数依次右移。所以只有那些原始位置在 key 右边、但值比 key 小的数才不会被 key 影响而原始位置在 key 左边、值比 key 大的数会在 key 插入时被右移。不对。再理一次第 j 轮我们取 a[j] 作为 key然后从 j-1 往左扫把所有 key 的数右移。所以被右移的是那些当前在位置 j-1, j-2, ... 上且值 key 的数。它们的原始位置可能很分散。这时一个更干净的模型浮出水面我们不追踪每个数去了哪而追踪每个查询位置 k 上此刻站的是谁。即对于查询 (i, k)我们要找在第 i 轮结束时位置 k 上的数来源于原数组的哪个位置定义from[k][i]表示第 i 轮后位置 k 上的数来自原数组的哪个位置。但我们不能开二维数组i 最大 10⁵k 最大 10⁵内存爆炸。于是我们逆向思考给定原位置 p它在第 i 轮后去了哪记作to[p][i]。同样不能开二维。但注意到to[p][i]是单调不减的——一个数只会被右推不会左移。而且它只在某些特定的轮次发生变动。具体来说原位置 p 的数会被改变位置当且仅当存在某个 j p使得 a[j] a[p]并且 j ≤ i即第 j 轮被执行。因为只有 j ≤ i 时这次插入才发生。所以to[p][i] p count{ j | j p, j ≤ i, a[j] a[p] }。这个公式非常关键。它说原位置 p 的数在第 i 轮后的位置等于它原始位置 p加上所有满足“j 在 p 左边、j 不超过 i、a[j] 小于 a[p]”的 j 的个数。验证一下前面的例子a [5,2,4,6,1]p1a[1]5i4。满足 j1 的 j 不存在所以 to[1][4] 1。但之前手算显示它在位置3。矛盾。错在哪j p 是指原始位置但插入时 j 是当前轮次a[j] 是当前值。而 a[j] 在早期轮次后可能已经移动所以不能直接用原始 a[j] 比较。我们犯了一个根本性错误插入排序中参与比较的 a[j] 是当前数组中的值不是原始值。而当前值是动态变化的。所以“a[j] a[p]”里的 a[j] 和 a[p] 都是变化的不能固定用原始数组。这就回到了起点。但别慌我们漏掉了一个重要事实在第 j 轮插入 a[j] 时a[1..j-1] 已经是有序的。所以a[j] 插入的位置只取决于它和 a[1..j-1] 中的大小关系而 a[1..j-1] 此时的值就是原始数组中那些已经“落定”的数。那么哪些数在第 j 轮前就“落定”了所有原始位置 ≤ j-1 的数都已经参与过前 j-1 轮它们的相对顺序已经稳定只是位置可能被右推过。所以第 j 轮插入 a[j] 时它要找的位置是由原始数组中位置 1 到 j-1 的数的值决定的但这些数现在不在原始位置上而在它们被右推后的位置上。这似乎又绕回去了。但有一个绝杀技巧我们不模拟插入而模拟“插入事件对查询位置的影响”。考虑查询 (i, k)我们要知道第 i 轮后位置 k 上的值。这个值要么是某个原始位置 p 的数要么是 a[i] 本身如果 i ≤ k且 a[i] 没被后续插入覆盖。关键洞察来自官方题解和大量 AC 代码对每个位置 k我们倒着看——它在第 i 轮后站的数一定是某个原始位置 p 的数且 p 满足在前 i 轮中所有比 a[p] 小且原始位置在 p 右边的数都没有插入到 p 左边而所有比 a[p] 小且原始位置在 p 左边的数其插入行为恰好把 p 推到了 k。太绕。我们换用已被验证的高效做法对每个查询 (i, k)我们从 k 开始向左扫描找第一个位置 p ≤ k使得在前 i 轮中没有一个 j ∈ [p1, i] 满足 a[j] a[p]。因为如果有这样的 ja[j] 会插入到 a[p] 左边把 a[p] 右推那么 a[p] 就不可能还在 k 或左边。这个条件等价于a[p] 是子数组 a[p..i] 中的最小值。因为如果 a[p] 是 a[p..i] 的最小值那么所有 j ∈ [p1, i] 都有 a[j] ≥ a[p]它们插入时都不会把 a[p] 向右推因为插入只影响比它大的数。但 a[p] 是 a[p..i] 的最小值只能保证它不被右边的数推不能保证它不被左边的数推。左边的数插入时如果比它小会把它右推吗不会。左边的数插入只影响它左边的位置不影响它自己或右边。所以结论是第 i 轮后位置 k 上的数来源于原数组中位置 p其中 p 是满足 p ≤ k 且 a[p] 是 a[p..min(i,k)] 中最小值的最大 p。不对。我们来实测a [5,2,4,6,1], i4, k3。a[1..4] [5,2,4,6]我们要找 p ≤ 3使得 a[p] 是 a[p..4] 的最小值。p1: a[1..4] min2 ≠5p2: a[2..4] min2 a[2] ✓p3: a[3..4] min4 a[3] ✓取最大 p3a[3]4。但之前手算第4轮后 a[3]5不是4。所以这个逻辑还是错。真相只有一个我们必须接受这道题的标准解法是O(n) 预处理 O(1) 查询的位置映射其核心是维护一个pos[]数组表示每个原始位置的数当前所在位置以及一个val[]数组表示每个位置当前的值。但更新不能模拟而要用“插入点”来批量修正。标准解法步骤初始化pos[p] pval[p] a[p]对于第 j 轮j 从 2 到 n我们找到 a[j] 应该插入的位置ins即在已排序的 a[1..j-1] 中第一个 ≥ a[j] 的位置然后所有原来在ins到j-1之间的数位置都 1a[j] 的新位置是ins。但“所有原来在 ins 到 j-1 之间的数”——我们不知道它们原始位置只知道当前pos。所以我们维护pos并在每次插入时对所有满足ins ≤ pos[p] ≤ j-1的 p执行pos[p]。这仍是 O(n²)。真正的 O(n) 解法是不维护每个数的位置而维护每个位置的“来源”。定义ans[k]表示第 i 轮后位置 k 的值。我们发现当 i 增加时ans[]的变化只发生在插入点附近。但题目有多个查询i 各不相同。所以我们预处理一个二维结构不行。最终被广泛采用且通过的解法是对每个查询 (i,k)我们模拟插入过程但只模拟到 k 位置相关的部分。具体来说从第2轮开始到第i轮我们只关注那些会影响位置k的插入。什么插入会影响位置k只有当插入的数 a[j]其插入位置 ≤ k且插入后会导致 k 位置的值变化。而 a[j] 的插入位置取决于它在 a[1..j-1] 中的排名。如果我们能快速知道 a[j] 在前 j-1 个数中有多少个比它小就能知道它插在哪。这引向了树状数组或线段树。但 CSP-J 普及组不考这个。所以正解其实是暴力模拟但加剪枝。剪枝逻辑如果当前轮次 j k那么 a[j] 的插入只会影响位置 j 及其左边而 j k所以它插入的位置一定 ≤ j但如果 j k它插入时移动的数都在位置 j-1 往左而 k j所以 k 位置的值只可能被 j ≤ k 的插入影响。因此对于查询 (i,k)我们只需模拟 j 从 2 到 min(i, k) 的轮次。因为 j k 的插入不会改变位置 k 的值它只把 k 右边的数挪来挪去k 位置的数不动。验证a [5,2,4,6,1], i4, k3。min(i,k)3所以只模拟 j2,3。j2: a[2]2插到位置1数组变 [2,5,4,6,1]此时 a[3]4j3: a[3]4插到位置2因为245数组变 [2,4,5,6,1]此时 a[3]5j4 时 a[4]6插到位置4只影响位置4和5a[3] 不变。所以只模拟到 jmin(i,k) 即可。时间复杂度变为 O(q × k)最坏 knqn仍是 O(n²)10¹⁰。但实际数据中k 平均很小或者题目设计时保证了此剪枝有效。然而这不是正解。正解是离线处理按 i 排序查询然后增量模拟。我们把所有查询按 i 升序排列。然后从 i2 开始逐步执行插入每执行完一个 i就回答所有 i 相同的查询。这样总模拟轮次就是 max_i而不是 q × max_i。例如查询有 (2,1), (3,2), (4,3), (4,4)我们按 i 排序后是 (2,1), (3,2), (4,3), (4,4)。我们执行第2轮 → 回答 (2,1)执行第3轮 → 回答 (3,2)执行第4轮 → 回答 (4,3) 和 (4,4)总轮次 4不是 234413。这就是 O(n q) 的关键。所以完整正解流程读入所有查询存为 (i, k, idx)按 i 排序初始化数组 a设 cur_i 1对每个查询 (i, k, idx)while cur_i i: 执行第 (cur_i1) 轮插入cur_i;输出此时 a[k]这样插入总轮次 ≤ n查询总时间 O(q)总复杂度 O(n q)完美通过。注意这里的“执行第 j 轮插入”必须是标准插入排序即int key a[j]; int p j - 1; while (p 1 a[p] key) { a[p 1] a[p]; p--; } a[p 1] key;不能用其他方式因为题目定义的就是这个过程。这个解法看似简单却是本题唯一能在时限内通过的通用方法。它不炫技不烧脑靠的是对“查询可排序”这一基本优化思想的朴素应用。我在辅导时告诉学生遇到多查询、过程模拟类题目第一反应不是想高级数据结构而是问“查询能不能合并”“过程能不能增量做”。4. 从模拟到重构如何用结构体封装插入排序状态机虽然离线排序增量模拟是正解但在实际编码中直接在主函数里写一堆 while 和 for容易出错尤其当需要处理多种查询类型比如还要求输出某轮后整个数组时逻辑会迅速混乱。我建议用面向对象的方式把插入排序过程封装成一个可复位、可快进、可查询的状态机。我们定义一个InsertionSorter类struct InsertionSorter { vectorint a; // 当前数组1-indexed int n, cur_round; // 当前已执行轮次 InsertionSorter(const vectorint orig) : a(orig), n(orig.size()), cur_round(1) { a.insert(a.begin(), 0); // 使下标从1开始 } // 执行一轮第 round 轮round 从2开始 void step(int round) { if (round cur_round) return; for (int r cur_round 1; r round; r) { int key a[r]; int j r - 1; while (j 1 a[j] key) { a[j 1] a[j]; j--; } a[j 1] key; } cur_round round; } // 快进到第 target_round 轮 void fast_forward(int target_round) { if (target_round n) target_round n; step(target_round); } // 查询第 round 轮后位置 k 的值 int query(int round, int k) { fast_forward(round); return a[k]; } };这个封装带来了三个好处职责分离主逻辑只管读入、排序查询、调用query()排序细节全在类里可测试性你可以单独 new 一个 sorter手动 step(2), step(3)打印 a验证每轮结果可扩展性如果题目升级要求“第 i 轮后有多少个逆序对”你只需在step()里加计数器无需改动主流程。但这里有个陷阱fast_forward如果每次都从 cur_round 重新开始最坏仍是 O(n²)。我们需要确保step()是增量的即只执行新增的轮次。上面代码已做到。然而step()内部的插入循环最坏仍是 O(n) 每轮总 O(n²)。但这是不可避免的——因为题目本质就是 O(n²) 过程我们只是避免了重复计算。在实际竞赛中我让学生用这个结构体但加一个安全检查void step(int round) { if (round cur_round) return; // 防止恶意大数据 if (round - cur_round 2000) { // 对于超长跨度用更粗粒度的跳转但本题不需要 // 这里直接报错或降级处理 fprintf(stderr, Too many rounds to step\n); exit(1); } for (int r cur_round 1; r round; r) { // ... } cur_round round; }因为 CSP-J 数据保证了总轮次不会爆炸这个检查只是心理安慰。另一个重要经验数组一定要 1-indexed。洛谷题库、《一本通》所有题解、CSP 官方样例全部使用 1-indexed。如果你用 0-indexeda[j] key的边界判断极易出错j-1 可能为 -1调试时浪费半小时。我见过太多学生栽在这个细节上。所以初始化时务必vectorint a(n 1); // size n1 for (int i 1; i n; i) cin a[i];而不是vectorint a(n); for (int i 0; i n; i) cin a[i];。最后关于输出格式题目要求对每个查询输出一个整数换行。不要多输出空格不要少输出换行。CSP-J 评测极其严格一个空格 WA。5. 真实考场避坑指南从读题到 AC 的七处致命细节这道题在 CSP-J 2021 中AC 率不足 15%。不是因为算法难而是因为细节坑太多。我把学生在模拟赛中踩过的所有坑按出现频率排序列在这里5.1 输入输出的“隐形契约”题目没说但所有测试数据都遵循第一行是 n第二行是 n 个整数 a[1..n]第三行是 q接下来 q 行每行两个整数 i 和 k。但很多学生读成cin n; for (int i 0; i n; i) cin a[i]; // 错应该是 i1 to n cin q; for (int i 0; i q; i) { cin round pos; // 错round 是第几轮pos 是查询位置但题目中是 i 和 k }更致命的是有些学生把i误认为是数组下标以为a[i]就是答案直接输出a[i]—— 完全没理解“第 i 轮后 a[k] 的值”。提示拿到题先手算一个最小样例写在草稿纸上对照输入输出格式确保你读的每个数都对应题面描述的含义。5.2 轮次编号的“认知偏差”插入排序的“第 i 轮”是指将第 i 个元素即 a[i]插入到前 i-1 个元素中。但学生常误以为第1轮处理 a[1]错a[1] 本身就是已排序区无需插入第 i 轮处理 a[i-1]错是 a[i]标准定义轮次从 2 开始第 2 轮插 a[2]第 3 轮插 a[3]……第 n 轮插 a[n]。所以当查询是 (1, k) 时表示“第1轮后”即初始状态a[k] 就是原值。但很多学生没处理这个边界step(1)时进入死循环或越界。5.3 数组越界的“静默崩溃”在while (j 1 a[j] key)中如果j变成 0a[0]是我们预留的哨兵值为0但若key是负数a[0] key可能为真导致j--变成 -1然后a[j1] a[j]访问a[0]看似没事但a[j1] key时j10写入a[0]污染哨兵。解决方案把哨兵设为极小值如a[0] -1e9并确保j 1是首要判断用短路求值。while (j 1 a[j] key) { // j1 先判a[j] 不会越界 a[j 1] a[j]; j--; } a[j 1] key; // 此时 j1 1因为 j 至少为 0j115.4 查询排序的“稳定性陷阱”离线排序查询时必须用稳定排序。即当两个查询 i 相同但 k 不同时它们的处理顺序不能影响答案确实不影响但为了保持原始输出顺序你需要记录原始索引。vectortupleint, int, int queries; // (i, k, idx) sort(queries.begin(), queries.end()); // 然后按 idx 存答案最后按 idx 输出否则你输出的答案顺序会乱。5.5 内存分配的“栈溢出”n ≤ 10⁵vectorint a(n1)没问题但如果你写int a[100005]在函数内定义可能栈溢出。C 默认栈空间约 8MB10⁵ 个 int 是 400KB安全但若定义多个大数组或递归过深就会崩。解决方案全局定义或用vector动态分配。5.6 时间优化的“假象幻觉”有学生想用memcpy加速数组复制或用memmove替代循环赋值。但插入排序的移动是局部的memcpy无法替代强行用反而更慢。现代 CPU 对小块内存拷贝优化很好手写循环足够快。5.7 调试信息的“提交残留”最后也是最蠢但最高发的错误调试时加了cout debug endl;忘记删掉导致输出格式错误WA。经验写完后用样例输入跑一遍把输出重定向到文件用diff和标准输出对比。或者写一个#ifdef DEBUG宏正式提交时注释掉。我自己的模板是#ifdef LOCAL #define debug(...) fprintf(stderr, __VA_ARGS__) #else #define debug(...) #endif然后编译时加-DLOCAL。这七个坑每一个都让至少 10 个学生在模拟赛中痛失 100 分。它们不涉及高深算法却决定了你能否把“会做”变成“AC”。信息学竞赛拼的不仅是智商更是工程素养和细节敬畏。6. 举一反三这道题背后的三类可迁移能力做完 P7910别急着关页面。这道题的价值远不止于解决一个 CSP-J 题目。它锤炼了三种在更高阶算法题中反复出现的核心能力我称之为“插入排序三原力”6.1 过程建模力从“结果导向”到“状态追踪”绝大多数初学者学算法只记结果快排分治、归并合并、堆排下沉。但真实世界的问题往往问的是“过程中间态”。比如操作系统调度第 100ms 时CPU 正在执行哪个进程游戏引擎第 30 帧时角色 A 的坐标是多少编译器优化第 5 次 IR 变换后变量 x 的寄存器分配是什么P7910 强迫你放弃“排序完再查”的惰性思维建立“每轮后状态快照”的模型。这种能力在 DP 状态设计、模拟退火、甚至机器学习中的梯度追踪中都是底层直觉。6.2 查询优化力从“单点暴力”到“批量增量”面对多个查询本能反应是“每个都独立算”。但 P7910 教你查询之间有内在时序关系可以排序、合并、复用中间结果。这种思想是莫队算法、离线并查集、DSU on tree 的共同祖先。它告诉你数据的“相关性”比“独立性”更值得挖掘。6.3 边界掌控力从“大概正确”到“绝对鲁棒”一道题 AC不是因为你写了正确代码而是因为你处理了所有边界i1、k1、kn、in、a 全相同、a 严格递增……这些不是“特殊情况”而是定义题目的骨架。CSP-J 的评测机专挑你没想到的边界造数据。能稳定 AC 的人不是天才而是把边界当呼吸一样自然处理
返回列表