[完结]以C++与Java为例,详解数据结构的动态增长策略
前言
本文改編自小夕的訂閱號文章《【萌味】小夕說,不了解動態空間增長的程序喵都是假喵(上)》、《【萌味】小夕說,不了解動態空間增長的程序喵都是假喵(中)》、《【萌味】小夕說,不了解動態空間增長的程序喵都是假喵(下)》。萌氣已過濾,需要呼吸萌氣的讀者請戳上面原文哦。
筆者學了數據結構后,知道了鏈表、樹、哈希表等數據結構與靜態數組的固定容量不同,它們是可以動態添加元素的。這種數據結構的初始大小可能很小,甚至幾乎為零,但是隨著新元素的加入,其大小(內存空間占用)會不斷增長,這個過程就叫做動態空間增長。
那么問題來了,所有支持動態空間增長的數據結構都是相同的增長方式嗎?了解這個又有什么意義呢?
筆者曾經很傻很天真的認為所有支持動態空間增長的數據結構都是每增加一個元素,數據結構的大小就增加1個單位。直到在一個中規模機器學習任務的數據預處理過程中遇到了“內存爆炸”的問題,即筆者明明計算的內存夠用,但是筆者可憐的電腦的內存卻意外爆滿了。這是怎么回事呢?
筆者為了避免講解太過抽象,所以建議:如果您擅長C++,那么請注意一下Vector數據結構;如果擅長Java,請注意一下ArrayList、LinkedList、哈希系列(HashSet/ HashTable/ HashMap);如果您不用Java也不用C++,或者已經脫離XX編程語言的層次,那么請注意一下可變數組(可增長順序表)、鏈表、哈希(散列)。筆者將基于上述數據結構展開講解。
遞增式擴容
對于Java的LinkedList,也就是數據結構中的鏈表,其空間增長方式就是筆者一開始的設想:每增加一個元素,其大小就增加一個單位(這里的一個單位就是指一個元素占用的空間大小)。原因就在于鏈表在內存中的存儲可以是不連續的。例如一個依次由節點1、節點2、節點3連接而成的鏈表在計算機內存中完全有可能是下面的存儲方式。
這樣的話,鏈表每增加一個元素,只需要在內存中找個縫將新元素插進去就可以。所以如果筆者手里有n個元素想插入鏈表,則需要開辟n次內存,每次均開辟一個元素的大小。
這種數據結構建立后,每次數據結構要擴容時均增加固定空間大小的做法被稱為【遞增式擴容】。顯然鏈表的空間增長方式就是遞增式擴容,而且遞增的單位為1(這里是指1個單位,即一個結點的大小)。
可以看到,如果是鏈表數據結構,或者是底層基于鏈表而實現的數據結構,采用遞增式擴容是最優選擇。因為要增加一個元素,則最好的情況就是其他什么都不動,僅僅是為該元素開辟一個單位的空間,然后塞入該元素。而遞增式擴容用于鏈表確實達到了這個最理想情況呢。因此,對于鏈表,以及底層基于鏈表結構實現的數據結構,都是采用遞增式擴容即可達到最優擴容效率(最優動態空間增長)。例如哈希中的橫向增長,再如基于鏈表實現的樹(如Java中的TreeSet,TreeMap等)。
因此在操縱大量數據的時候,尤其機器學習任務中常見的操縱大量樣本的時候,在內存的問題上可以安心的使用此類數據結構,不會導致“內存爆炸”的問題,內存只會慢慢的起火然后輕輕的告訴你滿了。當然了,不能僅考慮內存,有時操縱大量數據時對數據處理效率要求更高,這時候就要舍內存保速度啦。
那么哪些常見數據結構采用遞增式擴容無法達到最優呢?它們有什么共性嗎?還有,小夕遇到的內存爆炸是怎么回事呢?
源于數組
那么對于C++中的Vector,Java中的ArrayList、HashSet/ HashTable/ HashMap,也就是數據結構中的可變數組、哈希來說,空間增長方式是怎樣呢?可能有讀者此時在想“這些數據結構又不一樣,怎么放到一起討論了呢?”
其實這些表面看似不同的東西,底層的實現方式確是一樣的,它們在底層都是通過操縱靜態數組來實現他們的動態空間增長功能,下文會詳細介紹。
講到這里,可能有讀者會記得筆者在上一篇中也提到過哈希,說哈希的橫向增長是基于鏈表的,因此遞增式擴容是最優動態空間增長方案。那這一篇中又說哈希是基于靜態數組的,這是怎么回事呢?下面給沒有接觸過哈希的讀者先科普一下哈希:
哈希的橫向增長是基于鏈表實現的,即當新元素的哈希值與已有元素哈希值相同時,新元素會插入到某個鏈表中,因此是遞增式增長。但是更多的情況下,哈希是縱向增長的。學過數據結構的寶寶知道,哈希在縱向上就是一個指針數組,數組的每個索引值即代表一個哈希值,數組的每個元素是一個指向某鏈表的指針。畫個圖來看就是這樣的。
所以,在本篇文章中,我們不看哈希的橫向增長,只關注縱向增長,此時顯然是基于靜態數組實現的。
基于數組的擴容原理
下面筆者直接以“數據結構”代稱所有這些基于靜態數組實現的動態空間分配的數據結構,包括但不限于上文提到的C++中的Vector(即數據結構中的動態數組),Java中的ArrayList(即動態數組)、Hash系列(即哈希/散列)等。
具體來說,如何用靜態數組實現上述的動態空間增長的數據結構呢?其實很簡單,每次數據結構要擴容時只需要依次進行下述操作就可以完成:
開辟一段新的內存空間,空間大小就是擴容后的數據結構大小。
把舊數據結構,也就是舊的內存空間的元素一個個的復制到新的內存空間
釋放舊的內存空間(代碼上就是刪除舊空間的指針,當然像Java這種自動管理內存的語言就不用操心這一步了)
通過上述擴容的三步操作,可以看到每次哈希表的擴容操作的代價還是挺大的。第1步和第3步的代價不算大,但是第2步的代價會隨著要搬移元素數量的增加而直線上升。所以這就相當于一個完整搬家的過程:先買個新房子,再把舊房子里的全部家當搬到新房子里去,再把舊房子注銷。
加倍式擴容
既然代價如此之大,那么顯然我們要盡量減小擴容次數。怎么擴呢?一個很creative的想法就是每次使數據結構變為自身的兩倍!再機智一點,每次使數據結構變為自身的N倍!其中N只要大于1就可以!口說無憑,下面給出算法分析的過程。
假如數據結構A使用【遞增式擴容】。每次數據結構滿了的時候就固定的增加10個單位的空間(增加單位的數量不會影響最終分析出來的復雜度哦)。好,那小夕現在手里有n個元素想添加進數據結構,假如n的數值很大,遠遠的大于10,那么要執行多少次擴容操作呢?當然是n/10次啦~這n/10次擴容的累計開銷大約為
計算一下這個級數,就是
所以復雜度是O()的數量級,所以平均每個元素被添加進哈希表時的開銷為cost/n,也就是O(n)的復雜度。
假如數據結構B使用【加倍式擴容】。每次數據結構滿了的時候,數據結構的大小就變成原來的2倍(與之前同樣的,這個倍數取不同的值并不會影響最終分析出來的復雜度,當然倍數必須大于1!)。同樣,將n個元素添加進數據結構,假如n的數值很大,遠遠的大于2,那么要執行的擴容操作的次數是log2n!令c=log2n,則這c次擴容操作的累計開銷為
這個級數的和為
代入c=log2n得cost=2(n-1)。也就是說復雜度為O(n),所以平均每個元素被添加進哈希表時的開銷為cost/n,也就是O(1)的復雜度!注意前面我們計算過,這里數據結構A(遞增式擴容)的復雜度為O(n)!
講到這里讀者應該清楚了吧?所以如果有一天你要自己寫一個基于數組的動態空間增長的數據結構的話,可千萬不要寫成遞增式擴容了。
內存爆炸
正是因為這類數據結構采用了加倍式擴容,導致這類數據結構申請內存的時候翻倍翻倍的要。結果當時在那個中規模機器學習任務中,筆者算的是一個超大哈希表只需要占用5個G作右的內存空間,而實際上在往這個哈希表加數據時,從4個G直接爆到了接近8個G,導致筆者內存8G的小電腦直接崩盤了。
等等,看似此文可以結了,實際上,敏銳的讀者可能想到了:“遞增式擴容你都告訴我了每次擴容增加一個單位的空間就最優了,那加倍式擴容每次增大幾倍最優呢?”如果讀者能發現這一點的話,真的非常棒啦!答案是2倍嗎?當然不!那是幾呢?真的有最優倍率嗎?
一個視角:內存復用
如果倍率采用2甚至更大的數,那么被開辟過的舊空間永遠都不會被新開辟的空間利用。小夕舉個栗子。
if(倍率≥2){
那么以下是小夕為大家畫的三次擴容后的內存塊的占用情況
上圖中,內存塊一共有15個字節。粉色實心框是數據結構占用的內存塊,空心框是空閑的內存。
假如一開始數據結構的大小是1字節,占用了0xFF00這個字節,如圖中第一列。然后第一次擴容后數據結構大小變成2字節,無法利用之前的舊內存空間。
同樣,第二次擴容,第三次擴容后,數據結構的大小總是要比之前累計占用的舊內存空間之和還要大,總是大1個字節,所以永遠都無法重新利用之前的舊內存空間。
那么無法復用舊內存空間,對應有程序與操作系統各有什么影響呢?小夕還沒有探索出嚴謹的結論,讀者有思路可以跟小夕一起討論哦~
如果倍率改為比2大的數,結果是一樣的。有興趣的讀者可以自行畫畫圖~當然,數學好的喵喵不用畫圖也能證明出來的~(利用幾何級數的性質)
}
if(倍率<2&&倍率>1){
比如倍率采用1.5。小夕再畫一下圖~
可以看到,第三次擴容后的新數據結構大小約為338B!而舊空間的大小是250+225=475>338,也就是說新的哈希表可以挪到舊的內存空間了!內存得到了復用!
好咯,說到這里,讀者應該懂了,對于加倍式擴容,倍率必須小于2才能復用內存。那么為什么默認值取1.5,而不是1.6,1.7呢?小夕查了很多資料,發現這是一個啟發式策略(啟發式策略就是拍腦袋想出來的看似合理而沒有嚴謹理論依據的方法)。
一個疑問
那么既然看似倍率用1.5要優于2,為什么C++中部分Vector的實現中卻采用2呢?
注:感謝@冒泡 指正,有的C++中Vector的實現采用的1.5倍。這就是理論與工程的不同之處。在工程中不僅要考慮內存復用這一個問題,還要考慮到浮點數運算問題和大量數據場景下的擴容速度的問題。
關于浮點運算:此處感謝@謝天奇指正,小夕之前想當然了,深深抱歉!浮點運算速度不會因有效位的增加而降低,但一般來說浮點運算效率確實比整型運算的效率低。因此確實存在浮點運算問題,但若采用浮點數運算,計算浮點數時的速度在理論上是幾乎無變化的。
擴容速度也很好理解。大量數據時,2倍擴容速度會比1.5倍擴容速度少很多次擴容次數,因此效率會比1.5倍高很多。那么當程序不怎么看重內存復用,卻有大量數據待填入數據結構時,2倍是更合理的。
補充:關于哈希的擴容倍率
該章節由@冒泡提供,對哈希的擴容倍率的考慮不在上一章節討論范圍內。哈希之所以采用2倍的擴容倍率(更準確的說哈希的擴容倍率應采用2的冪次),是處于哈希表元素找位置的角度考慮的。
下面引用@冒泡清晰的講解:
一般來說,hash表元素找位置的辦法是元素的hash值對表大小取模理論上表大小是個正數就可以,不過對于一般的數字,計算機的整數除法是很慢的
如果表大小是2的冪,則可以用位運算來代替除法,比如表大小為1024,則K%1024可以優化為K&0x3FF,速度就快很多,所以hash表大小最好保持為2的冪,因此擴容時候只能乘以2,或乘以2的冪
因為這個原因,java的hash表擴容,才是翻兩倍
So
雖然很多數據結構都是基于靜態數組實現的動態空間增長,但是有的是上述提到的2倍的擴容倍率甚至更高,有的像Java中的ArrayList和C++中Vector的部分實現中則為1.5倍的擴容倍率。
總結
以上是生活随笔為你收集整理的[完结]以C++与Java为例,详解数据结构的动态增长策略的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 【完结】史上最萌最认真的机器学习/深度学
- 下一篇: SpingMVC框架:fileUploa