整數集合是 Redis 集合鍵的底層實現之一。當一個集合只包含整數值元素,並且元素數量不多時,Redis 就會使用整數集合作為集合鍵的底層實現。
1 整數集合的實現
整數集合是 Redis 用於儲存整數值的集合抽象資料結構。它可以儲存型別為 int16_t、int32_t、int64_t 的整數值,並且保證集合中不會出現重複元素。
每個 intset.h/intset
結構表示一個整數集合:
typedef struct intset {
uint32_t encoding;
uint32_t length;
int8_t contents[];
} intset;
- encding:編碼方式
- length:集合包含的元素數量
- contents[]:儲存元素的陣列
contents 陣列是整數集合的底層實現:整數集合的每個元素都是 contents 陣列的一個陣列項,各個項在陣列中按值的大小從小到大有序排列,並且陣列中不包含重複項。
length 屬性記錄了整數集合記錄的元素數量,也就是 contents 陣列的長度。
雖然 intset 結構將 contents 屬性宣告為 int8_t 型別的陣列,但實際上 contents 陣列並不儲存任何 int8_t 型別的值,contents 陣列的真正型別取決於 encoding 屬性的值,比如:如果 encoding 屬性的值為 INTSET_ENC_INT16,那麼 contents 就是一個 int16_t 型別的陣列,陣列裡的每個項都是一個 int16_t 型別的整數值,取值範圍為:[-32768-32767](2^(16-1))。
與之類似,encoding 的值為 INTSET_ENC_INT32,那麼陣列每項的取值範圍為:[-2147483648, 2147483647](2^(32-1)。
這裡也引發了一個問題,當我們對一個 encoding 為 INTSET_ENC_INT8 的 intset,插入 129 時(int8_t 的取值範圍是 [-128, 127]),會出現什麼?
這也就引發了 intset 的升級操作。與之對應,也有降級操作。接下來,我們來詳細認識下 intset 的升降級操作。
2 升級操作
每當我們要將一個新元素新增到整數集合時,如果新元素的型別比整數集合的 encoding 型別大,整數集合就需要先進行升級操作(upgrade),然後才能將新元素新增到整數集合中。
整個升級操作原始碼如下:
// intset.c/intsetUpgradeAndAdd()
/* Upgrades the intset to a larger encoding and inserts the given integer. */
static intset *intsetUpgradeAndAdd(intset *is, int64_t value) {
uint8_t curenc = intrev32ifbe(is->encoding);
uint8_t newenc = _intsetValueEncoding(value);
int length = intrev32ifbe(is->length);
int prepend = value < 0 ? 1 : 0;
/* First set new encoding and resize */
is->encoding = intrev32ifbe(newenc);
is = intsetResize(is,intrev32ifbe(is->length)+1);
/* Upgrade back-to-front so we don't overwrite values.
* Note that the "prepend" variable is used to make sure we have an empty
* space at either the beginning or the end of the intset. */
while(length--)
_intsetSet(is,length+prepend,_intsetGetEncoded(is,length,curenc));
/* Set the value at the beginning or the end. */
if (prepend)
_intsetSet(is,0,value);
else
_intsetSet(is,intrev32ifbe(is->length),value);
is->length = intrev32ifbe(intrev32ifbe(is->length)+1);
return is;
}
升級整數集合並新增新元素,共分為三步進行:
- 擴充套件底層陣列大小。根據新元素的型別,擴充套件整數集合底層陣列的大小,併為新元素分配空間。
- 元素轉換,並保持原有順序。將底層陣列現有的所有元素,都轉換成與新元素相同的型別,並將轉換後的元素放在正確的位置上,保證原有順序不發生改變。
- 將新元素新增到底層陣列中。
此外,一旦因插入新元素引發升級操作,就說明新插入的元素比集合中現有的所有元素的長度大,所以這個新元素的值要麼大於所有現有元素(正值),要麼就小於所有現有元素(負值),那麼:
- 在新元素小於所有現有元素時,新元素就會被放在底層陣列的最開頭的位置,即索引為 0 的位置;
- 在新元素大於所有現有元素時,新元素就會被放在底層陣列的最末尾的位置;
3 升級優勢
整數集合的升級策略主要有以下兩個好處:
- 提示整數集合的靈活性;
- 儘可能的節約記憶體;
3.1 提示靈活性
因為 C 語言是靜態型別語言,為了避免型別錯誤,我們通常不會將兩種不同型別的值放在同一個資料結構中。
但是,因為有了升級操作,整數集合可以通過它來自適應新元素,所以我們可以隨意地將 int16_t、int32_t、和 int64_t 型別的整數新增到集合中,而不必擔心出現型別錯誤,大大的提升了整數集合的靈活性。
3.2 節約記憶體
當然,要讓一個陣列可以同時儲存 int16_t、int32_t、和 int64_t 型別的整數值,我們可以粗暴的直接使用 int64_t 型別的陣列作為整數集合的底層實現,來儲存不同型別的值。但是,這樣一來,即使新增到集合中的都是 int16_t、int32_t 型別的值,陣列也都是需要使用 int64_t 型別的空間去儲存,出現浪費記憶體的情況。
而整數集合的升級操作,既能同時儲存三種不同型別的值,又可以確保升級操作只會在有需要的時候進行,達到節省記憶體的目的。
4 交、並、差集演算法
Redis 中的集合實現了交、並、差等操作,相關操作可參加 t_set.c
,其中
sinterGenericCommand()
實現交集,sunionDiffGenericCommand()
實現並集和差集。
它們都能同時對多個集合進行元素。當對多個集合進行差集運算時,會先計算出第一個和第二個集合的差值,然後再與第三個集合做差集,依次類推。
接下來,我們一起來認識下三個操作的實現思路。
4.1 交集
計算交集的過程大概可以分為三部分:
- 檢查各個集合,對於不存在的集合當做空集來處理。一旦出現空集,則不用繼續計算了,最終的交集就是空集。
- 對各個集合按照元素個數由少到多進行排序。這個排序有利於後面計算的時候從最小的集合開始,需要處理的元素個數較少。
- 對排序後第一個集合(也就是最小集合)進行遍歷,對於它的每一個元素,依次在後面的所有集合中進行查詢。只有在所有集合中都能找到的元素,才加入到最後的結果集合中。
需要注意的是,上述第 3 步在集合中進行查詢,對於 intset 和 dict 的儲存來說時間複雜度分別是 O(log n) 和 O(1)。但由於只有小集合才使用 intset,所以可以粗略地認為 intset 的查詢也是常數時間複雜度的。
4.2 並集
並集操作最簡單,只要遍歷所有集合,將每一個元素都新增到最後的結果集中即可。向集合中新增元素會自動去重,所以插入的時候無需檢測元素是否已存在。
4.3 差集
計算差集有兩種可能的演算法,它們的時間複雜度有所區別。
第一種演算法
對第一個集合進行遍歷,對於它的每一個元素,依次在後面的所有集合中進行查詢。只有在所有集合中都找不到的元素,才加入到最後的結果集合中。
這種演算法的時間複雜度為O(N*M),其中N是第一個集合的元素個數,M是集合數目。
第二種演算法
- 將第一個集合的所有元素都加入到一箇中間集合中。
- 遍歷後面所有的集合,對於碰到的每一個元素,從中間集合中刪掉它。
- 最後中間集合剩下的元素就構成了差集。
- 這種演算法的時間複雜度為O(N),其中N是所有集合的元素個數總和。
在計算差集的開始部分,會先分別估算一下兩種演算法預期的時間複雜度,然後選擇複雜度低的演算法來進行運算。還有兩點需要注意:
- 在一定程度上優先選擇第一種演算法,因為它涉及到的操作比較少,只用新增,而第二種演算法要先新增再刪除。
- 如果選擇了第一種演算法,那麼在執行該演算法之前,Redis的實現中對於第二個集合之後的所有集合,按照元素個數由多到少進行了排序。這個排序有利於以更大的概率查詢到元素,從而更快地結束查詢。
5 總結
- 整數集合是集合鍵的底層實現之一。
- 整數集合以有序、無重複的方式儲存集合元素。在有需要時,會根據新新增元素的型別,改變底層陣列的型別。
- 升級操作提升了操作的靈活性,並儘可能的節約了記憶體。
- 集合可以進行交、並、差集操作。