zoukankan      html  css  js  c++  java
  • 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)。

  • 相关阅读:
    linux /dev 常见特殊设备介绍与应用[loop,null,zero,full,random]
    由web程序出现乱码开始挖掘(Bom头、字符集与乱码)
    linux shell命令快捷获得系统帮助(一)[manpages定义规范]
    定期分割tomcat输出文件 catalina.out
    readyState的五种状态
    C# FileSystemWatcher
    不用第三个变量实现a,b的值交换
    我的eWork,我能赚钱的SOHO空间
    如何网上炒股
    没有为扩展名“.html”注册的生成提供程序
  • 原文地址:https://www.cnblogs.com/dingdangsunny/p/15210844.html
Copyright © 2011-2022 走看看