前端-javascript-实现链表(单、不循环、带有空头节点)

401 阅读6分钟

最近因为封装一些比较底层的东西,所以不得不重温数据结构,设计模式和算法。我发现虽然以前学过,但是还是有点慢,毕竟当初花了那么久的时间学的,所以我就按照我的理解把这些都记录下来,方便自己以后重温。但我写的文章有可能被其他人看到或者说是需要的人看到,那么我就尽量写的通熟易懂些,这样能将就更多的人。如果存在不好的地方或错误的地方请帮我指出,我会重新修改,尽量保持最好。

一、定义结构体

function Node (e, next) {
    this.element = e || null;
    this.next = next || null;
}

每一个节点只保存数据和下一个节点的地址。之所以采用这种方式,是方便初始化,下面的具体操作就能体现了。 结构图:

每一个节点的示意图

图1.1 节点结构图

二、定义链表类

下面是具体链表类的实现。

2.1 总体结构

function Link() {
    // 链表的数据
    let data = new Node();
    // 链表的长度
    let len = 0;
    // 插入方法
    // 移除方法
    // 获取方法
    // 获取长度
    // 查询(两种实现,递归和非递归)
    // 打印方法(两种实现,递归和非递归)
}

2.2 插入

我直接实现的是在指定位置插入元素。

this.insert = function (index, e) {
    if (index < 0 || index > len)
        throw new Error(`输入的索引 ${index} 不正确:index >= 0 && index <= ${len}`);
    let pre = findNode(index);
    pre.next = new Node(e, pre.next);
    len++;
}

其中findNode函数负责返回指定index位置的前一个节点。其中pre.next = new Node(e, pre.next);这条语句同等于:

let oldNext = pre.next;
let newNode = new Node(e);
pre.next = newNode;
newNode.next = oldNext;

前面说了得到的pre是要插入位置的前一个位置的节点,既然要在pre下一个节点插入元素。

  1. 首先需要将pre.next保存起来;
  2. 然后新建一个节点;
  3. 将前一个节点的next,也就是pre.next更改为新的节点,也就是上面的newNode;
  4. 最后newNode节点的next需要更新为原本pre.next所指的位置。

2.3 移除

this.remove = function (index) {
    if (index < 0 || index > len - 1) 
        throw new Error(`输入的索引 ${index} 不正确:index >= 0 && index < ${len}`);
    let pre = findNode(index);
    let e = pre.next.element;
    pre.next = pre.next.next;
    len--;
    return e;
}

其中值得说的是pre.next = pre.next.next;,pre.next这个节点是要删除的节点,既然要删除这个节点,只需要跳过它就可以,也就是在整个链表中都找不到它就代表删除这个节点了。由于我们都是通过next来查找节点的,那么只需要保证在next中不包含这个节点即可。如果我们让要删除节点的前一个节点指向要删除节点的下一个节点,这样就没有关联了。

2.4 根据下标获取数据

this.get = function (index) {
    if (index < 0 || index > len - 1) throw new Error(`输入的索引 ${index} 不正确:index >= 0 && index < ${len}`);
    let pre = findNode(index);
    return pre.next.element;
}

这个很简单,只需要返回对应的值即可。

2.5 查找指定元素是否在链表中

  • 先看非递归的写法:
this.contain = function (e, compare) {
    let pre = data.next;
    while(pre != null) {
        if (compare(pre.element, e))
            return true;
        pre = pre.next;
    }
    return false;
}

其中compare是比较函数,这样主要是为了处理非基本数值类型的数据。

  • 递归实现
this.contain = function (e, compare) {
    return _contain(data.next, e, compare);
}
function _contain(node, e, compare) {
    if (node === null) return false;
    return compare(node.element, e) ? true: _contain(node.next, e, compare);
}

递归的结束条件有两个,第一个是当整个链表遍历结束,另一个是当遇到节点相等的情况。第一个结束条件要高于第二个条件,因为只有当node有值的情况才能访问node.element,不然会报错。我对递归最简单的理解是,只处理当前节点,如果当前节点满足我们的要求则进行对应的处理,在这里就是如果传进来的node不为空的情况下,跟传进来的e相等就返回,其他的情况就是返回false,但是由于我们处理的是一组数据,所以其他情况就是看看这组数据中的其他数据的情况。其实最难理解的是返回值,对于这个情况,首先看返回值,再则看传进去的参数。只需要关注这两点就可以了。下面还有分析方法。

2.6 打印

打印所有节点中的数据。

  • 非递归实现
this.toString = function () {
    let str = "";
     let pre = data.next;
     for (let i = 0; i < len; i++ , pre = pre.next) {
       str += pre.element.toString();
       if (i !== len - 1)
         str += "->";
    }
    return str;
}

其中我使用toString()也是为了照顾引用类型(非基本数据类型)。

  • 递归实现
this.toString = function () {
    return _toString(data.next);
}
function _toString(node) {
    if (node.next)
        return node.element.toString() + "->" + _toString(node.next);
    return node.element.toString();
}

这里我没有先对终止情况进行处理,其实这是一样的,看下面的实现:

function _toString(node) {
    if (!node.next) return node.element.toString();
    return node.element.toString() + "->" + _toString(node.next);
}

之所以判断的是node.next,是因为最后一个数据不需要添加箭头,所以没有判断node。上面的打印出来的结果如下格式:100->180->90->80->70->60->50。按我上面的思路分析这个递归函数,分析之前先看看当不处理箭头的情况:

function _toString(node) {
    if (node === null) return "";
    return node.element.toString() + _toString(node.next);
}

我们编写这个函数首先判断传进来的是不是null,如果是的话,那么就直接返回空;如果不为空呢,那么就返回node.element.toString(),这个是这个函数的功能,但是我们知道由于我们处理的是一组数据,而且每一个新数据都是在前一个数据的后面,于是我们在返回node.element.toString()的时候把后面经过处理的数据连接上,也就是

node.element.toString() + _toString(node.next)

这样就明白了其中的缘由。

2.7 获取长度

this.size = function () {
    return len;
}

这里要说明一下,一定不能让用户直接操作长度,这样容易出现问题。

2.8 获取下标所对应的前一个节点(findNode)

function findNode(index) {
    let pre = data;
    for (let i = 0; i < index; i++) {
      pre = pre.next;
    }
    return pre;
}

这是一个私有方法,外面访问不到,只是在内部使用,前面只要是function开头的函数外部都访问不到。

三、总结

目前没有相关的动图来更好的理解链表中的添加和删除,这个时候你就需要在纸上自己画一画,发现一切都很清楚。现在封装这些都只是为了学习,一般不推荐使用自己封装的库,不是说我们封装的不好,而是官方的底层是使用c++写的,效率更高,如果是第三方库,你可以手动比较一下你自己写的和别人写的,然后再做决定。但是也存在没有好的库适合我们,那么我们就不得不自己封装,这个时候就得注意了,像我们上面那样写是有问题的,这里的问题是指外面可以更改函数的定义和实现,也就是说别人能覆盖你写的方法,按道理也没有什么问题,但是尽量不要将这个权限放开,毕竟这样会存在误操作。我封装库的原则是能不放开我就不放开,因为自由度越高,出现问题的概率就越高。当然公司内部使用,只要大家规定好就行。

不要为了什么而什么,一切都根据需要来就对了。

目前本人的技术水平有限只能说一些对自己有帮助的话,也有可能不对,我以后会新增或更正里面的语句。

学习的道路漫长,愿我能坚持到底。