++递归 字符串全排列_超全递归技巧整理,这次一起拿下递归
0. 前言
大家好,我是多選參數的程序鍋,一個正在 neng 操作系統、學數據結構和算法以及 Java 的硬核菜雞。本篇將主要介紹遞歸相關的內容,下面是本篇的內容提綱。
1. 遞歸基礎
★?爭哥:從我自己學習數據結構和算法的經歷來看,我覺得最難理解的知識點,一個是動態規劃,另一個是遞歸。好吧,在眾多不太熟練的數據結構和算法中,我也是這兩個。
”**遞歸從編程形式上看是函數自己調用自己,是一種編程方法。**很多數據結構和算法的實現都會采用遞歸這種方式,比如 DFS 深度優先搜索、前中后序二叉樹遍歷等等。那么怎么理解遞歸呢?遞歸其實分為兩個過程,去的過程叫過“遞”,回來的過程叫做"歸"。比如我們坐在電影院里看電影,想知道自己坐的是第幾排(別說電影票上有寫),那么我們會問前面一排的人,它是第幾排,這個過程叫過“遞”;之后前面一排的人同樣會問再前面一排的人他是第幾排,以此類推。當問到第一排的人之后,第一排的人向第二排的人回了個 1,以此類推;我們前面一排的人會給我們回了個第 n-1 排,那么這個過程叫做“歸”,從而得到我們是第 n 排。
1.1. 遞歸使用需要滿足的三個條件
要想使用遞歸一定要以下這三個條件,簡單來說就是可以分解成子問題,這些子問題的解法和原問題思路一樣,有終止條件。
- 一個問題的解可以分成幾個子問題的解。子問題的意思是數據規模更小的問題,也就是說一個數據規模比較大的問題解可以由幾個數據規模比較小的問題的解組成。
- 子問題除了數據規模不同之外,求解思路完全一樣。也就是子問題的求解方法和當前問題的求解方法是一樣。
- 存在遞歸終止條件。當前問題會被分解成子問題,子問題又會被分解成更小的子問題,以此類推下去,顯然不能無限遞下去,一定要終止條件,從而有歸的過程。
1.2. 編寫遞歸代碼的技巧
寫遞歸代碼最關鍵的是找到大問題分解為小問題的規律,并且基于此寫出遞推公式;之后再確定終止條件(也叫做基線條件);最后將這些翻譯成代碼即可。
另外在編程思考遞歸過程的時候,千萬不要鋪開模擬遞歸的過程,也就是千萬不要試圖想清楚整個遞和歸的過程,這種實際上會進入一個思維誤區。其實,**只需要考慮兩層即可,即假設子問題已有答案,然后思考原問題和子問題的解怎么聯系起來。**比如一個問題 A 可以分解為若干子問題 B、C、D,那么假設子問題 B、C、D 已經解決,在此基礎上思考如何解決問題 A 即可。不要去想一層層調用關系,不要試圖用人腦分解遞歸的每個步驟,屏蔽掉這些細節。
1.3. 遞歸方式存在的弊端
在遞歸實現代碼時,會遇到很多問題,比如堆棧溢出、重復計算、函數調用耗時多、空間復雜度高等問題。
堆棧溢出
因為遞歸的本質是函數調用,而函數調用過程中會使用棧來保存臨時變量(棧中保存著未完成的函數調換用)。如果遞歸求解的數據規模很大,調用層次很深,一直壓入棧,就會有棧溢出的風險。
那么如何避免棧溢出呢?可以設置遞歸的層次,一旦超過一定層次之后,就不在往下遞歸了,直接返回報錯。但是這種方式不能完全解決問題,因為可能層次設置太大,在未達到一定層次之前就已經棧溢出了。因此,這種方式適合最大深度比較小的。
重復計算
在遞歸的過程中還會出現重復計算的問題,如下面這個遞歸過程中就存在大量的重復計算:想要計算f(5),需要先計算 f(4) 和 f(3) ,而計算 f(4) 又會計算 f(3),f(3) 就被重復計算了。
為了避免重復計算,可以使用一個數據結構(比如散列表)來保存已經求解過的 f(k) 值。當遞歸到 k 的時候判斷,f(k) 是否已經求解過了,如果求解過了,那么直接返回,不需要重復計算。
函數調用耗時多、空間復雜度高
遞歸中會涉及到很多函數調用,當函數調用的數量比較多的時候,會使得耗時比較多。同時,由于調用一次就會在內核棧中保存一次現場數據,因此空間復雜度也會比較大。
1.4. 如何改寫為非遞歸代碼
針對上述遞歸存在的問題,可以將遞歸代碼轉化為非遞歸的形式。**一般來說,遞歸代碼都可以改寫成非遞歸代碼的形式。**因為遞歸本身就是借助棧來實現的,只不過遞歸使用的是系統?;蛘咛摂M機提供的。假如我們自己實現一個棧,模擬入棧、出棧的過程的話,那也是可以的(比如圖的深度優先比例時可以使用棧和循環來實現,一般情況都是使用遞歸)。
上述說到了模擬棧的方式,但是在有些遞歸代碼改為非遞歸代碼的形式中,不一定要那么做。**對于同一個問題而言,遞歸代碼是從最大的問題開始,先層層分解,分解完成之后會得到結果,再將結果層層返回,這是有一個有去有回的過程;假如我們知道子問題的答案的話,可以直接從子問題的答案開始,然后子問題求出大的問題的答案,這種相當于只取了歸的過程。**比如有這么個遞歸式:f(n)=f(n-1)+1,終止條件是f(1)=1,那么改為非遞歸的形式,如下所示。下面這種方式,其實就相當于從子問題的答案出發,從而推得更大問題的解,比如 f(1) = 1,推得 f(2) = f(1)+1=2。
int f(int n) {int ret = 1;
for (int i=2; i <= n; ++i) {
ret = ret + 1;
}
return ret;
}
從上述的例子中我們可以得出這樣一句話,使用遞歸可以讓解決方法更清晰,但是并沒有性能上的優勢;而使用循環的性能更好。
2. 遞歸樹---遞歸代碼的復雜度分析
遞歸代碼的復雜度一般比較難分析,一般可以通過遞推公式推導的方式來求解復雜度。但是有時候遞推公式推導比較繁瑣,這個時候我們可以使用遞歸樹的方式來分析遞歸算法的復雜度。(個人認為其實掌握遞歸樹即可,遞推公式最終也可以轉換為遞歸樹,只是遞推公式時沒有顯式的樹過程)。
遞歸的思想就是將大問題分解為小問題來求解,然后再將小問題分解成小小問題。這樣一層層分解,直到問題的數據規模被分解得足夠小,不用繼續遞歸分解為止。那么將這個過程畫成一顆樹,這顆樹就叫做遞歸樹。
比如斐波那契使用遞歸的方式求解時,就可以畫出下面這樣一顆遞歸樹。節點里的數字表示數據的規模,一個節點的求解可以分解為左右子節點兩個子問題的求解。
下面通過舉幾個例子來講解遞歸樹求解的方法。
2.1. 歸并排序
歸并排序的每次分解都是一分為二,整個遞歸過程畫成遞歸樹之后如圖所示。m(n) 的時間復雜度為 m(n/2) 的時間復雜度乘以 2,加上合并所需要的時間復雜度。而 m(n/2) 的時間復雜度等于 m(n/4) 的時間復雜度乘以 2,加上合并所需要的時間度。以此類推,最終時間復雜度為 m(1) 乘以 n,再加上這過程的合并操作所需的時間復雜度。
每一層合并操作所需要的時間復雜度是 O(n),m(1) 的時間復雜度為 O(1)。合并的次數為高度(從 0 開始算),那么最終時間復雜度為?(高度+1)*O(n)。從歸并排序的原理和遞歸樹來看,歸并排序的遞歸樹是一顆滿二叉樹。那么這顆數的高度為?,因此最終時間復雜度為 O(nlogn)。
2.2. 快速排序
快速排序在最好情況下,每次分區都能一分為二,那么此時快速排序的遞歸樹和時間復雜度都和歸并排序一樣,都是 O(nlogn)。那么,針對不是一分為二的情況。比如很槽糕的情況,每次都是 1:9 的話。那么對應的遞歸樹如圖所示。
快速排序時,都需要先分區,然后再遞歸。在分區時,需要遍歷區間內的所有數據。因此,每一層的分區操作所遍歷的數據個數之和是 n。同樣,我們需要求出樹的高度,時間復雜度即為?高度*O(n)。由于每次分區并不是均勻地一分為二,因此此時的遞歸樹不是滿二叉樹。但是此遞歸樹最長高度可以求得,即最右邊的那個分支,最短高度也可以求得,即最左邊的那個分支。從根節點到 q(1),最左邊的的深度是?;最右邊的深度是?。因此總體的時間復雜度應該位于??和??之間,由于對數復雜度不管底數是多少都可以統一成?,因此快速排序的時間仍然是 O(nlogn)。
假如上述比例變成 1:99,那么類似 1:9 的分析方法,最終的時間復雜度還是 O(nlogn),只要比例是一個常量值之比,那么時間復雜度都是 O(nlogn)。那么平均時間復雜度也是 O(nlogn)。
2.3. 斐波那契
前文拿斐波那契數列舉了個簡單的例子,下面我們來完完整整地分析一下斐波那契數列的時間復雜度。斐波那契使用遞歸的方式實現如下所示
int f(int n) {if (n == 1){
return 1;
}
if (n == 2) {
return 2;
}
return f(n-1) + f(n-2);
}
將整個遞歸的過程畫成遞歸樹,如圖所示。
f(n) 分解為 f(n-1) 和 f(n-2),那么在得到 f(n-1)、f(n-2) 的時間復雜度之后還需要做一個加法操作,該加法操作的時間為 1。那么 f(n-1) 分解成為 f(n-2)、f(n-3) 之后進行加法操作的時間復雜度也是 O(1),因此第二層所需的加法操作時間為 2。依次類推,第 k 層加法時間消耗需要 2^(k-1)。那么,整個算法的時間消耗就是每一層加法的時間消耗之和加上最后的 f(1)、f(2) 所需要的時間操作。
f(n) 分解為 f(n-1) 和 f(n-2),數據規模減少的快慢不一樣。最長路徑的層次應該是 n 層,最短路徑的層次差不多是 2/n 層。因此,最大的時間復雜度為 O(2^n-1),最小的時間復雜度為 O(2^(n/2)-1)。那么,這個算法的時間復雜度介于兩者之間,即時間復雜度是指數級的。雖然上述的計算過程不是特別精確,但是時間復雜度的數量級是沒有變的。
1 + 2 + ... + 2^(n-1) = 2^n-11 + 2 + ... + 2^(n/2-1) = 2^(n/2)-1
2.4. 全排列
全排列的意思是指把 n 個數據的所有排列情況全都找出來。全排列可以采用遞歸的方式實現:對于 n 個數據的全排列問題,假如我們確定了第一位數據(或者最后一位數據),那么就變成了剩下的 n-1 個數據的排列問題了。并且,第一位數據可以是 n 個數據中的任意一個,即第一位數據有 n 種情況。因此,n 個數據的全排列問題就分解成了 n 個 n-1 個數據全排列的問題了。因此這就滿足了遞歸的前兩個條件,即原問題的求解可以分解對成 n 個子問題的求解,并且對于這 n 個子問題的求解方式與原問題的求解方式一模一樣,只是數據規模不同。最后是否滿足遞歸的最后一個條件呢?當只剩下 1 個數據的時候,遞歸可以終止,因此是存在遞歸終止條件的。
我們將上述的過程寫成遞歸公式如下所示。
f(1, 2, 3, ..., n) = {第一位為1, f(2, 3, ..., n)} + {第一位為2,f(1, 3, ..., n)} + {第一位為n,f(1, 2, ..., n-1)}將上述的遞歸公式轉化為 Java 代碼如下所示
public void fullPermutation(char[] list, int start) {if (list.length == start) {
System.out.println(list);
}
for (int i = start; i < list.length; i++) {
swap(list, i, start);
fullPermutation(list, start + 1);
swap(list, i, start);
}
}
public void swap (char[] list, int i, int j) {
char temp;
temp = list[i];
list[i] = list[j];
list[j] = temp;
return;
}
public static void main(String[] args) {
new FullPermutation().fullPermutation(newchar[]{'1', '2', '3'}, 0);
}
接下去我們使用遞歸樹的方式來對這段代碼的時間復雜度進行分析。上述的過程可以畫出如下的遞歸樹。第一層有 ?n 個交換操作,第二層有 n 個節點,每個節點分解需要 n-1 次交換,所以第二層所需要進行交換的次數是 n(n-1)。依次類推,第三層所需要的交換次數是 n(n-1)(n-2),第 k 層所需要的交換次數是 n(n-1)...(n-k+1),最后一層的交換次數是 n(n-1)...2。
最終每一層的交換次數之和就是總的交換次數之和。最后一層的交換次數是 n! 次,而其他層的交換次數肯定小于 n! 次,因此最終的時間復雜度肯定是大于 O(n!),但小于 O(n*n!)。雖然具體的時間復雜度無法求出,但是通過這個范圍也可以知道全排列的時間復雜度是很大的。
★?上述的三個例子,掌握遞歸樹的求解方式才是最重要的,不要糾結于精確的時間復雜度是多少。
另外個人覺得,遞歸的時間復雜度分析方式只有一種,雖然專欄中說還有遞歸公式,但是遞歸公式其實最終也可以轉換為遞歸樹,因此最終還是遞歸樹。
”3. 總結
首先需要知道可以使用遞歸的三個條件:問題可以分解成子問題,這些子問題的解法和原問題思路是一樣,最后還需要有終止條件。
其次,在編寫遞歸代碼時,記得先找出遞歸公式以及終止條件,這是第一步。之后再根據遞歸公式和終止條件寫出代碼,此時很容易。
之后,在思考遞歸問題的時候。一定要注意不要將遞歸想下去,只考慮一層遞歸即可,即假設子問題都已經解決。這是我學習該篇內容中學到最為有用的一點,也將我之前的那些做法給拋棄掉了。
在剛接觸遞歸的時候,腦子很容易跟著機器執行的順序一層一層套用下去,就像 Debug 一個很深的函數調用鏈一樣。這樣往往只有遞的過程,沒有歸的過程,然后在這個過程你也不知道你在哪了。所以,剛接觸的時候遞歸往往讓我覺得很難分析??赡苣銜X得在紙上畫圖分析會好一點,其實也會很亂,因為一旦層次一深,你紙上也會很糊涂。所以,在處理遞歸問題時,不一定要 follow 機器的執行。在寫遞歸函數時,可以假設下一層調用已經能夠正確返回了,即子問題已經解決掉了。此時調用自身函數就像調用其他函數那樣,我不管那個函數怎么執行,反正調用之后給我返回了正確的結果。然后基于這個正確的返回,我只需要考慮怎么將其組合獲得最終問題的解即可。同時,還需要確保最深一層的邏輯,也就是遞歸的終止條件爭取即可。而這樣,中間的所有過程都可以不用考慮。因為不管在中間的哪一層,都是在執行同一份代碼,只是數據狀態不同。因此,只要保證了一層的結果正確性,那么整個遞歸過程就是正確的了。
因此,回到第二點。在寫遞歸代碼之前一定要先正確地寫出遞歸條件和終止條件。根據寫出來的遞歸公式和終止條件寫出來的代碼。那么就符合上段話中提到的,只思考第一層和最后一層的思想。
這句話是我從一位那邊大佬借鑒來并結合了自己的理解。機器執行遞歸代碼的過程對應的是深度優先的方式,而我們思考遞歸的過程應該采用廣度優先的方式,個人理解也就是在第一層的時候,我先將其子問題都當做得到了正確的解,然后基于這個我解決第一層的問題。解決完之后,我再解決其中一個子問題的過程。其實,我們在畫上面的遞歸樹時,采用的比較 nice 的方式也是這樣。
碎碎念,來自同一位大佬說的也結合了自己的理解。遞歸這種編程方式的背后,其實是樹和堆棧這兩種看似關聯不大的數據結構。遞歸樹相當于遞歸過程的完整示意圖,也就是說當遞歸完成之后,將它的過程畫出來之后是遞歸樹那樣子的形狀。那么,遞歸樹從根節點到樹中任意節點的路徑,都對應著某個時刻的函數調用鏈組成的堆棧。遞歸越深的節點月靠近棧頂,也就越早返回。因此可以說,遞歸的背后是一顆樹,遞歸的執行過程其實是在這棵樹上做深度遍歷的過程,每次進入下一層就是壓棧,每次退出當前層就是出棧。而所有入棧出棧的過程就形成了我們上面說的遞歸樹的形態。遞歸樹是遞歸的靜態邏輯背景,而當前堆棧的內容是動態運行前景。
★?在計算某個長度為 n 的入棧序列可以有多少中出棧序列和包含 n 個節點的二叉樹有多少形狀時,這兩道題的答案其實是相等就是卡特蘭數。這是因為 n 個節點形成的一棵二叉樹的后序遍歷對應的就是這 n 個節點的出棧順序(個人理解是后序遍歷,不是這位大佬說的中序遍歷)。進一步就是說 n 個節點形成的二叉樹有 x 棵,那么這 x 棵的后序遍歷就對應著 x 種出棧順序。
”其他
對遞歸代碼進行調試時,可以以下這幾種方式:1. 打印日志發現,遞歸值;2. 結合條件斷點進行調試。
另外在數據規模大的情況下請使用非遞歸代碼,使用遞歸代碼很容易造成棧溢出。
總結
以上是生活随笔為你收集整理的++递归 字符串全排列_超全递归技巧整理,这次一起拿下递归的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: hook控制浏览器的方法_Java-Ho
- 下一篇: portainer 启动mysql_do