转自:http://fengchangjian.com/?p=1063
所谓全排列,就是将集合中元素的所有排列情况依次输出。比如{1、2、3}的全排列为:123、132、213、231、312、321,共6种,满足计算公式N!(N为集合中元素个数,不重复)。
当元素不重复时,全排列采用递归思想较容易实现,它的递归公式推导步骤类似: 1、要求得123的全排列,只需求得:1并上23的全排列(1 23, 1 32),2并上13的全排列(2 13, 2 31),3并上12的全排列(3 12 321)。 2、对于23的全排列,只需求得2并上3的全排列,3并上2的全排列。步骤1中13、12的全排列也类似。 3、对于3的全排列或者2的全排列,就是它们的本身。递归结束。
递归实现不重复元素全排列算法的实现代码(C++)如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//交换a和b
void Swap( int * a, int * b)
{
int t = * a;
* a = * b;
* b = t;
}
//全排列函数。list:待排元素列表,start:起始位置下标,end:最后一个有效元素的下一个下标。
void Permutation( int start, int end, int list[ ] )
{
int i;
if ( start >= end) //递归结束,打印当前这次全排列结果,返回。
{
for ( i = 0 ; i < end; i++ )
{
printf ( "%d " , list[ i] ) ;
}
printf ( "\n " ) ;
return ;
}
//对于给定的list[start...end],要使区间中每一个元素都有放在第一位的机会,
//然后开始递归调用自身,得到list[start+1...end]的全排列。
for ( i = start; i < end; i++ )
{
Swap( & list[ i] , & list[ start] ) ; //交换元素,使每一个元素都有放在第一位的机会。
Permutation( start+ 1 , end, list) ; //递归调用
Swap( & list[ i] , & list[ start] ) ; //恢复原始的list,不影响下次递归调用。
}
}
上述程序的调用方法为:
1
2
3
4
5
6
7
8
9
#include <iostream>
using namespace std;
int main( )
{
int a[ ] = { 1 , 2 , 3 } ;
Permutation( 0 , 2 , a) ;
return 0 ;
}
完整程序见附件:Permutation.cpp 。
当待排元素列表含有重复项时,上述算法就需要改进,其中一种方法可以是维护一个存放不重复排列的集合,每次新生成1个排列,如果集合中不存在这个排列,则插入排列,否则,放弃。
要实现含重复元素的全排列算法,可以参考STL中next_premutation()函数的实现方法(在algorithm.h中声明)。该函数会将列表中元素按字典序(wiki )给出全排列中的下一个排列,它的实现算法为: 令当前排列为P(0)P(1)P(2)...P(n-1)P(n)。则求它下一个排列的过程为, 1、从后往前遍历,找到第一个P(i)>P(i-1)的元素,记录下标i。比如排列1、5、2、4、3中满足条件的元素为4,记下它的下标i = 3,因为P(i)是4,P(i-1)是2,满足P(i)>P(i-1)。如果找不到这样的i,则表示该序列已经是字典序中的最后一个序列,结束算法。 2、从后往前遍历,找到第一个P(j)>P(i-1)的数,记录下标k。还是上面这个例子,P(i-1)为2,从后往前第一个大于P(i-1)是P(4)=3,因此记录下j=4。 3、互换P(i-1)和P(j),得到新序列1、5、3、4、2。 4、将P[i...n]间的元素逆置,返回序列。上述例子中为逆置4和2,得到最终的序列1、5、3、2、4。
用比较通俗的例子解释一下上述步骤: 假设现在有一个序列4、6、5、3、2、1,要求得字典序的下一个序列。首先,从后往前找到第一个i,使得P(i)>P(i-1),明显这里i是1,P(i)=6,这个意思是,在6之后的元素,都是按值递减的,否则第一步求i的时候也不会找到第2个元素6才满足条件。现在知道,从i开始到最后,其实是字典序里的最大序列了(一直按值递减)。第二步,拿出i的前一个元素P(i-1)=4,将它与原序列从后往前第一个大于它的元素交换位置,这里这个与4交换的元素是5,这样序列就变成了5、6、4、3、2、1,至此,最高位升了一级(4->5),接着要把低位的从最大变成最小(就像199之后是200,最高为从1变成2后,要把低位从最大99变成最小00),这里的低位是最大序列6、4、3、2、1,变成最小序列只需逆置即可,变成1、2、3、4、6,原序列变为5、1、2、3、4、6,即为所求。
实现代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
/**
*如果存在当前序列在字典序中的下一个排列,则返回true,
*否则返回false。
*/
bool next_premutation( int list[ ] , int length)
{
int i, j;
//步骤1:得到i。
for ( i = length - 1 ; i > 0 ; i-- )
{
if ( list[ i] > list[ i- 1 ] )
{
break ; //记下下标i。
}
}
if ( i <= 0 )
{
//表示当前排列已经是字典序中的最后一个序列,没有下一个了。
return false ;
}
//步骤2:得到j。
for ( j = length - 1 ; j > 0 ; j-- )
{
if ( list[ j] > list[ i- 1 ] )
{
break ; //记下下标j。
}
}
//步骤3:互换list[i-1]和list[j]。
int temp = list[ i- 1 ] ;
list[ i- 1 ] = list[ j] ;
list[ j] = temp;
//步骤4:逆置list[i...n]。
int start, end;
for ( start = i, end = length- 1 ; start < end; start++ , end-- )
{
int temp = list[ start] ;
list[ start] = list[ end] ;
list[ end] = temp;
}
return true ;
}
采用这种方法要获得一个集合的全排列,可按下面方法调用(和stl函数next_permutation()的调用方法基本一致):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
using namespace std;
int main( )
{
int list[ ] = { 1 , 2 , 3 , 4 , 5 } ;
do
{
for ( int i = 0 ; i < 5 ; i++ )
{
printf ( "%d " , list[ i] ) ;
}
printf ( "\n " ) ;
} while ( next_premutation( list, 5 ) ) ;
return 0 ;
}
完整程序见附件:STL_Premutation.cpp 。
两种算法的运行效率如下,这里比的是以上两种全排列实现算法之间的相对效率,不考虑硬件等因素,为了抹去I/O带来性能消耗,测试程序中已经打印部分代码(这部分开销很致命)注释,以下是在虚拟机环境下测得的对集合{1、2、3、4、5、6、7、8、9、10}的全排列计算时间:
1
2
3
4
5
6
7
8
9
10
me@ubuntu:~/premutation$ time ./Premutation
real 0m0.227s
user 0m0.212s
sys 0m0.012s
me@ubuntu:~/premutation$ time ./STL_Premutation
real 0m0.105s
user 0m0.096s
sys 0m0.004s
由此可见,相比递归方法(耗时0m0.227s),第二种方法更加高效(耗时real 0m0.105s),还能防重复。其实想想也能理解,第二种实现方法与STL的next_permutation()基本一致,理论上应该是接近最优解了吧。但是,采用第二种方法计算全排列也有一个前提,就是这个待排序列必须是字典序中的最小数,因此,需要在循环调用next_premutation()前将序列排序,否则只能得到当前序列之后的"全"排列。