上一篇「一文學會遞歸解題」一文頗受你們好評,各大號紛紛轉載,讓筆者頗感欣慰,不過筆者注意到後臺有讀者有以下反饋java
確實,相信不少人(包括我本身)都有相似的感慨,對某個知識點,看確實是看懂了,但若是真的再用一樣的套路再去解一些帶有一樣解題思路,但稍加變形的題,每每會一籌莫展。對這種狀況有啥好的解決辦法嗎?mysql
除了勤加練習,還有一良策!面試
魯迅先生說:若是學習算法,最好一段時間內只刷某種算法思想或某種數據結構的題,啥意思呢?好比說你上次學了遞歸,那就持續找遞歸的題來刷,學了鏈表,這段時間就專門刷鏈表的題,千萬不可今天刷遞歸,明天刷動態規劃,後天又開始學習貪心算法。。。新手最怕的就是覺得本身懂了,淺嘗輒止,這是新手的大忌!必定要對同一類型的題窮追猛打,造成肌肉記憶,這樣以後再碰到同一類型的題就會條件反射地一看:哦,這題用 xxx 思想應該能夠靠譜。算法
言歸正轉,排列組合是面試中的熱門考點
由於看似簡單的排列組合能夠有挺多的變形,根據變形,難度能夠逐漸遞增,並且排列組合自己有挺多的解法,能很好地區分一個侯選者的算法水平,排列組合若是用遞歸挺不容易理解的(反正筆者一開始看了好幾遍代碼愣是沒看懂),以後我會教你們如何用一種很是簡單地方式來理解排列組合的遞歸,這也是寫本文的根本目的sql
接下來咱們看看如何用 「遞歸四步曲」來解排列組合,本文會從如下幾個方面來說解排列組合數組
排列的定義:從n個不一樣元素中,任取 m (m≤n,m與n均爲天然數,下同)個不一樣的元素按照必定的順序排成一列,叫作從n個不一樣元素中取出m個元素的一個排列;從n個不一樣元素中取出m(m≤n)個元素的全部排列的個數,叫作從n個不一樣元素中取出m個元素的排列數,當 n = m 時,咱們稱這樣的排列爲全排列性能優化
看到這個公式,你們是否是回憶起了高中的排列公式啦
數據結構
咱們從新溫習一下,以 1, 2, 3 這三個數字的全排列有多少種呢。分佈式
第一位咱們能夠選擇 3 個數字,因爲第二位不能與第一位相等,因此第二位只能選 2 個數字,第一,第二位既然選完了,那麼第三位就只有 1 個數字可選了,因此總共有 3 x 2 x 1 = 6 種排列。
函數
既然知道了什麼是全排列,那咱們來看看怎麼用程序來打印全排列的全部狀況:
求 數字 1 到 n (n < 10) 的全排列
這道題若是暫時沒什麼頭緒,咱們看看可否用最簡單的方式來實現全排列,什麼是最簡單的方式,暴力窮舉法!
你們仔細看上文中 1,2 ,3 的全排列,就是把全部狀況所有列舉出來了,因此咱們用暴力窮舉法怎麼解呢,對每一位的每種狀況都遍歷出來組成全部的排列,再剔除重複的排列,就是咱們要的全排列了
/** * 求數字第 1 到 n 的全排列 */ public void permutation(int n) { for(int i = 1; i < n + 1; i ++) { for(int j = 1; j < n + 1; j ++) { for(int k = 1; k < n + 1; k ++) { if (i != j && i != k && j != k) { System.out.println(i + j + k); } } } } }
時間複雜度是多少呢,作了三次循環,很顯然是
$$
O(n^3)
$$
不少人一看時間複雜度這麼高,多數都會嗤之以鼻,可是要我說,得看場景,就這題來講用暴力窮舉法徹底沒問題,n 最大才 9 啊,總共也才循環了 9^3 = 729 次,這對如今的計算機性能來講簡單不值一提,就這種場景來講,其實用暴力窮舉法徹底可行!
這裏說句題外話,你們在學習的過程當中必定要視場景選擇合適的技術方案,有句話說:過早的性能優化是萬惡之源,說的就是這個道理,這就比如,一個初創公司,dau 不過千,卻要搞分佈式,中間件,一個 mysql 表,記錄不過一千,卻要搞分庫分表。。。這就搞笑了,記住沒有最牛逼的技術,只有最合適的技術!能解決當前實際問題的技術,就是好技術!
這是筆者寫此文的根本目的!就是爲了講清楚怎麼用遞歸來更好地理解排列組合!由於我發現不少網友都以爲排列組合的遞歸解法實在不能 Get 到點上, 當初筆者也是看了好幾遍代碼才勉強理解,不過過了一段時間再看又忘了,後來根據筆者悟出的一套遞歸四步曲來理解,容易多了,現與各位分享!仔細看好啦
咱們先來觀察一下規律,看下怎樣才能找出排列是否符合遞歸的條件,由於如前文 所述,必需要找出題目是否能用遞歸才能再用遞歸四步曲來解題
乍一看確實看不出什麼因此然出來,那咱們假設第一個數字已經選中了(假定爲1),問題是否是轉化爲只求後面三位數的全排列了,發現沒有,此時全排列從前面 n 位數的全排列轉化成了求以後 n-1 位數的全排列了,問題從 n 變成了 n-1,規模變小了!並且變小的子問題與原問題具備相同的解決思路,都是從求某位開始的全排列!符合遞歸的條件!
既然咱們發現排列符合遞歸條件,那咱們就能夠用遞歸四步曲來解了
一、定義函數的功能
要求數字 1 到 n 的全排列,咱們定義如下函數的功能爲求從 k 位開始的全排列,數組 arr 存的是參與全排列的 1 到 n 這些數字
public void permutation(int arr[], k) { }
二、尋找遞推公式
注意上面造成遞歸的條件:第一個數字已經選中了!那第一位被選中有哪些狀況呢,顯然有如下幾種狀況
即在第一位上把全部的數字都選一遍,怎麼作才能把全部的數字都在第一位上都選一遍呢,把第一位與其餘 n-1 位數分別交換便可(注意每一次交換前都要保證是原始順序),以下
畫外音:第一步交換本身其實就是保持不變,由於咱們要保證在第一位全部數字都能取到,若是移除了這一步,則第一位少了數字 1 ,全排列就漏了
這樣咱們就把第一位的全部數字都選了遍,以後只要對剩餘的 n-1 位數作全排列便可(即調用第一步的函數),切忌再對 n-1 再作展開,只要咱們發現遞推關係就好了,千萬不要陷入層層展開子問題的陷阱當中去!注意要從函數的功能來理解,由於問題與子問題具備相同的解決思路,因此第 1 步定義的函數對子問題(求 n-1 ,n-2 ... 的全排列)一樣適用!
那遞歸的終止條件是什麼呢 ,顯然是從 n 縮小到對最後一位的全排列(此時 k 指向 arr 的最後一個元素)
因而咱們能夠得出遞推關係爲:
permutation(int arr[], k) = 選中第k位(將第k位與以後的 n- k 位分別交換) + permutation(int arr[], k+1)
三、將第二步的遞推公式用代碼表示出來補充到步驟 1 定義的函數中,補充後的函數以下
/** * * @param arr 表明全排列數字組成的數組 * @param k 表明第幾位 */ public void permutation(int[] arr, int k) { // 當 k 指向最後一個元素時,遞歸終止,打印此時的排列排列 if (k == arr.length - 1) { System.out.println(Arrays.toString(arr)); } else { for (int i = k; i < arr.length; i++) { // 將 k 與以後的元素 i 依次交換,而後能夠認爲選中了第 k 位 swap(arr, k, i); // 第 k 位選擇完成後,求剩餘元素的全排列 permutation(arr, k+1); // 這一步很關鍵:將 k 與 i 換回來,保證是初始的順序 swap(arr, k, i); } } } public static void swap (int[] arr, int i, int j) { int t = arr[i]; arr[i] = arr[j]; arr[j] = t; }
我看網上有很多人對最後一步(如圖示)不理解
回過頭去看上面的遞歸過程圖中咱們特地強調了注意每一次交換時都要保證是原始順序
因此最後一個 swap 要作的事情就是每次交換第一個數字與後面被選中的那個數,作完以後元素的全排列以後,要把數字交換回來,以保證接下來再用第一位與其餘位的數字進行交換前是原始的序列,這樣才能保證第一位數字與以後的 n-1 個元素依次交換以後都是不重複的。
註定必定要從函數的功能去理解遞歸,全排列的函數從功能上能夠這麼理解,選中第 k 位 + 計算以後的 n-k 位的全排序, 並且因爲是遞歸,以後的 n-k 位也能夠重複調用一樣的函數持續求解!
四、求時間/空間複雜度
因爲咱們只用了一個數組 arr,因此空間複雜度顯然是 O(n),
那時間複雜度呢,仔細看上面的編碼能夠很明顯地看出計算 n 的全排列須要作 n 次循環,循環裏是要作 2 次交換(因爲是固定數字,能夠認爲是常數 C ),還有一次對以後 n-1 次元素的全排列
因此 f(n) = n * (C + f(n-1)),C是常數能夠忽略,因此
f(n) = n * f(n-1) = n * (n-1) * f(n-2) = n!,因此時間複雜度是 O(n!),注意不可能有比這個更好的時間複雜度了!由於全排列的組合自己就有 n! 次,再怎麼優化都確定會有這麼屢次
在 n 較大的狀況下顯然是不可接受的,因此咱們要想辦法進行優化
除了遞歸解法,還有一種經常使用的解法:字典排序法
啥叫字典排序法?
舉個例子: 1 2 3 這三位數字的全排列以下
1 2 3 , 1 3 2 , 2 1 3 , 2 3 1 , 3 1 2 , 3 2 1
以上排列知足從小到大依次遞增,按這種方式排列的算法就叫字典排序法。
因此咱們只要從排列的最小值開始,依次按從小到大依次遞增的順序找尋下一個全排列的數字便可,直到最大值!就能找到全部全排列。
假設咱們定義了一個叫 nextPermutation 的函數,根據字典排序法,則從最小值 123 開始,持續調用這個函數便可求出全部全排列的組合,如圖示
那麼這個函數該怎麼實現呢
有 4 個步驟
一、從右到左(從個位數往高位數)尋找第一個左鄰小於右鄰的數,若是找不到說明此時的數字爲全排列的最大值
二、再從右往左找第一個比第一步找出的數更大的數
三、交換上面兩個步驟中的數
四、假設第一步尋找的數對應的位置爲 i,則將 i+1至最後一個元素從小到大進行排序,排好序後,此時的數字就是咱們要找的那個排列
舉個例子: 假設當前給的數字是 124653, 按這四個步驟來看如何尋找這個數按字典排序法的下一個全排列數字
一、從右到左(從個位數往高位數)尋找第一個左鄰小於右鄰的數,顯然是 4
二、再從右往左找第一個比第一步找出的數(4)更大的數, 顯然是 5
三、交換上面兩個步驟中的數,即交換 4, 5,此時數字爲 125643
四、 再對 643 從小到大進行排序,顯然應該爲 125346,,這一步的排序咱們用了快排
總體思路仍是很清晰的,若是不太清楚,建議你們多看幾遍。
思路清楚了,代碼寫起來就快了,直接貼上按以上步驟來實現的代碼吧,註釋寫得很詳細了,你們能夠對照着看
/** * * @param arr 當前排列 * @return boolean 若是還有下一個全排列數,則返回 true, 不然返回 false */ public boolean next_permutation(int[] arr) { int beforeIndex = 0; //記錄從右到左尋找第一個左鄰小於右鄰的數對應的索引 int currentIndex; boolean isAllReverse = true; // 是否存在從右到左第一個左鄰小於右鄰的數對應的索引 // 1. 從右到左(從個位數往高位數)尋找第一個左鄰小於右鄰的數 for(currentIndex = arr.length - 1; currentIndex > 0; --currentIndex){ beforeIndex = currentIndex - 1; if(arr[beforeIndex] < arr[currentIndex]){ isAllReverse = false; break; } } //若是不存在,說明這個數已是字典排序法裏的最大值,此時已經找到全部的全排列了,直接打印便可 if(isAllReverse){ return false; } else { // 2. 再從右往左找第一個比第一步找出的數更大的數的索引 int firstLargeIndex = 0; for(firstLargeIndex = arr.length - 1; firstLargeIndex > beforeIndex; --firstLargeIndex) { if (arr[firstLargeIndex] > arr[beforeIndex]) { break; } } // 3. 交換 上述 1, 2 兩個步驟中得出的兩個數 swap(arr, beforeIndex, firstLargeIndex); // 4. 對 beforeIndex 以後的數進行排序,這裏用了快排 quicksort(arr, beforeIndex + 1, arr.length); return true; } } public void swap (int[] arr, int i, int j) { int t = arr[i]; arr[i] = arr[j]; arr[j] = t; }
注:以上第四步的排序用到了快排(quicksort),限於篇幅關係沒有貼出快排的完整代碼,若是不瞭解快排,建議你們網上查查看,這裏不作詳細展開
那 next_permutation 的時間複雜度是多少呢,從以上的步驟中其實能夠看到是第四步作快排時的時間複雜度,即 O(nlogn)。
next_permutation 咱們寫好了,接下來要尋找全排列就容易了,思路以下
一、 首先對參與全排列的數字數組做排序,保證初始的排列數字必定是最小的
即若是起始的 int[] arr = {4,3,2,1} 通過快排後會變成 {1,2,3,4}
二、持續調用定義好的 next_permutation 函數,直到最大值
public void permutation(int[] arr) { // 一、 快排,保證 arr 裏的元素是從小到大的 quicksort(arr); // 二、持續調用定義好的 next_permutation 函數,直到最大值 while(next_permutation(arr)) { System.out.println(Arrays.toString(array)); } }
能夠看到若是定義好了 next_permutation,在算全排列仍是很簡單的,那用字典序法的時間和空間複雜度是多少呢
因爲全程只用了arr 數組,空間複雜度顯示是 O(n)
而時間複雜度顯然是第一步快排的空間複雜度 + 持續作 next_permutation 計算的時間複雜度
快排的時間複雜度爲 O(nlogn),而 next_permutation 因爲要計算 n! 次, 且根據以上分析咱們已經知道了 next_permutation 的時間複雜度是 O(nlogn), 因此總體的時間複雜度是
O(nlog) + O(n! * nlogn) = O(n! * nlogn)。
看起來字典序法比遞歸的時間複雜度更高,因此咱們應該使用傾向於使用遞歸嗎?
這裏注意: 遞歸的實現是經過調用函數自己,函數調用的時候,每次調用時要作地址保存,參數傳遞等,這是經過一個遞歸工做棧實現的。具體是每次調用函數自己要保存的內容包括:局部變量、形參、調用函數地址、返回值。那麼,若是遞歸調用N次,就要分配N局部變量、N形參、N調用函數地址、N返回值,這勢必是影響效率的,同時,這也是內存溢出的緣由,由於積累了大量的中間變量沒法釋放。
因此在時間複雜度差很少的狀況下,優化選擇非遞歸的實現方式
看完了排列,咱們來看看組合,首先咱們仍是先看看組合的定義
組合(combination)是一個數學名詞。通常地,從n個不一樣的元素中,任取m(m≤n)個元素爲一組,叫做從n個不一樣元素中取出m個元素的一個組合。咱們把有關求組合的個數的問題叫做組合問題。
假設有數字1, 2, 3, 4, 要從中選擇 2 個元素,共有多少種組合呢
共有 6 種
排列與組合最主要的區別就是排列是有序的,而組合是無序的,12 和 21 對組合來講是同樣的
如今咱們來看看若是從 n 個元素中選出 m 的組合共有幾種,以前詳細地講解了如何用遞歸解排列,相信你們應該對組合怎麼使用遞歸應該有一個比較清晰的思路。
咱們一塊兒來看看,假設要從 n 選 m 的組合的解題思路
這裏須要注意的是相對於全排列的每一個元素都能參與排列不一樣,組合中的每一個元素有兩種狀態,選中或未選中,因此造成遞歸分兩種狀況。
若是第一個元素選中,則要從以後的 n-1 個元素中選擇 m-1 個元素
若是第一個元素未被選中,則須要從以後的 n-1 個元素選擇 m 個元素
遞歸條件既然找到了,接下來咱們就按遞歸四步曲來解下組合。
一、定義函數的功能
定義如下函數爲從數組 arr 中第 k 個位置開始取 m 個元素(以下的 COMBINATION_CNT)
public static final int COMBINATION_CNT = 5; // 組合中須要被選中的個數 public static void combination(int[] arr, int k, int[] select) { }
這裏咱們額外引入了一個 select 數組,這個數組裏的元素若是爲1,則表明相應位置的元素被選中了,若是爲 0 表明未選中
如圖示,以上表示 arr 的 第 2,3 元素被選中做爲組合
二、尋找遞推公式
顯然遞推公式爲
combination(arr, k,m) = (選中 k 位置的元素 +combination(arr, k+1) ) + (不選中 k 位置的元素 +combination(arr, k+1) )
那麼終止條件呢,有兩個
public static final int COMBINATION_CNT = 5; // 組合中須要被選中的個數 public static void combination(int[] arr, int k, int[] select) { // 終止條件1:開始選取的數組索引 超出數組範圍了 if (k >= arr.length) { return; } int selectNum = selectedNum(select); // 終止條件2:選中的元素已經等於咱們要選擇的數量了 if (selectNum == COMBINATION_CNT) { for (int j = 0; j < select.length; j++) { if (select[j] == 1) { System.out.print(arr[j]); } } System.out.print("\n"); } else { // 第 k 位被選中 select[k] = 1; combination(arr, k+1, select); // 第 k 位未被選中 select[k] = 0; // 則從第 k+1 位選擇 COMBINATION_CNT - selectNum 個元素 combination(arr, k+1, select); } } public static void main(String[] args) { int[] arr = {1,2,3,4,5,6,7,8,9}; int[] select = {0,0,0,0,0,0,0,0,0}; // 一開始從 0 開始選 組合數 combination(arr, 0, select); }
四、求時間/空間複雜度
空間複雜度:因爲咱們用了一個輔助數組 select, 因此空間複雜度是 O(n)
時間複雜度:能夠看到 f(n) = 2f(n-1),因此時間複雜度是O(2^n),顯然是指數級別的
畫外音:你們能夠考慮一下怎麼優化,提示:每種元素只有選中和被選中的狀態,是否是對應了二進制的 0 和 1,能夠考慮用位運算
通過以上的講解,我相信你們對排列組合的遞歸解法應該是很明白了,不過面試中面試官可能還會對排列組合稍加變形,以進一步考察你的算法水平。
考慮如下狀況
期待你的回答!咱們下篇見
若有幫助,歡迎關注公衆號「碼海」