1
本章将讨论另一种列表: 链表 . 解释为什么有时链表优于数组, 还会实现一个基于对象的链表.

数组的缺点

数组不总是组织数据的最佳数据结构, 原因如下. 在很多编程语言中, 数组的长度是固定的, 所以当数组已被数据填满时, 再要加入新的元素就会非常困难. 在数组中, 添加和删除元素也很麻烦, 因为需要将数组中的其他元素向前或向后平移, 以反映数组刚刚进行了添加或删除操作. 然而, JS的数组不存在上述问题. 因为使用splice()方法不需要再访问数组中的其它元素了.

定义链表

由一组节点组成的集合. 每一个节点都使用一个对象的引用指向它的后继. 指向另一个节点的引用叫做链.
图片名称

数组元素靠它们的位置进行引用, 链表元素则是靠相互之间的关系进行引用. 在上图中, 我们说99跟在12后面, 而不说99是链表中的第二个元素. 遍历链表, 就是跟着连接, 从链表的首元素一直走到尾元素(但这不包含链表的头结点, 头结点常常永爱作为链表的接入点). 值得注意的是, 链表的尾元素指向一个null节点.

然鹅要标识出链表的起始节点却有点麻烦, 许多链表的实现都是在链表最前面有一个特殊节点, 叫做 头节点.

链表中插入一个节点的效率很高. 向链表中插入一个节点, 需要修改它前面的节点(前驱), 使其事项新加入的节点, 而新加入的节点则指向原来前驱指向的节点.

从链表中删除一个元素也很简单. 将待删除元素的前驱节点指向待删除元素的后继节点, 同时将待删除元素指向null, 元素就删除成功了.

设计一个基于对象的链表

我们设计的链表包含两个类. Node类用于表示节点, LinkedList类提供了插入节点、删除节点、显示列表元素的方法, 以及其他一些辅助方法.

Node类

Node类包含两个属性: element用来保存节点上的数据, next用来保存指向下一个节点的链接.

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

LinkedList类

LList类提供了对链表进行操作的方法. 该类的功能包括插入删除节点、在列表中查找给定的值.

class LList {
    constructor() {
        this._head = new Node('head');
    }
    _find(item) {
        let currNode = this._head;
        while (currNode.element != item) {
            currNode = currNode.next;
        };
        return currNode;
    }
    _findPrevious(item) {
        let currNode = this._head;
        while (currNode.next !== null && currNode.next.element !== item) {
            currNode = currNode.next;
        };
        return currNode;  
    }
    insert(newElement, item) {
        const newNode = new Node(newElement);
        const current = this._find(item);
        newNode.next = current.next;
        current.next = newNode
    }
    remove(item) {
        const prevNode = this._findPrevious(item);
        if (prevNode.next !== null) {
            prevNode.next = prevNode.next.next
        }
    }
    display() {
        let currNode = this._head;
        while (!(currNode.next === null)) {
            console.log(currNode.next.element);
            currNode = currNode.next;
        }
    }
};

插入新节点insert()
该方法向链表中插入一个节点. 向链表中插入新节点时, 需要明确指出要在哪个节点前面或后面插入元素.

在一个已知节点后面插入元素时, 先要找到 后面 的节点. 为此, 创建一个辅助方法find(), 该方法遍历链表, 查找给定数据. 如果找到数据, 该方法就返回保存该数据的节点.
find()方法演示了如何在链表上进行移动. 首先, 创建一个新节点, 并将链表的头节点赋给这个新创建的节点. 然后再链表上进行循环, 如果当前节点的element属性和我们要找的信息不符, 就从当前节点移动到下一个节点. 如果查找成功, 则返回该数据的节点; 否则返回null.

一旦找到 后面 的节点, 就可以将新的节点插入链表了. 首先, 将新节点的next属性设置为 后面 节点的next属性对应的值. 然后设置 后面 节点的next属性指向新节点.

在测试之前我们定义一个display()方法, 该方法用来显示链表中的元素.
display()先将列表的头节点赋给一个变量, 然后循环遍历链表, 当节点的next属性为null时循环结束. 为了只显示包含数据的节点(换句话说, 不显示头节点), 程序只访问当前节点的下一个节点中保存的数据: currNode.next.element.

测试程序:

const letters = new LList();
letters.insert('a', 'head');
letters.insert('b', 'a');
letters.insert('c', 'b');
letters.insert('d', 'c');
letters.display();

输出:

a
b
c
d

删除一个节点remove()
从链表中删除节点时, 需要先找到待删除节点前面的节点. 找到这个节点后, 修改它的next属性, 使其不再事项待删除节点, 而是指向待删除节点的下一个节点. 我们定义一个方法findPrevious(). 该方法遍历链表中的元素, 检查每一个节点的下一个节点中是否存储待删除数据. 如果找到, 返回该节点(即 前一个 节点), 这样就可以修改它的next属性了.

remove()方法中最重要的一行代码prevNode.next = prevNode.next.next;
这里跳过了待删除节点, 让 前一个 节点指向了待删除节点的后一个节点.

测试程序:

const letters = new LList();
letters.insert('a', 'head');
letters.insert('b', 'a');
letters.insert('c', 'b');
letters.insert('d', 'c');
letters.display();

letters.remove('d');
console.log('')
letters.display();

输出:

a
b
c
d

a
b
c

双向链表

尽管从链表的头节点到尾节点很简单, 但反过来, 从后向前遍历则没那么简单. 通过给Node对象增加一个属性, 该属性存储指向前驱节点的链接, 这样就容易多了. 此时向链表中插入一个节点需要更多的工作, 我们需要指出该节点正确的前驱和后继. 但是删除节点时效率提高了, 不需要再查找待删除节点的前驱节点了.

首先我们要为Node类增加一个previous属性:

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

insert()方法和单向链表的类似, 但是需要设置新节点的previous属性, 使其指向该节点的前驱.

...
insert(newElement, item) {
    const newNode = new Node(newElement);
    const current = this._find(item);
    newNode.next = current.next;
    newNode.previous = current;
    current.next = newNode;
}
...

remove()方法比单向链表的效率更高, 因为不需要查找前驱节点了. 首先需要在链表中找出存储待删除数据的节点, 然后设置该节点前驱的next属性, 使其指向待删除节点的后继; 设置该节点后继的previous属性, 使其指向待删除节点的前驱.

...
remove(item) {
    const currNode = this._find(item);
    if(currNode.next != null) {
        currNode.previous.next = currNode.next;
        currNode.next.previous = currNode.previous;
        currNode.next = null;
        currNode.previous = null;
    }
}
...

为了反序显示链表中元素, 需要给双向链表增加一个工具方法, 用来查找最后的节点. findLast()方法找出了链表中的最后一个节点, 同时免除了从前往后遍历链表之苦:

...
_findLast() {
    let currNode = this._head;
    while (currNode != null) {
        currNode = currNode.next;
    };

    return currNode;
}
...

有了这个工具方法, 就可以写一个方法, 反序显示双向链表中的元素. dispReverse()方法:

...
dispReverse() {
    let currNode = this._head;
    currNode = this._findLast();
    while (currNode.previous != null) {
        console.log(currNode.element);
        currNode = currNode.previous;
    }
}
...

全部代码:

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

class LList {
    constructor() {
        this._head = new Node('head');
    }
    _find(item) {
        let currNode = this._head;
        while (currNode.element != item) {
            currNode = currNode.next;
        };
        return currNode;
    }
    _findPrevious(item) {
        let currNode = this._head;
        while (currNode.next !== null && currNode.next.element !== item) {
            currNode = currNode.next;
        };
        return currNode;  
    }
    _findLast() {
        let currNode = this._head;
        while (currNode.next != null) {
            currNode = currNode.next;
        };

        return currNode;
    }
    insert(newElement, item) {
        const newNode = new Node(newElement);
        const current = this._find(item);
        newNode.next = current.next;
        newNode.previous = current;
        current.next = newNode
    }
    remove(item) {
        const currNode = this._find(item);
        if (currNode.next !== null) {
            currNode.previous.next = currNode.next;
            currNode.next.previous = currNode.previous;
            currNode.next = null;
            currNode.previous = null;
        } else {
            currNode.previous.next = null;
        }
    }
    display() {
        let currNode = this._head;
        while (!(currNode.next === null)) {
            console.log(currNode.next.element);
            currNode = currNode.next;
        }
    }
    dispReverse() {
        let currNode = this._head;
        currNode = this._findLast();
        while (currNode.previous !== null) {
            console.log(currNode.element);
            currNode = currNode.previous;
        }
    }
};

const letters = new LList();
letters.insert('a', 'head');
letters.insert('b', 'a');
letters.insert('c', 'b');
letters.insert('d', 'c');
letters.display();
letters.dispReverse();

letters.remove('d');
letters.remove('b');
console.log('')
letters.dispReverse();

程序输出:

a
b
c
d
d
c
b
a

c
a

循环链表

循环链表和单向链表相似, 节点类型都是一样的. 唯一的区别是, 在创建循环链表时, 让其头节点的next属性指向它本身.
_head.next = _head
这种行为会传导至链表中的每一个节点, 使得每一个节点的next属性都是指向链表的头节点. 换句话说, 链表的尾节点指向头节点, 形成了一个循环链表.

