Python中的链表简介

翻译自宝藏网站:https://realpython.com/linked-lists-python/

建议不排斥英文的同学直接阅读原文。

Linked lists就像list的一个不太为人所知的表亲。它们不那么流行,也不那么酷,你可能在算法课上都不记得它们了。但在合适的环境下,它们真的会发光。

在本文中,您将了解:

  • 什么是链表,什么时候应该使用链表
  • 如何使用collections.deque来满足你所有的链表需求
  • 如何实现你自己的链表
  • 其他类型的链表是什么?它们的用途是什么

理解链表(Linked Lists)

链表是对象的有序集合。那么,是什么让它们与普通列表不同呢?链表与列表(List)的不同之处在于它们在内存中存储元素的方式。列表使用一个连续的内存块来存储对其数据的引用,而链表则将引用作为其自身元素的一部分去存储。

在深入了解什么是链表以及如何使用链表之前,您应该首先了解它们的结构。链表中的每个元素称为一个节点,每个节点有两个不同的字段:

  1. Data包含了存储在该节点(node)中的数值;
  2. Next包含一个指向下一个元素的引用(reference)。

一个节点的样子如下图所示:

链表是节点的集合。第一个节点称为头节点,它被用作遍历列表的任何迭代的起点。最后一个节点的下一个引用必须指向None,以确定列表的结束。它是这样的:

既然您已经知道了链表是如何构造的,那么就可以看看它的一些实际用例了。

实际应用

在现实世界中,链表有多种用途。它们可以用于实现(剧透警告!)队列或堆栈以及图表。它们对于更复杂的任务也很有用,比如操作系统应用程序的生命周期管理。

队列或堆栈(Queues or Stacks)

队列和堆栈仅在检索元素的方式上有所不同。对于队列,使用先进先出(First-In/First-Out,FIFO)方法。这意味着在列表中插入的第一个元素就是第一个被检索的元素:

在上面的图表中,您可以看到队列的前(Front)后(Rear)元素。当您向队列添加新元素时,它们将被放到尾部(Rear的后面)。当您检索元素时,它们将从队列的前面(Front)获取。

对于堆栈,使用后进/先出(Last-In/First Out,LIFO)方法,这意味着在列表中插入的最后一个元素是第一个被检索的元素:

在上面的图中,可以看到插入到堆栈上的第一个元素(索引0)位于底部,而插入的最后一个元素位于顶部。由于堆栈使用LIFO方法,最后插入的元素(在顶部)将是第一个被检索的元素。

由于从队列和堆栈的边缘插入和检索元素的方式,链表是实现这些数据结构最方便的方法之一。在本文的后面,您将看到这些实现的示例。

图(Graphs)

图可以用来显示对象之间的关系或表示不同类型的网络。例如,一个图的可视化表示——比如一个有向无环图(DAG)——可能是这样的:

有不同的方法实现上面的图,但最常见的方法之一是使用邻接表。邻接表本质上是一个链表的列表,图的每个顶点都存储在连接顶点的集合旁边:

顶点 顶点链表
1 2→3→None
2 4→None
3 None
4 5→6→None
5 6→None
6 None

在上表中,图的每个顶点都列在左列中。右列包含一系列链表,存储与左列对应顶点相连的其他顶点。这个邻接表也可以用dict来表示:

graph = {
    1: [2, 3, None],
    2: [4, None],
    3: [None],
    4: [5, 6, None],
    5: [6, None],
    6: [None]
    }

这个字典的键是源顶点,每个键的值是一个列表。这个列表通常实现为链表。

Note:在上面的示例中,您可以避免存储None值,但是为了清晰和与后面的示例保持一致,我们在这里保留了它们。

在速度和内存方面,使用邻接表来实现图是非常有效的,例如,与邻接矩阵相比。这就是链表在图形实现中如此有用的原因。

性能比较:列表和链表

