記事ディレクトリ
1.スキップテーブルとは
Skiplist は本質的に、アルゴリズム内の検索問題を解決するために使用される検索構造であり、バランス検索ツリーおよびハッシュ テーブルと同じ値を持ち、キーまたはキー/値検索モデルとして使用できます
。では、比較してどのような利点があるのでしょうか?
そのため、詳細な実装を学習し終わったら、もう一度比較してみましょう。
skiplist
William Pugh
1990 年に出版された彼の論文「スキップ リスト: バランス ツリーに対する確率論的代替案」によって発明され、初めて登場しました。
skiplist
, その名の通り、まずはリストです。実際、それは順序付きリンクリストに基づいて開発されています。順序付きリンク リストの場合
、データ検索の時間計算量は ですO(N)
。
William Pugh によって始められた最適化のアイデア:
- 隣接する 2 つのノードごとに 1 つのレイヤーを作成する場合は、次の図 b に示すように、ポインターを追加してポインターが次のノードを指すようにします。このようにして、新しく追加されたすべてのポインターは新しいリンク リストに接続されますが、それに含まれるノードの数は元のリストの半分だけになります。新しく追加されたポインタにより、リンク リスト内の各ノードを 1 つずつ比較する必要がなくなり、比較する必要があるノードの数は元の約半分だけになります。
- 同様に、第 2 層の新しく生成されたリンク リスト上の 2 つの隣接するノードごとに 1 層を増やし続け、ポインタを追加してリンク リストの第 3 層を生成できます。以下の図 c に示すように、検索効率がさらに向上します。
- Skiplist は、この多層リンク リストのアイデアからインスピレーションを受けました。実際、上記のリンク リストを生成する方法によれば、上のリンク リストの各層のノードの数は、下位層のノードの数の半分であるため、検索プロセスは二分探索と非常によく似ています。検索の時間計算量は O (log n) に削減できます。しかし、この構造にはデータの挿入や削除の際に大きな問題があり、ノードの挿入や削除を行うと、上下に隣接する2層の連結リスト上のノード数の厳密な2:1の対応が崩れてしまいます。この対応を維持したい場合は、新しく挿入されたノードの背後にあるすべてのノード (新しく挿入されたノードを含む) を再調整する必要があります。これにより、時間計算量が O(n) に軽減されます。
検索処理: 検索対象の値がキーの場合、次のノードの値がキーより大きいかどうかを確認し、キーより大きい場合は下に進みます。キーより小さい場合は右に進みます。見つからない場合はレベル -1 になります。
このような問題を回避するために、スキップリストの設計は、厳密に比例関係を要求するのではなく、ノード挿入時にランダムに層番号を生成するという大胆な工夫が施されています。この方法では、挿入と削除のたびに他のノードのレイヤーの数を考慮する必要がなく、処理がはるかに簡単になります。詳細なプロセスを次の図に示します。
2. ジャンプ台の効率を確保するにはどうすればよいですか?
上で述べたように、スキップリストがノードを挿入すると、ランダムな数のレイヤーが生成されますが、なぜそんなにランダムに聞こえるのでしょうか? 検索の効率を確保するにはどうすればよいでしょうか?
ここで最初に詳細に分析する必要があるのは、ランダム レイヤーの数がどのようにして得られるのかということです。一般に、ジャンプ テーブルはレイヤの最大数 maxLevel に制限を設計し、追加のレイヤを追加する確率 p を設定します。次に、ランダム層の数を計算するための疑似コードは次のようになります。
Redis のスキップリスト実装では、これら 2 つのパラメーターの値は
p = 1/4
次のとおりですmaxLevel = 32
。注: Google のオープンソース プロジェクト LevelDB (小規模な KV タイプのデータベース) もスキップリストを使用しています。興味のある方は調べてみてください。
前述のrandomLevel()の擬似コードによれば、ノード層の数が増えるほど確率が低くなることが簡単にわかります。定量分析は
次のとおりです。
- ノード層の数は少なくとも 1 です。また、ノード層の数が 1 より大きい場合、確率分布が満たされます。
- ノード層の数が正確に 1 に等しい確率は 1-p です。
- ノード層の数が 2 以上である確率は p で、ノード層の数が正確に 2 に等しい確率は p(1-p) です。
- ノード層の数が 3 以上である確率は p 2で、ノード層の数が正確に 3 に等しい確率は p 2 *(1-p) です。
- ノード層の数が 4 以上である確率は p 3で、ノード層の数が 4 に正確に等しい確率は p 3 *(1-p)です。
- …
したがって、ノードの平均レイヤー数 (およびポインターの平均数) は次のように計算されます。
これで、簡単に計算できるようになりました。
- p=1/2 の場合、各ノードに含まれるポインターの平均数は 2 です。
- p=1/4の場合、各ノードに含まれるポインタの平均数は1.33である。
ジャンプリストの平均時間計算量は O(logN) ですが、その導出過程は比較的複雑なのでここで触れます。
3. スキップリストの実装
ノード設計
struct SkiplistNode
{
int _val;
vector<SkiplistNode*> _nextV;
SkiplistNode(int val, int level)
:_val(val)
, _nextV(level, nullptr)
{
}
};
各ノードの層の数はランダムであるため、新しいノードを申請するには、層の数と格納されている値を知る必要があります。層の数はベクトルの添え字で表すことができます。
全体的なデザイン
class Skiplist {
typedef SkiplistNode Node;
public:
Skiplist() {
srand(time(0));
// 头节点,层数是1
_head = new SkiplistNode(-1, 1);
}
private:
Node* _head; // 哨兵位头节点
size_t _maxLevel = 32; // 最高的层数
double _p = 0.25; // 增加一层的概率
};
ジャンプ テーブルでセンチネル ヘッド ノードの層数が最も多くなります。最初はセンチネルの層数は 1 です。層を追加する確率は 0.25 です。理論的には、確率が大きいほど効率が高くなります。 。
ノードのランダムな層数
この乱数のレイヤーを計算するための擬似コードは次のとおりです。
C言語は乱数を生成します
int RandomLevel()
{
size_t level = 1;
// rand() ->[0, RAND_MAX]之间
// rand() 《= RAND_MAX*_p 可以保证增加一层的概率是_p
// level <= maxLevel 保证随机层数不超过最高层数maxLevel
while (rand() <= RAND_MAX*_p && level < _maxLevel)
{
++level;
}
return level;
}
C++ は乱数を生成します
int RandomLevel()
{
static std::default_random_engine generator(std::chrono::system_clock::now().time_since_epoch().count());
static std::uniform_real_distribution<double> distribution(0.0, 1.0);
size_t level = 1;
while (distribution(generator) <= _p && level < _maxLevel)
{
++level;
}
return level;
}
ここで注意が必要なのは、C言語で生成される乱数の範囲は0~32767であるため、範囲が比較的狭いことです。ただし、いくつかの数値を加算または減算することで、乱数の範囲を拡張できます。
スキップリストの検索
検索プロセス: 検索は現在のノードの値ではなく、次のノードの値と比較されます。最初は cur がセンチネル ノードの先頭にあり、比較が開始されます。検索する値が target の場合、次のノードが空であるか、次のノードの値が target より大きい場合、cur は次の層に移動する必要があります。次のノードの値が target より小さい場合、cur は次の層に移動する必要があります。 curは右に行きます。見つかるか見つからなくなるまで、上記のプロセスを繰り返します (見つからない場合、cur はレイヤ -1 に移動します。注意: レイヤの数はレイヤ 0 から始まります)。
bool search(int 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;
}
スキップリストの挿入
値を挿入する場合でも、値を削除する場合でも、ポインタの指示関係を変更するには、値の前のノードを見つける必要があります。以前のポインターを保存する操作を関数にカプセル化して、それを挿入インターフェイスと削除インターフェイスに提供できます。
vector<Node*> FindPrevNode(int num)
{
Node* cur = _head;
int level = _head->_nextV.size() - 1;
// 插入位置每一层前一个节点指针
vector<Node*> prevV(level + 1, _head);
while (level >= 0)
{
// 目标值比下一个节点值要大,向右走
// 下一个节点是空(尾),目标值比下一个节点值要小,向下走
if (cur->_nextV[level] && cur->_nextV[level]->_val < num)
{
// 向右走
cur = cur->_nextV[level];
}
else if (cur->_nextV[level] == nullptr
|| cur->_nextV[level]->_val >= num)
{
// 更新level层前一个
prevV[level] = cur;
// 向下走
--level;
}
}
return prevV;
}
void add(int num) {
vector<Node*> prevV = FindPrevNode(num);
int n = RandomLevel();
Node* newnode = new Node(num, n);
// 如果n超过当前最大的层数,那就升高一下_head的层数
if (n > _head->_nextV.size())
{
_head->_nextV.resize(n, nullptr);
prevV.resize(n, _head);
}
// 链接前后节点
for (size_t i = 0; i < n; ++i)
{
newnode->_nextV[i] = prevV[i]->_nextV[i];
prevV[i]->_nextV[i] = newnode;
}
}
ノードの削除
bool erase(int 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;
}
注: 削除されたノードのレイヤー番号が最も大きい場合、センチネル ヘッド ノードのレイヤー番号を減らすことができます。削除されたノード層の数が最も多いかどうかを判断するにはどうすればよいですか? センチネルヘッドノードの最上位層から見て、この層のポインタがnullを指していれば、削除対象ノードの層数が最も多いことを意味する。現在のレイヤー ポインターが NULL を指している場合、次のレイヤー ポインターが NULL を指しているかどうかを確認する必要があります。同様に、ポインターが何も指さなくなるまで、最上位のノードを削除した後に残ったノードの最高レベルを計算できます。
スキップリストを印刷する
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];
}
}
ジャンプ テーブル関数を出力すると、ジャンプ テーブルがどのようなものかをよく観察できるようになります。
完全なコード
#include <iostream>
#include <vector>
#include <time.h>
#include <random>
#include <chrono>
using namespace std;
struct SkiplistNode
{
int _val;
vector<SkiplistNode*> _nextV;
SkiplistNode(int val, int level)
:_val(val)
, _nextV(level, nullptr)
{
}
};
class Skiplist {
typedef SkiplistNode Node;
public:
Skiplist() {
srand(time(0));
// 头节点,层数是1
_head = new SkiplistNode(-1, 1);
}
bool search(int 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;
}
vector<Node*> FindPrevNode(int num)
{
Node* cur = _head;
int level = _head->_nextV.size() - 1;
// 插入位置每一层前一个节点指针
vector<Node*> prevV(level + 1, _head);
while (level >= 0)
{
// 目标值比下一个节点值要大,向右走
// 下一个节点是空(尾),目标值比下一个节点值要小,向下走
if (cur->_nextV[level] && cur->_nextV[level]->_val < num)
{
// 向右走
cur = cur->_nextV[level];
}
else if (cur->_nextV[level] == nullptr
|| cur->_nextV[level]->_val >= num)
{
// 更新level层前一个
prevV[level] = cur;
// 向下走
--level;
}
}
return prevV;
}
void add(int num) {
vector<Node*> prevV = FindPrevNode(num);
int n = RandomLevel();
Node* newnode = new Node(num, n);
// 如果n超过当前最大的层数,那就升高一下_head的层数
if (n > _head->_nextV.size())
{
_head->_nextV.resize(n, nullptr);
prevV.resize(n, _head);
}
// 链接前后节点
for (size_t i = 0; i < n; ++i)
{
newnode->_nextV[i] = prevV[i]->_nextV[i];
prevV[i]->_nextV[i] = newnode;
}
}
bool erase(int 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;
}
}
//int RandomLevel()
//{
// size_t level = 1;
// // rand() ->[0, RAND_MAX]之间
// while (rand() <= RAND_MAX*_p && level < _maxLevel)
// {
// ++level;
// }
// return level;
//}
int RandomLevel()
{
static std::default_random_engine generator(std::chrono::system_clock::now().time_since_epoch().count());
static std::uniform_real_distribution<double> distribution(0.0, 1.0);
size_t level = 1;
while (distribution(generator) <= _p && level < _maxLevel)
{
++level;
}
return level;
}
void Print()
{
/*int level = _head->_nextV.size();
for (int i = level - 1; i >= 0; --i)
{
Node* cur = _head;
while (cur)
{
printf("%d->", cur->_val);
cur = cur->_nextV[i];
}
printf("\n");
}*/
Node* cur = _head;
while (cur)
{
printf("%2d\n", cur->_val);
// 打印每个每个cur节点
for (auto e : cur->_nextV)
{
printf("%2s", "↓");
}
printf("\n");
cur = cur->_nextV[0];
}
}
private:
Node* _head;
size_t _maxLevel = 32;
double _p = 0.5;
};
4. スキップリストとバランスドサーチツリーおよびハッシュテーブルの比較
- バランス検索ツリー (AVL ツリーおよび赤黒ツリー) と比較して、スキップリストはデータを順序立てて走査でき、時間計算量は同様です。スキップリストの利点は次のとおりです: a. スキップリストは実装が簡単で、制御も簡単です。バランスの取れたツリーの追加、削除、クエリ、変更、および走査はより複雑です。b. スキップリストの余分なスペース消費が少なくなります。バランス ツリー ノードには、3 点チェーン、バランス係数、またはカラー消費量を含む各値が格納されます。スキップリストの p = 1 / 2 の場合、各ノードに含まれるポインタの平均数は 2 であり、スキップリストの p = 1 / 4 の場合、各ノードに含まれるポインタの平均数は 1.33 です。
- ハッシュ テーブルと比較すると、スキップリストにはそれほど大きな利点はありません。比較すると、 a. ハッシュ テーブルの平均時間計算量は O(1) であり、スキップリストよりも高速です。b. ハッシュ テーブルはもう少し多くのスペースを消費します。スキップリストの利点は次のとおりです: a. 走査データは順序付けされます。b. スキップリストのスペース消費量はわずかに小さくなり、ハッシュ テーブルにはリンク ポインターとテーブル スペースの消費量が含まれます。c. ハッシュ テーブルを拡張するとパフォーマンスが低下します。d. 極端なシナリオではハッシュ テーブルでハッシュの衝突が多くなり、効率が急激に低下するため、リレーを構成するには赤黒ツリーが必要です。