字符串的全排列
題目描述:
輸入一個字符串,打印出該字符串中字符的所有排列。
例如輸入字符串abc,則輸出由字符a、b、c 所能排列出來的所有字符串:
abc、acb、bac、bca、cab 和 cba。
分析和解法:
這是典型的遞歸求解問題,遞歸算法有四個特性:
- 必須有可達到的終止條件,否則程序陷入死循環
- 子問題在規模上比原問題小
- 子問題可通過再次遞歸調用求解
- 子問題的解應能組合成整個問題的解
解法一:遞歸實現
對于字符串的排列問題:
如果能生成n-1個元素的全排列,就能生成n個元素的全排列。對于只有一個元素的集合,可以直接生成全排列。所以全排列的遞歸終止條件很明確,只有一個元素時。我們可以分析一下全排列的過程:
- 首先,我們固定第一個字符a,求后面兩個字符bc的排列
- 當兩個字符bc排列求好之后,我們把第一個字符a和后面的b交換,得到bac,接著我們固定第一個字符b,求后面兩個字符ac的排列
- 現在是把c放在第一個位置的時候了,但是記住前面我們已經把原先的第一個字符a和后面的b做了交換,為了保證這次c仍是和原先處在第一個位置的a交換,我們在拿c和第一個字符交換之前,先要把b和a交換回來。在交換b和a之后,再拿c和處于第一位置的a進行交換,得到cba。我們再次固定第一個字符c,求后面兩個字符b、a的排列
- 既然我們已經知道怎么求三個字符的排列,那么固定第一個字符之后求后面兩個字符的排列,就是典型的遞歸思路了
下面這張圖很清楚的給出了遞歸的過程:
源代碼如下:
#include <iostream>
#include <cstring>
using namespace std;
void AllPermutation(char* perm, int from, int to)
{
if(from > to)
return;
if(from == to) //打印當前排列
{
static int count = 1; //局部靜態變量,用來統計全排列的個數
cout << count++ << ":" << perm;
cout << endl;
}
if(from < to) //用遞歸實現全排列
{
for(int j = from; j <= to; j++) //第j個字符分別與它后面的字符交換就能得到新的排列
{
swap(perm[j], perm[from]);
//cout<<0;
AllPermutation(perm, from + 1, to);
//cout<<1;
swap(perm[j], perm[from]);
//cout<<2;
}
}
}
int main()
{
char str[100];
cin >> str;
AllPermutation(str, 0, strlen(str) - 1);
return 0;
}
但是如果輸入里有重復字符又該如何去掉呢?
由于全排列就是從第一個數字起,每個數分別與它后面的數字交換,我們先嘗試加個這樣的判斷——如果一個數與后面的數字相同那么這兩個數就不交換了。例如abb,第一個數與后面兩個數交換得bab,bba。然后abb中第二個數和第三個數相同,就不用交換了。但是對bab,第二個數和第三個數不同,則需要交換,得到bba。由于這里的bba和開始第一個數與第三個數交換的結果相同了,因此這個方法不行。
換種思維,對abb,第一個數a與第二個數b交換得到bab,然后考慮第一個數與第三個數交換,此時由于第三個數等于第二個數,所以第一個數就不再用與第三個數交換了。再考慮bab,它的第二個數與第三個數交換可以解決bba。此時全排列生成完畢!
這樣,我們得到在全排列中去掉重復的規則:
去重的全排列就是從第一個數字起,每個數分別與它后面非重復出現的數字交換。
源代碼如下:
#include <iostream>
#include <cstring>
using namespace std;
//在[from, to]區間中是否有字符與下標為from的字符相等
bool IsSwap(char* from, char* to)
{
char* p;
for(p = from; p < to; p++)
{
if(*p == *to)
return false;
}
return true;
}
void AllPermutation(char* perm, int from, int to)
{
if(from > to)
return;
if(from == to) //打印當前排列
{
static int count = 1; //局部靜態變量,用來統計全排列的個數
cout << count++ << ":" << perm;
cout << endl;
}
if(from < to) //用遞歸實現全排列
{
for(int j = from; j <= to; j++) //第j個字符分別與它后面的字符交換就能得到新的排列
{
if(IsSwap((perm + j), (perm + to)))
{
swap(perm[j], perm[from]);
//cout<<0;
AllPermutation(perm, from + 1, to);
//cout<<1;
swap(perm[j], perm[from]);
//cout<<2;
}
}
}
}
int main()
{
char str[100];
cin >> str;
AllPermutation(str, 0, strlen(str) - 1);
return 0;
}
分析:時間復雜度為O(n!)。這個解法不難想到,但是需要注意去除重復的那塊處理,用最后一位與前面的每個字符比較,如果相等,就不交換,否則交換。
解法二:字典序排列
首先,咱們得清楚什么是字典序。根據維基百科的定義:給定兩個偏序集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
- 過程: 從當前排列生成字典序剛好比它大的下一個排列
答案是肯定的:有,即是STL中的next_permutation算法。
在了解next_permutation算法是怎么一個過程之前,咱們得先來分析下“下一個排列”的性質。
- 假定現有字符串(A)x(B),它的下一個排列是:(A)y(B’),其中A、B和B’是“字符串”(可能為空),x和y是“字符”,前綴相同,都是A,且一定有y > x。
- 那么,為使下一個排列字典順序盡可能小,必有:
- A盡可能長
- y盡可能小
- B’里的字符按由小到大遞增排列
現在的問題是:找到x和y。怎么找到呢?咱們來看一個例子。
比如說,現在我們要找21543的下一個排列,我們可以從左至右逐個掃描每個數,看哪個能增大(至于如何判定能增大,是根據如果一個數右面有比它大的數存在,那么這個數就能增大),我們可以看到最后一個能增大的數是:x = 1。而1應該增大到多少?1能增大到它右面比它大的那一系列數中最小的那個數,即:y = 3,故此時21543的下一個排列應該變為23xxx,顯然 xxx(對應之前的B’)應由小到大排,于是我們最終找到比“21543”大,但字典順序盡量小的23145,找到的23145剛好比21543大。
由這個例子可以得出next_permutation算法流程為:
next_permutation算法
- 定義
- 升序:相鄰兩個位置ai < ai+1,ai 稱作該升序的首位
- 步驟(二找、一交換、一翻轉)
- 找到排列中最后(最右)一個升序的首位位置i,x = ai
- 找到排列中第i位右邊最后一個比ai 大的位置j,y = aj
- 交換x,y
- 把第(i+ 1)位到最后的部分翻轉
還是拿上面的21543舉例,那么,應用next_permutation算法的過程如下:
- x = 1;
- y = 3
- 1和3交換,得23541
- 翻轉541,得23145
23145即為所求的21543的下一個排列。
源代碼如下:
#include <iostream>
#include <cstring>
#include <algorithm>
#include <cassert>
using namespace std;
//反轉區間
void Reverse(char* begin, char* end)
{
while(begin < end)
swap(*begin++, *end--);
}
//下一個排列
bool NextPermutation(char* str)
{
assert(str); //檢查空指針
char *p, *q, *pFind;
char *pEnd = str + strlen(str) - 1;
if(str == pEnd)
return false;
p = pEnd;
while(p != str)
{
q = p;
p--;
if(*p < *q) //找升序的相鄰兩數,前一個數即替換數
{
//從后向前找比替換點大的第一個數
pFind = pEnd;
while(*pFind <= *p)
--pFind;
swap(*p, *pFind);
//替換點后的數全部反轉
Reverse(q, pEnd);
return true;
}
}
//如果沒有找到下一個排列,全部反轉后返回false
Reverse(str, pEnd);
return false;
}
int cmp(const void *a,const void *b)
{
return int(*(char *)a - *(char *)b);
}
int main()
{
char str[100];
cin >> str;
int count = 1;
qsort(str, strlen(str), sizeof(char), cmp);
do
{
cout << count++ << ":" << str << endl;
}while(NextPermutation(str));
return 0;
}
分析: 時間復雜度為O(n!)。這個版本是可以有重復字符的。
特別注意:
- 一定要注意邊界條件和判斷條件,到底是 > 還是 >= ,會影響結果。
參考資料:《編程之法》The Art of Programming By July
字符串的全排列和組合算法