【编程珠玑】【第二章】编程求解排列问题
尽管排列组合是生活中经常遇到的问题,可在程序设计时,不深入思考或者经验不足都让人无从下手。全排列在笔试面试中很热门,因为它难度适中,既可以考察递归实现,又能进一步考察非递归的实现,便于区分出考生的水平。
所谓全排列,就是打印出字符串中所有字符的所有排列。例如输入字符串abc,则打印出 a、b、c 所能排列出来的所有字符串 abc、acb、bac、bca、cab 和 cba 。
任何方法都是暴力可解的,本题目也不例外。对于给定的字符集合{a,b,c},第一位可以是 a 或 b 或 c 。当第一位为 a 时,第二位再遍历集合,发现 a 不行,因为前面已经出现 a 了,而 b 和 c 可以。当第二位为 b 时 , 再遍历集合,发现 a 和 b 都不行,c 可以,这样便得到了一个排列abc;第二位为c时,遍历集合会发现a和c都不行,而b可以,这样会得到acb。...此方法可以用递归或循环来实现,但是需要重复的遍历数组,复杂度高达 O(nn) 。有没有更优雅的解法呢。
1、不含重复元素的全排列(递归方法):
给定一个具有n个元素的集合(n>=1),要求输出这个集合中元素的所有可能的排列。全排列在很多程序都有应用,常规解法是一种递归的算法,递归实现的思路:
E =(a,b,c),
则prem(E)=a.perm(b,c)+b.perm(a,c)+c.perm(a,b),
a.perm(b,c)=ab.perm(c)+ac.perm(b)=abc+acb,
同理b.perm(a,c)和c.perm(a,b)依次递归进行。
例如,如果集合是{a,b,c},那么这个集合中元素的所有排列是{(a,b,c),(a,c,b),(b,a,c),(b,c,a),(c,a,b),(c,b,a)}共六种,显然,给定n个元素共有n!种不同的排列,如果给定集合是{a,b,c,d},可以用下面给出的简单算法产生其所有排列,即集合(a,b,c,d)的所有排列有下面的排列组成:
(1)以a开头后面跟着(b,c,d)的排列
(2)以b开头后面跟着(a,c,d)的排列
(3)以c开头后面跟着(a,b,d)的排列
(4)以d开头后面跟着(a,b,c)的排列
这显然是一种递归的思路,可以将其画成枚举树,以{1,2,3}为例:
先取一个元素,例如取出了1,那么就还剩下{2, 3}。然后再从剩下的集合中取出一个元素,例如取出2,那么还剩下{3}。以此类推,把所有可能的情况取一遍,就是全排列了:
知道了这个过程,算法也就写出来了:将数组看为一个集合,将集合分为两部分:0~begin和begin~end,其中0~begin表示已经选出来的元素,而begin~end表示还没有选择的元素。
perm(arr, begin, end){ 顺序从begin~end中选出一个元素与begin,交换(即选出一个元素放在第一个位置上) 调用perm(set, begin + 1, end) 直到begin==end,即剩余集合只有一个元素,不必再交换,直接输出arr }
对arr[begin...end]数组求全排列,设arr={a,b,c},begin为0,end为2,求解流程如下:(伪代码格式有问题,略了)
代码实现如下:
#include <stdio.h> #include <stdlib.h> #include <string.h> int n = 0; void swap(char *a, char *b) { char tmp= *a; *a = *b; *b = tmp; } void permutation(char* arr,int begin,int end){ int i,j; if(begin == end){ for(i=0;i<=end;i++) printf("%c ", arr[i]); n++; printf("\n"); }else{ for(j=begin;j<=end;j++){ swap(&arr[begin],&arr[j]); permutation(arr,begin+1,end); swap(&arr[begin],&arr[j]); } } return; } int main(void){ char arr[] = "abcde"; printf(" 原始字符串:%s \n\n", arr); permutation(arr,0,strlen(arr)-1); printf(" 共有%d 种全排列 \n", n); return 0; }
2、含重复元素的全排列(递归方法)
如果字符串中有重复字符的话,上面方法会产生许多重复的排列方式,不符合要求。为了得到不一样的排列,可能我们最先想到的方法是当遇到和自己相同的就不交换了,比如输入aba,第一个元素进行交换会得到aba,baa,第一个元素和第三个元素因为是重复的所以不需要进行交换。除此之外,当遇到与自己不同但是仍然是重复的元素时,也要注意一些事项。比如输入abb,通常交换第一个元素能够得到三个排列abb,bab,bba,但是bab和bba的意义都是以b开头+字符子集{a,b}(或者{b,a})全排列,因此在求bab和bba的全排列结果中会不可避免的出现重复。可见我们在bab和bba中只能留其中之一,这意味着当a与b进行交换时,因为b属于重复元素,所以a只能同所有重复元素中的一个(通常为所有该重复元素中的第一个)进行交换,忽略该元素其他重复元素。
这样得到在全排列中处理重复的规则:去重的全排列就是从第一个数字起每个数分别与它后面非重复出现的数字交换。用编程的话描述就是第i个数与第j个数交换时,要求[i,j)中没有与第j个数相等的数。下面给出完整代码:
#include<stdio.h> #include<string.h> #define true 1 #define false 0 int n=0; void swap(char *a ,char *b){ char temp = *a; *a = *b; *b = temp; } //在 str 数组中,[start,end) 中是否有与 str[end] 元素相同的 int canSwap(char* str,int start,int end){ int i; for(i=start;i<end;i++){ if(str[i] == str[end]) return false; } return true; } //递归去重全排列,start 为全排列开始的下标 //end为str数组最后元素的下标,数值上等于str长度-1 void permutation(char* arr,int start,int end){ int i,j; if(start == end){ for(i=0;i<=end;i++) printf("%c ", arr[i]); printf("\n"); n++; }else{ for(j=start;j<=end;j++){ //如果在[start,j)之间没有与arr[j]重复元素,则start可以与j上元素交换。 //否则意味着有重复元素,之前已经交换过,在这里直接忽略。 if(canSwap(arr,start,j)){ swap(&arr[start],&arr[j]); permutation(arr,start+1,end); swap(&arr[start],&arr[j]); } } } return; } int main(void){ char arr[] = "abb"; printf(" 原始字符串:%s \n\n", arr); permutation(arr,0,strlen(arr)-1); printf(" 共有%d 种全排列 \n", n); return 0; }
3、全排列的非递归实现,该方法对于重复和非重复的情况不加区分,均适用。
要考虑全排列的非递归实现,先来考虑如何计算字符串的下一个排列。如果能对字符串反复求出下一个排列,全排列的也就迎刃而解了。
如何计算字符串的下一个排列了?来考虑"926520"这个字符串,我们从后向前找第一双相邻的递增数字,"20"、"52"、"65"”都是非递增的,"26 "即满足要求,称前一个数字2为替换数,替换数的下标称为替换点,再从后面找一个比替换数大的最小数(这个数必然存在),0、2、6都不行,5可以,将5和2交换得到"956220"。还没有结束,替换结束之后替换点之后的数串是从大到小排列的"956220",为保证最小幅度增加数值,需要将替换点后的字符串"6220"颠倒即得到"950226"。
能在一个字符串中找到递增数字对,意味着存在某高位数值低于低位数值的情况,我们通过把这个高位数值与一个比它稍微大一点的低位数交换,能够最小限度的增大当前字符串代表的数值。这样便能够一步一步的变换,把一个较小的数字串逐渐增大直到成为一个不能再增大的数字串。当达到最大时,如1234变化到4321,4321中没有递增数字对,这个时候就结束整个循环。
这样,只要一个循环再加上计算字符串a下一个排列的函数Next_permutation(arr)就可以轻松的实现非递归的全排列算法。如果输入是一个非最小数,如926520,则使用排序算法将它转换为最小数022569,再使用此法求解其全排列。排序算法推荐使用VC库中的快速排序函数。
#include <stdio.h> #include <stdlib.h> #include <string.h> #define true 1 #define false 0 void swap(char *a, char *b){ char tmp = *a; *a = *b; *b = tmp; } //反转区间 void reverse(char *a, char *b){ while (a < b) swap(a++, b--); } //c语言库stdlib中快速排序的比较函数 int qsortCmp(const void *pa, const void *pb){ return *(char*)pa - *(char*)pb; } //下一个排列 int Next_permutation(char * arr){ if (arr == NULL) return false; char *p, *q, *pFind; char *pEnd = arr + strlen(arr)-1; //p和q标识了前后相邻的两个元素,其中p指向替换点。 p = pEnd; while (p != arr){ q = p; p--; //找到相邻递增对,前一个数(p指向)即替换数,进行替换并返回true if (*p < *q){ /*从最后位置pEnd向前查找比替换点大的第一个数,由pFind标识。值得注 *意的是,在分析中我们说的是找替换点之后所有比替换点大的数中的最小的那个, *但是因为替换点后面的数字都是非递增的,所以从后向前第一个比替换点大的数 *字一定就是所有比它大的数字中最小的那个。 */ pFind = pEnd; //从pEnd向前查找,找到第一个比替换点*p值大的pFind。 while (*pFind <= *p) --pFind; //替换pFind与替换点p的值 swap(pFind, p); //替换点后从q到pEnd的数全部反转 reverse(q, pEnd); return true; } } //如果while循环中没有找到相邻递增对,意味当前数串已经最大,没有下一个排列, //数串通常起始时最小,此时为最大,它的下一个串循环回到起始的最小数串,所以 //需要再一次调用reverse()把当前最大串反转成为最小串,而后返回false。 reverse(p, pEnd); return false; } int main(){ printf("全排列的非递归实现\n"); char str[] = "abb"; printf("%s的全排列如下:\n", str); qsort(str,strlen(str),sizeof(str[0]),qsortCmp); int i = 1; do{ printf("第%3d个排列\t%s\n", i++, str); }while (Next_permutation(str)); return 0; }
补充:STL中包含函数next_permutation(),它的原理和我们刚刚讲的几乎完全一致。
四、有一定约束条件的全排列:对数1,2,3,4,5要实现全排序。要求4必须在3的左边,其它的数位置随意。
对于这种有特定数字相互位置限定条件的题目,最简单的想法是使用递归或者非递归算法,在生成全排列的过程中对所有的排列进行筛选,筛选出满足约束条件的排列,而去掉不符合要求的排列。这里使用刚刚介绍完的非递归全排列的算法,只需在main函数中增加排列筛选的代码,比较简单,详情如下:
int main(){ char str[] = "234"; printf("%s的全排列如下:\n", str); qsort(str,strlen(str),sizeof(str[0]),qsortCmp); int n=1; int i,flag3,flag4; do{ flag3=flag4=0; for(i=0;i<strlen(str);i++){ if(str[i]=='3' ) flag3=1; if(str[i]=='4' && flag3==1) //在4出现之前已经出现3,则此排列被忽略。 flag4=1; } if(flag4!=1){ //在4出现之前已经出现3,则此排列被忽略,不予输出。 printf("第%3d个排列\t%s\n", n++, str); } }while(Next_permutation(str)); return 0; }
可是,总感觉这种方法过于简单暴力,一定还存在更复杂精妙的算法,留待以后补充。
5、总结
至此我们已经运用了递归与非递归的方法解决了全排列问题,总结一下就是:
1.全排列就是从第一个数字起每个数分别与它后面的数字交换。
2.去重的全排列就是从第一个数字起每个数分别与它后面非重复出现的数字交换。
3.全排列的非递归就是由后向前找替换数和替换点,然后由后向前找第一个比替换数大的数与替换数交换,最后颠倒替换点后的所有数据。