遞歸與分治策略是五大常見算法策略之一,分治策略的思想就是分而治之,即先將一個規模較大的大問題分解成若干個規模較小的小問題,再對這些小問題進行解決,獲得的解,在將其組合起來獲得最終的解。而分治與遞歸不少狀況下都是一塊兒結合使用的,能發揮出奇效(1+1>2),這篇文章咱們將先從遞歸提及,再逐漸向分治過渡,主要講解方式是經過9個例題來講明問題的,問題都是根據難度由簡到難,由淺入深,對遞歸與分治能有個大概的瞭解雛形,固然最重要仍是要作大量練習才能掌握。html
0、遞歸
0.0、Fibonacci數列(易)
0.一、階乘(易)
0.二、小青蛙跳臺階(易)
0.三、全排列問題(偏難)
0.四、整數劃分(偏難)
一、分治策略
1.0、歸併排序(通常)
1.一、二分查找(易)
1.二、棋盤覆蓋(偏難)
1.三、日程表問題(偏難)java
咱們第一次接觸遞歸通常都是在初學C語言時候的一道題目——Fibonacci數列中看到的,可能剛開始感受有點難以想象,函數竟然能夠調用本身!Amazing!但事實如此,它確實存在,而遞歸也爲咱們某些算法的設計提供很大的便利,通常來講遞歸函數在理解起來並非很難,甚至能夠經過數學概括法給予證實,但一直讓人詬病的一點莫過於Debug的時候了,有時候調試一個較爲複雜的遞歸函數能把人逼瘋。算法
咱們在這裏將會由易到難,用一些例題先來說解遞歸函數。採用Fibonacci數列來作這個引例來介紹遞歸函數。數組
第一個數是1,第二個數也是1,從第三個數開始,後面每一個數都等於前兩個數之和。要求:輸入一個n,輸出第n個斐波那契數。
咱們先來整理一下思路,分下面三步來看:數據結構
第一步,函數輸入n,輸出(也就是返回)第n個斐波那契數:函數
public static int fibonacci(int n){ }
第二步,明確遞歸終止條件:學習
public static int fibonacci(int n){ if(n == 1) return 1; else if (n == 2) return 1; }
第三步,尋找函數的遞歸關係:.net
public static int fibonacci(int n){ if(n == 1) return 1; else if(n == 2) return 1; else return fibonacci(n - 1) + fibonacci(n - 2); }
就這樣,咱們的一個斐波那契數列的遞歸函數就寫完了。固然,這只是咱們的一個開胃小菜,下面繼續是入門級別的一個題,算階乘。設計
輸入一個數,輸出它的階乘。咱們一樣用那三步往下走。
第一步,函數輸入n,返回n的階乘3d
public static int factorial(int n){ }
第二步,明確遞歸終止條件:
public static int factorial(int n){ //0的階乘等於1 if(n == 0) return 1; }
第三步,尋找函數的遞歸關係
public static int factorial(int n){ //0的階乘等於1 if(n == 0) return 1; else return factorial(n - 1) * n; }
作完前兩個你確定會以爲這不是很簡單嗎,不要急,咱們要由易到難,由淺入深,這樣階梯式的科學學習。下面這個例子是小青蛙跳臺階問題,這個問題被用於遞歸和動態規劃類問題的例題,咱們這裏先用遞歸解答,後面還會用動態規劃策略來解決這個問題。
一隻青蛙一次能夠跳上1級臺階,也能夠跳上2級,求該青蛙跳上一個n級的臺階共有多少種跳法。
仍是三步走,第一步,明確函數的輸入及返回
public static int Jump_Floor1(int n){ }
第二步,明確遞歸終止條件
若是n=1,那小青蛙只能一次跳上第一節臺階,因此一種跳法,若是n=2,那小青蛙能夠一次跳一節跳兩次,或者直接一次跳兩節,因此兩種跳法。
public static int Jump_Floor1(int n){ if(n <= 2){ return n; } }
第三步,尋找函數的遞歸條件
這裏可不能簡單的return Jump_Floor1(n-1)就完事兒了,分了兩種狀況:一、第一次跳1級就還有n-1級要跳,二、第一次跳2級就還有n-2級要跳
public static int Jump_Floor1(int n){ if(n <= 2){ return n; }else{ //這裏涉及到兩種跳法,一、第一次跳1級就還有n-1級要跳,二、第一次跳2級就還有n-2級要跳 return Jump_Floor1(n-1)+Jump_Floor1(n-2); } }
下面這個例題是排列問題,就是求出一組數的全排列。
咱們在全排列問題種須要用到一個交換函數swap用於交換兩個數的位置,做以下定義:k數組種元素爲待排列元素,k和m爲待交換兩元素的下標
private static void swap(int a[], int k, int m){ //交換k和m下標的元素的值 int temp = a[k]; a[k] = a[m]; a[m] = temp; }
接下來繼續回到遞歸函數
第一步,明確函數的輸入以及返回,這裏咱們須要輸入待排列元素組成的數組,數組的第一個元素的下標,以及最後一個元素的下標
public static void perm(int a[], int k, int m){ }
第二步,明確遞歸終止條件,就是當只剩下一個元素時
public static void perm(int a[], int k, int m){ if(k == m) { //只有一個元素 for (int i = 0; i <= m; i++) { System.out.print(a[i]+" "); } System.out.println(); } }
第三步,尋找遞歸條件
public static void perm(int a[], int k, int m){ if(k == m) { //只有一個元素 for (int i = 0; i <= m; i++) { System.out.print(a[i]+" "); } System.out.println(); }else{ //還有多個元素,遞歸產生排列 for (int i = k; i <= m; i++) { swap(a,k,i); //排列到這個元素就要將其放在第一個位置 perm(a,k+1,m); swap(a,k,i); //今後出口出去後還須要將剛剛調換的位置換回來 } } }
下面是遞歸這塊的最後一個例題了,整數劃分問題。
說明一下問題,什麼是整數劃分?
算法思路:咱們用q(n,m)表示將n用不大於m的數字劃分的方法的個數
第一步,明確函數輸入和返回
public static int equationCount(int n, int m){ }
第二步,明確遞歸終止條件
public static int equationCount(int n, int m){ if (n < 1 || m < 1) return 0; if(n == 1 || m == 1) return 1; }
第三步,尋找遞歸關係
public static int equationCount(int n, int m){ if (n < 1 || m < 1) return 0; if(n == 1 || m == 1) return 1; if(n < m) return equationCount(n,n); if(n == m) return equationCount(n,m-1)+1; return equationCount(n-m,m)+equationCount(n,m-1); //n > m的狀況 }
分治策略的基本思想就是將一個規模爲n的問題分解成k個規模較小的子問題,這些子問題互相獨立且與原問題相同。遞歸的解這些子問題,而後將子問題的解合併獲得原問題的解,和這種說法最貼切的就是咱們以前一篇文章介紹的歸併排序法了,這篇文章裏咱們還會再引出一遍。
咱們將分治策略解決問題的步驟概括爲:將大問題分解成子問題,分別解決子問題,再將子問題的解合併成大問題的解.
先看第一個典型的例子——歸併排序
這裏咱們對歸併排序主要注重體現它分治策略的算法邏輯,而不過多深究這個排序算法是如何執行的,具體的圖解歸併排序請移步個人另外一篇博文——數據結構之——八大排序算法。
歸併排序的思想是,先將數組分割成爲一個個小數組,直到每一個小數組中只含有一個元素,那麼在這一個小數組裏面,這一個元素天然就是有序的,而後將其合併起來(由merge函數實現),按從小到大的順序,逐層向上,就是將小問題的解合併爲大問題的解。
下面是將大問題分解成小問題的過程
/** * 只要數組的大小不爲1,就一直分割,直到不能分割爲止(即數組長度爲1), * 不能分割後按照出入棧順序,會將分割的小數組分別排序後歸併起來 * @param data 待排序數組 * @param start 起始位置 * @param end 終止位置 */ public static void merge_sort(int data[], int start, int end){ int mid = (start+end)/2; if(start < end){ merge_sort(data,start,mid); merge_sort(data,mid+1,end); merge(data,start,mid,end); } }
下面是合併小問題的解,歸併過程
/** * 這個函數是將數組合並在一塊兒的,其實並無將數組真的分開,只是用start和end指示不一樣的元素,來達到分割的目的 * @param p 指示子數組1的元素 * @param q 指示子數組2的元素 * @param r 指示合併後數組的元素 * @param start start到mid是須要合併的子數組1 * @param mid * @param end mid+1到end是須要合併的子數組2 */ private static void merge(int data[], int start, int mid, int end){ int p = start, q = mid+1, r = 0; int newdata[] = new int[end-start+1]; while(p <= mid && q <= end){ if(data[p] >= data[q]){ //從大到小排序 newdata[r++] = data[p++]; }else{ newdata[r++] = data[q++]; } } //此時,兩個子數組中會有一箇中元素還未被所有歸併到新數組中,做以下處理 while(p <= mid){ newdata[r++] = data[p++]; } while(q <= end){ newdata[r++] = data[q++]; } //再將有序的數組中的值賦給原數組,其實也能夠直接返回這個新數組 for (int i = start; i <= end; i++) { data[i] = newdata[i-start]; } }
而後是分治策略的另外一個經典例子———二分查找,顧名思義,就是在一個有序(從小到大)的數組中查找一個元素的位置,先從最中間將數組變爲兩個小數組,而後與中間值進行對比,若是相等直接返回,不相等又分兩種狀況,若是中間元素比待查找值小,就從後半個數組中繼續二分查找,反之,從前半個數組中二分查找。
public static int Binary_Search(int []data, int x, int n){ //data爲待搜索數組(有序),x爲待搜索元素,n爲數組大小 int left = 0, right = n - 1; //指示左右的兩個指示器 while(left <= right){ //left能夠等於right,由於有可能恰好兩個指示器同時指示到了待查找元素上 int mid = (left+right)/2; if(data[mid] > x) right = mid-1; else if(data[mid] < x) left = mid+1; else return mid; } return -1; //表示查找失敗 }
下面咱們逐漸加大難度,接下來這個問題叫作棋盤覆蓋,咱們先簡單介紹一下這個問題。
在一個2^k × 2^k (k≥0)個方格組成的棋盤中,恰有一個方格與其餘方格不一樣,稱該方格爲特殊方格。顯然,特殊方格在棋盤中可能出現的位置有4^k種,於是有4^k種不一樣的棋盤,圖4.10(a)所示是k=3時64種棋盤中的一個。棋盤覆蓋問題(chess cover problem)要求用圖4.10(b)所示的4種不一樣形狀的L型骨牌覆蓋給定棋盤上除特殊方格之外的全部方格,且任何2個L型骨牌不得重疊覆蓋。
圖4.10(a)
圖4.10(b)
在這裏爲了方便講解,咱們採用k=2時候的狀況來講明這個問題,設初始狀況爲
第一次將其分割成四個小塊,分紅了四個子棋盤,以黃線爲分割線
而後分別對其進行填充
填充完後,又能夠將其分割
重複上述填充操做,便可對全部方格填充
當k更大的時候的過程能夠參考這位大佬的博客棋盤覆蓋問題,接下來咱們用代碼實現。
static int board[][] = new int[4][4]; //棋盤 static int tag = 1; //骨牌編號 /** * 分治算法典例2———棋盤覆蓋問題 * @date 2019/11/3 afternoon * @param tr 棋盤左上角方格的行號 * @param tc 棋盤左上角方格的列號 * @param dr 特殊方格所在的行號 * @param dc 特殊方格所在的列號 * @param size 棋盤寬度 * @param s 當前棋盤寬度的一半 * @param tr+s 當前棋盤中間行的行號 * @param tc+s 當前棋盤中間列的列號 */ public static void chess(int tr, int tc, int dr, int dc, int size){ if(size == 1) return; int newtag = tag++; int s = size / 2; //分割棋盤 //覆蓋左上角子棋盤 if(dr < tr+s && dc < tc+s){ //特殊方格在此棋盤中 chess(tr,tc,dr,dc,s); }else{ //此棋盤中無特殊方格 board[tr+s-1][tc+s-1] = newtag; chess(tr,tc,tr+s-1,tc+s-1,s); } //覆蓋右上角子棋盤 if(dr < tr+s && dc >= tc+s){ chess(tr,tc+s,dr,dc,s); }else{ board[tr+s-1][tc+s] = newtag; chess(tr,tc+s,tr+s-1,tc+s,s); } //覆蓋左下角子棋盤 if(dr >= tr+s && dc < tc+s){ chess(tr+s,tc,dr,dc,s); }else{ board[tr+s][tc+s-1] = newtag; chess(tr+s,tc,tr+s,tc+s-1,s); } //覆蓋右下角子棋盤 if(dr >= tr+s && dc >= tc+s){ chess(tr+s,tc+s,dr,dc,s); }else{ board[tr+s][tc+s] = newtag; chess(tr+s,tc+s,tr+s,tc+s,s); } }
接下來的問題依然有一些難度,叫作打印日程表問題。
問題:設有n=2^k個選手參加循環賽,要求設計一個知足如下要求比賽日程表:
1)每一個選手必須與其它n-1個選手各賽一次;
2)每一個選手一天只能賽一次。
分析,按照上面的要求,能夠將比賽表設計成一個n行n-1列的二維表,其中第i行第j列的元素表示和第i個選手在第j天比賽的選手號。
採用分治策略,可將全部參加比賽的選手分紅兩部分,n=2^k 個選手的比賽日程表就能夠經過n=2^(k-1) 個選手的的比賽日程表來決定。遞歸的執行這樣的分割,直到只剩下兩個選手,比賽日程表的就能夠經過這樣的分治策略逐步構建。
說個大白話就是:先默認構造日程表第一行,即0,1,2,3,...而後先分割日程表,將左上角複製到右下角,右上角複製到左下角
初始化第一行不作贅述,讓chess[0][i] = i+1便可
下面在Excel中用圖示作一演示
代碼實現
/** * 將比賽日程表設計成n行n列,表中除了第一列,其餘n-1列纔是咱們要的,數組下標行列都從0開始,第i行j列表明第(i+1)位選手在第j天的對手: * 表格初始化會將第一行按1到n一次填充,而後遞歸填充下面的,用左上角和右上角分別去填充右下角和左下角,由於要是對稱矩陣(具體緣由好好想一想) * @param p 表示行序號 * @param q 表示列序號 * @param t 表示當前傳進函數方格的規模也就是大小 * @param arr 表格 */ public static void arrange(int p, int q, int t, int arr[][]){ if(t>=4){ //若是規模大於4,就繼續遞歸 arrange(p,q,t/2,arr); arrange(p,q+t/2,t/2,arr); } //填左下角 for(int i=p+t/2;i<p+t;i++){ for(int j=q;j<q+t/2;j++){ arr[i][j]=arr[i-t/2][j+t/2]; } } //填右下角 for(int i=p+t/2;i<p+t;i++){ for(int j=q+t/2;j<q+t;j++){ arr[i][j]=arr[i-t/2][j-t/2]; } } }