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

文章详情

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

康托展开与逆康托展开:排列排名算法详解及树状数组优化实现

康托展开与逆康托展开:排列排名算法详解及树状数组优化实现 第一次在洛谷刷到 P5367 的时候我盯着题面上“【模板】康托展开”这六个字看了好一会儿。康托展开这名字听着就比线段树、树状数组抽象结果点开题解一看核心逻辑居然简单到可以用一句话说清给你一个从 1 到 n 的排列问它在所有全排列里按字典序排第几。反过来给你一个名次也能还原出原排列。就这么个看似简单的东西却是信奥里排列枚举、状态压缩、甚至八数码问题里的常客值得花点时间彻底吃透。这篇文章我就把自己从零手写 P5367 的完整过程还原一遍包含公式推导、树状数组优化、AC 代码、逆康托展开以及我调试时踩过的坑。适合刚接触排列类算法的选手也适合那些“看过题解但没真正理解为什么这么写”的朋友。1. 先搞清楚 P5367 在考什么以及康托展开的用处很多人第一次接触“康托展开”这个概念第一反应是背公式。我之前也是这么干的结果隔两周再写又忘了。后来才意识到这玩意儿本质上就是一个“字典序排名计数器”它的定义和我们的直觉是完全一致的。1.1 从一个具体排列说起排名是什么假设 n 31 到 3 的全排列按字典序排出来是1 2 31 3 22 1 32 3 13 1 23 2 1这种顺序大家应该都熟悉就是一个一个数字比大小跟查英文词典差不多。现在我问你排列 (2,1,3) 排第几一眼扫过去第三。那如果 n 10给你一个随机排列比如 (3,1,4,2,5,9,8,7,6,10)你还想一眼看出来吗几乎不可能。康托展开要解决的就是这个问题给定排列 p快速计算它是第几个排列。题目 P5367 的 n 最大能到 10^6所以不可能把全排列枚举出来必须用 O(n log n) 甚至 O(n) 的做法。这也是为什么题目叫“模板”——它考察的是一种固定套路理解了就一劳永逸。1.2 除了刷题这玩意儿还能用在哪儿康托展开在实际竞赛里最常见的两个用途一是状态压缩比如某个搜索状态是一个排列我想给每种排列分配一个唯一的整数 ID 作为访问标记就可以用康托展开把排列映射成编号数组比哈希表快得多二是全排列枚举有时题目要求“从小到大输出第 k 个排列”这时候逆康托展开直接构造答案。另外著名的八数码问题里棋盘状态可以抽象成一个 0 到 8 的排列用康托展开压缩状态配合 BFS 或者 A* 就能跑。换句话说你如果打算打区域赛或者准备 NOIP 提高组康托展开和逆康托展开算是排列类问题的基本功。1.3 P5367 的具体数据范围与要求P5367 题目本身给了一个排列让你输出它的排名对 998244353 取模。这里有两个关键点排名从 1 开始。也就是说最小的排列 (1,2,3,...,n) 的排名是 1而不是 0。n 最大 10^6所以阶乘必须预处理所有中间计算要对 998244353 取模防止爆 long long。明白了这两点后面的公式和代码才有意义。2. 公式背后的数学逻辑排名为什么能拆成“阶乘和”我先直接给出康托展开的公式然后再一步步拆解确保你不仅会背还能理解每一项是怎么来的。对于一个排列 p (p1, p2, ..., pn)它的字典序排名满足rank 1 Σ cnt_i × (n - i)!其中 cnt_i 表示“在位置 i 之后且数值比 p_i 小的数字个数”。2.1 为什么是“后面有几个更小的数”以排列 (2,1,3) 为例i 1p1 2。在它后面比 2 小的数字有 1所以 cnt_1 1。i 2p2 1。后面没有比 1 小的数字cnt_2 0。i 3p3 3。后面没有数字cnt_3 0。套公式rank 1 1 × 2! 0 × 1! 0 × 0! 1 2 3。跟前面表格里对上了。那这一项 1 × 2! 到底代表什么我在草稿纸上画了挺久才想通。你要计算 (2,1,3) 的排名本质上是在数“有多少排列排在它前面”。排在它前面的排列字典序都比它小那么一定存在某个位置 i前面 i-1 位和 (2,1,3) 完全相同而第 i 位更小。对于 i 1第一位可以是任何比 2 小的数字也就是只有 1 这一种选择。第一位确定成 1 之后剩下两个位置可以任意排列有 2! 种方式。所以第一位就决定了 1 × 2! 2 个排列排在 (2,1,3) 前面也就是 (1,2,3) 和 (1,3,2)。对于 i 2第二位要小于 p2 1但前面第一位必须还是 2第二位比 1 小的数字不存在贡献为 0。i 3 同理。2.2 每一位贡献的精确含义我需要强调一个容易被忽略的细节cnt_i 不是“所有没出现过的比 p_i 小的数字个数”而是“位置 i 后面还没被使用过、且比 p_i 小的数字个数”。因为在计算第 i 位时前面 i-1 位已经固定了那 i-1 个数字就不能再参与第 i 位的替换了。举个例子n 4排列 (4,1,2,3)。第一位是 4后面比 4 小的数字有 1、2、3共 3 个。所以第一位贡献 3 × 3! 18。这 18 个排列指的是第一位固定为 1、2、3 中任意一个3 种选法后三位任意排列3! 种共 18 种。它们全部排在 (4,1,2,3) 前面。到第二位时p2 1后面比 1 小的数字已经没有了贡献为 0。继续往后扫每一位的贡献都是 0。最终 rank 1 18 19。你手动排一下 1 到 4 的 24 个排列也会发现(4,1,2,3) 恰好是第 19 个。2.3 阶乘数组的正确预处理方式预处理阶乘是最容易写错的地方特别是 0! 的定义。C 里数组下标从 0 开始所以fac[0] 1; for (int i 1; i n; i) fac[i] fac[i - 1] * i % MOD;这里 fac[i] 对应 i!。当 n 10^6 时fac 数组开到 10^6 5 就够了别用 vector 然后 push_back虽然能用但没必要。我在实际做题时还遇到过一个坑直接用 int 存 fac[i]结果 n 稍微大一点就溢出了。因为虽然每一步都取模但 fac[i - 1] * i 这一步在乘法时已经可能超过 int 范围。所以 fac 数组一定要用 long long或者每一步强转 long long。后面算 ans 时同理。3. 从 O(n²) 到 O(n log n)树状数组在展开里扮演的角色公式本身很好理解但如果你直接照着公式写每到一个位置都扫描一遍后面的数字数有多少比它小复杂度就是 O(n²)。n 1000 可能还无所谓n 10^6 直接超时。所以 P5367 考察的第二层东西是怎么快速统计“当前剩余数字中有多少个数小于 p_i”。3.1 动态维护剩余数字的思路需要一个数据结构支持两种操作查询当前还没被“消耗”掉的数字中有多少个小于 x。删除某个数字 p_i 已经用在排列前面了之后不能再参与统计。这个场景下最常见的做法就是树状数组。初始化时把所有数字 1 到 n 都标记为“存在”即对每个位置 add(i, 1)。然后遍历排列查询小于 p_i 的剩余数字个数即 sum(p_i - 1)把结果加到答案里。删除 p_i即 add(p_i, -1)。为什么这里用树状数组而不是线段树因为树状数组代码短、常数小、内存占用低。线段树当然也能做但模板题没必要把自己的代码写得又长又难调。树状数组的查询和修改都是 O(log n)整体 O(n log n)稳稳过 10^6 的数据。3.2 lowbit 和树状数组的“为什么”如果你对树状数组本身还不太熟我来补一个最关键的直觉。树状数组的核心是 lowbit 操作lowbit(x) x -x表示 x 的二进制表示里最低位的 1 所对应的值。add(pos, val) 的操作是从 pos 开始每次 pos lowbit(pos)把所有覆盖 pos 的区间都更新一遍。sum(pos) 的操作是从 pos 开始每次 pos - lowbit(pos)累加所有相关区间的和。这个过程很像爬楼梯每步跨的幅度恰好是当前二进制位最低位 1 的大小。我刚开始学树状数组时总觉得 lowbit 是个魔法。后来画了一张长为 8 的数组图把每个 bit[i] 负责的区间写出来就全明白了。这里我不画图用文字描述bit[1] 管 [1,1]bit[2] 管 [1,2]bit[3] 管 [3,3]bit[4] 管 [1,4]bit[5] 管 [5,5]bit[6] 管 [5,6]bit[7] 管 [7,7]bit[8] 管 [1,8]。查询 [1,7] 的和 bit[7] bit[6] bit[4]正好是 7 - 6 - 4 这条递减链。修改位置 3 时需要更新 bit[3]、bit[4]、bit[8]恰好是 3 - 4 - 8 这条递增链。逻辑是自洽的。3.3 展开时的操作顺序先查再删不能反过来这个顺序问题我犯过错。假设先执行 add(p_i, -1) 再查询 sum(p_i - 1)那 p_i 本身就已经被删掉了。问题是 p_i 不小于 p_i查询的是小于 p_i 的数按理说 p_i 不在统计范围内先删后查似乎也没问题。但你再想一步如果排列里出现重复值或者某些题目的排列不是 1 到 n 的完整排列而是部分排列顺序就可能有影响了。更关键的是先查再删在逻辑上更加清晰查询时当前未删除集合 从第 i 位开始往后所有还“活着”的数字。你把 p_i 删掉正好对应“p_i 已经安排到前面位置了”。所以标准顺序是int smaller bit_sum(p_i - 1); ans (ans smaller * fac[n - i]) % MOD; bit_add(p_i, -1);我在 P5367 的讨论区看到有人把顺序写反结果 WA 了还找不到原因。这类模板题操作顺序就是命门。4. 完整 AC 代码P5367 的 C 实现与逐段注释这部分给出我最终提交通过的代码然后逐段解释关键细节。代码风格偏竞赛向直接可读、可复制。#include bits/stdc.h using namespace std; using ll long long; const int MOD 998244353; const int MAXN 1000005; int n; int a[MAXN]; int bit[MAXN]; ll fac[MAXN]; int lowbit(int x) { return x (-x); } void bit_add(int idx, int val) { while (idx n) { bit[idx] val; idx lowbit(idx); } } int bit_sum(int idx) { int res 0; while (idx 0) { res bit[idx]; idx - lowbit(idx); } return res; } int main() { scanf(%d, n); for (int i 1; i n; i) { scanf(%d, a[i]); } fac[0] 1; for (int i 1; i n; i) { fac[i] fac[i - 1] * i % MOD; bit_add(i, 1); } ll ans 0; for (int i 1; i n; i) { int smaller bit_sum(a[i] - 1); ans (ans (ll)smaller * fac[n - i]) % MOD; bit_add(a[i], -1); } printf(%lld\n, (ans 1) % MOD); return 0; }4.1 读入与初始化为什么用 scanf 而不是 cinn 最大 10^6用 cin 配合 ios::sync_with_stdio(false) 和 cin.tie(nullptr) 其实也能过但我个人习惯在这类大输入量的模板题里直接上 scanf。原因不是 cin 一定超时而是 scanf 更稳妥不用记着关同步也不会因为某些评测环境的输入流问题翻车。初始化部分做了两件事预处理阶乘数组 fac同时把 1 到 n 全部插入树状数组。这两个操作可以合在同一个循环里省一次遍历。4.2 核心循环的计算顺序遍历排列的每一位查询 bit_sum(a[i] - 1)得到当前剩余数字中小于 a[i] 的个数。乘以 fac[n - i]累加到 ans。从树状数组中删除 a[i]。这里的 fac[n - i] 对应公式中的 (n - i)!。注意系数转成 ll 再乘防止 int 乘法溢出。ans 每轮都取模虽然中间结果不会爆 ll但取模是好习惯。4.3 输出时为什么加 1因为公式里 rank 1 Σ...而我们累加时没有处理那个“1”所以最后输出 (ans 1) % MOD。这里有个小坑如果 ans 1 恰好等于 MOD取模后变成 0但实际排名怎么可能是 0 呢其实不会因为 n 个排列的排名最大是 n!而 n! 通常远大于 MOD取模后出现 0 是正常的题目要的就是取模后的结果。所以直接 (ans 1) % MOD 没问题别画蛇添足判断。5. 反向推导逆康托展开与树状数组上的二分讲完正向展开逆康托展开几乎是必然配套的内容。题目 P5367 只要求正向但很多排列枚举的题会用到反向比如“输出字典序第 k 个排列”。理解逆过程能反过来加深你对正向公式的理解。5.1 逆康托展开的核心思路已知 n 和排名 k从 1 开始要还原排列。先把 k 减 1得到一个从 0 开始的编号 r。然后从第一位开始当前剩余数字集合 S初始为 {1,2,...,n}。第 i 位的候选数字中跳过 t r / (n - i)! 个未使用的数字取第 (t1) 个小的数字。令 r r % (n - i)!。为什么因为正向公式里第 i 位贡献是 cnt_i × (n - i)!。现在反过来r 里面包含了所有比它小的排列数除以 (n - i)! 就能反推出 cnt_i也就是“在第 i 位之后有多少个比 p_i 小”。从剩余集合的角度看cnt_i 恰好等于跳过多少个未使用的数字。5.2 树状数组上找第 k 个未使用数字找“第 k 个未使用的数字”可以用树状数组加二分复杂度 O(log n) 单次总 O(n log n)。直接二分位置如果 bit_sum(mid) k说明前 mid 个数字里至少有 k 个未使用收缩右边界。但这样每次是 O(log² n)对 n 10^6 来说勉强能过不够优雅。更好的做法是倍增跳 lowbit。思路类似求 LCA 的倍增从高位二进制往下试探找最大的 pos使得 bit_sum(pos) k最终 pos 1 就是所求。这个写法有点绕我给出完整代码int find_kth(int k) { int pos 0; for (int i 18; i 0; i--) { int nxt pos (1 i); if (nxt n bit[nxt] k) { pos nxt; k - bit[nxt]; } } return pos 1; }这里的 18 是因为 n 10^6 2^20取 18 或者 20 都行取一个能覆盖 n 的最大幂即可。判断 bit[nxt] k 而不是 是为了让 pos 停在“前缀和恰好小于 k 的最后一个位置”这样 pos 1 就是第 k 个数字。我在第一次写这个函数时把条件写成 bit[nxt] k结果找出来的数字偏后一位调了半天才发现是边界问题。记住要找第 k 个前缀和等于 k 的位置已经包含目标所以不能等于。5.3 逆展开完整示例用 n 3k 3期望还原 (2,1,3)。r 2fac[2] 2t 2 / 2 1剩余集合 {1,2,3}跳过 1 个数字取第 2 个 2。r 2 % 2 0。r 0fac[1] 1t 0 / 1 0剩余集合 {1,3}取第 1 个 1。r 0 % 1 0。剩余 {3}取 3。得到 (2,1,3)正确。6. 实测过程中遇到的坑与调试心得最后这块是我想重点分享的毕竟模板题不是背下来就完事真正动手写的时候总会有各种意想不到的问题。6.1 数据溢出一眼看不出来的 WA我第一次交 P5367 时fac 数组开的是 int代码长这样fac[i] fac[i - 1] * i % MOD。fac[i - 1] * i 这两个 int 相乘时结果可能已经超过 2^31 - 1再做 % 操作其实在未定义行为边缘试探。改成 long long 后就过了。这个问题我看很多新手都会踩因为在小数据上根本没区别一旦 n 到 10^6溢出就冒出来了。6.2 快读问题scanf 和 cin 的取舍如果你选择用 cin记得加这两行ios::sync_with_stdio(false); cin.tie(nullptr);否则 10^6 的读入在某些评测机上可能直接 TLE。我自己的习惯是 scanf简单粗暴不纠结。理论上还可以用 fread 手写快读但对于这道题完全没必要scanf 已经够快。6.3 阶乘数组的边界fac[0] 1 千万别忘很多人预处理阶乘直接从 1 开始循环里写 fac[1] 1然后发现最后一位的系数算错。康托展开在算到最后一位时用的是 (n-n)! 0! 1所以 fac[0] 1 必须设置。P5367 里最后一位的贡献虽然永远是 0因为后面没有更小的数但逆展开、或者处理某些变体问题时0! 是绕不开的。6.4 树状数组开多大MAXN 多开 5 到 10 个点我开的是 const int MAXN 1000005因为 n 最大 10^6多开 5 个位置防止边界越界。树状数组的 add 循环条件 idx n所以 bit 数组一定不能只开 n 个要开到 n 1 以上。很多 RE 都是数组开小了并不是算法写错。6.5 我在本地自测的验证脚本为了确认代码正确我写过一个暴力验证枚举 n 1 到 8 的所有排列用 next_permutation 生成排列记录它们的编号再用康托展开算编号比对结果。这种“暴力对拍”是刷模板题最好的学习方法。你可以把暴力代码和康托展开代码分开编译然后生成随机排列塞进去对比输出。可惜我当年偷懒没做后来在 n 7 的时候才发现逆展开的二分条件写错了。对拍的具体做法不复杂main 函数里跑两层循环外层枚举 n内层用 vector 存 1 到 ndo while(next_permutation(...)) 循环每次把当前排列丢给康托展开函数把返回值和一个自增计数器对比。任何 n 不超过 8 的小范围里跑几百个排列就能把边界问题暴露干净。6.6 模板题的后续扩展思路如果你把康托展开吃透了可以试着做几个变体排列中有重复数字时的排名怎么算需要除以重数的阶乘康托展开结合线段树求逆序对逆展开配合动态维护集合解决“第 k 个排列”类题目。这些扩展本质上都是同一套思想只是统计手段换一换。我个人在实际刷题中的体会是像康托展开这种模板题不要光看题解一定是自己把公式推到能默写的程度然后不看代码独立实现一遍再写一个逆康托展开验证正逆是否互逆。多来几轮比刷十道同类题都管用。最后再分享一个小技巧把树状数组、阶乘预处理、康托展开封装成三个独立的函数以后遇到排列压缩的题直接复用能帮你省下大量重复调试的时间。
返回列表