C++高級數據結構算法 | Skip List(跳躍表)


前言

前面的文章我們學習了性能高效的基於二叉搜索樹的動態數據結構紅黑樹,其平均時間複雜度爲O(log2n)O(log_2n),今天我們再來學習另外一種優秀的數據結構跳躍表(Skip List)其綜合性能與紅黑樹一樣,而且功能更強大,從某種意義上來說是可以替代紅黑樹的。但是由於紅黑樹的代碼實現需要嚴密的邏輯判斷,因此較爲複雜,而跳躍表就相對來講實現更容易一些。


跳躍表的基本概念與性質

跳躍表(Skip List)是1987年才誕生的一種嶄新的數據結構它在進行查找、插入、刪除等操作時的期望時間複雜度均爲O(logn),接近線性時間,有着近乎替代平衡樹的本領。而且最重要的一點,就是它的編程複雜度較同類的AVL樹,紅黑樹等要低得多,這使得其無論是在理解還是在推廣性上,都有着十分明顯的優勢。

在介紹跳躍表之前,我們先來思考一個問題,如果現在我們要維護一組有序的整數序列,在支持高效的插入,刪除和搜索的同時並能維護序列的有序性,那麼應該採用什麼什麼數據結構?

首先哈希表應該被排除掉,雖然支持O(1)的增,刪,查,但是HashMap不能維護有序性。接着我們思考下使用數組怎麼樣,使用數組查詢很快,但刪除和新增比較慢,最壞情況下是O(N)的複雜度,所以也被排除掉。

然後我們能想到的就是二叉查找樹,但二叉查找樹沒有維持平衡性,最壞情況下依然是O(N),所以也被排除掉,最後我們想到了二叉樹裏面的Boss,沒錯,它就是紅黑樹,它可以維持有序性,並且增,刪,查都有不錯的性能,目前看滿足了所有的需求。

但是,紅黑樹有一個缺點,不支持範圍搜索,或者做不到高效的範圍搜索,什麼是範圍搜索?簡單點說就是 sql 裏面 where 條件裏面 between 3 and 100,查詢一個區間的數據,數據庫裏面採用是B+樹索引,所以可以支持,但這不是今天討論的重點,除了B+樹索引外,那麼還有哪種數據結構可以實現有序,支持高效的增,刪,查,並支持區間搜索呢?

答案是肯定的,它就是我們要介紹的跳躍表跳躍表是一種數據結構,它允許快速查詢一個有序連續元素的數據鏈表。跳躍表這種結構在Lucene,Redis,Leveldb等框架中都有應用,比如redis裏面的zset結構,底層採用的就是跳躍表。


在這裏插入圖片描述
跳躍表的重要性質:

  • 一個跳錶應該由很多層結構組成
  • 每一層都是一個有序的鏈表
  • 最底層(Level 1)的鏈表包含所有元素
  • 如果一個元素出現在 Level i 的鏈表中,則它在 Level i 之下的鏈表也都會出現。
  • 每個節點包含兩個指針,一個指向同一鏈表中的下一個元素,一個指向下面一層的元素。(在實現中通常我們爲了一些操作的方便,給每個結點都提供了上下左右四個指針域)
  • Head指針指向最高層的第一個元素。

跳躍表結構分析

跳躍表的結構是多層的,通過從最高維度的表進行檢索再逐漸降低維度從而達到對任何元素的檢索接近線性時間的目的O(logn)O(logn)
在這裏插入圖片描述
如上圖:對節點8的檢索走紅色標記的路線,需要4步。對節點5的檢索走藍色路線,需要4步。由此可見,跳躍表本質上是一種網絡佈局結構,通過增加檢索的維度(層數)來減少鏈表檢索中需要經過的節點數

理想跳躍表應該具備如下特點:

  • 包含有N個元素節點的跳躍表擁有log2Nlog_2N
  • 上層鏈表包含的節點數恰好等於下層鏈表節點數的1/2

但如此嚴苛的要求在算法上過於複雜。因此通常的做法是:每次向跳躍表中增加一個節點就有50%的隨機概率向上層鏈表增加一個跳躍節點,並以此類推

接下來,我們做如下規範說明:

  • 跳躍表的層數,我們稱其維度。自頂向下,我們稱爲降維,反之亦然。
  • 表中,處於不同鏈表層的相同元素。我們稱爲“同位素”。
  • 最底層的鏈表,即包含了所有元素節點的鏈表是L1層,或稱基礎層。除此以外的所有鏈表層都稱爲跳躍層

