問題來源###
寫這篇博客來源於一次面試的經歷。經典面試題:如何用兩個棧實現一個隊列?它經常被拿來面試。如果對棧和隊列比較掌握的人,就可以輕松的答出來。
然而,那天坐在對面的面試者直接拋出:如何用有限個棧模擬常數效率操作的隊列呢?作為一個面試官,我佯裝鎮定,因為這個和用棧實現隊列可是一個天上一個地下的區別。聽他說完。之后幾個小時的面試,我根本無心面試,腦子里一直在想:他剛才說了啥?到底是怎么操作的?太優秀了!
看完這篇文章,以后面試別人或者被面試的過程中,遇到如何用棧實現一個隊列的問題,那么就可以秀一波操作了。應該很少能有人在臨場反應中能夠答出來吧。
當然篇幅有點長,也有點繞腦子。就當做是繁忙工作之余的一個點心。接下來就開始品嘗吧。
老生常談###
如何用兩個棧實現一個隊列呢?這是一個老生常談的問題,為了擴充博文的長度我決定還是寫一下過程。
比較笨的方法我就不說了( 一個棧作為緩沖棧,另一個儲存數據,當出隊列的時候,元素從一個棧倒出來,再倒回去。可真麻煩)
我們用兩個棧分別代表一個隊列的 尾部 tail
和一個隊列的頭部 head
。根據隊列的特性,尾部棧只負責入隊列操作,頭部棧只負責出隊列操作。
過程就如上圖所示,當要出隊列的時候,如果頭部棧有元素,那么立刻出棧,效率O ( 1 )。
如果頭部棧元素空了,就會把尾部棧的元素全部倒入頭部棧中,再出棧。很顯然倒元素的過程是一個O ( n )效率的操作,n是尾部棧里的元素個數。
出隊列的效率就是O(n+1) (出隊列操作也算上)而進棧都是O( 1 )的操作。
兩個棧實現一個隊列的效率####
那么這樣一個隊列,它的效率高不高呢?
如果這樣對隊列進行操作:進隊列 - 出隊列 - 進隊列 - 出隊列 -進隊列 ....
每次出隊列效率是O (2) ,那n次出隊列的效率就是 O ( 2 )+ O ( 2 ) + O( 2 )... => O( 2*n )
如果我這樣操作:進隊列 - 進隊列 - 進隊列 .... 出隊列 出隊列 出隊列 ....
那么出n次隊列的效率 是O( n+1 ) +O(1 )+O( 1 )+ ... => O( 2*n )
兩種極端的情況下n次出隊列的效率總的是 O (2*n) (別的情況下也是如此) 所以平均每次出隊列的效率是O ( 2 ) !是常數效率!
這不就已經達到文章標題的要求了嗎??對,是的,是我的題目不嚴謹。這道題目是源自於算法第四版的習題:
用有限個棧實現一個隊列,保證每個隊列(在最壞的情況下)都只需要常數次的棧操作
上面的方法在 最壞的情況下 是不滿足的,也就是O(n+1)的那一次。
題外話####
C# 中的 List
是怎么做到 Add 自動增加長度的呢?不是動態分配內存,而是當 List內部的array數組
快滿的時候 ,卡,一下直接復制一個二倍長度的array數組替換之前的數組,之后你再慢慢Add,直到再次堆滿數組。相關操作可以查看List的源碼
C# 中的 List 都可以這樣操作,說明偶爾一次操作不是常數,並不影響整個數據結構的效率。可以平均一下(為啥平均要加粗,因為它是接下來的主要算法思想),相當於常數效率了。
所以上述棧模擬隊列的操作完全合情合理,沒什么不好的地方。不好的是這道習題,非要在最壞的地方也需要常數次操作。到這里其實這道題目已經不是優化效率的題目,也不是考驗你的棧和隊列的熟練程度,而是腦筋急轉彎題。
解決思路###
就像之前所說的:
進隊列 - 出隊列 - 進隊列 - 出隊列 -進隊列 ....
它完美的避開了那最壞的情況。因為它每次進隊列之后都出隊列,那么尾部棧里的元素就很少,這是不會出現O(n)操作的關鍵:確保尾部棧的元素時時刻刻都要最少,而頭部棧的元素要最多
所以核心思路 就是:
將那次最壞情況下的 出隊列的 倒元素的 O(n) 操作給平均到每次出入隊列的操作中,確保尾部棧的元素時時刻刻都要比頭部棧的元素少。
如果理解不了,也沒關系,接下來會一步一步圍繞這個思路去解決問題。
NO.1 頭部棧副本###
初始情況####
這是一個開始的雙棧模擬一個隊列的情況:
如果一直在出隊列,刷刷幾下把頭部棧的元素出光了,那么下一次出隊列就是最壞的情況了。
引入頭部棧副本####
要做到尾部棧時時刻刻都在往頭部棧里倒元素,需要引入另一個頭部棧的副本 :head-l
,兩個頭部棧屬於雙胞胎關系。
在出隊列的同時,我們把尾部棧的元素同時倒入頭部棧副本中,當頭部棧的元素出光了,下次出隊列時只要交換頭部棧和頭部棧副本,就可以完美的銜接起來,避免了最壞情況的發生。
NO.2尾部棧副本###
細心的同學應該發現了,如果在上述過程中,突然進隊列怎么辦呢?尾部棧突然進隊列,那么就不能再往頭部棧副本中倒元素了。既然有頭部棧副本,那么也可以有一個尾部棧副本 :tail-l
進隊列往尾部棧副本中放元素。此時進出隊列的棧為 :tail-l
和head
等到頭部棧放空了,尾部棧倒空了,此時轉換角色。出隊列在head-l
,入隊列在tail
。 而倒元素是從tail-l
往head
中倒元素
到這里,應該窺見了這樣操作的中心思想:瘋狂的,一刻不停歇的讓tail中的元素進入head中,總是保持tail中的元素少於head中的元素
四個棧完美的實現了隊列中出隊列操作始終在常數效率。那么問題是不是就解決了呢?很顯然沒有,到這里才是一半。
NO3.頭部棧副本二###
接下來,隨着棧的數量增多,請一定要保持頭腦清醒,對棧有着深刻和正確的認識。
瘋狂進隊列,不出出隊列####
如果在上述過程中,不出隊列,一個元素也不出,而是瘋狂進隊列呢?
上圖的過程是:在tail-l
中入隊列,當tail
中元素倒空了,便交換角色,tail
屬於入隊列的棧。(原則一:入隊列時,哪個尾部棧誰是空的,誰就作為入隊列棧,這樣方便我們用代碼實現)
瘋狂進隊列的后果#####
與此同時一個元素也不出隊列。所以最終導致的結果是tail
中的元素老多了,老高了。那么會導致啥后果呢?從現在看來,出隊列照樣可以O(1)啊,如果聰明一點的同學應該會預料到會發生這樣的情況:一直出隊列的話
上圖的過程:先從head
中出隊列,當head
中元素出完了,便交換角色,head-l
出隊列。與此同時,tail-l
向已經空的head
中倒元素(原則二:當某個頭部棧為空時,那么尾部棧副本應該向這個頭部棧轉移元素,這里的尾部棧副本不一定就是tail-l
,也可以是tail
,因為它們二者是交換角色的關系)
同理head-l
元素出完了,又和head
交換角色,根據原則二,tail
中的元素需要向head-l
中倒元素。直到head
出隊列出空了。
此時按道理應該head-l
和head
交換角色,但這個時候卻發現tail
棧中的元素沒倒完啊,元素13 還壓在箱底呢!怎么辦呢?一次性倒完吧!可是這又違背了常數操作的規定啊。誰知道tail
棧中剩的元素會是多少個呢?
引入頭部棧副本二解決問題####
為了解決這個問題,再引入一個頭部棧副本二:head-r
,這個棧專門用來讓head
去倒元素進去,為什么要這樣做呢?看圖就知道了.
我們選取 瘋狂進隊列時的情況,加入head-r
, 在往tail
中進隊列的時候,head
往head-r
中倒元素。 當沒有head-r
時,由於沒有出隊列,所有的頭部棧的元素都原封不動,但因為了有了head-r
,那么head
就會倒空,那么根據原則二, tail-l
就需要往head
中倒元素。與此同時,head-r
的元素再倒回head-l
中,等tail-l
倒完之后,tail棧互換角色,head棧也互換角色,重新開始前面的操作。
l
所以引入head-r
后,又多了一個原則三:當head-r
為空時,需要從head棧中倒元素到head-r
中 以及原則四:當head
倒空時,head-r
需要把元素放到head-l
中,直到head-r dao空
所以head-r
就是為了緩解頭部棧元素原封不動的情況,讓尾部棧元素可以流動起來。
NO.4 頭部棧副本三###
請看下圖
同時出隊列和轉移元素,這樣就很尷尬了!!怎么辦呢?再來一個棧!(棧多隨便用,別搞出n個棧就可以)
這個棧是頭部棧副本三號!叫做head-c
,它是專門的一個臨時棧,相當於 當前處於出隊列 狀態的頭部棧的引用。 出隊列的操作都從head-c
中實現,而代碼實現其實可以用很巧妙的方法:一個棧,兩個游標,分別代表自身和head-c
所以下圖中名義上是head-l
在出隊列,實際上是head-c
在出隊列,還有一點,在head-c
中出隊列,就需要在head-r
中標記出來。下次head-r
需要倒元素的時候,就要注意標記的元素已經被出隊列了。
你可以證明出同時出隊列和轉移元素,出隊列不會受到影響。假設一直在出隊列,當head-c
中隊列出光了,head-r
中也倒滿了被遺棄的元素
,所以它相當於空的棧,無需往head-l
中再倒元素,下次再出隊列,會直接從head-l
中出隊列。
代碼實現###
c++: 根據上文中的四個原則,以及一個棧,兩個游標,再注意標記出隊列元素。就可以很容易實現代碼了。
c++ 有指針,指來指去,就更方便了。
#include <iostream>
#include <stdio.h>
#include <math.h>
#include <algorithm>
using namespace std;
//給head 棧專用的棧,有兩個游標top和top_c
struct StackHead
{
int s[1005];
int top=0;
int top_c=0;
};
//普通棧,增加了出隊列標記,這個出隊列標記是給head_r專用的
struct Stack
{
int s[1005];
int top=0;
int dequeue=0;
};
class Queue
{
public:
int num;
StackHead* head;
StackHead* head_l;
Stack* tail;
Stack* tail_l;
Stack* head_r;
Queue();
void Enqueue(int number);
int Dequeue();
private:
int copy_tail; // 1 表示tail_l中有元素,tail_l 中正在往head_l 中倒元素。0 表示tail_l中的元素倒空了。
int copy_head; // 1 表示head 正在往head_r 中倒元素。0 表示head中的元素倒空了。
//copy_tail == 0 && copy_head==0 表示,head_r 需要往head_l 中倒元素了。
void Judge();
void Copy();
void Change();
};
Queue::Queue()
{
head = new StackHead;
head_l = new StackHead;
tail = new Stack;
tail_l = new Stack;
head_r = new Stack;
copy_head=0;
copy_tail=0;
num=0;
}
//交換入隊列棧
void Queue::Change()
{
if(tail_l->top==0)
{
Stack* temp = tail;
tail = tail_l;
tail_l = temp;
}
}
int Queue::Dequeue()
{
int res = head->s[--head->top];
head_r->dequeue++;
Change();
Copy();
return res;
}
void Queue::Enqueue(int number)
{
tail->s[tail->top++]=number;
Change(); //原則一:入隊列哪個tail 棧為空,就往哪個棧里入。
num++;
Copy();
}
void Queue::Judge()
{
if(copy_tail==0&&tail_l->top>0&&head_l->top==0&&head_l->top_c==0) copy_tail=1;
if(copy_tail==1&&tail_l->top==0) copy_tail=0;
if(copy_head==0&&head->top_c>0) copy_head=1;
if(copy_head==1&&head->top_c==0) copy_head=0;
}
//復制操作
void Queue::Copy()
{
Judge();
//原則二
if(copy_tail==1)
{
head_l->s[head_l->top++] = tail_l->s[--tail_l->top];
head_l->top_c++;
}
//原則三
if(copy_head==1)
{
head_r->s[head_r->top++] = head->s[--head->top_c];
}
Judge();
//原則四
if(copy_head==0&©_tail==0)
{
if(head_r->top > head_r->dequeue)
{
head_l->s[head_l->top++]=head_r->s[--head_r->top];
head_l->top_c++;
}
// 在`head-c`中出隊列,就需要在`head-r`中標記出來。下次`head-r`需要倒元素的時候,就要注意標記的元素已經被出隊列了。
// 判斷head_r 中是否還有元素,因為head-r中的元素有部分可能是已經被出隊列的,所以通過判斷top是否等於dequeue。然后清空head_r,head_r為空,意味着head和head_l需要交換身份了,因為其中head_l被head_r倒滿了,需要作為新的出隊列棧。
if(head_r->top == head_r->dequeue)
{
head_r->top=0;
head_r->dequeue=0;
head->top_c=0;
head->top=0;
StackHead* temp = head;
head = head_l;
head_l = temp;
}
}
}
int main()
{
Queue s = Queue();
}
結語###
理論上用了6個棧,實際上用了5個棧。
其實這個問題在實際中的應用中並沒什么用。偶爾一次的O(n)效率的操作也無傷大雅,在大多數情況下。但是如果你遇到了無法承受任何一次O(n)操作的情況下,就可以想想如何把O(n)的操作給平均到每一步的操作中。
此外,偷梁換柱在上述過程也運用的爐火純青。
最后,我們認為很簡單的問題,往往深究下去,或者升級一下,會給我們帶來更多。
參考資料###
https://stackoverflow.com/questions/5538192/how-to-implement-a-queue-with-three-stacks