都說不建議使用遞歸操作,到底為什么?
點擊關(guān)注公眾號,Java干貨及時送達
作者:CG國斌
來源:blog.csdn.net/qq_35246620/article/details/104855300
遞歸的問題
如題,我們可能或多或少的都聽見過類似的話或者建議:
盡量少使用遞歸操作,甚至干脆就不要使用遞歸操作。
但我們在聽到這句話的時候,是否會產(chǎn)生過疑問,為什么不建議使用遞歸操作呢?
現(xiàn)在,我們就一起聊聊這個話題,看看遞歸到底會產(chǎn)生什么樣的問題。
首先,我們思考一道算法題:如何實現(xiàn)二叉樹的中序遍歷?
對于樹的遍歷,無論是前序、中序還是后序遍歷,大家可能下意識的就會想到遞歸,為什么呢?因為遞歸操作實現(xiàn)起來“簡單”啊,而且樹的結(jié)構(gòu)完美契合了遞歸的應(yīng)用場景!下面為實現(xiàn)二叉樹中序遍歷的遞歸實現(xiàn):
????public?List?inorder(TreeNode?root)? {
????????List?ans?=?new?ArrayList<>();
????????helper(root,?ans);
????????return?ans;
????}
????private?void?helper(TreeNode?root,?List?ans) ?{
????????if?(root?!=?null)?{
????????????if?(root.left?!=?null)?{
????????????????helper(root.left,?ans);
????????????}
????????????ans.add(root.val);
????????????if?(root.right?!=?null)?{
????????????????helper(root.right,?ans);
????????????}
????????}
????} 觀察上述代碼,在使用遞歸的時候,我們會在函數(shù)的某一部分,重復(fù)的調(diào)用某個函數(shù)自身,直到觸發(fā)終止條件時,遞歸才會停止,進而函數(shù)才會執(zhí)行完畢。說到這里,我們就發(fā)現(xiàn)了遞歸可能會產(chǎn)生問題的第一個地方:
如果終止條件有問題,那么遞歸將無法停止。
那么,我們進一步分析,如果遞歸無法停止,又會出現(xiàn)什么問題呢?
如果遞歸無法停止,函數(shù)會不斷的調(diào)用自身,從而無法執(zhí)行后序的流程。
其表現(xiàn)出來的現(xiàn)象,就是程序卡在了某處,無法繼續(xù)執(zhí)行。到這里,我們已經(jīng)從邏輯上分析了遞歸可能會產(chǎn)生的問題。接下來,我們再從 JVM 的層面上,分析遞歸可能會產(chǎn)生的問題。
我們知道,Java 源代碼需要編譯成字節(jié)碼文件,然后由 JVM 解釋執(zhí)行,為了能高效地管理程序方法的調(diào)用,有條不紊地進行嵌套的方法調(diào)用和方法返回,JVM 維護了一個棧結(jié)構(gòu),稱為虛擬機方法棧(如果調(diào)用的是 Native 方法,則為本地方法棧)。
棧里面存放的一個個實體稱為棧幀,每個棧幀都包括了局部變量表、操作數(shù)棧、動態(tài)連接、方法返回地址和一些額外的附加信息。在 JVM 中,方法調(diào)用的過程大致為:
除非被調(diào)用的方法是類方法,否則在每一次方法調(diào)用指令之前,JVM 會先把方法被調(diào)用的對象引用壓入操作數(shù)棧中,除了對象的引用之外,JVM 還會把方法的參數(shù)依次壓入操作數(shù)棧; 在執(zhí)行方法調(diào)用指令時,JVM 會將函數(shù)參數(shù)和對象引用依次從操作數(shù)棧彈出,并新建一個棧幀,把對象引用和函數(shù)參數(shù)分別放入新棧幀的局部變量表; JVM 把新棧幀壓入虛擬機方法棧,并把 PC(程序計數(shù)器)指向函數(shù)的第一條待執(zhí)行的指令。
因此,我們總是說,每個方法的執(zhí)行過程,都是一個棧幀從入棧到出棧的過程。這意味著,在執(zhí)行遞歸操作的時候,如果終止條件有問題,無法終止遞歸,則會出現(xiàn):
虛擬機方法棧只入棧不出棧
進而,當(dāng)棧中所有棧幀的大小總和大于-Xss設(shè)置的值時,就會出現(xiàn)棧溢出或者稱之為棧擊穿,即:
拋出StackOverflowError異常
此外,函數(shù)的執(zhí)行是有一定開銷的,例如每次都要保存局部變量、參數(shù)、調(diào)用函數(shù)地址、返回值等,而遞歸的開銷還要在此基礎(chǔ)上乘以迭代次數(shù),這自然會影響到函數(shù)的執(zhí)行效率。
但對于某些問題,如上面我們考慮的二叉樹的中序遍歷,在條件允許的情況下,我們還是傾向于使用遞歸實現(xiàn)的,因為相對來說,遞歸的實現(xiàn)更簡單,也更容易理解。
優(yōu)化的方法
說的這里,我們不妨再來聊聊如何優(yōu)化遞歸,其方法主要有三個,分別為:
限制遞歸次數(shù) 借助堆棧將遞歸轉(zhuǎn)化為非遞歸 使用尾遞歸形式
限制遞歸次數(shù)
對于“限制遞歸次數(shù)”來說,就是在調(diào)用函數(shù)的時候,同時傳入一個數(shù)字 N 作為遞歸的次數(shù),當(dāng)遞歸次數(shù)大于 N 的時候,強制結(jié)束遞歸并返回結(jié)果。仍以實現(xiàn)二叉樹的中序遍歷為例,在上述的遞歸實現(xiàn)之上,我們新增了一個int類型的參數(shù)level,作為遞歸可執(zhí)行的最大次數(shù),代碼示例為:
????public?List?inorder(TreeNode?root,?int?level)? {
????????List?ans?=?new?ArrayList<>();
????????helper(root,?ans,?level);
????????return?ans;
????}
????private?void?helper(TreeNode?root,?List?ans,?int?level) ?{
????????if?(level?>=?0)?{
????????????if?(root?!=?null)?{
????????????????if?(root.left?!=?null)?{
????????????????????helper(root.left,?ans,?level?-?1);
????????????????}
????????????????ans.add(root.val);
????????????????if?(root.right?!=?null)?{
????????????????????helper(root.right,?ans,?level?-?1);
????????????????}
????????????}
????????}
????} 如上述代碼所示,限制迭代次數(shù)能夠有效的防止棧溢出或者說是棧擊穿的問題,但卻有可能得不到我們想要的“正確”的結(jié)果。
例如,一棵 10 層的二叉樹,我們調(diào)用上述的inorder方法,將level設(shè)置為 5,即使用inorder(root, 5)來進行遍歷,這意味著我們僅能遍歷出這棵 10 層樹的前 5 層,并沒有把這棵樹完全遍歷出來,因此限制遞歸次數(shù)的方法是有瑕疵的,治標不治本。
借助堆棧將遞歸轉(zhuǎn)化為非遞歸
對于“借助堆棧將遞歸轉(zhuǎn)化為非遞歸”來說,就是利用堆棧模擬遞歸的執(zhí)行過程,這種方法幾乎是通用的方法,因為遞歸本身就是通過堆棧實現(xiàn)的,我們只要把遞歸函數(shù)調(diào)用的局部變量和相應(yīng)的狀態(tài)放入到一個棧結(jié)構(gòu)中,在函數(shù)調(diào)用和返回時做好push和pop操作,就可以了。仍以實現(xiàn)二叉樹的中序遍歷為例,我們利用堆棧將其改造為非遞歸的形式:
????public?List?inorder(TreeNode?root)? {
????????List?ans?=?new?ArrayList<>();
????????Stack?stack?=?new?Stack<>();
????????TreeNode?curr?=?root;
????????while?(curr?!=?null?||?!stack.isEmpty())?{
????????????while?(curr?!=?null)?{
????????????????stack.push(curr);
????????????????curr?=?curr.left;
????????????}
????????????curr?=?stack.pop();
????????????ans.add(curr.val);
????????????curr?=?curr.right;
????????}
????????return?ans;
????} 如上述代碼所示,我們利用Stack來存儲二叉樹的節(jié)點,由于中序遍歷的順序為首先遍歷左子樹、然后訪問根結(jié)點、最后遍歷右子樹,因此我們從根節(jié)點開始,依次將左節(jié)點壓入棧,直至把左子樹遍歷完,然后再依次彈棧,并將彈出的節(jié)點值存入我們設(shè)置的結(jié)果列表ans,最后再將當(dāng)前節(jié)點的右節(jié)點賦值給當(dāng)前節(jié)點,以保證后續(xù)的遍歷,如此循環(huán)即可。
使用尾遞歸形式
對于“使用尾遞歸形式”來說,則是將遞歸中對函數(shù)本身的調(diào)用下移到函數(shù)的最后一行。因此,像我們上面實現(xiàn)的二叉樹的中序遍歷,就很難用尾遞歸的形式來改寫,因為遞歸形式的中序遍歷需要在遍歷左右子樹之間,把結(jié)果存起來,從而給在函數(shù)最后一行調(diào)用函數(shù)自身的形式造成了很大的困難。
在此,我們以實現(xiàn)斐波那契數(shù)列為例,演示普通的遞歸形式與尾遞歸形式的區(qū)別:
普通遞歸形式
public?int?fibonacci(int?n)?{
????if?(n?2)?{
????????return?n;
????}
????return?fibonacci(n?-?1)?+?fibonacci(n?-?2);
}尾遞歸形式
public?int?fibonacciTail(int?n,?int?fn1,?int?fn2)?{
????if?(n?==?0)?{
????????return?fn1;
????}
????return?fibonacciTail(n?-?1,?fn2,?fn1?+?fn2);
}我們之所以說尾遞歸是對普通遞歸形式的優(yōu)化,其原因在于:普通遞歸,每次執(zhí)行遞歸調(diào)用的時候,JVM 都需要為局部變量創(chuàng)建棧來存儲;而尾遞歸,則是因為對函數(shù)自身的調(diào)用在尾部,因此根本不需要新創(chuàng)建棧來保持任何局部變量,直接傳遞參數(shù)即可,減少了?N - 1?個新棧的創(chuàng)建,其中 N 為需要遞歸的次數(shù)。
說了這么多,那么尾遞歸形式是否真的有優(yōu)化效果呢?我們不妨寫一個簡單的程序,來驗證一下:
public?class?RecursiveMethodTest?{
????public?static?int?fibonacci(int?n)?{
????????if?(n?2)?{
????????????return?n;
????????}
????????return?fibonacci(n?-?1)?+?fibonacci(n?-?2);
????}
????public?static?int?fibonacciTail(int?n,?int?fn1,?int?fn2)?{
????????if?(n?==?0)?{
????????????return?fn1;
????????}
????????return?fibonacciTail(n?-?1,?fn2,?fn1?+?fn2);
????}
????public?static?void?main(String[]?args)?{
????????int?n?=?30;
????????long?d1?=?System.nanoTime();
????????System.out.println("普通遞歸結(jié)果:"?+?fibonacci(n));
????????long?d2?=?System.nanoTime();
????????System.out.println("普通遞歸形式:遞歸?"?+?n?+?"?次,耗時?"?+?(d2?-?d1)?+?"?納秒。");
????????long?d3?=?System.nanoTime();
????????System.out.println("尾遞歸結(jié)果:"?+?fibonacciTail(n,?0,?1));
????????long?d4?=?System.nanoTime();
????????System.out.println("尾遞歸形式:遞歸?"?+?n?+?"?次,耗時?"?+?(d4?-?d3)?+?"?納秒。");
????}
}其執(zhí)行結(jié)果為:
普通遞歸結(jié)果:832040
普通遞歸形式:遞歸 30?次,耗時 4896196 納秒。
尾遞歸結(jié)果:832040
尾遞歸形式:遞歸 30?次,耗時 38125 納秒。如上述結(jié)果所示,尾遞歸與普通遞歸相比,快了近 128 倍。雖然這樣的測試還很粗糙,但也足以說明兩者的性能差異啦!
? ? ?
往 期 推 薦
1、我在產(chǎn)品上線前不小心刪除了7 TB的視頻 2、程序員最硬大佬,你絕對想不到?。。?/span> 3、IntelliJ IDEA快捷鍵大全 + 動圖演示 4、打不過就加入?微軟強推“親兒子”上位,還是中國特供版 5、活久見!NVIDIA正式開源其Linux GPU內(nèi)核模塊 點分享
點收藏
點點贊
點在看





