文章目录
0. 开篇问题
假设你是 Chrome 浏览器的开发工程师,你会如何实现网页前进后退的功能?
1. 什么是栈?
栈是一种操作受限的线性表,只允许在一端插入和删除数据。
后进者先出,先进者后出,这就是典型的“栈”结构。
2. 为什么需要栈?
相比数组和链表,栈带来的只有限制,并没有任何优势。那直接使用数组或者链表不就好了吗?为什么还要用这个“操作受限”的“栈”呢?
- 数组和链表确实能够替代栈
- 特定的数据结构是对特定场景的抽象
- 软件开发中,不是暴露越多接口越好,操作灵活会带来不可控。一句话“有限制,才自由”,这是软件开发的特点。
因此,当某个数据集合只涉及在一端插入和删除数据,并且满足后进先出、先进后出的特性,应该首选“栈”这种数据结构。
3. 如何实现一个栈?
实现一个栈,必须要实现栈的接口。
public interface StackInterface<T> {
boolean push(T item);
T pop();
}
3.1 顺序栈
public class ArrayStack<T> implements StackInterface<T> {
final static int LEN = 10;//栈最大元素个数
T[] items = (T[]) new Object[LEN];
int index = 0;
@Override
public boolean push(T item) {
if (index >= LEN) {
return false;
}
items[index++] = item;
return true;
}
@Override
public T pop() {
if (index <= 0) {
return null;
}
return items[--index];
}
}
出栈入栈时间复杂度:O(1)
出栈入栈空间复杂度:O(1)
3.2 链式栈
public class LinkStack<T> implements StackInterface<T> {
private class Node {
public Node(T item, Node next) {
this.item = item;
this.next = next;
}
T item;
Node next;
}
private Node top;
private int index;
@Override
public boolean push(T item) {
Node newNode = new Node(item, top);
top = newNode;
index++;
return true;
}
@Override
public T pop() {
if (index <= 0) {
return null;
}
T item = top.item;
top = top.next;
index--;
return item;
}
}
出栈入栈时间复杂度:O(1)
出栈入栈空间复杂度:O(1)
3.3 支持动态扩容的栈
链式栈天然支持动态扩容,顺序栈需要在栈容量不足时,做重新申请空间和数据搬移工作。
操作如图:
代码如下:
public class ArrayStack<T> implements StackInterface<T> {
int LEN = 2;
T[] items = (T[]) new Object[LEN];
int index = 0;
@Override
public boolean push(T item) {
if (index >= LEN) {
//判断栈满了,就申请2倍空间,然后搬移数据,再push
T[] tmpItems = (T[]) new Object[LEN+LEN];
LEN = 2*LEN;
System.arraycopy(items, 0, tmpItems, 0, items.length);
items = tmpItems;
}
items[index++] = item;
return true;
}
@Override
public T pop() {
if (index <= 0) {
return null;
}
return items[--index];
}
}
出栈入栈时间复杂度:最好O(1),最坏O(n),均摊O(1)
出栈入栈空间复杂度:O(1)
4. 栈的应用
4.1 函数调用栈
操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。
- 每进入一个函数,就会将其中的临时变量作为栈帧入栈
- 当被调用函数执行完成return之后,将这个函数对应的栈帧出栈。
如下代码:
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;
}
调用栈如图:
4.2 表达式求值
比如3+5*8-6,这个表达式看起来简单,但是对于计算机来说理解它是个复杂的事情。
利用两个栈,其中一个用来保存操作数,另一个用来保存运算符,从左向右遍历表达式。
- 当遇到数字,我们就直接压入操作数栈;
- 当遇到运算符,就与运算符栈的栈顶元素进行比较
(1) 若比运算符栈顶元素优先级高,就将当前运算符压入栈.
(2) 若比运算符栈顶元素的优先级低或者相同,从运算符栈中取出栈顶运算符,从操作数栈顶取出2个操作数,然后计算,把计算完的结果压入操作数栈 - 继续比较,知道遍历到最后,求出最终结果。
操作过程如下图所示:
4.3 括号匹配中应用
- 用栈保存为匹配的左括号,从左到右一次扫描字符串,当扫描到左括号时,则将其压入栈中;
- 当扫描到右括号时,从栈顶取出一个左括号,如果能匹配上,则继续扫描剩下的字符串。
- 如果扫描过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。
- 当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明未匹配的左括号为非法格式。
5. 解开篇答
如何实现浏览器的前进后退功能?
我们使用两个栈X 和 Y。
- 我们把首次浏览的页面依次压入栈 X。
- 当点击后退按钮时,再依次从栈 X 中出栈,并将出栈的数据依次放入栈 Y。
- 当我们点击前进按钮时,我们依次从栈 Y 中取出数据,放入栈 X 中。
- 当栈 X 中没有数据时,那就说明没有页面可以继续后退浏览了。
- 当栈 Y 中没有数据,那就说明没有页面可以点击前进按钮浏览了。
如下图演示的几种情况:
-
比如你顺序查看了 a,b,c 三个页面,我们就依次把 a,b,c 压入栈,这个时候,两个栈的数据就是这个样子:
-
当你通过浏览器的后退按钮,从页面 c 后退到页面 a 之后,我们就依次把 c 和 b 从栈 X 中弹出,并且依次放入到栈 Y。这个时候,两个栈的数据就是这个样子:
-
这个时候你又想看页面 b,于是你又点击前进按钮回到 b 页面,我们就把 b 再从栈 Y 中出栈,放入栈 X 中。此时两个栈的数据是这个样子:
-
这个时候,你通过页面 b 又跳转到新的页面 d 了,页面 c 就无法再通过前进、后退按钮重复查看了,所以需要清空栈 Y。此时两个栈的数据这个样子:
6. 课后思考
6.1 函数调用栈
为什么函数调用要用“栈”来保存临时变量呢?用其他数据结构不行吗?
- 不一定非用栈来保存临时变量,只不过函数调用符合后进先出特性,用栈来实现最顺理成章。
- 从调用函数进入被调函数,对于数据来说变化的是什么呢?是作用域。
- 本质上只要能保证每进入一个新的函数,都是一个新的作用域就可以。而实现用栈非常方便,在进入被调函数时,分配一段栈空间给这个函数的变量,在函数结束的时候,将栈顶复位,正好回到调用函数的作用域内。
6.2 JVM堆栈概念
栈内存用来存储局部变量和方法调用,堆内存用来存储 Java 中的对象。那 JVM 里面的“栈”跟我们这里说的“栈”是不是一回事呢?如果不是,那它为什么又叫作“栈”呢?
- 内存中的堆栈和数据结构堆栈不是一个概念,可以说内存中的堆栈是真实存在的物理区,数据结构中的堆栈是抽象的数据存储结构。
- 内存空间在逻辑上分为三部分:代码区、静态数据区和动态数据区,动态数据区又分为栈区和堆区。
- 代码区:存储方法体的二进制代码。高级调度(作业调度)、中级调度(内存调度)、低级调度(进程调度)控制代码区执行代码的切换。
- 静态数据区:存储全局变量、静态变量、常量,常量包括final修饰的常量和String常量。系统自动分配和回收。
- 栈区:存储运行方法的形参、局部变量、返回值。由系统自动分配和回收。
- 堆区:new一个对象的引用或地址存储在栈区,指向该对象存储在堆区中的真实数据。