1. 程式人生 > >為什麼我要放棄javaScript資料結構與演算法(第三章)—— 棧

為什麼我要放棄javaScript資料結構與演算法(第三章)—— 棧

有兩種結構類似於陣列,但在新增和刪除元素時更加可控,它們就是棧和佇列。

第三章 棧

棧資料結構

棧是一種遵循後進先出(LIFO)原則的有序集合。新新增的或待刪除的元素都儲存在棧的同一端,稱為棧頂,另一端就叫做棧底。在棧裡, 新元素都靠近棧頂,舊元素都接近棧底。

棧也被用在程式語言的編譯器和記憶體中儲存變數、方法呼叫等。

建立棧

  1. 先宣告這個類
   function Stack(){
       // 各種屬性和方法的宣告
   }
  1. 選擇陣列這種資料結構來儲存棧裡的元素
   let items = [];
  1. 為棧宣告一些方法

    • push(element(s)): 新增一個(或者幾個)新元素到棧頂
    • pop():移除棧頂的元素,同時返回被移除的元素
    • peek():返回棧頂的元素,不會對棧做任何修改(這個方法不會移除棧頂的元素,僅僅返回它)
    • isEmpty():如果棧裡沒有任何元素的就返回true,否則就返回false.
    • clear():移除棧裡的所有元素
    • size():返回棧裡的元素個數,這個方法和陣列的length屬性很類似。

向棧新增元素

我們要實現的第一個方法是 push,這個方法負責向棧裡新增新元素,該方法只新增元素到棧頂,也就是棧的末尾。

this.push = function(element){
    return items.push(element);
}

只能用 push 和 pop 方法新增和刪除棧中元素,這樣一來,我們的棧就自然遵從了 LIFO 原則。

向棧移除元素

我們要實現的第一個方法是 pop,這個方法主要用來移除棧裡的元素。棧遵從 LIFO 原則,因此移出的是最後新增進去的元素。棧的 pop 方法可以這麼寫

this.pop = function(){
    return items.pop();
}

只能用 push 和 pop 方法新增和刪除棧中元素,這樣一來,我們的棧就自然遵從了 LIFO 原則。

檢視棧頂元素

現在為類實現一些額外的輔助方法,如果想知道棧裡最後新增的元素是什麼,可以用 peek 方法,這個方法將返回棧頂的元素。

this.peek = function(){
    return items[items.length-1];
}

因為類內部是用陣列儲存元素的,所以訪問陣列的最後一個元素可以用 length - 1

檢查棧是否為空

isEmpty ,如果棧為空的話就返回true,否則就返回false

this.isEmpty = function(){
    return items.length == 0;
}

類似於陣列的 length 屬性,我們也能實現棧的 length,對於集合,最好用 size 代替 length。因為棧的內部使用陣列儲存元素,所以能簡單地返回棧的長度。

this.size = function(){
    return items.length;
}

清空和列印棧元素

實現 clear 方法。clear 方法用來移除棧裡所有的元素,把棧清空。實現這個方法最簡單的方式是

this.clear = function(){
    items = [];
    return null;
}

打印出來棧裡面的內容,通過實現輔助方法 print 來實現。

this.print = function(){
    console.log(items.toString());
}

例項

function Stack(){
        let items = [];
        this.push = function(element){
            return items.push(element);
        }
        this.pop = function(){
            return items.pop();
        }
        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 = [];
        }
        this.print = function(){
            console.log(items.toString());
        }
    }
let stack = new Stack();
console.log(stack.isEmpty()); // true 判斷是否為空
stack.push(5); // 往棧裡新增元素 5
stack.push(8); // 往棧裡新增元素 8
console.log(stack.peek()); // 檢視最後一個元素 8
stack.push(11); // 往棧裡新增元素 11
console.log(stack.size()); // 3 輸出棧的元素個數
console.log(stack.isEmpty()); // false 判斷是否為空
stack.push(15); // 往棧裡新增元素 15
stack.print(); // 5,8,11,15 輸出棧裡的元素

下面是流程圖

流程圖

ECMAScript6 和 Stack 類

建立了一個可以當做類來使用的 Stack 函式。JavaScript 函式都有建構函式,可以用來模擬類的行為。我們宣告一個私有的 items變數,它只能被 Stack 函式/類訪問。然而,這個方法為每個類的例項都建立了一個 items 變數的副本。因此如果要建立多個 Stack例項,就不太適合。我們可以嘗試用 ES6語法來宣告 Stack 類。

用 ES6 宣告 Stack 類

class Stack{
    constructor(){
        this.items = []; // {1}
    }
    push(elememt){
        this.items.push(element);
    }
    // 其他方法
}

只是用 ES6 的簡化語法把 Stack 函式轉換成 Stack 類。這種方法不能像其他語言(Java、C++、C#)一樣直接在類裡面宣告變數,只能在類的建構函式 constructor 裡宣告,在類的其他函式裡用 this.nameofVariable 就可以引用這個變數。

儘管程式碼看起來更加簡潔、更漂亮,變數 items 卻是公共的。ES6 類是基於原型的。雖然基於原型的類比基於函式的類更節省記憶體,也更適合建立多個例項,卻不能夠宣告私有屬性(變數)或方法。而且,在這種情況下,我們希望 Stack 類的使用者只能訪問暴露給類的方法。否則,就有可能從棧的中間移除元素(因為我們用陣列來儲存其值),這不是我們希望看到的。

用ES6的限定作用域 Symbol 實現類

ES6 新增了一種叫做 Symbol 的基本型別,它是不可變的,可以用作物件的屬性。

let _items = Symbol(); // 聲明瞭 Symbol 型別的變數
class Stack{
    constructor(){
        this[_items] = [] // 要訪問 _items,只需把所有的 this.items都換成 this.[_items]
    }
    push(element){
        return this[_items].push(element);
    }
    pop (){
        return this[_items].pop();
    }
    peek (){
        return this[_items][this[_items].length-1];
    }
    isEmpty (){
        return this[_items].length == 0;
    }
    size (){
        return this[_items].length;
    }
    clear (){
        this[_items] = [];
    }
    print (){
        console.log(this[_items].toString());
    }
}

這種方法建立了一個假的私有屬性,因為ES6 新增的Object.getOwnPropertySymbols 方法能夠取到類裡面宣告的所有 Symbols 屬性。下面是一個破壞 Stack 類的例子

let stack = new Stack();
stack.push(5);
stack.push(8);
let objectSymbols = Object.getOwnPropertySymbols(stack);
console.log(objectSymbols.length); // 1
console.log(objectSymbols); // [Symbol()]
console.log(objectSymbols[0]); // Symbol()
stack[objectSymbols[0]].push(1); 
stack.print(); // 5,8,1

很明顯可以通過訪問 stack[objectSymbol[0]] 得到 _items。並且 _items屬性是一個數組,可以進行任意的陣列操作,比如從中間刪除或者是新增元素。我們操作的是棧,不應該有這種行為出現。

用ES6類的 WeakMap 實現類

有一種資料型別可確保屬性是私有的,這就是 WeakMap。後面會深入探討 Map 這種資料結構,現在只需要知道 WeakMap 可以儲存鍵值對,其中鍵是物件,值可以是任意資料型別。

如果使用 WeakMap 來儲存 items 變數,那麼 Stack 類是這樣的

const items = new WeakMap(); // 聲明瞭一個 WeakMap 型別的變數 items
class Stack{
    constructor(){
        items.set(this, []) // 在 constructor 中,以this(Stack類自己引用)為鍵,把代表棧的陣列存入 items
    }
    push(element){
        let s = items.get(this);
        s.push(element);
    }
    pop (){
        let s = items.get(this);
        let r = s.pop();
        return r;
    }
    peek (){
        let s = items.get(this);
        return s[s.length-1];
    }
    isEmpty (){
        let s = items.get(this);
        return s.length == 0;
    }
    size (){
        let s = items.get(this);
        let r = s.length
        return r;
    }
    clear (){
        items.set(this, [])
    }
    print (){
        let s = items.get(this);
        console.log(s.toString());
    }
}

現在 items 在 Stack 類裡是真正的私有屬性了,但是還有一件事要做, items 現在仍然是在 Stack 類以外宣告的,因此任何誰都可以改動它。我們可以用一個閉包(外層函式)把 Stack 類包起來,這樣就可以在這個函式裡訪問 WeakMap

let stack = (function(){
    const items = new WeakMap();
    class Stack {
        constructor(){
            items.set(this, []);
        }
        // 其他方法
    }    
    return Stack; // 當 Stack 函式裡的建構函式被呼叫時,會返回 Stack 類的一個例項。
})()

現在,Stack 類有一個名為 items 的私有屬性。然後用這種方法的話,擴充套件類無法繼承其屬性。將其與最開始用 function 實現的 Stack 類來做個比較,我們會發現一些相似之處。

事實上,儘管 ES6 引入了類的語法,我們仍然不能像在其他程式語言中一樣宣告私有屬性或方法。有很多種方法都可以達到相同的效果,但無論是語法還是效能,這些方法都有各自的缺點和優點。

用棧解決問題

棧的實際應用非常廣泛。在回溯問題中,它可以儲存訪問過的任務或是路徑、撤銷的操作。Java 和 C# 用棧來儲存變數和方法呼叫,特別是處理遞迴演算法時,有可能丟擲一個棧溢位異常(stack overflow)

下面,學習使用棧的三個最著名的演算法例項。首先是十進位制轉二進位制的問題,以及任意進位制轉換的演算法,然後是平衡圓括號問題,最後,會學習棧解決漢諾塔的問題。

從十進位制到二進位制

計算科學中,二進位制非常重要,因為計算機裡的所有內容都是用二進位制數字表示(0和1)。沒有十進位制和二進位制相互轉化的能力,與計算機交流就很困難。要把十進位制化成十進位制,將該十進位制數字和2整除,直到結果為0為止。

例項:數字10轉為二進位制的數字。

數字10轉為二進位制的數字

function divideBy2(decNumber){
    var remStack = new Stack(),
    rem,
    binaryString = '';
    while(decNumber > 0){
        rem = Math.floor(decNumber % 2); // 拿到被2整除的餘數
        remStack.push(rem);
        decNumber = Math.floor(decNumber / 2) // 拿到被2整除的整數
    }
    while (! remStack.isEmpty()){
        binaryString += remStack.pop().toString();
    }
    return binaryString;
}

console.log(divideBy2(10)); // 1010
console.log(divideBy2(233)); // 11101001
console.log(divideBy2(100)); // 11101001

JavaScript有數字型別,但是不會區分究竟是整數還是浮點數,使用 Math.floor 讓除法只返回整數部分。

進位制轉換演算法

可以傳入任意進位制的基數作為引數

function baseConverter(decNumber, base){
    var remStack = new Stack(),
    rem,
    baseString = '',
    digits = '0123456789ABCDEF';
    while(decNumber > 0){
        rem = Math.floor(decNumber % base); // 拿到被base整除的餘數
        remStack.push(rem);
        decNumber = Math.floor(decNumber / base) // 拿到被base整除的整數
    }
    while (! remStack.isEmpty()){
        baseString += digits[remStack.pop()]; 
    }
    return baseString;
}
console.log(baseConverter(100345,2)); // 11000011111111001
console.log(baseConverter(100345, 8)); // 303771
console.log(baseConverter(100345, 16)); // 187F9

需要改動的地方:在將十進位制轉為二進位制的時候,餘數是0或者1,轉為八進位制的時候,餘數為0~7,同理16進位制是0~9加上A~F。所以要做個轉換,通過定義 digits ,digits[remStack.pop()] 來實現轉化。

小結

通過這一章,學習了棧這一資料結構的相關內容。可以用程式碼自己實現棧,還講解了棧裡面的相關方法。

書籍連結: 學習JavaScript資料結構與演算法