[译] JavaScript 中的数据结构:写给前端软件工程师

2,191 阅读16分钟
原文链接:Data Structures in JavaScript: For Frontend Software Engineers

作者:Thon Ly

前言

随着越来越多的业务逻辑从后端转移到前端,前端工程中的专业技能变得愈发重要。作为一名前端工程师,我们可以依靠类似 React 一样的视图类框架来实现高效的产出。但视图框架反过来又依赖类似 Redux 这样的状态管理库来管理状态。React 和 Redux 一起组成响应式编程范式,响应式编程允许视图随着状态的改变而更新。而后端也越来越多地只作为 API 服务器,仅提供端点以检索和更新数据。实际上,后端开始「趋向」于仅充当前端的数据库,并期望前端工程师可以处理所有的控制器逻辑。越来越流行的微服务以及 GraphGL 也证实了这种发展趋势。

现在,前端工程师除了对 HTML、CSS 有着扎实的理解之外,也应该掌握 JavaScript。特别是,随着客户端上的数据存储成为服务器上数据库的「副本」,熟知惯用数据结构的知识已经变得至关重要。从一定意义上讲,衡量一个工程师的经验水平,可以通过 TA 是否有能力可以在各种复杂的情况下选择合适的特定数据结构来进行判断。

水平低的程序员担心代码。而优秀的程序员则关心数据结构及其之间的关系。

 —— Linus Torvalds,Linux 和 Git 的作者

从本质上说,有三种基本数据结构类型:栈(Stack)队列(Queue)是一种类似数组的数据结构,它们之间的区别仅仅体现在数据项的插入和移除的方式上。链表(Linked List)树(Tree)图(Graph)则是另一种节点与节点之间维持引用关系的数据结构。最后一种数据结构,散列表(Hash Table,也称哈希表)依赖散列函数(Hash Function)来保存和定位数据。

就复杂性而言,队列是最简单的两种,并且二者都可以通过链表来进行构造。则是最复杂的,因为它们继承了链表的概念。散列表需要利用这些数据结构来可靠地执行。就执行效率而言,链表在对数据的记录和排序上表现最好,同时散列表也更加擅长查找和提取数据。

为了解释原因并说明何时使用对应的数据结构,本文将遵循这些依赖关系的顺序。下面让我们开始进入正题。

栈(Stack)

JavaScript 中最重要的栈当数调用栈,当执行函数时,会将函数作用域压入调用栈。在编程方式上,栈就是一个带有两个基本操作的数组(Array): push(压入) 操作和 pop (弹出)操作。push 操作可以向数组的顶部添加元素,pop 操作则在数组顶部删除元素。换句话说,栈遵循「后进先出(Last In, First Out. LIFO)」的规则。

下面代码是一个栈的示例。注意,我们可以颠倒栈的顺序:原先的栈底变成栈顶,原先的栈顶变成栈底。正因如此,可以使用数组的 unshift 和 shift 方法来分别代替 push 和 pop 方法。

class Stack {
    constructor(...items) {
        this.reverse = false;
        this.stack = [...items];
    }

    push(...items) {
        return this.reverse
            ? this.stack.unshift(...items)
            : this.stack.push(...items);
    }

    pop() {
        return this.reverse ? this.stack.shift() : this.stack.pop();
    }
}
const stack = new Stack(4, 5);
stack.push(1, 2, 3); //  [4, 5, 1, 2, 3]

const stack = new Stack(4, 5);
stack.reverse = true;
stack.push(1, 2, 3); // [1, 2, 3, 4, 5]

const stack = new Stack(1, 2, 3);
stack.pop(); // 3

const stack = new Stack(1, 2, 3);
stack.reverse = true;
stack.pop(); // 1

随着栈中成员数量的增加,push 和 pop 的性能也比 unshift 和 shift 要更好,因为 unshift 和 shift 需要把所有的成员的索引向后移动一位,而 push 和 pop 则不需要。

队列(Queue)

JavaScript 是事件驱动型的编程语言,这种特性使得 JS 可以支持非阻塞操作。在浏览器内部,通过使用事件队列来对监听函数进行入队,以及使用 Event loop 来监听注册的时间,浏览器仅需管理一条线程就能运行整个 JavaScript 代码。为了在单线程环境中支持异步性(以节省 CPU 资源并增强 Web 体验),监听器函数仅在调用栈为空时才出队并执行。Promise 函数依赖这种事件驱动结构来对异步代码实现「同步风格」的执行,同时不会阻塞其他的操作。

在编程方式上,队列也是一个带有两种基本操作的数组:unshift 操作和 pop 操作。unshift 将一个项目加入到数组的尾部(入队),pop 操作则是将项目成员从数组的首部移除(出队)。换句话说,队列遵循「先进先出(First In, First Out. FIFO)」的规则。如果方向颠倒,可以使用 push 和 shift 分别代替 unshift 和 pop。

下面是一个队列的代码示例:

class Queue {
    constructor(...items) {
        this.reverse = false;
        this.queue = [...items];
    }

    enqueue(...items) {
        return this.reverse
            ? this.queue.push(...items)
            : this.queue.unshift(...items);
    }

    dequeue() {
        return this.reverse ? this.queue.shift() : this.queue.pop();
    }
}

const queue = new Queue(4, 5);
queue.enqueue(1, 2, 3); // [1, 2, 3, 4, 5]

const queue = new Queue(4, 5);
queue.reverse = true;
queue.enqueue(1, 2, 3); // [4, 5, 1, 2, 3]

const queue = new Queue(1, 2, 3);
queue.dequeue(); // 3 

const queue = new Queue(1, 2, 3);
queue.reverse = true;
queue.dequeue(); // 1

链表(Linked List)

如同数组一样,链表也是按照顺序存储数据元素,但却不是通过维护索引实现,链表是通过指向其他节点的指针实现。第一个节点称之为头节点,最后一个节点被称为尾节点。在单向链表中,每个节点都只有一个指针,该指针指向下一个节点。我们从头结点开始向后遍历链表中剩余的节点。在双向链表中,还有一个指向前一个节点的指针,因此可以实现从尾部向头部「反向」遍历链表。

由于只需要改变节点的指针,所以链表插入和删除某个节点操作所消耗的时间是固定的。在数组中执行同样的操作需要消耗的时间却是线性增长的,因为紧随其后的节点都需要被移动。另外,只要还有空间,链表就可以增长。因此,即使是自动调整大小的「动态」数组,操作代价也可能变得出乎意料的高。为了查找或者编辑链表中的某个元素,可能会遍历整个链表,遍历的时间复杂度是线性的。但如果使用数组索引,却只需要花费一丁点的时间。

如同数组一样,链表也可以用作栈来操作。只需将头部作为唯一的插入和取出位置即可。链表同样也可以用作队列来操作。可以通过双向链表来实现在尾部插入和在头部删除的操作。对于元素数量庞大的队列来说,使用链表实现的队列,与使用数组实现的队列相比有更好的性能,这是因为数组的 shift 和 unshift 操作同样会移动所有紧随其后的节点,这个操作的时间复杂度则是线性的。

链表在客户端和服务端上都是很有用的。在客户端,状态管理库比如 Redux 将其中间件逻辑以链表的方式进行组织。当发出 action 时,action 就会从一个中间件传送到下一个中间件,然后所有的节点都被访问到,直到到达 reducer 。在服务器端,如同 Express 一样的 Web 框架也使用相同的原理来组织自己的中间件逻辑。当接收到一个请求(request)时,请求会从一个中间件传送到下一个中间件,直到响应(response)被发出。

下面是一个链表的代码示例:

class Node {
    constructor(value, next, prev) {
        this.value = value;
        this.next = next;
        this.prev = prev;
    }
}

class LinkedList {
    constructor() {
        this.head = null;
        this.tail = null;
    }

    addToHead(value) {
        const node = new Node(value, null, this.head);
        if (this.head) this.head.next = node;
        else this.tail = node;
        this.head = node;
    }

    addToTail(value) {
        const node = new Node(value, this.tail, null);
        if (this.tail) this.tail.prev = node;
        else this.head = node;
        this.tail = node;
    }

    removeHead() {
        if (!this.head) return null;
        const value = this.head.value;
        this.head = this.head.prev;
        if (this.head) this.head.next = null;
        else this.tail = null;
        return value;
    }

    removeTail() {
        if (!this.tail) return null;
        const value = this.tail.value;
        this.tail = this.tail.next;
        if (this.tail) this.tail.prev = null;
        else this.head = null;
        return value;
    }

    search(value) {
        let current = this.head;
        while (current) {
            if (current.value === value) return value;
            current = current.prev;
        }
        return null;
    }

    indexOf(value) {
        const indexes = [];
        let current = this.tail;
        let index = 0;
        while (current) {
            if (current.value === value) indexes.push(index);
            current = current.next;
            index++;
        }
        return indexes;
    }
}

树(Tree)

树(Tree)类似于链表,但不同的是树在不同的层级下引用多个子节点。换句话说,每个节点最多只能有一个父节点。文档对象模型(DOM)就是这样的结构,有一个根节点 html, html 中的分支都包含在 head 和 body 节点中,然后进一步细分为所有我们熟悉的 HTML 标签。在 JS 内部,原型继承和与 React 组件的组合也会产生树结构。当然,React 中作为代表内存中 DOM 结构的虚拟 DOM,同样也是一个树结构。

二叉查找树是一种比较特殊的树,因为二叉树中的每个节点最多只能有两个子节点。左子节点的值必须小于或等于其父节点的值,同时右子节点的值必须大于其父节点的值。以这种方式组织和平衡树结构,由于每次迭代中我们可以忽略二分之一的分支,所以就可以在对数时间内查找任意值。插入和删除操作同样也在对数时间内完成。除此之外,可以轻松地最左叶节点中和最右叶节点中,分别找到最小值和最大值。

对树的遍历可以以垂直或水平过程进行。深度优先遍历(DFT)是在垂直方向上,其递归算法相较于迭代算法来说更加优雅。可以通过前序、中序、后序来遍历节点。如果需要先访问根节点,然后再检查子节点,应该选择前序遍历。如果需要先访问子节点,然后再检查根节点,则应选择后序遍历。顾名思义,中序遍历就是按照左、根、右的顺序遍历节点。这些性质使得二叉查找树非常适合排序。

广度优先遍历(BFT)是在水平方向上,其迭代算法相较于递归算法来说更加优雅。广度优先遍历需要使用队列来跟最每次迭代的所有子节点。但是,此类队列所需的内存可能并不小。 如果树的形状更宽,则广度优先遍历是更好的选择。 同样,广度优先遍历在任何两个节点之间采用的路径是最短的路径。

下面是一个二叉查找树的代码示例:

class Tree {
    constructor(value) {
        this.value = value;
        this.left = null;
        this.right = null;
    }

    insert(value) {
        if (value <= this.value) {
            if (!this.left) this.left = new Tree(value);
            else this.left.insert(value);
        } else {
            if (!this.right) this.right = new Tree(value);
            else this.right.insert(value);
        }
    }

    contains(value) {
        if (value === this.value) return true;
        if (value < this.value) {
            if (!this.left) return false;
            else return this.left.contains(value);
        } else {
            if (!this.right) return false;
            else return this.right.contains(value);
        }
    }

    depthFirstTraverse(order, callback) {
        order === "pre" && callback(this.value);
        this.left && this.left.depthFirstTraverse(order, callback);
        order === "in" && callback(this.value);
        this.right && this.right.depthFirstTraverse(order, callback);
        order === "post" && callback(this.value);
    }

    breadthFirstTraverse(callback) {
        const queue = [this];
        while (queue.length) {
            const root = queue.shift();
            callback(root.value);
            root.left && queue.push(root.left);
            root.right && queue.push(root.right);
        }
    }

    getMinValue() {
        if (this.left) return this.left.getMinValue();
        return this.value;
    }

    getMaxValue() {
        if (this.right) return this.right.getMaxValue();
        return this.value;
    }
}

图(Graph)

如果一棵树可以拥有多个父节点,那么它就变成了一张图(Graph)。在图中连接每个节点的,可以是有方向的,也可以是无方向的,可以是有权重的,也可以是无权重的。既有方向也有权重的边类似于向量。

社交网络和互联网本身也是图。 自然界中最复杂的图是我们人类的大脑。现在,我们试图将神经网络复制到机器中,期望使机器具有「超级智能」。

散列表(Hash Table)

散列表是一种包含键值(Key-Value)对的,类似字典的数据结构。在内存中的每对键值都通过散列函数(Hash Function)来确定其位置,散列函数接受键(Key)作为参数,并返回该键值对应当插入或读取的地址。如果两个或者多个键返回的地址是相同的,则会发生冲突(Collision)。为了健壮起见,getter 和 setter 应该预见到这些事件,以确保可以恢复所有的数据,并且不会覆盖任何数据。 通常,可以使用链表来实现最简单的解决方案,或者搞一个超大的表也是可行的。

如果知道地址会按照整数排列,就可以直接使用数组来存储键值对。对于实现更复杂的地址映射,可以使用 Map 或者 Object。散列表查找和插入数据的平均时间是固定的。由于冲突和大小的调整,这种微小的成本可能会增加到线性时间。在实践中,我们可以假设散列函数能够非常聪明地减少发生冲突和调整,以及降低冲突和调整的操作成本。如果键代表地址,那么就不需要散列,那么简单的对象文字就足够了。当然,总会有一个权衡。 键和值之间的简单对应关系以及键和地址之间的简单关联性,牺牲了数据之间的关系。 因此,散列表对于存储数据而言并不是最佳的方案。

如果权衡的结果是需要从存储中检索数据,那么没有别的数据结构比散列表更适合查找、插入以及删除。毫无疑问,散列表被应用到各个方面。从数据库到服务器再到客户端,散列表特别是散列函数,对于应用软件的性能以及安全性是至关重要的。数据库查询的速度很大程度上依赖于保持指向记录的索引表的顺序。如此一来,二分查找就可以在对数时间内执行,尤其是对大数据领域来说,这是一个巨大的性能优势。

在客户端以及服务器端,许多流行的库使用记忆化,以最大限度地提高性能。通过在散列表中维护输入和输出的记录,对于相同的输入,函数只需要执行一次即可。非常流行的 Reselect 库在启用 Redux 的应用中,使用这种缓存策略来优化 mapStateToProps 函数。实际上,JavaScript 引擎在幕后也利用散列表调用堆来存储我们创建的所有变量和原语。工程师可从调用堆上的指针来访问它们。

互联网本身也依赖散列算法来安全地运行。互联网的结构使得任何计算机都可以通过互连设备的网络与任何其他计算机通信。每当一台设备登录到互联网,它也可以成为让数据流通过的路由器。但是,这是一把双刃剑。分散的结构意味着在网络中的任意设备都能侦听和篡改帮助中继的数据包。诸如 MD5 和 SHA256 之类的散列函数在阻止像中间人这样的攻击中扮演着至关重要的角色。只因用上了散列函数,所以通过 HTTPS 进行电子商务就很安全了。

受互联网的启发,区块链技术寻求在协议层面上将网络结构开源。通过使用散列函数来为每个数据块创建不变的「指纹」。基本上,整个数据库都可以在网络上公开存在,任何人都可以查看并且向其贡献数据。从结构上讲,区块链只是加密哈希的二进制树的单链接列表。哈希加密如此神秘,令人难以破解,以至于任何人都能够公开地创建和更新财务交易的数据库。更加令人难以置信的是,区块链还可以拥有创造「金钱」的强大力量。 以前只有政府和中央银行才有这种权利,现在任何人都可以安全地创建自己的「货币」。这是 Ethereum 的创始人以及比特币的创始人中本聪(假名)的深刻见解。

随着越来越多的数据库走向开源,对能够抽象出所有低级加密复杂性的前端工程师的需求也变得日益复杂。在未来,主要的差异化将会是用户体验。

下面是一个散列表的代码示例:

class Node {
    constructor(key, value) {
        this.key = key;
        this.value = value;
        this.next = null;
    }
}

class Table {
    constructor(size) {
        this.cells = new Array(size);
    }

    hash(key) {
        let total = 0;
        for (let i = 0; i < key.length; i++) total += key.charCodeAt(i);
        return total % this.cells.length;
    }

    insert(key, value) {
        const hash = this.hash(key);
        if (!this.cells[hash]) {
            this.cells[hash] = new Node(key, value);
        } else if (this.cells[hash].key === key) {
            this.cells[hash].value = value;
        } else {
            let node = this.cells[hash];
            while (node.next) {
                if (node.next.key === key) {
                    node.next.value = value;
                    return;
                }
                node = node.next;
            }
            node.next = new Node(key, value);
        }
    }

    get(key) {
        const hash = this.hash(key);
        if (!this.cells[hash]) return null;
        else {
            let node = this.cells[hash];
            while (node) {
                if (node.key === key) return node.value;
                node = node.next;
            }
            return null;
        }
    }

    getAll() {
        const table = [];
        for (let i = 0; i < this.cells.length; i++) {
            const cell = [];
            let node = this.cells[i];
            while (node) {
                cell.push(node.value);
                node = node.next;
            }
            table.push(cell);
        }
        return table;
    }
}

关于使用上述及其他数据结构的算法习题,可以查阅:Algorithms in JavaScript: 40 Problems, Solutions, and Explanations

总结

随着逻辑越来越多地从服务端转义到客户端,在前端的数据层将成为首要。数据层的正确管理需要掌握逻辑所依据的数据结构。没有哪个数据结构在每个情景下都是完美的,因为对某个方面的优化往往意味着失去其他方面的优势。有些数据结构在数据排序方面具有很高的效率,而有些数据结构则更擅长查找数据。通常,一个为另一个而牺牲。在一个极端,链表的健壮性最好,并且能够用来创建队列(线性时间)。在其他方面,没有哪个数据结构可以赶得上散列表的查找速度(恒定时间)。则处于中间(对数时间),只有可以描绘自然界最复杂的结构,比如人类的大脑。具有能在合适时机使用合适数据结构以及了解其原因的技能,才是「明星工程师」所必备的素质。

到处都能找到示例中的这些数据结构。从数据库到服务器,再到客户端,甚至是 JavaScript 引擎自身,这些数据结构将硅芯片上实际上打开和关闭的「开关」具体化为逼真的「对象」。尽管只是数字化的,但这些对象对社会的影响却是巨大的。 如果你能够自由且安全地阅读本文,就已经证明了互联网的出色架构及其数据结构。 然而,这仅仅是开始。 相信未来几十年后,人工智能和去中心化区块链将重新定义人类的意义并深刻影响我们的日常生活。 存在主义的见解和制度的「脱媒」将是互联网最终成熟的标志。