本文以前遭遇搬運,已聯繫掘金對舊文刪除處理,現從新發布一遍。
更多精彩內容能夠關注公衆號承香墨影(ID:cxmydev)。java
不少寫程序的人都據說過一個公式,程序 = 算法 + 數據結構。而在 Java 中,自身已經提供了豐富的集合,來幫助咱們處理和管理數據,可是多數狀況下,咱們比較經常使用的就那麼幾個,可這並不妨礙咱們學習瞭解其餘「冷門」的集合類。web
可是集合類那麼多,怎麼學?一個一個方法看其內部實現?我想你就算耐着性子看完了,沒幾天也都忘乾淨了,由於細節太多了,同時使用的頻率低,遺忘是必然的。算法
全部的集合類都是爲數據和業務服務的,對外無外乎就是增刪改查幾種操做,對內沒法避免的還有一些例如初始化、數據的維護、動態擴容等等實現,細節都在內部。數組
在學習集合類的時候,咱們應該保持一個清晰的主線,只關注幾個重要的問題,你就能夠還原對這個集合的認識。數據結構
好比我在看一個集合類的時候,會思考三個問題:app
程序的世界裏,沒有銀彈,不然其餘集合就沒有了存在的必要。也就是說每一個集合必定有不一樣的側重點,它在時間和空間上,必定是有所取捨,有所平衡,在這個目標下,作出最合適的優化實現。性能
就拿 HashMap 來講,它做爲一個工業級的集合類,其中有很多複雜的實現細節,用哈希表讓查詢的時間複雜度達到 O(1),可是「哈希衝突」是它須要面對的問題,因此它採用「拉鍊法」的方式解決衝突,又由於鏈表會讓時間複雜度在極端狀況下退化到 O(n),又引入了紅黑樹,以保證在最惡劣的狀況下時間複雜度也不會退化的太嚴重。學習
凡事都要講個平衡,沒有銀彈。測試
那接下來,再說說本文的主角 SparseArray。優化
在 Android 中,IDE 偶爾會提到咱們應該使用 SparseArray 替換掉 HashMap,其根本緣由就在於 SparseArray 相比較 HashMap 會更省內存。
具體理解 SparseArray 呢?記住三句話就行了。
接下來,咱們就圍繞這三句話,看看 SparseArray 的細節。
在分析這三句話以前,先來了解一下 SparseArray 的基本使用,對 SparseArray 有一個基本認識,才能更好的理解它。
在建立 SparseArray 的時候,須要指定一個泛型類型,它就是 SparseArray 存儲的數據類型。
val sparseArray = SparseArray<Student>()
// or
val sparseArray = SparseArray<Student>(10)
複製代碼
其中 Key 數組的類型已經被定義成了一個 int 類型的數組,因此無需也沒辦法在構造時指定。
雖然構造 SparseArray 的時候無需指定 key,可是在增刪改查的時候,Key 是一個重要的檢索條件,它做爲定位一個數據的惟一標識,會貫穿 SparseArray 的大部分的操做方法。
// 增長數據
sparseArray.put(int key, E value)
// 移除數據
sparseArray.delete(int key)
// 獲取數據
sparseArray.get(int key)
sparseArray.get(int key, E valueIf NotFound)
複製代碼
最基本的增刪改查就這幾個方法,不過 SparseArray 內部使用數組這種順序表的結構,一樣也提供了一些經過 index 的操做方式。
sparseArray.indexOfKey(int key);
sparseArray.indexOfValue(E value);
sparseArray.keyAt(int index);
sparseArray.valueAt(int index);
sparseArray.setValueAt(int index);
sparseArray.removeAt(int index);
sparseArray.removeAt(int index,int size);
複製代碼
SparseArray 的使用方式,符合咱們的使用習慣,基本上看看方法名就大概知道它的含義。
接下來就來看看它的內部實現。
SparseArray 內部使用雙數組,分別存儲 Key 和 Value,Key 是 int[],用於查找 Value 對應的 Index,來定位數據在 Value 中的位置。
在 SparseArray 內部,採用兩個數組來存放數據,它們是一一對應的關係。
mValue 數組是爲了存儲數據的索引,它和 mKey 數組的關係是一一對應的,咱們經過 key 的值,就能夠定位出它在 mKey 數組中的 index,進而能夠操做 mValue 數組中對應的位置。
由於是一一對應的關係,全部對數據的操做,都須要對這兩個數據進行操做。第一句話就是爲了對 SparseArray 數據是如何存儲的,有一個基本的認識。
使用二分查找來定位 Key 數組中對應值的位置,因此 Key 數組是有序的。
既然是使用數組這種順序表,在查找的時候一般須要從前向後遍歷數組,這時的時間複雜度就是 O(n),這明顯不是 SparseArray 想要的。
在 SparseArray 中,採用二分查找算法,來快速經過 key 定位出它在 mKey 數組中的位置,二分查找的實現,在 ContainerHelpers.binarySearch()
中。正由於使用了二分查找, SparseArray 的查找操做,時間複雜度能夠作到 O(logn)。
再看 SparseArray 的源碼,全部和 key 相關的操做,第一步都是經過二分查找,定位出它的 index,再進行後續的處理,例如 get()
的實現。
public E get(int key, E valueIfKeyNotFound) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
if (i < 0 || mValues[i] == DELETED) {
return valueIfKeyNotFound;
} else {
return (E) mValues[i];
}
}
複製代碼
先忽略其中的 DELETE 標記的邏輯,後面會講到。
binarySearch()
中會經過 key 查找對應的 index,若是查詢不到,它會返回一個負數 i,注意這個 i 是有意義的,i 的相反數,就是 key 在 mKey 數組中,比較合適的位置(index)。
什麼叫比較合適的位置?
就是雖然這個 key 沒有在 mKey 數組中找到,可是若是把 key 插入到 mKey 數組的第 i 個位置上,mKey 數組依然是有序的。
咱們知道,二分查找的前提條件,就是必須是針對有序而且支持下標隨機訪問的數據結構,因此它在執行插入操做的時候,必須保證 mKey 數據中的數據有序。也正由於如此,mKey 數組是一個基本類型的 int 數組,自然有序而且大小比對也簡單。
咱們看看 put()
方法的實現就清楚了。
public void put(int key, E value) {
int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
if (i >= 0) {
mValues[i] = value;
} else {
i = ~i;
if (i < mSize && mValues[i] == DELETED) {
mKeys[i] = key;
mValues[i] = value;
return;
}
if (mGarbage && mSize >= mKeys.length) {
gc();
// Search again because indices may have changed.
i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
}
mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
mSize++;
}
}
複製代碼
數組的插入,必定會伴隨着數據的搬移。
而在 put()
方法中,也會用到二分查找定位 key 的 index,咱們主要關注其中的 GrowingArrayUtils.insert()
方法。
在這個 insert()
方法中,完成兩個任務:
這些高級集合類,和基本數據結構有一個最顯著的區別,就是它支持動態擴容,而 SparseArray 動態擴容的實現代碼,在 GrowingArrayUtils 的 insert()
方法中,其原理就是一次動態複製來擴容。
擴容的邏輯也很簡單,就是依據當前的 Size 動態放大,Size 在 4 以上時,每次擴容 2 倍。具體算法在 GrowingArrayUtils 的 growSize()
方法中。
public static int growSize(int currentSize) {
return currentSize <= 4 ? 8 : currentSize * 2;
}
複製代碼
第二句話,說明了 SparseArray 內部使用二分查找,在 mKey 數組中定位 key 的位置。又由於須要支持二分查找,因此 mKey 數組內存儲的數據,必須是有序的。因此在 put()
操做的時候,就須要經過數組插入的方式,來保證數據的有序。
又由於使用了數組結構,在數據空間不夠時,還須要採起動態擴容的方式,採用新舊數組複製的方式,增大數組的空間,這部分操做都封裝在 GrowingArrayUtils 的 insert()
方法中。
使用數組就要面臨刪除數據時數據搬移的問題,因此引入了 DELETE 標記。
SparseArray 用到了「數組」結構,在插入的時候爲了給新數據騰位置,須要執行一個時間複雜度度爲 O(n) 的搬移操做,這是沒法避免的。
可是刪除操做實際上是有優化空間的。對數組的刪除,若是不作數據搬移,數組中必然存在數據空洞。
SparseArray 對刪除操做的優化,引入 DELETE 標記,以此來減小在刪除數據時對數據的搬運次數,以此達到在刪除時作到 O(1) 的時間複雜度。
而在插入的時候,遇到 DELETE 標識,表示當前數據已經被刪除掉了。
這樣優化的刪除的同時,對插入操做也起到了必定的優化,就像前面展現的 put()
代碼實現中,若是二分查找的結果,發現對應文字的 value 爲 DELETE,則直接替換,減小了一次插入帶來的數組的數據搬運。
注意 DELETE 標識是在 mValue 數組中存儲的,mKey 中依然存儲着它上一次對應數據的 key 值。
可是引入 DELETE 標識就會有個問題,雖然數據被刪除了,可是它依然在數組中佔有位置空間,也就是它會影響到一些操做,例如在調用 size()
方法的時候,確定是想知道真實數據的數量,而不該該包含 DELETE 標識的數據量。又例如在 put()
操做時發現數組空間不夠,可是數組內存在 DELETE 標識,此時應該作的是去清理 DELETE 標識,而不是去擴容數組。
引入 DELETE 讓 delete()
操做能夠作到 O(1) 的時間複雜度,可是也帶來了問題,這就引入了 SparseArray 的 gc()
機制。
GC 我相信你們都比較熟悉,它在 JVM 裏表明對內存的回收機制,而在 SparseArray 中,它標識了對 DELETE 標識的回收。
在一些必要的條件下,會觸發 gc()
邏輯,來清理雙數組中的 DELETE 標識。
gc()
方法中,經過一次循環,就能夠完成 DELETE 標識的清除,在 gc()
方法中,用了一個布爾類型的 mGarbage
屬性,來記錄當前 mValue 中,是否存在 DELETE 標識,這是斷定是否須要 GC 的依據。。
gc()
會有一次循環,這是 O(n) 的時間複雜度,那何時執行 gc()
也是有講究的。
若是你在 SparseArray 文件中搜索 gc()
方法的調用,你會發現有不少地方都用到了 gc()
方法。原則上,有兩類操做可能會觸發到 gc()
邏輯,全部和 size 相關的操做以及和數組擴容相關的操做。這很好理解,經過 index 獲取數據,必須保證 size 的準確,因此若是有 DELETE 標識,必須執行 gc()
,擴容時也是,存在 DELETE 標識說明還有剩餘的空間,無需進行擴容。
到這裏第三句話也理清楚了,引入 DELETE 標識是爲了減小刪除數據時數據的搬移次數,而這必然帶來了數組的「空洞」,爲了解決這個問題,又須要在適當的時候觸發 GC 操做,來回收 DELETE 標識。
三句話理解 SparseArray 就說完了,這三句話幫助咱們理解 SparseArray 使用的數據結構,實現原理,以及如何平衡遇到的問題。
理解了這些,就能夠很好的區分 SparseArray 的使用場景,以及能夠借鑑的地方。
固然我這樣解釋,必定是忽略了一些細節,可是這些都不是最重要的,最後這裏再查缺補漏。
SparseArray 的 Key 是基本數據類型的 int[],從文檔和其餘的文章中都會提到,自動裝箱和拆箱對性能的影響,但我認爲這不是最重要的。
若是你理解 SparseArray 的設計目標,就會發現用基本數據類型是一個必然的結果,SparseArray 就是爲了節省內存空間而存在的。之因此用基本數據類型,首先基本數據類型自己比類更省空間,其次由於用到了二分查找,因此須要保證 mKey 數組的有序,排序就涉及到了數據大小的比對,使用基本數據類型也能很好的解決兩數比對大小的效率問題。
節省和浪費其實都是相對的,說到 SparseArray 更省內存,一般是與 HashMap 之間作對比。
HashMap 相較於 SparseArray 複雜不少,使用到的屬性變量也多很多,而 SparseArray 從頭至尾就是在操做兩個數組,大小的差距可想而知。
同時 HashMap 爲了應對哈希衝突,引入了「負載因子」,它的默認值是 0.75。什麼意思呢?就是 table 的容量達到了指定尺寸的 0.75%,就會開始擴容,也就是必然有 25% 的空間是不存儲數據而被浪費的。而 SparseArray 是能夠把數組利用到最後一個空間,不會輕易擴容。
在 Stack Overflow 中就有人以 1000 條數據做爲樣本,計算 HashMap 與 SparseArray 對內存的佔用狀況,單位是 byte。
雖然這裏的測試不是很嚴謹,可是依然能夠看到他們在內存空間的使用上,已經不是一個數量積的了。
對計算方式有興趣的,能夠在文末找到 Stack Overflow 的地址。
前面提到,SparseArray 由於須要使用二分查找,這就要求 mKey 中存儲的數據必須是有序的。
因此當插入數據的數據模型不一樣時,其效率也會受到影響,這徹底取決於插入數據時,key 的有序度。
有序度是什麼意思呢?
有序度這個概念一般被使用在排序中,例如 1 2 3 4 就被稱爲滿有序度,而相反的 4 3 2 1 則被稱爲滿逆序度。
這很好理解,若是每次插入都須要伴隨着數組的數據搬移,那必然是會影響效率,而若是每次插入新數據,只是在數組尾部追加,固然要快很多。
爲此 SparseArray 還提供了一個 append()
方法,來優化追加的狀況。該方法會判斷追加的 key 值,是否大於 mKey 數組中最大的值,若是是則直接追加在數組末尾,不然執行 put()
方法插入 mKey 數組。
下面就用一個 10000 的數據,分別用三種方式將數據加入到全新的 SparseArray 中,看其對執行效率的影響,單位是毫秒。
append()
追加 10000 調滿有序度的數據,耗時 14ms。能夠看到,插入的 key 是徹底逆序的,效率最差。而若是 key 的數據是有序的,使用 append()
的效率要高於直接使用 put()
。若是是逆序的狀況,append()
也會退化成 put()
去執行,因此追加 key 更大的數據場景下,能夠考慮用 append()
。
SparseArray 還存在一些變體類,用於處理不一樣的狀況。
可是核心原理與 SparseArray 一致,有興趣單獨瞭解。
到這裏就基本上講清楚 SparseArray 的全部細節,如題所說,記住三句話就能夠理解 SparseArray。
使用雙數組結構來存儲數據,Key 是 int[] 類型,和 Value 數據的 Index 一一對應。須要使用二分查找來保證查詢的時間複雜度在 O(logn),因此 mKey 數組必須是有序的,同時爲了優化刪除數據時數據的搬移,引入了 DELETE 標記,讓刪除操做的時間複雜度控制在 O(1) 上,而又由於刪除數據實際上是將本來的「數組空洞」標記成了 DELETE,因此在必要的時候須要 GC 來清理 DELETE 標記。
到這裏就結束了,若是三句話過短,記住三個點:雙數組、二分查找、DELETE 標識。
References:
本文對你有幫助嗎?留言、轉發、點好看是最大的支持,謝謝!
「聯機圓桌」👈推薦個人知識星球,一年 50 個優質問題,上桌聯機學習。
公衆號後臺回覆成長『成長』,將會獲得我準備的學習資料。