前端進階之從零到一實現單向 & 雙向鏈表

前言

前端工程師對於算法和數據結構這塊的知識的掌握程度,是進階高級工程師的很是重要的標誌之一,爲了總結一下數據結構和算法方面的知識,筆者今天繼續把鏈表這一塊的知識補上,也做爲本身知識體系的一個梳理,筆者早在去年就寫過一篇關於使用javascript實現二叉樹和二叉搜索樹的文章,若是感興趣或者想進階高級的朋友們能夠參考學習一下: JavaScript 中的二叉樹以及二叉搜索樹的實現及應用.javascript

你將收穫

  • 鏈表的概念和應用
  • 原生javascript實現一條單向鏈表
  • 原生javascript實現一條個雙單向鏈表
  • 鏈表和數組的對比及優缺點

正文

1. 鏈表的概念和應用

鏈表是一種線性表數據結構,由一系列結點(鏈表中每個元素稱爲結點)組成,結點能夠在運行時動態生成。每一個結點包括兩個部分:一個是存儲數據元素的數據域,另外一個是存儲下一個結點地址的指針域。css

以上概念用圖表示爲如下結構: 前端

鏈表是非連續的,因此說從底層存儲結構上看,它不須要一整塊連續的存儲空間,而是經過「指針」將一組零散的數據單元串聯起來成爲一個總體。 鏈表也有幾種不一樣的類型: 單向鏈表雙向鏈表循環鏈表。上圖就是一種 單向鏈表。由其定義不難發現雙向鏈表無非就是每一個節點加上了先後節點的指針引用,以下圖所示:
那什麼是循環鏈表呢? 循環鏈表本質上是一種特殊的單向鏈表,惟一的區別就在於它的尾結點指向了鏈表的頭結點,這樣首尾相連,造成了一個環,因此叫作循環鏈表。 以下圖所示:
固然咱們還能夠擴展出雙向循環鏈表,這裏就不一一舉例了。總之鏈表結構在計算機底層語言中應用的比較多,當咱們在用高級語言作編程時可能不會察覺,好比咱們用javascript敲js的時候,其實咱們在深刻了解鏈表以後咱們就會發現鏈表有不少應用場景,好比 LRU 緩存淘汰最近消息推送等。

舉個更接地氣的,當咱們在用PS畫圖時軟件提供了一個動做面板,能夠記錄用戶以前的操做記錄,並批量執行動做,或者當咱們在使用編輯器時的回退撤銷功能等,用鏈表結構來存儲狀態信息仍是比較方便的。vue

最近比較火的react hooks API,其結構也是一個鏈表型的數據結構,因此學習鏈表仍是很是有幫助的。讀到這裏可能仍是有點懵,接下來咱們先用js實現一個鏈表,這樣有助於理解鏈表的本質,後面筆者會總結一下鏈表和數組的對比以及優劣勢,方便你們對鏈表有一個更加直觀的認識。java

2.原生javascript實現一條單向鏈表

在上面一節介紹的鏈表結構中你們可能對鏈表有了初步的認識,由於javascript中沒有鏈表的數據結構,爲了模擬鏈表結構,咱們能夠經過js面向對象的方式實現一個鏈表結構及其API,具體設計以下: node

有了以上需求點以後,這個鏈表纔是基本可用的鏈表,那麼咱們一步步來實現它把。

2.1 定義鏈表結構

爲了實現鏈表以及鏈表的操做,首先咱們須要先定義鏈表的基本結構,第一步就是定義節點的數據結構。咱們知道一個節點會有本身的值以及指向下一個節點的引用,因此能夠這樣定義節點:react

let Node = function(el) {
      this.el = el;
      this.next = null;
 }
複製代碼

接下來咱們定義一下鏈表的基本骨架:webpack

// 單向鏈表, 每個元素都有一個存儲元素自身的節點和一個指向下一個元素引用的節點組成
function linkedList() {
  let Node = function(el) {
      this.el = el;
      this.next = null;
  }
  let length = 0
  let head = null  // 用來存儲第一個元素的引用

  // 尾部添加元素
  this.append = (el) => {};  
  //插入元素
  this.insert = (pos, el) => {};  
  // 移除指定位置的元素
  this.removeAt = (pos) => {};  
  // 移除指定節點
  this.remove = (el) => {};    
  // 查詢節點所在位置
  this.indexOf = (el) => {};  
  // 判斷鏈表是否爲空
  this.isEmpty = () => {};  
  // 返回鏈表長度
  this.size = () => {};  
  // 將鏈表轉化爲數組返回
  this.toArray = () => {}; 
}
複製代碼

由以上代碼咱們能夠知道鏈表的初始長度爲0,頭部元素爲null,接下來咱們實現添加節點的功能。css3

2.2 實現添加節點

