我接觸過的前端數據結構與算法


咱們已經討論過了前端與計算機基礎的不少話題,諸如SQL面向對象多線程,本篇將討論數據結構與算法,以我接觸過的一些例子作爲說明。javascript

1. 遞歸

遞歸就是本身調本身,遞歸在前端裏面算是一種比較經常使用的算法。假設如今有一堆數據要處理,要實現上一次請求完成了,才能去調下一個請求。一個是能夠用Promise,就像《前端與SQL》這篇文章裏面提到的。可是有時候並不想引入Promise,能簡單處理先簡單處理。這個時候就能夠用遞歸,以下代碼所示:css

var ids = [34112, 98325, 68125];
(function sendRequest(){
    var id = ids.shift();
    if(id){
        $.ajax({url: "/get", data: {id}}).always(function(){
            //do sth.
            console.log("finished");
            sendRequest();
        });
    } else {
        console.log("finished");
    }
})(); 
複製代碼

上面代碼定義了一個sendRequest的函數,在請求完成以後再調一下本身。每次調以前先取一個數據,若是數組已經爲空,則說明處理完了。這樣就用簡單的方式實現了串行請求不堵塞的功能。html

再來說另一個場景:DOM樹。前端

因爲DOM是一棵樹,而樹的定義自己就是用的遞歸定義,因此用遞歸的方法處理樹,會很是地簡單天然。例如用遞歸實現一個查DOM的功能document.getElementById。java

function getElementById(node, id){
    if(!node) return null;
    if(node.id === id) return node;
    for(var i = 0; i < node.childNodes.length; i++){
        var found = getElementById(node.childNodes[i], id);
        if(found) return found;
    }
    return null;
}
getElementById(document, "d-cal");複製代碼

document是DOM樹的根結點,通常從document開始往下找。在for循環裏面先找document的全部子結點,對全部子結點遞歸查找他們的子結點,一層一層地往下查找。若是已經到了葉子結點了尚未找到,則在第二行代碼的判斷裏面返回null,返回以後for循環的i加1,繼續下一個子結點。若是當前結點的id符合查找條件,則一層層地返回。因此這是一個深度優先的遍歷,每次都先從根結點一直往下直到葉子結點,再從下往上返回。node

最後在控制檯驗證一下,執行結果以下圖所示:git

使用遞歸的優勢是代碼簡單易懂,缺點是效率比不上非遞歸的實現。Chrome瀏覽器的查DOM是使用非遞歸實現。非遞歸要怎麼實現呢?github

以下代碼:web

function getByElementId(node, id){
    //遍歷全部的Node
    while(node){
        if(node.id === id) return node;
        node = nextElement(node);
    }
    return null;
}複製代碼

仍是依次遍歷全部的DOM結點,只是這一次改爲一個while循環,函數nextElement負責找到下一個結點。因此關鍵在於這個nextElement如何非遞歸實現,以下代碼所示:ajax

function nextElement(node){
    if(node.children.length) {
        return node.children[0];
    }
    if(node.nextElementSibling){
        return node.nextElementSibling;
    }
    while(node.parentNode){
        if(node.parentNode.nextElementSibling) {
            return node.parentNode.nextElementSibling;
        }
        node = node.parentNode;
    }
    return null;
}複製代碼

仍是用深度遍歷,先找當前結點的子結點,若是它有子結點,則下一個元素就是它的第一個子結點,不然判斷它是否有相鄰元素,若是有則返回它的下一個相鄰元素。若是它既沒有子結點,也沒有下一個相鄰元素,則要往上返回它的父結點的下一個相鄰元素,至關於上面遞歸實現裏面的for循環的i加1.

在控制檯裏面運行這段代碼,一樣也能夠正確地輸出結果。無論是非遞歸仍是遞歸,它們都是深度優先遍歷,這個過程以下圖所示。

實際上getElementById瀏覽器是用的一個哈希map存儲的,根據id直接映射到DOM結點,而getElementsByClassName就是用的這樣的非遞歸查找。

