news 2026/9/9 1:43:52

洛谷P1088火星人:全排列字典序与康托展开进化解法

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
洛谷P1088火星人:全排列字典序与康托展开进化解法

当年在考场上第一次看到“火星人”这道题,心里又惊又喜。惊的是题面很有意思,外星人用手指数数,每个手指还都有自己的编号;喜的是它本质上就是全排列,字典序的下一个排列,这不就是当年教科书上“求后继排列”的经典问题吗?结果真动手写的时候,我卡了挺久。因为N和M都可以到10000,直接傻乎乎地从第一个排列开始枚举到目标位置,根本不可能;而如果只会用DFS全排列去“数”,N一大早就爆了。

这篇文章就从洛谷P1088这道题出发,把“给定一个排列,求它后面第M个字典序排列”这件事彻底讲清楚。既有考场上一行STL带走的偷懒做法,也有原理层面的排列序号进位法。不管你是备战NOIP、CSP-J/S的选手,还是刚学全排列、康托展开的初学者,都能找到自己能用的东西。

1. 题目解读与核心难点

1.1 火星人的手指密码到底是什么

先还原一下题面:火星人有一排N根手指,每根手指都有一个不重复的编号,从1到N。他们计数的方式很特别,把手指从左到右排成一列,用一个排列来表示一个数。所有的排列按照字典序从小到大排列好,比如N=3的时候就是123、132、213、231、312、321这样一路排下去。外星人会先伸出某个排列,然后告诉你“我要继续往后数M个数”,你要算出他最后摆出来的那个排列是什么。

输入给的是N、M,以及当前伸出的那个排列。N和M的范围都是10000以内。也就是说,N=10000、M=10000是可能出现的,而且题目只给一个测试点,时限一般是1秒左右。这不是一道让你去脑补“外星人有多神奇”的题,它考的就是排列生成、字典序以及如何处理大规模数据。

1.2 为什么不能直接从第一个排列开始数

很多新手第一反应是:既然所有排列已经按字典序排好了,那我用搜索把从123...N开始的排列一个一个生成出来,数到目标位置不就行了?这个思路在N很小的时候完全成立。N=3、N=4的时候,DFS把全排列枚举出来,再从头找目标排列的下M个,完全没问题。

但问题是N最大到10000。全排列的个数是N!,这个数字增长得极其恐怖,N=10就已经300多万个,N=10000根本不可能一一枚举。所以这道题考查的重点不是“生成全排列再查找”,而是“已知一个排列,如何在这个有序的排列序列中向后跳跃M步”。

换个更简单的说法:123和132是两个相邻的排列,但要你直接从“87654321”这种排列往后跳10000步,总不能把它前面几十亿个排列全扫一遍吧?我们需要的是排列与排列之间的“加减法”运算,而不是把所有排列都暴力列出来。

1.3 数据范围直接决定思路走向

看到N、M都是10000,其实是在暗示:你可以接受O(NM)级别的解法,也可以接受O(N log N)级别的解法,但绝对不能接受O(N!)或者O(2^N)之类的东西。换句话说,这道题给了两条路:

  • 一条路是利用STL的next_permutation函数,每次在当前排列上向后走一步,重复M次。由于next_permutation的单次平均代价很低,实际运行往往跑不满O(NM),很多选手用这个写法直接AC。
  • 另一条路是把排列看成一个奇特的“数字”,通过进制进位的思想,直接从原排列加上M得到新排列。这个写起来复杂一点,但时间复杂度是稳定的O(N log N)。

我当年在考场上先用了第一种方法,AC之后又回头研究第二种,发现它对于理解康托展开、逆康托展开非常有帮助。下面两章分别展开讲。

2. 思路A:STL next_permutation 通关(最简单写法)

2.1 next_permutation 到底做了什么

C++的STL里有一个函数叫next_permutation,它做的事情就是:对于一个数组表示的排列,原地修改它,使它变成字典序中的下一个排列。如果当前排列已经是字典序中最大的那个,比如N=3时的321,函数会返回false,并把数组改成最小的排列123。

