intset 是用于实现集合 (set) 这种对外的数据结构。它包含的元素无序,且不能重复。当插入的元素都是整形,底层使用 intset 存储,否则使用 dict。
结构体定义如下:
// intset 结构体
typedef struct intset {
uint32_t encoding; // 数据编码,表示 intset 中的每个数据元素用几个字节(2、4、8)来存储,超出上限就需要转 dict 存储
uint32_t length; // 表示 intset 中的元素个数
int8_t contents[]; // 柔性数组 总长度为 encoding * length
} intset;
各字段含义如下:
可选值:
#define INTSET_ENC_INT16 (sizeof(int16_t)) // 2字节
#define INTSET_ENC_INT32 (sizeof(int32_t)) // 4字节
#define INTSET_ENC_INT64 (sizeof(int64_t)) // 8字节
需要注意的是,intset 会随着数据的添加而改变它的数据编码
127.0.0.1:6379> sadd myset 1 2 3
(integer) 3
127.0.0.1:6379> memory usage myset
(integer) 61
127.0.0.1:6379> sadd myset 4 5
(integer) 2
127.0.0.1:6379> memory usage myset
(integer) 65
127.0.0.1:6379> sadd myset 36666
(integer) 1
127.0.0.1:6379> memory usage myset
(integer) 79
我们来分析一下:
添加 3 个小元素后,myset 占用 61 字节,再添加两个小元素,myset 占用 65 字节,说明 1 个小元素占用 2字节,与我们的分析一致。同时也可以得出除了元素所占的空间,结构体本身占用 65 - 5*2 = 55 字节。
接着我们添加了 36666 元素,超出了 2 字节的表示范围,因此变为了4字节编码,共有(1、2、3、4、5、36666)6 个元素,占用 24 字节。
结构体本身 55 字节 + 元素 24 字节 = 79 字节
与输出结果一致。
了解了数据结构后,分析两个主要函数(插入和查找):
intset *intsetAdd(intset *is, int64_t value, uint8_t *success) {
// 获取要插入 value 的编码类型
uint8_t valenc = _intsetValueEncoding(value);
uint32_t pos;
if (success) *success = 1;
// 如果比当前的编码类型大,需要进行升级
if (valenc > intrev32ifbe(is->encoding)) {
return intsetUpgradeAndAdd(is, value);
} else {
// 如果当前 value 已经存在
if (intsetSearch(is, value, &pos)) {
if (success) *success = 0;
return is;
}
// 内存扩充以容纳新的元素
is = intsetResize(is, intrev32ifbe(is->length) + 1);
// 将待插入位置后面的元素统一向后移动 1 个位置
if (pos < intrev32ifbe(is->length)) intsetMoveTail(is, pos, pos + 1);
}
// 把 value 插入到指定 pos
_intsetSet(is, pos, value);
// length + 1
is->length = intrev32ifbe(intrev32ifbe(is->length) + 1);
return is;
}
// 在指定的 intset 中查找指定的元素 value,如果找到,则返回 1 并且将参数 pos 指向找到的元素位置
// 如果没找到,则返回 0 并且将参数 pos 指向能插入该元素的位置。
static uint8_t intsetSearch(intset *is, int64_t value, uint32_t *pos) {
// intset 里的数据是按小端(little endian)模式存储的,因此在大端(big endian)机器上运行时,这里的 intrev32ifbe 会做相应的转换
int min = 0, max = intrev32ifbe(is->length) - 1, mid = -1;
int64_t cur = -1;
// 如果 intset 为空,直接返回 0 和要插入的位置 0
if (intrev32ifbe(is->length) == 0) {
if (pos) *pos = 0;
return 0;
} else {
// 如果要插入的 value > 最大值 或 < 最小值,那么要插入的位置就确定了
if (value > _intsetGet(is, max)) {
if (pos) *pos = intrev32ifbe(is->length);
return 0;
} else if (value < _intsetGet(is, 0)) {
if (pos) *pos = 0;
return 0;
}
}
// 二分查找要插入的位置
while (max >= min) {
mid = ((unsigned int) min + (unsigned int) max) >> 1;
cur = _intsetGet(is, mid);
if (value > cur) {
min = mid + 1;
} else if (value < cur) {
max = mid - 1;
} else {
break;
}
}
// 如果找到的值就是要插入的值,返回1,并把 pos 指向该位置
if (value == cur) {
if (pos) *pos = mid;
return 1;
} else { // 返回 0,并把 pos 指向该插入的位置
if (pos) *pos = min;
return 0;
}
}
Intset 和 ziplist 相比,都有时间换空间的意思,但也有一些不同:
另外,intset 在以下情况发生时会转变为 dict
127.0.0.1:6379> sadd myset 1 2 3
(integer) 3
127.0.0.1:6379> object encoding myset
"intset"
127.0.0.1:6379> sadd myset zwj
(integer) 1
127.0.0.1:6379> object encoding myset
"hashtable
set-max-intset-entries
配置的值(默认 512
)的时候,此时查找效率为 O(log N),数据量大时会比较慢,也会导致 intset 转成 dictRedis 的 set 数据结构还提供了计算交集(sinter
)、并集(sunion
)、差集(sdiff
)的方法:
计算交集的过程大概可以分为三部分:
O(N*M) worst case where N is the cardinality of the smallest set and M is the number of sets.
计算并集只需要遍历所有集合,将每一个元素都添加到最后的结果集合中。向集合中添加元素会自动去重。
O(N) where N is the total number of elements in all given sets.
计算差集(A 和 B 的差集是 A 中有 B 中没有的 A - B)
计算差集有两种可能的算法,它们的时间复杂度有所区别。
第一种算法:
这种算法的时间复杂度为 O(N*M),其中N是第一个集合的元素个数,M是集合数目。
第二种算法:
这种算法的时间复杂度为O(N),其中N是所有集合的元素个数总和。
在计算差集的开始部分,会先分别估算一下两种算法预期的时间复杂度,然后选择复杂度低的算法来进行运算。还有两点需要注意:
由多到少
进行了排序。这个排序有利于以更大的概率查找到元素,从而更快地结束查找。