[譯] JavaScript 中的數據結構:寫給前端軟件工程師

原文連接: Data Structures in JavaScript: For Frontend Software Engineers

做者:Thon Lyjavascript

前言

隨着愈來愈多的業務邏輯從後端轉移到前端,前端工程中的專業技能變得愈發重要。做爲一名前端工程師,咱們能夠依靠相似 React 同樣的視圖類框架來實現高效的產出。但視圖框架反過來又依賴相似 Redux 這樣的狀態管理庫來管理狀態。React 和 Redux 一塊兒組成響應式編程範式,響應式編程容許視圖隨着狀態的改變而更新。然後端也愈來愈多地只做爲 API 服務器,僅提供端點以檢索和更新數據。實際上,後端開始「趨向」於僅充當前端的數據庫,並指望前端工程師能夠處理全部的控制器邏輯。愈來愈流行的微服務以及 GraphGL 也證明了這種發展趨勢。html

如今,前端工程師除了對 HTML、CSS 有着紮實的理解以外,也應該掌握 JavaScript。特別是,隨着客戶端上的數據存儲成爲服務器上數據庫的「副本」,熟知慣用數據結構的知識已經變得相當重要。從必定意義上講,衡量一個工程師的經驗水平,能夠經過 TA 是否有能力能夠在各類複雜的狀況下選擇合適的特定數據結構來進行判斷。前端

水平低的程序員擔憂代碼。而優秀的程序員則關心數據結構及其之間的關係。

 —— Linus Torvalds,Linux 和 Git 的做者java

從本質上說,有三種基本數據結構類型:棧(Stack)隊列(Queue)是一種相似數組的數據結構,它們之間的區別僅僅體如今數據項的插入和移除的方式上。鏈表(Linked List)樹(Tree)圖(Graph)則是另外一種節點與節點之間維持引用關係的數據結構。最後一種數據結構,散列表(Hash Table,也稱哈希表)依賴散列函數(Hash Function)來保存和定位數據。node

就複雜性而言,隊列是最簡單的兩種,而且兩者均可以經過鏈表來進行構造。則是最複雜的,由於它們繼承了鏈表的概念。散列表須要利用這些數據結構來可靠地執行。就執行效率而言,鏈表在對數據的記錄和排序上表現最好,同時散列表也更加擅長查找和提取數據。程序員

爲了解釋緣由並說明什麼時候使用對應的數據結構,本文將遵循這些依賴關係的順序。下面讓咱們開始進入正題。算法

棧(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 引擎自身,這些數據結構將硅芯片上實際上打開和關閉的「開關」具體化爲逼真的「對象」。儘管只是數字化的,但這些對象對社會的影響倒是巨大的。 若是你可以自由且安全地閱讀本文,就已經證實了互聯網的出色架構及其數據結構。 然而,這僅僅是開始。 相信將來幾十年後,人工智能和去中心化區塊鏈將從新定義人類的意義並深入影響咱們的平常生活。 存在主義的看法和制度的「脫媒」將是互聯網最終成熟的標誌。

相關文章
相關標籤/搜索