在大多数编程语言中,链表和数组在内存中的存储方式有明显的不同。然而,在Python中,列表是动态数组。这意味着列表和链表的内存使用非常相似。

参考阅读:http://www.laurentluce.com/posts/python-list-implementation/

由于列表和链表在内存使用方面的差异非常小,所以在时间复杂度方面,最好关注它们的性能差异。

元素的插入和删除

在Python中,可以使用.insert()或.append()将元素插入到列表中。要从列表中删除元素,可以使用对应的.remove()和.pop()。

这些方法之间的主要区别在于,使用.insert()和.remove()在列表的特定位置插入或删除元素,而使用.append()和.pop()只在列表的末尾插入或删除元素。

现在,关于Python列表,您需要知道的是,插入或删除不在列表末尾的元素需要在后台进行一些元素移动,这使得操作花费的时间更复杂。为了更好地理解.insert()、.remove()、.append()和.pop()的实现如何影响它们的性能,可以阅读上面提到的关于Python中如何实现列表的文章。

记住这一切,即使使用.append()或.insert()在列表的末尾插入元素,其时间复杂度为O(1),当你在列表的开头插入一个元素,平均时间复杂度会随着列表的大小而变化,即O(n)。

另一方面,链表在插入和删除链表开头或结尾的元素时要简单得多,它们的时间复杂度始终是常数O(1)。

由于这个原因,在实现队列(FIFO)时,链表比普通列表具有性能优势,在队列(FIFO)中,元素会在链表的开始处不断插入和删除。但在实现堆栈(LIFO)时,它们的执行类似于列表,在堆栈中,在列表的末尾插入和删除元素。

检索元素

在元素查找方面,列表比链表的性能要好得多。当您知道要访问哪个元素时,list可以在O(1)时间内执行此操作。使用链表做同样的操作需要O(n),因为您需要遍历整个链表来找到元素。

然而,在搜索特定元素时,列表和链表的执行情况非常相似,时间复杂度为O(n)。在这两种情况下,您都需要遍历整个列表,以找到要查找的元素。

引入collections.deque

在Python中,collections模块中有一个特定的对象,可以用于链表,名为deque(发音为" deck"),它代表双端队列。

collections.deque使用了一个链表的实现,在这个链表中,你可以在O(1)的性能下访问、插入或移除链表开头或结尾的元素。

如何使用collections.deque

默认情况下,有很多方法都带有deque对象。然而,在本文中,您将只涉及其中的几个,主要用于添加或删除元素。

首先,您需要创建一个链表。你可以在deque中使用下面的代码:

from collections import deque
deque()

上面的代码将创建一个空链表。如果你想在创建时填充它,那么你可以给它一个可迭代的输入:

deque(['a','b','c'])

deque('abc')

deque([{'data': 'a'}, {'data': 'b'}])

 

初始化deque对象时,可以传递任意可迭代对象作为输入,比如字符串(也是可迭代对象)或对象列表。

现在您已经知道了如何创建deque对象,您可以通过添加或删除元素来与它进行交互。你可以创建一个abcde链表,并像这样添加一个新元素f:

llist = deque("abcde")
llist
llist.append("f")
llist
llist.pop()
llist

append()和pop()都是从链表右侧添加或删除元素。不过,你也可以使用deque快速添加或删除列表左侧或头部的元素:

llist.appendleft("z")
llist
llist.popleft()
llist

使用deque对象从列表的两端添加或删除元素非常简单。现在您已经准备好学习如何使用collections.deque来实现队列或堆栈。

如何实现队列和堆栈

如上所述,队列和堆栈之间的主要区别在于从每个队列检索元素的方式。接下来,您将了解如何使用collections.deque实现这两种数据结构。

队列

对于队列,您希望向列表添加值(enqueue),当时机合适时,您希望删除列表中最长的元素(dequeue)。例如,想象在一家时髦而客满的餐厅里排队。如果你想为客人安排一个公平的座位,那么你可以先排一个队,然后在他们到达的时候添加一些人:

from collections import deque
queue = deque()
queue


queue.append("Mary")
queue.append("John")
queue.append("Susan")
queue

现在玛丽、约翰和苏珊都在排队。记住,由于队列是FIFO,第一个进入队列的人应该是第一个离开队列的人。

现在想象一下,过了一段时间,出现了一些可用的表。在此阶段,您希望按照正确的顺序将人员从队列中删除。你可以这样做:

queue.popleft()

queue

queue.popleft()

queue

每次调用popleft()时,都会从链表中删除head元素,模拟真实的队列。

堆栈

如果您想要创建一个堆栈呢?这个想法或多或少和队列是一样的。唯一的区别是堆栈使用后进先出(LIFO)方法,这意味着最后插入堆栈的元素应该是第一个被移除的元素。

假设你正在创建一个web浏览器的历史记录功能,在这个功能中存储用户访问的每个页面,这样他们就可以很容易地回到过去。假设这些是随机用户在浏览器上的操作:

  1. 访问Real Python的网站
  2. 导航到Pandas:如何读取和写入文件
  3. 单击Python中读取和写入CSV文件的链接

如果你想把这个行为映射到堆栈中,你可以这样做:

from collections import deque
history = deque()

history.appendleft("https://realpython.com/")
history.appendleft("https://realpython.com/pandas-read-write-files/")
history.appendleft("https://realpython.com/python-csv/")
history

在本例中,您创建了一个空的历史对象,并且每次用户访问新站点时,您都使用appendleft()将其添加到历史变量中。这样做可以确保每个新元素都被添加到链表的头。

现在假设用户在阅读了这两篇文章之后,想要回到Real Python主页选择一篇新的文章来阅读。知道你有一个堆栈,想要使用LIFO删除元素,你可以做以下事情:

history.popleft()

history.popleft()

history

你走吧!使用popleft(),可以从链表的头部删除元素,直到到达Real Python主页。

从上面的例子中,您可以看到在工具箱中有collections.deque是多么有用,所以下次遇到基于队列或堆栈的挑战时,一定要使用它。

实现你自己的链表

既然您已经知道如何使用collections.deque来处理链表,您可能会想,为什么要在Python中实现自己的链表呢?有几个理由:

  1. 练习你的Python算法技能
  2. 学习数据结构理论
  3. 为工作面试做准备

如果您对上面的任何内容都不感兴趣,或者您已经熟练地用Python实现了自己的链表,可以跳过下一节。否则,是时候实现一些链表了!

如何创建链表

首先,创建一个类来表示你的链表:

class LinkedList:
    def __init__(self):
        self.head = None

对于链表,您需要存储的唯一信息是链表开始的位置(链表的头部)。接下来,创建另一个类来表示链表的每个节点:

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

在上面的类定义中,您可以看到每个节点的两个主要元素:data和next。你也可以在这两个类中添加__repr__,以获得更有用的对象表示:

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None

    def __repr__(self):
        return self.data

class LinkedList:
    def __init__(self):
        self.head = None

    def __repr__(self):
        node = self.head
        nodes = []
        while node is not None:
            nodes.append(node.data)
            node = node.next
        nodes.append("None")
        return " -> ".join(nodes)

看一个使用上面的类快速创建带有三个节点的链表的例子:

llist = LinkedList()
llist

first_node = Node("a")
llist.head = first_node
llist

second_node = Node("b")
third_node = Node("c")
first_node.next = second_node
second_node.next = third_node
llist

通过定义节点的数据和下一个值,您可以非常快速地创建一个链表。这些LinkedList和Node类是我们实现的起点。从现在开始,我们要做的就是增加它们的功能。

下面是对链表的__init__()的一个小小的改变,它允许你用一些数据快速创建链表:

def __init__(self, nodes=None):
    self.head = None
    if nodes is not None:
        node = Node(data=nodes.pop(0))
        self.head = node
        for elem in nodes:
            node.next = Node(data=elem)
            node = node.next

