由“栈的逆序”谈谈递归算法

要求将一个栈逆序,使用递归。

 

我们先看看最常规的解法应该是怎样的,显然对于“逆序”这种问题描述,栈这种数据结构就会蹦入我们的脑海。

 

实现代码如下:

  1. public static LinkedStack<Integer> reverseStackDirectly(LinkedStack<Integer> stack) {  
  2.       
  3.     if(null != stack && !stack.isEmpty()) {  
  4.         LinkedStack<Integer> auxiliary = new LinkedStack<Integer>();  
  5.         while(!stack.isEmpty()) {  
  6.             auxiliary.push(stack.pop());  
  7.         }  
  8.         return auxiliary;  
  9.     }  
  10.       
  11.     return stack;  
  12. }  

 

代码的思路很明确,首先开辟了一个新的栈作为辅助栈,将原栈中的元素依次弹出并压入到辅助栈中,最后返回辅助栈。

 

当然我们的这种做法是不符合题意的,题目规定需要使用递归来解决。其实大家都明白,递归究其本质,也使用到了栈这种数据结构,只不过在递归中使用的是程序运行期间的方法调用栈,它并不由我们显式创建和管理。


下面我们考虑递归的解法,在考虑递归解法的时候,需要铭记的一点是:发现原问题中的子问题。

就本问题而言,子问题就是可以考虑成以下两种形式:

  1. 取出栈顶元素后,将栈进行逆序,最后将取出的栈顶元素插入到栈底
  2. 取出栈底元素后,将栈进行逆序,最后将取出的栈底元素压入到栈顶

 

我们可以发现,不管使用哪一种子问题的描述,都出现了将栈进行逆序这个步骤,而这个步骤,在不考虑具体的待操作对象的时候,和我们要解决的大问题是一样的。

 

不妨将第一种子问题的描述形式转换成代码:

  1. public static void reverseStack(LinkedStack<Integer> stack) {  
  2.       
  3.     if(null != stack && !stack.isEmpty()) {  
  4.         // 将栈顶元素取出  
  5.         Integer top = stack.pop();  
  6.         // 递归地将该栈逆序  
  7.         reverseStack(stack);  
  8.         // 将之前取出的元素插入栈底  
  9.         insertToStackBottom(stack, top);  
  10.     }  
  11. }  

 

我们先不考虑如何实现最后一个步骤:将取出的元素插入栈底。

仅仅对比一下使用递归以及非递归的方法时,程序结构上的不同点:

  • 在递归实现中,我们首先声明了一个变量来保存栈顶元素,然后递归调用本方法。
  • 在非递归实现中,我们直接拿到了栈顶元素并压入到辅助栈中。

 

在声明一个方法内变量时,实际上是向方法调用栈的栈顶栈帧上添加了一个变量。这一点从代码上不明显,就Java程序而言,javap这个工具能够让你看到具体发生了什么,就上面的程序而言:

 

  1. public static void reverseStack(LinkedStack stack);     0  aload_0 [stack]  
  2.      1  ifnull 28  
  3.      4  aload_0 [stack]  
  4.      5  invokevirtual LinkedStack.isEmpty() : boolean [18]  
  5.      8  ifne 28  
  6.     11  aload_0 [stack]  
  7.     12  invokevirtual LinkedStack.pop() : java.lang.Object [24]  
  8.     15  checkcast java.lang.Integer [28]  
  9.     18  astore_1 [top]  
  10.     19  aload_0 [stack]  
  11.     20  invokestatic misc.ReverseStack.reverseStack(LinkedStack) : void [30]  
  12.     23  aload_0 [stack]  
  13.     24  aload_1 [top]  
  14.     25  invokestatic misc.ReverseStack.insertToStackBottom(LinkedStack, java.lang.Integer) : void [32]  
  15.     28  return  
  16.   
  17.       Local variable table:  
  18.         [pc: 0, pc: 29] local: stack index: 0 type: LinkedStack  
  19.         [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时,进行了方法的递归调用,这里发生的操作是,创建了新的栈帧,新的栈帧中含有传入的参数(同样以本地变量的形式存在),并将该新创建的栈帧压入方法调用栈。然后接着执行同样的操作,最后递归一层层返回,也就是方法调用栈一个栈帧接一个的弹出。

 

重述一下,本地变量表是属于一个栈帧的,而一个栈帧则是方法调用栈的一个元素。所以,将栈顶元素保存到一个本地变量中,本质还是将这个本地变量压入了栈中(表现形式为栈帧被压入了方法调用栈),只不过这个过程不那么明显罢了。

 

  1. // 非递归实现  
  2. auxiliary.push(stack.pop());  
  3. // 递归实现  
  4. Integer top = stack.pop();  
  5. reverseStack(stack);  

 

至此,前两个步骤已经完成。就剩下最后一个步骤:将取出的栈顶元素插入到栈底。

 

首先还是从最直观的想法开始,栈的特性决定了直接将元素插入到栈底是不可能的。所以我们可以将当前栈中的所有元素弹出,然后将待插入元素压入栈,此时压入的位置当然就是栈底了,最后将之前弹出的元素再压入到栈中。很明显的,这里又涉及到了元素的弹出和压入,不难得出这个顺序也是满足栈的操作特点的,更具体的,元素的弹出和再压入是满足“后出先进”(Last-out-First-In)规律的。


所以最直观的实现如下所示:

  1. private static void insertToStackBottom(LinkedStack<Integer> stack, Integer bottom) {  
  2.     assert (null != stack);  
  3.     LinkedStack<Integer> auxiliary = new LinkedStack<Integer>();  
  4.     while(!stack.isEmpty()) {  
  5.         auxiliary.push(stack.pop());  
  6.     }  
  7.     stack.push(bottom);  
  8.     while(!auxiliary.isEmpty()) {  
  9.         stack.push(auxiliary.pop());  
  10.     }  
  11. }  

 

上述代码使用了一个辅助栈作为原栈中元素的临时存储空间。待传入的元素被压入到栈底之后,再将临时栈中的元素压入原栈中。但是,很明显的,这里又使用了显式声明的栈。

 

如果想将以上的方法使用递归实现,那么就必须找出可以利用的子问题。听起来好像是在使用动态规划求解问题。实际上,递归算法和动态规划算法之间也有很微妙的关系,一般的动态规划方法有自顶向下以及自底向上的方法。而自顶向下的方法往往会采用递归加上备忘录的方式实现。


将以上问题使用递归的方式进行描述如下:

  • 将栈顶元素取出,将待插入元素插入到栈的栈底,将取出的栈顶元素压入

 

以上的子问题描述感觉不是很自然,但是为了不显式的使用栈结构,也只能如此了。

很明显的,当栈中不含有任何元素的时候,就可以将待插入元素压入栈了,因此可以写出以下的递归实现:

  1. private static void insertToStackBottom(LinkedStack<Integer> stack,  
  2.         Integer bottom) {  
  3.     // 当栈为空的时候,将待插入的元素放到栈底  
  4.     if(stack.isEmpty()) {  
  5.         stack.push(bottom);  
  6.         return;  
  7.     }  
  8.     // 取出栈顶的元素  
  9.     Integer top = stack.pop();  
  10.     // 将传入的栈底元素放到栈底  
  11.     insertToStackBottom(stack, bottom);  
  12.     // 还原  
  13.     stack.push(top);  
  14. }  

 

还是来比较一下此方法的非递归版本和递归版本:

  • 递归实现中,存在声明本地变量然后利用方法调用栈来保存本地变量的情况
  • 非递归实现中,显式的创建了一个栈,来保存相关变量

 

仔细比较一下,可以发现以下两种实现本质上也是相同的:

  1. // 非递归实现  
  2. while(!stack.isEmpty()) {  
  3.     auxiliary.push(stack.pop());  
  4. }  
  5. stack.push(bottom);  
  6. while(!auxiliary.isEmpty()) {  
  7.     stack.push(auxiliary.pop());  
  8. }  
  9. // 递归实现  
  10. Integer top = stack.pop();  
  11. insertToStackBottom(stack, bottom);  
  12. stack.push(top);  

 

具体而言,非递归实现中将栈中的元素都显式地保存在了另外创建的一个栈中,而递归实现则隐式地将栈中的元素都保存到了方法调用栈中(通过栈帧中的本地变量表)

 

前文中还提到了另外一种子问题的描述,即:

  • 取出栈底元素后,将栈进行逆序,最后将取出的栈底元素压入到栈顶

本质上是一样的,这里就不提供实现了。

 

我想,之所以递归算法有时候难以理解,可能是因为对方法调用栈的运行规律还不够了解所致。一般而言,同一个问题的递归实现总是会比非递归实现来的更简洁一些,这里是指代码量上的简洁,而代码量上的简洁来源于对方法调用栈的隐式使用 —— 不需要显式声明、操作栈这类数据结构当然会减少代码量。但是毫无疑问,递归实现对思维的要求会更高一些,首先你需要对待解决问题有一个全局的认识,知道如何将问题分解成子问题;其次,还需要有较好的编程能力,知道如何处理递归过程中的各种边界判断和终止条件。

posted @ 2017-09-08 11:37  锐洋智能  阅读(830)  评论(0编辑  收藏  举报