它的原理其实只有三步:

  1. 从后往前找到第一个满足a[i] < a[i+1]的位置i,这个位置就是需要变动的“关键位置”。如果整个数组从后往前一直是降序,说明已经不存在更大的排列。
  2. 再从后往前找到第一个比a[i]大的元素a[j]。
  3. 交换a[i]和a[j],然后把i+1到末尾这一段反转。因为这段原本是降序,反转后变成升序,刚好得到字典序中紧挨着的下一个排列。

举个例子,123的下一个排列是132。从后往前看,2 < 3,所以i指向2这个位置,然后从后往前找第一个比2大的数,找到3,交换得到132,然后i+1到末尾只有一位,不用反转,完成。

整个next_permutation的时间复杂度最坏是O(N),但平均情况下只需要扫描尾部一小段。比如从123456到123465,只需要改动最后两位。这也是为什么很多人说“M次调用也能过”的根本原因。

2.2 考场上一行STL带走的代码

对于P1088,代码短到令人感动:

#include <bits/stdc++.h> using namespace std; int n, m; int a[10005]; int main() { scanf("%d%d", &n, &m); for (int i = 1; i <= n; ++i) { scanf("%d", &a[i]); } while (m--) { next_permutation(a + 1, a + n + 1); } for (int i = 1; i <= n; ++i) { printf("%d ", a[i]); } printf("\n"); return 0; }

注意next_permutation接收的是迭代器区间,左闭右开。数组从1开始存储时,就是a+1到a+n+1。如果谁把右边界写成a+n,那永远只会对前n-1个元素做排列,最后一个元素始终不变,提交上去等着Wrong Answer吧。

我在这里要提一句:如果你使用的是vector,对应的写法是next_permutation(v.begin(), v.end()),不需要加1,因为vector迭代器是从0开始的。别在vector、数组混用的时候搞混。

2.3 复杂度、风险与考场上怎么选

这个写法的时间复杂度,理论上限是O(NM),也就是1e8。有不少人拿到OJ上一跑,几百毫秒就过了,越觉得玄学。其实next_permutation每次调用都要从后往前找第一个上升点。如果排列尾部比较随机,大多数情况下上升点很快就能找到,扫描长度很短。只有在排列呈整体降序、接近末尾的时候,单次扫描才会接近O(N)。所以实际运行时间远远好于最坏情况。

不过,OI比赛里“实际跑得快”和“理论上说得过去”是两回事。我见过一些选手因为依赖STL而翻车,不是这道题翻车,而是遇到某些数据恰好卡在next_permutation的坏情况下,导致超时。所以我的建议是:

  • 如果你现在打的是普及组、CSP-J这类比赛,N、M在10000以内,用next_permutation完全可行,不要有心理负担。
  • 如果你想扎实掌握排列算法,或者担心以后遇到N、M更大的变种题,那就一定要会下一章的手写进位法。STL虽好,但背后的原理才是通用能力。

3. 思路B:排列序号进位法(原理更优的解法)

3.1 排列也可以当成一个“数字”来算

如果说next_permutation是“一步一步走”,那进位法就是“坐电梯直达目标层”。要理解它,先得明白一个很漂亮的性质:任何一个排列,都能唯一转换成一个“变进制数”。

这个变进制数每一位表示的含义是:当前这个位置上,右边剩下没有用过的数字中,有多少个比它小。举个N=3的例子,看排列231:

  • 第一位是2。在还没用过的数字{1,2,3}中,比2小的只有1,所以这一位对应的系数是1。
  • 第二位是3。此时还剩下{1,3},比3小的只有1,所以这一位系数也是1。
  • 第三位是1。此时剩{1},比1小的一个都没有,系数为0。

所以231对应的系数序列是(1,1,0)。这个系数序列从左到右乘以(n-1)!、(n-2)!、...、0!,再累加起来,就是这个排列在字典序中的编号。计算一下:1×2! + 1×1! + 0×0! = 3。也就是说,在字典序从0开始编号的情况下,231确实是第3个排列。你可以对照一下:0号是123,1号是132,2号是213,3号正好是231,完全吻合。

这就是经典的康托展开。很多教材上是直接给公式,初学者看得一脸懵。我自己当年学的时候,是把它想象成“按位确定排列”的编号:第一位有N种选择,其中第x小的数字就对应x×(N-1)!;第二位有N-1种选择,又对应y×(N-2)!……一路乘下去,刚好把一个排列映射成一个整数。

