Android數據結構之SparseArray

引言

在Android開發中,當須要存儲鍵值對時,通常都是用java自帶的HashMap。可是細心的同窗可能會發現,有時候若是實際的HashMap的key-vaule中的key是Integer時,AndroidStudio會提示一個warnning,具體是說推薦使用SparseArray替代HashMap:java

warnning

雖說warnning不影響實際功能,可是有個warnning放在那裏總讓人不爽。由於是lint靜態掃描報的,能夠用@SuppressLint("UseSparseArrays")忽略掉。可是既然google特意出了這麼一個類用來替代key爲Integer的HashMap,那是否是真的比HashMap更好用?android

優缺點

It is intended to be more memory efficient than using a HashMap to map Integers to Objects, both because it avoids auto-boxing keys and its data structure doesn't rely on an extra entry object for each mapping.算法

源碼的註釋除了提到SparseArray有節約自動裝箱開銷的優勢外,還提到SparseArray由於少了須要Map.Entry<K, V>做爲輔助的存儲結構引入的內存開銷。數組

由於Map<K, V>的泛型聲明,key必須是Integer不能是int,因此確實會帶來自動裝箱的問題。數據結構

這兩個優勢都是讓SparseArraymore memory efficient的,這是由於SparseArray的誕生就是針對某些Android設備內存比較緊張的狀況的。app

可是通常來講,SparseArray是比Hashmap慢的,在數據集大小隻有上百個的時候,差異不大。優化

使用

不論是HashMap仍是SpareArray,他們的做用都是維護一組邏輯上的key-value的對應關係。那麼,在這組關係上最常作的操做就是存和取了。google

存/取

HashMap的存操做和取操做分別對應方法put(K key, V value)get(Object key),大概用過HashMap的沒有不知道這兩個方法的。而SpareArray對的兩個方法分別是put(int key, E value)get(int key),和HashMap的方法看起來幾乎沒有區別,key爲Integer的hashmap的相關代碼能夠無縫換成SpareArray。spa

SparseArray<String> sparseArray = new SparseArray<>();
sparseArray.put(200, "firstValue");
sparseArray.put(100, "secondValue");
System.out.println(sparseArray.get(100));

輸出:
>> secondValue

HashMap<Integer, String> hashMap = new HashMap<>();
hashMap.put(200, "firstValue");
hashMap.put(100, "secondValue");
System.out.println(hashMap.get(100));

輸出:
>> secondValue
複製代碼

遍歷

SpareArray的遍歷要稍微麻煩些。3d

首先先創建一個概念,SparseArray執行put的時候實際上是按照key的大小有序插入的。簡單來講,SparseArray維護了各個鍵值對的排序關係,具體的規則是以key升序排列。因此不一樣於HashMap只能經過key查找value,Sparse還能經過index查找value(或者key),方法是valueAt(int index)(或者keyAt(int index))。這裏的index是升序排序中鍵值對的位置,index是SparseArray相比Map多出來的概念,看了後面的源碼實現分析就好理解了。

拿上面的代碼舉例,put了key爲100和200的兩個鍵值對,size爲2,200-"firstValue"這對key-value對在index 0的位置,100-"secondValue"這對鍵值對在index 1的位置。順序是根據key的大小排的,跟put的前後順序無關。因此valueAt(0)拿到的是"secondValue"

具體的遍歷代碼:

for (int index = 0; index < sparseArray.size(); index++) {
    System.out.println(String.format("key: %d, value: %s", sparseArray.keyAt(index), sparseArray.valueAt(index)));
}

輸出:
>> key: 100, secondValue
>> key: 200, firstValue for (Map.Entry<Integer, String> entry : hashMap.entrySet()) {
    System.out.println(String.format("key: %d, value: %s", entry.getKey(), entry.getValue()));
}

輸出:
>> key: 100, secondValue
>> key: 200, firstValue
複製代碼

實現細節

和hashmap比較

大體講下hashmao的原理。hashmap使用key的hashcode來決定entry的存放位置,解決hash衝突使用的開散列方法,因此hashmap的底層數據結構看起來是一個鏈表的數組,鏈表的節點是包含了key和value的Entry類。看起來就像下圖:

hashmap

而SparseArray的底層數據結構更簡單,只有int[] mKeysObject[] mValues兩個數組。那這裏就有個問題了:不一樣於HashMap專門用一個Entry的類存放key跟value,SpareArray裏key和value分別存放在兩個數組裏,那key和value是怎麼對應上的?

答案就是,是根據index對應的,mKeys和mValues中index同樣的key和value就是相互對應的。因此SparseArray實際存儲的數據看起來是這樣的:

sparsearray

HashMap中基於Entry創建的key-value對應關係會致使Entry佔用內存,而sparse基於index的對應關係是邏輯的,節省下了Entry類的內存,這又是SparseArray的一個優勢。

存/取

前面提到,SparseArray中實際存儲的數據是有序的。那麼保證有序的關鍵就在每次的存和刪操做中:在本來有序的狀況下,保證存和刪操做後仍是有序的。

看存操做的實現,註釋說明了關鍵點:

public void put(int key, E value) {
    // 二分查找找到此次插入的key應該插入哪一個位置能夠保持整個結構的有序
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
        // >=0表示在mKeys的前size個元素中找到了key
        mValues[i] = value;
    } else {
        // <0在mKeys的前size個元素中沒找到key(前面沒有放過這個key的話就會找不到)
        // 不過返回的i的絕對值表示了key應該放在這個index以保持操做後的數組依然有序
        i = ~i;

        // 本次數據應該放入的位置是可用的,直接使用(這個key被標記刪除了)
        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.
            // gc可能改變了底層存儲數據的數組的結構,再二分查找一次index
            i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
        }

        // 真正放入數據,若是mKeys和mValues的長度比i小,會引發擴容
        // 擴容相關的邏輯看下面分析
        mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
        mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
        mSize++;
    }
}
複製代碼

因此保證全部存儲的數據都是有序排列的關鍵就在於每次插入的時候如何肯定插入的新數據插入的位置。上面看到每次肯定實際插入的位置是基於二分查找肯定的。舉個例子:

  • 原先的數據是mIndexs = {1, 4, 6, 8},size爲4
  • 要插入的key是7
  • 第一次二分查找返回的index是-3,說明如今的數據中沒有這個index,這個key應該被插入index爲3的位置
  • 調用GrowingArrayUtils.insert將7插入index爲3的位置,實際會引起mKeys擴容到8,原先的key8往右移
  • 最後的數據是mIndexs = {1, 4, 6 ,7, 8},保持了有序

其實實際插入數據的過程相似於優化後的插入排序,肯定了插入的位置後把這個位置後面的數據移動一位,而後把新數據放入空出來的位置。

取的過程很簡單,一樣是根據二分查找找到若是有這個key的話它應該在哪一個位置,若是找到的index<0反過來就證實了沒有這個key:

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];
    }
}
複製代碼

HashMap的取操做在hash分桶時時間複雜度爲O(1),可是在發生hash衝突的時候最後會在鏈表中順序查找,而SparseArray的取操做徹底依賴於二分查找,時間複雜度理論上老是O(nlogn),沒有hash衝突致使訪問慢的問題;不過HashMap的hash衝突通常不多,整體來講SparseArray老是比HashMap慢些;並且二分查找的時間複雜度也決定了SparseArray不適合大量數據的場景。

刪/gc

SparseArray刪除數據是經過delete(int key)方法刪除的。在刪除一條數據的時候,不是直接執行實際的刪除操做,而是先把要刪除的數據標記爲DELETE狀態,在須要獲取size、擴容、取數據的時候,會執行gc,一次性真正把前面標記的全部刪除數據刪掉。

public void delete(int key) {
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

    if (i >= 0) {
        if (mValues[i] != DELETED) {
            mValues[i] = DELETED;
            mGarbage = true;
        }
    }
}
複製代碼

gc的過程有點相似虛擬機的gc中的標記整理算法。具體就是遍歷全部數據,收集全部沒有被刪除的數據移動到最前面。

private void gc() {
    int n = mSize;
    int o = 0;
    int[] keys = mKeys;
    Object[] values = mValues;

    for (int i = 0; i < n; i++) {
        Object val = values[i];

        if (val != DELETED) {
            if (i != o) {
                keys[o] = keys[i];
                values[o] = val;
                values[i] = null;
            }

            o++;
        }
    }

    mGarbage = false;
    mSize = o;
}
複製代碼

這樣作的好處有兩個:

  • 若是在剛delete了一條數據後又放了一條相同key的數據進來,這條數據由於被覆蓋了後面也不用執行真正的gc了,節省了操做時間
  • 若是一次性delete多條數據,能夠把真正的刪除操做放在一次gc中而不是屢次gc中,節省時間

擴容/縮容

前面提到,在put數據的時候可能會引起擴容。擴容的時機很簡單,當底層的數組沒有空餘的空間存放新的數據時就會引起擴容。擴容的算法很簡單,基本上就是翻倍,GrowingArrayUtils#growSize

public static int growSize(int currentSize) {
    return currentSize <= 4 ? 8 : currentSize * 2;
}
複製代碼

不過須要注意的是,growSize算出來size不必定是擴容操做後真正的size,由於擴容時新的數組是調用ArrayUtils#newUnpaddedArray生成新數組的,這個方法涉及內存對齊,實際返回的數組的size通常比要求的大小要大。

SparseArray是沒有縮容機制的。假如前面存了大量的數據致使數組擴容到了1024,哪怕調用clear清空全部數據底層數組的大小仍是1024。因此先存放大量數據在刪到只剩少許須要長期持有的數據場景下,用SpareArray可能會致使空間的浪費。

總結

  • 建議使用SparseArray替換HashMap是由於得益於下面幾點,SparseArray可能比HashMap更節省內存,而某些android設備上內存是緊缺資源:
    • 避免了Integer的自動裝箱
    • 基於index創建的key和value的映射關係相比Map基於Entry結構創建key和value的映射關係節約內存
    • 某些場景如hash衝突下訪問速度可能優於hashmap;不適合數據集比較大的場景。
  • SparseArray沒有縮容機制。某些場景下不適合使用,好比:大量地put後大量地delete,而後長久持有SparseArray,致使大量的空位置無法被虛擬機gc,浪費內存
  • SparseArray通常來講比Hashmap慢,由於二分查找比較慢,並且插入刪除數據涉及數組的copy。在數據集不大時不明顯
  • SparseArray每次插入刪除數據都保證了全部存儲數據的排列有序
  • SparseArray能夠經過index定位數據,Hashmap不行
相關文章
相關標籤/搜索