SkipList (跳躍表)解析及其實現


導言

伊蘇系列是電子游戲(音樂)公司Falcom所制作的一套動作角色扮演游戲(ARPG)系列,該系列的劇情圍繞着冒險家——亞特魯·克里斯汀的冒險故事展開。伊蘇·起源是這個系列中我最喜歡的作品之一,喜歡的原因之一是這部作品是以一座魔物修建的垂直聳立、直通天際的未完成建築——達姆之塔為舞台而展開的,游戲的流程就是從塔底一路向上推動劇情,直到塔頂與達雷斯和達姆決戰,這真是獨特而刺激的游戲體驗啊!

不過,我的主要目的並不是向你推薦這部游戲,而是想通過達姆之塔這個場景做個文章。達姆之塔的游戲場景有上百個,想要從塔底到達塔頂,就只能一路沖上去(這不是廢話嗎),那么問題來了,玩 rpg 類的游戲總有需要跑地圖搜集道具、推動劇情的時候,如果要我一個一個場景地找過去,未免也太枯燥了點吧。還好在我們進入達姆之塔之前,會獲得一個名叫水晶的道具,通過這個道具我們就可以在已經激活的存檔點進行傳送。雖然不能直接傳送到我想要去的場景,但是我可以過水晶傳送到分散在達姆之塔的各個存檔點處,已經比一個一個找場景快了不少了。

查找結點的效率如何提升?

單鏈表我們很熟悉,插入和刪除操作比順序表快,是個動態的結構,不適合隨機訪問。當我們想要查找單鏈表中的某個元素的時候,即使這個表是有序表,我們也不能利用二分查找降低時間復雜度,還是得一個一個遍歷結點,如圖所示,當我們想要查找元素 24 時,也得從頭結點開始一個一個結點比較過去,總共需要比較 5 次才能找到。

如果我們真的需要在鏈表中頻繁地查找,這種方式絕對不可取,因為它的效率實在是太低,我們希望的是查找操作的時間復雜度能夠降到二分查找的水平,即 O(㏒n)。用什么方法實現?
回憶一下我提過的達姆之塔,我們把達姆之塔的上百個場景抽象成一個線性表,每個場景都是按照一定的順序才能到達,所以我們把每一個場景都當成一個結點,這時因為一個一個場景地跑很慢,所以我可以通過已經激活的存檔點通過道具傳送來節省時間,這不就是我們提高鏈表查找速度的好方法嗎?我們找貓畫虎,選擇這個鏈表中的一些結點作為跳躍點,在單鏈表的基礎上開辟一條捷徑出來,如圖所示。這個時候我們如果要尋找元素 24,我們只需要查找 3 次就行了,換個數字看看,假設我要查找元素 34,按照原來的做法需要查找 6 次,但是有了一條捷徑之后我們只需要 4 次就能找到,相比之前這已經有進步了。

不過,是誰規定捷徑只有一條的,如果把我們開辟的這個捷徑也當做是單鏈表,我們在這個單鏈表上繼續開辟捷徑可否?當然是可行的,如圖所示。這個時候查找元素 24 僅需要 2 次,查找元素 34 僅需要 3 次,我們再來看個例子,例如查找元素 42,單鏈表需要查找 8 次,開辟一條捷徑需要 5 次,開辟兩條捷徑時需要 4 次,效率已經被再一次提高了。

別忘了我們現在舉的例子數據量是很少的,如果我有成百上千的結點呢?我們甚至可以再往上繼續開辟捷徑,數據規模一增大,效率的提升是太明顯了,而且這么操作的思想與二分法非常接近。

什么是跳躍表?

