跳表值得手写因其并发友好、内存局部性优、可控性强;需避免层级越界、断链、硬编码层数等错误,next数组须内联固定长度以提升缓存效率。

跳表在 C++ 里为什么值得手写而不是直接用 std::set
跳表不是 STL 标准组件,std::set 底层是红黑树,O(log n) 查找但常数大、内存局部性差;跳表靠多层链表 + 随机提升实现均摊 O(log n),插入/删除更易并发,且自己控制层级和随机策略时,能压测、调参、嵌入自定义比较逻辑——比如你正做日志索引或实时排序缓存,又不想引入 Boost 或第三方库,手写一个轻量跳表反而更可控。
常见错误现象:find() 返回空指针却没检查层级越界;insert() 后忘记更新前驱数组导致断链;随机层数硬编码为 16,实际数据量小却浪费内存。
- 层级上限别设死,按
log2(n)动态估算(n 是预期最大节点数),初始可设为16,但运行时根据实际 size 调整 - 随机提升概率必须是 0.5(即每次抛硬币),用
rand() & 1比rand() % 2更快且无 bias - 所有节点的
next数组大小必须统一为当前最大层级,哪怕某次插入只到第 2 层——否则遍历时指针访问会越界
skip_list_node 的内存布局怎么避免 cache line 断裂
跳表性能吃内存布局很重:每个节点带一个 next 指针数组,如果数组长度不固定或用 std::vector 动态分配,指针分散在堆上,一次查找要跨多个 cache line。必须把 next 做成固定长度的内联数组。
示例结构体关键点:
立即学习“C++免费学习笔记(深入)”;
struct skip_list_node {
int key;
std::string value; // 实际业务字段
skip_list_node* next[16]; // 必须是 [16],不能是 std::unique_ptr<skip_list_node*>[]
};
-
next[16]放在结构体末尾,用malloc(sizeof(skip_list_node) + 16 * sizeof(skip_list_node*))分配整块内存 - 不要用
std::array<skip_list_node></skip_list_node>,它会让编译器在结构体里塞 padding,破坏紧凑性 - 如果 key 是小整型(如
int),把它放结构体最前面,利于 CPU 预取时提前拿到比较值
插入时怎么维护 update 数组而不漏层
跳表插入本质是“从顶层往下找每层最后一个 ≤ key 的节点”,把这些节点记进 update[] 数组,再逐层把新节点插到它们后面。漏掉某一层,就会导致该层链表断裂,后续 find() 在那层直接跳过新节点。
典型错误:for (int i = current_level - 1; i >= 0; i--) 循环里,没对每一层都执行 update[i] = x,而是只在找到位置时才赋值。
- 初始化
update数组全为 head 指针:skip_list_node* update[16]; for (int i = 0; i - 搜索时每下降一层,先保存当前节点到
update[i],再沿next[i]走:“先存后走”,不是“走到再存” - 新节点层级为
lvl,则只对i = 0到lvl-1层执行插入,更高层不用动
查不到节点时 find() 返回什么最安全
返回 nullptr 看似自然,但容易掩盖边界问题:比如 key 小于所有节点,或跳表为空,find() 走完顶层就停了,根本没进任何 next 指针——这时候返回 nullptr 没问题;但如果用户想“找 floor key”,就需要返回小于等于 key 的最大节点,此时 nullptr 就不够用了。
更稳妥的做法是让 find() 返回一对指针:std::pair<skip_list_node bool></skip_list_node>,bool 表示是否精确命中。但多数场景下,只要文档写清行为,返回 nullptr 即可。
- 绝对不要返回野指针或未初始化的局部变量地址
- 如果支持重复 key,
find()应返回第一个匹配节点,而非任意一个——这由插入时“相等时往右走”的约定保证 - 调试时可在
find()开头加 assert:assert(head_ != nullptr);,空跳表应单独处理,不进主逻辑
跳表最难调的永远不是算法逻辑,而是指针赋值顺序和内存释放时机:delete 节点前,必须确保所有层级的前驱节点的 next[i] 都已绕过它,否则删完立刻触发 use-after-free。这个点没有银弹,只能靠 ASan + 反复压测。








