全排列算法解析完整版.docx

上传人:b****7 文档编号:11517668 上传时间:2023-03-02 格式:DOCX 页数:28 大小:37.27KB
下载 相关 举报
全排列算法解析完整版.docx_第1页
第1页 / 共28页
全排列算法解析完整版.docx_第2页
第2页 / 共28页
全排列算法解析完整版.docx_第3页
第3页 / 共28页
全排列算法解析完整版.docx_第4页
第4页 / 共28页
全排列算法解析完整版.docx_第5页
第5页 / 共28页
点击查看更多>>
下载资源
资源描述

全排列算法解析完整版.docx

《全排列算法解析完整版.docx》由会员分享,可在线阅读,更多相关《全排列算法解析完整版.docx(28页珍藏版)》请在冰豆网上搜索。

全排列算法解析完整版.docx

全排列算法解析完整版

全排列以及相关算法

在程序设计过程中,我们往往要对一个序列进行全排列或者对每一个排列进行分析。

全排列算法便是用于产生全排列或者逐个构造全排列的方法。

当然,全排列算法不仅仅止于全排列,对于普通的排列,或者组合的问题,也可以解决。

本文主要通过对全排列以及相关算法的介绍和讲解、分析,让读者更好地了解这一方面的知识,主要涉及到的语言是C和C++。

本文的节数:

1.全排列的定义和公式:

2.时间复杂度:

3.列出全排列的初始思想:

4.从第m个元素到第n个元素的全排列的算法:

5.全排列算法:

6.全排列的字典序:

7.求下一个字典序排列算法:

8.C++STL库中的next_permutation()函数:

