由《演算法導論》10-1例題與習題引發的思考
阿新 • • 發佈:2018-12-10
這一節涉及到棧,佇列,雙端佇列及一些組合形式,為了突出重點,簡化問題,令元素型別一律為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;
}
- 以上三個型別的實現有一些共同的邏輯。是時候來總結一下了。
- 需要儲存哪些資訊?和普通線性表不同,呼叫棧的Push,Pop方法時,被操作的元素所處的位置是呼叫方和被呼叫方之間的一種約定,這裡約定為棧頂的元素。類似的,雙方約定呼叫佇列的Enqueue、Dequeue所操作的元素分別為隊尾和隊頭。既然這一資訊不是由引數傳入,就需要型別自行維護。總結一下,需要儲存的是待操作元素的位置資訊。每個棧有一個,每個佇列有兩個。
- 邏輯層資訊和儲存層資訊,選擇儲存哪一個?假設你有一個菜譜,如果你想照著它炒出一盤菜,你還需要存放和操作食材的廚房。每個邏輯資料結構就好像一個菜譜,如果你想用程式實現它並執行起來,你還需要一個儲存和操作它的介質,那就是物理儲存結構,比如陣列。陣列是儲存層的結構,而棧是邏輯層的結構,隨之而產生的是每個元素都有兩個位置資訊,我叫它們邏輯層下標和儲存層下標。兩者構成一對對映,通常是滿射。通過對映法則,兩者可以互相求得。所以我們只需要在資料成員中儲存一方,就可以在需要時計算出另一方。我在上面的實現中,都選擇了儲存邏輯下標,並將計算儲存下標的工作封裝在一個函式中,這樣做的好處是:1. 幾乎每個介面都有邏輯層的處理,但不是每個都需要動用儲存層邏輯,所以儲存邏輯層資訊可以使得介面在邏輯層的處理更直接高效。例如,IsEmpty介面就無需計算儲存層下標;2. 當你想改換一種物理儲存結構時,例如從陣列改為連結串列,你只需要修改從邏輯層下標到物理層下標的對映過程,靈活性較好。
- 對映法則可以很靈活。通常,考慮效率和易讀性,棧下標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,分別表示隊頭和隊尾元素的下一個元素的下標。未完待續...