上面是單個選擇器的查找,按id,按class等,多個選擇器應該如何查找呢?

2. 複雜選擇器的查DOM

如實現一個document.querySelector:

document.querySelector(".mls-info > div .copyright-content")複製代碼

首先把複雜選擇器作一個解析,序列爲如下格式:

//把selector解析爲
var selectors = [
{relation: "descendant",  matchType: "class", value: "copyright-content"},
{relation: "child",       matchType: "tag",   value: "div"},
{relation: "subSelector", matchType: "class", value: "mls-info"}];複製代碼

從右往左,第一個selector是.copyright-content,它是一個類選擇器,因此它的matchType是class,它和第二個選擇器是祖先和子孫關係,所以它的relation是descendant;同理第二個選擇器的matchType是tag,而relation是child,表示是第三個選擇器的直接子結點;第三個選擇器也是class,可是它沒有下一個選擇器了,relation用subSelector表示。

matchType的做用就在於用來比較當前選擇器是否match,以下代碼所示:

function match(node, selector){
    if(node === document) return false;
    switch(selector.matchType){
        //若是是類選擇器
        case "class":
            return node.className.trim().split(/ +/).indexOf(selector.value) >= 0;

        //若是是標籤選擇器
        case "tag":
            return node.tagName.toLowerCase() === selector.value. toLowerCase();

        default:
            throw "unknown selector match type";
    }
}複製代碼

根據不一樣的matchType作不一樣的匹配。

在匹配的時候,從右往左,依次比較每一個選擇器是否match. 在比較下一個選擇器的時候,須要找到相應的DOM結點,若是當前選擇器是下一個選擇器的子孫時,則須要比較當前選擇器全部的祖先結點,一直往上直到document;而若是是直接子元素的關係,則比較它的父結點便可。因此須要有一個找到下一個目標結點的函數:

function nextTarget(node, selector){
    if(!node || node === document) return null;
    switch(selector.relation){
        case "descendant":
            return {node: node.parentNode, hasNext: true};
        case "child":
            return {node: node.parentNode, hasNext: false};
        case "sibling":
            return {node: node.previousSibling, hasNext: true};
        default:
            throw "unknown selector relation type";
          //hasNext表示當前選擇器relation是否容許繼續找下一個節點
    }
}複製代碼

有了nextTarge和match這兩個函數就能夠開始遍歷DOM,以下代碼所示:

最外層的while循環和簡單選擇器同樣,都是要遍歷全部DOM結點。對於每一個結點,先判斷第一個選擇器是否match,若是不match的話,則繼續下一個結點,若是不是標籤選擇器,對於絕大多數結點將會在這裏判斷不經過。若是第一個選擇器match了,則根據第一個選擇器的relation,找到下一個target,判斷下一個targe是否match下一個selector,只要有一個target匹配上了,則退出裏層的while循環,繼續下一個選擇器,若是全部的selector都能匹配上說明匹配成功。若是有一個selecotr的全部target都沒有match,則說明匹配失敗,退出selector的for循環,直接從頭開始對下一個DOM結點進行匹配。

這樣就實現了一個複雜選擇器的查DOM。寫這個的目的並非要你本身寫一個查DOM的函數拿去用,而是要明白查DOM的過程是怎麼樣的,能夠怎麼實現,瀏覽器又是怎麼實現的。還有能夠怎麼遍歷DOM樹,當明白這個過程的時候,遇到相似的問題,就能夠觸類旁通。

最後在瀏覽器上運行一下,以下圖所示:

3. 重複值處理

如今有個問題,以下圖所示:

當地圖往下拖的時候要更新地圖上的房源標籤數據,上圖綠框表示不變的標籤,而黃框表示新加的房源。

—後端每次都會把當前地圖可見區域的房源返回給我,當用戶拖動的時候須要知道哪些是原先已經有的房源,哪些是新加的。把新加的房源畫上,而把超出區域的房源刪掉,已有的房源保持不動。所以須要對比當前房源和新的結果哪些是重複的。由於若是不這樣作的話,改爲每次都是所有刪掉再從新畫,已有的房源標籤就會閃一下。所以爲了不閃動作一個增量更新。

