前端进阶之从零到一实现单向 & 双向链表

前言

前端工程师对于算法和数据结构这块的知识的掌握程度,是进阶高级工程师的很是重要的标志之一,为了总结一下数据结构和算法方面的知识,笔者今天继续把链表这一块的知识补上,也做为本身知识体系的一个梳理,笔者早在去年就写过一篇关于使用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数据可视化等前端知识和实战,欢迎在公号《趣谈前端》加入咱们的技术群一块儿学习讨论,共同探索前端的边界。

更多推荐

相关文章
相关标签/搜索