08.栈

08.栈:如何实现浏览器的前进和后退功能?

markdown文件已上传至github

浏览器的前进后退功能:当你访问完一系列页面a-b-c,点击浏览器的后退按钮,就可以查看之前浏览过的页面b和a.当你后退到a页面,点击前进按钮,就可以重新查看页面b和c。但是,如果你后退到页面b,点击了新页面d后,就无法再通过前进、后退功能查看页面c了。

那么如何实现这个功能呢?

这就要用到”栈“这种数据结构了。

1.如何理解“栈”?

可以把栈比喻成一叠盘子,放盘子的时候从下往上放,取盘子的时候从上往下取。特点:先进后出,后进先出。

栈是一种操作受限的线性表,只允许在栈插入和删除。

数组和线性表确实可以替代栈,但是它们暴露了太多的操作接口,虽然操作灵活自由,但是却容易出错。

特定的数据结构是满足特定场景的,当某个数据集合只涉及在一端插入和删除,并且满足后进先出、先进后出的特性,我们就要首选“栈”这种数据结构。

2.如何实现一个栈?

栈主要包括两个结构:出栈和入栈,即在栈顶插入一个元素和在栈顶删除一个元素。

栈可以用数组来实现,也可以用链表来实现。前者叫顺序栈,后者叫链式栈

下面代码是用Java实现的顺序栈:


// 基于数组实现的顺序栈
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;
  }
}

栈操作的空间复杂度和时间复杂度

不管是顺序栈还是链式栈,存储数据只需要一个大小为n的数组就够了。在入栈和出栈过程中,只需要一两个临时变量,所以空间复杂度为O(1).

**注意:**存储数据需要一个大小为n的数组,并不是说空间复杂度为O(n)。因为这个n是必须的,无法省掉。所以我们说空间复杂度的时候,是指除了原本的数据存储空间外,算法运行还需要额外的存储空间。

不管是顺序栈还是链式栈,入栈和出栈只涉及栈顶个别数据的操作,所以时间复杂度O(1)。

3.支持动态扩容的顺序栈。

上面是基于数组实现的顺序栈,是一个固定大小的栈。在初始化栈时需要事先指定栈的大小,当栈满之后就无法再往栈里面添加数据了。

尽管链式栈的大小不受限制,但要存储next指针,内存消耗相对较多。

所以,如何实现一个支持动态扩容的栈呢?

实现这样一个栈,我们只需要底层依赖一个支持动态扩容的数组就可以了。栈满后就申请一个更大的数组,将原来的数据搬移到新数组中,去下图:

img

实际上支持动态扩容的顺序栈,我们平时开发中并不常用到。

如何分析支持动态扩容的顺序栈的操作时间复杂度?

对于出栈操作,不会涉及内存的重新申请和数据搬移,所以时间复杂度为O(1)。

对于入栈操作,栈中有空闲空间时,入栈操作的时间复杂度为O(1),当空间不够时需要重新申请内存和数据搬移,所以最好情况的时间复杂度为O(1),最坏情况时间复杂度时O(n)。那么入栈的平均时间复杂度是多少呢?

这里入栈操作的时间复杂度分析要用到之前复杂度章节的摊还分析法,也叫“均摊分析”。

为了分析方便,事先做一些假设和定义:

  • 栈空间不够时,我们重新申请一个是原来大小两倍的数组。
  • 为了简化分析,假设只有入栈操作没有出栈操作。
  • 定义不涉及内存搬迁的入栈操作为simple-push操作,时间复杂度为O(1)。

如果当前栈大小为K,当再有数据入栈时,就需要重新申请2倍大小内存,并且做K个数据的搬移操作,然后在入栈。但是接下来K-1次入栈操作都不需要再重新申请内存和搬移数据,为simple-push操作。所以这K次入栈操作总共涉及了K个数据搬移,将K个数据搬移均摊到K次入栈操作,那么每个入栈操作只需要一个数据搬移和一个simple-push操作。以此类推,入栈操作的均摊时间复杂度为O(1)。

img

通过这个例子可以看到,均摊时间复杂度一般都等于最好情况下的时间复杂度。

4.栈在函数调用中的应用

栈作为一个比较基础的数据结构,应用场景还是蛮多。其中比较经典的一个应用场景就是函数调用栈。

操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将临时变量作为一个栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。

为了方便理解,可以看下这段代码的执行过程。


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的值。下图为执行到add()函数时,函数调用栈的情况。

img

5.栈在表达式求值中的作用。

栈的另一个应用场景,编译器利用栈来实现表达式求值。

