C++ 排列组合完整指南

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]);            // 撤销
    }
}

解释:

  1. l 代表当前正在选择的位置。
  2. swap(nums[l], nums[i])i 位置的数调到 l 位置。
  3. l==r 时所有位都已固定,输出当前排列。
  4. 回溯时交换回原位,确保后续交换的基准正确。

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_permutationprev_permutation

5.1 next_permutation 的原理

  • 收尾 :若当前序列已是最大字典顺序,返回 false
  • 步骤
    1. 从右向左找第一个 a[i] < a[i+1]i
    2. 从右向左找第一个 a[j] > a[i]j
    3. 交换 a[i]a[j]
    4. 反转 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 个排列,bab2 个,bba3 个。

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. 小结与实战建议

  1. 先学习基础 :熟悉 递归回溯位掩码 这三种基本工具。
  2. 多用 STL :如 next_permutation()prev_permutation() 大幅简化代码,避免手工实现。
  3. 预处理 :对 nCrn! 等做一次预热,避免重复计算。
  4. 剪枝:在递归/回溯中提前判断不可能满足条件的分支,可节约大量时间。
  5. 边界特例 :特殊小值时,如 k=0k=n,直接返回答案,避免无用的枚举。
  6. 可读性:排列/组合算法里很容易因为小错导致报错(如下标越界、循环条件不对),注释清晰、变量命名明确能让你免受此困扰。
  7. 实战案例 :多练习 BZOJ、Codeforces 等平台的排列/组合题,甚至实现一个组合数模 10^9+7 的快速运算库。

最后 :排列与组合不只是数学概念,更是 算法设计的骨架。在贵复杂度考察与求解难题时,你可将其视作可组合的工具箱,把问题拆解为排列生成、组合计数或解码问题,进而快速落地实现优化与求解。

祝学习愉快,代码高效!

相关推荐
代码中介商1 小时前
银行管理系统的业务血肉 —— 流程、状态机、输入校验与持久化(下篇)
c语言·算法
橙子也要努力变强2 小时前
信号捕捉底层机制-机理篇2
linux·服务器·c++
foundbug9992 小时前
自适应滤除直达波干扰的MATLAB实现
开发语言·算法·matlab
盐焗鹌鹑蛋2 小时前
【C++】stack和queue类
c++
XDH_CS2 小时前
MySQL 8.0 安装与 MySQL Workbench 使用全流程(超详细教程)
开发语言·数据库·mysql
小短腿的代码世界3 小时前
Qt实时盈亏计算深度解析:从持仓数据到动态盈亏展示
开发语言·qt
小康小小涵3 小时前
基于ESP32S3实现无人机RID模块底层源码编译
linux·开发语言·python
lzjava20243 小时前
Python的函数
开发语言·python