第9章使用栈和队列打造优雅的代码
直到现在,我们围绕数据结构的讨论主要集中在它们如何影响各种操作的性能上。然而,在你的编程工具箱中拥有各种数据结构也能让你创建更简单、更易读的代码。
在本章中,你将会探索两种新的数据结构:栈(Stacks)和队列(Queues)。事实上,这两种结构并不是完全新的。**它们只是带有一些限制的数组。**然而,正是这些限制让它们变得如此优雅。
更具体地说,栈和队列是处理临时数据的优雅工具。从操作系统架构到打印作业再到数据遍历,栈和队列作为临时容器,可以用来构建优美的算法。
把临时数据想象成餐馆的食品订单。每个顾客的订单在餐点制作和送达之前都很重要;然后你就会把订单单扔掉。在处理完后,你不需要保留那些信息。临时数据是一种在被处理后不再具有任何意义的信息,所以在处理完后你可以丢弃它们。
栈和队列处理这类临时数据,但它们特别关注数据处理的顺序,你现在就会了解到这点。
栈
"栈的数据存储方式与数组相同——它只是元素的列表。唯一的限制是栈具有以下三个约束条件:
- 数据只能插入到栈的末尾。
- 数据只能从栈的末尾删除。
- 只能读取栈的最后一个元素。
你可以将栈看作是一摞盘子;除了顶部的那一个,你无法看到任何盘子的正面。同样地,你只能在栈的顶部添加盘子,不能在除顶部之外的任何位置添加,也只能从顶部移除盘子。实际上,大多数计算机科学文献将栈的末尾称为顶部,将栈的开始称为底部。
我们的图表将按照这个术语来看待栈,将栈视为垂直的数组,如下所示:
正如你所看到的,数组中的第一个项成为栈的底部,而最后一个项成为栈的顶部。
虽然栈的限制看起来有点限制性,但我们很快将会看到它们对我们是有益处的。
要看一个栈的实际运行过程,让我们从一个空栈开始。
向栈中插入一个新值也称为将其推入栈。可以将其想象成将一道菜加到盘子堆的顶部。
让我们将 5 推入栈中:
再次强调,这里没有任何花哨的操作。我们只是简单地将一个数据元素插入到数组的末尾。
接下来,让我们将 3 推入栈中:
然后,让我们将 0 推入栈中:
请注意,我们总是将数据添加到栈的顶部(即末尾)。如果我们想要将 0 插入到栈的底部或中间,是不允许的,因为这就是栈的性质:数据只能添加到顶部。
从栈顶移除元素称为从栈中弹出。由于栈的限制,我们只能从顶部弹出数据。
让我们从示例栈中弹出一些元素。
首先,我们弹出 0:
现在我们的栈只包含两个元素:5 和 3。
接下来,我们弹出 3:
现在我们的栈只包含 5 了。
用来描述栈操作的一个方便的缩略词是 LIFO,代表“后进先出”。这只是意味着推入栈的最后一项总是最先弹出的项。这有点像那些懒惰的学生——他们总是最后到达教室,但是第一个离开。"
抽象数据类型
大多数编程语言实际上并没有栈作为内置的数据类型或类。相反,需要你自己来实现。这与大多数语言中可用的数组形成了鲜明的对比。
要创建一个栈,通常需要使用其中一个内置的数据结构来实际保存数据。以下是使用 Ruby 实现栈的一种方式,它在内部使用数组:
class Stack
def initialize
@data = []
end
def push(element)
@data << element
end
def pop
@data.pop
end
def read
@data.last
end
end
正如你所看到的,我们的栈实现将数据存储在一个名为 @data
的数组中。当初始化栈时,我们自动创建一个空数组 @data = []
。我们的栈还包含了将新元素推入 @data
数组、从 @data
数组弹出元素以及从 @data
数组读取元素的方法。
然而,通过围绕数组构建 Stack
类,我们建立了一个接口,强制用户以有限的方式与数组进行交互。尽管通常可以从数组的任何索引读取数据,但当通过栈接口使用数组时,只能读取最后一项。插入和删除数据也是如此。
因此,栈数据结构并不是数组所具有的那种数据结构。数组是大多数编程语言内置的,并直接与计算机的内存进行交互。而栈实际上是关于如何与数组交互的一组规则和过程,以便我们可以达到特定的结果。
实际上,栈甚至并不关心底层使用的是什么数据结构。它只关心是否存在一系列数据元素,按照后进先出的方式进行操作。我们是使用数组还是其他类型的内置数据结构实现这一点并不重要。因此,栈是抽象数据类型的一个例子——它是一种围绕其他内置数据结构的一套理论规则的数据结构。
我们在《为什么数据结构很重要》中遇到的集合也是抽象数据类型的另一个例子。集合的某些实现使用数组作为底层结构,而其他实现实际上使用哈希表。但集合本身只是一个理论概念:它是一系列非重复数据元素。
在本书的其余部分中,我们会遇到许多抽象数据类型——它们是在其他内置数据结构的基础上编写的代码块。
值得注意的是,即使是内置数据结构也可以是抽象数据类型。即使编程语言实现了自己的 Stack
类,也无法改变栈数据结构仍然是一种允许在底层使用各种数据结构的概念。
实操中的栈
尽管栈通常不用于长期存储数据,但在各种算法中处理临时数据时,它可以是一个很好的工具。
我们来看一个例子:假设我们要创建一个 JavaScript 语法检查器(JavaScript linter),即一个检查程序员的 JavaScript 代码并确保每行语法正确的程序。创建一个 linter 可能非常复杂,因为有许多不同方面的语法需要检查。
在此,我们将重点关注 linter 的一个特定方面——括号的开合。这包括括号、方括号和大括号——这些都是常见的导致令人沮丧的语法错误的原因。
要解决这个问题,让我们首先分析括号在哪些情况下会导致语法错误。我们将发现三种错误的语法情况。
第一种是有一个开放的括号,但缺少对应的闭合括号,比如这样:
(var x = 2;
我们将其称为 Syntax Error Type /#1。
第二种情况是有一个闭合括号,但前面没有对应的开放括号:
var x = 2;)
我们将其称为 Syntax Error Type /#2。
第三种情况(Syntax Error Type /#3)是一个闭合括号与前一个开放括号类型不匹配,例如:
(var x = [1, 2, 3)];
在上面的示例中,有一组匹配的括号,以及一对匹配的方括号,但是闭合括号的位置是错误的,因为它与前一个开放括号不匹配,而前一个开放括号是一个方括号。
那么,我们如何实现一个算法来检查一行 JavaScript 代码,确保没有与括号相关的语法错误呢?这就是栈允许我们实现漂亮的 linting 算法的地方,它的工作原理如下:
我们准备一个空栈,然后按照以下规则从左到右读取每个字符:
- 如果我们发现任何不是括号类型(圆括号、方括号或大括号)的字符,我们忽略它并继续。
- 如果遇到一个开放括号,我们将其推入栈中。栈中有这个括号意味着我们正在等待关闭这个特定的括号。
- 如果遇到一个闭合括号,我们将弹出栈中的顶部元素并检查它。我们然后分析:
- 如果我们弹出的项(始终是一个开放括号)与当前的闭合括号不匹配,则意味着我们遇到了 Syntax Error Type /#3。
- 如果由于栈为空而无法弹出元素,则意味着当前的闭合括号没有对应的开放括号。这是 Syntax Error Type /#2。
- 如果我们弹出的项与当前的闭合括号相匹配,这意味着我们成功关闭了这个开放括号,并且我们可以继续解析 JavaScript 代码的行。
- 如果我们在行末尚有内容,而栈上仍然有剩余项,则意味着有一个开放括号没有对应的闭合括号,这是 Syntax Error Type /#1。
让我们使用以下示例来演示:
在准备一个空栈后,我们开始从左到右读取每个字符。
步骤 1:我们从第一个字符开始,它恰好是一个开放括号:
步骤 2:由于它是一个开放括号,我们将其推入栈中:
然后我们忽略所有字符 var x =,因为它们不是括号字符。
步骤 3:我们遇到下一个开放括号:
步骤 4:我们将其推入栈中:
然后我们忽略 y。
步骤 5:我们遇到开放方括号:
步骤 6:我们也将其添加到栈中:
然后我们忽略 1、2、3。
步骤 7:我们遇到第一个闭合括号——一个闭合方括号:
步骤 8:我们弹出栈顶元素,它恰好是一个开放方括号:
由于我们的闭合方括号与栈顶元素相匹配,意味着我们可以继续算法而不会抛出任何错误。
步骤 9:我们继续,遇到一个闭合大括号:
步骤 10:我们弹出栈中的顶部项:
它是一个开放大括号,所以它与当前的闭合括号匹配。
步骤 11:我们遇到一个闭合圆括号:
步骤 12:我们弹出栈中的最后一个元素。它是一个相应的匹配项,因此到目前为止没有错误。
由于我们已经完成了整个代码行的解析,并且我们的栈是空的,因此我们的 linter 可以得出结论,这一行(涉及开合括号的语法)没有语法错误。
这个例子展示了如何使用栈来检查括号是否在 JavaScript 代码中正确地开合。栈的 LIFO(Last In, First Out)特性允许我们检查开合括号的匹配情况,从而发现可能的语法错误。
栈对于处理临时数据和实现许多算法非常有用,这里只是一个简单的例子,但它展示了栈在代码分析和验证方面的强大作用。
代码实现:基于栈的代码检查工具
这是前述算法的 Ruby 实现。请注意,我们使用了之前的 Ruby Stack 类的实现:
class Linter
def initialize
# We use a simple array to serve as our stack:
@stack = Stack.new
end
def lint(text)
# We start a loop which reads each character in our text:
text.each_char do |char|
# If the character is an opening brace:
if is_opening_brace?(char)
# We push it onto the stack:
@stack.push(char)
# If the character is a closing brace:
elsif is_closing_brace?(char)
# Pop from stack:
popped_opening_brace = @stack.pop
# If the stack was empty, so what we popped was nil,
# it means that an opening brace is missing:
if !popped_opening_brace
return "#{char} doesn't have opening brace"
end
# If the popped opening brace doesn't match the
# current closing brace, we produce an error:
if is_not_a_match(popped_opening_brace, char)
return "#{char} has mismatched opening brace"
end
end
end
# If we get to the end of line, and the stack isn't empty:
if @stack.read
# It means we have an opening brace without a
# corresponding closing brace, so we produce an error:
return "#{@stack.read} does not have closing brace"
end
# Return true if line has no errors:
return true
end
private
def is_opening_brace?(char)
["(", "[", "{"].include?(char)
end
def is_closing_brace?(char)
[")", "]", "}"].include?(char)
end
def is_not_a_match(opening_brace, closing_brace)
closing_brace != {"(" => ")", "[" => "]", "{" => "}"}[opening_brace]
end
end
lint 方法接受一个包含 JavaScript 代码的字符串,并通过以下方式迭代每个字符:
text.each_char do |char|
如果遇到一个开放括号,我们将其推入栈中:
if is_opening_brace?(char)
@stack.push(char)
需要注意的是,我们使用了一个称为 is_opening_brace? 的辅助方法,它检查字符是否为开放括号:
["(", "[", "{"].include?(char)
当我们遇到一个闭合括号时,我们将栈顶元素弹出并存储在一个名为 popped_opening_brace 的变量中:
popped_opening_brace = @stack.pop
我们的栈只存储开放括号,因此无论我们弹出的是什么,它都将是某种类型的开放括号,假设栈中有东西可以弹出。但是,栈可能为空,此时我们的弹出结果将为 nil。如果是这种情况,表示我们遇到了 Syntax Error Type /#2:
if !popped_opening_brace
return "#{char} doesn't have opening brace"
end
出于简化的目的,当在检查期间遇到任何错误时,我们返回一个包含错误消息的基本字符串。
假设我们确实从栈中弹出了一个开放括号,我们接着检查它是否与当前闭合括号匹配。如果不匹配,这就是 Syntax Error Type /#3:
if is_not_a_match(popped_opening_brace, char)
return "#{char} has mismatched opening brace"
end
(is_not_a_match 辅助方法是稍后在我们的代码中定义的。)
最后,在代码完成解析行之后,我们通过 @stack.read 检查栈中是否有任何未闭合的开放括号。如果有,表示存在一个未关闭的开放括号,并生成一个错误消息。这是 Syntax Error Type /#1:
if @stack.read
return "#{@stack.read} does not have closing brace"
end
最后,如果 JavaScript 不包含错误,我们返回 true。
然后,我们可以这样使用我们的 Linter 类:
linter = Linter.new
puts linter.lint("( var x = { y: [1, 2, 3] } )")
在此示例中,JavaScript 代码是正确的,所以我们只会得到 true。然而,如果输入的行有错误,比如缺少开放括号:
"var x = { y: [1, 2, 3] })"
我们将得到错误消息 ) doesn't have opening brace.
。
在这个示例中,我们使用了栈来使用一个非常简洁的算法实现了我们的代码检查工具。但是如果一个栈实际上是在数组的基础上实现的,为什么要使用栈呢?我们不能使用数组完成相同的任务吗?
有限数据结构的重要性
根据定义,如果栈只是数组的一种受限版本,那意味着数组可以做任何栈能做的事情。如果是这样,那么栈有什么优势呢?
像栈这样的受限数据结构(以及我们马上要遇到的队列)有几个重要原因。
首先,当我们使用受限数据结构时,我们可以防止潜在的错误。例如,linting 算法只有在我们从栈的顶部专门移除项目时才有效。如果程序员无意中编写了从数组中间移除项目的代码,算法就会崩溃。通过使用栈,我们被强制只能从顶部移除项目,因为无法让栈移除其他项目。
其次,像栈这样的数据结构为我们解决问题提供了新的思维模型。例如,栈给了我们后进先出(LIFO)的整体概念。然后我们可以将这种 LIFO 思维应用于解决各种问题,比如刚刚描述的 linting。
一旦我们熟悉了栈及其 LIFO 特性,我们使用栈编写的代码对其他开发人员来说既熟悉又优雅。只要有人看到算法中使用了栈,他们立即知道该算法是使用基于 LIFO 的过程。
栈总结
栈非常适合处理应该按照后进先出顺序处理的任何数据。例如,文字处理器中的“撤销”功能就是栈的一个很好的用例。当用户输入时,我们通过将每次按键推送到栈中来跟踪每次按键。然后,当用户按下“撤销”键时,我们从栈中弹出最近的按键,并从文档中删除它。此时,他们次新的按键现在位于栈的顶部,如果需要,可以随时撤销。
队列
队列是另一种设计用来处理临时数据的数据结构。在许多方面,它类似于栈,只是它按照不同的顺序处理数据。与栈一样,队列也是一个抽象数据类型。
你可以把队列想象成电影院排队的人群。排在队列最前面的人最先离开队列并进入电影院。对于队列,添加到队列中的第一个项目是被移除的第一个项目。这就是为什么计算机科学家将缩写“FIFO”应用于队列:先进先出。
和人群排队一样,通常用横向来表示队列。也常常把队列的开始称为前端,队列的结束称为后端。
和栈一样,队列是具有三个限制的数组(只是一组不同的限制):
- 数据只能插入到队列的末尾。(这与栈的行为完全相同。)
- 数据只能从队列的前端删除。(这与栈的行为相反。)
- 只有在队列前端的元素才能被读取。(这也是与栈的行为相反。)
让我们看看一个空队列是如何运作的。
首先,我们插入一个5(插入队列的常用术语是enqueue,但我们会交替使用insert和enqueue这两个术语):
接下来,我们插入一个9:
然后,我们插入一个100:
![[Pasted image 20231216211745.png]]
到目前为止,队列的功能就像一个栈一样。然而,删除数据是相反的,因为我们是从队列的前端移除数据(从队列中移除一个元素也被称为dequeuing)。
如果我们想要删除数据,我们必须从5开始,因为它在队列的前端:
接下来,我们移除了9:
现在,我们的队列只包含一个元素,即100。
队列实现
我提到队列是一种抽象数据类型。和许多其他抽象数据类型一样,在许多编程语言中并没有内置实现。
下面是一个 Ruby 实现的队列:
class Queue
def initialize
@data = []
end
def enqueue(element)
@data << element
end
def dequeue
# Ruby 的 shift 方法移除并返回数组的第一个元素:
@data.shift
end
def read
@data.first
end
end
再次说明,我们的 Queue 类通过接口将数组封装起来,限制了我们与数据的交互方式,只允许我们以特定的方式处理数据。enqueue 方法允许我们在数组末尾插入数据,而dequeue 方法则移除数组中的第一个项目。而 read 方法允许我们查看数组中的第一个元素。
实操中的队列
队列在许多应用程序中很常见,从打印作业到网络应用程序中的后台工作程序都有它们的身影。
假设我们正在为一台可以接受来自网络上各个计算机的打印作业的打印机编写一个简单的 Ruby 接口。我们希望确保按照接收顺序打印每个文档。
这段代码使用了我们之前的 Ruby Queue 类的实现:
class PrintManager
def initialize
@queue = Queue.new
end
def queue_print_job(document)
@queue.enqueue(document)
end
def run
while @queue.read
print(@queue.dequeue)
end
end
private
def print(document)
# 实际打印机的代码放在这里。
# 为了演示,我们将打印到终端:
puts document
end
end
我们可以这样使用这个类:
print_manager = PrintManager.new
print_manager.queue_print_job("First Document")
print_manager.queue_print_job("Second Document")
print_manager.queue_print_job("Third Document")
print_manager.run
每次调用 queue_print_job 时,我们将“文档”(在此示例中表示为字符串)添加到队列中:
def queue_print_job(document)
@queue.enqueue(document)
end
当我们调用 run 时,我们按照接收顺序处理每个文档并打印它们:
def run
while @queue.read
print(@queue.dequeue)
end
end
请注意,在打印文档时我们是如何逐个从队列中移除文档的。
运行上述代码时,程序将按照接收顺序输出三个文档:
First Document
Second Document
Third Document
尽管这个示例做了简化,抽象掉了一些实际的打印系统可能需要处理的细节,但队列在这样的应用程序中的基本用途是非常真实的,也是构建这样一个系统的基础。
队列也是处理异步请求的理想工具——它们确保按照接收顺序处理请求。它们还常用于模拟现实世界中需要按照一定顺序发生事件的情况,比如等待起飞的飞机和等待医生的病人。
总结
正如你所见,栈和队列是程序员处理各种实际算法的优雅工具。
既然你已经了解了栈和队列,你就解锁了一个新成就:你可以学习递归,因为递归依赖于栈。递归还作为我在本书后面将要介绍的许多更高级和高效的算法的基础。
练习
- 如果你正在为一个呼叫中心编写软件,该软件将呼叫者放置在等待状态,然后将他们分配给“下一个可用的代表”,你会使用栈还是队列?
- 如果你按照以下顺序将数字推入栈中:1, 2, 3, 4, 5, 6,然后弹出两个项目,你将能够从栈中读取哪个数字?
- 如果你按照以下顺序向队列中插入数字:1, 2, 3, 4, 5, 6,然后出队两个项目,你将能够从队列中读取哪个数字?
- 写一个函数,使用栈来颠倒一个字符串。(例如,“abcde"会变成"edcba”。)你可以使用我们之前实现的Stack类。
答案
- 据推测,我们可能希望对呼叫者友善,并按照接收顺序回答他们的电话。为此,我们会使用队列,它按照先进先出(FIFO)的方式处理数据。
- 我们将能够读取数字 4,它现在是栈的顶部元素。这是因为我们已经弹出了之前位于数字 4 之上的数字 6 和数字 5。
- 我们将能够读取数字 3,它现在位于队列的最前端,之前已经出队了数字 1 和数字 2。
- 我们可以利用栈的特性,因为我们以与推入栈的顺序相反的方式弹出每个项目。因此,我们首先将字符串的每个字符推入栈中。然后,我们在弹出每个字符的同时将它们添加到一个新字符串的末尾:
def reverse(string)
stack = Stack.new
string.each_char do |char|
stack.push(char)
end
new_string = ""
while stack.read
new_string += stack.pop
end
return new_string
end