3.2 在变进制上直接加M,坐电梯到目标

既然排列能映射成序号,那从当前排列向后数M个,本质上就是“当前排列序号 + M”对应的那个排列。如果N不太大,比如几百以内,直接用康托展开算出序号,加上M,再做逆康托展开还原,就能得到答案。但N到10000时,(N-1)!这个数字早就超出long long的范围,不能硬算阶乘。

聪明一点的做法是:不要真的去算那个巨大的序号,而是模拟“在这个变进制数上加上M”的过程。这跟十进制加法一样,从低位开始,一位一位地加,超出某一位的进制范围就向高位进位。区别只在于每一位的进制不一样,所以叫变进制。

我把具体算法写出来。首先求系数数组c[i],其中c[i]表示a[i]右边有多少个数字比a[i]小。接下来,令cur = M,从数组最后一个位置往前处理:

  • 把当前位的系数c[i]加上cur。
  • 这一位能取的最大范围是“右边剩余数字个数”,更准确地说,从右往左数,第q个位置对应的取值个数是q(第0个位置固定为0,即最后一个位置只能取0)。
  • 于是令c[i] = c[i] % q,cur = c[i] / q,其中q从2开始递增,也就是右边的位置依次对应q=2、3、4...。
  • 循环结束后,新的c数组就是目标排列对应的系数。

是不是有点绕?我拿231这个排列,M=1来验证。231的系数是(1,1,0)。从右往左:

  • 最右边一位系数0,加上cur=1,得到1。这一位固定没有余地,模1得0,进位1。
  • 中间一位系数1,加上进位1,得到2。这一位的范围是{0,1},也就是能选2种,模2得0,进位1。
  • 最左边一位系数1,加上进位1,得到2。这一位范围是{0,1,2},能选3种,模3得2,进位0。

于是新的系数是(2,0,0)。根据3.1的公式,2×2! + 0×1! + 0×0! = 4,正好是312的编号。因为231的编号是3,加1等于4,所以目标排列就是312。整个计算过程与数字的加法进位完全一致,只是进制不同而已。

写代码的时候,可以用一个整数数组直接在原系数上做进位,不需要真算阶乘,非常清爽。

3.3 用树状数组还原目标排列

进位完成后,得到的是系数数组,还需要还原成排列。还原过程其实很简单:从左到右,根据每一位的系数,在当前还没使用的数字集合中选取第(系数+1)小的数字。因为系数为x,表示“在剩余数字中,有x个数字比它小”,所以它就是剩余数字中第x+1小的那个。

这里最直接的办法是开一个bool数组标记某个数字是否用过,然后每次从1开始扫描到N找第x+1个未使用的数字。但这样是O(N)找一个数字,总复杂度O(N^2),N=10000时就是1e8,运气不好会超时。用树状数组维护“某个编号是否被使用”,可以在O(log N)时间内完成“找第k个可用的数字”这件事。

思路是用树状数组存储每个数字的剩余状态,1表示还没被使用。给定系数c[i],要找的就是前缀和恰好等于c[i]+1的最左位置,这个可以用二分答案配合树状数组查询实现,也可以直接在树状数组上做“树上二分”(也就是在bit上找第k个1的位置)。

#include <bits/stdc++.h> using namespace std; const int MAXN = 10005; int n, m; int a[MAXN], c[MAXN]; int tree[MAXN]; int lowbit(int x) { return x & -x; } void add(int idx, int val) { for (int i = idx; i <= n; i += lowbit(i)) { tree[i] += val; } } int sum(int idx) { int res = 0; for (int i = idx; i > 0; i -= lowbit(i)) { res += tree[i]; } return res; } // 在树状数组中找第k个1的位置 int kth(int k) { int pos = 0; for (int i = 20; i >= 0; --i) { int nxt = pos + (1 << i); if (nxt <= n && tree[nxt] < k) { k -= tree[nxt]; pos = nxt; } } return pos + 1; } int main() { scanf("%d%d", &n, &m); for (int i = 1; i <= n; ++i) { scanf("%d", &a[i]); } // 第一步:计算每个位置右边比自己小的数字个数 // 用树状数组边插入边统计,也可以直接暴力O(N^2) memset(tree, 0, sizeof(tree)); for (int i = n; i >= 1; --i) { c[i] = sum(a[i] - 1); add(a[i], 1); } // 第二步:模拟进位,加上m int base = 2; for (int i = n; i >= 1; --i) { c[i] += m; m = c[i] / base; c[i] %= base; ++base; } // 第三步:根据系数还原排列 memset(tree, 0, sizeof(tree)); for (int i = 1; i <= n; ++i) { add(i, 1); } for (int i = 1; i <= n; ++i) { int pos = kth(c[i] + 1); printf("%d ", pos); add(pos, -1); } printf("\n"); return 0; }

我来解释几个关键细节。第一步求c[i]的时候,从右往左遍历,每次查询当前树状数组中小于a[i]的数字个数,然后把a[i]本身插入。这样就得到了“右边比它小的数字个数”。第二步的进位从右往左,base从2开始递增,因为最右边一位永远只能取0,倒数第二位只能取0或1,再往前一位可以取0、1、2,以此类推。第三步初始化树状数组全部为1,表示所有数字都未使用,然后依次取出第c[i]+1小的数字,取出来后标记为0。

这个算法的总复杂度是O(N log N),N=10000时非常快,哪怕N开到1e6也能扛住。

3.4 两种写法的硬碰硬对比

对比维度STL next_permutation排列序号进位法
代码长度很短,十几行较长,需要树状数组
时间复杂度理论最坏O(NM),实际远快O(N log N)稳定
原理难度低,会用STL即可高,需要理解康托展开与逆展开
优点不易写错,适合考场速战效率稳定,可扩展到N很大
缺点依赖STL,遇到数据卡可能超时容易在进位、还原细节上出错

从应试角度讲,这两种方法都是正解。从学习角度讲,我更推荐把进位法彻底弄懂,因为它能帮你打通康托展开、逆康托展开、求第K小字典序排列等一系列问题。

4. 常见问题与调试心得

4.1 我明明照着文章写的,为什么Wrong Answer

这道题最常见的“想让代码通过但怎么都差一点”的坑,我整理成了一张速查表:

症状可能原因解决办法
结果总是比答案大一点多调用了一次next_permutation注意题目要求“后续第M个”,不是“执行M+1次”
结果完全乱掉数组下标与迭代器区间传错next_permutation(a+1, a+n+1)是左闭右开,不要写a+n
进位法结果部分正确进位时base初始值写错从右往左,base从2开始递增,别从1开始
进位法输出有重复或缺失还原系数时选了已使用的数字确认kth函数找到的是第c[i]+1个未使用数字,找完立刻删掉
本地跑大样例超时用了O(N^2)暴力找第k小改用树状数组+二分或直接在BIT上跳
输出末尾多个空格每行输出后等多数OJ接受行尾空格,但比赛建议控制格式

其中“base从1开始”这个错误我记得特别清楚。为什么最后一位永远只能取0?因为你已经确定到最后一个数字时,剩下的数字只有一个,它不可能是“比某个数字小”的第几个,所以系数固定为0。从右往左第2位开始才有0/1两种选择,所以base从2开始。

4.2 推荐的对拍与压力测试技巧

一道题写完,光靠样例远远不够。我习惯写一个对拍器来验证。生成一个小N(比如N=8以内)的随机排列,用最暴力的办法枚举全排列数到目标位置,再用你的算法算一遍,对比结果是否一致。如果小数据没错,再逐渐加大N测试性能。

C++写暴力验证非常简单:先生成目标起始排列,然后用next_permutation循环M次,这个本身就可以当作暴力标准答案。然后再把进位法跑一边,两个结果比对。如果N=8、M=100时结果都能对上,基本可以判断算法核心没问题,剩下的就是边界条件。

性能测试部分,我建议专门构造一个接近降序的排列,比如N=10000,起始排列是987654321...这样的顺序,M取10000。这种数据对STL方法的压力最大,如果跑下来没超时,说明这道题用STL确实是稳的。

4.3 从这道题延伸出去的算法清单

