王道C++班级参考资料
——
C语言部分卷5数据结构
节2

最新版本V3.0
王道C++团队
COPYRIGHT ⓒ 2021-2024. 王道版权所有

概述

Gn!

相比较于数组和链表,栈是一种操作受限的线性结构。

这种操作受限体现在:

  1. 栈只能在同一端添加、删除以及访问元素(栈顶)

  2. 另一端无法执行任何操作(栈底),栈底的元素既不能直接增删,也不能直接访问。

你可以把栈想象成一个杯子,杯底相当于栈底,无法直接进行任何操作。杯口就相当于栈顶,是唯一可以进行添加、删除和访问操作的地方。

在栈中,最先添加到栈中的元素总是最后被删除的,遵循后进先出(LIFO, Last In First Out)的原则。

如下图所示:

栈-示意图

栈这种数据结构的基本操作主要包括以下几种:

  1. 入栈/压栈(push):在栈顶添加一个元素,成为新的栈顶元素,其余元素则被压入栈底。时间复杂度O(1)

  2. 出栈/弹栈(pop):删除栈顶元素,下一个元素成为新的栈顶元素。时间复杂度O(1)

  3. 访问栈顶元素(peek):返回栈顶元素的值。时间复杂度O(1)

  4. 判空(is_empty):检查栈中是否有任何元素。时间复杂度O(1),因为只需要检查一下栈顶元素即可。

说白了,栈仍然还是一个线性数据结构(数组或链表都可以),只不过在实现时,只需要提供栈顶的增删访问操作罢了。

为什么需要栈?

Gn!

栈是一种操作受限的线性结构,既然如此,那么为什么非要使用栈呢?直接使用链表或数组不好吗?

使用栈主要有以下好处:

  1. 高效率。由于栈的操作仅限于顶部,栈的所有基本操作(push、pop、peek)通常都具有 O(1) 的时间复杂度,这使得栈在性能上非常高效。

  2. 简化代码、增强代码可读性。由于操作受限,编程时的考虑因素更少,更容易实现。也有助于写出更清晰、更易于维护的代码。

  3. 非常适合用于解决特定问题。在实际的某些应用场景中,如函数调用管理、括号匹配问题、表达式求值、浏览器的前进后退功能等,自然而然地符合"先进后出"的特点,天然就适合使用栈来解决。

总之,虽然链表提供了更多的灵活性,但在需要特定数据访问模式(如LIFO)的场景中,栈的这种“受限”实际上是一种优势。

这说明在数据结构的选择上,关键在于根据具体的应用需求和场景来决定最合适的结构。

栈的实现方式

Gn!

栈的实现有两种方式:

  1. 基于链表实现

  2. 基于数组实现

这两种实现方式各有优缺点,总得来说:

  1. 如果以一个固定长度的数组来实现栈,实现方式非常简洁,依赖于数组随机访问效率特别高。也不需要额外的空间来存储指针。但缺点是栈大小固定,无法扩容。

  2. 如果用一个动态数组来实现栈,栈具有更灵活的容量,同样随机访问效率高且不需要额外空间存储指针。但缺点是,重分配内存可能是一个较大的性能开销,拖慢整体栈的效率。

  3. 如果以链表来实现,灵活性比数组更强,且扩容不涉及内存重分配,栈整体性能稳定。但缺点是空间占用更多,需要存储额外的指针。当然,链表实现肯定会更复杂一些,这也算个小毛病。

总的来说:

  1. 在已知栈大小限制或不需要频繁调整栈大小时,优先考虑使用固定长度的数组来实现栈,这会提供更好的性能以及更简单的实现以及使用方式。

  2. 在栈大小未知或需要频繁调整栈大小时,可以考虑使用动态数组或链表来实现栈,它们的区别是:

    1. 动态数组实现的缺点是如果需要频繁调整大小,那么性能会非常差。所以如果不需要频繁的进行扩容操作,且对性能需求高,则优先选择动态数组实现。而且现代很多高级编程语言(比如C++、Java等)都已提供了现成的动态数组实现。

    2. 如果栈的大小完全不可预测,使用动态数组实现会导致频繁调整大小的操作,那么可以更多的考虑使用链表实现。

在这里,我们就基于链表来实现一下栈,基于数组的实现就留给大家做作业了。

二级指针链式栈的实现

Gn!

在课堂上,我们就基于链表来实现一个链式栈,同时为了复习巩固二级指针的语法,我们采用二级指针的语法来实现这个链式栈。

链式栈模型

Gn!

以链表为基础实现一个栈,首先要基于以下结构体:

链式栈模型-结构体

一般而言,我可以再定义一个结构体用于表示整个栈,但这里我们直接在main函数中使用以下语句创建一个栈:

也就是说,这个链式栈的模型如下图所示:

链式栈-模型图

于是栈的四个基本操作,就可以变为链表的四个操作:

  1. 入栈:也就是头插法在链表中插入一个结点

  2. 出栈:删除链表的第一个结点

  3. 访问栈顶元素:访问链表的第一个结点

  4. 判空:判断链表的第一个结点是不是NULL

设计头文件

Gn!

我们要做的第一步仍然是编写头文件。我们可以创建一个头文件"linked_stack.h"(链式栈)

参考代码如下:

链式栈-头文件参考代码

注意事项:

  1. 入栈和出栈的函数需要对传入的栈顶指针做出修改,所以需要传入栈顶指针的二级指针!!

  2. 访问和判空则不需要修改栈帧指针,直接传入指针即可。可以使用const修饰它,这是一个好习惯。

实现判空

Gn!

判空是最容易实现的,而且判空对后续操作会有影响,所以我们可以最先实现它。参考代码如下:

实现判空-参考代码

只要栈顶指针为空指针,就表示这个链式栈为空,此时就无法进行出栈/访问栈顶元素这样的操作了。

实现入栈

Gn!

所谓入栈,也就是在链表的头部,使用头插法插入一个结点。这个逻辑我们之前已经写过了,这里只需要注意二级指针的处理就可以了,参考代码如下:

实现入栈-参考代码

注意二级指针解引用一次可以用于修改原本实参的一级指针。

实现出栈

Gn!

所谓出栈,也就是根据头指针删除链表的第一个结点。这个逻辑我们也很熟悉了,参考代码如下:

实现出栈-参考代码

注意,要先判空再执行出栈操作,否则会因解引用空指针导致错误。

实现访问栈顶元素

Gn!

访问栈顶元素再简单不过了。参考代码如下:

实现访问栈顶元素-参考代码

仍然要注意先判空,才能解引用。

补充: 不使用二级指针的链式栈

Gn!

上述代码实现栈,主要目的是为了练习二级指针的语法,为了更方便的使用栈,我们一般还是选择基于以下声明定义来实现栈:

链式栈的实现-参考头文件

这些函数的实现可以参考以下代码:

链式栈的实现-参考实现源文件

以上。

补充: 动态数组栈

Gn!

基于动态数组,我们也完全可以实现一个可以自动扩容的动态数组栈。首先基于以下头文件:

动态数组栈头文件-参考代码

这些函数的实现可以参考以下代码:

动态数组栈实现-参考代码

写完这些实现,可以写一个测试用例来测试这个动态栈。如下:

动态栈-测试用例代码

此动态数组栈会将0~999顺序存进栈中,再遍历出栈时,遵循"先进后出"的原则,此段代码会将999- 0,倒着输出出来。

栈的应用场景

Gn!

栈特别适用那些存在"后进先出"逻辑的场景,在实际的开发,栈很常用。栈至少可以应用于以下领域:

  1. 函数调用机制

  2. 括号匹配问题

  3. 表达式求值问题

  4. 浏览器历史记录前进后退功能

  5. 深度优先遍历算法(一般直接用函数调用者递归实现)

  6. ...

函数调用机制

Gn!

在编程语言当中,函数(方法)的调用机制都要依赖于栈的"后进先出"机制,这是程序员日常接触到最常见的栈的应用场景。

稍微要注意的是,编程语言中函数调用栈,是编程语言以及计算机硬件共同管理维护的,这个过程程序员一般是不可控自动完成的,不能就简单的将函数调用栈等同于数据结构里的栈。

但是好在,它们的共同特点是"后进先出",对于普通的程序员而言,只需要:

  1. 就把函数调用简单的理解成:"函数栈帧进栈,成为栈顶元素"

  2. 把函数执行结束理解成:"栈顶的函数栈帧出栈销毁"

这样就可以了。

知道这些,对于理解一门编程语言的函数调用过程,理解程序的执行流程是足够的。

括号匹配问题

Gn!

如果仅仅是某一种括号(比如小括号)的匹配,那只需要写一个循环遍历字符串即可求解匹配问题。但如果是多种类型的括号同时存在一个字符串中,比如:

  1. {[()]}:匹配成功

  2. ()[]{}:匹配成功

  3. [{()}]:匹配成功

  4. [({}]:匹配失败

  5. [()]{:匹配失败

  6. ...

单靠循环求解就非常困难了。这时我们就可以考虑使用栈这种数据结构来求解。怎么思考这个问题呢?

栈的核心操作是入栈和出栈,这和括号匹配有啥关系呢?

很简单按照下列思路就可以了:

  1. 遍历整个字符串

  2. 遇到任意左括号,就将它的对应右括号入栈

  3. 遇到任意右括号:

    1. 先判断当前栈是否为空,如果栈为空说明括号匹配失败

    2. 在栈不为空的前提下,立刻将当前栈顶元素出栈,判断栈顶元素是否和该右括号是同类型右括号

      • 如果不是,说明括号匹配失败

      • 如果是同类型右括号,那就继续遍历字符串重复上面的操作。

  4. 如果遍历完字符串,发现栈也同时为空,说明匹配括号成功。如果发现栈中还有残留的右括号,那么说明匹配失败。

我们使用上面已经实现的一个链式栈来辅助完成,参考的代码实现如下:

括号匹配问题C语言实现-参考代码

若传入的字符串不包含括号,此函数也会返回true,没有括号那也算是匹配成功吧。

表达式求值

Gn!

表达式求值对于任何编程语言而言都是基础逻辑,这个过程一般发生在程序的运行期间。

程序员在代码中输入的表达式,一般被我们称之为"中缀表达式",也就是我们日常生活、数学中使用的表达式,指的是将运算符放在操作数中间的一种表达式,如:

  1. A + B

  2. 7 * 3

  3. (1 + 2) * (3 - 4)

  4. ..

中缀表达式的主要特点是它们对于人类来说非常直观,但如果让计算机直接处理中缀表达式的求值,就意味着需要像人类一样考虑优先级和括号等问题,实现起来就太麻烦了,一般计算机都不会直接处理中缀表达式。

为了能够在计算机中更轻松的处理表达式求值,普遍会先把中缀表达式处理成"后缀表达式",也就是一种将运算符放在操作数后面的表达式形式,如:

  1. A B + 等价于 A + B

  2. 7 3 * 等价于 7 * 3

  3. 1 2 + 3 4 - * 等价于 (1 + 2) * (3 - 4)

  4. ...

后缀表达式虽然对于人类来说不是特别直观比较容易摸不到头脑,但对于计算机而言,进行一些特殊处理,可以很容易很轻松地进行表达式求值运算。

总之,在计算机内部处理表达式求值问题时,往往会存在两个过程:

  1. 将中缀表达式处理成后缀表达式

  2. 利用后缀表达式计算求值

而这两个过程,大多都会选择使用栈来辅助完成,利用栈的"后进先出"的特点。

转换成后缀表达式

Gn!

中缀表达式转换成后缀表达式的过程,需要一个运算符栈来辅助完成,大致过程思路如下:

  1. 从左往右遍历中缀表达式:

  2. 如果遇到操作数:直接将其添加到结果后缀表达式中,不进栈。

  3. 如果遇到操作符(如 +, -, *, /),那么就比较当前遍历到的运算符与运算符栈顶的运算符的优先级:

    1. 如果当前运算符的优先级高于栈顶运算符的优先级,或者栈为空,则将当前运算符压入栈中。

    2. 如果当前运算符的优先级低于或等于栈顶运算符的优先级,则需要将栈顶的运算符弹栈并添加到后缀表达式中,然后再次比较新的栈顶运算符与当前操作符的优先级。这一过程持续进行,直到当前运算符可以被压入栈中。

    3. 如果当前栈顶是一个左括号,那么不论是什么运算符,都直接将运算符入栈。

  4. 如果遇到左括号(立刻将它压入运算符栈。

  5. 如果遇到右括号 ):立刻将运算符栈中的运算符弹栈并输出到后缀表达式中,这个过程会持续到遇到左括号为止。但不会将左括号添加到后缀表达式中,只是从运算符栈中移除它。且也不会把右括号添加到后缀表达式中,后缀表达式中不存在小括号。

  6. 遍历完中缀表达式后,若运算符栈中还剩余运算符,则将这些运算符依次弹栈,添加到后缀表达式中。

  7. 得到最终的后缀表达式结果。

举个例子,将中缀表达式 2 * (3 + 4) / 2 - 2 转换为后缀表达式:

  1. 扫描到 2,它是操作数,所以添加到结果后缀表达式:2

  2. 扫描到 *,此时栈为空,压入运算符栈:栈内为 *(左边表示栈顶)

  3. 扫描到 (,遇到左小括号立刻压入操作符栈:栈内为 ( *

  4. 扫描到 3,它是操作数,添加到后缀表达式:2 3

  5. 扫描到 +,当前栈顶是左小括号,直接将加号压入运算符栈:栈内为 + ( *

  6. 扫描到 4,添加到后缀表达式:2 3 4

  7. 扫描到 ),弹出栈内操作符直到遇到 (:后缀表达式变为 2 3 4 +,栈内为 *

  8. 扫描到 /,由于其优先级与栈顶的 * 相同,根据左结合性,弹出 * 并加入后缀表达式,然后将 / 压入栈:后缀表达式为 2 3 4 + *,栈内为 /

  9. 扫描到 2,添加到后缀表达式:2 3 4 + * 2

  10. 扫描到 -,它的优先级不如栈顶的/,于是将/出栈加入后缀表达式中,并让-入栈。此时后缀表达式是2 3 4 + * 2 /,栈内为:-

  11. 最后扫描到2,它是操作数,直接添加到后缀表达式中2 3 4 + * 2 / 2

  12. 最后,弹出栈内剩余的操作符并添加到后缀表达式:2 3 4 + * 2 / 2 -

现在你已经得到了一个后缀表达式,那么接下来我们仍然可以利用栈来计算这个后缀表达式。

下列是利用C语言代码实现,中缀表达式转换为中缀表达式的过程。只考虑"+ - * /"四种运算符以及"()",只考虑整数运算。

代码实现如下:

转换后缀表达式-参考代码

简单了解一下即可。

后缀表达式求值

Gn!

后缀表达式求值也需要利用栈结构来辅助完成,这个过程会更简单一些:

  1. 从左往右遍历中缀表达式:

  2. 如果遇到操作数:直接将其压入栈中。

  3. 如果遇到运算符

    1. 从栈中弹出所需数量的操作数,执行相应的运算,然后将结果压回栈中。比如对于二元操作符(如 +, -, *, /),需要弹出两个操作数。

    2. 注意:对于-/这样在意操作数前后的二元运算符来说,假如此时栈中的两个操作数是a b(左边表示栈顶),那么它们运算规则应该是b - a。(思考一下这是为什么?)

  4. 重复此过程,直到整个表达式被扫描完毕。

  5. 表达式的结果就是栈顶数值。

比如我们上面已经得到的一个后缀表达式2 3 4 + * 2 / 2 -,利用栈,其计算过程和结果是:

  1. 前三个扫描到的都是操作数,于是都直接入栈。栈中元素是:4 3 2(左边表示栈顶)

  2. 扫描到+,于是将栈顶的两个操作数出栈(因为加法是二元运算符),于是计算3 + 4结果是7,将7存入栈顶。栈中元素目前是:7 2

  3. 继续扫描到*,于是将栈顶的两个操作数出栈(因为乘法是二元运算符),于是计算2 * 7结果是14,将14存入栈顶。栈中元素目前是:14

  4. 继续扫描到2,它是一个操作数,于是直接入栈。栈中元素目前是:2 14

  5. 继续扫描到/,于是将栈顶的两个操作数出栈(因为除法是二元运算符),于是计算14 / 2结果是7,将它入栈。此时栈中元素是:7

  6. 继续扫描到2,它是一个操作数,于是直接入栈。栈中元素目前是:2 7

  7. 继续扫描到-,于是将栈顶的两个操作数出栈(因为减法是二元运算符),于是计算7 - 2结果是5,将它入栈。此时栈中元素是:5

  8. 发现后缀表达式扫描完成,于是此时的栈顶元素5,就是表达式的最终结果。

同样基于一个操作数栈,可以实现后缀表达式求值,只考虑"+ - * /"四种运算符以及"()",只考虑整数运算。参考代码如下:

总结

以上,我们就通过栈这种数据结构,解决了计算机内的表达式求解问题。当然实际情况,由于运算符更多,需要考虑的优先级也更多,会更复杂一些,但逻辑上是完全没有问题的。

浏览器历史前进后退功能

Gn!

浏览器历史的前进后退功能,看起来很强大的功能,实际上只需要两个栈就可以实现了:

  1. 一个前进栈,用于存储用户在点击后退按钮后,再点击前进按钮可能访问的页面。最初是空的。

  2. 一个后退栈,用于指示存储用户当前访问的页面,以及用户的历史访问页面记录以用作实现后退按钮。

具体来说是:

  1. 访问新页面:当用户访问一个新页面时,将新访问的当前页面压入后退栈中,并立刻清空前进栈。后退栈的栈顶始终表示当前正在访问的页面。

  2. 后退按钮:当用户点击浏览器的后退按钮时,后退栈弹栈,并且将弹出的页面压入前进栈。后退栈弹栈后,后退栈栈顶下的一个页面就成为了新的当前页面。

  3. 前进按钮:前进按钮仅在前进栈不为空时生效。当用户点击浏览器的前进按钮时,前进栈弹栈,并且将弹出的页面压入后退栈。此时前进栈弹出的页面就成为了后退栈的栈顶,也就成为了新的当前页面。

可以画图来理解这个过程:

浏览器前进后退功能-栈示意图

这种使用两个栈的方法非常适合处理线性的浏览历史,因为它自然地模拟了后退和前进操作的LIFO(后进先出)特性。此外,它也使得浏览器能够快速访问历史记录,无需遍历整个历史列表。

栈在浏览器前进后退功能的应用,充分体现了栈可以用于记录"轨迹"的作用,在算法思想中有一种叫做"回溯"的思想,它就是利用了栈的这个特点。

The End