数据结构-链表

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

数组的缺点

数组不总是组织数据的最佳数据结构, 原因如下. 在很多编程语言中, 数组的长度是固定的, 所以当数组已被数据填满时, 再要加入新的元素就会非常困难. 在数组中, 添加和删除元素也很麻烦, 因为需要将数组中的其他元素向前或向后平移, 以反映数组刚刚进行了添加或删除操作. 然而, 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();
};

相关推荐