攤還分析(2)——算法導論(24)

1. 動態表

先來介紹動態表的概念。

我們在使用數組時,通常都是先創建一個大小固定的數組,然後再將數據填充進去。這時難免會遇到創建的數組過小或過大的情況。過小則滿足不了存儲需求;過大則浪費存儲空間。於是我們對普通數組進行包裝,創造出一種叫做動態表的數據結構。

所謂動態,就是它能夠自動地進行表的擴張(在數組容量不夠時)與收縮(在數組容量過大時)。具體地,它通常會採取這樣的策略:在插入數據時,若檢查到數組容量過小,則會創建一個新的容量較大的數組,然後將原始數組裏面的數據複製到新的數組中,最後再執行插入操作;在刪除數據時,先執行刪除數據操作,然後若檢查到數組容量過大,則會創建一個新的容量較小的數組(當然要保證容量足夠),最後同樣將原始數組裏的數據複製到新的數組中。

本篇博客關注於動態表的擴張和收縮問題,將使用攤還分析證明,雖然插入和刪除操作可能會引起表的擴張和收縮,從而有較高的實際代價,但它們的攤還代價都是\(O(1)\)

需要說明的是,用什麼樣的數據結構來組織動態表不是固定和重要的,除了以上所說的採用數組的方式,你也可以使用堆、棧或散列表來實現。

2. 表擴張

在介紹表的擴張之前,我們先引入在學習散列表時接觸的概念:裝載因子\(\alpha\)

非空表T的裝載因子\(\alpha(T)\)定義爲表中儲存的數據項的數量比上表的規模(槽的數量)。

下面用一小段Java代碼給出表的擴張操作:

public class Table<T> {

    private Object[] values;
    private int size; // 表中元素的個數

    public void insert(T value) {
        if (values == null) {
            values = new Object[1];
        }
        if (size == values.length) { // 表的擴張
            Object[] newValues = new Object[size * 2]; // 創建一個容量是之前兩倍的數組
            System.arraycopy(values, 0, newValues, 0, values.length); // 拷貝原始數據到新數組
            values = newValues;
        }
        values[size] = value;// 插入新數據
        size++;
    }
}

從代碼中我們發現,整個insert操作實際上包含兩個"插入"過程,一個是copy原始數據,另一個是插入新數據。我們把每次基本插入操作的代價設定爲1,然後用基本插入操作的次數來描述insert操作的運行時間。

下面我們分析對一個空表執行n個insert操作的代價。設第\(i\)次操作的代價爲\(c_i\)。如果當前表有空間容納新的插入項,那麼\(c_i = 1\);如果當前表已填滿,則會發生一次擴張,此時需要將舊錶裏的\(i-1\)項(因爲已經插入了i-1次數據)數據copy到新表裏面,還要插入新的數據,因此代價\(c_i = i\)。一個操作的最壞時間是\(O(n)\),因此n次操作總運行時間的上界爲\(O(n^2)\)

和上一篇中的兩個例子一樣,這個上界也不是緊確的,因爲在執行n個操作中,不可能每次操作都遇到最壞情況,即不是每次都需要擴張表。事實上,只有當\(i - 1\)爲2的冪時,才需要擴張。

採用聚合分析分析該問題:第i個操作的代價是:

\[\begin{eqnarray}c_i= \begin{cases} i, &\text{若$i-1$恰爲2的冪}\cr 1, &\text{其他} \end{cases} \end{eqnarray} \]

因此n個操作的總代價是:

\[\sum_{i=1}^{n}c_i \leq n + \sum_{j=0}^{⌊\lg n⌋} 2^j < n + 2n = 3n \]

而單一操作的攤還代價最多爲3。

我們可以用覈算法來更加直觀地考慮爲什麼每次插入一項數據需要付出3個單位的代價。假設表在某次擴張後容量變爲\(m\),此時表中有\(\frac{m}{2}\)項數據,它們都沒有儲存任何信用。在進行下一次插入時,我們付出3個單位的代價,1個單位用來支付本次插入;1個單位存儲起來用來支付以後移動該數據時的代價;還有1個單位也存儲起來用來支付原始的\(\frac{m}{2}\)項數據中的某項在下一次移動時的代價。這樣,在表下一次擴張時,不需要消費額外的代價來支付移動數據這m項數據的代價。

我們還可以用勢能法來分析:定義如下的勢函數:\(\Theta_i(T) = 2 T.size - T.cap\),它表示第\(i\)次插入後的勢能,\(T.size\)表示表中數據的項數,\(T.cap\)表示表的大小。易知,\(\Theta_0(T) = 0\);表始終處於等於或超出半滿狀態,即\(T.size \geq T.cap / 2\),因此\(\Theta_i(T) \geq \Theta_0(T) = 0\)。因此,n個insert操作的總攤還代價給出了總實際代價的上界。

下面分析第i個操作的攤還代價。設\(cap_i\)爲第\(i\)次操作後表的規模。

  1. 若第\(i\)次操作表沒有擴張,一次操作本身的實際代價爲1;其引起的勢能的變化爲2。因此一次操作的攤還代價爲3。
  2. 若第\(i\)次操作表發生了擴張,一次操作本身的實際代價爲\(i\);其引起的勢能變化爲:\([2i - 2(i-1)] - [2×(i - 1) - (i -1)] = 2 - (i - 1) = 3 - i\)。因此一次操作的攤還代價也爲3。

