最近看到一道题目:要求将一个栈逆序,使用递归。
我们先看看最常规的解法应该是怎样的,显然对于“逆序”这种问题描述,栈这种数据结构就会蹦入我们的脑海。
实现代码如下:
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);
具体而言,非递归实现中将栈中的元素都显式地保存在了另外创建的一个栈中,而递归实现则隐式地将栈中的元素都保存到了方法调用栈中(通过栈帧中的本地变量表)。
前文中还提到了另外一种子问题的描述,即:
- 取出栈底元素后,将栈进行逆序,最后将取出的栈底元素压入到栈顶
本质上是一样的,这里就不提供实现了。
我想,之所以递归算法有时候难以理解,可能是因为对方法调用栈的运行规律还不够了解所致。一般而言,同一个问题的递归实现总是会比非递归实现来的更简洁一些,这里是指代码量上的简洁,而代码量上的简洁来源于对方法调用栈的隐式使用 —— 不需要显式声明、操作栈这类数据结构当然会减少代码量。但是毫无疑问,递归实现对思维的要求会更高一些,首先你需要对待解决问题有一个全局的认识,知道如何将问题分解成子问题;其次,还需要有较好的编程能力,知道如何处理递归过程中的各种边界判断和终止条件。
———————————————————————————————————–
最后,如何有任何疑问或者建议,烦请留言,衷心感谢!