关于全排列算法的思考
已经是求职过程中第三次碰到这个问题了,笔试两次,又面了一次,觉得这个问题确实值得深思啊。我还是尽量总结下吧,说不定以后还会碰到。
问题:已知输入为一个字符串,求其全排列的输出。比如输入为abc,那么输出有以下几种:
abc
acb
bac
bca
cab
cba
即如果输入字符串的长度为N的话,会输出N!个结果。
方法一:递归
思路是这样的:我们维护两个序列,一个序列是要进行全排列的序列,我们暂称之为源序列,另一个序列是全排列之后的结果序列,我们称其为结果序列。过程如下:
1)初始时源序列为输入的字符串序列,结果序列为空
2)如果源序列中的元素个数大于1,则对源序列中的每一个元素,进行如下操作:
3)如果源序列中元素个数不大于1,则打印结果序列+源序列
下面给出了该思路的java实现,参考[1]
public class MainClass {
}
这个实现中endingString对应叙述中的源序列,beginningString对应结果序列,为了方便理解,我以abc为示例画了个调用图:
这个方法有两个问题:
1)每次递归时都要产生新源序列和结果序列,作为递归参数传进去,上面的实现使用java语言写的,要是用C++的话就得用传值的方式生成std::string,不断地产生和释放新串,当输入串只有几个十几个字符时当然不是什么问题了,如果字符串长度一长,估计就扛不住了。
2)同样,当字符串长度一长,递归深度成线性增加,这个也可能照成性能上的影响。
这个其实在百度招聘的笔试试卷上见过一次,我当时也没多想,直接给出了本文第二种方法的实现代码,结果面试时面试官说了一种比较容易懂的方法就是这种方法。我跟面试官解释了我的代码半天,好像也没解释清楚。Sigh!
方法二:递归+置换
我先把实现代码给出来吧:
void Swap(int *a, int *b)
{
}
int Permutation(int A[], int start, int end)
{
}
int main(int argc, char *argv[])
{
}
有点区别的是我这里用的是整数数组,不过我想和字符串数组应该是一个道理。那么怎么解释这种方法呢?两次面试都是拿着代码找解释,那个尴尬啊,这也更加坚定了我要彻底搞清楚这个问题的决心。
其核心是:将每个元素放到n个元素组成的队列最前方,然后对剩余元素进行全排列,依次递归下去。[2]这样解释起来好像还是有点不明白,我还是以abc为例画个程序图吧
这个方法的一个巧妙之处在于,每次和子序列的首元素交换,然后调用递归并返回后,重新再进行一次交换,这样就能保证在递归的某一层中,每轮循环开始第一次Swap前序列顺序与子递归返回第二次Swap后的顺序保持一致,即图中的竖直方向。这样就使得子调用递归后不会影响下一轮循环的调用。
1 2 3 4
1 2 3 5
。。。
9 8 7 6
其实就是全排列的变种,全排列是求Ann,而大摩的那道题是要求部分排列Anm,m < n,其实上面两种方法都有对应的解法,第一种只要把源序列长度结束判断条件改为endingString.length() <= (n – m) 即可;第二种,将结束条件改为start + (n - m) >= end即可。
方法三:非递归循环
1、用一个数组 repeat_cnt[] 来保存每层的循环变量:repeat_cnt[0] 保存第 0 层循环变量的位置,repeat_cnt[1] 保存第 1 层循环变量的位置......repeat_cnt[n-1] 保存第 n-1 层循环变量的位置
2、标记当前正在第几层循环:layer
3、集合长度已知 :n = strlen(s)
4、临时数组:tmp_seq[],长度为 n,用于存储一个可能全排列的序列
3、算法描述如下:
循环体(layer == 0 且 repeat_cnt[0]== size
{
}
下面是将原作者用Python编写的 permutation 函数改成了C++版本的,对于C/C++程序员来说肯能更直接一些,该函数接受两个参数
第一个参数:要全排列的字符串
第二个参数:选几个数排列,默认全排序
第三个参数:是否允许重复,默认不允许
void permutation(char* s, int m = 0, bool duplicate = false)
{
}
这种方法和前面两种方法进行比较,不难发现,首先,它不需要额外分配太多的空间,仅仅在堆中分配O(n)的辅助空间,然后一直重用直到调用结束,因此空间利用率很高,其次,它没有递归调用,也不需要函数调用栈开销,当字符串长度很长时,也不会出现方法一中的两个问题,所以这种方法的优势还是很明显的,唯一一个缺点就是这种方法可能不是那么容易理解。关于循环层数的动态控制,《编程之美》上有一道类似的题[4],手机上每个数字对应0个或多个字母,给定一个数字数组,打印所有可能的字符串组合。比如下面的字符串数组就描述了这种对应关系,0和1没有对应的字母,2~9每个数字可能对应3~4个不等字母中的一个。
char *c[10] = {
};
我们假设输入的数字数组不可能有0或1这两个数字(因为即使有这两个数字,对应的字符也为空),比如我们给一个例子,输入为数字串为42,那么4可能对应GHI中的一个,2可能对应ABC中的一个,那么输出就为:
GA
GB
GC
HA
HB
HC
IA
IB
IC
这个例子很简单,直接使用二重循环就能写出来,但是当输入数字串的长度增加时,如何控制循环层数呢?
我先给个递归的解法吧:
char s[20]; //s用于保存结果字符串
//返回值是所有可能组合的数目
//第一个参数是输入数字串首地址
//第二个参数是输入数字串长度,也就是对应最后结果字符串的长度
//第三个参数表示当前正在处理串中第几个数字
int recursive_search(int number[], int N, int k)
{
}
下面是微软的人给的一种非递归方法:
int nonrecursive_search(int number[], int N)
{
}
我第一次看这段代码时,也没怎么看明白,后来发现这当中包含了一种“进位”的思想,一开始循环计数器变量数组answer全部都是0,这是可以看成值最小的一种组合,然后从最后一个循环计数器开始,进行递增,如果发现该位到达上限,那么就把该位置零,然后转到前面一位(高位)进行递增。每次都从最低位开始递增循环计数器,且保证每次的计数器数组都是在上一次的基础上增一,有点像数数那样。直到最后,进位溢出,最后answer数组中的各计数器又都变为0。
int cal_str_nonrecursive2(int number[], int N)
{
}
这种写法比微软的那个写法多用了一个用来存放结果字符串的数组s[],这个数组是不是必须得要呢?嗯,需要考虑下。其实不需要,可以删掉上述代码中和s相关的代码行,然后将打印语句修改为:
printf("%c", c[number[i]][repeat_cnt[i] - 1]);
有兴趣的可以稍微想想。你甚至可以想出前面permutation函数中,不需要tmp_seq[]数组的方法。
方法四:字典序法
上面的三种方法都是基于位置变换的方法,也就是说,不管字符串中的字符的具体值到底是什么,因此,如果输入的字符串中有重复的数值时,输出将会重复。考虑输入为aab的字符串,如果按照前面的三种方法,将会输出六个全排列,单实际上,如果要求结果不重复时,只有一下三个全排列:
aab
aba
baa
“
设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 | pi < pi+1}
(右边的数从右至左是递增的,因此k是所有大于pj的数字中序号最大者)
3)对换pj,pk
4)再将pj+1......pk-1pkpk+1...pn倒转得到排列p' = p1p2...pj-1pjpn......pk+1pkpk-1...pj+1,
这就是排列p的下一个排列。
例如839647521是数字1~9的一个排列。从它生成下一个排列的步骤如下:
自右至左找出排列中第一个比右边数字小的数字4;
在该数字后的数字中找出比4大的数中最小的一个5;
将5与4交换得到839657421;
将7421倒转得到839651247;
所以839647521的下一个排列是839651247。
”
附下原作者写的代码:
#include <iostream>
#include <algorithm>
#include <string>
using namespace std;
template<class Iter>
bool my_next_permutation( Iter start, Iter end )
{
}
int main()
{
}
以aab为输入分析,输出为
aab
aba
baa
确实能去掉重复的排列。关于重复的排列,我还想到了这样一个问题:要从n个数中取出m个数进行排列(m<n),打印出所有不同的排列,其中n个数中可能有若干个数相同。想到了一个最一般化的方法:先从n个数中取出m个数进行组合,并保证每个组合的唯一性,然后对每一个组合进行按字典序全排列。如何从n个数中选m个数进行全排列并保持其唯一性,有空我再研究下吧。
后记:
参考:
[1] 《Recursive method to find all permutations of a String》http://www.java2s.com/Tutorial/Java/0100__Class-Definition/Recursivemethodtofindall
[2] 《全排列算法》http://hi.baidu.com/microgrape/blog/item/52f779df41afac5e94ee37b7
[3] 《求一组序列的全排列》http://www.cnblogs.com/myqiao/archive/2009/07/29/1533563.html
[4] 《编程之美》p218,3.2,电话号码对应英语单词