跳表
跳表(skiplist)是一種有序的數據結構,是在有序鏈表的基礎上發展起來的。
在 Redis 中跳表是有序集合(sort set)的底層實現之一。
說到 Redis 中的有序集合,是不是和 Java 中的 TreeMap 很像?都是有序集合。
那么:
- 為什么會出現跳表這種數據結構呢?
- 跳表的原理是什么?Redis又是怎么實現的?
- 和同類中(二叉平衡樹)相比,有什么優缺點呢?
為什么會出現跳表?跳表解決了什么樣的問題?
跳表可以說是平衡樹的一種替代品。它也是為了解決元素隨機插入后快速定位的的問題。到這里,你可能會說 hash 表解決的不是很好嗎?插入和查找都是 O(1) 的時間復雜度。是的,hash表是很好的解決了查找的問題,但若想要有序呢?這個時候 hash 表就不行了,二叉查找樹可以解決這個問題。
但是由於二叉查找樹在按大小順序進行插入的時候,就會退化為鏈表。所以又出現了平衡二叉樹,而根據算法不同,又分為AVL樹、B-Tree、B+Tree、紅黑樹等。看到這里,是不是頭都大了(天吶,這么多算法,這么復雜的實現)。
而跳表的出現就是為了解決平衡二叉樹復雜的問題。所以它可以說是平衡樹的一種替代品。它以一種較為簡單的方式實現了平衡二叉樹的功能。
跳表的原理是什么?Redis又是怎么實現的?
跳表的原理網上一搜一大堆,這里就不重復了,這里給出看起來不錯的兩篇文章:
http://copyfuture.com/blogs-details/6097031f2015d499a2321e23ea3f1324
https://lotabout.me/2018/skip-list/
Redis 中跳表由 redis.h/zskiplistNode(跳表節點)和 redis.h/zskiplist(跳表節點的相關信息,比如節點的數量、表頭、表尾指針)兩個結構定義。他們的具體結構如下:
/*
* 跳躍表節點
*/
typedef struct zskiplistNode {
// 成員對象
robj *obj;
// 分值
double score;
// 后退指針
struct zskiplistNode *backward;
// 層
struct zskiplistLevel {
// 前進指針
struct zskiplistNode *forward;
// 跨度
unsigned int span;
} level[];
} zskiplistNode;
/*
* 跳躍表
*/
typedef struct zskiplist {
// 表頭節點和表尾節點
struct zskiplistNode *header, *tail;
// 表中節點的數量
unsigned long length;
// 表中層數最大的節點的層數(表頭節點不計)
int level;
} zskiplist;
/*
* 有序集合
*/
typedef struct zset {
// 字典,鍵為成員,值為分值
// 用於支持 O(1) 復雜度的按成員取分值操作
dict *dict;
// 跳躍表,按分值排序成員
// 用於支持平均復雜度為 O(log N) 的按分值定位成員操作
// 以及范圍操作
zskiplist *zsl;
} zset;
一個完整跳表的示意圖如下(出自《Redis設計與實現第二版》第五章:跳躍表):
位於圖片最左邊的是 zskiplist 結構,該結構包含以下屬性:
- header :指向跳表的表頭節點。
- tail :指向跳表的表尾節點。
- level :記錄目前跳表內,層數最大的那個節點的層數(表頭節點的層數不計算在內)。
- length :跳表的長度,即跳表目前包含節點的數量。
位於 zskiplist 結構右方的是四個 zskiplistNode 結構,該結構包含以下屬性:
- level :層,節點中用 L1、L2、L3 等字樣標記節點的各個層,L1 代表第一層,L2 代表第二層,以此類推。每個層都帶有兩個屬性:前進指針和跨度。前進指針用於訪問位於表尾方向的其他節點,而跨度則表示前進指針所指向節點和當前節點的距離,跨度越大,相距越遠。在上面的圖片中,連線上帶有數字的箭頭就代表前進指針,數字就是跨度。當程序從表頭向表尾進行遍歷時,訪問會沿着層的前進指針進行。
- backward :后退指針,節點中用 Bw 字樣標記節點的后退指針,它指向位於當前節點的前一個節點。后退指針在程序從表尾向表頭遍歷時使用。
- score :各個節點中的 1.0、2.0、3.0 是節點所保存的分值。在跳躍表中,節點按各自所保存的分值從小到大排列。
- obj :成員對象,各個節點中的 o1、o2、o3 是節點所保存的成員對象。
注:
- 表頭節點不存儲數據,所以圖中省略了表頭節點的部分屬性。
- 由於跳表中查找元素的時間復雜度是 O(logn) ,所以有序集合(zset)中又定義了一個存儲鍵值對的字典,所以有序集合中根據 key 查找分數的時間復雜度為 O(1)。
跳表和同類中(二叉平衡樹)相比,有什么優缺點呢?
這里我就直接引用網上的資料了,如下(出自:http://copyfuture.com/blogs-details/6097031f2015d499a2321e23ea3f1324):
上期思考問題
上篇思考問題: Java 中 HashMap 關於擴容和收縮的3個問題是怎么解決的呢?
(1)觸發條件是什么?是手動觸發還是自動觸發。
(2)擴容或者收縮的規則是什么?具體過程是怎樣的?
(3)當哈希表在擴容時,是否允許別的線程來操作這個哈希表?
首先,Java 中的 HashMap 沒有收縮,只有擴容。
-
擴容的觸發條件是:put時先添加元素,添加完元素之后,看當前size是否大於hash表總容量*擴容因子。若是,則擴容。 如下:
/** * The next size value at which to resize (capacity * load factor). * * @serial */ int threshold; final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { ... ++modCount; if (++size > threshold) resize(); // 重新調整大小。 afterNodeInsertion(evict); return null; }
-
擴容的規則是:每次擴容二倍,如果超過最大值(1 << 30)則不擴容。具體代碼如下:
/** * The maximum capacity, used if a higher value is implicitly specified * by either of the constructors with arguments. * MUST be a power of two <= 1<<30. */ static final int MAXIMUM_CAPACITY = 1 << 30; final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } ... }
-
Java中的 HashMap 擴容是一次性的,是線程不安全的,不允許別的線程來操作 hash 表。所以 Java 有 Hashtable、ConcurrentHashMap 這些線程安全的 Hash 表。
本期思考問題
暫無
參考資料:
https://juejin.im/post/57fa935b0e3dd90057c50fbc
http://copyfuture.com/blogs-details/6097031f2015d499a2321e23ea3f1324
https://lotabout.me/2018/skip-list/
《Redis設計與實現(第二版)》