跳躍表(skiplist)由大牛 William Pugh 在論文《Skip lists: a probabilistic alternative to balanced trees》中提出,跳躍表以有序的方式在層次化的鏈表中保存元素,效率和平衡樹媲美:查找、刪除、添加等操作都可以在對數期望時間下完成。跳躍表體現了“空間換時間”的思想,從本質上來說,跳躍表是在單鏈表的基礎上在選取部分結點添加索引,這些索引在邏輯關系上構成了一個新的線性表,並且索引的層數可以疊加,生成二級索引、三級索引、多級索引,以實現對結點的跳躍查找的功能。與二分查找類似,跳躍表能夠在 O(㏒n)的時間復雜度之下完成查找,與紅黑樹等數據結構查找的時間復雜度相同,但是相比之下,跳躍表能夠更好的支持並發操作,而且實現這樣的結構比紅黑樹等數據結構要簡單、直觀許多。

跳躍表必須是完美的?

但是現在問題來了,上文我舉的例子是一個很完美的跳躍表,它嚴格地按照二分法的思想建立了捷徑。從理論上講,單個跳躍表的層數按照嚴格二分的條件建立,層數就應該是 ㏒n 層(以2為底,n 為結點個數),但是在實際操作中,我們的數據會剛剛好是 2 的 n 次方嗎?如果不是這么剛好的數據,沒辦法嚴格地二分,我要怎么開辟捷徑呢?如果我對這個跳躍表進行插入或刪除操作,破壞了嚴格地二分結構,又該怎么辦?如果我們要強行解決這些問題,那就又要引入一大堆七七八八又難以實現的規則了,只怕這么做比建一棵樹更為困難了。
既然我們沒有辦法很好地嚴格二分,也沒有很好的規則去描述這些問題的處理方式,那么我們就不使用嚴格二分的方法就行了啊,不要一條絕路走到黑嘛!分析一下我們的目的,我們希望的事情是讓查找操作的效率提升,如果我只開辟一條捷徑,效率也確確實實是提升了的,如果能繼續開辟捷徑,如果最后我們能達到嚴格地二分,效率就會被提升,那也就是說我們並不是為了要實現二分,而是通過不斷地努力去盡量地實現二分。

如果你還是不明白,那我舉個比較好理解的例子吧。大家都知道,拋一枚硬幣,花面朝上的可能性為 0.5,字面朝上的可能性也是 0.5,但是如果要證明這件事實,應該怎么證明呢?如果要嚴格地去證明,那就必須不斷丟硬幣,然后統計每一次是哪一面朝上,只有無論拋多少次硬幣,統計出來的數據有一半是花面朝上,一半都是字面朝上,才能證明這一點,但是我們知道這種情況只是有可能發生,往往都不能嚴格地出現這樣的統計數據。我們是怎么得到拋硬幣硬幣花面朝上的概率為 0.5,字面朝上的概率為 0.5的呢?請打開課本《概率論與數理統計》:

我們無法直接證明這個事實,但是我們可以通過“頻率的穩定性”得到啟發,提出了概率的定義,進而確定了事件的概率。從我舉的例子里,我們不僅得到了啟發,更是找到了解決問題的方法。也就是說,我們找到某種方式來實現捷徑的開辟,這種方式在統計學的角度來說,可以往嚴格二分的情況趨近,在理論上實現 O(㏒n) 的時間復雜度。

拋硬幣實驗

模擬建表

其實在我剛剛舉的例子中,我們已經解決了理論上趨近二分的問題了,思考一下,我們希望的嚴格二分,實質上就是任選跳躍表中的兩層,上層的結點個數是下層結點個數的 1/2,也就是說下層的每個結點同時也是上層的結點的概率是 1/2,這不就和拋硬幣花/字面朝上的概率是一致的嘛!所以我們可以利用算法模擬拋硬幣實驗,以此決定一個結點被多少層的表所占有。
別急,我們模擬一次建表,首先我們先規定當拋硬幣是花面朝上時該層結點同時被上一層占有,該跳躍表的層數上限為 4 層。先造個頭結點,然后插入表頭結點,如圖所示:

接下來插入第 2 個結點,並進行拋硬幣實驗,拋出字面,說明該結點不被上一層占有,插入結束:

插入第 3 個結點,並進行拋硬幣實驗,拋出花面,說明該結點被上一層占有,進行操作:

由於此時層數由 1 層變為了 2 層,因此需要開辟一層新的鏈表,即:

此時插入還沒有結束,繼續進行拋硬幣實驗,拋出字面,說明該結點不被上一層占有,插入結束。
接下來插入第 4 個結點,並進行拋硬幣實驗,拋出字面,說明該結點不被上一層占有,插入結束:

接下來插入第 5 個結點,並進行拋硬幣實驗,拋出字面,說明該結點不被上一層占有,插入結束。
插入第 6 個結點,並進行拋硬幣實驗,拋出花面,說明該結點被上一層占有,由於第二層捷徑已經開辟,所以進行操作:

此時插入還沒有結束,繼續進行拋硬幣實驗,拋出花面,說明該結點被上一層占有,由於此時層數由 2 層變為了 3 層,因此需要開辟一層新的鏈表,即:

好了,可以就此打住了,相信我們模擬了這么多步,你應該很清楚我們如何用拋硬幣的思想來決定一個結點被表中幾層同時占有的吧。當數據量足夠大,拋硬幣實驗次數足夠多,最終建立的跳躍表就會趨近於二分的情況了。

操作解析

接下來我們寫一個函數模擬拋硬幣實驗,並根據拋硬幣實驗的結果決定單個結點被多少層共同占有。由於生成一個隨機數,這個隨機數是奇\偶數的概率也是 1/2,所以用隨機數來描述拋硬幣實驗的實現。

偽代碼

代碼實現

int tossCoinExperiment()	//拋硬幣實驗
{
    int level = 1;    //初始化層數為 1

    while (rand() % 2 != 0)    //隨機數模擬拋硬幣實驗
    {
	if (level >= MAXLEVEL)
	{
	    break;    //層數已超過預定最大規模,結束實驗
	}
	level++;    //實驗結果為正面,層數加 1
    }
    return level;
}

跳躍表的結構體定義

跳躍表表頭結構體定義

結構體包含兩個成員,分別是該跳躍表的層數,便於我們能夠從最高層開始查找,還有一個是表頭的后繼,連接跳躍表的主體部分,同時該跳躍表的最大層數需要預設完畢。

typedef struct skipList
{
    int level;    //跳躍表的規模,即層數
    Node* head;    //存儲 skipList 的后繼,即頭結點
} skipList;
#define MAXLEVEL 5    //限制跳躍表的規模,需要事前估計,不能讓跳躍表的層數無限增生

跳躍表結點結構體定義

我們在實際需要用跳躍表存儲的數據往往是沒有一種規律來描述順序的,因此此處效仿 python 的字典結構和 C++ 的 STL 中的 Map 容器,用“鍵”來作為索引,用有序的數列來充當,作為我們查找的標准,“值”的部分存儲我們需要的數據即可,“鍵”和“值”的數據類型可以修改。

typedef int keyType;
typedef char valueType;
typedef struct node
{
    keyType key;	// 即“鍵”,起到索引作用
    valueType value;	// 即“值”,用於存儲數據
    struct node* next[1];	// 后繼指針數組,柔性數組
} Node;

newNode() 方法

柔性數組

數組大小待定的數組,在 C/C++ 中結構體的最后一個元素可以是大小未知的數組,也就是說這個數組可以沒有長度,所以柔性數組常出現與結構體中。柔性數組可以滿足結構體長度邊長的需求,能夠解決使用數組時內存的冗余和數組的越界問題。
使用方法是在一個結構體的最后,申明一個長度為空的數組,對於編譯器來說,此時長度為 0 的數組並不占用空間,因為數組名本身只是一個偏移量,數組名這個符號本身代表了一個不可修改的地址常量,但對於這個數組的大小,我們可以在后續操作中進行動態分配,對於編譯器而言,數組名僅僅是一個符號,它不會占用任何空間,它在結構體中,只是代表了一個偏移量,代表一個不可修改的地址常量!對於柔性數組的這個特點,很容易構造出變成結構體,如緩沖區,數據包等。
為什么在這里提柔性數組呢?一般我們使用數組為了防止越界,都會將數組的長度開得大一些,但是空閑的空間太多就會造成空間上的浪費。而我們一個結點占有的層數是按照一次拋硬幣實驗的結果來確定的,是一個不確定的量,為了不造成空間的浪費,我們使用了柔性數組來描述結點與層數的關系。如果你還不理解,可以看下方參考資料的相關連接進行進一步學習。

