《大话数据结构》栈学习笔记

360 阅读6分钟

在学习数据结构之前, 栈这个词是我听到过最多的数据结构名称了(其次是链表),大部分技术贴都会在开头写上“技术栈”三个字,然后罗列这个帖子内容中用到的各种技术。

那么真实的栈究竟是什么呢?又是为了解决什么问题呢?这一章给了我答案。

栈的定义

栈本质上是线性表,但是具有后进先出的特点,是限定仅在表尾进行插入和删除操作的线性表。栈相当于在地上挖了一个坑,存放数据就是把数据丢进坑里,先存入的数据在栈底,最后存入的在栈顶。对栈中的数据操作分为进栈和出栈,这些操作都只作用与当前的栈顶。

栈的抽象数据类型

栈的各元素,数据类型都是相同的(因为栈本质上就是线性表),它的删除和插入操作是最为特别的。

InitStack(*S); //初始化一个空栈
DestoryStack(*S);//销毁一个栈
ClearStack(*S);//清空一个栈,还原为原始的空栈
StackEmpty(S);//如果栈为空,返回True
GetTop(S,*e);//若栈非空,用e返回栈顶元素
Push(*S,e);//插入新元素e到栈顶
Pop(*S,*e);//删除栈顶的一个元素,并将数据返回给e
StackLength(S);//返回栈的元素个数

栈的顺序存储

线性表拥有两种存储概念,一个是顺序存储,一个是链式存储,栈也不例外。

顺序存储的栈结构为数组和top标记。

typedef struct{
  int data[MAXSIZE];//最大长度为MAXSIZE的数组,这也限制了这个栈最多存储多少数据
  int top;
}SqStack;

整数top实时记录着这个栈栈顶位于哪里,如果top为-1,那么这个栈是一个空栈,当top为MAXSIZE-1时,栈就满了。

顺序存储栈的进栈

进栈操作名为Push。

Status Push(SqStack *S,int e){
  if(S->top == MAXSIZE-1){//检查是否满栈
    return ERROR;
  }
  S->top++; //更新栈顶
  S->data[S->top] = e;
  return OK;
}

入栈的算法:

  1. 先确认这个栈是否已满,满栈就返回错误;
  2. 插入数据前,先将栈顶指示器更新到原栈顶上方的空白的空间;
  3. 将数据赋给栈顶指示器现在指向的那个空白空间,完成插入。

看上去挺简单的。

顺序存储栈的出栈

出栈操作名为Pop。

Status Pop(SqStack *S,int *e){
  if(S->top == -1){//检查是否为空栈
    return ERROR;
  }
  S->data[S->top] = *e;//将要被出栈的元素返回给e
  S->top--;
  return OK;
}

出栈的算法:

  1. 先确认这个栈是否为空栈,空栈是无法出栈,就返回错误;
  2. 删除前先将要被出栈的数据保存给e
  3. 将栈顶指示器下移,即可完成出栈,实际上并没有真正的删除,毕竟这个是数组,之后要继续插入,直接赋予新的值就行了。

出栈的操作很有意思,只是将栈顶指示器移动,实际上出栈的数据现在还是存在于数组中的。

两个栈共享空间

顺序存储栈是很简单高效的栈类型,但是也有缺点,也是线性表顺序存储的通病,那就是要先确定好数组的空间大小。预设的空间如果大于需求,就会造成资源的浪费,如果小了,就更不好了。为了解决这个问题,两栈共享空间的概念就产生了。

两个栈共用一个数组空间,这样能够大大提高资源的利用率,结构如下:

typedef struct{
  int data[MAXSIZE];
  int top1; //栈1的栈顶指示器
  int top2; //栈2的栈顶指示器
}SqDoubleStack;

两栈共享结构的入栈出栈操作要复杂一些,主要是多了栈号参数,它作用是告诉程序要对这个共享栈的哪个栈进行操作。

