想象你生活在一座棋盘式老城,路面全是单车道,每个路口都有红绿灯。某天政府决定修高架:第一层高架每隔两条街设一个匝道,第二层每隔四条街设一个,第三层每隔八条街设一个……从此老司机去城市另一端时,先上最高层高架一路飞驰,发现快过头了,就下到低一层的快速路继续逼近,最后落到地面街道精确停靠——全程跳过几十个红绿灯。 这就是跳表(Skip List)的核心思想:在一条普通有序链表之上,用多层“索引通道”让查找跳跃式前进,把 O(n) 的逐个比较变成 O(log n) 的快速逼近。Redis 的 Sorted Set(有序集合)正是这项技术的明星应用——游戏排行榜、实时热榜、延迟队列背后都有它的身影。本文将用“城市立体交通网”这个比喻,带你从零看懂跳表的多级索引、掷硬币式的概率平衡,以及 Redis 如何用 skiplist + hash 双结构同时实现“按分数排序”和“按成员查分”。
跳表是理解“以空间换时间”和“概率即平衡”两大工程思想的绝佳入口。掌握它,你不仅能看懂 Redis 核心数据结构的源码,还能在设计高并发有序存储方案时多一把趁手的工具。
可视化演示
展示跳表多级稀疏索引结构、从顶层向下的查找过程、概率晋升插入机制及Redis ZSet双结构应用,交互切换深入理解跳表核心原理。
本节配有交互动效(进入视口后自动加载,约 3KB)。
跳表:有序链表上的多级稀疏索引 底层完整有序链表,上层按概率抽取节点形成稀疏索引 最高层节点最少,逐层向下密度递增 查找:从最高层向右走,碰壁则下降一层 多级索引实现O(logN)查找。
跳跃查找思想
周五晚高峰,你开车横穿整座城市。如果只走地面街道,每个路口都可能遇到红灯,十公里路程要停几十次。聪明的做法是先上环城高架——高架入口虽少,但上去之后可以一口气越过七八个地面路口,等快到目的地了再下桥走最后几百米。跳表的灵感与修高架如出一辙:它给普通链表“修高架”。
普通有序链表的查找只能从头节点开始,逐个 next 向后比较,找第 n 个元素平均要比较 n/2 次,时间复杂度为 O(n),这就是城市地面交通的拥堵。跳表的改进是:额外维护若干层“索引链表”,每一层都比下一层稀疏。查找时从最高层出发,沿着当前层向右走,一旦发现“下一个节点已经超过目标”,就下移到低一层继续——整个过程像高架→快速路→地面街道的逐级下降,跳过了大量冗余比较。
理想情况下,如果第 i 层每两个节点挑一个进入第 i+1 层,那么顶层大约只剩 2 个节点,层数约为 log₂n。层数每增加 1,单次跳跃能跨过的节点数翻倍,这正是查找能从 O(n) 降到 O(log n) 的物理基础:
有了这套“立体路网”,老司机找目标时先走高架远眺,再下快速路逼近,最后落地街道精确停靠,全程不超过 log₂n 次“跳跃+下移”。这正是跳表名字的由来——用多级索引“跳”过冗余节点。
- n底层节点总数:底层链表保存全部 n 个数据元素,是查找的最终归属地。
- log₂二分次数:n 个有序元素,每次折半后剩余规模的对数级表达。
- L(n)理想层数:让顶层只剩约 2 个节点所需的索引层数,也是查找路径的“高度”。
多级索引结构
高架桥不是每栋楼都有匝道。地面街道(L0)每个小区都有出入口;一级快速路(L1)每隔一个街区设入口;城市环线(L2)每隔四个街区才有一个入口;跨城高速(L3)只在几个大枢纽接驳。层数越高,入口越稀疏,但单次跨越的距离越远。
跳表的多级索引完全复刻了这套结构。L0 是包含全部 n 个节点的普通有序链表;L1 从 L0 中约每两个取一个节点;L2 从 L1 中约每两个取一个……每一层节点数约为下一层的一半。工程实现中,每个节点内部用一个 forward 指针数组保存它在各层的后继节点,相当于每个路口记录了通往各层高架的入口位置。
在第 k 层出现的节点,需要连续通过 k 次“晋升”。如果晋升概率 p = 1/2,那么一个节点正好出现在第 k 层的概率为:
工程实现中,层数不需要预先规划。每个新节点根据自己的“匝道数量”(随机层数)就地扩建 forward 指针数组,各层指针互不干扰,形成一座自然生长的立体交通网。
- L_k第 k 层索引:k=0 是底层完整链表,k 越大越接近顶层,节点越稀疏。
- 1/2单次晋升概率:从低层进入高一层时,只有约一半节点能“入选”。
- k 次方连续晋升:要出现在第 k 层,必须连续 k 次都成功入选。
- 数值验证n=10 时 L2 期望节点数为 10/4 = 2.5 个,与“每隔三四个街区一个匝道”吻合。
概率平衡机制
你可能想问:每条高架的入口位置是谁定的?城市规划局没有派人去数车流,而是用最简单粗暴的办法——掷硬币。每个路口抛一枚硬币,正面就建一个上高架的匝道,反面就不建。看似随意,但大数定律保证:一半路口有匝道,四分之一的路口有双层匝道,八分之一有第三层……分布天然均匀,不需要任何人工规划。
跳表的“层数”正是这样掷出来的。插入新节点时,从第 0 层开始反复抛硬币(用随机数生成器模拟),每出现一次正面层数 +1,直到出现反面为止,得到最终层数。这个过程完全随机、无状态,不需要像红黑树那样在插入后做旋转或染色。单个新节点的期望层数是一个等比级数求和:
这就是“概率平衡”的精髓:用随机性替代强制调整。红黑树靠变色和旋转维持绝对平衡;跳表揣着一枚硬币走天下,虽然每条路径不完全均衡,但统计上所有路径都接近最优。老工程师的直觉是:与其精打细算,不如抛硬币。
- level(x)新节点的随机层数:插入节点 x 时掷硬币得到的层高,决定它拥有多少层指针。
- p单次晋升概率:每次掷硬币出现正面的概率,Redis 实现中取 0.5。
- 等比级数求和层数为 k 的概率是 (1-p)·p^k,对全部 k 求期望得到 1/(1-p)。
- 代入 p=0.5平均每个节点约 2 层,既保证足够跳跃能力,又不至于过度浪费空间。
Sorted Set 双结构
假设你是景区游客中心的工作人员,每天要回答两类问题:“张三是多少分?”(按姓名查分值)和“排行榜前十都是谁?”(按分数排序)。如果只准备一本按姓名拼音排列的花名册,查分数倒是快,但排榜要翻完整本书;如果只准备一本按分数排的排行榜,排榜快,可要查“张三”还得在榜里从头找到人。
Redis 的 Sorted Set(Zset)没有二选一,而是两本册子同时维护:一个 hash 字典(dict)存储 member → score 映射,提供 O(1) 的按成员查分;一个 skiplist 按 score 排序存储 member,提供 O(log n) 的排序与范围操作。执行 ZADD 时,先更新 hash,再把 (score, member) 插入 skiplist 保持有序。
一次 ZADD 的总成本是两个结构的开销相加:
游客中心的两本册子由同一套流程协同更新:哈希册保证“我叫张三,考了 92 分”一秒查得;跳表册保证“92 分排第几、周围是谁”尽收眼底。Redis 把“点查”和“排序”两个世界无缝拼接,这正是 Zset 能同时支撑排行榜与会员查询的秘密。
- O(1)hash 更新:对 dict 按 member 键写入或修改 score,与集合大小无关,像花名册按姓名直接翻页。
- O(log n)skiplist 插入:把 (score, member) 按分值插入有序跳表,维持排行榜顺序。
- 加法顺序执行:两步先后串行完成,总耗时近似两者相加。
核心操作实现
老司机接到新订单,要往排行榜里插一个 87 分的新成员。他先上最高层高架向右一路狂飙,快接近 87 分时(下一个节点已是 90 分,超过了),就下到快速路;在快速路上再次逼近,发现下一跳又过头了,再下到地面街道;最后在 85 分和 90 分之间的空位停稳,把新门牌挂进街道,再抛硬币决定要不要顺便开通高架入口。
跳表插入的工程实现正是这个“逐层下降找前驱”的过程:从最高层开始,每层向右找到“最后一个小于新值的节点”记入 update[] 数组;然后根据随机层数创建新节点,顺着 update[] 在各层完成指针拼接。删除是逆操作:先找到目标节点,然后逐层把指向前方目标节点的指针摘除。范围查询 zrangebyscore 则先用 O(log n) 定位到分数下限,再沿 L0 向后遍历输出。
如果范围查询要返回 m 个元素,总代价为:
插入 87 分的新成员,耗时只在“下桥”途中;桥修好后,每次路过 85→90 这段路,都能稳稳经过 87。范围查询更像是沿地面街道顺路挨家挨户发通知——起点找得快,整条街逛完也快。
- O(log n)定位起点:从最高层逐层下探,找到第一个不小于范围下界的节点。
- m结果数量:需要输出的元素个数,例如排行榜前 10 名,m=10。
- 对比 O(n)如果从底层开始找起点,最坏要扫过全部 n 个节点。
- 两阶段相加定位 + 沿底层链表遍历输出,总耗时相加。
性能复杂度分析
修高架当然要花钱——土地、桥墩、钢筋水泥都是额外开销。跳表也一样,每一层索引的 forward 指针都是额外的内存。但城市愿意为通行效率买单:多花一倍指针空间,换来查找从“扫码式逐户”升级为“高架飞驰”。
跳表总指针数的期望值可以精确计算:第 i 层期望节点数为 n × (1/2)^i,对所有层求和,得到总和约为 2n。也就是说,跳表的空间复杂度是 O(n),常数因子约为 2——每个节点平均多存一个指针的代价,完全可控。
当晋升概率为 p 时,期望总指针数为:
相比红黑树,跳表的工程优势有三:一是实现简单,红黑树的左旋右旋变色让无数程序员头秃,跳表只需要“掷硬币 + 连指针”;二是范围查询天然高效,红黑树找区间需要中序遍历并回溯父节点;三是并发友好,跳表的各层指针可以通过 CAS 无锁更新,而红黑树的全局再平衡在高并发下是灾难。Redis 作者在源码注释中坦言:跳表“足够简单,且能很好地完成任务”。
- n底层节点数:所有数据必然出现在 L0,这是指针开销的基数。
- 等比级数每层节点数按 p 的比例递减,形成等比数列。
- 级数求和等比级数的和收敛到 n/(1-p),这是概率论的标准结论。
- p=1/2 代入期望总指针数为 2n,空间复杂度量级为 O(n)。
工程应用场景
城市核心区寸土寸金,但几乎所有繁华地带都离不开立体交通。同样,在内存数据库和有序集合的世界里,凡是“既要有序、又要高效增删、还要范围查询”的场景,跳表都是主力选手。Redis 的 Zset 就是最著名的例子。
几个经典场景包括:直播打赏榜、游戏天梯榜用 ZADD 写分、ZREVRANGE 取 Top N;分布式延迟队列用 ZADD(时间戳,任务) 将到期时间作为 score,定时用 ZRANGEBYSCORE 取出所有已到期任务执行;内存数据库的有序索引、好友动态时间线等也常以 Zset 作为底层结构。
排行榜取 Top K 的复杂度同样优秀:
从直播间的热榜到 AI 训练任务的延迟调度,跳表像城市的立体路网一样默默支撑着“有序世界”的运转。下次你用 Redis 敲下 ZADD 时,不妨想想那个掷硬币修高架的老工程师——概率与索引的结合,让复杂世界变得简单而高效。
- ZREVRANGE倒序取区间:从分数最高端开始取 m 个元素,对应排行榜前 m 名。
- 定位最高分跳表支持从最高层快速找到分值最大的节点。
- m取出条数:一次取 m 条结果,与总用户数无关,T 只随 m 线性增长。
- 结论百万级排行榜取 Top 10 依然是微秒级响应。
AI 应用场景
推荐系统候选池实时排序
阶段:特征工程与实时召回后的候选排序服务
解决的问题:推荐系统为每个用户生成候选物品后,需要按 CTR 预估分排序并截取 Top K。传统做法每次请求都全量排序,候选池因实时行为频繁更新,成本居高不下。用 Redis Zset 以预估分为 score 增量写入,ZREVRANGE 毫秒取 Top K,同时用 ZREMRANGEBYSCORE 淘汰低分项,让候选池始终“鲜活且有序”。
具体案例:某短视频推荐系统为每个用户维护一个“会话候选池”Zset,新物品的 CTR 预估分实时 ZADD,每次刷新页面用 ZREVRANGE key 0 49 取 50 个推荐项,全程亚毫秒,支撑每秒数万用户的个性化刷新。
分布式延迟队列与 AI 任务调度
阶段:模型训练与推理任务的异步调度
解决的问题:AI 训练任务常有“延迟触发”(如失败后 30 分钟重试、定时评测)。传统方案用数据库轮询,既慢又有时间误差;用 Redis Zset 以目标触发时间戳为 score、任务 ID 为 member,扫描线程每秒 ZRANGEBYSCORE(now, now) 取出到期任务,删除后派发执行,精准且高效。
具体案例:某 MLOps 平台的模型重试队列用 Zset 管理 10 万个延迟任务,扫描线程每秒一次 O(log n + m) 查询即可唤醒到点任务,重试延迟控制在百毫秒内,避免了对训练集群的无效空转。
HNSW 向量检索中的跳表思想
阶段:向量数据库的近似最近邻检索(ANN)
解决的问题:RAG 应用中需要在百万级向量里快速查找相似向量。HNSW(分层可导航小世界图)受跳表直接启发:用随机函数决定向量节点的层数,高层图稀疏连接远距离节点充当“高架”,底层图密集连接近邻充当“街道”,搜索时从顶层贪婪逼近再逐层下探——与跳表查找路径如出一辙。
具体案例:Milvus 与 FAISS 中的 HNSW 索引在 100 万条 768 维向量上实现毫秒级召回,正是跳表“多级索引 + 概率分层”思想在向量空间的延伸,为 RAG 应用的实时问答提供了底层加速。
Python 演示
import random class SkipNode: def __init__(self, value, level): self.value = value self.next = [None] * (level + 1) class SkipList: MAX_LEVEL = 16 P = 0.5 def __init__(self): self.head = SkipNode((float('-inf'), ''), self.MAX_LEVEL) self.level = 0 def random_level(self): level = 0 while random.random() < self.P and level < self.MAX_LEVEL: level += 1 return level def find(self, value): cur = self.head for i in range(self.level, -1, -1): while cur.next[i] and cur.next[i].value < value: cur = cur.next[i] cur = cur.next[0] return cur.value if cur else None def insert(self, value): update = [self.head] * (self.MAX_LEVEL + 1) cur = self.head for i in range(self.level, -1, -1): while cur.next[i] and cur.next[i].value < value: cur = cur.next[i] update[i] = cur new_level = self.random_level() if new_level > self.level: for i in range(self.level + 1, new_level + 1): update[i] = self.head self.level = new_level node = SkipNode(value, new_level) for i in range(new_level + 1): node.next[i] = update[i].next[i] update[i].next[i] = node def delete(self, value): update = [self.head] * (self.MAX_LEVEL + 1) cur = self.head for i in range(self.level, -1, -1): while cur.next[i] and cur.next[i].value < value: cur = cur.next[i] update[i] = cur target = cur.next[0] if target and target.value == value: for i in range(self.level + 1): if update[i].next[i] and update[i].next[i].value == value: update[i].next[i] = target.next[i] while self.level > 0 and self.head.next[self.level] is None: self.level -= 1 return True return False def range_query(self, low, high): cur = self.head for i in range(self.level, -1, -1): while cur.next[i] and cur.next[i].value < low: cur = cur.next[i] result = [] cur = cur.next[0] while cur and cur.value <= high: result.append(cur.value) cur = cur.next[0] return result class ZSet: def __init__(self): self.dict = {} self.sl = SkipList() def zadd(self, member, score): if member in self.dict: self.sl.delete((self.dict[member], member)) self.dict[member] = score self.sl.insert((score, member)) def zscore(self, member): return self.dict.get(member) def zrangebyscore(self, low, high): return self.sl.range_query((low, ''), (high, '\uffff')) if __name__ == '__main__': z = ZSet() z.zadd('alice', 85) z.zadd('bob', 92) z.zadd('carol', 78) z.zadd('dave', 92) print('bob score:', z.zscore('bob')) print('80-100:', z.zrangebyscore(80, 100))
while random.random() < self.P and level < self.MAX_LEVEL:掷硬币晋升:每出现一次正面层数 +1,直到反面或达到上限。for i in range(self.level, -1, -1):从最高层开始逐层下降,查找的高架→地面路径。while cur.next[i] and cur.next[i].value < value:每层向右移动的条件:下一跳存在且值仍小于目标。if member in self.dict: self.sl.delete((self.dict[member], member))ZADD 时先按旧分数删掉旧记录,避免重复排序。self.sl.insert((score, member))把 (分数,成员) 作为整体插入跳表,分数优先、成员名次之。return self.sl.range_query((low, ''), (high, '\uffff'))范围查询:定位下限后沿底层遍历,直到超过上限。
常见误区
误区“层数是随机生成的,万一手气差所有节点都只有一层,跳表不就变回普通链表了吗?O(log n) 是不是骗人的?”
正解单次极端结果概率极低。一个 100 万节点的跳表,全部节点层高只有 1 的概率是 2 的负一百万次方,比连续中十次彩票头奖还低无数倍。跳表保证的是期望性能,大数定律让大量独立随机事件的叠加结果高度稳定,工程上可视为稳定的 O(log n)。何况 MAX_LEVEL 限制了层高上限,绝不会无限增长。
为何常见:初学者把“随机”等同于“不可控”,忽略了概率论中“大量独立事件叠加后分布稳定”的基本直觉。
误区“跳表每层得保持严格二分的结构吧?插入删除后不需要旋转或重新分布节点吗?感觉维护成本好高。”
正解完全不需要。跳表的“平衡”来自插入时的随机层数,而不是插入后的结构调整。每个新节点掷硬币决定层数并就地插入,旧节点完全不动,便天然维持了“每层约一半”的统计结构。这也是跳表相比平衡树最大的工程优势——把“事后修复”变成“事前概率”。
为何常见:习惯了平衡树“插入后必须旋转/变色”的思路,想不到可以用随机化在源头解决问题。
误区“Redis 的 Sorted Set 就是跳表,没有别的结构了。”
正解Zset 是 dict + skiplist 双结构。dict 负责 member → score 的 O(1) 点查(ZSCORE),skiplist 负责按 score 排序与范围操作(ZRANGE / ZRANK)。两者在一次 ZADD 命令中被原子地协同更新。只讲跳表就丢掉了一半设计精髓。
为何常见:技术文章标题总强调“跳表”,让人忽略了源码里其实还有一张哈希表。
误区“反正是空间换时间,我把 MAX_LEVEL 设成 128 甚至 256,性能一定更好。”
正解层数是 log₂n 级别的,100 万节点约需 20 层,2 的 32 次方已是 42 亿节点规模。MAX_LEVEL 设置过高只会徒增头节点的空指针,且每次查找都要多做无意义的层数判断。Redis 采用 32 或 64 上限,背后是经过权衡的工程智慧。
为何常见:把“空间换时间”误解为“空间越多越快”,忽略了指针跳转本身也有微小的成本。
误区“跳表是链表加强版,查找肯定也是从头到尾遍历吧?最多比普通链表快一点。”
正解恰恰相反,跳表查找从最高层开始,向右走并与目标比较,下一个节点大于目标时下移一层。整个过程是“高层快速扫过、低层精确定位”的倒金字塔路径,步数约 log₂n 而不是 n。
为何常见:沿用了普通链表的“从 head 开始 next”心智模型,没意识到多级索引的意义正是“从最稀疏的道路出发”。
面试题
Q1. 跳表查找的平均时间复杂度是多少?
- A.O(1)
- B.O(log n)← 正确答案
- C.O(n)
- D.O(n log n)
答案:B。跳表通过多级索引让查找路径长度约为 log₂n,就像高架桥几步跳过几十个路口,期望复杂度为 O(log n)。A 是哈希表的复杂度,C 是普通链表的复杂度,D 是排序类算法的典型复杂度。
Q2. 跳表新节点的层数是如何决定的?
- A.根据节点值大小计算
- B.随机抛硬币,正面则升层← 正确答案
- C.插入后统一重新平衡
- D.由用户显式指定
答案:B。跳表用随机化(掷硬币)决定层数,每次晋升概率为 p,无需事后调整,这就是“概率平衡”的含义。C 是红黑树的平衡思路,A 和 D 与跳表设计无关。
Q3. Redis Zset 中,ZSCORE(按成员查分值)依赖哪个结构?
- A.skiplist
- B.dict← 正确答案
- C.数组
- D.双向链表
答案:B。Zset 是 dict + skiplist 双结构,dict 存 member → score 映射,提供 O(1) 的点查能力。skiplist 负责按分数排序,数组和双向链表均未参与 Zset 实现。
Q4. 跳表期望的空间复杂度是?
- A.O(1)
- B.O(log n)
- C.O(n)← 正确答案
- D.O(n log n)
答案:C。期望总指针数 = n/(1-p),p=1/2 时为 2n,空间复杂度为 O(n)。虽然常数因子为 2,但量级与节点数线性相关,不是对数级或线性对数级。
Q5. 相比红黑树,跳表在哪些方面有优势?
- A.实现简单,无需旋转变色← 正确答案
- B.范围查询天然高效← 正确答案
- C.并发场景更易实现无锁← 正确答案
- D.最坏时间复杂度更低
答案:ABC。跳表实现简单(掷硬币 + 连指针)、范围查询沿底层链表直接遍历、并发下可用 CAS 逐层更新。但跳表在极端随机下最坏是 O(n),红黑树保证最坏 O(log n),所以 D 错误。
Q6. 跳表查找时,正确的搜索路径是?
- A.从底层向右逐个扫描
- B.从最高层向右,快过头了下移← 正确答案
- C.从最高层向左搜索
- D.随机选一层开始
答案:B。查找从最高层出发向右走,当下一个节点大于目标时下移到低一层,逐层逼近目标。A 是普通链表行为,C 方向错误,D 的随机起点无法保证对数级性能。
Q7. 晋升概率 p=0.5 时,一个节点出现在第 k 层的概率是?
- A.1/2
- B.1/2^k← 正确答案
- C.1/(2n)
- D.k/2
答案:B。节点要出现在第 k 层,必须连续 k 次晋升成功,每次概率 1/2,所以总概率为 (1/2)^k。A 是单次晋升概率,C 混淆了节点数量,D 无概率意义。
Q8. 以下哪些场景适合用跳表(Zset)实现?
- A.实时排行榜 Top N← 正确答案
- B.基于到期时间的延迟队列← 正确答案
- C.无序 KV 缓存
- D.按范围获取有序数据← 正确答案
答案:ABD。排行榜需要排序与范围取数(ZREVRANGE),延迟队列把时间戳当 score 并用 ZRANGEBYSCORE 取到期任务,有序范围查询正是跳表强项。C 是无序 KV,哈希表更合适,不需要跳表的有序性。
Q9. 跳表第 i 层的期望节点数约为第 i-1 层的多少?
- A.相等
- B.一半← 正确答案
- C.两倍
- D.随机无规律
答案:B。晋升概率 p=1/2,每层只有约一半节点进入上一层,所以第 i 层节点数约为第 i-1 层的一半,形成逐层稀疏的多级索引,就像高架匝道逐层减少。
Q10. 关于 Redis Zset 的 ZADD 操作,下列说法正确的是?
- A.先更新 dict,再插入 skiplist← 正确答案
- B.当 member 已存在时,按新 score 调整排序位置← 正确答案
- C.只需操作 skiplist 即可
- D.时间代价约为 O(1) + O(log n)← 正确答案
答案:ABD。ZADD 先写 dict(member → score),再把 (score, member) 插入 skiplist;member 已存在时先删旧记录再插新分值,保证有序。C 忽略 dict 的存在。总代价是 hash 的 O(1) 加跳表的 O(log n)。