一、先认识跳表
问题:跳表到底是什么?
跳表,英文叫 Skip List,是一种基于链表的数据结构。
普通链表查找元素时,只能从头到尾一个一个找,时间复杂度是
O(n)。跳表在普通链表的基础上,增加了多层"索引"。查找时可以先在高层索引中快速跳跃,接近目标后再往下查找。
你可以把跳表理解成:
普通链表是走路,跳表是先坐电梯到合适楼层,再走几步。
普通链表的问题
问题:普通链表为什么查找慢?
假设我们有一个有序链表:
text
1 -> 3 -> 5 -> 7 -> 9 -> 11 -> 13 -> 15 -> 17 -> 19
如果我们要查找
17,普通链表只能这样找:
text
1 -> 3 -> 5 -> 7 -> 9 -> 11 -> 13 -> 15 -> 17
它必须一个节点一个节点往后走。
如果链表里有 100 万个元素,最坏情况下可能要走 100 万步。
这就是普通链表最大的问题:
插入和删除虽然方便,但查找太慢。
跳表的核心思想
问题:跳表是怎么解决普通链表查找慢的问题的?
跳表的核心思想是:
在原始链表上方建立多级索引,让查找可以跳着走。
比如原始链表是这样:
text
Level 0: 1 -> 3 -> 5 -> 7 -> 9 -> 11 -> 13 -> 15 -> 17 -> 19
我们给它加一层索引:
text
Level 1: 1 --------> 5 --------> 9 --------> 13 --------> 17
Level 0: 1 -> 3 -> 5 -> 7 -> 9 -> 11 -> 13 -> 15 -> 17 -> 19
再加一层更高的索引:
text
Level 2: 1 --------------------> 9 --------------------> 17
Level 1: 1 --------> 5 --------> 9 --------> 13 --------> 17
Level 0: 1 -> 3 -> 5 -> 7 -> 9 -> 11 -> 13 -> 15 -> 17 -> 19
查找时,我们不再从底层一个个走,而是先从最高层开始跳。
高层索引节点少、跨度大,所以可以快速跳过很多不可能的节点。
用图理解跳表
问题:跳表的结构长什么样?
如果你的 Markdown 编辑器支持 Mermaid,可以直接渲染下面这张图。
#mermaid-svg-ZdiiUhAOnL4aO5Zd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .error-icon{fill:#552222;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .marker.cross{stroke:#333333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ZdiiUhAOnL4aO5Zd p{margin:0;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .cluster-label text{fill:#333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .cluster-label span{color:#333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .cluster-label span p{background-color:transparent;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .label text,#mermaid-svg-ZdiiUhAOnL4aO5Zd span{fill:#333;color:#333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .node rect,#mermaid-svg-ZdiiUhAOnL4aO5Zd .node circle,#mermaid-svg-ZdiiUhAOnL4aO5Zd .node ellipse,#mermaid-svg-ZdiiUhAOnL4aO5Zd .node polygon,#mermaid-svg-ZdiiUhAOnL4aO5Zd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .rough-node .label text,#mermaid-svg-ZdiiUhAOnL4aO5Zd .node .label text,#mermaid-svg-ZdiiUhAOnL4aO5Zd .image-shape .label,#mermaid-svg-ZdiiUhAOnL4aO5Zd .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .rough-node .label,#mermaid-svg-ZdiiUhAOnL4aO5Zd .node .label,#mermaid-svg-ZdiiUhAOnL4aO5Zd .image-shape .label,#mermaid-svg-ZdiiUhAOnL4aO5Zd .icon-shape .label{text-align:center;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .node.clickable{cursor:pointer;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .arrowheadPath{fill:#333333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ZdiiUhAOnL4aO5Zd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZdiiUhAOnL4aO5Zd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ZdiiUhAOnL4aO5Zd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .cluster text{fill:#333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .cluster span{color:#333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZdiiUhAOnL4aO5Zd rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .icon-shape,#mermaid-svg-ZdiiUhAOnL4aO5Zd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .icon-shape p,#mermaid-svg-ZdiiUhAOnL4aO5Zd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .icon-shape .label rect,#mermaid-svg-ZdiiUhAOnL4aO5Zd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ZdiiUhAOnL4aO5Zd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZdiiUhAOnL4aO5Zd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZdiiUhAOnL4aO5Zd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Level 0
Level 1
Level 2
1
9
17
1
5
9
13
17
1
3
5
7
9
11
13
15
17
19
可以看到,越高的层,节点越少,跨度越大。
查找时从高层开始,可以快速跳过大量不可能的节点。
二、跳表是怎么工作的?
查找过程
问题:跳表查找一个元素时,具体是怎么走的?
假设我们要查找数字
15。跳表的查找规则很简单:
- 从最高层的头节点开始。
- 如果右边节点的值小于目标值,就向右走。
- 如果右边节点的值大于或等于目标值,就向下一层走。
- 重复这个过程,直到找到目标值,或者走到底层仍然找不到。
text
目标:查找 15
Level 2: 1 --------------------> 9 --------------------> 17
9 < 15,继续向右
17 > 15,向下
Level 1: 1 --------> 5 --------> 9 --------> 13 --------> 17
13 < 15,继续向右
17 > 15,向下
Level 0: 1 -> 3 -> 5 -> 7 -> 9 -> 11 -> 13 -> 15 -> 17 -> 19
找到 15
跳表查找的过程很像二分查找,但它不是数组结构,而是链表结构。
可以简单记成一句话:
能往右就往右,不能往右就往下。
插入过程
问题:跳表插入一个元素时,需要做什么?
插入元素时,跳表主要做两件事。
第一,先找到每一层中,新节点应该插入的位置。
第二,随机决定新节点要出现在多少层。
为什么是随机决定层数?
因为跳表不像红黑树那样通过旋转和染色来保持平衡,而是通过随机层数,让整体结构大概率保持均衡。
通常规则是:
text
每次有 50% 的概率继续升一层
例如插入一个节点
8,它可能只出现在第 0 层:
text
Level 0: 1 -> 3 -> 5 -> 7 -> 8 -> 9
也可能同时出现在第 0 层和第 1 层:
text
Level 1: 1 --------> 5 --------> 8 --------> 9
Level 0: 1 -> 3 -> 5 -> 7 -> 8 -> 9
也可能出现在更多层。
层数越高,概率越低。
所以跳表整体会形成一种"下面节点多,上面节点少"的结构。
删除过程
问题:跳表删除元素时复杂吗?
删除元素其实也很直观。
先查找目标元素在每一层的前驱节点,然后把这些前驱节点的指针绕过目标节点。
比如删除
9:
text
删除前:
Level 1: 1 --------> 5 --------> 9 --------> 13
Level 0: 1 -> 3 -> 5 -> 7 -> 9 -> 11 -> 13
删除后:
Level 1: 1 --------> 5 --------------------> 13
Level 0: 1 -> 3 -> 5 -> 7 -------> 11 -> 13
本质上就是修改指针。
普通链表删除节点只需要修改一层指针,而跳表可能需要修改多层指针。
跳表为什么快?
问题:跳表为什么能做到平均
O(log n)?普通链表查找元素的时间复杂度是:
text
O(n)
跳表因为有多层索引,平均查找时间复杂度是:
text
O(log n)
O(log n)的意思是:数据量增长很多时,查找次数增长得很慢。举个例子:
| 数据量 n | 普通链表最坏查找次数 | 跳表平均查找次数 |
|---|---|---|
| 10 | 10 | 约 3~4 |
| 100 | 100 | 约 7 |
| 1000 | 1000 | 约 10 |
| 1000000 | 1000000 | 约 20 |
所以跳表非常适合处理大量有序数据的查找、插入和删除。
三、跳表的 C++ 代码实现
节点结构
问题:跳表的节点和普通链表节点有什么区别?
普通链表节点通常只有一个
next指针。
text
value + next
跳表节点有多个
forward指针,每个指针对应一层。
text
value + forward[0] + forward[1] + forward[2] + ...
可以理解为:
一个节点不只知道自己底层的下一个节点,还知道更高层的下一个索引节点。
完整 C++ 实现
问题:如何用 C++ 实现一个简单跳表?
下面这份代码实现了跳表的基本功能:
- 查找
search- 插入
insert- 删除
erase- 打印跳表
display
cpp
#include <iostream>
#include <vector>
#include <random>
#include <ctime>
using namespace std;
class Node {
public:
int value;
vector<Node*> forward;
Node(int value, int level) {
this->value = value;
this->forward.resize(level, nullptr);
}
};
class SkipList {
private:
int maxLevel;
float probability;
int currentLevel;
Node* head;
mt19937 generator;
uniform_real_distribution<float> distribution;
public:
SkipList(int maxLevel = 16, float probability = 0.5)
: maxLevel(maxLevel),
probability(probability),
currentLevel(1),
generator(static_cast<unsigned int>(time(nullptr))),
distribution(0.0, 1.0) {
head = new Node(-1, maxLevel);
}
~SkipList() {
Node* current = head;
while (current != nullptr) {
Node* next = current->forward[0];
delete current;
current = next;
}
}
int randomLevel() {
int level = 1;
while (distribution(generator) < probability && level < maxLevel) {
level++;
}
return level;
}
bool search(int target) {
Node* current = head;
for (int i = currentLevel - 1; i >= 0; i--) {
while (current->forward[i] != nullptr &&
current->forward[i]->value < target) {
current = current->forward[i];
}
}
current = current->forward[0];
return current != nullptr && current->value == target;
}
void insert(int value) {
vector<Node*> update(maxLevel, nullptr);
Node* current = head;
for (int i = currentLevel - 1; i >= 0; i--) {
while (current->forward[i] != nullptr &&
current->forward[i]->value < value) {
current = current->forward[i];
}
update[i] = current;
}
current = current->forward[0];
if (current != nullptr && current->value == value) {
return;
}
int newLevel = randomLevel();
if (newLevel > currentLevel) {
for (int i = currentLevel; i < newLevel; i++) {
update[i] = head;
}
currentLevel = newLevel;
}
Node* newNode = new Node(value, newLevel);
for (int i = 0; i < newLevel; i++) {
newNode->forward[i] = update[i]->forward[i];
update[i]->forward[i] = newNode;
}
}
void erase(int value) {
vector<Node*> update(maxLevel, nullptr);
Node* current = head;
for (int i = currentLevel - 1; i >= 0; i--) {
while (current->forward[i] != nullptr &&
current->forward[i]->value < value) {
current = current->forward[i];
}
update[i] = current;
}
current = current->forward[0];
if (current == nullptr || current->value != value) {
return;
}
for (int i = 0; i < currentLevel; i++) {
if (update[i]->forward[i] != current) {
break;
}
update[i]->forward[i] = current->forward[i];
}
delete current;
while (currentLevel > 1 && head->forward[currentLevel - 1] == nullptr) {
currentLevel--;
}
}
void display() {
for (int i = currentLevel - 1; i >= 0; i--) {
Node* current = head->forward[i];
cout << "Level " << i << ": ";
while (current != nullptr) {
cout << current->value;
if (current->forward[i] != nullptr) {
cout << " -> ";
}
current = current->forward[i];
}
cout << endl;
}
}
};
int main() {
SkipList skipList;
vector<int> nums = {3, 6, 7, 9, 12, 19, 17, 26, 21, 25};
for (int num : nums) {
skipList.insert(num);
}
cout << "初始跳表:" << endl;
skipList.display();
cout << endl;
cout << "查找 19:" << (skipList.search(19) ? "存在" : "不存在") << endl;
cout << "查找 100:" << (skipList.search(100) ? "存在" : "不存在") << endl;
cout << endl;
cout << "插入 15:" << endl;
skipList.insert(15);
skipList.display();
cout << endl;
cout << "删除 19:" << endl;
skipList.erase(19);
skipList.display();
return 0;
}
运行结果示例
问题:运行结果是不是每次都一样?
不一定。
因为跳表的层数是随机生成的,所以你每次运行时,高层索引可能不完全一样。
但是第 0 层一定是有序的。
text
初始跳表:
Level 3: 7
Level 2: 7 -> 17 -> 21
Level 1: 3 -> 7 -> 12 -> 17 -> 21 -> 25
Level 0: 3 -> 6 -> 7 -> 9 -> 12 -> 17 -> 19 -> 21 -> 25 -> 26
查找 19:存在
查找 100:不存在
插入 15:
Level 3: 7
Level 2: 7 -> 17 -> 21
Level 1: 3 -> 7 -> 12 -> 17 -> 21 -> 25
Level 0: 3 -> 6 -> 7 -> 9 -> 12 -> 15 -> 17 -> 19 -> 21 -> 25 -> 26
删除 19:
Level 3: 7
Level 2: 7 -> 17 -> 21
Level 1: 3 -> 7 -> 12 -> 17 -> 21 -> 25
Level 0: 3 -> 6 -> 7 -> 9 -> 12 -> 15 -> 17 -> 21 -> 25 -> 26
注意:上面的运行结果只是示例,真实输出的层数结构可能不同。
四、核心代码讲解
Node 节点
问题:
Node类的作用是什么?每个节点保存两个东西:
value:节点的值forward:一个数组,保存每一层的下一个节点
cpp
class Node {
public:
int value;
vector<Node*> forward;
Node(int value, int level) {
this->value = value;
this->forward.resize(level, nullptr);
}
};
如果一个节点的层数是 3,那么它就有 3 个
forward指针。
text
forward[0] -> 第 0 层的下一个节点
forward[1] -> 第 1 层的下一个节点
forward[2] -> 第 2 层的下一个节点
第 0 层是最底层,保存所有数据。
越往上的层,节点越少,主要用来加速查找。
randomLevel 随机层数
问题:为什么需要
randomLevel?跳表最有意思的地方就在这里。
它不是通过复杂算法来严格保持平衡,而是通过随机数决定节点层数。
cpp
int randomLevel() {
int level = 1;
while (distribution(generator) < probability && level < maxLevel) {
level++;
}
return level;
}
如果
probability = 0.5,那么大概会出现这种分布:
text
第 0 层:所有节点
第 1 层:约 1/2 的节点
第 2 层:约 1/4 的节点
第 3 层:约 1/8 的节点
第 4 层:约 1/16 的节点
层数越高,节点越少。
这就形成了类似"高速公路"的结构。
search 查找
问题:
search函数是怎么查找元素的?查找时,从最高层开始。
cpp
bool search(int target) {
Node* current = head;
for (int i = currentLevel - 1; i >= 0; i--) {
while (current->forward[i] != nullptr &&
current->forward[i]->value < target) {
current = current->forward[i];
}
}
current = current->forward[0];
return current != nullptr && current->value == target;
}
这段代码的核心逻辑是:
text
能往右走就往右走
不能往右走就往下走
因为链表是有序的,所以只要右边节点比目标值小,就说明还可以继续向右跳。
如果右边节点大于或等于目标值,就说明不能继续向右跳了,需要下降一层继续找。
当循环结束后,
current停在目标值的前一个位置。所以最后还需要执行:
cpp
current = current->forward[0];
这一步是移动到底层中可能等于目标值的节点。
insert 插入
问题:
insert函数是怎么插入元素的?插入之前,需要先找到每一层的前驱节点。
cpp
vector<Node*> update(maxLevel, nullptr);
update[i]表示第i层中新节点应该插入在哪个节点后面。
cpp
for (int i = currentLevel - 1; i >= 0; i--) {
while (current->forward[i] != nullptr &&
current->forward[i]->value < value) {
current = current->forward[i];
}
update[i] = current;
}
找到位置之后,先判断这个值是否已经存在。
cpp
current = current->forward[0];
if (current != nullptr && current->value == value) {
return;
}
如果已经存在,这里直接返回。
如果不存在,就随机生成新节点的层数。
cpp
int newLevel = randomLevel();
如果新节点的层数超过了当前跳表层数,需要更新跳表的当前层数。
cpp
if (newLevel > currentLevel) {
for (int i = currentLevel; i < newLevel; i++) {
update[i] = head;
}
currentLevel = newLevel;
}
然后创建新节点。
cpp
Node* newNode = new Node(value, newLevel);
最后修改每一层的指针,完成插入。
cpp
for (int i = 0; i < newLevel; i++) {
newNode->forward[i] = update[i]->forward[i];
update[i]->forward[i] = newNode;
}
这和普通链表插入很像。
普通链表只改一层指针,而跳表可能要改多层指针。
erase 删除
问题:
erase函数是怎么删除元素的?删除也是先找到每一层的前驱节点。
cpp
vector<Node*> update(maxLevel, nullptr);
Node* current = head;
for (int i = currentLevel - 1; i >= 0; i--) {
while (current->forward[i] != nullptr &&
current->forward[i]->value < value) {
current = current->forward[i];
}
update[i] = current;
}
然后判断目标节点是否存在。
cpp
current = current->forward[0];
if (current == nullptr || current->value != value) {
return;
}
如果存在,就让前驱节点跳过它。
cpp
for (int i = 0; i < currentLevel; i++) {
if (update[i]->forward[i] != current) {
break;
}
update[i]->forward[i] = current->forward[i];
}
因为 C++ 需要手动释放内存,所以删除节点后要执行:
cpp
delete current;
删除完成后,如果最高层已经没有节点,就降低跳表当前层数。
cpp
while (currentLevel > 1 && head->forward[currentLevel - 1] == nullptr) {
currentLevel--;
}
这样可以避免跳表保留没有意义的空层。
析构函数
问题:为什么要写析构函数?
C++ 不像 Java、Python 那样有自动垃圾回收,所以我们还写了析构函数释放所有节点。
cpp
~SkipList() {
Node* current = head;
while (current != nullptr) {
Node* next = current->forward[0];
delete current;
current = next;
}
}
因为第 0 层包含所有节点,所以只需要沿着第 0 层一路释放即可。
五、跳表和其他结构的对比
跳表和二分查找
问题:跳表和二分查找有什么区别?
二分查找通常用于数组。
数组可以通过下标快速访问中间位置,例如:
cpp
int mid = left + (right - left) / 2;
但是链表不能通过下标快速访问中间节点。
链表只能从头节点一路
next过去。跳表通过增加多级索引,让链表也能获得类似二分查找的效果。
| 对比项 | 二分查找 | 跳表 |
|---|---|---|
| 底层结构 | 数组 | 链表 |
| 是否有序 | 必须有序 | 必须有序 |
| 查找复杂度 | O(log n) |
平均 O(log n) |
| 插入删除 | 数组移动元素,成本较高 | 链表改指针,效率较高 |
| 实现难度 | 简单 | 中等 |
跳表和红黑树
问题:跳表和红黑树相比有什么特点?
跳表和红黑树都可以支持高效的查找、插入、删除。
它们的平均复杂度都可以达到
O(log n)。
| 对比项 | 跳表 | 红黑树 |
|---|---|---|
| 实现难度 | 相对简单 | 较复杂 |
| 平衡方式 | 随机层数 | 旋转和染色 |
| 查找效率 | 平均 O(log n) |
O(log n) |
| 插入删除 | 修改多层指针 | 可能需要旋转 |
| 范围查询 | 很方便 | 也支持 |
| 工程可读性 | 较好 | 较复杂 |
跳表的优势是实现简单,逻辑直观。
红黑树的优势是理论上更稳定,最坏情况下也是
O(log n)。
时间复杂度和空间复杂度
问题:跳表的时间复杂度和空间复杂度是多少?
跳表的平均性能很好,但最坏情况下可能退化成普通链表。
| 操作 | 平均时间复杂度 | 最坏时间复杂度 |
|---|---|---|
| 查找 | O(log n) |
O(n) |
| 插入 | O(log n) |
O(n) |
| 删除 | O(log n) |
O(n) |
为什么最坏是
O(n)?因为跳表依赖随机层数。
如果随机结果非常差,所有节点都只有一层,那么跳表就退化成普通链表。
但这种情况概率很低。
在实际工程中,跳表的平均性能非常好。
跳表需要额外保存多层索引,所以空间复杂度是:
text
O(n)
虽然每个节点可能有多个
forward指针,但高层节点数量会越来越少。所以整体空间仍然是线性的。
六、实际应用与总结
实际应用
问题:跳表在实际项目中真的会用到吗?
会。
跳表不是只存在于教科书里,它在真实工程中也很常见。
比较典型的应用有:
- Redis 的有序集合
ZSet- LevelDB、RocksDB 等存储系统中的内存表结构
- 有序数据的快速查找
- 排行榜系统
- 分数排序系统
- 范围查询场景
比如排行榜中,经常需要根据分数排序,还要支持快速插入、删除、查询排名。
这种场景就很适合使用跳表。
生活例子
问题:有没有更生活化的方式理解跳表?
假设你要在一本字典里找某个单词。
如果没有目录,你只能从第一页开始一页一页翻。
这就像普通链表。
如果字典有目录,你可以先通过目录快速定位到某个章节,然后再在章节里查找具体单词。
这就像跳表。
跳表的多层索引,其实就相当于多级目录。
text
普通链表:
第一页 -> 第二页 -> 第三页 -> 第四页 -> 第五页 -> ...
跳表:
总目录 -----------------------> 第五章
章节目录 --------> 第三节 -----> 第五节
正文页 -> 第1页 -> 第2页 -> 第3页 -> 第4页 -> 第5页
优缺点
问题:跳表有什么优点和缺点?
跳表的优点主要有:
- 查找、插入、删除的平均时间复杂度都是
O(log n)- 比红黑树更容易理解和实现
- 很适合范围查询
- 基于链表,插入和删除不需要移动大量元素
- 工程实现比较灵活
跳表的缺点主要有:
- 需要额外空间保存索引
- 性能依赖随机层数
- 最坏情况下可能退化成普通链表
- 对初学者来说,多层指针一开始不太直观
适用场景
问题:什么时候应该想到跳表?
当你遇到下面这些需求时,可以考虑跳表:
- 数据需要保持有序
- 需要频繁查找
- 需要频繁插入和删除
- 希望代码比平衡树更简单
- 需要支持范围查询
- 可以接受平均意义上的高性能
如果只是简单存储数据,不需要有序,也不需要范围查询,那么哈希表可能更合适。
最后总结
问题:学完跳表后,最应该记住什么?
跳表是一种非常巧妙的数据结构。
它本质上还是链表,但通过多层索引,让链表拥有了接近二分查找的能力。
普通链表只能一步一步走,而跳表可以一层一层跳。
跳表的核心思想可以总结成一句话:
text
用空间换时间,用多层索引加速有序链表的查找。
对于小白来说,理解跳表时不需要一开始就纠结代码细节。
你只要先记住这几个关键点:
- 跳表是有序链表的升级版
- 跳表有很多层索引
- 查找时从最高层开始
- 能向右就向右,不能向右就向下
- 插入时随机决定节点层数
- 平均查找、插入、删除复杂度都是
O(log n)当你理解了这些,再回头看 C++ 代码,就会发现跳表其实并没有想象中那么难。