完整教程:【算法速成课 3】康托展开(Cantor Expansion)/ 题解 P3014 [USACO11FEB] Cow Line S

专栏:算法速成课_proMatheus的博客-CSDN博客

前置知识:树状数组


前导

康托展开(Cantor Expansion)是一种将一个排列,映射为一个唯一整数的编码方法。

常用于排列的哈希、状态压缩或字典序编号等场景。

题意

任务一:求一个全排列是第几个全排列,按字典序(即从小到大)。

任务二:求第 K 个全排列。

1.康托展开(任务 1)

分析

假如我问你,求 13425 是第几个 5 的全排列,你会怎么做?

先一个个列出来?

12345,12354,12435,12453,12534,12543,13245,13254,13425...

发现是第 9 个。

那有没有更快的方法?

我们发现 13425 的第二个位是 3,这代表 12XXX 都在它的前面。

直接加上所有 12XXX 的数量 6

接着发现第三个位是 4,照理来说应该是它后面、比它小的 2 在它这个位置,

但现在这里是 4,代表 132XX 都在 13425 的前面,加上数量 2

6+2=8,这是所有在 13425 前面的数,即 13425 是第 9 个全排列。

再回顾总结下,假设要求 n 的全排列。

如果有比当前第 i 个位上的数 x 小,且还没出现过的数 a

那么这个 a 肯定能顶替 x 得到更小的字典序,排在题目给出排列的前面(1 到 i-1 位不动)。

累计答案加上 (n-i)!,这表示所有 a 顶替 x 构成的排列都排在题目给出排列的前面

很明显,有几个这样的 a 就应该加几个 (n-i)!

实现

例题:P5367 【模板】康托展开 - 洛谷

想要求出比当前 x 小且还没出现过数的个数,可以考虑使用树状数组 / 线段树 / 平衡树。

后两个都稍有麻烦,我们用树状数组

时间复杂度 O(nlog\ n)

代码:

#include
using namespace std;
typedef long long LL;
const int N = 1e6 + 10;
const LL P = 998244353;
int n, a[N];
LL c[N], fac[N];
void add(int x, LL d) {
	for (; x <= n; x += x & -x) {
		c[x] = (c[x] + d) % P;
	}
}
LL get_sum(int x) {
	LL res = 0;
	for (; x >= 1; x -= x & -x) {
		res = (res + c[x]) % P;
	}
	return res;
}
int main () {
	ios::sync_with_stdio(false);
	cin.tie(0);
	cin >> n;
	for (int i = 1; i <= n; i ++) {
		cin >> a[i];
	}
	memset(c, 0, sizeof(c));
	fac[0] = 1;
	for (int i = 1; i <= n; i ++) {  // 初始化阶乘数组
		fac[i] = fac[i - 1] * i % P;   // 一定要取模!!
	}
	LL ans = 0;
	for (int i = 1; i <= n; i ++) {
		int x = a[i];
		LL t = ( (x - 1) - get_sum(x - 1) + P) % P;
		// get_sum 求出来的是出现过比 x 小的数,要求没出现过的
		ans = (ans + t * fac[n - i] % P) % P;
		add(x, 1);   // 把 x 放进去
	}
	cout << (ans + 1) << "\n";   // 别忘了加 1,ans 是在题目给出排列前面的排列数
	return 0;
}

2.逆康托展开(任务 2)

分析

聪明的你肯定想到了应该怎么做:

假设给出的排列序号是 K,循环 1 到 nK 先减减。

这样 K 就代表着在答案排列前面的排列个数,接下来每一个位都根据前面的排列定值

当前循环到 i

如果 K 里面有 a 个 (n-i)!,取 1 到 i-1 位未出现的第 a+1 小数为 t

那么当前位就等于 t

因为按理说当前位就该是 1 到 i-1 位未出现的最小数了,但 K 里又有 a 个 (n-i)!

这代表在第 i 位,还有a 个比它小的数构成的排列排在它前面(1 到 i-1 位不动),

又由于不能重复,所以取 1 到 i-1 位未出现的第 a+1 小数。

别忘了 K-=a*(n-i)!

还是拿 13425 的例子,现在我们只知道 K=8\ \ (9-1)

当 i=1 时,K 里面有 0 个 (n-1)!=241 到 0 位未出现的第 1 小数为 1

当 i=2 时,K 里面有 1 个 (n-2)!=61 到 1 位未出现的第 2 小数为 3

K=2

当 i=3 时,K 里面有 1 个 (n-3)!=21 到 2 位未出现的第 2 小数为 4

K=0

当 i=4 时,K 里面有 0 个 (n-4)!=11 到 3 位未出现的第 1 小数为 2

当 i=5 时,K 里面有 0 个 (n-5)!=11 到 4 位未出现的第 1 小数为 5

得出 13425

实现

难点在求 1 到 i-1 位未出现的最小数,这玩意 O(n^2),想优化上线段树 or 树状数组上倍增。

(不过例题 n 很小不需要,无所谓我会给出两份代码)

例题:P3014 [USACO11FEB] Cow Line S - 洛谷

P 操作就是逆展开。

20!=2,432,902,008,176,640,000,long long 的范围是 -2^{63} 到 2^{63}-1

即差不多 \pm 9.2e18,可以放心用。