給柔性數組分配空間

為了描述結點的后繼,我們在單鏈表一般是加入一個結點成員,但是由於結點占有的層數由拋硬幣實驗決定,因此一個結點可能有多個后繼。所以這里我們選擇的是柔性數組,方便我們動態分配足夠的空間,並且不浪費,一個結點需要的空間為這個結點本身和柔性數組的元素個數個單位結點空間。為了配合對柔性數組的動態內存分配,我們需要另外結合 malloc 函數寫一個方法,供后續函數調用。

#define newNode(n)((Node*)malloc(sizeof(Node) + n * sizeof(Node*)));    //定義一個方法 newNode() 用於給柔性數組分配空間

跳躍表的建立與銷毀

建立跳躍表表頭操作

操作解析

表頭的成員為層數和后繼結點,層數表示了該跳躍表為幾層跳躍表,層數的最大值已提前預設,需要初始化。此處建議配一個 MAXLEVEL(暫定為3) 層的頭結點,方便修改跳躍表的最大層數和結點之間的邏輯關系。
由於 MAXLEVEL 是個定值,因此雖然有個循環結構,建立表頭的時間復雜度為 O(1)。

偽代碼

代碼實現

skipList* createList()    //建立 SkipList
{
    skipList* a_list = new skipList;	//動態分配空間
    if (a_list == NULL)    //判空操作
    {
	return NULL;
    }
    a_list->level = 0;    //初始化層數為0

    Node* head = createNode(MAXLEVEL - 1, 0, 0);	//建立一個頭結點
    if (head == NULL)	//判空操作
    {
	delete a_list;
	return NULL;
    }
	
    a_list->head = head;	//修改 SkipList 的后繼為頭結點
    for (int i = 0; i < MAXLEVEL; i++)
    {
	head->next[i] = NULL;	//初始化頭結點的每一層的后繼都是 NULL
    }

    srand(time(0));    //預備拋硬幣實驗
    return a_list;
}

創建單個結點操作

操作解析

只需要在分配空間之后,將對應的“鍵”和“值”賦值進結點即可,不要忘了判斷內存是否分配成功。時間復雜度為 O(1)。

代碼實現

Node* createNode(int level, keyType key, valueType value)	//創建單個結點
{
    Node* ptr = newNode(level);    //根據限定層數,給柔性數組分配空間

    if (ptr == NULL)	//判空操作
    {
	return NULL;
    }

    ptr->key = key;    //將“鍵”賦值給新結點
    ptr->value = value;    //將“值”賦值給新結點
    return ptr;
}

銷毀操作

操作解析

同單鏈表銷毀操作,由於最底層以上的層數都是我們在對應的地方通過柔性數組用多個指針所建立的,因此只需要對最底層鏈表操作即可,即圖中用紅色方框括起來的部分,釋放完所有結點之后,不要忘了把表頭的空間一並釋放。時間復雜度 O(n)。

代碼實現

void deleteSkipList(skipList* list)    //銷毀 skipList
{
    Node* head = list->head;
    Node* ptr;

    if (list == NULL)    //空表操作
    {
	return;
    }

    while (head != NULL && head->next[0] != NULL)    //在最底層依次釋放各個結點
    {
	ptr = head->next[0];
	head->next[0] = head->next[0]->next[0];
	delete ptr;
    }
    free(list);    //釋放 skipList
}

插入操作

操作解析

