數據結構和算法躬行記(1)——鏈表

  鏈表(Linked List)是不一樣於數組的另外一種數據結構,它的存儲單元(即結點或元素)除了包含任意類型的數據以外,還須要包含指向另外一個結點的引用,後文會用術語連接表示對結點的引用。node

  下面會列出鏈表與數組的具體不一樣:面試

  (1)數組須要一塊連續的內存空間來存儲;而鏈表則偏偏相反,經過指針將零散的內存串聯在一塊兒。數組

  (2)數組在插入和刪除時,會作數據搬移,其時間複雜度是 O(n);而鏈表只需考慮相鄰結點的指針變化,所以時間複雜度是 O(1)。緩存

  (3)當隨機訪問第 K 個元素時,數據可根據首地址和索引計算出對應的內存地址,其時間複雜度爲 O(1);而鏈表則須要讓指針依次遍歷連接的結點,所以時間複雜度是 O(n)。數據結構

  本系列中面試例題來源於LeetCode、《劍指Offer》等渠道。像下面這樣以「面試題」爲前綴的題目,其解法大都來源於《劍指Offer》一書。oop

  面試題5 替換空格。合併數組,從後往前合併,減小數字移動次數。ui

1、鏈表結構

  鏈表包含三種最多見的鏈表結構:單鏈表、雙向鏈表和循環鏈表。this

1)單鏈表spa

  單鏈表的結點結構以下所示,其中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)。

2、經典例題

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;
  }
}
相關文章
相關標籤/搜索