動態陣列的記憶體增長因子如何選擇-1.5倍還是2倍?
C++ 中的 vector 長度是如何動態增長的
c++ 標準庫函式預設情況下提供了合理的效能,但是,如何做到“合理”的呢,read on next.
介紹
假如我們需要從一個檔案中讀取一組 double 型別的值,並儲存在一個數據結構中,我們可以通過以下方式很快速的實現:
vector<double> values;
double x;
while (cin >> x)
values.push_back(x);
當迴圈結束時,values 將儲存所有這些值。我們可以通過變數 i 和 values[i] 來快速訪問這些值。
直觀來說,標準庫函式中的 vector 類就像是一個內建陣列:我們可以把他想象成一組儲存資料的連續的記憶體塊,能夠通過 values[i] 來訪問。確實,C++ 標準並沒有明確規定 vector 的元素必須佔用連續的記憶體塊,但是 2000 年 10 月的標準委員會 (the standard commitee) 會議通過,認為這是一項疏忽,並把這個要求加入到了它的技術勘誤表中 (Technical Corrigendum)。這一遲來的決定,並沒有造成多大的問題,因為使用 vector 實現的這些程式碼中,都是按照這個約定來工作的。
如果 vector 的元素是儲存在連續的記憶體中的,那麼就可以很容易的理解 vector 的元素訪問為什麼如此高效了 - 這就像是陣列一樣,能夠對資料元素隨機存取 (it simply uses the same mechanism as the built-in arrays use.)。但是,不容易理解的是, vector 是如何高效的組織元素自己動態增長的呢,因為儲存在連續的記憶體中,不可避免的需要將資料從一個記憶體塊複製到另一個記憶體塊。現在處理器在處理連續記憶體塊的複製方面已經可以做到很高效了,但是這些拷貝是不被釋放的,會佔用大量的記憶體。因此,需要思考,標準庫函式中 vector 的增長,在沒有佔用大量時間和空間的情況下,是如何實現的。
下面來討論一種簡單、高效的策略來管理這種動態記憶體的增長。
大小和容量 (size and capacity)
想要弄清楚 vector 是如何工作的,首先就要清楚 vector 並不僅僅是一個連續的記憶體塊,每一個 vector 都有兩個相關兩的記憶體塊。一個是大小塊,儲存有 vector 元素個數,另一個為容量,是 vector 的整個記憶體大小,能夠儲存 vector 的所有元素。比如 v 是一個 vector 類的物件,v.size() 和 v.capacity() 將返回 v 的大小和容量。可以想成下面這種結構:
--------------------------------- | elements | available space | --- size --- ---------- capacity -------------
在 vector 的末尾額外使用一個記憶體的意義,是在使用 push_back
加入元素的時候,可以不需要再去申請記憶體。如果這塊記憶體剛好緊鄰 vector 的記憶體,那麼直接將這塊記憶體直接加入到 vector 上就能實現記憶體增長。但是這種情況極少,大多數情況下,都需要去申請一塊更大的新記憶體,然後將所有的元素都拷貝到新記憶體塊中,然後釋放掉原有的記憶體塊。
記憶體再分配會經過一下四步:
1. 申請足夠大的記憶體儲存所有的元素
2. 將原有的元素拷貝到新記憶體中
3. 銷燬原記憶體塊中的元素
4. 將原記憶體塊釋放,歸還給作業系統
如果有 n 個元素,那麼上述操作的時間複雜度為 O(n),每次增加一個元素都需要執行一次。2 和 3 步驟將耗費主要的時間。因此,這種方法,當我們為 vector 在申請 n 大小的時候需要耗費 O(n) 的時間。
現在採用一種折中的方法。當我們再分配的時候,我們額外申請很多空間,這樣,在下一次新增元素的時候,就不需要頻繁的進行再分配,這樣能節約很多時間。但是這種方式的代價就是需要浪費一些空間。另一方面,我們可以只申請一點額外的空間,這樣做可以節約空間,只需要花時間在額外的再分配上。換句話說,我們是通過空間換取了時間。
再分配策略 (Reallocation Strategy)
舉一個極端點的例子,假設我們需要往一個 vector 物件中新增元素,我們每一次都需要擴大 vector 的容量,即申請多一個的空間儲存新增的元素。雖然這麼做,能很好的節省空間,需要多大就申請多大,但是如果需要新增 n 個元素,那麼我們需要重新申請 n 次空間,並且每次都需要將原來的元素拷貝到新申請的記憶體塊中,時間為 O(n)。也就是說,如果我們需要在一個空的 vector 中新增 k 個元素,總的時間為
O(1 + 2 + 3 + ... + k) = O(k(1+k)/2)
即時間複雜度為 O(k2),這種策略效率是很差的。(That’s terrible!)
現在,我們換一種策略,假設我們每次擴大 vector 的容量的時候,不是每次只增加一,而是增加一個恆定的大小 C。通過這個增長因子 C,能明顯較少重申請的次數,這當然是一個進步,但是效率能有多大提升呢?
上面這中策略在我們每次新增 C 個元素的時候,都需要擴大 vector 的容量,衝申請一次。假設我們需要新增 K*C 個元素,那麼第一次重申請需要拷貝 C 個元素,第二次需要拷貝 2C 個元素……,消耗的總時間仍然是二次方。
二次方的時間複雜度的策略,還有很大的可優化的空間,即使使用高效能的處理器和大記憶體也不能解決問題。
上述策略分析得知,為 vector 再分配記憶體,擴大容量時,每次申請僅僅是擴大一個固定的大小,時間複雜度達到二次方。相反,額外申請的容量必須隨著 vector 容量的增長而增長。那麼,如果我們每次為 vector 重新申請記憶體時,額外申請的空間是之前的 2 倍大小的話,可以顯著的降低時間複雜度,為 O(n)。
當我們為 vector 重新申請記憶體並向 vector 新增元素時,我們將 vector 想成一下這種結構:
最後一次重新分配記憶體之後,有一半的空間可以新增新元素,所以這部分元素不需要拷貝,但是原先的那 n/2
個元素需要進行拷貝到新申請的記憶體中;同理,上一次重分配時,有 n/4
的元素需要拷貝,以此一次類推,獲得如下公式
total = (n/2)*1 + (n/2^2)*2 + (n/2^3)*3 + ... + (n/2^k)*k
這就是時間複雜度的計算公式,結果為
total = 2n - (n/2^k)(2+k)
時間複雜度達到了 O(n)。
討論 discussion
C++ 標準並沒有強制要求 vector 類必須按照特定的方式管理記憶體,標準要求的是建立一個 n 個元素的 vector ,重複呼叫 push_back
新增元素的時間控制在 O(n) 內。我們討論的重分配的策略恰恰是一種最直觀的能夠滿足這一條件的方式。
既然 vector 的效能這麼好,那我們當然可以使用如下的迴圈進行編碼
vector<double> values;
double x;
while (cin >> x) {
values.push_back (x)
}
這種再分配的策略申請的額外記憶體會隨著 vector 容量 (capacity) 增長而增長,它比每次只固定增加某一大小的方法效能要高,但是,如果你能預測或者已經知道你需要儲存的元素的數量,直接申請一塊能夠儲存所有元素的記憶體塊儲存這些元素,豈不更好嗎?
一條華麗的分割線
上面的譯文,講述了 c++ 中 vector 的動態增長因子為 2,以及為 2 的好處,但是並沒有說明為什麼是 2
What is the ideal growth rate for a dynamically allocated array
動態陣列的增長因素依賴很多元素,包括時間與空間的權衡以及用於記憶體分配的演算法。關於最佳增長因子,展開過很多次討論,包括推薦使用黃金比例來進行。(The Golden Ratio - Phi, 1.618)。以下是一些比較流行的語言中的增長因子2:
在有些討論中3,認為增長因子 1.5 要優於 2,首先我們分析一下增長因子為 2 的情況:
1、 假如現在申請了一個 16B 的記憶體空間
2、 當我們需要更多記憶體的時候,我們申請了 32B 的記憶體空間,釋放掉之前的 16B
3、 當我們在需要更多記憶體的時候,我們需要申請 64B 的記憶體空間,釋放掉之前的 32B 的空間,此時一共釋放了 16B+32B=48B 的空間
4、 當我們繼續需要更大的空間的時候,我們需要申請 128B 的空間,釋放的總空間大小為 112B
5、 依次類推
根據以上思路,每次記憶體重申請都是原來記憶體的兩倍大小,根據申請的記憶體大小和釋放的記憶體大小可知,指數增長總是更快,也就是說釋放的總的記憶體大小總是小於下一次需要申請的記憶體大小,這樣造成的情況就是,每次都不能對之前的記憶體複用4 (上面的文章中已經說過了,一般申請都是連續的記憶體)。
如果增長因子為 1.5 的話,分析如下:
1、 假如申請了 16B 的記憶體空間
2、 再次申請記憶體需要申請 24B 的空間,釋放掉之前的 16B 的空間
3、 需要更多記憶體時,需要申請 36B 的空間,釋放空間為 40B
4、 需要更多記憶體時,需要申請 54B 的空間,釋放空間為 76B
5、 需要更多記憶體時,需要申請 81B 的空間,釋放空間為 130B
6、 需要更多記憶體時,需要申請 122B 的空間,此時,可以使用前面釋放的 130B 的空間,這樣就能充分利用到記憶體。
有些人認為,如果增長因子是 1.5,那麼在計算申請記憶體的時候,需要先轉換成浮點數,然後計算後,在轉換成整數,這樣大大增加了計算量。
但是,1.5 倍,等於是 old * 3/2,/2 可以使用位運算 >>1 來實現。所以,並不需要非要進行浮點運算轉換。
那麼,下面我們思考一下,如果我們增長因子為 x,最開始申請的記憶體空間大小為 T。下一次需要申請的記憶體為 T*x
,釋放空間為 T;當需要更多記憶體時,再次申請記憶體為 T*x^2
,釋放空間為 T + T*x
;……
我們的目標就是後面申請新記憶體時,能夠重用到之前釋放的那些記憶體(假設他們是相鄰的),也就是說新申請的記憶體不超過之前釋放的記憶體之和,得到如下等式:
T*x^n <= T + T*x + T*x^2 + ... + T*x^(n-2)
等式兩邊消除 T 所得:
x^n <= 1 + x + x^2 + ... + x^(n-2)
通常,我們說第 n 次申請記憶體,期望是第 n 次申請記憶體,能夠使用到之前釋放的記憶體,也就是釋放的記憶體總和不小於第 n 次申請的記憶體大小。
比如,根據上面的等式,當我們想要在第三步就能達到這種預期,即 n = 3
x^3 <= 1 + x
等式的解為 0 < x < 2
,精確一點就是 0 < x < 1.3
,當 n 為其他值時如下
n maximum-x (roughly)
3 1.3
4 1.4
5 1.53
6 1.57
7 1.59
22 1.61
增長因子都比 2 小。
參考文獻: