手把手教你实现跳表!

发布于我的博客也许同步更新于博客园

引入

跳表(跳跃表)能够维护一个数的集合(作用类似普通平衡树),查找时间复杂度为 \(\log n\),与平衡树一样基于链表结构。由于不需要平衡树那么多旋转什么的,所以效率比较高,一般认为性能能打红黑树。除此以外,链表的特性使它能够以线性时间遍历某个子段。Redis 的有序集合就是用跳表实现的。

更简单来说,跳表是一个支持 \(\Theta(\log n)\) 时间随机访问的链表

定义

上面这个东西叫链表。

我们知道,链表只支持线性时间访问,所以不能二分。我们如果想维护一个有序序列的话,虽然插入删除很快,但是找到一个值对应的位置很慢。

我们又知道,链表的访问形式实际上是一个一个遍历,而它有 \(n\) 个元素,这是它 \(\Theta(n)\) 复杂度随机访问的根源所在。

那我们是不是可以给链表精简一下呢?比如说,我给链表多加几层,每层减少一半的元素,像这样:

(蓝色方框括起来的是一个节点,实现的时候我们不需要把上面几层显式地建出来,只需要创建对应层的指针即可。)

这样的话,我就能像上图这样找到 \(78\) 这个节点了。

橙色路径是原有路径,走了 \(4\) 次。而上面的绿色路径只走了 \(\log_2 4 = 2\) 个次。好好好,那我这样建的话,我就能在链表上二分了!
实际上这个东西叫完美跳表。

跳表分两种,一种是上面的完美跳表(暂且这样叫)。这个东西最大的特点就是过于理想化了。如果加上插入删除的话,维护对应层的指针就太难了,每次都得更新。

另一种是基于随机化的跳表。
要随机化的东西叫做 \(level\)。一个跳表节点的 \(level\),代表着这个节点同时存在于 \(1 \sim level\) 层的链表中。比如说,上图的值为 \(1\) 的节点 \(level = 3\),值为 \(23\) 的节点 \(level = 1\)
\(level\) 的方式类似于抛硬币,计算 \(level\) 时,如果硬币正面朝上,就 \(+1\) 并继续抛;如果反面朝上,则停止。通过这样定下节点 \(level\) 的跳表就是我们今天要实现的跳表。

基本实现

为了方便演示,这里就不再封装跳表了,其实跟着教程一边走一边封装也是可行的。

一些变量

int level 记录跳表的最高 level。

Node head 为了防止过多的边界的分类讨论,建立一个空结点当头节点。

节点

动态分配内存太慢了,如果用动态分配的,我还不如直接 STL。

所以开好数组作为预分配的空间,然后我们可以开一个指针记录分配到了哪一个位置。需要创建新节点的时候直接返回一个 ++tot 即可。

struct Node {
    int key, level;
    Node* nxt[MAX_LEVEL];
} space[N], *tot = space;

此处 level 的含义是:该节点存在于 0 到 level - 1 层的链表中,与前文定义 1 到 level 不同。

垃圾回收可以自己实现,待会的整体演示里面会放。

分配一个新节点空间,返回新节点的指针:

#define new_node() (++tot)

创建一个值为 key 高度为 level 的节点:

Node* create_node(int level, int key) {
    Node* res = new_node();
    res->level = level;
    res->key = key;
    return res;
}

随机生成 level

前面说了,是抛硬币。

所以我们可以直接借用一些随机数生成器

然后我们肯定不能让层数无限大啊,所以需要设置一个 MAX_LEVEL 作为最大层数。

#define MAX_LEVEL 12

std::random_device seed;
std::minstd_rand rng(seed());

int random_level() {
    int res = 1;
    while (res < MAX_LEVEL && (rng() & 1)) {
        ++res;
    }
    return res;
}

