測試開發基礎之算法(4):單鏈表的操作與應用

本篇文章介紹了鏈表的存儲結構,單鏈表的常見操作,並對leetcode上關於鏈表的比較容易的題目進行了編程實現,另外還介紹了雙向鏈表、循環鏈表的特點。

1. 鏈表的存儲結構

與數組需要連續的內存進行存儲不同,鏈表不需要連續的內存。鏈表通過指針將零散的內存串聯起來,形成鏈式結構。下面是數組和鏈表在內存中存儲方式的對比圖。
在這裏插入圖片描述
圖片來源:極客時間-數據結構與算法之美

2. 單鏈表的邏輯結構

鏈表有三種常見結構,它們分別是:單鏈表、雙向鏈表和循環鏈表。我們首先來看最簡單、最常用的單鏈表。
在這裏插入圖片描述
單鏈表邏輯圖中,有幾個概念:

  • 頭指針——記錄鏈表的基地址,通過它就可以找到鏈表上任意結點。它指向鏈表中第一個結點的地址,也叫Head指針,整個鏈表的存取就必須從頭指針開始進行。
  • 結點——鏈表中的元素稱爲結點,每個結點都是由數據域和指針域組成。數據域存儲結點的數據,指針域存儲下一個結點的位置。
  • 尾結點——鏈表中最後一個結點,稱爲尾結點,它的指針域是Null(None)。

3. 創建一個單鏈表

由於鏈表中存放的是結點,所以鏈表的建立必然要先建立結點數據類型。結點類的定義如下:

class Node:
    """鏈表的Node結點"""

    def __init__(self, data: int, next_node=None):
        """
        :param data: 結點的數據域,存儲結點的數據
        :param next_node: 節點的下一個Node節點的地址
        """
        self.data = data
        self._next = next_node

我們通過結點間傳遞值的方式將指針指向下一個結點。如下代碼是一個鏈表的創建過程。

class SingleLinkedList:
    def __init__(self):
        """
        初始化爲空表,只有頭指針,執行None
        """
        self._head = None
   	def is_empty(self):
   		"""
        頭指針指向第一個結點的地址,如果是指向None,則鏈表爲空
        """
        return self._head is None

if __name__ == '__main__':
    li = SingleLinkedList()
    n1 = Node(1)
    n2 = Node(2)
    n3 = Node(3)
    # 加入第一個結點
    li._head = n1 
    # 第一個結點連接第二個節點
    n1.next = n2
    # 第二個結點連接第三個節點
    n2.next = n3
    print(f"n2.data={n2.data}") # 輸出n2.data=2
    print(f"n2.next={n2.next}")  # 輸出n2.next=<__main__.Node object at 0x108cd8940>
    print(f"n3={n2.next.data}")  #輸出n3=3

4.遍歷鏈表

獲取鏈表長度的思路是,設定一個指針,從第一個結點開始往後移動,每經過一個結點,計數器加1。當指針移動到最後一個結點時,計數器的值就是鏈表的長度。根據這個思路,在鏈表類SingleLinkedList中添加length方法,代碼如下:

def length(self):
	"""
    從第一個結點開始遍歷,直到結點爲None。當結點不None時,計數器加1
    """
    count = 0
    p = self._head
    while p is not None:
        count += 1
        p = p.next
    return count

遍歷鏈表指的是從鏈表的第一個結點一直往後逐個輸出鏈表的所有結點。根據這個思路,在鏈表類SingleLinkedList中添加travel方法,代碼如下:

def travel(self):
    if self._head is None:
        print("There is no node in linked list!")
    else:
        p = self._head
        while p is not None:
            print(f"current.data={p.data}, current.next={p.next},")
            p = p.next

5. 插入結點到鏈表中

與數組一樣,鏈表也支持數據的查找、插入和刪除操作。
數組在插入和刪除時,因爲涉及到數據搬移,所以時間複雜都是O(n),按下標隨機訪問的時間複雜度是O(1)。
鏈表中插入或者刪除一個數據,不涉及數據的搬移,所以時間複雜度是O(1)。但是查找的時間的複雜度是O(n),因爲任何結點的訪問都是要從頭指針開始。
下面我們具體看看鏈表插入的實現方式。根據插入的位置不同,鏈表插入分爲尾部插入、頭部插入、任意位置插入三種。

  • 鏈表尾部插入

要考慮兩種情況,鏈表爲空和非空。如下圖所示。
在這裏插入圖片描述
鏈表爲空時,直接讓鏈表頭指針指向新增結點就可以了。當鏈表不爲空時,在鏈表尾部插入新結點,先將頭指針移動到尾結點,用尾結點指向新增結點。
增加一個insert_at_end方法到上面的鏈表類SingleLinkedList中,根據上面的思路,實現代碼如下:

def insert_at_end(self, data):
    node = Node(data)
    if self._head is None:  # 鏈表爲空
        self._head = node
    else:
        p = self._head
        while p.next is not None:  # 移動頭指針到最後
            p = p.next
        p.next = node   # 最尾部的結點指向新增結點
  • 鏈表頭部插入

在鏈表開頭插入數據,考慮兩種情況:鏈表爲空和鏈表不爲空。如下圖所示:
在這裏插入圖片描述
鏈表爲空時,將頭指針指向新增結點就可以了(self._head = node)。當鏈表不爲空時,需要先讓新增結點指向原來頭指針指向的位置(node.next=self._head),再讓頭指針指向新增結點(self._head=node)。注意順序不能顛倒,否則,就會造成指針丟失和內存泄漏(想想爲什麼)。
增加一個insert_at_start方法到上面的鏈表類SingleLinkedList中,根據上面的思路,實現代碼如下:

    def insert_at_start(self, data):
        """
        在頭部添加一個節點
        """
        node = Node(data)  # 生成一個結點
        if self._head is None:  # 鏈表爲空
            self._head = node
        else:
            node.next = self._head  # 1.先設定新增結點的下一個節點
            self._head = node   # 2.再更新頭指針的指向
  • 任意索引位置插入

相比前面在鏈表開頭插入新結點和在鏈表尾部加入新結點,在任意索引位置插入新結點更具有普遍意義。我們需要根據索引的情況,分三種情況處理:

  1. 索引小於等於0時,在鏈表開頭插入新結點;
  2. 索引大於等於鏈表長度時,在鏈表尾部插入新結點;
  3. 如果索引位於鏈表中間位置,那我們就從頭指針開始,往後移動指針p,移動到索引處,將新結點插入。這裏插入需要兩步:node.next=p.next 和p.next=node。

根據這個思路,增加一個insert_to_index方法到上面的鏈表類SingleLinkedList中,代碼如下:

def insert_to_index(self, index: int, value: int) -> None:
    if index <= 0:  # 插入到鏈表頭部
        self.insert_at_start(value)
    elif index >= self.length():  # 插入到鏈表尾部
        self.insert_at_end(value)
    else:  # 插入到index處
        p = self._head # 定義指向頭指針的指針
        position = 0
        while position < index -1:  # 移動指針到index處
            position += 1
            p = p.next
        node = Node(value)
        node.next = p.next # 插入新結點
        p.next = node

上面代碼加了詳細註釋,不難理解。

6. 查找鏈表中的結點

鏈表的查找,必須從頭指針開始,時間複雜都是O(n)。有兩種查找方法,一種是根據值來查找,找到則返回結點,找不到則返回None。一種是根據索引來查找。查找方法需要考慮鏈表爲空和不爲空兩種情況。如下圖所示:
在這裏插入圖片描述

  • 根據值來查找結點

當鏈表爲空時,直接返回None。當鏈表不爲空,需要從頭指針開始遍歷鏈表,判斷結點的數據域是否等於要查找的值,如果找到則返回這個結點。如果找到鏈表尾部還沒找到要查找的值,則返回None。
增加一個search_by_value方法到上面的鏈表類SingleLinkedList中,根據上面的思路,實現代碼如下:

def search_by_value(self, value: int) -> Optional[Node]:
    if self._head is None:  # 空鏈表,肯定找不到value
        return None
    p = self._head  # 指針p從頭指針開始
    while p:  # 遍歷到鏈表尾部
        if p.data == value:  # 找到與value相同的節點p
            return p
        p = p.next  # 指針繼續往後移動
    return None  # 遍歷完整個鏈表還沒找到value

上面代碼考慮到鏈表爲空和不爲空兩種情況。鏈表爲空時(self._head is None),直接返回None。鏈表不爲空時,定義一個指針,剛開始指向頭指針(p = self._head),判斷指針所指結點的數據域是否與要查找的value相等(p.data == value),如果相等,則返回指針所指的結點。如果不相等,指針繼續往後移動,直到鏈表尾部。當指針到達鏈表尾部時還沒找到value,則返回None。
寫一段測試代碼,測試一下上面的方法:

if __name__ == '__main__':
    l = SingleLinkedList()
    for i in range(1, 9):
        l.insert_at_end(i)
    l.travel()
    a=l.search_by_value(3)
    print(a.data,a.next)

輸出內容:

current.data=1, current.next=<__main__.Node object at 0x10e790588>,
current.data=2, current.next=<__main__.Node object at 0x10e7905c0>,
current.data=3, current.next=<__main__.Node object at 0x10e7905f8>,
current.data=4, current.next=<__main__.Node object at 0x10e790630>,
current.data=5, current.next=<__main__.Node object at 0x10e790668>,
current.data=6, current.next=<__main__.Node object at 0x10e7906a0>,
current.data=7, current.next=<__main__.Node object at 0x10e7906d8>,
current.data=8, current.next=None,
3 <__main__.Node object at 0x10e7905f8>

測試結果通過。

  • 根據索引查找鏈表

根據索引查找鏈表,指的是從頭指針開始,找到第index個結點。在上圖中,d1表示索引爲1的結點,d2位索引爲2的結點。在查找前,需要對查找索引進行判斷,如果索引是負數或者超過了鏈表長度了,就返回None。否則,指針從頭指針開始往後計數,當指針指向索引處,就返回對應的結點。
根據這個思路,增加一個search_by_index方法到上面的鏈表類SingleLinkedList中,實現代碼如下:

def search_by_index(self, index: int) -> Optional[Node]:
    # 如果index大於列表長度則返回None
    if index > self.length() or index < 0:
        return None
    p = self._head  # 指針p從頭指針開始
    position = 0
    while p:
        if position == index:  
            return p
        p = p.next
        position += 1
    return None
  • 查找中間結點

再來看看如何查找鏈表中的一個特殊結點——中間結點。中間結點表示鏈表中位置在中間的結點。對於偶數個結點的鏈表,中間結點是中間靠後的那個。

尋找中間結點,可以採用雙指針策略。初始,快指針fast和慢指針slow都是指向第一個結點。慢指針slow每次往後移動一步,快指針fast每次往後移動兩步。直到fast或者fast.next指向了None,停止移動,此時slow就是指向中間結點。可以畫一個圖感受一下,會更容易理解。
在這裏插入圖片描述
代碼如下:

def find_middle_node(self):
    slow = self._head
    fast = self._head  # 對於偶數個結點的鏈表,中間結點是中間靠後的那個。如果 fast = self._head.next,則是中間靠前的那個
    while fast and fast.next:
        slow = slow.next  # 每次移動1步
        fast = fast.next.next  # 每次移動2步
    return slow

if __name__ == '__main__':
    l = SingleLinkedList()
    for i in range(1, 9):
        l.insert_at_end(i)
    l.travel()
    b = l.find_middle_node()
    # b = find_middle_node(l._head)
    if b:
    	print(b.data, b.next)

7.切分單鏈表

切分鏈表是將原始鏈表按照某個索引切成兩部分,形成兩個鏈表。第一個鏈表的頭結點還是原始鏈表的頭,尾結點是索引處的前一個結點。第二個鏈表頭結點是索引處的結點,尾結點是原始鏈表的尾結點。
思路是藉助按照索引查找鏈表結點的方法,找到索引接點的前一個結點,將索引處的結點指向None。

    def split(self, index):
        index -= 1
        if index <= 0:
            return None, self
        if index >= self.length():
            return self, None
        new = SingleLinkedList()
        p = self.search_by_index(index)
        q = p.next
        p.next = None  # 修改索引結點的前一個結點的指向,使得兩個鏈表斷開
        new._head = q
        return self, new

8. 刪除單鏈表中的結點

與單鏈表的查找類似,刪除鏈表也有兩種常用的應用場景,一個是刪除某個索引處的結點,另一個是刪除某個值所在的結點。
刪除單鏈表結點的思路,可以參考查找的思路,然後將找到的結點刪除。

  • 按照索引刪除

鏈表的索引範圍是0~length-1。按索引刪除時,核心是先從頭指針開始往後尋找待刪除的結點p。當找到p後,將p前面的結點指向p後面一個結點,這樣就刪除了這個結點p。對於待刪除的結點是鏈表的第一個結點,需要特殊處理,self._head = p.next。下面看下完整的代碼:

def remove_by_index(self, index):
    if self._head is None:
        raise IndexError("Empty linked list!")
    if index < 0 or index > self.length()-1:
        raise IndexError(f"Index {index} out of range!")
    p = self._head  # 定義指針指向鏈表頭部
    pre = None  # 輔助變量,指向p的前一個結點
    position = 0
    while p:
        if position != index:  # 尋找要刪除的結點p
            pre = p
            p = p.next
            position += 1
        else:
            if p == self._head:  # 刪除的結點是頭結點
                self._head = p.next
            else:
                pre.next = p.next  # 把p前面的node指向p後面的結點(就是把p刪除了)
            break  # 刪除完成,跳出循環

這段代碼中,先處理了鏈表爲空或者索引超過範圍的情況。定義了輔助變量pre指向p結點的前一個結點。在while循環中找到待刪除的結點p。如果是p是第一個結點,則self._head = p.next。如果p不是第一個結點,pre.next = p.next。注意,刪除結點,要跳出循環。

  • 按照值刪除

與按照索引刪除類似。都是要先找到要刪除的結點。直接上代碼,加上註釋。

def remove_by_value(self, value):
    pre = None  # p的前面的結點
    p = self._head
    while p:
        if p.data != value:  # 尋找要刪除的結點p
            pre = p
            p = p.next
        else:
            if p == self._head:  # 刪除的結點是頭結點
                self._head = p.next
            else:
                pre.next = p.next  # 把p前面的node指向p後面的結點(就是把p刪除了)
            break  # 刪除完成,跳出循環
    else:
        raise ValueError(f"{value} does not exist in the single link list")

代碼中也是在while循環中找要刪除的結點。再分別處理結點是頭結點和一般結點的情況。如果找不到,則報ValueError異常。

9.反轉單鏈表

鏈表反轉,指的是將當前結點指向它原來的前面一個結點。爲了方便理解,對1->2->3->4->5->NULL這個鏈表來做反轉後,輸出的效果是5->4->3->2->1->NULL。反轉鏈表後,原鏈表head指向原鏈表最後一個結點。
爲了反轉鏈表,我們需要三個變量,分別是pre,p和nex。p表示當前結點,pre表示當前結點的前驅結點,nex表示當前結點的後繼結點。
初始時,p指向原始鏈表的第一個結點,pre指向None。p從第一個結點遍歷到最後一個結點。在遍歷的過程中,執行下面的操作:

  1. 獲取當前結點的下一個節點nex(nex = p.next)
  2. 變更當前結點指向,指向前驅結點(p.next = pre)
  3. 前驅結點指向當前結點(pre = p)
  4. 當前結點指向後繼結點(p = nex)

上面四個步驟一直循環,直到p指向鏈表尾部(p is None)。循環結束後,將原始鏈表的頭指向原始鏈表的最後一個結點(pre)。至此,反轉完成了。用圖示的方式描述上面的操作過程,可能更加清晰一些。
在這裏插入圖片描述
重複反轉指針的步驟,直到p指向原始鏈表尾部。代碼如下:

def reverse(self):
    if not (self._head and self._head.next):
        return
    pre = None
    p = self._head
    while p:
        nex = p.next  # nex保存當前結點的後繼結點,避免改變p的指向後,找不到原始鏈表
        p.next = pre  # 當前結點p指向前趨結點
        pre = p  # 前驅結點往後移一位
        p = nex  # 當前結點往後移一位
    self._head = pre  # 原始鏈表頭指針指向pre
if __name__ == '__main__':
    l = SingleLinkedList()
    for i in range(1, 9):
        l.insert_at_end(i)
    l.travel()
    l.reverse()
    l.travel()

10.判斷鏈表中是否有環

鏈表中有環指的是具有下面這樣的結構:
在這裏插入圖片描述
判斷鏈表中是否有環,可以設定一個快指針和一個慢指針,一旦他們進入環,就一定會相遇。想象一下,在環形跑道上,走路的人和跑步的人,一定會在某一個時刻相遇。
下面的圖例展示了過程。slow指針每次走一步,fast指針每次走兩步,在環上,一定會有機會相遇。
在這裏插入圖片描述
代碼如下:

def has_loop(self):
    if self._head is None:
        return False
    slow = self._head
    fast = self._head
    while slow.next and fast.next:
        slow = slow.next  # 慢指針每次移動一步
        fast = fast.next.next  # 快指針每次移動兩步
        if slow == fast:  # 快慢指針相遇,則說明有鏈表上有環
            return True
    return False
    
if __name__ == '__main__':
    l = SingleLinkedList()
    for i in range(1, 10):
        l.insert_at_end(i)
    print(l.has_loop())
    l.search_by_index(8).next = l.search_by_index(3) # 創建環
    print(l.has_loop())
  • 環的大小

現在我們知道如何判斷鏈表上是否有環。那麼如何知道環的大小(長度)呢?也就是如何知道環上有幾個結點呢?這裏有一個思路。
當fast按照每次2步,slow每次1步的方式走,發現fast和slow重合後,確定了單向鏈表有環路。接下來,讓fast不動,slow 繞着環移動,每次移動一步,計數count加1,當兩指針再次相遇時,count即是環的大小。可以結合上圖第(7)幅圖想一想過程,代碼實現如下:

def find_loop_entrance(self):
    if self._head is None:
        return False
    slow = self._head
    fast = self._head
    while slow.next and fast.next:
        slow = slow.next  # 慢指針每次移動一步
        fast = fast.next.next  # 快指針每次移動兩步
        if slow == fast:  # 快慢指針相遇,則說明有鏈表上有環
            break
    count = 0
    slow = slow.next
    while slow != fast:
        count += 1
        slow = slow.next
    return count
  • 環的入口結點

再來進一步,如何找出環的入口點(起點)?
當fast按照每次2步,slow每次1步的方式走,發現fast和slow重合,確定了單向鏈表有環路。接下來,讓slow回到鏈表的頭部,然後slow從鏈表頭開始,fast從環上相遇處開始,都按照每次1步的方式前進。當fast和slow再次相遇的時候,slow所在的位置就是環路的入口了。可以結合上圖第(7)幅圖想一想過程,代碼如下:

def fin_loop_entrance(self):
    if self._head is None:
        return False
    slow = self._head
    fast = self._head
    while slow.next is not None and fast.next is not None:
        slow = slow.next  # 慢指針每次移動一步
        fast = fast.next.next  # 快指針每次移動兩步
        if slow == fast:  # 快慢指針相遇,則說明有鏈表上有環
            break

    slow = self._head
    while slow != fast:
        slow = slow.next
        fast = fast.next
    return slow

上面就是針對單鏈表的最基礎的操作了。下面看幾個leetcode上的經典題目。

11.練習題:合併兩個有序的鏈表

來自leetcode的題目:https://leetcode-cn.com/problems/merge-two-sorted-lists/

將兩個有序鏈表合併,合併後仍然有序。新鏈表是通過拼接給定的兩個鏈表的所有結點組成的。例如輸入鏈表a是1->2->4, 鏈表b是1->3->4。輸出新鏈表是:1->1->2->3->4->4。
直觀的想法將兩個鏈表轉化爲數組,直接排序,最後組裝一個新的鏈表。但是這樣比較浪費存儲空間。我們想想還有沒有空間複雜度是O(1)的算法。

  • 迭代法

首先,新建一個哨兵結點 “dummy_head” ,再維護一個 prev 指針,初始時prev指向dummy_head結點。之後,循環下面的操作:將兩個鏈表相同位置上比較小的結點放到prev的下一個結點處,並將較小結點所在的鏈表指針後移一位。

在循環終止的時候, 兩個鏈表最多有一個是非空的。由於輸入的兩個鏈表都是有序的,所以不管哪個鏈表是非空的,它包含的所有元素都比前面已經合併鏈表中的所有元素都要大。這意味着我們只需要簡單地將非空鏈表接在合併鏈表的後面,並返回合併鏈表。

上面思路可能不太好理解,下面在用圖的方式來說明一下,可能容易理解:
在這裏插入圖片描述
代碼如下:

def merge_linked_lists_by_iteration(first_linked_list: SingleLinkedList, second_linked_list: SingleLinkedList) -> SingleLinkedList:
    """
    合併兩個有序鏈表
    :param first: 第一個鏈表
    :param second:第二個鏈表
    """
    new = SingleLinkedList()  # 創建新的鏈表
    dummy_node = Node(-1)  # 創建一個dummy結點, 作爲合併後的第一個結點
    prev = dummy_node  # 定義當前正在處理結點的前一個結點
    first = first_linked_list._head
    second = second_linked_list._head
    while first and second:  # 直到兩個鏈表任意一個爲空
        if first.data <= second.data:
            prev.next = first  # 讓prev指向較小的結點
            first = first.next
        else:
            prev.next = second
            second = second.next
        prev = prev.next

    # 將不爲空的鏈表拼接到已經合併的鏈表尾部
    if first:
        prev.next = first
    if second:
        prev.next = second
    new._head = dummy_node.next  # 新鏈表的頭指針指向dummy結點的下一個結點
    return new

if __name__ == '__main__':
    l1 = SingleLinkedList()
    for i in [1, 3, 5]:
        l1.insert_at_end(i)
    l1.travel()
    l2 = SingleLinkedList()
    for i in [2, 4, 6, 8]:
        l2.insert_at_end(i)
    l2.travel()
    new = SingleLinkedList()
    new = merge_linked_lists_by_iteration(l1,l2)
    new.travel()

時間複雜度:O(n)或者O(m)。因爲每次循環迭代中,first_linked_list 和 second_linked_list 只有一個結點會被放進合併鏈表中, while 循環的次數等於兩個鏈表的最短的那個。所有其他工作都是常數級別的,所以總的時間複雜度是線性的。

空間複雜度:O(1)。迭代的過程只會產生幾個指針,所以它所需要的空間是常數級別的。

  • 遞歸算法

除了迭代的算法,遞歸的方法更容易理解,選出兩個鏈表頭部較小的一個結點與兩個鏈表剩下部分 merge 合併。
首先考慮邊界情況。如果list1爲空鏈表,則直接返回list2,如果list2是空鏈表,則直接返回list1。如果list1和list2都不是空鏈表,我們要判斷 list1 和 list2 哪個鏈表的第一個結點更小,然後遞歸地決定下一個添加到結果裏的值。如果兩個鏈表都是空的,那麼過程終止,所以遞歸過程最終一定會終止。
代碼如下:

def merge_linked_lists_by_recursion(first_linked_list: SingleLinkedList,
                                    second_linked_list: SingleLinkedList) -> SingleLinkedList:

    #  遞歸的終止條件
    if first_linked_list._head is None:
        return second_linked_list
    if second_linked_list._head is None:
        return first_linked_list

    list1 = first_linked_list._head
    list2 = second_linked_list._head
    new = SingleLinkedList()  # 創建新的鏈表
    if list1.data < list2.data:
        temp = list1
        temp.next = merge_linked_lists_by_recursion(list1.next, list2)
    else:
        temp = list2
        temp.next = merge_linked_lists_by_recursion(list1, list2.next)
    new._head = temp
    return new

時間複雜度:O(n + m)。 因爲每次調用merge_linked_lists_by_recursion遞歸都會去掉 list1 或者 list2 的第一個結點(直到至少有一個鏈表爲空),函數中只會遍歷每個元素一次。所以,時間複雜度與合併後的鏈表長度爲線性關係。

空間複雜度:O(n + m)。調用merge_linked_lists_by_recursion退出時 l1 和 l2 中每個元素都一定已經被遍歷過了,所以 n + m個棧幀會消耗 O(n + m)的空間。

12. 練習題:找到兩個單鏈表相交的起始結點

來自https://leetcode-cn.com/problems/intersection-of-two-linked-lists/
兩個相交的鏈表,是指有下面這樣的結構,結點8就是他們的相交的起始結點:
在這裏插入圖片描述
我們將b鏈表的尾巴指向a節點的頭部,將a節點的尾巴指向b鏈表的頭部。這樣,形成了兩個環狀鏈表。
在這裏插入圖片描述
然後我們定義兩個指針,一個從a鏈表頭出發,一個從b鏈表頭出發,因爲是環形的,最終兩個鏈表會相遇,而第一個相遇的結點就是相交的起始結點。

def get_intersection_nodes(first_linked_list: SingleLinkedList, second_linked_list: SingleLinkedList) -> Optional[Node]:
    """
    找到兩個單鏈表相交的起始結點
    """
    if not first_linked_list._head or not second_linked_list._head:
        return None

    first = first_linked_list._head
    second = second_linked_list._head
    while first != second:
        if first:
            first = first.next
        else:
            first = second_linked_list._head
        if second:
            second = second.next
        else:
            second = first_linked_list._head
    return first

if __name__ == '__main__':
    l1 = SingleLinkedList()
    for i in [100, 200, 300, 400, 500]:
        l1.insert_at_end(i)
    l1.travel()

    l2 = SingleLinkedList()
    for i in [700, 800]:
        l2.insert_at_end(i)
    l2._head.next.next = l1.search_by_index(3)  # 創建相交結點
    l2.travel()
    print(get_intersection_node_plus(l1, l2))
    print(get_intersection_node_plus(l1, l2).data)

13. 練習題:判斷一個鏈表是否爲迴文鏈表

來自 https://leetcode-cn.com/problems/remove-duplicates-from-sorted-list/
迴文鏈表類似: 1->2->2->1。即從後往前和從前往後遍歷鏈表時,得到的內容相同。
解決思路是:找到中間結點,然後把後半部分鏈表反轉,然後再逐個結點對比。
代碼如下:

    def is_palindrome(self) -> bool:
        """
        判斷一個鏈表是否爲迴文鏈表。
        """
        if not (self._head and self._head.next):  # 鏈表爲空或者只有一個結點
            return True
        middle = self.find_middle_node()  # 找到中間結點
        new = SingleLinkedList()  # 創建新空鏈表
        new._head = middle  # 新鏈表頭指針指向後半部分
        new.reverse()  # 對新鏈表反轉
        p = self._head  # 指向原鏈表第一個結點
        q = new._head  # 指向翻轉後新鏈表第一個結點
        while p and q:  # 前半部分和翻轉後的後半部分逐一對比
            if p.data != q.data:
                return False
            p = p.next
            q = q.next
        return True
        
if __name__ == '__main__':
    l1 = SingleLinkedList()
    for i in [100, 200, 300, 200, 100]:
        l1.insert_at_end(i)
    l1.travel()
    print(l1.is_palindrome())

14. 基本操作:排序鏈表

歸併排序通常是對鏈表進行排序的首選方法。鏈表緩慢的隨機訪問性能使得其他一些算法(如快速排序)性能很差,而其他一些算法(如堆排序)則完全不可能實現。

    def merge_sort(self):
        """
        1、找到鏈表的middle節點,鏈表有偶數個結點時,獲取的中間結點是靠前的那個
        2、然後遞歸對前半部分和後半部分分別進行歸併排序,
        3、最後對兩個以排好序的鏈表進行Merge。
        """
        if not (self._head and self._head.next):
            return self._head

        slow, fast = self._head, self._head.next  # 這裏fast=head.next,則鏈表有偶數個結點時,中間結點slow是靠前的那個
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
        right = merge_sort(slow.next)  # slow.next是中間結點靠後的那個,也就是後半部分的第一個結點
        slow.next = None
        left = merge_sort(self._head)
		# 以下是合併兩個有序鏈表
        # 創建一個dummy結點, 作爲合併後的第一個結點
        new = SingleLinkedList()  # 創建新的鏈表
        dummy_head = Node(-1)
        prev = dummy_head  # 定義當前正在處理結點的前一個結點
        while left and right:  # 直到兩個鏈表任意一個爲空
            if left.data <= right.data:
                prev.next = left  # 讓prev指向較小的結點
                left = left.next
            else:
                prev.next = right
                right = right.next
            prev = prev.next

        # 將不爲空的鏈表拼接到已經合併的鏈表尾部
        if left:
            prev.next = left
        if right:
            prev.next = right
        new._head = dummy_head.next # dummy節點的下一結點纔是真正的鏈表結點
        return new

