目錄
棧、堆、隊列都屬於常見數據結構。
一、棧
1.1 簡介
棧 是一種遵循 後進先出(LIFO) 原則的有序集合。新添加和待刪除的數據都保存在棧的同一端棧頂,另一端就是棧底。新元素靠近棧頂,舊元素靠近棧底。 棧由編譯器自動分配釋放。棧使用一級緩存。調用時處於存儲空間,調用完畢自動釋放。
舉個栗子:乒乓球盒子/搭建積木
1.2 基本數據結構的存儲(存儲棧)
javaScript中,數據類型分爲基本數據類型和引用數據類型,基本數據類型包含:null、undefined、string、number、boolean、symbol、bigint 這幾種。在內存中這幾種數據類型存儲在棧空間,我們按值訪問。原始類型都存儲在棧內存中,是大小固定並且有序的。
1.3 執行棧(函數調用棧)
我們知道了基本數據結構的存儲之後,我們再來看看JavaScript中如何通過棧來管理多個執行上下文。
- 程序執行進入一個執行環境時,它的執行上下文就會被創建,並被推入執行棧中(入棧)。
- 程序執行完成時,它的執行上下文就會被銷燬,並從棧頂被推出(出棧),控制權交由下一個執行上下文。
JavaScript中每一個可執行代碼,在解釋執行前,都會創建一個可執行上下文。按照可執行代碼塊可分爲三種可執行上下文。
- 全局可執行上下文:每一個程序都有一個全局可執行代碼,並且只有一個。任何不在函數內部的代碼都在全局執行上下文。
- 函數可執行上下文:每當一個函數被調用時, 都會爲該函數創建一個新的上下文。每個函數都被調用時都會創建它自己的執行上下文。
- Eval可執行上下文:Eval也有自己執行上下文。
因爲JS執行中最先進入全局環境,所以處於"棧底的永遠是全局執行上下文"。而處於"棧頂的是當前正在執行函數的執行上下文",當函數調用完成後,它就會從棧頂被推出(理想的情況下。閉包會阻止該操作)。
"全局環境只有一個,對應的全局執行上下文也只有一個,只有當頁面被關閉之後它纔會從執行棧中被推出,否則一直存在於棧底"
看個例子:
let name = '蝸牛';
function sayName(name) {
sayNameStart(name);
}
function sayNameStart(name) {
sayNameEnd(name);
}
function sayNameEnd(name) {
console.log(name);
}
當代碼進行時聲明:
執行sayName函數時,會把直接函數壓如執行棧,並且會創建執行上下文,執行完畢編譯器會自動釋放:
1.4 棧的執行狀態
假設用ESP指針來保存當前的執行狀態,在系統棧中會產生如下的過程:
- 調用func, 將 func 函數的上下文壓棧,ESP指向棧頂
- 執行func,又調用f函數,將 f 函數的上下文壓棧,ESP 指針上移
- 執行完 f 函數,將ESP 下移,f函數對應的棧頂空間被回收
- 執行完 func,ESP 下移,func對應的空間被回收
圖示如下:
1.5 創建一個棧(實現棧方法)
我們需要自己創建一個棧,並且這個棧包含一些方法。
- push(element(s)):添加一個(或多個)新元素到棧頂
- pop():刪除棧頂的元素,並返回該元素
- peek():返回棧頂的元素,不對棧做任何操作
- isEmpty():檢查棧是否爲空
- size():返回棧的元素個數
- clear():清空棧
function Stack() {
let items = [];
this.push = function(element) {
items.push(element);
};
this.pop = function() {
let s = items.pop();
return s;
};
this.peek = function() {
return items[items.length - 1];
};
this.isEmpty = function() {
return items.length == 0;
};
this.size = function() {
return items.length;
};
this.clear = function() {
items = [];
}
}
但是這樣的方式在創建多個實例的時候爲創建多個items的副本。就不太合適了。如果用ES6實現Stack類。可以用WeakMap實現,並保證屬性是私有的。
let Stack = (function() {
const items = new WeakMap();
class Stack {
constructor() {
items.set(this, []);
}
getItems() {
let s = items.get(this);
return s;
}
push(element) {
this.getItems().push(element);
}
pop() {
return this.getItems().pop();
}
peek() {
return this.getItems()[this.getItems.length - 1];
}
isEmpty() {
return this.getItems().length == 0;
}
size() {
return this.getItems().length;
}
clear() {
this.getItems() = [];
}
}
return Stack;
})();
1.6 使用棧解決問題
棧可以解決十進制轉爲二進制的問題、任意進制轉換的問題、平衡園括號問題、漢羅塔問題。
// 例子十進制轉二進制問題
function divideBy2(decNumber) {
var remStack = new Stack(),
rem,
binaryString = '';
while (decNumber > 0) {
rem = Math.floor(decNumber % 2);
remStack.push(rem);
decNumber = Math.floor(decNumber / 2);
}
while(!remStack.isEmpty()) {
binaryString += remStack.pop().toString();
}
return binaryString;
}
// 任意進制轉換的算法
function baseConverter(decNumber, base) {
var remStack = new Stack(),
rem,
binaryString = '',
digits = '0123456789ABCDEF';
while (decNumber > 0) {
rem = Math.floor(decNumber % base);
remStack.push(rem);
decNumber = Math.floor(decNumber / base);
}
while(!remStack.isEmpty()) {
binaryString += digits[remStack.pop()].toString();
}
return binaryString;
}
1.7 棧溢出問題
1.7.1 棧大小限制
不同瀏覽器對調用棧的大小是有限制,超過將出現棧溢出的問題。下面這段代碼可以檢驗不用瀏覽器對調用棧的大小限制。
var i = 0;
function recursiveFn () {
i++;
recursiveFn();
}
try {
recursiveFn();
} catch (ex) {
console.log(`我的最大調用棧 i = ${i} errorMsg = ${ex}`);
}
1.7.2 遞歸調用的棧溢出問題
function Fibonacci (n) {
if ( n <= 1 ) {return 1};
return Fibonacci(n - 1) + Fibonacci(n - 2);
}
Fibonacci(10) // 89
Fibonacci(100) // 超時
Fibonacci(500) // 超時
上面代碼是一個階乘函數,計算n的階乘,最多需要保存n個調用記錄,複雜度 O(n) 。如果超出限制,會出現棧溢出問題。
1.7.3 尾遞歸調用優化
遞歸非常耗費內存,因爲需要同時保存成千上百個調用幀,很容易發生“棧溢出”錯誤(stack overflow)。但對於尾遞歸來說,由於只存在一個調用幀,所以永遠不會發生“棧溢出”錯誤。
function Fibonacci2 (n , ac1 = 1 , ac2 = 1) {
if( n <= 1 ) {return ac2};
return Fibonacci2(n - 1, ac2, ac1 + ac2);
}
Fibonacci2(100) // 573147844013817200000
Fibonacci2(1000) // 7.0330367711422765e+208
Fibonacci2(10000) // Infinity
由此可見,“尾調用優化”對遞歸操作意義重大,所以一些函數式編程語言將其寫入了語言規格。ES6 亦是如此,第一次明確規定,所有 ECMAScript 的實現,都必須部署“尾調用優化”。這就是說,ES6 中只要使用尾遞歸,就不會發生棧溢出(或者層層遞歸造成的超時),相對節省內存。
二、堆
2.1 簡介
堆,一般由操作人員(程序員)分配釋放,若操作人員不分配釋放,將由OS(操作系統)回收釋放。分配方式類似鏈表。堆存儲在二級緩存中。
2.2 堆內存
JavaScript 的數據類型除了原始類型,還有一類是 Object 類型,它包含:
- Object
- Function
- Array
- Date
- RegExp
Object 類型都存儲在堆內存中,是大小不定,複雜可變的。 Object 類型數據的 指針 存儲在棧內存空間, 指針實際指向的值存儲在堆內存空間。
一種特殊情況:閉包變量是存在堆內存中的
function count() {
let num = -1;
return function() {
num++;
return num;
}
}
count()(); // 0
上述閉包,在調用count函數時,創建num變量,return時清空棧,如果閉包變量是存在棧中,那return的時候就被清空了,整個輸出結果就不是0了,所以閉包變量是存在堆內存中的。
2.3 爲什麼會有堆內存、棧內存之分
通常與垃圾回收機制有關。爲了使程序運行時佔用的內存最小。
當一個方法執行時,每個方法都會建立自己的內存棧,在這個方法內定義的變量將會逐個放入這塊棧內存裏,隨着方法的執行結束,這個方法的內存棧也將自然銷燬了。因此,所有在方法中定義的變量都是放在棧內存中的;
當我們在程序中創建一個對象時,這個對象將被保存到運行時數據區中,以便反覆利用(因爲對象的創建成本通常較大),這個運行時數據區就是堆內存。堆內存中的對象不會隨方法的結束而銷燬,即使方法結束後,這個對象還可能被另一個引用變量所引用(方法的參數傳遞是很常見),則這個對象依然不會被銷燬,只有當一個對象沒有任何引用變量引用它時,系統的垃圾回收機制纔會在覈實的時候回收它。
三、隊列
3.1 簡介
隊列遵循FIFO,先進先出原則的一組有序集合。隊列在尾部添加元素,在頂部刪除元素。在現實中最常見的隊列就是排隊。先排隊的先服務。
3.2 任務隊列
JavaScript是單線程,單線程任務被分爲同步任務和異步任務。同步任務在調用棧中等待主線程依次執行,異步任務會在有了結果之後,將回調函數註冊到任務隊列,等待主線程空閒(調用棧爲空),放入執行棧等待主線程執行。
Event loop執行如下圖,任務隊列只是其中的一部分。
執行棧在執行完同步任務之後,如果執行棧爲空,就會去檢查微任務(MicroTask)隊列是否爲空,如果爲空的話,就會去執行宏任務隊列(MacroTask)。否則就會一次性執行完所有的微任務隊列。 每次一個宏任務執行完成之後,都會去檢查微任務隊列是否爲空,如果不爲空就會按照先進先出的方式執行完微任務隊列。然後在執行下一個宏任務,如此循環執行。直到結束。
3.3 創建一個隊列(實現隊列方法)
實現包含以下方法的Queue類
- enqueue(element(s)):向隊列尾部添加一個(或多個)元素。
- dequeue():移除隊列的第一項,並返回移除的元素。
- front():返回隊列的第一個元素--最先被添加,最先被移除的元素。
- isEmpty():判斷隊列是否爲空。
- size():返回隊列的長度。
// 隊列Queue類簡單實現
function Queue() {
let items = [];
// 添加元素
this.enqueue = function(element) {
items.push(element);
};
// 刪除元素
this.dequeue = function() {
return items.shift();
};
// 返回隊列第一個元素
this.front = function() {
return items[0];
};
// 判斷隊列是否爲空
this.isEmpty = function() {
return items.length === 0;
};
// 返回隊列長度
this.size = function() {
return items.length;
};
}
ES6語法實現Queue隊列類,利用WeakMap來保存私有屬性items,並用外層函數(閉包)來封裝Queue類。
let Queue1 = (function() {
const items = new WeakMap();
class Queue1 {
constructor() {
items.set(this, []);
}
// 獲取隊列
getQueue() {
return items.get(this);
}
// 添加元素
enqueue (element) {
this.getQueue().push(element);
}
// 刪除元素
dequeue() {
return this.getQueue().shift();
}
// 返回隊列第一個元素
front() {
return this.getQueue()[0];
}
// 判斷隊列是否爲空
isEmpty() {
return this.getQueue().length === 0;
}
// 返回隊列長度
size() {
return this.getQueue().length;
}
}
return Queue1;
})();
3.4 優先隊列
元素的添加和刪除基於優先級。常見的就是機場的登機順序。頭等艙和商務艙的優先級高於經濟艙。實現優先隊列,設置優先級。
// 優先列隊
function PriorityQueue() {
let items = [];
// 創建元素和它的優先級(priority越大優先級越低)
function QueueElement(element, priority) {
this.element = element;
this.priority = priority;
}
// 添加元素(根據優先級添加)
this.enqueue = function(element, priority) {
let queueElement = new QueueElement(element, priority);
// 標記是否添加元素的優先級的值最大
let added = false;
for (let i = 0; i < items.length; i++) {
if (queueElement.priority < items[i].priority) {
items.splice(i, 0, queueElement);
added = true;
break;
}
}
if (!added) {
items.push(queueElement);
}
};
// 刪除元素
this.dequeue = function() {
return items.shift();
};
// 返回隊列第一個元素
this.front = function() {
return items[0];
};
// 判斷隊列是否爲空
this.isEmpty = function() {
return items.length === 0;
};
// 返回隊列長度
this.size = function() {
return items.length
};
// 打印隊列
this.print = function() {
for (let i = 0; i < items.length; i++) {
console.log(`${items[i].element} - ${items[i].priority}`);
}
};
}
3.5 循環隊列(擊鼓傳花)
// 循環隊列(擊鼓傳花)
function hotPotato(nameList, num) {
let queue = new Queue(); //{1} // 構造函數爲4.3創建
for(let i =0; i< nameList.length; i++) {
queue.enqueue(nameList[i]); // {2}
}
let eliminted = '';
while(queue.size() > 1) {
// 把隊列num之前的項按照優先級添加到隊列的後面
for(let i = 0; i < num; i++) {
queue.enqueue(queue.dequeue()); // {3}
}
eliminted = queue.dequeue(); // {4}
console.log(eliminted + '在擊鼓傳花遊戲中被淘汰');
}
return queue.dequeue(); // {5}
}
let names = ['John', 'Jack', 'Camila', 'Ingrid', 'Carl'];
let winner = hotPotato(names, 7);
console.log('獲勝者是:' + winner);
實現一個模擬擊鼓傳花的遊戲:
- 利用隊列類,創建一個隊列。
- 把當前玩擊鼓傳花遊戲的所有人都放進隊列。
- 給定一個數字,迭代隊列,從隊列的開頭移除一項,添加到隊列的尾部(如遊戲就是:你把花傳給旁邊的人,你就可以安全了)。
- 一旦迭代次數到達,那麼這時拿着花的這個人就會被淘汰。
- 最後剩下一個人,這個人就是勝利者。