Python中的链表简介
翻译自宝藏网站:https://realpython.com/linked-lists-python/
建议不排斥英文的同学直接阅读原文。
Linked lists就像list的一个不太为人所知的表亲。它们不那么流行,也不那么酷,你可能在算法课上都不记得它们了。但在合适的环境下,它们真的会发光。
在本文中,您将了解:
- 什么是链表,什么时候应该使用链表
- 如何使用collections.deque来满足你所有的链表需求
- 如何实现你自己的链表
- 其他类型的链表是什么?它们的用途是什么
理解链表(Linked Lists)
链表是对象的有序集合。那么,是什么让它们与普通列表不同呢?链表与列表(List)的不同之处在于它们在内存中存储元素的方式。列表使用一个连续的内存块来存储对其数据的引用,而链表则将引用作为其自身元素的一部分去存储。
在深入了解什么是链表以及如何使用链表之前,您应该首先了解它们的结构。链表中的每个元素称为一个节点,每个节点有两个不同的字段:
- Data包含了存储在该节点(node)中的数值;
- 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浏览器的历史记录功能,在这个功能中存储用户访问的每个页面,这样他们就可以很容易地回到过去。假设这些是随机用户在浏览器上的操作:
- 访问Real Python的网站
- 导航到Pandas:如何读取和写入文件
- 单击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中实现自己的链表呢?有几个理由:
- 练习你的Python算法技能
- 学习数据结构理论
- 为工作面试做准备
如果您对上面的任何内容都不感兴趣,或者您已经熟练地用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()添加新节点时,可以看到节点总是被添加到列表的末尾。
节点间插入
在两个节点之间插入会给已经很复杂的链表插入增加一层复杂性,因为你可以使用两种不同的方法:
- 在已有节点后插入
- 在现有节点前插入
将它们分成两个方法似乎有些奇怪,但链表的行为与普通链表不同,每种情况都需要不同的实现。
下面是一个方法,它将一个节点添加到一个具有特定数据值的现有节点之后:
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")
就是这样!现在您知道了如何实现链表以及遍历、插入和删除节点的所有主要方法。如果你对自己所学的知识感到满意,并且渴望更多,那么可以选择以下挑战之一:
- 创建一个方法来从特定的位置检索元素:get(i)或者llist[i]。
- 创建一个方法来反转链表:list.reverse()。
- 使用enqueue()和dequeue()方法创建继承本文链接列表的Queue()对象。
除了很好的练习,自己做一些额外的挑战也是吸收你所学知识的有效方法。如果你想重新使用本文中的所有源代码,那么你可以从下面的链接下载你需要的一切:https://realpython.com/bonus/linked-lists/
使用高级链表
到目前为止,您一直在学习一种特定类型的链表,称为单链表。但是还有更多类型的链表可以用于稍微不同的目的。
如何使用双链表
双链表不同于单链表,它们有两个引用:
- 前面的字段引用前面的节点。
- 下一个字段引用下一个节点。
最终结果是这样的:
如果你想实现上面的内容,那么你可以对现有的Node类做一些修改,以包含以前的字段:
class Node: def __init__(self, data): self.data = data self.next = None self.previous = None
这种实现将允许您从两个方向遍历列表,而不是只使用next遍历列表。你可以用next来前进,用previous来后退。
在结构方面,这是一个双链表的样子:
如何使用循环链表
循环链表是一种链表,它的最后一个节点指向链表的头,而不是指向None。这就是为什么它们是圆形的。循环链表有很多有趣的用例:
- 多人游戏中每个玩家的回合
- 管理给定操作系统的应用程序生命周期
- 实现斐波那契堆
这是一个循环链表的样子:
循环链表的优点之一是可以从任何节点开始遍历整个链表。由于最后一个节点指向列表的头部,因此需要确保在到达起始点时停止遍历。否则,您将进入一个无限循环。
在实现方面,循环链表与单链表非常相似。唯一的区别是你可以在遍历列表时定义起点:
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)。