算法练习 - 字符串的全排列(字典序排列)

题目描述

输入一个字符串,打印出该字符串中字符的所有排列。例如输入字符串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
  • y尽可能小 (同一个位置上,字符越小整个字符串的字典序越小,比如:131<121, acdbc)
  • B'里的字符按由小到大递增排列 (小朋友都懂的道理不解释)

现在的问题是如何找到“x”和“y”?

举个例子,现在我们要找21543的下一个排列,我们可以从左至右逐个扫描每个数,看哪个能增大(至于如何判定能增大,是根据如果一个数右面有比它大的数存在,那么这个数就能增大),我们可以看到最后一个能增大的数是:x = 1。而1应该增大到多少?1能增大到它右面比它大的那一系列数中最小的那个数,即:y = 3,故此时21543的下一个排列应该变为23xxx,显然 xxx(对应之前的B’)应由小到大排,于是我们最终找到比“21543”大,但字典顺序尽量小的23145,找到的23145刚好比21543大。

抽象概括一下上面的例子就是“二找、一交换、一翻转”。

  1.     一找:找到排列中最后(最右)一个升序的首位位置i,x = ai
  2.     二找:找到排列中第i位右边最后一个比ai 大的位置j,y = aj
  3.     一交换:交换x,y
  4.     一翻转:把第(i+ 1)位到最后的部分翻转

*升序:相邻两个位置ai < ai+1,ai 称作该升序的首位

 找21543的下一个排列,套用“二找、一交换、一翻转”就是

  1. 一找: 找到排列中最后(最右)一个升序的首位, x = 1
  2. 二找: 找到排列中1的右边最后一个比1大的位置,y = 3
  3. 一交换:1和3交换,得23541
  4. 一翻转:翻转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(str.c_str());
	 sort(perm, perm+str.size());
	 cout<


方法二:

既然给定一个字符串我们可以生成字典序刚好比它大的下一个排列,那么就可以生成字典序刚好比它小的前一个排列。具体方法请自行思考(将前面讲的内容全部反过来想)。这样做的优点是节省了一次字符串排序,缺点是烧脑(还要照葫芦画瓢,实现一个向前查找的函数吐舌头

void CalcByDictionary(const string &str)
{
	string s = str;
	char* perm = const_cast(str.c_str());
	cout<(s.c_str());
	while(true)
	{
		if(!FindNextPermutation(perm, num))
		{
			break;
		}
		cout</*
 * Find out the previous (smaller) permutation of current string.
 * @ char* perm [in/out] String
 * @ int num [in] The length of string.
 */
bool FindPrePermutation(char* perm, int num)
{
	int i = 0;
	for(i = num - 2; (perm[i] <= perm[i+1]) && i >= 0; --i)
		;
	if(i < 0)
	{
		return false;
	}
	int j = 0;
	for(j = num - 1; (j > i) && perm[j] >= perm[i]; --j)
		;

	swap(perm[i], perm[j]);
	reverse(perm + i + 1, perm + num);
	return true;
}

 

 

 

 

 

题外话

最近一直在看July和他伙伴们的《程序员编程艺术:面试和算法心得》,收获良多。在学习的过程中发现,虽然原书讲解的很细致,但是真正理解仍然需要花费大量的思考和实践。因此做了这个系列的文章,只是希望将自己的思考记录下来,供以后查阅。

 

你可能感兴趣的:(算法练习)