追加節點的時候首先須要知道頭部節點是否存在,若是不存在直接賦值,存在的話則從頭部開始遍歷,直到找到下一個節點爲空的節點,再賦值,並將鏈表長度+1,代碼以下:web

// 尾部添加元素
this.append = (el) => {
    let node = new Node(el),
        current;
    if(!head) {
      head = node
    }else {
      current = head;
      while(current.next) {
        current = current.next;
      }
      current.next = node;
    }
    length++
};
複製代碼

2.3 實現插入節點

實現插入節點邏輯首先咱們要考慮邊界條件,若是插入的位置在頭部或者比尾部位置還大,咱們就不必從頭遍歷一遍處理了,這樣能夠提升性能,因此咱們能夠這樣處理:

//插入元素
this.insert = (pos, el) => {
    if(pos >=0 && pos <= length) {
      let node = new Node(el),
          previousNode = null,
          current = head,
          curIdx = 0;
      if(pos === 0) {
        node.next = current;
        head = node;
      }else {
        while(curIdx++ < pos) { previousNode = current; current = current.next; } node.next = current; previousNode.next = node; length++; return true } }else { return false } }; 複製代碼

2.4 根據節點的值查詢節點位置

根據節點的值查詢節點位置實現起來比較簡單,咱們只要從頭開始遍歷,而後找到對應的值以後記錄一下索引便可:

// 查詢節點所在位置
this.indexOf = (el) => {
    let idx = -1,
        curIdx = -1,
        current = head;
    while(current) {
      idx++
      if(current.el === el) {
        curIdx = idx
        break;
      }
      current = current.next;
    }
    return curIdx
}; 
複製代碼

這裏咱們之因此要用idx和curIdx兩個變量來處理,是由於若是用戶傳入的值不在鏈表裏,那麼idx的值就會有問題,因此用curIdx來保證準確性。

2.5 移除指定位置的節點

移除指定位置的節點也須要判斷一下邊界條件,可插入節點相似,但要注意移除以後必定要將鏈表長度-1,代碼以下:

// 移除指定位置的元素
this.removeAt = (pos) => {
    // 檢測邊界條件
    if(pos >=0 && pos < length) {
      let previousNode = null,
               current = head,
               curIdx = 0;
      if(pos === 0) {
        // 若是pos爲第一個元素
        head = current.next
      }else {
        while(curIdx++ < pos) { previousNode = current; current = current.next; } previousNode.next = current.next; } length --; return current.el }else { return null } }; 複製代碼

2.6 移除指定節點

移除指定節點實現很是簡單,咱們只須要利用以前實現好的查找節點先找到節點的位置,而後在用實現過的removeAt便可,代碼以下:

// 移除指定節點
this.remove = (el) => {
  let idx = this.indexOf(el);
  this.removeAt(idx);
}; 
複製代碼

2.7 獲取節點長度

這裏比較簡單,直接上代碼:

// 返回鏈表長度
this.size = () => {
  return length
}; 
複製代碼

2.8 判斷鏈表是否爲空

判斷鏈表是否爲空咱們只須要判斷長度是否爲零便可:

// 返回鏈表長度
this.size = () => {
  return length
};
複製代碼

2.9 打印節點

打印節點實現方式有不少,你們能夠按照本身喜歡的格式打印,這裏筆者直接將其打印爲數組格式輸出,代碼以下:

// 將鏈表轉化爲數組返回
this.toArray = () => {
    let current = head,
        results = [];
    while(current) {
      results.push(current.el);
      current = current.next;
    }
    return results
}; 
複製代碼

這樣,咱們的單向鏈表就實現了,那麼咱們能夠這麼使用:

let link = new linkedList()
// 添加節點
link.append(1)
link.append(2)
// 查找節點
link.indexOf(2)
// ...
複製代碼

3.原生javascript實現一條個雙單向鏈表

有了單向鏈表的實現基礎,實現雙向鏈表也很簡單了,咱們無非要關注的是雙向鏈表的節點建立,這裏筆者實現一個例子供你們參考:

let Node = function(el) {
      this.el = el;
      this.previous = null;
      this.next = null;
 }
let length = 0
let head = null  // 用來存儲頭部元素的引用
let tail = null  // 用來存儲尾部元素的引用
複製代碼

由代碼可知咱們在節點中會有上一個節點的引用以及下一個節點的引用,同時這裏筆者添加了頭部節點和尾部節點方便你們操做。 你們能夠根據本身的需求實現雙向鏈表的功能,這裏筆者提供一份本身實現的代碼,能夠參考交流一下:

