棧、堆、佇列深入理解,面試無憂

蝸牛的北極星之旅發表於2019-11-18

一、前言

三者都屬於資料結構,作為專業的技術人員來說,理解資料結構是不可或缺的一部分。在日常的面試中,可能會遇到棧、堆、佇列等一系列問題。

  1. Event Loop的執行棧、任務佇列。
  2. 變數儲存的堆、棧問題。
  3. 棧、佇列資料結構的實現問題。
  4. 棧、堆、佇列是什麼?
  5. 還有一些列相關的手寫問題。

在面試中,經常問遇到與之相關的一些列問題哈。

二、棧

2.1 簡介

棧 是一種遵循 後進先出(LIFO) 原則的有序集合。新新增和待刪除的資料都儲存在棧的同一端棧頂,另一端就是棧底。新元素靠近棧頂,舊元素靠近棧底。 棧由編譯器自動分配釋放。棧使用一級快取。呼叫時處於儲存空間,呼叫完畢自動釋放。

舉個例子:乒乓球盒子/搭建積木

棧、堆、佇列深入理解,面試無憂

2.2 基本資料結構的儲存(儲存棧)

javaScript中,資料型別分為基本資料型別和引用資料型別,基本資料型別包含:string、number、boolean、undefined、null、symbol、bigint這幾種。在記憶體中這幾種資料型別儲存在棧空間,我們按值訪問。原型型別都儲存在棧記憶體中,是大小固定並且有序的。

棧、堆、佇列深入理解,面試無憂

2.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函式時,會把直接函式壓如執行棧,並且會建立執行上下文,執行完畢編譯器會自動釋放:

棧、堆、佇列深入理解,面試無憂

2.4 建立一個棧(實現棧方法)

我們需要自己建立一個棧,並且這個棧包含一些方法。

  • 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的副本。就不太合適了。 用ES如何6實現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;
})();
複製程式碼

2.5 使用棧解決問題

棧可以解決十進位制轉為二進位制的問題、任意進位制轉換的問題、平衡園括號問題、漢羅塔問題。

// 例子十進位制轉二進位制問題
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;
}
複製程式碼

2.6 棧溢位問題

2.6.1 棧大小限制

不同瀏覽器對呼叫棧的大小是有限制,超過將出現棧溢位的問題。下面這段程式碼可以檢驗不用瀏覽器對呼叫棧的大小限制。

var i = 0;
function recursiveFn () {
    i++;
    recursiveFn();
}
try {
    recursiveFn();
} catch (ex) {
    console.log(`我的最大呼叫棧 i = ${i} errorMsg = ${ex}`);
}
複製程式碼

谷歌瀏覽器:

棧、堆、佇列深入理解,面試無憂
QQ瀏覽器:
棧、堆、佇列深入理解,面試無憂
搜狗瀏覽器:
棧、堆、佇列深入理解,面試無憂
微軟 Edge瀏覽器:
棧、堆、佇列深入理解,面試無憂

2.6.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) 。如果超出限制,會出現棧溢位問題。

2.6.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.6.4 為什麼這個例子裡尾遞迴佔用的棧記憶體也會隨著引數增加呢

棧、堆、佇列深入理解,面試無憂
說好的尾遞迴優化只存在一個呼叫幀了,說好的永遠不會發生“棧溢位”了。這路給大家解釋一下原因。 各大瀏覽器(除了safari)根本就沒部署尾呼叫優化,直接在瀏覽器上的控制檯上除錯尾遞迴的程式碼當然還是會出現棧溢位的問題。謝謝 EternallyMaybe提出並給到的補充。
棧、堆、佇列深入理解,面試無憂

列子來源:ECMAScript 6 入門

三、堆

3.1 簡介

堆,一般由操作人員(程式設計師)分配釋放,若操作人員不分配釋放,將由OS回收釋放。分配方式類似連結串列。堆儲存在二級快取中。

3.2 堆記憶體

JavaScript 的資料型別除了原始型別,還有一類是 Object 型別,它包含:

  • Object
  • Function
  • Array
  • Date
  • RegExp

Object 型別都儲存在堆記憶體中,是大小不定,複雜可變的。 Object 型別資料的 指標 儲存在棧記憶體空間, 指標實際指向的值儲存在堆記憶體空間。

棧、堆、佇列深入理解,面試無憂

3.3 為什麼會有堆記憶體、棧記憶體之分

通常與垃圾回收機制有關。為了使程式執行時佔用的記憶體最小。

當一個方法執行時,每個方法都會建立自己的記憶體棧,在這個方法內定義的變數將會逐個放入這塊棧記憶體裡,隨著方法的執行結束,這個方法的記憶體棧也將自然銷燬了。因此,所有在方法中定義的變數都是放在棧記憶體中的;

當我們在程式中建立一個物件時,這個物件將被儲存到執行時資料區中,以便反覆利用(因為物件的建立成本通常較大),這個執行時資料區就是堆記憶體。堆記憶體中的物件不會隨方法的結束而銷燬,即使方法結束後,這個物件還可能被另一個引用變數所引用(方法的引數傳遞時很常見),則這個物件依然不會被銷燬,只有當一個物件沒有任何引用變數引用它時,系統的垃圾回收機制才會在核實的時候回收它。

四、佇列

4.1 簡介

佇列遵循FIFO,先進先出原則的一組有序集合。佇列在尾部新增元素,在頂部刪除元素。在現實中最常見的佇列就是排隊。先排隊的先服務。(請大家文明排隊,不要插隊。)

棧、堆、佇列深入理解,面試無憂

4.2 任務佇列

JavaScript是單執行緒,單執行緒任務被分為同步任務和非同步任務。同步任務在呼叫棧中等待主執行緒依次執行,非同步任務會在有了結果之後,將回撥函式註冊到任務佇列,等待主執行緒空閒(呼叫棧為空),放入執行棧等待主執行緒執行。

Event loop執行如下圖,任務佇列只是其中的一部分。

棧、堆、佇列深入理解,面試無憂

執行棧在執行完同步任務之後,如果執行棧為空,就會去檢查微任務(MicroTask)佇列是否為空,如果為空的話,就會去執行巨集任務佇列(MacroTask)。否則就會一次性執行完所有的微任務佇列。 每次一個巨集任務執行完成之後,都會去檢查微任務佇列是否為空,如果不為空就會按照先進先出的方式執行完微任務佇列。然後在執行下一個巨集任務,如此迴圈執行。直到結束。

4.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;
})();
複製程式碼

4.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}`);
        }
    };
}

複製程式碼

4.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. 最後剩下一個人,這個人就是勝利者。

參考來源:

相關文章