C++ 排列组合完整指南
1. 何谓排列与组合?
排列(Permutation) :在
n个不同元素中,取k个元素按顺序排布,例如
① 1 2 3,② 1 3 2,③ 2 1 3 ......
要求 顺序 重要,计为不同排列。组合(Combination) :在
n个不同元素中,取k个元素,但不考虑顺序 ,例如
① {1,2,3},② {1,3,2} 被视同{1,2,3}。
记号:排列数: P(n,k)=n!/(n−k)!
组合数:
排列数 > 组合数,因排列将顺序计入。
2. 基本数学定义与常用公式
| 符号 | 含义 | 例子 |
|---|---|---|
| P(n,k)P(n,k) | n 个元素中选取 k 个 有序 的排列 | P(3,2)=3×2=6P(3,2)=3×2=6 |
| C(n,k)C(n,k) | n 个元素中选取 k 个 无序 的组合 | C(4,2)=6C(4,2)=6 |
| n!n! | n 的阶乘 | 4!=244!=24 |
| (n−k)!(n−k)! | 余数阶乘 | (4−2)!=2!(4−2)!=2! |
组合数的递推关系(Pascal 三角)
C(n,k)=C(n−1,k−1)+C(n−1,k), C(n,0)=C(n,n)=1
递推很容易在程序里实现,对大多数竞赛题是直接采用记忆化或预处理的方式获取组合数。
3. C++ 中最常见的枚举技巧
| 方式 | 适用场景 | 主要代码特征 |
|---|---|---|
| 递归 + 回溯(Backtracking) | 需要完整枚举所有排列/组合,或在枚举过程中做剪枝 | 使用 std::vector<int> 或 std::string 存储当前去向 |
STL next_permutation/prev_permutation |
已经有全排列/组合(按字典顺序)生成 | 声明 std::vector<int> 或 string 并排序后循环 |
| 位掩码 | 经典组合枚举(如二进制数) | 用位运算更新集合(mask & (mask-1)) |
| 组合数快速映射 | 给定编号,快速取得对应的组合 | 通过组合数表逆推 |
小贴士 :不同实现的性能差别可达十几倍,选型要看
n,k的大小与题目要求。
4. 递归 / 回溯实现(全排列 / 全组合)
4.1 完全排列(全排列)
cpp
#include <bits/stdc++.h>
using namespace std;
// 方式一:直接递归回溯
void permute(vector<int>& nums, int l, int r){
if (l == r){
for(int x: nums) cout<<x<<' ';
cout<<"\n";
return;
}
for (int i = l; i <= r; i++){
swap(nums[l], nums[i]); // 选取第 l 位
permute(nums, l+1, r);
swap(nums[l], nums[i]); // 撤销
}
}
解释:
l代表当前正在选择的位置。swap(nums[l], nums[i])把i位置的数调到l位置。- 当
l==r时所有位都已固定,输出当前排列。- 回溯时交换回原位,确保后续交换的基准正确。
4.2 组合(k 选 n)
cpp
void combination(vector<int>& nums, int k, int start, vector<int>& cur){
if (cur.size() == k){
for(int val: cur) cout<<val<<' ';
cout<<"\n";
return;
}
for(int i = start; i < nums.size(); ++i){
cur.push_back(nums[i]); // 选中
combination(nums, k, i+1, cur);
cur.pop_back(); // 复原
}
}
start用来保证顺序不重复。
剪枝 :若nums.size() - start < k - cur.size(),说明剩余元素不足k,可直接返回。
5. STL 便利工具:next_permutation 与 prev_permutation
5.1 next_permutation 的原理
- 收尾 :若当前序列已是最大字典顺序,返回
false。 - 步骤 :
- 从右向左找第一个
a[i] < a[i+1]的i。 - 从右向左找第一个
a[j] > a[i]的j。 - 交换
a[i]与a[j]。 - 反转
a[i+1 ... end]。
- 从右向左找第一个
5.2 示例代码
cpp
void all_permutations(vector<int> nums){
sort(nums.begin(), nums.end()); // 先排成最小字典序
do{
for(int x: nums) cout<<x<<' ';
cout<<"\n";
}while(next_permutation(nums.begin(), nums.end()));
}
next_permutation的时间复杂度平均 O(n) ,实现层面上更快、更稳定,尤其在n<=20时表现优异。
5.3 组合------利用多位标记
要生成 k 个 1 的所有组合,可以先构造位掩码:
cpp
void combinations(int n, int k){
vector<int> bits(n, 0);
fill(bits.end()-k, bits.end(), 1);
do{
// 处理每种组合
for(int i=0;i<n;i++) if (bits[i]) cout<<i+1<<' ';
cout<<"\n";
}while(next_permutation(bits.begin(), bits.end()));
}
这里
bits序列的字典序正好对应组合的字典序。
6. 位运算 + 位掩码实现组合
6.1 二进制枚举
cpp
// 生成 n 个元素的所有子集(等价于 2^n 组合)
void subsets(int n){
for(int mask = 0; mask < (1<<n); ++mask){
for(int i = 0; i < n; ++i)
if(mask & (1<<i)) cout<<i+1<<' ';
cout<<"\n";
}
}
6.2 只取 k 位的组合
cpp
// count ones = k 的方式
void k_combinations(int n, int k){
int mask = (1<<k)-1; // k 个 1
while (mask < (1<<n)){
for(int i=0;i<n;i++){
if(mask & (1<<i)) cout<<i+1<<' ';
}
cout<<"\n";
// 下一个组合: Gosper hack
int c = mask & -mask;
int r = mask + c;
mask = (((r^mask) >> 2)/c) | r;
}
}
Gosper hack 取出下一个具有相同位数的掩码,时间复杂度 接近 O(1) 每次生成。
对
n取值不准的情况,用next_permutation处理1/0序列同样可行。
7. 组合数计算(nCr)
在 C++ 中,我们可以构建一个 组合数表 C[n+1][k+1],采用动态规划:
cpp
const int MAX_N = 60;
long long C[MAX_N+1][MAX_N+1];
void precompute(){
for(int n=0; n<=MAX_N; ++n){
C[n][0] = C[n][n] = 1;
for(int k=1; k<n; ++k)
C[n][k] = C[n-1][k-1] + C[n-1][k];
}
}
若
n可能超过 60(导致long long超限),可以使用__int128或自行实现大整数(如boost::multiprecision::cpp_int)。
组合数快速查表
cpp
long long nCr(int n, int r){
if(r<0 || r>n) return 0;
return C[n][r];
}
组合数的快速求和(塞塔等)
- 求
递推可以直接实现:
cpp
long long sum_combinations(int n, int k){
long long sum=0;
for(int i=0;i<=k;i++) sum += nCr(n,i);
return sum;
}
8. 组合数映射(从下标恢复组合)
给定组合下标 idx (0-indexed),以及 n, k,可以 逆推 组合元素。
思路 :按字典序枚举,每步挑一个元素 i 的时候,要判断之前组合中有多少种。
算法 (采自《组合数逆序算法》):
cpp
vector<int> kth_combination(int n, int k, long long idx){
vector<int> result;
int a = 0; // 当前最小可选值
while(k > 0){
// 统计从 a 开始,取 k-1 个的组合数
long long cnt = nCr(n - a - 1, k - 1);
if(idx < cnt){
result.push_back(a+1);
a++; // next candidate starts from a+1
k--;
}else{
idx -= cnt; // skip these many combos
a++; // move to the next possible element
}
}
return result;
}
该方法时间复杂度 O(nk)O(nk),仅需一次组合表查询。
9. 组合问题的应用案例
| 场景 | 典型题目 | 关键点 |
|---|---|---|
| 1. 数码密码 | "给定长度为 4 的密码,由数字 0~9 组成,找出所有合法密码?" | 枚举 10⁴ 组织;利用剪枝避免重复;可用 DP 计数 |
| 2. 图的生成 | "给定 N 个顶点,输出所有可能的边集合" | nC2 条边,枚进行子集 |
| 3. 组合优化 | "最小化某种成本的组合" | DP + 组合数表 |
| 4. 概率计算 | "从 52 张牌中取 5 张的可能性" | 单纯 C(52,5) |
| 5. 背包问题 | "把物品按重量/价值配对,计算最大价值" | 子集枚举 |
小结:组合在算法竞赛里往往与 DP、位运算、滑动窗口交织一起,掌握这些技巧是通向高水平的必经之路。
10. 性能与复杂度分析
| 方案 | 时间复杂度 | 空间复杂度 | 对象 | 适用范围 |
|---|---|---|---|---|
| 递归回溯(排列) | O(n×n!)O(n×n!) | O(n)O(n) | 需要全枚举 | n≤10n≤10 或更小 |
next_permutation |
O(n×n!)O(n×n!) | O(n)O(n) | 需要全枚举 | n≤15n≤15 或更大 |
| 位掩码组合 | O(C(n,k))O(C(n,k)) | O(1)O(1) | 只枚举组合 | n≤30n≤30 |
| 位掩码子集 | O(2n)O(2n) | O(1)O(1) | 只枚举子集 | n≤20n≤20 |
| 组合数快速计数 | O(1)O(1) | O(n2)O(n2) | single query | up to large N |
| 组合映射 | O(nk)O(nk) | O(1)O(1) | query by index | kk small |
提示:
- 对
n>20的排列巨大的,通常不需要枚举,应该改用规约/剪枝。- 对
k极大(如k ~ n/2)的组合,使用next_combination或 Gosper hack 的技术验证子集大小。- 一定要注意
long long的溢出,对组合数C(60,30)远超1e18,若需储存可使用__int128。
11. 进阶话题
11.1 多重集排列
当序列包含 重复元素 时,next_permutation 对处理扰动非常好。
cpp
// 先 sort,再 next_permutation
vector<char> s = {'a','b','b'};
sort(s.begin(), s.end());
do {
for(char c: s) cout<<c;
cout<<"\n";
} while(next_permutation(s.begin(), s.end()));
输出:
abb
bab
bba
编号 :若要计算abb是第 1 个排列,bab第 2 个,bba第 3 个。
11.2 部分排列(k≠n)
组合+排列 结合:
cpp
void k_permutations(vector<int>& nums, int k){
vector<int> part(k);
vector<bool> used(nums.size(), false);
function<void()> dfs = [&](){
if(part.size() == k){
// 输出 part
for(int x: part) cout<<x<<' ';
cout<<"\n";
return;
}
for(auto i: nums){ if(!used[i]){used[i]=true; part.push_back(i); dfs(); part.pop_back(); used[i]=false;} }
};
dfs();
}
随着
k变小,复杂度从n!下降到nP(k) = n!/(n-k)!。
11.3 组合数量直接生成(不枚举)
利用 组合数表 和 递推 在不完整枚举的前提下返回第 idx 组合,这里常用的技巧是 "自下而上选元素"。
12. 小结与实战建议
- 先学习基础 :熟悉 递归 、回溯 、位掩码 这三种基本工具。
- 多用 STL :如
next_permutation()与prev_permutation()大幅简化代码,避免手工实现。 - 预处理 :对
nCr、n!等做一次预热,避免重复计算。 - 剪枝:在递归/回溯中提前判断不可能满足条件的分支,可节约大量时间。
- 边界特例 :特殊小值时,如
k=0或k=n,直接返回答案,避免无用的枚举。 - 可读性:排列/组合算法里很容易因为小错导致报错(如下标越界、循环条件不对),注释清晰、变量命名明确能让你免受此困扰。
- 实战案例 :多练习 BZOJ、Codeforces 等平台的排列/组合题,甚至实现一个组合数模
10^9+7的快速运算库。
最后 :排列与组合不只是数学概念,更是 算法设计的骨架。在贵复杂度考察与求解难题时,你可将其视作可组合的工具箱,把问题拆解为排列生成、组合计数或解码问题,进而快速落地实现优化与求解。
祝学习愉快,代码高效!