綜上所述,一次插入操作的攤還代價爲\(O(1)\)

3. 表收縮

表收縮過程正好與擴張過程相反,這裏不在贅述。

4. 表的擴張與收縮

值得注意的是,因爲我們在擴張時,通常是將表的規模擴增爲原來的2倍,相應地,你可能也認爲我們應該在表中元素個數不足規模的一半,即\(\alpha<\frac{1}{2}\)時,進行表的收縮。遺憾的是,這不是一個好的策略。考慮如下場景,當表在發生一次擴張操作後,我們進行兩次刪除操作,可以看到,表會在這兩次刪除操作中的第二次發生收縮;接下來我們再進行兩次插入操作,同樣,表又會在第二次插入操作時進行擴張。重複上述行爲,表會一直進行收縮,擴張。即:刪除、刪除(收縮)、插入、插入(擴張)、刪除、刪除(收縮)…這樣,對於n個操作,每個操作的攤還代價爲\(\Theta(n)\)

我們改進一下此策略,允許裝載因子\(\alpha\)小於\(\frac{1}{2}\),可以想象,選取\(\frac{1}{4}\)作爲\(\alpha\)的下界是比較合理的,下面我們用勢能法證明這點。

首先是勢函數的選取,定義勢函數爲:

\[\begin{eqnarray}\Theta(T)= \begin{cases} 2T.size - T.cap, &\text{若$\alpha(T) \geq 1/2$}\cr T.cap/2 - T.size, &\text{若$\alpha(T) < 1/2$} \end{cases} \end{eqnarray} \]

同樣,空表的勢爲0,;勢始終大於0。因此對於n個插入和刪除的操作序列,其總攤還代價是總實際代價的上界。

下面我們分析第\(i\)個操作的攤還代價。分兩種情況討論。在此之前,我們做如下定義:用\(c_i\)表示第\(i\)個操作的實際代價,用\(c_i'\)表示攤還代價。同樣,\(size_i\)表示第\(i\)次操作後儲存的數據項的數量,\(cap_i\)表示第\(i\)個操作後表的規模。用\(\alpha_i\)表示第\(i\)個操作後的勢。

4.1 第\(i\)個操作是插入操作

\(size_i = size_{i-1} + 1\)

1. 當\(\alpha_{i-1} \geq 1 / 2\)

這種情況與我們在表擴張小節中分析的情況一致,攤還代價\(c_i'\)至多爲3。

2. 當\(\alpha_{i-1} < 1 / 2且\alpha_i < 1/2\)$:

\(i\)個操作並不能引起表的擴張,\(cap_i = cap_{i-1}\)。因此\(c_i' = 1 + [(cap_i/2 - size_i) - (cap_{i-1}/2 - size_{i-1}] = 0\)

3. 當\(\alpha_{i-1} < 1 / 2且\alpha_i\geq1/2\)

\(i\)個操作並不能引起表的擴張,\(cap_i = cap_{i-1}\)。因此\(c_i' = 1 + [(2size_i - cap_i) - (cap_{i-1}/2 - size_{i-1})] = 3size_i - cap_i - cap_{i-1} / 2\),又因爲\(\alpha_{i-1} = (i-1 ) / cap_{i-1}\),因此\(c_i' = 3\alpha_{i-1}cap_{i-1} - 3/2 cap_{i-1} + 3 < 3\)

因此,當第\(i\)個操作是插入操作時,其攤還代價至多爲3。

4.2 第\(i\)個操作是刪除操作

\(size_i = size_{i-1} - 1\)

1. 當\(\alpha_{i-1} \geq 1 / 2且\alpha_i > 1/2\)

\(i\)個刪除操作不會引起表的收縮,\(cap_i = cap_{i-1},\)。因此\(c_i' = 1 + [(2size_i - cap_i) - (2size_{i-1} - cap_{i-1})] = -1\)

2. 當\(\alpha_{i-1} \geq 1 / 2且\alpha_i < 1/2\)

\(i\)個刪除操作不會引起表的收縮,\(cap_i = cap_{i-1}\)。因此\(c_i' = 1 + [( cap_i /2 - size_i) - (2size_{i-1} - cap_{i-1})] < 2\)

3. 當\(\alpha_{i-1} < 1 / 2\)

\(i\)個操作可能引起表的收縮。

① 若沒有引起表的收縮。則\(c_i' = 1 + [(cap_i / 2 - size_i) - (cap_{i-1}/2 - size_{i-1})] = 2\)

② 若引起表的收縮。則\(c_i' = (size_i + 1) + [(cap_i / 2 - size_i) - (cap_{i-1} / 2 - size_{i-1})] = 1\)

因此,當第\(i\)個操作是刪除操作時,其攤還代價至多爲2。

總之,由於每個操作的攤還代價都爲常數,在一個動態表上執行任意n個操作的實際運行時間是\(O(n)\)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章