【數據結構】 線性表的鏈接表


鏈接表

  鏈接表簡稱鏈表,它的基本想法建立在下面三點之上

  1. 把表中的元素分別存儲在一批獨立的儲存塊中(稱之為鏈表的節點)。

  2. 保證從組成表結構的任意一個節點出發可以找到與其相關的下一節點。

  3. 在前一個節點里用鏈接的方式顯式地記錄與下一個節點之間的關聯。

  一般而言,鏈表的每個節點的儲存單元里只儲存一個元素,當然也可以儲存多個元素,下面按照一個元素說明。其次每個節點的鏈接單元可以鏈接向多個方向。下面按照鏈接單元性質上的不同來說明不同的鏈表類型

■  單鏈表

  單鏈表指每個節點的鏈接域只儲存下一個節點地址信息的鏈表,單鏈表是最簡單的一種鏈表形式。每個節點從邏輯上來說是一個二元組(elem,next)。elem是元素域,用於儲存這個節點的數據信息(或者在數據的關聯信息)。next是鏈接域,保存着同一個鏈表中下一個節點的標識。

  在常見的單鏈表中,引用首節點的元素p可以找到首節點所在的位置,從而通過鏈條找到整個鏈表的所有內容。也就是說,想要掌握一個單鏈表,只要掌握住這個鏈表的首節點的引用就可以了。這個首節點的引用也可以被稱為表頭變量或者表頭指針。

  理所當然,鏈表的尾節點上的鏈接域是空的,沒有內容。所以通過判斷一個節點的鏈接域是不是空值就可以知道鏈表有沒有結束。嚴格來說,當表頭指針就是空的時候也表示了鏈表結束,只不過此時鏈表是個空表,還沒開始就已結束。

  在實現單鏈表的算法上,並不需要關心某個具體的表里各個節點鏈接域中具體是什么值,而只需要關心節點之間的邏輯關系。對鏈表的操作也只需要根據鏈表的邏輯結構考慮和實現。

  下面一個是通過python實現的一個單鏈表類,目前只有一個初始化方法:

class LNode(): def __init__(self, elem, next_=None): self.elem = elem self.next = next_

■  基本鏈表操作

  創建空鏈表:因為鏈表在創建的時候不需要空間大小,元素個數等參數,非常方便。只需要創建一個鏈表節點作為表頭節點,然后把表頭指針設置成None即可。

  刪除鏈表:指丟棄這個鏈表中的所有節點,在其他一些語言中可能需要一個一個節點地回收鏈表占據的空間十分麻煩,在python中,由於存在自動的資源回收系統,只需要把表頭指針設置成None就可以了,系統會自動回收首節點后面的所有鏈表節點,因為他們是沒人引用的資源了。

  判斷空鏈表:將表頭指針和None作比較,如果表頭指針是None那么就是一個空鏈表。一般而言鏈表是不限制元素個數的所以不會滿,除非程序用光了可用的所有內存。

  ●  加入元素

  同順序表一樣,加入元素到鏈表分情況考慮,插入在表頭,插入在特定位置和插入在表尾。

  插入在表頭的情況,通過三步完成插入:1.創建一個新節點存入數據,用一個變量q來引用這個節點。 2.把當前表頭指針的head賦值給q.next。3.把q賦值給head使得q成為表頭指針。用代碼來表示的話就是:

q = LNode(13)
q.next = head head = q

  這樣13變成了這個鏈表的首元素。注意弄清首元素和表頭指針的區別。表頭指針是指用來引用首節點的那個變量,而首元素是指首節點的元素域中的內容。

  如果插入是在指定的任意位置的話,那么按照以下步驟操作:1.創建一個新節點並且存入數據,用一個變量q來引用這個節點。2.把前一節點pre的next域賦值給q的next域。3.把q賦值給pre的next域。用代碼來表示就是:(從這個過程中我們可以看到,在任意位置插入新節點時,不必關心插入后后一個節點的信息,而只要關注其前一個節點即可)

q = LNode(13)
q.next = pre.next pre.next = q

  至於在表尾插入新元素,從代碼上說用上面這三行代碼也是可以的。只不過在表尾的場合中,pre.next原本就是None。

  ●  刪除元素

  刪除節點分成兩種情況,分別是表頭刪除和表中刪除。

  表頭刪除的話很簡單,只要把表頭指針指向當前表中第二個節點即可。第一個節點由於失去了所有引用,python自動就把它回收處理了。

  當在表中刪除某個節點時,需要知道被刪除節點的前一個節點的信息。而從代碼上來說,只需要:

pre.next = pre.next.next

  再一次,python用它的垃圾回收機制幫我們自動回收了這個被刪除的節點(因為在此之前只有它的前一個節點的next域引用了它,當這個next域變成后一個節點的時候,這個節點就沒有任何引用了)

  ●  定位,掃描和遍歷

  因為單鏈表的話只有一個方向的鏈接屬性,開始情況下只掌握表頭指針。想要深入表中,只有從表頭節點開始一個個找下去,逐步進行。這種過程被稱之為掃描。掃描的基本模式用代碼來表示就是:

p = head #p代表了當前掃描的節點指針 while p is not None and 其他一些條件: #對p中的元素數據做出一些處理 p = p.next #將p指向下一節點

  這段代碼中的while循環結束的條件可以由具體場景來定,當如果是還沒有掃描完全部就可以跳出循環的情況那就是一個定位操作了。常見的定位有比如按下標定位:

p = head
while p is not None and i > 0: i -= 1 p = p.next

  另外還可以按元素內容定位,比如我們設計了一個謂語函數pred來判斷元素的值是否是我們需要的:

p = head
while p is not None and not pred(p.elem): p = p.next

  從頭到尾完整的一次掃描就是遍歷了,遍歷過程中往往是需要對整個鏈表中的所有元素內容都做出一定動作的時候:

p = head
while p is not None: print p.elem p = p.next

  ●  求表的長度

  表長度可以在遍歷之前維護一個變量來記錄表長度,遍歷一個節點這個變量+1:

p = head
count = 0 while p is not None: count += 1 p = p.next print count

  因為遍歷是一個O(n)的操作,當鏈表比較長的時候,且要較頻繁地統計鏈表的長度的時候,每次都這么遍歷一下是不明智的。一個解決的辦法是學順序表,在鏈表當中添加一個節點,讓這個節點來維護鏈表長度的信息。(我看不出有什么必要一定要以一個鏈表節點的形式來維護鏈表長度的信息,我認為完全可以在鏈表外部維護一個變量,比如添加一個LNode類的類變量。。)

  ●  基本鏈表操作的復雜性

  總結下鏈表操作的時間復雜度如下:

  創建空表:O(1)

  刪除表:O(1)(在Python的使用層面上來說是這樣,但是實際上python解釋器還在內部進行了內存管理的操作,這部分可能並不是O(1)這么簡單的)

  判斷空表:O(1)

  加入元素:

    表頭加入:O(1)

    表尾或定位加入:O(n)(因為需要先找到插入位置的前一個節點,而即使是通過下標找,因為是鏈表不是順序表所以必將掃描整個表導致時間花費上升)

  刪除元素:

    表頭刪除:O(1)

    表尾或定位刪除:O(n)

  掃描,定位和遍歷因為需要檢查一批的表節點,所以其復雜度必然是O(n)的,也因此其他所有用到掃描,定位,遍歷的操作的復雜度也不會低於O(n)

■  單鏈表類的實現

  剛才基本已經說完鏈表基本操作的所有規程,然后也給出了一個鏈表節點類的定義。結合兩者應該就可以給出一個實現了鏈表的類了。下面是鏈表類LList的代碼:(在這段代碼之前就已經寫了LNode類的定義了)

class LList(): def __init__(self): self._head = None # 雖然上面提到了很多head的變動,但是這些變動應該都在類內進行。不讓類的使用者在類外自由使用表頭指針是有利的。 def is_empty(self): return self._head is None def prepend(self, elem): q = LNode(elem) q.next = self._head self._head = q def pop(self): if self._head is None: raise ValueError("empty link list") e = self._head.elem self._head = self._head.next return e def append(self, elem): if self._head is None: # 空表時下面的循環進都不會進去,所以必須作為特殊情況單獨拿出來考慮 self._head = LNode(elem) return p = self._head while p.next is not None: p = p.next # 不像順序表那么方便,需要從頭開始逐個遍歷到表尾 p.next = LNode(elem) def pop_last(self): if self._head is None: raise ValueError("empty link list") p = self._head while p.next.next is not None: # 這個比較聰明,當p.next.next是None的時候就意味着當前元素是倒數第二個了 p = p.next e = p.next p.next = None return e def printall(self): p = self._head while p is not None: print p.elem, if p.next is not None: print ',', p = p.next def __str__(self): self.printall() return ""

  然后是對這個鏈表類的使用:

mlist = LList()
for i in range(10): mlist.prepend(i) for i in range(11, 20): mlist.append(i) print mlist #結果 #9 , 8 , 7 , 6 , 5 , 4 , 3 , 2 , 1 , 0 , 11 , 12 , 13 , 14 , 15 , 16 , 17 , 18 , 19

■  對上述實現的進一步改進

  ●  增加迭代功能

  除了直接print出這個鏈表中所有內容,更多時候我們期待的是能夠遍歷列表中的內容然后做出一些操作。雖然遍歷鏈表在類里面做了很多次了,但是目前還沒有提供給外部的接口可以來遍歷。所以有必要把這鏈表中的內容們做成可以迭代的形式。說到可迭代的話大概有兩種思路。第一是額外添加一個生成器方法在類中,這樣外部可以調用這個方法來獲得一個鏈表內容的生成器。第二則是在類中實現__iter__和next方法讓它本身變成一個迭代器。

  關於第一種生成器的法子:

def elements(self): p = self._head while p is not None: yield p.elem p = p.next for item in mlist.elements(): print item

  甚至可以更加進階一點,給生成器加個過濾函數:

def filter(self,pred): p = self._head while p is not None: if pred(p.elem): yield p.elem p = p.next #此時生成器生產數據之前還會先通過pred這個過濾函數判斷一下是否要生成

  關於第二種把類本身改造成迭代器的代碼:(這段是我自己寫的不是書上的,質量難免有下降。。)

    def __iter__(self): return self def next(self): if self._head is None: raise StopIteration if not hasattr(self,"count"): self.count = 0 return self._head.elem else: self.count += 1 p = self._head i = 0 while p is not None and i < self.count: i += 1 p = p.next if p is None: raise StopIteration else: return p.elem

  這樣在LList的實例mlist就可以直接用for item in mlist這樣的形式來迭代了。

■  更多其他形式的鏈表

  上面講的主要都是單鏈表這一種鏈表,除此之外還有一些單鏈表的變形和雙鏈表存在。

  所謂單鏈表的變形就是指這種數據結構本質上還是單鏈表,但是通過一些結構上的小改進讓這個單鏈表能更好地適應實際需要。

  ●  帶有尾節點引用的單鏈表

  以上提到的單鏈表,在尾部添加節點的過程中並不是很好。每次添加都要從頭開始逐個向后推知道最后一個節點。為了提高后端插入的效率,可以在表頭指針上再加一個引用至表尾節點的域,通過維護這個域,在想要進行表尾插入(或任何表尾操作)的時候,就可以只用O(1)的復雜度了。

 

  如何實現這個帶尾節點引用的鏈表?因為除了尾節點的操作之外,其他操作基本都沒有變化,所以應該考慮通過繼承和擴充之前定義的鏈表類來實現。基於這種想法,我們給出新類LList1。在其初始化方法中除了需要有一個self._head變量用來當做表頭指針外還需要再加上一個self._rear用來表示尾節點指針。此外應該仔細考慮上面的LList類中已經給出的所有操作,對於有些操作比如判空,定位插入刪除等可以不用管self._rear,而對於尾部插入刪除,表頭插入等等就需要考慮新加入self._rear之后這些操作應該做哪些變化使得我們可以順利地維護好self._rear這個變量。

class LList1(LList):
    def __init__(self):
        LList.__init__(self)
        self._rear = None

    def prepend(self,elem):
        self._head = LNode(elem,self._head) #其實不用像LList中的prepend這么復雜,因為LNode類的初始化方法中已經給出了指定next的接口
        if self._rear is None:  #判斷是否空表,如果是_rear還是None表明prepend前還是空表,就把_rear設置成等同於_head(表頭即表尾)
            self._rear = self._head

    def append(self,elem):
        if self.is_empty(): #這里換了一種判空方式
            self._rear = LNode(elem)
            self._head = self._rear
        else:
            self._rear.next = LNode(elem)
            self._rear = self._rear.next

    def pop_last(self):
        if self.is_empty():
            raise ValueError("empty link list")
        p = self._head
        if p.next is None:
            e = p.elem
            self._head = None
            self._rear = None
            return e
        while p.next.next is not None:
            p = p.next
        e = p.next.elem
        p.next = None
        self._rear = p
        return e

  結合上面的例子,簡單地討論一下類設計中的一個重要原則,就是要做到邏輯上的自洽。比如在加入尾節點指針之后,我們可能要改變對於空表的定義。原先空表是指self._head是None的表,但是現在是維持原定義不變還是要求_head和_rear同時為None才能算是空表呢。這一點上面我自己寫的代碼就不是很好。在很多地方很明顯的表明我想的其實是想讓_head和_rear同時為None才算空表,但是在判空的時候又用了is_empty()方法,這個方法只判斷了_head的情況。所以最好的還是在LList1這個類中再重寫一下is_empty方法來統一類的設計。

 

  ●  循環單鏈表

  單鏈表的尾節點的next域通常是None,如果把它改成鏈表中某個節點的指針那么這個鏈表就變成了一個循環鏈表,如果這個節點是表頭節點的話那么整個鏈表就變成了環狀鏈表。

  環狀鏈表的表頭和表尾相互銜接在一起,這就帶來一個問題,我該用哪個指針來指代這個表。稍加思考可以看出,用表尾節點的指針來作為指代整個鏈表的對象比較合理。用表尾指針的話可以同時把表頭操作和表尾操作都變成O(1)操作。當然在循環鏈表中的表頭和表尾大多意義上只是個概念問題,從表的內部形態上來看沒有明顯的表頭表尾。任何一個節點都可以做表頭也可以做表尾。

  實現循環鏈表需要注意幾點:

  1. 和帶尾節點指針的鏈表一樣,在初始化時應該考慮增加一個_rear變量,邏輯上始終指向尾節點,從而為其他操作提供一個參考
  2. 進行循環遍歷表時應該要改變循環判斷條件,原先是判斷一個節點的鏈接域是不是None,而現在應該判斷某個節點是不是尾節點

  3. prepend和append方法操作的其實是同一個對象了。所以在定義時可以定義一個方法然后在相對應的另一個中直接調用之前的那個方法。

  具體實現就不多說了,書上的話沒有繼承LList類而是重新開了一個LClist類

 

  ●  雙鏈表

  單鏈表只能做一個方向的搜索和遍歷,即使是增加了尾節點引用,也只是支持了O(1)的尾部操作。如果希望兩端插入和刪除操作都能高效完成,就需要改變節點的基本結構。比如改造鏈表為雙鏈表,每個節點除了next鏈接域之外還應該有一個prev鏈接域指向本節點的前一個節點。下面簡單說明一下雙鏈表的實現。

  首先是雙鏈表節點的實現:繼承單鏈表節點的LNode類

class DLNode(LNode):
  def __init__(self,elem,prev=None,next_=None):
    LNode.__init__(self,elem,next_)
    self.prev = prev

 

  關於雙鏈表類,可以選擇繼承一下單鏈表類,其中的判空操作,檢索,print等等操作是可以保持不變的,因為這些操作即使是在雙鏈表中也只用了next域控制。對於要變動節點的操作,應該都是要有鎖改變的。另外,為了直接改進雙鏈表為帶有尾節點引用的雙鏈表,我們讓它直接繼承LList1類。

class DLList(LList1):
    def __init__(self):
        LList1.__init__(self)

    def prepend(self,elem):
        p = DLNode(elem,None,self._head)
        if self._head is None:  #如果是空表就設置一下尾節點引用
            self._rear = p
        else:
            p.next.prev = p #如果不是空表,使得原表頭幾點的prev指向新建出來的節點
        self._head = p

    def append(self,elem):
        p = DLNode(elem,self._rear,None)
        if self._rear is None:
            self._head = p
        else:
            p.prev.next = p
        self._rear = p

    def pop(self):
        if self._head is None:
            raise ValueError("empty double link list")
        e = self._head.elem
        self._head = self._head.next
        if self._head is not None:
            self._head.prev = None
        return e

■  一些更高端的鏈表操作

  以上是一些鏈表的變形。之前說到的鏈表操作大多都是基於鏈表結構的單參數操作,此外鏈表還有一些更加復雜一些的操作值得一提。

  ●  reverse

  鏈表進行反轉並不是節點進行反轉,因為節點的具體地址和形式對於用戶而言是不知道的,所以我們只要把鏈表中的元素內容進行反轉就可以讓使用者以為鏈表整個已經反轉過來了。一個進行反轉操作的算法是在表頭和表尾設置兩個掃描指針,分別從前到后從后到前掃描鏈表,每掃描一對元素互換他們的位置直到兩個指針相遇。但是這種算法要求鏈表可以進行雙向掃描,雙鏈表的話ともかく,對於單鏈表行不通。即使可以做到效率也是很差。

  對於最簡單的單鏈表,一個事實就是在表頭部分進行操作是最高效的。表頭操作時,不論是刪除還是插入都是O(1)。基於這一點,為了改進單鏈表的反轉算法,我們可以考慮逐個把一個表的節點pop出來然后prepend到一個新的表中去,因為插入新表也是從表頭開始,所有得到的新表就變成了原來舊表的反轉了。這個方法看起來似乎在空間上消耗有點大其實不然,因為鏈表不是順序表,每個節點被刪除后空間就被釋放,而新建一個節點所占用的空間也僅僅只有這個節點的大小。由於舊表中pop和新表中prepend兩個操作都是O(1)的,整體操作就變成了O(n)操作。比如像LList類中添加一個反轉本身的方法,代碼如下:

    def reverse(self):
        p = None
        while self._head is not None:
            q = self._head
            self._head = q.next #摘下原來的表頭節點
            q.next = p
            p = q   #將摘下的節點加入到p引用的新表中,並且把p移到新表的表頭
        self._head = p  #最終改變本實例的表頭引用到新表

  ●  sort

  排序操作比反轉更加復雜些,書上為了說明鏈表的排序采用的是較為簡單的排序算法,插入排序。插入排序的要義是三點:

  1. 在操作過程中維護一個排列好的序列,初始情況下這個序列只有一個元素。這個序列在整個排序過程中一直保持正確的排序

  2. 每次從尚未處理的亂序序列中取出一個元素,將其插入有序序列中,保證有序序列仍然有序

  3. 所有元素都插入有序序列后排序結束

  再具體一點,一般來說可以讓有序序列和無序序列共同占有整個需要排序的序列的空間,初始有序序列元素為首元素。此外取出亂序序列中的元素后從哪個方向插入也是一個問題。一般順序表可以考慮從后往前掃描,只要是大於當前元素的話就把有序序列中的元素后移。但是由於單鏈表只能從前往后掃描,所以這步操作要改成從前往后,只要是小於當前元素就保持不動,直到找到該插入的位置之后使得該位置之后的所有有序序列中的元素向后推移一格。所以我們可以得到的方法是這樣的:

    def sort1(self):
        if self._head is None:
            return
        crt = self._head.next   #crt是指向亂序序列第一個節點的指針,初始情況下指向第二個節點
        while crt is not None:
            x = crt.elem
            p = self._head
            while p is not crt and p.elem <= x: #從頭開始掃描有序序列並判斷當前節點元素內容是不是小於x,小於就不做處理
                p = p.next
            while p is not crt:
                y = p.elem
                p.elem = x
                x = y
                p = p.next
            p.elem = x  #循環跳出時有序序列的最大值還沒有被賦給crt的位置,還要再操作一下
            crt = crt.next

  以上這種排序實現是基於元素值的交換。而在鏈表的場合中,其實還可以進行鏈接關系的轉換。下面是轉換鏈接關系的代碼,我沒有細看具體說明就不多講了。

    def sort(self):
        p = self._head
        if p is None or p.next is None:
            return
        rem = p.next
        p.next = None
        while rem is not None:
            p = self._head
            q = None
            while p is not None and p.elem <= rem.elem:
                q = p
                p = p.next
            if q is None:
                self._head = rem
            else:
                q.next = rem
            q = rem
            rem = rem.next
            q.next = p  #最后三句賦值語句的順序不能出錯,否則將導致丟失正在處理的節點

 

■  鏈表總結

  鏈表相比於順序表有自身的缺點和優點。分析如下:

  優點:

    表結構是通過一些鏈接形成的,所以表結構很容易修改和調整

    修改表結構和數據排列方式不必修改或移動每一個數據本身,而可以通過修改節點之間的鏈接關系來實現

    整個表由一些小儲存塊組成,在空間上來說很優化,比較容易安排

  缺點:

    定位訪問需要線性的時間,相比於順序表時間復雜度高

    為了解決很多線性時間的問題可以對鏈表做出雙鏈表、尾指針優化,但是這增大了結構復雜度和空間上的消耗

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM