跳表这种数据结构,在普通有序链表基础上加了几层索引,从而达到 logN 级别的查询效率。
图片来自 wikipedia
详细介绍网上有很多文章,这里主要说一下如何使用 PHP 来实现一个跳表。关键点在于那“几层索引”如何在编码上实现。一般的链表每个节点有一个后继节点,这里则多个后继节点,一个节点层高多少,它就有多少个后继节点,每层一个,因此使用一个数组来保存各层的后继节点,也就是网上代码里边的 forwards 数组。来看节点的定义:
class SkipListNode
{
/**
* 数据本身
*
* @var null
*/
public $data;
/**
* 该节点的最高层级索引
*
* @var
*/
public $maxLevel;
/**
* 该节点在各个层级下的后继节点
* 默认空节点的第一层的后继节点为 null
*
* @var array
*/
public $forwards = [null];
}
每个节点的层高是随机的,最低有一层,假定它有下一层的概率为 p,当然要控制最高层级(这里限定最高 32 层),那么一个节点的层高由以下函数生成:
function randomLevel()
{
//层级索引从0开始,[0, 31]
$level = 0;
while (lcg_value() < $this->p && $level < $this->maxLevelCount - 1) {
$level += 1;
}
return $level;
}
对于插入操作,先确定节点层高,从此层开始搜索,自左而右,自上而下,找到最底层最后一个比待插入值小的节点。每一层最后一个比待插入值小的节点,就是待更新的节点,保存到 update 数组中。
对于查找操作,从当前跳表最高层级开始(有个属性存储当前最高层级),同样的找到最底层最后一个比待插入值小的节点。找得到并且它的下一个节点的值等于要查找的值,那么它下一个节点就是要查找的节点,否则为不存在。
对于删除操作,先查找到目标节点,若找不到直接返回。期间循环层级时同插入时一样保存待更新节点到 update 数组中,删除时循环 update 数组调整指针,只需要调整后继节点值等于待删除值的节点。
图片来自 cnblogs 网友
完整代码如下:
class SkipList
{
/**
* 允许的最大层数量,默认共32层,层级索引为 0 ~ 31
* @var int
*/
private $maxLevelCount;
/**
* 当前最大层级索引
* @var
*/
private $currentMaxLevel = 0;
/**
* 有下一级的概率,默认 0.25
* @var float
*/
private $p;
private $head;
public function __construct($maxLevelCount = 32, $p = 0.25)
{
$this->maxLevelCount = $maxLevelCount;
$this->p = $p;
$this->head = new SkipListNode();
}
/**
* 随机生成一个层级索引
*
* @return int
*/
function randomLevel()
{
//层级索引从0开始,[0, 31]
$level = 0;
while (lcg_value() < $this->p && $level < $this->maxLevelCount - 1) {
$level += 1;
}
return $level;
}
public function insert($value)
{
//获得新节点该在哪个层级
$level = $this->randomLevel();
$newNode = new SkipListNode();
$newNode->data = $value;
$newNode->maxLevel = $level;
if ($level > $this->currentMaxLevel) {
//更新当前最大层级索引
$this->currentMaxLevel = $level;
}
$currentNode = $this->head;
//待更新的节点数组,保存每一层最后一个小于待插入值的节点
$update = [];
for ($i = $level; $i >= 0; $i--) {
//循环完成一次,就是向下移动一层
while (isset($currentNode->forwards[$i]) && $currentNode->forwards[$i]->data !== null && $currentNode->forwards[$i]->data < $value) { //向后继节点移动 $currentNode = $currentNode->forwards[$i];
}
$update[$i] = $currentNode;
}
echo "to insert: $value, level: $level, currentNode value: " . $currentNode->data . PHP_EOL;
for ($j = 0; $j <= $level; $j++) { //更新指针,就是普通链表的操作,新节点的后继节点为前节点的后继节点 //这里有可能待更新的节点层级没有新节点的高导致 forwards 数组中数据不存在,当不存在是即指向 null $newNode->forwards[$j] = isset($update[$j]->forwards[$j]) ? $update[$j]->forwards[$j]: null;
//原前节点的后继节点是新节点
$update[$j]->forwards[$j] = $newNode;
}
return $newNode;
}
public function find($value)
{
$node = $this->head;
for ($i = $this->currentMaxLevel; $i >= 0; $i--) {
while (isset($node->forwards[$i]) && $node->forwards[$i]->data !== null && $node->forwards[$i]->data < $value) { $node = $node->forwards[$i];
}
}
//前边寻找到最后一个小于待查找值的节点,它的下一个要么是要找的值,要么就找不到了。
if ($node->forwards[0] !== null && $node->forwards[0]->data == $value) {
return $node->forwards[0];
}
return null;
}
public function delete($value)
{
//类似插入,找到所要有更新的节点数组
$currentNode = $this->head;
$update = [];
for ($i = $this->currentMaxLevel; $i >= 0; $i--) {
//循环完成一次,就是向下移动一层
while (isset($currentNode->forwards[$i]) && $currentNode->forwards[$i]->data !== null && $currentNode->forwards[$i]->data < $value) { //向后继节点移动 $currentNode = $currentNode->forwards[$i];
}
$update[$i] = $currentNode;
}
$toDelete = $currentNode->forwards[0];
if ($toDelete === null || $toDelete->data != $value) {
return false;
}
for ($j = $this->currentMaxLevel; $j >= 0; $j--) {
$next = $update[$j]->forwards[$j];
if ($next !== null && $next->data == $value) {
//此类节点需要调整指针
$update[$j]->forwards[$j] = $next->forwards[$j];
}
}
return $toDelete;
}
public function dump()
{
$node = $this->head;
$data = [];
while (isset($node->forwards[0])) {
$data[] = $node->forwards[0]->data;
$node = $node->forwards[0];
}
print_r(implode(' -> ', $data));
echo PHP_EOL;
}
}
redis 中有序集合使用了跳表,有面试题会问为何要使用它而不是使用一些平衡树结构比如红黑树之类的。看了实现不难得出:
首先,平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速。
其次,对于很常见的范围查找 zrange(),跳表实现起来非常轻松,找到最小值节点后,向后遍历即可。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。
第三,对内存控制更灵活,通过修改 p 的值从而改变各节点层高的概率分布,层高越高索引层级越多内存占用越多但查找更快,可以按需选择。
最后,算法实现上简单很多,方便调试。