1. 程式人生 > >由《演算法導論》10-1例題與習題引發的思考

由《演算法導論》10-1例題與習題引發的思考

這一節涉及到棧,佇列,雙端佇列及一些組合形式,為了突出重點,簡化問題,令元素型別一律為int,物理儲存結構一律採用定長陣列。

  • 例題1 :棧 下面的程式碼就實現了一個簡單的棧,棧內可容納元素個數有上限。在實現每個型別的時候都應該問問自己,為什麼需要維護這些資料成員,多一個會不會更好?少一個可行嗎?
    • 因為棧是一種邏輯資料結構,而每種邏輯資料結構的實現都需要依賴一種物理儲存結構的支援,這裡我選擇了陣列,所以我需要維護一個數組及其總長度(m_array和m_totalLength)。
    • 由於Push、Pop、Top方法中待處理元素的位置資訊,不是由引數給定的,而是由棧自身維護的,所以我還需要記錄當前待處理元素(這裡指的是棧頂元素)的下標(m_top),其取值範圍是[-1, m_totalLength-1]。
      • m_top的作用之一是用來計算棧頂元素所對應的陣列元素的下標f(m_top),從而將邏輯層操作轉化成儲存層的操作,這裡我將對映法則定義為f(m_top) = m_top。
      • m_top的作用之二是計算:當前棧內元素個數 = m_top + 1,從而判斷棧是否為空或已滿。
      • 這已經是資料成員最精簡的版本,一個也不能少,多一個也沒必要。
class Stack
{
public:
    Stack(int len);         //len表示棧的最大長度

    void Push(int val);     //壓棧,若棧已滿,報錯”Overflow“
    void Pop();             //出棧,若棧為空,報錯”Underflow“
    int  Top() const;           //讀取棧頂,若棧為空,報錯”Empty“

    bool IsEmpty() const;   //棧是否為空
    bool IsFull()  const;       //棧是否已滿

private:
    int* m_array;   //陣列
    const int m_totalLength;    //棧的最大長度,也是陣列的總長度
    int m_top;  //棧頂元素下標 
};

Stack::Stack(int len)
    :m_totalLength(len),
      m_array(new int[len]),
      m_top(-1)
{}

void Stack::Push(int val)
{
    if(IsFull())
        cerr << "Overflow" << endl;
    else
        m_array[++m_top] = val;
}

void Stack::Pop()
{
    if(IsEmpty())
        cerr << "Underflow" << endl;
    else
        --m_top;
}

int Stack::Top() const
{
    if(IsEmpty())
    {
        cerr << "Empty" << endl;
        return -1;
    }
    else
        return m_array[m_top];
}

bool Stack::IsEmpty() const
{
    return m_top == -1;
}

bool Stack::IsFull()  const
{
    return m_top == m_totalLength - 1;
}
  • 例題2:佇列 同樣的,佇列可同時容納元素個數有上限。我都需要儲存哪些資料成員呢?
    • 陣列及其總長度(m_array, m_totalLength)
    • 入隊,出隊方法中待處理元素位置資訊,這裡指的是隊頭和隊尾元素的下標,同樣需要由方法內部維護(m_begin, m_end)。
    • m_begin, m_end的作用之一是計算隊頭、隊尾所對應陣列元素下標,依據邏輯含義應是隻增不減的,而依據環形佇列的對映法則計算出的陣列元素下標是在[0, m_totalLength)區間內迴圈取值的。
    • m_begin, m_end的作用之二是計算當前容納元素個數,作為判斷操作合法性的邊界條件。
    • 具體實現中,在不影響上述兩作用的前提下,必須對m_begin,m_end的值加以限制。(見方法Dequeue)
    • 由於入隊,出隊方法的被呼叫頻率不同且無關,必須被記錄至兩個變數中,所以資料成員不能更少了。
class Queue
{
public:
    Queue(int len);
    void Enqueue(int val);
    void Dequeue();
    int Front() const;
    int Back() const;
    bool IsEmpty() const;
    bool IsFull() const;
private:
    int IndexInArray(indexInQueue) const;
private:
    int* m_array;
    const int  m_totalLength;
    int  m_begin;   //index of front element
    int  m_end;     //index of the one next to back element
};

Queue::Queue(int len)
    : m_totalLength(len),
      m_array(new int[len]),
      m_begin(0),
      m_end(0)
{
}

void Queue::Enqueue(int val)
{
    if(IsFull())
        cerr << "Overflow" << endl;
    else
    {
        m_array[IndexInArray(m_end)] = val;
        ++m_end;
    }
}

void Queue::Dequeue()
{
    if(IsEmpty())
        cerr << "Underflow" << endl;
    else
    {
        ++m_begin;
        //限制m_begin,m_end取值範圍
        if(m_begin >= m_totalLength)
        {
            m_begin -= m_totalLength;
            m_end -= m_totalLength;
        }
    }
}

int Queue::Front() const
{
    if(IsEmpty())
    {
        cerr << "Empty" << endl;
        return -1;
    }
    return m_array[IndexInArray(m_begin)];
}

int Queue::Back() const
{
    if(IsEmpty())
    {
        cerr << "Empty" << endl;
        return -1;
    }
    return m_array[IndexInArray(m_end - 1)];
}

bool Queue::IsEmpty() const
{
    return m_begin == m_end;
}

bool Queue::IsFull() const
{
    return m_end - m_begin == m_totalLength;
}

int Queue::IndexInArray(indexInQueue) const
{
    assert(indexInQueue >= 0);
    return indexInQueue % m_totalLength;
}
  • 習題10.1-2 不失一般性,把陣列視為環形陣列。 因為需要把一個數組分給兩個棧使用,所以我們需要設定一個分界線,兩個棧分別以分界線處兩個相鄰元素為起點,分別向左右兩個方向生長,直到二者總長度達到陣列總長度為止。 如此說來,除了陣列和總長度外,還需要儲存一個常量(分界線的位置m_divide)和兩個變數(兩個棧頂元素下標m_top[A],m_top[B])。 m_top[A]和m_top[B]儲存的是邏輯層的棧內下標,它的作用和Stack::m_top以及Queue::m_begin,Queue::m_end都是一樣的,一是計算對應的陣列元素下標,二是計算邊界條件,判斷呼叫合法性。
class DoubleStack
{
public:
    enum StackID
    {
        A = 0,
        B = 1
    };

    DoubleStack(int len);

    void Push(StackID id, int val);

    void Pop(StackID id);

    int  Top(StackID id) const;

    bool IsEmpty(StackID id) const;

    bool IsFull() const;
private:
    int TopIndexInArray(StackID id) const;
private:
    int* m_array;
    const int m_totalLength;
    int  m_top[2];
    const int m_divide;
};

DoubleStack::DoubleStack(int len)
    : m_array(new int[len]),
      m_totalLength(len),
      m_divide(len/2) //any value within [0, m_totalLength) is ok
{
    m_top[A] = -1;
    m_top[B] = -1;
}

void DoubleStack::Push(StackID id, int val)
{
    if(IsFull())
    {
        cout << "Overflow" << endl;
        return;
    }
    ++ m_top[id];
    m_array[TopIndexInArray(id)] = val;
}

void DoubleStack::Pop(StackID id)
{
    if(IsEmpty(id))
    {
        cerr << "Underflow" << endl;
        return;
    }
    -- m_top[id];
}

int  DoubleStack::Top(StackID id) const
{
    if(IsEmpty(id))
    {
        cerr << "Empty" << endl;
        return -1;
    }
    return m_array[TopIndexInArray(id)];
}

bool DoubleStack::IsEmpty(StackID id) const
{
    return m_top[id] == -1;
}

bool DoubleStack::IsFull() const
{
    return m_top[A] + m_top[B] + 2 == m_totalLength;
}

int DoubleStack::TopIndexInArray(DoubleStack::StackID id) const
{
    //let m_array[m_divide] belongs to stack B
    if(id == A)
        return (m_divide - (m_top[A] + 1) + m_totalLength) % m_totalLength;
    else
        return (m_divide + m_top[B]) % m_totalLength;
}
  • 以上三個型別的實現有一些共同的邏輯。是時候來總結一下了。
    1. 需要儲存哪些資訊?和普通線性表不同,呼叫棧的Push,Pop方法時,被操作的元素所處的位置是呼叫方和被呼叫方之間的一種約定,這裡約定為棧頂的元素。類似的,雙方約定呼叫佇列的Enqueue、Dequeue所操作的元素分別為隊尾和隊頭。既然這一資訊不是由引數傳入,就需要型別自行維護。總結一下,需要儲存的是待操作元素的位置資訊。每個棧有一個,每個佇列有兩個。
    2. 邏輯層資訊和儲存層資訊,選擇儲存哪一個?假設你有一個菜譜,如果你想照著它炒出一盤菜,你還需要存放和操作食材的廚房。每個邏輯資料結構就好像一個菜譜,如果你想用程式實現它並執行起來,你還需要一個儲存和操作它的介質,那就是物理儲存結構,比如陣列。陣列是儲存層的結構,而棧是邏輯層的結構,隨之而產生的是每個元素都有兩個位置資訊,我叫它們邏輯層下標和儲存層下標。兩者構成一對對映,通常是滿射。通過對映法則,兩者可以互相求得。所以我們只需要在資料成員中儲存一方,就可以在需要時計算出另一方。我在上面的實現中,都選擇了儲存邏輯下標,並將計算儲存下標的工作封裝在一個函式中,這樣做的好處是:1. 幾乎每個介面都有邏輯層的處理,但不是每個都需要動用儲存層邏輯,所以儲存邏輯層資訊可以使得介面在邏輯層的處理更直接高效。例如,IsEmpty介面就無需計算儲存層下標;2. 當你想改換一種物理儲存結構時,例如從陣列改為連結串列,你只需要修改從邏輯層下標到物理層下標的對映過程,靈活性較好。
    3. 對映法則可以很靈活。通常,考慮效率和易讀性,棧下標x到陣列下標f(x)的對映法則往往定義為:f(x) = x。如果你很任性,就想玩些花樣,其實你完全可以把陣列當做環形陣列,將陣列的任意位置作為起始點,比如f(x) = x + 2,就是用陣列的第三個元素儲存第一個入棧的元素,滿棧前最後兩個入棧的元素放在陣列第一個,第二個元素上。或者你還可以倒過來儲存,f(x) = 陣列總長度 - 1 - x;甚至你可以毫無規律地將邏輯下標{0, 1, 2, 3}對映成儲存下標{3, 1, 2, 0},只要它是滿射,只要你開心。為什麼我要這樣折騰這個對映法則呢?因為有時候,它可以幫助我們靈活地解決問題。例如下面的習題10.1-2,如何將兩個棧的邏輯下標對映到一個數組的儲存下標上去,既要彼此不干擾,又可以最大限度利用陣列,這便是對映法則的用武之地了。具體實現見函式DoubleStack::TopIndexInArray。
  • 10.1-4 同例題2
  • 10.1-5 雙端佇列 按照前面總結的規律,需要儲存的是待操作元素的位置資訊,這裡有兩個待操作元素的位置會移動,所以儲存它們在邏輯層的下標,m_begin, m_end,分別表示隊頭和隊尾元素的下一個元素的下標。未完待續...