1. 从一次实际问题说起:为什么需要 next_permutation
先交代一个背景。我在做某个算法练习平台上的题目时,遇到一个很实际的需求:给定若干个数字,要求输出它们能组成的所有排列。比如输入{1, 2, 3},输出123, 132, 213, 231, 312, 321。如果你刚接触这类问题,第一反应可能是自己写递归回溯,毕竟全排列是回溯算法的经典案例。但问题来了:如果题目只是要求“按字典序输出所有排列”,那手写递归反而绕了远路,因为 C++ 标准库早就给我们备好了现成的轮子——next_permutation。
这个函数的全称是std::next_permutation,定义在<algorithm>头文件里。它做的事情非常单纯:把一个序列原地修改成“下一个字典序更大的排列”。如果当前序列已经是字典序最大的那个(比如321),它会返回false,并把序列重置为字典序最小的排列(比如123)。配合do...while循环,一行代码就能把全排列枚举完。
但很多人只是在刷题时用过它,并不知道它底层到底怎么实现的,也不知道它有哪些暗坑。这篇文章我打算从原理、使用、手写实现三个角度把这东西彻底讲透。不管你是刚学 C++ 的初学者,还是已经工作但想把算法基础补扎实的开发者,看完这篇应该都能做到:不仅会用,还能在面试现场手写出一个不输标准库的实现。
2. 核心原理拆解:字典序与“下一个排列”的定义
2.1 字典序到底是什么
想理解next_permutation,先得理解它输出的顺序——字典序。别被这个名词吓到,它其实就是我们查英文字典时的那种排序方式。比较两个排列A和B时,从左到右逐位比较:
- 找到第一个不相等的位,该位上数字较小的排列排在前面。
- 如果某个排列是另一个的前缀,则较短的那个排在前面。
比如排列123和132,第一位都是1,第二位分别是2和3,因为2 < 3,所以123排在132前面。对所有n!个排列按这个规则排序,得到的就是全排列的字典序序列。
还有一个等价的理解方式:把每一位想象成一个数字的“位权”。三位数字的排列abc,可以映射成一个三位数(如果允许首位为 0 的话),字典序就等价于数值从小到大。但注意,这只是三位的特例,因为位数短的时候简单;位数一长,“字典序”和“数值大小”就不再等同了。比如103在字典序中排在130前,但数值上103 < 130,二者恰好一致;可对于132和201,字典序是132在前,数值也是132在前,这又一致。真正不一致的情况要等你处理的是字符序列、或者位数不同时才出现。总之,字典序是一种“逐位比较”的顺序,数值序只是它在数字序列上的特例。
2.2 “下一个排列”的严格定义
如果已经有了一套全排列的字典序排序,那么“下一个排列”就是当前排列在这个排序中的后继。比如排列213的按字典序排列的后继是231。next_permutation干的就是这个活:把[first, last)范围内的元素重排为“字典序意义上恰好比当前序列大一个”的序列。
这里有两个关键细节:
- 如果当前序列已处于字典序最大值(元素降序排列),则不存在后继。
- 此时
next_permutation返回false,并把序列翻转为字典序最小值(升序排列),使得循环枚举可以重新开始。
这个“翻转回最小值”的设计非常巧妙。它意味着你不需要手动把序列重新排序,配合do...while就能无限枚举所有排列,直到回到起点。
2.3 算法步骤:从右向左找“拐点”
next_permutation的实现核心只有四步,任何一个看过算法书的人都应该能背下来:
- 从右向左扫描,找到第一个满足
a[i] < a[i+1]的位置i。这个位置称为“拐点”或“分割点”。 - 如果找不到这样的
i,说明整个序列已经是降序,即当前是最后一个排列。此时把整个序列反转,返回false。 - 从右端向左扫描,找到第一个大于
a[i]的元素a[j]。交换a[i]和a[j]。 - 将
a[i+1]到末尾这一段反转,使其变成升序。
为什么要从右向左找?因为字典序相邻的两个排列,差异通常集中在尾部,尾部尽量保持原状能让新的排列“恰好”大一点。为什么要找第一个大于a[i]的元素而不是最小的?因为从右向左遇到的第一个> a[i]的元素,恰好就是右侧区域中比a[i]大且最靠近它的候选者,交换后配合尾部反转,能得到字典序最小的后继。
举个具体例子,序列[1, 3, 2]:
- 从右向左扫描:
2 > 1吗?不。回到3和2,发现3 > 2,继续向左。1和3,发现1 < 3,所以i = 0,a[i] = 1。 - 从右向左找第一个
> 1的元素:2 > 1,所以j = 2,a[j] = 2。 - 交换:得到
[2, 3, 1]。 - 反转
i+1 = 1到末尾的部分(即[3, 1]反转成[1, 3]):得到[2, 1, 3]。
验证一下,[1, 3, 2]的字典序后继确实是[2, 1, 3]。从枚举序列来看:1 2 3→1 3 2→2 1 3→2 3 1→3 1 2→3 2 1,213恰好排在132后面。
2.4 复杂度分析:均摊 O(1) 的魔法
很多人第一次看到这个算法时,会担心它的复杂度——每次调用都可能做 O(n) 的扫描,那枚举所有排列岂不是 O(n × n!)?事实是,所有排列的总计算量其实是 O(n!),均摊到每次调用上只有 O(1)。这个结论我第一次看到时也觉得很反直觉,但它是对的。
原因在于:大多数时候,next_permutation只需要扫描非常少的尾部元素就能完成交换和反转。具体来说,尾部逆序的长度决定了这一次操作的成本。假设当前排列尾部有k个元素处于降序状态,那么找拐点需要扫描k步,反转尾部也需要 O(k) 步。数学上可以证明,所有排列的k之和是 O(n!),而不是 O(n × n!)。所以用这个函数枚举全排列,总复杂度是 O(n!),这正是理论下界(因为输出本身就有 n! 个),已经是渐近最优的。
这个结论也提醒我们一个实用点:使用next_permutation枚举全排列是高校可行的方案,不需要再用递归回溯去优化什么——除非题目要求输出顺序不是字典序,或者你需要对大量重复元素剪枝(这点后面细说)。
3. 标准库用法与细节:vector、数组、字符串的通用套路
3.1 最基础的用法:vector 搭配 do...while
next_permutation接受两个随机访问迭代器,因此vector、array、string甚至原生数组都能直接使用。最常见也最不容易出错的用法是配合do...while,因为next_permutation会“跳过当前排列,直接尝试下一个”,如果用while开头,第一个排列会被漏掉。
#include <algorithm> #include <vector> #include <iostream> int main() { std::vector<int> nums = {3, 1, 2}; // 先排序,确保从最小排列开始 std::sort(nums.begin(), nums.end()); do { for (int x : nums) std::cout << x << " "; std::cout << "\n"; } while (std::next_permutation(nums.begin(), nums.end())); return 0; }输出为:
1 2 3 1 3 2 2 1 3 2 3 1 3 1 2 3 2 1注意我特意先sort了一次。next_permutation只能枚举“从当前序列状态开始的后续排列”,如果初始序列是{3, 1, 2},枚举顺序就是312 → 321 → 123 → 132 → 213 → 231,同样能把 6 个排列全部走完,但顺序不符合字典序的“最小到最大”。如果你希望输出真正从123开始的字典序全排列,必须先sort。这是个很容易忽略的坑。
3.2 字符串与字符数组的全排列
这个函数对std::string同样有效,因为 string 的迭代器也是随机访问迭代器。处理字符全排列时,一个常见的需求是去重。标准库的next_permutation本身就自带去重能力,它不会产生重复排列。比如输入string s = "aab",经过sort后为"aab",枚举结果如下:
aab aba baa只有 3 个而不是 6 个。这在内部是如何实现的呢?关键在于拐点查找条件:
while (i > first && !(*i < *(i+1))) --i;注意这里找拐点的条件是比较<而不是<=。对于前后两个元素相等的情况(比如aa),它不会被判定为拐点。换句话说,相等的相邻元素会被“视而不见”,从而在找下一个排列时自动跳过重复分支。这是标准库同为高效去重的关键:不需要set,不需要判重,天然正确。
来实战一下。题目是“输入一个字符串,按字典序输出所有不重复的排列”,标准解法如下:
#include <algorithm> #include <string> #include <iostream> void printPermutations(std::string s) { std::sort(s.begin(), s.end()); do { std::cout << s << "\n"; } while (std::next_permutation(s.begin(), s.end())); } int main() { std::string str = "aba"; printPermutations(str); return 0; }输出为aab, aba, baa,完美符合要求。如果没有next_permutation,你自己写递归处理重复字符时,要用visited数组 + 排序去重判断,麻烦得多。
3.3 自定义比较器:从数字全排列到自定义对象的排列
next_permutation有一个重载版本,接受第三个参数comp作为比较器。默认版本使用operator<,即升序排列。如果你需要降序的字典序,可以使用std::greater<>(),或者干脆用prev_permutation。但更常见的是处理自定义对象。
比如现在有一组学生的姓名和成绩,要按照“成绩从低到高”来枚举所有可能的排序,当成绩相同时按姓名字典序排。这时可以直接定义比较器传递进去:
#include <algorithm> #include <vector> #include <tuple> #include <iostream> int main() { std::vector<std::tuple<std::string, int>> students = { {"Alice", 85}, {"Bob", 90}, {"Cindy", 85} }; std::sort(students.begin(), students.end(), [](const auto& a, const auto& b) { if (std::get<1>(a) != std::get<1>(b)) return std::get<1>(a) < std::get<1>(b); return std::get<0>(a) < std::get<0>(b); }); int count = 0; do { for (const auto& s : students) { std::cout << std::get<0>(s) << ":" << std::get<1>(s) << " "; } std::cout << "\n"; ++count; } while (std::next_permutation(students.begin(), students.end(), [](const auto& a, const auto& b) { if (std::get<1>(a) != std::get<1>(b)) return std::get<1>(a) < std::get<1>(b); return std::get<0>(a) < std::get<0>(b); })); std::cout << "Total: " << count << "\n"; return 0; }这里有个极其重要的细节:自定义排序必须和字典序比较逻辑保持一致。也就是说,你给sort的比较器、你给next_permutation的比较器、以及语义上的“排列顺序”,三者必须完全相同。如果排序用了一种比较规则,生成排列用了另一种,结果一定是错的。这个是我实际踩过的坑:某次处理多个关键字的结构体时,sort忘了加第二个关键字,结果枚举出的排列顺序混乱,浪费了半天排查时间。
3.4 处理有重复元素时的时间优化
前面提到标准库自带去重,但如果你自己写递归全排列,去重往往用“排序 + 同层去重”的剪枝。用next_permutation时去重是自动的,这一点非常省心。但在极端数据下,比如数组是[1, 1, 1, ..., 1, 2],只有一个不同的元素时,实际有效排列只有n个(2 的位置不同),但算法每次仍然要扫描整个数组判断“还有没有下一个”。这个开销是 O(n) 每次,总复杂度 O(n²),相比输出规模 O(n) 倒也不算离谱,但在n = 20这种大尺寸下还是会有可感知的耗时。
如果遇到这样的场景,我的经验是先计算有效排列的个数(等于n! / (cnt1! * cnt2! * ...)),再决定要不要继续枚举。或者直接改用“对元素下标做全排列然后去重输出”——但那样反而可能更慢。其实大多数算法题里的重复不会这么极端,直接用标准库就好,不用过度优化。
3.5 prev_permutation:上一个排列的镜像实现
标准库还有一个对应的std::prev_permutation,功能是生成“上一个字典序排列”。它的实现和next_permutation高度对称:
- 从右向左找到第一个
a[i] > a[i+1]的位置i。 - 如果找不到,说明已经是最小排列,翻转整个序列并返回
false。 - 从右向左找到第一个
< a[i]的元素a[j]。 - 交换
a[i]和a[j],反转i+1到末尾的部分。
如果你需要输出所有排列但要求顺序是逆字典序,可以让序列保持降序,然后用do...while配合prev_permutation。或者更简单:用next_permutation枚举完所有排列,然后反转输出顺序。但注意反转输出顺序需要把排列先存下来,内存占用 O(n!),对于 n 大时是不可行的。这时直接用prev_permutation从头降序枚举是省内存的最优解。
4. 手写实现:从零复刻 next_permutation
4.1 为什么建议手写一遍
有人会问:标准库不好用吗?为什么要手写?我的看法是:手写不是为了在实际项目中替代标准库,而是为了验证你是否真正理解了算法的每一处细节。面试中面试官让你“写一个全排列”,如果你直接回答“调用 next_permutation”,虽然正确但通常不会让你过关——他们想看的是逻辑思维,不是 API 背诵。而手写一遍也能帮你真正理解前面讲的复杂度分析与去重原因。
4.2 基于 vector 的手写模板
下面是完全对应标准库逻辑的实现,我命名为my_next_permutation。模板写法可以同时兼容vector<int>、vector<char>、string等一切支持随机访问迭代器的容器:
#include <algorithm> template<typename Iterator> bool my_next_permutation(Iterator first, Iterator last) { if (first == last) return false; // 空序列 Iterator i = last; if (first == --i) return false; // 只有一个元素 // 第一步:从右向左找第一个 a[i] < a[i+1] Iterator i1 = i; while (true) { if (*i < *i1) { // 找到拐点 // 第二步:从右向左找第一个 > *i 的元素 Iterator j = last; while (!(*i < *--j)) {} // 注意 --j 在比较前执行 std::iter_swap(i, j); // 交换 std::reverse(i1, last); // 反转 i 后面的部分 return true; } if (i == first) { // 已经是最小排列 std::reverse(first, last); // 全反转成最大排列 return false; } i1 = i; --i; } }这里稍微解释下变量命名:i是我们说的拐点位置,i1是i的下一个位置。*i < *i1就是判断“当前元素是否小于右边相邻元素”。j是右侧第一个大于*i的元素位置,std::iter_swap交换两个迭代器指向的值。std::reverse会把[first, last)内的元素原地反转。
有几点特别注意:
while (!(*i < *--j))中--j先执行,所以 j 从last的前一个元素开始比较。这是为了确保 j 不会越界。由于右侧必然存在大于*i的元素(刚才找到拐点时已经保证*i1 > *i),这个循环一定能终止。i1必须在 i 移动之前保存,因为后面反转区间需要用到i的下一个位置。如果直接写std::reverse(i + 1, last),对于随机访问迭代器没问题,但对于只能双向移动的迭代器就不行了。标准库为了支持更多容器,特意用保存 i1 的方式。我们手写的话,用i + 1更直观,但遵循标准逻辑能让你理解更透彻。
用这个函数替代标准库测试:
#include <vector> #include <iostream> int main() { std::vector<int> v = {1, 2, 3}; do { for (int x : v) std::cout << x << ' '; std::cout << "\n"; } while (my_next_permutation(v.begin(), v.end())); return 0; }输出和标准库完全一致。如果你做完这一步,再把my_next_permutation里的<改成>,你就能得到一个my_prev_permutation。这两个函数合在一起,你就掌握了排列枚举的半壁江山。
4.3 递归回溯写法与 next_permutation 的对比
很多教科书里教的全排列写法是递归回溯:
void backtrack(std::vector<int>& nums, int start, std::vector<std::vector<int>>& res) { if (start == nums.size()) { res.push_back(nums); return; } for (int i = start; i < nums.size(); ++i) { std::swap(nums[start], nums[i]); backtrack(nums, start + 1, res); std::swap(nums[start], nums[i]); // 回溯 } }注意,这种写法输出的是“交换产生的排列”,不是字典序。比如输入[1,2,3],它的输出顺序是:
1 2 3 1 3 2 2 1 3 2 3 1 3 2 1 3 1 2这和字典序123, 132, 213, 231, 312, 321有明显差异。如果你在算法题里要求“输出字典序全排列”,要么在最后把结果排序(代价 O(n! log n!)),要么用回溯 + 有序枚举(先排序,每次按顺序选未使用的数),要么直接用next_permutation。三种方案中,next_permutation最简洁,且不需要额外的res数组存储全部结果,内存占用 O(1)。
但也别就此否定回溯法。有些题目要求在排列过程中做剪枝(比如皇后问题),或者要处理排列中的约束条件(比如相邻字符不能相同),这时递归回溯灵活得多。next_permutation更适合“纯枚举所有排列”的场景。
4.4 手写版的常见错误汇总
我见过不少人在手写时踩进这几个坑:
- 把
i和i1搞混。拐点是i,而反转区间开头是i1。如果反转成[i, last),结果会把拐点元素也反转掉,生成错误的排列。 - 遗漏“已经是最大排列”的判断。某个排列已经是降序时,如果不反转直接返回 false,循环会提前结束,漏掉后续排列。
- 比较符号写反。找拐点时用
>=而不是>,会导致重复元素被当成拐点,生成重复排列或者死循环。 - 对空序列或单元素序列没有处理。标准库定义里,空序列返回 false,单元素序列调用后返回 false 且不修改。手写时如果不加这两个判断,
last--会在 begin 之前操作,导致未定义行为。
5. 实战案例一:下一个排列问题的标准解
5.1 题目描述与思路
力扣(按通用说法叫“某在线评测平台”)有一道经典题就是“下一个排列”。题目要求给定一个整数数组,原地把它修改成下一个字典序排列,如果已经是最后一个排列,则修改成最小排列。这不就是next_permutation吗?标准库一行调用,但面试时面试官明确要求不能使用标准库函数。所以你需要手写。
这类题的完整解题思路从两句话开始:
- 标准排列的字典序顺序,决定了“下一个”必然是把后面的某个稍大的数字挪到前面来。
- 为了让新排列尽量小(恰好是下一个),这个“稍大的数字”应该尽量靠右、尽量小;同时被挪到后面的部分要升序排列。
手动演算一遍:[6, 3, 4, 9, 8, 7]。
- 从右向左找第一个升序对:
4 < 9,所以i = 2,a[i] = 4。 - 从右向左找第一个大于 4 的数:
7 > 4,所以j = 5,a[j] = 7。 - 交换:
[6, 3, 7, 9, 8, 4]。 - 反转
i+1 = 3到末尾[9, 8, 4],得到[6, 3, 7, 4, 8, 9]。
验证一下,这个序列确实排在原序列后一个。如果自己写代码时想验证,可以先把所有排列枚举出来,再找到[6, 3, 4, 9, 8, 7]的位置和它的后继。这种暴力验证方法虽然笨,但对于初学阶段排查手写逻辑是否正确,非常有效。
5.2 完整实现代码
#include <vector> #include <algorithm> void nextPermutation(std::vector<int>& nums) { int n = nums.size(); if (n <= 1) return; // 找拐点 int i = n - 2; while (i >= 0 && nums[i] >= nums[i + 1]) { --i; } if (i >= 0) { // 从右向左找第一个大于 nums[i] 的元素 int j = n - 1; while (nums[j] <= nums[i]) { --j; } std::swap(nums[i], nums[j]); } // 反转 i+1 到末尾 std::reverse(nums.begin() + i + 1, nums.end()); }注意两个细节对比标准库版本:
- 找拐点时条件是
nums[i] >= nums[i+1]。这里允许等于,目的是跳过相等的相邻元素,避免把重复元素当作拐点。对于无重复数组,>=和>效果一样;但对于有重复数组,必须用>=才能得到去重效果。 - 如果
i < 0,说明整个数组是降序的(或者全是相等元素),此时不交换,直接反转整个数组。这实现“已经是最大排列则变成最小排列”的要求。
测试用例:
| 输入数组 | 输出数组 |
|---|---|
| [1, 2, 3] | [1, 3, 2] |
| [3, 2, 1] | [1, 2, 3] |
| [1, 1, 5] | [1, 5, 1] |
| [1] | [1] |
5.3 延伸:如何从任意排列开始,枚举到指定个数
有时你并不想枚举全部排列,而想从某个状态开始,连续生成 k 个排列。比如题目说“给定一个排列,求它后面第 k 个排列”。最简单的做法是循环调用 k 次next_permutation。但 k 很大时(比如 10^9),O(kn) 不可行。这时候有两种思路:
- 数学法:把排列映射成“康托展开”的序号,然后序号加 k 再逆展开。复杂度 O(n²),适合大 k、小 n。
- 分块跳:每次调用
next_permutation时,尾部逆序长度决定了那次跳过的字典序间隔。你可以写一个“快速前进 k 步”的函数,在遍历尾部时累加间隔,找到目标位置。这个思路很有意思,但实现复杂,算法题里少见,我就不展开写了。
总之,标准库给你提供的是“一次一步”的能力,而“跳多步”则是考察你对康托展开等进阶算法的理解。两者搭配可以覆盖几乎所有排列枚举问题。
6. 实战案例二:组合枚举与 next_permutation 的巧妙结合
6.1 用 0/1 序列枚举组合
全排列和组合是两个常被一同提起的概念。组合的朴素枚举方式是“在 n 个元素中选 k 个”,可以用递归,也可以用二进制掩码。但有一个很巧妙的思路是用next_permutation:构造一个长度为 n 的0/1序列,其中 k 个 1、n-k 个 0,对这个序列做全排列,每个排列中“1”的位置就是一组选中的下标。
例如 n = 5, k = 2。初始序列[1, 1, 0, 0, 0],用next_permutation枚举它三个 1 两个 0 的全部排列:
#include <algorithm> #include <vector> #include <iostream> void printCombinations(int n, int k) { std::vector<int> mask(n, 0); for (int i = 0; i < k; ++i) mask[i] = 1; // 注意:初始状态就是字典序最大,需要 sort std::sort(mask.begin(), mask.end()); do { for (int i = 0; i < n; ++i) { if (mask[i]) std::cout << i << ' '; } std::cout << "\n"; } while (std::next_permutation(mask.begin(), mask.end())); } int main() { printCombinations(5, 2); return 0; }输出为下标组合(从 0 开始):
0 1 0 2 0 3 0 4 1 2 1 3 1 4 2 3 2 4 3 4这正是 C(n,k) 个组合。这里有个容易犯错的地方,我特意在代码里加了sort(mask.begin(), mask.end())。为什么?因为[1,1,0,0,0]不是字典序最小的状态,直接调用next_permutation只会枚举它后面那部分,导致漏掉大量组合。必须先把它变成[0,0,0,1,1]才能从最小状态开始枚举。如果要得到字典序升序的组合,注意排序后第一个组合是0 1,最后一个组合是3 4,非常整齐。
6.2 组合枚举对比:二进制枚举 vs next_permutation
实现组合枚举还有另一种常见做法——从 0 到(1 << n) - 1遍历。但那种方式输出的顺序是二进制序,不是字典序,而且 n 超过 20 时1 << n会溢出(除非用 64 位整数),next_permutation则没有这个限制,只要容器能装得下,就能枚举到任意长度的组合。另外,用next_permutation的 0/1 方法天然去重(重复元素问题在 mask 场景不存在),而且每生成一个新的 mask 只需要 O(n) 时间,和暴力判断二进制中 1 的位置相同。
6.3 其他符号化枚举场景
类似的思路还能用于很多场景:比如枚举所有合法的括号序列(把(记作 1,)记作 -1,并过滤前缀和非负 + 总和为 0 的序列),或者枚举一笔画区域填充方案等。核心技巧是:把一个“带约束的枚举问题”映射成一个 0/1 串的全排列问题,再交给next_permutation去暴力枚举。
这种“状态压缩 + 全排列”的组合拳,在处理小规模暴力搜索题时很有用。比如某个模拟赛中有一道题:给定 8 盏灯,要求枚举所有满足“相邻两盏灯不能同时亮”的开关方案,我当时的做法就是构造 8 位 0/1 串,筛选掉非法状态。虽然直接枚举 2^8 = 256 种也完全可行,但用next_permutation的思路在约束不对称时写起来更直观。
7. 常见问题与排查技巧实录
7.1 全排列数量超大时该如何处理
先说一个最基本的数字概念。n 个不同元素的全排列数量是 n!,增长速度极快:
| n | n! |
|---|---|
| 5 | 120 |
| 8 | 40320 |
| 10 | 3628800 |
| 12 | 479001600 |
| 15 | 1307674368000 |
如果一次把所有排列都存进vector<vector<int>>,n=10 时结果数量就有 362 万个,每个 vector 内部还有动态内存分配,十分容易被内存拖垮。我的经验是:能用流式处理就别全量存储。上面讲的do...while循环打印方式就是流式的,内存只占 O(n)。
如果必须存储所有排列,最好在拿到一个排列时立刻处理并释放,而不是堆在那里。如果遇到“要求返回所有排列的题目”,务必确认 n 的范围。n 不超过 8 时全量存储问题不大,n=9 时已经要斟酌,n=10 或以上基本不可行。题目如果出到 n=12 还要求全排列,那大概率是要求你输出到文件或者做某种剪枝。
7.2 为什么枚举顺序和预期不符
这个问题几乎每个人都遇到过。原因通常是以下几种:
- 忘了先排序。
next_permutation从当前序列状态开始往后枚举,如果最初状态是最大值附近,前面的排列就会被跳过。 - 使用
while而不是do...while。第一个排列被吞掉。 - 自定义比较器和 sort 用的比较器不一致。比如 sort 按全局排序,但 next_permutation 按另一个规则,两者互相矛盾,结果乱得没法看。
- 在循环体内修改了序列。比如在
do...while中打印后,又手动修改了数组内容,导致next_permutation基于错误状态继续。要修改也应在while判断条件调用后,绝不能在循环体里改。
排查技巧:先在循环开头打印当前序列,观察第一个输出是否是最小排列。如果第一个不是最小排列,百分之百是 sort 漏了或者是用while开头。再看中间某一步是否跳过了某个排列,多半是循环体内意外修改了序列。
7.3 大整数或无符号类型上的比较陷阱
另一个容易忽视的问题是:next_permutation的比较是operator<,对于无符号整数和浮点数也适用。比如一个vector<unsigned long>,比较大小没有问题,但当你手写算法时用到“反向扫描找拐点”的代码,尤其在表达式里混用无符号和有符号数时容易出事。例如:
for (auto i = nums.size() - 2; i >= 0; --i)这是一个经典的死循环写法。nums.size()返回size_t无符号类型,当nums.size()为 1 时,1 - 2得到一个巨大的无符号数,循环条件i >= 0永远为真。正确写法是先把长度转为有符号,或者使用int i = n - 2; while (i >= 0)的方式。这个坑在 LeetCode 风格的写法中特别常见,我写过无数次,后来养成习惯:涉及下标回退循环的一律用int,涉及容器长度比较时用size_t,二者不要混用。
7.4 内存与引用语义的坑
next_permutation会原地修改序列。如果你不希望原始数据被改变,一定要先复制一份再操作。举一个真实的翻车案例:我初学的时候写过这样的代码:
std::vector<int> original = {1, 2, 3}; auto current = original; // 正确复制 // 然后对 current 做全排列,original 不受影响后来有一次图省事,直接写了auto& current = original;,然后把current传入next_permutation。结果枚举到一半,original也被改了,后续使用原始数据的逻辑全部崩溃。原地修改是next_permutation的效率优势,但同时也是使用时要小心的地方。除非你就是想在原序列上操作,否则务必用值复制。
7.5 如何验证手写实现是否正确
自己写完my_next_permutation后,最好的验证方法就是和标准库对拍。写一个小程序:生成随机数组,排序后分别用标准库和自己写的函数枚举,比较每一轮的输出序列是否完全一致。如果 n 比较小,还可以直接枚举所有排列和字典序列表对照。这个对拍脚本几乎是万能的:
#include <algorithm> #include <vector> #include <iostream> #include <random> int main() { std::mt19937 rng(42); for (int test = 0; test < 1000; ++test) { int n = 3 + rng() % 6; std::vector<int> a(n), b(n); for (int i = 0; i < n; ++i) a[i] = b[i] = rng() % 4; std::sort(a.begin(), a.end()); b = a; int steps = rng() % 50; for (int s = 0; s < steps; ++s) { bool ra = std::next_permutation(a.begin(), a.end()); bool rb = my_next_permutation(b.begin(), b.end()); if (ra != rb || a != b) { std::cout << "Mismatch at step " << s << "\n"; return 1; } } } std::cout << "All tests passed\n"; return 0; }这一套对拍法不仅适用于 next_permutation,也适用于任何你自己实现的算法和标准库函数做对比。接触工程化编程之后你会发现,写代码本身不难,难的是找到一种高效的方式验证代码的正确性。对拍就是最实用的入门方法。
8. 总结与扩展:从 next_permutation 到排列组合枚举的思维框架
8.1 一个模板走天下:所有排列问题的第一步思考
写到这里,你应该能感受到我的核心建议:拿到任何涉及排列枚举的题目,先问自己三个问题。
- 题目要求的是“所有排列”还是“特定顺序的某一个排列”?
- 序列中有没有重复元素?要不要去重?
- 输出顺序有没有强制要求(字典序升序、降序、任意)?
如果三个问题的答案是“所有排列 + 可能重复 + 字典序”,那么直接上sort + do...while(next_permutation),十行代码搞定。如果答案是“特定排列 + 不允许重复 + 任意顺序”,那递归回溯可能更合适。如果答案是“带剪枝约束”,则需要自己写深度优先搜索。
8.2 还能怎么扩展:集合划分、序列生成器与其他容器适配
next_permutation虽然是为排列设计的,但它可以扩展到更广的场景。
- 集合划分:把 n 个元素分成若干个非空子集,可以用“划分号”序列枚举。如果把每个元素标记为它所属的集合编号,那么所有划分方案等价于“n 个位置、k 种标签”的序列枚举。用
next_permutation去重枚举标签序列,可以避免递归时重复计数。 - 组合总和:上面提过的 0/1 掩码枚举组合,是
next_permutation在组合问题中最经典的应用。 - 全排列生成器的流式处理:在需要边生成边处理的场景,直接调用
next_permutation比把所有排列存在内存里再遍历要节省几个数量级的内存。
另外,这个函数支持双向迭代器,但要求是随机访问。所以std::list不能用——它的迭代器是双向的,无法随机访问。如果要用list存排列,要么手动转成vector,要么实现一个基于双向迭代器版本的排列算法(那样效率会下降,因为反转和查找都无法 O(1) 跳转)。
8.3 面试场景中的加分表达
如果面试时让你实现“下一个排列”,不要只闷头写代码。先说出思路,让面试官看到你的思考过程。我的推荐表达大致是:
“从右向左找到第一个升序对,这说明这里是可以增大的位置。为了让增长幅度尽量小,我要从右侧选一个刚好比它大的数和它交换,然后把后面的序列反转为升序——这样得到的排列就是字典序意义上的下一个。”
这段话讲完,就算代码有小瑕疵,面试官也知道你理解了本质。比闷头写一个递归回溯版本要通过率高得多。
8.4 个人经验与踩坑记录
最后分享几个我在实际工程和比赛中积累的零散经验。
第一,初次使用next_permutation时,我犯过最蠢的错误是忘了#include <algorithm>,导致编译错误。看似低级,但当你用 IDE 自动补全时很容易忽略这个问题。另外,某些老编译器里next_permutation对const char*这类裸指针数组不适用,因为它们不是随机访问迭代器。
第二,在 Windows 上用 MSVC 和 Linux 上用 GCC 编译时,标准库的next_permutation行为是完全一致的,不用担心暴露出平台差异。C++ 标准对此有严格的规定。
第三,如果你在算法竞赛环境中经常使用#include <bits/stdc++.h>,那可以不用管头文件问题。但如果是正经工程代码或面试现场,建议分清<algorithm>、<vector>、<string>各自的职责,别依赖万能头文件。
第四,有一个场景是很多人不知道的:next_permutation能对“无序容器”使用,比如std::deque也可以,因为 deque 迭代器也是随机访问。但std::set不行,因为它的迭代器是双向的,而且容器本身不允许原地修改顺序。
第五,对于性能极度敏感的场景,比如要枚举上亿个排列时,标准库的next_permutation可能还比不过你针对特定容器写死的手写版本,因为通用实现有迭代器抽象的开销。但这种优化通常只在大规模并行统计时需要,日常刷题和业务代码完全没必要在意。
8.5 练习题目推荐列表
如果你看完这篇想动手练习,我给你推荐几个循序渐进的题目类型,难度从低到高:
- 输出某个字符串的全排列(要求字典序)。
- 给定一个排列,求它的字典序序号(康托展开)。
- 给定一个序号,求序号对应的排列(逆康托展开)。
- 给定 n 个有重复元素的数组,输出所有不重复的排列。
- 枚举 C(n, k) 的所有组合,按字典序输出。
- 实现一个生成器,每调用一次返回下一个排列,但不允许在函数内部保存额外状态(提示:用数组的当前状态做函数式无状态生成器)。
这些题目做完后,你对排列组合枚举的掌握基本就到下一个层次了。
总的来说,next_permutation是我在 C++ 标准库中最喜欢的算法函数之一:实现简洁、用途广泛、边界处理微妙。很多初学者在接触它时会觉得“既然标准库有了,何必还要理解原理”,但恰恰是这种“知其然而不知其所以然”的心态,会在你遇到需要变通的时候卡住。希望这篇文章能帮你把next_permutation从“会用”变成“懂用”。