介紹
Android提供了SparseArray,這也是一種KV形式的數據結構,提供了類似於Map的功能。但是實現方法卻和HashMap不一樣。它與Map相比,可以說是各有千秋。
優點
- 佔用內存空間小,沒有額外的Entry對象
- 沒有Auto-Boxing
缺點
- 不支持任意類型的Key,只支持數字類型(int,long)
- 數據條數特別多的時候,效率會低於HashMap,因爲它是基於二分查找去找數據的
總的來說,SparseArray適用於數據量不是很大,同時Key又是數字類型的場景。
比如,存儲某月中每天的某種數據,最多也只有31個,同時它的key也是數字(可以使用1-31,也可以使用時間戳)。
再比如,你想要存儲userid與用戶數據的映射,就可以使用這個來存儲。
接下來,我將講解它的特性與實現細節。
直觀的認知
它使用的是兩個數組來存儲數據,一個數組存儲key,另一個數組來存儲value。隨着我們不斷的增加刪除數據,它在內存中是怎麼樣的呢?我們需要有一個直觀的認識,能幫助我們更好的理解和體會它。
初始化的狀態
內部有兩個數組變量來存儲對應的數據,mKeys
用來存儲key,mValues
用來存儲泛型數據,注意,這裏使用了Object[]
來存儲泛型數據,而不是T[]
。爲什麼呢?這個後面在講。
插入數據
如下圖所示,插入數據,總是“緊貼”數組的左側,換句話說,總是從最左邊的一個空位開始使用。我一開始沒詳細探究的時候,都以爲它是類似HashMap
那樣稀疏的存儲。
另一個值得注意的事情是,key總是有序的,不管經過多少次插入,key數組中,key總是從小到大排列。
擴容
當一直插入數據,快滿的時候,就會自動的擴容,創建一個更大的數組出來,將現有的數據全部複製過去,再插入新的數據。這是基於數組實現的數據結構共同的特性。
刪除
刪除是使用標記刪除的方法,直接將目標位置的有效元素設置爲一個DELETED標記對象。
查詢數據
怎麼查數據呢?
比如我們查5這個數據get(5)
,那麼它是在mKeys
中去查找是否存在5,如果存在,返回index,然後用這個index在對應的mValues
取出對應的值就好了。
實現
接下來我們按照自己的理解,來實現這樣的一個數據結構,從而學習它的一些細節和思想,加深對它的理解,有利於在生產中,能更有效的,正確的使用它。
確定接口(API)
首先,確定一下,我們需要暴露什麼樣的功能給別人使用。當然了,答案是顯而易見的,當然是插入,查詢,刪除等功能了。
public class SparseArray<E> {
public SparseArray() {
}
public SparseArray(int initCap) {
}
public void put(int key, E value) {
}
public E get(int key) {
}
public void delete(int key) {
}
public int size() {
}
}
上面列舉了我們需要的功能,無參構造函數,有參數構造函數(期望能主動設置初始容量),put數據,get數據,刪除數據,以及獲取當前數據有多少。
實現put方法
put數據是最核心的方法,一般我們開發一個東西,也是先開發創建數據的功能,這樣才能接着開發展示數據的功能。所以我們先來實現put方法。
按照之前的理解,我們需要一些成員變量來存儲數據。
private int[] mKeys;
private Object[] mValues;
private int mSize = 0;
需要先找到put到什麼位置
這裏會有兩種情況:
- 我要put的key不存在,應該put到什麼地方?
- 我要put的key已經存在,直接覆蓋
因此第一步,需要先找一下,當前key,是否存在。我們使用二分查找來處理。
public void put(int key, E value) {
int i = BinarySearch.search(mKeys, mSize, key);
if (i >= 0) {
// 找到了有兩種情況
// 1.是對應的mValues有一個有效的數據對象,直接覆蓋
// 2.對應的mValues裏面是一個DELETED對象,同樣的,直接覆蓋
mValues[i] = value;
} else {
}
}
如果在數組中找到了,那麼操作就很簡單,直接覆蓋就完事了。
如果沒找到呢,我們需要將數據插入到正確的位置上,這個所謂正確的位置,指的是,插入之後,依然保證數組有序的情況。打個比方:1, 4, 5, 8
,請問3
應該插入哪裏,當然是放到index=1
的地方,結果就是1, 3, 4, 5, 8
了。
那如果key不存在,怎麼知道應該放到哪裏呢?
我們來看一下這個二分查找,它幫我們解決了這個小問題。
public static int search(int[] arr, int size, int target) {
int lo = 0;
int hi = size - 1;
while (lo <= hi) {
final int mid = (lo + hi) >>> 1;
final int value = arr[mid];
if (value == target) {
return mid;
} else if (value > target) {
hi = mid - 1;
} else {
lo = mid + 1;
}
}
return ~lo;
}
按照傳統的思想,查找類的API,如果找不到,一般都會返回-1,但是這個二分查找,返回了lo的取反。這會達到什麼效果呢。
情況1:數組是空的,那麼查找任何東西,都找不到,那會怎麼樣?根據代碼可以知道,循環都進不去,那麼直接返回了~0
,也就是最大的負數。我們只需要知道它是一個負數。
情況2:數組不是空的,比如1, 3, 5
,我們找2
,這裏簡單的單步執行一下:
lo = 0, size = 3, hi = 2, 好,進入循環
mid = (0 + 2) / 2 = 1, value = 3
value > 2, 所以 hi = 1 - 1 = 0, 再次循環
mid = (0 + 0) / 2 = 0, value = 1
value < 2, so, lo = 0 + 1; 退出循環
返回~1
如果你在嘗試去驗算其他情況,你會發現,返回值剛好是它應該放置的位置的取反。換句話說,返回值再取反後,就可以得到,這個key應該插入的位置。
這應該是二分查找的一個小技巧。非常的實用!
接下來,想一想,0取反是負數,任何正數取反,也都是負數,也就是說,只要是負數,就代表沒找到,再將這個數取反,就得到了,應該put的位置!
所以,代碼繼續實現爲:
public void put(int key, E value) {
int i = BinarySearch.search(mKeys, mSize, key);
if (i >= 0) {
// 找到了有兩種情況
// 1.是對應的mValues有一個有效的數據對象,直接覆蓋
// 2.對應的mValues裏面是一個DELETED對象,同樣的,直接覆蓋
mValues[i] = value;
} else {
i = ~i;
mKeys = GrowingArrayUtil.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtil.insert(mValues, mSize, i, value);
mSize++;
}
}
實現get方法
接下來,我們實現get方法。
get方法實現就比較簡單了,只需要通過二分查找找到對應的index,再從value數組中取出對象即可。
public E get(int key) {
// 首先查找這個key存不存在
int i = BinarySearch.search(mKeys, mSize, key);
if (i < 0) {
return null;
} else {
return (E)mValues[i];
}
}
實現delete方法
delete方法,就是刪除某個key,對應的細節是,找到這個key是否存在,如果存在的話,將value數組中對應位置的數據設置爲一個常量DELETED
。這樣做的好處就是比較快捷,而不需要真正的去刪除元素。當然由於這個DELETED對象存在value數組中,對put和get以及size方法都會帶來一些影響。
下面的代碼,定義一個靜態的final變量DELETED
用來作爲標記已經刪除的變量。
另一個成員變量標記,當前value數組中是否有刪除元素這個狀態信息。
private static final Object DELETED = new Object();
/**
* 標記是否有DELETED元素標記
* */
private boolean mHasDELETED = false;
public void delete(int key) {
// 刪除的時候爲標記刪除,先要找到是否有這個key,如果沒有,就沒必要刪除了;
// 找到了key看一下對應的value是否已經是DELETED,如果是的話,也沒必要再刪除了
int i = BinarySearch.search(mKeys, mSize, key);
if (i >= 0 && mValues[i] != DELETED) {
mValues[i] = DELETED;
mHasDELETED = true;
}
}
實現size方法
size方法返回在這個容器中,數據對象有多少個。由於DELETED
對象的存在,key數組和value數組,以及成員變量mSize
都沒法靠譜得直接得到有效數據的count。
因此這裏需要一個內部的工具方法gc()
,它的作用就是,如果有DELETED
對象存在,那麼就重新整理一下數組,將DELETED
對象都移除,數組中只保留有效數據即可。
先來看gc
的實現
private void gc() {
int placeHere = 0;
for (int i = 0; i < mSize; i++) {
Object obj = mValues[i];
if (obj != DELETED) {
if (i != placeHere) {
mKeys[placeHere] = mKeys[i];
mValues[placeHere] = obj;
mValues[i] = null;
}
placeHere++;
}
}
mHasDELETED = false;
mSize = placeHere;
}
它的內部邏輯很簡單,就是從頭到尾遍歷value數組,把每一個不是DELETED
的對象都重新放置一遍,覆蓋掉前面的DELETED
對象。
然後,我們再看一下size的實現
public int size() {
if (mHasDELETED) {
gc();
}
return mSize;
}
完善get方法
假設有這樣的一個場景,put(1, a), put(2, b), delete(2), get(2)
。按照現在的get實現,就會返回DELETED
對象出去,所以,由於DELETED
的存在,我們需要完善一下get方法的邏輯。
public E get(int key) {
// 首先查找這個key存不存在
int i = BinarySearch.search(mKeys, mSize, key);
// 這裏有兩種情況
// 如果key小於0,說明在mKeys中,沒有目標key,沒找到
// 如果key大於0,還要看一下,對應的mValues中,是否那個元素是DELETED,因爲刪除的時候是標記刪除的
// 以上兩種情況都是沒有找到
if (i < 0 || mValues[i] == DELETED) {
return null;
} else {
return (E)mValues[i];
}
}
完善put方法
補充的代碼上面我都寫了註釋,講解了這兩坨額外的代碼是用來處理什麼情況的。
public void put(int key, E value) {
int i = BinarySearch.search(mKeys, mSize, key);
if (i >= 0) {
// 找到了有兩種情況
// 1.是對應的mValues有一個有效的數據對象,直接覆蓋
// 2.對應的mValues裏面是一個DELETED對象,同樣的,直接覆蓋
mValues[i] = value;
} else {
i = ~i;
// 這一段代碼是處理這一的場景的
// 1 2 3 5, delete 5, put 4
if (i < mSize && mValues[i] == DELETED) {
mKeys[i] = key;
mValues[i] = value;
return;
}
// 另一種情況
// 如果有刪除的元素,並且數組裝滿了,這個時候需要先GC,再重新搜一下key的位置
if (mHasDELETED && mSize >= mKeys.length) {
gc();
i = ~BinarySearch.search(mKeys, mSize, key);
}
mKeys = GrowingArrayUtil.insert(mKeys, mSize, i, key);
mValues = GrowingArrayUtil.insert(mValues, mSize, i, value);
mSize++;
}
}
最後,GrowingArrayUtil.insert是做了什麼?
其實說起來很簡單,用一個過程來概括一下一般情況。
[1, 2, 3, 4, 5, 0, 0, 0, 0, 0]
insert(index=2, value=99)
1.複製index=2以前的元素 [1, 2, 3, 4, 5, 0, 0, 0, 0, 0]
2.複製index=2以後的元素,往後挪一位 [1, 2, 3, 3, 4, 5, 0, 0, 0, 0]
3.將index=2的位置,放入99 [1, 2, 99, 3, 4, 5, 0, 0, 0, 0]
當然,這裏要處理,如果剛好數據滿了,插入新數據,就需要創建一個新的,更大的數組來複制以前的數據了。
/**
* @param rawArr 原始數組
* @param size 有效數據的長度,與數組長度不一樣,如果數組長度大於有效數據的長度,那麼往裏面插入數據是OK的
* 如果有效數據的長度等於數組的長度,那麼要插入數據,就要創建更大的數組
* @param insertIndex 插入index
* @param insertValue 插入到index的數值
* */
public static int[] insert(int[] rawArr, int size, int insertIndex, int insertValue) {
if (size < rawArr.length) {
System.arraycopy(rawArr, insertIndex, rawArr, insertIndex + 1, size - insertIndex);
rawArr[insertIndex] = insertValue;
return rawArr;
}
int[] newArr = new int[rawArr.length * 2];
System.arraycopy(rawArr, 0, newArr, 0, insertIndex);
newArr[insertIndex] = insertValue;
System.arraycopy(rawArr, insertIndex, newArr, insertIndex + 1, size - insertIndex);
return newArr;
}
public static <T> Object[] insert(Object[] rawArr, int size, int insertIndex, T insertValue) {
if (size < rawArr.length) {
System.arraycopy(rawArr, insertIndex, rawArr, insertIndex + 1, size - insertIndex);
rawArr[insertIndex] = insertValue;
return rawArr;
}
Object[] newArr = new Object[rawArr.length * 2];
System.arraycopy(rawArr, 0, newArr, 0, insertIndex);
newArr[insertIndex] = insertValue;
System.arraycopy(rawArr, insertIndex, newArr, insertIndex + 1, size - insertIndex);
return newArr;
}
好了,關於SparseArray
的講解就到這裏結束了。完整的源碼可以查看我寫的,也可以查看官方的。
之前提到的一些疑問點
- 爲什麼用
Object[]
,而不是T[]
我的理解是,如果使用泛型數組T[]
,你就必須構造出一個泛型數組,那麼構造泛型數組,你需要能創建泛型對象,也就是說,必須調用T
的構造函數才能創建泛型對象,但是由於是泛型,構造函數是不確定的,只能通過反射的形式來調用,這樣顯然就效率和穩定性上有一些問題。因此大多數泛型的實現,都是通過Object
對象來存儲泛型數據。
如果你覺得這篇內容對你有幫助的話,不妨打賞一下,哪怕是小小的一份支持與鼓勵,也會給我帶來巨大的動力,謝謝:)