本人做了测试,在随机种子 + 1000 次取值的测试下,梅森缠绕和线性同余两种算法通过 & 1 求出来的平均值基本上就是 \(0.5\pm 0.03\),而且线性同余性能远比梅森缠绕高。然后用 PCG 算法测试了一下,发现 PCG 官方给的 C++ 实现能够做到 \(0.5\pm 0.02\),性能接近线性同余,但是这玩意考场上得自己实现,所以还是用线性同余吧。

插入节点

声明:

void insert(int key);

三步走:找到需要插入的位置,插入节点,更新对应 level 的链表。

首先我们直接从高 level 开始跳,跳不了了就跳低一级的 level 即可找到需要插入的位置。
同时记录每一个 level 的当前位置之前的节点。(即可能需要更新后向指针的节点)。

Node *cur = head;  // current

for (int lev = level - 1; lev != -1; --lev) {
    while (cur->nxt[lev] && cur->nxt[lev]->key < key)
        cur = cur->nxt[lev];  // 存在满足要求的点就跳
    update[lev] = cur;
}

细节:可能当前 level 还没有到跳表可能达到的最高 level,但是当前这个节点随机到的 level 值在这两个数中间,所以需要将 levelMAX_LEVEL 这段补全为 head

int lev = random_level(); // 当前节点的 level 值
if (lev > level) {
    for (int i = level; i < lev; ++i)
        update[i] = head;

    level = lev;
}

创建节点:

cur = create_node(lev, key);

执行插入操作,即对于每一层链表,更新前一个节点的指针,并让当前节点的后向指针指向后一个节点。

for (int i = lev - 1; i > -1; --i) {  // 普通链表插入操作
    cur->nxt[i] = update[i]->nxt[i];
    update[i]->nxt[i] = cur;
}

删除节点

和插入类似。

void erase(int key) {
    nodePointer cur = head;  // current

    for (int lev = level - 1; lev != -1; --lev) {
        while (cur->nxt[lev] && cur->nxt[lev]->key < key)
            cur = cur->nxt[lev];  // 存在满足要求的点就跳
        update[lev] = cur;
    }

    cur = cur->nxt[0];
    for (int i = 0; i < level; ++i)
        if (update[i]->nxt[i] == cur)
            update[i]->nxt[i] = cur->nxt[i];
        else
            break;

    while (level > 1 && !head->nxt[level - 1])  // 更新当前最大层数
        --level;
}

额外要注意的是,可能跳表的最高层就这一个节点,删了就没了,所以要判断并更新最大层数。

查找结点

实际上上面两个函数的第一部分就相当于查找。

bool find(int key) {
    Node* cur;

    for (int lev = level - 1; lev > -1; --lev)
        while (cur->nxt[lev] && cur->nxt[lev]->key < key)
            cur = cur->nxt[lev];  // 存在满足要求的点就跳

    return cur->nxt[0] ? cur->nxt[0]->key == key : false;
}

查找前驱后继的方法也差不多,前驱就是查找后直接返回 cur 而不是 cur->nxt[0],后继可以跳到 cur->nxt[lev]->key <= key 的位置之后返回 cur->nxt[0]->key。最后的代码中有体现。

随机访问(按排名查询)

上面其实已经实现了跳表的基本功能了,但是显然,目前实现的功能都可以用平衡树替代,而且平衡树还能够按照数的排名查询。

由于维护的是有序序列,所以按照数的排名查询相当于随机访问。

接下来我们来实现跳表的随机访问。具体方法:维护每个后向指针的“跨度”(span),即它跳了几个节点。

形式化来说,设指针 \(ptr\) 从第 \(a\) 个节点指向第 \(b\) 个节点,则 \(ptr\) 的跨度为 \(b-a\)

除此以外,我们还需要维护一个长度 length,在每次 eraseinsert 的时候加减一下就好了。

重写智能指针

啥是智能指针?不太清楚,但是我感觉维护一个 span 的指针实在太智能了!

我们需要给指针记录一个“跨度”,那就维护一个结构体作为指针,存原来的裸指针和跨度。

总的来说,需要构造函数并重载一个运算符,一个类型转换。

struct nodePointer {
    int span;
    Node* pointer;
    nodePointer() {
        this->pointer = nullptr; // 构造函数,将指针初始化为空
    }
    nodePointer(Node* node) {
        this->pointer = node;  // 如果提供了指针就用提供的
    }
    Node* operator->() {
        return pointer; // 指针原有的箭头运算符,访问 nodePointer->x 相当于访问 pointer->x
    }
    operator Node*() const {
        return pointer; // 智能指针转换为裸指针,直接返回 pointer 就好了
    }
};

不要在所有地方都使用 nodePointer,我们只在需要维护跨度的地方使用就好了。
编写代码时一定要注意类型的使用,比如说 unsigned long 不应乱用之类的。如果错误地更新 span,而你滥用了 nodePointer,可能就没那么容易找到问题了。

博主因为滥用 unsigned,跳表调了两天多。

需要维护跨度的地方只有跳转用的指针,即 nxt[]

更改后的代码:

struct Node {
    int key, level;
    struct nodePointer {
        int span;
        Node* pointer;
        nodePointer() {
            this->pointer = nullptr; // 构造函数,将指针初始化为空
        }
        nodePointer(Node* node) {
            this->pointer = node;  // 如果提供了指针就用提供的
        }
        Node* operator->() {
            return pointer; // 指针原有的箭头运算符,访问 nodePointer->x 相当于访问 pointer->x
        }
        operator Node*() const {
            return pointer; // 智能指针转换为裸指针,直接返回 pointer 就好了
        }
    };
    nodePointer nxt[MAX_LEVEL];
} space[N];
using nodePointer = typename Node::nodePointer; // 为了方便书写,缩一下

重写插入函数

开一个数组记录每一层“上一个节点”的位置(利用跨度)。

int lst_pos[MAX_LEVEL];

然后在函数开头找位置的时候顺便把它处理出来:

for (int lev = level - 1; lev > -1; --lev) {
    // 更新 lst_pos
    if (lev == level - 1)
        lst_pos[lev] = 0; // 默认得是 0
    else
        lst_pos[lev] = lst_pos[lev + 1]; // 否则从上一层继承

    while (cur->nxt[lev] && cur->nxt[lev]->key < key) {
        lst_pos[lev] += cur->nxt[lev].span; // 更新
        cur = cur->nxt[lev];  // 存在满足要求的点就跳
    }
    update[lev] = cur;
}

插入的时候计算一下就好了。

然后 level 大于这个节点的指针跨度要加一。

结合代码理解。

for (int i = 0; i < lev; ++i) {  // 普通链表插入操作
    cur->nxt[i] = update[i]->nxt[i];
    update[i]->nxt[i].pointer = cur; // 这里不要直接让 nxt[i] = cur,因为后面还要用到 nxt[i].span
    cur->nxt[i].span = update[i]->nxt[i].span - (lst_pos[0] - lst_pos[i]); // lst_pos[0] 实际上就是上一个节点的位置
    update[i]->nxt[i].span = lst_pos[0] - lst_pos[i] + 1;
}
for (int i = lev; i < level; ++i) ++update[i]->nxt[i].span; // 维护高于新节点的指针的跨度

别忘了 ++length

重写删除函数

把要删掉的指针的 span 加起来赋值给新指针就好了。

insert 一样,别忘记比当前节点高的指针跨度要 -1

for (int i = 0; i < level; ++i)
    if (update[i]->nxt[i] == cur)
        update[i]->nxt[i].pointer = cur->nxt[i], update[i]->nxt[i].span += cur->nxt[i].span - 1;  // 跨度直接扔给前面那个指针就行了
    else
        --update[i]->nxt[i].span;

随机访问(按照排名查询)

int findrk(int k) {
    assert(k <= length && k); // k 不满足要求就异常
    Node* cur = head;
    for (int lev = level - 1; lev > -1 ; --lev)
        while (cur->nxt[lev] && k - cur->nxt[lev].span > 0) {
            k -= cur->nxt[lev].span;
            cur = cur->nxt[lev];  // 存在满足要求的点就跳
        }
    return cur->nxt[0]->key;
}

