究極面試題:如何用有限個棧模擬常數效率操作的隊列?


問題來源###

寫這篇博客來源於一次面試的經歷。經典面試題:如何用兩個棧實現一個隊列?它經常被拿來面試。如果對棧和隊列比較掌握的人,就可以輕松的答出來。

然而,那天坐在對面的面試者直接拋出:如何用有限個棧模擬常數效率操作的隊列呢?作為一個面試官,我佯裝鎮定,因為這個和用棧實現隊列可是一個天上一個地下的區別。聽他說完。之后幾個小時的面試,我根本無心面試,腦子里一直在想:他剛才說了啥?到底是怎么操作的?太優秀了!

看完這篇文章,以后面試別人或者被面試的過程中,遇到如何用棧實現一個隊列的問題,那么就可以秀一波操作了。應該很少能有人在臨場反應中能夠答出來吧。
當然篇幅有點長,也有點繞腦子。就當做是繁忙工作之余的一個點心。接下來就開始品嘗吧。

老生常談###

如何用兩個棧實現一個隊列呢?這是一個老生常談的問題,為了擴充博文的長度我決定還是寫一下過程。

比較笨的方法我就不說了( 一個棧作為緩沖棧,另一個儲存數據,當出隊列的時候,元素從一個棧倒出來,再倒回去。可真麻煩)

我們用兩個棧分別代表一個隊列的 尾部 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-lhead

等到頭部棧放空了,尾部棧倒空了,此時轉換角色。出隊列在head-l,入隊列在tail。 而倒元素是從tail-lhead中倒元素

到這里,應該窺見了這樣操作的中心思想:瘋狂的,一刻不停歇的讓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-lhead交換角色,但這個時候卻發現tail棧中的元素沒倒完啊,元素13 還壓在箱底呢!怎么辦呢?一次性倒完吧!可是這又違背了常數操作的規定啊。誰知道tail棧中剩的元素會是多少個呢?

引入頭部棧副本二解決問題####

為了解決這個問題,再引入一個頭部棧副本二:head-r ,這個棧專門用來讓head去倒元素進去,為什么要這樣做呢?看圖就知道了.

我們選取 瘋狂進隊列時的情況,加入head-r, 在往tail中進隊列的時候,headhead-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&&copy_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

園子里的一篇文章


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM