1. 程式人生 > >資料結構和演算法躬行記(1)——連結串列

資料結構和演算法躬行記(1)——連結串列

  連結串列(Linked List)是不同於陣列的另一種資料結構,它的儲存單元(即結點或元素)除了包含任意型別的資料之外,還需要包含指向另一個結點的引用,後文會用術語連結表示對結點的引用。

  下面會列出連結串列與陣列的具體不同:

  (1)陣列需要一塊連續的記憶體空間來儲存;而連結串列則恰恰相反,通過指標將零散的記憶體串聯在一起。

  (2)陣列在插入和刪除時,會做資料搬移,其時間複雜度是 O(n);而連結串列只需考慮相鄰結點的指標變化,因此時間複雜度是 O(1)。

  (3)當隨機訪問第 K 個元素時,資料可根據首地址和索引計算出對應的記憶體地址,其時間複雜度為 O(1);而連結串列則需要讓指標依次遍歷連結的結點,因此時間複雜度是 O(n)。

  本系列中面試例題來源於LeetCode、《劍指Offer》等渠道。像下面這樣以“面試題”為字首的題目,其解法大都來源於《劍指Offer》一書。

  面試題5 替換空格。合併陣列,從後往前合併,減少數字移動次數。

一、連結串列結構

  連結串列包含三種最常見的連結串列結構:單鏈表、雙向連結串列和迴圈連結串列。

1)單鏈表

  單鏈表的結點結構如下所示,其中next是後繼指標,可連結下一個結點。

class Node {
  constructor(key=null) {
    this.next = null;
    this.key = key;
  }
}

  而單鏈表又可以細分為有頭結點的單鏈表和無頭結點的單鏈表,其中頭結點不儲存任何資料,如下圖1所示。

圖 1

  下面以有頭結點的單鏈表為例,演示單鏈表的插入、遍歷和刪除。

class List {
  constructor() {
    this.header = new Node();   //頭結點
  }
  add(node) {
    //插入
    if (!this.header.next) {
      this.header.next = node;
      return;
    }
    let current = this.header;
    while (current.next != null) {
      current = current.next;
    }
    current.next = node;
  }
  traverse() {
    //遍歷
    let current = this.header.next;
    while (current) {
      console.log(current.key);
      current = current.next;
    }
  }
  del(node) {
    //刪除
    let current = this.header.next,     //當前結點
      prev = this.header;               //前驅結點
    while (current != node) {
      current = current.next;
      prev = prev.next;
    }
    if (current) {
      prev.next = current.next;
      current.next = null;
    }
  }
}

  儘管刪除操作的時間複雜度是 O(1),但遍歷查詢是主要的耗時點,複雜度為 O(n)。因為在刪除時需要知道前驅結點,而單鏈表不能直接讀取,只能從頭開始遍歷。

  面試題6 從尾到頭列印連結串列。每經過一個結點,就放到棧中。當遍歷完後,從棧頂輸出。

  面試題18 刪除連結串列的結點。將結點 j 覆蓋結點 i,結點 i 的next指標指向 j 的下一個結點,這樣能避免獲取結點 i 的前置結點。

  面試題52 兩個連結串列的第一個公共結點。分別把兩個連結的結點放入兩個棧中,尾結點就是兩個棧的頂部,如果相同就接著比較下一個棧頂,直至找到最後一個相同結點。

2)雙向連結串列

  雙向連結串列顧名思義包含兩個方向的指標:前驅和後繼,結點結構如下所示。

class Node {
  constructor(key = null) {
    this.prev = null;
    this.key = key;
    this.next = null;
  }
}

  雙向連結串列比單鏈表要佔更多的記憶體空間,依託用空間換時間的設計思想,雙向連結串列要比單鏈表更加的高效。

  例如之前的刪除,由於已經儲存了前驅結點,也就避免了多餘的遍歷(如下所示)。當希望在某個結點之前插入結點,雙向連結串列的優勢也很明顯。

class List {
  add(node) {
    //插入
    if (!this.header.next) {
      this.header.next = node;
      node.prev = this.header;
      return;
    }
    let current = this.header;
    while (current.next != null) {
      current = current.next;
    }
    current.next = node;
    node.prev = current;
  }
  del(node) {
    //刪除
    let current = this.header.next;     //當前結點
    while (current != node) {
      current = current.next;
    }
    if (current) {
      current.prev.next = current.next;
      current.next = null;
    }
  }
}

