棧、堆、隊列 深入理解

目錄

一、棧

1.1 簡介

1.2 基本數據結構的存儲(存儲棧)

1.3 執行棧(函數調用棧)

1.4 棧的執行狀態

1.5 創建一個棧(實現棧方法)

1.6 使用棧解決問題

1.7 棧溢出問題

二、堆

2.1 簡介

2.2 堆內存

一種特殊情況:閉包變量是存在堆內存中的

2.3 爲什麼會有堆內存、棧內存之分

三、隊列

3.1 簡介

3.2 任務隊列

3.3 創建一個隊列(實現隊列方法)

3.4 優先隊列

3.5 循環隊列(擊鼓傳花)


棧、堆、隊列都屬於常見數據結構。

一、棧

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指針來保存當前的執行狀態,在系統棧中會產生如下的過程:

  1. 調用func, 將 func 函數的上下文壓棧,ESP指向棧頂
  2. 執行func,又調用f函數,將 f 函數的上下文壓棧,ESP 指針上移
  3. 執行完 f 函數,將ESP 下移,f函數對應的棧頂空間被回收
  4. 執行完 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);

 

 

實現一個模擬擊鼓傳花的遊戲:

  1. 利用隊列類,創建一個隊列。
  2. 把當前玩擊鼓傳花遊戲的所有人都放進隊列。
  3. 給定一個數字,迭代隊列,從隊列的開頭移除一項,添加到隊列的尾部(如遊戲就是:你把花傳給旁邊的人,你就可以安全了)。
  4. 一旦迭代次數到達,那麼這時拿着花的這個人就會被淘汰。
  5. 最後剩下一個人,這個人就是勝利者。


 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章