跳躍表的結構定義

/**
 *  SkipList的性質
 *  (1) 由很多層結構組成,level是通過一定的概率隨機產生的,基本是50%的產生機率。
 *  (2) 每一層都是一個有序的鏈表,默認是升序。
 *  (3) 最底層(Level 1)的鏈表包含所有元素。
 *  (4) 如果一個元素出現在Level i 的鏈表中,則它在Level i 之下的鏈表也都會出現。
 *  (5) 每個節點包含四個指針,但有可能爲nullptr。
 *  (6) 每一層鏈表橫向爲單向連接,縱向爲雙向連接。
 */
template<typename T>
class SkipList
{
public:
	SkipList() : level(1)
	{
		Listhead = new Node();
		nodeSum = 1;
	}
	/* 跳躍表的表銷燬 */
	~SkipList();
	
	/* 插入元素 */
	void insert(const T& val)
	
	/* 刪除元素 */
	void remove(const T& val)
	
	/* 查詢元素 */
	bool search(const T& val)
	
	/* 產生隨機值 */
	bool randomVal();
	
	/* 層序遍歷跳躍表 */
	void print();
private:
	struct Node
	{
		Node(T data = T())
			:_data(data)
			, _up(nullptr)
			,_down(nullptr)
			, _left(nullptr)
			, _right(nullptr)
		{}
		T _data;
		Node* _up;
		Node* _down;
		Node* _left;
		Node* _right;
	};
	Node* Listhead; // 跳躍表頭節點
	int level; // 跳躍表的層數
	int nodeSum; // 跳躍表的結點個數
};

拋硬幣產生隨機值

插入元素的時候,元素所佔有的層數完全是隨機的,即該元素是否要從基礎層添加到跳躍層,我們通過隨機算法產生:

static unsigned int seed = NULL; // 隨機種子

template<typename T>
/* 丟硬幣產生隨機值 */
bool SkipList<T>::randomVal()
{
	if (seed == NULL)
		seed = (unsigned)time(NULL);

	srand(seed);
	int K= rand() % 2;  // 兩種情況,正反兩面
	seed = rand();
	if (K == 0)
		return true;
	else
		return false;
}

相當與做一次丟硬幣的實驗,如果遇到正面,繼續丟,遇到反面,則停止。

用實驗中丟硬幣的次數 K 作爲元素佔有的層數。顯然隨機變量 K 滿足參數爲 p = 1/2 的幾何分佈,

K 的期望值 E[K]=1/p=2E[K] = 1/p = 2,就是說,各個元素的層數,期望值是 2 層。


跳躍表的元素添加操作

向跳躍表中添加元素是跳躍表最爲複雜的操作,簡單分析如下:

  • 首先進入最底層Level 1,然後尋找合適位置插入(大於其值之前的位置)
  • 注意我們爲了在刪除元素時的操作方便,還需要記錄up指針和left指針
  • 搜索過程中需要判斷該元素是否已經存在,若存在則不進行插入
  • 根據拋硬幣產生的隨機值決定是否將該元素添加至跳躍層,爲了防止不斷進行升維,我們在這裏限制插入一個元素最多升維一次。
  • 升維後進行上下相同元素和左右元素的串聯
  • 另外我們還需要保證在在最高層有一個元素的情況下,不會再向上擴層升維
