データ構造 --- ジャンプリスト

なぜジャンプ台があるのですか?

前回の学習では、リンクリストコンテナを学習しました。このコンテナの先頭と末尾にデータを挿入する時間計算量はO(1)ですが、このコンテナには欠陥があり、それを見つけるのに時間がかかります。データの順序は関係なく、データが存在するかどうかを判断する 複雑さはO(N) 激しいループでしかデータの存在の有無を知ることができない データが順序付けられていても、二分探索ではデータを見つけることができない. そこで、この問題を解決するために、誰かが提案したのが、ジャンプリストコンテナです。

スキップテーブルの原理

以前に学習したリンク リストの構造は次のとおりです:
ここに画像の説明を挿入します
次に、リストをジャンプするというアイデアは、2 つの隣接するノードごとに 1 レベルずつ上げられ、ポインターが次のノードを指すようにポインターが追加されます。このように、
ここに画像の説明を挿入します
新しく追加されたすべてのポインターは接続されて新しいリンク リストを形成しますが、そこには元のノード数の半分しか含まれていません。新たに追加されたポインタにより、リンク リスト内の各ノードを 1 つずつ比較する必要がなくなり、比較する必要があるノードの数は元の数の約半分だけになります。たとえば、要素 19 を見つけたい場合、まずヘッド ノードの先頭ポインタが指すノード (ノード内の要素は 6) が 19 より大きいかどうかを比較し、大きい場合はこのノードにジャンプします。 、その後、それが 19 より大きいことは明らかなので、ノード 6 に移動し、要素 6 の先頭ポインタが指すノード (ノード 9) が 19 より大きいかどうかを比較します。要素 9 にジャンプし、次にノード 17 に移動します。ノード 17 が最上位であるため、ノードが指す要素は 21 よりも 19 より大きいため、ポインタが指すノードにジャンプすることはできません。ノードを比較する必要があります。ノードの指すノードへのポインタ (ノード 17 の下位ポインタ) が指す値はちょうど 19 なので、指定された要素が見つかります。これがスキップ リスト内の要素を見つける原理です。
ここに画像の説明を挿入します

ただし、上記の検索プロセスでは効率はあまり向上しませんでした。最良のケースは O(N/2) であるため、さらに効率を向上させるために、新しく生成されたリンク リスト上の各 2 つの隣接ノードの検索を続けます。 1 レベル上げて、第 3 レベルのリンク リストを生成するポインタを追加すると、
ここに画像の説明を挿入します
この時点で要素 19 の検索が高速になり、比較後、ノード 9 に到達し、その後ノード 17 にジャンプします。ノード 19 に到達します:
ここに画像の説明を挿入します
ノードの層をさらに追加すると、ノード検索の効率が高くなることがわかります。同様に、リンク リスト内のデータ数が十分に長い限り、次のノードを追加できます。ノードを追加できなくなるまで複数のレイヤーなどを繰り返します。この時点で、最初の比較で要素の半分がフィルタリングされ、別の比較で要素の 1/4 がフィルタリングされ、要素の 1/8 がフィルタリングされます。別の比較の後にフィルタリングすることができます。などなど。この時点で要素を見つける時間計算量は O(logN) であることがわかりますが、ここに問題があります。このような構造は logN の効率をもたらすことができますが、を使用すると、効率がすっきりした構造に置き換わります。このノードのレイヤー番号は何ですか? 1 から 10 までは問題ありませんが、挿入後、リンク リストは以前の規則を維持できません (各隣接ノードが 1 レベルずつ上がります)。この対応を維持するには、新しく挿入されたノードの後ろにあるすべてのノード (新規を含む) が必要です。挿入されたノード) が再調整され、時間計算量が O(n) に戻ります。このような問題を回避するために、スキップリストの設計は、厳密に比例関係を要求せず、ノード挿入時にランダムに層番号を生成するという大胆な工夫が施されています。このようにすると、挿入と削除のたびに他のノードの階層数を考慮する必要がなくなり、処理が大幅に容易になります。
ここに画像の説明を挿入します
そこで問題となるのが、層数をランダムに割り当てた場合、効率をどう確保するかということです。非常に多くの乱数があります: 1 は乱数であり、1w も乱数であり、非常に大きな乱数が複数存在する可能性があります。たとえば、現在 100 個のノードがありますが、99 個のノードの高さは 100 ですが、多くの高レベルのものが必要です。ノード? 理論的には、より高いレベルのノードが出現する確率は小さいはずなので、スキップ テーブルのスペース効率と時間効率を向上させるために、スキップ テーブルの最大レベル制限を設計します。 maxLevel を設定し、もう 1 つのレイヤーを追加する確率 P を設定します。これは、各ノードの高さが少なくとも 1 であることを意味します。各上位レベルの確率 p、ノードの高さが 1 である確率は次のようになります。 -P (層の高さはありません。増加する確率は 1-p) なので、高さが 1 になる確率は 1-P です。同様に、高さが 1 層増加する確率は p です。増加しない確率は 1-p なので、高さが 2 である確率は p* (1-p)、ノード数が 3 である確率は p*p*(1-p) となります。ノード番号が大きくなるほど、発生確率が小さくなるのは難しくありません。これがスキップ リストの原理です。 次に、スキップ
ここに画像の説明を挿入します
テーブルの実装方法を見てみましょう。

スキップテーブルのシミュレーション実装

準備

まず、データを保存するために各ノードに変数があり、次に次のノードの位置を記録するために大量のポインターが必要です。ポインターの数は不明であり、複数あるため、ベクトル オブジェクトを作成できます。ポインターを格納するには、ここでポインターを記述するクラスを作成できます。このクラスのコンストラクターには 2 つのパラメーターが必要です。1 つのパラメーターはノードのレイヤー数を表し、もう 1 つのパラメーターはノードに格納されているデータを表します。その後、配列がコンストラクターで初期化され、各ポインターが空に初期化される場合、ここでのコードは次のようになります。

template<class T>
struct ListNode
{
    
    
	typedef ListNode<T> Node;
	ListNode(T val,int size)
		:_nextV(size,nullptr)
		,_val(val)
	{
    
    }
	T _val;
	vector<Node*> _nextV;
};

次に、ジャンプ テーブル クラスにポインター変数を格納してヘッド ノードを記録し、別の変数を作成して現在のノードの最大レイヤー数とレイヤー数が増加する確率を記録します。コンストラクターで、このポインターをポイントします。新しく作成されたノード。ノードの高さは 1 で、格納される値は要素のデフォルトの構造です。次のコードでは乱数を使用するため、コンストラクターにタイムスタンプを追加する必要があります。その場合のコードは次のとおりです。次のように:

template<class T>
class Skiplist
{
    
    
public:
	typedef ListNode<T> Node;
	Skiplist()
	{
    
    
		srand(time(0));
		_head = new Node(T(), 1);
	}

private:
	Node* _head;
	int _maxLevel = 32;
	double _p = 0.5;
};

検索関数

ここではこの要素を見つけるだけでなく、直線要素の他のノードも見つける必要があるため、find 関数がこのクラスのキーとなります。たとえば、要素 21 を見つけたいとします。ノード 21 の前には多くのノードがあります
ここに画像の説明を挿入します
。ノード 19 の 3 つのノードはすべてノード 21 を指しているため、find 関数は 21 が存在するかどうかを判断するだけでなく、ノード 21 を指すノードも返す必要があります。その理由は次のとおりです。後の挿入機能と消去機能を容易にしますが、どのノードが 21 を指しているかはわかっていますが、これらのノードのどれが 21 を指しているでしょうか?という質問があります。したがって、このとき、配列内で次の表を組み合わせる必要がありますが、高さポインタには特定のノードを指すポインタが 1 つしかないため、配列の次の表を使用して補助的な判断を行うことができます。配列内の次のテーブルが 1 に記録されているノードがノード 9 である場合、ノード 9 の中段と下段のテーブルの 1 番のポインタがそのノードを指していることを意味し、find 関数はクエリを実行するだけでなく、ノードが存在するかどうかだけでなく、どの位置がノードを指しているのかも調べます。これは後で便利です。挿入関数と削除関数の場合、最初にこの関数には T 型パラメータが必要で、その後、戻り値はベクトル型とベクトル内の要素型になります。ノード*です

vector<Node*> find(const T& target)
{
    
    }

次に、関数内でベクトルを作成し、そのサイズをヘッド ノードの長さに初期化してから、ヘッド ノードに配列の数を格納する変数レベルを作成します。これは、変数内の値を常に比較する必要があるためです。したがって、現在探しているノードを指すノード タイプ ポインターも作成する必要があります。その後、while ループを作成できます。ループの目的は、ターゲット ノードを指すすべてのノードを記録することであるため、条件ループの終わりがレベルが 0 以上である場合、ここでのコードは次のようになります。

vector<Node*> find(const T& target)
{
    
    
	int level = _head->_nextV.size()-1;//这里是下表所以要减一
	vector<Node*> tmp(level+1,_head);//这里要加一,并且每个元素都指向头结点
	Node* cur = _head;
	while (level >= 0)
	{
    
    
	}
}

