想象一下,有两个管理员:
- 老王 :管理着一个巨大的传统图书馆(代表传统的
HashMap<Integer, Object>
)。 - 小张 :管理着一个高效的新式数字档案馆(代表我们的
SparseArray
)。
他们的工作都是:给你一个编号(key) ,你快速找到对应的书籍/档案(value) 。
第一章:老王的图书馆(HashMap的实现)
老王的图书馆非常有名,藏书极多。他的工作方式是这样的:
- 巨型索引室 :有一个巨大的房间,里面有无数的抽屉(
数组桶
)。 - 复杂的编号规则 :每本书的编号(
key
,比如10005
)会被送入一个复杂的哈希函数计算机 。这个计算机会算出一个抽屉号码(hash值
),比如10005 % 1000 = 5
号抽屉。 - 处理冲突 :老王跑到5号抽屉,发现里面已经有好几本书了(
哈希冲突
)。他必须把新书和里面的每一本书的编号都对比一下(遍历链表
),才能找到正确的那本,或者把新书挂上去。 - 开销 :为了应对海量的书籍,这个索引室必须造得非常大(
大的初始容量
),即使很多抽屉是空的,也很占地方(内存开销大
)。而且每次找书都可能要在一个抽屉里翻找多次(查询时间不稳定,最坏情况是O(n)
)。
老王的方式很强大,但代价是:
- 内存消耗大 :需要一个大数组和每个条目额外的
Entry
对象(存储hash, key, value, next指针)。 - 自动装箱 :如果编号是
int
基本类型,存入之前必须先把它包装成一个Integer
对象(装箱
),这又会创建大量小对象,增加GC压力。
第二章:小张的数字档案馆(SparseArray的智慧)
小张看到了老王的方法,觉得对于某些特定类型的档案(键(key)永远是整数)来说,这太浪费了。他设计了一套更精巧的系统:
核心设计:两本紧密关联的登记册
小张只有两本数组登记册 ,一本记录档案编号 (int[] mKeys
),另一本记录对应的档案本身 (Object[] mValues
)。
索引 (Index) | 0 | 1 | 2 | 3 | 4 | ... |
---|---|---|---|---|---|---|
mKeys |
5 | 50 | 80 | 100 | 200 | ... |
mValues |
档案A | 档案B | 档案C | 档案D | 档案E | ... |
关键在于:这两本登记册的同一行(同一索引)是完全对应的! mKeys[2]
的值是80
,那么mValues[2]
存放的就是编号为80
的档案。
神乎其技的查找术:二分查找
现在,有人要来借编号为80
的档案。小张怎么做?
-
他不会像老王那样去计算抽屉号,而是直接打开编号登记册(
mKeys
) 。 -
他知道这本登记册里的编号是从小到大排好序的(这是实现的关键!)。
-
他用一种名为二分查找的高效算法来快速定位。
- 先翻到登记册中间(比如第100页),发现编号是
500
,比80
大。 - 于是他抛弃后半本,再在前半本中间翻(第50页),发现编号是
30
,比80
小。 - 他再在50页到100页中间翻...
- 如此反复,只需几步(log₂(n)次) 就精准地定位到了
80
这个编号就在登记册的第2
号位置!
- 先翻到登记册中间(比如第100页),发现编号是
就这样,他通过mKeys
的索引,直接去mValues[2]
取出了档案C。
优势瞬间体现:
- 极致的内存节省 :没有巨大的索引室,只有两个紧凑的数组。没有额外的
Entry
对象,键是基本类型int
,避免了自动装箱。 - 查询效率稳定 :对于百量级的数据,二分查找(
O(log n)
)和哈希表(理想情况O(1)
)的实际速度相差无几,非常快。
应对删除的巧思:懒人标记法
现在要删除编号为50
的档案(mKeys[1]
)。小张如果直接把这行划掉,然后后面的所有记录都要向前移动一格(数组复制
),这个操作在数据量大时是很慢的。
他的策略是:
- 不直接删除 ,只是在
mValues[1]
的位置上贴一张便签,写上 "此处分馆已拆除" (一个特殊的DELETED
标记)。 - 这个位置就变成了一个空位。
- 下次需要插入(put) 新档案时,小张会优先回收这些带有"拆除"标记的空位,直接覆盖掉它们,避免了不必要的数组移动。
- 只有在合适的时候(比如空位太多),他才会做一次大规模的"档案整理"(
gc()
或put
时触发),真正地压缩数组。
这种延迟删除的机制,再次提升了性能。
第三章:总结与抉择(使用场景)
现在,故事讲完了,我们来总结一下两位管理员的优缺点:
特性 | 老王 (HashMap<Integer, Object>) | 小张 (SparseArray) |
---|---|---|
内存开销 | 较大(数组+Entry对象+装箱) | 极小(仅两个数组) |
查找效率 | 平均O(1),最坏O(n) | 稳定O(log n) |
键类型 | 任何Object(通用) | 只能是int(专用) |
删除性能 | 较好 | 较好(得益于延迟删除) |
插入性能 | 平均O(1),扩容时慢 | O(log n) + 可能数组复制 |
数据量大了以后 | 依然表现良好 | 查找、插入变慢(log n 变慢) |
所以,你何时应该聘请"小张"(使用SparseArray)?
- 键是整数(int)时:这是前提条件。
- 数据量不大 (通常指几百到几千条 ):这是最关键的因素。在这个量级下,
O(log n)
和O(1)
的效率差异人体几乎无法感知,但内存节省是实实在在的。 - 对内存极其敏感:尤其是在Android开发中,避免大量小对象创建、减少GC停顿,对应用流畅度至关重要。
典型使用场景:
- Android开发中的
View
的Tag
:view.setTag(int key, Object tag)
底层就是用SparseArray
实现的! - 映射Android资源ID :比如用
R.id.xxx
这种整数ID作为键来查找某些对象。 - 替代
HashMap<Integer, Boolean>
、HashMap<Integer, User>
等 :只要是整数做键,且数据量不大,SparseArray
是你的不二之选。它还有一系列兄弟,如SparseBooleanArray
、SparseLongArray
等,进一步节省内存。
何时应该坚持用"老王"(HashMap)?
- 当键不是整数 (
String
,Object
等)时。 - 当数据量非常大 (数万条以上)时,
HashMap
的O(1)
查询优势会碾压SparseArray
的O(log n)
。 - 当你需要遍历键值对 时,
HashMap
的EntrySet
更为方便。
最终结语
SparseArray
不是要取代HashMap
,而是在Android这个特定环境下,针对int-Object
映射这一特定场景,做出的一个在时间效率和空间效率上的完美权衡 。它用有序数组 + 二分查找 + 延迟删除这套组合拳,用可以忽略不计的时间代价,换来了极其宝贵的内存空间。
希望这个故事能让你彻底理解SparseArray
的设计哲学。下次在Android代码里看到它,你就能会心一笑,知道它正是那位高效又节俭的"管理员小张"。