通过上述修改,创建链表以在下面的示例中使用将会快得多。

如何遍历链表

使用链表最常见的事情之一就是遍历它。遍历意味着遍历每个节点,从链表的头开始,到下一个值为None的节点结束。

遍历只是迭代的一种更花哨的说法。所以,记住这一点,创建一个__iter__来添加与普通链表相同的行为:

def __iter__(self):
    node = self.head
    while node is not None:
        yield node
        node = node.next

上面的方法遍历列表并yield每个节点。关于这个__iter__,要记住的最重要的事情是,您需要始终验证当前节点不是None。当该条件为True时,表示已到达链表的末尾。

在生成当前节点之后,您希望移动到列表中的下一个节点。这就是为什么要添加node = node.next。下面是一个遍历随机列表并打印每个节点的示例:

llist = LinkedList(["a", "b", "c", "d", "e"])
llist

for node in llist:
    print(node)

在其他文章中,您可能会看到将遍历定义为一个名为traverse()的特定方法。然而,使用Python的内置方法来实现上述行为会使这个链表实现更具Python风格。

如何插入新节点

将新节点插入链表有不同的方法,每种方法都有自己的实现和复杂程度。这就是为什么您会看到它们被分割成特定的方法,用于在列表的开头、末尾或节点之间插入。

在开头插入

在列表的开始插入一个新节点可能是最简单的插入,因为您不需要遍历整个列表来完成它。只需要创建一个新节点,然后将列表头指向它。

看一下LinkedList类中add_first()的实现:

def add_first(self, node):
    node.next = self.head
    self.head = node

在上面的例子中,你设置了self。head作为新节点的下一个引用,这样新节点就会指向旧的self.head。在此之后,您需要声明列表的新头部是插入的节点。

下面是它如何使用:

llist = LinkedList()
llist

llist.add_first(Node("b"))
llist

llist.add_first(Node("a"))
llist

如您所见,add_first()总是将节点添加到列表的头部,即使列表之前是空的。

在末尾插入

在列表末尾插入新节点将迫使您首先遍历整个链表,并在到达链表末尾时添加新节点。你不能像在普通列表中那样在末尾添加内容,因为在链表中你不知道哪个节点是最后的。

下面是一个将节点插入到链表末尾的函数的示例实现:

def add_last(self, node):
    if self.head is None:
        self.head = node
        return
    for current_node in self:
        pass
    current_node.next = node

首先,您希望遍历整个列表,直到到达末尾(也就是说,直到for循环引发StopIteration异常)。接下来,要将current_node设置为列表上的最后一个节点。最后,您希望添加新节点作为current_node的下一个值。

下面是一个add_last()的例子:

llist = LinkedList(["a", "b", "c", "d"])
llist

llist.add_last(Node("e"))
llist

llist.add_last(Node("f"))
llist

在上面的代码中,首先创建一个有四个值(a、b、c和d)的列表。然后,当使用add_last()添加新节点时,可以看到节点总是被添加到列表的末尾。

节点间插入

在两个节点之间插入会给已经很复杂的链表插入增加一层复杂性,因为你可以使用两种不同的方法:

  1. 在已有节点后插入
  2. 在现有节点前插入

将它们分成两个方法似乎有些奇怪,但链表的行为与普通链表不同,每种情况都需要不同的实现。

下面是一个方法,它将一个节点添加到一个具有特定数据值的现有节点之后:

def add_after(self, target_node_data, new_node):
    if self.head is None:
        raise Exception("List is empty")

    for node in self:
        if node.data == target_node_data:
            new_node.next = node.next
            node.next = new_node
            return

    raise Exception("Node with data '%s' not found" % target_node_data)

在上面的代码中,您遍历链表,寻找具有指示要在何处插入新节点的数据的节点。当找到要查找的节点时,将立即插入新节点,并重新连接下一个引用,以保持列表的一致性。

