康托展开
康托展开是一种全排列到自然数的双射(映射)算法,简单来说,对于一个由 \(1 \sim n\) 共 \(n\) 个不重复数字构成的全排列,若按字典序将这 \(n!\) 种所有可能的排列从小到大进行排序(从第 \(0\) 个或第 \(1\) 个开始计):
- 正向康托展开:输入一个排列,计算出它是字典序中的第几个排列(求解排列的字典序排名)。
- 逆康托展开:输入排名 \(k\),还原出第 \(k\) 小的原始排列。
在算法竞赛中,康托展开常用于状态压缩与哈希。例如著名的八数码问题,网格中数字的排列即为一个全排列。通过康托展开,可以将任意一个 \(n\) 阶排列无冲突地映射为一个闭区间 \([0,n!-1]\) 之间的唯一整数,从而可以作为状态数组的下标,支撑搜索操作。
对于一个长度为 \(n\) 的排列 \(P = (p_1, p_2, \dots, p_n)\),其康托展开值 \(X\)(表示比当前排列字典序更小的排列数量,即从 \(0\) 开始计数时的排名)计算公式为 \(X = a_1 \cdot (n-1)! + a_2 \cdot (n-2)! + \dots + a_i \cdot (n-i)! + \dots + a_n \cdot 0!\),简写为 \(X = \sum\limits_{i=1}^{n} a_i \cdot (n-i)!\),其中:
- \(a_i\) 表示在当前位置 \(i\) 右侧(即 \(p_{i+1} \dots p_n\) 中),比 \(p_i\) 严格小的元素的个数。
- \((n-i)!\) 表示位置 \(i\) 后面的剩余 \(n-i\) 个位置全排列的总方案数。
以 \(n=5\) 的排列 \(P = (3, 4, 1, 5, 2)\) 为例,求其从 \(0\) 开始计数的字典序排名 \(X\):
- 第 \(1\) 位 \(p_1 = 3\),右侧剩余元素为 \(\{4, 1, 5, 2\}\),比 \(3\) 小的元素有 \(\{1, 2\}\),共 \(a_1 = 2\) 个,贡献为 \(a_1 \cdot (5-1)! = 2 \cdot 4! = 2 \cdot 24 = 48\)。
- 第 \(2\) 位 \(p_2 = 4\),右侧剩余元素为 \(\{1, 5, 2\}\),比 \(4\) 小的元素有 \(\{1, 2\}\),共 \(a_2 = 2\) 个,贡献为 \(a_2 \cdot (5-2)! = 2 \cdot 3! = 2 \cdot 6 = 12\)。
- 第 \(3\) 位 \(p_3 = 1\),右侧剩余元素为 \(\{5, 2\}\),比 \(1\) 小的元素有 \(0\) 个,即 \(a_3 = 0\),贡献为 \(a_3 \cdot (5-3)! = 0 \cdot 2! = 0\)。
- 第 \(4\) 位 \(p_4 = 5\),右侧剩余元素为 \(\{2\}\),比 \(5\) 小的元素有 \(\{2\}\),共 \(a_4 = 1\) 个,贡献为 \(a_4 \cdot (5-4)! = 1 \cdot 1! = 1 \cdot 1 = 1\)。
- 第 \(5\) 位 \(p_5 = 2\),右侧无剩余元素,比其小的元素个数 \(a_5 = 0\),贡献为 \(a_5 \cdot 0! = 0 \cdot 1 = 0\)。
计算总和得到 \(X = 48 + 12 + 0 + 1 + 0 = 61\),这说明排列 \((3, 4, 1, 5, 2)\) 的康托展开值为 \(61\),即前面有 \(61\) 个更小的排列,它自身是字典序第 \(62\) 小的排列。
逆康托展开是康托展开的逆过程:已知阶数 \(n\) 与排名值 \(X\),还原出原始的排列 \(P\)。
假设给定 \(n=5, X=61\),还原步骤如下:
- 计算第 \(1\) 位:\(a_1 = \lfloor X / (5-1)! \rfloor = \lfloor 61 / 24 \rfloor = 2\),更新 \(X = X \pmod{24} = 61 \pmod{24} = 13\),\(a_1=2\) 意味着在当前可用数字集合 \(\{1, 2, 3, 4, 5\}\) 中,第 \(1\) 位数字前有 \(2\) 个比它小的数,因此选择索引为 \(2\) 的元素(从 \(0\) 开始计数),即数字 \(3\),剩余可用数字为 \(\{1, 2, 4, 5\}\)。
- 计算第 \(2\) 位:\(a_2 = \lfloor 13 / (5-2)! \rfloor = \lfloor 13 / 6 \rfloor = 2\),更新 \(X = 13 \pmod 6 = 1\),从剩余数字 \(\{1, 2, 4, 5\}\) 中,选择索引为 \(2\) 的数字,即数字 \(4\),剩余可用数字为 \(\{1, 2, 5\}\)。
- 计算第 \(3\) 位:\(a_3 = \lfloor 1 / (5-3)! \rfloor = \lfloor 1 / 2 \rfloor = 0\),更新 \(X = 1 \pmod 2 = 1\),从剩余数字 \(\{1, 2, 5\}\) 中,选择索引为 \(0\) 的元素,即数字 \(1\),剩余可用数字为 \(\{2, 5\}\)。
- 计算第 \(4\) 位:\(a_4 = \lfloor 1 / (5-4)! \rfloor = \lfloor 1 / 1 \rfloor = 1\),更新 \(X = 1 \pmod 1 = 0\),从剩余数字 \(\{2, 5\}\) 中,选择索引为 \(1\) 的元素,即数字 \(5\),剩余可用数字为 \(\{2\}\)。
- 计算第 \(5\) 位:填入最后剩下的数字 \(2\)。
还原得到的排列为 \((3, 4, 1, 5, 2)\),与原输入完全一致。
在计算 \(a_i\) 时,如果对于每个 \(p_i\) 都用一层循环去扫描其右侧的所有元素,那么康托展开的时间复杂度为 \(O(n^2)\)。在算法竞赛中,当 \(n\) 达到 \(10^5\) 级别,\(O(n^2)\) 会引发超时。
计算 \(a_i\) 的实质是求序列中位于位置 \(i\) 右侧且数值比 \(p_i\) 小的元素个数(本质上是求逆序对的变体),因此可以用树状数组优化到 \(O(n \log n)\)。
例题:P5367 【模板】康托展开
给定一个长度为 \(N \ (1 \le N \le 10^6)\) 的排列(有数字 \(1 \sim N\) 组成),求该排列在所有 \(1 \sim N\) 的全排列中按字典序由小到大排列的排名。由于答案可能非常大,最终结果需要对 \(998244353\) 取模。
参考代码
#include <iostream>
using namespace std;
using ll = long long;
const int N = (int)1e6 + 5;
const int MOD = 998244353;
int n;
int f[N]; // 存储阶乘模 MOD 的值
int bit[N]; // 树状数组,维护当前数字的使用状态
// 树状数组 lowbit 函数
int lowbit(int x) {
return x & (-x);
}
// 树状数组单点修改
void add(int x, int v) {
for (int i = x; i <= n; i += lowbit(i)) {
bit[i] += v;
}
}
// 树状数组前缀和查询
int query(int x) {
int s = 0;
for (int i = x; i > 0; i -= lowbit(i)) {
s += bit[i];
}
return s;
}
int main()
{
cin >> n;
// 预处理阶乘并初始化树状数组
f[0] = 1;
for (int i = 1; i <= n; i++) {
f[i] = (ll)f[i - 1] * i % MOD;
add(i, 1);
}
int ans = 1;
// 遍历每一个位置并计算康托展开值
for (int i = 1; i <= n; i++) {
int x; cin >> x;
// 查询尚未使用的数字中小于 x 的数量
int s = query(x - 1);
// 累加当前位的贡献 s * (n - i)!
ans += (ll)s * f[n - i] % MOD; ans %= MOD;
// 将当前数字 x 标记为已使用
add(x, -1);
}
cout << ans << "\n";
return 0;
}
例题:UVA652 Eight
在一个 \(3 \times 3\) 的九宫格棋盘上放置了编号为 \(1 \sim 8\) 的 \(8\) 个数字以及一个空格(用字符
x表示),每次操作允许将空格x与其上下左右相邻的数字交换位置,分别用字母u、d、l、r表示空格的移动方向,对应上下左右。给定初始状态,求将棋盘还原为如下目标状态的最短操作序列:1 2 3 4 5 6 7 8 x如果无法到达目标状态,则输出
unsolvable。测试数据包含 \(N\) 组独立的输入,输出答案时每两组结果之间需要打印一个空行。
本题包含多组数据,且所有测试用例的目标状态是固定的(即 1 2 3 4 5 6 7 8 x)。如果对每一组数据分别做一次 BFS,不仅会包含大量的重复计算,还可能超时。可以从固定目标状态出发,发起一次反向 BFS,遍历并记录从目标状态出发所能到达的所有可达状态,以及每个状态到达目标状态的路径。预处理只需执行 \(1\) 次,遍历全部 \(9!=362880\) 种可能的排列后,查询阶段即可在极其简短的时间内回答任意初始状态的答案。
将字符 x 映射为数字 \(9\),则整个棋盘可以看作数字 \(1 \sim 9\) 的一个长度为 \(9\) 的全排列。利用康托展开算法,可将任意一个全排列唯一映射为区间 \([0,9!-1]\) 即 \([0,362879]\) 内的一个整数索引。利用这一索引,可以直接使用一维数组存储每个状态的前驱节点以及到达目标状态的操作方向字符。
在反向 BFS(从目标状态逆推到初始状态)中,每次状态转移时,空格在反向搜索中向上移动,相当于正向探索时空格向下移动,……,注意这个相反的关系。
参考代码
#include <iostream>
#include <queue>
#include <algorithm>
using namespace std;
const int F = 362880; // 9! 的全排列总数
const int R[] = {-1, 1, 0, 0};
const int C[] = {0, 0, -1, 1};
const char M[] = "durl"; // 反向搜索对应的正向操作字符
struct State {
int v, x, a[9];
};
int a[9], f[9], nxt[F];
char dir[F];
// 康托展开:把 9 个数的排列映射到 0 ~ 362879 的哈希值
int cantor(int a[]) {
int res = 0;
for (int i = 0; i < 9; i++) {
int cnt = 0;
for (int j = i + 1; j < 9; j++) {
if (a[j] < a[i]) {
cnt++;
}
}
res += cnt * f[8 - i];
}
return res;
}
// 预处理函数:从终点反向 BFS 搜索所有可达状态
void init() {
f[0] = 1;
for (int i = 1; i < 9; i++) f[i] = f[i - 1] * i;
State s;
for (int i = 0; i < 9; i++) {
s.a[i] = i + 1; // 目标状态:1 2 3 4 5 6 7 8 9 (9 表示 x)
}
s.v = cantor(s.a);
s.x = 8; // x 初始在最右下角(下标 8)
dir[s.v] = 'E'; // 'E' 标记为终点标志
queue<State> q;
q.push(s);
while (!q.empty()) {
State u = q.front();
q.pop();
int r = u.x / 3, c = u.x % 3;
for (int i = 0; i < 4; i++) {
int nr = r + R[i], nc = c + C[i];
if (nr >= 0 && nr < 3 && nc >= 0 && nc < 3) {
int nx = nr * 3 + nc;
State v = u;
swap(v.a[u.x], v.a[nx]);
v.x = nx;
v.v = cantor(v.a);
// 如果该状态尚未被记录过
if (dir[v.v] == 0) {
dir[v.v] = M[i]; // 记录该状态正向移动的方向
nxt[v.v] = u.v; // 记录到达目标状态的相邻节点
q.push(v);
}
}
}
}
}
void solve() {
for (int i = 0; i < 9; i++) {
char c; cin >> c;
if (c == 'x') a[i] = 9;
else a[i] = c - '0';
}
int s = cantor(a);
// 无解判定
if (dir[s] == 0) {
cout << "unsolvable\n";
} else {
// 有解,沿预处理的前驱链正向输出移动序列
int cur = s;
while (dir[cur] != 'E') {
cout << dir[cur];
cur = nxt[cur];
}
cout << "\n";
}
}
int main()
{
init(); // 仅需一次的反向预处理
int n; cin >> n;
for (int i = 0; i < n; i++) {
if (i > 0) cout << "\n"; // 数据组之间输出空行
solve();
}
return 0;
}

浙公网安备 33010602011771号