把這個問題抽象一下就變成:—給兩個數組,須要找出第一個數組裏面的重複值和非重複值。即有一個數組保存上一次狀態的房源,而另外一個數組是當前狀態的新房源數據。找到的重複值是須要保留,找到非重複值是要刪掉的。

最直觀的方法是使用雙重循環。

(1)雙重循環

以下代碼所示:

var lastHouses = [];
filterHouse: function(houses){
    if(lastHouses === null){
        lastHouses = houses;
        return {
            remainsHouses: [], 
            newHouses: houses
        };  
    }   
    var remainsHouses = [], 
        newHouses = []; 

    for(var i = 0; i < houses.length; i++){
        var isNewHouse = true;
        for(var j = 0; j < lastHouses.length; j++){
            if(houses[i].id === lastHouses[j].id){
                isNewHouse = false;
                remainsHouses.push(lastHouses[j]);
                break;
            }   
        }   
        if(isNewHouse){
            newHouses.push(houses[i]);
        }   
    }   
    lastHouses = remainsHouses.concat(newHouses);
    return {
        remainsHouses: remainsHouses,
        newHouses: newHouses
    };  
}
複製代碼

上面代碼有一個雙重for循環,對新數據的每一個元素,判斷老數據裏面是否已經有了,若是有的話則說明是重複值,若是老數據循環了一遍都沒找到,則說明是新數據。因爲用到了雙重循環,因此這個算法的時間複雜度爲O(N2),對於百級的數據還好,對於千級的數據可能會有壓力,由於最壞狀況下要比較1000000次。

—(2)使用Set

以下代碼所示:

var lastHouses = new Set();
function filterHouse(houses){
    var remainsHouses = [],
        newHouses = [];
    for(var i = houses.length - 1; i >= 0; i--){
        if(lastHouses.has(houses[i].id)){
            remainsHouses.push(houses[i]);
        } else {
            newHouses.push(houses[i]);
        }
    }
    for(var i = 0; i < newHouses.length; i++){
        lastHouses.add(newHouses[i].id);
    }
    return {remainsHouses: remainsHouses, 
            newHouses: newHouses};
}複製代碼

老數據的存儲lastHouses從數組改爲set,但若是一開始就是數組呢,就像問題抽象裏面說的給兩個數組?那就用這個數組的數據初始化一個Set.

使用Set和使用Array的區別在於能夠減小一重循環,調用Set.prototype.has的函數。Set通常是使用紅黑樹實現的,紅黑樹是一種平衡查找二叉樹,它的查找時間複雜度爲O(logN)。因此時間上進行了改進,從O(N)變成O(logN),而整體時間從O(N2)變成O(NlogN)。實際上,Chrome V8的Set是用哈希實現的,它是一個哈希Set,查找時間複雜度爲O(1),因此整體的時間複雜度是O(N).

無論是O(NlogN)仍是O(N),表面上看它們的時間要比O(N2)的少。但實際上須要注意的是它們前面還有一個係數。使用Set在後面更新lastHouses的時候也是須要時間的:

for(var i = 0; i < newHouses.length; i++){
    lastHouses.add(newHouses[i].id);
}複製代碼

若是Set是用樹的實現,這段代碼是時間複雜度爲O(NlogN),因此總的時間爲O(2NlogN),可是因爲大O是不考慮係數的,O(2NlogN) 仍是等於O(NlogN),當數據量比較小的時侯,這個係數會起到很大的做用,而數據量比較大的時候,指數級增加的O(N2)將會遠遠超過這個係數,哈希的實現也是一樣道理。因此當數據量比較小時,如只有一兩百可直接使用雙重循環處理便可。

上面的代碼有點冗長,咱們能夠用ES6的新特性改寫一下,變得更加的簡潔:

function filterHouse(houses){
    var remainsHouses = [],
        newHouses = []; 
    houses.map(house => lastHouses.has(house.id) ? remainsHouses.push(house) 
                        : newHouses.push(house));
    newHouses.map(house => lastHouses.add(house.id));
    return {remainsHouses, newHouses};
}複製代碼

代碼從16行變成了8行,減小了一半。

(3)使用Map

使用Map也是相似的,代碼以下所示:

var lastHouses = new Map();
function filterHouse(houses){
    var remainsHouses = [],
        newHouses = [];
    houses.map(house => lastHouses.has(house.id) ? remainsHouses.push(house)
			: newHouses.push(house));
    newHouses.map(house => lastHouses.set(house.id, house));
    return {remainsHouses, newHouses};
}複製代碼

哈希的查找複雜度爲O(1),所以總的時間複雜度爲O(N),Set/Map都是這樣,代價是哈希的存儲空間一般爲數據大小的兩倍

(4)時間比較

最後作下時間比較,爲此得先造點數據,比較數據量分別爲N = 100, 1000, 10000的時間,有N/2的id是重複的,另一半的id是不同的。用如下代碼生成:

var N = 1000;
var lastHouses = new Array(N);
var newHouses = new Array(N);
var data = new Array(N);
for(var i = 0; i < N / 2; i++){
    var sameNumId = i;
    lastHouses[i] = newHouses[i] = {id: sameNumId};
}
for(; i < N; i++){
    lastHouses[i] = {id: N + i};
    newHouses[i] = {id: 2 * N + i};
}複製代碼

而後須要—將重複的數據隨機分佈,可用如下函數把一個數組的元素隨機分佈:

//打散
function randomIndex(arr){
    for(var i = 0; i < arr.length; i++){
        var swapIndex = parseInt(Math.random() * (arr.length - i)) + i;
        var tmp = arr[i];
        arr[i] = arr[swapIndex];
        arr[swapIndex] = tmp;
    }
}
randomIndex(lastHouses);
randomIndex(newHouses);複製代碼

Set/Map的數據:

var lastHousesSet = new Set();
for(var i = 0; i < N; i++){
    lastHousesSet.add(lastHouses[i].id);
}

var lastHousesMap = new Map();
for(var i = 0; i < N; i++){
    lastHousesMap.set(lastHouses[i].id, lastHouses[i]);
}複製代碼

分別重複100次,比較時間:

console.time("for time");
for(var i = 0; i < 100; i++){
    filterHouse(newHouses);
}
console.timeEnd("for time");

console.time("Set time");
for(var i = 0; i < 100; i++){
    filterHouseSet(newHouses);
}
console.timeEnd("Set time");

console.time("Map time");
for(var i = 0; i < 100; i++){
    filterHouseMap(newHouses);
}
console.timeEnd("Map time");複製代碼

使用Chrome 59,當N = 100時,時間爲: for < Set < Map,以下圖所示,執行三次:

當N = 1000時,時間爲:Set = Map < for,以下圖所示:

當N = 10000時,時間爲Set = Map << for,以下圖所示:

能夠看出,Set和Map的時間基本一致,當數據量小時,for時間更少,但數據量多時Set和Map更有優點,由於指數級增加仍是挺恐怖的。這樣咱們會有一個問題,究竟Set/Map是怎麼實現的。

4. Set和Map的V8哈希實現

咱們來研究一下Chrome V8對Set/Map的實現,源碼是在chrome/src/v8/src/js/collection.js這個文件裏面,因爲Chrome一直在更新迭代,因此有可能之後Set/Map的實現會發生改變,咱們來看一下如今是怎麼實現的。

以下代碼初始化一個Set:

var set = new Set();
//數據爲20個數
var data = [3, 62, 38, 42, 14, 4, 14, 33, 56, 20, 21, 63, 49, 41, 10, 14, 24, 59, 49, 29];
for(var i = 0; i < data.length; i++){
    set.add(data[i]);
}
複製代碼

這個Set的數據結構究竟是怎麼樣的呢,是怎麼進行哈希的呢?

