前端必会数据结构与算法系列之链表(三)

419 阅读6分钟

1. 什么是链表

数据结构的存储⽅式只有两种:数组(顺序存储)和链表(链式存储)(摘自labuladong的算法小抄)。

我们在使用数组的过程中会发现如下问题:

  1. 数组的大小是固定的,并且数组需要连续的内存空间。
  2. 从数组的起点或中间插入或移除项的成本很高,因为需要移动元素。

链表解决了数组的上述问题,链表不需要连续的内存,链表中的元素可存储在内存的任何地方,如下图

image.png

并且链表添加或移除元素的时候不需要移动其它元素,只需要更改next 的指向即可。

下图是一个简单的链表

用JavaScript代码模拟如下:

const a = { val: 'a' };
const b = { val: 'b' };
const c = { val: 'c' };
const d = { val: 'd' };
a.next = b;
b.next = c;
c.next = d;

// 遍历链表
let p = a;
while (p) {
    console.log(p.val);
    p = p.next;
}

// 在c和d中间插入e
const e = { val: 'e' };
c.next = e;
e.next = d;

// 删除e
c.next = d;

1.1 链表特点:

  1. 多个元素组成的列表。
  2. 元素存储不连续,用next指针连在一起。

1.2 链表相对于数组的优点

  1. 不需要连续的内存
  2. 添加或移除元素的时候不需要移动其它元素,只需要更改 next 的指向即可。

1.3 链表缺点

链表需要指针,在数组中,我们可以直接访问任何位置的任何元素,而想要访问链表中间的一个元素,需要从表头开始迭代链表直到找到所需的元素。

1.4 根据数组生成链表

function createLinkList(arr: number[]): ILinkListNode {
    const length = arr.length
    if (length === 0) throw new Error('arr is empty')

    let curNode: ILinkListNode = {
        value: arr[length - 1]
    }
    if (length === 1) return curNode

    for (let i = length - 2; i >= 0; i--) {
        curNode = {
            value: arr[i],
            next: curNode
        }
    }

    return curNode
}

const arr = [100, 200, 300, 400, 500]
const list = createLinkList(arr)
console.info('list:', list)

2. 模拟链表操作

javascript本身没有链表,所以我们用JavaScript来模拟一个链表。

一个链表包含如下常用方法

push(element):向链表尾部添加一个新元素。
insert(element, position):向链表的特定位置插入一个新元素。
getElementAt(index):返回链表中特定位置的元素。如果链表中不存在这样的元素,则返回 undefined。
remove(element):从链表中移除一个元素。
indexOf(element):返回元素在链表中的索引。如果链表中没有该元素则返回-1。
removeAt(position):从链表的特定位置移除一个元素。
isEmpty():如果链表中不包含任何元素,返回 true,如果链表长度大于 0则返回 false。
size():返回链表包含的元素个数,与数组的 length 属性类似。
toString():返回表示整个链表的字符串。由于列表项使用了 Node 类,就需要重写继承自 JavaScript 对象默认的 toString 方法,让其只输出元素的值。

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

class LinkedList {
    constructor () {
        this.count = 0
        this.head = null
    }

    // 向链表尾部添加新元素
    push(element) {
        const node = new Node(element)
        let current
        if (this.head === null) { // 链表为空,将元素赋给head
            this.head = node
        } else { // 遍历直到找到next为空的节点,将该元素next指向新加入的元素
            current = this.head
            while (current.next !== null) { // 获得最后一项
                current = current.next
            }
            // 将其 next 赋为新元素,建立连接
            current.next = node
        }
        this.count++
    }
    
    // 返回链表中特定位置的元素
    getElementAt(index) {
        if (index >= 0 && index <= this.count) {
            let node = this.head
            // 遍历找到index位置的元素
            for (let i = 0; i < index && node !== null; i++) {
                node = node.next
            }
            return node
        }
        return null
    }

    // 从链表中移除元素
    removeAt (index) {
        if (index >= 0 && index < this.count) {
            let current = this.head
            if (index === 0) { // 元素位置为0,即表头,直接将表头设置为第二个元素
                this.head = current.next
            } else { // 遍历找到该元素前一项,将前一项next指向该元素的next
                const previous = this.getElementAt(index - 1)
                current = previous.next
                // 将 previous 与 current 的下一项链接起来:跳过 current,从而移除它
                previous.next = current.next
            }
            this.count--
            return current.element
        }
        return null
    }

    // 插入元素
    insert(element, index) {
        if (index >= 0 && index <= this.count) {
            const node = new Node(element)
            if (index === 0) { // 在0位置插入,将新节点next设置为表头head
                const current = this.head
                node.next = current
                this.head = node
            } else { // 找到插入位置前一项,将该元素next设置为前一项的next,前一项next设置为该元素
                const previous = this.getElementAt(index - 1)
                node.next = previous.next
                previous.next = node
            }
            this.count++
            return true
        }
        return false
    }

    // 返回某个链表位置
    indexOf(element) {
        let current = this.head
        for (let i = 0; i < this.size() && current !== null; i++) {
            if (element === current.element) {
                return i
            }
            current = current.next
        }
        return -1
    }

    // 从链表中移除元素
    remove(element) {
        const index = this.indexOf(element)
        return this.removeAt(index)
    }

    isEmpty() {
        return this.size() === 0
    }

    size() {
        return this.count
    }

    getHead() {
        return this.head
    }

    clear() {
        this.head = null
        this.count = 0
    }
}

const list = new LinkedList()
list.push(15)
list.push(10)
console.log(list.toString())

推荐文章:
链表代码实现分析
Java中链表实现

3. 链表操作的复杂度分析

  • prepend: O(1)
  • append: O(1)
  • 查找(lookup): O(n)
  • 插入(insert): O(1)
  • 删除(delete): O(1)

4. 双向链表

双向链表和普通链表的区别在于,在链表中,一个节点只有链向下一个节点的链接。而在双向链表中,链接是双向的:一个链向下一个元素,另一个链向前一个元素,如下图所示:

双向链表代码实现:

class DoublyNode extends Node {
    constructor(element, next, prev) {
        super(element, next);
        this.prev = prev;
    }
}

class DoublyLinkedList extends LinkedList {
    constructor() {
        super();
        this.tail = undefined;
    }
    
    push(element) {
        const node = new DoublyNode(element);
        if (this.head == null) {
            this.head = node;
            this.tail = node;
        } else {
            this.tail.next = node;
            node.prev = this.tail;
            this.tail = node;
        }
        this.count++;
    }
    
    // 向任意位置插入元素
    insert(element, index) {
        if (index >= 0 && index <= this.count) {
            const node = new DoublyNode(element); 
            let current = this.head;
            if (index === 0) { // 在头部插入
                //  如果双向链表为空,head 和 tail 都指向这个新节点
                if (this.head == null) {
                    this.head = node;
                    this.tail = node;
                } else {
                    node.next = this.head;
                    current.prev = node;
                    this.head = node;
                }
            } else if (index === this.count) { // 尾部插入
                current = this.tail;
                current.next = node;
                node.prev = current;
                this.tail = node;
            } else { // 中间插入
                const previous = this.getElementAt(index - 1);
                current = previous.next;
                node.next = current;
                previous.next = node;
                current.prev = node;
                node.prev = previous;
            } 
            this.count++; 
            return true;
        }
        return false;
    }
    
    // 从任意位置移除元素
    removeAt(index) {
        if (index >= 0 && index < this.count) {
            let current = this.head; 
            if (index === 0) { // 删除头部
                this.head = current.next;
                // 如果只有一项,更新tail
                if (this.count === 1) {
                    this.tail = undefined; 
                } else {
                    this.head.prev = undefined;
                }
            } else if (index === this.count - 1) { // 删除尾部
                current = this.tail;
                this.tail = current.prev;
                this.tail.next = undefined;
            } else { // 删除中间
                current = this.getElementAt(index);
                const previous = current.prev;
                // 将previous与current的下一项链接起来——跳过current 
                previous.next = current.next;
                current.next.prev = previous;
            }
            this.count--;
            return current.element;
        }
        return undefined;
    }
}

起点插入新元素: image.png

尾部插入元素:

image.png

中间插入元素: image.png

5. 循环链表

循环链表可以像链表一样只有单向引用,也可以像双向链表一样有双向引用。循环链表和链表之间唯一的区别在于,最后一个元素指向下一个元素的指针(tail.next)不是引用undefined,而是指向第一个元素(head)

双向循环链表有指向 head 元素的 tail.next 和指向 tail 元素的 head.prev

循环链表代码实现

class CircularLinkedList extends LinkedList {
    constructor() {
        super();
    }

    push(element) {
        const node = new Node(element);
        let current;
        if (this.head == null) {
            this.head = node;
        } else {
            current = this.getElementAt(this.size() - 1);
            current.next = node;
        }
        // 指向头部
        node.next = this.head;
        this.count++;
    }

    insert(element, index) {
        if (index >= 0 && index <= this.count) {
            const node = new Node(element);
            let current = this.head;
            if (index === 0) { // 头部插入
                if (this.head == null) { // 链表为空
                    this.head = node;
                    node.next = this.head;
                } else {
                    node.next = current;
                    current = this.getElementAt(this.size());
                    this.head = node;
                    current.next = this.head;
                }
            } else { // 其余位置插入
                const previous = this.getElementAt(index - 1);
                node.next = previous.next;
                previous.next = node;
            }
            this.count++;
            return true;
        }
        return false;
    }

    removeAt(index) {
        if (index >= 0 && index < this.count) {
            let current = this.head;
            if (index === 0) {
                if (this.size() === 1) { // 只有一个元素情况
                    this.head = undefined;
                } else {
                    const removed = this.head;
                    current = this.getElementAt(this.size() - 1);
                    this.head = this.head.next;
                    current.next = this.head;
                    current = removed;
                }
             } else {
                const previous = this.getElementAt(index - 1);
                current = previous.next;
                previous.next = current.next;
            }
            this.count--;
            return current.element;
        }
        return undefined;
    }
}

链表为空时头部插入 image.png

链表不为空时头部插入 image.png

6. 有序链表

有序链表是指保持元素有序的链表结构。除了使用排序算法之外,我们还可以将元素插入到正确的位置来保证链表的有序性

有序链表代码实现:

const Compare = {
    LESS_THAN: -1,
    BIGGER_THAN: 1,
    EQUALS: 0
}

function defaultCompare(a, b) {
    if (a === b) {
        return Compare.EQUALS;
    }
    return a < b ? Compare.LESS_THAN : Compare.BIGGER_THAN;
}
class SortedLinkedList extends LinkedList {
    constructor(compareFn = defaultCompare) {
        super();
        this.compareFn = compareFn;
    }

    push(element) {
        if (this.isEmpty()) {
            super.push(element);
        } else {
            const index = this.getIndexNextSortedElement(element);
            super.insert(element, index);
        }
    }

    insert(element, index = 0) {
        if (this.isEmpty()) {
            return super.insert(element, index === 0 ? index : 0);
        }
        const pos = this.getIndexNextSortedElement(element);
        return super.insert(element, pos);
    }
    
    // 获取插入位置
    getIndexNextSortedElement(element) {
        let current = this.head;
        let i = 0;
        for (; i < this.size() && current; i++) {
            const comp = this.compareFn(element, current.element);
            // 找到插入位置
            if (comp === Compare.LESS_THAN) {
                return i;
            }
            current = current.next;
        }
        return i;
    }
}

7. 使用双向链表创建栈结构

之所以使用双向链表而不是链表,是因为对栈来说,我们会向链表尾部添加元素,也会从链表尾部移除元素。DoublyLinkedList 类有列表最后一个元素的引用,无须迭代整个链表的元素就能获取它。双向链表可以直接获取头尾的元素,减少过程消耗,它的时间复杂度和原始的 Stack 实现相同,为 O(1)。

class StackLinkedList {
    constructor() {
        this.items = new DoublyLinkedList();
    }

    push(element) {
        this.items.push(element);
    }

    pop() {
        if (this.isEmpty()) {
            return undefined;
        }
        const result = this.items.removeAt(this.size() - 1);
        return result;
    }

    peek() {
        if (this.isEmpty()) {
            return undefined;
        }
        return this.items.getElementAt(this.size() - 1).element;
    }

    isEmpty() {
        return this.items.isEmpty();
    }

    size() {
        return this.items.size();
    }

    clear() {
        this.items.clear();
    }

    toString() {
        return this.items.toString();
    }
}

8. 跳表

8.1 什么是跳表

跳表是链表的优化,跳表在工程中主要在redis中进行运用

跳表的优化思想:

  1. 升维、空间换时间
  2. 添加更多指针(头、尾、中...指针)

1. 原始链表

image.png 查询时间复杂度O(n)

2. 添加索引

image.png

3. 增加一级索引

可以看到,一级索引当问速度为2n,二级索引访问速度为4n,以此类推,增加(log2n级)多级索引

image.png

8.2 跳表的时间复杂度

n/2、n/4、 n/8、第k级索弓结点的个数就是n/(2^k)

假设索引有h级,最高级的索引有2个结点。n/(2^h)=2,从而求得 h=log2(n)-1

image.png 索引的高度: logn, 每层索引遍历的结点个数: 3,在跳表中查询任意数据的时间复杂度就是0(logn)

8.3 现实中跳表的形态

在现实中,由于元素的增加和删除,导致有些所以并不是完全非常工整的,经过多次改动之后,有些地方会多跨,有些地方会少跨,索引的增加和删除时间复杂度为logn

image.png

image.png

8.4 工程中的应用

LRU Cache - Linked list
Redis - Skip List
为啥 redis 使用跳表(skiplist)而不是使用 red-black?

9. 常见链表问题

1. 原型链

  • 原型链的本质是链表。
  • 原型链上的节点是各种原型对象,比如Function.prototype、Object.prototype.....
  • 原型链通过 __proto__ 属性连接各种原型对象。
obj -> Object.prototype -> null
func -> Function.prototype -> Object.prototype -> null
arr -> Array.prototype -> Object.prototype -> null

原型链知识点:如果A沿着原型链能找到B.prototype, 那么 A instanceof B 为 true.

const obj = {}
obj instanceof Object // true

如果在A对象上没有找到x属性,那么会沿着原型链找x属性

2. instanceof原理,并用代码实现

  • 知识点:如果A沿着原型链能找到B.prototype,那么A instanceof B为true。
  • 解法: 遍历A的原型链,如果找到B.prototype,返回true,否则返回false
const instanceOf = (A, B) => {
    let p = A;
    while(p) {
        if(p === B.prototype) {
            return true;
        }
        p = p.__proto__
    }
    return false;
}
var foo = [],
F = function() {};
Object.prototype.a = 'value a';
Function.prototype.b = 'value b';

console.log(foo.a);
console.log(foo.b);

console.log(F.a);
console.log(F.b);

3. 使用链表指针获取JSON的节点值

const json = {
    a: { b: { c: 1 } },
    d: { e: 2 },
};

const path = ['a', 'b', 'c'];

let p = json;
path.forEach((k) => {
    p=p[k];
});
  • 链表里的元素存储不是连续的,之间通过 next 连接
  • JavaScript中没有链表,但可以用Object模拟链表。
  • 链表常用操作:修改next、遍历链表。
  • JS 中的原型链也是一一个链表。
  • 使用链表指针可以获取JSON的节点值。

10. leetcode常见题解

10.1 简单

1. 删除链表中的节点

难度:简单

题解:删除链表中的节点

2. 反转链表

难度:简单

题解:反转链表(递归法+双指针迭代法)

3. 删除排序链表中的重复元素

难度:简单

题解:删除排序链表中的重复元素

4. 合并两个有序链表

难度:简单

题解:合并两个有序链表(递归与迭代法)

10.2 中等

1. 两数相加

难度:中等

题解:两数相加

2. 两数相加II

难度:中等

题解:两数相加II(栈实现)

3. 反转链表 II

难度:中等

题解:反转链表II(迭代法)

4. 两两交换链表中的节点

难度:中等

题解:两两交换链表中的节点(迭代法与递归法)

5. 删除排序链表中的重复元素 II

难度:中等

题解:[删除排序链表中的重复元素 II

6. 旋转链表

难度:中等

题解:旋转链表(环解法)

7. 排序链表

难度:中等

题解:

10.3 困难

1. K 个一组翻转链表

难度:困难

题解:K 个一组翻转链表

难度:困难

2. 合并K个升序链表

难度:困难

题解:

11. 双指针问题

11.1 快慢指针

1. 链表中倒数第k个节点

难度:简单

题解:链表中倒数第k个节点(快慢指针)

2. 删除链表的倒数第 N 个结点

难度:中等

题解:删除链表的倒数第N个节点(双指针)

3. 链表的中间结点

难度:简单

题解:链表的中间节点(快慢指针)

4. 环形链表

难度:简单

题解:环形链表(快慢指针)

5. 环形链表II

难度:中等

题解:环形链表II(快慢指针)

11.2 左右指针

1. 回文链表

难度:简单

题解:回文链表(多解法)

2. 反转字符串

题解:反转字符串(左右指针)

11.3 其他双指针

1. 爬楼梯

难度:简单

题解:爬楼梯(双指针)

2. 移动0

难度:简单

题解:移动零(双指针)

3. 盛水最多的容器

难度:中等

题解:盛水最多的容器(双指针)

4. (高频老题)三数之和

难度:中等

题解:三数之和(排序+双指针)

5. 比较版本号

难度:中等

题解:

具体题解参考: leetcode刷题之路

持续更新ing...