这道题虽然只是普及组,但它的内核非常硬核。排列相关的几类问题,完全可以串成一条学习线:

  • 康托展开:求一个排列在字典序中的编号,常用于状态压缩DP做哈希。
  • 逆康托展开:根据编号恢复排列,上面的进位法本质上就是这个。
  • 求第K个字典序排列:标答思路就是逆康托展开,N大时用树状数组或线段树优化。
  • 可重集排列:序列里允许重复元素时,排列数的计算要用到多重集排列公式,组合数选阶乘。
  • 部分排列:从N个数字中选K个排列的问题,编号方式和全排列相似但每一位选项范围更复杂。

如果你把“火星人”完全吃透,后面的这些算法会顺畅很多。因为变进制进位的核心思想,就是“每一位的选项个数不同”,而可重集、部分排列都是在这个思想上稍作修改。

最后分享一个我的个人习惯

我现在带学生刷题,总会在讲完STL解法之后,逼着他们亲手写一遍进位法,就算题目用STL能过也必须写。原因很简单:STL的next_permutation是别人做好的轮子,你能用,别人也能用,但考场上万一题目升级成“给定一个10万位的大排列,求后面第10万个排列”,STL那套很可能就被数据按在地上摩擦。进位法虽然代码多一些,但它逼着你理解排列的本质,理解变进制,理解树状数组的妙用。理解过一遍之后,以后再遇到类似题,心态完全不一样。

还有一个小技巧:当年我被“系数还原”卡住的时候,喜欢拿N=3把所有排列手写一遍,然后对着纸演算进位过程。纸上画三分钟,比盯着屏幕干想半小时效率高得多。如果你今天在这道题上栽了跟头,别急着翻题解,先把手上的N调成3或4,把排列一张一张画出来,再对比算法每一步的结果,很多疑点会瞬间解开。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/9 1:43:10

自动植肥灌溉控制器参数解析与田间可靠性实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/9 1:43:08

AI编程工具Cursor全解析:从VS Code迁移到Composer实战指南

我平时的主力编辑器一直是 VS Code&#xff0c;但真正把 AI 编程工具当“队友”而不是“补全插件”来用&#xff0c;是换了 Cursor 之后才开始的。这里不聊那些“未来已来”的空话&#xff0c;我只想从实际操作层面&#xff0c;把 Cursor 这套 AI 编程工具的里里外外拆开讲清楚…

作者头像 李华
网站建设 2026/9/9 1:42:50

OpenHarmony上React Native数值输入Hook:useNumber设计与踩坑实录

在 OpenHarmony 上跑 React Native&#xff0c;最磨人的往往不是业务逻辑&#xff0c;而是那些看起来不起眼的输入控件。特别是数字输入——商品数量、价格、分数、年龄、经纬度&#xff0c;哪个页面少了数值输入都难受。我在把一套 RN 页面迁到 OpenHarmony 真机时&#xff0c…

作者头像 李华
网站建设 2026/9/9 1:42:19

AI Agent的沙箱安全屋:多层隔离设计与TitanIDE实践

当一个天生就具备工具调用能力的 AI Agent&#xff0c;第一次拿到终端权限时&#xff0c;那种感觉就像一个刚从驾校毕业的新手&#xff0c;突然坐进了一辆自动驾驶赛车的驾驶舱。它帮你调接口、写脚本、跑测试、连数据库&#xff0c;效率惊人&#xff0c;但你没看到的是&#x…

作者头像 李华
网站建设 2026/9/9 1:41:38

React Native on OpenHarmony:自定义useList实现列表加载与竞态处理

React Native跑在OpenHarmony上&#xff0c;放在两年前还是不太敢想的事&#xff0c;现在却已经成了很多团队的现实选项。公司现有的RN代码库想快速覆盖鸿蒙生态&#xff0c;又不想再养一支ArkTS原生团队&#xff0c;这类诉求在我接触的项目里越来越常见。列表页又是移动应用里…

作者头像 李华
网站建设 2026/9/9 1:41:35

Auto-Rig Pro 3.40z角色自动绑定插件详解:安装、流程与避坑

简介&#xff1a;面向Blender三维艺术家与动画师的自动绑定工具Auto_Rig_Pro 3.40z资源包&#xff0c;专门解决角色骨骼生成、蒙皮权重分配及IK/FK切换等绑定效率难题。压缩包共9个文件&#xff0c;包含blend工程文件、Python脚本、bmap映射预设及zip扩展模块&#xff0c;bmap预…

作者头像 李华