前言
栈结构:先进后出,后进先出
我们可以把栈理解为叠在一起的盘子。我们平时放盘子的时候,都是从下往上一个个放,取得时候也是从上往下一个个依次取,不能从中间任意抽出。
从栈的操作特性来看,栈是一种操作受限的线性表,只允许在一端插入和删除数据。当某个数据集集合只涉及在一端插入和删除数据,并且满足后进先出,先进后出的特性,我们就应该首选栈这种数据结构。
如何实现一个栈
从刚才栈的定义中,我们可以看出,栈主要分为两个操作:入栈和出栈,也就是在栈顶插入一个数据和栈顶删除一个数据。
实际上,栈可以用数组来实现,也可以用链表来实现。用数组实现的栈叫做顺序栈,用链表实现的栈叫做链式栈。
// 基于数组实现的顺序栈
public class ArrayStack {
private String[] items; // 数组
private int count; // 栈中元素个数
private int n; //栈的大小
// 初始化数组,申请一个大小为n的数组空间
public ArrayStack(int n) {
this.items = new String[n];
this.n = n;
this.count = 0;
}
// 入栈操作
public boolean push(String item) {
// 数组空间不够了,直接返回false,入栈失败。
if (count == n) return false;
// 将item放到下标为count的位置,并且count加一
items[count] = item;
++count;
return true;
}
// 出栈操作
public String pop() {
// 栈为空,则直接返回null
if (count == 0) return null;
// 返回下标为count-1的数组元素,并且栈中元素个数count减一
String tmp = items[count-1];
--count;
return tmp;
}
}
/**
* 基于链表实现的栈。
*
* Author: Zheng
*/
public class StackBasedOnLinkedList {
private Node top = null;
public void push(int value) {
Node newNode = new Node(value, null);
// 判断是否栈空
if (top == null) {
top = newNode;
} else {
newNode.next = top;
top = newNode;
}
}
/**
* 我用-1表示栈中没有数据。
*/
public int pop() {
if (top == null) return -1;
int value = top.data;
top = top.next;
return value;
}
public void printAll() {
Node p = top;
while (p != null) {
System.out.print(p.data + " ");
p = p.next;
}
System.out.println();
}
private static class Node {
private int data;
private Node next;
public Node(int data, Node next) {
this.data = data;
this.next = next;
}
public int getData() {
return data;
}
}
}
不管是顺序栈还是链式栈,我么存储数据只需要一个大小为n的数组就够了,在入栈和出栈的过程中,只需要一个两个临时变量存储空间,所以空间复杂度为O(1)。
这里存储数据需要大小为n的数组并不是空间复杂度就是O(n)。因为这个n个空间是必须的,无法省掉。所以我们说空间复杂度的时候,是指除了原本的数据存储空间外,算法运行还需要额外的存储空间。
支持动态扩容的顺序栈
基于数组实现的栈是一个固定的栈,也就是说在初始化栈时需要事先指定栈的大小。当栈满只有,就无法再往栈里添加数据了。尽管链式栈的大小不受限,但要存储next指针,内存消耗比较多。那么如何基于数组实现一个可以支持动态扩容的栈?
如果要实现一个支持动态扩容的栈,我们只需要底层依赖一个支持动态扩容的数组就可以了。当栈满之后,我们就申请一个更大的数组,将原来的数据搬移到新的数组中。
实际上支持动态扩容的顺序栈我们平时开发用不到。现在我们分析一个支持动态扩容的顺序栈的入栈、出栈的时间复杂度。
对于出栈来说,我们不会设计内存的重新申请和数据搬移,所以出栈的时间复杂度仍然是O(1)。但是对于入栈的操作,情况就不一样了。当栈中有空闲时间时,入栈的时间复杂度为O(1)。当空间不够的时候,就需要重新申请内存和数据搬移,所以时间复杂度就变为了O(n)。
栈在函数调用的应用
我们知道,操作系统给每个线程都分配了一个独立的内存空间,这块内存被组织成栈这种结构,用来存储函数被调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用的函数执行完毕返回之后,将这个函数的栈帧出栈。
int main() {
int a = 1;
int ret = 0;
int res = 0;
ret = add(3, 5);
res = a + ret;
printf("%d", res);
reuturn 0;
}
int add(int x, int y) {
int sum = 0;
sum = x + y;
return sum;
}
从代码中看出,main调用量add方法,获取计算结果,并且与临时变量a
相加,最后打印res的值。如下图:
栈在表达式求值中的应用
有一个四则运算:34+13*9+44-12/3
实际上编译器是通过两个栈来实现的。其中一个保存操作数的栈,另一个保存运算符的栈。从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈,当遇到运算符,就与运算符栈的栈顶元素进行比较。
如果比运算符栈顶元素的优先级高,就将当前运算符压入栈。如果比运算符栈顶的优先级低或者相同,从运算符栈中取栈顶运算符,从操作数栈的栈顶取两个操作数,进行运算,再把运算后的记过压入操作数栈,继续比较。
栈在括号匹配中的应用
假设表达式中只包括三个括号,圆括号()、方括号[]和花括号{},并且可以任意嵌套。
{[] ()[{}]}或[{()}([])]等都为合法格式,而{[}()]或[({)]为不合法的格式
那如何检查它是否合法?
我们用栈来保存为匹配的左括号,从左到右扫描字符串。当扫描左括号时,则将其压入栈中;当扫描右括号时,从栈顶取一个左括号,如果能够匹配,比如(),[],{}匹配,则扫描剩下的字符串。如果扫描的过程中,遇到不能配对的右括号,或者栈中没有数据,则说明非法格式。
当所有括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则说明有未匹配的左括号,为非法格式。