为了方便解释,假设算术表达式只包含加减乘除四则运算,比如34+13*9+44-12/3。 如何实现这样一个表达式求值的功能?

实际上编译器是通过两个栈来实现的。其中一个保存操作数的栈,另一个是保存运算符的栈。

具体过程:

  • 从左至右遍历表达式,遇到数字就压入操作数栈,遇到运算符就与运算符栈的栈顶元素进行比较。

  • 如果比运算符栈顶元素的优先级高,就将当前运算符压入栈。

  • 如果比运算符栈顶元素的优先级底或者相同,**从运算符栈中取栈顶运算符,从操作数栈的栈顶取2个操作数,**然后进行计算,再把计算完的结果压入操作数栈,继续比较。

将3+5*8-6这个表达式的计算过程画成一张图,结合图来理解计算过程。

img

6.栈在括号匹配中的应用。

用栈来检查表达式中的括号是否匹配

假设表达式中只包含三种括号,圆括号()、方括号{}、花括号{},并且它们可以任意嵌套,{{[]}}()这种形式为合法格式,{([(])}),类似这种为不合法格式。

现在有一个包含三种括号的表达式字符串,如何检查它是否合法?

  • 可以用栈来保存未匹配的左括号,从左到右依次扫描字符串。
  • 当扫描到左括号时,则将其压入栈中;
  • 当扫描到右括号时,从栈顶取出一个左括号。如果能匹配则继续扫描剩下的字符串。如果扫描过程中遇到不能匹配的字符串,或者栈中没有数据,则说明为非法格式。

7.如何实现浏览器的前进、后退功能?

用两个栈可以完美地解决这个问题。

具体

  • 用两个栈X和Y,我们把首次浏览的页面依次压入栈X。

  • 当点击后退按钮时,再依次从栈X种出栈,并将出栈的数据依次放入栈Y中。

  • 当我们点击前进按钮时,依次从栈Y中取出数据,放入栈X中。

  • 当栈X中没有数据时,那就说明没有页面可以继续后退浏览了。当栈Y中没有数据,就说明没有页面可以点击前进按钮进行浏览了。

8.思考题

1.函数调用栈来保存临时变量,为什么函数调用要用“栈”来保存临时变量?用其他数据结构不行吗?

其实,我们不一定非要用栈来保存临时变量,只不过如果这个函数调用符合后进先出的特性,用栈这种数据结构来实现,是最顺理成章的选择。

从调用函数进入被调用函数,对于数据来说,变化的是什么呢?是作用域。所以根本上,只要能保证每进入一个新的函数,都是一个新的作用域就可以。而要实现这个,用栈就非常方便。在进入被调用函数的时候,分配一段栈空间给这个函数的变量,在函数结束的时候,将栈顶复位,正好回到调用函数的作用域内。

2.JVM 内存管理中有个“堆栈”的概念。栈内存用来存储局部变量和方法调用,堆内存用来存储 Java 中的对象。那 JVM 里面的“栈”跟我们这里说的“栈”是不是一回事呢?如果不是,那它为什么又叫作“栈”呢?

内存中的堆栈和数据结构堆栈不是一个概念,可以说内存中的堆栈是真实存在的物理区,数据结构中的堆栈是抽象的数据存储结构。
内存空间在逻辑上分为三部分:代码区、静态数据区和动态数据区,动态数据区又分为栈区和堆区。
代码区:存储方法体的二进制代码。高级调度(作业调度)、中级调度(内存调度)、低级调度(进程调度)控制代码区执行代码的切换。
静态数据区:存储全局变量、静态变量、常量,常量包括final修饰的常量和String常量。系统自动分配和回收。
栈区:存储运行方法的形参、局部变量、返回值。由系统自动分配和回收。
堆区:new一个对象的引用或地址存储在栈区,指向该对象存储在堆区中的真实数据。

9.参考

这个是我学习王争老师的《数据结构与算法之美》所做的笔记,王争老师是前谷歌工程师,该课程截止到目前已有87244人付费学习,质量不用多说。

在这里插入图片描述

截取了课程部分目录,课程结合实际应用场景,从概念开始层层剖析,由浅入深进行讲解。本人之前也学过许多数据结构与算法的课程,唯独王争老师的课给我一种茅塞顿开的感觉,强烈推荐大家购买学习。课程二维码我已放置在下方,大家想买的话可以扫码购买。

在这里插入图片描述

本人做的笔记并不全面,推荐大家扫码购买课程进行学习,而且课程非常便宜,学完后必有很大提高。

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/supreme_1/article/details/107676027