我們已經討論過了前端與計算機基礎的很多話題,諸如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》
綜合以上,本篇討論了幾個話題:
- 遞迴和查DOM
- Set/Map的實現
- 陣列去重的幾種方法比較
- 棧和堆
- 節流
- 影像處理
本篇從前端的角度對一些演算法做一些分析和總結,只列了一些我認為比較重要,其它的還有很多沒有提及。演算法和資料結構是一個永恆的話題,它的目的是用最小的時間和最小的空間解決問題。但是有時候不用太拘泥於一定要最優的答案,能夠合適地解決問題就是好方法,而且對於不同的應用場景可能要採取不同的策略。反之,如果你的程式碼裡面動不動就是三四重迴圈,還有巢狀了很多if-else,你可能要考慮下采用合適的資料結構和演算法去優化你的程式碼。