什麼是全排列?
從n個不一樣元素中任取m(m≤n)個元素,按照必定的順序排列起來,叫作從n個不一樣元素中取出m個元素的一個排列。當m=n時全部的排列狀況叫全排列。
那麼ABC的全排列有哪些?根據定義獲得:
ABC
ACB
BAC
BCA
CAB
CBAjava
如何經過程序求解?
方法一:
暴力法,爲何是暴力法?由於暴力是機器惟一聽得懂的語言。
如何暴力?
對一個空的字符串添加字母,添加三次,這個字母是ABC這三個中的一個。
每添加完三個字母后,也就是獲得一個排列之後,咱們要檢查這是否是個有效的排列。
若是是就輸出,不然跳過。
有效的排列是指什麼?是排列的全部數字都不相同,這裏我使用雙重循環來判斷。
這個判斷函數複雜度較高爲O(N²),可是容易理解,因此目前就先不使用更高效的算法。算法
java 代碼:segmentfault
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)個位置而已。spa
如今咱們逆向思惟來考慮(主動和被動)!
以前考慮的是寫for循環來填充候選答案向量,如今換個想法,咱們的候選答案向量要被填充。
當候選答案向量的每一維都被填充好,那麼就產生了一個候選答案。
怎樣用代碼來描述這樣一個過程呢?遞歸!雖然很難想到,可是使用遞歸確實能夠描述這個過程。
在遞歸的過程當中,使用一個變量k表示當前正在填充的候選答案向量的下標(0到n-1,n是排列長度)。那麼
當k等於n的時候,也就表明當前正在填充的是候選答案向量的下標是n,而n已經超出了該向量,那麼也就意味着填充結束!rest
java 代碼:code
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,意義是回溯!
從各個角度看,這裏的回溯和剛纔的方法惟一不一樣的就是名字好聽,比較高大上,代碼簡短優美。
有人可能會說上面的那種作法是後剪枝,回溯是先剪枝。不過其實二者是一回事,先剪晚剪都是
剪。
所以!!!
回溯其實就是剪了枝的深度優先搜索!!!
說到底,回溯就是個深度優先搜索算法,即使是剪了枝的,也掩蓋不了它是個暴力解法。
既然:深度優先搜索+剪枝=回溯。
那麼:寬度優先搜索+剪枝=???
這個我以後有時間再寫。
搜索很暴力,很無腦,很低效,但是有一種稱之爲記憶化的方法,卻可以明顯改善它的性能。
甚至可使得搜索的效率比強大的動態規劃都要好!
這就像是小孩子同樣,沒受教育以前很頑劣,受過教育以後就好像變了一我的同樣。
有關記憶化搜索,我也有時間再寫!
爲何要從暴力法開始講起?由於暴力是機器惟一聽得懂的語言。