函數實現向跳躍表中插入一個“鍵”為 key,“值”為 value 的結點。由於我們進行插入操作時,插入結點的層數先要確定因此需要進行拋硬幣實驗確定占有層數。
由於新結點根據占有的層數不同,它的后繼可能有多個結點,因此需要用一個指針通過“鍵”進行試探,找到對應的“鍵”的所有后繼結點,在創建結點之后依次修改結點每一層的后繼,不要忘了給結點判空。在插入操作時,“鍵”可能已經存在,此時可以直接覆蓋“值”就行了,也可以讓用戶決定,可以適當發揮。

模擬插入操作

我們還是舉個例子吧,假設如圖所示跳躍表,我們要往里面插入一個被 3 層共同占有的結點 16。

首先我們需要用一個試探指針找到需要插入的結點的前驅,即用紅色的框框出來的結點。需要注意的是,由於當前的跳躍表只有 2 層,而新結點被 3 層占有,因此新結點在第 3 層的前驅就是頭結點。
接下來的操作與單鏈表相同,只是需要同時對每一層都操作。如圖所示,紅色箭頭表示結點之間需要切斷的邏輯聯系,藍色的箭頭表示插入操作新建立的聯系。

插入的最終效果應該是如圖所示的。

時間復雜度

雖然在代碼中有一個嵌套循環操作,但這個循環實際上執行的就是查找操作,查找到需要插入的位置,由於到下一層鏈表查找的時候,可以直接從上層鏈表跳躍到對應結點,無需從頭遍歷,因此這個嵌套循環結構時間復雜度為 O(㏒n)。由於代碼中各個結構獨立運行,所以用加法描述,且時間復雜度最大的部分為就是查找操作(由於跳躍表的層數上限是預設好的,因此相關操作時間復雜度是常數階),所以時間復雜度為 O(㏒n)。

偽代碼

代碼實現

bool insertNode(skipList* list, keyType key, valueType value)    //插入操作
{
    Node* successor[MAXLEVEL];    //保存插入位置在每一層的后繼
    Node* ptr = NULL;    //前驅試探指針
    Node* pre = list->head;    //拷貝插入的結點位置
    int level;

    for (int i = list->level - 1; 0 <= i; i--)    //通過 ptr 指針的試探,找到每一層的插入位置
    {
	while ((ptr = pre->next[i]) && ptr->key < key)    //單層遍歷之后自動跳到下一層,且不需要從頭開始
	{
		pre = ptr;    //單層遍歷,直到找到插入位置的前驅
	}
	successor[i] = pre;    //拷貝插入的位置
    }

    if (ptr != NULL && ptr->key == key)    //如果對應的“鍵”存在,修改對應的“值”並返回
    {
	ptr->value = value;
	return true;
    }

    level = tossCoinExperiment();    //拋硬幣實驗,確定插入的結點層數
    if (level > list->level)    //如果新結點的層數超過現有規模,先修改 skipList 的規模
    {
	for (int i = list->level; level > i; i++)
	{                 //直接將前面無法試探層的后繼,改為頭結點
	    successor[i] = list->head;  
	}
	list->level = level;    //更新 skipList 的層數
    }

    ptr = createNode(level, key, value);    //建立新結點
    if (ptr == NULL)    //判空操作
    {
	return false;
    }

    for (int i = level - 1; 0 <= i; i--)    //每一層插入新結點
    {			     //結點的插入操作,與單鏈表相同
	ptr->next[i] = successor[i]->next[i];
	successor[i]->next[i] = ptr;
    }
    return true;
}

刪除操作

操作解析

由於需要刪除的結點在每一層的前驅的后繼都會因刪除操作而改變,所以和插入操作相同,需要一個試探指針找到刪除結點在每一層的前驅的后繼,並拷貝。接着需要修改刪除結點在每一層的前驅的后繼為刪除結點在每一層的后繼,保證跳躍表的每一層的邏輯順序仍然是能夠正確描述。

模擬刪除操作

我們擁有如圖所示的跳躍表,現在我要刪除結點 16,由於該結點同時被 3 個層所共有,因此我們需要找到 3 個層該結點的前驅。