ループの中で、現在 cur が指しているノードのレベルポインタが指している要素がターゲットより小さいかどうかを判定し、小さい場合には、レベルポインタが指している要素を cur で指します。目標より大きい場合、または現在のポインタが指す要素が空の場合は、挿入位置を指す前のノード、または削除する要素が見つかったことを意味します。このとき、ノードのアドレスを記入します。配列内のレベル テーブルに値を入力し、レベルの値を減らすと、ここでのクォータ コードは次のようになります。

vector<Node*> find(const T& target)
{
    
    
	int level = _head->_nextV.size()-1;//这里是下表所以要减一
	vector<Node*> tmp(level+1,_head);//这里要加一,并且每个元素都指向头结点
	Node* cur = _head;
	while (level >= 0)
	{
    
    
		// 目标值比下一个节点值要大,向右走
		// 下一个节点是空(尾),目标值比下一个节点值要小,向下走
		if (cur->_nextV[level] != nullptr && cur->_nextV[level]->_val < target)
		{
    
    
			// 向右走
			cur = cur->_nextV[level];
		}
		else if (cur->_nextV[level] == nullptr || cur->_nextV[level]->_val >= target)
		{
    
    
			// 更新level层前一个
			tmp[level] = cur;
			// 向下走
			level--;
		}
	}
	return tmp;
}

もちろん、このような検索関数はまだ使用するのが難しすぎるため、ここでも考え方は同じで、コードを直接見ていきます。

bool search(T target) 
{
    
    
	Node* cur = _head;
	int level = _head->_nextV.size() - 1;
	while (level >= 0)
	{
    
    
		// 目标值比下一个节点值要大,向右走
		// 下一个节点是空(尾),目标值比下一个节点值要小,向下走
		if (cur->_nextV[level] && cur->_nextV[level]->_val < target)
		{
    
    
			// 向右走
			cur = cur->_nextV[level];
		}
		else if (cur->_nextV[level] == nullptr || cur->_nextV[level]->_val > target)
		{
    
    
			// 向下走
			--level;
		}
		else
		{
    
    
			return true;
		}
	}
	return false;
}

挿入関数

値が重複すると挿入に失敗する場合があるため、挿入関数の戻り値はbool、関数の引数はT型となります。

bool insert(T num)
{
    
    
}

次に、関数の先頭で、find 関数の戻り値を受け取る配列を作成する必要があります。次に、乱数を生成して数値の高さを記録する必要があるため、ここで次の関数を作成する必要があります。木の高さをランダムに作成します。ここでのコードは次のとおりです。

int RandomLevel()
{
    
    
	size_t level = 1;
	// rand() ->[0, RAND_MAX]之间
	while (rand() <= RAND_MAX*_p && level < _maxLevel)
	{
    
    
		++level;
	}
	return level;
}

rand 関数によって生成されるデータ サイズには範囲があることがわかっているので、この範囲を使用してランダムな高さを生成できます。この関数を使用すると、高さを取得し、新しいノードを作成してノードの高さを次のように設定できます。関数の戻り値.value の場合、コードは次のようになります。

bool insert(T num)
{
    
    
	vector<Node*> prevV = FindPrevNode(num);
	int height = RandomLevel();
	Node* newnode = new Node(num, height);
}

ノードを挿入した後、ノードの高さによってリンク リスト全体の高さが更新される可能性があるため、新しく作成されたノードの高さがヘッド ノードの高さより大きいかどうかを判断する必要があり、ヘッド ノードと上記の prevV を拡張する必要がある場合は、ここに if ステートメントを追加する必要があります。

bool insert(T num)
{
    
    
	vector<Node*> prevV = FindPrevNode(num);
	int height = RandomLevel();
	Node* newnode = new Node(num, height);
	if (height > _head->_nextV.size())
	{
    
    
		_head->_nextV.resize(height, nullptr);
		prevV.resize(height, _head);
	}
}

次に、prevV 配列内のノードの高さ要素のポインタを変更して、新しく挿入されたノードが以前にポイントしていたノードを指すようにし、その後、これらのノードが新しく挿入されたノードを指すようにする必要があります。 , 要素 15 が挿入されますここに画像の説明を挿入します
。すると、次のようになります:
ここに画像の説明を挿入します
配列 prevV は、この位置を指すすべてのノードを記録するだけです。その後、while ループを作成して変更を加えることができ、完全なコードは次のようになります。

bool insert(T num)
{
    
    
	vector<Node*> prevV = find(num);
	int height = RandomLevel();
	Node* newnode = new Node(num, height);
	if (height > _head->_nextV.size())
	{
    
    
		_head->_nextV.resize(height, nullptr);
		prevV.resize(height, _head);
	}
	for (int i = 0; i < height; i++)
	{
    
    
		newnode->_nextV[i] = prevV[i]->_nextV[i];
		prevV[i]->_nextV[i] = newnode;
	}
}

消去機能

