字符串的全排列(字典序排列)
题目描述
输入一个字符串,打印出该字符串中字符的所有排列。例如输入字符串abc,则输出由字符a、b、c 所能排列出来的所有字符串abc, acb, bac, bca, cab, cba。
题目分析
穷举与递归
又是一个经典问题,最容易想到的解决方法仍然是穷举(我实在是太爱穷举法了,每当被问到算法问题不知道如何解决的时候,总可以祭出穷举大旗,从而多争取3分钟的思考时间)。穷举虽好,但它大多数情况下都不是被需要的那个答案,是因为看起来代码太Low不够高大上吗?
在这种情况下,穷举法裹着貂皮大衣的亲戚——递归就出现了。虽然空间复杂度和时间复杂度没有任何改进,而且还增加了系统开销(关于递归法的系统开销不在这里讨论,之后再找专门的时间阐述),但是就是因为长得好看(代码看起来精炼),递归的B格儿就高了很多。
递归法对于这个题目同样非常适用,基本思路就是固定一个字符,然后对剩余的字符做全排列……不赘述,请自己想。如果你也跟我一样永远想不明白递归,那就画画图,写写代码,debug一下,每天花3-4个小时,静下心来仔细捉摸,总(ye)会(bu)想(hui)明白的。贴一段July和他伙伴们在《程序员编程艺术:面试和算法心得》中的代码实现,供做噩梦时使用。p.s. 我已加了注释
/* * Permute full array of input string by general recusion * @ char* perm [in/out] The string need to do permutation * @ int from [in] The start position of the string * @ int to [in] The end position of the string */ void CalcAllPermutation(char* perm, int from, int to) { if (to <= 1) { return; } if (from == to) { //all characters has been permuted for (int i = 0; i <= to; i++) cout << perm[i]; cout << endl; } else { // always select one character, then full array the left ones. for (int j = from; j <= to; j++) { swap(perm[j], perm[from]); //swap the selected character to the beginning of string CalcAllPermutation(perm, from + 1, to); // Permute left characters in full array. swap(perm[j], perm[from]); //recovery the string to original one (swap the selected character back to its position.) } } }
字典序
这是一个比递归更有趣的答案,不知道算不算经典解法,起码开拓了思路,跟每一次接触新鲜的算法一样,仍然想了半天的时间,因此照例把思考过程更细致的记录下来(虽然July和他伙伴们在《程序员编程艺术:面试和算法心得》中已经说了很多),再加上一些小修改。
字典序,顾名思义,就是按照字典的顺序进行排列。根据维基百科的定义:给定两个偏序集A和B,(a,b)和(a′,b′)属于笛卡尔集 A × B,则字典序定义为(a,b) ≤ (a′,b′) 当且仅当 a < a′ 或 (a = a′ 且 b ≤ b′)。所以给定两个字符串,逐个字符比较,那么先出现较小字符的那个串字典顺序小,如果字符一直相等,较短的串字典顺序小。例如:abc < abcd < abde < afab。
总结一下,字典序排序其实就是同一组字符组成的一系列字符串,
起点: 字典序最小的排列, 1~n , 例如12345
终点: 字典序最大的排列,n~1, 例如54321
过程: 从当前字符串排列生成字典序刚好比它大的下一个排列,比如12345的字典序下一个排列是12354
现在来进一步分析一下算法实现,其实对于字典序排列,关键点就是找到“下一个排列”。基本的查找方法
假定现有字符串(A)x(B),它的下一个排列是:(A)y(B’),其中A、B和B’是“字符串”(可能为空),x和y是“字符”,前缀相同,都是A,且一定有y > x。那么,为使下一个排列字典顺序尽可能小,必有:
- A尽可能长 (A越长,那么B‘就越短,从而y所在的位越低,很明显的同一个字符放在低位肯定比放在高位要小,比如:100<10< 1,
abc<aac<aaa)
- y尽可能小 (同一个位置上,字符越小整个字符串的字典序越小,比如:131<121, acd<abc)
- B'里的字符按由小到大递增排列 (小朋友都懂的道理不解释)
现在的问题是如何找到“x”和“y”?
举个例子,现在我们要找21543的下一个排列,我们可以从左至右逐个扫描每个数,看哪个能增大(至于如何判定能增大,是根据如果一个数右面有比它大的数存在,那么这个数就能增大),我们可以看到最后一个能增大的数是:x = 1。而1应该增大到多少?1能增大到它右面比它大的那一系列数中最小的那个数,即:y = 3,故此时21543的下一个排列应该变为23xxx,显然 xxx(对应之前的B’)应由小到大排,于是我们最终找到比“21543”大,但字典顺序尽量小的23145,找到的23145刚好比21543大。
抽象概括一下上面的例子就是“二找、一交换、一翻转”。
- 一找:找到排列中最后(最右)一个升序的首位位置i,x = ai
- 二找:找到排列中第i位右边最后一个比ai 大的位置j,y = aj
- 一交换:交换x,y
- 一翻转:把第(i+ 1)位到最后的部分翻转
*升序:相邻两个位置ai < ai+1,ai 称作该升序的首位
找21543的下一个排列,套用“二找、一交换、一翻转”就是
- 一找: 找到排列中最后(最右)一个升序的首位, x = 1
- 二找: 找到排列中1的右边最后一个比1大的位置,y = 3
- 一交换:1和3交换,得23541
- 一翻转:翻转541,得23145
道理讲完了,但是你真的懂了吗?反正本人看到这里又看了算法之后,仍然懵懵懂懂(请原谅我的智商吧,其实我自己也挺着急的,妈妈已经急哭,表示对我放弃治疗了)。因此,下面的部分很重要。
首先来看第一找“找到排列中最后(最右)一个升序的首位位置i,x = ai”
这意味着什么?这意味着字符串在x之后所有字符都是降序排列的,如下图。在找到x=1之前,543已经达到了最大字典序,因此不可能通过改变543的顺序得到更大的字符串。
那么为什么不是修改首位的“2”呢?还记得前面介绍的字符串(A)x(B)的下一个排列是(A)y(B’)的方法吗?,A要尽可能长。对“1”进行操作正式保证了A尽可能长。
接下来,看一下第二找和一交换:“找到排列中第i位右边最后一个比ai 大的位置j,y = aj”,“交换x,y”
说完了“A要尽可能长”,现在该说y要尽可能小了。为什么“第二找”和“一交换”之后,y就最小了呢?既然首位的“2”是不在范围内的,而“1”(即x)是要被交换的,那么y只能来自“5”,“4”,'3“,因为543是降序排列的(注意,x可是找到的字符串中最后一个升序的首位哟),因此“5”,“4”,'3“中比”1“(即x)大的最小的字符就是y。于是y=3。交换x,y之后,即得到新的字符串(A)y(B')
此时的B'仍然不是我们最终需要的B',因为它还不满足最后一个条件B'里的字符按由小到大递增排列。为了做到这一点,于是有了最后的“一翻转”。那么为什么简单的翻转之后B'里的字符就按照由小到大的顺序排列了呢?
我们在来回顾一下B和B‘的确定过程。首先,B是一个降序排列的字符串;然后我们在B中找到了比x小的最小的y(此处有些绕,自己写几个字符串就一目了然了),也就是说y的右侧如果还有字符的话也一定比x小(因为B是降序),接下来交换x和y得到B',因此B’也是降序的。对于一个降序的字符串来说,翻转之后即为升序排列。于是我们得到了最终的(A)y(B'),即23145。
代码实现
好了,该讲的都讲完了,现在看代码
/* * Find out the next (bigger) permutation of current string. * @ char* perm [in/out] String * @ int num [in] The length of string. */ bool FindNextPermutation(char* perm, int num) { int i = 0; for(i = num - 2; (perm[i] >= perm[i+1]) && i >= 0; --i) ; // Find x if(i < 0) { return false; // The input string is a single character } int j = 0; for(j = num - 1; (j > i) && perm[j] <= perm[i]; --j) ; // Find y swap(perm[i], perm[j]); // swap x and y reverse(perm + i + 1, perm + num); // reverse B' return true; }
这段代码实现了从当前字符串生成字典序刚好比它大的下一个排列,但是如果我们拿到的字符串不是字典序最小的排列,该如何处理呢?
对原始字符串进行排序,将原始字符串转换为字典序最小的排列后,再通过字典序排序进行全排列。这样做的好处是实现简单,缺点是要多做一次字符串排序。关于排序算法不在本文讨论范围,这里我直接使用了STL的sort函数
void CalcByDictionary(const string &str) { char* perm = const_cast<char*>(str.c_str()); sort(perm, perm+str.size()); cout<<str<<endl; while(true) { if(!FindNextPermutation(perm, str.size())) { break; } cout<<s<<endl; } }
题外话
最近一直在看July和他伙伴们的《程序员编程艺术:面试和算法心得》,收获良多。在学习的过程中发现,虽然原书讲解的很细致,但是真正理解仍然需要花费大量的思考和实践。因此做了这个系列的文章,只是希望将自己的思考记录下来,供以后查阅。