微调

我们可以对 MAX_LEVEL 和选取 level 的概率进行微调。

比如说下面的普通平衡树代码,把选取层数的 \(p\) 改成了 \(\frac 14\),即 (rng() & 1) && (rng() & 1)MAX_LEVEL 设为了 \(7\),经测试这样比较快,在无快读不开 O2 的情况下吊打 Splay/FHQ/Treap,加了快读 O2 之后不知道为啥跑不过我之前写的指针 FHQ 了。另外数组 Treap 始终被吊打。这就是指针带给我的自信

复杂度分析

空间复杂度

设定了最高 level,所以空间复杂度只能是 \(\Theta(n)\) 的。

时间复杂度

OI Wiki

后记

实际上跳表最大的优点是能够顺序访问,这点是很多平衡树做不到的,FHQ Treap 分裂区间之后中序遍历是可以的,但是常数太大。

等我把跳表模板题搞出来,他们都得死!

完整代码

含类型泛化和封装成类。

另外实现了一些输入输出操作,自己看应该能看懂了。

#include <iostream>
#include <random>
#include <cassert>

#include <cstdlib>

using std::cin;
using std::cout;

std::random_device seed;
std::minstd_rand rng(seed());

#define N 106
#define MAX_LEVEL 32

using i32 = signed int;

template <typename T>
class skiplist {
private:
    i32 level;
    struct Node {
        T key;
        i32 level; // 千万的别用 unsigned
        struct nodePointer {
            i32 span; // 这个也千万他妈的别用 unsigned
            Node* pointer;
            nodePointer() {
                this->pointer = nullptr; // 构造函数,将指针初始化为空
            }
            nodePointer(Node* node) {
                this->pointer = node;  // 如果提供了指针就用提供的
            }
            Node* operator->() {
                return pointer; // 指针原有的箭头运算符,访问 nodePointer->x 相当于访问 pointer->x
            }
            operator Node*() const {
                return pointer; // 智能指针转换为裸指针,直接返回 pointer 就好了
            }
        };
        nodePointer nxt[MAX_LEVEL];
    } space[N];
    i32 bintop;
    using nodePointer = typename Node::nodePointer;
    Node *head, *tail, *tot, *rubbin[N / 4 * 3];

#define new_node() (bintop ? rubbin[bintop--] : ++tot)
#define del_node(x) (rubbin[++bintop] = (x))

    Node* create_node(const i32& level, const T& key) {
        Node* res = new_node();
        res->level = level;
        res->key = key;
        return res;
    }

    i32 random_level() {
        i32 res = 1;
        while (res < MAX_LEVEL && (rng() & 1)) {
            ++res;
        }
        return res;
    }

    Node* update[MAX_LEVEL];
    i32 lst_pos[MAX_LEVEL + 1];  // 每个 level 遍历到的最后一个元素的位置

public:
    skiplist() {
        tail = nullptr;
        level = 0;
        head = tot = space;
        bintop = 0;
        length = 0;
        lst_pos[MAX_LEVEL] = 0;
        for (i32 i = 0; i < MAX_LEVEL; ++i)
            head->nxt[i] = nullptr, head->nxt[i].span = 0;
    }

