我接觸過的前端資料結構與演算法

人人網FED發表於2017-07-02


我們已經討論過了前端與計算機基礎的很多話題,諸如SQL物件導向多執行緒,本篇將討論資料結構與演算法,以我接觸過的一些例子做為說明。

1. 遞迴

遞迴就是自己調自己,遞迴在前端裡面算是一種比較常用的演算法。假設現在有一堆資料要處理,要實現上一次請求完成了,才能去調下一個請求。一個是可以用Promise,就像《前端與SQL》這篇文章裡面提到的。但是有時候並不想引入Promise,能簡單處理先簡單處理。這個時候就可以用遞迴,如下程式碼所示:

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的函式,在請求完成之後再調一下自己。每次調之前先取一個資料,如果陣列已經為空,則說明處理完了。這樣就用簡單的方式實現了序列請求不堵塞的功能。

再來講另外一個場景:DOM樹。

由於DOM是一棵樹,而樹的定義本身就是用的遞迴定義,所以用遞迴的方法處理樹,會非常地簡單自然。例如用遞迴實現一個查DOM的功能document.getElementById。

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符合查詢條件,則一層層地返回。所以這是一個深度優先的遍歷,每次都先從根結點一直往下直到葉子結點,再從下往上返回。

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

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

如下程式碼:

function getByElementId(node, id){
    //遍歷所有的Node
    while(node){
        if(node.id === id) return node;
        node = nextElement(node);
    }
    return null;
}複製程式碼

還是依次遍歷所有的DOM結點,只是這一次改成一個while迴圈,函式nextElement負責找到下一個結點。所以關鍵在於這個nextElement如何非遞迴實現,如下程式碼所示:

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。

實際編碼過程中1、2、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,你可能要考慮下采用合適的資料結構和演算法去優化你的程式碼。


相關文章