如果你希望可以从后面向前遍历链表, 但是又不想付出额外代价来创建一个双向链表, 那么就需要使用循环链表. 从循环链表的尾节点向后移动, 就等于从后向前遍历链表.

创建循环链表, 只需要修改单向链表的LList类的构造函数:

class LList {
    constructor() {
        this._head = new Node('head');
        this._head.next = this._head;
    }
    ...
}

只要修改一处, 就将单向链表变成了循环链表. 但是其它一些方法需要修改才能工作正常. eg: display()就需要修改, 原来的方式在循环链表里会陷入死循环. while循环条件需要修改, 需要检查头节点, 当循环到头节点时退出循环.

...
display() {
    let currNode = this._head;
    while (currNode.next !== null && currNode.next.element !== 'head') {
        console.log(currNode.next.element);
        currNode = currNode.next;
    }
}
...

链表的其它方法

advance()前移

单向链表就可以完成该功能. 但是为了配合后移功能我们采用双向链表.

...
advance(n) {
    while ( n && this._head.next != null) {
        this._head = this._head.next;
        n--;
    };
}
...

使整个链表向前移动, 从头结点开始, 移动几位就是头节点赋值为第几个next节点.

back()后移

与前移不同的后移功能需要在双向链表上实现.

...
back(n) {
    while ( n && this._head.element != 'head') {
        this._head = this._head.previous;
        n--;
    };
}
...

是整个链表向后移动, 如果第一个节点(当前节点)为头节点(head)则不移动.

show()只显示当前节点数据

...
show() {
    return this._head;
}
...

循环链表解决犹太历史学家弗拉维奥·约瑟夫基和他的同伴生存问题.

传说在公元1 世纪的犹太战争中,犹太历史学家弗拉维奥·约瑟夫斯和他的40个同胞被罗马士兵包围。犹太士兵决定宁可自杀也不做俘虏,于是商量出了一个自杀方案。他们围成一个圈,从一个人开始,数到第三个人时将第三个人杀死,然后再数,直到杀光所有人。约瑟夫和另外一个人决定不参加这个疯狂的游戏,他们快速地计算出了两个位置,站在那里得以幸存。写一段程序将n 个人围成一圈,并且第m个人会被杀掉,计算一圈人中哪两个人最后会存活。使用循环链表解决该问题。
首先我们看到他们围成一个圈判断应该使用循环链表来处理改问题.
完整代码:

window.log = console.log.bind(console);
class Node {
    constructor(element) {
        this.element = element;
        this.next = null;
    }
};

class LList {
    constructor() {
        this._head = new Node('head');
        this._head.next = this._head;
        this.currentNode = this._head;
    }
    _find(item) {
        let currNode = this._head;
        while (currNode.element != item) {
            currNode = currNode.next;
        };
        return currNode;
    }
    _findPrevious(item) {
        let currNode = this._head;
        while (currNode.next !== null && currNode.next.element !== item) {
            currNode = currNode.next;
        };
        return currNode;  
    }
    insert(newElement, item) {
        const newNode = new Node(newElement);
        const current = this._find(item);
        newNode.next = current.next;
        current.next = newNode;
    }
    remove(item) {
        const prevNode = this._findPrevious(item);
        if (prevNode.next !== null) {
            prevNode.next = prevNode.next.next
        }
    }
    // 前移
    advance(n) {
        while ( n ) {
            if(this.currentNode.next.element == 'head') {
                this.currentNode = this.currentNode.next.next;
            } else {
                this.currentNode = this.currentNode.next;
            } 
            n--;
        };
    }
    show() {
        return this.currNode;
    }
    count() {
        let currNode = this._head;
        let i = 0;
        while (currNode.next.element != 'head') {
            currNode = currNode.next;
            ++i
        };
        
        return i;
    }
    display() {
        let currNode = this._head;
        
        while (currNode.next !== null && currNode.next.element !== 'head') {
            console.log(currNode.next.element);
            currNode = currNode.next;
        }
    }
};

const p = new LList();

const peopleNum = 40;
for(let i = 1; i <= peopleNum; i++) {
    if(i === 1) {
        p.insert(`people${i}`, 'head');
    } else {
        p.insert(`people${i}`, `people${i - 1}`);
    }
};

p.display();
while (p.count() > 2) {
    p.advance(3);
    p.remove(p.currentNode.element);
    log('/////////////////')
    p.display();
};

伍陆柒
1.2k 声望25 粉丝

如果觉得我的文章对大家有用的话, 可以去我的github start一下[链接]