【數據結構】跳表


什么是跳表

跳表全稱為跳躍列表,它允許快速查詢,插入和刪除一個有序連續元素的數據鏈表。跳躍列表的平均查找和插入時間復雜度都是O(logn)。快速查詢是通過維護一個多層次的鏈表,且每一層鏈表中的元素是前一層鏈表元素的子集(見右邊的示意圖)。一開始時,算法在最稀疏的層次進行搜索,直至需要查找的元素在該層兩個相鄰的元素中間。這時,算法將跳轉到下一個層次,重復剛才的搜索,直到找到需要查找的元素為止。

img

一張跳躍列表的示意圖。每個帶有箭頭的框表示一個指針, 而每行是一個稀疏子序列的鏈表;底部的編號框(黃色)表示有序的數據序列。查找從頂部最稀疏的子序列向下進行, 直至需要查找的元素在該層兩個相鄰的元素中間。

跳表的演化過程

對於單鏈表來說,即使數據是已經排好序的,想要查詢其中的一個數據,只能從頭開始遍歷鏈表,這樣效率很低,時間復雜度很高,是 O(n)。
那我們有沒有什么辦法來提高查詢的效率呢?我們可以為鏈表建立一個“索引”,這樣查找起來就會更快,如下圖所示,我們在原始鏈表的基礎上,每兩個結點提取一個結點建立索引,我們把抽取出來的結點叫做索引層或者索引,down 表示指向原始鏈表結點的指針。

img

現在如果我們想查找一個數據,比如說 15,我們首先在索引層遍歷,當我們遍歷到索引層中值為 14 的結點時,我們發現下一個結點的值為 17,所以我們要找的 15 肯定在這兩個結點之間。這時我們就通過 14 結點的 down 指針,回到原始鏈表,然后繼續遍歷,這個時候我們只需要再遍歷兩個結點,就能找到我們想要的數據。好我們從頭看一下,整個過程我們一共遍歷了 7 個結點就找到我們想要的值,如果沒有建立索引層,而是用原始鏈表的話,我們需要遍歷 10 個結點。

通過這個例子我們可以看出來,通過建立一個索引層,我們查找一個基點需要遍歷的次數變少了,也就是查詢的效率提高了。

那么如果我們給索引層再加一層索引呢?遍歷的結點會不會更少呢,效率會不會更高呢?我們試試就知道了。

img

現在我們再來查找 15,我們從第二級索引開始,最后找到 15,一共遍歷了 6 個結點,果然效率更高。

當然,因為我們舉的這個例子數據量很小,所以效率提升的不是特別明顯,如果數據量非常大的時候,我們多建立幾層索引,效率提升的將會非常的明顯,感興趣的可以自己試一下,這里我們就不舉例子了。

這種通過對鏈表加多級索引的機構,就是跳表了。

跳表具體有多快

通過上邊的例子我們知道,跳表的查詢效率比鏈表高,那具體高多少呢?下面我們一起來看一下。

衡量一個算法的效率我們可以用時間復雜度,這里我們也用時間復雜度來比較一下鏈表和跳表。前面我們已經講過了,鏈表的查詢的時間復雜度為 O(n),那跳表的呢?

如果一個鏈表有 n 個結點,如果每兩個結點抽取出一個結點建立索引的話,那么第一級索引的結點數大約就是 n/2,第二級索引的結點數大約為 n/4,以此類推第 m 級索引的節點數大約為 n/(2^m)。

假如一共有 m 級索引,第 m 級的結點數為兩個,通過上邊我們找到的規律,那么得出 n/(2^m)=2,從而求得 m=log(n)-1。如果加上原始鏈表,那么整個跳表的高度就是 log(n)。我們在查詢跳表的時候,如果每一層都需要遍歷 k 個結點,那么最終的時間復雜度就為 O(k*log(n))。

那這個 k 值為多少呢,按照我們每兩個結點提取一個基點建立索引的情況,我們每一級最多需要遍歷兩個個結點,所以 k=2。為什么每一層最多遍歷兩個結點呢?

因為我們是每兩個結點提取一個結點建立索引,最高一級索引只有兩個結點,然后下一層索引比上一層索引兩個結點之間增加了一個結點,也就是上一層索引兩結點的中值,看到這里是不是想起來我們前邊講過的二分查找,每次我們只需要判斷要找的值在不在當前結點和下一個結點之間即可。

img

如上圖所示,我們要查詢紅色結點,我們查詢的路線即黃線表示出的路徑查詢,每一級最多遍歷兩個結點即可。

所以跳表的查詢任意數據的時間復雜度為 O(2*log(n)),前邊的常數 2 可以忽略,為 O(log(n))。

跳表是用空間來換時間

跳表的效率比鏈表高了,但是跳表需要額外存儲多級索引,所以需要的更多的內存空間。

跳表的空間復雜度分析並不難,如果一個鏈表有 n 個結點,如果每兩個結點抽取出一個結點建立索引的話,那么第一級索引的結點數大約就是 n/2,第二級索引的結點數大約為 n/4,以此類推第 m 級索引的節點數大約為 n/(2^m),我們可以看出來這是一個等比數列。

這幾級索引的結點總和就是 n/2+n/4+n/8…+8+4+2=n-2,所以跳表的空間復雜度為 o(n)。

那么我們有沒有辦法減少索引所占的內存空間呢?可以的,我們可以每三個結點抽取一個索引,或者沒五個結點抽取一個索引。這樣索引結點的數量減少了,所占的空間也就少了。

跳表的插入和刪除

我們想要為跳表插入或者刪除數據,我們首先需要找到插入或者刪除的位置,然后執行插入或刪除操作,前邊我們已經知道了,跳表的查詢的時間復雜度為 O(logn),因為找到位置之后插入和刪除的時間復雜度很低,為 O(1),所以最終插入和刪除的時間復雜度也為 O(longn)。

我么通過圖看一下插入的過程。

img

刪除操作的話,如果這個結點在索引中也有出現,我們除了要刪除原始鏈表中的結點,還要刪除索引中的。因為單鏈表中的刪除操作需要拿到要刪除結點的前驅結點,然后通過指針操作完成刪除。所以在查找要刪除的結點的時候,一定要獲取前驅結點。當然,如果我們用的是雙向鏈表,就不需要考慮這個問題了。

如果我們不停的向跳表中插入元素,就可能會造成兩個索引點之間的結點過多的情況。結點過多的話,我們建立索引的優勢也就沒有了。所以我們需要維護索引與原始鏈表的大小平衡,也就是結點增多了,索引也相應增加,避免出現兩個索引之間結點過多的情況,查找效率降低。

跳表是通過一個隨機函數來維護這個平衡的,當我們向跳表中插入數據的的時候,我們可以選擇同時把這個數據插入到索引里,那我們插入到哪一級的索引呢,這就需要隨機函數,來決定我們插入到哪一級的索引中

這樣可以很有效的防止跳表退化,而造成效率變低。

跳表的代碼實現

最后我們來看一下跳變用代碼怎么實現。

package skiplist;

import java.util.Random;

/**
 * 跳表的一種實現方法。
 * 跳表中存儲的是正整數,並且存儲的是不重復的。
 */
public class SkipList {

  private static final int MAX_LEVEL = 16;

  private static final float SKIPLIST_P = 0.5f;

  private int levelCount = 1;

  private Node head = new Node();  // 帶頭鏈表

  private Random r = new Random();

  public Node find(int value) {
    Node p = head;
    for (int i = levelCount - 1; i >= 0; --i) {
      while (p.forwards[i] != null && p.forwards[i].data < value) {
        p = p.forwards[i];
      }
    }

    if (p.forwards[0] != null && p.forwards[0].data == value) {
      return p.forwards[0];
    } else {
      return null;
    }
  }

  public void insert(int value) {
    int level = randomLevel();
    Node newNode = new Node();
    newNode.data = value;
    newNode.maxLevel = level;
    Node update[] = new Node[level];
    for (int i = 0; i < level; ++i) {
      update[i] = head;
    }

    // record every level largest value which smaller than insert value in update[]
    Node p = head;
    for (int i = level - 1; i >= 0; --i) {
      while (p.forwards[i] != null && p.forwards[i].data < value) {
        p = p.forwards[i];
      }
      update[i] = p;// use update save node in search path
    }

    // in search path node next node become new node forwords(next)
    for (int i = 0; i < level; ++i) {
      newNode.forwards[i] = update[i].forwards[i];
      update[i].forwards[i] = newNode;
    }

    // update node hight
    if (levelCount < level) levelCount = level;
  }

  public void delete(int value) {
    Node[] update = new Node[levelCount];
    Node p = head;
    for (int i = levelCount - 1; i >= 0; --i) {
      while (p.forwards[i] != null && p.forwards[i].data < value) {
        p = p.forwards[i];
      }
      update[i] = p;
    }

    if (p.forwards[0] != null && p.forwards[0].data == value) {
      for (int i = levelCount - 1; i >= 0; --i) {
        if (update[i].forwards[i] != null && update[i].forwards[i].data == value) {
          update[i].forwards[i] = update[i].forwards[i].forwards[i];
        }
      }
    }
  }

 // 理論來講,一級索引中元素個數應該占原始數據的 50%,二級索引中元素個數占 25%,三級索引12.5% ,一直到最頂層。
  // 因為這里每一層的晉升概率是 50%。對於每一個新插入的節點,都需要調用 randomLevel 生成一個合理的層數。
  // 該 randomLevel 方法會隨機生成 1~MAX_LEVEL 之間的數,且 :
  //        50%的概率返回 1
  //        25%的概率返回 2
  //      12.5%的概率返回 3 ...
  private int randomLevel() {
    int level = 1;

    while (Math.random() < SKIPLIST_P && level < MAX_LEVEL)
      level += 1;
    return level;
  }

  public void printAll() {
    Node p = head;
    while (p.forwards[0] != null) {
      System.out.print(p.forwards[0] + " ");
      p = p.forwards[0];
    }
    System.out.println();
  }

  public class Node {
    private int data = -1;
    private Node forwards[] = new Node[MAX_LEVEL];
    private int maxLevel = 0;

    @Override
    public String toString() {
      StringBuilder builder = new StringBuilder();
      builder.append("{ data: ");
      builder.append(data);
      builder.append("; levels: ");
      builder.append(maxLevel);
      builder.append(" }");

      return builder.toString();
    }
  }

}


免責聲明!

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



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