递推递归与排列组合
说明
排列组合
排列组合问题在暴力枚举的情况一般有3种情况
我们在此记个数为N
- 情况一:打印n个数的全排列:
- 情况二:打印n个数中任意m个数的全排列
- 情况三:打印n个数中任意m个数的组合
递推与递归
递推是按照一定的规律来计算序列中的每个项,通常是通过计算前面的一些项来得出序列中的指定项的值。其思想是把一个复杂的庞大的计算过程转化为简单过程的多次重复,该算法利用了计算机速度快和不知疲倦的机器特点。
所谓递归,实际上是一种把大问题逐步缩小成最小同类问题的过程,其中最小问题的解是已知的,往往是所给条件。
比较两者,可清楚的发现递归是逆向的递推。递推是从最小问题出发一步步向前求解,递归则是从最大问题开始不断去调用自己达到最小问题的解然后再逐层归来。
所以一般来讲递推的效率大于递归,并且在递归中问题的n是在计算前就已经知道的,而递推可以在求解中确定。
我们以斐波那契数列距离,其数学递推公式如下(无论递推还是递归,一般都从递推公式开始分析)
\[\begin{cases}f(n) = f(n-1) + f(n-2) \\f(1) = f(2) = 1 \\n>2,n\in {N}^{+} \end{cases} \]接下来我们利用代码来分别示范一遍两种方法
- 斐波那契——递归
#include <iostream>
#include <ctime>
using namespace std;
//由于数列发散快,使用int类型当n较大时可能会溢出,此处仅为举例
int func_fib(unsigned int n)
{
if(n==0) //尽管递推公式给出n不会等于零,但为了以防万一添加了n为0的情况
return 0;
if(n==1 || n==2)
return 1;
else
return func_fib(n-1) + func_fib(n-2);
}
int main()
{
unsigned int n = 0;
cout << "请输入所求斐波那契数列项的项数n: " << endl;
cin >> n;
//输出结果和所用时间,单位:时钟单位
clock_t start,end;
start = clock(); //开始
cout << func_fib(n) << endl;
end = clock(); //结束
cout << "计算所用时间为: " << (double)(end-start) << endl;
return 0;
}
- 斐波那契——递推
#include <iostream>
#include <ctime>
using namespace std;
//同样,n过大会溢出
int func_fib(unsigned n)
{
int fib = 0;
int fib_n = 1;
for(int i=0; i<n; i++)
{
int temp = fib_n;
fib_n = fib + fib_n;
fib = temp;
}
return fib;
}
int main()
{
cout << "请输入所求斐波那契数列项的项数n: " << endl;
unsigned int n;
cin >> n;
//输出结果和所用时间
clock_t start,end;
start = clock(); //开始
cout << func_fib(n) << endl;
end = clock(); //结束
cout << "计算所用时间为:" << (double)(end-start) << endl;
return 0;
}
算法
我们对上述排列组合对三种情况写代码
情况一
输出全排列
STL方法:
#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
//输出序列
void printSequence(vector<int> num)
{
vector<int>::iterator it; //迭代器
cout << "当前序列: ";
//遍历
for(it=num.begin(); it!=num.end(); it++)
cout << *it << " ";
cout << endl;
}
int main()
{
/*
* 注意,使用sort()与alogrithm需要包含algorithm头文件
*/
//初始化序列
vector<int> num{2,1,5,3,4};
//排序(正序)
sort(num.begin(), num.end());
//输出全排列
do{
printSequence(num);
}while(next_permutation(num.begin(), num.end()));
return 0;
}
递归方法:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
void printSequence(vector<int> num)
{
vector<int>::iterator it; //迭代器
cout << "当前序列: ";
//遍历
for(it=num.begin(); it!=num.end(); it++)
cout << *it << " ";
cout << endl;
}
/*
*这里num必须用引用。
*否则迭代器指向的num是原实例,而传入的num是拷贝的实例,输出结果都是原序列。
*详细情况可以调用下方的test尝试下(该函数已被注释)
*/
void Perm(vector<int> &num, vector<int>::iterator begin, vector<int>::iterator end)
{
if(begin == end-1)
printSequence(num); //打印,此处还可以统计个数
else{
vector<int>::iterator it;
for(it=begin; it<end; it++)
{
swap(*begin, *it);
Perm(num, begin+1, end);
swap(*begin, *it);
}
}
}
//void test(vector<int> num, vector<int>::iterator it)
//{
// num[0] = 10;
// cout << *it << " " << num[0] << endl;
//}
int main()
{
vector<int> num{1,2,3};
Perm(num, num.begin(), num.end());
return 0;
}
情况二
想要打印n个数中任意m个数的全排列其实很简单,只需要在上述代码中稍作修改即可
打印序列函数printSequence
void printSequence(vector<int> num, vector<int>::iterator begin, vector<int>::iterator end)
{
vector<int>::iterator it; //迭代器
cout << "当前序列: ";
//遍历
for (it = begin; it != end; it++)
cout << *it << " ";
cout << endl;
}
获取全排列的函数Perm(增加了计数功能,需传入变量cnt)
/*
*这里num必须用引用。
*否则迭代器指向的num是原实例,而传入的num是拷贝的实例,输出结果都是原序列。
*详细情况可以调用下方的test尝试下(该函数已被注释)
*参数:num:原序列,begin:vector的begin迭代器, end:vector的end迭代器,m:从n个数中打印m个数的全排列中的m,cnt:用于统计最后全排列个数
*/
void Perm(vector<int>& num, vector<int>::iterator begin, vector<int>::iterator end, unsigned int m, int &cnt)
{
if (begin == num.begin() + m)
{
printSequence(num, num.begin(), num.begin() + m); //打印
++cnt; //统计个数
}
else {
vector<int>::iterator it;
for (it = begin; it < end; it++)
{
swap(*begin, *it);
Perm(num, begin + 1, end, m, cnt);
swap(*begin, *it);
}
}
}
情况三
组合不同于排列,排列有序而组合无序。所以,我们先来研究其子集的生成。
我们按如下记集合A,其中n为其元素的个数
\[A = \left \{ x_0,x_1,x_2,...,x_{n-1} \right \} \]其子集有
\[\phi ,\left \{ x_0 \right \} ,\left \{ x_1 \right \},...,\left \{ x_0,x_1,...,x_{n-1}\right \} \]不妨设子集个数为N,则有
\[N = 2^{n} \]这个式子很容易跟二进制联系起来,我们不由得会去想子集跟二进制之间的关系
我们不妨构造一个n位的二进制数,每一位都对应集合中的一个元素,当子集中包含该元素,则对应的二进制数的该位上的值就为1否则为0,那么每个子集都对应一个独一无二的n位二进制数了。
以n=3时的集合为例,此时有
\[A = \left \{ x_0,x_1,x_2 \right \} \]则其子集与二进制数对应关系如下
子集 | 空集 | x0 | x1 | x0,x1 | x2 | x2,x0 | x2,x1 | x2,x1,x0 |
---|---|---|---|---|---|---|---|---|
二进制数 | 000 | 001 | 010 | 011 | 100 | 101 | 110 | 111 |
此外我们还需要知道:
- 位运算
1<<n //该位运算等价于2的n次幂
- 与运算
此处与运算仅举例,当两个二进制数位数不一致则再前面补0然后对应位置做与运算
接下来我们以打印集合{0,1,2,..,n-1}的子集为例,则有如下函数
void print_subset(int n)
{
for (int i = 0; i < (1 << n); i++) //从000到2^n-1对应的二进制,一共2^n个子集
{
//将每个子集都取出,对应数字i
//在子集i的情况下,不断尝试取元素个数为j,其中每个元素的值跟其序号相同都为j
for (int j = 0; j < n; j++) //如果子集i中含多个数,此处的for则打印多个数,若i仅1个数此处for也只寻找并打印仅含的这一个数j
if (i & (1 << j)) //将2的j次幂转化为二进制形式,通过与运算,去测试仅包含一个十进制数j的集合是不是该子集的子集
cout << j << " "; //如果是,则说明子集i中包含十进制数j,此时打印j。
cout << endl;
}
}
那么基础知识已经介绍完了,我们回到情况三,对照子集对应二进制数的方法。我们知道一个子集对应唯一的一个二进制数,那么一个有k个元素的子集它对应二进制数一定有k个1。所以找查子集的问题就变成了找查含k个1的二进制数。
这里介绍个技巧(kk为一个名为kk的二进制数)
kk = kk & (kk-1)
它可以跳过0消除数中最后一个1,这样我们执行此操作的次数就是二进制数中含1的个数
则针对情况三有如下代码
//打印n个数中取m个数的组合
void print_set(int n, int m)
{
for (int i = 0; i < (1 << n); i++)
{
int cnt = 0, kk = i;
while (kk)
{
kk = kk & (kk - 1);
cnt++;
}
if (cnt == m)
{
for (int j = 0; j < n; j++) //此处跟上方print_subset函数同理
if (i & (1 << j))
cout << j << " ";
cout << endl;
}
}
}
参考
- 罗永军,郭卫斌. 算法竞赛入门到进阶[M]. 北京 : 清华大学出版社,2019.