這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。資料結構和演算法是電腦科學的重要組成部分。雖然有時候它們看起來很嚇人,但大多數演算法都有簡單的解釋。同樣,當問題能用演算法來解釋清楚的時候,演算法的學習和應用也會很有趣。這篇文章的目標讀者是那些對鏈表感到不舒服的人,或者那些想要看到並學習如何用 Golang 構建一個鏈表的人。我們將看到如何通過一個(稍微)實際的例子來實現它們,而不是簡單的理論和程式碼範例。在此之前,讓我們來談談一些理論。## 鏈錶鏈表是比較簡單的資料結構之一。維基百科關於連結清單的文章指出:> 在電腦科學中,鏈表是資料元素的線性集合,其中線性順序不是由它們在記憶體中的物理位置所給出的。相反,每個元素指向下一個元素。它是由一組節點群組成的資料結構,它們共同代表一個序列。在最簡單的形式下,每個節點都由資料和一個指向下個節點的引用(換句話說,一個連結)組成。儘管這些看起來可能太過或令人困惑,讓我們把它分解一下。 線性資料結構,是一種其元素組成某種序列的資料結構。 就這麼簡單。為什麼記憶中的物理位置不重要呢? 當你有數組時,數組的記憶體數量是固定的,就是說,如果你有一個 5 項的數組,語言只會在記憶體中只抓取 5 個記憶體位址,一個接一個。 因為這些地址建立一個序列,數組知道它的值將儲存在什麼記憶體範圍內,因此這些值的物理位置將建立一個序列。有了鏈表,就有點不同了。在定義中,您將注意到“每個元素指向下一個”,使用“資料和引用(換句話說,就是連結)指向下一個節點”。這意味著連結清單的每個節點儲存兩個東西:一個值和一個指向列表中的下一個節點的引用。就這麼簡單。## 資料流人類所感知到的一切都是某種資訊或資料,我們的感官和頭腦知道如何處理並將其轉化為有用的資訊。 不管我們是看,聞,還是摸,都是我們在處理資料,並從資料中找到意義。當我們瀏覽我們的社交媒體網路時,我們總是求助於資料,按時間順序排列,有看不完的資訊。那麼,我們如何使用鏈表來建模這樣的新聞流呢? 讓我們先快速探索一下簡單的 Tweet,例如:![](https://raw.githubusercontent.com/studygolang/gctt-images/master/data-structures/tweet_jack.png)執行個體我們社交網路的目的,我們從 Twitter 獲得靈感並建立一個 `Post` 類型,它有一個 `body`,一個 `publishDate` 和一個 `next` 文章的連結:```gotype Post struct {body stringpublishDate int64 // Unix timestampnext *Post // link to the next Post}```接下來,我們如何建模一個文章的提要?如果我們知道資料流是由一個連著另一個的文章組成,那麼我們可以試著建立一個這樣的類型:```gotype Feed struct {length int // we'll use it laterstart *Post}````Feed` 結構將有一個開始(或 `start`),它指向提要中的第一個 `Post` 和一個 `length` 屬性,該屬性將在任意時刻儲存 `Feed` 的大小。因此,假設我們想建立一個有兩個文章的 `Feed`,第一步是在 `Feed` 類型上建立一個 `Append` 函數:```gofunc (f *Feed) Append(newPost *Post) {if f.length == 0 {f.start = newPost} else {currentPost := f.startfor currentPost.next != nil {currentPost = currentPost.next}currentPost.next = newPost}f.length++}```然後我們可以兩次調用它:```gofunc main() {f := &Feed{}p1 := Post{body: "Lorem ipsum",}f.Append(&p1)fmt.Printf("Length: %v\n", f.length)fmt.Printf("First: %v\n", f.start)p2 := Post{body: "Dolor sit amet",}f.Append(&p2)fmt.Printf("Length: %v\n", f.length)fmt.Printf("First: %v\n", f.start)fmt.Printf("Second: %v\n", f.start.next)}```那麼這段代碼是用來幹嘛的呢?首先,`main` 函數 - 建立一個指向 `Feed` 結構的指標,兩個 `Post` 結構包含一些虛構的內容,它兩次調用 `Feed` 上的 `Append` 函數,使得它的長度為 2 。我們檢查 `Feed` 的兩個值,它訪問 `Feed` (實際上是 `Post` )的 `start` 和 `start` 後的 `next` 項,這是第二個 `Post` 。當我們運行程式時,輸出將會是:```Length: 1First: &{Lorem ipsum 1257894000 <nil>}Length: 2First: &{Lorem ipsum 1257894000 0x10444280}Second: &{Dolor sit amet 1257894000 <nil>}```可以看出,當我們在 `Feed` 添加第一個 `Post` 後,它的長度為 `1` 並且第一個 `Post` 擁有一個 `body` 和一個 `publishDate` (作為 Unix 時間戳記),與此同時,它的 `next` 值為 `nil` 。 然後,我們將第二個 `Post` 添加到 `Feed` 中,當我們查看兩個 `Posts` 時,我們會看到第一個 `Post` 的內容與之前的內容相同,但它的指標指向列表中的下一個 `Post` 。 第二個 `Post` 也有一個 `body` 和一個 `publishDate` ,但是沒有指向列表中的下一個 `Post` 的指標。 此外,當我們添加更多的 `Posts` 時,`Feed` 的長度也會增加。現在讓我們回過頭來看 `Append` 函數並解構它,這樣我們就能更好地理解如何使用鏈表。 首先,該函數建立一個指向 `Post` 值的指標,將 `body` 參數作為 `Post`的 `body`,並將 `publishDate` 設定為目前時間的 Unix 時間戳記表示。然後,我們檢查 `Feed` 的`length`是否為 `0` — 這意味著它沒有 `Post` 。第一個被添加的 `Post` 會被設為起始 `Post`,為方便起見,我們把它命名為 `start`。但是,如果 `Feed` 的長度大於 0 ,那麼我們的演算法就會發生不同的變化。 它將從 `Feed` 的 `start` 開始,它將遍曆所有的 `Post`,直到找到一個沒有指向 `next` 的指標。 然後,它將把新的 `Post` 附加到列表的最後一個 `Post` 上。## 最佳化 `Append`想象一下,我們有個使用者刷 `Feed`,就像其他社交網路一樣。 由於文章是按時間順序排列的,基於 `publishDate` ,`Feed` 會隨著使用者的滑動而變得越來越多,更多的 `Post` 會被附加到 `Feed` 上。 考慮到這種方法,我們採用了 `Append` 函數,因為 `Feed` 變得越來越長,`Append` 函數將會付出越來越沉重的代價。 為什麼? 因為我們必須遍曆整個 `Feed` ,在末尾添加一個 `Post` 。如果你聽說過 `Big-O` 標記法,這個演算法有一個 `O(n)` 的時間複雜度,這意味著它在添加 `Post` 之前總是遍曆整個 `Feed` 。您可以想象,這可能非常低效,特別是如果“Feed”增長相當長。如何改進“追加”函數,降低其[漸近複雜](https://en.wikipedia.org/wiki/Asymptotic_computational_complexity)性?因為我們的 `Feed` 資料結構只是一個 `Post` 的列表,要遍曆它,我們必須知道列表的開頭(稱為 `start` ),它是 `Post` 類型的指標。 因為在我們的樣本 `Append` 中總是添加一個 `Post` 到 `Feed` 的末尾,如果 `Feed` 不僅知道它的起始 `start` 元素,而且還知道它的 `end` 結束元素,那麼我們可以大大提高演算法的效能。 當然,對於最佳化總是有一個折衷,這裡的權衡是資料結構將消耗更多的記憶體(對於 `Feed` 結構的新屬性)。擴充我們的 `Feed` 資料結構是輕而易舉:```gotype Feed struct {length intstart *Postend *Post}```但是,我們的 `Append` 演算法必須被調整,以適應 `Feed` 的新結構。這是使用 `Post`的 `end` 屬性的 `Append` 的版本:```gofunc (f *Feed) Append(newPost *Post) {if f.length == 0 {f.start = newPostf.end = newPost} else {lastPost := f.endlastPost.next = newPostf.end = newPost}f.length++}```這看起來簡單一點,對吧?讓我給你一些好訊息:1. 現在代碼更簡單、更短了。2. 我們極大地提高了函數的時間複雜度。我們在重新審視一下演算法,它做了兩件事情:如果 `Feed` 為空白,它就會設定一個新的 `Post` 作為 `Feed` 的開頭和結尾,反之它會設定一個新的 `Post` 作為 `end` 項並且依附到鏈表中先前的 `Post` 。很重要的一點是它很簡單,並且演算法複雜度為 `O(1)` ,也稱為常數時間複雜度。這意味著無論 `Feed` 結構的長度如何,`Append` 都將執行相同的操作。很簡單,對吧?但讓我們想象一下,`Feed` 實際上是我們的設定檔中的 `Post`列表。因此,它們是我們的,我們應該能夠刪除它們。我的意思是,什麼樣的社交網路不允許使用者(至少)刪除他們的文章?## 移除一個 `Post`正如我們在前一節中建立的,我們希望我們的 `Feed` 使用者能夠刪除他們的文章。那麼,我們如何建立模型呢?如果我們的 `Feed` 是一個數組,我們就會刪除該條目並對其進行處理,對吧?這事實上就是鏈表閃耀的地方。當數組大小改變時,運行時會捕獲一個新的記憶體塊來儲存數組的項。 由於其設計的鏈表,每個條目都有一個指向列表中的下一個節點的指標,並可以分散到整個記憶體空間中,從空間的角度來看,從列表中添加或者刪除節點是低開銷的。 當一個人想要從一個鏈表中移除一個節點時,只需要串連被刪除節點的鄰居節點。 垃圾收集語言(如 Go 語言)使這個更加容易,因為我們不必擔心釋放被分配的記憶體 —— GC 將啟動並刪除所有未使用的對象。為了讓我們操作方便,讓我們給每個 `Feed` 上的 `Post` 設定一個限制,它將有一個獨特的 `publishDate` 。這意味著發行者可以在他們的 `Feed` 上每秒鐘建立一個 `Post` 。將其付諸實施,我們可以很容易地從 `Feed` 中刪除 `Post`:```gofunc (f *Feed) Remove(publishDate int64) {if f.length == 0 {panic(errors.New("Feed is empty"))}var previousPost *PostcurrentPost := f.startfor currentPost.publishDate != publishDate {if currentPost.next == nil {panic(errors.New("No such Post found."))}previousPost = currentPostcurrentPost = currentPost.next}previousPost.next = currentPost.nextf.length--}````Remove` 函數將把 `Post` 作為一個 `publishDate` 作為一個參數,它將檢測哪些 `Post` 需要刪除(或未連結)。 這個函數很小。如果它檢測到 `Feed` 的 `start` 項將被刪除,它將會重新分配 `Feed` 的 `start`,並在 `Feed` 中添加第二個 `Post` 。 否則,它會跳轉到 `Feed` 中的每個 `Post` ,直到它遇到一個 `Post`, 該 `Post` 有一個匹配的 `publishDate` 作為函數參數。 當它找到一個時,它會把之前的和下一個 `Post` 串連在一起,有效地從 `Feed` 中刪除中間(匹配)一個。有一個邊界情況,我們需要確保我們在 `Remove` 函數中覆蓋 —— 如果 `Feed` 沒有帶有指定的 `publishDate` 的 `Post` ? 為了簡單,函數會檢查 `Feed` 中的下一個 `Post` ,然後再跳到它。如果下一個是 nil 的函數,它告訴我們它找不到一個 `publishDate` 的 `Post` 。## 插入一個 `Post`現在我們有了添加和移除的方法,讓我們來看看一些假設的情形。 假設產生 `Post` 的來源並不是按照時間順序將他們發送到我們的應用程式的。 這就意味著需要基於 `publishDate` 將 `Post` 放到 `Feed` 中合適的位置。比如這樣:```gofunc (f *Feed) Insert(newPost *Post) {if f.length == 0 {f.start = newPost} else {var previousPost *PostcurrentPost := f.startfor currentPost.publishDate < newPost.publishDate {previousPost = currentPostcurrentPost = previousPost.next}previousPost.next = newPostnewPost.next = currentPost}f.length++}```本質上,這是一個非常類似於 `Remove` 函數的演算法,因為儘管它們都做了一件非常不同的事情(在 `Feed` 中添加 v.s. 刪除 `Post`),它們都是基於搜尋演算法的。 這意味著,這兩個函數實際上遍曆整個 `Feed` ,搜尋與 `publishDate` 匹配的 `Post `,並在函數的參數中接收到一個 `Post` 。 唯一的區別是,`Insert` 實際上會在日期匹配的地方放置新的 `Post` ,而 `Remove` 將從 `Feed` 中刪除 `Post` 。此外,這意味著這兩個函數都具有相同的時間複雜度,即 O(n) 。 這意味著在最壞的情況下,函數必須遍曆整個 `Feed` 才能到達需要插入新 `Post` (或刪除)項。## 如果我們使用數組呢? 如果你問自己,讓我先說,你有一個觀點。 的確,我們可以將所有的 `Post` 儲存在一個數組中(或者是一個 Go 語言的 slice ),可以輕鬆地將條目推到它上面,甚至還可以使用 O(1) 複雜性隨機訪問。鑒於數組的性質,它的值必須儲存在記憶體中,所以讀取速度非常快而且開銷很低。 一旦你有了儲存在數組中的東西,就可以用它的 0-based 索引來擷取它。 在插入一個條目時,無論是在中間還是在最後,數組的效率都不如鏈表。 這是因為如果數組沒有為新項保留更多的記憶體,它將不得不保留它並使用它。 但是,如果下一個記憶體位址不是閒置,它將不得不“移動”到一個新的記憶體位址,只有那樣才有空間容納它的所有項(新和舊的)。看看我們到目前為止的所有例子和討論,我們可以為我們建立的每一個演算法建立一個具有時間複雜度的鏈表,並將它們與數組的相同演算法進行比較:| Action | Array | Linked list || ------- |:-----:| -----------:|| Access | O(1) | O(n) || Search | O(n) | O(n) || Prepend | O(1) | O(1) || Append | O(n) | O(1) || Delete | O(n) | O(n) |正如你所看到的,當面對一個特定的問題時,選擇正確的資料結構可以真正地成就或毀掉你所建立的產品。 對於不斷增長的 `Feed`,插入 `Post` 是最重要的,鏈表會做得更好,因為插入非常代價更小。 但是,如果我們的手上有一個不同的問題需要頻繁的刪除或大量的檢索/搜尋,那麼我們就必須為我們正在處理的問題選擇正確的資料結構。你可以看到 `Feed` 的整個實現,並在[這裡](https://play.golang.org/p/fqLPjf_ekD6)體驗它。另外,Go 語言也有自己的鏈表實現,它已經內建了一些不錯的功能。你可以在[這裡](https://golang.org/pkg/container/list/)看到它的文檔。
via: https://ieftimov.com/golang-datastructures-linked-lists
作者:Ilija Eftimov 譯者:SergeyChang 校對:rxcai
本文由 GCTT 原創編譯,Go語言中文網 榮譽推出
本文由 GCTT 原創翻譯,Go語言中文網 首發。也想加入譯者行列,為開源做一些自己的貢獻嗎?歡迎加入 GCTT!
翻譯工作和譯文發表僅用於學習和交流目的,翻譯工作遵照 CC-BY-NC-SA 協議規定,如果我們的工作有侵犯到您的權益,請及時聯絡我們。
歡迎遵照 CC-BY-NC-SA 協議規定 轉載,敬請在本文中標註並保留原文/譯文連結和作者/譯者等資訊。
文章僅代表作者的知識和看法,如有不同觀點,請樓下排隊吐槽
697 次點擊