一、栈的定义
相信大家对于栈或多或少有一些了解,可能大多数人会告诉你栈是一种先进后出的数据结构。这其实说了跟没说一样(❁´◡`❁)!当然(last in,first out)是栈最有特色的性质。
这里可以给大家一些比较好理解的例子,像男生对枪械感兴趣的,不难发现:弹夹其实就是一个先进后出的容器,压入子弹后,先打出去的一定是最后压入的。同理,第一枚压入的子弹一定是最后打出的。
再比如,我们在浏览网页时,经常会有回退(即回到上一个浏览的网页)这一操作,那么同样不难想象,第一次回退操作的结果肯定是回到,除当前页面最后浏览的,而并不是第一个浏览的网页,这同样是先进后出的典型代表。
接下来我们展开讲一讲怎么将后进先出抽象出来。这里我们先回顾一下线性表的插入和删除操作,先找到插入位置的前驱结点,然后做指针或数组下标的相关操作。那么栈只不过是将操作对象局限为最后一个结点罢了。
那么其实总结起来说,栈还就只是一种后进先出的容器罢了,更简单明了地说,栈的本质还是一种顺序表,不过是只能在其一端进行插入和删除操作的特殊线性表。
那么就会有很多人问,既然栈的本质还是一种线性表,那我们为什么还要去重新定义这样一种数据结构呢?直接在线性表的基础上进行数组下标或者是链表指针的操作不就行了。首先我想说这是一个好问题,因为我也有这样的疑问。但是大家想一下,我们既然有了数组,为什么还要有顺序表呢?因为大家清楚顺序表本质就是一个数组。其实这就涉及到程序设计的问题,我们当然希望在编写程序的时候关注的问题越少越好,所以栈这个数据结构的创建就使得程序设计时的思考范围变小了,可以不需要去花精力考虑类似于数组下标等问题。
二、理解并手撕栈
头文件及宏定义
#include <stdio.h>
#include <stdlib.h>
#define elemtype int
#define initcapacity 10
#define increment 10
initcapacity:初始化栈时的最大容量。
increment:在后面栈满后对空间进行再分配时,栈的最大空间的增量。
栈的定义
(这里以顺序栈为例,后期会更新其他实现:链栈、队列栈)
typedef struct Stack{
elemtype *base;
elemtype *top;
int stacksize;
} Stack;
结构体中定义了两个elemtype类型的指针(*top、*base),然后是栈的最大容量stacksize。其中*top指向栈顶,*base指向栈底,所以由栈的LIFO特性可以知道,所有的操作都是在top一端进行的。
初始化栈(initStack)
Stack* initStack(){
Stack *s=(Stack*)malloc(sizeof(Stack));
s->base=(elemtype*)malloc(sizeof(elemtype)*initcapacity);
s->top=s->base;
s->stacksize=initcapacity;
return s;
}
初始化时,当然是先要定义结构体Stack *s,然后将结构体中的元素(*top、*base)分配空间,而空间的长度就是宏定义的initcapacity。最后还要更新一下s->stacksize。
(ps:此处可以给s->base分配,再将其赋给s->top,也可以反过来,没有区别)
(ps:函数返回值是*Stack,当然也可以传入*Stack指针然后再进行初始化。)
销毁栈(destroyStack)
void destroyStack(Stack *s){
free(s->base);
s->base=NULL;
s->top=NULL;
s->stacksize=0;
}
这里进行的操作是,先对结构体中的s->base进行free()操作,然后将其指针指置空。最后更新s->stacksize=0。
这里大家可能也会有问题,为什么只free(s->base),不用free(s->top)?
这里要简单解释一下free()函数的机制,我们输入由动态分配的内存的首地址,然后函数会将自定计算这块内存的长度,然后将其设置为可分配状态,最后由操作系统释放掉。而我们这里的首地址是s->base,s->top并不是首地址,所以应该释放掉s->base。
这里可能大家还会有疑惑,既然是将栈销毁,为什么不直接free(s),也就是直接将结构体释放掉,这样难道不是更方便吗?
首先我们要明确销毁栈的目的是什么?应该是,在我不需要这个栈时,我希望这一块空间被释放掉,也就是可被再分配。
那么如果直接将释放结构体,那么在结构体中动态分配的内存(也就是s->base)是无法释放的,这也就会导致内存泄漏,与我们的目的就不符了。
清空栈(clearStack)
void clearStack(Stack *s){
s->top=s->base;
s->stacksize=0;
}
清空栈比较简单,我只需要将将top指针重置为base就可以了。
(ps:这种方式的清空并不是真正意义上的将栈中元素删除了,它其实是一种清空的假象,栈的物理结构并没有改变,只不过那些没有删去的元素不用再去访问它了,在我要入栈时就会覆盖掉那些元素)
!!!如果有强迫症的话,可以将每一个元素删除 (^///^)。
压栈(pushStack)
void pushStack(Stack *s,elemtype e){
if(s->top - s->base >= s->stacksize){
s->base=(elemtype*)realloc(s->base,sizeof(elemtype)*(initcapacity+increment));
if(!s->base){
printf("fail realloc\n");
exit(0);
}
s->top=s->base+s->stacksize;
s->stacksize+=increment;
}
*s->top=e;
s->top++;
}
其实压栈的核心操作就是两行代码:
*s->top=e;
s->top++;
另一大坨主要是考虑扩容和代码健壮性的校验代码:
s->base=(elemtype*)realloc(s->base,sizeof(elemtype)*(initcapacity+increment));
值得注意的是如果不熟悉realloc()函数,要注意一下该函数的输入,两个参数,(原内存首地址,再分配后的内存空间总数) ,然后进行强制类型转换。
弹栈(popStack)
void popStack(Stack *s){
if(s->top==s->base){
printf("stack is empty\n");
exit(0);
}
printf("pop-%d\n",*(--s->top));
}
弹栈的操作比较简单,先判断是否为空栈,然后将顶部元素弹出。这里需要注意的是s->top,并不是头部元素,一般情况下它是空的,等待着要入栈的元素。所以弹出的元素应该是,--s->top指向的元素。
(ps:s->top是一个指针,要输出值的话,是*s->top)
栈长度、顶部元素获取
int lengthStack(Stack *s){
return s->top - s->base;
}
elemtype GetTop(Stack *s){
return *(s->top-1); //时刻记住s->top,是一个指针,要取值的话,加*!!!
}
这里涉及到的知识带是,指针可以相减获得两指针的距离:
s->top - s->base;
同样,这里需要注意栈顶元素是*(s->top-1)。
遍历栈(traverseStack)
void traveraeStack(Stack *s){
if(s->base==s->top){
printf("Stack is empty!\n");
exit(0);
}
int len=lengthStack(s);
for(int i=1;i<=len;++i){
printf("%d->",*(s->top-i));
}
printf("end\n");
}
这里在遍历的时候也有一个小细节,不能直接写 *--(s->top),因为*s是作为指针传入的,这样会修改结构体中s->top的值,那么遍历结束后s->top就会与s->base重合了!!!此时再进行遍历就会是空栈!!!
运行结果
没有bug!!!
三、写在最后
其实栈的实现逻辑是很简单的,就抓住栈的LIFO特性就可以了,后面的学的队列也是这样。
今天是以顺序栈为例分析的,其实还有链表实现栈,队列实现栈,以及以栈为基础的双端栈,这些数据结构都会之后的博客中,我都会写到。然后文章有什么问题的话,希望大家能够帮忙指正!
然后源代码已经上传到下面了,有需要的可以自取。
顺序栈(源码)