最近看到一道題目:要求將一個棧逆序,使用遞迴。
我們先看看最常規的解法應該是怎樣的,顯然對於“逆序”這種問題描述,棧這種資料結構就會蹦入我們的腦海。
實現代碼如下:
public static LinkedStack<Integer> reverseStackDirectly(LinkedStack<Integer> stack) {if(null != stack && !stack.isEmpty()) {LinkedStack<Integer> auxiliary = new LinkedStack<Integer>();while(!stack.isEmpty()) {auxiliary.push(stack.pop());}return auxiliary;}return stack;}
代碼的思路很明確,首先開闢了一個新的棧作為輔助棧,將原棧中的元素依次彈出並壓入到輔助棧中,最後返回輔助棧。
當然我們的這種做法是不符合題意的,題目規定需要使用遞迴來解決。其實大家都明白,遞迴究其本質,也使用到了棧這種資料結構,只不過在遞迴中使用的是程式運行期間的方法調用棧,它並不由我們顯式建立和管理。
下面我們考慮遞迴的解法,在考慮遞迴解法的時候,需要銘記的一點是:發現原問題中的子問題。
就本問題而言,子問題就是可以考慮成以下兩種形式:
- 取出棧頂元素後,將棧進行逆序,最後將取出的棧頂元素插入到棧底
- 取出棧底元素後,將棧進行逆序,最後將取出的棧底元素壓入到棧頂
我們可以發現,不管使用哪一種子問題的描述,都出現了將棧進行逆序這個步驟,而這個步驟,在不考慮具體的待操作對象的時候,和我們要解決的大問題是一樣的。
不妨將第一種子問題的描述形式轉換成代碼:
public static void reverseStack(LinkedStack<Integer> stack) {if(null != stack && !stack.isEmpty()) {// 將棧頂元素取出Integer top = stack.pop();// 遞迴地將該棧逆序reverseStack(stack);// 將之前取出的元素插入棧底insertToStackBottom(stack, top);}}
我們先不考慮如何?最後一個步驟:將取出的元素插入棧底。
僅僅對比一下使用遞迴以及非遞迴的方法時,程式結構上的不同點:
- 在遞迴實現中,我們首先聲明了一個變數來儲存棧頂元素,然後遞迴調用本方法。
- 在非遞迴實現中,我們直接拿到了棧頂元素並壓入到輔助棧中。
在聲明一個方法內變數時,實際上是向方法調用棧的棧頂棧幀上添加了一個變數。這一點從代碼上不明顯,就Java程式而言,javap這個工具能夠讓你看到具體發生了什麼,就上面的程式而言:
public static void reverseStack(LinkedStack stack); 0 aload_0 [stack] 1 ifnull 28 4 aload_0 [stack] 5 invokevirtual LinkedStack.isEmpty() : boolean [18] 8 ifne 28 11 aload_0 [stack] 12 invokevirtual LinkedStack.pop() : java.lang.Object [24] 15 checkcast java.lang.Integer [28] 18 astore_1 [top] 19 aload_0 [stack] 20 invokestatic misc.ReverseStack.reverseStack(LinkedStack) : void [30] 23 aload_0 [stack] 24 aload_1 [top] 25 invokestatic misc.ReverseStack.insertToStackBottom(LinkedStack, java.lang.Integer) : void [32] 28 return Local variable table: [pc: 0, pc: 29] local: stack index: 0 type: LinkedStack [pc: 19, pc: 28] local: top index: 1 type: java.lang.Integer
幾個重要的地方:
- aload_x這個指令的意思是本地變數x中的值壓入當前棧幀中
- astore_x是將當前棧幀中棧頂的元素儲存到本地變數x中
當PC(程式計數器)的值為18時,發生的astore_1[top] 的意義就是講top的值儲存到索引為1的本地變數中,本地變數參考最下面的Local
variable table。
緊接著,在PC等於20時,進行了方法的遞迴調用,這裡發生的操作是,建立了新的棧幀,新的棧幀中含有傳入的參數(同樣以本地變數的形式存在),並將該新建立的棧幀壓入方法調用棧。然後接著執行同樣的操作,最後遞迴一層層返回,也就是方法調用棧一個棧幀接一個的彈出。
重述一下,本地變數表是屬於一個棧幀的,而一個棧幀則是方法調用棧的一個元素。所以,將棧頂元素儲存到一個本地變數中,本質還是將這個本地變數壓入了棧中(表現形式為棧幀被壓入了方法調用棧),只不過這個過程不那麼明顯罷了。
// 非遞迴實現auxiliary.push(stack.pop());// 遞迴實現Integer top = stack.pop();reverseStack(stack);
至此,前兩個步驟已經完成。就剩下最後一個步驟:將取出的棧頂元素插入到棧底。
首先還是從最直觀的想法開始,棧的特性決定了直接將元素插入到棧底是不可能的。所以我們可以將當前棧中的所有元素彈出,然後將待插入元素壓入棧,此時壓入的位置當然就是棧底了,最後將之前彈出的元素再壓入到棧中。很明顯的,這裡又涉及到了元素的彈出和壓入,不難得出這個順序也是滿足棧的操作特點的,更具體的,元素的彈出和再壓入是滿足“後出先進”(Last-out-First-In)規律的。
所以最直觀的實現如下所示:
private static void insertToStackBottom(LinkedStack<Integer> stack, Integer bottom) {assert (null != stack);LinkedStack<Integer> auxiliary = new LinkedStack<Integer>();while(!stack.isEmpty()) {auxiliary.push(stack.pop());}stack.push(bottom);while(!auxiliary.isEmpty()) {stack.push(auxiliary.pop());}}
上述代碼使用了一個輔助棧作為原棧中元素的臨時儲存空間。待傳入的元素被壓入到棧底之後,再將臨時棧中的元素壓入原棧中。但是,很明顯的,這裡又使用了顯式聲明的棧。
如果想將以上的方法使用遞迴實現,那麼就必須找出可以利用的子問題。聽起來好像是在使用動態規劃求解問題。實際上,遞迴演算法和動態規划算法之間也有很微妙的關係,一般的動態規劃方法有自頂向下以及自底向上的方法。而自頂向下的方法往往會採用遞迴加上備忘錄的方式實現。
將以上問題使用遞迴的方式進行描述如下:
- 將棧頂元素取出,將待插入元素插入到棧的棧底,將取出的棧頂元素壓入
以上的子問題描述感覺不是很自然,但是為了不顯式的使用棧結構,也只能如此了。
很明顯的,當棧中不含有任何元素的時候,就可以將待插入元素壓入棧了,因此可以寫出以下的遞迴實現:
private static void insertToStackBottom(LinkedStack<Integer> stack,Integer bottom) {// 當棧為空白的時候,將待插入的元素放到棧底if(stack.isEmpty()) {stack.push(bottom);return;}// 取出棧頂的元素Integer top = stack.pop();// 將傳入的棧底元素放到棧底insertToStackBottom(stack, bottom);// 還原stack.push(top);}
還是來比較一下此方法的非遞迴版本和遞迴版本:
- 遞迴實現中,存在聲明本地變數然後利用方法調用棧來儲存本地變數的情況
- 非遞迴實現中,顯式的建立了一個棧,來儲存相關變數
仔細比較一下,可以發現以下兩種實現本質上也是相同的:
// 非遞迴實現while(!stack.isEmpty()) {auxiliary.push(stack.pop());}stack.push(bottom);while(!auxiliary.isEmpty()) {stack.push(auxiliary.pop());}// 遞迴實現Integer top = stack.pop();insertToStackBottom(stack, bottom);stack.push(top);
具體而言,非遞迴實現中將棧中的元素都顯式地儲存在了另外建立的一個棧中,而遞迴實現則隱式地將棧中的元素都儲存到了方法調用棧中(通過棧幀中的本地變數表)。
前文中還提到了另外一種子問題的描述,即:
- 取出棧底元素後,將棧進行逆序,最後將取出的棧底元素壓入到棧頂
本質上是一樣的,這裡就不提供實現了。
我想,之所以遞迴演算法有時候難以理解,可能是因為對方法調用棧的運行規律還不夠瞭解所致。一般而言,同一個問題的遞迴實現總是會比非遞迴實現來的更簡潔一些,這裡是指代碼量上的簡潔,而代碼量上的簡潔來源於對方法調用棧的隱式使用 —— 不需要顯式聲明、操作棧這類資料結構當然會減少代碼量。但是毫無疑問,遞迴實現對思維的要求會更高一些,首先你需要對待解決問題有一個全域的認識,知道如何將問題分解成子問題;其次,還需要有較好的編程能力,知道如何處理遞迴過程中的各種邊界判斷和終止條件。
-----------------------------------------------------------------------------------------------------------
最後,如何有任何疑問或者建議,煩請留言,衷心感謝!