哈希的一個關鍵的地方是哈希算法,即對一堆數或者字符串作哈希運算獲得它們的隨機值,V8的數字哈希算法是這樣的:

function ComputeIntegerHash(key, seed) {
  var hash = key;
  hash = hash ^ seed;  //seed = 505553720
  hash = ~hash + (hash << 15);  // hash = (hash << 15) - hash - 1;
  hash = hash ^ (hash >>> 12);
  hash = hash + (hash << 2);
  hash = hash ^ (hash >>> 4);
  hash = (hash * 2057) | 0;  // hash = (hash + (hash << 3)) + (hash << 11);
  hash = hash ^ (hash >>> 16);
  return hash & 0x3fffffff;
}複製代碼

把數字進行各類位運算,獲得一個比較隨機的數,而後對這個數對行散射,以下所示:

var capacity = 64;
var indexes = [];
for(var i = 0; i < data.length; i++){
    indexes.push(ComputeIntegerHash(data[i], seed) 
                      & (capacity - 1)); //去掉高位
}
console.log(indexes)複製代碼

散射的目的是獲得這個數放在數組的哪一個index。

因爲有20個數,容量capacity從16開始增加,每次擴大一倍,到64的時候,能夠保證capacity > size * 2,由於只有容量是實際存儲大小的兩倍時,散射結果重複值才能比較低。

計算結果以下:

能夠看到散射的結果仍是比較均勻的,可是仍然會有重複值,如14重複了3次。

而後進行查找,例如如今要查找key = 56是否存在這個Set裏面,先把56進行哈希,而後散射,按照存放的時候一樣的過程:

function SetHas(key){
    var index = ComputeIntegerHash(56, seed) & this.capacity;
    //可能會有重複值,因此須要驗證命中的index所存放的key是相等的
    return setArray[index] !== null 
              && setArray[index] === key;
}
複製代碼

上面是哈希存儲結構的一個典型實現,可是Chrome的V8的Set/Map並非這樣實現的,略有不一樣。

哈希算法是同樣的,可是散射的時候用來去掉高位的並非用capacity,而是用capacity的一半,叫作buckets的數量,用如下面的data作說明:

var data = [9, 33, 68, 57];複製代碼

因爲初始化的buckets = 2,計算的結果以下:

因爲buckets很小,因此散射值有不少重複的,4個數裏面1重複了3次。

如今一個個的插入數據,觀察Set數據結構的變化。

(1)插入過程

a) 插入9

以下圖所示,Set的存儲結構分紅三部分,第一部分有3個元素,分別表示有效元素個數、被刪除的個數、buckets的數量,前兩個數相加就表示總的元素個數,插入9以後,元素個數加1變成1,初始化的buckets數量爲2. 第二部分對應buckets,buckets[0]表示第1個bucket所存放的原始數據的index,源碼裏面叫作entry,9在data這個數組裏面的index爲0,因此它在bucket的存放值爲0,而且bucket的散射值爲0,因此bucket[0] = 0. 第三部分是記錄key值的空間,9的entry爲0,因此它放在了3 + buckets.length + entry * 2 = 5的位置,每一個key值都有兩個元素空間,第一個存放key值,第二個是keyChain,它的做用下面將提到。

b) 插入33

如今要插入33,因爲33的bucket = 1,entry = 1,因此插入後變成這樣:

c) 插入68

68的bucket值也爲1,和33重複了,由於entry = buckets[1] = 1,不爲空,說明以前已經存過了,entry爲1指向的數組的位置爲3 + buckets.length + entry * 2 = 7,也就是說以前的那個數是放在數組7的位置,因此68的相鄰元素存放值keyChain爲7,同時bucket[1]變成68的entry爲2,以下圖所示:

d) 插入57

插入57也是一樣的道理,57的bucket值爲1,而bucket[1] = 2,所以57的相鄰元素存放3 + 2 + 2 * 2 = 9,指向9的位置,以下圖所示:

(2)查找

