写在前面:
- 本系列笔记主要以《数据结构(C语言版)》为参考,结合下方视频教程对数据结构的相关知识点进行梳理。所有代码块使用的都是C语言,如有错误欢迎指出。
- 视频链接:第01周a--前言_哔哩哔哩_bilibili
一、栈和队列的定义和特点
1、栈的定义和特点
(1)栈(stack)是限定仅在表尾进行插入或删除操作的线性表,对栈来说,表尾端有其特殊含义,称为栈顶(top),相应地,表头端称为栈底(bottom)。不含元素的空表称为空栈。
(2)栈的修改是按后进先出的原则进行的,最早进入栈的元素最晚离开,因此栈又称为后进先出(LIFO)的线性表。
2、队列的定义和特点
(1)和栈相反,队列(queue)是一种先进先出(FIFO)的线性表,它只允许在表的一端进行插入,而在另一端删除元素。
(2)最早进入队列的元素最早离开。在队列中,允许插入的一端称为队尾(rear),允许删除的一端称为队头(front)。
二、栈的表示和操作的实现
1、顺序栈的表示和实现
(1)顺序栈是指利用顺序存储结构实现的栈,即利用一组地址连续的存储单元依次存放自栈底到栈顶的数据元素,同时附设指针top指示栈顶元素在顺序栈中的位置(为了操作方便,通常top指示真正的栈顶元素之上的下标地址)。通常习惯的做法是:以top=0表示空栈,鉴于C语言中数组的下标约定从0开始,则当以C语言作描述语言时,如此设定会带来很大不便,因此另设指针base指示栈底元素在顺序栈中的位置,当top和base的值相等时,表示空栈。顺序栈的定义如下:
#define MAXSIZE 100
typedef int SElemType; //以整型为例
typedef struct SqStack
{
SElemType* base; //栈底指针
SElemType* top; //栈顶指针
int stacksize; //栈可用的最大容量
}SqStack;
enum Status
{
OVERFLOW,
ERROR,
OK
};
①base为栈底指针,初始化完成后,栈底指针base始终指向栈底的位置,若base的值为NULL,则表明栈结构不存在;top为栈顶指针,其初值指向栈底。
②每当插入新的栈顶元素时,指针top增1;删除栈顶元素时,指针top减1。因此,栈空时top和base的值相等,都指向栈底;栈非空时,top始终指向栈顶元素的上一个位置。
③stacksize指示栈可使用的最大容量。
(2)顺序栈的初始化操作就是为顺序栈动态分配一个预定义大小的数组空间。
Status InitStack(SqStack *S) //初始化
{
S->base = (SElemType*)malloc(sizeof(SElemType)*MAXSIZE);
if (!S->base)
return OVERFLOW;
S->top = S->base;
S->stacksize = MAXSIZE;
return OK;
}
①算法步骤:
[1]为顺序栈动态分配一个最大容量为MAXSIZE的数组空间,使base指向栈空间的基地址,即栈底。
[2]栈顶指针top初始为base,表示栈为空。
[3] stacksize置为栈的最大容量MAXSIZE。
②时间复杂度:O(1)。
(3)入栈操作是指在栈顶插入新的元素。
Status Push(SqStack *S, SElemType e) //入栈
{
if (StackLength(*S) == S->stacksize)
return ERROR;
*(*S).top = e;
(*S).top++;
return OK;
}
①算法步骤:
[1]判断栈是否满,若满则返回ERROR。
[2]将新元素压入栈顶,栈顶指针加1。
②时间复杂度:O(1)。
(4)出栈操作是指将栈顶元素删除。
Status Pop(SqStack *S, SElemType *e) //出栈
{
if (S->base == S->top)
return ERROR;
(*S).top--;
*e = *(*S).top;
}
①算法步骤:
[1]判断栈是否空,若空则返回ERROR。
[2]栈顶指针减1,栈顶元素出栈。
②时间复杂度:O(1)。
(5)获取当前栈顶元素的值:
SElemType GetTop(SqStack S) //取栈顶元素
{
if (S.base != S.top)
return *(S.top - 1);
}
(6)判断顺序栈是否为空:
bool StackEmpty(SqStack S) //判断顺序栈是否为空
{
if (S.base == S.top)
return true;
else
return false;
}
(7)获取当前顺序栈的元素个数:
int StackLength(SqStack S) //求顺序栈长度
{
return S.top - S.base;
}
(8)清空顺序栈中的所有元素:
Status ClearStack(SqStack S) //清空顺序栈
{
if (S.base)
S.top = S.base;
return OK;
}
(9)销毁顺序栈的整个数据结构:
Status DestoryStack(SqStack *S) //销毁顺序栈
{
if (S->base)
{
free(S->base);
S->stacksize = 0;
S->base = S->top = NULL;
}
return OK;
}
2、链栈的表示和实现
(1)链栈是指采用链式存储结构实现的栈,通常链栈用单链表来表示。
typedef int SElemType; //以整型为例
typedef struct StackNode
{
SElemType data;
struct StackNode* next;
}StackNode, *LinkStack;
enum Status
{
OVERFLOW,
ERROR,
OK
};
由于对栈的主要操作是在栈顶插入和删除元素,显然以链表的头部作为栈顶是最方便的,而且没必要像单链表那样为了操作方便附加一个头结点。
(2)链栈的初始化操作就是构造一个空栈,因为没必要设头结点,所以直接将栈顶指针置空即可。
Status InitStack(LinkStack *S) //初始化
{
*S = NULL;
return OK;
}
(3)链栈在入栈前不需要判断栈是否满,只需要为入栈元素动态分配一个结点空间。
Status Push(LinkStack *S, SElemType e) //入栈
{
LinkStack p = (LinkStack)malloc(sizeof(StackNode));
p->data = e;
p->next = *S;
*S = p;
return OK;
}
(4)链栈在出栈前需要判断栈是否为空,在出栈后需要释放出栈元素的栈顶空间。
Status Pop(LinkStack *S, SElemType *e) //出栈
{
if (*S == NULL)
return ERROR;
*e = (*S)->data;
LinkStack p = *S;
*S = (*S)->next;
free(p);
return OK;
}
(5)当栈非空时,取栈顶元素操作返回当前栈顶元素的值,栈顶指针保持不变。
SElemType GetTop(LinkStack S) //取栈顶元素
{
if (S != NULL)
return S->data;
}
(6)判断链栈是否为空:
int StackEmpty(LinkStack S) //判断链栈是否为空
{
if (S == NULL)
return 1;
else
return 0;
}
(7)链栈的清空、销毁以及获取链栈长度的操作与单链表类似,这里不再赘述。
三、栈与递归
1、采用递归算法解决的问题
(1)所谓递归是指,若在一个函数、过程或者数据结构定义的内部又直接(或间接)出现定义本身的应用,则称其是递归的,或者是递归定义的。
(2)在以下3种情况下,常常使用递归的方法。
①定义是递归的:
有很多数学函数是递归定义的,如阶乘函数和斐波那契数列,对于类似的复杂问题,若将之分解成几个相对简单且解法相同或类似的子问题来求解,便称作递归求解,这种分解-求解的策略叫作“分治法”。
采用“分治法”进行递归求解的问题需要满足以下3个条件:
[1]能将一个问题转变成一个新问题,而新问题与原问题的解法相同或类似,不同的仅是处理的对象,并且其处理对象更小且变化有规律。
[2]可以通过上述转化而使问题简化。
[3]必须有一个明确的递归出口,或称递归的边界。
②数据结构是递归的:
某些数据结构本身具有递归的特性,则它们的操作可递归地描述。例如,对于链表,其结点LNode的定义由数据域data和指针域next组成,而指针域next是一种指向LNode类型的指针,即LNode的定义中又用到了其自身,所以链表是一种递归的数据结构。
对于递归的数据结构,相应算法采用递归的方法来实现特别方便。
③问题的解法是递归的:
虽然问题的本身可能没有明显的递归结构,但用递归求解比迭代求解更简单,如汉诺塔问题和八皇后问题。
2、递归过程与递归工作栈
通常,一个函数在运行期间调用另一个函数时,在运行被调用函数之前,系统需先完成3件事:
①将所有的实参、返回地址等信息传递给被调用函数保存。
②为被调用函数的局部变量分配存储区。
③将控制转移到被调函数的入口。
而从被调用函数返回调用函数之前,系统也应完成3件事:
①保存被调用函数的计算结果。
②释放被调用函数的数据区。
③依照被调用函数保存的返回地址将控制转移到调用函数。
当有多个函数构成嵌套调用时,按照“后调用先返回”的原则,上述函数之间的信息传递和控制转移必须通过“栈”来实现。系统将整个程序运行时所需的数据空间安排在一个栈中,每调用一个函数,就为它在栈顶分配一个存储区,每从一个函数退出,就释放它的存储区,如此,当前正运行的函数的数据区必在栈顶。
递归函数的运行过程类似于多个函数的嵌套调用,只是调用函数和被调用函数是同一个函数,因此,和调用相关的一个重要概念是递归函数运行的“层次”。假设调用该递归函数的主函数为第0层,则从主函数调用递归函数为进人第1层;从第i层递归调用本函数为进人“下一层”,即第i+ 1层;退出第i层递归应返回至“上一层”,即第i- 1层。
为了保证递归函数正确执行,系统需设立一个“递归工作栈”作为整个递归函数运行期间使用的数据存储区,每一层递归所需信息构成一个工作记录,其中包括所有的实参、所有的局部变量,以及上一层的返回地址。每进入一层递归,就产生一个新的工作记录并将其压人栈顶,每退出一层递归,就从栈顶弹出一个工作记录,则当前执行层的工作记录必是递归工作栈栈顶的工作记录,称这个记录为“活动记录”。
3、递归算法的效率分析
(1)时间复杂度分析:
在算法分析中,当一个算法中包含递归调用时,其时间复杂度的分析可以转化为一个递归方程求解。实际上,这是数学上求解渐近阶的问题,而递归方程的形式多种多样,其求解方法也不一而足。迭代法是求解递归方程的一种常用方法,其基本步骤是迭代地展开递归方程的右端,使之成为一个非递归的和式,然后通过对和式的估计来达到对方程左端(方程的解)的估计。
(2)空间复杂度分析:
递归函数在执行时,系统需设立一个“递归工作栈”存储每一层递归所需的信息,此工作栈是递归函数执行的辅助空间,因此,分析递归算法的空间复杂度需要分析工作栈的大小。
对于递归算法,空间复杂度S(n)= O(f(n)),其中f(n)为“递归工作栈”中工作记录的个数与问题规模n的函数关系。
4、利用栈将递归转换为非递归的方法
递归程序在执行时需要系统提供隐式栈这种数据结构来实现,对于一般的递归过程,仿照递归算法执行过程中递归工作栈的状态变化可直接写出相应的非递归算法,这种利用栈消除递归过程的步骤如下:
①设置一个工作栈存放递归工作记录(包括实参、返回地址及局部变量等)。
②进入非递归调用入口(被调用程序开始处)将调用程序传来的实参和返回地址入栈(递归程序不可以作为主程序,因而可认为初始是被某个调用程序调用)。
③进入递归调用入口:当不满足递归结束条件时,逐层递归,将实参、返回地址及局部变量人栈,这一过程可用循环语句来实现——模拟递归分解的过程。
④递归结束条件满足,将到达递归出口的给定常数作为当前的函数值。
⑤返回处理:在栈不空的情况下,反复退出栈顶记录,根据记录中的返回地址进行要求规定的操作,即逐层计算当前函数值,直至栈空为止——模拟递归求值过程。
通过以上步骤,可将任何递归算法改写成非递归算法,但改写后的非递归算法和原来比较起来,结构不够清晰,可读性差,有的还需要经过一系列的优化。