// 雙向鏈表, 每個元素都有一個存儲元素自身的節點和指向上一個元素引用以及下一個元素引用的節點組成
function doubleLinkedList() {
  let Node = function(el) {
      this.el = el;
      this.previous = null;
      this.next = null;
  }
  let length = 0
  let head = null  // 用來存儲頭部元素的引用
  let tail = null  // 用來存儲尾部元素的引用

  // 尾部添加元素
  this.append = (el) => {
    let node = new Node(el)
    if(!head) {
      head = node
    }else {
      tail.next = node;
      node.previous = tail;
    }
    tail = node;
    length++
  };  
  // 插入元素
  this.insert = (pos, el) => {
    if(pos >=0 && pos < length) {
      let node = new Node(el);
      if(pos === length - 1) {
        // 在尾部插入
        node.previous = tail.previous;
        node.next = tail;
        tail.previous = node;
        length++;
        return true
      }
      let current = head,
          i = 0;
      while(i < pos) {
        current = current.next;
        i++
      }
      node.next = current;
      node.previous = current.previous;
      current.previous.next = node;
      current.previous = node;
      length ++;
      return true    
    }else {
      throw new RangeError(`插入範圍有誤`)
    }
  };  
  // 移除指定位置的元素
  this.removeAt = (pos) => {
    // 檢測邊界條件
    if(pos < 0 || pos >= length) {
      throw new RangeError(`刪除範圍有誤`)
    }else {
      if(length) {
        if(pos === length - 1) {
          // 若是刪除節點位置爲尾節點,直接刪除,節省查找時間
          let previous = tail.previous;
          previous.next = null;
          length --;
          return tail.el
        }else {
          let current = head,
              previous = null,
              next = null,
              i = 0;
          while(i < pos) {
            current = current.next
            i++
          }
          previous = current.previous;
          next = current.next;
          previous.next = next;
          length --;
          return current.el
        }
      }else {
        return null
      }
    }
  };  
  // 移除指定節點
  this.remove = (el) => {
    let idx = this.indexOf(el);
    this.removeAt(idx);
  };
  // 查詢指定位置的鏈表元素
  this.get = (index) => {
    if(index < 0 || index >= length) {
      return undefined
    }else {
      if(length) {
        if(index === length - 1) {
          return tail.el
        }
        let current = head,
            i = 0;
        while(i < index) {
          current = current.next
          i++
        }
        return current.el
      }else {
        return undefined
      }
    }
  }
  // 查詢節點所在位置
  this.indexOf = (el) => {
    let idx = -1,
        current = head,
        curIdx = -1;
    while(current) {
      idx++
      if(current.el === el) {
        curIdx = idx;
        break;
      }
      current = current.next;
    }
    return curIdx
  };  
  // 判斷鏈表是否爲空
  this.isEmpty = () => {
    return length === 0
  };  
  // 返回鏈表長度
  this.size = () => {
    return length
  };  
  // 將鏈表轉化爲數組返回
  this.toArray = () => {
    let current = head,
        results = [];
    while(current) {
      results.push(current.el);
      current = current.next;
    }
    return results
  }; 
}
複製代碼

4.鏈表和數組的對比及優缺點

實現完鏈表以後咱們會對鏈表有更深刻的認知,接下來咱們進一步分析鏈表的優缺點。 筆者將從3個維度來帶你們分析鏈表的性能狀況:

  • 插入刪除性能
  • 查詢性能
  • 內存佔用

咱們先看看插入和刪除的過程:

由上圖能夠發現,鏈表的插入、刪除數據效率很是高,只須要考慮相鄰結點的指針變化,由於不須要移動其餘節點,時間複雜度是 O(1)。

再來看看查詢過程:

咱們對鏈表進行每一次查詢時,都須要從鏈表的頭部開始找起,一步步遍歷到目標節點,這個過程效率是很是低的,時間複雜度是 O(n)。這方面咱們使用數組的話效率會更高一點。

咱們再看看內存佔用。鏈表的內存消耗比較大,由於每一個結點除了要存儲數據自己,還要儲存先後結點的地址。可是好處是能夠動態分配內存。

另外一方面,對於數組來講,也存在一些缺點,好比數組必須佔用整塊、連續的內存空間,若是聲明的數組數據量過大,可能會致使「內存不足」。其次就是數組一旦須要擴容,會從新申請連續的內存空間,而且須要把上一次的數組數據所有copy到新的內存空間中。

綜上所述,當咱們的數據存在頻繁的插入刪除操做時,咱們能夠採用鏈表結構來存儲咱們的數據,若是涉及到頻繁查找的操做,咱們能夠採用數組來處理。實際工做中不少底層框架的封裝都是採用組合模式進行設計,通常純粹採用某種數據結構的比較少,因此具體仍是要根據所處環境進行適當的方案設計。

最後

若是想學習更多H5遊戲, webpacknodegulpcss3javascriptnodeJScanvas數據可視化等前端知識和實戰,歡迎在公號《趣談前端》加入咱們的技術羣一塊兒學習討論,共同探索前端的邊界。

更多推薦

相關文章
相關標籤/搜索