/* 插入元素 */
void insert(const T& val)
{
	Node* curNode = Listhead;
	/* 向下走到最底層Level 1 */
	while (curNode->_down != nullptr)
	{
		curNode = curNode->_down;
	}

	Node* curHead = curNode;
	Node* newNode = nullptr;
	/* 尋找合適位置進行插入 */
	while (curNode->_right != nullptr)
	{
		/* 待插入元素小於表中節點值,找到了合適位置 */
		if (val < curNode->_right->_data)
		{
			newNode = new Node(val);
			newNode->_right = curNode->_right;
			curNode->_right->_left = newNode;
			
			curNode->_right = newNode;
			newNode->_left = curNode; // 更新新節點的left指針

			break;
		}
		/* 待插入元素等於表中節點值,不進行插入 */
		else if (val == curNode->_right->_data)
		{
			return;
		}
		curNode = curNode->_right;
	}
	
	/* 遍歷到末節點也沒有合適位置,直接插入到鏈表末尾即可 */
	if (newNode == nullptr)
	{
		newNode = new Node(val);
		curNode->_right = newNode;
		newNode->_left = curNode;
	}
	
	/* 限制升維最高升一層,防止不斷升層,浪費空間 */
	int maxLevel = level + 1;
	
	int curLevel = 1;
	/* 根據拋硬幣產生的隨機值決定是否將該元素添加至跳躍層 */
	while (randomVal())
	{
		/* 跳躍表層數不能超過最大限制,防止不斷升層 */
		if (level > maxLevel)
		{
			return;
		}

		curLevel++;
		
		/* 是否要進行升維,此操作每添加一個元素最多執行一次 */
		if (level < curLevel)
		{
			level++; // 跳躍表的層數增加
			Node* newHead = new Node();
			newHead->_down = Listhead;
			Listhead->_up = newHead; // 更新up結點
			Listhead = newHead; // 使Listhead指向當前新結點
		}
		
		// curHead升一層(要麼進行了升維,要麼本來層數就大於1)
		curHead = curHead->_up; 
		curNode = curHead;
		
		/* 將新插入的結點繼續添加到跳躍層中,搜尋算法與Level 1層相同 */
		Node* skipNode = nullptr;
		while (curNode->_right != nullptr)
		{
			if (val < curNode->_right->_data && 
				skipNode == nullptr)
			{
				skipNode = new Node(val);
				skipNode->_right = curNode->_right;
				curNode->_right->_left = skipNode;

				curNode->_right = skipNode;
				skipNode->_left = curNode; // 更新left指針域
			}
			curNode = curNode->_right;
		}
		
		/* 該跳躍層沒有結點,即上述升維後的結果。將新結點添加到新產生的跳躍層 */
		if (skipNode == nullptr)
		{
			skipNode = new Node(val);
			curNode->_right = skipNode;
			skipNode->_left = curNode; // 更新left指針域
		}
		
		/**
		 * 將基礎層和跳躍層 或 跳躍層與跳躍層 中新插入的結點串聯起來
		 * newNode初始是Level 1層中新插入的結點
		 * 我們需要使newNode走向其值存在的最高層(垂直關係)
		 */
		while (newNode->_up != nullptr)
		{
			newNode = newNode->_up;
		}
		skipNode->_down = newNode; // 更新跳躍結點的down指針域
		newNode->_up = skipNode; // 更新newNode的up指針域

		/* 保證在一層只有一個元素的情況下,不會再向上擴層 */
		if (curHead->_right == skipNode)
		{
			return;
		}
	}
}

跳躍表的查詢操作

由於跳躍表的刪除操作需要使用到查詢操作的思想,跳躍表的優點就是查詢效率高,是線性時間的複雜度,我們實現查詢操作要充分考慮跳躍表的性質,即每層是有序的遞增的。接下來看一下查詢操作的實現:

  • 首先我們從最高層開始向右遍歷,若查詢元素小於遍歷到的結點值,繼續向右遍歷,直到找到該元素或結點值大於查詢元素值
  • 若查詢元素大於本層遍歷到的結點值,那麼我們需要進行降維,即向下走一層,因爲本層不可能出現該元素
  • 當把最後一層都遍歷完畢也沒有找到該元素時,在繼續降維時發現已到最底層,因此查詢失敗
/* 查詢元素 */
bool search(const T& val)
{
	/* 從首層結點開始 */
	Node* preNode = Listhead;
	Node* curNode = preNode->_right;

	while (true)
	{
		if (curNode != nullptr)
		{
			/* 要查詢的val大,在本層向右遍歷 */
			if (curNode->_data < val)
			{
				preNode = curNode;
				curNode = curNode->_right;
				continue;
			}
			/* 找到要查詢的元素 */
			else if (val == curNode->_data)
			{
				return true;
			}
		}
		/* 判斷是否將所有層都遍歷完 */
		if (preNode->_down == nullptr)
		{
			return false;
		}
		
		/* 繼續向下層遍歷 */
		preNode = preNode->_down;
		curNode = preNode->_right;
	}
}

跳躍表的元素刪除操作

跳躍表的刪除操作較增加操作簡單一些,基本流程如下:

  • 首先從最高層開始進行元素查找,查找的思路與查詢操作完全相同
  • 若沒有查詢到待刪除元素值,直接返回即可
  • 若查詢到了待刪除元素,我們從該元素所在的最高層開始進行元素刪除,每層的元素刪除和單鏈表的元素刪除一樣,因爲我們爲每個結點都保存了left指針,因此刪除非常方便,將待刪除結點的左右結點串聯起來即可
  • 繼續降維向下層遍歷,這是一種自上而下的垂直刪除操作的過程,直到將基礎層(最底層)的該元素刪除
  • 還需要注意的是,當我們在本層刪除了該元素後,還需要判斷本層是否還有其他元素,若沒有其他元素了,那麼我們需要將該層銷燬(釋放Head節點內存),並將跳躍表層數level減一。
/* 刪除元素 */
void remove(const T& val)
{
	/* 首先查詢待刪除元素是否在跳躍表中,查詢方式和上述查詢元素方法相同 */
	Node* preNode = Listhead;
	Node* curNode = preNode->_right;

	while (true)
	{
		if (curNode != nullptr)
		{
			/* 待刪除的val值大,在本層向右遍歷 */
			if (curNode->_data < val)
			{
				preNode = curNode;
				curNode = curNode->_right;
				continue;
			}
			/* 找到要刪除的元素位置,跳出循環進行後續操作 */
			else if (curNode->_data == val)
			{
				break;
			}
		}
		/* 判斷是否將所有層都遍歷完 */
		if (preNode->_down == nullptr)
		{
			break;
		}
		/* 繼續向下層遍歷 */
		preNode = preNode->_down;
		curNode = preNode->_right;
	}
	
	/* 判斷是否查詢到了待刪除的元素 */
	if (curNode == nullptr)
	{
		return;
	}
	
	/* 從找到的位置開始垂直刪除元素 */
	while (curNode != nullptr)
	{
		Node* delNode = curNode;
		/* 找到待刪除元素的前驅結點 */
		preNode = curNode->_left;
		/* 將待刪除元素的左右結點串聯起來 */
		preNode->_right = curNode->_right;
		if (curNode->_right != nullptr)
		{
			/* 更新左指針域 */
			curNode->_right->_left = preNode;
		}
		/* 垂直向下層進行刪除 */
		curNode = curNode->_down;

		delete delNode;
		delNode = nullptr;
	}
	
	/** 
	 * 若將該結點刪除後,本層已經沒有元素,那麼需要更新層數,並將該層釋放
	 * 我們採取自上而下的方式進行逐層判斷
	 */
	Node* head = Listhead;
	while (head->_right == nullptr && head->_down != nullptr)
	{
		level--; /* 更新跳躍表的層數 */
		Node* delNode = head;
		head = head->_down;
		
		delete delNode;
		delNode = nullptr;
	}
	Listhead = head; /* 更新跳躍表頭節點ListHead */
}

跳躍表的表釋放

對於跳躍表的釋放我們在析構函數中完成,具體的釋放操作與單鏈表的釋放是類似的,我們只需要自頂而下的對每層的鏈表進行釋放即可完成整個跳躍表的釋放。

~SkipList()
{
	Node* head = Listhead;
	Node* delNode = nullptr;
	while (head != nullptr)
	{
		/* 釋放本層元素 */
		Node* curNode = head->_right;
		while (curNode != nullptr)
		{
			delNode = curNode;
			curNode = delNode->_right;
			delete delNode;
			delNode = nullptr;
		}
		
		/* 釋放頭節點,並使head指針指向下一層,準備釋放下一層的元素 */
		delNode = head;
		head = head->_down;
		delete delNode;
	}
}

跳躍表的層序遍歷

按層打印跳躍表我們可以將問題分解,每層按照遍歷單鏈表的方式進行打印,然後跳到下一層進行打印,直到打印完所有的層即可:

/* 按層打印跳躍表 */
void print()
{
	cout << "level = " << level << endl;
	Node* head = Listhead;
	while (head != nullptr)
	{
		Node* curNode = head->_right;
		while (curNode != nullptr)
		{
			cout << curNode->_data << " ";
			curNode = curNode->_right;
		}
		cout << endl;
		/* 跳到下一層 */
		head = head->_down;
	}
}

上述對於跳躍表的操作講解是我的一點理解和提煉,因此講解的較爲簡單,大家也可以參考這篇博文《跳躍表原理》,有具體的圖示,便於理解。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章