【譯】2019年JavaScript中的計算機科學:鏈表

早在2009年,我就挑戰本身一年內堅持每週寫一篇博客文章。我曾經讀到過,堅持發表文章是爲博客帶來流量的最好的方法。基於個人全部文章的理念,一週發表一篇文章看起來是一個很實際的目標,而事實上我缺乏了博客文章的52個理念。(譯者注:不太清楚這裏的意思,查閱到 52 Ideas For Blog Posts 這篇文章比較符合語境)我挖掘了一些寫到一半的章節,並最終編撰了 JavaScript 高級程序設計,在其中發掘了不少關於經典計算機科學的材料,包括數據結構與算法。我將這些材料在2009年及2012年加工成爲了幾篇文章,並從其中獲得了許多積極的反饋。javascript

在這些文章發佈的十週年之際,我決定在2019年使用 JavaScript 更新、拓展並從新發表它們。去看看其中哪些內容變了,哪些沒有變也不失爲一種樂趣,但願你們喜歡。java

什麼是鏈表?

鏈表是一種使用線性的方式來存儲不一樣的值的數據結構。鏈表中的每一個值都包含於本身的節點中,且這個節點包含了其指向鏈表中下一個節點的連接數據。這個連接爲指向另外一個節點對象的指針,若是沒有下一個節點,則爲 null。若是鏈表中每一個節點只有一個指向另外一個節點的指針(常見的爲指向下一個節點),那麼這種鏈表稱做爲單鏈表(或就稱爲鏈表);而若是鏈表中每一個節點有兩個指針(常見的爲指向上一個節點和下一個節點),一般咱們稱之爲雙向鏈表。本文中,我將主要探討單鏈表。git

爲何要使用單鏈表?

鏈表最主要的優勢就是它能夠存儲任意數量的值,同時只佔用這些值所需的內存大小。對於內存小的舊電腦來講,充分利用內存空間仍是很重要的。相反,在 C 語言中的內置數組類型要求你指定數組的長度(有多少項),而後根據數組長度來分配內存。預留內存空間意味着這部份內存不能用於運行任何其餘程序,即便這部份內存從未被使用。你能夠很輕鬆在一臺內存小的機器上使用數組來耗盡內存;而鏈表就是用於解決這個問題的。github

雖然最初的目的是爲了更好的內存管理,可是當開發人員沒法預估一個數組最終會包含多少項時,鏈表也就變得更受歡迎。使用鏈表來根據須要添加數據要比精準地猜想數組可能包含的最多數量的項要簡單得多。所以,鏈表在各類編程語言中也做爲一種基礎的內置數據結構。算法

鏈表的設計

鏈表最重要的部分就是它的節點的數據結構。每個節點都必須包含一個指向鏈表中下一個節點的指針和一些數據。以下爲 JavaScript 中簡單的表示:編程

class LinkedListNode {
  constructor (data) {
    this.data = data;
    this.next = null;
  }
}
複製代碼

LinkedListNode 類中,data 屬性包含了此節點應存儲的值,next 屬性爲指向鏈表中下一個節點的指針。next 的初始值應該爲 null,由於你暫時沒法知道此節點在鏈表中的下一個節點是什麼。你能夠像下面的例子同樣使用 LinkedListNode 來建立一個鏈表:數組

// 建立第一個節點
const head = new LinkedListNode(12);

// 添加第二個節點
head.next = new LinkedListNode(99);

// 添加第三個節點
head.next.next = new LinkedListNode(37);
複製代碼

第一個節點咱們通常稱爲 ,所以 head 標識符在此例子中表示鏈表中的第一個節點。建立第二個節點並將 head.next 賦值於第二個節點,這樣鏈表中就有了兩個節點。經過給 head.next.next 賦值一個新節點來建立第三個節點,其中 head.next.next 爲第二個節點中指向下一個節點的指針。第三個節點的 next 指針在鏈表中保持爲 null。下圖展現了最終的數據結構:數據結構

咱們能夠經過鏈表每一個節點中的 next 指針來遍歷全部數據。以下就是一個簡單的遍歷鏈表數據並打印每一個節點的值的例子:編程語言

let current = head;

while (current !== null) {
  console.log(current.data);
  current = current.next;
}
複製代碼

上述代碼使用 current 來做爲鏈表中移動的指針,它的初始值爲鏈表的 headwhile 循環的條件爲當 current 不爲 null。在循環內部,current 對應的節點所存儲的值將會被打印出來,接着 currentnext 指針將往前移動,即 current 將被賦值爲 current.next 所指向的節點。ide

大部分的鏈表操做都是使用這種遍歷算法或其餘相似的算法,所以瞭解這種算法對於理解鏈表通常來講很是重要。

LinkedList

若是你曾使用 C 寫過鏈表,你可能會在這一步就停下來,認爲本身的任務已經完成了(儘管你將會用 struct 而不是類來表示每一個節點)。然而,在面向對象的語言中,譬如 JavaScript,更多的是建立一個類來封裝這個功能。以下就是一個簡單的例子:

const head = Symbol("head");

class LinkedList {
  constructor() {
    this[head] = null;
  }
}
複製代碼

LinkedList 類表示爲包含了鏈表中的節點之間交互的方法的鏈表類。其惟一的屬性 head 爲使用 symbol 定義的變量,並指向鏈表中的第一個節點。使用 symbol 數據類型而不用 string 來定義此屬性就是爲了明確地代表並不想要此屬性在此類外進行修改。

給鏈表添加新數據

在鏈表中添加新數據須要遍歷鏈表找到正確的位置,而後建立新的節點,最後插到正確的位置。這裏有個特殊的例子就是,當鏈表爲空時,也就是你只須要新建一個節點而後賦予 head 便可:

const head = Symbol("head")

class LinkedList {
  constructor() {
    this[head] = null;
  }

  add(data) {

    // 新建一個節點
    const newNode = new LinkedListNode(data);

    // 特殊狀況:鏈表中無節點
    if (this[head] === null) {

      // 只須要將新建的節點賦予 `head`
      this[head] = newNode;
    } else {

      // 從第一個節點開始尋找
      let current = this[head];

      // 跟隨 `next` 連接直到到鏈表的尾部
      while (current.next !== null) {
        current = current.next;
      }

      // 將新建的節點賦予 `next` 指針
      current.next = newNode;
    }
  }
}
複製代碼

add() 方法接收單個參數,能夠爲任何數據,而後將它添加到鏈表的尾部。若是鏈表爲空(this[head]null),你只須要將新建的節點 newNode 賦予給 this[head] 便可。若是鏈表不爲空,那麼你須要遍歷鏈表中已有的節點,找打最後一個節點。這個遍歷的過程開始於 this[head] 並跟隨每一個節點的 next 連接,直到找到最後一個節點。最後一個節點的 next 指針指向 null,所以在這個節點中止遍歷很是重要,而不是當 currentnull 時中止(如上一節所述)。而後你能夠將新建的節點賦予其 next 屬性,以此來將數據添加到鏈表中。

注意

傳統的算法使用了兩個指針 currentpreviouscurrent 指向當前節點,previous 指向 current 的上一個節點。當 currentnull 時,意味着 previous 指向了鏈表中的最後一項。我認爲當你能夠檢查 current.next 的值並在其爲 null 時退出循環時還要使用這種方法並不合乎邏輯。

add() 方法的算法時間複雜度爲 O(n),由於你必須遍歷整個鏈表來找到正確的位置來插入新的節點。你能夠經過從鏈表尾部開始遍歷(除去頭部以外)來將時間複雜度下降爲 O(1) ,這樣你能夠當即將新節點插入到正確的位置。

檢索鏈表中的數據

鏈表不容許隨機訪問其數據,但你仍然能夠經過遍歷鏈表並返回數據來檢索任何給定位置的數據。爲此,你須要添加一個 get() 方法,它接受一個從零開始的索引做爲參數來檢索數據,以下所示:

class LinkedList {

  // 爲了簡潔,先隱藏以前添加的方法

  get(index) {

    // 確保 `index` 爲非負整數
    if (index > -1) {

      // 用於遍歷的指針
      let current = this[head];

      // 用於跟蹤鏈表中的位置
      let i = 0;

      // 遍歷鏈表直到找到 `index` 索引或到達鏈表尾部
      while ((current !== null) && (i < index)) {
        current = current.next;
        i++;
      }

      // 若是 `current` 不爲 `null`,返回對應的數據
      return current !== null ? current.data : undefined;
    } else {
      return undefined;
    }
  }

}
複製代碼

get() 方法首先先確保 index 爲非負數,不然返回 undefined。變量 i 是用於跟蹤遍歷鏈表的深度;其中的循環就像你以前看到的基本遍歷同樣,只不過是多了一個條件:當 i 等於 index 時應該退出循環;這也就意味着要考慮以下兩種退出循環的狀況:

  1. currentnull,意味着鏈表實際長度小於 index
  2. i 等於 index,意味着 current 就是 index 索引所在的節點

若是 currentnull,那麼就返回 undefined;反之返回 current.data。這個檢查確保了 get() 將不會在鏈表中找不到索引時拋出錯誤(儘管或許你會用拋出錯誤而不是返回 undefined)。

get() 方法的算法時間複雜度範圍從刪除第一個節點的 O(1)(不須要遍歷)到刪除最後一個節點的 O(n)(須要遍歷整個鏈表)。咱們很難再去減少其算法時間複雜度,由於總須要檢索來檢查正確的返回值。

從鏈表中移除數據

從鏈表中移除數據會有點棘手,由於你要確保在移除節點後,全部節點的 next 指針都保持有效。譬如,若是你想要在一個有三個節點的鏈表中移除第二個節點,你須要確保第一個節點的 next 指針指向第三個節點,而再也不是第二個節點。以這種方式來跳過第二個節點能夠頗有效地從鏈表中移除第二個節點。

移除的操做其實就兩個步驟:

  1. 找到特定的索引(與 get() 同樣的算法)
  2. 移除特定索引的節點

尋找這個特定的索引就和 get() 方法同樣,但在循環內部你還須要追蹤 current 的上一個節點,由於你須要修改上一個節點的 next 指針。

同時你還須要考慮如下四種特殊狀況:

  1. 鏈表爲空(不須要遍歷)
  2. 索引小於零
  3. 索引大於鏈表的節點數
  4. 索引爲零(移除鏈表頭部 head

在前三個特殊狀況中,是沒法完成移除操做,所以拋出錯誤也就很合理了。第四個特殊狀況要求從新對 this[head] 進行賦值。以下是 remove() 方法的相關實現:

class LinkedList {

  // 爲了簡潔,先隱藏以前添加的方法

  remove(index) {

    // 特殊狀況:空鏈表或非法 `index`
    if ((this[head] === null) || (index < 0)) {
      throw new RangeError(`index ${index} does not exist in the list.`)
    }

    // 特殊狀況:移除第一個節點
    if (index === 0) {

      // 臨時存儲節點數據
      const data = this[head].data;

      // 用下一個節點(第二個節點)來替換鏈表的 head
      this[head] = this[head].next

      // 返回鏈表原來的 head 的數據
      return data;
    }

    // 用於遍歷鏈表的指針
    let current = this[head];

    // 追蹤循環中 current 的上一個節點
    let previous = null;

    // 用於追蹤鏈表中的位置
    let i = 0;

    // 與 `get()` 相同的循環算法
    while ((current !== null) && (i < index)) {

      // 保存 current 的值
      previous = current;

      // 遍歷到下一個節點
      current = current.next;

      // 增長次數
      i++;
    }

    // 若是找到要移除的節點則移除它
    if (current !== null) {

      // 經過跳過此節點(即再也不連接此節點)來移除它
      previous.next = current.next;

      // 返回剛纔被移除的節點的值
      return current.data;
    }

    // 若是找不到要移除的節點則拋出錯誤
    throw new RangeError(`Index ${index} does not exist in the list.`);
  }
}
複製代碼

remove() 方法首先先檢查前兩個特殊狀況,空鏈表 this[head]nullindex 小於零;兩種狀況都將拋出錯誤。

下一個特殊狀況就是當 index0 時,意味着你須要刪除鏈表的頭部。新的鏈表的頭部將會成爲原來鏈表中的第二個節點,所以你能夠將 this[head].next 賦值於 this[head]。不用擔憂若是鏈表中只有一個節點,由於 this[head] 最終會等於 null,也就意味着移除節點後鏈表變成了空鏈表。惟一的問題就是,咱們須要將鏈表原來的頭部的數據存儲在一個局部變量 data 中,以便返回它。

在處理了四個特殊狀況的前三個以後,如今你能夠繼續進行相似於 get() 方法中的遍歷。就像以前提到的,remove() 方法中的循環有一點不同,那就是它使用了 previous 來跟蹤 current 的上一個節點,由於 previous 是可否正確移除節點的關鍵信息。和 get() 方法相似,當退出循環時,current 可能爲 null,也就表示未找到 index。若是發生了這種狀況,那就拋出錯誤;反之將 current.next 賦值於 previous.next,這樣就頗有效地將 current 從鏈表中移除了。最後一步就是將 current 中所儲存的值返回便可。

remove() 方法的算法時間複雜度與 get() 方法同樣,當移除第一個節點時,時間複雜度爲 O(1);當一處最後一個節點時,時間複雜度爲 O(n)

使鏈表可迭代

爲了能使用 JavaScript 中的 for-of 循環和數組解構,數據的集合必須使可迭代的。在默認狀況下,JavaScript 中的內置的集合(如 Array 和 Set)都是可迭代的,你能夠經過在類上指定 Symbol.iterator 生成器(genenrator)方法來使你本身定義的類可迭代。我更傾向於先實現一個 values() 生成器方法(用來匹配內置集合類中找到的方法),而後直接使用 Symbol.iterator 來調用 values()

values() 方法只須要對鏈表進行基本的遍歷並 yield 每一個節點的值便可:

class LinkedList {

  // 爲了簡潔,先隱藏以前添加的方法

  *values() {

    let current = this[head];

    while (current !== null) {
      yield current.data;
      current = current.next;
    }
  }

  [Symbol.iterator]() {
    return this.values();
  }

}
複製代碼

values() 方法經過前置的 * 星號來標明它爲一個生成器方法。該方法用於遍歷鏈表,並使用 yield 來返回其遇到的每一個節點的值。(注意 Symbol.iterator 方法並非生成器,由於它是從 values() 生成器方法中返回一個迭代器)

使用類

實現了上述的方法後,你就能夠以下例子同樣使用這個鏈表類:

const list = new LinkedList();
list.add("red");
list.add("orange");
list.add("yellow");

// 獲取鏈表的第二個節點
console.log(list.get(1));         // orange

// 打印全部節點
for (const color of list) {
  console.log(color);
}

// 移除鏈表中第二個節點
console.log(list.remove(1));      // orange

// 獲取鏈表中新的第二個節點
console.log(list.get(1));         // yellow

// 將鏈表轉換爲數組
const array1 = [...list.values()];
const array2 = [...list];
複製代碼

這個鏈表的基本實現還能夠添加 size 屬性來計算鏈表中的節點個數,或者其餘相似 indexOf() 的方法。完整的代碼在個人 GitHub 的 Computer Science in JavaScript 中。

總結

鏈表可能並非你天天都會用到的東西,但它在計算機科學技術中式很是基礎的一種數據結構。其利用節點來指向彼此節點的概念在不少其餘的數據結構中及其餘的高級編程語言中都有所體現。可以很好地理解鏈表的工做原理對於如何全面理解怎麼建立及使用其餘數據結構很是重要。

而對於 JavaScript 來講,你應該儘可能使用內置的集合類,譬如 Array,而不是本身寫一個。由於內置的集合類已經針對生產使用進行了優化,並在不一樣的執行環境下有良好的支持。

相關文章
相關標籤/搜索