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

最近看到一道题目:要求将一个栈逆序,使用递归。

 

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

 

实现代码如下:

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;
}

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

 

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

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

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

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

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

 

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

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);

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

 

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

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

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

 

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

———————————————————————————————————–

最后,如何有任何疑问或者建议,烦请留言,衷心感谢!

    原文作者:递归算法
    原文地址: https://blog.csdn.net/dm_vincent/article/details/8008238
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