(#include

9.字典序的中介数,由中介数求序号:

10.由中介数求排列:

11.递增进位制数法:

12.递减进位制数法:

13.邻位对换法:

14.邻位对换法全排列:

15.邻位对换法的下一个排列:

16.邻位对换法的中介数:

17.组合数的字典序与生成:

由于本文的,内容比较多,所以希望读者根据自己的要求阅读,不要一次性读完,有些章节可以分开读。

第1节到第5节提供了全排列的概念和一个初始的算法。

第6节到第8节主要讲述了字典序的全排列算法。

第9到第10节讲了有关字典序中中介数的概念。

第11到第12节主要介绍了不同的中介数方法,仅供扩展用。

第13节到15节介绍了邻位对换法的全排的有关知识。

16节讲了有关邻位对换法的中介数,仅供参考。

第17节讲了组合数生成的算法。

1.全排列的定义和公式:

从n个数中选取m(m<=n)个数按照一定的顺序进行排成一个列,叫作从n个元素中取m个元素的一个排列。

由排列的定义,显然不同的顺序是一个不同的排列。

从n个元素中取m个元素的所有排列的个数,称为排列数。

从n个元素取出n个元素的一个排列,称为一个全排列。

全排列的排列数公式为n!

,通过乘法原理可以得到。

2.时间复杂度:

n个数(字符、对象)的全排列一共有n!

种,所以全排列算法至少时O(n!

)的。

如果要对全排列进行输出,那么输出的时间要O(n*n!

),因为每一个排列都有n个数据。

所以实际上,全排列算法对大型的数据是无法处理的,而一般情况下也不会要求我们去遍历一个大型数据的全排列。

3.列出全排列的初始思想:

解决一个算法问题,我比较习惯于从基本的想法做起,我们先回顾一下我们自己是如何写一组数的全排列的:

1,3,5,9(为了方便,下面我都用数进行全排列而不是字符)。

1,3,5,9.(第一个)

首先保持第一个不变,对3,5,9进行全排列。

同样地,我们先保持3不变,对5,9进行全排列。

保持5不变,对9对进行全排列,由于9只有一个,它的排列只有一种:

9。

接下来5不能以5打头了,5,9相互交换,得到

1,3,9,5.

此时5,9的情况都写完了,不能以3打头了,得到

1,5,3,9

1,5,9,3

1,9,3,5

1,9,5,3

这样,我们就得到了1开头的所有排列,这是我们一般的排列数生成的过程。

再接着是以3、5、9打头,得到全排列。

这里还要注意的一点是,对于我们人而言,我们脑子里相当于是储存了一张表示原有数组的表,1,3,5,9,1开头的所有排列完成后,我们选择3开头,3选完了之后,我们选择5开头,而不会再返过来选1,而且知道选到9之后结束,但对于计算机而言,我们得到了3,5,1,9后,可能再次跳到1当中,因为原来数组的顺序它已经不知道了,这样便产生了错误。

对于算法的设计,我们也可以维护这样一个数组,它保存了原始的数据,这是一种方法。

同时我们还可以再每次交换后再交换回来,变回原来的数组,这样程序在遍历的时候便不会出错。

读者可以练习一下这个过程,思考一下你是如何进行全排列的,当然,你的方法可能和我的不太一样。

我们把上面全排列的方法归纳一下,基本上就是:

任意选一个数(一般从小到大或者从左到右)打头,对后面的n-1个数进行全排列。

聪明的读者应该已经发现,这是一个递归的方法,因为要得到n-1个数的全排列,我们又要先去得到n-2个数的全排列,而出口是只有1个数的全排列,因为它只有1种,为它的本身。

写成比较规范的流程:

1.开始for循环。

2.改变第一个元素为原始数组的第一个元素(什么都没做)。

3.求第2个元素到第n个元素的全排列。

4.要求第2个元素到第n个元素的全排列,要递归的求第3个元素到第n个元素的全排列。

......

5.直到递归到第n个元素到第n元素的全排列,递归出口。

6.将改变的数组变回。

7.改变第一个元素为原始数组的第二个元素。

(注:

理论上来说第二次排列时才改变了第一个元素,即第6步应该此时才开始执行,但由于多执行一次无义的交换影响不大,而这样使得算法没有特殊情况,更容易读懂,如果一定要省时间可以把这步写在此处,这种算法我在下文中便不给出了,读者可以自己写。

5.求第2个元素到第n个元素的全排列。

6.要求第2个元素到第n个元素的全排列,要递归的求第3个元素到第n个元素的全排列。

......

5.直到递归到第n个元素到第n元素的全排列,递归出口。

6.将改变的数组变回。

......

8.不断地改变第一个元素,直至n次使for循环中止。

为了实现上述过程,我们要先得到从第m个元素到第n个元素的排列的算法:

4.从第m个元素到第n个元素的全排列的算法:

voidPermutation(intA[],intm,intn)

{

if(m==n)

{

Print(A);//直接输出,因为前n-1个数已经确定,递归到只有1个数。

return;

}

else

{

for(i=m;i

{

swap(a[m],a[i]);//交换,对应第二步

Permutation(A,m+1,n);//递归调用,对应三至五步

swap(a[m],a[i]);//交换,对应第六步

}

}

为了使代码运行更快,Print函数和swap函数直接写成表达式而不是函数(如果是C++的话建议把swap写成内联函数,把Print写成宏)

 

voidPermutation(intA[],intm,intn)

{

inti,inttemp;

if(m==n)

{

for(i=0;i

{

if(i!

=n-1)

printf("%d",A[i]);//有加空格

else

printf("%d"A[i]);//没加空格

}//直接输出,因为前n-1个数已经确定,递归到只有1个数。

printf("\n");

return;

}

else

{

for(i=m;i

*/

{

temp=A[m];

A[m]=A[i];

A[i]=temp;//交换,对应第二步

Permutation(A,m+1,n);//递归调用,对应三至五步

temp=A[m];

A[m]=A[i];

A[i]=temp;

//交换,对应第六步

}

}

}

这个算法用于列出从第m个元素到第n个元素的所有排列,注意n不一定是指最后一个元素。

算法的复杂度在于for循环和递归,最大的for是n,递归为n-1所以为

O(n*n-1*n-2*...1)=O(n!

).

对上述算法进行封装,便可以得到列出全排列的函数:

5.全排列算法:

voidFull_Array(intA[],intn)

{

Permutation(A,0,n);

}

如果读者仅仅需要一个全排列的递归算法,那么看到上面就可以了。

下面将对全排列的知识进行扩充。

6.全排列的字典序:

字典序的英语一般叫做dictionaryorder,浅显明白。

定义:

对于一个序列a1,a2,a3,a4,a5....an的两个排列b1,b2,b3,b4,b5...bn和c1,c2,c3,c4,,

如果它们的前k(常数)项一样,且c(k+1)>b(k+1),则称排列c位于排列b(关于字典序)的后面。

如1,2,3,4的字典序排在1,2,4,3的前面(k=2),1,3,2,4的字典序在1,2,3,4(k=1)的后面。

下面列出1,2,3按字典序的排列结果:

1,2,3

1,3,2

2,1,3

2,3,1

3,1,2

3,2,1

(有些读者会发现它们手写排列的时候也不自觉得遵照着这个规则以妨漏写,对于计算机也一样,如果有这样习惯的读者的话,那它们的实际算法更适合于表达为下面要讲的算法。

定义字典序的好处在于,排列变得有序了,而我们前面的递归算法的排列是无序的,由同一个序列生成的不同数组(排列)如1,2,3,4和2,3,4,1的输出结果的顺序是不同的,这样便没有统一性,而字典序则可以解决这个问题。

很明显地,对于一个元素各不相同的元素集合,它的每个排列的字典序位置都不相同,有先有后。

接下来讲讲如何求某一个排列的紧邻着的后一个字典序。

对证明不感兴趣的读者只要读下面加色的字即可。

定理:

我们先来构造这样一个在它后面的字典序,再证明这是紧邻它的字典序。

对于一个排列a1,a2,a3...an,如果a(n)>a(n-1),那么a1,a2,a3...a(n),a(n-1)是它后面的字典序,否则,也就是a(n-1)>a(n),此时如果a(n-2)

更一般地,从a(n)开始不断向前找,直到找到a(m+1)>a(m)【如果a(n)

(1)>a

(2)>...a(n),是最大的字典序】,显然后面的序列满足a(m+1)>a(m+2)>...a(n).找到a(m+1)到a(n)中比a(m)大的最小的数,和a(m)交换,并把交换后的a(m+1)到a(n)按照从小到大排序,前m-1项保持不变,得到的显然也是原排列后面的字典序,这个字典序便是紧挨着排列的后一个字典序。

下面证明它是紧挨着的。

1.如果还存在前m-1项和原排列相同并且也在原排列后面的字典序a1,a2,a3...bm,...,bm>原am,假设它在我们构造的字典序前面,那么必有bm<交换后的am,但这是不可能的,因为am是后面序列中大于原来am的最小的一个,而bm必然又是后面序列中的大于am的一个元素,产生了矛盾。

2.如果还存在前前m项和原排列相同并且也在原排列后面的字典序,它不可能在我们构造的字典序前面,因为我们对后面的数进行了升序排列,不存在比a(m+1)还小的数。

3.如果还存在前k项(ka(k+1)[k+1

证明完毕。

证明完成后,我们便可以通过上述的构造方法求得一个排列的下一个字典序排列了。

7.求下一个字典序排列算法:

 

boolNext_Permutation(intA[],intn)

{

inti,m,temp;

for(i=n-2;i>=0;i--)

{

if(A[i+1]>A[i])

break;

}

if(i<0)

returnfalse;

m=i;

i++;

for(;i

if(A[i]<=A[m])

{

i--;

break;

}

swap(A[i],A[m]);

sort(A+m+1,A+n);

Print(A);

returntrue;

}

swap和Print函数读者可以自己写也可以参照我上面的写法,排序我这里直接使用了C++标准库中的sort,读者也可以自己写。

有了这个算法后,我们便可以写一个非递归的列出全排列的方法,而且这个方法还带顺序:

voidFull_Array(intA[],intn)

{

sort(A,A+n);

Print(A);

while(Next_Permutation(A,n));

}

这个算法的时间复杂度为O(n^2+n!

*n)=O(n!

*n)[前面是排序的复杂度,后面是遍历O(n!

)乘以输出n]。

这个算法还有一个好处,那便是它可以处理元素相同的情况,不会重复输出。

(之前的算法会重复,而且要注意的是,这个算法判断句中的>、<有没有=号都是确定的,不能改,否则出现处理相同元素时便会陷入死循环,具体的写法读者可以自己举例判断,看看怎样会进入死循环。

如果要使之前递归的算法不重复,在交换之前要判断相邻着的两个数是否相同,如果相同,则不交换,比如和A[i]交换,要判断A[i]是否等于A[i+1])。

不过这个算法的缺陷是它把原来数组给改变了,读者可以自行在Next_Permutation当中使用int*Array=newint(sizeof(A);或者int*Array=malloc(sizeof(A)),然后把数组拷贝一遍,不对原数组进行处理,那么相应的全排列也要自己改写了,我这里就不写了。

8.C++STL库中的next_permutation()函数:

(#include

幸运的是,C++已经给我们提供了next_permutation模版函数,所以不用自己写,也就不用担心死循环的问题,不过这个函数没有输出,而是直接把数组变成了它的下一个字典序。

下面给出它的源代码:

 

//TEMPLATEFUNCTIONnext_permutation

templateinline

boolnext_permutation(_BI_F,_BI_L)

{

_BI_I=_L;//定义新的迭代器_I并将尾地址赋给它。

if(_F==_L||_F==--_I)//如果首地址等于尾地址或者等于尾地址小1,直接返回false

return(false);//要注意是因为我们传递的是尾地址加1(A+n=A[n]的地址),这个判断主要是考虑边界问题。

for(;;)//死循环,用于找到a(m+1)

{

_BI_Ip=_I;//定义迭代器_Ip并将_I赋给它。

if(*--_I<*_Ip)//这里在比较a(m+1)和a(m)的大小,没找到则到下一个循环。

如果//找到,进入条件句,由于是用了--运算符,所以得到的实际上是_Ip,也即a(m)。

{

_BI_J=_L;//定义新的迭代器_J并将尾地址赋给它,相当于从结尾开始//找。

前面我的算法是从a(m+1)开始往后找,理论上从结尾开始找比较好,建议读者写的时//候也从结尾往前找。

for(;!

(*_I<*--_J);)//循环,直到找到_I<_J,由于是减减,所以得到了第一

//个比_I大的元素。

由于是从结尾开始找,所以加了"!

",和我的相反。

;//仅仅为了找而找。

iter_swap(_I,_J);//a(m)和a(i)交换,相当于我写的swap语句,注意传递的是迭//代器,修改的是值。

reverse(_Ip,_L);//相比于我的全排序,直接把a(m+1)到a(n)反序更有效率。

return(true);//返回。

}

if(_I==_F)//判断是否到起点了,相应于m+1=2,则把刚才反过来的反回去,//再返回false

{

reverse(_F,_L);

//有些读者喜欢在反序之前判断是否到起点,而实际上到起点的情况只有一种(最后一个),//不断地判断很浪费时间,还不如在最后再反回来。

return(false);

}

}

}

它的第一个参数是迭代器(指针、数组)的首地址,第二个参数是末地址,可以这样传递:

next_permutation(A,A+n)来获得整个数组的下个字典序。

我在上面已经写了完整的解释,大家可以对比我的算法和C++标准库里的算法,当然大家可以明显看到标准库算法的优越性,大家可以照着上面的解释自己写一个,不用全一样,模式一样即可,它的算法是最高效的(要不怎么能当模板?

)。

当然,标准库的算法为了使效率最快,安全性最高,总是喜欢用一些++啊--啊之类的运算符使得代码难读,读者在这方面可以不用模仿,算法上模仿就成了。

下面再补充一些:

在库中还有一个可以增加比较器的模版函数,不过实际上这个函数也不善于处理大型数据,有兴趣可以用。

在C++标准库中还提供了找上一个字典序的算法,perv_permutation(),用法一样,下面我附上原码,就不解释了,原理差不多,有兴趣的读者可以自己读读或者自己写一个。

//TEMPLATEFUNCTIONprev_permutation

templateinline

boolprev_permutation(_BI_F,_BI_L)

{_BI_I=_L;

if(_F==_L||_F==--_I)

return(false);

for(;;)

{_BI_Ip=_I;

if(!

(*--_I<*_Ip))

{_BI_J=_L;

for(;*_I<*--_J;)

;

iter_swap(_I,_J);

reverse(_Ip,_L);

return(true);}

if(_I==_F)

{reverse(_F,_L);

return(false);}}}

9.字典序的中介数,由中介数求序号:

这里我要引入一个新的概念:

中介数。

为什么要引入这么一个概念呢?

很多时候,我们要通过一个排列得出它的字典序中的位置(序号),比如1234567应该排在第0位(开始位),1234576应该排在第1位,7654321排在第7!

-1=5039位。

当然,我们可以通过计算Next_Permutation函数的迭代次数来得到这个数据,但这样时间复杂度最差为O(n!

),是非常不划算的,因为我们仅仅要求一个数的位置。

所以我们要先从数学的角度去计算这个问题。

3456721排在第几位?

1.先看看首位3,在以3开头的数前面有以2开头和以1开头的数,这些数的个数为2*6!

个,其中2代表1和2两个数。

2.再看看第二位4。

如果已经确定由3开头,那么在4开头前面的数有几种呢?

也是以2开头的数和以1开头的所有数,因为3已经放在开头,所以这里不算,这些数的个数为2*5!

3.再往下看,第三位5.如果已经确定34开头,在5开头前面的有几种呢?

由于3、4已经放在前面,这里还是只有1、2.这些数的个数为2*4!

4.345已经确定,再6开头的前面有2*3!

,再7开头前面有2*2!

,在2开头前面有1*1!

个(因为只剩1了),在1开头前面的没有了。

(0*0!

于是我们得到在3456721前面的数有2*6!

+2*5!

+2*4!

+2*3!

+2*2!

+1*1=1745位。

也说是说,在3456721前面的数一共有1745位,由于我们是从0开始的,所以它的位置也就是1745位。

上述计算过程对应了相应的算法:

从高位往低位看,或者对于数组而言从序号小的往大的看,按照3,4,5,6,7,2,1[a[0],a[1],a[2],a[3],a[4],a[5],a[6]]的顺序,看它的右边比它小的数的个数k[1],k[2],k[3]...(因为左边的数已经确定了),每一位的k[i]*[(n-i-1)]!

[此处n=7]乘以它所在的位数-1的阶乘,比如3在第7位(在数组中是第0位,所以用n-0),减1得到后面还剩6位,而k[0]为2,因为在3的右边比3小的数只有两个。

其它的位依次类推。

为了简化公式,我们一般取i=为数组序号加1(即a[0]我们认为是a[1],用来和实际意义相近,因为我们实际中第一个数都是1而不是C语言中的0)。

得到公式:

n-1

∑k[i](n-i)!

i=1

其中i为数组的序号,从1开始,实际写算法时应从0开始,n为数组元素的个数,k[i]表示第i个元素的右边(从第i+1个元素开始到数组末尾)比第i个元素小的元素的个数。

有了上述公式,就可以方便的计算一个排列在字典序中的位置了。

比如7654321

=6*6!

+5*5!

+4*4!

+3*3!

+2*2!

+1*1!

=5039.

到此,我们给出字典序中介数的定义:

定义:

由上述算法给出的数组k[i]按照顺序排列所形成的数

叫做这个排列的字典序中介数。

如7654321的中介数为654321,3456721的中介数为222221。

中介数之所以称为中介数,是因为我们知道一个排列的中介数后,便可以很方便得求得它在字典序中的序号,而这个数在实际上没有明确的意义,所以称为中介数,要注意的是,中介数比原来排列的位数(数组的元素个数)少一位,因为最后一位的结果必然是0。

读者应多多练习求一个排列的中介数以及相应的序号。

以上算法的时间复杂度为

,其中求中介数为

,由中介数到序号为O(n)。

这个算法比初始的想法的O(n!

)要好的多。

这个算法实际的代码我就不写了,读者可以自己写,这要是掌握这个算法的原理和过程。

这里还要说的是这个算法不适合于有重复数字的情况,下面所讲的所有算法均不适合(可以想想为什么)。

10.由中介数求排列:

通过中介数可以很方便地求序号,而通过一个排列可以很方便地得到它的中介数,那么自然要想到另一个过程:

通过中介数能否很方便地反推回排列呢?

首先,显然地,中介数和排列是一一对应的,不存在一个中介数对应多个排列的情况(读者可以想想为什么)。

所以,必然有办法可以求回去。

按照我的习惯,还是从基本的想法做起,我们是怎样求得中介数的,由此反推回如何求排列。

比如222221。

乍一看似乎很难入手,正如微分容易而积分难一样,当然,这个逆过程比积分要简单的多,因为我们已经确定它是唯一的。

方法:

从最左边开始推算:

2开头,证明最高位的右边比它小的数只有两个,马上可以得到最高位是3.第二位也是2,因为3已经有了,那么还有比它小的两个数,那就是4了,以此类推,我们可以很方便地求得一个中介数的原排列。

方法即从最左边推算。

这是一个逆过程,最初看看是个不错的想法,做出来也比较快,而实际上不是这样的。

我们看看我们实际的运算过程:

最左边的数如果是x1,那么它的原排列位是x1+1,当我们去看第二个数时,我们先假想它是x2+1,如果第一个排列位比x2+1来得大,那么没有问题,如果比它小或者相等,则要加上1,所以实际上,我们每求一位数,都要先让它和前面的所有数进行比较来获得它的定位。

求解中介数还有一些类似的方法,这里我便不罗列了,反正大同小异,表面上看起来比较简单,但实际实现起来是比较复杂的(由其是对计算机而言)。

而且,在这种方法中,我们很难通过已知的排列序号反推出原来的排列,所以有必要给出新的中介数形式。

11.递增进位制数法:

为了改变由中介数求原排列的复杂性,我们要修改我们定义中介数的方法,我们的中介数是和相应的位数相关联的,中介数的下标对应了位的下标。

我们定义一种

展开阅读全文
相关资源
猜你喜欢
相关搜索

当前位置:首页 > 高中教育 > 小学教育

copyright@ 2008-2022 冰豆网网站版权所有

经营许可证编号:鄂ICP备2022015515号-1