数据结构-----跳表

2022-08-04,

本文转载自博客:https://blog.csdn.net/sinat_35261315/article/details/62890796

-------------------------------------------------------------------------------------------------------------------------------------------------------------------------

链表
同数组比较,链表的插入删除操作复杂度是O(1),然而如果想要查找一个节点,你可能会这么写:

LinkedNode<T> *targetNode = headerNode;
while(targetNode)
{
    if(targetNode->element == theElement)
        return targetNode;
    targetNode = targetNode->next;
}
return NULL;
1
2
3
4
5
6
7
8
你会发现每次都是从链表头节点开始,时间复杂度是n(节点个数)。为了提高算法速度,就有了跳表的概念。

二分法
介绍跳表之前,先简单解释一下二分法。 
对于一个有序序列来说,通常在查找某一个元素时,可以不需要从头到尾一个个比较,而是使用二分的方式,步骤如下: 
1.先将中间的元素和待查找元素比较。 
2.1:如果相等, 返回 
2.2:如果中间元素小于待查找元素,在右侧部分中查找(返回步骤1) 
2.3:如果中间元素大于待查找元素,在左侧部分中查找(返回步骤1)

跳表
结合链表和二分法的特点,将链表进行加工,创造一个二者的结合体: 
1.链表从头节点到尾节点是有序的 
2.可以进行跳跃查找(形如二分法)

next数组 
实现第一步,只需在插入的时候进行有序插入即可,核心步骤在第二步,构建一个可以实现在节点之间跳跃的链。 
对于普通链表而言,你的节点也许会长这个样子(或者再多个构造析构函数?):

template<class T>
class linkedNode
{
public:
    T element;
    linkedNode<T> *next;
};
1
2
3
4
5
6
7
这里的next指针是我们用于将此节点同后面一个节点连接的桥梁,正如你知道的那样,next是下一个节点的指针。 
需要你考虑的事情来了,如果需要在不挨着的节点之间实现跳转,那么对于某一个节点来说,它就需要存储一个以上的指向别的节点的next指针。多个同种类型的数据通常可以采用数组存储(vector, list等STL容器也可以啦),这样,我们的跳表节点或许大概差不多应该长这个样子:

template<class T>
class skipNode
{
public:
    T element;
    skipNode<T> **next; //一个存储着skipNode<T>*类型变量的一维数组
};
1
2
3
4
5
6
7
暂且不论next数组的大小是多少,先让我告诉你跳表的样子。 

你可能会觉得跳表这个东西很奇怪,不过最好不是因为我画的比较丑。先不管竖着的线是什么,横着看每一层好像都是一个链表,对吗。另外我觉得你也应该注意一下竖着看的时候每一个节点的名字,它们也都是一样的(不用考虑为什么有的节点有一个,有的有两个甚至更多,这是插入函数应该解决的问题)。

所以我想你现在脑子中可能有了对next数组的一种假设,尽管事实可能并不是你想的那样,不过不要紧,让我借着这张图告诉你next数组是什么。

通常我们会定义跳表的级数,简单来解释,就是层数-1(最下面一级是0级)。所以这张图表示的跳表的级数是(0, 1, 2)。而在第2级的节点(比如node5),它的next数组大小就是(2 + 1) == 3, 在第1级的节点(比如node4),它的next数组的大小就是(1 + 1) == 2, 在第0级的节点(比如node3),它的next数组大小是1;

所以在申请一个skipNode的指针时,传入这个节点所在的级数好像是理所应当的事情,然后对next进行初始化,把skipNode加工一下,像这样:

template<class T>
class skipNode<T>
{
public:
    skipNode(const T& theElement, size_t size):
        element(theElement)
    {
        next = new skipNode<T>*[size+1];
    }

    T element;
    skipNode<T> **next;
};
1
2
3
4
5
6
7
8
9
10
11
12
13
现在开始解释next数组,你最好看着上面的图: 
对于第2级的node5来说

node5->next[2] == tailNode;
node5->next[1] == node7;
node5->next[0] == node6;
1
2
3
对于第1级的node2来说

node2->next[1] == node4;
node2->next[0] == node3;
1
2
如果你还是不理解,我想你可以把每一列的同名节点看成是不同的节点,它们存的element数据是一样的,而且每个节点只有一个指向下一个节点的指针。再把每一层看成一个链表。然后再看一遍。最后把每一列合成一个节点,它们只是有一个指针数组而已,你应该这么想。

find 
我觉得这个函数可以帮助你更好地理解next数组的作用。如果没有,唔,怪我表达能力太弱。。。 

继续看着这张图,让我先解释一下headerNode和tailNode是什么(如果你因为它们的存在感到一丝困惑的话),它们是设计者(就是我们啦)人为添加的两端节点,很像存在头节点和尾节点的链表(又或者像队列?),它们不存储需要保存的有用的数据,但是需要人为给定一个数,让它比整个跳表中的element都大,仅仅是用来判断是否是头和尾。当跳表为空时,级数为0,headerNode->next[0] == tailNode;

现在回到上面那个图片上,如果我想要查找node6,想想二分法(但不是从中间开始)。 
假设targetNode记录着此时的节点,初值赋为headerNode;

第一次你应该比较的是headerNode->next[2]->element和theElement(待查找数据),也就是node5->element和theElement。显然node5小于theElement(别忘了跳表的数据也是有序的),这一步的结果导致下一次应该从第2级的node5开始查询。也就是令targetNode = targetNode->next[2];

第二次你应该比较targetNode->next[2]->element和theElement,也就是tailNode->element和theElement。看看前面,tailNode->element是最大的,对吗。所以结果是大于,这一步的结果导致下一次应该从第1级的node5开始查询。这里从第2级跳到第1级。但是没有改变targetNode。

第三次你应该比较targetNode->next[1]->element和theElement,也就是node7->element和theElement。显然也是大于,这一步的结果导致下一次应该从第0级的node5开始查询。这里从第1级跳到第0级。也没有改变targetNode。

第四次你应该比较targetNode->next[0]->element和theElement,也就是node6->element和theElement。这时你应该庆幸终于相等了,此时结束。如果小于,改变targetNode = targetNode->next[0](像第一步那样),如果大于,结束,不然还往哪一级降,没了,对吗。

没懂?接着看: 
比较的时候的三种情况,以targetNode->next[i]->element和theElement为例: 
1.小于:令targetNode = targetNode->next[i]; //第i级链表的下一个 
2.大于:向下降级,i- - //不改变targetNode 
3.等于:向下降级,i- - //不改变targetNode

退出后,再次比较targetNode->next[0]和theElement,判断是否找到。 
所以整个运算下来,targetNode是要查找的节点前面那个节点。如果你不明白,想想找不到那个节点时的情况,比如说,额,查找node6.6。 
让我们看看代码:

template<class K, class E>
pair<const K, E> *skipNode<K, E>::find(const K& theKey)
{
    skipNode<K, E>* beforeNode = headerNode;
    for(int i = curLevels; i >= 0; --i) //降级
    {
        while(beforeNode->next[i]->element.first < theKey)
            beforeNode = beforeNode->next[i]; //跳转到同一级的下一个
    }
    //跳出存在两种可能
    //1.找到了,此时 if(beforeNode->next[0]->element.first == theKey)
    //2.没找到,此时 if(beforeNode->next[0]->element.first > theKey)
    if(beforeNode->next[0]->element.first == theElement.first)
        return &beforeNode->next[0]->element;
    return NULL;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
insert 
像红黑树,AVL树一样,跳表也是一种平衡结构,只不过这种平衡结构是利用随机函数产生的级数维持的(终于填了前面的坑,还记得吗,为什么有的节点有2级,有的只有0级)。

接着上面那个图,想想插入node6.6的过程中会发生什么(假设随机函数给它分配的是第2级)。。。静思一秒钟。 
啊!插入之后会破坏每一级的结构,会破坏哪些节点的结构呢。。。静思一秒钟。 
啊!自然是第2级的node5,第1级的node5,第0级的node6这些节点的next数组。

分析过后,第一步便是找到这几个节点。我觉得你应该停一下想想怎么找,实在想不出来,参考find函数。 
find函数中,for循环内套while循环,想想每次while循环结束之后的beforeNode是什么。哇哦,它就是我们要找的节点。我们用一个last数组记录这些节点,last[i]表示第i级要找的节点。

第二步:利用随机函数分配级数level 
找节点是为了改变这些节点的next数组,分配级数是为了只改变找到的那些节点中位于第0级到第level级的节点。啊!你又想了,万一随机出的level比目前最大级数还大怎么办。这时就将跳表增加一级呗,像这样:

int level = levels();
if(level > curLevels)
{
    level = ++curLevels;
    last->next[level] = heaerNode; 
    //增加一级的同时也要改变last数组
    //用于将新节点插入时newNode->next[level] = last[level]->next[level]
}
1
2
3
4
5
6
7
8
万事俱备后,开始更新涉及到的节点的next数组,看代码之前,先想想怎么在链表中插入节点。

skipNode<K,E> *newNode = new skipNode<K, E>(thePair, level+1);

//因为只影响了[0, level]级的节点,更高级的节点不受影响
for(int i = 0; i <= level; ++i)
{
    //每一级都插入新节点
    newNode->next[i] = last[i]->next[i];
    last[i]->next[i] = newNode;
}
1
2
3
4
5
6
7
8
9
总结一下插入函数,需要两个额外的函数: 
1.随机函数,用于生成新节点的级数 
2.初始化last数组,保存每一级中处在待插入位置前面的节点

//随机函数,为新节点生成级数
template<class K, class E>
int skipList<K, E>::levels()
{
    int level = 0;
    //cutOff预先设定的值,cutOff = prob * MAX_RAND;
    //prob:是第i级同时又是第i-1级的概率,人为设定
    while(rand() > cutOff) 
        level++;
    return level;
}

template<class K, class E>
skipNode<K, E>* skipList<K, E>::search(const K& theKey)
{
    //与find函数相同
    skipNode<K, E>* beforeNode = headerNode;
    for(int i = curLevels; i >= 0; --i)
    {
        while(beforeNode->next[i]->element.first < theKey)
            beforeNode = beforeNode->next[i];
        last[i] = beforeNode; //找到每一级待插入位置前面的节点
    }

    //返回找到的节点,用于判断是否已存在键为theKey的节点
    return beforeNode->next[0];
}

template<class K, class E>
void skipList<K, E>::insert(const pair<const K, E>& thePair)
{
    if(thePair.first >= tailNode->element.first)
        return;

    skipNode<K, E> *theNode = search(thePair.first);
    if(theNode->element.first == thePair.first) //判断是否存在该节点
    {
        theNode->element.second = thePair.second; //更新值
        return;
    }

    int level = levels();
    //cutLevels:记录当前最大级数
    if(level > curLevels)
    {
        level = ++curLevels; //级数加一,让新节点作为最高级节点
        last[level] = headerNode;
    }

    //每一级如同链表插入一样进行插入
    skipNode<K, E> *newNode = new skipNode<K, E>(thePair, level+1);
    for(int i = 0; i <= level; ++i)
    {
        newNode->next[i] = last[i]->next[i];
        last[i]->next[i] = newNode;
    }
    dSize++;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
erase 
如果你理解了插入函数,删除函数会带给你从未有过的轻松感,至少比起还在文章开头徘徊的你是这样。噢对,还有一件事,或许你应该在回忆链表删除的状态里待几秒钟。如果没有头绪,插入函数最后一个for循环说不定会帮到你。

要删除一个节点,自然是先找到这个节点啦,同时还要初始化last数组啦。然后从第0级开始到最高级,如果last[i]->next[i]是要删除的节点,就删除掉。直接上代码:

template<class K, class E>
void skipList<K, E>::erase(const K& theKey)
{
    if(theKey >= tailNode->first)
        return;

    skipNode<K, E> *theNode = search(theKey);
    if(theNode->element.first != theKey)
        return; //不存在要删除的节点

    int i = 0;
    while(i <= curLevels && last[i]->next[i] == theNode)
    {
        //每一级的链表删除
        last[i]->next[i] = theNode->next[i];
        i++;
    }

    //如果删除的是最高级,且未删除前,最高级就一个节点,则当前节点减一
    while(last[curLevels]->next[curLevels] == tailNode)
        curLevels--;

    delete theNode;
    dSize--;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
现在唯一可能会让你感到头疼的就是构造函数了。想想构造函数应该做些什么,对数据成员进行初始化?啊,对,那是所有构造函数应该做的事情。我说的是具体应该怎么做。 
我猜你看上述代码的时候会对像cutOff, curLevels这些凭空出现的变量有点不理解,没关系,正如你想的那样,把它们放到private数据成员里。总结一下让你感到困惑的变量:

int dSize; //记录着跳表中节点的个数,初始为0
int curLevels; //记录跳表当前最大级数,如果是图片那样的话,它的值是2
int cutOff; //用于随机函数的随机算法,一个很大的值
int maxLevel; //跳表可以存储的最大级数
skipNode<K, E> *heaerNode; //跳表头节点,键是跳表中最大的
skipNode<K, E> *tailNode; //尾节点,键同headerNode
skipNode<K, E> **last; //记录每一级中要查找的节点前面那个节点
1
2
3
4
5
6
7
8
构造函数首先初始化这些变量,然后给节点申请内存,将头节点和尾节点连接起来(因为此时跳表是空的)。就这么简单。

//prob: 是第i级同时又是第i-1级的概率
//maxPairs: 跳表可以存储的最多节点个数
//largetKey: 最大键
template<class K, class E>
skipList<K, E>::skipList(K largerKey, int maxPairs, double prob):
    cutOff(prob * RAND_MAX),
    maxLevel((int)ceil(logf((float)maxPairs) / logf(1 / prob)) - 1),
    cutLevels(0),
    dSize(0)
{
    pair<K, E> tailPair;
    tailPair.first = largerKey;
    headerNode = new skipNode<K, E>(tailPair, maxLevel+1);
    tailNode = new skipNode<K, E>(tailPair, 0);
    last = new skipNode<K, E>*[maxLevel+1];

    for(int i = 0; i <= maxLevel; ++i)
    {
        headerNode->next[i] = tailNode; 
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
析构函数就没什么了,对第0级的每一个节点,释放它的next数组(可以写在skipNode的析构函数中),然后释放它自己。

完整代码下载
--------------------- 

本文地址:https://blog.csdn.net/woaitingting1985/article/details/85986748

《数据结构-----跳表.doc》

下载本文的Word格式文档,以方便收藏与打印。