数据结构初阶(4)栈
1. 栈
1.1 栈的概念及结构
栈 :一种特殊的线性表,其只允许在固定的一端进行插入和删除元素操作。
普通线性表:允许在两头、中间任意一点进行操作(数据管理)
栈:只允许在一个端口进行数据操作。
队列:只允许在两个端口进行数据操作。
线性表:逻辑上数据是依次存储的。
顺序表:数据在物理上也连续依次存储的线性表。
链表:数据在物理上不连续依次存储的线性表。
栈进行数据插入和删除操作的一端称为 栈顶 ,另一端称为 栈底 。
栈中的数据元素遵守 后进先出原则(Last In First Out,LIFO)。
类比:肉串、弹夹——只能在一端进——后串进去的先被吃、后压入的先射出。
压栈 :栈的插入操作叫做进栈/压栈/入栈。入数据在栈顶。
出栈 :栈的删除操作叫做出栈。出数据也在栈顶。
1.2 栈的实现
逻辑分析
栈的实现一般可以使用顺序表(数组)或者链表实现。
相对而言数组的结构实现更优一些。因为数组在尾上插入数据的代价比较小
栈和队列的数据结构都是数组或链表。
堆的数据结构是二叉树
栈分为数组栈和链式栈,一般都是选择数组栈。
栈的适用场景:所有后进先出的场景
1.括号匹配问题 ——后进来的左括号在s遍历到右括号时优先匹配
2.递归改非递归
因为递归的核心缺陷——要连续创建函数栈帧,而进程里面给栈区分配的空间并不多,所以当函数调用递归的深度太深时,栈空间可能会溢出,所以有些地方需要把递归该为非递归。
a. 简单一点的递归改非递归就直接在这个地方就改成循环就可以了
b. 复杂一点的则需要借助栈来进行辅助
代码实现
(1)Stack.h
栈:只允许在一个端口进行数据操作的线性表。
// 入栈——不像顺序表需要指明在哪push,默认在栈顶push
void StackPush(Stack* ps, STDataType data);
// 出栈——不像顺序表需要指明在哪pop,默认在栈顶pop
void StackPop(Stack* ps);
有些书上会命名为Insert、erase,这里的栈的入栈、出栈命名风格跟随C++的STL会更好。
// 下面是定长的静态栈的结构,实际中一般不实用,所以我们主要实现下面的支持动态增长的栈
typedef int STDataType;
#define N 10
typedef struct Stack
{STDataType _a[N];int _top; // 栈顶
}Stack;//支持动态增长的栈
typedef int STDataType;
typedef struct Stack
{STDataType* _a;int _top; // 栈顶(下标)——记录了有效数据个数int _capacity; // 容量
}Stack;// 初始化栈
void StackInit(Stack* ps);
// 入栈
void StackPush(Stack* ps, STDataType data);
// 出栈
void StackPop(Stack* ps);
// 获取栈顶元素
STDataType StackTop(Stack* ps);
// 获取栈中有效元素个数
int StackSize(Stack* ps);
// 获取栈的元素个数
int STSize(ST* ps);
// 检测栈是否为空,如果为空返回非零结果,如果不为空返回0
int StackEmpty(Stack* ps);
// 销毁栈
void StackDestroy(Stack* ps);
这些是栈这个数据结构常见的会实现的接口(不一定都实现这些,也不一定只有这些)。
支持动态增长的栈——动态顺序表
注意:这个数组栈st在栈区,而数组栈保存的数据*a在堆区。
(2)Stack.c
两个注意点
- 初始化的top影响其他的接口函数的书写。
- 扩容的逻辑。
① 初始化
代码实现。
//初始化
void STInit(ST* ps)
{assert(ps);//可以初始化为空,也可以给一段空间ps->a = NULL;//初始化为0ps->top = 0;ps->capacity = 0;
}
规定
栈顶是指最后一个有效数据的下一个位置的地址——一开始就是a[0]。
(根据《微机原理与接口技术》关于内存区域——栈,的规定)
但是一般来讲
注意
栈(数据结构)并没有规定top指向哪里
但是 初始化函数 决定了top指向哪里
——top初始化为-1,指向最后一个有效数据(栈顶元素),则入/出栈操作的就是a[++top];
——top初始化为0,指向最后一个有效数据的下一位置,则入/出栈操作的就是a[top++];
要是top=0指向栈顶元素——则区分不开没数据和有一个数据。
② 入栈
注意
//在栈顶插入不一定是尾插——数组是尾插,链表就不一定(头插 / 尾插)。
//故栈插入不说尾和头,只是在一端,这一端叫作“栈顶”
//插入:--Push是C++的STL的命名风格,而一般常见的还有insert......
代码实现。
//插入
void STPush(ST* ps, STDataType x)
{assert(ps);//如果空间满了/一开始没开空间//——>就扩容if (ps->top == ps->capacity) //当top(下标) == capacity(容量){//如果空间为0就给4个初始空间,如果空间不为0就翻倍空间int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;//即使一开始为空,也可以直接用realloc扩容——当指针为空,realloc的行为相当于malloc//使用临时指针tmp接收STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity * sizeof(STDataType));if (tmp == NULL){perror("realloc fail");return;//或者直接结束掉程序exit(1);都可以}//成功开辟空间就赋值ps->a = tmp;ps->capacity = newcapacity;}//由于top是0——>指向栈顶元素的下一个,所以直接先插入,再++ps->a[ps->top] = x;//赋值栈顶之上的元素ps->top++;
}
注意——操作符的优先级问题
有些操作符优先级表中会把++(后缀)和->放到同一优先级(1-最高)。
实际上
简单助记法:先取元素,再对元素进行单目运算,再双目运算,再三目运算,最后聚成一个可以带;的完整表达式。
取元素操作符不按“单目/双目”论,ps->x的x本身并不是->的右操作数,而是ps的成员名。
约束->的条件是左侧必须是 左值/指针 。
实际上
根据结合性,表达式应自左向右计算,先计算ps->top,再计算(ps->top)++。
只有 一元/前缀运算符、全部赋值运算符 是从右到左结合;其余均为从左到右。
此外,++必须作用于可修改的左值,产生一个右值,而->不能作用于右值。
语法上天然阻止了ps->(x++)这种解析,因此编译器永远只会把它看成(ps->x)++。
所谓的(++a)++也不行。
所以ps->x++会被编译器解析成(ps->x)++。
③ 出栈
代码实现。
//弹出
void STPop(ST* ps)
{assert(ps);assert(!STEmpty(ps)); //不为空栈,就弹出ps->top--; //不用抹除数据
}
④ 获取栈顶元素
代码实现。
//取
STDataType STTop(ST* ps)
{//报错——>当ps为空(假)assert(ps);//报错——>当STEmpty(ps)为真assert(!STEmpty(ps));return ps->a[ps->top - 1];
}
⑤ 获取有效数据个数
代码实现。
//获取有效数据个数——只要指针不为空,直接返回top
int STSize(ST* ps)
{assert(ps);return ps->top; //相当于数组下标的下一个
}
⑥ 检空
代码实现。
//检空
bool STEmpty(ST* ps)
{assert(ps);//不需要麻烦地使用//if(...)...return true//else(...)...return false//一句话搞定——直接用表达式的结果作为返回值,是更好的return ps->top == 0;
}
⑦ 销毁
代码实现。
//销毁
void STDestroy(ST* ps)
{assert(ps);free(ps->a);ps->a = NULL;ps->top = ps->capacity = 0;
}
动态栈才需要 销毁函数 ,因为动态栈维护的数据在堆区。
注意区分
数据结构
- “栈”:特殊线性表
- “堆”:特殊二叉树
操作系统内存区域
- “栈”:局部变量、函数指针、……
- “堆”:动态内存管理、……
它们几乎没有关联,仅仅是两个不同研究领域的同名名词而已。
它们的命名都是依据使用习惯来的。
(3)Test.c
两个注意点
- 使用栈之前不要忘了先初始化(不初始化的话里面就会有随机值)。
- 使用完栈之后不要忘了销毁,避免内存泄漏。
#include"Stack.h"void test()
{//定义一个栈的数据结构出来ST s;//1.使用栈之前不要忘了先初始化(不初始化的话里面就会有随机值)STInit(&s);//插入数据STPush(&s, 1);STPush(&s, 2);STPush(&s, 3);//获取栈顶元素——3int top = STTop(&s);//打印栈顶元素——3printf("%d ", top);//弹出栈顶元素——剩1、2STPop(&s);//插入数据——1、2、4、5STPush(&s, 4);STPush(&s, 5);while (!STEmpty(&s)){//取栈顶元素——5、4、2、1int top = STTop(&s);//打印栈顶元素——5、4、2、1printf("%d ", top);//弹出栈顶元素——5、4、2、1STPop(&s);}//取完一次——栈就空了——栈的需求就是这样//2.使用完栈之后不要忘了销毁,避免内存泄漏STDestroy(&s);
}int main()
{test();return 0;
}
遍历一遍,栈就空了——会不会不好?
——不会,栈的需求就是这样。
注意
- 入栈顺序1-2-3-4-5
- 出栈顺序不一定就是5-4-3-2-1,可能在4-5进栈之前,3就已经出栈了
- 甚至有可能1-2-3-4-5(入一个,就立刻出一个)
- 模拟出栈顺序,是常考的选择题
(后进先出是相对于同一时刻栈内的元素而言)