栈
顺序栈
链表栈
利用栈实现递归(递推求组合数)
在上一章中我们讨论了线性表数据结构,重点探讨了线性表的几种实现方法。可以发现对线性表的操作,没有特别的限制:可以在表中任意当前位置插入或删除元素,而且可以重置当前节点指向。这一章我们将详细介绍一种操作受限的线性表——栈。
栈是一种只支持两种主要的操作的受限线性表。可以将栈想像成一个“黑盒”,我们只能看到黑盒中置于顶端的物体。对栈里元素的操作也只能针对顶端的元素进行。习惯上称栈的可访问元素即栈的顶端,为栈顶。对栈的两种操作称为压栈(push,在栈顶添加新的元素)和出栈(pop,弹出栈顶元素)。这种受限的操作下,元素通常遵循后出先入(LIFO,Last In First Out)的原则。
根据这些性质对栈的接口设计如下:
import Element.ElemItem;
/**
* 栈数据结构及相关操作
*/
public interface Stack {
public ElemItem pop(); // 弹出并返回栈顶元素
public void push(ElemItem elem); // 将元素elem压入栈中
public ElemItem getTop(); // 获取栈顶的元素
public int getSize(); // 获取栈的大小
public int getTotalSize(); // 获取栈的允许最大大小
public void clear(); // 清除栈中所有元素
public void printStack(); // 打印栈中所有元素
}
上面的函数中除了我们所讨论的压栈push和出栈pop之外,还有只读取而不弹出栈顶元素的getTop函数,获取栈大小的getSize函数和元素打印函数。
虽然栈的数据结构比较确定,但栈的实现方法可以多种多样。类似上一章内容,这一章将介绍基于数组结构的顺序栈和基于链表结构的链表栈。
顺序栈
类似于顺序表,顺序栈也借助于数组结构,在创建顺序栈的的同时也限制了栈的大小。在设计顺序栈类时,需要引入三个私有成员:栈顶位置top,当前栈元素个数totalSize和记录栈中元素项的数组sqlStackData[]。
栈顶位置top实际上是栈中第一个可压入元素的位置,它的大小事实上与栈的当前大小相同。在压栈时首先将元素赋值给数组的第top项,然后将top向上移一层,当然在这之前需要判断栈中是否有足够的空间。在弹出栈顶元素时,首先将top向下移一层,随后将top指向的元素返回。
在上一章的顺序表类设计过程中,我们分析出在对顺序表中元素进行插入和删除时涉及到数组元素的移位操作,其时间代价为O(n)。在这里我们不得不分析一下对顺序栈插入和删除元素时会不会也存在类似的高复杂度操作。事实上,如果我们将数组的第一个位置作为栈底,压入和弹出栈顶元素都不需要对数组中元素移位,复杂度为O(1)。
顺序栈类的设计如下:
import Element.ElemItem;
/**
* 顺序栈实现栈的接口,SequentianStack.java
*/
public class SequentialStack implements Stack{
private int top; // 栈顶位置,同时也表示栈中元素的个数
private int totalSize; // 线性栈中元素的最大个数
private ElemItem sqlStackData[]; // 线性栈中的元素
public SequentialStack( int _totalSize){ // 有参数的构造函数
top = 0; // 初始栈顶为0
totalSize = _totalSize; // 栈的最大元素个数
if (_totalSize > 0) sqlStackData =
new ElemItem[totalSize];
}
public SequentialStack() // 无参数的构造函数
{
top = 0; // 栈顶为0
totalSize = 0; // 最大栈的元素个数为0
}
public ElemItem pop() {
// 返回栈顶元素并将栈顶下移
if(top > 0) return sqlStackData[--top];
System.out.println("栈为空!");
return null;
}
public void push(ElemItem elem) {
// 向栈顶压入元素,并将栈顶指向上移;在栈已满时不压栈
if(top < totalSize) sqlStackData[top++] = elem;
else System.out.println("栈已满!");
}
public ElemItem getTop() {
// 返回栈顶元素
if(top > 0) return sqlStackData[top - 1];
System.out.println("栈为空!");
return null;
}
public int getSize() {
return top;
}
public void clear() {
top = 0;
}
public int getTotalSize() {
return totalSize;
}
public void printStack() {
if(top == 0){
System.out.println("当前栈为空!");
return;
}
System.out.println("当前栈从栈顶至栈底元素为:");
for( int i = top - 1; i >= 1; i--)
System.out.print(sqlStackData[i].getElem() + ", ");
System.out.println(sqlStackData[0].getElem() + ".");
}
}
从代码可以发现,顺序栈事实上是顺序表的简化版本,这也是栈的“受限性”的体现。下面是顺序栈的一个示例程序:
import Element.ElemItem;
/**
* 顺序栈的实例代码,SampleSequentialStack.java
*/
public class SampleSequentialStack {
public static void main(String args[]){
SequentialStack sqlStack = new SequentialStack(10);
for( int i = 0; i < 15; i++){
sqlStack.push( new ElemItem<Integer>(i));
}
sqlStack.printStack();
System.out.println("-弹出元素:" + sqlStack.pop().getElem());
System.out.println("-弹出元素:" + sqlStack.pop().getElem());
System.out.print("-弹出两个元素后");
sqlStack.printStack();
sqlStack.push( new ElemItem<Double>(12.345));
System.out.print("-压入元素"+12.345+",");
sqlStack.printStack();
sqlStack.push( new ElemItem<String>("JAVA"));
System.out.print("-压入元素"+"JAVA"+",");
sqlStack.printStack();
System.out.print("-压入元素"+"JAVA"+",");
sqlStack.push( new ElemItem<String>("JAVA"));
sqlStack.printStack();
ElemItem e = new ElemItem();
for( int i = 0; i < 15;i++)
if((e = sqlStack.pop())!= null)
System.out.println("-弹出元素:" + e.getElem());
}
}
在本例中,首先创建大小为10的栈,然后向栈中压入数值为0~9的整型ElemItem对象。细心的读者会发现,代码中压栈的元素是0~14,个数超过了10个,从程序的运行结果或者经过简单的分析我们就会明白,实际上压入栈中的只有0~9,剩下的5个元素是无效的,被“溢出”了。在创建顺序栈后,连续弹出栈顶两个元素,接着再先后压入一个双精度类型的元素项12.345和两个字符串类型的元素项“JAVA”,同样最后一个压入的字符串对象是无效的。最后,将栈中元素连续弹出15次,可以发现前10次弹出是有效的,后5次操作都是对空栈的操作。本示例程序的运行结果如下:
栈已满!
栈已满!
栈已满!
栈已满!
栈已满!
当前栈从栈顶至栈底元素为:
9, 8, 7, 6, 5, 4, 3, 2, 1, 0.
-弹出元素:9
-弹出元素:8
-弹出两个元素后当前栈从栈顶至栈底元素为:
7, 6, 5, 4, 3, 2, 1, 0.
-压入元素12.345,当前栈从栈顶至栈底元素为:
12.345, 7, 6, 5, 4, 3, 2, 1, 0.
-压入元素JAVA,当前栈从栈顶至栈底元素为:
JAVA, 12.345, 7, 6, 5, 4, 3, 2, 1, 0.
-压入元素JAVA,栈已满!
当前栈从栈顶至栈底元素为:
JAVA, 12.345, 7, 6, 5, 4, 3, 2, 1, 0.
-弹出元素:JAVA
-弹出元素:12.345
-弹出元素:7
-弹出元素:6
-弹出元素:5
-弹出元素:4
-弹出元素:3
-弹出元素:2
-弹出元素:1
-弹出元素:0
栈为空!
栈为空!
栈为空!
栈为空!
栈为空!
相对于顺序表,顺序栈是一个简化的线性表,其主要操作的复杂度都为O(1),但是存在的主要问题是动态性很差,在创建顺序栈的同时必须确定栈的最大大小。但是在实际的应用中对栈的大小的估计常常会很困难,这大大限制了顺序栈的实用范围。
链表栈
在上一章内容中我们分析比较了链表和顺序表的性质。链表相比顺序表具有动态性,高效的链表不仅能动态添加和删除节点,而且其操作复杂度更低。本章将利用类似链表的结构来设计实现栈,主要客服的问题就是顺序栈缺乏的动态性。
在上一章中设计链表类时,我们提出了两种设计方法,第二种设计更高效。主要原因是第二种设计方法能更快地定位当前节点的前一个节点。在这里设计链表栈时我们也需要做类似的考虑吗?上一节中我们发现,栈事实上是普通线性表的一种简化或受限。直观上,如果直接将上一章中设计的高效链表中tail的前一个节点换成这里的top,如图,这样栈顶元素可以用top的后向指针访问。这种结构下新元素的压入能以O(1)复杂度完成,但是弹出栈顶元素后,又难免要从栈底开始逐一寻找top的前一节点的位置,这需要O(n)的复杂度。
这样的设计很明显是不合理的。栈顶位置的移动发生在两种情况下:压入新元素项和弹出栈顶元素项。如果将上图中的指针方向反转,将top直接指向栈顶位置,并去掉栈底处的哑节点,如图在压栈首先创建新的节点,将该节点的后向指针指向当前的top,并重置top为新建的节点,如图。在弹出栈顶元素时直接将top重置为当前top的下一个节点。
图 链表栈中压入新元素项
向栈中压入新的元素项时:
首先,创建新的节点,并将其后向指针指向当前的top;
第二步,将top重置为新建的节点。
弹出栈中栈顶元素时:
将top重置为当前top的下一节点即可。
可以发现在这样设计的数据结构中,弹出栈顶元素后top的移动方向跟节点的后向指针方向是相同,这避免了上面的寻找过程。通过分析,链式栈的压栈和弹出操作的复杂度都是O(1)。这与上一节中设计的顺序栈的操作复杂度相同,重要的是链式栈的优势在于可以动态压栈和弹出。下面是链式栈类的设计,在链表栈类中包含了两个私有成员变量,表示栈顶节点的top和记录当前栈中元素项个数的currSize:
import Element.ElemItem;
import List.SingleNode;
/**
* 链表栈类,LinkStackjava
*/
public class LinkStack implements Stack{
private SingleNode top; // 栈顶,这里利用单链表中的节点对象
private int currSize; // 当前栈的大小
public LinkStack(){ // 初始化栈
top = null;
currSize = 0;
}
public ElemItem pop() {
if(top == null){
System.out.println("当前栈为空!");
return null;
}
currSize--;
SingleNode forReturn = top;
top = top.getNext();
return forReturn.getElem();
}
public void push(ElemItem elem) {
top = new SingleNode(elem, top);
currSize++;
}
public ElemItem getTop() {
if(top != null) return top.getElem();
System.out.println("当前栈为空!");
return null;
}
public int getSize() {
return currSize;
}
public int getTotalSize() {
return currSize;
}
public void clear() {
currSize = 0;
top = null;
}
public void printStack() {
SingleNode ptr = top;
if(ptr == null)
{
System.out.println("当前栈为空!");
return;
}
System.out.println("当前栈从栈顶至栈底元素为:");
while(ptr!= null && ptr.getNext()!= null)
{
System.out.print(ptr.getElem().getElem() + ", ");
ptr = ptr.getNext();
}
System.out.println(ptr.getElem().getElem() + ".");
}
}
链式栈的使用比顺序栈更灵活,在压栈时没有个数的限制,当然在弹出栈中元素时弹出次数超过当前栈的深度时将无效。下面的示例程序描述了这一过程:
import Element.ElemItem;
/**
* 链表栈的示例程序,SampleLinkStackjava
*/
public class SampleLinkStack {
public static void main(String args[]){
LinkStack lnkStack = new LinkStack();
for( int i = 0; i < 10; i++){
lnkStack.push( new ElemItem<Integer>(i));
}
lnkStack.printStack();
System.out.println("-弹出元素:" + lnkStack.pop().getElem());
System.out.println("-弹出元素:" + lnkStack.pop().getElem());
System.out.print("-弹出两个元素后");
lnkStack.printStack();
lnkStack.push( new ElemItem<Double>(12.345));
System.out.print("-压入元素"+12.345+",");
lnkStack.printStack();
lnkStack.push( new ElemItem<String>("JAVA"));
System.out.print("-压入元素"+"JAVA"+",");
lnkStack.printStack();
System.out.print("-压入元素"+"JAVA"+",");
lnkStack.push( new ElemItem<String>("JAVA"));
lnkStack.printStack();
ElemItem e = new ElemItem();
for( int i = 0; i < 15;i++)
if((e = lnkStack.pop())!= null)
System.out.println("-弹出元素:" + e.getElem());
}
}
在本示例程序中首先创建一个链表栈,此时栈中没有元素,栈顶为空。向栈中连续压入10个元素项,元素项为数值0~9的整型ElemItem对象。连续两次弹出栈顶元素,并打印显示当前栈中所有元素,打印元素的顺序与顺序栈中一样,从站定开始逐一打印。接下来向栈中压入一个双精度类型的元素项12.3456和两个字符串类型的元素项“JAVA”,可以发现,此时不会出现顺序栈中的“溢出”现象。最后连续弹出15次栈顶元素,事实上栈中只有11个元素,所以最后的4次弹出是无效的。实例程序的输出为:
当前栈从栈顶至栈底元素为:
9, 8, 7, 6, 5, 4, 3, 2, 1, 0.
-弹出元素:9
-弹出元素:8
-弹出两个元素后当前栈从栈顶至栈底元素为:
7, 6, 5, 4, 3, 2, 1, 0.
-压入元素12.345,当前栈从栈顶至栈底元素为:
12.345, 7, 6, 5, 4, 3, 2, 1, 0.
-压入元素JAVA,当前栈从栈顶至栈底元素为:
JAVA, 12.345, 7, 6, 5, 4, 3, 2, 1, 0.
-压入元素JAVA,当前栈从栈顶至栈底元素为:
JAVA, JAVA, 12.345, 7, 6, 5, 4, 3, 2, 1, 0.
-弹出元素:JAVA
-弹出元素:JAVA
-弹出元素:12.345
-弹出元素:7
-弹出元素:6
-弹出元素:5
-弹出元素:4
-弹出元素:3
-弹出元素:2
-弹出元素:1
-弹出元素:0
当前栈为空!
当前栈为空!
当前栈为空!
当前栈为空!
利用栈实现递归(递推求组合数)
在本章开始时我们总结了对栈中元素的操作遵循LIFO原则,这种原则虽然很简单,但在计算机的应用非常广泛。在算术计算中,我们通常使用“中缀法”,而用计算机计算时更有效的方法是“后缀法”。后缀法中索要计算的表达式的形式是每个操作符都出现在它的两个参数之后,而中缀法中出现在两个参数中间。这种表达的目的就是利用栈对元素的操作准则来高效地运算。详细内容请读者参见《C程序设计语言·第2版 BRIAN W. KERNIGHAN》。
本节我们主要讨论常见的递归算法用栈的等效实现。前面我们介绍过递归算法的使用,即“函数调用函数本身”。用这种方法,我们可以编写出很简洁的程序。但是实际上递归算法常常会带来内存资源的浪费和重复运算,通常在编程时我们要遵循的原则是“能用迭代算法的情况下就避免使用递归算法”(当然很多情况下只能使用递归算法),具体内容还将在本书最后一部分“算法设计策略”中详细讨论。
首先我们看一个常见的问题——找组合数。给定正整数1~N,从中选出K个整数,在不考虑顺序的条件下有多少中选择方法,并写出个选择结果?
这是一个最简单的组合问题,我们很容易写出其结果为Cnk。在寻找各个选择结果时,可以考虑将每个选择中数字大小以降序输出,这种限定可以给问题的解决带来很多方便。以N=8,K=4为例,选择的方法可以为8765,…, 4321。可以发现每种选择的第一个数C1只能是8、7、6、5、4,第二个数C2是C1 -1~3,……以此类推。
具体来说,输出结果的第一位取值范围N~K,取定第一位之后问题就降级为在N-1个数中选择K-1个数的情况,以此类推,直至在N-K+1个数中选择1个数的基本情况。对这个基本情况的处理可以直接依次选择N-K+1~1。
这个过程很容易用递归算法的伪代码实现如下:
FOR I = N TO K STEP -1
选择I作为组合数的第K个数;
IF K > 1 THEN
COMB(N – 1, K - 1)
ELSE // 直接选择
打印选择的组合数结果;
END IF
NEXT I
以N=8,K=4为例,分析递归算法在调用本身的过程。
COMB(8, 4),I=8,K=4,则递归调用函数COMB(7, 3),此时I=8,K=4被存储到一个栈中,这称为活动记录,用{8, 4}表示。类似地分析可以发现,栈中首先压入的记录{8,4}{7,3}{6,2}{5,1},对应的组合数为8765(8对应组合数的第四位,5为第一位),如图(a),记录{5,1}中K为1,所以继续选择4~1作为组合数的第1位,并打印。
接下来弹出栈中{6,2}并压入{5,2}和{4,1},如图(b),此时对应的组合数为8754,记录{4,1}中K为1,所以继续选择3~1作为组合数的第1位,并打印。接下来将从栈顶往下第二层弹出{4,2}压入{3,2}和{2,1},弹出{3,2}压入{2,2}和{1,1}如图(c)。
以此类推((d)~(f)),可以发现,在栈顶元素中N和K相等时,就弹出,直到栈顶元素N和K不同为止,如(e)中,一直弹出直到{8,4}作为栈顶为止。
将上面这种借助于栈结构对递归算法的分析过程可以作出如下的流程图,其中I的初始值为N。
图 利用栈结构完成组合数问题
根据上面的分析,我们可以利用常见的递归法和借助于栈结构的迭代法完成组合数问题,下面是组合数类的设计,其中包含了两种方法实现的组合数输出函数:
import Element.ElemItem;
/**
* 用递归和递推法实现组合数,Comb.java
*/
public class Comb {
int a[]; // 数组用于记录组合数
int count = 0; // 组合数个数记录
public Comb(){ // 构造函数
a = new int[100];
}
// 打印结果
public void print(){
System.out.print(++count + ":\t");
for( int i = a[0]; i >= 1; i--){
System.out.print(a[i] + ", ");
}
System.out.println();
}
// 递归算法实现组合数输出
public void recursiveComb( int m, int k){
if(m < k) return; // 参数不符合条件
int i;
for(i = m; i >= k; i--){
a[k] = i;
if(k > 1)
// 子问题
recursiveComb(i - 1, k - 1);
else // k == 1时打印
print();
}
}
// 利用栈实现上述的递归算法(这是一种递推算法)
public void stackComb( int m, int k){
if(m < k) return; // 参数不符合条件
// 创建一个链表栈
LinkStack lstack = new LinkStack();
// 待压栈的数据对和待退栈的数据
CombPair p_push, p_pop;
int i = m;
while (k > 0) // 首先压栈
{
a[k] = i;
p_push = new CombPair(i, k);
lstack.push( new ElemItem<CombPair>(p_push));
i--;k--;
}
while(lstack.getSize() > 0)
{
// 弹出栈顶元素并作处理
p_pop = (CombPair)(lstack.pop().getElem());
i = p_pop.i;
k = p_pop.k;
// 改变记录组合结果的数组
a[k] = i;
// 因为栈顶的元素一定是满足k=1的,所以直接打印
print();
// 考虑是否需要再弹出栈中元素
if (i == k)
{
while(i == k) // 不断弹出直到元素对两个数不同位置
{
if (lstack.getSize() > 0)
{
// 元素对两个数相同时弹出栈顶元素
p_pop = (CombPair)(lstack.pop().getElem());
i = p_pop.i;
k = p_pop.k;
}
else break;
}
// 栈中弹出的元素(k不等于1)的进一步处理
i--;
a[k] = i;
while (k > 0 && i >= k)
{
// 更新后进行压栈操作
a[k] = i;
p_push = new CombPair(i, k);
lstack.push( new ElemItem<CombPair>(p_push));
k--;i--;
}
}
else // 不需要再弹出栈中元素时(此时k依然等于1)
{
i--; // 更新
p_push = new CombPair(i, k);
lstack.push( new ElemItem<CombPair>(p_push)); // 压栈
}
}
}
public static void main(String agrs[]){
Comb c = new Comb();
c.a[0] = 4;
// c.recursiveComb(8, 4);
c.stackComb(8, 4);
}
}
在本书后面的内容中我们将继续讨论基于递归思想的算法,通过这种基于栈结构的分析方法,我们能对递归算法有更深入的理解。递归算法常常效率很低,事实上我们可以从原理上去理解并用递推过程来优化递归算法。