Redis 學習筆記(篇三):跳表


跳表

跳表(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設計與實現第二版》第五章:跳躍表):

《Redis設計與實現第二版》26頁

位於圖片最左邊的是 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 是節點所保存的成員對象。

注:

  1. 表頭節點不存儲數據,所以圖中省略了表頭節點的部分屬性。
  2. 由於跳表中查找元素的時間復雜度是 O(logn) ,所以有序集合(zset)中又定義了一個存儲鍵值對的字典,所以有序集合中根據 key 查找分數的時間復雜度為 O(1)。

跳表和同類中(二叉平衡樹)相比,有什么優缺點呢?

這里我就直接引用網上的資料了,如下(出自:http://copyfuture.com/blogs-details/6097031f2015d499a2321e23ea3f1324):

參見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設計與實現(第二版)》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM