CS61B資料結構及演算法筆記:(二)單向串列及雙向串列

Evan
8 min readMay 22, 2021

--

在上一篇文章提到,可以從單獨一個節點,藉由指定下一個節點,形成一個 linked list;除了上述,另一個可以建立 linked list 的方法為直接建立一個 linked list 的類別(class):單向串列(SLList)。

單向串列(Single linked list: SLList)

SLList 除了包含一串 linked list 所需要的 head 以外,還包含了可以對 head 進行修改的方法(method),例如:從前面增加節點、從後面增加節點、返回這個 linked list 的節點數量等等功能,以增加程式碼的可讀性及方便性。

舉例來說:同樣是建立一個 linked list,如果使用上一篇文章(一)的方法,雖然可以藉由一行一行的程式碼修改 head 達到我們要的目的,不過程式碼會顯得很冗長,當程式愈來愈複雜時也不易使用;若藉由實例一個 SLList,先將想要的邏輯寫成方法,視需求直接呼叫相對應的方法即可達到同樣的目的,如圖一所示。

圖一

Java 所有的程式碼都必須寫在類別內,而要實例一個類別必須使用 new 這個預留字串將該類別實例(instantiate)成物件(object)才能使用,而一個類別要可以被實例需要有一個構築函數(constructor),用來初始化一個物件。

SLList 的範例程式碼如下說明:

  1. Private
    被宣告成 private 的成員(member),成員包含變數和方法,代表只有在同一個 .java 檔案內的成員才可以使用,目的為避免使用者使用不該被使用的成員,造成預期之外的錯誤。例如:SLList 內的變數 size,目的為記錄整個 SLList 所包含的節點數量,如果使用者意外地修改了這個變數,這個變數就無法達到我們原本的預期。
  2. Caching
    其中一個可以記錄 SLList 內節點數量的方法,即建立一個方法 size(),利用迴圈和變數來計算節點數量。不過這個方法的缺點為當每次呼叫這個方法時,會從頭到尾計算一次,當節點愈來愈多時,計算時間也會隨之增加。為了解決上述缺點,只要在類別內新增一個變數 size,當呼叫會改變節點數量的方法時,時同時調整該變數。因此在每次呼叫 size() 時,不管節點數量多少,都可以直接從該變數直接得到正確的節點數量。從上一篇文章可知,一個整數變數需要 32 位元的空間來存放,雖然這個方式在運算速度上比較快,不過相對的會使用到額外的記憶體空間,在後續文章將會補充更多例子來說明運算效能和空間之間的權衡。
  3. Nested Classes
    因為 SLList 需要使用另一個類別 Node,除了在另一個 .java 檔案寫入另一個類別 Node 以外,Java 提供一個可以直接在 SLList 內新增另一個類別的方式,稱作巢狀類別(Nested Classes)。這雖然不會對效能產生影響,不過透過看出巢狀類別的關係,可以增加程式碼的可讀性及維護性。在範例中可以發現 Node 這個類別並不會使用到 SSlist 的成員,因此可以宣告該類別為 static,進而節省記憶體的空間。

SLList 的好處除了方便使用,也易於建立一個空串列(empty list)。Java 允許一個類別裡面有多個構築函數,藉由輸入來區分要如何實例一個物件,舉例來說:

SLList l1 = new SLList(5);// 建立一個 head 為 Node(5)的串列

SLList l2 = new SLList();// 建立一個空串列

不過可以發現,如果要對一個空串列呼叫 addLast 這個方法時,程式會出現錯誤,原因是依照目前的方法,當程式要判斷 p.next != null 時,會因為 p 是空值,而空值並沒有 next 屬性而彈出錯誤訊息。改善的方法有兩種:

  1. 在 addLast 的方法中寫入特例的判斷,當對空串列呼叫方法時,直接令串列的 head 為一個新的節點。這個方法雖然可行,但並不常用,原因是寫程式時會盡量希望程式碼不要有額外的特例邏輯,當程式碼愈來愈複雜時,如果要考慮各種特例而寫入相因應的程式碼,程式碼會變得愈來愈難讀,也不好維護。
  2. 將 SLList 內代表第一個節點的 head 替換成另一個節點 sentinel(等同於另一個常被使用的名詞 dummy),如圖二所示。sentinel 的意義為 next 永遠指向第一個節點的節點,也就是之前的 head,因此 sentinel 本身的節點並不重要,可以是任意節點。有了 sentinel 後,addLast 就不需要為了空串列而寫入特例的程式碼。
圖二

然而,即使是有 sentinel 的幫助簡化了程式碼,不過目前在使用 addLast 方法的效率還是不夠好,原因是當從後面新增一個節點時,需要經過前面所有的節點找到最後一個節點。同樣地,可以藉由在 SLList 內新增另一個指標 last 永遠指向最後一個節點,當要加入最後一個節點時,直接令最後一個節點的下一個節點為新節點,再將 last 繼續指向最後一個節點即可,如圖三所示。

圖三

目前的方法已經可以有效地建立一個串列、增加串列節點,以及記錄串列長度,不過當我們希望這個類別能使用更多種方法時,像是刪除最後一個節點的方法 removeLast,並無法有效地刪除最後一個節點。原因是,要刪除最後一個節點需要找到倒數第二個節點,令它的下一個節點指向 null,再由 garbage colletor 自動刪除最後一個節點,而目前 last 永遠指向最後一個節點,即使是令 last = null,還是需要重新找到新的 last 位置。

雙向串列(Doubly linked list: DLList)

常用的方法為稍微修改節點這個類別,讓每一個節點不只可以指向下一個節點,還可以指向上一個節點,如圖四所示。而只要透過 last 的上一個節點就可以快速找到倒數第二個節點,直接刪除最後一個節點,這種類別又稱雙向串列(DLList)。相對地,雖然能有效達到刪除最後一個節點的功能,卻也付出每一個節點都要新增一個額外的指標空間的代價。

圖四

到目前為止,DLList 已經可以滿足我們的需求,不過當在寫程式碼時,因為 last 有時會指向 sentinel 有時會指向最後一個節點,還是會碰到特例的問題。舉例來說,當對一個空串列呼叫 addFirst 這個方法時,就需要將 last 從指向 sentinel 改為指向最後一個節點,也就是第一個節點。而對一個非空串列呼叫 addFirst 這個方法時,事實上並不需要更動 last,因為向前增加節點並不會影響最後一個節點。同樣地,為了避免這種特例的問題,通常會做一點更動,直觀上會將 last 改為永遠指向最後一個節點 sentL 即可避免特例的問題,如圖五所示。

圖五

事實上,在實作時常用另一種神奇的方式,令 sentinel 的上一個節點指向最後一個節點,而最後一個節點的下一個節點指向 sentinel,如圖六所示。但不管是用循環 sentinel,或者是兩個 sentinel 都可以完成 DLList。

通行(Generic type)

我們通常不會希望節點裡面只能存放整數,Java提供通行的表示式<>,只要在類別之後加上<任意字串>,代表這個類別能接受各種型態的資料如 Integer、Double、String等等,並且只要在建立物件時指定該類別內的通行為何種型態,Java就會將類別內的<任意字串>替換成指定的型態,例如:

LinkedListDeque<String> example= new LinkedListDeque<>(“apple”);

代表將 LinkedListDeque 這個類別內的 <任意字串>替換成 <String>型態,一個通行且運送循環 sentinel 的 DLList 範例如下:

更詳細的通行介紹會後續補充。

雖然目前不管是 SLList 或是 DLList 看起來沒甚麼特別的功能,不過當介紹愈來愈多種資料結構時,就可以發現每種資料結構都有其優缺點,不同的資料結構在不同情境下搜尋的效率也差非常多。下一篇文章將會介紹 Python 中常用的 list 實際上是如何運作。

--

--