第14章节点式数据结构
在接下来的几章中,我们将探索各种建立在单一概念——节点上的数据结构。不久后你将会看到,节点是分散存储在计算机内存中的数据片段。基于节点的数据结构提供了组织和访问数据的新方式,具有许多重大的性能优势。
在本章中,我们将探讨链表,这是最简单的基于节点的数据结构,也是未来章节的基础。你还将发现链表看起来几乎和数组一样,但在效率上有着自己的权衡,对于某些情况可以提升性能。
链表
就表面上看,数组和链表在外观和操作上非常相似,但在内部有着很大的区别。
如《为什么数据结构很重要》所述,计算机内存可以看作是一组巨大的单元,用于存储数据的位。你学到的是,在创建数组时,你的代码会在内存中找到一组连续的空单元,并指定它们来存储应用程序的数据,就像下面展示的那样。
你也了解到计算机有能力在一个步骤内访问任何内存地址,并可以利用这种能力立即访问数组中的任何索引。如果你编写了这样的代码:“查找索引为4的值”,你的计算机可以在单个步骤中定位到该单元。这是因为你的程序知道数组从哪个内存地址开始——比如说,内存地址为1000——因此,它知道如果要查找索引4,只需直接跳转到内存地址1004。
链表工作方式大不相同。与连续的内存块不同,链表中的数据可以分散在计算机内存的不同单元中。分散存储在内存中的连接数据被称为节点。在链表中,每个节点代表列表中的一个项目。那么,关键问题是:如果节点在内存中不相邻,计算机如何知道哪些节点属于同一个链表呢?这就是链表的关键所在:每个节点还附带了一些额外信息,即链表中下一个节点的内存地址。这个额外的数据片段——指向下一个节点内存地址的指针——被称为链接。这里是链表的可视化描述:
在这个例子中,我们有一个包含四个数据项的链表:“a”、“b”、“c"和"d”。然而,它使用了八个内存单元来存储这些数据,因为每个节点由两个内存单元组成。第一个单元存储实际数据,而第二个单元作为一个链接,指示下一个节点在内存中的起始位置。最后一个节点的链接包含空值,因为链表在那里结束。
(链表的第一个节点也可以称为头部,最后一个节点称为尾部。)
如果计算机知道链表开始的内存地址,它就具备了开始处理该链表所需的一切!由于每个节点都包含指向下一个节点的链接,计算机只需跟随每个链接,便可将整个列表串联起来。
链表的数据可以分布在计算机内存中,这是它相对于数组的潜在优势。相比之下,数组需要找到一整块连续的单元来存储其数据,随着数组大小的增长,这可能变得越来越困难。这些细节由你的编程语言在底层处理,因此你可能不必担心它们。然而,你很快会看到,链表和数组之间有更多实质性的区别,可以深入了解。
实现链表
有些编程语言,比如Java,已经内置了链表。然而,很多语言并没有内置链表,而且自己实现它们也相当简单。
让我们使用Ruby来创建自己的链表。我们将使用两个类来实现:Node(节点)和LinkedList(链表)。先创建Node类:
class Node
attr_accessor :data, :next_node
def initialize(data)
@data = data
end
end
Node类有两个属性:data包含节点的主要值(例如,字符串"a"),而next_node包含链表中下一个节点的链接。我们可以这样使用这个类:
node_1 = Node.new("once")
node_2 = Node.new("upon")
node_3 = Node.new("a")
node_4 = Node.new("time")
node_1.next_node = node_2
node_2.next_node = node_3
node_3.next_node = node_4
通过这段代码,我们创建了一个包含四个节点的列表,分别包含字符串"once"、“upon”、“a"和"time”。
需要注意的是,在我们的实现中,next_node指向另一个Node实例,而不是一个实际的内存地址编号。然而,效果是相同的——节点可能分散在计算机内存中,但我们可以使用节点的链接将列表串联在一起。
接下来,我们将简单地讨论每个链接指向另一个节点,而不是特定的内存地址。因此,我们将使用简化的图表来描述链表,比如这样:
这个图中的每个节点都由两个“单元”组成。第一个单元包含节点的数据,第二个单元指向下一个节点。
这反映了我们对Node类的Ruby实现。在其中,data方法返回节点的数据,而next_node方法返回列表中的下一个节点。在这个上下文中,next_node方法充当节点的链接。
虽然我们已经能够仅使用Node类创建这个链表,但我们需要一种简单的方法告诉程序链表的起始位置。为此,除了之前的Node类,我们将创建一个LinkedList类。以下是LinkedList类的基本形式:
class LinkedList
attr_accessor :first_node
def initialize(first_node)
@first_node = first_node
end
end
到目前为止,LinkedList实例只是跟踪列表的第一个节点。
之前,我们创建了一个包含node_1、node_2、node_3和node_4的节点链。现在,我们可以使用我们的LinkedList类通过以下代码引用这个列表:
这个list变量现在充当了对链表的控制,因为它是LinkedList的一个实例,可以访问列表的第一个节点。
一个非常重要的点出现了:处理链表时,我们只能立即访问其第一个节点。正如我们很快就会看到的那样,这将产生严重的影响。
尽管乍一看,链表和数组很相似——它们都只是一些东西的列表。然而,当我们深入分析时,我们将看到这两种数据结构的性能有着相当大的差异!让我们深入研究四种经典操作:读取、搜索、插入和删除。
读取
正如你所知,计算机可以在O(1)的时间内从数组中读取。但现在让我们来了解从链表中读取的效率。
假设你想读取链表中第三个项的值,计算机不能在一步中找到它,因为它无法立即知道在计算机的内存中哪里可以找到它。毕竟,链表的每个节点都可能在内存的任何地方!我们的程序立即知道的只是链表第一个节点的内存地址。但它不知道其他节点在哪里。
因此,要读取第三个节点,计算机必须经历一个过程。首先,它访问第一个节点。然后,它按照第一个节点的链接访问第二个节点,然后访问第二个节点的链接以到达第三个节点。
因此,要到达任何节点,我们总是需要从第一个节点开始(我们最初只能访问到的唯一节点),然后跟随节点链,直到达到我们想要的节点。
结果表明,如果我们要从列表中的最后一个节点读取,对于列表中的N个节点,将需要N步。与可以在O(1)中读取任何元素的数组相比,链表在最坏情况下的读取效率为O(N),这是一个重大劣势。但不要担心,链表很快就会展现其优势。
代码实现:读取
让我们继续在我们的LinkedList类中添加一个读取方法:
def read(index)
# 我们从列表的第一个节点开始:
current_node = first_node
current_index = 0
while current_index < index do
# 我们一直跟随每个节点的链接,直到达到我们要找的索引位置:
current_node = current_node.next_node
current_index += 1
# 如果我们超出了列表的末尾,意味着值无法在列表中找到,所以返回 nil:
return nil unless current_node
end
return current_node.data
end
如果我们想从列表中读取第四个节点,例如,我们会通过以下方式调用我们的方法,并传入节点的索引:
list.read(3)
让我们来解释一下这个方法是如何工作的。
首先,我们创建了一个名为current_node
的变量,它指向我们当前正在访问的节点。由于我们将从访问第一个节点开始,我们说:
current_node = first_node
需要回顾的是,first_node
是LinkedList类的一个属性。
我们还跟踪current_node
的索引,这样我们就能知道何时达到所需的索引位置。我们使用:
current_index = 0
因为第一个节点的索引是0。
然后,我们启动一个循环,当current_index
小于我们试图读取的索引时运行:
while current_index < index do
在循环的每次迭代中,我们访问列表中的下一个节点,并将其设为新的current_node
:
current_node = current_node.next_node
我们还将current_index
增加1:
current_index += 1
在每次迭代结束时,我们检查是否已经到达了列表的末尾,如果我们试图读取的索引不在列表中,我们将返回nil:
return nil unless current_node
这是因为列表的最后一个节点实际上会有一个next_node
为nil,因为最后一个节点从未被分配过自己的next_node
。在这种情况下,当我们在最后一个节点上调用current_node = current_node.next_node
时,current_node
变成了nil。
最后,如果我们跳出了循环,那是因为我们达到了所需的索引位置。然后我们可以用下面的方式返回节点的值:
return current_node.data
搜索
正如你所知,搜索意味着在列表中查找一个值并返回其索引。我们已经知道数组的线性搜索速度为O(N),因为计算机需要逐个检查每个值。
而链表的搜索速度也为O(N)。要搜索一个值,我们需要经历与阅读相似的过程。也就是说,我们从第一个节点开始,并按照每个节点到下一个节点的链接进行。在此过程中,我们检查每个值,直到找到我们要找的内容。
代码实现:搜索
下面是我们如何在Ruby中实现搜索操作。我们将这个方法称为index_of,并传入我们要搜索的值:
def index_of(value)
# 我们从列表的第一个节点开始:
current_node = first_node
current_index = 0
begin
# 如果找到我们正在寻找的数据,我们返回它:
if current_node.data == value
return current_index
end
# 否则,我们移动到下一个节点:
current_node = current_node.next_node
current_index += 1
end while current_node
# 如果我们在整个列表中找不到数据,我们返回nil:
return nil
end
然后我们可以使用以下方式在列表中搜索任何值:
list.index_of("time")
正如你所见,搜索的机制类似于阅读。主要的区别在于循环不会停在特定的索引处,而是一直运行,直到我们找到值或到达列表的末尾。
插入
毫不否认,从性能的角度来看,链表尚未给我们留下深刻的印象。它们在搜索方面与数组一样糟糕,而在读取方面则更糟。但不用担心——链表将会有它们的时刻。实际上,这个时刻就是现在。在某些情况下,链表在插入操作上具有与数组截然不同的优势。
回顾一下,在数组中进行插入的最坏情况是当程序将数据插入到索引0时,因为它首先必须将其余的数据向右移动一个单元格,这导致效率为O(N)。然而,在链表中,在列表的开头进行插入只需要一步,即O(1)。让我们看看为什么。
假设我们有以下链表:
如果我们想要将“yellow”添加到列表的开头,我们所需做的就是创建一个新节点,并使其链接指向包含“blue”的节点:
(在我们的代码中,我们还需要更新LinkedList实例,使其first_node属性现在指向这个“yellow”节点。)
与数组相比,链表提供了在列表前面插入数据的灵活性,而无需移动任何数据。这多么美妙啊?
事实上,理论上,在链表中任何位置插入数据只需要一步,但有一个问题。让我们继续使用我们的例子。
这是我们现在的链表:
假设我们现在想要在索引2(即“blue”和“green”之间)插入“purple”。实际的插入只需要一步。也就是说,我们可以创建新的紫色节点,并简单地更改蓝色节点的链接,指向紫色节点,如下所示:
然而,要让计算机做到这一点,它首先需要到达索引1(“blue”)处的节点,以便可以修改其链接指向新创建的节点。然而,正如我们所见,从链表中读取(即访问给定索引处的项)已经需要O(N)。让我们看看它是如何进行的。
我们知道我们想在索引1之后添加一个新节点。因此,计算机需要到达列表的索引1。要做到这一点,我们必须从列表的开头开始:
然后,通过跟随第一个链接,我们访问下一个节点:
现在,我们找到了索引1,最终可以添加新节点:
在这种情况下,添加“purple”花费了三步。如果我们将其添加到列表的末尾,则需要五步:四步来访问索引3,一步来插入新节点。
因此,实际上,在链表中进行插入是O(N),因为在列表末尾进行插入的最坏情况将需要N+1步。然而,我们已经看到,在列表开头进行插入的最佳情况只需O(1)。
有趣的是,我们的分析显示数组和链表的最佳和最坏情况正好相反。以下表格将这一切进行了详细的列举:
情况 | 数组 | 链表 |
---|---|---|
插入在开头 | 最坏情况 | 最佳情况 |
插入在中间 | 平均情况 | 平均情况 |
插入在末尾 | 最佳情况 | 最坏情况 |
正如你所见,数组更适合在末尾进行插入,而链表更适合在开头进行插入。现在,我们已经发现了链表擅长的一件事情——在列表开头插入元素。在本章后面,我们将看到一个很好的实际例子,可以利用这一点。
代码实现:链表插入
让我们在我们的LinkedList类中添加一个插入方法。我们将其命名为insert_at_index:
def insert_at_index(index, value)
# 创建一个具有提供值的新节点:
new_node = Node.new(value)
# 如果我们在列表的开头插入:
if index == 0
# 让新节点连接到原来的第一个节点:
new_node.next_node = first_node
# 确定新节点现在是列表的第一个节点:
self.first_node = new_node
return
end
# 如果我们在除开头之外的其他位置插入:
current_node = first_node
current_index = 0
# 首先,我们访问新节点应该插入的前一个节点:
while current_index < (index - 1) do
current_node = current_node.next_node
current_index += 1
end
# 让新节点连接到下一个节点:
new_node.next_node = current_node.next_node
# 修改前一个节点的链接指向我们的新节点:
current_node.next_node = new_node
end
要使用这个方法,我们传入新值以及要插入的索引。
例如,要在索引3处插入“purple”,我们可以这样写:
list.insert_at_index(3, "purple")
让我们分解一下这个insert_at_index方法。
首先,我们使用提供给方法的值创建一个新的Node实例:
new_node = Node.new(value)
接下来,我们处理在索引0处插入的情况,也就是在列表的开头插入。这种情况下的算法与在列表的其他位置进行插入是不同的,因此我们单独处理这种情况。
要在列表开头插入,我们只需让我们的新节点链接到列表的第一个节点,并声明我们的新节点是以后的第一个节点:
if index == 0
new_node.next_node = first_node
self.first_node = new_node
return
end
return关键字提前结束了方法,因为没有其他需要执行的步骤。
其余的代码处理的是我们在除开头以外的任何位置进行插入的情况。
和读取和搜索一样,我们首先访问列表的第一个节点:
current_node = first_node
current_index = 0
然后,我们使用while循环来访问我们想要插入新节点之前的节点:
while current_index < (index - 1) do
current_node = current_node.next_node
current_index += 1
end
此时,current_node是紧随我们的新节点之前的节点。
接下来,我们设置我们新节点的链接指向current_node之后的节点:
new_node.next_node = current_node.next_node
最后,我们更改current_node的链接(再次强调,这个节点将成为我们的新节点之前的节点)以指向我们的新节点:
current_node.next_node = new_node
完成了!
删除
链表在删除方面也表现出色,特别是在从列表开头删除时。
要从链表的开头删除节点,我们只需要一步操作:将链表的first_node指向第二个节点。
让我们回到包含值“once”、“upon”、“a”和“time”的链表的例子。如果我们想删除值为“once”的节点,我们只需简单地将链表更改为从“upon”开始:
list.first_node = node_2
与数组不同,删除第一个元素意味着将所有剩余数据向左移动一个单元,这需要O(N)时间。
当涉及到删除链表的最后一个节点时,实际的删除只需一步操作——我们只需取倒数第二个节点并将其链接设为空。然而,要访问倒数第二个节点甚至需要N步,因为我们需要从链表的开头开始,沿着链接一直访问直到达到它。
以下表格对比了数组和链表在各种删除场景下的情况。请注意,它与插入的情况是相同的:
情况 数组 链表
从开头删除 最坏情况 最佳情况
从中间删除 平均情况 平均情况
从末尾删除 最佳情况 最坏情况
虽然从链表的开头或末尾删除很简单,但从中间删除稍微复杂一些。
假设我们想要从示例链表中删除索引2(“purple”)处的值,该链表是一个包含颜色的链表,如下面图表所示。
要做到这一点,我们需要首先访问要删除节点的前一个节点(“blue”)。然后,我们将其链接更改为指向我们要删除节点之后的节点(“green”)。
以下可视化演示了我们将“blue”节点的链接从“purple”更改为“green”的过程:
有趣的是,无论我们从链表中删除多少节点,这些节点仍然存在于内存中的某个地方。我们只是通过确保列表中没有其他节点链接到它来从列表中删除节点。这会将节点从我们的列表中删除,即使节点在内存中仍然存在。(不同的编程语言以各种方式处理这些被删除的节点。有些会自动检测它们没有被使用,并对其进行“垃圾回收”,释放内存。)
代码实现:删除
以下是我们 LinkedList 类中删除操作可能的实现方式。它被称为 delete_at_index,我们传入要删除的索引:
def delete_at_index(index):
# 如果要删除的是第一个节点:
if index == 0:
# 简单地将第一个节点设置为当前第二个节点:
self.first_node = first_node.next_node
return
current_node = first_node
current_index = 0
# 首先,我们找到要删除节点的前一个节点,并将其称为 current_node:
while current_index < (index - 1):
current_node = current_node.next_node
current_index += 1
# 找到要删除节点之后的节点:
node_after_deleted_node = current_node.next_node.next_node
# 修改 current_node 的链接,指向 node_after_deleted_node,将要删除的节点从列表中移除:
current_node.next_node = node_after_deleted_node
这个方法与之前看到的 insert_at_index 方法非常相似。让我们强调一些新颖的点。
这个方法首先处理索引为 0 的情况,意味着我们打算删除列表的第一个节点。代码非常简单:将列表的 first_node 设为当前的第二个节点就行了!
方法的其余部分处理列表中其他位置的删除。为此,我们使用 while 循环来访问我们想要删除的节点之前的节点。这个节点成为我们的 current_node。
然后,我们获取要删除节点之后紧接的节点,并将其存储在一个名为 node_after_deleted_node 的变量中:
node_after_deleted_node = current_node.next_node.next_node
注意我们在访问该节点时的小技巧。它简单地是在 current_node 之后两个节点!
接着,我们修改 current_node 的链接,指向 node_after_deleted_node:
current_node.next_node = node_after_deleted_node
这段代码实现了根据索引删除链表中节点的功能。
链表操作效率
我们的分析显示链表和数组的比较如下所示:
操作 | 数组 | 链表 |
---|---|---|
读取 | O(1) | O(N) |
搜索 | O(N) | O(N) |
插入 | O(N) (O(1)末尾) | O(N) (O(1)开头) |
删除 | O(N) (O(1)末尾) | O(N) (O(1)开头) |
从宏观角度来看,链表在时间复杂度方面似乎并不出众。它们在搜索、插入和删除方面与数组类似,并且在读取方面要慢得多。那么,为什么有人会想要使用链表呢?
解锁链表强大之处的关键在于实际的插入和删除步骤只需 O(1)。
但这不仅仅在于在列表的开头插入或删除时才相关吗?我们看到,在其他位置插入或删除时,需要最多 N 步才能访问我们想要删除或插入的节点!
事实上,在某些情况下,我们可能已经为其他目的访问了正确的节点。接下来的例子就是一个很好的例证。
链表实操
链表的一个亮点是在对单个列表进行多次元素删除时。比如,我们正在开发一个应用程序,遍历现有的电子邮件地址列表,并删除任何格式无效的电子邮件地址。
无论列表是数组还是链表,我们都需要逐个检查每个电子邮件地址,这自然需要 N 步。但是,让我们看看当我们实际删除每个电子邮件地址时会发生什么。
对于数组来说,每次删除一个电子邮件地址,我们需要另外 O(N) 步来将剩余的数据向左移动以填补空隙。所有这些移动都会在我们检查下一个电子邮件地址之前发生。
假设我们有 1,000 个电子邮件地址,其中约有 100 个无效。我们的算法需要 1,000 步来读取所有 1,000 个电子邮件地址。除此之外,对于删除操作,可能需要多达额外的 100,000 步,因为对于这 100 个已删除的地址,我们可能需要移动多达 1,000 个其他元素。
然而,对于链表来说,在遍历列表时,每次删除只需要一步,因为我们可以简单地更改节点的链接以指向适当的节点,然后继续。因此,对于我们的 1,000 封电子邮件,算法只需要 1,100 步,因为有 1,000 步读取和 100 步删除。
因此,事实证明,链表在遍历整个列表并进行插入或删除时是一种非常优秀的数据结构,因为在进行插入或删除时,我们不需要担心移动其他数据。
双向链表
链表实际上有许多不同的类型。我们到目前为止讨论的链表是“经典”链表,但通过一些轻微的修改,我们可以赋予链表额外的超能力。
链表的一种变体是双向链表。双向链表类似于链表,但每个节点有两个链接——一个指向下一个节点,另一个指向前一个节点。此外,双向链表始终跟踪第一个节点和最后一个节点,而不仅仅是第一个节点。
双向链表的样子如下所示:
我们可以在 Ruby 中这样实现双向链表的核心:
class Node
attr_accessor :data, :next_node, :previous_node
def initialize(data)
@data = data
end
end
class DoublyLinkedList
attr_accessor :first_node, :last_node
def initialize(first_node=nil, last_node=nil)
@first_node = first_node
@last_node = last_node
end
end
由于双向链表始终知道其第一个节点和最后一个节点的位置,我们可以在单步或 O(1) 的时间内访问它们。因此,就像我们可以在 O(1) 的时间内从列表的开头读取、插入或删除一样,我们也可以在 O(1) 的时间内从列表的末尾进行相同的操作。
以下是在双向链表末尾插入的示例:
正如您所见,我们创建了一个新节点(“Sue”),并让它的 previous_node 指向链表的最后一个节点(“Greg”)。然后,我们将最后一个节点(“Greg”)的 next_node 更改为指向这个新节点(“Sue”)。最后,我们声明新节点(“Sue”)为链表的最后一个节点。
代码实现:双向链表插入
以下是我们可以添加到 DoublyLinkedList 类中的新 insert_at_end 方法的实现:
def insert_at_end(value)
new_node = Node.new(value)
# 如果链表中还没有元素:
if !@first_node
@first_node = new_node
@last_node = new_node
else # 如果链表已经至少有一个节点:
new_node.previous_node = @last_node
@last_node.next_node = new_node
@last_node = new_node
end
end
让我们突出这个方法中最重要的部分。
首先,我们创建新节点:
new_node = Node.new(value)
然后,我们将新节点的 previous_node 链接指向到此时的最后一个节点:
new_node.previous_node = @last_node
接下来,我们更改最后一个节点的链接(到此为止为 nil),将其指向我们的新节点:
@last_node.next_node = new_node
最后,我们告诉我们的 DoublyLinkedList 实例,它的最后一个节点是我们的新节点:
@last_node = new_node
前进和后退
在“经典”链表中,我们只能沿着链表向前移动。也就是说,我们可以访问第一个节点,并通过链接找到列表中的所有其他节点。但是我们无法向后移动,因为没有节点知道前一个节点是什么。
双向链表则提供了更多的灵活性,因为我们可以在列表中向前和向后移动。事实上,我们甚至可以从最后一个节点开始,向前移动到第一个节点。
双向链表作为队列
因为双向链表可以立即访问列表的前端和末端,所以它们可以在任一侧以 O(1) 的时间插入数据,同时以 O(1) 的时间从任一侧删除数据。
由于双向链表可以在 O(1) 时间内在末尾插入数据并在前端删除数据,因此它们是队列的理想底层数据结构。
我们在第9章讨论了队列,你可能还记得它们是一种仅允许在末尾插入数据并从开头移除数据的列表。你在那里了解到队列是抽象数据类型的一个例子,并且我们能够使用数组在底层实现它们。
然而,由于队列在末尾插入并在开头删除,数组作为底层数据结构的表现并不完美。虽然数组在末尾插入时为 O(1),但在开头删除时却是 O(N)。
另一方面,双向链表在末尾插入和从开头删除时都是 O(1)。这正是它成为队列理想底层数据结构的原因。
代码实现:基于双向链表的队列
以下是基于双向链表构建的队列的完整示例:
class Node
attr_accessor :data, :next_node, :previous_node
def initialize(data)
@data = data
end
end
class DoublyLinkedList
attr_accessor :first_node, :last_node
def initialize(first_node=nil, last_node=nil)
@first_node = first_node
@last_node = last_node
end
def insert_at_end(value)
new_node = Node.new(value)
if !@first_node
@first_node = new_node
@last_node = new_node
else
new_node.previous_node = @last_node
@last_node.next_node = new_node
@last_node = new_node
end
end
def remove_from_front
removed_node = @first_node
@first_node = @first_node.next_node
return removed_node
end
end
class Queue
attr_accessor :queue
def initialize
@data = DoublyLinkedList.new
end
def enqueue(element)
@data.insert_at_end(element)
end
def dequeue
removed_node = @data.remove_from_front
return removed_node.data
end
def read
return nil unless @data.first_node
return @data.first_node.data
end
end
为了使其工作,我们在 DoublyLinkedList 类中添加了一个 remove_from_front
方法,如下所示:
def remove_from_front
removed_node = @first_node
@first_node = @first_node.next_node
return removed_node
end
正如您所见,我们通过将列表的 @first_node
设置为当前第二个节点来有效删除第一个节点。然后我们返回删除的节点。
Queue 类在我们的 DoublyLinkedList 上实现了其方法。enqueue 方法依赖于 DoublyLinkedList 的 insert_at_end
方法:
def enqueue(element)
@data.insert_at_end(element)
end
类似地,dequeue 方法利用链表从列表前端删除的能力:
def dequeue
removed_node = @data.remove_from_front
return removed_node.data
end
通过使用双向链表来实现队列,我们现在可以在 O(1) 的速度下进行插入和删除操作。这简直是双倍的棒。
总结
正如我们所看到的,数组和链表之间微妙的差异为我们的代码带来了比以往更快的新方式。
通过学习链表,您还了解了节点的概念。然而,链表只是基于节点的数据结构中最简单的一种。在接下来的章节中,您将学习到更复杂、更有趣的基于节点的结构,它们将揭示节点如何带来巨大的力量和效率,展示出全新的可能性。
练习
- 给经典的 LinkedList 类添加一个打印列表所有元素的方法。
- 给双向链表 DoublyLinkedList 类添加一个以相反顺序打印列表所有元素的方法。
- 给经典的 LinkedList 类添加一个返回列表最后一个元素的方法。假设您不知道列表中有多少元素。
- 这是一个棘手的问题。给经典的 LinkedList 类添加一个将列表反转的方法。也就是说,如果原始列表是 A -> B -> C,列表的所有链接都应该更改为 C -> B -> A。
- 这是一个很有意思的链表谜题。假设您可以访问到经典链表中间某处的节点,但没有访问链表本身的权限。也就是说,您有一个指向 Node 实例的变量,但没有访问 LinkedList 实例的权限。在这种情况下,如果您遵循这个节点的链接,您可以找到从这个中间节点到末尾的所有项,但是您无法找到在列表中在这个节点之前的节点。编写代码有效地从列表中删除这个节点。整个剩余列表应保持完整,只删除这个节点。
答案
- 我们可以使用简单的 while 循环来实现这个方法:
def print
current_node = first_node
while current_node
puts current_node.data
current_node = current_node.next_node
end
end
- 对于双向链表,我们可以直接访问最后的节点,并通过它们的“previous node”链接来访问前面的节点。这段代码与前面的练习恰好相反:
def print_in_reverse
current_node = last_node
while current_node
puts current_node.data
current_node = current_node.previous_node
end
end
- 在这里,我们使用一个 while 循环来遍历每个节点。但在向前移动之前,我们使用节点的链接提前检查以确保存在下一个节点:
def last
current_node = first_node
while current_node.next_node
current_node = current_node.next_node
end
return current_node.data
end
- 将经典链表反转的一种方法是在遍历列表时跟踪三个变量。
首要变量是 current_node
,它是我们正在迭代的主要节点。我们还跟踪 next_node
,它是 current_node
紧接在后面的节点。同时,我们还跟踪 previous_node
,它是 current_node
紧接在前面的节点。请参考以下示意图:
请注意,当我们首次开始且 current_node
是第一个节点时,previous_node
指向 null;在第一个节点之前没有节点。
一旦我们设置好这三个变量,我们就开始执行我们的算法,进入循环。
在循环内部,我们首先将 current_node
的链接更改为指向 previous_node
:
接着,我们将所有变量向右移动:
然后,我们重新开始循环,重复这个过程,将 current_node
的链接指向 previous_node
,直到我们到达列表的末尾。一旦到达末尾,列表将完全被反转。
以下是该算法的实际实现:
def reverse!
previous_node = nil
current_node = first_node
while current_node
next_node = current_node.next_node
current_node.next_node = previous_node
previous_node = current_node
current_node = next_node
end
self.first_node = previous_node
end
- 信不信由你,我们可以在没有访问前置节点的情况下删除中间的节点。
以下是一个示例情况的图示。有四个节点,但我们只能访问节点 “b”。这意味着我们无法访问节点 “a”,因为在经典链表中链接只向前指向。我们用虚线表示这一点——也就是说,我们无法访问虚线左侧的任何节点。
现在,这是我们如何删除节点 “b”(即使我们没有访问节点 “a”)。为了清晰起见,我们将这个节点称为“访问节点”,因为这是我们可以访问的第一个节点。
首先,我们获取访问节点之后的下一个节点,并将其数据复制到访问节点中,覆盖访问节点的数据。在我们的示例中,这意味着将字符串 “c” 复制到我们的访问节点中。
然后,我们更改访问节点的链接,并将其指向它右边的两个节点。这实际上删除了原始的 “c” 节点。
以下是实现这一点的实际代码:
def delete_middle_node(node)
node.data = node.next_node.data
node.next_node = node.next_node.next_node
end