如圖 3 個紅色方框所在的結點,需要注意的是,跳躍表第 3 層有且僅有結點 16,因此它的前驅就是頭結點。
刪除操作與單鏈表相同,還是一樣,我們需要把每一層的邏輯關系都進行修改。

如圖所示,紅色箭頭表示需要切斷的邏輯聯系,藍色表示需要修改的邏輯聯系。

時間復雜度

同插入操作,為 O(㏒n)。

偽代碼

代碼實現

bool deleteNode(skipList* list, keyType key)    //刪除結點
{
    Node* successor[MAXLEVEL];    //保存插入位置在每一層的后繼
    Node* ptr = NULL;
    Node* pre = list->head;    //前驅試探指針

    for (int i = list->level - 1; 0 <= i; i--)    //通過 pre 指針的試探,找到每一層的刪除位置
    {
	while ((ptr = pre->next[i]) && key > ptr->key)
	{
	    pre = ptr;    //單層遍歷,直到找到刪除位置的前驅
	}
	successor[i] = pre;    //拷貝刪除位置
    }

    if (ptr == NULL || (ptr != NULL && ptr->key != key))
    {
	return false;    //判斷要刪除的結點是否存在
    }

    for (int i = list->level - 1; i >= 0; i--)    //修改每一層刪除結點的前驅的后繼
    {
	if (successor[i]->next[i] == ptr)
	{
	    successor[i]->next[i] = ptr->next[i];    //刪除操作同單鏈表
	    if (list->head->next[i] == NULL)    //如果被刪除的結點在最上層,且有且只有該節點
	    {
                list->level--;    //skipList 的層數減 1
	    }
	}
    }
    delete ptr;    //釋放空間
    return true;
}

查找操作

終於到了跳躍表的拿手絕活了。

操作解析

跳躍表只需要從最上層開始遍歷,由於每一層的鏈表都是有序的,因此當查找的“鍵”不存在於某一層中的時候,只需要在比查找目標的“鍵”要大的結點向下一次跳躍即可,重復操作,直至跳躍到最底層的鏈表。
舉個例子吧,假設有如圖所示的跳躍表,我們需要找到結點 8,那么查找的路徑如圖中藍色箭頭所示。

偽代碼

代碼實現

valueType* searchNode(skipList* list, keyType key)    //查找操作
{
    Node* pre = list->head;
    Node* ptr = NULL;
	
    for (int i = list->level - 1; i >= 0; i--)    //從最上層開始向下跳躍
    {
	while ((ptr = pre->next[i]) && key > ptr->key)
	{
	    pre = ptr;    //找到一層中最接近被查找元素的“鍵”
	}
	if (ptr != NULL && ptr->key == key)    //如果“建”相等,結束查找
	{
	    return &(ptr->value);
	}
    }
    return NULL;    //沒找到,返回 NULL
}

時間復雜度分析

由於查找操作的時間復雜度分析涉及到概率學的知識,我功力有限,因此轉載一些資料供參考。



轉載自Skip List(跳躍表)原理詳解與實現

簡單應用

跳躍字母表

實現一個最大層數不超過 7 層的跳躍表,跳躍表的“鍵”為首相是 1,公差是 1 的等差數列,“值”為 26 個大寫英文字母。要求用插入操作完成建表,並且能夠實現對第 i 個字母的查找。最后銷毀跳躍表。

代碼實現

此處直接把上述的所有操作的代碼封裝好,然后寫個 main 函數來嘗試着建立跳躍表吧。

運行結果

參考資料

《概率論與數理統計 第四版》—— 浙江大學,盛驟 等編著
跳躍表的原理及實現
skiplist 跳躍表詳解及其編程實現
C語言柔性數組講解
深入淺出C語言中的柔性數組
漫畫算法:什么是跳躍表?
跳躍表原理
怎樣用通俗易懂的文字解釋正態分布及其意義?
以后有面試官問你「跳躍表」,你就把這篇文章扔給他
跳躍表詳解


免責聲明!

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



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