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)。
總結
以上是生活随笔為你收集整理的Python中的链表简介的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 微信服务号、订阅号和企业号的区别(运营和
- 下一篇: 数据类型