数据结构与算法面试之——一文搞定栈结构 1.什么是栈? 实际上我们每天在使用浏览器的时候的前进和后退能力本身就是依赖于栈结构实现,那么什么是栈呢?后进者先出,先进者后出,这就是典型的“栈”结构。从栈的操作特性来看,是一种“操作受限”的线性表,只允许在端插入和删除数据。 事实上,从功能上来说,数组或链表确实可以替代栈,;但是特定的数据结构是对特定场景的抽象,而且,数组或链表暴露了太多的操作接口,操作上的确灵活自由,但使用时就比较不可控,自然也就更容易出错。当某个数据集合只涉及在某端插入和删除数据,且满足后进者先出,先进者后出的操作特性时,我们应该首选栈这种数据结构,即:特定的数据结构是对特定场景的抽象。 举例说明:栈这个数据结构是对计算机组成原理里面函数调用这个特定场景的抽象,当发生函数调用时,call指令的下一条指令的地址入栈,当函数调用完成后,出栈,把call指令的下一条指令的地址取出存入PC寄存器,根据PC寄存器存入的指令地址,找到该条指令,存入指令寄存器,共CPU解析执行。 又比如:浏览器的前进和后退能力;以及树的广度遍历迭代时,如果采用的是非递归的算法,也是需要使用栈的能力。 2 如何实现栈 正如前文所说,实际上真正底层的数据结构只有两种:数组和链表,因此,栈本身也是通过数组和链表实现,只不过,使用数组实现的栈称为顺序栈,使用链表实现的栈称为链式栈。 在使用数组实现栈的时候,控制只在数组的尾部进行插入删除等操作,来避免数据的迁移带来的复杂度递增,但是这里也需要注意,由于数组的大小是固定的,因此,在实际使用中需要注意数组的扩容;在使用链表实现链式栈的时候,控制之在链表的头部进行插入删除操作,避免遍历带来的复杂度增加。 2.1 指定数组大小的栈的实现 既然是指定数组大小,那么只要是底层维护一个指定长度的数组即可,不需要考虑扩容的问题。 从上面的代码也能够快速看出,实际上在入栈和出栈过程中,只需要一两个临时的变量存储空间即可,而且只涉及到个别操作的操作,因此时间复杂度和空间复杂度均为O(1);再次强调下,空间复杂度指的是:除了原本的数据存储空间外,算法运行还需要额外的存储空间。 2.2 支持动态扩容的顺序栈 既然是顺序栈还要支持动态扩容,所以简单的实现逻辑就是:如果当数组空间不足时,我们重新申请一块更大的内存,将原有数组的数据进行拷贝迁移。 对于出栈操作来说,我们不会涉及内存的重新申请和数据的搬移,所以出栈的时间复杂度仍然是 O(1)。但是,对于入栈操作来说,情况就不一样了。当栈中有空闲空间时,入栈操作的时间复杂度为 O(1)。但当空间不够时,就需要重新申请内存和数据搬移,所以最坏时间复杂度就变成了 O(n)。 但是对于均摊复杂度的分析就有些复杂,分析如下:如果当前栈大小为 K,并且已满,当再有新的数据要入栈时,就需要重新申请 2 倍大小的内存,并且做 K 个数据的搬移操作,然后再入栈。但是,接下来的 K-1 次入栈操作,我们都不需要再重新申请内存和搬移数据,所以这 K-1 次入栈操作都只需要一个 simple-push 操作就可以完成。为了让你更加直观地理解这个过程,我画了一张图。
你应该可以看出来,这 K 次入栈操作,总共涉及了 K 个数据的搬移,以及 K 次 simple-push 操作。将 K 个数据搬移均摊到 K 次入栈操作,那每个入栈操作只需要一个数据搬移和一个 simple-push 操作。以此类推,入栈操作的均摊时间复杂度就为 O(1)。 通过这个例子的实战分析,也印证了前面讲到的,均摊时间复杂度一般都等于最好情况时间复杂度。因为在大部分情况下,入栈操作的时间复杂度 O 都是 O(1),只有在个别时刻才会退化为 O(n),所以把耗时多的入栈操作的时间均摊到其他入栈操作上,平均情况下的耗时就接近 O(1)。 2.3 链式栈的实现 链式栈是基于链表实现的,因此,要定义链式栈,首先需要定义结点。 基于结点,链式栈的实现为: 3 栈的应用 3.1 栈在函数调用中的应用 操作系统给每个线程分配了一块独立的内存空间,这块内存被组织成“栈”这种结构,用来存储函数调用时的临时变量。每进入一个函数,就会将其中的临时变量作为栈帧入栈,当被调用函数执行完成,返回之后,将这个函数对应的栈帧出栈。 关于函数调用栈的详细讲解,可以查看这篇文章,函数调用栈. 实际上,Java的虚拟机栈也是同样的道理。Java中的虚拟机栈就是用来java方法执行的内存模型。每个方法在执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。java字节码指令先将方法中的临时变量存放到栈帧中的局部变量表中,然后根据具体的计算字节码指令在操作数栈中对其进行入栈和出栈操作,最终将计算得到的结果压入操作数栈中返回,随着方法执行完毕,栈帧随之销毁。这就很好地实现了方法之间的执行调用关系这一实际开发场景。 对应的图为:
实际上,虚拟内存空间分为:内核空间(高地址)和用户空间(低地址)。用户空间从低到高布局依次为: 代码区(Text Segment,存放二进制代码数据), 数据区(Data Segment,存放初始化过的静态常量和已初始化过的全局变量数据), BSS区(BSS Segment,存放未初始化的静态变量数据)堆区:程序运行过程中动态分配的大块内存,由低地址向高地址分配内存映射:由高地址向低地址分配栈区:存储局部、临时变量,函数调用时,存储函数的返回指针,用于控制函数的调用和返回。在程序块开始时自动分配内存,结束时自动释放内存,其操作方式类似于数据结构中的栈。 3.2 栈在表达式求值中的应用 利用两个栈,其中一个用来保存操作数,另一个用来保存运算符。我们从左向右遍历表达式,当遇到数字,我们就直接压入操作数栈;当遇到运算符,就与运算符栈的栈顶素进行比较,若比运算符栈顶素优先级高,就将当前运算符压入栈,若比运算符栈顶素的优先级低或者相同,从运算符栈中取出栈顶运算符,从操作数栈顶取出2个操作数,然后进行计算,把计算完的结果压入操作数栈,继续比较。 王争老师以:3+5*8-6 这个表达式的计算过程画成了一张图:
以运算符号作引导,当“-”要入栈就得和“x”作比较,发现自己没有“x”优先级高,所以拿出“5”、“8”、“x”进行运算;再次尝试入栈继续和“+”作比较,自己和“+”同级,依旧需要拿出“3”、“40”、“+”进行运算;再次尝试入栈,没有可比较的运算符,入栈成功。 3.3 栈在括号匹配中的应用 用栈保存为匹配的左括号,从左到右一次扫描字符串,当扫描到左括号时,则将其压入栈中;当扫描到右括号时,从栈顶取出一个左括号,如果能匹配上,则继续扫描剩下的字符串。如果扫描过程中,遇到不能配对的右括号,或者栈中没有数据,则说明为非法格式。 当所有的括号都扫描完成之后,如果栈为空,则说明字符串为合法格式;否则,说明未匹配的左括号为非法格式。 是实际的应用开发中,应该也是类似的能力。比如说:浏览器接收到HTML字符流,在构建DOM树时,应该就是采用栈的形式来进行标签的匹配;又比如,在Spring项目中,我们会使用pom管理依赖的文件引用,在使用<dependency>和</dependency>文件应该也是相互匹配。 3.4.栈在浏览器前进后退功能中的应用 我们使用两个栈X和Y,我们把首次浏览的页面依次压如栈X,当后退按钮时,再依次从栈X中出栈,并将出栈的数据一次放入Y栈。当前进按钮时,我们依次从栈Y中取出数据,放入栈X中。当栈X中没有数据时,说明没有页面可以继续后退浏览了。当Y栈没有数据,那就说明没有页面可以前进浏览了。 但是在使用两个栈实现前进和后退能力的时候,如果打开一个新的页面,就需要将Y栈的数据清空,因为X栈的数据的前进的页面已经与Y栈的数据不再存在前后关系。 实际上,浏览器的前进后退功能使用数组加游标的方式也是可以实现,浏览记录直接append到数组,游标记录当前页位置下标,后退游标减一,前进游标加一。如果在中间某个位置跳转到新页面了,就把游标的下一个设置为新的就好。 4 关于栈与函数调用临时变量的关系 我们在讲栈的应用时,讲到用函数调用栈来保存临时变量,为什么函数调用要用“栈”来保存临时变量呢?用其他数据结构不行吗? 1)我们不一定非要用栈来保存临时变量,只不过如果这个函数调用符合后进先出的特性,用栈这种数据结构来实现,是最顺理成章的选择。 从调用函数进入被调用函数,对于数据来说,变化的是什么呢?是作用域。所以根本上,只要能保证每进入一个新的函数,都是一个新的作用域就可以。而要实现这个,用栈就非常方便。在进入被调用函数的时候,分配一段栈空间给这个函数的变量,在函数结束的时候,将栈顶复位,正好回到调用函数的作用域内。 ——引自评论区。 这里解释一下,最重要的是因为只能操作”栈顶素”,在作用域的角度看,操作完一个作用域的再回到之前的作用域下,用栈保存临时变量则是最好的选择,其他的数组,链表等都能”违规地”进行随机访问。 2)函数调用之所以用栈,是因为函数调用中经常嵌套,例子为:A调用B,B又调用C,那么就需要先把C执行完,结果复制给B中的临时变量和,B的执行结果再赋值给A的临时变量,嵌套越深的函数越需要被先执行,这样刚好符合栈的特点,因此每次遇到函数调用,只需要压栈,最后依次从栈顶弹出依次执行即可; 3)因为函数调用的执行顺序符合后进者先出,先进者后出的特点。比如函数中的局部变量的生命周期的长短是先定义的生命周期长,后定义的生命周期短;还有函数中调用函数也是这样,先开始执行的函数只有等到内部调用的其他函数执行完毕,该函数才能执行结束。正是由于函数调用的这些特点,根据数据结构是特定应用场景的抽象的原则,我们优先考虑栈结构。 5.为什么JVM中的堆栈中也被称为栈 问题:我们都知道,JVM内存管理中有个“堆栈”的概念。栈内存用来存储局部变量和方法调用,堆内存用来存储Java中的对象。那JVM里面的“栈”跟我们这里说的“栈”是不是一回事呢?如果不是,那它为什么又叫作“栈”呢? 5.1 从数据存储类型 内存中的堆栈和数据结构堆栈不是一个概念,可以说内存中的堆栈更多的是对象的存储,数据结构中的堆栈是抽象的数据存储结构。 内存空间在逻辑上分为三部分:代码区、静态数据区和动态数据区,动态数据区又分为栈区和堆区。 代码区:存储方法体的二进制代码。高级调度(作业调度)、中级调度(内存调度)、低级调度(进程调度)控制代码区执行代码的切换。 静态数据区:存储全局变量、静态变量、常量,常量包括final修饰的常量和String常量。系统自动分配和回收。 栈区:存储运行方法的形参、局部变量、返回值。由系统自动分配和回收。 堆区:new一个对象的引用或地址存储在栈区,指向该对象存储在堆区中的真实数据。 5.2 从数据的特性 为什么内存中的“栈”也叫“栈”,而且英文都是stack? 我认为,虽然内存中的栈和数据结构的栈不是一回事,即内存中的栈是一段虚拟的内存空间,数据结构中的栈是一种抽象的数据类型,但是它们都有“栈”的特性——后进先出,所以都叫“栈”也无可厚非。可以把内存中的栈当中数据结构栈的一种实现嘛,所以我觉得可以理解为一回事! 5.3 从各自的定义理解 1)首先,在内存管理和数据结构上,都有“堆”和“栈”这两个概念。 (2)内存管理上的“堆”和“栈”,强调的是数据的生命周期(分配释放是否有次序要求);数据结构上的“堆”和“栈”,强调的是数据的组织。 (3)内存管理上的“栈”符合数据结构的“栈”的特点,即LIFO,两者同名可以接受。 (4)数据结构上的“堆”定义:它是一种数组对象,它可以被视为一科完全二叉树结构;应用场景包括堆排序,优先队列等。和内存管理的“堆”定义不同(这里我不是很确定,没有看过内存管理上堆的实现细节)。 参考: (1)数据结构之“堆”:http://www.cnblogs.com/Jason-Damon/archive/2012/04/18/2454649.html (2)专题 | 堆、栈简介:https://zhuanlan.zhihu.com/p/45597548 (3)知乎| 为什么c++中要分为heap(堆)和stack(栈)? – Milo Yip的回答:https://www.zhihu.com/question/281940376/answer/424990646
2024最新激活全家桶教程,稳定运行到2099年,请移步至置顶文章:https://sigusoft.com/99576.html
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请联系我们举报,一经查实,本站将立刻删除。 文章由激活谷谷主-小谷整理,转载请注明出处:https://sigusoft.com/25756.html