唯一的例外是,如果列表为空,则不可能在现有节点之后插入新节点,或者如果列表不包含您正在搜索的值。下面是add_after()的一些例子:

llist = LinkedList()
llist.add_after("a", Node("b"))

llist = LinkedList(["a", "b", "c", "d"])
llist

llist.add_after("c", Node("cc"))
llist

llist.add_after("f", Node("g"))

在空列表上使用add_after()会导致异常。当您试图在不存在的节点之后添加时,也会发生同样的情况。其他一切都按预期工作。

现在,如果你想实现add_before(),那么它看起来像这样:

def add_before(self, target_node_data, new_node):
    if self.head is None:
        raise Exception("List is empty")

    if self.head.data == target_node_data:
        return self.add_first(new_node)

    prev_node = self.head
    for node in self:
        if node.data == target_node_data:
            prev_node.next = new_node
            new_node.next = node
            return
        prev_node = node

    raise Exception("Node with data '%s' not found" % target_node_data)

在实现上面的方法时,有一些事情需要记住。首先,与add_after()一样,如果链表为空(第2行)或查找的节点不存在(第16行),则需要确保引发异常。

其次,如果您试图在列表头之前添加一个新节点(第5行),那么您可以重用add_first(),因为您所插入的节点将是列表的新头。

最后,对于任何其他情况(第9行),您应该使用prev_node变量跟踪最后检查的节点。然后,在找到目标节点时,可以使用prev_node变量重新连接下一个值。

再一次,一个例子胜过千言万语:

llist = LinkedList()
llist.add_before("a", Node("a"))

llist = LinkedList(["b", "c"])
llist

llist.add_before("b", Node("a"))
llist

llist.add_before("b", Node("aa"))
llist.add_before("c", Node("bb"))
llist

llist.add_before("n", Node("m"))

有了add_before(),您现在就有了在列表中任何位置插入节点所需的所有方法。

如何移除节点

要从链表中删除一个节点,首先需要遍历该列表,直到找到想要删除的节点。找到目标后,希望链接它的上一个和下一个节点。这种重新链接将目标节点从列表中删除。

这意味着在遍历列表时需要跟踪上一个节点。看看一个示例实现:

def remove_node(self, target_node_data):
    if self.head is None:
        raise Exception("List is empty")

    if self.head.data == target_node_data:
        self.head = self.head.next
        return

    previous_node = self.head
    for node in self:
        if node.data == target_node_data:
            previous_node.next = node.next
            return
        previous_node = node

    raise Exception("Node with data '%s' not found" % target_node_data)

在上面的代码中,首先检查列表是否为空(第2行)。如果为空,则抛出异常。然后,检查要删除的节点是否为列表的当前头(第5行),如果是,则希望列表中的下一个节点成为新的头。

如果没有出现上述情况,则开始遍历列表,寻找要删除的节点(第10行)。如果找到它,则需要更新它的上一个节点以指向它的下一个节点,从而自动从列表中删除找到的节点。最后,如果遍历整个列表而没有找到要删除的节点(第16行),则会引发异常。

注意,在上面的代码中,您是如何使用previous_node来跟踪上一个节点的。这样做可以确保当您找到要删除的正确节点时,整个过程将更加直观。

下面是一个例子:

llist = LinkedList()
llist.remove_node("a")

llist = LinkedList(["a", "b", "c", "d", "e"])
llist

llist.remove_node("a")
llist

llist.remove_node("e")
llist

llist.remove_node("c")
llist

llist.remove_node("a")

就是这样!现在您知道了如何实现链表以及遍历、插入和删除节点的所有主要方法。如果你对自己所学的知识感到满意,并且渴望更多,那么可以选择以下挑战之一:

  1. 创建一个方法来从特定的位置检索元素:get(i)或者llist[i]。
  2. 创建一个方法来反转链表:list.reverse()。
  3. 使用enqueue()和dequeue()方法创建继承本文链接列表的Queue()对象。

