SparseArray 学习

在安卓项目中, 新建一个 HashMap 对象, 会有提示: 建议使用 SparseArray 会有更好的表现. 尤其是键为 Integer 类型的 maps, 更加高效. 尤其是 value 是原始数据类型时, 可以使用 SparseIntArray 等来避免装箱的操作. (其实还有其它优势)

为什么叫 SparseArray?

SparseArray 稀疏数组. 为什么叫稀疏?

先来看下 Javascript 中关于稀疏数组的定义:

稀疏数组就是包含从 0 开始的不连续索引的数组. 通常, 数组的 length 属性值代表数组中元素的个数. 如果数组是稀疏的, length 属性值大于元素的个数. 可以用 Array() 构造函数或简单地指定数组的索引值大于当前数组长度来创建稀疏数组.

可能有点懵, 我先说下我写完这篇笔记的理解. 在 SparseArray 数组中, 部分需要删除的元素, 不会被立即从数组中删除, 而是被标记为需要删除. 只有在需要的时候, 才一次性去删除和释放空间. 这时, 数组的 length 长度不能反映有效元素的个数, 因为被标记需要删除的元素也被计算进去了. SparseArray 中, 有一个单独的成员变量来计算实际有效元素的个数. 所以 SparseArray 数组就是一大串数组中有很多标记为可删除的元素, 所以是稀疏的, 零散的. 之后会详细记录对于删除标志位的学习.

类型

Android Studio 给出的提示 - value 是原始数据类型时, 可以避免装箱的操作. 那详细看一下 SparseArray 给出的类型:

SparseArray          <int, Object>
SparseBooleanArray   <int, boolean>
SparseIntArray       <int, int>
SparseLongArray      <int, long>
LongSparseArray      <long, Object>

会发现它和 HashMap 唯一不同的就是键值对的类型, HashMap的键值都为泛型, 但是 SparseArray 的键值只能为 int/long. 因为 long 的范围更大, 所以 LongSparseArray 储存的数据比 SparseArray 多.

int 的范围是 -2^31 到 2^31-1, 而 long 是 -2^63 到 2^63-1

使用方法

SparseArray<String> sparseArray= new SparseArray<>();
       
// 增加元素
sparseArray.append(0, "myValue"); // append 方式, 追加. 其实也包含了 put. 后面源码分析
sparseArray.put(1, "myValue"); // put 方式, 指定 key 更新
        
//删除元素, 二者等同
sparseArray.remove(1);
sparseArray.delete(1);

//修改元素, put/append 相同的 key 值即可
sparseArray.put(1,"newValue");
sparseArray.append(1,"newValue");

设计思想

成员变量

首先来 SparseArray 的源码, 其中定义了一些成员变量:

private static final Object DELETED = new Object();
private boolean mGarbage = false;

// 需要说明一下
// 这里的 mKeys 数组是按照 key 值递增存储的
private int[] mKeys;
private Object[] mValues;
private int mSize;
  • DELETED - 标志字段, 用于判断是否删除.
  • mGarbage - 标志字段, 用于确定当前是否需要垃圾回收.
  • mKeys 数组 - 存储 key. int 类型.
  • mValues 数组 - 存储值
  • mSize 当前元素数量

append 方法

public void append(int key, E value) {
    
    // 当 mSize 不为 0 并且不大于 mKeys 数组中的最大值时
    // 因为 mKeys 是一个升序数组, 最大值即为 mKeys[mSize-1]
    if (mSize != 0 && key <= mKeys[mSize - 1]) {
        // 执行 put 方法
        put(key, value);
        return;
    }
    
    // 不是 put 则证明需要新增
    
    // 当垃圾回收标志 mGarbage 为 true 并且当前元素已经占满整个数组
    if (mGarbage && mSize >= mKeys.length) {
        gc(); // 执行垃圾回收进行空间压缩
    }
        
    // 当 mSize 为 0, 或者 key 值大于当前 mKeys 数组最大值的时候
    // 在数组最后一个位置插入元素.
    mKeys = GrowingArrayUtils.append(mKeys, mSize, key);
    mValues = GrowingArrayUtils.append(mValues, mSize, value);
    
    // 元素加一
    mSize++;
}

会发现处理以下两种情况:

  1. 如果是更新对应 key 的值, append 方法调用 put 方法. 此时 key 值不等于 0 且小于等于最大值. 因为 key 值之前有提到是递增存储.
  2. 如果是新增. 即 key 等于 0, 数组为空, 或者 key 大于当前最大值, 在最后插入.

put 方法

public void put(int key, E value) {

    // 二分查找
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);
    
    // 查找到
    if (i >= 0) {
        mValues[i] = value; // 更新值
    } else { // 没有找到
        i = ~i; // 获得二分查找结束后, lo 的值
        
        // 元素要添加的位置正好被标记为 DELETED, 直接覆盖它的值即可.
        if (i < mSize && mValues[i] == DELETED) {
            mKeys[i] = key;
            mValues[i] = value;
            return;
        }
        
        // 垃圾回收, 但是空间压缩后,mValues 数组和 mKeys 数组元素有变化, 需要重新计算插入的位置
        if (mGarbage && mSize >= mKeys.length) {
           gc();

            // 重新二分查找插入的位置
            i = ~ContainerHelpers.binarySearch(mKeys, mSize, key);
        }
        
        // 在指定位置 i 处, 插入元素
        mKeys = GrowingArrayUtils.insert(mKeys, mSize, i, key);
        mValues = GrowingArrayUtils.insert(mValues, mSize, i, value);
        
        // 元素加一
        mSize++;
    }
}

方法里的二分查找取反操作很奇怪, 我们先看下二分查找的代码:

static int binarySearch(int[] array, int size, int value) {

    int lo = 0;
    int hi = size - 1;

    while (lo <= hi) {
        final int mid = (lo + hi) >>> 1;
        final int midVal = array[mid];

        if (midVal < value) {
            lo = mid + 1;
        } else if (midVal > value) {
            hi = mid - 1;
        } else {
            return mid;  // 找到了
        }
    }
    return ~lo;  // 和普通二分查找不一样的地方. 返回了负的 lo.
}

正常没找到, 我们可能会返回 -1, 表示查找失败就够了. 那为什么返回一个 lo 的取反?

  1. 首先需要和 -1, 一样的功能, 需要一个负数表示没有找到.
  2. 这个 lo 值其实就是新元素的插入位置.

好了, 回到 put 方法, 在拿到待插入元素应该插入的位置之后, 它首先判断需要插入的位置对应 mValues 数组的值是不是 DELETED, 如果是的话,直接覆盖, 那为什么这样做?

先看下 delete 方法源码

public void delete(int key) {
    // 查找要删除的元素下标
    int i = ContainerHelpers.binarySearch(mKeys, mSize, key);

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

SparseArray 也有 remove 方法, 它其实是直接调用 delete, 所以二者是一样的效果.

看到这个 delete 方法, 并没有直接去删除, 而是将 value 值设为 DELETED 标志位. 然后设置 mGarbage 为 true, 也就是垃圾回收标志位. 再来看下之前紧跟着的 gc() 方法:

private void gc() {
  
    int n = mSize; // 压缩前数组大小
    int o = 0;     // 压缩后数组大小, 初始为0
    int[] keys = mKeys; // 保存新的 key 值的数组
    Object[] values = mValues; // 保存新的 value 值的数组

    for (int i = 0; i < n; i++) { 
        Object val = values[i];
        
        // 如果没有 DELETED 标志位
        if (val != DELETED) {
            
            // i != o, 说明前面已经有元素被打上 DELETED 标签
            if (i != o) {
                // 将位置 i 的元素移动到 o 处. 其实 o 处就是之前 DELETED 标签的位置. 被覆盖
                keys[o] = keys[i];
                values[o] = val;
                values[i] = null; // 释放空间
            }

            // 新数组元素加一
            o++;
        }
    }
    
    // 回收完毕, 还原标志位
    mGarbage = false;
    
    // 回收之后数组的大小
    mSize = o;
}

所以是通过 gc() 方法, 根据标志位用 DELETED 之后的元素覆盖该标志位的元素, 达到压缩和空间释放的目的.

为什么不直接删除

回头来看 put 方法:

// 添加元素时直接覆盖 DELETED 标志位的元素
if (i < mSize && mValues[i] == DELETED) {
    mKeys[i] = key;
    mValues[i] = value;
    return;
}

还记得这一段吗? 在添加元素的时候, 如果对应的元素正好被标记了 DELETE, 那么直接覆盖它即可! 这样做, 和每次都删除元素相比, 可以直接省去删除元素的操作, 大大提高了效率. 只有空间不足的时候, 才调用 gc() 方法一次性压缩空间, 效率进一步.

SparseArray 与 HashMap、ArrayMap 比较

SparseArray 的限制在于键必须是 int/long 类型, 值必须是Object类型. 这样可以避免key自动装箱产生过多的 Object. 但是这样的话, 如果 key 值相同, 那么数据就会被直接覆盖.

  • SparseArray 不能保证保留插入顺序, 在迭代的时候应该注意. SparseArray 中没有 Iterator, 只实现了 Cloneable 接口, 没有继承 CollectionList 或者 Map 接口.

  • 查找数据使用的是二分法, 明显比通过 hashcode 慢, 所以数据越大, 查找速度慢的劣势越明显.

优点:

  • 避免了基本数据类型的装箱操作
  • 不需要额外的结构体, 单个元素的存储成本更低
  • 数据量小的情况下, 随机访问的效率更高

缺点:

  • 插入操作需要复制数组, 增删效率降低
  • 数据量巨大时, 复制数组成本巨大,gc() 成本也大, 查询效率也会明显下降

总结

  1. 通过标志位, 延迟删除机制的机制大大提高效率. 也是稀疏数组的意义所在.
  2. 键为 Integer 类型的 maps, 更加高效. 尤其是 value 是原始数据类型时, 可以使用 SparseIntArray 等来避免装箱.
  3. 二分法相比于 hashmap, 在大体量数据时, 效率降低.
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容