1. 生成{1, 2, …, n}的排列
由数学公式 A n n = n ! A_n^n=n! Ann=n!可知,{1, 2, …, n}的排列共n!种可能,当n取3时,3!=6,而当n取6时,6!=720。下面讨论如何按字典序升序输出{1, 2, …, n}的所有排列。
考虑用递归的思想,下面以n=3为例分析。大致的思路是:
①将最高位从1~n进行升序枚举
②然后将剩下的元素集合进行排列
③递归调用①②
这里需要重点考虑的是递归的参数:
①由于集合的取值为1-n,所以需要将n传入递归函数
②已经生成排列的部分序列
③当前递归的深度,或者说是已经处理的的位数或者剩下的位数(可以通过②中的序列元素数量进行推测,因此本参数可以省略。)
伪代码如下:
void my_permutation(n,序列S){
if(S.size()==n) 输出S;//递归终点(序列中已包含所有元素)
else{
for(int i=1; i<=n; i++){// 升序枚举下一位的元素
if(i不在S中) my_permutatoin(n, 序列S后面加元素i) //递归调用
}
}
}
例如:假如当前序列为"1",首先判断序列的长度是否为n,如果是则输出,否则升序枚举下一位为{2, 3},也就是将"12"、"13"传入函数中进行递归,直到当前序列的长度为n时停止递归。示意图如下,当前的过程即下图中虚线框中的部分。
下面给出示例代码(本文均为C++风格代码,如果不喜欢C++可参见文末附录中的C代码):
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
void my_permutation(int n, vector<int> vec){
if(n == vec.size()){ // 如果vec中已经包含1, 2, ...n的所有元素,则为递归中止条件
for(auto i : vec) cout << i;
cout << endl;
}else{
for(int i=1; i<=n; i++){ // 升序枚举当前位
if(find(vec.begin(), vec.end(), i) == vec.end()){
vec.push_back(i);
my_permutation(n, vec);
vec.pop_back(); // 恢复
}
}
}
}
int main(){
vector<int> vec;
my_permutation(3, vec);
return 0;
}
输出结果为:
123
132
213
231
312
321
注意:该例题虽然非常简单,但是也非常的重要!
2. 生成含重复元素的排列
上例中待排列的集合为最简单的{1, 2, …, n},其中不含重复的元素。如果我们给定一个重复的元素集合如{1, 2, 2, 3, 3, 4}则无法得到正确的结果,因为上述代码中通过判断已生成序列中是否含有某元素来进行枚举的,重复的元素会被跳过。
因此我们需要将判断条件:
if(find(vec.begin(), vec.end(), i) == vec.end()){
}
改为:
if(vec.count(S[i] < S.count(S[i]){
}
其中,S是要进行排列的全部元素集合(含重复值)。
修改完成的代码如下:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
void my_permutation(vector<int> vec, vector<int> S){
if(S.size() == vec.size()){ // 如果vec中已经包含1, 2, ...n的所有元素,则为递归中止条件
for(auto i : vec) cout << i;
cout << endl;
}else{
for(int i=0; i<S.size(); i++){
if(count(vec.begin(), vec.end(), S[i]) < count(S.begin(), S.end(), S[i])){
vec.push_back(S[i]);
my_permutation(vec, S);
vec.pop_back(); // 恢复
}
}
}
}
int main(){
vector<int> vec;
vector<int> S = {1, 2, 2, 3, 3, 4};
my_permutation(vec, S);
return 0;
}
截取部分输出如下:
122334
122334
122343
122343
122334
122334
122343
122343
122433
122433
122433
可以看到,出现了许多的重复值,这是因为在枚举的时候对于相同的元素进行了多次的枚举。因此我们需要再进行一些改动,使得相同元素在一次递归中仅枚举一次。这里在枚举循环中加入以下判断即可:
if(!i || (S[i]!=S[i-1])){
}
最终的代码如下:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
void my_permutation(vector<int> vec, vector<int> S){
if(S.size() == vec.size()){ // 如果vec中已经包含1, 2, ...n的所有元素,则为递归中止条件
for(auto i : vec) cout << i;
cout << endl;
}else{
for(int i=0; i<S.size(); i++){
if(!i || (S[i]!=S[i-1])){
if(count(vec.begin(), vec.end(), S[i]) < count(S.begin(), S.end(), S[i])){
vec.push_back(S[i]);
my_permutation(vec, S);
vec.pop_back(); // 恢复
}
}
}
}
}
int main(){
vector<int> vec;
vector<int> S = {1, 2, 2, 3, 3, 4};
my_permutation(vec, S);
return 0;
}
截取部分输出如下:
122334
122343
122433
123234
123243
123324
123342
123423
123432
可以看到,重复值已经没有了,得到了我们想要的结果。
3. 解答树
前面对于过程的描述,我们给出了一种这样的树形图:
它描述了我们递归枚举生成排列的过程,树的每一层依次为每一位的的枚举过程,而树的每一个分支表示不同的排列情况,树的叶子结点即为所有的解。对于这样的树,我们称之为解答树。
仔细观察便可以得出,递归求解的过程实际上是对解答树进行深度优先搜索(DFS)的过程,当搜索到叶子结点(也就是递归终点)的时候即找到一个解,而所有的叶子结点的集合即为解空间。
下面对解答树的结点数进行计算:
第0层:1
第1层:
C
n
1
=
n
C_n^1=n
Cn1=n
第2层:
C
n
1
⋅
C
n
−
1
1
=
n
(
n
−
1
)
C_n^1·C_{n-1}^1=n(n-1)
Cn1⋅Cn−11=n(n−1)
…
第k层:
C
n
1
⋅
C
n
−
1
1
.
.
.
C
n
−
(
k
−
1
)
1
=
n
(
n
−
1
)
.
.
.
(
n
−
(
k
−
1
)
)
=
n
!
(
n
−
k
)
!
C_n^1·C_{n-1}^1...C_{n-(k-1)}^1=n(n-1)...(n-(k-1))=\frac{n!}{(n-k)!}
Cn1⋅Cn−11...Cn−(k−1)1=n(n−1)...(n−(k−1))=(n−k)!n!
…
第n-1层:
C
n
1
⋅
C
n
−
1
1
.
.
.
C
2
1
=
n
(
n
−
1
)
.
.
.
2
=
n
!
C_n^1·C_{n-1}^1...C_{2}^1=n(n-1)...2=n!
Cn1⋅Cn−11...C21=n(n−1)...2=n!
第n层:
C
n
1
⋅
C
n
−
1
1
.
.
.
C
2
1
=
n
(
n
−
1
)
.
.
.
2
=
n
!
C_n^1·C_{n-1}^1...C_{2}^1=n(n-1)...2=n!
Cn1⋅Cn−11...C21=n(n−1)...2=n!
求和可得:
T
(
n
)
=
∑
k
=
0
n
n
!
(
n
−
k
)
!
=
n
!
∑
k
=
0
n
1
(
n
−
k
)
!
\displaystyle T(n)=\sum_{k=0}^n\frac{n!}{(n-k)!}=n!\sum_{k=0}^n\frac{1}{(n-k)!}
T(n)=k=0∑n(n−k)!n!=n!k=0∑n(n−k)!1
下面证明
lim
n
→
+
∞
∑
k
=
0
n
1
(
n
−
k
)
!
=
∑
n
=
0
+
∞
1
n
!
=
e
\displaystyle \lim_{n\rightarrow+\infty}\sum_{k=0}^n\frac{1}{(n-k)!}=\sum_{n=0}^{+\infty}\frac{1}{n!}=e
n→+∞limk=0∑n(n−k)!1=n=0∑+∞n!1=e
证明:令 f ( x ) = e x f(x)=e^x f(x)=ex
f ( x ) f(x) f(x)在x=0处进行泰勒展开:
f ( x ) = 1 + x 1 ! + x 2 2 ! + . . . + x n n ! = e x f(x)=1+\frac{x}{1!}+\frac{x^2}{2!}+...+\frac{x^n}{n!}=e^x f(x)=1+1!x+2!x2+...+n!xn=ex
取x=1,则:
e 1 = e = 1 + 1 1 ! + 1 2 ! + . . . + 1 n ! = ∑ k = 0 n 1 k ! e^1=e=1+\frac{1}{1!}+\frac{1}{2!}+...+\frac{1}{n!}=\sum_{k=0}^{n}\frac{1}{k!} e1=e=1+1!1+2!1+...+n!1=k=0∑nk!1
因此
T
(
n
)
=
e
⋅
n
!
T(n)=e·n!
T(n)=e⋅n!
由于最后两层的结点数均为
n
!
n!
n!,前n+1-2层结点数之和为
S
(
n
)
=
e
⋅
n
!
−
2
⋅
n
!
=
(
e
−
2
)
n
!
<
n
!
S(n)=e·n!-2·n!=(e-2)n!<n!
S(n)=e⋅n!−2⋅n!=(e−2)n!<n!
因此,前n-1层结点数之和S(n)小于最后两层任意一层的结点数
综上,在解答树中结点数主要集中在最后两层,前面各层的结点数几乎可以忽略不计。
总结
本文重点介绍了利用递归枚举的方法来生成集合的排列,首先以一个较为简单的例子来描述递归枚举的过程,然后进一步解决了含重复元素的排列问题,最后通过解答树来解释了递归求解的过程,这对理解递归是非常重要的。此外,对许多问题的求解实际上可以理解为对解答树的深度优先搜索,通过遍历解答树的所有结点来求解问题是一种很常用且很实用的方法。当然,对解答树中可能存在无解的分支,或者说是重复的分支,这就需要我们通过一定的技巧来改善我们遍历解答树的方法从而提升算法的效率。