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)。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章