Erase関数の実装考え方も同様で、まず削除したい要素が現在存在するかどうかを判断する必要がある、存在しない場合は直接falseを返す、存在する場合はまず配列を作成して記録するfind関数でノードを指すノードを指定してから、insert関数を実行し、同様にノード内のポインタの指す位置を変更し、ノードを指すポインタを次のノードに指し、次に挿入関数を実行します。ここでのコードは次のとおりです。

bool erase(T num)
{
    
    
	vector<Node*> prevV = FindPrevNode(num);
	// 第一层下一个不是val,val不在表中
	if (prevV[0]->_nextV[0] == nullptr || prevV[0]->_nextV[0]->_val != num)
	{
    
    
		return false;
	}
	else
	{
    
    
		Node* del = prevV[0]->_nextV[0];
		// del节点每一层的前后指针链接起来
		for (size_t i = 0; i < del->_nextV.size(); i++)
		{
    
    
			prevV[i]->_nextV[i] = del->_nextV[i];
		}
		delete del;
	}
}

しかしここで問題があり、最上位ノードを削除した場合、先頭ノードも変更する必要があるのでしょうか?したがって、ここではヘッド ノード内の null 以外のポインターの数を確認し、その長さを減らす必要があります。完全なコードは次のようになります。

bool erase(T num)
{
    
    
	vector<Node*> prevV = FindPrevNode(num);
	// 第一层下一个不是val,val不在表中
	if (prevV[0]->_nextV[0] == nullptr || prevV[0]->_nextV[0]->_val != num)
	{
    
    
		return false;
	}
	else
	{
    
    
		Node* del = prevV[0]->_nextV[0];
		// del节点每一层的前后指针链接起来
		for (size_t i = 0; i < del->_nextV.size(); i++)
		{
    
    
			prevV[i]->_nextV[i] = del->_nextV[i];
		}
		delete del;		
	}
	int i = _head->_nextV.size() - 1;
	while (i >= 0)
	{
    
    
		if (_head->_nextV[i] == nullptr)
			--i;
		else
			break;
	}
	_head->_nextV.resize(i + 1);
	return true;
}

テスト

テストを容易にするために、印刷関数を追加できます。

void Print()
{
    
    
	Node* cur = _head;
	while (cur)
	{
    
    
		printf("%2d\n", cur->_val);
		// 打印每个每个cur节点
		for (auto e : cur->_nextV)
		{
    
    
			printf("%2s", "↓");
		}
		printf("\n");
		cur = cur->_nextV[0];
	}
}

次に、次のコードを使用してテストします。

int main()
{
    
    
	Skiplist<int> tmp;
	tmp.insert(11);
	tmp.insert(5);
	tmp.insert(6);
	tmp.insert(12);
	tmp.insert(2);
	tmp.insert(3);
	tmp.insert(7);
	tmp.insert(17);
	tmp.insert(19);
	cout << tmp.search(11) << endl;
	cout << tmp.search(20) << endl;
	tmp.erase(11);
	cout << tmp.search(11) << endl;
	cout << endl;
	tmp.Print();
	return 0;
}

コードの実行結果は次のとおりです。
ここに画像の説明を挿入します
コードの実行結果が期待どおりであることがわかります。

効率比較

  1. バランス検索ツリー (AVL ツリーおよび赤黒ツリー) と比較して、スキップリストはデータを順序立てて走査でき、時間計算量は同様です。スキップリストの利点は次のとおりです: a. スキップリストは実装が簡単で、制御も簡単です。バランスの取れたツリーでの追加、削除、チェック、および変更のトラバースはより複雑です。b. スキップリストの余分なスペース消費が少なくなります。バランスのとれたツリー ノードは、3 本のチェーン、バランス係数/色などの消費を使用して各値を保存します。スキップリストのp=1/2の場合、各ノードに含まれるポインタの平均数は2であり、スキップリストのp=1/4の場合、各ノードに含まれるポインタの平均数は1.33である。
  2. Skiplist には、ハッシュ テーブルと比較してそれほど大きな利点はありません。比較すると、ハッシュ テーブルの平均時間計算量は O(1) であり、スキップリストよりも高速です。b. ハッシュ テーブルのスペース消費量がわずかに多くなります。スキップリストの利点は次のとおりです: a. トラバーサル データが整然としている; b. スキップリストのスペース消費がわずかに小さく、ハッシュ テーブルにはリンク ポインターとテーブル スペースの消費があります。c. ハッシュ テーブルの拡張ではパフォーマンスが低下します。d. 極端なシナリオでは、ハッシュ テーブルでハッシュの競合が多くなり、効率が急激に低下するため、リレーを補うために赤黒ツリーが必要になります。

おすすめ

転載: blog.csdn.net/qq_68695298/article/details/131965609