3)迴圈連結串列

  迴圈連結串列是一種特殊的單鏈表,它的尾結點的後繼結點是頭結點,適合處理具有環形結構的問題,例如約瑟夫環。

  面試題62 圓圈中最後剩下的數字。用環形連結串列模擬圓圈,每刪除一個數字需要 m 步運算,共有 n 個數字,時間複雜度O(mn)。

二、經典例題

1)單鏈表逆序

  從連結串列的第二個結點開始,把遍歷到的結點插入到頭結點的後面,直至結束,例如head→1→2→3變為 head→2→1→3。

  採用遞迴的方式完成單鏈表的逆序,如下所示。例題:LeetCode的206. 反轉連結串列。

class List {
  reverse() {
    //逆序
    this.recursive(this.header.next);
  }
  recursive(node) {
    if (!node) return;
    const current = node,
      next = current.next;
    if (!next) {
      //頭結點指向逆序後連結串列的第一個結點
      this.header.next = current;
      return;
    }
    this.recursive(next);
    /************************************
    * 移動結點
    * 例如Node(2).next.next就是Node(3)
    * 巧妙的將Node(3).next連結為Node(2)
    ************************************/
    current.next.next = current;
    current.next = null;
  }
}

2)連結串列中環的檢測

  第一種思路是快取每個經過的結點,每到一個新結點,就判斷當前序列中是否存在,如果存在,就說明訪問過了。

  第二種思路是使用兩個指標,快指標每次前移兩步,慢指標每次前移一步,當兩個指標指向相同結點時,就證明有環,否則就沒有環,如下所示。例題:LeetCode的141. 環形連結串列。

class List {
  isLoop() {
    //檢測環
    let fast = this.header.next,
      slow = this.header.next;
    while (fast && fast.next) {
      slow = slow.next;
      fast = fast.next.next;
      if (slow == fast) return true;
    }
    return false;
  }
}

3)合併兩個有序連結串列

  用兩個指標遍歷兩個連結串列,如果head1指向的資料小於head2的,則將head1指向的結點歸入合併後的連結串列中,否則用head2的,如下所示。例題:LeetCode的21. 合併兩個有序連結串列。

function merge(head1, head2) {
  let cur1 = head1.next,
    cur2 = head2.next,
    cur = null,         //合併後的尾結點
    head = null;        //合併後的頭結點
  //合併後連結串列的頭結點為第一個結點元素最小的那個連結串列的頭結點
  if (cur1.key > cur2.key) {
    head = head2;
    cur = cur2;
    cur2 = cur2.next;
  } else {
    head = head1;
    cur = cur1;
    cur1 = cur1.next;
  }
  //每次找連結串列剩餘結點的最小值對應的結點連線到合併後連結串列的尾部
  while (cur1 && cur2) {
    if (cur1.key > cur2.key) {
      cur.next = cur2;
      cur = cur2;
      cur2 = cur2.next;
    } else {
      cur.next = cur1;
      cur = cur1;
      cur1 = cur1.next;
    }
  }
  //當遍歷完一個連結串列後把另外一個連結串列剩餘的結點連結到合併後的連結串列後面
  if (cur1 != null) cur.next = cur1;
  if (cur2 != null) cur.next = cur2;
  return head;
}

4)找出連結串列倒數第 n 個結點

  使用兩個指標,快指標比慢指標先前移 n 步,然後兩個指標同時移動。當快指標到底後,慢指標的位置就是所要找的結點,如下所示。例題:LeetCode的劍指 Offer 22. 連結串列中倒數第k個節點。

class List {
  findLast(n) {
    //刪除連結串列倒數第 n 個結點
    let slow = null,
      fast = null;
    slow = fast = this.header.next;
    let i = 0;
    //前移 n 步
    while (i < n && fast) {
      fast = fast.next;
      i++;
    }
    while (fast) {
      fast = fast.next;
      slow = slow.next;
    }
    return slow;
  }
}

5)求連結串列的中間結點

  使用兩個指標一起遍歷連結串列。慢指標每次走一步,快指標每次走兩步。那麼當快指標到達連結串列的末尾時,慢指標必然處於中間位置,如下所示。例題:LeetCode的876. 連結串列的中間結點。

class List {
  middle() {
    //求連結串列的中間結點
    let slow = this.header.next,
      fast = this.header.next;
    while (slow && fast && fast.next) {
      slow = slow.next;
      fast = fast.next.next;
    }
    return slow;
  }
}

&n