除了很好的练习,自己做一些额外的挑战也是吸收你所学知识的有效方法。如果你想重新使用本文中的所有源代码,那么你可以从下面的链接下载你需要的一切:https://realpython.com/bonus/linked-lists/

使用高级链表

到目前为止,您一直在学习一种特定类型的链表,称为单链表。但是还有更多类型的链表可以用于稍微不同的目的。

如何使用双链表

双链表不同于单链表,它们有两个引用:

  1. 前面的字段引用前面的节点。
  2. 下一个字段引用下一个节点。

最终结果是这样的:

如果你想实现上面的内容,那么你可以对现有的Node类做一些修改,以包含以前的字段:

class Node:
    def __init__(self, data):
        self.data = data
        self.next = None
        self.previous = None

这种实现将允许您从两个方向遍历列表,而不是只使用next遍历列表。你可以用next来前进,用previous来后退。

在结构方面,这是一个双链表的样子:

如何使用循环链表

循环链表是一种链表,它的最后一个节点指向链表的头,而不是指向None。这就是为什么它们是圆形的。循环链表有很多有趣的用例:

  1. 多人游戏中每个玩家的回合
  2. 管理给定操作系统的应用程序生命周期
  3. 实现斐波那契堆

这是一个循环链表的样子:

循环链表的优点之一是可以从任何节点开始遍历整个链表。由于最后一个节点指向列表的头部,因此需要确保在到达起始点时停止遍历。否则,您将进入一个无限循环。

在实现方面,循环链表与单链表非常相似。唯一的区别是你可以在遍历列表时定义起点:

class CircularLinkedList:
    def __init__(self):
        self.head = None

    def traverse(self, starting_point=None):
        if starting_point is None:
            starting_point = self.head
        node = starting_point
        while node is not None and (node.next != starting_point):
            yield node
            node = node.next
        yield node

    def print_list(self, starting_point=None):
        nodes = []
        for node in self.traverse(starting_point):
            nodes.append(str(node))
        print(" -> ".join(nodes))

遍历列表现在会收到一个额外的参数starting_point,用于定义开始和迭代过程的结束(因为列表是循环的)。除此之外,大部分代码与LinkedList类中的代码相同。

以最后一个例子作为总结,看看当你给它一些数据时,这种新的列表类型是如何表现的:

circular_llist = CircularLinkedList()
circular_llist.print_list()

a = Node("a")
b = Node("b")
c = Node("c")
d = Node("d")
a.next = b
b.next = c
c.next = d
d.next = a
circular_llist.head = a
circular_llist.print_list()

circular_llist.print_list(b)

circular_llist.print_list(d)

这就对了!在遍历列表时,您将注意到不再有None。这是因为循环列表没有特定的结束。您还可以看到,选择不同的开始节点将呈现相同列表的略有不同的表示。

总结

在本文中,您学到了不少东西!最重要的是:

  • 什么是链表,什么时候应该使用链表
  • 如何使用collections.deque来实现队列和堆栈
  • 如何实现你自己的链表和节点类,加上相关的方法
  • 其他类型的链表是什么?它们的用途是什么

如果你想了解更多关于链表的知识,请查看Vaidehi Joshi’s Medium post(https://medium.com/basecs/whats-a-linked-list-anyway-part-1-d8b7e6508b9d),它提供了一个很好的视觉解释。如果你对更深入的指南感兴趣,那么Wikipedia article(https://en.wikipedia.org/wiki/Linked_list)是相当全面的。最后,如果您对collections.deque当前实现背后的原因感到好奇,那么请查看Raymond Hettinger’s thread(https://mail.python.org/pipermail/python-dev/2007-November/075244.html)。

posted @ 2021-09-02 15:35  叮叮当当sunny  阅读(6798)  评论(0编辑  收藏  举报