入栈的算法:

  1. 先确认栈是否已满,也就是top1的下一位是否和top2一致
  2. 从栈号参数确认要对哪个栈进行入栈。
  3. 进行简单地入栈操作。
Status Push(SqDoubleStack *S, int e, int StackNumber){
  if(S->top1+1 == S->top2) return ERROR; //检查栈是否已满
  if(StackNumber == 1){//操作对象是栈1
    S->top1++; //更新栈1的栈顶指示器
    S->data[top1] = e; //将新元素插入栈1
  }
  else if(StackNumber == 2){ //操作对象是栈2
    S->top2--; //更新栈2的栈顶指示器
    S->data[top2] = e; // 将新元素插入栈2
  } 
  return OK;
}

出栈的算法:

  1. 先确认栈是否已满,也就是top1的下一位是否和top2一致
  2. 从栈号参数确认要对哪个栈进行入栈。
  3. 进行简单地入栈操作。
Status Push(SqDoubleStack *S, int *e, int StackNumber){
  if (StackNumber == 1){
    if(S->top1 == -1) return ERROR; //检查栈1是否为空
    *e = data[S->top1];
    S->top1--;
  }
  else if(StackNumber == 2){
    if (S->top2 == MAXSIZE) retunr ERROR; //检查栈2是否为空
    *e = data[S->top2];
    S->top2--;
  }
  return OK;
}

栈的链式存储

顺序存储栈的长度在创栈的那一刻就被限定了。既然栈本质上是线性表,那么肯定也有相应的链式存储结构,也就是链栈。

链栈自顶向下,每个结点都有一个指针域指向其下面的第一个结点,而结点组成的链栈,本身还有一个指针用来作为栈顶指示器。

typedef struct StackNode{ //链栈的结点
  int data;
  struct StackNode *next; //指向后继的指针
}StackNode;

typedef struct StackNode * LinkStackPtr; //指向链栈结点的指针

typedef struct{ //整个链栈结构
  LinkStackPtr top;//链栈的栈顶指示器
  int count;//链栈当前长度
}LinkStack;

跟顺序存储不同,链栈可以存储任意、动态数量的数据,最典型的就是我们平时浏览网页,随着我们点击的链接加载的网页越多,记录着我们浏览过的网页就是链栈,我们点击后退能一直回退到最开始的首页。

链栈的进栈

由于是链式结构,进栈就要创建新的内存空间。

进栈算法:

  1. 创建一个结点大小的内存空间。
  2. 将数据存放入这个新创建的节点,并将这个结点的后继设为当前的栈顶
  3. 更新栈顶,让栈顶指示器指向这个新创建的节点。
  4. 最后不要忘了更新链栈长度
Status Push(LinkStack *S, int e){
  LinkStackPtr s = (LinkStackPtr)malloc(sizeof(StackNode));
  s->data = e;
  s->next = S->top;
  S->top = s;
  S->count++;
  return OK;
}

链栈的出栈

由于是链式结构,就要涉及到内存的管理,出栈要及时释放掉被出栈的那块内存。

出栈算法:

  1. 先检查当前链栈是否为空,并声明一个临时结点指针。
  2. 将要被出栈的数据保存在变量中
  3. 临时结点指针存储下要被出栈的结点的地址
  4. 更新栈顶指示器到要被出栈的结点的下一个结点
  5. free掉临时结点指针存储的地址处的结点
  6. 最后不要忘了更新链栈长度
Status Pop(LinkStack *S, int *e){
  LinkStackPtr p;
  if (StackEmpty(*S)) return ERROR; //检查链栈是否为空
  *e = S->top->data; //把要被出栈的数据存储起来
  p = S->top; //先存储原栈顶的地址
  S->top = S->top->next;//更新栈顶指示器
  free(p);//释放原栈顶结点
  S->count--;//更新链栈长度
  return OK;
}

小结

学好了前面的线性表,栈的概念就容易理解了,后面是队列,是个硬骨头,加油!