7.2 枚举排列
有没有想过如何打印所有排列呢?输入整数n,按字典序从小到大的顺序输出前n个数的所有排列。前面讲过,两个序列的字典序大小关系等价于从头开始第一个不相同位置处的大小关系。例如,(1,3,2) < (2,1,3),字典序最小的排列是(1, 2, 3, 4,…, n),最大的排列是(n, n-1, n-2,…, 1)。n=3时,所有排列的排序结果是(1, 2, 3)、(1, 3, 2)、(2, 1, 3)、(2, 3, 1)、(3, 1, 2)、(3, 2, 1)。
7.2.1 生成1~n的排列
我们尝试用递归的思想解决:先输出所有以1开头的排列(这一步是递归调用),然后输出以2开头的排列(又是递归调用),接着是以3开头的排列……最后才是以n开头的排列。
以1开头的排列的特点是:第一位是1,后面是2~9的排列。根据字典序的定义,这些2~9的排列也必须按照字典序排列。换句话说,需要“按照字典序输出2~9的排列”,不过需注意的是,在输出时,每个排列的最前面要加上“1”。这样一来,所设计的递归函数需要以下参数:
这样可得到一个伪代码:
void print_permutation(序列A, 集合S)
{
if(S为空) 输出序列A;
else 按照从小到大的顺序依次考虑S的每个元素v
{
print_permutation(在A的末尾填加v后得到的新序列, S-{v});
}
}
暂时不用考虑序列A和集合S如何表示,首先理解一下上面的伪代码。递归边界是S为空的情形,这很好理解:现在序列A就是一个完整的排列,直接输出即可。接下来按照从小到大的顺序考虑S中的每个元素,每次递归调用以A开头。
下面考虑程序实现。不难想到用数组表示序列A,而集合S根本不用保存,因为它可以由序列A完全确定——A中没有出现的元素都可以选。C语言中的函数在接受数组参数时无法得知数组的元素个数,所以需要传一个已经填好的位置个数,或者当前需要确定的元素位置cur,代码如下:
void print_permutation(int n, int* A, int cur) {
if(cur == n) { //递归边界
for(int i = 0; i < n; i++) printf("%d ", A[i]);
printf("\n");
}
else for(int i = 1; i <= n; i++) { //尝试在A[cur]中填各种整数i
int ok = 1;
for(int j = 0; j < cur; j++)
if(A[j] == i) ok = 0; //如果i已经在A[0]~A[cur-1]出现过,则不能再选
if(ok) {
A[cur] = i;
print_permutation(n, A, cur+1); //递归调用
}
}
}
循环变量i是当前考察的A[cur]。为了检查元素i是否已经用过,上面的程序用到了一个标志变量ok,初始值为1(真),如果发现有某个A[j]==i时,则改为0(假)。如果最终ok仍为1,则说明i没有在序列中出现过,把它添加到序列末尾(A[cur]=i)后递归调用。
声明一个足够大的数组A,然后调用print_permutation(n, A, 0),即可按字典序输出1~n的所有排列。
7.2.2 生成可重集的排列
如果把问题改成:输入数组P,并按字典序输出数组A各元素的所有全排列,则需要对上述程序进行修改——把P加到print_permutation的参数列表中,然后把代码中的if(A[j] == i)和A[cur] = i分别改成if(A[j] == P[i])和A[cur] = P[i]。这样,只要把P的所有元素按从小到大的顺序排序,然后调用print_permutation(n, P, A, 0)即可。
这个方法看上去不错,可惜有一个小问题:输入1 1 1后,程序什么也不输出(正确答案应该是唯一的全排列1 1 1),原因在于,这样禁止A数组中出现重复,而在P中本来就有重复元素时,这个“禁令”是错误的。
一个解决方法是统计A[0]~A[cur-1]中P[i]的出现次数c1,以及P数组中P[i]的出现次数c2。只要c1<c2,就能递归调用。
else for(int i = 0; i < n; i++) {
int c1 = 0, c2 = 0;
for(int j = 0; j < cur; j++) if(A[j] == P[i]) c1++;
for(int j = 0; j < n; j++) if(P[i] == P[j]) c2++;
if(c1 < c2) {
A[cur] = P[i];
print_permutation(n, P, A, cur+1);
}
}
结果又如何呢?输入1 1 1,输出了27个1 1 1。遗漏没有了,但是出现了重复:先试着把第1个1作为开头,递归调用结束后再尝试用第2个1作为开头,递归调用结束后再尝试用第3个1作为开头,再一次递归调用。可实际上这3个1是相同的,应只递归1次,而不是3次。
换句话说,我们枚举的下标i应不重复、不遗漏地取遍所有P[i]值。由于P数组已经排过序,所以只需检查P的第一个元素和所有“与前一个元素不相同”的元素,即只需在“for(i = 0; i < n; i++)”和其后的花括号之前加上“if(!i || P[i] != P[i-1])”即可。
至此,结果终于正确了。
7.2.3 解答树
假设n=4,序列为{1,2,3,4},如图7-1所示的树显示出了递归函数的调用过程。其中,结点内部的序列表示A,位置cur用高亮表示,另外,由于从该处开始的元素和算法无关,因此用星号表示。
图7-1 排列生成算法的解答树
这棵树和前面介绍过的二叉树不同。第0层(根)结点有n个子结点,第1层结点各有n-1个子结点,第2层结点各有n-2个子结点,第3层结点各有n-3个子结点,……,第n层结点都没有子结点(即都是叶子),而每个叶子对应于一个排列,共有n!个叶子。由于这棵树展示的是从“什么都没做”逐步生成完整解的过程,因此将其称为解答树。
提示7-2:如果某问题的解可以由多个步骤得到,而每个步骤都有若干种选择(这些候选方案集可能会依赖于先前作出的选择),且可以用递归枚举法实现,则它的工作方式可以用解答树来描述。
这棵解答树一共有多少个结点呢?可以逐层查看:第0层有1个结点,第1层n个,第2层有n*(n-1)个结点(因为第1层的每个结点都有n-1个结点),第3层有n*(n-1)*(n-2)个(因为第2层的每个结点都有n-2个结点),……,第n层有n*(n-1)*(n-2)*…*2*1=n!个。
下面把它们加起来。为了推导方便,把n*(n-1)*(n-2)*…*(n-k)写成n!/(n-k-1)!,则所有结点之和为:
根据高等数学中的泰勒展开公式,
,因此
。由于叶子有n!个,倒数第二层也有n!个结点,因此上面的各层全部加起来也不到n!。这是一个很重要的结论:在多数情况下,解答树上的结点几乎全部来源于最后一两层。和它们相比,上面的结点数可以忽略不计。
不熟悉泰勒展开公式也没有关系:可以写一个程序,输出
随着n增大时的变化,并发现它能很快收敛。这就是计算机的优点之一——可以通过模拟避开数学推导。即使无法严密而精确地求解,也可以找到令人信服的实验数据。
7.2.4 下一个排列
枚举所有排列的另一个方法是从字典序最小排列开始,不停调用“求下一个排列”的过程。如何求下一个排列呢?C++的STL中提供了一个库函数next_permutation。看看下面的代码片段,就会明白如何使用它了。
#include<cstdio>
#include<algorithm> //包含next_permutation
using namespace std;
int main( ) {
int n, p[10];
scanf("%d", &n);
for(int i = 0; i < n; i++) scanf("%d", &p[i]);
sort(p, p+n); //排序,得到p的最小排列
do {
for(int i = 0; i < n; i++) printf("%d ", p[i]); //输出排列p
printf("\n");
} while(next_permutation(p, p+n)); //求下一个排列
return 0;
}
需要注意的是,上述代码同样适用于可重集。
提示7-3:枚举排列的常见方法有两种:一是递归枚举,二是用STL中的next_permutation。