在計算機編程中,棧是一種很常見的數據結構,它遵從后進先出(LIFO——Last In First Out)原則,新添加或待刪除的元素保存在棧的同一端,稱作棧頂,另一端稱作棧底。在棧中,新元素總是靠近棧頂,而舊元素總是接近棧底。
讓我們來看看在JavaScript中如何實現棧這種數據結構。
function Stack() {
let items = [];
// 向棧添加新元素 this.push = function (element) { 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()); }; }
我們用最簡單的方式定義了一個Stack類。在JavaScript中,我們用function來表示一個類。然后我們在這個類中定義了一些方法,用來模擬棧的操作,以及一些輔助方法。代碼很簡單,看起來一目了然,接下來我們嘗試寫一些測試用例來看看這個類的一些用法。
let stack = new Stack(); console.log(stack.isEmpty()); // true stack.push(5); stack.push(8); console.log(stack.peek()); // 8 stack.push(11); console.log(stack.size()); // 3 console.log(stack.isEmpty()); // false stack.push(15); stack.pop(); stack.pop(); console.log(stack.size()); // 2 stack.print(); // 5,8 stack.clear(); stack.print(); //
返回結果也和預期的一樣!我們成功地用JavaScript模擬了棧的實現。但是這里有個小問題,由於我們用JavaScript的function來模擬類的行為,並且在其中聲明了一個私有變量items,因此這個類的每個實例都會創建一個items變量的副本,如果有多個Stack類的實例的話,這顯然不是最佳方案。我們嘗試用ES6(ECMAScript 6)的語法重寫Stack類。
class Stack { constructor () { this.items = []; } push(element) { 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的簡化語法將上面的Stack函數轉換成了Stack類。類的成員變量只能放到constructor構造函數中來聲明。雖然代碼看起來更像類了,但是成員變量items仍然是公有的,我們不希望在類的外部訪問items變量而對其中的元素進行操作,因為這樣會破壞棧這種數據結構的基本特性。我們可以借用ES6的Symbol來限定變量的作用域。
let _items = Symbol(); class Stack { constructor () { this[_items] = []; } push(element) { 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()); } }
這樣,我們就不能再通過Stack類的實例來訪問其內部成員變量_items了。但是仍然可以有變通的方法來訪問_items:
let stack = new Stack(); let objectSymbols = Object.getOwenPropertySymbols(stack);
通過Object.getOwenPropertySymbols()方法,我們可以獲取到類的實例中的所有Symbols屬性,然后就可以對其進行操作了,如此說來,這個方法仍然不能完美實現我們想要的效果。我們可以使用ES6的WeakMap類來確保Stack類的屬性是私有的:
const items = new WeakMap(); class Stack { constructor () { items.set(this, []); } push(element) { let s = items.get(this); s.push(element); } pop() { let s = items.get(this); return s.pop(); } peek() { let s = items.get(this); return s[s.length - 1]; } isEmpty() { return items.get(this).length === 0; } size() { return items.get(this).length; } clear() { items.set(this, []); } print() { console.log(items.get(this).toString()); } }
現在,items在Stack類里是真正的私有屬性了,但是,它是在Stack類的外部聲明的,這就意味着誰都可以對它進行操作,雖然我們可以將Stack類和items變量的聲明放到閉包中,但是這樣卻又失去了類本身的一些特性(如擴展類無法繼承私有屬性)。所以,盡管我們可以用ES6的新語法來簡化一個類的實現,但是畢竟不能像其它強類型語言一樣聲明類的私有屬性和方法。有許多方法都可以達到相同的效果,但無論是語法還是性能,都會有各自的優缺點。
let Stack = (function () { const items = new WeakMap(); class Stack { constructor () { items.set(this, []); } push(element) { let s = items.get(this); s.push(element); } pop() { let s = items.get(this); return s.pop(); } peek() { let s = items.get(this); return s[s.length - 1]; } isEmpty() { return items.get(this).length === 0; } size() { return items.get(this).length; } clear() { items.set(this, []); } print() { console.log(items.get(this).toString()); } } return Stack; })();
下面我們來看看棧在實際編程中的應用。
進制轉換算法
將十進制數字10轉換成二進制數字,過程大致如下:
10 / 2 = 5,余數為0
5 / 2 = 2,余數為1
2 / 2 = 1,余數為0
1 / 2 = 0, 余數為1
我們將上述每一步的余數顛倒順序排列起來,就得到轉換之后的結果:1010。
按照這個邏輯,我們實現下面的算法:
function divideBy2(decNumber) { let remStack = new Stack(); let 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; } console.log(divideBy2(233)); // 11101001 console.log(divideBy2(10)); // 1010 console.log(divideBy2(1000)); // 1111101000
Stack類可以自行引用本文前面定義的任意一個版本。我們將這個函數再進一步抽象一下,使之可以實現任意進制之間的轉換。
function baseConverter(decNumber, base) { let remStack = new Stack(); let rem, baseString = ''; let digits = '0123456789ABCDEF'; while(decNumber > 0) { rem = Math.floor(decNumber % base); remStack.push(rem); decNumber = Math.floor(decNumber / base); } while(!remStack.isEmpty()) { baseString += digits[remStack.pop()]; } return baseString; } console.log(baseConverter(233, 2)); // 11101001 console.log(baseConverter(10, 2)); // 1010 console.log(baseConverter(1000, 2)); // 1111101000 console.log(baseConverter(233, 8)); // 351 console.log(baseConverter(10, 8)); // 12 console.log(baseConverter(1000, 8)); // 1750 console.log(baseConverter(233, 16)); // E9 console.log(baseConverter(10, 16)); // A console.log(baseConverter(1000, 16)); // 3E8
我們定義了一個變量digits,用來存儲各進制轉換時每一步的余數所代表的符號。如:二進制轉換時余數為0,對應的符號為digits[0],即0;八進制轉換時余數為7,對應的符號為digits[7],即7;十六進制轉換時余數為11,對應的符號為digits[11],即B。
漢諾塔
有關漢諾塔的傳說和由來,讀者可以自行百度。這里有兩個和漢諾塔相似的小故事,可以跟大家分享一下。
1. 有一個古老的傳說,印度的舍罕王(Shirham)打算重賞國際象棋的發明人和進貢者,宰相西薩·班·達依爾(Sissa Ben Dahir)。這位聰明的大臣的胃口看來並不大,他跪在國王面前說:“陛下,請您在這張棋盤的第一個小格內,賞給我一粒小麥;在第二個小格內給兩粒,第三格內給四粒,照這樣下去,每一小格內都比前一小格加一倍。陛下啊,把這樣擺滿棋盤上所有64格的麥粒,都賞給您的仆人吧!”。“愛卿。你所求的並不多啊。”國王說道,心里為自己對這樣一件奇妙的發明所許下的慷慨賞諾不致破費太多而暗喜。“你當然會如願以償的。”說着,他令人把一袋麥子拿到寶座前。計數麥粒的工作開始了。第一格內放一粒,第二格內放兩粒,第三格內放四粒,......還沒到第二十格,袋子已經空了。一袋又一袋的麥子被扛到國王面前來。但是,麥粒數一格接以各地增長得那樣迅速,很快就可以看出,即便拿來全印度的糧食,國王也兌現不了他對西薩·班·達依爾許下的諾言了,因為這需要有18 446 744 073 709 551 615顆麥粒呀!
這個故事其實是一個數學級數問題,這位聰明的宰相所要求的麥粒數可以寫成數學式子:1 + 2 + 22 + 23 + 24 + ...... 262 + 263
推算出來就是:
其計算結果就是18 446 744 073 709 551 615,這是一個相當大的數!如果按照這位宰相的要求,需要全世界在2000年內所生產的全部小麥才能滿足。
2. 另外一個故事也是出自印度。在世界中心貝拿勒斯的聖廟里,安放着一個黃銅板,板上插着三根寶石針。每根針高約1腕尺,像韭菜葉那樣粗細。梵天在創造世界的時候,在其中的一根針上從下到上放下了由大到小的64片金片。這就是所謂的梵塔。不論白天黑夜,都有一個值班的僧侶按照梵天不渝的法則,把這些金片在三根針上移來移去:一次只能移一片,並且要求不管在哪一根針上,小片永遠在大片的上面。當所有64片都從梵天創造世界時所放的那根針上移到另外一根針上時,世界就將在一聲霹靂中消滅,梵塔、廟宇和眾生都將同歸於盡。這其實就是我們要說的漢諾塔問題,和第一個故事一樣,要把這座梵塔全部64片金片都移到另一根針上,所需要的時間按照數學級數公式計算出來:1 + 2 + 22 + 23 + 24 + ...... 262 + 263 = 264 - 1 = 18 446 744 073 709 551 615
一年有31 558 000秒,假如僧侶們每一秒鍾移動一次,日夜不停,節假日照常干,也需要將近5800億年才能完成!
好了,現在讓我們來試着實現漢諾塔的算法。
為了說明漢諾塔中每一個小塊的移動過程,我們先考慮簡單一點的情況。假設漢諾塔只有三層,借用百度百科的圖,移動過程如下:
一共需要七步。我們用代碼描述如下:
function hanoi(plates, source, helper, dest, moves = []) { if (plates <= 0) { return moves; } if (plates === 1) { moves.push([source, dest]); } else { hanoi(plates - 1, source, dest, helper, moves); moves.push([source, dest]); hanoi(plates - 1, helper, source, dest, moves); } return moves; }
下面是執行結果:
console.log(hanoi(3, 'source', 'helper', 'dest'));
[ [ 'source', 'dest' ], [ 'source', 'helper' ], [ 'dest', 'helper' ], [ 'source', 'dest' ], [ 'helper', 'source' ], [ 'helper', 'dest' ], [ 'source', 'dest' ] ]
可以試着將3改成大一點的數,例如14,你將會得到如下圖一樣的結果:
如果我們將數改成64呢?就像上面第二個故事里所描述的一樣。恐怕要令你失望了!這時候你會發現你的程序無法正確返回結果,甚至會由於超出遞歸調用的嵌套次數而報錯。這是由於移動64層的漢諾塔所需要的步驟是一個很大的數字,我們在前面的故事中已經描述過了。如果真要實現這個過程,這個小程序恐怕很難做到了。
搞清楚了漢諾塔的移動過程,我們可以將上面的代碼進行擴充,把我們在前面定義的棧的數據結構應用進來,完整的代碼如下:
function towerOfHanoi(plates, source, helper, dest, sourceName, helperName, destName, moves = []) { if (plates <= 0) { return moves; } if (plates === 1) { dest.push(source.pop()); const move = {}; move[sourceName] = source.toString(); move[helperName] = helper.toString(); move[destName] = dest.toString(); moves.push(move); } else { towerOfHanoi(plates - 1, source, dest, helper, sourceName, destName, helperName, moves); dest.push(source.pop()); const move = {}; move[sourceName] = source.toString(); move[helperName] = helper.toString(); move[destName] = dest.toString(); moves.push(move); towerOfHanoi(plates - 1, helper, source, dest, helperName, sourceName, destName, moves); } return moves; } function hanoiStack(plates) { const source = new Stack(); const dest = new Stack(); const helper = new Stack(); for (let i = plates; i > 0; i--) { source.push(i); } return towerOfHanoi(plates, source, helper, dest, 'source', 'helper', 'dest'); }
我們定義了三個棧,用來表示漢諾塔中的三個針塔,然后按照函數hanoi()中相同的邏輯來移動這三個棧中的元素。當plates的數量為3時,執行結果如下:
[ { source: '[object Object]', helper: '[object Object]', dest: '[object Object]' }, { source: '[object Object]', dest: '[object Object]', helper: '[object Object]' }, { dest: '[object Object]', source: '[object Object]', helper: '[object Object]' }, { source: '[object Object]', helper: '[object Object]', dest: '[object Object]' }, { helper: '[object Object]', dest: '[object Object]', source: '[object Object]' }, { helper: '[object Object]', source: '[object Object]', dest: '[object Object]' }, { source: '[object Object]', helper: '[object Object]', dest: '[object Object]' } ]
棧的應用在實際編程中非常普遍,下一章我們來看看另一種數據結構:隊列。