    void insert(const T& key) {
        Node* cur = head;  // current

        for (i32 lev = level - 1; lev > -1; --lev) {
            // 更新 lst_pos,这里由于已经把 lst_pos[MAX_LEVEL] 设为 0 了,所以不需要像上文一样特判
            lst_pos[lev] = lst_pos[lev + 1];

            while (cur->nxt[lev] && cur->nxt[lev]->key < key) {
                lst_pos[lev] += cur->nxt[lev].span;
                cur = cur->nxt[lev];  // 存在满足要求的点就跳
            }
            update[lev] = cur;
        }
        i32 lev = random_level();
        if (lev > level) {
            for (i32 i = level; i < lev; ++i) {
                update[i] = head;
                update[i]->nxt[i].span = length;  // 这层都还没有节点,直接从 head 指向尾部(nullptr),跨度为 length
            }
            level = lev;
        }

        cur = create_node(lev, key);

        for (i32 i = 0; i < lev; ++i) {  // 普通链表插入操作
            cur->nxt[i] = update[i]->nxt[i];
            update[i]->nxt[i].pointer = cur; // 这里不要直接让 nxt[i] = cur,因为后面还要用到 nxt[i].span
            cur->nxt[i].span = update[i]->nxt[i].span - (lst_pos[0] - lst_pos[i]); // lst_pos[0] 实际上就是上一个节点的位置
            update[i]->nxt[i].span = lst_pos[0] - lst_pos[i] + 1;
        }
        for (i32 i = lev; i < level; ++i) ++update[i]->nxt[i].span;

        ++length;
    }

    void erase(const T& key) {
        Node* cur = head;  // current

        for (i32 lev = level - 1; lev != -1; --lev) {
            while (cur->nxt[lev] && cur->nxt[lev]->key < key)
                cur = cur->nxt[lev];  // 存在满足要求的点就跳
            update[lev] = cur;
        }

        cur = cur->nxt[0];
        for (i32 i = 0; i < level; ++i)
            if (update[i]->nxt[i] == cur)
                update[i]->nxt[i].pointer = cur->nxt[i].pointer, update[i]->nxt[i].span += cur->nxt[i].span - 1;  // 跨度直接扔给前面那个指针就行了
            else
                --update[i]->nxt[i].span;

        while (level > 1 && !head->nxt[level - 1])  // 更新当前最大层数
            --level;
        del_node(cur);
        --length;
    }

    bool find(const T& key) {
        Node* cur = head;

        for (i32 lev = level - 1; lev > -1; --lev)
            while (cur->nxt[lev] && cur->nxt[lev]->key < key)
                cur = cur->nxt[lev];  // 存在满足要求的点就跳

        return cur->nxt[0] ? cur->nxt[0]->key == key : false;
    }

    T findrk(i32 k) {
        assert(k <= length && k); // k 不满足要求就异常
        Node* cur = head;
        for (i32 lev = level - 1; lev > -1 ; --lev)
            while (cur->nxt[lev] && k - cur->nxt[lev].span > 0) {
                k -= cur->nxt[lev].span;
                cur = cur->nxt[lev];  // 存在满足要求的点就跳
            }
        return cur->nxt[0]->key;
    }

    i32 length;
};

skiplist<i32> list;

#include <string>

int main() {
    std::ios::sync_with_stdio(false);
    cin.tie(nullptr);
    cout.tie(nullptr);

    i32 n, tx;
    cin >> n;
    for (i32 i = 1; i <= n; ++i) {
        cin >> tx;
        list.insert(tx);
    }
    std::string s;
    while (cin >> s) {
        switch (s[0]) {
            case ('l'): cout << list.length << std::endl; break;
            case ('i'):
            case ('a'):
                cin >> tx;
                list.insert(tx);
                break;
            case ('d'):
            case ('r'): {
                cin >> tx;
                if (list.find(tx))
                    list.erase(tx);
                else
                    cout << "该值不存在" << std::endl;
                break;
            }
            case ('f'):
                cin >> tx;
                cout << (list.find(tx) ? "存在" : "不存在") << std::endl;
                break;
            case ('g'):
                cin >> tx;
                cout << list.findrk(tx) << std::endl;
                break;
            default: cout << "未知命令" << std::endl;
        }
    }

    return 0;
}

参考文献

《跳跃表数据结构与算法分析》 纪卓志 George

《跳表》 OI Wiki

推荐阅读

《关于 skip list 的一些扩展想法》

posted @ 2023-09-28 09:40  Tibrella  阅读(793)  评论(1编辑  收藏  举报

若未特殊声明,闲话除外全部内容使用 FDL 1.3 授权