15. 練習題:刪除已排序鏈表中所有重複的結點

來自:https://leetcode-cn.com/problems/remove-duplicates-from-sorted-list
給定一個排序鏈表,刪除所有重複的結點,使得每個結點只出現一次。
這是一個簡單的問題,僅需要操作一遍指針即可。由於輸入的列表已排序,因此我們可以通過將當前結點的值與它後繼結點的值進行比較,來確定它是否爲重複結點。如果它是重複的,就將當前結點的 next 指針指向它的下下一個結點。

def delete_duplicates(head: Node) -> Node:
    current = head
    while current and current.next:
        if current.val == current.next.val:
            current.next = current.next.next
        else:
            current = current.next
    return head

那如何刪除未排序的鏈表中的所有重複結點呢?思路:先排序,在按照上面的方法去重。

16. 鏈表應用:LRU緩存淘汰

軟件開發中,緩存有着非常廣泛的應用。緩存大小一般都比較有限,當緩存滿了之後,就要決定哪些數據將被淘汰。常見的緩存淘汰策略有三種:先進先出策略FIFO(First in,First Out)、最少使用策略LFU(Least Frequently Used )、最近最少使用策略LRU(Least Recently Used)。
使用本文介紹的單鏈表,實現LRU緩存淘汰算法的思路是這樣的:維護一個有序單鏈表,越靠近鏈表尾部的結點是越早之前訪問的,當有一個新的數據被訪問時,我們從鏈表頭部開始遍歷鏈表。

  1. 如果該數據已經緩存在鏈表中了,我們遍歷鏈表找到這個數據之後,將其從原來位置上刪除,再將其插入到鏈表頭部。
  2. 如果該數據沒有在緩存鏈表中,分下面兩種情況處理:
  • 如果緩存鏈表沒有滿,則把它放到鏈表開頭
  • 如果此時緩存鏈表滿了,則把鏈表尾部的結點刪除,然後在鏈表頭部插入數據。

17. 鏈表 VS 數組性能大比拼

數組比較適合隨機訪問,而鏈表比較適合插入和刪除。數組的插入和刪除因爲涉及到數據的搬移,因此時間複雜度是O(n),而鏈表進行隨機訪問時,因爲要從鏈表頭部開始訪問,時間複雜度是O(n)。
在這裏插入圖片描述

18. 特殊鏈表

上面介紹了單鏈表的各種操作及應用,接着來看另外兩個複雜的升級版,循環鏈表和雙向鏈表。
循環鏈表與單鏈表的區別是,循環鏈表的尾結點不是指向None,而是指向鏈表的第一個結點,循環鏈表的結構如下圖所示:
在這裏插入圖片描述
循環鏈表的優點是,從鏈表尾部到鏈表頭部比較方便。當要處理的數據具有喚醒結構時,比較適合採用循環鏈表來存儲。比如著名的約瑟夫問題。

雙向鏈表每個結點都有兩個指針,一個指向前驅結點,一個指向後繼結點。
在這裏插入圖片描述
雙向鏈表比單鏈表耗費更多的存儲空間,但是它的好處是支持高效的雙向遍歷。在某些情況下的插入、刪除等操作都要比單鏈表簡單、高效。
比如,我們在刪除某個指針指向的結點q時,需要知道這個結點的前驅結點。如果是單鏈表,就要從頭開始找,當找到p.next=q,這時p就是前驅結點。如果是雙向鏈表就方便了,因爲p節點的前驅結點就是p.prev。因此,單鏈表刪除某個指針的結點,時間複雜度是O(n),雙向鏈表刪除某個指針的結點,時間複雜度是O(1)。插入操作也是同樣的。

參考文獻

https://blog.csdn.net/allien83/article/details/84782517
https://blog.csdn.net/qq_43185391/article/details/91308128
https://stackabuse.com/linked-lists-in-detail-with-python-examples-single-linked-lists/
https://blog.csdn.net/qq_35923749/article/details/81218967

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