如今要查找33這個數,經過一樣的哈希散射,獲得33的bucket = 1,bucket[1] = 3,3指向的index位置爲11,可是11放的是57,不是要找的33,因而查看相鄰的元素爲9,非空,可繼續查找,位置9存放的是68,一樣不等於33,而相鄰的index = 10指向位置7,而7存放的就是33了,經過比較key值相等,因此這個Set裏面有33這個數。

這裏的數據總共是4個數,可是須要比較的次數比較多,key值就比較了3次,key值的相鄰keyChain值比較了2次,總共是5次,比直接來個for循環還要多。因此數據量比較小時,使用哈希存儲速度反而更慢,可是當數據量偏大時優點會比較明顯。

(3)擴容

—再繼續插入第5個數的時候,發現容量不夠了,須要繼續擴容,會把容量提高爲2倍,bucket數量變成4,再把全部元素再從新進行散射。

—Set的散射容量即bucket的值是實際元素的一半,會有大量的散射衝突,可是它的存儲空間會比較小。假設元素個數爲N,須要用來存儲的數組空間爲:3 + N / 2 + 2 * N,因此佔用的空間仍是挺大的,它用空間換時間。

(4)Map的實現

—和Set基本一致,不一樣的地方是,map多了存儲value的地方,以下代碼:

var map = new Map();
map.set(9, "hello");複製代碼

生成的數據結構爲:

固然它不是直接存的字符串「hello」,而是存放hello的指針地址,指向實際存放hello的內存位置。

(5)和JS Object的比較

—JSObject主要也是採用哈希存儲,具體我在《從Chrome源碼看JS Object的實現》這篇文件章裏面已經討論過。

和JS Map不同的地方是,JSObject的容量是元素個數的兩倍,就是上面說的哈希的典型實現。存儲結構也不同,有一個專門存放key和一個存放value的數組,若是能找到key,則拿到這個key的index去另一個數組取出value值。當發生散列值衝突時,根據當前的index,直接計算下一個查找位置:

inline static uint32_t FirstProbe(uint32_t hash, uint32_t size) { 
  return hash & (size - 1);
}

inline static uint32_t NextProbe( uint32_t last, uint32_t number, uint32_t size) { 
  return (last + number) & (size - 1);
}複製代碼

一樣地,查找的時候在下一個位置也是須要比較key值是否相等。

上面討論的都是數字的哈希,實符串如何作哈希計算呢?

(6)字符串的哈希計算

以下所示,依次對字符串的每一個字符的unicode編碼作處理:

uint32_t AddCharacterCore(uint32_t running_hash, uint16_t c) {
  running_hash += c;
  running_hash += (running_hash << 10);
  running_hash ^= (running_hash >> 6);
  return running_hash;
}

uint32_t running_hash = seed;
char *key = "hello";
for(int i = 0; i < strlen(key); i++){
    running_hash = AddCharacterCore(running_hash, key[i]);
}複製代碼


接着討論一個經典話題

5. 數組去重

以下,給一個數組,去掉裏面的重複值:

var a = [3, 62, 3, 38, 20, 42, 14, 5, 38, 29, 42];

輸出
[3, 62, 38, 20, 42, 14, 5, 29];
複製代碼

方法1:使用Set + Array

以下代碼所示:

function uniqueArray(arr){
    return Array.from(new Set(arr));
}複製代碼

在控制檯上運行:

優勢:代碼簡潔,速度快,時間複雜度爲O(N)

缺點:須要一個額外的Set和Array的存儲空間,空間複雜度爲O(N)

方法2:使用splice

以下代碼所示:

function uniqueArray(arr){
    for(var i = 0; i < arr.length - 1; i++){
        for(var j = i + 1; j < arr.length; j++){
            if(arr[j] === arr[i]){
                arr.splice(j--, 1);
            }
        }
    }
    return arr;
}複製代碼

優勢:不須要使用額外的存儲空間,空間複雜度爲O(1)
缺點:須要頻繁的內存移動,雙重循環,時間複雜度爲O(N2)

注意splice刪除元素的過程是這樣的,這個我在《從Chrome源碼看JS Array的實現》已作過詳細討論:

它是用的內存移動,並非寫個for循環一個個複製。內存移動的速度仍是很快的,最快1s能夠達到30GB,如下圖所示

方法3:只用Array

以下代碼所示:

function uniqueArray(arr){
    var retArray = [];
    for(var i = 0; i < arr.length; i++){
        if(retArray.indexOf(arr[i]) < 0){
            retArray.push(arr[i]);
        }
    }
    return retArray;
}複製代碼

時間複雜度爲O(N2),空間複雜度爲O(N)

方法4:使用Object + Array

下面代碼是goog.array的去重實現:

和方法三的區別在於,它再也不是使用Array.indexOf判斷是否已存在,而是使用Object[key]進行哈希查找,因此它的時間複雜度爲O(N),空間複雜爲O(N).


最後作一個執行時間比較,對N = 100/1000/10000,分別重複1000次,獲得下面的表格:

Object + Array最省時間,splice的方式最耗時(它比較省空間),Set + Array的簡潔方式在數據量大的時候時間將明顯少於須要O(N2)的Array,一樣是O(N2)的splice和Array,Array的時間要小於常常內存移動操做的splice。

—

實際編碼過程當中一、二、4都是能夠可取的—

—方法1 一行代碼就能夠搞定

—方法2 能夠用來添加一個Array.prototype.unique的函數

—方法4 適用於數據量偏大的狀況


上面已經討論了哈希的數據結構,再來討論下棧和堆

6. 棧和堆

(1)數據結構的棧

棧的特色是先進後出,只有push和pop兩個函數能夠操做棧,分別進行壓棧和彈棧,還有top函數查看棧頂元素。棧的一個典型應用是作開閉符號的處理,如構建DOM。有如下html:

<html> <head></head> <body> <div>hello, world</div> <p>goodbye, world</p> </body> </html>複製代碼

將會構建這麼一個DOM:

上圖省略了document結點,而且這裏咱們只關心DOM父子結點關係,省略掉兄弟節點關係。

首先把html序列化成一個個的標籤,以下所示:

1 html ( 2 head ( 3 head ) 4 body ( 5 div ( 6 text 7 div ) 8 p ( 9 text 10 p ) 11 body ) 12 html)

其中左括號表示開標籤,右括號表示閉標籤。

以下圖所示,處理html和head標籤時,它們都是開標籤,因此把它們都壓到棧裏面去,並實例一個HTMLHtmlElement和HTMLHeadElement對象。處理head標籤時,因爲棧頂元素是html,因此head的父元素就是html。

處理剩餘其它元素以下圖所示:

遇到第三個標籤是head的閉標籤,因而進行彈棧,把head標籤彈出來,棧頂元素變成了html,因此在遇到第一個標籤body的時候,html元素就是body標籤的父結點。其它節點相似處理。

上面的過程,我在《從Chrome源碼看瀏覽器如何構建DOM樹》已經作過討論,這裏用圖表示意,可能會更加直觀。

(2)內存棧

函數執行的時候會把局部變量壓到一個棧裏面,以下函數:

function foo(){
    var a = 5,
        b = 6,
        c = a + b;
}
foo();複製代碼

a, b, c三個變量在內存棧的結構以下圖所示:

先遇到a把a壓到棧裏面,而後是b和c,對函數裏面的局部變量不斷地壓棧,內存向低位增加。棧空間大小8MB(可以使用ulimit –a查看),假設一個變量用了8B,一個函數裏面定義了10個變量,最多遞歸8MB / (8B * 10) = 80萬次就會發生棧溢出stackoverflow

這個在《WebAssembly與程序編譯》這篇文章裏面作過討論。

(3)堆

—數據結構裏的堆一般是指用數組表示的二叉樹,如大堆排序和小堆排序。內存裏的堆是指存放new出來動態建立變量的地方,和棧相對,以下圖所示:

討論完了棧和堆,再分析一個比較實用的技巧。

6. 節流

節流是前端常常會遇到的一個問題,就是不想讓resize/mousemove/scroll等事件觸發得太快,例如說最快每100ms執行一次回調就能夠了。以下代碼不進行節流,直接兼聽resize事件:

$(window).on("resize", adjustSlider);複製代碼

因爲adjustSlider是一個很是耗時的操做,我並不想讓它執行得那麼快,最多500ms執行一次就行了。那應該怎麼作呢?以下圖所示,藉助setTimout和一個tId的標誌位:


最後再討論下圖像和圖形處理相關的。

7. 圖像處理

假設要在前端作一個濾鏡,如用戶選擇了本地的圖片以後,點擊某個按鈕就能夠把圖片置成灰色的:

效果以下:

一個方法是使用CSS的filter屬性,它支持把圖片置成灰圖的:

img{
    filter: grayscale(100%);
}複製代碼

因爲須要把真實的圖片數據傳給後端,所以須要對圖片數據作處理。咱們能夠用canvas獲取圖片的數據,以下代碼所示:

<canvas id="my-canvas"></canvas>複製代碼

JS處理以下:

var img = new Image();
img.src = 「/test.jpg」; //經過FileReader等
img.onload = function(){
    //畫到canvas上,位置爲x = 10, y = 10
    ctx.drawImage(this, 10, 10);
}

function blackWhite() {
    var imgData = ctx.getImageData(10, 10, 31, 30);
    ctx.putImageData(imgData, 50, 10);
    console.log(imgData, imgData.data);
}複製代碼

這個的效果是把某張圖片原封不動地再畫一個,以下圖所示:

如今對imgData作一個灰化處理,這個imgData是什麼東西呢?它是一個一維數組,存放了從左到右,從上到下每一個像素點的rgba值,以下圖所示:

這張圖片尺寸爲31 * 30,因此數組的大小爲31 * 30 * 4 = 3720,而且因爲這張圖片沒有透明通道,因此a的值都爲255。

經常使用的灰化算法有如下兩種:

—(1)平均值

Gray = (Red + Green + Blue) / 3

—(2)按人眼對三原色的感知度:綠 > 紅 > 藍

Gray = (Red * 0.3 + Green * 0.59 + Blue * 0.11)

第二種方法更符合客觀實際,咱們採用第二種方法,以下代碼所示:

function blackWhite() {
    var imgData = ctx.getImageData(10, 10, 31, 30);
    var data = imgData.data;
    var length = data.length;
    for(var i = 0; i < length; i += 4){
        var grey = 0.3 * data[i] + 0.59 * data[i + 1] + 0.11 * data[i + 2];
        data[i] = data[i + 1] = data[i + 2] = grey;
    }
    ctx.putImageData(imgData, 50, 10);
}複製代碼

執行的效果以下圖所示:

其它的濾鏡效果,如模糊、銳化、去斑等,讀者有興趣可繼續查找資料。

還有一種是圖形算法

8. 圖形算法

以下須要計算兩個多邊形的交點:

這個就涉及到圖形算法,能夠認爲圖形算法是對矢量圖形的處理,和圖像處理是對所有的rgba值處理相對。這個算法也多種多樣,其中一個可參考《A new algorithm for computing Boolean operations on polygons


綜合以上,本篇討論了幾個話題:

  1. — 遞歸和查DOM
  2. — Set/Map的實現
  3. — 數組去重的幾種方法比較
  4. — 棧和堆
  5. — 節流
  6. — 圖像處理

本篇從前端的角度對一些算法作一些分析和總結,只列了一些我認爲比較重要,其它的還有不少沒有說起。算法和數據結構是一個永恆的話題,它的目的是用最小的時間和最小的空間解決問題。可是有時候不用太拘泥於必定要最優的答案,可以合適地解決問題就是好方法,並且對於不一樣的應用場景可能要採起不一樣的策略。反之,若是你的代碼裏面動不動就是三四重循環,還有嵌套了不少if-else,你可能要考慮下采用合適的數據結構和算法去優化你的代碼。

相關文章
相關標籤/搜索