有序全排列生成算法
作者:goal00001111(高粱)
写本文的动力来自一个NOI题目:输出n个数的第m种全排列。
输入两个自然数m,n 1<=n<=20,1<=m<=n!
输出n个数的第m种全排列。
要解决这个问题,必须要生成有序的全排列。
生成n个数字的全排列是算法学习中的一个经典案例,也是信息学奥赛中的一个常考内容,值得我们去深入研究。生成全排列的算法很多,大概分类有直接模拟法,设置中介数法和数学分析法(这是我杜撰的一个名称),其中直接模拟法又可以分为递归和非递归模拟。设置中介数后,更是可以分为字典序全排列生成法,递增进位排列生成算法,递减进位排列生成算法和循环左移排列生成算法等类别。此外还有邻位对换法和邻元素增值法等另类生成方法。利用这些算法生成的全排列,有些是有序全排列,有些却是无序的,本文主要探讨有序全排列。
在上面列举的算法中,字典序全排列生成法和邻元素增值法,以及我杜撰的数学分析法可以生成有序全排列,即可以输出n个数的第m种全排列。其中字典序全排列生成法根据是否设置中介数又可以分为两类,不用设置中介数的方法即直接模拟法。
我们首先来看最简单的递归直接模拟法。这是普通递归方法的一个改进。普通的递归方法是利用将当前数组左界元素与其右侧的元素交换位置来实现排列,这样得到的全排列是无序的。所以我们不能直接调换位置,而是将左界右侧的元素顺序右移,不破坏原排列的顺序,保证右侧元素的递增性。
为了回答本文开头提出的问题,我们统一设置一个接口void Permutation(long long n, long long m);其中n表示共n个自然数,m表示第m种全排列。
在子程序中我们先创建一个数组a[n]来存储n个自然数,然后递归查找并输出n个数的第m种全排列。下面是主要的代码:(完整的代码附在文章后面,下同)
/*
函数名称:Permutation
函数功能:输出n个数的第m种全排列
输入变量:long long n:1,2,3,...,n共n个自然数(1<=n<=20)
long long m:第m种全排列(1<=m<=n!)
输出变量:无
*/
void Permutation(long long n, long long m)// 递归直接模拟法
{
long long *a = new long long[n];//用来存储n个自然数
for (int i=0; i<n; i++)
{
a[i] = i + 1;
}
long long s = 0;//记录当前是第几个全排列
Try(a, 0, n-1, m, s);//递归查找并输出n个数的第m种全排列
delete []a;
}
/*
函数名称:Try
函数功能:递归查找并输出n个数的第m种全排列 ,找到第m种全排列返回true,否则返回false
输入变量:long long a[]:用来存储n个自然数,按照第m种全排列存储
int left:数组的左界
int right:数组的右界
long long m:第m种全排列(1<=m<=n!)
long long & s:记录当前是第几个全排列
输出变量:找到第m种全排列返回true,否则返回false
*/
bool Try(long long a[], int left, int right, long long m, long long & s)
{
if (left == right)//已经到达数组的右界,直接输出数组
{
s++;
if (s == m)//找到第m种全排列
{
cout << s << ": ";
for (int i=0; i<=right; i++)
{
cout << a[i] << ' ';
}
cout << endl;
return true;
}
}
else
{ //将当前最左边的元素与其后面的元素交换位置
//当i=left时,与自身交换,相当于不换,直接输出
for (int i=left; i<=right; i++)
{//这是与其他递归算法所不同的地方,其他算法是Swap(a[left],a[i]);
MoveRight(a, left, i);
if (Try(a, left+1, right, m, s))//左界右移一位,递归寻找第m种全排列
{
return true;
}
MoveLeft(a, left, i);//再换回来
}
}
return false;
}
在递归函数Try中,我们每次只分析left与right之间的元素,并不断将left右移,当left==right时,一个全排列被生成。
在这里我们用void MoveRight(long long a[], int left, int right)代替普通递归算法中的void Swap(long long a[], int left, int right);函数功能是将left与right之间的元素按顺序右移,而right位置的元素左移到left位置。这样可以保证left+1与right之间的元素按增序排列。
我在这里先简单的介绍子函数MoveRigh和MoveLeft的功能,而把重点放在对主要功能函数Permutation和Try的理解上。函数的完整代码我放在了文章后面,有兴趣的读者也可以自己实现。以下的内容也采用这种方法。
递归直接模拟法算法简单明了,当m较小的时候还是很实用的(与n的大小关系不大),我的测试结果是当n = 100,m=10000000时,用时为1秒;当n = 1000,m=10000000时,用时为2秒;当n = 10000,m=10000000时,用时为3秒。
比递归直接模拟法速度更快的是循环直接模拟法,用循环代替递归,可以大大的减小开销,提高速度。循环直接模拟法又叫字典序生成算法,以下有关字典序生成算法的文字转载自网友visame的专栏。
字典序生成算法:
设P是1~n的一个全排列:p = p1p2...pn = p1p2...pj-1pjpj+1......pk-1pkpk+1...pn
1)从排列的右端开始,找出第一个比右边数字小的数字的序号j(j从左端开始计算),即j = max{i | pi < pi+1}
2)在pj的右边的数字中,找出所有比pj大的数中最小的数字pk,即 k = max{i | pj < pi}
(右边的数从右至左是递增的,因此k是所有大于pj的数字中序号最大者)
3)对换pj,pk
4)再将pj+1......pk-1pkpk+1...pn倒转得到排列p' = p1p2...pj-1pjpn......pk+1pkpk-1...pj+1,
这就是排列p的下一个排列。
例如83964