什麼是全排列?
從n個不同元素中任取m(m≤n)個元素,按照一定的順序排列起來,叫做從n個不同元素中取出m個元素的一個排列。當m=n時所有的排列情況叫全排列。
那麼ABC的全排列有哪些?根據定義得到:
ABC
ACB
BAC
BCA
CAB
CBA
如何通過程序求解?
方法一:
暴力法,爲什麼是暴力法?因爲暴力是機器唯一聽得懂的語言。
如何暴力?
對一個空的字符串添加字母,添加三次,這個字母是ABC這三個中的一個。
每添加完三個字母后,也就是得到一個排列以後,我們要檢查這是不是個有效的排列。
如果是就輸出,否則跳過。
有效的排列是指什麼?是排列的所有數字都不相同,這裏我使用雙重循環來判斷。
這個判斷函數複雜度較高爲O(N²),但是容易理解,所以目前就先不使用更高效的算法。
java 代碼:
public class Main {
public static void main(String[] args) throws Exception{
//等待求解的全排列集合
char[]num = new char[]{'A','B','C'};
for(int i = 0;i < num.length;i++)
for (int j = 0;j < num.length;j++)
for (int k = 0;k < num.length;k++)
{
char[]temp = new char[3];
temp[0] = num[i];
temp[1] = num[j];
temp[2] = num[k];
if(is_Legal(temp))
System.out.println(temp);
}
}
static boolean is_Legal(char[]temp)
{
for(int i = 0;i < temp.length;i++)
for(int j = i+1;j < temp.length;j++)
if(temp[i] == temp[j])
return false;
return true;
}
}
可以看到,通過3個for循環,不斷填充候選答案的第0項,第1項,第2項。這樣可以產生所有的候選答案,然後通過對每個候選答案判斷是否合法來選擇輸出與否。
不過這裏產生了兩個問題。
1:如果現在求的全排列不是3個數,而是10個數甚至20個數,那怎麼辦?要寫十多個for循環?這樣豈不是要累死。
2:是否有必要產生所有的候選答案?換句話說,有些候選答案在產生過程中就已經是不合法的了,那麼我們還有必要將這個候選答案完全“填充”嗎(爲什麼要加深"填充"?因爲很重要!)?
比如說AAB這個候選答案,在產生AA的時候就已經不合法了(不管第三個數填什麼都是非法的)。
第一個問題,實際上是代碼編寫技巧的問題,比較容易解決,使用模板即可。那我們先來解決第一個問題!
Let's start!
我們發現,每個for循環做的事情,就是填充候選答案向量的某個位置,並且是固定的,第一個for就填充候選答案向量的第1個位置(下標是0),第二個for循環填充第2個位置,第三個for循環填充第3個位置。
那麼如果寫100個for循環,原理也是一樣,不過就是填充第100(候選答案向量的下標是99)個位置而已。
現在我們逆向思維來考慮(主動和被動)!
之前考慮的是寫for循環來填充候選答案向量,現在換個想法,我們的候選答案向量要被填充。
當候選答案向量的每一維都被填充好,那麼就產生了一個候選答案。
怎樣用代碼來描述這樣一個過程呢?遞歸!雖然很難想到,但是使用遞歸確實可以描述這個過程。
在遞歸的過程中,使用一個變量k表示當前正在填充的候選答案向量的下標(0到n-1,n是排列長度)。那麼
當k等於n的時候,也就代表當前正在填充的是候選答案向量的下標是n,而n已經超出了該向量,那麼也就意味着填充結束!
java 代碼:
public class Main {
public static void main(String[] args) throws Exception{
//等待求解的全排列集合
char[]num = new char[]{'A','B','C'};
dfs(num,0,num.length,new char[num.length]);
}
static void dfs(char[]num,int k,int n,char[]temp)
{
if(k == n)
{
if(is_Legal(temp))
System.out.println(temp);
return;
}
for(int i = 0;i < num.length;i++) {
temp[k] = num[i];
dfs(num, k + 1, n, temp);
}
}
static boolean is_Legal(char[]temp)
{
for(int i = 0;i < temp.length;i++)
for(int j = i+1;j < temp.length;j++)
if(temp[i] == temp[j])
return false;
return true;
}
}
細心的讀者可能注意到了,遞歸函數的名字是dfs。這是什麼意思呢?這是深度優先搜索!
搜索?遍歷?傻傻分不清。
它真的是深度優先搜索嗎?是真的嗎?
是真的!
如果是的話,那它的搜索空間(解空間)是什麼?
是向量[x,y,z]組成的集合,而x,y,z in {'A','B','C'}。in代表前面的變量是後面{}裏的某個元素。
這是一個基於3維解空間的深度優先搜索!
至此,第一個問題已經解決!
接下來我們來看第二個問題!
Exciting!
是否有必要產生所有候選答案?當然沒有!只要我們在產生候選答案向量的時候,每一次填充完都判斷
這次填充是否合法,如果不合法則不再繼續填充。(不過第一次填充不需要判斷,想想爲什麼?)
java 代碼:
public class Main {
public static void main(String[] args) throws Exception{
//等待求解的全排列集合
char[]num = new char[]{'A','B','C'};
dfs(num,0,num.length,new char[num.length]);
}
static void dfs(char[]num,int k,int n,char[]temp)
{
if(k == n)
{
System.out.println(temp);
return;
}
for(int i = 0;i < num.length;i++) {
//每次填充完就判斷,如果不合法,則根本不會向下進行!
temp[k] = num[i];
if(is_Legal(temp,k+1))
dfs(num, k + 1, n, temp);
}
}
//cur代表這是第幾次填充,第cur次填充對應着填充
//第cur-1下標的地方,因此上面調用時爲下標+1,也就是k+1
static boolean is_Legal(char[]temp,int cur)
{
for(int i = 0;i < cur;i++)
for(int j = i+1;j < cur;j++)
if(temp[i] == temp[j])
return false;
return true;
}
}
也可以在最前面那種三個for循環裏每一次都判斷,比較簡單,讀者可以自行嘗試。
不知道讀者是否聽說過剪枝這個詞但卻一直無法理解它的含義。
可以明確是,上面的這個判斷就是所謂的剪枝!
爲什麼理解不了剪枝?因爲從代碼和算法裏只能看到剪,而看不到枝。既然是剪枝,那麼必須要又枝給你剪才行啊!!!那麼這枝在哪呢?
看一下我畫的圖,最左邊是候選答案下標。然後右邊表明了每一層填充的是哪些字母。這個填充過程像是一顆三叉樹,但是這個樹實際上不存在的,這只是邏輯上的樹而已,而這個邏輯上的樹(或圖)上的路徑我們把它稱之爲枝,剪枝的意思就是把這棵邏輯上的樹(或圖)的某條路徑剪去。
那麼對於這個問題,當填充完第1層的時候,哪些路徑被剪去了呢?答案是AA,BB,CC。
不過這個圖我畫的並不完整,因爲缺少了第3層(只有0,1,2層),第三層是最終的答案,讀者可以自行嘗試畫出。
至此第二個問題也已經解決!
讀者的內心是不是“這和回溯有毛線關係啊?”彆着急,接着看。
Interesting!
不知道讀者有沒有覺得,上面的寫法很醜陋?我們剪枝與否爲什麼填充完結果才能判斷?
難道就不能一開始就知道哪個字母能填哪個不能填嗎?
就像是站在上帝的視角上看這個問題,好像通靈萬物,未卜先知,洞悉一切一樣。
這個能確實做到,不過不能未卜先知,但是可以利用之前的結果來先知!
我們在遞歸程序中添加一個boolean類型的數組(或hash表),來記錄哪個字母現在已經在候選答案向量中了,這樣一來,凡是不在的我都可以添加進去,而已經在候選答案向量中的不可添加。
當然也可以不使用一個額外的表去存儲哪些字母已經在答案向量中,而是直接在答案向量中查找,因爲答案向量已經記錄了哪些字母在,哪些字母不在了,只不過這樣的話查詢的時間消耗比用Hash表大!不過原理一樣,讀者可以自行嘗試!
需要注意的是,添加一個字母到候選答案向量中的時候,就要把該字母加入表中,而當這個字母不在答案向量中時需要及時移除。
java 代碼:
public class Main {
public static void main(String[] args) throws Exception{
//等待求解的全排列集合
char[]num = new char[]{'A','B','C'};
backtrack(num,0,num.length,new char[num.length],new boolean[num.length]);
}
static void backtrack(char[]num,int k,int n,char[]temp,boolean[]hash)
{
if(k == n)
{
System.out.println(temp);
return;
}
for (int i = 0;i < num.length;i++)
//如果不在候選答案向量中則添加該字母
if( !hash[i] )
{
hash[i] = true;
temp[k] = num[i];
backtrack(num,k+1,n,temp,hash);
//下一個for循環的時候就是放該層的
// 下一個可以放的字母,這輪循環放的是這個字母
//那麼下一輪循環顯然放的不是這個字母了,那麼這個字母需要被
//移除出hash表
hash[i] = false;
}
}
}
函數名是backtrack,意義是回溯!
從各個角度看,這裏的回溯和剛纔的方法唯一不同的就是名字好聽,比較高大上,代碼簡短優美。
有人可能會說上面的那種做法是後剪枝,回溯是先剪枝。不過其實兩者是一回事,先剪晚剪都是
剪。
因此!!!
回溯其實就是剪了枝的深度優先搜索!!!
說到底,回溯就是個深度優先搜索算法,即便是剪了枝的,也掩蓋不了它是個暴力解法。
既然:深度優先搜索+剪枝=回溯。
那麼:寬度優先搜索+剪枝=???
這個我之後有時間再寫。
搜索很暴力,很無腦,很低效,可是有一種稱之爲記憶化的方法,卻能夠明顯改善它的性能。
甚至可以使得搜索的效率比強大的動態規劃都要好!
這就像是小孩子一樣,沒受教育之前很頑劣,受過教育之後就好像變了一個人一樣。
有關記憶化搜索,我也有時間再寫!
爲什麼要從暴力法開始講起?因爲暴力是機器唯一聽得懂的語言。