淺談演算法和資料結構(1):棧和佇列

發表於2014-11-03

最近晚上在家裡看Algorithems,4th Edition,我買的英文版,覺得這本書寫的比較淺顯易懂,而且“圖碼並茂”,趁著這次機會打算好好學習做做筆記,這樣也會印象深刻,這也是寫這一系列文章的原因。另外普林斯頓大學在Coursera 上也有這本書同步的公開課,還有另外一門演算法分析課,這門課程的作者也是這本書的作者,兩門課都挺不錯的。

計算機程式離不開演算法和資料結構,本文簡單介紹棧(Stack)和佇列(Queue)的實現,.NET中與之相關的資料結構,典型應用等,希望能加深自己對這兩個簡單資料結構的理解。

1. 基本概念

概念很簡單,棧 (Stack)是一種後進先出(last in first off,LIFO)的資料結構,而佇列(Queue)則是一種先進先出 (fisrt in first out,FIFO)的結構,如下圖:

2. 實現

現在來看如何實現以上的兩個資料結構。在動手之前,Framework Design Guidelines這本書告訴我們,在設計API或者實體類的時候,應當圍繞場景編寫API規格說明書。

1.1 Stack的實現

棧是一種後進先出的資料結構,對於Stack 我們希望至少要對外提供以下幾個方法:

Stack<T>() 建立一個空的棧
void Push(T s) 往棧中新增一個新的元素
T Pop() 移除並返回最近新增的元素
boolean IsEmpty() 棧是否為空
int Size() 棧中元素的個數

要實現這些功能,我們有兩中方法,陣列和連結串列,先看連結串列實現:

棧的連結串列實現:

我們首先定義一個內部類來儲存每個連結串列的節點,該節點包括當前的值以及指向下一個的值,然後建立一個節點儲存位於棧頂的值以及記錄棧的元素個數;

現在來實現Push方法,即向棧頂壓入一個元素,首先儲存原先的位於棧頂的元素,然後新建一個新的棧頂元素,然後將該元素的下一個指向原先的棧頂元素。整個Pop過程如下:

實現程式碼如下:

Pop方法也很簡單,首先儲存棧頂元素的值,然後將棧頂元素設定為下一個元素:

基於連結串列的Stack實現,在最壞的情況下只需要常量的時間來進行Push和Pop操作。

棧的陣列實現:

我們可以使用陣列來儲存棧中的元素Push的時候,直接新增一個元素S[N]到陣列中,Pop的時候直接返回S[N-1].

首先,我們定義一個陣列,然後在建構函式中給定初始化大小,Push方法實現如下,就是集合裡新增一個元素:

Pop方法:

在Push和Pop方法中,為了節省記憶體空間,我們會對陣列進行整理。Push的時候,當元素的個數達到陣列的Capacity的時候,我們開闢2倍於當前元素的新陣列,然後將原陣列中的元素拷貝到新陣列中。Pop的時候,當元素的個數小於當前容量的1/4的時候,我們將原陣列的大小容量減少1/2。

Resize方法基本就是陣列複製:

當我們縮小陣列的時候,採用的是判斷1/4的情況,這樣效率要比1/2要高,因為可以有效避免在1/2附件插入,刪除,插入,刪除,從而頻繁的擴大和縮小陣列的情況。下圖展示了在插入和刪除的情況下陣列中的元素以及陣列大小的變化情況:

分析:1. Pop和Push操作在最壞的情況下與元素個數成比例的N的時間,時間主要花費在擴大或者縮小陣列的個數時,陣列拷貝上。

2. 元素在記憶體中分佈緊湊,密度高,便於利用記憶體的時間和空間區域性性,便於CPU進行快取,較LinkList記憶體佔用小,效率高。

2.2 Queue的實現

Queue是一種先進先出的資料結構,和Stack一樣,他也有連結串列和陣列兩種實現,理解了Stack的實現後,Queue的實現就比較簡單了。

Stack<T>() 建立一個空的佇列
void Enqueue(T s) 往佇列中新增一個新的元素
T Dequeue() 移除佇列中最早新增的元素
boolean IsEmpty() 佇列是否為空
int Size() 佇列中元素的個數

首先看連結串列的實現:

Dequeue方法就是返回連結串列中的第一個元素,這個和Stack中的Pop方法相似:

Enqueue和Stack的Push方法不同,他是在連結串列的末尾增加新的元素:

同樣地,現在再來看如何使用陣列來實現Queue,首先我們使用陣列來儲存資料,並定義變數head和tail來記錄Queue的首尾元素。

和Stack的實現方式不同,在Queue中,我們定義了head和tail來記錄頭元素和尾元素。當enqueue的時候,tial加1,將元素放在尾部,當dequeue的時候,head減1,並返回。

3. .NET中的StackQueue

在.NET中有Stack和Queue泛型類,使用Reflector工具可以檢視其具體實現。先看Stack的實現,下面是擷取的部分程式碼,僅列出了Push,Pop方法,其他的方法希望大家自己使用Reflector檢視:

可以看到.NET中的Stack的實現和我們之前寫的差不多,也是使用陣列來實現的。.NET中Stack的初始容量為4,在Push方法中,可以看到當元素個數達到陣列長度時,擴充2倍容量,然後將原陣列拷貝到新的陣列中。Pop方法和我們之前實現的基本上相同,下面是具體程式碼,只擷取了部分:

下面再看看Queue的實現:

可以看到.NET中Queue的實現也是基於陣列的,定義了head和tail,當長度達到陣列的容量的時候,使用了SetCapacity方法來進行擴容和拷貝。

4. StackQueue的應用

Stack這種資料結構用途很廣泛,比如編譯器中的詞法分析器、Java虛擬機器、軟體中的撤銷操作、瀏覽器中的回退操作,編譯器中的函式呼叫實現等等。

4.1 執行緒堆 (Thread Stack)

執行緒堆是操作系型系統分配的一塊記憶體區域。通常CPU上有一個特殊的稱之為堆指標的暫存器 (stack pointer) 。在程式初始化時,該指標指向棧頂,棧頂的地址最大。CPU有特殊的指令可以將值Push到執行緒堆上,以及將值Pop出堆疊。每一次Push操作都將值存放到堆指標指向的地方,並將堆指標遞減。每一次Pop都將堆指標指向的值從堆中移除,然後堆指標遞增,堆是向下增長的。Push到執行緒堆,以及從執行緒堆中Pop的值都存放到CPU的暫存器中。

當發起函式呼叫的時候,CPU使用特殊的指令將當前的指令指標(instruction pointer),如當前執行的程式碼的地址壓入到堆上。然後CPU通過設定指令指標到函式呼叫的地址來跳轉到被呼叫的函式去執行。當函式返回值時,舊的指令指標從堆中Pop出來,然後從該指令地址之後繼續執行。

當進入到被呼叫的函式中時,堆指標減小來在堆上為函式中的區域性變數分配更多的空間。如果函式中有一個32位的變數分配到了堆中,當函式返回時,堆指標就返回到之前的函式呼叫處,分配的空間就會被釋放。

如果函式有引數,這些引數會在函式呼叫之前就被分配在堆上,函式中的程式碼可以從當前堆往上訪問到這些引數。

執行緒堆是一塊有一定限制的記憶體空間,如果呼叫了過多的巢狀函式,或者區域性變數分配了過多的記憶體空間,就會產生堆疊溢位的錯誤。

下圖簡單顯示了執行緒堆的變化情況。

4.2 算術表示式的求值

Stack使用的一個最經典的例子就是算術表示式的求值了,這其中還包括字首表示式和字尾表示式的求值。E. W. Dijkstra發明了使用兩個Stack,一個儲存操作值,一個儲存操作符的方法來實現表示式的求值,具體步驟如下:

1) 當輸入的是值的時候Push到屬於值的棧中。

2) 當輸入的是運算子的時候,Push到運算子的棧中。

3) 當遇到左括號的時候,忽略

4) 當遇到右括號的時候,Pop一個運算子,Pop兩個值,然後將計算結果Push到值的棧中。

下面是在C#中的一個簡單的括號表示式的求值:

執行結果如下:

下圖演示了操作棧和資料棧的變化。

在編譯器技術中,字首表示式,字尾表示式的求值都會用到堆。

4.3 Object-C中以及OpenGL中的圖形繪製

在Object-C以及OpenGL中都存在”繪圖上下文”,有時候我們對區域性物件的繪圖不希望影響到全域性的設定,所以需要儲存上一次的繪圖狀態。下面是Object-C中繪製一個圓形的典型程式碼:

可以看到,在drawGreenCircle方法中,在設定填充顏色之前,我們Push儲存了繪圖上下文的資訊,然後在設定當前操作的一些環境變數,繪製圖形,繪製完成之後,我們Pop出之前儲存的繪圖上下文資訊,從而不影響後面的繪圖。

4.4 一些其他場景

有一個場景是利用stack 處理多餘無效的請求,比如使用者長按鍵盤,或者在很短的時間內連續按某一個功能鍵,我們需要過濾到這些無效的請求。一個通常的做法是將所有的請求都壓入到堆中,然後要處理的時候Pop出來一個,這個就是最新的一次請求。

Queue的應用

在現實生活中Queue的應用也很廣泛,最廣泛的就是排隊了,”先來後到” First come first service ,以及Queue這個單詞就有排隊的意思。

還有,比如我們的播放器上的播放列表,我們的資料流物件,非同步的資料傳輸結構(檔案IO,管道通訊,套接字等)

還有一些解決對共享資源的衝突訪問,比如印表機的列印佇列等。訊息佇列等。交通狀況模擬,呼叫中心使用者等待的時間的模擬等等。

5. 一點點感悟

本文簡單介紹了Stack和Queue的原理及實現,並介紹了一些應用。

最後一點點感悟就是不要為了使用資料結構而使用資料結構。舉個例子,之前看到過一個陣列反轉的問題,剛學過Stack可能會想,這個簡單啊,直接將字串挨個的Push進去,然後Pop出來就可以了,完美的解決方案。但是,這是不是最有效地呢,其實有更有效地方法,那就是以中間為對摺,然後左右兩邊替換。

相關文章