逆展开 O(n^2) 代码(我用了 set,总时间复杂度 O(Kn^2) 可 AC):

#include
using namespace std;
typedef long long LL;
const int N = 22;
int n, a[N];
LL c[N], fac[N];
void add(int x, LL d) {
	for (; x <= n; x += x & -x) {
		c[x] += d;
	}
}
LL get_sum(int x) {
	LL res = 0;
	for (; x >= 1; x -= x & -x) {
		res += c[x];
	}
	return res;
}
int main () {
	ios::sync_with_stdio(false);
	cin.tie(0);
	int K;
	cin >> n >> K;
	fac[0] = 1;
	for (int i = 1; i <= n; i ++) {
		fac[i] = fac[i - 1] * i;   // 阶乘这一块 /.
	}
	while (K --) {
		char s[5];
		cin >> s;
		if (s[0] == 'Q') {   // 正展开
			memset(c, 0, sizeof(c));   // 每个询问都要清空一次
			LL ans = 0;
			for (int i = 1; i <= n; i ++) {
				cin >> a[i];
				LL t = a[i] - 1 - get_sum(a[i] - 1);
				ans += t * fac[n - i];
				add(a[i], 1);
			}
			cout << (ans + 1) << "\n";
		}
		else {   		   // 逆展开
			LL k;     // 这玩意可有 20! 那么大
			cin >> k; k --;   // 减减
			set set_;  // 未使用数字集合
			for (int i = 1; i <= n; i ++) {
				set_.insert(i);    // 全都放进去
			}
			for (int i = 1; i <= n; i ++) {
				int aa = k / fac[n - i];  // 重名了用 aa
				auto it = set_.begin();
				advance(it, aa);   // 移到第 aa + 1 个元素
				a[i] = *it;
				set_.erase(*it);     // 删了
				k -= aa * fac[n - i];  // 别忘了减
			}
			for (int i = 1; i <= n; i ++) {
				cout << a[i] << " ";
			}
			cout << "\n";
 		}
	}
	return 0;
}

逆展开树状数组倍增 O(n\ logn) 做法,总时间复杂度 O(Kn\ logn)

#include
using namespace std;
typedef long long LL;
const int N = 22;
int n, a[N];
LL c[N], fac[N];
void add(int x, LL d) {
	for (; x <= n; x += x & -x) {
		c[x] += d;
	}
}
LL get_sum(int x) {
	LL res = 0;
	for (; x >= 1; x -= x & -x) {
		res += c[x];
	}
	return res;
}
// 在树状数组 c 中找第 k 小的可用数(k 从 1 开始)
// 这里利用了树状数组的特性,即查询 1 到 n 最多遍历 log n 个值
int kth(int k) {
    int p = 0, s = 0;
	// p 是当前树状数组上刚刚遍历到的节点(当前遍历范围可使用数字最大值)
	// s 是当前已遍历范围里可使用数字的总个数
	// 整个过程就是不断调整 p 的大小(遍历范围的最大值)
	// 来看看 s 什么时候刚好等于 k - 1
	// 由于最后 s 肯定停在 k - 1 的临界值(再大一点就不是了)
	// 所以 p + 1 是第 k 个数
    // n <= 20,所以 1 << 5 = 32 足够
    for (int i = 5; i >= 0; i --) {
        int t = p + (1 << i);
        if (t <= n && s + c[t] < k) {  // 节点还小于 n,总个数要小于 k
            s += c[t];
            p = t;
        }
    }
    // 现在 p 是最大的满足 get_sum(p) < k 的下标
    return p + 1;
}
int main () {
	ios::sync_with_stdio(false);
	cin.tie(0);
	int K;
	cin >> n >> K;
	fac[0] = 1;
	for (int i = 1; i <= n; i ++) {
		fac[i] = fac[i - 1] * i;   // 阶乘这一块 /.
	}
	while (K --) {
		char s[5];
		cin >> s;
		if (s[0] == 'Q') {   // 正展开
			memset(c, 0, sizeof(c));   // 每个询问都要清空一次
			LL ans = 0;
			for (int i = 1; i <= n; i ++) {
				cin >> a[i];
				LL t = a[i] - 1 - get_sum(a[i] - 1);
				ans += t * fac[n - i];
				add(a[i], 1);
			}
			cout << (ans + 1) << "\n";
		}
		else {   		   // 逆展开
			LL k;     // 这玩意可有 20! 那么大
			cin >> k; k --;   // 减减
			memset(c, 0, sizeof(c));   // 现在这个树状数组是存未使用的数字
			for (int i = 1; i <= n; i++) {
				add(i, 1);     // 全都放进去
			}
			for (int i = 1; i <= n; i ++) {
				int aa = k / fac[n - i];   // 重名了用 aa
				int t = kth(aa + 1);     // 第 (aa + 1) 小,kth 是 1-indexed
				a[i] = t;
				add(t, -1);              // 删掉这个数
				k -= aa * fac[n - i];      // 别忘了减
			}
			for (int i = 1; i <= n; i ++) {
				cout << a[i] << " ";
			}
			cout << "\n";
 		}
	}
	return 0;
}

posted @ 2025-11-22 14:46  gccbuaa  阅读(2)  评论(0)    收藏  举报