目录
栈和队列是两种重要的线性结构。从数据结构的角度看,栈和队列也是线性表,其特殊性在于栈和队列的基本操作是线性表操作的子集,它们是操作受限的线性表,因此,可称为限定性的数据结构。但从数据类型角度来看,它们是和线性表不相同的两类重要的抽象数据类型。本章除了讨论栈和队列的定义、表示方法和实现外,还将给出一些应用的例子。
3.1 栈和队列
栈(stack)是限定仅在表尾进行插入或删除操作的线性表。因此,对栈来说,表尾端有其特殊含义,称为栈顶(top),相应地,表头端称为栈底(bottom)。不含元素的空表称为空栈。
假设栈,则为栈底元素,为栈顶元素。栈中元素按的次序进栈,退栈的第一个元素应为栈顶元素。换句话说,栈的修改是按后进先出(先进后出)的原则进行的,如下图(a)所示。因此,栈又称为后进先出的线性表,它的这个特性可用铁路调度站形象地表示。
在日常生活中,还有很多类似栈的例子。例如,洗干净的盘子总是逐个往上叠放在已经洗好的盘子上面,而用的时候从上往下逐个取用。栈的操作特点正是上述实际应用的抽象。在程序设计中,如果需要按照保存数据时相反的顺序来使用数据,则可以利用栈来实现。
3.1.2 队列的定义和特点
和栈相反,队列(quene)是一种先进先出(First In First Out,FIFO)的线性表。它只允许在表的一端进行插入,而在表的另一端删除元素。这和日常生活中的排队是一样的,最早进入队列的元素最早离开。在队列中,允许插入的一段称为队尾(rear),允许删除的一端则称为队头(front)。假设队列为,那么,就是队头元素,则是队尾元素。队列中的元素是按照的顺序进入的,退出队列也只能按照这个次序依次退出,也就是说,只有在都离开队列后,才能离开队列。下图为队列的示意图。
队列在程序设计中也经常出现。一个最典型的例子就是操作系统中的作业排队。在允许多道程序运行的计算机中,同时有几个作业运行。如果运行的结果都需要通过通道输出,那就要按请求输入的先后次序排队。每当通道传输完毕可以接收新的输出任务时,队头的作业先从队列中退出做输出操作。凡是申请输出的作业都从队尾进入队列。
3.2 案例引入
案例3.1:数制的转换
十进制数N和其他d进制数的转换是计算机实现计算的基本问题,其解决方法很多,其中一个简单算法基本下列原理:(其中,div为整除运算,mod为求余运算),例如:,其运算过程如下:
N | N div 8 | N mod 8 |
1348 | 168 | 4 |
168 | 21 | 0 |
21 | 2 | 5 |
2 | 0 | 2 |
假设现要编制一个满足下列要求的程序:对于输入的任意一个非负十进制整数,输出与其等值的八进制数、上述计算过程是从低位到高位顺序产生八进制数的各个数位;而输出过程应从高位到低位进行,恰好和计算过程相反,因而我们可以使用栈来解决这个问题。在计算过程中依次将得到的余数压入栈中,计算完毕后,再依次弹出栈中的余数就是数制转换的结果。
案例3.2:括号匹配的检验
假设表达式中允许包括两种括号:圆括号和方括号,其嵌套的顺序随意即([]())或[([])]等为正确的格式,[(]或([())或(()])均为不正确的格式。检验括号是否匹配的方法可用“期待的急迫程度”这个概念来描述。例如,考虑下列括号序列:
[ | ( | [ | ] | [ | ] | ) | ] |
1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 |
当计算机接受了第一个括号后,它期待着与其匹配的第八个括号的出现,然而等来的却是第二个括号,显然第二个括号的期待急迫高于第一个括号,此时第一个括号“[”只能暂时靠边,而迫切与第二个括号相匹配的、第七个括号“)”的出现。类似地,因等来的是第三个括号“[”,其期待匹配的程度较第二个括号更急迫,则第二个括号也只能靠边,让位于第三个括号。在接受了第四个括号后,第三个括号的期待得到满足,消解之后,第二个括号的期待匹配就成为当前最急迫的任务了,......,依次类推。可见,这个处理过程恰与栈的特点相吻合,每读入一个括号,若是右括号,则或者使置于栈顶的最急迫的期待得以消解,或者是不合法的情况:若是左括号,则作为一个新的更急迫的期待压入栈中,自然使原有的在栈中的所有未消解的期待的急迫性都降了一级。
案例3.3:表达式求值。
表达式求值是程序设计语言编译中的一个最基本问题,其实现是栈应用的又一个典型例子。“算符优先法”,是一种简单直观、广为使用的表达式求值算法。
要把一个表达式翻译成正确求值的一个机器指令序列,或者直接对表达式求值,首先要能够正确解释表达式。算符优先法就是根据算术四则运算规则确定的运算优先关系,实现对表达式的编译或解释执行的。
在表达式计算中先出现的运算符不一定先运算,具体运算顺序是需要通过运算符优先关系的比较,确定合适的运算时机,而运算时机的确定是可以借助栈来完成的。将扫描不到的不能进行运算的运算数和运算符分别压入运算数栈和运算符栈中,在条件中满足时再分别从栈中弹出进行运算。
上述3个应用实例都是借助栈的后进先出的特性来处理问题的,在日常生活中,符合先进先出特性的应用更为常见。
案例3.4:舞伴问题
假设在周末舞会上,男士们和女士们进入舞厅时,各自排成一队。跳舞开始时,依次从男队和女队的对头各出一人配成舞伴。若两队初始人数不相同,则较长的那一队中未配对者等待下一轮舞曲。现要求写一算法模拟上述舞伴配对问题。
先入队的男士或女士应先出队配成舞伴,因此该问题具有典型的先进先出特性,可用队列作为算法的数据结构。
从上面的应用案例可以看出,不论是借助栈还是队列来解决问题,最基本的操作都是“入”和“出”。对于栈,在栈顶插入元素的操作称作“入栈”,删除栈顶元素的操作称作“出栈”;对于队列,在队尾插入元素的操作称作“入队”,在对头删除元素的操作称作“出队”。和线性表一样,栈和队列的存储结构也包括顺序结构和链式两种。
本章后续章节将依次给出不同存储结构表示的栈和队列的基本操作,并介绍栈的一个非常重要的应用--在程序设计语言中来实现递归,借助栈的基本操作,读者可以深刻理解递归的处理机制。本章最后将利用栈和队列给出上述四个案例的具体实现。
总结:栈和队列在本质上是与线性表相差不多的,但在这两种结构中输入和输入数据的格式不同。栈是先进后出,就像子弹上膛一样,也可以说是在日常生活中的堆放东西。例如,我们把几本书堆放在一起,最先堆放的那一本书总是要先把最上面的书拿走后才能拿到对下面的那本书。对于队列来说,它是先到先服务的,就好比我们日常生活中的排队等待,第一个排队的肯定是先被服务的。
3.3 栈的表示和操作的实现
3.3.1 栈的类型定义
栈的基本操作除了入栈和出栈外,还有栈的初始化、栈空的判定,以及取栈顶元素等。下面给出栈的抽象数据类型定义:
ADT Stack{
数据对象:D={}
数据关系:R={}
约定为栈顶,为栈底。
基本操作:
InitStack(&S)
操作结果:构造一个空栈。
DestroyStack(&S)
初始条件:栈S已存在。
操作结果:栈S被销毁。
ClearStack(&S)
初始条件:栈S已存在。
操作结果:将S清为空栈。
StackEmpty(S)
初始条件:栈S已存在。
操作结果:若栈S为空栈,则返回true,否则返回false。
StackLength(S)
初始条件:栈S已存在。
操作结果:返回S的元素个数,即栈的长度。
GetTop(S)
初始条件:栈S已存在且非空。
操作结果:返回S的栈顶元素,不修改栈顶指针。
Push(&S,e)
初始条件:栈S已存在。
操作结果:插入元素e为新的栈顶元素。
Pop(&S,&e)
初始条件:栈S已存在且非空。
操作结果:删除S的栈顶元素,并用e返回其值。
StackTraverse(S)
初始条件:栈S已存在且非空。
操作结果:从栈底到栈顶依次对S的每个数据元素进行访问。
}ADT Stack
在以后各章中引用的栈大多为如上定义的数据类型,栈的数据元素类型在应用程序内定义。
和线性表类似,栈也有两种存储表示方法,分别成为顺序栈和链栈。
3.3.2 顺序栈的表示和实现
顺序栈是指利用顺序存储结构实现的栈,即利用一组地址连续的存储单元依次存放自栈底到栈顶的元素,同时附设指针top指示栈顶元素在顺序栈中的位置。通常习惯的做法是:以top=0表示空栈,鉴于C语言中数组的下标约定从0开始,则当以C语言作描述语言时,如此设定会带来很大不便,因此另设指针base指示栈底元素在顺序栈中的位置。当top和base的值相等时,表示空栈。顺序栈的定义如下:
//------顺序栈的存储结构------
#define MAXSIZE 100 //顺序栈存储空间的初始分配量
typedef strcut
{
SElemType *base; //栈底指针
SElemType *top; //栈顶指针
int stacksize; //栈可用的最大容量
}SqStack;
说明:
1)base为栈底指针,初始化完成后,栈底指针base始终指向栈底的位置,若base的值为NULL,则表明栈结构不存在。top为栈顶指针,其初值指向栈底。每当插入新的栈顶元素时,指针top增1;删除栈顶元素时,指针top减1。因此,栈空时,top和base的值相等,都指向栈底;栈非空时,top始终指向栈顶元素的上一个位置。
2)stacksize指示栈可使用的最大容量,后面算法3.1的初始化操作作为顺序栈动态分配MAXSIZE大小的数组空间,将stacksize置为MAXSIZE。
下图所示为顺序栈中数据元素和栈指针之间的对应关系。
由于顺序栈的插入和删除只在栈顶进行,因此顺序栈的基本操作比顺序表要简单得多,一下给出顺序栈部分操作的实现。
1.初始化
顺序栈的初始化操作就是为顺序栈动态分配一个预定义大小的数组空间。
算法3.1 顺序栈的初始化
【算法步骤】
1)为顺序栈动态分配一个最大容量为MAXSIZE的数组空间,使base指向这段空间的基地址,即栈底。
2)栈顶指针top初始为base,表示栈空。
3)stacksize置为栈的最大容量MAXSIZE。
【算法描述】
Status InitStack(SqStack &S)
{//构造一个空栈S
S.base=new SElemType[MAXSIZE]; //为顺序栈动态分配一个最大容量为MAXSIZE的数组空间
if(!S.base)
exit(OVERFLOW);
S.top=S.base; //top初始为base,空栈
S.stacksize=MAXSIZE;
return OK;
}
2.入栈
入栈操作是指在栈顶插入一个新元素。
3.2算法 顺序栈的入栈
【算法步骤】
1)判断栈是否为满,若满则返回ERROR。
2)将新元素压入栈顶,栈顶指针加1。
【算法描述】
Status Push(SqStack &S,SElemType e)
{//插入元素e为新的栈顶元素
if(S.top-S.base==S.stacksize)
return ERROR; //栈满
*S.top++=e; //元素e压入栈顶,栈顶指针加1
return OK;
}
3.出栈
出栈操作是将栈顶元素删除。
算法3.3 顺序栈的出栈
【算法步骤】
1)判断栈是否为空,若空则返回ERROR。
2)栈顶指针减1,栈顶元素出栈。
【算法描述】
Status Pop(SqStack &S,SElemType &e)
{//删除S的栈顶元素,用e返回其值
if(S.top==S.base)
return ERROR;
e=*--S.top; //栈顶指针减1,将栈顶元素赋给e
return OK;
}
4.取栈顶元素
当栈非空时,次操作返回当前栈顶元素的值,栈顶指针保持不便。
算法3.4 取顺序栈的栈顶元素
【算法描述】
SElemType GetTop(SqStack S)
{//返回S的栈顶元素,不修改栈顶指针
if(S.top!=S.base)
return *(S.top-1);
}
由于顺序栈和顺序表一样,受到最大空间容量的限制,虽然可以在“满员”时重新分配空间扩大容量,但工作量较大,应尽量避免。因此在应用程序无法预先估计栈可能达到的最大容量时,还是应该使用下面介绍的链栈。
3.3.3 链栈的表示和实现
链栈是指采用链式存储结构实现的栈。通常用单链表来表示,如下图所示。链栈的结点与单链表的结构相同,在此用Stack Node表示。
定义如下:
//------链栈的存储结构------
typedef struct Stacknode
{
ElemType data;
struct StackNode *next;
}StackNode,*LinkStack;
由于栈的主要操作是在栈顶插入和删除,显然以链表的头部作为栈顶是最方便的,而且没必要像单链表那样为了操作方便附加一个头结点。
下面给出链栈部分操作的实现。
1.初始化
链栈的初始化操作就是构造一个空栈,因为没必要设头结点,所以直接将栈顶指针置空即可。
算法3.5 链栈的初始化
【算法描述】
Status InitStack(LinkStack &S)
{//构造一个空栈S,栈顶指针置空
S=NULL;
return OK;
}
2.入栈
和顺序栈的入栈操作不同的是,链栈在入栈前不需要判断栈是否满,只需要为入栈元素动态分配一个结点空间,如下图所示。
算法3.6 链栈的入栈
【算法步骤】
1)为入栈元素e分配空间,用指针p指向。
2)将新结点数据域置为e。
3)将新结点插入栈顶。
4)修改栈顶指针为p。
【算法描述】
Status Push(LinkStack &S,SElemType e)
{//在栈顶插入元素e
p=new StackNode; //生成新结点
p->data=e;
p->next=S; //将新结点插入栈顶
S=p; //修改栈顶指针为p
return OK;
}
3.出栈
和顺序栈一样,链栈在出栈前也需要判断栈是否为空,不同的是,链栈在出栈后需要释放出栈元素的栈顶空间,如下图所示
算法3.7 栈链的出栈
【算法步骤】
1)判断栈是否为空,若空则返回ERROR。
2)将栈顶元素赋给e。
3)临时保存栈顶元素的空间,以备释放。
4)修改栈顶指针,指向新的栈顶元素。
5)释放原栈顶元素的空间。
【算法描述】
Status Pop(LinkStack &S,SElemType &e)
{//删除S的栈顶元素,用e返回其值
if(S==NULL)
return ERROR;
e=S->data;
p=S;
S=S->next;
delete p;
return OK;
}
4.取栈顶元素
与顺序栈一样,当栈非空时,此操作返回当前栈顶元素的值,栈顶指针S保持不变。
算法3.8 取链栈的栈顶元素
【算法描述】
SElemType GetTop(LinkStack S)
{//返回S的栈顶元素,不修改栈顶指针
if(S!=NULL)
return S->data;
}
总结:顺序栈的取值、删除和插入十分简单:对于取值只需移动栈顶元素(top)即可;对于插入和删除,只需在栈顶操作即可。但比较麻烦的是,由于栈的结构是先进后出,所以若要取一个栈底元素,则只能先将栈底上面的元素取出后再取栈底元素。而链栈又能很好地克服这个缺点。对于链栈,不需要分配固定的存储空间,只需要生成一个新结点再将该结点入栈(->next指向栈顶)。需要注意的是,上述链栈中的S是“链栈的头部”。
3.4 栈与递归
栈有一个重要应用是在程序设计语言中实现递归。递归是算法设计中最常用的手段,它通常把一个大型复杂问题的描述和求解变得简洁和清晰。因此递归算法常常比非递归算法更易设计,尤其是当问题本身或所涉及的数据是递归定义的时候,使用递归方法更加合适。为使读者增强理解和设计递归算法的能力,本节将介绍栈在递归算法的内部实现中所起的作用。
3.4.1 采用递归算法解决的问题
所谓递归是指,若在一个函数、过程或者数据结构定义的内部又直接(或间接)出现定义本身的应用,则称它们是递归的,或者是递归定义的。在一下三种情况下,常常使用递归的方法。
1.定义是递归的
有很多数学函数是递归定义的,如大家熟悉的阶乘函数
二阶Fibonacci数列
对于上述中的阶乘函数,可以使用递归过程来求解
long Fact(long n)
{
if(n==0)
return 1;
else
return n*Fact(n-1);
}
类似地,可写出Fibnoacci数列的递归程序:
long Fib(long n)
{
if(n==1||n==2)
return 1;
else
return Fib(n-1)+Fib(n-2);
}
下图为阶乘函数的主程序调用Fact(4)的执行过程。在函数过程体中,else语句以参数3、2、1、0执行递归调用。最后一次递归调用的函数因参数n为0执行if语句,递归终止,逐步返回,返回时依次计算1*1、2*1、3*2、4*6,最后将计算结果24返回给主程序。
对于类似这种的复杂问题,若能够分解成几个相对简单且解法相同或类似的子问题来求解,便称作递归求解。例如,在上图中,计算4!时先计算3!,然后再进一步分解进行求解,这种分解-求解的策略叫做“分治法”。
采取分治法进行递归求解的问题需要满足以下三个条件。
1)能将一个问题转变成一个新问题,而新问题与原问题的解法相同或类同,不同的仅是处理的对象,并且这些处理对象更小且变化有规律。
2)可以通过上述转化而使问题简化。
3)必须有一个明确的递归出口,或称递归的边界。
“分治法”求解递归问题算法的一般形式为:
void p(参数表)
{
if(递归结束条件成立) 可直接求解;
else p(较小的参数);
}
可见,上述阶乘函数和Fibonacci数列的递归过程均与此一般形式相对应。
2.数据结构是递归的
某些数据结构本身具有递归的特性,则它们的操作可递归地描述。
例如,对于链表,其结点LNode地定义由数据域data和指针域next组成,而指针域next是一种指向LNode类型的指针,即LNode的定义中又用到了其自身,所以链表是一种递归的数据结构。
对于递归的数据结构,相应算法采用递归的方法来实现特别方便。链表的创建和链表结点的遍历输出都可以采用递归的方法。算法3.9是从前向后遍历输出链表结点的递归算法,调用此递归函数前,参数p指向单链表的首元结点,在递归过程中,p不断指向后继结点,直到p为NULL时递归结束。显然,这个问题满足上述给出的采用“分治法”进行递归求解的问题需要满足的三个条件。
算法3.9 遍历输出链表中各个结点的递归算法
【算法步骤】
1)如果p为NULL,递归结束返回。
2)否则输出p->data,p指向后继结点继续递归。
【算法描述】
void TraverseList(LinkList p)
{
if(p==NULL)
return ERROR;
else
{
cout<<p->data<<endl;
TraverseList(p->next);
}
}
在递归算法中,如果当递归结束条件成立,只执行return操作时,“分治法”求解递归问题算法的一般形式可以简化为:
void p(参数表)
{
if(递归结束条件不成立)
p(较小的参数);
}
因此,算法3.9可以简化为:
void TraverseList(LinkList p)
{
if(p)
{
cout<<p->data<<endl;
TraverseList(p->next);
}
}
后面章节要介绍的广义表、二叉树等也是典型的具有递归特性的数据结构,其相应算法也可采用递归的方法来实现。
3.问题的解法是递归的
还有一类问题,虽然问题本身没有明显的递归结构,但用递归求解比迭代求解更简单,如Hanoi塔问题、八皇后问题、迷宫问题等。
【例3.1】 n阶Hanoi塔问题
【问题描述】
假设有3个分别命名为A、B和C的塔座,在塔座A上插有n个直径大小各不相同,依小到大编号为1,2,...,n的圆盘(如下图所示)。现要求将塔座A上的n个圆盘移至塔座C上,并仍按同样顺序叠排,圆盘移动时必须遵循下列规则:
1)每次只能移动一个圆盘;
2)圆盘可以插在A、B和C中的任一塔座上;
3)任何时刻都不能将一个较大的圆盘压在较小的圆盘之上。
【问题分析】
如何实现移动圆盘的操作呢?可以用分治求解的递归方法来解决这个问题。设A柱上最初的盘总数为n,则当n=1时,只要将编号为1的圆盘从塔座A直接移至塔座C上即可;否则, 执行以下三步:
1)用C柱做过渡,将A柱上的(n-1)个盘子移到B柱上;
2)将A柱上最后一个盘子直接移到C柱上;
3)用A柱做过渡,将B柱上的(n-1)个盘子移到C柱上。
具体移动过程类似于上图。
根据这种解法,如何将n-1个圆盘从一个塔座移至另一个塔座的问题是一个和原问题具有相同特征属性的问题,只是问题的规模小1,因此可以用同样的方法求解。
为了便于描述算法,将搬动操作定义为move(A,n,C),是指将编号为n的圆盘从A移到C,同时设要给初值为0的全局变量m,对搬动进行计数:
int m=0;
void move(char A,int n,char C)
{
count<<++m<<","<<n<<","<<A<<","<<C<<endl;
}
算法3.10 Hanoi塔问题的递归算法
【算法步骤】
1)如果n=1,则直接将编号为1的圆盘从A移到C,递归结束。
2)否则
递归,将A上编号为1至n-1的圆盘移到B,C做辅助塔;
直接将编号为n的圆盘从A移到C;
递归,将B上编号为1至n-1的圆盘移到C,A做辅助塔。
【算法描述】
void Hanoi(int n,char A,char B,char C)
{//将塔座A上的n个圆盘按规则搬到C上,B做辅助塔
if(n==1)
move(A,1,C); //将编号为1的圆盘从A移到C
else
{
Hanoi(n-1,A,C,B); //将A上编号为1至n-1的圆盘移到B,C做辅助塔
move(A,n,C); //将编号为n的圆盘从A移到C
Hanoi(n-1,B,A,C); //将B上编号为1至n-1的圆盘移到C,A做辅助塔
}
}
3.4.2 递归过程于递归工作栈
一个递归函数,在函数的执行过程中,需多次进行自我调用。那么,这个递归函数是如何执行的?先看任意两个函数之间进行调用的情形。
与汇编语言程序设计中主程序和子程序之间的链接及信息交换类似,在高级语言编制的程序中,调用函数和被调用函数之间的链接信息交换需通过栈来进行。
通常,当在一个函数的运行期间调用另一个函数时,在运行被调用函数之前,系统需先完成3件事:
1)将所有的实参、返回地址等信息传递给被调函数保存;
2)为被调用函数的局部变量分配存储区;
3)将控制转移到被调函数的入口。
从而被调用函数返回调用函数之前,系统也应完成3件事:
1)保存被调函数的计算结果;
2)释放被调函数的数据区;
3)依照被调函数保存的返回地址将控制转移到被调函数。
当有多个函数构成嵌套调用时,按照“后调用先返回”的原则,上述函数之间的信息传递和控制转移必须通过“栈”来实现,即系统将整个程序运行时所需的数据空间安排在一个栈中,每当调用一个函数时,就为它在栈顶分配一个存储区,每当从一个函数退出时,就释放它的存储区,则当前正运行的函数的数据区必在栈顶。
例如,在下图(c)所示的主函数main中调用了函数first,而在函数first中又调用了函数second,则下图(a)所示为当前正在执行函数second中某个语句时栈的状态,而下图(b)展示从函数second退出之后正执行函数first中某个语句时栈的状态(图中以语句标号表示返回地址)。
一个递归函数的运行过程类似于多个函数的嵌套调用,只是调用函数和被调用函数是同一个函数,因此和每次调用相关的一个重要概念是递归函数运行的“层次”。假设调用该递归函数的主函数为第0层,则从主函数调用递归函数为进入第1层;从第i层递归调用本函数为进入“下一层”,即第i+1层。反之,退出第i层递归应返回至“上一层”,即第i-1层。为u可保证递归函数正确执行,系统需设立一个“递归工作栈”作为整个递归函数运行期间使用的数据存储区。每一层递归所需信息构成一个信息记录,其中包括所有的实参、所有的局部变量,以及上一层的返回地址。每进入一层递归,就产生要给新的工作记录压入栈顶。每退出一层递归,就从栈顶弹出一个工作记录,则当前执行层的工作记录必是递归工作栈栈顶的工作记录,称这个记录为“活动记录”。
下面以下图所示的阶乘函数Fact(4)为例,介绍递归过程中递归工作栈和活动记录的使用。主函数调用Fact(4),当函数运行结束后,控制返回到RetLoc1,在此处n被赋为24(即4!)。
这里暂忽略局部变量temp的入栈和出栈情况。RetLoc2是递归调用Fact(n-1)的返回地址,当Fact(n-1)结束后,返回到RetLoc2,在此处计算n*(n-1)!,然后将结果赋给临时变量temp。
主函数执行后依次启动了5个函数调用。下图1所示为每次函数调用时活动记录的进栈情况。主程序外部调用Fact(4)的活动记录在栈底,Fact(1)调用Fact(0)进栈的活动记录在栈顶。
递归结束条件出现于函数Fact(0)的内部,执行Fact(0)引起了返回语句的执行。退出栈顶的活动记录,返回地址返回到上一层Fact(1)的调用递归出RetLoc2,继续执行语句temp=1*1,接着执行return temp又引起新的退栈操作。此退栈过程直至Fact(4)执行完毕后,将控制权转移给main为止,其过程如下图2所示。
3.4.3 递归算法的效率分析
1.时间复杂度的分析
在算法分析当中,当一个算法中包含递归调用时,其时间复杂度的分析可以转化为一个递归方程求解。实际上,这个问题是数学上求解渐近阶的问题,而递归方程的形式多种多样,其求解方法也不一而足。迭代法是求解递归方程的一种常用的方法,其基本步骤是迭代地展开递归方程的右端,使之成为要给非递归的和式,然后通过对和式的估计来达到对方程左端(即方程的解)的估计。
下面以阶乘的递归函数Fact(n)为例,说明通过迭代法求解递归方程来计算时间复杂度的方法。
设Fact(n)的执行时间是T(n)。此递归函数中语句if(n==0) return 1;的执行时间是O(1),递归调用Fact(n-1)的执行时间是T(n-1),所以else return n*Fact(n-1);的执行时间是O(1)+T(n-1)。其中,设两数相乘和赋值操作的执行时间为O(1),则对某常数C、D有如下递归方程:
设n>2,利用上式对T(n-1)展开,即在上式中用n-1代替n得到
再代入中,有
同理,当n>3时,有
依次类推,当n>i时有
最后,当i=n时有
求得递归方程的解为:T(n)=O(n)
采用这种方法计算Fibonacci数列和Hanoi塔问题递归算法的时间复杂度均为O()。
2.空间复杂度
递归函数再执行时,系统需设立一个“递归工作栈”存储每一层递归所需的信息,此工作栈是递归函数执行的辅助空间,因此,分析递归算法的空间复杂度需要分析工作栈的大小。
对于递归算法,空间复杂度
其中,f(n)为“递归工作栈”中工作记录的个数与问题规模n的函数关系。
根据这种分析方法不难得到,前面讨论的阶乘问题、Fibonacci数列问题、Hanoi塔问题的递归算法的空间复杂度均为O(n)。
3.4.4 利用栈将递归转换为非递归的方法
通过上述讨论,可以看出递归程序再执行时需要系统提供隐式栈这种数据结构来实现,对于一般的递归过程,仿照递归算法执行过程中递归工作栈的状态变化可直接写出相应的非递归算法。这种利用栈消除递归过程的步骤如下。
1)设置一个工作栈存放递归工作记录(包括实参、返回地址及局部变量等)。
2)进入非递归调用入口(即被调用程序开始处)将调用程序传来的实在参数和返回地址入栈(递归程序不可以作为主程序,因而可认为初始是被某个调用程序调用)。
3)进入递归调用入口:当不满足递归结束条件时,逐层递归,将实参、返回地址及局部变量入栈,这一过程可用循环语句来实现——模拟递归分解的过程。
4)递归结束条件满足,将到达递归出口的给定常数作为当前的函数值。
5)返回处理:在栈不空的情况下,反复退出栈顶记录,根据记录中的返回地址进行题意规定的操作,即逐层计算当前函数值,直至栈空为止——模拟递归求值过程。
通过以上步骤,可将任何递归算法改写成非递归算法。但改写后的非递归算法和原来比较起来,结构不够清晰,可读性差,有的还需要经过一系列的优化。
由于递归函数机构清晰,程序易读,而且其正确性容易得到证明,因此,利用允许递归调用的语言(如C语言)进行程序设计时,给用户编制程序和调试程序带来很大方便,因为对这样一类递归问题变成时,不需用户自己而由系统来管理递归工作栈。
总结:常见的算法有迭代算法和递归算法。其中迭代算法的实现使用循环语句来实现;递归算法的实现是通过一次一次地调用自身来实现地。而递归算法的核心就是分治思想。把一个问题划分为多个层次,然后从内到外依次执行这些层次。在栈的结构中,栈底就相当于是这个问题的最外层,栈顶就相当于这个问题的最顶层。对于一个有层次的问题,从里到外的解决,可以使用栈结构来实现。