<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>数据结构归档 - 枫阿雨&#039;s blog</title>
	<atom:link href="https://www.crazyfay.com/tag/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.crazyfay.com/tag/数据结构/</link>
	<description>CrazyFay</description>
	<lastBuildDate>Thu, 25 May 2023 06:31:52 +0000</lastBuildDate>
	<language>zh-CN</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.5.2</generator>

<image>
	<url>https://www.crazyfay.com/wp-content/uploads/2023/04/cropped-DockerGopher-32x32.png</url>
	<title>数据结构归档 - 枫阿雨&#039;s blog</title>
	<link>https://www.crazyfay.com/tag/数据结构/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>LazySkiplist: A Simple Optimistic skip-list Algorithm论文翻译与实现</title>
		<link>https://www.crazyfay.com/2023/05/14/lazyskiplist-a-simple-optimistic-skip-list-algorithm%e8%ae%ba%e6%96%87%e7%bf%bb%e8%af%91%e4%b8%8e%e5%ae%9e%e7%8e%b0/</link>
					<comments>https://www.crazyfay.com/2023/05/14/lazyskiplist-a-simple-optimistic-skip-list-algorithm%e8%ae%ba%e6%96%87%e7%bf%bb%e8%af%91%e4%b8%8e%e5%ae%9e%e7%8e%b0/#respond</comments>
		
		<dc:creator><![CDATA[crazyfay]]></dc:creator>
		<pubDate>Sun, 14 May 2023 06:47:28 +0000</pubDate>
				<category><![CDATA[学习笔记]]></category>
		<category><![CDATA[数据结构]]></category>
		<category><![CDATA[论文笔记]]></category>
		<guid isPermaLink="false">https://www.crazyfay.com/?p=318</guid>

					<description><![CDATA[<p>最近在研究无锁跳表，无意间发现了这篇论文，虽然是有锁的实现，但是采用了乐观同步的机制，是一个理论上简单且高效的 [&#8230;]</p>
<p><a rel="nofollow" href="https://www.crazyfay.com/2023/05/14/lazyskiplist-a-simple-optimistic-skip-list-algorithm%e8%ae%ba%e6%96%87%e7%bf%bb%e8%af%91%e4%b8%8e%e5%ae%9e%e7%8e%b0/">LazySkiplist: A Simple Optimistic skip-list Algorithm论文翻译与实现</a>最先出现在<a rel="nofollow" href="https://www.crazyfay.com">枫阿雨&#039;s blog</a>。</p>
]]></description>
										<content:encoded><![CDATA[<p>最近在研究无锁跳表，无意间发现了这篇论文，虽然是有锁的实现，但是采用了乐观同步的机制，是一个理论上简单且高效的并发安全的跳表实现。苦于国内网上少有针对此篇论文的翻译与解读，本篇博客致力于翻译此篇论文，并基于 <a href="https://github.com/zhangyunhao116/skipmap">zhangyunhao116/skipmap</a> 的开源代码逻辑，深入理解篇论文的思想。</p>
<blockquote>
<p>论文原文:<br />
<a href="https://people.csail.mit.edu/shanir/publications/LazySkipList.pdf">https://people.csail.mit.edu/shanir/publications/LazySkipList.pdf</a></p>
</blockquote>
<h2>论文翻译</h2>
<h3>摘要</h3>
<p>由于跳跃表具有高度分布式的特性和缺乏全局再平衡机制，它们正在成为并发应用中越来越重要的对数搜索结构。不幸的是，文献中的并发跳跃表实现，无论是基于锁还是无锁的，都没有被证明是正确的。此外，这些算法的复杂结构很可能是缺乏证明的原因，也给希望扩展和修改算法或在其基础上构建新结构的软件设计人员带来了障碍。</p>
<p>本文提出了一种简单的基于锁的并发跳跃表算法。与其他并发跳跃表算法不同，该算法始终保持跳跃表的性质，便于推理其正确性。尽管它是基于锁的，但该算法通过一种新颖的乐观同步方式实现了高度的可扩展性：在添加或删除节点之前，它在不获取锁的情况下进行搜索，仅需要进行短暂的基于锁的验证。实验证据表明，在最常见的搜索结构使用模式下，这种更简单的算法与已知的最佳无锁算法的性能相当。</p>
<h3>1 引言</h3>
<p>跳跃表（Skip-lists）^[11]^是一种越来越重要的数据结构，用于存储和检索有序的内存中数据。在本文中，我们提出了一种新的基于锁的并发跳跃表算法，该算法在大多数常见的使用条件下似乎与现有的最佳并发跳跃表实现表现相当。我们实现的主要优点在于它更简单，更易于推理。<br />
Pugh^[10]^提出的原始基于锁的并发跳跃表实现由于使用指针反转而变得相当复杂，据我们所知，尚未证明其正确性。Doug Lea^[8]^根据Fraser和Harris^[2]^的工作编写的ConcurrentSkipListMap，并作为Java SE 6平台的一部分发布，是我们所知的最有效的并发跳跃表实现。该算法是无锁的，并且在实践中表现良好。这种实现的主要限制在于它过于复杂。某些交错操作可能导致通常的跳跃表不变式被违反，有时是暂时性的，有时是永久性的。这些违规似乎不会影响性能或正确性，但它们使得很难推理算法的正确性。相比之下，此处介绍的算法基于锁，并且始终保持跳跃表的不变性。该算法足够简单，我们能够提供直接的正确性证明。<br />
我们的新颖基于锁的算法的关键是两种互补的技术的结合。首先，它是乐观的：方法在不获取锁的情况下遍历列表。此外，在遍历列表时，它们可以忽略其他线程获取的锁。只有当方法发现所需的项时，才会锁定该项及其前驱，并验证列表是否未更改。其次，我们的算法是延迟的：删除项涉及在物理上将其移除（取消链接）之前，通过标记逻辑上删除它。<br />
Lea^[7]^观察到最常见的搜索结构使用模式中，搜索操作明显占主导地位，插入操作占主导地位，而删除操作占很小比例。一个典型的模式是90％的搜索操作，9％的插入操作和仅有1％的删除操作（参见^[3]^）。在Sun Fire T2000多核和Sun Enterprise 6500上进行的初步实验测试表明，尽管我们新的乐观基于锁的算法非常简单，但在这种常见的使用模式下，它的性能与Lea的ConcurrentSkipListMap算法相当。实际上，只有在多程序环境中出现极端争用的不常见条件下，我们的新算法才比ConcurrentSkipListMap算法表现更好。这是因为我们的原始实验实现没有添加任何争用控制。 </p>
<p>因此，我们相信在需要理解并可能修改基本跳跃表结构的应用程序中，所提出的算法可能是ConcurrentSkipListMap算法的一个可行替代方案。</p>
<p><img decoding="async" src="http://43.130.6.58:8080/wp-content/uploads/2023/05/f1.png" alt="Figure 1" /><br />
Figure 1: 一个最大高度为4的跳表。每个节点下方的数字（即next指针数组）是该节点的键，其中左侧哨兵节点的键为-∞，右侧哨兵节点的键为+∞。</p>
<h3>2 背景</h3>
<p>跳跃表（Skip-list）^[11]^是一种按键排序的链表。每个节点被分配一个随机的高度，高度最大值有限。在任何一个高度上的节点数以指数方式减少。跳跃表节点在每个高度上都有一个后继节点。例如，一个高度为3的节点有三个下一个指针，一个指向高度为1的下一个节点，另一个指向高度为2的下一个节点，依此类推。图1显示了一个带有整数键的跳跃表。</p>
<p>我们将跳跃表看作具有多个层次的列表，并且我们讨论每个层次上节点的前驱和后继。除了底层之外，每个层次上的列表是下一层级列表的子列表。由于更高的高度上节点的数量指数级减少，因此我们可以通过首先在较高的层次上进行搜索来快速找到键，跳过较短节点的大量数量，并逐渐向下工作，直到找到具有所需键的节点，或者到达底层。因此，跳跃表操作的期望时间复杂度是列表长度的对数。</p>
<p>在列表的开始和结束处，有左哨兵和右哨兵节点，这样比较方便。这些节点具有最大的高度，当跳跃表为空时，右哨兵在每个层次上都是左哨兵的后继节点。左哨兵的键值比任何可能添加到集合中的键都要小，而右哨兵的键值比任何可能添加到集合中的键都要大。因此，搜索跳跃表始终从左哨兵开始。</p>
<pre><code class="language-java">class Node {
    int key;
    int topLayer;
    Node ∗∗ nexts;
    bool marked;
    bool fullyLinked;
    Lock lock;
} </code></pre>
<p>Figure 2: A node</p>
<h3>3 我们的算法</h3>
<p>我们在支持三个方法的集合对象的实现的背景下介绍我们的并发跳跃表算法，这三个方法是add、remove和contains：<code>add(v)</code>将v添加到集合中，并且当v不在集合中时返回true；<code>remove(v)</code>从集合中删除v，并且当v在集合中时返回true；<code>contains(v)</code>当v在集合中时返回true。我们展示了我们的实现是可线性化的^[6]^，也就是说，每个操作似乎在其调用和响应之间的某个点（线性化点）上以原子方式执行。我们还展示了该实现是无死锁的，并且contains操作是无等待的；也就是说，只要线程不停地执行步骤，它就保证能完成contains操作，而不受其他线程活动的影响。</p>
<p>我们的算法建立在Heller等人的懒惰列表算法^[4]^基础上，这是一个简单的并发链表算法，其add和remove操作使用乐观细粒度锁定机制，而contains操作是无等待的：我们在跳跃表的每个层次上使用懒惰列表。与懒惰列表一样，每个节点的Key严格大于其前驱节点的Key，并且每个节点都有一个标记标志，用于使remove操作看起来是原子的。然而，与简单的懒惰列表不同，我们可能需要在多个层次上链接节点，因此可能无法通过单个原子指令插入节点，该指令可以作为成功添加操作的线性化点。因此，在懒惰跳跃表中，我们为每个节点添加了一个额外的标志fullyLinked，在节点在所有层次上都被链接之后将其设置为true；设置此标志是我们跳跃表实现中成功添加操作的线性化点。图2显示了一个节点的字段。</p>
<p>如果且仅当列表中存在一个未标记、完全链接的具有该Key的节点时（即从左哨兵可达），Key就在抽象集合中。 </p>
<p>为了维护跳跃表的不变性，即每个列表都是较低层次列表的子列表，在对需要修改的所有节点获取锁时才会对列表结构（即next指针）进行更改。（有一个例外是涉及添加操作的规则，稍后讨论。）</p>
<p>在对算法的下面详细描述中，我们假设存在垃圾收集器来回收从跳跃表中删除的节点，因此在任何线程可能仍然访问这些节点时，删除的节点不会被重新利用。在证明（第4节）中，我们假设节点永远不会被重新利用。在没有垃圾回收的编程环境中，我们可以使用解决重复使用问题的解决方案^[5]^或危险指针[hazard pointers] ^[9]^来实现相同的效果。我们还假设Key是从MinInt+1到MaxInt-1的整数。我们使用MinInt和MaxInt作为LSentinel和RSentinel的Key，它们分别是左哨兵和右哨兵节点。 </p>
<p>在跳跃表中进行搜索是通过findNode辅助函数完成的（见图3），该函数接受一个键v和两个最大高度的节点指针数组preds和succs，并且与顺序跳跃表完全一样进行搜索，从最高层开始，每次遇到大于或等于v的节点时向下一个更低的层继续搜索。线程在preds数组中记录了在每个层次上遇到的最后一个键小于v的节点及其后继节点（后继节点的键必须大于或等于v）。如果找到具有所需Key的节点，findNode返回找到此类节点的第一个层次的索引；否则，返回-1。为了简化演示，即使在更高层次找到具有所需键的节点，findNode也会继续执行到底层，因此在findNode终止后，preds数组和succs数组中的所有条目都被填充（请参见第3.4节，了解实际实现中使用的优化）。请注意，findNode不获取任何锁，也不会在出现与其他线程冲突的访问时进行重试。现在我们逐个考虑每个操作。</p>
<pre><code class="language-java">int findNode( int v, Node∗ preds[], Node∗ succs[]) {
    int lFound = -1;
    Node∗ pred = &amp;LSentinel ;
    for(int layer = MaxHeight −1; layer ≥ 0; layer−−){
        Node∗ curr = pred −&gt; nexts[layer];
        while(v &gt; curr−&gt;key) {
            pred = curr;
            curr = pred−&gt;nexts[layer];
    }
    if(lFound == −1 &amp;&amp; v == curr −&gt; key){
        lFound = layer;
    }
    preds[layer] = pred;
    succs [layer] = curr;
    }
    return lFound;
}</code></pre>
<p>Figure 3: The findNode helper function</p>
<h5>3.1 添加操作</h5>
<p>添加操作（见图4）调用findNode函数来确定列表中是否已存在具有该键的节点。如果存在（第59-66行），并且该节点未被标记，则添加操作返回false，表示键已经在集合中。然而，如果该节点尚未完全链接，则线程将等待，直到它完全链接（因为在节点完全链接之前，该键不在抽象集合中）。如果该节点被标记，则表示另一个线程正在删除该节点，因此执行添加操作的线程只需重试。</p>
<p>如果没有找到具有适当键的节点，则线程会锁定并验证findNode返回的所有前驱节点，直到达到新节点的高度（第69-84行）。该高度由add操作的开始处使用randomLevel函数确定。验证（第81-83行）检查对于每个层次i ≤ topNodeLayer，preds[i]和succs[i]是否在第i层仍然相邻，并且都没有被标记。如果验证失败，线程遇到了冲突的操作，因此释放它获取的锁（在第97行的finally块中）并重试。</p>
<p>如果线程成功锁定并验证findNode结果直到新节点的高度，则添加操作将保证成功，因为线程将持有所有锁，直到完全链接其新节点。在这种情况下，<strong>线程分配一个具有对应键和高度的新节点，将其链接到列表中，设置新节点的fullyLinked标志（这是添加操作的线性化点）</strong>，然后在释放所有锁之后返回true（第86-97行）。写入newNode-&gt;nexts[i]的线程是唯一一种情况，其中线程修改了它没有锁定的节点的nexts字段。这是安全的，因为在线程将preds[i]-&gt;nexts[i]设置为newNode之后，newNode不会在第i层链接到列表中，而是在写入newNode-&gt;nexts[i]之后才链接。</p>
<pre><code class="language-java">bool add (int v){
    int topLayer = randomLevel(MaxHeight);
    Node∗ preds [MaxHeight] , succs[MaxHeight];
    while(true){
        int lFound = findNode(v, preds, succs);
        if(lFound != −1){
            Node∗ nodeFound = succs[lFound];
            if(!nodeFound−&gt;marked){
                while (!nodeFound−&gt;fullyLinked){;}
                return false;
            }
            continue;
        }
        int highestLocked = −1;
        try{
            Node ∗pred, ∗succ, ∗prevPred = null;
            bool valid = true;
            for (int layer = 0;  valid &amp;&amp; (layer ≤ topLayer); layer++){
                pred = preds[layer];
                succ = succs[layer];
                if(pred != prevPred) {
                    pred−&gt;lock.lock();
                    highestLocked = layer;
                    prevPred = pred;
                }
                valid = !pred−&gt;marked &amp;&amp; !succ−&gt;marked &amp;&amp; pred−&gt;nexts[layer] == succ;
            }
            if(!valid) continue;
            Node∗ newNode = new Node (v, topLayer);
            for(int layer = 0; layer ≤ topLayer; layer++){
                newNode−&gt;nexts[layer] = succs [layer];
                preds[layer] −&gt; nexts[layer] = newNode;
            }
            newNode −&gt; fullyLinked = true;
            return true;
        } finally{
            unlock(preds, highestLocked);
        }
    }
}</code></pre>
<h5>3.2 删除操作</h5>
<p>删除操作（见图5）同样调用findNode函数来确定列表中是否存在具有适当键的节点。如果存在，则线程检查该节点是否“可以删除”（图6），这意味着它完全链接、未被标记，并且它是在其顶层找到的节点。如果节点满足这些要求，线程将锁定该节点并验证它是否仍未被标记。如果是这样，线程将标记该节点，逻辑上删除它（第111-121行）；也就是说，<strong>节点的标记是删除操作的线性化点</strong>。</p>
<p>该过程的其余部分完成了“物理”删除操作，通过先锁定其所有层次上的前驱节点（直到删除节点的高度为止）（第124-138行），然后一次将节点从列表中剪接出来（第140-142行），从而将节点从列表中删除。为了维护跳跃表结构，节点在从更高层次拼接出来之前先从较低层次拼接出来（尽管为了确保没有死锁，如第4节所讨论的，锁的获取顺序是从较低层次向上获取）。与添加操作类似，在更改任何已删除节点的前驱节点之前，线程验证这些节点确实是已删除节点的前驱节点。这是通过weakValidate函数完成的，该函数与validate相同，只是当后继节点被标记时不会失败，因为在这种情况下，后继节点应该是刚刚被标记的待删除的节点。如果验证失败，则线程释放对旧前驱节点的锁定（但不包括已删除的节点），并尝试通过再次调用findNode来找到已删除节点的新前驱节点。然而，此时它已经设置了局部的isMarked标志，以便不会尝试标记另一个节点。在成功从列表中删除已删除节点后，线程释放所有锁，并返回true。</p>
<p>如果未找到节点，或者找到的节点不是“可以删除”的（即已被标记、未完全链接或未在其顶层找到），则操作简单地返回false（第148行）。容易看出，如果节点未被标记，这是正确的，因为对于任何键，跳跃表中最多只有一个具有该键的节点（即从左哨兵可达），并且一旦节点被放入列表中（必须通过findNode找到），它就不会被删除，直到被标记。然而，如果节点已被标记，该论证则变得更加棘手，因为在找到该节点时，它可能不在列表中，而列表中可能有具有相同键的某个未标记节点。然而，正如我们在第4节中所论证的那样，这种情况下，在执行删除操作期间必定存在某个时刻，该键不在抽象集合中。</p>
<pre><code class="language-java">bool remove (int v){
    Node∗ nodeToDelete = null;
    bool isMarked = false;
    int topLayer = −1;
    Node∗ preds[MaxHeight], succs[MaxHeight];
    while(true){
        int lFound = findNode(v, preds, succs);
        if(isMarked || (lFound != −1 &amp;&amp; okToDelete(succs[lFound],lFound))){
            if(!isMarked){
                nodeToDelete = succs[lFound];
                topLayer = nodeToDelete −&gt; topLayer;
                nodeToDelete −&gt; lock.lock();
                if(nodeToDelete−&gt;marked) {
                    nodeToDelete−&gt;lock.unlock ();
                    return false;
                }
            nodeToDelete−&gt;marked = true;
            isMarked = true;
            }
            int highestLocked = −1;
            try {
                Node ∗pred, ∗succ, ∗prevPred = null;
                bool valid = true;
                for (int layer = 0; valid &amp;&amp; (layer ≤ topLayer); layer++) {
                    pred = preds[layer];
                    succ = succs[layer];
                    if (pred != prevPred) {
                        pred−&gt;lock.lock();
                        highestLocked = layer;
                        prevPred = pred;
                    }
                    valid = ! pred−&gt;marked &amp;&amp; pred−&gt;nexts[layer] == succ;
                }
                if (!valid) continue;
                for (int layer = topLayer; layer ≥ 0; layer−−) {
                    preds [layer]−&gt;nexts [layer] = nodeToDelete−&gt;nexts [layer];
                }
                nodeToDelete−&gt;lock.unlock();
                return true;
            } finally {
             unlock (preds, highestLocked);
            }
        }
        else return false;
    }
}</code></pre>
<p>Figure 5: The remove method</p>
<pre><code class="language-java">bool okToDelete ( Node∗ candidate , int lFound ) {
    return (candidate−&gt;fullyLinked
                &amp;&amp; candidate−&gt;topLayer==lFound
                &amp;&amp; !candidate−&gt;marked);
}</code></pre>
<p>Figure 6: The okToDelete method</p>
<pre><code class="language-java">bool contains (int v) {
    Node∗ preds [MaxHeight] , succs[MaxHeight] ;
    int lFound = findNode (v, preds, succs) ;
    return ( lFound != −1
                &amp;&amp; succs [lFound]−&gt;fullyLinked
                &amp;&amp; !succs [lFound]−&gt;marked ) ;
}</code></pre>
<p>Figure 7: The contains method</p>
<h5>3.3 包含操作</h5>
<p>最后，我们考虑包含操作（见图7），它只是调用findNode，并且当且仅当找到一个未标记、完全链接的具有适当键的节点时返回true。如果找到这样的节点，则根据定义可以立即得出结论，该键在抽象集合中。然而，如上所述，如果该节点已被标记，那么很难看出返回false是安全的。我们在第4节中对此进行了论证。</p>
<h5>3.4 实现问题</h5>
<p>我们使用Java编程语言实现了该算法，以便与Doug Lea在java.util.concurrent包中的非阻塞跳跃表实现进行比较。伪代码中的数组堆栈变量被线程本地变量所取代，我们使用了直接的锁实现（我们无法使用内置的对象锁，因为我们的获取和释放模式无法始终使用synchronized块来表示）。</p>
<p>所呈现的伪代码针对简单性而不是效率进行了优化，可以改进的方式有很多，我们在实现中应用了其中许多方式。例如，如果找到具有适当键的节点，则添加和包含操作无需继续查找；它们只需要确定该节点是否完全链接且未标记。如果是这样，包含操作可以返回true，添加操作可以返回false。如果不是，则包含操作可以返回false，添加操作要么在返回false之前等待（如果节点未完全链接），要么必须重试。然而，删除操作确实需要搜索到底层以找到要删除的节点的所有前驱节点，但是一旦找到并标记了某一层上的节点，它可以在较低层次上搜索确切的节点，而无需比较键。这是正确的，因为一旦线程标记了节点，其他线程就无法取消链接它。</p>
<p>此外，在伪代码中，findNode总是从可能的最高层开始搜索，尽管我们预期大部分时间最高层将是空的（即只有两个哨兵节点）。通过维护一个跟踪最高非空层的变量，很容易因为每当该变量发生变化时，导致变化的线程必须锁定左哨兵。与非阻塞版本相比，这种容易性存在差异，因为并发的删除和添加操作之间的竞争可能导致记录的跳跃表高度小于其最高节点的实际高度。</p>
<h3>4 正确性</h3>
<p>在本节中，我们概述我们的跳跃表算法的证明。我们要展示四个属性：算法实现了可线性化的集合、它是无死锁的、contains操作是无等待的，以及底层数据结构维护了一个正确的跳跃表结构，我们将在下面更详细地定义。</p>
<h4>4.1 可线性化性</h4>
<p>在证明过程中，我们对初始化做出了以下简化假设：节点被初始化为具有其键和高度，它们的nexts数组被初始化为全部为null，它们的fullyLinked和marked字段被初始化为false。此外，为了推理的目的，我们假设节点永远不会被回收，并且有无穷无尽的新节点可用（否则，我们需要修改算法以处理节点耗尽的情况）。</p>
<p>我们首先得出以下观察结果：节点的键永远不会更改（即key = k是稳定的），节点的marked和fullyLinked字段永远不会被设置为false（即marked和fullyLinked是稳定的）。虽然初始时nexts[i]为null，但它永远不会被写入null（即nexts[i] 6= null是稳定的）。此外，线程只有在持有节点的锁时才会写入节点的marked或nexts字段（唯一的例外是在链接节点到第i层之前的add操作中写入节点的nexts[i]）。</p>
<p>根据这些观察结果和对代码的检查，我们可以很容易地看出，在任何操作中，在调用findNode后，对于所有的i，我们有preds[i]-&gt;key &lt; v和succs[i]-&gt;key ≥ v，并且对于i &gt; lFound（即findNode返回的值），有succs[i]-&gt;key &gt; v。此外，在remove操作中，只有在该节点被其他线程标记之前，nodeToDelete才会被设置一次，并且此线程在完成操作之前，线程的isMarked变量将始终为true。我们还通过okToDelete知道节点是完全链接的（实际上只有完全链接的节点才能被标记）。</p>
<p>此外，验证和在写入节点之前锁定节点的要求确保在成功验证后，验证检查的属性（对于add和remove而言略有不同）在释放锁之前保持为true。</p>
<p>我们可以利用这些属性得出以下基本引理（fundamental lemma）：</p>
<p>Lemma 1 For a node n and 0 ≤ i ≤ n-&gt;topLayer:</p>
<pre><code>                                        n->nexts[i] 6= null =⇒ n->key < n->nexts[i]->key</code></pre>
<p>我们定义关系→i，表示在第i层上，如果m-&gt;nexts[i] = n或者存在m0使得m →i m0且m0-&gt;nexts[i] = n，则表示m导向n（读作“m leads to n at layer i”）。因为一个节点在任何层上最多只有一个直接后继节点，所以→i关系在第i层上“遵循”一个链表的结构，特别地，跳表中的第i层列表包括那些满足LSentinel →i n（以及LSentinel本身）的节点n。另外，根据引理1，如果m →i n且m →i n0且n-&gt;key &lt; n0-&gt;key，则n →i n0。</p>
<p>基于这些观察，我们可以证明，在算法的任何可达状态下，如果m →i n，则在任何后续状态中，除非存在将n从第i层列表中切割的操作（即执行第141行），否则m →i n将继续成立。这个结论在最近的一篇论文中正式证明了懒惰列表算法，并且该证明可以适应这个算法。因为在n被切割出列表之前，它必须已经被标记，而且由于fullyLinked标志在初始化后不会被设置为false，这个结论意味着只有通过标记节点才能从抽象集合中移除键，而我们之前已经证明标记节点是成功删除操作的线性化点。</p>
<p>类似地，我们可以观察到，如果LSentinel →i n在算法的某个可达状态中不成立，则除非在某个执行中出现n = newNode的情况（如前所述，前一行不会改变第i层的列表，因为newNode尚未链接）。然而，该行的执行发生在newNode被插入但尚未完全链接的过程中。因此，将节点添加到任何级别的列表的唯一操作是设置节点的fullyLinked标志。</p>
<p>最后，我们论证如果一个线程找到了一个标记节点，那么该节点的键在该线程执行操作的过程中必定不在列表中。有两种情况：如果节点在线程调用操作时已经被标记，则该节点必须在那个时刻已经存在于跳表中，因为标记节点不能添加到跳表中（只能将新分配的节点添加到跳表中），并且由于跳表中没有两个具有相同键的节点，因此跳表中没有未标记的节点具有该键。因此，在调用操作时，键不在跳表中。另一种情况是，如果线程调用操作时节点没有被标记，那么它必须在第一个线程发现它之前被其他线程标记，然后，根据我们之前的论证，当其他线程首次标记节点时，键在该线程的操作执行期间不在抽象集合中。这个结论在简单懒惰列表[1]的形式证明中也得到了证实，而且可以适用于这个算法。</p>
<p>综上所述，我们通过观察和代码分析，证明了一些重要的性质。在任何操作中，在调用findNode之后，我们有preds[i]-&gt;key &lt; v且succs[i]-&gt;key ≥ v对于所有i成立，并且对于i &gt; lFound（findNode返回的值），succs[i]-&gt;key &gt; v。对于remove操作中的线程，nodeToDelete只被设置一次，除非该节点被其他线程标记，该线程将标记该节点，并且在完成操作之前，线程的isMarked变量将保持为true。我们还通过okToDelete知道该节点是完全链接的（实际上，只有完全链接的节点才能被标记）。</p>
<p>此外，验证和在写入节点之前要求锁定节点的要求确保在成功验证之后，验证所检查的属性（对于add和remove来说略有不同）在释放锁之前保持不变。</p>
<p>基于这些性质，我们可以得出以下基本引理的证明。</p>
<h4>4.2 维护跳跃表的不变性</h4>
<p>我们的算法保证跳跃表的不变性在任何时候都得到保持。通过“跳跃表的不变性”，我们指的是每个层次的列表是低层次列表的子列表。保持这种结构的重要性在于，跳跃表的复杂度分析要求具备这种结构。 要看到算法如何保持跳跃表的结构，注意到将新节点链接到跳跃表的过程始终从底层到顶层进行，并且在插入节点时持有所有即将成为该节点前驱节点的锁。另一方面，当从列表中删除节点时，高层节点先于低层节点被取消链接，同样在删除节点的直接前驱节点上持有锁定。</p>
<p>保持跳跃表结构的性质，在锁无关的算法中并不保证。在该算法中，在将节点链接到底层后，将节点从顶层到底层在其余的层次上链接。这可能导致节点仅在其顶层和底层上链接，使得顶层的列表不是其下一层列表的子列表，例如。此外，在除底层之外的任何层次上尝试链接节点时，不会进行重试，因此这种不符合跳跃表结构的状态可能会无限期地持续存在。</p>
<p>这是我们基于锁的算法与锁无关算法之间的一个重要区别。我们的基于锁的算法通过明确获取锁并在从底层到顶层链接节点时维护必要的锁定纪律，以确保在任何时候都保持跳跃表的结构。</p>
<p><img decoding="async" src="http://43.130.6.58:8080/wp-content/uploads/2023/05/f8.png" alt="Figure 8" /><br />
Figure 8: 在执行1,000,000次操作的情况下，每毫秒的吞吐量，其中有9%的添加操作，1%的删除操作和90%的包含操作，键范围分别为200,000和2,000,000。</p>
<h4>4.3 死锁自由和无等待性</h4>
<p>该算法是死锁自由的，因为线程总是先获取具有较大键的节点上的锁。更具体地说，如果一个线程持有具有键v的节点上的锁，则它不会尝试获取具有大于或等于v的键的节点上的锁。我们可以看到这是正确的，因为添加和删除方法都从底层向上获取前驱节点的锁，并且前驱节点的键小于较低层的其他前驱节点的键。唯一的其他锁获取是删除操作删除的节点。这是该操作获取的第一个锁，它的键大于任何前驱节点的键。</p>
<p>包含操作是无等待的也很容易理解：它不获取任何锁，也不会进行重试；它只搜索一次列表。</p>
<h3>5 性能</h3>
<p>我们通过在Java编程语言中实现我们的跳跃表算法来评估其性能，正如之前所描述的那样。我们将我们的实现与Doug Lea在java.util.concurrent包的ConcurrentSkipListMap类中的非阻塞跳跃表实现进行了比较。据我们所知，这是目前最好的广泛可用的并发跳跃表实现。我们还实现了一个简单的顺序跳跃表，其中的方法通过同步来确保线程安全，作为实验中的基准。本节中，我们描述了从这些实验中获得的一些结果。</p>
<p>我们使用两个具有不同架构的多处理器系统进行实验。第一个系统是基于单个UltraSPARC°r T1处理器的Sun Fire T2000服务器，该处理器包含8个计算核心，每个核心具有4个硬件线程，主频为1200 MHz。每个具有4个线程的核心具有一个8KB级1数据缓存和一个16KB指令缓存。所有8个核心共享一个3MB级2统一（指令和数据）缓存和一个4路交错的32GB主存储器。数据访问延迟比大约为1:8:50（L1:L2:内存访问）。另一个系统是较旧的Sun Enterprise 6500服务器，它包含15个系统板，每个板上有两个主频为400 MHz的UltraSPARC°r II处理器和2GB内存，总共有30个处理器和60GB内存。每个处理器在芯片上有一个16KB的数据级1缓存和一个16KB的指令缓存，以及一个8MB的外部缓存。系统时钟频率为80 MHz。</p>
<p>我们展示了从一个空的跳跃表开始，每个线程执行一百万（1,000,000）个随机选择的操作的实验结果。我们变化了线程数量，添加、删除和包含操作的相对比例，以及选择键的范围。每个操作的键是从指定范围均匀随机选择的。 在接下来的图表中，我们比较每毫秒操作的吞吐量，所显示的结果是每组参数的六次运行的平均值。</p>
<p>图8展示了实验结果，其中9%的操作是添加操作，1%的操作是删除操作，剩下的90%是包含操作，键的范围为20万或200万。不同的范围会产生不同程度的竞争，其中200,000范围的竞争更高，与2,000,000范围相比。从这些实验中可以看出，我们的实现和Lea的实现都具有良好的可扩展性（而顺序算法如预期的那样相对平坦）。除了在旧系统上使用200,000范围的情况下，我们的实现在所有情况下都稍微占优势。</p>
<p>图9展示了一系列实验的结果，其中添加操作的百分比变化，而删除操作的百分比固定为1%，剩余的百分比分配给包含操作。键的范围设置为两百万。如图所示，我们的实现在不同的添加操作百分比下优于Lea的实现，展示了它的可伸缩性和效率。</p>
<p>总体而言，实验结果表明，我们基于锁的并发跳表算法的性能与最佳的无锁实现相当，甚至更好。它在常见的使用模式下实现了高度的可伸缩性和效率，其中搜索操作占主导地位，插入操作占主导地位。我们的算法始终保持跳表的特性，简化了对正确性的推理，并且它是无死锁和无等待的，确保所有线程都能取得进展。</p>
<p>这些性能结果验证了我们基于锁的并发跳表算法的有效性，并突显了它作为现有并发跳表实现的可行替代方案的潜力，特别是在强调简单性、可伸缩性和性能的应用中。</p>
<p>在下一组实验中，我们运行了更高比例的添加和删除操作，分别为20%和10%（剩余70%的包含操作）。结果如图9所示。可以看到，在T2000系统上，两种实现的性能相似，Lea在多程序环境下，当范围较小（更高的竞争）时略有优势。而在范围较大时情况则相反。这种现象在旧系统上更为明显：在范围较小、64个线程的情况下，Lea的实现优势达到13%，而在范围较大、相同数量的线程下，我们的算法有20%的优势。</p>
<p>为了探究这种现象，我们进行了一项具有显著较高竞争水平的实验：半数添加操作和半数删除操作，范围为20万。结果如图10所示。可以清楚地看到，在这种竞争水平下，我们的实现的吞吐量在接近多程序区域时迅速下降，特别是在T2000系统上。这种下降并不令人意外：在我们当前的实现中，当添加或删除操作无法通过验证或无法立即获取锁时，它只是调用yield；没有适当的机制来管理竞争。由于添加和删除操作要求在锁定之前搜索阶段看到的前驱节点不发生更改，我们预计在高竞争下，它们将反复失败。因此，我们预计引入退避机制或其他形式的竞争控制将极大地提高性能。为了验证高冲突水平确实是问题所在，我们添加了计数器来计算实验期间每个线程执行的重试次数。计数器确实显示，在64个线程运行时执行了许多次重试，特别是在T2000系统上。大多数重试是由添加方法执行的，这是有道理的，因为删除方法在搜索更低层的前驱节点之前标记要删除的节点，这可以防止并发添加操作更改这些前驱节点的next指针。<br />
<img decoding="async" src="http://43.130.6.58:8080/wp-content/uploads/2023/05/f9.png" alt="Figure 9" /></p>
<p>Figure 9: 在执行1,000,000次操作的情况下，每毫秒的吞吐量，其中有20%的添加操作，10%的删除操作和70%的包含操作，键范围分别为200,000和2,000,000。</p>
<p><img decoding="async" src="http://43.130.6.58:8080/wp-content/uploads/2023/05/f10.png" alt="Figure 10" /><br />
Figure 10: 在执行1,000,000次操作的情况下，每毫秒的吞吐量，其中有50%的添加操作和50%的删除操作，键范围为200,000。</p>
<h3>6 结论</h3>
<p>我们展示了如何使用一种非常简单的算法构建可扩展的高并发跳表。我们的实现还不完善，显然在处理高度竞争的情况下可以从更好的竞争控制机制中获益。尽管如此，我们相信即使在其原始形式下，对于大多数使用情况来说，它仍然是一种有趣且可行的替代方案，可以替代ConcurrentSkipListMap。</p>
<h3>引用</h3>
<p>[1] Colvin, R., Groves, L., Luchangco, V., and Moir, M. Formal verification of a lazy concurrent list-based set. In Proceedings of Computer-Aided Verification (Aug. 2006). </p>
<p>[2] Fraser, K. Practical Lock-Freedom. PhD thesis, University of Cambridge, 2004.11 </p>
<p>[3] Fraser, K., and Harris, T. Concurrent programming without locks. Unpublished manuscript, 2004. </p>
<p>[4] Heller, S., Herlihy, M., Luchangco, V., Moir, M., Shavit, N., and Scherer III, W. N. A lazy concurrent list-based set algorithm. In Proceedings of 9th International Conference on Principles of Distributed Systems (Dec. 2005). </p>
<p>[5] Herlihy, M., Luchangco, V., and Moir, M. The repeat offender problem: A mechanism for supporting dynamic-sized, lock-free data structures. In Proceedings of Distributed Computing: 16th International Conference (2002). [6] Herlihy, M., and Wing, J. Linearizability: A correctness condition for concurrent objects. ACM Transactions on Programming Languages and Systems 12, 3 (July 1990), 463–492. </p>
<p>[7] Lea, D. Personal communication, 2005. </p>
<p>[8] Lea, D. ConcurrentSkipListMap. In java.util.concurrent. </p>
<p>[9] Michael, M. Hazard pointers: Safe memory reclamation for lock-free objects. IEEE Transactions on Parallel and Distributed Systems 15, 6 (June 2004), 491–504. </p>
<p>[10] Pugh, W. Concurrent maintenance of skip lists. Tech. Rep. CS-TR-2222, 1990. </p>
<p>[11] Pugh, W. Skip lists: A probabilistic alternative to balanced trees. Communications of the ACM 33, 6 (June 1990), 668–676</p>
<hr />
<h2>论文实现</h2>
<p>原本我是打算按照此篇论文自行实现一个并发安全跳表的，但是我在github上找到了一个基于此论文的优秀的golang开源实现，代码的质量很高，在此篇博客中，我将基于这个开源代码进行论文实现的代码分析。</p>
<blockquote>
<p>原仓库：<br />
<a href="https://github.com/zhangyunhao116/skipmap">https://github.com/zhangyunhao116/skipmap</a></p>
</blockquote>
<p>源作者的代码使用了Golang的泛型，但由于在本篇文章中基于泛型实现来分析源码会导致理解成本的增加，因此我在原作者的代码上进行了部分修改，以降低额外的学习难度。</p>
<p>先来看一下我们最后要怎么使用这个跳表，从一个使用案例作为代码分析的入口</p>
<pre><code class="language-go">func TestSKL(t *testing.T) {
    // 构建跳表
    skl := NewSkipList()
    // 构建测试用的K、V
    k := &amp;Item{Key: []byte(&quot;key&quot;)}
    // 测试 key2 为 nil
    k2 := &amp;Item{Key: nil}
    v1 := &quot;val&quot;
    v2 := &quot;val2&quot;
    v3 := &quot;val3&quot;
    // 加入 k v1
    skl.Store(k, v1)
    checkLoad(skl.Load(k))

    // 删除 k
    skl.Delete(k)
    checkLoad(skl.Load(k))

    // 加入 k v2
    skl.Store(k, v2)
    checkLoad(skl.Load(k))

    // 加入 k v1
    skl.Store(k, v1)
    checkLoad(skl.Load(k))

    // 不加入 k2 的时候，能否查找到 nil
    checkLoad(skl.Load(k2))

    skl.Store(k2, v3)
    checkLoad(skl.Load(k2))
}

type Item struct {
    Key []byte
}

func (i *Item) Less(key ComparableKey) bool {
    return bytes.Compare(i.Key, key.(*Item).Key) == -1
}

func checkLoad(v any, ok bool) {
    if ok {
        fmt.Println(v.(string))
    } else {
        fmt.Println(&quot;not found key&quot;)
    }
}</code></pre>
<h3>NewSkipList()</h3>
<p>先来看一下核心的结构体</p>
<pre><code class="language-go">// ConcurrentSkipList 跳表的顶层结构
type ConcurrentSkipList struct {
    length       int64
    highestLevel uint64 // highest level for now
    header       *Node
}</code></pre>
<p>ConcurrentSkipList是这个跳表的顶层结构体，拥有三个字段，维护着跳表的一些元信息，包括长度和最大高度，然后维护着头节点的引用</p>
<pre><code class="language-go">// 跳表的节点
type Node struct {
    key   ComparableKey
    value unsafe.Pointer // *any
    flags bitflag
    next  tower // [level]*orderednode
    mu    sync.Mutex
    level uint32
}</code></pre>
<p>Node是跳表最核心的节点，是跳表最基小单元。</p>
<p>每个Node中维护着当前节点的Key、Value，节点状态标记(flags)、指向下一个节点的指针列表(tower)、必要的锁、当前节点的高度的信息</p>
<p>其中Key的类型是 ComparbleKey，是一个接口，需要在用户的调用方自行实现</p>
<pre><code class="language-go">type ComparableKey interface {
    Less(than ComparableKey) bool
}</code></pre>
<p>bitflag结构体本质上就是两个bool值，用来标记是否已经属于全连接状态(前置节点和后继节点的指针全部已经准备好，此时这个节点已经可以被查询、删除、修改了)和处于删除标记状态，主要包含了set和get方法用来读取和修改标记位</p>
<pre><code class="language-go">const (
    fullyLinked = 1 &lt;&lt; iota
    deletedMarked
)

// concurrent-safe bitflag.
type bitflag struct {
    data uint32
}

func (f *bitflag) SetTrue(flags uint32) {
    for {
        old := atomic.LoadUint32(&amp;f.data)
        if old&amp;flags != flags {
            // Flag is 0, need set it to 1.
            n := old | flags
            if atomic.CompareAndSwapUint32(&amp;f.data, old, n) {
                return
            }
            continue
        }
        return
    }
}

func (f *bitflag) SetFalse(flags uint32) {
    for {
        old := atomic.LoadUint32(&amp;f.data)
        check := old &amp; flags
        if check != 0 {
            // Flag is 1, need set it to 0.
            n := old ^ check
            if atomic.CompareAndSwapUint32(&amp;f.data, old, n) {
                return
            }
            continue
        }
        return
    }
}

func (f *bitflag) Get(flag uint32) bool {
    return (atomic.LoadUint32(&amp;f.data) &amp; flag) != 0
}

func (f *bitflag) MGet(check, expect uint32) bool {
    return (atomic.LoadUint32(&amp;f.data) &amp; check) == expect
}</code></pre>
<p>tower是当前节点的指向下一个节点的指针列表，因为在跳表的示意图中，一个跳表有很多个后继指针，摞起来像个塔一样，所以是不是用tower来表示非常形象hhh，在tower中主要也就是get和set方法，用来读取修改节点在某层的后继指针</p>
<pre><code class="language-go">type tower [maxLevel]*Node

func (t *tower) load(i int) *Node {
    return t[i]
}

func (t *tower) store(i int, node *Node) {
    t[i] = node
}

// 这里看上去有些负载，因为atomic.LoadPointer方法接收的是*unsafe.Pointer参数，
// 而unsafe.Pointer(&amp;t[i]))只能转换到unsafe.Pointer类型，而且不能直接取地址
// 所以还要通过(*unsafe.Pointer)来转化类型，才能将数据转成atomic.LoadPointer能接收的类型
func (t *tower) atomicLoad(i int) *Node {
    return (*Node)(atomic.LoadPointer((*unsafe.Pointer)(unsafe.Pointer(&amp;t[i]))))
}

// 和上面类似
func (t *tower) atomicStore(i int, node *Node) {
    atomic.StorePointer((*unsafe.Pointer)(unsafe.Pointer(&amp;t[i])), unsafe.Pointer(node))
}</code></pre>
<p>NewSkipList()方法</p>
<p>从函数名上就可以得知，这个函数的作用是新建一个跳表的实例，在内部实现中，创建了跳表的第一个节点，并且给节点设置为<code>全连接</code>的状态，创建跳表实例并返回这个实例给调用方</p>
<pre><code class="language-go">func NewSkipList() *ConcurrentSkipList {
    // 创建第一个节点
    h := newNode(nil, nil, maxLevel)
    // 设置为全连接状态
    h.flags.SetTrue(fullyLinked)
    return &amp;ConcurrentSkipList{
        header:       h,
    }
}</code></pre>
<p>newNode方法</p>
<p>传入key、value、level，构建一个Node并返回</p>
<pre><code class="language-go">func newNode(key ComparableKey, value any, level int) *Node {
    node := &amp;Node{
        key:   key,
        level: uint32(level),
    }
    node.storeVal(value)
    return node
}</code></pre>
<p>一些Node的基本操作方法，包括对值的get/set和对后继节点的get/set还包含了原子操作</p>
<pre><code class="language-go">func (n *Node) storeVal(value any) {
    atomic.StorePointer(&amp;n.value, unsafe.Pointer(&amp;value))
}

func (n *Node) loadVal() any {
    return *(*any)(atomic.LoadPointer(&amp;n.value))
}

func (n *Node) loadNext(i int) *Node {
    return n.next.load(i)
}

func (n *Node) storeNext(i int, node *Node) {
    n.next.store(i, node)
}

func (n *Node) atomicLoadNext(i int) *Node {
    return n.next.atomicLoad(i)
}

func (n *Node) atomicStoreNext(i int, node *Node) {
    n.next.atomicStore(i, node)
}</code></pre>
<h3>findNode(...)</h3>
<p>查找某一个节点，如果找到立即返回，未找到则返回nil，这是一个内部使用的辅助方法。</p>
<pre><code class="language-go">// findNode takes a key and two maximal-height arrays then searches exactly as in a sequential skipmap.
// The returned preNodes and nextNodes always satisfy preNodes[i] &gt; key &gt;= nextNodes[i].
// (without fullpath, if find the node will return immediately)
func (s *ConcurrentSkipList) findNode(key ComparableKey, preNodes *[maxLevel]*Node, nextNodes *[maxLevel]*Node) *Node {
    x := s.header
    // 从高最高节点开始查询
    for i := int(atomic.LoadUint64(&amp;s.highestLevel)) - 1; i &gt;= 0; i-- {
        // 原子获取最高层的下一个节点
        nextNode := x.atomicLoadNext(i)
        // 循环，直到找到目标节点的后继点击（或者尾节点）
        for nextNode != nil &amp;&amp; (nextNode.key.Less(key)) {
            // 如果下一个节点的键值小于目标键值，说明还需要继续向后遍历，更新当前节点为下一个节点，
            // 并更新下一个节点为当前节点的下一个节点。这个过程会在当前层级上持续进行，
            // 直到找到一个下一个节点的键值大于等于目标键值，或者下一个节点为nil。
            x = nextNode
            nextNode = x.atomicLoadNext(i)
        }
        // 此时next node

        // 在每个层级上，代码都会记录当前节点作为前驱节点，并记录下一个节点作为后继节点，存储在preNodes和nextNodes数组中。
        // 这样，当查找结束时，preNodes和nextNodes数组中存储的节点序列就构成了一个部分有序的路径，满足preNodes[i] &gt; key &gt;= nextNodes[i]的条件。
        preNodes[i] = x
        nextNodes[i] = nextNode

        // Check if the key already in the skipmap.
        if nextNode != nil &amp;&amp; nextNode.key == key {
            // 如果在查找过程中找到一个节点的键值与目标键值相等，说明目标键值已经存在于跳表中，直接返回该节点
            return nextNode
        }
    }
    // 如果没有找到，返回nil
    return nil
}</code></pre>
<p>用于delete的find辅助方法</p>
<pre><code class="language-go">// findNodeDelete takes a key and two maximal-height arrays then searches exactly as in a sequential skip-list.
// The returned preNodes and nextNodes always satisfy preNodes[i] &gt; key &gt;= nextNodes[i].
func (s *ConcurrentSkipList) findNodeDelete(key ComparableKey, preNodes *[maxLevel]*Node, nextNodes *[maxLevel]*Node) int {

    // lFound represents the index of the first layer at which it found a node.
    lFound, x := -1, s.header
    // 代码通过循环遍历每个层级的链表，从最高层级开始向下搜索，找到满足条件的节点。
    for i := int(atomic.LoadUint64(&amp;s.highestLevel)) - 1; i &gt;= 0; i-- {
        nextNode := x.atomicLoadNext(i)
        // 在每个层级上，代码比较节点的键与目标键的大小关系，根据节点的键是否小于目标键决定向后移动到下一个节点。
        for nextNode != nil &amp;&amp; (nextNode.key.Less(key)) {
            x = nextNode
            nextNode = x.atomicLoadNext(i)
        }
        // 同时，代码记录节点的前驱节点和后继节点到预定义的数组中。
        preNodes[i] = x
        nextNodes[i] = nextNode

        // Check if the key already in the skip list.
        if lFound == -1 &amp;&amp; nextNode != nil &amp;&amp; nextNode.key == key {
            // 如果在搜索过程中找到了目标节点（即节点的键等于目标键），则记录该节点所在的层级索引为lFound，表示找到了节点。
            lFound = i
        }
    }
    // 最后，代码返回lFound，即找到的节点所在的最高层级索引。
    return lFound
}</code></pre>
<p>可以看到，findNode和findNodeDelete都是实际查找节点的辅助方法，使用的时候会传入目标节点的前置指针列表和后继指针列表的引用，在方法执行过程中对其赋值，在查找过程中都是从最高层向下逐级查找，在返回值上面，findNode会返回找到的节点的引用，而findNodeDelete会返回能找到此节点的最高层级，这部分的差异在下文会有所解释</p>
<h3>Load(key)</h3>
<p>供外层调用的核心方法，用于加载一个Key所对应的Value</p>
<pre><code class="language-go">// Load returns the value stored in the map for a key, or nil if no
// value is present.
// The ok result indicates whether value was found in the map.
func (s *ConcurrentSkipList) Load(key ComparableKey) (value any, ok bool) {
    // 从头节点开始
    x := s.header
    // 从最高节点开始
    for i := int(atomic.LoadUint64(&amp;s.highestLevel)) - 1; i &gt;= 0; i-- {
        nex := x.atomicLoadNext(i)
        for nex != nil &amp;&amp; (nex.key.Less(key)) {
            x = nex
            nex = x.atomicLoadNext(i)
        }

        // Check if the key already in the skip list.
        // 如果找到了数据
        if nex != nil &amp;&amp; nex.key == key {
            // 检查数据是否是就绪状态(处于全链接状态)
            if nex.flags.Get(fullyLinked) {
                // 如果就绪，直接返回
                return nex.loadVal(), true
            }
            return
        }
    }
    return
}</code></pre>
<p>可以看到，这Load方法是完全不加锁的，而是在返回数据时进行一个原子操作，如果节点处于就绪状态，直接返回，如果处于未就绪状态，我们可以认为在逻辑上，这个节点在这个时间点上是不在这个跳表中的，直接返回空</p>
<h3>Store(key, value)</h3>
<p>供外层调用的核心方法，用于存储一对Key-Value</p>
<pre><code class="language-go">// Store sets the value for a key.
func (s *ConcurrentSkipList) Store(key ComparableKey, value any) {
    // 获取一个随机的高度
    level := s.randomlevel()
    // 初始化目标节点的前置、后继节点的指针列表
    var preNodes, nextNodes [maxLevel]*Node
    for {
        nodeFound := s.findNode(key, &amp;preNodes, &amp;nextNodes)
        // 代码首先调用findNode函数查找指定键的节点。如果找到了节点，则表示目标键已经存在于跳表中。
        if nodeFound != nil { // indicating the key is already in the skip-list
            if !nodeFound.flags.Get(deletedMarked) {
                // We don&#039;t need to care about whether or not the node is fully linked,
                // just replace the value.
                // 代码检查该节点是否被标记（deletedMarked），如果没有被标记，则说明该节点是完全链接的，直接替换其值即可。
                nodeFound.storeVal(value)
                return
            }
            // If the node is deletedMarked, represents some other goroutines is in the process of deleting this node,
            // we need to add this node in next loop.
            // 如果节点被标记了，表示其他协程正在删除该节点的过程中，需要继续下一轮循环。
            continue
        }
        // 如果没有找到节点，则表示目标键不存在于跳表中，需要将其添加到跳表中。

        // Add this node into skip list.
        var (
            highestLocked               = -1 // the highest level being locked by this process
            valid                       = true
            preNode, nextNode, prevPred *Node
        )
        for layer := 0; valid &amp;&amp; layer &lt; level; layer++ {
            // 代码进入一个循环，在每次循环中尝试添加节点到不同的层级。
            // 在循环内部，代码对前驱节点和后继节点进行加锁，并进行一系列有效性检查。
            preNode = preNodes[layer]   // target node&#039;s previous node
            nextNode = nextNodes[layer] // target node&#039;s next node
            if preNode != prevPred {    // the node in this layer could be locked by previous loop
                preNode.mu.Lock()
                highestLocked = layer
                prevPred = preNode
            }
            // valid check if there is another node has inserted into the skip list in this layer during this process.
            // It is valid if:
            // 1. The previous node and next node both are not deletedMarked.
            // 2. The previous node&#039;s next node is nextNode in this layer.
            // 在有效性检查中，代码检查前驱节点和后继节点是否被标记，并且前驱节点的下一个节点是否为后继节点。
            // 这些检查用于确保在添加节点过程中没有其他协程插入节点。
            valid = !preNode.flags.Get(deletedMarked) &amp;&amp; (nextNode == nil || !nextNode.flags.Get(deletedMarked)) &amp;&amp; preNode.loadNext(layer) == nextNode
        }
        if !valid {
            // 如果检查失败，说明在此过程中有其他节点插入了跳表，代码需要释放已加的锁，并继续下一轮循环。
            unlockordered(preNodes, highestLocked)
            continue
        }
        // 如果有效性检查通过，代码创建一个新的节点（nn），设置其键值和层级，并将其链接到各个层级的前驱节点和后继节点之间。
        nn := newNode(key, value, level)
        for layer := 0; layer &lt; level; layer++ {
            nn.storeNext(layer, nextNodes[layer])
            preNodes[layer].atomicStoreNext(layer, nn)
        }
        // 最后，将新节点标记为完全链接（fullyLinked），释放之前加的锁，并增加跳表的长度计数。
        nn.flags.SetTrue(fullyLinked)
        unlockordered(preNodes, highestLocked)
        atomic.AddInt64(&amp;s.length, 1)
        return
    }
}

func unlockordered(preNodes [maxLevel]*Node, highestLevel int) {
    var prevPred *Node
    // 从高层向低层逐一解锁
    for i := highestLevel; i &gt;= 0; i-- {
        if preNodes[i] != prevPred { // the node could be unlocked by previous loop
            preNodes[i].mu.Unlock()
            prevPred = preNodes[i]
        }
    }
}</code></pre>
<p>总的来说，Store操作就是先通过findNode找节点，如果存在，直接通过原子操作修改值就OK，如果不存在，则给待加入节点的前驱后继节点自底向上逐层加锁，加锁成功后再次检查安全性，查看是否在加锁过程总有其它加入的节点，在通过检测后进行添加节点，逐层修改前驱节点的指向，最后将该新节点设置为全链接状态，逐层释放锁，增加跳表长度信息，添加完成。</p>
<p>多数的逻辑都很清晰，主要难理解的地方在于那个校验的过程，我们来梳理一下</p>
<pre><code class="language-go">valid = !preNode.flags.Get(deletedMarked) &amp;&amp; (nextNode == nil || !nextNode.flags.Get(deletedMarked)) &amp;&amp; preNode.loadNext(layer) == nextNode</code></pre>
<ol>
<li><code>!preNode.flags.Get(deletedMarked)</code>: 检查前驱节点<code>preNode</code>的<code>flags</code>属性是否包含<code>deletedMarked</code>标记，即确认前驱节点没有处在删除过程中。</li>
<li><code>nextNode == nil || !nextNode.flags.Get(deletedMarked)</code> : 待插入节点的后继节点是否为空或者没有在删除过程中，即确认后继节点的指针是可以指向的。</li>
<li><code>preNode.loadNext(layer) == nextNode</code>: 确认前驱节点在当前层的后继节点为待插入节点的后继节点。</li>
</ol>
<p>当以上三点全部通过的时候，则正面前后的节点都是安全的，所以可以执行插入操作</p>
<pre><code class="language-go">// 生成随机高度，并在必要的时候更新最高高度
// randomlevel returns a random level and update the highest level if needed.
func (s *ConcurrentSkipList) randomlevel() int {
    // Generate random level.
    // 可以直接使用抛硬币的方式生成节点高度
    level := randomLevel()
    // Update highest level if possible.
    for {
        hl := atomic.LoadUint64(&amp;s.highestLevel)
        if uint64(level) &lt;= hl {
            break
        }
        if atomic.CompareAndSwapUint64(&amp;s.highestLevel, hl, uint64(level)) {
            break
        }
    }
    return level
}</code></pre>
<h3>Delete(key)</h3>
<p>供外层调用的核心方法，用于删除一对Key-Value</p>
<pre><code class="language-go">// Delete deletes the value for a key.
func (s *ConcurrentSkipList) Delete(key ComparableKey) bool {

    var (
        nodeToDelete        *Node
        isMarked            bool // represents if this operation mark the node
        topLayer            = -1
        preNodes, nextNodes [maxLevel]*Node
    )
    for {
        // 代码首先调用findNodeDelete函数查找指定键的节点，并检查是否可以进行删除操作。如果满足以下条件之一，则可以进行删除操作：
        lFound := s.findNodeDelete(key, &amp;preNodes, &amp;nextNodes)
        // 如果前一次循环已经标记了要删除的节点（isMarked为true）。
        if isMarked || // this process mark this node or we can find this node in the skip list
            // 如果找到了目标节点，并且该节点(待删除的节点)被完全链接（fullyLinked），且该节点在当前层级的最高层级。
            lFound != -1 &amp;&amp; nextNodes[lFound].flags.Get(fullyLinked) &amp;&amp; (int(nextNodes[lFound].level)-1) == lFound {
            if !isMarked { // we don&#039;t mark this node for now
                nodeToDelete = nextNodes[lFound]
                topLayer = lFound
                nodeToDelete.mu.Lock()
                if nodeToDelete.flags.Get(deletedMarked) {
                    // The node is deletedMarked by another process,
                    // the physical deletion will be accomplished by another process.
                    // 如果上述条件不满足，则返回false，表示删除操作无法执行，当前节点被其它线程删除中。
                    nodeToDelete.mu.Unlock()
                    return false
                }
                // 添加删除标记
                nodeToDelete.flags.SetTrue(deletedMarked)
                isMarked = true
            }
            // Accomplish the physical deletion.
            var (
                highestLocked               = -1 // the highest level being locked by this process
                valid                       = true
                preNode, nextNode, prevPred *Node
            )
            // 如果可以执行删除操作，代码开始进行物理删除操作(从下到上)。首先，获取节点的前驱节点和后继节点，并加锁以确保操作的正确性。
            for layer := 0; valid &amp;&amp; (layer &lt;= topLayer); layer++ {
                preNode, nextNode = preNodes[layer], nextNodes[layer]
                if preNode != prevPred { // the node in this layer could be locked by previous loop
                    preNode.mu.Lock()
                    highestLocked = layer
                    prevPred = preNode
                }
                // valid check if there is another node has inserted into the skip list in this layer
                // during this process, or the previous is deleted by another process.
                // It is valid if:
                // 1. the previous node exists.
                // 2. no another node has inserted into the skip list in this layer.
                // 在有效性检查中，代码检查前驱节点和后继节点是否被标记，并且前驱节点的下一个节点是否为后继节点。
                // 这些检查用于确保在删除节点过程中没有其他协程插入节点或删除前驱节点。如果检查失败，说明在此过程中有其他节点插入了跳表，代码需要释放已加的锁，并继续下一轮循环。

                valid = !preNode.flags.Get(deletedMarked) &amp;&amp; preNode.atomicLoadNext(layer) == nextNode
            }
            if !valid {
                unlockordered(preNodes, highestLocked)
                continue
            }
            // 如果有效性检查通过，代码开始进行物理删除操作。
            for i := topLayer; i &gt;= 0; i-- {
                // Now we own the `nodeToDelete`, no other goroutine will modify it.
                // So we don&#039;t need `nodeToDelete.loadNext`
                // 在每个层级上，代码将前驱节点的下一个节点设置为要删除节点的下一个节点，从而将要删除节点从跳表中移除。
                preNodes[i].atomicStoreNext(i, nodeToDelete.loadNext(i))
            }
            // 最后，释放已加的锁，并更新跳表的长度计数。
            nodeToDelete.mu.Unlock()
            unlockordered(preNodes, highestLocked)
            atomic.AddInt64(&amp;s.length, -1)
            return true
        }
        return false
    }
}</code></pre>
<p>删除的逻辑与插入的逻辑大体相同，先调用findNodeDelete，如果没有找到则直接返回false，如果找到了则校验待删除节点状态是否为可删除状态(全链接，未被删除标记)，如果校验通过，进行物理删除操作，在通过原子操作，从最高层开始将待删除节点的前驱节点的后继指针指向待删除节点的后继节点，最后释放锁，更新跳表长度</p>
<h3>Len()</h3>
<p>供外层调用的核心方法，用于获取跳表的元素数量</p>
<pre><code class="language-go">// Len returns the length of this skipmap.
func (s *ConcurrentSkipList) Len() int {
    return int(atomic.LoadInt64(&amp;s.length))
}</code></pre>
<h3>Range(...)</h3>
<p>供外层调用的核心方法，用于获取跳表的元素数量</p>
<pre><code class="language-go">// Range calls f sequentially for each key and value present in the skipmap.
// If f returns false, range stops the iteration.
//
// Range does not necessarily correspond to any consistent snapshot of the Map&#039;s
// contents: no key will be visited more than once, but if the value for any key
// is stored or deleted concurrently, Range may reflect any mapping for that key
// from any point during the Range call.
func (s *ConcurrentSkipList) Range(f func(key any, value any) bool) {
    x := s.header.atomicLoadNext(0)
    for x != nil {
        if !x.flags.MGet(fullyLinked|deletedMarked, fullyLinked) {
            x = x.atomicLoadNext(0)
            continue
        }
        if !f(x.key, x.loadVal()) {
            break
        }
        x = x.atomicLoadNext(0)
    }
}</code></pre>
<p>从头节点开始遍历所有节点</p>
<hr />
<p><a rel="nofollow" href="https://www.crazyfay.com/2023/05/14/lazyskiplist-a-simple-optimistic-skip-list-algorithm%e8%ae%ba%e6%96%87%e7%bf%bb%e8%af%91%e4%b8%8e%e5%ae%9e%e7%8e%b0/">LazySkiplist: A Simple Optimistic skip-list Algorithm论文翻译与实现</a>最先出现在<a rel="nofollow" href="https://www.crazyfay.com">枫阿雨&#039;s blog</a>。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.crazyfay.com/2023/05/14/lazyskiplist-a-simple-optimistic-skip-list-algorithm%e8%ae%ba%e6%96%87%e7%bf%bb%e8%af%91%e4%b8%8e%e5%ae%9e%e7%8e%b0/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>从BST、AVL树、2-3树杀到BLT(红黑树)，常见树状数据结构解读 &#8211; CFC例会2021.10.10</title>
		<link>https://www.crazyfay.com/2021/10/10/%e4%bb%8ebst%e3%80%81avl%e6%a0%91%e3%80%812-3%e6%a0%91%e6%9d%80%e5%88%b0blt%e7%ba%a2%e9%bb%91%e6%a0%91%ef%bc%8c%e5%b8%b8%e8%a7%81%e6%a0%91%e7%8a%b6%e6%95%b0%e6%8d%ae%e7%bb%93%e6%9e%84%e8%a7%a3/</link>
					<comments>https://www.crazyfay.com/2021/10/10/%e4%bb%8ebst%e3%80%81avl%e6%a0%91%e3%80%812-3%e6%a0%91%e6%9d%80%e5%88%b0blt%e7%ba%a2%e9%bb%91%e6%a0%91%ef%bc%8c%e5%b8%b8%e8%a7%81%e6%a0%91%e7%8a%b6%e6%95%b0%e6%8d%ae%e7%bb%93%e6%9e%84%e8%a7%a3/#respond</comments>
		
		<dc:creator><![CDATA[crazyfay]]></dc:creator>
		<pubDate>Sun, 10 Oct 2021 15:36:07 +0000</pubDate>
				<category><![CDATA[CFC例会]]></category>
		<category><![CDATA[CFC]]></category>
		<category><![CDATA[数据结构]]></category>
		<guid isPermaLink="false">http://net.crazyfay.xyz/?p=132</guid>

					<description><![CDATA[<p>让我们开始吃树~ 提起树状数据结构的家族，我们不得不从二叉树开始说起。 在学二叉树的时候，我们知道，二叉树是指 [&#8230;]</p>
<p><a rel="nofollow" href="https://www.crazyfay.com/2021/10/10/%e4%bb%8ebst%e3%80%81avl%e6%a0%91%e3%80%812-3%e6%a0%91%e6%9d%80%e5%88%b0blt%e7%ba%a2%e9%bb%91%e6%a0%91%ef%bc%8c%e5%b8%b8%e8%a7%81%e6%a0%91%e7%8a%b6%e6%95%b0%e6%8d%ae%e7%bb%93%e6%9e%84%e8%a7%a3/">从BST、AVL树、2-3树杀到BLT(红黑树)，常见树状数据结构解读 &#8211; CFC例会2021.10.10</a>最先出现在<a rel="nofollow" href="https://www.crazyfay.com">枫阿雨&#039;s blog</a>。</p>
]]></description>
										<content:encoded><![CDATA[<h1>让我们开始吃树~</h1>
<p>提起树状数据结构的家族，我们不得不从二叉树开始说起。</p>
<p>在学二叉树的时候，我们知道，二叉树是指每个结点最多只有两个子结点的树。</p>
<p>二叉树，是指树中每个结点最多只有两个结点的树。当然，二叉树本身好像没有什么太大的作用。我们平时所说的二叉树，基本上就是指二叉排序树(二叉查找树)。</p>
<h2>二叉查找树（BST）</h2>
<p>二叉查找树就是在二叉树的基础上增加有序性，这个有序性一般是指自然顺序，有了有序性，我们就可以使用二叉树来快速的查找、删除、插入元素了。</p>
<p>但是，二叉查找树有个非常严重的问题，试想，还是这三个元素，如果按照A、B、C的顺序插入元素会怎样？</p>
<p>那么如果按照顺序插入，就会变成一个单链表。没错，当按照元素的自然顺序插入元素的时候，二叉查找树就退化成单链表了，单链表的插入、删除、查找元素的时间复杂度是多少？？</p>
<p>所以，在极限状态下，二叉查找树的时间复杂度是非常差的。</p>
<p>既然，插入元素后有可能导致二叉查找树的性能变差，我们是不是增加了一些手段，让插入后的二叉查找树依然性能良好？</p>
<p>这种手段，就叫做平衡。可以做到自平衡的树就叫做平衡树。</p>
<h2>平衡树</h2>
<p>平衡树，是指插入、删除元素后可以自平衡的二叉查找树。我们平衡的手段，就是旋转。</p>
<p>平衡这个概念一直都有，直到62年，发明了第一种平衡树—-AVL树。</p>
<p>严格来说，平衡树是指可以自平衡的二叉查找树，关键词就是：自平衡、二叉、查找（有序）。</p>
<h2>AVL树</h2>
<p>AVL树是指任意节点的两个子树的高度差不超过1的平衡树。</p>
<p>当数据量非常多的时候，你会非常难以判断这是否是一颗AVL树，比如</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/7E29AAC1F02A0FC1DD4D2F6242232D5F.png" alt="7E29AAC1F02A0FC1DD4D2F6242232D5F" /></p>
<p>如果把上面看成一颗二叉排序树，他是一颗AVL树，其实你很难一眼就看出来他是一颗AVL树，这就是AVL树的第一个缺点，不够直观，特别是当结点特别多的时候。</p>
<p>第二个缺点就是，在插入和删除的时候自平衡的过程非常的复杂。</p>
<p>基于这些缺点，所以，后来又发展出来了各种各样的神奇的平衡树。</p>
<h2>多路平衡二叉树</h2>
<h3>索引</h3>
<p>一般来说，我们操作的数据都是存储在内存（CPU）中的，但如若我们要操作的数据集非常大，大到内存已经没办法处理了怎么办呢？如数据库中的上千万条记录的数据表、硬盘中的上万个文件等，他们必然不能都存储在内存中，而是存储在外存中的。</p>
<p>对于外存中的数据，常见如数据库，我们通常通过索引表来进行数据查找和交互，一般来说，索引表本身也很大，因为数据库的数据非常多，因此索引也不可能全部存储在内存中，因此索引表往往也是以索引文件的形式存储的磁盘上。Mysql的MyISAM引擎的索引文件和数据文件是分离的，一张数据库表就有它对应的索引表，索引表中一个索引对应一条数据库记录的磁盘地址，内存中发起请求通过指定索引的查找索引表即可定位唯一的一条数据；当然Mysql的InnoDB引擎的索引表也是数据表，即索引表保存了索引和完整的数据记录。但是不管怎么说数据的查找都会依赖索引，并且建议通过索引查找，因为如果没有走索引，那就会走全表扫描，使得查找效率大大降低。 因为索引文件同样存储在磁盘上，这样的话，索引查找过程中每查找一次就要产生一次磁盘I/O消耗，相对于CPU存取，I/O存取的消耗要高几个数量级，访问磁盘的成本大概是访问内存的十万倍左右。</p>
<p>实际上，考虑到磁盘IO是非常高昂的操作，计算机操作已经系统做了一些优化，当一次IO时，不光把当前磁盘地址的数据，而是把相邻的数据也都读取到内存缓冲区内，局部预读性原理告诉我们，当计算机访问一个地址的数据的时候，与其相邻的数据也会很快被访问到。</p>
<p>每一次IO读取的数据我们称之为一页(page)。具体一页有多大数据跟操作系统有关，一般为4k或8k大小的连续磁盘块，也就是我们读取一页内的数据时候，实际上才发生了一次IO，这个理论对于索引的数据结构设计非常有帮助，通常，索引节点的大小被设计为一页的大小。</p>
<h3>多路平衡查找树</h3>
<p>对于一旦涉及到这样的外部存储设备（外存），关于时间复杂度的计算就会发生变化，访问某个表/集合元素的时间已经不仅仅是寻找该元素所需比较次数的函数，我们必须考虑对硬盘IO进行操作的时间。由于IO耗时远大于CPU耗时，所以此处评价一个数据结构作为索引的优劣最重要的指标就是要尽量减少查找过程中磁盘IO的存取次数。</p>
<p>我们之前谈的树，都是一个节点可以有多个孩子，但是它自身只存储一个元素。二叉树限制更多，节点最多只能有两个孩子。一个节点只能存储一个元素，在元素非常多的时候，就使得要么树的度非常大（节点拥有子树的个数的最大值），要么树的高度非常大，甚至两者都必须足够大才行，这就使得IO次数非常多，这显然成了时间效率上的瓶颈，并且由于一次IO读取一个节点的数据，普通二叉树并不能容纳更多的数据，这样又造成了磁盘块空间的浪费。</p>
<p>以上种种限制迫使我们设计出每一个节点可以存储多个元素，并且数据结构的高度可控的数据结构，为此引入了多路查找树的概念。一颗平衡多路查找树同样可以使得数据的查找效率保证在O(logN)这样的对数级别上，此时底数为叉数或者阶。</p>
<p>多路查找树（muitl-way search tree），其每一个节点的孩子数可以多于两个，且每一个节点处可以存储多个元素。由于它是一颗平衡查找树，所有元素之间存在某种特定的排序关系。在这里，每一个节点可以存储多少个元素，以及它的孩子数的多少是非常关键的。</p>
<h2>2-3树</h2>
<p>因为AVL树所带来的搜索性能的提升，不足以弥补平衡树所带来的性能损耗。所以，就开始思考，有没有一种绝对平衡的树。没有高度差，没有高度差就没有平衡因子，没有平衡因子就没有旋转操作。</p>
<p>随着这种思考，衍生出了2-3树。也就是二叉-三叉树。</p>
<p>2-3树就是一种绝对平衡的树，任意节点到它所有的叶子结点的深度都是相等的。</p>
<h3>定义</h3>
<p>一颗2-3树或为一颗空树，或有以下结点组成：</p>
<p>2-节点：含有一个元素和两个子树，左子树的所有元素的值均小于它的父结点，右子树所有元素的值均大于它的父结点。</p>
<p>3-节点：还有两个元素和三个子树（左中右 子树），左子树所有元素的值均小于它的父结点，中子树所有元素的值都位于父结点两个元素之间，右子树所有元素的值均大于它的父结点。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/B2803DEAA17FCC1531BE221167C2774B.png" alt="B2803DEAA17FCC1531BE221167C2774B" /></p>
<h3>2-3树查找元素</h3>
<p>2-3树的查找类似于二分，根据元素的大小来决定来决定查找的方向。要判断一个元素是否存在，我们就要先将待查找元素和根元素比较，如果他和任意一个相等，那查找命中，否则根据比较结果来选择查找方向。</p>
<h3>2-3树插入元素</h3>
<p>插入元素首先进行查找命中。若查找命中则不插此元素。如果需要支持重复的元素则将这个元素对象添加一个属性count。若查找未命中，则在叶子结点中插入这个元素。</p>
<p>空树的插入很简单，创建一个结点就可以了。如果不是空树，插入又分成了四种情况：</p>
<h4>1、向2-结点中插入元素</h4>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/3ED36C433E50EA763289F84365918580.png" alt="3ED36C433E50EA763289F84365918580" /></p>
<h4>2、向一颗只含有一个3-节点的树中插入元素</h4>
<p>如果命中查找结束于3-节点，先临时将其成为4-节点，把待插入元素添加到其中，然后将4-节点转化为3个2-节点，中间的节点成为左右节点的父节点。如果之前临时4-节点有父节点，就会变成向一个父节点为2-节点的3-节点中插入元素，中间节点与父节点为2-节点的合并。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/3F37574E0D7E518219A587287BF06A72.png" alt="3F37574E0D7E518219A587287BF06A72" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/0DC1D717FF21D0C9D0032817E131C5E7.png" alt="0DC1D717FF21D0C9D0032817E131C5E7" /></p>
<h4>3、向一个父结点为2-节点的3-节点中插入元素</h4>
<p>同前者</p>
<h4>4、向一个父结点为3-节点的3-节点中插入元素</h4>
<p>插入元素后一直向上分解临时的4-节点，直到遇到2-节点的父节点变成3-节点不再分解。如果达到树根节点还是4-节点，则进行分解根节点，此时树高+1（只有分解根节点才会增加树高），下面动画2-3树插入会出这个例子。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/A905BF2AC6C40E37686814BBE3F01CA8.png" alt="A905BF2AC6C40E37686814BBE3F01CA8" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/EC12747008D32B0297348F6048F7D6EC.png" alt="EC12747008D32B0297348F6048F7D6EC" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/DC1F61426E9BF80FFDE20C2998B6D82F.png" alt="DC1F61426E9BF80FFDE20C2998B6D82F" /></p>
<h3>2-3树的删除操作</h3>
<p>2-3树的删除也分为三种情况，与插入相反。</p>
<h4>1、当删除元素为于3-节点的叶子结点上</h4>
<p>只需要删除该元素即可，不会影响到整棵树的其他结点结构。</p>
<h4>2、当删除元素位于非叶子结点</h4>
<p>使用中序遍历找到待删除节点的后继节点，然后将后继节点与待删除节点位置互换，此时就将问题转化为删除节点为叶子节点（平衡树的非叶子节点中序遍历后继节点肯定是叶子节点），如果该叶子是3-节点，则跟情况（1）一样，如果该节点是2-节点，则跟后面的情况（3）一样；</p>
<h4>3、当删除元素位于2-结点的叶子结点上</h4>
<p>删除元素2-结点的叶子结点的步骤相对很复杂，删除后需要做出相应的判断。并根据判断结果调整树的结构。</p>
<h5>1、删除结点为2结点，父结点为2结点，兄弟结点为3结点。</h5>
<p>操作步骤：当前待删除节点的父节点是2-节点、兄弟节点是3-节点，将父节点移动到当前待删除节点位置，再将兄弟节点中最接近当前位置的key移动到父节点中。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/518D67EA6D8509D16FAD9D1904A8ACDD.png" alt="518D67EA6D8509D16FAD9D1904A8ACDD" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/2E5024C27092234C297400CEDA7B9F83.png" alt="2E5024C27092234C297400CEDA7B9F83" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/B315F2F64D1D370C81EF93A1AD5C09EE.png" alt="B315F2F64D1D370C81EF93A1AD5C09EE" /></p>
<h5>2、删除结点为2-结点，父结点为2-结点，兄弟结点为2-结点</h5>
<p>操作步骤：当前待删除节点的父节点是2-节点、兄弟节点也是2-节点，先通过移动兄弟节点的中序遍历直接后驱到兄弟节点，以使兄弟节点变为3-节点；</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/95B6DE7B306E67E29947EFFF89F42EDD.png" alt="95B6DE7B306E67E29947EFFF89F42EDD" /></p>
<p>删除结点4位2-结点，兄弟结点7也为2结点，需要中序遍历得到兄弟结点7的直接后继8，然后结点7和8构成3-结点。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/2A6B444B73D9E8964CBC550C7F456150.png" alt="2A6B444B73D9E8964CBC550C7F456150" /></p>
<p>重复1情况</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/3C2ABC11B792416137572369FB0DB94E.png" alt="3C2ABC11B792416137572369FB0DB94E" /></p>
<h5>3、删除结点为2-结点，父结点为3-结点</h5>
<p>操作步骤：当前待删除节点的父节点是3-节点，拆分父节点使其成为2-节点，再将再将父节点中最接近的一个拆分key与中孩子合并，将合并后的节点作为当前节点。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/AAB07A766FB58AE68FFD61B2D04FC8AE.png" alt="AAB07A766FB58AE68FFD61B2D04FC8AE" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/57283249AA855D78DF813CD4F1EFE570.png" alt="57283249AA855D78DF813CD4F1EFE570" /></p>
<h5>2-3树为满二叉树，删除叶子结点</h5>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/B4AB2DA1688378CB75073FCEE7120E83.png" alt="B4AB2DA1688378CB75073FCEE7120E83" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/B6B20279263A44F14C8D76BCBABCCF0B.png" alt="B6B20279263A44F14C8D76BCBABCCF0B" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/63E811D4A31FC56C749DA5C775DA82BA.png" alt="63E811D4A31FC56C749DA5C775DA82BA" /></p>
<p>2-3 树作为一种平衡查找树，查询效率比普通的二叉排序树要稳定许多。但是2-3树需要维护两种不同类型的结点，查找和插入操作的实现需要大量的代码，而且它们所产生的额外开销可能会使算法比标准的二叉查找树更慢。</p>
<p>可以看到，上面自平衡的过程中，出现了一种节点，它具有四个子节点和三个数据元素，这个节点可以称作4节点，如果把4节点当作是可以允许存在的，那么，就出现了另一种树：2-3-4树。</p>
<h2>2-3-4树</h2>
<p>2-3-4树，它的每个非叶子节点，要么是2节点，要么是3节点，要么是4节点，且可以自平衡，所以称作2-3-4树。</p>
<p>2节点、3节点、4节点的定义在上面已经提及，我们再重申一下：</p>
<p>2节点：包含两个子节点和一个数据元素；</p>
<p>3节点：包含三个子节点和两个数据元素；</p>
<p>4节点：包含四个子节点和三个数据元素；</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/12B4C9A2A5A4050E63B3BCBCC514B79D.png" alt="12B4C9A2A5A4050E63B3BCBCC514B79D" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/389431E5C233E35BE980B59763EA9D0E.png" alt="389431E5C233E35BE980B59763EA9D0E" /> 插入M，依旧符合2-3-4树的规则。在插入N呢？</p>
<p>插入N，L上移。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/850CBC62655548D8E981256180E8D93F.png" alt="850CBC62655548D8E981256180E8D93F" /></p>
<p>F上移 <img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/9B452644301239F387791283F00FE9AB.png" alt="9B452644301239F387791283F00FE9AB" /></p>
<p>是不是挺简单的，至少比AVL树那种左旋右旋简单得多。同样地，在2-3-4树自平衡的过程中出现了临时的5节点，所以，如果允许5节点的存在呢？嗯，2-3-4-5树由此诞生！同样地，还有2-3-4-5-6树、2-3-4-5-6-7树……子子孙孙，无穷尽也~所以，有人就把这一类树归纳为一个新的名字：<strong>B树</strong>。</p>
<h2>B树</h2>
<p>B树，表示的是一类树，它允许一个节点可以有多于两个子节点，同时，也是自平衡的，叶子节点的高度都是相同的。所以，为了更好地区分一颗B树到底属于哪一类树，我们给它一个新的属性：度（Degree）。具有度为3的B树，表示一个节点最多有三个子节点，也就是2-3树的定义。具有度为4的B树，表示一个节点最多有四个子节点，也就是2-3-4树的定义。</p>
<p>B树，一个节点可以存储多个元素，有利于缓存磁盘数据，整体的时间复杂度趋向于O(log n)，原理也比较简单，所以，经常用于数据库的索引，包括早期的mysql也是使用B树来作为索引的。但是，B树有个大缺陷，比如，我要按范围查找元素，以上面的2-3-4树为例，查找大于B且小于K的所有元素，该怎么实现呢？很难，几乎无解，所以，后面又出现替代B树的方案：B+树。当然了，B+树不是本节的重点，本节的重点是红黑树。 来了来了，有意思的红黑树来了~~</p>
<h2>红黑树</h2>
<p>我们先用图来体会</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/6496AA7628604F9B1E5EAF31283A5AC0.png" alt="6496AA7628604F9B1E5EAF31283A5AC0" /></p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/EAAAED290A50BB6C57414B7F5CB6D1C5.png" alt="EAAAED290A50BB6C57414B7F5CB6D1C5" /></p>
<p>我可以跟大家说，这棵树，就是一颗红黑树。红黑树就是2-3-4树。</p>
<p>我们知道2-3-4的插入、删除、查找元素的原理是相当简单的，那么，我们是不是可以利用2-3-4树来记忆红黑树呢？答案是肯定的，我们就来看看如何利用2-3-4树来快速掌握红黑树，再也不用死记硬背了~~</p>
<p>现在，我们来看一看红黑树的精髓所在，我们来看一看，什么是红黑树的黑高，为什么要有红黑树，红黑树的旋转跟AVL有什么区别，如何去选择？红黑树是如何保持平衡的？直接进入正题</p>
<h2>什么是红黑树</h2>
<p>红黑树是一颗自平衡的二叉排序树，树上的每一个结点都遵循下面的规则（特别注意，这里的自平衡和平衡二叉树AVL的高度有区别）。我们再来看一看红黑树的定义：</p>
<p>1、每一个结点都有一个颜色，要么是红色，要么是黑色。</p>
<p>2、树的根结点为黑色</p>
<p>3、树中不存在两个相邻的红色节点（红色节点的父结点和孩子结点均不为黑色）</p>
<p>4、从任意一个结点出发，包括根结点，到其任何后代NULL结点（默认都是黑色啊）的每条路径都具有相同数量的黑色结点。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/71865477AD0E83C3DD9551E34BF50344.png" alt="71865477AD0E83C3DD9551E34BF50344" /></p>
<p>这就是一颗典型的红黑树，树中的每个结点的颜色要么是黑色，要么是红色；根结点 6 为黑色结点；树中不存在两个相邻的红色结点，比如结点 15 为红色结点，其父亲节点 6 与两个孩子结点就一定是黑色，而不能是红色； 从结点到其后代的 NUll结点 的每条路径上具有相同数目的黑色结点，比如根结点 6 到其左子树的 NULL结点 包含三个黑色结点，到其右子树所有的 NULL 结点也包含三个黑色结点。 可能还不够清晰，为此我对上图做了修改为所有默认为黑色的 NULL 结点给了一个标记。</p>
<p>现在解释规则的第四条简直不能再清晰了！比如根结点 6 到 NULL结点 a 的路径 6→2→a 上的黑色结点为 3 个，从根结点 6 到结点 c 的路径 6→15→10→9→c 中包含的黑色结点个数也是 3 个，同理从根结点 6 到其他所有 NULL结点 的黑色结点数都是 3 。再举个栗子，从红色结点 15 到NULL结点 d 的路径 15→18→g 包含 2 个黑色结点，到NULL结点 c 的路径 15→10→9→c 也包含黑色结点 2 个，从结点 15 到其所有后代的 NULL结点的 黑色结点数目都是 2 。</p>
<h2>为什么要有红黑树</h2>
<p>大多数二叉排序树BST的操作（查找、最大值、最小值、插入、删除等等）都是O（h）的时间复杂度，h 为树的高度。但是对于斜树而言（BST极端情况下出现），BST的这些操作的时间复杂度将达到O（n），n就是结点数。为了保证BST的所有操作的时间复杂度的上限为O（logn） ，就要想办法把一颗BST树的高度一直维持在logn，而红黑树就做到了这一点，红黑树的高度始终都维持在logn，n 为树中的顶点数目。</p>
<p>这个时候就有一个疑问，不对啊。AVL树不也始终是一个均值么？</p>
<h2>红黑树RBT与平衡二叉树AVL的比较</h2>
<p>AVL 树比红黑树更加平衡，但AVL树在插入和删除的时候也会存在大量的旋转操作。所以当你的应用涉及到频繁的插入和删除操作，切记放弃AVL树，选择性能更好的红黑树；当然，如果你的应用中涉及的插入和删除操作并不频繁，而是查找操作相对更频繁，那么就优先选择 AVL 树进行实现。</p>
<h2>一颗红黑树到底是如何保持平衡的呢？</h2>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/E3654B58B591C1A33A74CECDCC4210FB.png" alt="E3654B58B591C1A33A74CECDCC4210FB" /></p>
<p>举一个很简单但是很经典的例子，包含三个结点的单链是不可能出现在红黑树当中的。关于这一点，我们可以自己绘制一条单链，然后尝试为其着色，来判断。</p>
<p>从上图中可以发现，将根结点 9 涂黑色，其他结点分四种情况着色，结果都不满足红黑树的性质要求。唯一的办法就是调整树的高度</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/E5B35A3EEBB7DD434042973E18FD67DC.png" alt="E5B35A3EEBB7DD434042973E18FD67DC" /></p>
<p>这就算我们对于红黑树的初探，然后我们来看两个重要的概念。</p>
<h2>什么是一颗红黑树的黑高？</h2>
<p>在一颗红黑树中，从某个结点 x 出发（不包含该结点）到达一个叶结点的任意一条简单路径上包含的黑色结点的数目称为 黑高 ，记为 bh(x) 。所以我们发现，其实6和15的黑高是一样的，都是2。</p>
<p>计算结点 6 的黑高，从结点 6 到结点 c 的路径是 6→15→10→9→c ，其中黑色结点为 6、10、c ，但是在计算黑高时，并不包含结点本身，所以从结点 6 到结点 c 的路径上的黑色结点个数为 2 ，那么 bh(6)=2 ；从结点 15 到结点 c 的路径为 15→10→9→c ，其中黑色结点为 10、c ，所以从结点 15 到结点 c 的路径上黑色结点数目为 2 ，bh(15)=2 。</p>
<p>因为红黑树的黑高为其根结点的黑高。所以根据红黑树的性质3和性质4，一颗红黑树的黑高bh一定&gt;= h/2。</p>
<table>
<thead>
<tr>
<th><code>1 2 3 </code></th>
<th><code>Number of nodes from a node to its farthest descendant leaf is no more than twice as the number of nodes to the nearest descendant leaf.  从一个结点到其最远的后代叶结点的顶点数目不会超过从该结点到其最近的叶结点的结点数目的两倍。 </code></th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
</tr>
</tbody>
</table>
<p>Copy</p>
<p>其中黑高 bh 就表示从根结点 6 到离它最近的叶结点 2 包含的结点数 2 ，而 h 则表示从根结点 6 到其最远的叶结点 9 所包含的结点数目 4 ，显然这一公式是合理的。</p>
<p>引出来一个道理：一颗有n个结点的红黑树的高度h&lt;=2lg(n+1)。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/E86059A00EBED8EF4471C6B7B5D10C61.png" alt="E86059A00EBED8EF4471C6B7B5D10C61" /></p>
<p>这就合并成了一颗2-3-4树，这棵树中的每一个结点有2、3、4个孩子结点，而一颗2-3-4树的叶结点有着相同的深度h‘。</p>
<p>也正是基于这个，所以对于有n个结点的红黑树而言，不论查找、删除、最大值、最小值等等的时间复杂都平均下来了，也就是O（logn）。</p>
<h2>红黑树的插入</h2>
<p>其实红黑树的操作也很简单，就是比AVL多了一个着色的操作。</p>
<p>在AVL中，我们通过左旋和右旋来调整由于插入和删除所造成的不平衡的问题。在红黑树中，我们使用两种方式：</p>
<p>1、重新着色</p>
<p>2、旋转</p>
<p>当红黑树中出现不平衡的状态，我们首先会考虑重新着色，如果重新着色依旧不能使红黑树平衡，那么就考虑旋转。插入操作主要有两种情况，具体取决于叔叔结点的颜色。如果叔叔结点是红色的，我们会重新着色。如果叔叔结点是黑色的，我们会旋转或者重新着色，或者两者都考虑。</p>
<p>假设x是新插入的一个结点。</p>
<p>1、进行标准的BST插入并将新插入的结点设置为红色</p>
<p>2、如果x是根结点，将x的颜色转化为黑色（整棵树的黑高增加1）</p>
<p>3、如果x的父结点p不是黑色并且x不是根结点，则：</p>
<p>1）、如果x的叔叔结点u是红色；</p>
<p>2）、如果x的叔叔结点u是黑色，则对于x、x的父结点p和x的爷爷结点g有四种情况：</p>
<table>
<thead>
<tr>
<th><code> 1 2 3 4 5 6 7 8 9 10 11 12 13 </code></th>
<th><code>					LL（p是g的左孩子且x是p的左孩子） 					LR（P是g的右孩子且x是p的右孩子） 					RR（p是g的右孩子且x是p的右孩子） 					RL（p是g的右孩子且x是p的左孩子） 					将插入结点x的父结点p和叔叔结点u的颜色变成黑色 					将x的爷爷结点g设置为红色 					将g看作是x，对于新的x重复2、3两步 </code></th>
</tr>
</thead>
<tbody>
<tr>
<td></td>
<td></td>
</tr>
</tbody>
</table>
<p>Copy</p>
<h3>插入结点x的叔叔结点u是红色</h3>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/A872E605C02B25828EE1BABE4FD90F68.png" alt="A872E605C02B25828EE1BABE4FD90F68" /></p>
<p>对于新插入结点 x，我们执行标准的 BST 插入之后，将插入结点 x 着色为红色；如果插入结点不是根结点，且x的父结点 p 为红色结点，分为 1) 和 2) 两种情况处理，我们先看的是 1) 的情况：x 的叔叔结点 u 为红色，如下图所示：</p>
<p>第一步：将父亲结点p和叔叔结点u都设置为黑色</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/EB1EE1A5D235916CF858F83EF95A1593.png" alt="EB1EE1A5D235916CF858F83EF95A1593" /></p>
<p>第二步：将g的颜色设置为红色</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/783D67F6415A8E54A62C584F495E0853.png" alt="783D67F6415A8E54A62C584F495E0853" /></p>
<p>第三步：针对于g结点，在执行第二，第三步。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/F584AD4A6EFB98D5841EDF667F96A98F.png" alt="F584AD4A6EFB98D5841EDF667F96A98F" /></p>
<h3>插入结点x的叔叔结点u为黑色</h3>
<p>当插入结点x的叔叔结点为黑色的时候，根据插入接待你x、x的父结点p和x的爷爷结点g可能出现的位置关系，分为四种情况。</p>
<h4>LL</h4>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/BAC53F055512CDC1DF608A348CDE2030.png" alt="BAC53F055512CDC1DF608A348CDE2030" /></p>
<h4>LR</h4>
<p>首先通过左旋p转化为LL的情况：</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/387FD926658BB053CADF74B2946DD6C8.png" alt="387FD926658BB053CADF74B2946DD6C8" /></p>
<p>然后按照LL的情况处理：</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/F94D034B6484308267D98246D5BE3361.png" alt="F94D034B6484308267D98246D5BE3361" /></p>
<h4>RR</h4>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/3D9FFA2BFAA18346D3D9AA290341CA81.png" alt="3D9FFA2BFAA18346D3D9AA290341CA81" /></p>
<h4>RL</h4>
<p>先右旋转化成RR的情况</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/589F56B2687BBA47E27255CF6C386513.png" alt="589F56B2687BBA47E27255CF6C386513" /></p>
<p>然后按照RR的情况处理</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/753194AB972C1327F80AA836F49139AF.png" alt="753194AB972C1327F80AA836F49139AF" /></p>
<p>到这里，插入排序就全部讲完了。但我也知道很多人可能会有疑惑，那就让我们来构造一颗红黑树，看看运用上述规则到底是否适用。</p>
<h3>红黑树插入操作示例</h3>
<p>下面就带大家构造一颗稍微复杂一点儿的红黑树：</p>
<p>初始时，我们已知的插入依次插入：</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/485ED9801A998E3AEB85E0617689B04F.png" alt="485ED9801A998E3AEB85E0617689B04F" /></p>
<p>第一步：插入结点2，结点2就是根结点，设置为黑色：</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/840C811471AACCAA8A6FA71A4CB0D81C.png" alt="840C811471AACCAA8A6FA71A4CB0D81C" /></p>
<p>第二步：插入结点6，首先执行标准的BST插入操作并将结点6设置为红色，但6号结点的父结点为2的颜色为黑色，所以什么都不用做。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/FFF3BBAF8F0B30424F3814FD5140AAFD.png" alt="FFF3BBAF8F0B30424F3814FD5140AAFD" /></p>
<p>第三步：插入结点9，执行BST插入，设置为红色。其父结点6颜色为红色，且叔叔结点null为黑色，属于RR的情况。故对其爷爷结点2进行左旋操作，并交换g和p的颜色。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/6DCE43C564A5DBFA78A84821E59145F3.png" alt="6DCE43C564A5DBFA78A84821E59145F3" /></p>
<p>第四步：插入结点 10，执行标准的 BST 插入操作并设置为红色，其父结点 9为红色，其叔叔结点 2为红色结点，将其父结点 9 和叔叔结点涂黑色，并将其爷爷结点涂红色并设置为新的x，判断其爷爷结点 6 ，发现为根结点，重新更新为黑色。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/DC12CBBFC81E33FF8814DEE4890FE4EC.png" alt="DC12CBBFC81E33FF8814DEE4890FE4EC" /></p>
<p>第五步：插入结点 12，执行标准的BST插入操作并设置为红色，其父结点 10为红色，其叔叔结点为黑色，其爷爷结点 9为红色，RR 的情况，则左旋 g ，交换 g 和 p 的颜色。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/9892751E774B518DC8EB24FFEFA0FCBE.png" alt="9892751E774B518DC8EB24FFEFA0FCBE" /></p>
<p>第六步：插入结点15</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/04CF406253747A5DED5FFA8350F8EB80.png" alt="04CF406253747A5DED5FFA8350F8EB80" /></p>
<p>第七步：插入20</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/DF87DC3B353A0EB16ED35469A53B5691.png" alt="DF87DC3B353A0EB16ED35469A53B5691" /></p>
<p>第八步：插入18</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/D3BA3649C4614D0FD53D27C995DE9510.png" alt="D3BA3649C4614D0FD53D27C995DE9510" /></p>
<p>第九步：插入结点1</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/DAE605F639F0FD305D27E68875182679.png" alt="DAE605F639F0FD305D27E68875182679" /></p>
<p>第十步：插入结点5</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/804C82BD07329C8927A6D38D29D6E232.png" alt="804C82BD07329C8927A6D38D29D6E232" /></p>
<p>第十一步：插入结点13</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/089C087A20E2B5F5A989ADCA450A1482.png" alt="089C087A20E2B5F5A989ADCA450A1482" /></p>
<h2>红黑树的删除</h2>
<p>说起红黑树的删除操作，就不得不提我们讲的红黑树的插入。与红黑树的插入操作类似，红黑树的删除也是重新着色和旋转来保证每一次删除操作后依旧满足红黑树的属性。</p>
<p>在插入操作中，通过判断插入结点 x 的叔叔结点 u 的颜色来确定恰当的平衡操作。而删除操作中，是通过检查兄弟结点的颜色来决定恰当的平衡操作。 红黑树中插入一个结点最容易出现两个连续的红色结点，违背红黑树的性质3（红黑树中不存在两个相邻的红色结点）。而删除操作，最容易造成子树黑高（Black Height）的变化（删除黑色结点可能导致根结点到叶结点黑色结点的数目减少，即黑高降低）。 与插入操作相比，红黑树的删除操作相对复杂一点，但多点儿耐心，还是没有问题的。为了理解删除操作，我们先来看一个 双黑（Double Black） 的概念。</p>
<p>当删除结点 v 是黑色结点，且其被其黑色子节点替换时，其子结点就被标记为 双黑。</p>
<p>所以说，删除操作最主要的任务就是将转化为双黑结点转换为我们普通的黑色结点。</p>
<h3>删除操作总纲</h3>
<p>删除操作总体上分为三步，我们先提高挈领地看一下，有个宏观概念，然后步步为营，攻陷删除。</p>
<p>首先我们假定要删除的结点为 v ，u 是用来替换 v 的孩子结点（注意，当 v 是叶结点时， u 是 NULL结点，且NULL结点我们还是当做黑色结点处理）。</p>
<p>删除操作总纲：</p>
<p>1、执行标准的BST的删除操作</p>
<p>2、简单情况：u或者v时红色</p>
<p>3、复杂情况：u和v都是黑色</p>
<p>1）u是双黑结点</p>
<p>2）当前结点u是双黑结点且不是根结点</p>
<p>a）u的兄弟结点s是黑色且s的孩子结点至少有一个是红色（LL、LR、RR、RL）</p>
<p>b）u的兄弟结点s是黑色且它的两个孩子都是黑色</p>
<p>c）u的兄弟结点s是红色（s是其父结点p的左孩子、s是其父结点的右孩子）</p>
<p>3）当前结点u是双黑结点且是根结点</p>
<h4>1、执行标准的BST删除操作</h4>
<p>在标准的 BST 删除操作中，我们最终都会以删除一个叶子结点或者只有一个孩子的结点而结束（对于内部节点，就是要删除结点左右孩子都存在的情况，最终都会退化到删除结点是叶子结点或者是只有一个孩子的情况）。所以我们仅需要处理被删除结点是叶结点或者仅有一个孩子的情况。</p>
<h4>2、简单情况：u或者v是红色</h4>
<p>如果 u 或者 v 是红色，我们将替换结点 v 的结点 u 标记为黑色结点（这样黑高就不会变化）。注意这里是 u 或者 v 是红色结点，因为在一棵红黑树中，是不允许有两个相邻的红色结点的，而结点 v 是结点 u 的父结点，因此只能是 u 或者 v 是红色结点。</p>
<p>删除结点 v 为黑色结点 10 ，替换结点 v 的结点 u 为红色结点 9 的情况：</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/E998BC18E2221335D655D54A880860C3.png" alt="E998BC18E2221335D655D54A880860C3" /></p>
<p>删除结点v为红色结点20，替换结点v的结点u为黑色NULL结点的情况：</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/82C031BACEA211F8CF24F745921F8BF7.png" alt="82C031BACEA211F8CF24F745921F8BF7" /></p>
<h4>3、复杂情况 u和v都是黑色结点</h4>
<h5>3.1 结点u是双黑结点</h5>
<p>当要删除结点 v 和孩子结点 u 都是黑色结点，删除结点 v ，导致结点 u 变为双黑结点。当 u 变成双黑结点时，我们的主要任务将变成将该双黑结点 u 变成普通的单黑结点。一定要特别注意，我们在上篇就提到的，NULL结点为黑色结点 ， 所以删除黑色的叶子结点就会产生一个双黑结点。</p>
<h5>3.2 当前结点u是双黑结点且不是根结点</h5>
<p>当前结点 u 是双黑结点且不是根结点，又包含三种情况进行处理。我们约定结点 u 的兄弟结点为 s .</p>
<h6>u的兄弟结点s是黑色且s的孩子结点至少有一个是红色</h6>
<p>对于这种情况，需要对 u 的兄弟结点 s 进行旋转操作，我们将 s 的一个红色子结点用 r 表示，u 和 s 的父结点用 p 表示，那么结点 p 、s 和 r 的位置将出现以下四种情况（LL、LR、RR、RL）。</p>
<p>LL（s 是 p 的左孩子，r 是 s 的左孩子，或者 s 的两个孩子都是红色结点）：</p>
<p>我们删除下图中的结点 25 为例进行说明：</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/47F45A75A7A4C77EC8F93844551F637A.png" alt="47F45A75A7A4C77EC8F93844551F637A" /></p>
<p>删除结点 25 ，用结点 25 的NULL结点 替换结点 25 ，产生一个双黑结点 u ，双黑结点 u 的兄弟结点 s 为 15 ，结点 s 是其父结点 20（p） 的左孩子，其左孩子 10（r） 正好是红色结点。即为 LL 情况。</p>
<p>s 的左孩子 r 颜色设置为 s 的颜色，s 的颜色设置为父结点 p 的颜色，然后右旋p结点。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/B365E1DCE80C79C724740016C9198FD7.png" alt="B365E1DCE80C79C724740016C9198FD7" /></p>
<p>LR（s是p的左孩子，r是s的右孩子，或者s的两个孩子都是红色）</p>
<p>删除结点25，不过结点25的兄弟结点15只有一个右孩子18</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/ED0BD044441090991C09B4870B1A6AB4.png" alt="ED0BD044441090991C09B4870B1A6AB4" /></p>
<p>将结点r的颜色设置为p的颜色。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/77109905F56AF51695852D06CAEF95A8.png" alt="77109905F56AF51695852D06CAEF95A8" /></p>
<p>左旋结点15（s）</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/4529DFE05EF3F73472843C26F6DDF7C5.png" alt="4529DFE05EF3F73472843C26F6DDF7C5" /></p>
<p>右旋结点20（p），p的颜色设置为黑色，双黑变单黑</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/6FE0B534D1B200D6C123DCC8056BC1E5.png" alt="6FE0B534D1B200D6C123DCC8056BC1E5" /></p>
<p>RR（s 是 p 的右孩子，r 是 s 的右孩子，或者 s 的两个孩子都是红色结点）： 删除结点 2 ，用结点 2 的NULL结点 a 替换结点 2 ，产生一个双黑结点 u ，双黑结点 u 的兄弟结点 s 为 15 ，结点 s 是其父结点 6（p） 的右孩子，其右孩子 18（r） 正好是红色结点。即为 RR 情况（仔细观察其实和 LL 情况是对称的）。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/DDFF3AB3A5E287C3DF08F8A3882B1645.png" alt="DDFF3AB3A5E287C3DF08F8A3882B1645" /></p>
<p>r的颜色变为s的颜色，s的颜色变为p的颜色。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/4D5A9C4C0E5177DADEDD6ACE4F419D16.png" alt="4D5A9C4C0E5177DADEDD6ACE4F419D16" /></p>
<p>左旋p，p的颜色设置为黑色，双黑变单黑</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/6B9B2BEA5A26F17C1C7B179C288C11EA.png" alt="6B9B2BEA5A26F17C1C7B179C288C11EA" /></p>
<p>RL情况（s 是 p 的右孩子，r 是 s 的左孩子，或者 s 的两个孩子都是红色结点）： 该情况与 LR情况是对称的</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/090DF18F173F907F0A93EE7BE9AAB1B5.png" alt="090DF18F173F907F0A93EE7BE9AAB1B5" /></p>
<p>结点r的颜色变为p的颜色</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/971DF875FBA49689BB16F34575809EB8.png" alt="971DF875FBA49689BB16F34575809EB8" /></p>
<p>右旋结点15</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/5EA6A746A5BE1B2DDDCF0EEDC08402CD.png" alt="5EA6A746A5BE1B2DDDCF0EEDC08402CD" /></p>
<p>左旋结点6（p），p的颜色设置为黑色，双黑变单黑</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/DC92A4314FC4883F40A9B5BCA154997B.png" alt="DC92A4314FC4883F40A9B5BCA154997B" /></p>
<h6>u 的兄弟结点 s 是黑色且 s 的两个孩子结点都是黑色</h6>
<p>对于这种情况需要递归地进行处理，如果删除结点后得到的双黑结点的父结点此时为黑色，则结点 u 变单黑，且结点 u 的父结点 p 变双黑，然后对结点 u 的父结点 p 继续进行处理，直到当前处理的双黑结点的父结点为红色结点，此时将双黑结点的父结点设置为黑色，双黑结点变为单黑结点（红色 + 双黑 = 单黑）。</p>
<p>假设以 10 为根结点的子树为整棵树的左子树，删除结点 9 ，产生双黑结点 c 且其兄弟结点 12（s） 为黑色，兄弟结点的左右孩子均为黑色。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/A7F83400044B0B8EEF2E1A8C0B2FA1DC.png" alt="A7F83400044B0B8EEF2E1A8C0B2FA1DC" /></p>
<p>此时双黑结点的兄弟结点 12 变为红色结点，然后将 u 的父结点 10 变为双黑结点，一直向上判断。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/2BE847F90AF58CEBCF33B268983BA852.png" alt="2BE847F90AF58CEBCF33B268983BA852" /></p>
<p>那么这个过程什么时候结束呢？</p>
<p>如下图，删除结点12，得到一个双黑结点u，双黑结点的兄弟结点31及兄弟结点的孩子结点均为黑色，且双黑结点的父结点19为红色结点，刚好是不再继续向上判断的情况：</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/191961C3F6241419FD3C17C13D9058CA.png" alt="191961C3F6241419FD3C17C13D9058CA" /></p>
<p>此时只需要将结点 u 的兄弟结点 31 的颜色变为红色，双黑结点 u 的父结点 19 由红色变为黑色结点，双黑结点 u 变为单黑结点。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/101EC1DCE86A281B1A21810F8331E517.png" alt="101EC1DCE86A281B1A21810F8331E517" /></p>
<h6>u的兄弟结点s是红色结点</h6>
<p>当前 u 的兄弟结点 s 是红色结点时，通过旋转操作将 u 当前的兄弟结点向上移动，并对 u 的父结点和其旋转前的兄弟结点重新着色，接着继续对结点 u 旋转后的兄弟结点 s 进行判断，确定相应的平衡操作。旋转操作将 u 的兄弟结点情况又会转换为前面刚提到的3.2（a）和（b）的情况。根据兄弟结点 s 是父结点 p 的左右孩子又分为两种情况。</p>
<p>情况一：u 的兄弟结点 s 是父结点 p 的左孩子 ,对结点 p 进行右旋操作。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/F060EB4BDB2288074296B591D15C8C40.png" alt="F060EB4BDB2288074296B591D15C8C40" /></p>
<p>删除结点 18 ，产生一个双黑结点 u ，且 u 的兄弟结点 s 是红色，兄弟结点 s 是其父结点的左孩子，接着就是对其父结点 15 进行右旋操作。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/DB4E292F57206B059158F41EC6222152.png" alt="DB4E292F57206B059158F41EC6222152" /></p>
<p>对结点 15 进行右旋操作，并且对旋转前的 p 和 s 进行重新着色后，继续对双黑结点旋转后的兄弟结点进行判断，发现此时正好和 3.2（b）的情况是一样，进行相应处理，如下图所示。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/15FB505213F4A542203AAB3B72D660DD.png" alt="15FB505213F4A542203AAB3B72D660DD" /></p>
<p>情况二：u 的兄弟结点 s 是父结点 p 的左孩子 ,对结点 p 进行左旋操作（这种情况与上面的是对称的）。</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/image-20210820222516333.png" alt="image-20210820222516333" /></p>
<p>删除结点 6 ，产生一个双黑结点 u ，且 u 的兄弟结点 10（s） 为红色，s 是父结点 p 的右孩子，左旋P</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/AB814B4FD3EC65616B544B91175BE54D.png" alt="AB814B4FD3EC65616B544B91175BE54D" /></p>
<p>对双黑结点 u 旋转后的兄弟结点继续判断：</p>
<p><img decoding="async" src="https://img-1307890592.cos.ap-chengdu.myqcloud.com/typroa/BC3CB355D3B57DB51009CCA721C8E472.png" alt="BC3CB355D3B57DB51009CCA721C8E472" /></p>
<p>3.3 当前结点u是双黑结点且是根结点</p>
<p>当前结点 u 是双黑结点且是根结点时，直接将双黑结点变为单黑结点，整颗红黑树的黑高减 1.</p>
<h2>红黑树与AVL树的比较</h2>
<p>红黑树中的每个结点需要一个存储位表示结点的颜色，可以是红或黑。通过对任何一条从根到叶子的路径上各个结点着色方式的限制，红黑树确保对于每一个结点其到叶子结点的最长路径不会超过最短路径的两倍，因此，红黑树是一种弱平衡二叉树（由于是弱平衡，可以看到，在相同结点的情况下，AVL树的高度&lt;=红黑树），相对于要求严格的AVL树来说，它的旋转次数少，所以对于插入，删除操作较多的情况下，使用红黑树。</p>
<blockquote>
<p>来自一年后的补充:<br />
红黑树的逻辑操作，第一次啃需要很长时间，啃下来之后长时间不复习，也很容易忘记，真正熟练的理解红黑树的详细逻辑是有一定困难的，而有时候不需要在这些地方上浪费太多的时间。（几乎忘得一干二净的我如此补充到</p>
</blockquote>
<p><a rel="nofollow" href="https://www.crazyfay.com/2021/10/10/%e4%bb%8ebst%e3%80%81avl%e6%a0%91%e3%80%812-3%e6%a0%91%e6%9d%80%e5%88%b0blt%e7%ba%a2%e9%bb%91%e6%a0%91%ef%bc%8c%e5%b8%b8%e8%a7%81%e6%a0%91%e7%8a%b6%e6%95%b0%e6%8d%ae%e7%bb%93%e6%9e%84%e8%a7%a3/">从BST、AVL树、2-3树杀到BLT(红黑树)，常见树状数据结构解读 &#8211; CFC例会2021.10.10</a>最先出现在<a rel="nofollow" href="https://www.crazyfay.com">枫阿雨&#039;s blog</a>。</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.crazyfay.com/2021/10/10/%e4%bb%8ebst%e3%80%81avl%e6%a0%91%e3%80%812-3%e6%a0%91%e6%9d%80%e5%88%b0blt%e7%ba%a2%e9%bb%91%e6%a0%91%ef%bc%8c%e5%b8%b8%e8%a7%81%e6%a0%91%e7%8a%b6%e6%95%b0%e6%8d%ae%e7%bb%93%e6%9e%84%e8%a7%a3/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
