文章目录
背景
数组-链表
- 数组优点
随机访问速度较快(基于下标访问)。
实现简单,使用简单。
内存地址连续,对cpu缓存很友好
- 数组缺点
内存连续可以是优点,也可以是缺点,如果在内存紧张的情况下,数组将会被大大限制。 插入和删除的时候会导致元素的移动(数据拷贝),较慢。
数组大小固定,大大的限制了元素的个数,对于很多动态的数据不友好。
- 链表有点
链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。
删除插入不用移动其他元素。
不受元素大小限制,可以随意扩展。
有序链表插入、删除值需要调整指针
- 链表缺点
不支持直接通过索引进行快速访问
失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。
随机访问效率相对数组来说较低。
优化链表随机访问的方法
与数组不同,链表中的节点是通过指针(或引用)连接起来的,每个节点只包含到下一个节点(以及在双向链表中到前一个节点)的链接。因此,要访问链表中的特定元素,通常需要从头节点开始逐个遍历,直到到达目标位置。这种访问方式的时间复杂度为O(n),其中n是要访问的节点的位置。
过以下方法来加速对特定位置的访问
链表加哈希表
以在链表之外维护一个数组或哈希表,用来存储指向链表中重要节点的指针。这样可以在一定程度上实现近似的随机访问,但会增加内存消耗和更新数据结构的开销
分块技术
将链表分成多个小块,并为每个块创建一个索引。这种方式类似于数据库中的索引,可以在一定程度上提高访问速度,但仍然不如数组那样高效
跳表
跳表是一种特殊的链表,它通过多级索引来加快查找速度。虽然不是真正的随机访问,但它提供了比普通链表更快的查找性能
介绍
跳表(SkipList),是一种随机化的数据结构,可以看作是一种可以进行二分查找的有序链表。跳表通过增加多级索引(即“跳跃”的层)来优化链表的查找、插入和删除操作,使得这些操作的时间复杂度降低到O(logn)。跳表在Redis和LevelDB等系统中都有应用,其实现和维护相对简单,但性能上却与红黑树、AVL树等平衡树不相上下。
跳表的理解
数据结构
- 跳表由多层链表组成,每一层都是一个有序链表。
- 底层链表包含所有元素,保持有序。
- 上层链表的元素是底层链表元素的子集,层数越高,元素越少。
- 每一层链表中的元素都通过指针(或称为“向前指针”)连接,同时,上层链表的节点还通过指针指向下层链表中的对应节点(或称为“向下指针”)。
索引机制
- 跳表通过随机技术决定哪些节点应增加向前指针以及在该节点中应增加多少个指针。
- 索引层级的分配是随机的,但通常遵循一定的概率分布,以保证跳表的平衡性。
操作原理 - 查找:从顶层链表开始,逐层向下查找,直到找到目标元素或确定目标元素不存在。
- 插入:首先通过查找确定插入位置,然后在原始链表中插入新节点,并根据随机概率决定是否将该节点添加到上层索引链表中。
- 删除:通过查找找到目标节点,然后依次从各层索引链表中删除该节点,最后从原始链表中删除该节点。
层数随机
跳表(Skip List)中的层之所以是随机的,主要是为了保持数据结构的平衡性,并且保证平均情况下能够达到O(log n)的时间复杂度进行查找、插入和删除操作。通过引入随机性,可以确保每一层的节点数量减少大约一半,从而形成类似于二分查找的效果。
为什么随机可以保证效率
通过概率分布来决定每个节点的高度(即它出现在哪些层级),可以使得跳表的层级结构趋于一种自然平衡状态,从而保证了平均情况下的高效查找、插入和删除操作
均匀分布
当我们以一定的概率p(通常为0.5)来决定是否增加一层时,实际上是在模拟一个几何分布。这种分布的特点是随着层数的增加,出现的概率呈指数级下降。
这意味着大多数节点只会出现在较低的层级,而只有少数节点会出现在较高的层级。这样的分布有助于保持整个结构的扁平化。
对数时间复杂度:
由于每一层上的节点数量大约是下一层的一半,因此从顶层到底层的查找过程类似于二分查找。这样就能够在O(log n)的时间内完成查找。
例如,如果最底层有n个节点,那么上一层大约有n/2个节点,再上一层大约有n/4个节点,以此类推。这与完全二叉树的结构相似,但跳表是一个动态的数据结构,允许高效的更新操作。
避免最坏情况:
如果不是随机分配高度,而是使用固定的模式或规则,可能会导致某些特定输入序列导致非常不平衡的结构,进而影响性能。例如,如果总是将新节点添加到最高层,那么跳表就会变得非常高且不均衡。
随机性帮助跳表避免了这种最坏情况的发生,即使在连续插入具有相同值的节点时,也能保持相对良好的平衡性。
实现细节
- 每层都是有序列表
- 生成随机层数:当插入一个新节点时,首先确定该节点的层数。这个层数是通过一个随机过程生成的,比如以0.5的概率决定是否增加一层。
- 维护多层索引:每个节点都可能出现在多个层级中,从最底层到它被随机选择的最高层。每层都是一个链表,高层的链表包含较少的节点,这些节点是从低层链表中选取出来的。
- 查找操作:从最高层开始向下搜索,直到找到目标值或确定不存在为止。每次比较后,如果当前节点大于目标值,则向右移动;如果小于目标值,则向下移动到下一层继续搜索。
通过这种方式,跳表利用了随机性的力量,创建了一个自适应且平衡的数据结构,能够在动态环境中提供高效的查找、插入和删除操作。随机性在这里起到了关键作用,因为它使得跳表能够自我调整,以适应不断变化的数据集,并保持其性能特性。
跳表与二分查找
跳表(Skip List)和二分查找(Binary Search)都是用于在有序数据集中进行高效查找的数据结构或算法,但它们的工作原理、适用场景以及实现方式都有所不同。
二分查找
定义:二分查找是一种在有序数组中查找特定元素的搜索算法。
工作原理:通过每次比较中间元素与目标值来缩小查找范围。如果中间元素正好是目标值,则查找过程结束;如果目标值大于或小于中间元素,则在数组的一半中继续进行相同的操作。
时间复杂度:O(log n),其中n是数组长度。 空间复杂度:O(1),因为它不需要额外的空间。
适用性:适用于静态且已排序的数组。如果数组经常变动,则需要频繁地重新排序,这会使得效率降低。
跳表与红黑数
尽管红黑树在许多情况下都能提供高效的查找、插入和删除操作,但跳表的存在仍然有其必要性和优势。以下是跳表相对于红黑树的一些优势
- 简单性:跳表的算法和数据结构相对简单,容易理解和实现。与红黑树相比,跳表不需要复杂的旋转和颜色调整等操作来维持树的平衡,这降低了实现的复杂性和出错的可能性。
- 灵活性:跳表在插入和删除操作时,只需要调整相邻的指针,而不需要像红黑树那样进行复杂的重新平衡操作。这使得跳表在处理动态数据集时更加灵活和高效。
- 有序性:跳表维护了元素的有序性,这使得范围查询等操作更加高效。虽然红黑树也可以通过中序遍历实现范围查询,但跳表通过其多层索引结构可以更快地定位到查询范围的起始和结束点。
- 空间效率:虽然跳表需要存储多级索引,从而消耗比红黑树更多的内存空间,但在某些应用场景下,这种空间消耗是可以接受的。特别是在内存数据库如Redis中,由于数据完全存储在内存中,因此空间效率并不是首要考虑的因素。
- 性能稳定性:跳表在插入和删除操作中,性能相对稳定,因为只会影响链表中的相邻节点。而红黑树虽然操作也是对数时间复杂度,但在最坏情况下(如连续插入有序元素)可能需要多次旋转和颜色变换来重新平衡树,从而影响性能。
- 应用场景:跳表特别适用于那些需要频繁进行范围查询、插入和删除操作的有序数据集。例如,Redis的有序集合就使用了跳表来存储数据,以支持高效的查找、插入和删除操作。
跳表与HASH
- 数据结构特点:跳表通过多级索引结构实现快速查询,适用于有序数据集;哈希表通过哈希函数将键映射到存储位置的索引上,实现快速的键值查找。
- 查询效率:跳表和哈希表都具有较高的查询效率,但哈希表在键值查找方面通常更快;跳表在有序集合操作和范围查询方面更具优势。
- 空间复杂度:哈希表通常只占用较少的额外空间(主要用于哈希表的扩容和哈希冲突的处理);跳表需要存储多级索引,因此会占用更多的空间。
- 应用场景:跳表更适合于需要保持元素有序的场景,如排行榜、有序集合、范围查询等;哈希表则更适用于键值存储、缓存系统、实时数据分析等场景
使用
跳表适用于需要频繁进行插入、删除和查找操作的数据集,尤其是当数据集较大时,跳表能够显著提高这些操作的效率。以下是一些跳表使用的具体场景:
- 数据库索引:在数据库系统中,跳表可以用作索引结构,以加速对大量数据的查询操作。
- 缓存系统:在缓存系统中,跳表可以用于管理缓存项的插入、删除和查找,以提高缓存的命中率和访问速度。
- 分布式系统:在分布式系统中,跳表可以用于实现分布式哈希表(DHT)等数据结构,以支持高效的键值对存储和检索。
- 有序集合的操作:跳表特别适用于实现有序集合(Sorted Set)的各种操作,如添加元素、删除元素、查找元素、获取排名、获取分数范围内的元素等。由于跳表通过多级索引结构实现快速查询,因此其时间复杂度为O(log n),能够高效地处理有序集合的相关操作。Redis中的有序集合(Zset)就采用了跳表作为底层数据结构之一,以实现快速的查找、插入和删除操作。
- 排行榜:由于跳表能够快速地定位到数据的位置,并获取其排名,因此非常适合用于实现排行榜功能。无论是网站的用户积分排行榜、游戏的玩家排名榜,还是电商平台的商品-
销量排行榜等,都可以通过跳表来实现高效的数据处理和展示。- 范围查询:跳表的多级索引结构使得范围查询变得非常高效。当需要查询某个分数范围内的所有元素时,跳表可以快速地定位到起始和结束位置,并遍历该范围内的所有元素。
实现
随机层数的实现
在跳表中,每个新插入的节点都会被赋予一个随机的高度(层数)。
这个高度决定了该节点会在哪些层级出现。通常,高度的选择基于一定的概率分布,最常见的做法是使用一个概率p(如0.5),来决定是否增加一层。具体来说:
为新节点分配第一层。
对于每一层之上的一层,以概率p决定是否继续添加。如果决定添加,则该节点也在这一层上出现;否则,停止。
例如,假设p=0.5:
1 节点总是出现在最底层(第0层)。
2 有50%的概率出现在第1层。
3 如果已经出现在第1层,那么再有50%的概率出现在第2层。
4 这个过程一直持续,直到某个层没有被选择为止。
这种随机性的引入使得跳表的结构变得不规则,但总体上仍然保持了良好的平衡性。随着节点数量的增加,跳表的高度也会增长,但是由于每增加一层的概率都是递减的,所以整个结构不会变得太高。
跳表实现以及测试
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <time.h>
#define MAX_LEVEL 16
#define RAND_MAX_FOR_LEVEL (RAND_MAX / MAX_LEVEL)
typedef struct Node {
int key;
struct Node **forward;
struct Node *left, *right;
} Node;
typedef struct SkipList {
pthread_mutex_t lock;
int level;
Node *header;
} SkipList;
void skiplist_init(SkipList *list) {
int i=0;
pthread_mutex_init(&list->lock, NULL);
list->level = 1;
list->header = malloc(sizeof(Node));
if(!list->header) return;
list->header->forward = malloc(sizeof(Node*) * MAX_LEVEL);
if(!list->header->forward) return;
list->header->left = list->header->right = list->header;
for (i = 0; i < MAX_LEVEL; i++) {
list->header->forward[i] = NULL;
}
}
int randomLevel() {
int lvl = 1;
while (rand() / RAND_MAX_FOR_LEVEL && lvl < MAX_LEVEL) lvl++;
return lvl;
}
Node* createNode(int level, int key) {
int i = 0;
Node *node = malloc(sizeof(Node));
node->forward = malloc(sizeof(Node*) * level);
node->key = key;
for ( i = 0; i < level; i++) {
node->forward[i] = NULL;
}
node->left = node->right = node; // 这些字段在此示例中未使用
return node;
}
void skiplist_insert(SkipList *list, int key) {
pthread_mutex_lock(&list->lock);
int lvl = randomLevel();
int i = 0;
if (lvl > list->level) {
for ( i = list->level; i < lvl; i++) {
list->header->forward[i] = NULL;
}
list->level = lvl;
}
Node *update[MAX_LEVEL];
Node *curr = list->header;
for ( i = lvl - 1; i >= 0; i--) {
while (curr->forward[i] && curr->forward[i]->key < key) {
curr = curr->forward[i];
}
update[i] = curr;
}
curr = curr->forward[0];
if (curr == NULL || curr->key != key) {
Node *newNode = createNode(lvl, key);
for ( i = 0; i < lvl; i++) {
newNode->forward[i] = update[i]->forward[i];
update[i]->forward[i] = newNode;
}
}
pthread_mutex_unlock(&list->lock);
}
Node* skiplist_search(SkipList *list, int key) {
pthread_mutex_lock(&list->lock);
Node *curr = list->header;
int i = 0;
for ( i = list->level - 1; i >= 0; i--) {
while (curr->forward[i] && curr->forward[i]->key < key) {
curr = curr->forward[i];
}
}
curr = curr->forward[0];
pthread_mutex_unlock(&list->lock);
return curr && curr->key == key ? curr : NULL;
}
void skiplist_delete(SkipList *list, int key) {
pthread_mutex_lock(&list->lock);
Node *update[MAX_LEVEL];
Node *curr = list->header;
int i = 0;
for ( i = list->level - 1; i >= 0; i--) {
while (curr->forward[i] && curr->forward[i]->key < key) {
curr = curr->forward[i];
}
update[i] = curr;
}
curr = curr->forward[0];
if (curr && curr->key == key) {
for (int i = 0; i < list->level; i++) {
if (update[i]->forward[i] != curr) break;
update[i]->forward[i] = curr->forward[i];
}
// 尝试减少跳表的层级(可选优化)
while (list->level > 1 && list->header->forward[list->level - 1] == NULL) {
list->level--;
}
free(curr);
}
pthread_mutex_unlock(&list->lock);
}
void skiplist_destroy(SkipList *list) {
// 遍历并释放所有节点(略,这里只释放了header)
Node *curr = list->header->forward[0];
while (curr) {
Node *next = curr->forward[0];
free(curr);
curr = next;
}
pthread_mutex_destroy(&list->lock);
free(list->header);
}
int main() {
srand(time(NULL));
SkipList list;
skiplist_init(&list);
int i = 0;
// 插入10个随机数据
for ( i = 0; i < 10; i++) {
int key = rand() % 100; // 生成0到99之间的随机数
printf("Inserting %d\n", key);
skiplist_insert(&list, key);
}
// 查找数据
int search_key = 50;
Node *found = skiplist_search(&list, search_key);
if (found) {
printf("Found %d\n", found->key);
} else {
printf("%d not found\n", search_key);
}
// 删除数据
search_key = 25;
printf("Deleting %d\n", search_key);
skiplist_delete(&list, search_key);
// 清理资源
skiplist_destroy(&list);
return 0;
}
编译
gcc -g sklist.c -o sklist -lpthread
测试结果