mirror of
https://github.com/krahets/hello-algo.git
synced 2024-12-25 12:36:30 +08:00
deploy
This commit is contained in:
parent
26a2e7f171
commit
47b7d6fd44
49 changed files with 161 additions and 162 deletions
|
@ -3434,9 +3434,9 @@
|
|||
<p>然而在本开源书中,内容更迭的时间被缩短至数日甚至几个小时。</p>
|
||||
</div>
|
||||
<h2 id="1621">16.2.1 内容微调<a class="headerlink" href="#1621" title="Permanent link">¶</a></h2>
|
||||
<p>在每个页面的右上角有一个「编辑」图标,您可以按照以下步骤修改文本或代码:</p>
|
||||
<p>您可以按照以下步骤修改文本或代码:</p>
|
||||
<ol>
|
||||
<li>点击编辑按钮,如果遇到“需要 Fork 此仓库”的提示,请同意该操作。</li>
|
||||
<li>点击页面的右上角的“编辑图标”,如果遇到“需要 Fork 此仓库”的提示,请同意该操作。</li>
|
||||
<li>修改 Markdown 源文件内容,检查内容的正确性,并尽量保持排版格式的统一。</li>
|
||||
<li>在页面底部填写修改说明,然后点击“Propose file change”按钮。页面跳转后,点击“Create pull request”按钮即可发起拉取请求。</li>
|
||||
</ol>
|
||||
|
|
|
@ -3536,7 +3536,7 @@
|
|||
|
||||
|
||||
<h1 id="41">4.1 数组<a class="headerlink" href="#41" title="Permanent link">¶</a></h1>
|
||||
<p>「数组 Array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将某个元素在数组中的位置称为该元素的「索引 Index」。</p>
|
||||
<p>「数组 array」是一种线性数据结构,其将相同类型元素存储在连续的内存空间中。我们将某个元素在数组中的位置称为该元素的「索引 index」。</p>
|
||||
<p><img alt="数组定义与存储方式" src="../array.assets/array_definition.png" /></p>
|
||||
<p align="center"> 图:数组定义与存储方式 </p>
|
||||
|
||||
|
@ -4248,7 +4248,7 @@
|
|||
</div>
|
||||
<h3 id="6">6. 查找元素<a class="headerlink" href="#6" title="Permanent link">¶</a></h3>
|
||||
<p>在数组中查找指定元素需要遍历数组,每轮判断元素值是否匹配,若匹配则输出对应索引。</p>
|
||||
<p>因为数组是线性数据结构,所以上述查找操作被称为「线性查找」。</p>
|
||||
<p>因为数组是线性数据结构,所以上述查找操作被称为“线性查找”。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="6:12"><input checked="checked" id="__tabbed_6_1" name="__tabbed_6" type="radio" /><input id="__tabbed_6_2" name="__tabbed_6" type="radio" /><input id="__tabbed_6_3" name="__tabbed_6" type="radio" /><input id="__tabbed_6_4" name="__tabbed_6" type="radio" /><input id="__tabbed_6_5" name="__tabbed_6" type="radio" /><input id="__tabbed_6_6" name="__tabbed_6" type="radio" /><input id="__tabbed_6_7" name="__tabbed_6" type="radio" /><input id="__tabbed_6_8" name="__tabbed_6" type="radio" /><input id="__tabbed_6_9" name="__tabbed_6" type="radio" /><input id="__tabbed_6_10" name="__tabbed_6" type="radio" /><input id="__tabbed_6_11" name="__tabbed_6" type="radio" /><input id="__tabbed_6_12" name="__tabbed_6" type="radio" /><div class="tabbed-labels"><label for="__tabbed_6_1">Java</label><label for="__tabbed_6_2">C++</label><label for="__tabbed_6_3">Python</label><label for="__tabbed_6_4">Go</label><label for="__tabbed_6_5">JS</label><label for="__tabbed_6_6">TS</label><label for="__tabbed_6_7">C</label><label for="__tabbed_6_8">C#</label><label for="__tabbed_6_9">Swift</label><label for="__tabbed_6_10">Zig</label><label for="__tabbed_6_11">Dart</label><label for="__tabbed_6_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
|
|
@ -3523,17 +3523,17 @@
|
|||
|
||||
<h1 id="42">4.2 链表<a class="headerlink" href="#42" title="Permanent link">¶</a></h1>
|
||||
<p>内存空间是所有程序的公共资源,在一个复杂的系统运行环境下,空闲的内存空间可能散落在内存各处。我们知道,存储数组的内存空间必须是连续的,而当数组非常大时,内存可能无法提供如此大的连续空间。此时链表的灵活性优势就体现出来了。</p>
|
||||
<p>「链表 Linked List」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,我们可以通过它从当前节点访问到下一个节点。这意味着链表的各个节点可以被分散存储在内存各处,它们的内存地址是无须连续的。</p>
|
||||
<p>「链表 linked list」是一种线性数据结构,其中的每个元素都是一个节点对象,各个节点通过“引用”相连接。引用记录了下一个节点的内存地址,我们可以通过它从当前节点访问到下一个节点。这意味着链表的各个节点可以被分散存储在内存各处,它们的内存地址是无须连续的。</p>
|
||||
<p><img alt="链表定义与存储方式" src="../linked_list.assets/linkedlist_definition.png" /></p>
|
||||
<p align="center"> 图:链表定义与存储方式 </p>
|
||||
|
||||
<p>观察上图,链表中的每个「节点 Node」对象都包含两项数据:节点的“值”、指向下一节点的“引用”。</p>
|
||||
<p>观察上图,链表的组成单位是「节点 node」对象。每个节点都包含两项数据:节点的“值”和指向下一节点的“引用”。</p>
|
||||
<ul>
|
||||
<li>链表的首个节点被称为“头节点”,最后一个节点被称为“尾节点”。</li>
|
||||
<li>尾节点指向的是“空”,它在 Java, C++, Python 中分别被记为 <span class="arithmatex">\(\text{null}\)</span> , <span class="arithmatex">\(\text{nullptr}\)</span> , <span class="arithmatex">\(\text{None}\)</span> 。</li>
|
||||
<li>在 C, C++, Go, Rust 等支持指针的语言中,上述的“引用”应被替换为“指针”。</li>
|
||||
</ul>
|
||||
<p>如以下代码所示,链表以节点对象 <code>ListNode</code> 为单位,每个节点除了包含值,还需额外保存下一节点的引用(指针)。因此在相同数据量下,<strong>链表通常比数组占用更多的内存空间</strong>。</p>
|
||||
<p>链表节点 <code>ListNode</code> 如以下代码所示。每个节点除了包含值,还需额外保存一个引用(指针)。因此在相同数据量下,<strong>链表比数组占用更多的内存空间</strong>。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="1:12"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><input id="__tabbed_1_4" name="__tabbed_1" type="radio" /><input id="__tabbed_1_5" name="__tabbed_1" type="radio" /><input id="__tabbed_1_6" name="__tabbed_1" type="radio" /><input id="__tabbed_1_7" name="__tabbed_1" type="radio" /><input id="__tabbed_1_8" name="__tabbed_1" type="radio" /><input id="__tabbed_1_9" name="__tabbed_1" type="radio" /><input id="__tabbed_1_10" name="__tabbed_1" type="radio" /><input id="__tabbed_1_11" name="__tabbed_1" type="radio" /><input id="__tabbed_1_12" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">Java</label><label for="__tabbed_1_2">C++</label><label for="__tabbed_1_3">Python</label><label for="__tabbed_1_4">Go</label><label for="__tabbed_1_5">JS</label><label for="__tabbed_1_6">TS</label><label for="__tabbed_1_7">C</label><label for="__tabbed_1_8">C#</label><label for="__tabbed_1_9">Swift</label><label for="__tabbed_1_10">Zig</label><label for="__tabbed_1_11">Dart</label><label for="__tabbed_1_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -4302,7 +4302,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<h3 id="5">5. 查找节点<a class="headerlink" href="#5" title="Permanent link">¶</a></h3>
|
||||
<p>遍历链表,查找链表内值为 <code>target</code> 的节点,输出节点在链表中的索引。此过程也属于「线性查找」。</p>
|
||||
<p>遍历链表,查找链表内值为 <code>target</code> 的节点,输出节点在链表中的索引。此过程也属于线性查找。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="6:12"><input checked="checked" id="__tabbed_6_1" name="__tabbed_6" type="radio" /><input id="__tabbed_6_2" name="__tabbed_6" type="radio" /><input id="__tabbed_6_3" name="__tabbed_6" type="radio" /><input id="__tabbed_6_4" name="__tabbed_6" type="radio" /><input id="__tabbed_6_5" name="__tabbed_6" type="radio" /><input id="__tabbed_6_6" name="__tabbed_6" type="radio" /><input id="__tabbed_6_7" name="__tabbed_6" type="radio" /><input id="__tabbed_6_8" name="__tabbed_6" type="radio" /><input id="__tabbed_6_9" name="__tabbed_6" type="radio" /><input id="__tabbed_6_10" name="__tabbed_6" type="radio" /><input id="__tabbed_6_11" name="__tabbed_6" type="radio" /><input id="__tabbed_6_12" name="__tabbed_6" type="radio" /><div class="tabbed-labels"><label for="__tabbed_6_1">Java</label><label for="__tabbed_6_2">C++</label><label for="__tabbed_6_3">Python</label><label for="__tabbed_6_4">Go</label><label for="__tabbed_6_5">JS</label><label for="__tabbed_6_6">TS</label><label for="__tabbed_6_7">C</label><label for="__tabbed_6_8">C#</label><label for="__tabbed_6_9">Swift</label><label for="__tabbed_6_10">Zig</label><label for="__tabbed_6_11">Dart</label><label for="__tabbed_6_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
|
|
@ -3509,7 +3509,7 @@
|
|||
|
||||
<h1 id="43">4.3 列表<a class="headerlink" href="#43" title="Permanent link">¶</a></h1>
|
||||
<p><strong>数组长度不可变导致实用性降低</strong>。在实际中,我们可能事先无法确定需要存储多少数据,这使数组长度的选择变得困难。若长度过小,需要在持续添加数据时频繁扩容数组;若长度过大,则会造成内存空间的浪费。</p>
|
||||
<p>为解决此问题,出现了一种被称为「动态数组 Dynamic Array」的数据结构,即长度可变的数组,也常被称为「列表 List」。列表基于数组实现,继承了数组的优点,并且可以在程序运行过程中动态扩容。我们可以在列表中自由地添加元素,而无须担心超过容量限制。</p>
|
||||
<p>为解决此问题,出现了一种被称为「动态数组 dynamic array」的数据结构,即长度可变的数组,也常被称为「列表 list」。列表基于数组实现,继承了数组的优点,并且可以在程序运行过程中动态扩容。我们可以在列表中自由地添加元素,而无须担心超过容量限制。</p>
|
||||
<h2 id="431">4.3.1 列表常用操作<a class="headerlink" href="#431" title="Permanent link">¶</a></h2>
|
||||
<h3 id="1">1. 初始化列表<a class="headerlink" href="#1" title="Permanent link">¶</a></h3>
|
||||
<p>我们通常使用“无初始值”和“有初始值”这两种初始化方法。</p>
|
||||
|
|
|
@ -3468,8 +3468,8 @@
|
|||
|
||||
|
||||
<h1 id="131">13.1 回溯算法<a class="headerlink" href="#131" title="Permanent link">¶</a></h1>
|
||||
<p>「回溯算法 Backtracking Algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。</p>
|
||||
<p>回溯算法通常采用「深度优先搜索」来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。</p>
|
||||
<p>「回溯算法 backtracking algorithm」是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。</p>
|
||||
<p>回溯算法通常采用“深度优先搜索”来遍历解空间。在二叉树章节中,我们提到前序、中序和后序遍历都属于深度优先搜索。接下来,我们利用前序遍历构造一个回溯问题,逐步了解回溯算法的工作原理。</p>
|
||||
<div class="admonition question">
|
||||
<p class="admonition-title">例题一</p>
|
||||
<p>给定一个二叉树,搜索并记录所有值为 <span class="arithmatex">\(7\)</span> 的节点,请返回节点列表。</p>
|
||||
|
|
|
@ -3787,7 +3787,7 @@
|
|||
</div>
|
||||
<h2 id="232">2.3.2 推算方法<a class="headerlink" href="#232" title="Permanent link">¶</a></h2>
|
||||
<p>空间复杂度的推算方法与时间复杂度大致相同,只需将统计对象从“操作数量”转为“使用空间大小”。</p>
|
||||
<p>而与时间复杂度不同的是,<strong>我们通常只关注「最差空间复杂度」</strong>。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。</p>
|
||||
<p>而与时间复杂度不同的是,<strong>我们通常只关注最差空间复杂度</strong>。这是因为内存空间是一项硬性要求,我们必须确保在所有输入数据下都有足够的内存空间预留。</p>
|
||||
<p>观察以下代码,最差空间复杂度中的“最差”有两层含义。</p>
|
||||
<ol>
|
||||
<li><strong>以最差输入数据为准</strong>:当 <span class="arithmatex">\(n < 10\)</span> 时,空间复杂度为 <span class="arithmatex">\(O(1)\)</span> ;但当 <span class="arithmatex">\(n > 10\)</span> 时,初始化的数组 <code>nums</code> 占用 <span class="arithmatex">\(O(n)\)</span> 空间;因此最差空间复杂度为 <span class="arithmatex">\(O(n)\)</span> 。</li>
|
||||
|
@ -5134,7 +5134,7 @@ O(1) < O(\log n) < O(n) < O(n^2) < O(2^n) \newline
|
|||
<p align="center"> 图:递归函数产生的平方阶空间复杂度 </p>
|
||||
|
||||
<h3 id="4-o2n">4. 指数阶 <span class="arithmatex">\(O(2^n)\)</span><a class="headerlink" href="#4-o2n" title="Permanent link">¶</a></h3>
|
||||
<p>指数阶常见于二叉树。高度为 <span class="arithmatex">\(n\)</span> 的「满二叉树」的节点数量为 <span class="arithmatex">\(2^n - 1\)</span> ,占用 <span class="arithmatex">\(O(2^n)\)</span> 空间:</p>
|
||||
<p>指数阶常见于二叉树。高度为 <span class="arithmatex">\(n\)</span> 的“满二叉树”的节点数量为 <span class="arithmatex">\(2^n - 1\)</span> ,占用 <span class="arithmatex">\(O(2^n)\)</span> 空间:</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="9:12"><input checked="checked" id="__tabbed_9_1" name="__tabbed_9" type="radio" /><input id="__tabbed_9_2" name="__tabbed_9" type="radio" /><input id="__tabbed_9_3" name="__tabbed_9" type="radio" /><input id="__tabbed_9_4" name="__tabbed_9" type="radio" /><input id="__tabbed_9_5" name="__tabbed_9" type="radio" /><input id="__tabbed_9_6" name="__tabbed_9" type="radio" /><input id="__tabbed_9_7" name="__tabbed_9" type="radio" /><input id="__tabbed_9_8" name="__tabbed_9" type="radio" /><input id="__tabbed_9_9" name="__tabbed_9" type="radio" /><input id="__tabbed_9_10" name="__tabbed_9" type="radio" /><input id="__tabbed_9_11" name="__tabbed_9" type="radio" /><input id="__tabbed_9_12" name="__tabbed_9" type="radio" /><div class="tabbed-labels"><label for="__tabbed_9_1">Java</label><label for="__tabbed_9_2">C++</label><label for="__tabbed_9_3">Python</label><label for="__tabbed_9_4">Go</label><label for="__tabbed_9_5">JS</label><label for="__tabbed_9_6">TS</label><label for="__tabbed_9_7">C</label><label for="__tabbed_9_8">C#</label><label for="__tabbed_9_9">Swift</label><label for="__tabbed_9_10">Zig</label><label for="__tabbed_9_11">Dart</label><label for="__tabbed_9_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
|
|
@ -3766,7 +3766,7 @@
|
|||
\]</div>
|
||||
<p>但实际上,<strong>统计算法的运行时间既不合理也不现实</strong>。首先,我们不希望将预估时间和运行平台绑定,因为算法需要在各种不同的平台上运行。其次,我们很难获知每种操作的运行时间,这给预估过程带来了极大的难度。</p>
|
||||
<h2 id="221">2.2.1 统计时间增长趋势<a class="headerlink" href="#221" title="Permanent link">¶</a></h2>
|
||||
<p>「时间复杂度分析」采取了一种不同的方法,其统计的不是算法运行时间,<strong>而是算法运行时间随着数据量变大时的增长趋势</strong>。</p>
|
||||
<p>时间复杂度分析统计的不是算法运行时间,<strong>而是算法运行时间随着数据量变大时的增长趋势</strong>。</p>
|
||||
<p>“时间增长趋势”这个概念比较抽象,我们通过一个例子来加以理解。假设输入数据大小为 <span class="arithmatex">\(n\)</span> ,给定三个算法函数 <code>A</code> 、 <code>B</code> 和 <code>C</code> :</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="2:12"><input checked="checked" id="__tabbed_2_1" name="__tabbed_2" type="radio" /><input id="__tabbed_2_2" name="__tabbed_2" type="radio" /><input id="__tabbed_2_3" name="__tabbed_2" type="radio" /><input id="__tabbed_2_4" name="__tabbed_2" type="radio" /><input id="__tabbed_2_5" name="__tabbed_2" type="radio" /><input id="__tabbed_2_6" name="__tabbed_2" type="radio" /><input id="__tabbed_2_7" name="__tabbed_2" type="radio" /><input id="__tabbed_2_8" name="__tabbed_2" type="radio" /><input id="__tabbed_2_9" name="__tabbed_2" type="radio" /><input id="__tabbed_2_10" name="__tabbed_2" type="radio" /><input id="__tabbed_2_11" name="__tabbed_2" type="radio" /><input id="__tabbed_2_12" name="__tabbed_2" type="radio" /><div class="tabbed-labels"><label for="__tabbed_2_1">Java</label><label for="__tabbed_2_2">C++</label><label for="__tabbed_2_3">Python</label><label for="__tabbed_2_4">Go</label><label for="__tabbed_2_5">JS</label><label for="__tabbed_2_6">TS</label><label for="__tabbed_2_7">C</label><label for="__tabbed_2_8">C#</label><label for="__tabbed_2_9">Swift</label><label for="__tabbed_2_10">Zig</label><label for="__tabbed_2_11">Dart</label><label for="__tabbed_2_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
|
@ -3982,9 +3982,9 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>算法 <code>A</code> 只有 <span class="arithmatex">\(1\)</span> 个打印操作,算法运行时间不随着 <span class="arithmatex">\(n\)</span> 增大而增长。我们称此算法的时间复杂度为「常数阶」。</p>
|
||||
<p>算法 <code>B</code> 中的打印操作需要循环 <span class="arithmatex">\(n\)</span> 次,算法运行时间随着 <span class="arithmatex">\(n\)</span> 增大呈线性增长。此算法的时间复杂度被称为「线性阶」。</p>
|
||||
<p>算法 <code>C</code> 中的打印操作需要循环 <span class="arithmatex">\(1000000\)</span> 次,虽然运行时间很长,但它与输入数据大小 <span class="arithmatex">\(n\)</span> 无关。因此 <code>C</code> 的时间复杂度和 <code>A</code> 相同,仍为「常数阶」。</p>
|
||||
<p>算法 <code>A</code> 只有 <span class="arithmatex">\(1\)</span> 个打印操作,算法运行时间不随着 <span class="arithmatex">\(n\)</span> 增大而增长。我们称此算法的时间复杂度为“常数阶”。</p>
|
||||
<p>算法 <code>B</code> 中的打印操作需要循环 <span class="arithmatex">\(n\)</span> 次,算法运行时间随着 <span class="arithmatex">\(n\)</span> 增大呈线性增长。此算法的时间复杂度被称为“线性阶”。</p>
|
||||
<p>算法 <code>C</code> 中的打印操作需要循环 <span class="arithmatex">\(1000000\)</span> 次,虽然运行时间很长,但它与输入数据大小 <span class="arithmatex">\(n\)</span> 无关。因此 <code>C</code> 的时间复杂度和 <code>A</code> 相同,仍为“常数阶”。</p>
|
||||
<p><img alt="算法 A 、B 和 C 的时间增长趋势" src="../time_complexity.assets/time_complexity_simple_example.png" /></p>
|
||||
<p align="center"> 图:算法 A 、B 和 C 的时间增长趋势 </p>
|
||||
|
||||
|
@ -5595,7 +5595,7 @@ O((n - 1) \frac{n}{2}) = O(n^2)
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用「动态规划」或「贪心」等算法来解决。</p>
|
||||
<p>指数阶增长非常迅速,在穷举法(暴力搜索、回溯等)中比较常见。对于数据规模较大的问题,指数阶是不可接受的,通常需要使用动态规划或贪心等算法来解决。</p>
|
||||
<h3 id="5-olog-n">5. 对数阶 <span class="arithmatex">\(O(\log n)\)</span><a class="headerlink" href="#5-olog-n" title="Permanent link">¶</a></h3>
|
||||
<p>与指数阶相反,对数阶反映了“每轮缩减到一半”的情况。设输入数据大小为 <span class="arithmatex">\(n\)</span> ,由于每轮缩减到一半,因此循环次数是 <span class="arithmatex">\(\log_2 n\)</span> ,即 <span class="arithmatex">\(2^n\)</span> 的反函数。相关代码如下:</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="12:12"><input checked="checked" id="__tabbed_12_1" name="__tabbed_12" type="radio" /><input id="__tabbed_12_2" name="__tabbed_12" type="radio" /><input id="__tabbed_12_3" name="__tabbed_12" type="radio" /><input id="__tabbed_12_4" name="__tabbed_12" type="radio" /><input id="__tabbed_12_5" name="__tabbed_12" type="radio" /><input id="__tabbed_12_6" name="__tabbed_12" type="radio" /><input id="__tabbed_12_7" name="__tabbed_12" type="radio" /><input id="__tabbed_12_8" name="__tabbed_12" type="radio" /><input id="__tabbed_12_9" name="__tabbed_12" type="radio" /><input id="__tabbed_12_10" name="__tabbed_12" type="radio" /><input id="__tabbed_12_11" name="__tabbed_12" type="radio" /><input id="__tabbed_12_12" name="__tabbed_12" type="radio" /><div class="tabbed-labels"><label for="__tabbed_12_1">Java</label><label for="__tabbed_12_2">C++</label><label for="__tabbed_12_3">Python</label><label for="__tabbed_12_4">Go</label><label for="__tabbed_12_5">JS</label><label for="__tabbed_12_6">TS</label><label for="__tabbed_12_7">C</label><label for="__tabbed_12_8">C#</label><label for="__tabbed_12_9">Swift</label><label for="__tabbed_12_10">Zig</label><label for="__tabbed_12_11">Dart</label><label for="__tabbed_12_12">Rust</label></div>
|
||||
|
@ -6213,7 +6213,7 @@ n! = n \times (n - 1) \times (n - 2) \times \cdots \times 2 \times 1
|
|||
<li>当 <code>nums = [?, ?, ..., 1]</code> ,即当末尾元素是 <span class="arithmatex">\(1\)</span> 时,需要完整遍历数组,<strong>达到最差时间复杂度 <span class="arithmatex">\(O(n)\)</span></strong> 。</li>
|
||||
<li>当 <code>nums = [1, ?, ?, ...]</code> ,即当首个元素为 <span class="arithmatex">\(1\)</span> 时,无论数组多长都不需要继续遍历,<strong>达到最佳时间复杂度 <span class="arithmatex">\(\Omega(1)\)</span></strong> 。</li>
|
||||
</ul>
|
||||
<p>「最差时间复杂度」对应函数渐近上界,使用大 <span class="arithmatex">\(O\)</span> 记号表示。相应地,「最佳时间复杂度」对应函数渐近下界,用 <span class="arithmatex">\(\Omega\)</span> 记号表示:</p>
|
||||
<p>“最差时间复杂度”对应函数渐近上界,使用大 <span class="arithmatex">\(O\)</span> 记号表示。相应地,“最佳时间复杂度”对应函数渐近下界,用 <span class="arithmatex">\(\Omega\)</span> 记号表示:</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="16:12"><input checked="checked" id="__tabbed_16_1" name="__tabbed_16" type="radio" /><input id="__tabbed_16_2" name="__tabbed_16" type="radio" /><input id="__tabbed_16_3" name="__tabbed_16" type="radio" /><input id="__tabbed_16_4" name="__tabbed_16" type="radio" /><input id="__tabbed_16_5" name="__tabbed_16" type="radio" /><input id="__tabbed_16_6" name="__tabbed_16" type="radio" /><input id="__tabbed_16_7" name="__tabbed_16" type="radio" /><input id="__tabbed_16_8" name="__tabbed_16" type="radio" /><input id="__tabbed_16_9" name="__tabbed_16" type="radio" /><input id="__tabbed_16_10" name="__tabbed_16" type="radio" /><input id="__tabbed_16_11" name="__tabbed_16" type="radio" /><input id="__tabbed_16_12" name="__tabbed_16" type="radio" /><div class="tabbed-labels"><label for="__tabbed_16_1">Java</label><label for="__tabbed_16_2">C++</label><label for="__tabbed_16_3">Python</label><label for="__tabbed_16_4">Go</label><label for="__tabbed_16_5">JS</label><label for="__tabbed_16_6">TS</label><label for="__tabbed_16_7">C</label><label for="__tabbed_16_8">C#</label><label for="__tabbed_16_9">Swift</label><label for="__tabbed_16_10">Zig</label><label for="__tabbed_16_11">Dart</label><label for="__tabbed_16_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -6544,8 +6544,8 @@ n! = n \times (n - 1) \times (n - 2) \times \cdots \times 2 \times 1
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>值得说明的是,我们在实际中很少使用「最佳时间复杂度」,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。<strong>而「最差时间复杂度」更为实用,因为它给出了一个效率安全值</strong>,让我们可以放心地使用算法。</p>
|
||||
<p>从上述示例可以看出,最差或最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,<strong>「平均时间复杂度」可以体现算法在随机输入数据下的运行效率</strong>,用 <span class="arithmatex">\(\Theta\)</span> 记号来表示。</p>
|
||||
<p>值得说明的是,我们在实际中很少使用最佳时间复杂度,因为通常只有在很小概率下才能达到,可能会带来一定的误导性。<strong>而最差时间复杂度更为实用,因为它给出了一个效率安全值</strong>,让我们可以放心地使用算法。</p>
|
||||
<p>从上述示例可以看出,最差或最佳时间复杂度只出现于“特殊的数据分布”,这些情况的出现概率可能很小,并不能真实地反映算法运行效率。相比之下,<strong>平均时间复杂度可以体现算法在随机输入数据下的运行效率</strong>,用 <span class="arithmatex">\(\Theta\)</span> 记号来表示。</p>
|
||||
<p>对于部分算法,我们可以简单地推算出随机数据分布下的平均情况。比如上述示例,由于输入数组是被打乱的,因此元素 <span class="arithmatex">\(1\)</span> 出现在任意索引的概率都是相等的,那么算法的平均循环次数就是数组长度的一半 <span class="arithmatex">\(\frac{n}{2}\)</span> ,平均时间复杂度为 <span class="arithmatex">\(\Theta(\frac{n}{2}) = \Theta(n)\)</span> 。</p>
|
||||
<p>但对于较为复杂的算法,计算平均时间复杂度往往是比较困难的,因为很难分析出在数据分布下的整体数学期望。在这种情况下,我们通常使用最差时间复杂度作为算法效率的评判标准。</p>
|
||||
<div class="admonition question">
|
||||
|
|
|
@ -3482,7 +3482,7 @@
|
|||
</code></pre></div>
|
||||
</div>
|
||||
<div class="tabbed-block">
|
||||
<div class="highlight"><pre><span></span><code><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a><span class="c1">// 使用多种「基本数据类型」来初始化「数组」</span>
|
||||
<div class="highlight"><pre><span></span><code><a id="__codelineno-3-1" name="__codelineno-3-1" href="#__codelineno-3-1"></a><span class="c1">// 使用多种基本数据类型来初始化数组</span>
|
||||
<a id="__codelineno-3-2" name="__codelineno-3-2" href="#__codelineno-3-2"></a><span class="kd">var</span><span class="w"> </span><span class="nx">numbers</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">[</span><span class="mi">5</span><span class="p">]</span><span class="kt">int</span><span class="p">{}</span>
|
||||
<a id="__codelineno-3-3" name="__codelineno-3-3" href="#__codelineno-3-3"></a><span class="kd">var</span><span class="w"> </span><span class="nx">decimals</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">[</span><span class="mi">5</span><span class="p">]</span><span class="kt">float64</span><span class="p">{}</span>
|
||||
<a id="__codelineno-3-4" name="__codelineno-3-4" href="#__codelineno-3-4"></a><span class="kd">var</span><span class="w"> </span><span class="nx">characters</span><span class="w"> </span><span class="p">=</span><span class="w"> </span><span class="p">[</span><span class="mi">5</span><span class="p">]</span><span class="kt">byte</span><span class="p">{}</span>
|
||||
|
|
|
@ -3414,7 +3414,7 @@
|
|||
<h1 id="31">3.1 数据结构分类<a class="headerlink" href="#31" title="Permanent link">¶</a></h1>
|
||||
<p>常见的数据结构包括数组、链表、栈、队列、哈希表、树、堆、图,它们可以从“逻辑结构”和“物理结构”两个维度进行分类。</p>
|
||||
<h2 id="311">3.1.1 逻辑结构:线性与非线性<a class="headerlink" href="#311" title="Permanent link">¶</a></h2>
|
||||
<p><strong>「逻辑结构」揭示了数据元素之间的逻辑关系</strong>。在数组和链表中,数据按照顺序依次排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出祖先与后代之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。</p>
|
||||
<p><strong>逻辑结构揭示了数据元素之间的逻辑关系</strong>。在数组和链表中,数据按照顺序依次排列,体现了数据之间的线性关系;而在树中,数据从顶部向下按层次排列,表现出祖先与后代之间的派生关系;图则由节点和边构成,反映了复杂的网络关系。</p>
|
||||
<p>逻辑结构可被分为“线性”和“非线性”两大类。线性结构比较直观,指数据在逻辑关系上呈线性排列;非线性结构则相反,呈非线性排列。</p>
|
||||
<ul>
|
||||
<li><strong>线性数据结构</strong>:数组、链表、栈、队列、哈希表。</li>
|
||||
|
@ -3437,7 +3437,7 @@
|
|||
<p align="center"> 图:内存条、内存空间、内存地址 </p>
|
||||
|
||||
<p>内存是所有程序的共享资源,当某块内存被某个程序占用时,则无法被其他程序同时使用了。<strong>因此在数据结构与算法的设计中,内存资源是一个重要的考虑因素</strong>。比如,算法所占用的内存峰值不应超过系统剩余空闲内存;如果缺少连续大块的内存空间,那么所选用的数据结构必须能够存储在离散的内存空间内。</p>
|
||||
<p><strong>「物理结构」反映了数据在计算机内存中的存储方式</strong>,可分为连续空间存储(数组)和离散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,同时在时间效率和空间效率方面呈现出互补的特点。</p>
|
||||
<p><strong>物理结构反映了数据在计算机内存中的存储方式</strong>,可分为连续空间存储(数组)和离散空间存储(链表)。物理结构从底层决定了数据的访问、更新、增删等操作方法,同时在时间效率和空间效率方面呈现出互补的特点。</p>
|
||||
<p><img alt="连续空间存储与离散空间存储" src="../classification_of_data_structure.assets/classification_phisical_structure.png" /></p>
|
||||
<p align="center"> 图:连续空间存储与离散空间存储 </p>
|
||||
|
||||
|
|
|
@ -3417,8 +3417,8 @@
|
|||
<p>在本书中,标题带有的 * 符号的是选读章节。如果你时间有限或感到理解困难,可以先跳过,等学完必读章节后再单独攻克。</p>
|
||||
</div>
|
||||
<h2 id="331">3.3.1 原码、反码和补码<a class="headerlink" href="#331" title="Permanent link">¶</a></h2>
|
||||
<p>从上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个。例如,<code>byte</code> 的取值范围是 <span class="arithmatex">\([-128, 127]\)</span> 。这个现象比较反直觉,它的内在原因涉及到原码、反码、补码的相关知识。</p>
|
||||
<p>在展开分析之前,我们首先给出三者的定义:</p>
|
||||
<p>在上一节的表格中我们发现,所有整数类型能够表示的负数都比正数多一个,例如 <code>byte</code> 的取值范围是 <span class="arithmatex">\([-128, 127]\)</span> 。这个现象比较反直觉,它的内在原因涉及到原码、反码、补码的相关知识。</p>
|
||||
<p>实际上,<strong>数字是以“补码”的形式存储在计算机中的</strong>。在分析这样做的原因之前,我们首先给出三者的定义:</p>
|
||||
<ul>
|
||||
<li><strong>原码</strong>:我们将数字的二进制表示的最高位视为符号位,其中 <span class="arithmatex">\(0\)</span> 表示正数,<span class="arithmatex">\(1\)</span> 表示负数,其余位表示数字的值。</li>
|
||||
<li><strong>反码</strong>:正数的反码与其原码相同,负数的反码是对其原码除符号位外的所有位取反。</li>
|
||||
|
@ -3427,8 +3427,7 @@
|
|||
<p><img alt="原码、反码与补码之间的相互转换" src="../number_encoding.assets/1s_2s_complement.png" /></p>
|
||||
<p align="center"> 图:原码、反码与补码之间的相互转换 </p>
|
||||
|
||||
<p>显然「原码」最为直观。但实际上,<strong>数字是以「补码」的形式存储在计算机中的</strong>。这是因为原码存在一些局限性。</p>
|
||||
<p>一方面,<strong>负数的原码不能直接用于运算</strong>。例如,我们在原码下计算 <span class="arithmatex">\(1 + (-2)\)</span> ,得到的结果是 <span class="arithmatex">\(-3\)</span> ,这显然是不对的。</p>
|
||||
<p>「原码 true form」虽然最直观,但存在一些局限性。一方面,<strong>负数的原码不能直接用于运算</strong>。例如在原码下计算 <span class="arithmatex">\(1 + (-2)\)</span> ,得到的结果是 <span class="arithmatex">\(-3\)</span> ,这显然是不对的。</p>
|
||||
<div class="arithmatex">\[
|
||||
\begin{aligned}
|
||||
& 1 + (-2) \newline
|
||||
|
@ -3437,7 +3436,7 @@
|
|||
& = -3
|
||||
\end{aligned}
|
||||
\]</div>
|
||||
<p>为了解决此问题,计算机引入了「反码」。如果我们先将原码转换为反码,并在反码下计算 <span class="arithmatex">\(1 + (-2)\)</span> ,最后将结果从反码转化回原码,则可得到正确结果 <span class="arithmatex">\(-1\)</span> 。</p>
|
||||
<p>为了解决此问题,计算机引入了「反码 1's complement code」。如果我们先将原码转换为反码,并在反码下计算 <span class="arithmatex">\(1 + (-2)\)</span> ,最后将结果从反码转化回原码,则可得到正确结果 <span class="arithmatex">\(-1\)</span> 。</p>
|
||||
<div class="arithmatex">\[
|
||||
\begin{aligned}
|
||||
& 1 + (-2) \newline
|
||||
|
@ -3455,7 +3454,7 @@
|
|||
-0 & = 1000 \space 0000
|
||||
\end{aligned}
|
||||
\]</div>
|
||||
<p>与原码一样,反码也存在正负零歧义问题,因此计算机进一步引入了「补码」。我们先来观察一下负零的原码、反码、补码的转换过程:</p>
|
||||
<p>与原码一样,反码也存在正负零歧义问题,因此计算机进一步引入了「补码 2's complement code」。我们先来观察一下负零的原码、反码、补码的转换过程:</p>
|
||||
<div class="arithmatex">\[
|
||||
\begin{aligned}
|
||||
-0 = \space & 1000 \space 0000 \space \text{(原码)} \newline
|
||||
|
|
|
@ -3474,12 +3474,12 @@
|
|||
|
||||
|
||||
<h1 id="121">12.1 分治算法<a class="headerlink" href="#121" title="Permanent link">¶</a></h1>
|
||||
<p>「分治 Divide and Conquer」,全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现,包括“分”和“治”两步:</p>
|
||||
<p>「分治 divide and conquer」,全称分而治之,是一种非常重要且常见的算法策略。分治通常基于递归实现,包括“分”和“治”两步:</p>
|
||||
<ol>
|
||||
<li><strong>分(划分阶段)</strong>:递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。</li>
|
||||
<li><strong>治(合并阶段)</strong>:从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解。</li>
|
||||
</ol>
|
||||
<p>已介绍过的「归并排序」是分治策略的典型应用之一,它的分治策略为:</p>
|
||||
<p>我们已学过的“归并排序”是分治策略的典型应用之一,其算法原理为:</p>
|
||||
<ol>
|
||||
<li><strong>分</strong>:递归地将原数组(原问题)划分为两个子数组(子问题),直到子数组只剩一个元素(最小子问题)。</li>
|
||||
<li><strong>治</strong>:从底至顶地将有序的子数组(子问题的解)进行合并,从而得到有序的原数组(原问题的解)。</li>
|
||||
|
@ -3504,7 +3504,7 @@
|
|||
<p>分治不仅可以有效地解决算法问题,<strong>往往还可以带来算法效率的提升</strong>。在排序算法中,快速排序、归并排序、堆排序相较于选择、冒泡、插入排序更快,就是因为它们应用了分治策略。</p>
|
||||
<p>那么,我们不禁发问:<strong>为什么分治可以提升算法效率,其底层逻辑是什么</strong>?换句话说,将大问题分解为多个子问题、解决子问题、将子问题的解合并为原问题的解,这几步的效率为什么比直接解决原问题的效率更高?这个问题可以从操作数量和并行计算两方面来讨论。</p>
|
||||
<h3 id="1">1. 操作数量优化<a class="headerlink" href="#1" title="Permanent link">¶</a></h3>
|
||||
<p>以「冒泡排序」为例,其处理一个长度为 <span class="arithmatex">\(n\)</span> 的数组需要 <span class="arithmatex">\(O(n^2)\)</span> 时间。假设我们把数组从中点分为两个子数组,则划分需要 <span class="arithmatex">\(O(n)\)</span> 时间,排序每个子数组需要 <span class="arithmatex">\(O((\frac{n}{2})^2)\)</span> 时间,合并两个子数组需要 <span class="arithmatex">\(O(n)\)</span> 时间,总体时间复杂度为:</p>
|
||||
<p>以“冒泡排序”为例,其处理一个长度为 <span class="arithmatex">\(n\)</span> 的数组需要 <span class="arithmatex">\(O(n^2)\)</span> 时间。假设我们把数组从中点分为两个子数组,则划分需要 <span class="arithmatex">\(O(n)\)</span> 时间,排序每个子数组需要 <span class="arithmatex">\(O((\frac{n}{2})^2)\)</span> 时间,合并两个子数组需要 <span class="arithmatex">\(O(n)\)</span> 时间,总体时间复杂度为:</p>
|
||||
<div class="arithmatex">\[
|
||||
O(n + (\frac{n}{2})^2 \times 2 + n) = O(\frac{n^2}{2} + 2n)
|
||||
\]</div>
|
||||
|
@ -3520,8 +3520,8 @@ n(n - 4) & > 0
|
|||
\end{aligned}
|
||||
\]</div>
|
||||
<p><strong>这意味着当 <span class="arithmatex">\(n > 4\)</span> 时,划分后的操作数量更少,排序效率应该更高</strong>。请注意,划分后的时间复杂度仍然是平方阶 <span class="arithmatex">\(O(n^2)\)</span> ,只是复杂度中的常数项变小了。</p>
|
||||
<p>进一步想,<strong>如果我们把子数组不断地再从中点划分为两个子数组</strong>,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是「归并排序」,时间复杂度为 <span class="arithmatex">\(O(n \log n)\)</span> 。</p>
|
||||
<p>再思考,<strong>如果我们多设置几个划分点</strong>,将原数组平均划分为 <span class="arithmatex">\(k\)</span> 个子数组呢?这种情况与「桶排序」非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 <span class="arithmatex">\(O(n + k)\)</span> 。</p>
|
||||
<p>进一步想,<strong>如果我们把子数组不断地再从中点划分为两个子数组</strong>,直至子数组只剩一个元素时停止划分呢?这种思路实际上就是“归并排序”,时间复杂度为 <span class="arithmatex">\(O(n \log n)\)</span> 。</p>
|
||||
<p>再思考,<strong>如果我们多设置几个划分点</strong>,将原数组平均划分为 <span class="arithmatex">\(k\)</span> 个子数组呢?这种情况与“桶排序”非常类似,它非常适合排序海量数据,理论上时间复杂度可以达到 <span class="arithmatex">\(O(n + k)\)</span> 。</p>
|
||||
<h3 id="2">2. 并行计算优化<a class="headerlink" href="#2" title="Permanent link">¶</a></h3>
|
||||
<p>我们知道,分治生成的子问题是相互独立的,<strong>因此通常可以并行解决</strong>。也就是说,分治不仅可以降低算法的时间复杂度,<strong>还有利于操作系统的并行优化</strong>。</p>
|
||||
<p>并行优化在多核或多处理器的环境中尤其有效,因为系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间。</p>
|
||||
|
|
|
@ -3422,9 +3422,9 @@
|
|||
<h1 id="142">14.2 动态规划问题特性<a class="headerlink" href="#142" title="Permanent link">¶</a></h1>
|
||||
<p>在上节中,我们学习了动态规划是如何通过子问题分解来求解问题的。实际上,子问题分解是一种通用的算法思路,在分治、动态规划、回溯中的侧重点不同:</p>
|
||||
<ul>
|
||||
<li>「分治算法」递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。</li>
|
||||
<li>「动态规划」也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。</li>
|
||||
<li>「回溯算法」在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。</li>
|
||||
<li>分治算法递归地将原问题划分为多个相互独立的子问题,直至最小子问题,并在回溯中合并子问题的解,最终得到原问题的解。</li>
|
||||
<li>动态规划也对问题进行递归分解,但与分治算法的主要区别是,动态规划中的子问题是相互依赖的,在分解过程中会出现许多重叠子问题。</li>
|
||||
<li>回溯算法在尝试和回退中穷举所有可能的解,并通过剪枝避免不必要的搜索分支。原问题的解由一系列决策步骤构成,我们可以将每个决策步骤之前的子序列看作为一个子问题。</li>
|
||||
</ul>
|
||||
<p>实际上,动态规划常用来求解最优化问题,它们不仅包含重叠子问题,还具有另外两大特性:最优子结构、无后效性。</p>
|
||||
<h2 id="1421">14.2.1 最优子结构<a class="headerlink" href="#1421" title="Permanent link">¶</a></h2>
|
||||
|
@ -3441,7 +3441,7 @@
|
|||
<div class="arithmatex">\[
|
||||
dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
|
||||
\]</div>
|
||||
<p>这便可以引出「最优子结构」的含义:<strong>原问题的最优解是从子问题的最优解构建得来的</strong>。</p>
|
||||
<p>这便可以引出最优子结构的含义:<strong>原问题的最优解是从子问题的最优解构建得来的</strong>。</p>
|
||||
<p>本题显然具有最优子结构:我们从两个子问题最优解 <span class="arithmatex">\(dp[i-1]\)</span> , <span class="arithmatex">\(dp[i-2]\)</span> 中挑选出较优的那一个,并用它构建出原问题 <span class="arithmatex">\(dp[i]\)</span> 的最优解。</p>
|
||||
<p>那么,上节的爬楼梯题目有没有最优子结构呢?它的目标是求解方案数量,看似是一个计数问题,但如果换一种问法:“求解最大方案数量”。我们意外地发现,<strong>虽然题目修改前后是等价的,但最优子结构浮现出来了</strong>:第 <span class="arithmatex">\(n\)</span> 阶最大方案数量等于第 <span class="arithmatex">\(n-1\)</span> 阶和第 <span class="arithmatex">\(n-2\)</span> 阶最大方案数量之和。所以说,最优子结构的解释方式比较灵活,在不同问题中会有不同的含义。</p>
|
||||
<p>根据状态转移方程,以及初始状态 <span class="arithmatex">\(dp[1] = cost[1]\)</span> , <span class="arithmatex">\(dp[2] = cost[2]\)</span> ,可以得出动态规划代码。</p>
|
||||
|
@ -3794,7 +3794,7 @@ dp[i] = \min(dp[i-1], dp[i-2]) + cost[i]
|
|||
</div>
|
||||
</div>
|
||||
<h2 id="1422">14.2.2 无后效性<a class="headerlink" href="#1422" title="Permanent link">¶</a></h2>
|
||||
<p>「无后效性」是动态规划能够有效解决问题的重要特性之一,定义为:<strong>给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关</strong>。</p>
|
||||
<p>无后效性是动态规划能够有效解决问题的重要特性之一,定义为:<strong>给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关</strong>。</p>
|
||||
<p>以爬楼梯问题为例,给定状态 <span class="arithmatex">\(i\)</span> ,它会发展出状态 <span class="arithmatex">\(i+1\)</span> 和状态 <span class="arithmatex">\(i+2\)</span> ,分别对应跳 <span class="arithmatex">\(1\)</span> 步和跳 <span class="arithmatex">\(2\)</span> 步。在做出这两种选择时,我们无须考虑状态 <span class="arithmatex">\(i\)</span> 之前的状态,它们对状态 <span class="arithmatex">\(i\)</span> 的未来没有影响。</p>
|
||||
<p>然而,如果我们向爬楼梯问题添加一个约束,情况就不一样了。</p>
|
||||
<div class="admonition question">
|
||||
|
|
|
@ -3510,7 +3510,7 @@
|
|||
<p>如果一个问题满足决策树模型,并具有较为明显的“加分项“,我们就可以假设它是一个动态规划问题,并在求解过程中验证它。</p>
|
||||
<h2 id="1432">14.3.2 问题求解步骤<a class="headerlink" href="#1432" title="Permanent link">¶</a></h2>
|
||||
<p>动态规划的解题流程会因问题的性质和难度而有所不同,但通常遵循以下步骤:描述决策,定义状态,建立 <span class="arithmatex">\(dp\)</span> 表,推导状态转移方程,确定边界条件等。</p>
|
||||
<p>为了更形象地展示解题步骤,我们使用一个经典问题「最小路径和」来举例。</p>
|
||||
<p>为了更形象地展示解题步骤,我们使用一个经典问题“最小路径和”来举例。</p>
|
||||
<div class="admonition question">
|
||||
<p class="admonition-title">Question</p>
|
||||
<p>给定一个 <span class="arithmatex">\(n \times m\)</span> 的二维网格 <code>grid</code> ,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角单元格。请返回从左上角到右下角的最小路径和。</p>
|
||||
|
|
|
@ -3448,7 +3448,7 @@
|
|||
|
||||
|
||||
<h1 id="141">14.1 初探动态规划<a class="headerlink" href="#141" title="Permanent link">¶</a></h1>
|
||||
<p>「动态规划 Dynamic Programming」是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。</p>
|
||||
<p>「动态规划 dynamic programming」是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。</p>
|
||||
<p>在本节中,我们从一个经典例题入手,先给出它的暴力回溯解法,观察其中包含的重叠子问题,再逐步导出更高效的动态规划解法。</p>
|
||||
<div class="admonition question">
|
||||
<p class="admonition-title">爬楼梯</p>
|
||||
|
@ -3997,7 +3997,7 @@ dp[i] = dp[i-1] + dp[i-2]
|
|||
<p><img alt="爬楼梯对应递归树" src="../intro_to_dynamic_programming.assets/climbing_stairs_dfs_tree.png" /></p>
|
||||
<p align="center"> 图:爬楼梯对应递归树 </p>
|
||||
|
||||
<p>观察上图发现,<strong>指数阶的时间复杂度是由于「重叠子问题」导致的</strong>。例如:<span class="arithmatex">\(dp[9]\)</span> 被分解为 <span class="arithmatex">\(dp[8]\)</span> 和 <span class="arithmatex">\(dp[7]\)</span> ,<span class="arithmatex">\(dp[8]\)</span> 被分解为 <span class="arithmatex">\(dp[7]\)</span> 和 <span class="arithmatex">\(dp[6]\)</span> ,两者都包含子问题 <span class="arithmatex">\(dp[7]\)</span> 。</p>
|
||||
<p>观察上图发现,<strong>指数阶的时间复杂度是由于“重叠子问题”导致的</strong>。例如:<span class="arithmatex">\(dp[9]\)</span> 被分解为 <span class="arithmatex">\(dp[8]\)</span> 和 <span class="arithmatex">\(dp[7]\)</span> ,<span class="arithmatex">\(dp[8]\)</span> 被分解为 <span class="arithmatex">\(dp[7]\)</span> 和 <span class="arithmatex">\(dp[6]\)</span> ,两者都包含子问题 <span class="arithmatex">\(dp[7]\)</span> 。</p>
|
||||
<p>以此类推,子问题中包含更小的重叠子问题,子子孙孙无穷尽也。绝大部分计算资源都浪费在这些重叠的问题上。</p>
|
||||
<h2 id="1412">14.1.2 方法二:记忆化搜索<a class="headerlink" href="#1412" title="Permanent link">¶</a></h2>
|
||||
<p>为了提升算法效率,<strong>我们希望所有的重叠子问题都只被计算一次</strong>。为此,我们声明一个数组 <code>mem</code> 来记录每个子问题的解,并在搜索过程中这样做:</p>
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 69 KiB After Width: | Height: | Size: 69 KiB |
|
@ -3480,7 +3480,7 @@
|
|||
|
||||
|
||||
<h1 id="91">9.1 图<a class="headerlink" href="#91" title="Permanent link">¶</a></h1>
|
||||
<p>「图 Graph」是一种非线性数据结构,由「顶点 Vertex」和「边 Edge」组成。我们可以将图 <span class="arithmatex">\(G\)</span> 抽象地表示为一组顶点 <span class="arithmatex">\(V\)</span> 和一组边 <span class="arithmatex">\(E\)</span> 的集合。以下示例展示了一个包含 5 个顶点和 7 条边的图。</p>
|
||||
<p>「图 graph」是一种非线性数据结构,由「顶点 vertex」和「边 edge」组成。我们可以将图 <span class="arithmatex">\(G\)</span> 抽象地表示为一组顶点 <span class="arithmatex">\(V\)</span> 和一组边 <span class="arithmatex">\(E\)</span> 的集合。以下示例展示了一个包含 5 个顶点和 7 条边的图。</p>
|
||||
<div class="arithmatex">\[
|
||||
\begin{aligned}
|
||||
V & = \{ 1, 2, 3, 4, 5 \} \newline
|
||||
|
@ -3491,9 +3491,9 @@ G & = \{ V, E \} \newline
|
|||
<p><img alt="链表、树、图之间的关系" src="../graph.assets/linkedlist_tree_graph.png" /></p>
|
||||
<p align="center"> 图:链表、树、图之间的关系 </p>
|
||||
|
||||
<p>那么,图与其他数据结构的关系是什么?如果我们把「顶点」看作节点,把「边」看作连接各个节点的指针,则可将「图」看作是一种从「链表」拓展而来的数据结构。<strong>相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂</strong>。</p>
|
||||
<p>那么,图与其他数据结构的关系是什么?如果我们把顶点看作节点,把边看作连接各个节点的指针,则可将图看作是一种从链表拓展而来的数据结构。<strong>相较于线性关系(链表)和分治关系(树),网络关系(图)的自由度更高,从而更为复杂</strong>。</p>
|
||||
<h2 id="911">9.1.1 图常见类型<a class="headerlink" href="#911" title="Permanent link">¶</a></h2>
|
||||
<p>根据边是否具有方向,可分为「无向图 Undirected Graph」和「有向图 Directed Graph」。</p>
|
||||
<p>根据边是否具有方向,可分为「无向图 undirected graph」和「有向图 directed graph」。</p>
|
||||
<ul>
|
||||
<li>在无向图中,边表示两顶点之间的“双向”连接关系,例如微信或 QQ 中的“好友关系”。</li>
|
||||
<li>在有向图中,边具有方向性,即 <span class="arithmatex">\(A \rightarrow B\)</span> 和 <span class="arithmatex">\(A \leftarrow B\)</span> 两个方向的边是相互独立的,例如微博或抖音上的“关注”与“被关注”关系。</li>
|
||||
|
@ -3501,7 +3501,7 @@ G & = \{ V, E \} \newline
|
|||
<p><img alt="有向图与无向图" src="../graph.assets/directed_graph.png" /></p>
|
||||
<p align="center"> 图:有向图与无向图 </p>
|
||||
|
||||
<p>根据所有顶点是否连通,可分为「连通图 Connected Graph」和「非连通图 Disconnected Graph」。</p>
|
||||
<p>根据所有顶点是否连通,可分为「连通图 connected graph」和「非连通图 disconnected graph」。</p>
|
||||
<ul>
|
||||
<li>对于连通图,从某个顶点出发,可以到达其余任意顶点。</li>
|
||||
<li>对于非连通图,从某个顶点出发,至少有一个顶点无法到达。</li>
|
||||
|
@ -3509,21 +3509,21 @@ G & = \{ V, E \} \newline
|
|||
<p><img alt="连通图与非连通图" src="../graph.assets/connected_graph.png" /></p>
|
||||
<p align="center"> 图:连通图与非连通图 </p>
|
||||
|
||||
<p>我们还可以为边添加“权重”变量,从而得到「有权图 Weighted Graph」。例如,在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。</p>
|
||||
<p>我们还可以为边添加“权重”变量,从而得到「有权图 weighted graph」。例如,在王者荣耀等手游中,系统会根据共同游戏时间来计算玩家之间的“亲密度”,这种亲密度网络就可以用有权图来表示。</p>
|
||||
<p><img alt="有权图与无权图" src="../graph.assets/weighted_graph.png" /></p>
|
||||
<p align="center"> 图:有权图与无权图 </p>
|
||||
|
||||
<h2 id="912">9.1.2 图常用术语<a class="headerlink" href="#912" title="Permanent link">¶</a></h2>
|
||||
<ul>
|
||||
<li>「邻接 Adjacency」:当两顶点之间存在边相连时,称这两顶点“邻接”。在上图中,顶点 1 的邻接顶点为顶点 2、3、5。</li>
|
||||
<li>「路径 Path」:从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。在上图中,边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。</li>
|
||||
<li>「度 Degree」表示一个顶点拥有的边数。对于有向图,「入度 In-Degree」表示有多少条边指向该顶点,「出度 Out-Degree」表示有多少条边从该顶点指出。</li>
|
||||
<li>「邻接 adjacency」:当两顶点之间存在边相连时,称这两顶点“邻接”。在上图中,顶点 1 的邻接顶点为顶点 2、3、5。</li>
|
||||
<li>「路径 path」:从顶点 A 到顶点 B 经过的边构成的序列被称为从 A 到 B 的“路径”。在上图中,边序列 1-5-2-4 是顶点 1 到顶点 4 的一条路径。</li>
|
||||
<li>「度 degree」:一个顶点拥有的边数。对于有向图,「入度 In-Degree」表示有多少条边指向该顶点,「出度 Out-Degree」表示有多少条边从该顶点指出。</li>
|
||||
</ul>
|
||||
<h2 id="913">9.1.3 图的表示<a class="headerlink" href="#913" title="Permanent link">¶</a></h2>
|
||||
<p>图的常用表示方法包括「邻接矩阵」和「邻接表」。以下使用无向图进行举例。</p>
|
||||
<p>图的常用表示方法包括“邻接矩阵”和“邻接表”。以下使用无向图进行举例。</p>
|
||||
<h3 id="1">1. 邻接矩阵<a class="headerlink" href="#1" title="Permanent link">¶</a></h3>
|
||||
<p>设图的顶点数量为 <span class="arithmatex">\(n\)</span> ,「邻接矩阵 Adjacency Matrix」使用一个 <span class="arithmatex">\(n \times n\)</span> 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 <span class="arithmatex">\(1\)</span> 或 <span class="arithmatex">\(0\)</span> 表示两个顶点之间是否存在边。</p>
|
||||
<p>如下图所示,设邻接矩阵为 <span class="arithmatex">\(M\)</span> 、顶点列表为 <span class="arithmatex">\(V\)</span> ,那么矩阵元素 <span class="arithmatex">\(M[i][j] = 1\)</span> 表示顶点 <span class="arithmatex">\(V[i]\)</span> 到顶点 <span class="arithmatex">\(V[j]\)</span> 之间存在边,反之 <span class="arithmatex">\(M[i][j] = 0\)</span> 表示两顶点之间无边。</p>
|
||||
<p>设图的顶点数量为 <span class="arithmatex">\(n\)</span> ,「邻接矩阵 adjacency matrix」使用一个 <span class="arithmatex">\(n \times n\)</span> 大小的矩阵来表示图,每一行(列)代表一个顶点,矩阵元素代表边,用 <span class="arithmatex">\(1\)</span> 或 <span class="arithmatex">\(0\)</span> 表示两个顶点之间是否存在边。</p>
|
||||
<p>如下图所示,设邻接矩阵为 <span class="arithmatex">\(M\)</span> 、顶点列表为 <span class="arithmatex">\(V\)</span> ,那么矩阵元素 <span class="arithmatex">\(M[i, j] = 1\)</span> 表示顶点 <span class="arithmatex">\(V[i]\)</span> 到顶点 <span class="arithmatex">\(V[j]\)</span> 之间存在边,反之 <span class="arithmatex">\(M[i, j] = 0\)</span> 表示两顶点之间无边。</p>
|
||||
<p><img alt="图的邻接矩阵表示" src="../graph.assets/adjacency_matrix.png" /></p>
|
||||
<p align="center"> 图:图的邻接矩阵表示 </p>
|
||||
|
||||
|
@ -3535,12 +3535,12 @@ G & = \{ V, E \} \newline
|
|||
</ul>
|
||||
<p>使用邻接矩阵表示图时,我们可以直接访问矩阵元素以获取边,因此增删查操作的效率很高,时间复杂度均为 <span class="arithmatex">\(O(1)\)</span> 。然而,矩阵的空间复杂度为 <span class="arithmatex">\(O(n^2)\)</span> ,内存占用较多。</p>
|
||||
<h3 id="2">2. 邻接表<a class="headerlink" href="#2" title="Permanent link">¶</a></h3>
|
||||
<p>「邻接表 Adjacency List」使用 <span class="arithmatex">\(n\)</span> 个链表来表示图,链表节点表示顶点。第 <span class="arithmatex">\(i\)</span> 条链表对应顶点 <span class="arithmatex">\(i\)</span> ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。</p>
|
||||
<p>「邻接表 adjacency list」使用 <span class="arithmatex">\(n\)</span> 个链表来表示图,链表节点表示顶点。第 <span class="arithmatex">\(i\)</span> 条链表对应顶点 <span class="arithmatex">\(i\)</span> ,其中存储了该顶点的所有邻接顶点(即与该顶点相连的顶点)。</p>
|
||||
<p><img alt="图的邻接表表示" src="../graph.assets/adjacency_list.png" /></p>
|
||||
<p align="center"> 图:图的邻接表表示 </p>
|
||||
|
||||
<p>邻接表仅存储实际存在的边,而边的总数通常远小于 <span class="arithmatex">\(n^2\)</span> ,因此它更加节省空间。然而,在邻接表中需要通过遍历链表来查找边,因此其时间效率不如邻接矩阵。</p>
|
||||
<p>观察上图可发现,<strong>邻接表结构与哈希表中的「链地址法」非常相似,因此我们也可以采用类似方法来优化效率</strong>。例如,当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 <span class="arithmatex">\(O(n)\)</span> 优化至 <span class="arithmatex">\(O(\log n)\)</span> ,还可以通过中序遍历获取有序序列;此外,还可以将链表转换为哈希表,将时间复杂度降低至 <span class="arithmatex">\(O(1)\)</span> 。</p>
|
||||
<p>观察上图,<strong>邻接表结构与哈希表中的“链式地址”非常相似,因此我们也可以采用类似方法来优化效率</strong>。比如当链表较长时,可以将链表转化为 AVL 树或红黑树,从而将时间效率从 <span class="arithmatex">\(O(n)\)</span> 优化至 <span class="arithmatex">\(O(\log n)\)</span> ;还可以把链表转换为哈希表,从而将时间复杂度降低至 <span class="arithmatex">\(O(1)\)</span> 。</p>
|
||||
<h2 id="914">9.1.4 图常见应用<a class="headerlink" href="#914" title="Permanent link">¶</a></h2>
|
||||
<p>实际应用中,许多系统都可以用图来建模,相应的待求解问题也可以约化为图计算问题。</p>
|
||||
<p align="center"> 表:现实生活中常见的图 </p>
|
||||
|
|
|
@ -3426,7 +3426,7 @@
|
|||
|
||||
|
||||
<h1 id="92">9.2 图基础操作<a class="headerlink" href="#92" title="Permanent link">¶</a></h1>
|
||||
<p>图的基础操作可分为对「边」的操作和对「顶点」的操作。在「邻接矩阵」和「邻接表」两种表示方法下,实现方式有所不同。</p>
|
||||
<p>图的基础操作可分为对“边”的操作和对“顶点”的操作。在“邻接矩阵”和“邻接表”两种表示方法下,实现方式有所不同。</p>
|
||||
<h2 id="921">9.2.1 基于邻接矩阵的实现<a class="headerlink" href="#921" title="Permanent link">¶</a></h2>
|
||||
<p>给定一个顶点数量为 <span class="arithmatex">\(n\)</span> 的无向图,则有:</p>
|
||||
<ul>
|
||||
|
|
|
@ -3496,15 +3496,15 @@
|
|||
<p class="admonition-title">图与树的关系</p>
|
||||
<p>树代表的是“一对多”的关系,而图则具有更高的自由度,可以表示任意的“多对多”关系。因此,我们可以把树看作是图的一种特例。显然,<strong>树的遍历操作也是图的遍历操作的一种特例</strong>,建议你在学习本章节时融会贯通两者的概念与实现方法。</p>
|
||||
</div>
|
||||
<p>「图」和「树」都是非线性数据结构,都需要使用「搜索算法」来实现遍历操作。</p>
|
||||
<p>与树类似,图的遍历方式也可分为两种,即「广度优先遍历 Breadth-First Traversal」和「深度优先遍历 Depth-First Traversal」,也称为「广度优先搜索 Breadth-First Search」和「深度优先搜索 Depth-First Search」,简称 BFS 和 DFS。</p>
|
||||
<p>图和树都是非线性数据结构,都需要使用搜索算法来实现遍历操作。</p>
|
||||
<p>与树类似,图的遍历方式也可分为两种,即「广度优先遍历 breadth-first traversal」和「深度优先遍历 depth-first traversal」。它们也被称为「广度优先搜索 breadth-first search」和「深度优先搜索 depth-first search」,简称 BFS 和 DFS 。</p>
|
||||
<h2 id="931">9.3.1 广度优先遍历<a class="headerlink" href="#931" title="Permanent link">¶</a></h2>
|
||||
<p><strong>广度优先遍历是一种由近及远的遍历方式,从距离最近的顶点开始访问,并一层层向外扩张</strong>。具体来说,从某个顶点出发,先遍历该顶点的所有邻接顶点,然后遍历下一个顶点的所有邻接顶点,以此类推,直至所有顶点访问完毕。</p>
|
||||
<p><img alt="图的广度优先遍历" src="../graph_traversal.assets/graph_bfs.png" /></p>
|
||||
<p align="center"> 图:图的广度优先遍历 </p>
|
||||
|
||||
<h3 id="1">1. 算法实现<a class="headerlink" href="#1" title="Permanent link">¶</a></h3>
|
||||
<p>BFS 通常借助「队列」来实现。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。</p>
|
||||
<p>BFS 通常借助队列来实现。队列具有“先入先出”的性质,这与 BFS 的“由近及远”的思想异曲同工。</p>
|
||||
<ol>
|
||||
<li>将遍历起始顶点 <code>startVet</code> 加入队列,并开启循环。</li>
|
||||
<li>在循环的每轮迭代中,弹出队首顶点并记录访问,然后将该顶点的所有邻接顶点加入到队列尾部。</li>
|
||||
|
|
|
@ -3474,7 +3474,7 @@
|
|||
</ol>
|
||||
<p>哈希表的结构改良方法主要包括链式地址和开放寻址。</p>
|
||||
<h2 id="621">6.2.1 链式地址<a class="headerlink" href="#621" title="Permanent link">¶</a></h2>
|
||||
<p>在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 Separate Chaining」将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。</p>
|
||||
<p>在原始哈希表中,每个桶仅能存储一个键值对。「链式地址 separate chaining」将单个元素转换为链表,将键值对作为链表节点,将所有发生冲突的键值对都存储在同一链表中。</p>
|
||||
<p><img alt="链式地址哈希表" src="../hash_collision.assets/hash_table_chaining.png" /></p>
|
||||
<p align="center"> 图:链式地址哈希表 </p>
|
||||
|
||||
|
@ -4587,10 +4587,10 @@
|
|||
</div>
|
||||
<div class="admonition tip">
|
||||
<p class="admonition-title">Tip</p>
|
||||
<p>当链表很长时,查询效率 <span class="arithmatex">\(O(n)\)</span> 很差,<strong>此时可以将链表转换为「AVL 树」或「红黑树」</strong>,从而将查询操作的时间复杂度优化至 <span class="arithmatex">\(O(\log n)\)</span> 。</p>
|
||||
<p>当链表很长时,查询效率 <span class="arithmatex">\(O(n)\)</span> 很差,<strong>此时可以将链表转换为“AVL 树”或“红黑树”</strong>,从而将查询操作的时间复杂度优化至 <span class="arithmatex">\(O(\log n)\)</span> 。</p>
|
||||
</div>
|
||||
<h2 id="622">6.2.2 开放寻址<a class="headerlink" href="#622" title="Permanent link">¶</a></h2>
|
||||
<p>「开放寻址 Open Addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测、多次哈希等。</p>
|
||||
<p>「开放寻址 open addressing」不引入额外的数据结构,而是通过“多次探测”来处理哈希冲突,探测方式主要包括线性探测、平方探测、多次哈希等。</p>
|
||||
<h3 id="1">1. 线性探测<a class="headerlink" href="#1" title="Permanent link">¶</a></h3>
|
||||
<p>线性探测采用固定步长的线性查找来进行探测,对应的哈希表操作方法为:</p>
|
||||
<ul>
|
||||
|
|
|
@ -3426,7 +3426,7 @@
|
|||
|
||||
|
||||
<h1 id="61">6.1 哈希表<a class="headerlink" href="#61" title="Permanent link">¶</a></h1>
|
||||
<p>散列表,又称「哈希表 Hash Table」,其通过建立键 <code>key</code> 与值 <code>value</code> 之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个键 <code>key</code> ,则可以在 <span class="arithmatex">\(O(1)\)</span> 时间内获取对应的值 <code>value</code> 。</p>
|
||||
<p>「哈希表 hash table」,又称「散列表」,其通过建立键 <code>key</code> 与值 <code>value</code> 之间的映射,实现高效的元素查询。具体而言,我们向哈希表输入一个键 <code>key</code> ,则可以在 <span class="arithmatex">\(O(1)\)</span> 时间内获取对应的值 <code>value</code> 。</p>
|
||||
<p>以一个包含 <span class="arithmatex">\(n\)</span> 个学生的数据库为例,每个学生都有“姓名”和“学号”两项数据。假如我们希望实现“输入一个学号,返回对应的姓名”的查询功能,则可以采用哈希表来实现。</p>
|
||||
<p><img alt="哈希表的抽象表示" src="../hash_map.assets/hash_table_lookup.png" /></p>
|
||||
<p align="center"> 图:哈希表的抽象表示 </p>
|
||||
|
@ -3843,8 +3843,8 @@
|
|||
</div>
|
||||
</div>
|
||||
<h2 id="612">6.1.2 哈希表简单实现<a class="headerlink" href="#612" title="Permanent link">¶</a></h2>
|
||||
<p>我们先考虑最简单的情况,<strong>仅用一个数组来实现哈希表</strong>。在哈希表中,我们将数组中的每个空位称为「桶 Bucket」,每个桶可存储一个键值对。因此,查询操作就是找到 <code>key</code> 对应的桶,并在桶中获取 <code>value</code> 。</p>
|
||||
<p>那么,如何基于 <code>key</code> 来定位对应的桶呢?这是通过「哈希函数 Hash Function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 <code>key</code> ,输出空间是所有桶(数组索引)。换句话说,输入一个 <code>key</code> ,<strong>我们可以通过哈希函数得到该 <code>key</code> 对应的键值对在数组中的存储位置</strong>。</p>
|
||||
<p>我们先考虑最简单的情况,<strong>仅用一个数组来实现哈希表</strong>。在哈希表中,我们将数组中的每个空位称为「桶 bucket」,每个桶可存储一个键值对。因此,查询操作就是找到 <code>key</code> 对应的桶,并在桶中获取 <code>value</code> 。</p>
|
||||
<p>那么,如何基于 <code>key</code> 来定位对应的桶呢?这是通过「哈希函数 hash function」实现的。哈希函数的作用是将一个较大的输入空间映射到一个较小的输出空间。在哈希表中,输入空间是所有 <code>key</code> ,输出空间是所有桶(数组索引)。换句话说,输入一个 <code>key</code> ,<strong>我们可以通过哈希函数得到该 <code>key</code> 对应的键值对在数组中的存储位置</strong>。</p>
|
||||
<p>输入一个 <code>key</code> ,哈希函数的计算过程分为两步:</p>
|
||||
<ol>
|
||||
<li>通过某种哈希算法 <code>hash()</code> 计算得到哈希值。</li>
|
||||
|
@ -4862,7 +4862,7 @@
|
|||
<div class="highlight"><pre><span></span><code><a id="__codelineno-37-1" name="__codelineno-37-1" href="#__codelineno-37-1"></a><span class="m">12836</span><span class="w"> </span>%<span class="w"> </span><span class="nv">100</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="m">36</span>
|
||||
<a id="__codelineno-37-2" name="__codelineno-37-2" href="#__codelineno-37-2"></a><span class="m">20336</span><span class="w"> </span>%<span class="w"> </span><span class="nv">100</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="m">36</span>
|
||||
</code></pre></div>
|
||||
<p>如下图所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为「哈希冲突 Hash Collision」。</p>
|
||||
<p>如下图所示,两个学号指向了同一个姓名,这显然是不对的。我们将这种多个输入对应同一输出的情况称为「哈希冲突 hash collision」。</p>
|
||||
<p><img alt="哈希冲突示例" src="../hash_map.assets/hash_collision.png" /></p>
|
||||
<p align="center"> 图:哈希冲突示例 </p>
|
||||
|
||||
|
@ -4871,7 +4871,7 @@
|
|||
<p align="center"> 图:哈希表扩容 </p>
|
||||
|
||||
<p>类似于数组扩容,哈希表扩容需将所有键值对从原哈希表迁移至新哈希表,非常耗时。并且由于哈希表容量 <code>capacity</code> 改变,我们需要通过哈希函数来重新计算所有键值对的存储位置,这进一步提高了扩容过程的计算开销。为此,编程语言通常会预留足够大的哈希表容量,防止频繁扩容。</p>
|
||||
<p>「负载因子 Load Factor」是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,<strong>也常被作为哈希表扩容的触发条件</strong>。例如在 Java 中,当负载因子超过 <span class="arithmatex">\(0.75\)</span> 时,系统会将哈希表容量扩展为原先的 <span class="arithmatex">\(2\)</span> 倍。</p>
|
||||
<p>「负载因子 load factor」是哈希表的一个重要概念,其定义为哈希表的元素数量除以桶数量,用于衡量哈希冲突的严重程度,<strong>也常被作为哈希表扩容的触发条件</strong>。例如在 Java 中,当负载因子超过 <span class="arithmatex">\(0.75\)</span> 时,系统会将哈希表容量扩展为原先的 <span class="arithmatex">\(2\)</span> 倍。</p>
|
||||
|
||||
|
||||
|
||||
|
|
|
@ -3426,7 +3426,7 @@
|
|||
|
||||
|
||||
<h1 id="82">8.2 建堆操作<a class="headerlink" href="#82" title="Permanent link">¶</a></h1>
|
||||
<p>在某些情况下,我们希望使用一个列表的所有元素来构建一个堆,这个过程被称为「建堆」。</p>
|
||||
<p>在某些情况下,我们希望使用一个列表的所有元素来构建一个堆,这个过程被称为“建堆操作”。</p>
|
||||
<h2 id="821">8.2.1 借助入堆方法实现<a class="headerlink" href="#821" title="Permanent link">¶</a></h2>
|
||||
<p>最直接的方法是借助“元素入堆操作”实现。我们首先创建一个空堆,然后将列表元素依次执行“入堆”。</p>
|
||||
<p>设元素数量为 <span class="arithmatex">\(n\)</span> ,入堆操作使用 <span class="arithmatex">\(O(\log{n})\)</span> 时间,因此将所有元素入堆的时间复杂度为 <span class="arithmatex">\(O(n \log n)\)</span> 。</p>
|
||||
|
|
|
@ -3494,10 +3494,10 @@
|
|||
|
||||
|
||||
<h1 id="81">8.1 堆<a class="headerlink" href="#81" title="Permanent link">¶</a></h1>
|
||||
<p>「堆 Heap」是一种满足特定条件的完全二叉树,可分为两种类型:</p>
|
||||
<p>「堆 heap」是一种满足特定条件的完全二叉树,可分为两种类型:</p>
|
||||
<ul>
|
||||
<li>「大顶堆 Max Heap」,任意节点的值 <span class="arithmatex">\(\geq\)</span> 其子节点的值。</li>
|
||||
<li>「小顶堆 Min Heap」,任意节点的值 <span class="arithmatex">\(\leq\)</span> 其子节点的值。</li>
|
||||
<li>「大顶堆 max heap」:任意节点的值 <span class="arithmatex">\(\geq\)</span> 其子节点的值。</li>
|
||||
<li>「小顶堆 min heap」:任意节点的值 <span class="arithmatex">\(\leq\)</span> 其子节点的值。</li>
|
||||
</ul>
|
||||
<p><img alt="小顶堆与大顶堆" src="../heap.assets/min_heap_and_max_heap.png" /></p>
|
||||
<p align="center"> 图:小顶堆与大顶堆 </p>
|
||||
|
@ -3505,12 +3505,12 @@
|
|||
<p>堆作为完全二叉树的一个特例,具有以下特性:</p>
|
||||
<ul>
|
||||
<li>最底层节点靠左填充,其他层的节点都被填满。</li>
|
||||
<li>我们将二叉树的根节点称为「堆顶」,将底层最靠右的节点称为「堆底」。</li>
|
||||
<li>我们将二叉树的根节点称为“堆顶”,将底层最靠右的节点称为“堆底”。</li>
|
||||
<li>对于大顶堆(小顶堆),堆顶元素(即根节点)的值分别是最大(最小)的。</li>
|
||||
</ul>
|
||||
<h2 id="811">8.1.1 堆常用操作<a class="headerlink" href="#811" title="Permanent link">¶</a></h2>
|
||||
<p>需要指出的是,许多编程语言提供的是「优先队列 Priority Queue」,这是一种抽象数据结构,定义为具有优先级排序的队列。</p>
|
||||
<p>实际上,<strong>堆通常用作实现优先队列,大顶堆相当于元素按从大到小顺序出队的优先队列</strong>。从使用角度来看,我们可以将「优先队列」和「堆」看作等价的数据结构。因此,本书对两者不做特别区分,统一使用「堆」来命名。</p>
|
||||
<p>需要指出的是,许多编程语言提供的是「优先队列 priority queue」,这是一种抽象数据结构,定义为具有优先级排序的队列。</p>
|
||||
<p>实际上,<strong>堆通常用作实现优先队列,大顶堆相当于元素按从大到小顺序出队的优先队列</strong>。从使用角度来看,我们可以将“优先队列”和“堆”看作等价的数据结构。因此,本书对两者不做特别区分,统一使用“堆“来命名。</p>
|
||||
<p>堆的常用操作见下表,方法名需要根据编程语言来确定。</p>
|
||||
<p align="center"> 表:堆的操作效率 </p>
|
||||
|
||||
|
@ -4118,7 +4118,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<h3 id="3">3. 元素入堆<a class="headerlink" href="#3" title="Permanent link">¶</a></h3>
|
||||
<p>给定元素 <code>val</code> ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,<strong>需要修复从插入节点到根节点的路径上的各个节点</strong>,这个操作被称为「堆化 Heapify」。</p>
|
||||
<p>给定元素 <code>val</code> ,我们首先将其添加到堆底。添加之后,由于 val 可能大于堆中其他元素,堆的成立条件可能已被破坏。因此,<strong>需要修复从插入节点到根节点的路径上的各个节点</strong>,这个操作被称为「堆化 heapify」。</p>
|
||||
<p>考虑从入堆节点开始,<strong>从底至顶执行堆化</strong>。具体来说,我们比较插入节点与其父节点的值,如果插入节点更大,则将它们交换。然后继续执行此操作,从底至顶修复堆中的各个节点,直至越过根节点或遇到无须交换的节点时结束。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="4:9"><input checked="checked" id="__tabbed_4_1" name="__tabbed_4" type="radio" /><input id="__tabbed_4_2" name="__tabbed_4" type="radio" /><input id="__tabbed_4_3" name="__tabbed_4" type="radio" /><input id="__tabbed_4_4" name="__tabbed_4" type="radio" /><input id="__tabbed_4_5" name="__tabbed_4" type="radio" /><input id="__tabbed_4_6" name="__tabbed_4" type="radio" /><input id="__tabbed_4_7" name="__tabbed_4" type="radio" /><input id="__tabbed_4_8" name="__tabbed_4" type="radio" /><input id="__tabbed_4_9" name="__tabbed_4" type="radio" /><div class="tabbed-labels"><label for="__tabbed_4_1"><1></label><label for="__tabbed_4_2"><2></label><label for="__tabbed_4_3"><3></label><label for="__tabbed_4_4"><4></label><label for="__tabbed_4_5"><5></label><label for="__tabbed_4_6"><6></label><label for="__tabbed_4_7"><7></label><label for="__tabbed_4_8"><8></label><label for="__tabbed_4_9"><9></label></div>
|
||||
<div class="tabbed-content">
|
||||
|
|
|
@ -3439,7 +3439,7 @@
|
|||
|
||||
<div class="admonition tip">
|
||||
<p class="admonition-title">Tip</p>
|
||||
<p>当 <span class="arithmatex">\(k = n\)</span> 时,我们可以得到从大到小的序列,等价于「选择排序」算法。</p>
|
||||
<p>当 <span class="arithmatex">\(k = n\)</span> 时,我们可以得到从大到小的序列,等价于“选择排序”算法。</p>
|
||||
</div>
|
||||
<h2 id="832">8.3.2 方法二:排序<a class="headerlink" href="#832" title="Permanent link">¶</a></h2>
|
||||
<p>我们可以对数组 <code>nums</code> 进行排序,并返回最右边的 <span class="arithmatex">\(k\)</span> 个元素,时间复杂度为 <span class="arithmatex">\(O(n \log n)\)</span> 。</p>
|
||||
|
|
Binary file not shown.
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 48 KiB |
|
@ -3375,7 +3375,7 @@
|
|||
</div>
|
||||
<p align="center"> 图:查字典步骤 </p>
|
||||
|
||||
<p>查阅字典这个小学生必备技能,实际上就是著名的二分查找算法。从数据结构的角度,我们可以把字典视为一个已排序的「数组」;从算法的角度,我们可以将上述查字典的一系列操作看作是「二分查找」。</p>
|
||||
<p>查阅字典这个小学生必备技能,实际上就是著名的二分查找算法。从数据结构的角度,我们可以把字典视为一个已排序的“数组”;从算法的角度,我们可以将上述查字典的一系列操作看作是“二分查找”。</p>
|
||||
<p><strong>例二:整理扑克</strong>。我们在打牌时,每局都需要整理扑克牌,使其从小到大排列,实现流程如下图所示。</p>
|
||||
<ol>
|
||||
<li>将扑克牌划分为“有序”和“无序”两部分,并假设初始状态下最左 1 张扑克牌已经有序。</li>
|
||||
|
@ -3385,7 +3385,7 @@
|
|||
<p><img alt="扑克排序步骤" src="../algorithms_are_everywhere.assets/playing_cards_sorting.png" /></p>
|
||||
<p align="center"> 图:扑克排序步骤 </p>
|
||||
|
||||
<p>上述整理扑克牌的方法本质上是「插入排序」算法,它在处理小型数据集时非常高效。许多编程语言的排序库函数中都存在插入排序的身影。</p>
|
||||
<p>上述整理扑克牌的方法本质上是“插入排序”算法,它在处理小型数据集时非常高效。许多编程语言的排序库函数中都存在插入排序的身影。</p>
|
||||
<p><strong>例三:货币找零</strong>。假设我们在超市购买了 <span class="arithmatex">\(69\)</span> 元的商品,给收银员付了 <span class="arithmatex">\(100\)</span> 元,则收银员需要找我们 <span class="arithmatex">\(31\)</span> 元。他会很自然地完成如下图所示的思考。</p>
|
||||
<ol>
|
||||
<li>可选项是比 <span class="arithmatex">\(31\)</span> 元面值更小的货币,包括 <span class="arithmatex">\(1\)</span> 元、<span class="arithmatex">\(5\)</span> 元、<span class="arithmatex">\(10\)</span> 元、<span class="arithmatex">\(20\)</span> 元。</li>
|
||||
|
@ -3397,7 +3397,7 @@
|
|||
<p><img alt="货币找零过程" src="../algorithms_are_everywhere.assets/greedy_change.png" /></p>
|
||||
<p align="center"> 图:货币找零过程 </p>
|
||||
|
||||
<p>在以上步骤中,我们每一步都采取当前看来最好的选择(尽可能用大面额的货币),最终得到了可行的找零方案。从数据结构与算法的角度看,这种方法本质上是「贪心算法」。</p>
|
||||
<p>在以上步骤中,我们每一步都采取当前看来最好的选择(尽可能用大面额的货币),最终得到了可行的找零方案。从数据结构与算法的角度看,这种方法本质上是“贪心”算法。</p>
|
||||
<p>小到烹饪一道菜,大到星际航行,几乎所有问题的解决都离不开算法。计算机的出现使我们能够通过编程将数据结构存储在内存中,同时编写代码调用 CPU 和 GPU 执行算法。这样一来,我们就能把生活中的问题转移到计算机上,以更高效的方式解决各种复杂问题。</p>
|
||||
<div class="admonition tip">
|
||||
<p class="admonition-title">Tip</p>
|
||||
|
|
|
@ -3493,7 +3493,7 @@
|
|||
<p>值得说明的是,数据结构与算法是独立于编程语言的。正因如此,本书得以提供多种编程语言的实现。</p>
|
||||
<div class="admonition tip">
|
||||
<p class="admonition-title">约定俗成的简称</p>
|
||||
<p>在实际讨论时,我们通常会将「数据结构与算法」简称为「算法」。比如众所周知的 LeetCode 算法题目,实际上同时考察了数据结构和算法两方面的知识。</p>
|
||||
<p>在实际讨论时,我们通常会将“数据结构与算法”简称为“算法”。比如众所周知的 LeetCode 算法题目,实际上同时考察了数据结构和算法两方面的知识。</p>
|
||||
</div>
|
||||
|
||||
|
||||
|
|
|
@ -3461,10 +3461,10 @@
|
|||
<h2 id="021">0.2.1 行文风格约定<a class="headerlink" href="#021" title="Permanent link">¶</a></h2>
|
||||
<ul>
|
||||
<li>标题后标注 <code>*</code> 的是选读章节,内容相对困难。如果你的时间有限,建议可以先跳过。</li>
|
||||
<li>文章中的重要名词会用 <code>「 」</code> 括号标注,例如 <code>「数组 Array」</code> 。请务必记住这些名词,包括英文翻译,以便后续阅读文献时使用。</li>
|
||||
<li><strong>加粗的文字</strong> 表示重点内容或总结性语句,这类文字值得特别关注。</li>
|
||||
<li>专有名词和有特指含义的词句会使用 <code>“双引号”</code> 标注,以避免歧义。</li>
|
||||
<li>涉及到编程语言之间不一致的名词,本书均以 Python 为准,例如使用 <span class="arithmatex">\(\text{None}\)</span> 来表示“空”。</li>
|
||||
<li>重要专有名词及其英文翻译会用 <code>「 」</code> 括号标注,例如 <code>「数组 array」</code> 。建议记住它们,以便阅读文献。</li>
|
||||
<li><strong>加粗的文字</strong> 表示重点内容或总结性语句,这类文字值得特别关注。</li>
|
||||
<li>当涉及到编程语言之间不一致的名词时,本书均以 Python 为准,例如使用 <span class="arithmatex">\(\text{None}\)</span> 来表示“空”。</li>
|
||||
<li>本书部分放弃了编程语言的注释规范,以换取更加紧凑的内容排版。注释主要分为三种类型:标题注释、内容注释、多行注释。</li>
|
||||
</ul>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="1:12"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><input id="__tabbed_1_4" name="__tabbed_1" type="radio" /><input id="__tabbed_1_5" name="__tabbed_1" type="radio" /><input id="__tabbed_1_6" name="__tabbed_1" type="radio" /><input id="__tabbed_1_7" name="__tabbed_1" type="radio" /><input id="__tabbed_1_8" name="__tabbed_1" type="radio" /><input id="__tabbed_1_9" name="__tabbed_1" type="radio" /><input id="__tabbed_1_10" name="__tabbed_1" type="radio" /><input id="__tabbed_1_11" name="__tabbed_1" type="radio" /><input id="__tabbed_1_12" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">Java</label><label for="__tabbed_1_2">C++</label><label for="__tabbed_1_3">Python</label><label for="__tabbed_1_4">Go</label><label for="__tabbed_1_5">JS</label><label for="__tabbed_1_6">TS</label><label for="__tabbed_1_7">C</label><label for="__tabbed_1_8">C#</label><label for="__tabbed_1_9">Swift</label><label for="__tabbed_1_10">Zig</label><label for="__tabbed_1_11">Dart</label><label for="__tabbed_1_12">Rust</label></div>
|
||||
|
|
|
@ -3412,7 +3412,7 @@
|
|||
|
||||
|
||||
<h1 id="101">10.1 二分查找<a class="headerlink" href="#101" title="Permanent link">¶</a></h1>
|
||||
<p>「二分查找 Binary Search」是一种基于分治思想的高效搜索算法。它利用数据的有序性,每轮减少一半搜索范围,直至找到目标元素或搜索区间为空为止。</p>
|
||||
<p>「二分查找 binary search」是一种基于分治思想的高效搜索算法。它利用数据的有序性,每轮减少一半搜索范围,直至找到目标元素或搜索区间为空为止。</p>
|
||||
<div class="admonition question">
|
||||
<p class="admonition-title">Question</p>
|
||||
<p>给定一个长度为 <span class="arithmatex">\(n\)</span> 的数组 <code>nums</code> ,元素按从小到大的顺序排列,数组不包含重复元素。请查找并返回元素 <code>target</code> 在该数组中的索引。若数组不包含该元素,则返回 <span class="arithmatex">\(-1\)</span> 。</p>
|
||||
|
|
|
@ -3426,7 +3426,7 @@
|
|||
|
||||
|
||||
<h1 id="105">10.5 重识搜索算法<a class="headerlink" href="#105" title="Permanent link">¶</a></h1>
|
||||
<p>「搜索算法 Searching Algorithm」用于在数据结构(例如数组、链表、树或图)中搜索一个或一组满足特定条件的元素。</p>
|
||||
<p>「搜索算法 searching algorithm」用于在数据结构(例如数组、链表、树或图)中搜索一个或一组满足特定条件的元素。</p>
|
||||
<p>根据实现思路,搜索算法总体可分为两种:</p>
|
||||
<ul>
|
||||
<li><strong>通过遍历数据结构来定位目标元素</strong>,例如数组、链表、树和图的遍历等。</li>
|
||||
|
@ -3436,17 +3436,17 @@
|
|||
<h2 id="1051">10.5.1 暴力搜索<a class="headerlink" href="#1051" title="Permanent link">¶</a></h2>
|
||||
<p>暴力搜索通过遍历数据结构的每个元素来定位目标元素。</p>
|
||||
<ul>
|
||||
<li>「线性搜索」适用于数组和链表等线性数据结构。它从数据结构的一端开始,逐个访问元素,直到找到目标元素或到达另一端仍没有找到目标元素为止。</li>
|
||||
<li>「广度优先搜索」和「深度优先搜索」是图和树的两种遍历策略。广度优先搜索从初始节点开始逐层搜索,由近及远地访问各个节点。深度优先搜索是从初始节点开始,沿着一条路径走到头为止,再回溯并尝试其他路径,直到遍历完整个数据结构。</li>
|
||||
<li>“线性搜索”适用于数组和链表等线性数据结构。它从数据结构的一端开始,逐个访问元素,直到找到目标元素或到达另一端仍没有找到目标元素为止。</li>
|
||||
<li>“广度优先搜索”和“深度优先搜索”是图和树的两种遍历策略。广度优先搜索从初始节点开始逐层搜索,由近及远地访问各个节点。深度优先搜索是从初始节点开始,沿着一条路径走到头为止,再回溯并尝试其他路径,直到遍历完整个数据结构。</li>
|
||||
</ul>
|
||||
<p>暴力搜索的优点是简单且通用性好,<strong>无须对数据做预处理和借助额外的数据结构</strong>。</p>
|
||||
<p>然而,<strong>此类算法的时间复杂度为 <span class="arithmatex">\(O(n)\)</span></strong> ,其中 <span class="arithmatex">\(n\)</span> 为元素数量,因此在数据量较大的情况下性能较差。</p>
|
||||
<h2 id="1052">10.5.2 自适应搜索<a class="headerlink" href="#1052" title="Permanent link">¶</a></h2>
|
||||
<p>自适应搜索利用数据的特有属性(例如有序性)来优化搜索过程,从而更高效地定位目标元素。</p>
|
||||
<ul>
|
||||
<li>「二分查找」利用数据的有序性实现高效查找,仅适用于数组。</li>
|
||||
<li>「哈希查找」利用哈希表将搜索数据和目标数据建立为键值对映射,从而实现查询操作。</li>
|
||||
<li>「树查找」在特定的树结构(例如二叉搜索树)中,基于比较节点值来快速排除节点,从而定位目标元素。</li>
|
||||
<li>“二分查找”利用数据的有序性实现高效查找,仅适用于数组。</li>
|
||||
<li>“哈希查找”利用哈希表将搜索数据和目标数据建立为键值对映射,从而实现查询操作。</li>
|
||||
<li>“树查找”在特定的树结构(例如二叉搜索树)中,基于比较节点值来快速排除节点,从而定位目标元素。</li>
|
||||
</ul>
|
||||
<p>此类算法的优点是效率高,<strong>时间复杂度可达到 <span class="arithmatex">\(O(\log n)\)</span> 甚至 <span class="arithmatex">\(O(1)\)</span></strong> 。</p>
|
||||
<p>然而,<strong>使用这些算法往往需要对数据进行预处理</strong>。例如,二分查找需要预先对数组进行排序,哈希查找和树查找都需要借助额外的数据结构,维护这些数据结构也需要额外的时间和空间开支。</p>
|
||||
|
|
|
@ -3426,7 +3426,7 @@
|
|||
|
||||
|
||||
<h1 id="113">11.3 冒泡排序<a class="headerlink" href="#113" title="Permanent link">¶</a></h1>
|
||||
<p>「冒泡排序 Bubble Sort」通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样,因此得名冒泡排序。</p>
|
||||
<p>「冒泡排序 bubble sort」通过连续地比较与交换相邻元素实现排序。这个过程就像气泡从底部升到顶部一样,因此得名冒泡排序。</p>
|
||||
<p>我们可以利用元素交换操作模拟上述过程:从数组最左端开始向右遍历,依次比较相邻元素大小,如果“左元素 > 右元素”就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="1:7"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><input id="__tabbed_1_4" name="__tabbed_1" type="radio" /><input id="__tabbed_1_5" name="__tabbed_1" type="radio" /><input id="__tabbed_1_6" name="__tabbed_1" type="radio" /><input id="__tabbed_1_7" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1"><1></label><label for="__tabbed_1_2"><2></label><label for="__tabbed_1_3"><3></label><label for="__tabbed_1_4"><4></label><label for="__tabbed_1_5"><5></label><label for="__tabbed_1_6"><6></label><label for="__tabbed_1_7"><7></label></div>
|
||||
<div class="tabbed-content">
|
||||
|
|
|
@ -3427,7 +3427,7 @@
|
|||
|
||||
<h1 id="118">11.8 桶排序<a class="headerlink" href="#118" title="Permanent link">¶</a></h1>
|
||||
<p>前述的几种排序算法都属于“基于比较的排序算法”,它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 <span class="arithmatex">\(O(n \log n)\)</span> 。接下来,我们将探讨几种“非比较排序算法”,它们的时间复杂度可以达到线性阶。</p>
|
||||
<p>「桶排序 Bucket Sort」是分治思想的一个典型应用。它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。</p>
|
||||
<p>「桶排序 bucket sort」是分治思想的一个典型应用。它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并。</p>
|
||||
<h2 id="1181">11.8.1 算法流程<a class="headerlink" href="#1181" title="Permanent link">¶</a></h2>
|
||||
<p>考虑一个长度为 <span class="arithmatex">\(n\)</span> 的数组,元素是范围 <span class="arithmatex">\([0, 1)\)</span> 的浮点数。桶排序的流程如下:</p>
|
||||
<ol>
|
||||
|
|
|
@ -3440,7 +3440,7 @@
|
|||
|
||||
|
||||
<h1 id="119">11.9 计数排序<a class="headerlink" href="#119" title="Permanent link">¶</a></h1>
|
||||
<p>「计数排序 Counting Sort」通过统计元素数量来实现排序,通常应用于整数数组。</p>
|
||||
<p>「计数排序 counting sort」通过统计元素数量来实现排序,通常应用于整数数组。</p>
|
||||
<h2 id="1191">11.9.1 简单实现<a class="headerlink" href="#1191" title="Permanent link">¶</a></h2>
|
||||
<p>先来看一个简单的例子。给定一个长度为 <span class="arithmatex">\(n\)</span> 的数组 <code>nums</code> ,其中的元素都是“非负整数”。计数排序的整体流程如下:</p>
|
||||
<ol>
|
||||
|
@ -3736,7 +3736,7 @@
|
|||
</div>
|
||||
<h2 id="1192">11.9.2 完整实现<a class="headerlink" href="#1192" title="Permanent link">¶</a></h2>
|
||||
<p>细心的同学可能发现,<strong>如果输入数据是对象,上述步骤 <code>3.</code> 就失效了</strong>。例如,输入数据是商品对象,我们想要按照商品价格(类的成员变量)对商品进行排序,而上述算法只能给出价格的排序结果。</p>
|
||||
<p>那么如何才能得到原数据的排序结果呢?我们首先计算 <code>counter</code> 的「前缀和」。顾名思义,索引 <code>i</code> 处的前缀和 <code>prefix[i]</code> 等于数组前 <code>i</code> 个元素之和,即</p>
|
||||
<p>那么如何才能得到原数据的排序结果呢?我们首先计算 <code>counter</code> 的“前缀和”。顾名思义,索引 <code>i</code> 处的前缀和 <code>prefix[i]</code> 等于数组前 <code>i</code> 个元素之和,即:</p>
|
||||
<div class="arithmatex">\[
|
||||
\text{prefix}[i] = \sum_{j=0}^i \text{counter[j]}
|
||||
\]</div>
|
||||
|
|
|
@ -3414,9 +3414,9 @@
|
|||
<h1 id="117">11.7 堆排序<a class="headerlink" href="#117" title="Permanent link">¶</a></h1>
|
||||
<div class="admonition tip">
|
||||
<p class="admonition-title">Tip</p>
|
||||
<p>阅读本节前,请确保已学完「堆」章节。</p>
|
||||
<p>阅读本节前,请确保已学完“堆“章节。</p>
|
||||
</div>
|
||||
<p>「堆排序 Heap Sort」是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和“元素出堆操作”实现堆排序:</p>
|
||||
<p>「堆排序 heap sort」是一种基于堆数据结构实现的高效排序算法。我们可以利用已经学过的“建堆操作”和“元素出堆操作”实现堆排序:</p>
|
||||
<ol>
|
||||
<li>输入数组并建立小顶堆,此时最小元素位于堆顶。</li>
|
||||
<li>不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列。</li>
|
||||
|
|
|
@ -3426,7 +3426,7 @@
|
|||
|
||||
|
||||
<h1 id="114">11.4 插入排序<a class="headerlink" href="#114" title="Permanent link">¶</a></h1>
|
||||
<p>「插入排序 Insertion Sort」是一种简单的排序算法,它的工作原理与手动整理一副牌的过程非常相似。</p>
|
||||
<p>「插入排序 insertion sort」是一种简单的排序算法,它的工作原理与手动整理一副牌的过程非常相似。</p>
|
||||
<p>具体来说,我们在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置。</p>
|
||||
<p>回忆数组的元素插入操作,设基准元素为 <code>base</code> ,我们需要将从目标索引到 <code>base</code> 之间的所有元素向右移动一位,然后再将 <code>base</code> 赋值给目标索引。</p>
|
||||
<p><img alt="单次插入操作" src="../insertion_sort.assets/insertion_operation.png" /></p>
|
||||
|
|
|
@ -3426,7 +3426,7 @@
|
|||
|
||||
|
||||
<h1 id="116">11.6 归并排序<a class="headerlink" href="#116" title="Permanent link">¶</a></h1>
|
||||
<p>「归并排序 Merge Sort」基于分治思想实现排序,包含“划分”和“合并”两个阶段:</p>
|
||||
<p>「归并排序 merge sort」基于分治思想实现排序,包含“划分”和“合并”两个阶段:</p>
|
||||
<ol>
|
||||
<li><strong>划分阶段</strong>:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题。</li>
|
||||
<li><strong>合并阶段</strong>:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。</li>
|
||||
|
|
|
@ -3454,8 +3454,8 @@
|
|||
|
||||
|
||||
<h1 id="115">11.5 快速排序<a class="headerlink" href="#115" title="Permanent link">¶</a></h1>
|
||||
<p>「快速排序 Quick Sort」是一种基于分治思想的排序算法,运行高效,应用广泛。</p>
|
||||
<p>快速排序的核心操作是「哨兵划分」,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程为:</p>
|
||||
<p>「快速排序 quick sort」是一种基于分治思想的排序算法,运行高效,应用广泛。</p>
|
||||
<p>快速排序的核心操作是“哨兵划分”,其目标是:选择数组中的某个元素作为“基准数”,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧。具体来说,哨兵划分的流程为:</p>
|
||||
<ol>
|
||||
<li>选取数组最左端元素作为基准数,初始化两个指针 <code>i</code> 和 <code>j</code> 分别指向数组的两端。</li>
|
||||
<li>设置一个循环,在每轮中使用 <code>i</code>(<code>j</code>)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素。</li>
|
||||
|
@ -3794,8 +3794,8 @@
|
|||
</div>
|
||||
<h2 id="1151">11.5.1 算法流程<a class="headerlink" href="#1151" title="Permanent link">¶</a></h2>
|
||||
<ol>
|
||||
<li>首先,对原数组执行一次「哨兵划分」,得到未排序的左子数组和右子数组。</li>
|
||||
<li>然后,对左子数组和右子数组分别递归执行「哨兵划分」。</li>
|
||||
<li>首先,对原数组执行一次“哨兵划分”,得到未排序的左子数组和右子数组。</li>
|
||||
<li>然后,对左子数组和右子数组分别递归执行“哨兵划分”。</li>
|
||||
<li>持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序。</li>
|
||||
</ol>
|
||||
<p><img alt="快速排序流程" src="../quick_sort.assets/quick_sort_overview.png" /></p>
|
||||
|
@ -4004,14 +4004,14 @@
|
|||
<li><strong>非稳定排序</strong>:在哨兵划分的最后一步,基准数可能会被交换至相等元素的右侧。</li>
|
||||
</ul>
|
||||
<h2 id="1153">11.5.3 快排为什么快?<a class="headerlink" href="#1153" title="Permanent link">¶</a></h2>
|
||||
<p>从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与「归并排序」和「堆排序」相同,但通常快速排序的效率更高,原因如下:</p>
|
||||
<p>从名称上就能看出,快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与“归并排序”和“堆排序”相同,但通常快速排序的效率更高,原因如下:</p>
|
||||
<ul>
|
||||
<li><strong>出现最差情况的概率很低</strong>:虽然快速排序的最差时间复杂度为 <span class="arithmatex">\(O(n^2)\)</span> ,没有归并排序稳定,但在绝大多数情况下,快速排序能在 <span class="arithmatex">\(O(n \log n)\)</span> 的时间复杂度下运行。</li>
|
||||
<li><strong>缓存使用效率高</strong>:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像「堆排序」这类算法需要跳跃式访问元素,从而缺乏这一特性。</li>
|
||||
<li><strong>复杂度的常数系数低</strong>:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与「插入排序」比「冒泡排序」更快的原因类似。</li>
|
||||
<li><strong>缓存使用效率高</strong>:在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像“堆排序”这类算法需要跳跃式访问元素,从而缺乏这一特性。</li>
|
||||
<li><strong>复杂度的常数系数低</strong>:在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与“插入排序”比“冒泡排序”更快的原因类似。</li>
|
||||
</ul>
|
||||
<h2 id="1154">11.5.4 基准数优化<a class="headerlink" href="#1154" title="Permanent link">¶</a></h2>
|
||||
<p><strong>快速排序在某些输入下的时间效率可能降低</strong>。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 <span class="arithmatex">\(n - 1\)</span> 、右子数组长度为 <span class="arithmatex">\(0\)</span> 。如此递归下去,每轮哨兵划分后的右子数组长度都为 <span class="arithmatex">\(0\)</span> ,分治策略失效,快速排序退化为「冒泡排序」。</p>
|
||||
<p><strong>快速排序在某些输入下的时间效率可能降低</strong>。举一个极端例子,假设输入数组是完全倒序的,由于我们选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 <span class="arithmatex">\(n - 1\)</span> 、右子数组长度为 <span class="arithmatex">\(0\)</span> 。如此递归下去,每轮哨兵划分后的右子数组长度都为 <span class="arithmatex">\(0\)</span> ,分治策略失效,快速排序退化为“冒泡排序”。</p>
|
||||
<p>为了尽量避免这种情况发生,<strong>我们可以优化哨兵划分中的基准数的选取策略</strong>。例如,我们可以随机选取一个元素作为基准数。然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意。</p>
|
||||
<p>需要注意的是,编程语言通常生成的是“伪随机数”。如果我们针对伪随机数序列构建一个特定的测试样例,那么快速排序的效率仍然可能劣化。</p>
|
||||
<p>为了进一步改进,我们可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),<strong>并将这三个候选元素的中位数作为基准数</strong>。这样一来,基准数“既不太小也不太大”的概率将大幅提升。当然,我们还可以选取更多候选元素,以进一步提高算法的稳健性。采用这种方法后,时间复杂度劣化至 <span class="arithmatex">\(O(n^2)\)</span> 的概率大大降低。</p>
|
||||
|
|
|
@ -3413,12 +3413,12 @@
|
|||
|
||||
<h1 id="1110">11.10 基数排序<a class="headerlink" href="#1110" title="Permanent link">¶</a></h1>
|
||||
<p>上一节我们介绍了计数排序,它适用于数据量 <span class="arithmatex">\(n\)</span> 较大但数据范围 <span class="arithmatex">\(m\)</span> 较小的情况。假设我们需要对 <span class="arithmatex">\(n = 10^6\)</span> 个学号进行排序,而学号是一个 <span class="arithmatex">\(8\)</span> 位数字,这意味着数据范围 <span class="arithmatex">\(m = 10^8\)</span> 非常大,使用计数排序需要分配大量内存空间,而基数排序可以避免这种情况。</p>
|
||||
<p>「基数排序 Radix Sort」的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。</p>
|
||||
<p>「基数排序 radix sort」的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。</p>
|
||||
<h2 id="11101">11.10.1 算法流程<a class="headerlink" href="#11101" title="Permanent link">¶</a></h2>
|
||||
<p>以学号数据为例,假设数字的最低位是第 <span class="arithmatex">\(1\)</span> 位,最高位是第 <span class="arithmatex">\(8\)</span> 位,基数排序的步骤如下:</p>
|
||||
<ol>
|
||||
<li>初始化位数 <span class="arithmatex">\(k = 1\)</span> 。</li>
|
||||
<li>对学号的第 <span class="arithmatex">\(k\)</span> 位执行「计数排序」。完成后,数据会根据第 <span class="arithmatex">\(k\)</span> 位从小到大排序。</li>
|
||||
<li>对学号的第 <span class="arithmatex">\(k\)</span> 位执行“计数排序”。完成后,数据会根据第 <span class="arithmatex">\(k\)</span> 位从小到大排序。</li>
|
||||
<li>将 <span class="arithmatex">\(k\)</span> 增加 <span class="arithmatex">\(1\)</span> ,然后返回步骤 <code>2.</code> 继续迭代,直到所有位都排序完成后结束。</li>
|
||||
</ol>
|
||||
<p><img alt="基数排序算法流程" src="../radix_sort.assets/radix_sort_overview.png" /></p>
|
||||
|
|
|
@ -3398,7 +3398,7 @@
|
|||
|
||||
|
||||
<h1 id="112">11.2 选择排序<a class="headerlink" href="#112" title="Permanent link">¶</a></h1>
|
||||
<p>「选择排序 Selection Sort」的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。</p>
|
||||
<p>「选择排序 selection sort」的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。</p>
|
||||
<p>设数组的长度为 <span class="arithmatex">\(n\)</span> ,选择排序的算法流程如下:</p>
|
||||
<ol>
|
||||
<li>初始状态下,所有元素未排序,即未排序(索引)区间为 <span class="arithmatex">\([0, n-1]\)</span> 。</li>
|
||||
|
|
|
@ -3412,7 +3412,7 @@
|
|||
|
||||
|
||||
<h1 id="111">11.1 排序算法<a class="headerlink" href="#111" title="Permanent link">¶</a></h1>
|
||||
<p>「排序算法 Sorting Algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更有效地查找、分析和处理。</p>
|
||||
<p>「排序算法 sorting algorithm」用于对一组数据按照特定顺序进行排列。排序算法有着广泛的应用,因为有序数据通常能够被更有效地查找、分析和处理。</p>
|
||||
<p>在排序算法中,数据类型可以是整数、浮点数、字符或字符串等;顺序的判断规则可根据需求设定,如数字大小、字符 ASCII 码顺序或自定义规则。</p>
|
||||
<p><img alt="数据类型和判断规则示例" src="../sorting_algorithm.assets/sorting_examples.png" /></p>
|
||||
<p align="center"> 图:数据类型和判断规则示例 </p>
|
||||
|
@ -3420,8 +3420,8 @@
|
|||
<h2 id="1111">11.1.1 评价维度<a class="headerlink" href="#1111" title="Permanent link">¶</a></h2>
|
||||
<p><strong>运行效率</strong>:我们期望排序算法的时间复杂度尽量低,且总体操作数量较少(即时间复杂度中的常数项降低)。对于大数据量情况,运行效率显得尤为重要。</p>
|
||||
<p><strong>就地性</strong>:顾名思义,「原地排序」通过在原数组上直接操作实现排序,无须借助额外的辅助数组,从而节省内存。通常情况下,原地排序的数据搬运操作较少,运行速度也更快。</p>
|
||||
<p><strong>稳定性</strong>:「稳定排序」在完成排序后,相等元素在数组中的相对顺序不发生改变。稳定排序是优良特性,也是多级排序场景的必要条件。</p>
|
||||
<p>假设我们有一个存储学生信息的表格,第 1, 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失。</p>
|
||||
<p><strong>稳定性</strong>:「稳定排序」在完成排序后,相等元素在数组中的相对顺序不发生改变。</p>
|
||||
<p>稳定排序是多级排序场景的必要条件。假设我们有一个存储学生信息的表格,第 1 列和第 2 列分别是姓名和年龄。在这种情况下,「非稳定排序」可能导致输入数据的有序性丧失。</p>
|
||||
<div class="highlight"><pre><span></span><code><a id="__codelineno-0-1" name="__codelineno-0-1" href="#__codelineno-0-1"></a><span class="c1"># 输入数据是按照姓名排序好的</span>
|
||||
<a id="__codelineno-0-2" name="__codelineno-0-2" href="#__codelineno-0-2"></a><span class="c1"># (name, age)</span>
|
||||
<a id="__codelineno-0-3" name="__codelineno-0-3" href="#__codelineno-0-3"></a><span class="w"> </span><span class="o">(</span><span class="s1">'A'</span>,<span class="w"> </span><span class="m">19</span><span class="o">)</span>
|
||||
|
|
|
@ -3466,7 +3466,7 @@
|
|||
|
||||
|
||||
<h1 id="53">5.3 双向队列<a class="headerlink" href="#53" title="Permanent link">¶</a></h1>
|
||||
<p>对于队列,我们仅能在头部删除或在尾部添加元素。然而,「双向队列 Deque」提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。</p>
|
||||
<p>对于队列,我们仅能在头部删除或在尾部添加元素。然而,「双向队列 deque」提供了更高的灵活性,允许在头部和尾部执行元素的添加或删除操作。</p>
|
||||
<p><img alt="双向队列的操作" src="../deque.assets/deque_operations.png" /></p>
|
||||
<p align="center"> 图:双向队列的操作 </p>
|
||||
|
||||
|
@ -3797,7 +3797,7 @@
|
|||
<p>双向队列的实现与队列类似,可以选择链表或数组作为底层数据结构。</p>
|
||||
<h3 id="1">1. 基于双向链表的实现<a class="headerlink" href="#1" title="Permanent link">¶</a></h3>
|
||||
<p>回顾上一节内容,我们使用普通单向链表来实现队列,因为它可以方便地删除头节点(对应出队操作)和在尾节点后添加新节点(对应入队操作)。</p>
|
||||
<p>对于双向队列而言,头部和尾部都可以执行入队和出队操作。换句话说,双向队列需要实现另一个对称方向的操作。为此,我们采用「双向链表」作为双向队列的底层数据结构。</p>
|
||||
<p>对于双向队列而言,头部和尾部都可以执行入队和出队操作。换句话说,双向队列需要实现另一个对称方向的操作。为此,我们采用“双向链表”作为双向队列的底层数据结构。</p>
|
||||
<p>我们将双向链表的头节点和尾节点视为双向队列的队首和队尾,同时实现在两端添加和删除节点的功能。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="2:5"><input checked="checked" id="__tabbed_2_1" name="__tabbed_2" type="radio" /><input id="__tabbed_2_2" name="__tabbed_2" type="radio" /><input id="__tabbed_2_3" name="__tabbed_2" type="radio" /><input id="__tabbed_2_4" name="__tabbed_2" type="radio" /><input id="__tabbed_2_5" name="__tabbed_2" type="radio" /><div class="tabbed-labels"><label for="__tabbed_2_1">LinkedListDeque</label><label for="__tabbed_2_2">pushLast()</label><label for="__tabbed_2_3">pushFirst()</label><label for="__tabbed_2_4">popLast()</label><label for="__tabbed_2_5">popFirst()</label></div>
|
||||
<div class="tabbed-content">
|
||||
|
|
|
@ -3466,8 +3466,8 @@
|
|||
|
||||
|
||||
<h1 id="52">5.2 队列<a class="headerlink" href="#52" title="Permanent link">¶</a></h1>
|
||||
<p>「队列 Queue」是一种遵循先入先出(First In, First Out)规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列的尾部,而位于队列头部的人逐个离开。</p>
|
||||
<p>我们把队列的头部称为「队首」,尾部称为「队尾」,把将元素加入队尾的操作称为「入队」,删除队首元素的操作称为「出队」。</p>
|
||||
<p>「队列 queue」是一种遵循先入先出规则的线性数据结构。顾名思义,队列模拟了排队现象,即新来的人不断加入队列的尾部,而位于队列头部的人逐个离开。</p>
|
||||
<p>我们把队列的头部称为“队首”,尾部称为“队尾”,把将元素加入队尾的操作称为“入队”,删除队首元素的操作称为“出队”。</p>
|
||||
<p><img alt="队列的先入先出规则" src="../queue.assets/queue_operations.png" /></p>
|
||||
<p align="center"> 图:队列的先入先出规则 </p>
|
||||
|
||||
|
@ -3750,7 +3750,7 @@
|
|||
<h2 id="522">5.2.2 队列实现<a class="headerlink" href="#522" title="Permanent link">¶</a></h2>
|
||||
<p>为了实现队列,我们需要一种数据结构,可以在一端添加元素,并在另一端删除元素。因此,链表和数组都可以用来实现队列。</p>
|
||||
<h3 id="1">1. 基于链表的实现<a class="headerlink" href="#1" title="Permanent link">¶</a></h3>
|
||||
<p>对于链表实现,我们可以将链表的「头节点」和「尾节点」分别视为队首和队尾,规定队尾仅可添加节点,而队首仅可删除节点。</p>
|
||||
<p>对于链表实现,我们可以将链表的“头节点”和“尾节点”分别视为“队首”和“队尾”,规定队尾仅可添加节点,队首仅可删除节点。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="2:3"><input checked="checked" id="__tabbed_2_1" name="__tabbed_2" type="radio" /><input id="__tabbed_2_2" name="__tabbed_2" type="radio" /><input id="__tabbed_2_3" name="__tabbed_2" type="radio" /><div class="tabbed-labels"><label for="__tabbed_2_1">LinkedListQueue</label><label for="__tabbed_2_2">push()</label><label for="__tabbed_2_3">pop()</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -4639,7 +4639,7 @@
|
|||
</div>
|
||||
<p align="center"> 图:基于数组实现队列的入队出队操作 </p>
|
||||
|
||||
<p>你可能会发现一个问题:在不断进行入队和出队的过程中,<code>front</code> 和 <code>rear</code> 都在向右移动,<strong>当它们到达数组尾部时就无法继续移动了</strong>。为解决此问题,我们可以将数组视为首尾相接的「环形数组」。</p>
|
||||
<p>你可能会发现一个问题:在不断进行入队和出队的过程中,<code>front</code> 和 <code>rear</code> 都在向右移动,<strong>当它们到达数组尾部时就无法继续移动了</strong>。为解决此问题,我们可以将数组视为首尾相接的“环形数组”。</p>
|
||||
<p>对于环形数组,我们需要让 <code>front</code> 或 <code>rear</code> 在越过数组尾部时,直接回到数组头部继续遍历。这种周期性规律可以通过“取余操作”来实现,代码如下所示。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="5:12"><input checked="checked" id="__tabbed_5_1" name="__tabbed_5" type="radio" /><input id="__tabbed_5_2" name="__tabbed_5" type="radio" /><input id="__tabbed_5_3" name="__tabbed_5" type="radio" /><input id="__tabbed_5_4" name="__tabbed_5" type="radio" /><input id="__tabbed_5_5" name="__tabbed_5" type="radio" /><input id="__tabbed_5_6" name="__tabbed_5" type="radio" /><input id="__tabbed_5_7" name="__tabbed_5" type="radio" /><input id="__tabbed_5_8" name="__tabbed_5" type="radio" /><input id="__tabbed_5_9" name="__tabbed_5" type="radio" /><input id="__tabbed_5_10" name="__tabbed_5" type="radio" /><input id="__tabbed_5_11" name="__tabbed_5" type="radio" /><input id="__tabbed_5_12" name="__tabbed_5" type="radio" /><div class="tabbed-labels"><label for="__tabbed_5_1">Java</label><label for="__tabbed_5_2">C++</label><label for="__tabbed_5_3">Python</label><label for="__tabbed_5_4">Go</label><label for="__tabbed_5_5">JS</label><label for="__tabbed_5_6">TS</label><label for="__tabbed_5_7">C</label><label for="__tabbed_5_8">C#</label><label for="__tabbed_5_9">Swift</label><label for="__tabbed_5_10">Zig</label><label for="__tabbed_5_11">Dart</label><label for="__tabbed_5_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
|
|
|
@ -3534,9 +3534,9 @@
|
|||
|
||||
|
||||
<h1 id="51">5.1 栈<a class="headerlink" href="#51" title="Permanent link">¶</a></h1>
|
||||
<p>「栈 Stack」是一种遵循先入后出(First In, Last Out)原则的线性数据结构。</p>
|
||||
<p>「栈 stack」是一种遵循先入后出的逻辑的线性数据结构。</p>
|
||||
<p>我们可以将栈类比为桌面上的一摞盘子,如果需要拿出底部的盘子,则需要先将上面的盘子依次取出。我们将盘子替换为各种类型的元素(如整数、字符、对象等),就得到了栈数据结构。</p>
|
||||
<p>在栈中,我们把堆叠元素的顶部称为「栈顶」,底部称为「栈底」。将把元素添加到栈顶的操作叫做「入栈」,而删除栈顶元素的操作叫做「出栈」。</p>
|
||||
<p>在栈中,我们把堆叠元素的顶部称为“栈顶”,底部称为“栈底”。将把元素添加到栈顶的操作叫做“入栈”,而删除栈顶元素的操作叫做“出栈”。</p>
|
||||
<p><img alt="栈的先入后出规则" src="../stack.assets/stack_operations.png" /></p>
|
||||
<p align="center"> 图:栈的先入后出规则 </p>
|
||||
|
||||
|
@ -3572,7 +3572,7 @@
|
|||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p>通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的「数组」或「链表」视作栈来使用,并通过“脑补”来忽略与栈无关的操作。</p>
|
||||
<p>通常情况下,我们可以直接使用编程语言内置的栈类。然而,某些语言可能没有专门提供栈类,这时我们可以将该语言的“数组”或“链表”视作栈来使用,并在程序逻辑上忽略与栈无关的操作。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="1:12"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><input id="__tabbed_1_4" name="__tabbed_1" type="radio" /><input id="__tabbed_1_5" name="__tabbed_1" type="radio" /><input id="__tabbed_1_6" name="__tabbed_1" type="radio" /><input id="__tabbed_1_7" name="__tabbed_1" type="radio" /><input id="__tabbed_1_8" name="__tabbed_1" type="radio" /><input id="__tabbed_1_9" name="__tabbed_1" type="radio" /><input id="__tabbed_1_10" name="__tabbed_1" type="radio" /><input id="__tabbed_1_11" name="__tabbed_1" type="radio" /><input id="__tabbed_1_12" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">Java</label><label for="__tabbed_1_2">C++</label><label for="__tabbed_1_3">Python</label><label for="__tabbed_1_4">Go</label><label for="__tabbed_1_5">JS</label><label for="__tabbed_1_6">TS</label><label for="__tabbed_1_7">C</label><label for="__tabbed_1_8">C#</label><label for="__tabbed_1_9">Swift</label><label for="__tabbed_1_10">Zig</label><label for="__tabbed_1_11">Dart</label><label for="__tabbed_1_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -4567,7 +4567,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<h3 id="2">2. 基于数组的实现<a class="headerlink" href="#2" title="Permanent link">¶</a></h3>
|
||||
<p>在基于「数组」实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 <span class="arithmatex">\(O(1)\)</span> 。</p>
|
||||
<p>使用数组实现栈时,我们可以将数组的尾部作为栈顶。在这样的设计下,入栈与出栈操作就分别对应在数组尾部添加元素与删除元素,时间复杂度都为 <span class="arithmatex">\(O(1)\)</span> 。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="4:3"><input checked="checked" id="__tabbed_4_1" name="__tabbed_4" type="radio" /><input id="__tabbed_4_2" name="__tabbed_4" type="radio" /><input id="__tabbed_4_3" name="__tabbed_4" type="radio" /><div class="tabbed-labels"><label for="__tabbed_4_1">ArrayStack</label><label for="__tabbed_4_2">push()</label><label for="__tabbed_4_3">pop()</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -5169,7 +5169,7 @@
|
|||
<p>综上,我们不能简单地确定哪种实现更加节省内存,需要针对具体情况进行分析。</p>
|
||||
<h2 id="514">5.1.4 栈典型应用<a class="headerlink" href="#514" title="Permanent link">¶</a></h2>
|
||||
<ul>
|
||||
<li><strong>浏览器中的后退与前进、软件中的撤销与反撤销</strong>。每当我们打开新的网页,浏览器就会将上一个网页执行入栈,这样我们就可以通过「后退」操作回到上一页面。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。</li>
|
||||
<li><strong>浏览器中的后退与前进、软件中的撤销与反撤销</strong>。每当我们打开新的网页,浏览器就会将上一个网页执行入栈,这样我们就可以通过后退操作回到上一页面。后退操作实际上是在执行出栈。如果要同时支持后退和前进,那么需要两个栈来配合实现。</li>
|
||||
<li><strong>程序内存管理</strong>。每次调用函数时,系统都会在栈顶添加一个栈帧,用于记录函数的上下文信息。在递归函数中,向下递推阶段会不断执行入栈操作,而向上回溯阶段则会执行出栈操作。</li>
|
||||
</ul>
|
||||
|
||||
|
|
|
@ -3427,7 +3427,7 @@
|
|||
|
||||
<h1 id="73">7.3 二叉树数组表示<a class="headerlink" href="#73" title="Permanent link">¶</a></h1>
|
||||
<p>在链表表示下,二叉树的存储单元为节点 <code>TreeNode</code> ,节点之间通过指针相连接。在上节中,我们学习了在链表表示下的二叉树的各项基本操作。</p>
|
||||
<p>那么,能否用「数组」来表示二叉树呢?答案是肯定的。</p>
|
||||
<p>那么,我们能否用数组来表示二叉树呢?答案是肯定的。</p>
|
||||
<h2 id="731">7.3.1 表示完美二叉树<a class="headerlink" href="#731" title="Permanent link">¶</a></h2>
|
||||
<p>先分析一个简单案例。给定一个完美二叉树,我们将所有节点按照层序遍历的顺序存储在一个数组中,则每个节点都对应唯一的数组索引。</p>
|
||||
<p>根据层序遍历的特性,我们可以推导出父节点索引与子节点索引之间的“映射公式”:<strong>若节点的索引为 <span class="arithmatex">\(i\)</span> ,则该节点的左子节点索引为 <span class="arithmatex">\(2i + 1\)</span> ,右子节点索引为 <span class="arithmatex">\(2i + 2\)</span></strong> 。</p>
|
||||
|
|
|
@ -3627,7 +3627,7 @@
|
|||
|
||||
<p>G. M. Adelson-Velsky 和 E. M. Landis 在其 1962 年发表的论文 "An algorithm for the organization of information" 中提出了「AVL 树」。论文中详细描述了一系列操作,确保在持续添加和删除节点后,AVL 树不会退化,从而使得各种操作的时间复杂度保持在 <span class="arithmatex">\(O(\log n)\)</span> 级别。换句话说,在需要频繁进行增删查改操作的场景中,AVL 树能始终保持高效的数据操作性能,具有很好的应用价值。</p>
|
||||
<h2 id="751-avl">7.5.1 AVL 树常见术语<a class="headerlink" href="#751-avl" title="Permanent link">¶</a></h2>
|
||||
<p>「AVL 树」既是二叉搜索树也是平衡二叉树,同时满足这两类二叉树的所有性质,因此也被称为「平衡二叉搜索树」。</p>
|
||||
<p>AVL 树既是二叉搜索树也是平衡二叉树,同时满足这两类二叉树的所有性质,因此也被称为「平衡二叉搜索树 balanced binary search tree」。</p>
|
||||
<h3 id="1">1. 节点高度<a class="headerlink" href="#1" title="Permanent link">¶</a></h3>
|
||||
<p>在操作 AVL 树时,我们需要获取节点的高度,因此需要为 AVL 树的节点类添加 <code>height</code> 变量。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="1:12"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><input id="__tabbed_1_4" name="__tabbed_1" type="radio" /><input id="__tabbed_1_5" name="__tabbed_1" type="radio" /><input id="__tabbed_1_6" name="__tabbed_1" type="radio" /><input id="__tabbed_1_7" name="__tabbed_1" type="radio" /><input id="__tabbed_1_8" name="__tabbed_1" type="radio" /><input id="__tabbed_1_9" name="__tabbed_1" type="radio" /><input id="__tabbed_1_10" name="__tabbed_1" type="radio" /><input id="__tabbed_1_11" name="__tabbed_1" type="radio" /><input id="__tabbed_1_12" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">Java</label><label for="__tabbed_1_2">C++</label><label for="__tabbed_1_3">Python</label><label for="__tabbed_1_4">Go</label><label for="__tabbed_1_5">JS</label><label for="__tabbed_1_6">TS</label><label for="__tabbed_1_7">C</label><label for="__tabbed_1_8">C#</label><label for="__tabbed_1_9">Swift</label><label for="__tabbed_1_10">Zig</label><label for="__tabbed_1_11">Dart</label><label for="__tabbed_1_12">Rust</label></div>
|
||||
|
@ -3778,7 +3778,7 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>「节点高度」是指从该节点到最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。</p>
|
||||
<p>“节点高度”是指从该节点到最远叶节点的距离,即所经过的“边”的数量。需要特别注意的是,叶节点的高度为 0 ,而空节点的高度为 -1 。我们将创建两个工具函数,分别用于获取和更新节点的高度。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="2:12"><input checked="checked" id="__tabbed_2_1" name="__tabbed_2" type="radio" /><input id="__tabbed_2_2" name="__tabbed_2" type="radio" /><input id="__tabbed_2_3" name="__tabbed_2" type="radio" /><input id="__tabbed_2_4" name="__tabbed_2" type="radio" /><input id="__tabbed_2_5" name="__tabbed_2" type="radio" /><input id="__tabbed_2_6" name="__tabbed_2" type="radio" /><input id="__tabbed_2_7" name="__tabbed_2" type="radio" /><input id="__tabbed_2_8" name="__tabbed_2" type="radio" /><input id="__tabbed_2_9" name="__tabbed_2" type="radio" /><input id="__tabbed_2_10" name="__tabbed_2" type="radio" /><input id="__tabbed_2_11" name="__tabbed_2" type="radio" /><input id="__tabbed_2_12" name="__tabbed_2" type="radio" /><div class="tabbed-labels"><label for="__tabbed_2_1">Java</label><label for="__tabbed_2_2">C++</label><label for="__tabbed_2_3">Python</label><label for="__tabbed_2_4">Go</label><label for="__tabbed_2_5">JS</label><label for="__tabbed_2_6">TS</label><label for="__tabbed_2_7">C</label><label for="__tabbed_2_8">C#</label><label for="__tabbed_2_9">Swift</label><label for="__tabbed_2_10">Zig</label><label for="__tabbed_2_11">Dart</label><label for="__tabbed_2_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -3980,7 +3980,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<h3 id="2">2. 节点平衡因子<a class="headerlink" href="#2" title="Permanent link">¶</a></h3>
|
||||
<p>节点的「平衡因子 Balance Factor」定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。</p>
|
||||
<p>节点的「平衡因子 balance factor」定义为节点左子树的高度减去右子树的高度,同时规定空节点的平衡因子为 0 。我们同样将获取节点平衡因子的功能封装成函数,方便后续使用。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="3:12"><input checked="checked" id="__tabbed_3_1" name="__tabbed_3" type="radio" /><input id="__tabbed_3_2" name="__tabbed_3" type="radio" /><input id="__tabbed_3_3" name="__tabbed_3" type="radio" /><input id="__tabbed_3_4" name="__tabbed_3" type="radio" /><input id="__tabbed_3_5" name="__tabbed_3" type="radio" /><input id="__tabbed_3_6" name="__tabbed_3" type="radio" /><input id="__tabbed_3_7" name="__tabbed_3" type="radio" /><input id="__tabbed_3_8" name="__tabbed_3" type="radio" /><input id="__tabbed_3_9" name="__tabbed_3" type="radio" /><input id="__tabbed_3_10" name="__tabbed_3" type="radio" /><input id="__tabbed_3_11" name="__tabbed_3" type="radio" /><input id="__tabbed_3_12" name="__tabbed_3" type="radio" /><div class="tabbed-labels"><label for="__tabbed_3_1">Java</label><label for="__tabbed_3_2">C++</label><label for="__tabbed_3_3">Python</label><label for="__tabbed_3_4">Go</label><label for="__tabbed_3_5">JS</label><label for="__tabbed_3_6">TS</label><label for="__tabbed_3_7">C</label><label for="__tabbed_3_8">C#</label><label for="__tabbed_3_9">Swift</label><label for="__tabbed_3_10">Zig</label><label for="__tabbed_3_11">Dart</label><label for="__tabbed_3_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -4120,10 +4120,10 @@
|
|||
<p>设平衡因子为 <span class="arithmatex">\(f\)</span> ,则一棵 AVL 树的任意节点的平衡因子皆满足 <span class="arithmatex">\(-1 \le f \le 1\)</span> 。</p>
|
||||
</div>
|
||||
<h2 id="752-avl">7.5.2 AVL 树旋转<a class="headerlink" href="#752-avl" title="Permanent link">¶</a></h2>
|
||||
<p>AVL 树的特点在于「旋转 Rotation」操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,<strong>旋转操作既能保持树的「二叉搜索树」属性,也能使树重新变为「平衡二叉树」</strong>。</p>
|
||||
<p>我们将平衡因子绝对值 <span class="arithmatex">\(> 1\)</span> 的节点称为「失衡节点」。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面我们将详细介绍这些旋转操作。</p>
|
||||
<p>AVL 树的特点在于“旋转”操作,它能够在不影响二叉树的中序遍历序列的前提下,使失衡节点重新恢复平衡。换句话说,<strong>旋转操作既能保持“二叉搜索树”的性质,也能使树重新变为“平衡二叉树”</strong>。</p>
|
||||
<p>我们将平衡因子绝对值 <span class="arithmatex">\(> 1\)</span> 的节点称为“失衡节点”。根据节点失衡情况的不同,旋转操作分为四种:右旋、左旋、先右旋后左旋、先左旋后右旋。下面我们将详细介绍这些旋转操作。</p>
|
||||
<h3 id="1_1">1. 右旋<a class="headerlink" href="#1_1" title="Permanent link">¶</a></h3>
|
||||
<p>如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树,将该节点记为 <code>node</code> ,其左子节点记为 <code>child</code> ,执行「右旋」操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的特性。</p>
|
||||
<p>如下图所示,节点下方为平衡因子。从底至顶看,二叉树中首个失衡节点是“节点 3”。我们关注以该失衡节点为根节点的子树,将该节点记为 <code>node</code> ,其左子节点记为 <code>child</code> ,执行“右旋”操作。完成右旋后,子树已经恢复平衡,并且仍然保持二叉搜索树的特性。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="4:4"><input checked="checked" id="__tabbed_4_1" name="__tabbed_4" type="radio" /><input id="__tabbed_4_2" name="__tabbed_4" type="radio" /><input id="__tabbed_4_3" name="__tabbed_4" type="radio" /><input id="__tabbed_4_4" name="__tabbed_4" type="radio" /><div class="tabbed-labels"><label for="__tabbed_4_1"><1></label><label for="__tabbed_4_2"><2></label><label for="__tabbed_4_3"><3></label><label for="__tabbed_4_4"><4></label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -4142,7 +4142,7 @@
|
|||
</div>
|
||||
<p align="center"> 图:右旋操作步骤 </p>
|
||||
|
||||
<p>此外,如果节点 <code>child</code> 本身有右子节点(记为 <code>grandChild</code> ),则需要在「右旋」中添加一步:将 <code>grandChild</code> 作为 <code>node</code> 的左子节点。</p>
|
||||
<p>此外,如果节点 <code>child</code> 本身有右子节点(记为 <code>grandChild</code> ),则需要在右旋中添加一步:将 <code>grandChild</code> 作为 <code>node</code> 的左子节点。</p>
|
||||
<p><img alt="有 grandChild 的右旋操作" src="../avl_tree.assets/avltree_right_rotate_with_grandchild.png" /></p>
|
||||
<p align="center"> 图:有 grandChild 的右旋操作 </p>
|
||||
|
||||
|
@ -4349,15 +4349,15 @@
|
|||
</div>
|
||||
</div>
|
||||
<h3 id="2_1">2. 左旋<a class="headerlink" href="#2_1" title="Permanent link">¶</a></h3>
|
||||
<p>相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行「左旋」操作。</p>
|
||||
<p>相应的,如果考虑上述失衡二叉树的“镜像”,则需要执行“左旋”操作。</p>
|
||||
<p><img alt="左旋操作" src="../avl_tree.assets/avltree_left_rotate.png" /></p>
|
||||
<p align="center"> 图:左旋操作 </p>
|
||||
|
||||
<p>同理,若节点 <code>child</code> 本身有左子节点(记为 <code>grandChild</code> ),则需要在「左旋」中添加一步:将 <code>grandChild</code> 作为 <code>node</code> 的右子节点。</p>
|
||||
<p>同理,若节点 <code>child</code> 本身有左子节点(记为 <code>grandChild</code> ),则需要在左旋中添加一步:将 <code>grandChild</code> 作为 <code>node</code> 的右子节点。</p>
|
||||
<p><img alt="有 grandChild 的左旋操作" src="../avl_tree.assets/avltree_left_rotate_with_grandchild.png" /></p>
|
||||
<p align="center"> 图:有 grandChild 的左旋操作 </p>
|
||||
|
||||
<p>可以观察到,<strong>右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的</strong>。基于对称性,我们可以轻松地从右旋的代码推导出左旋的代码。具体地,只需将「右旋」代码中的把所有的 <code>left</code> 替换为 <code>right</code> ,将所有的 <code>right</code> 替换为 <code>left</code> ,即可得到「左旋」代码。</p>
|
||||
<p>可以观察到,<strong>右旋和左旋操作在逻辑上是镜像对称的,它们分别解决的两种失衡情况也是对称的</strong>。基于对称性,我们只需将右旋的实现代码中的所有的 <code>left</code> 替换为 <code>right</code> ,将所有的 <code>right</code> 替换为 <code>left</code> ,即可得到左旋的实现代码。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="6:12"><input checked="checked" id="__tabbed_6_1" name="__tabbed_6" type="radio" /><input id="__tabbed_6_2" name="__tabbed_6" type="radio" /><input id="__tabbed_6_3" name="__tabbed_6" type="radio" /><input id="__tabbed_6_4" name="__tabbed_6" type="radio" /><input id="__tabbed_6_5" name="__tabbed_6" type="radio" /><input id="__tabbed_6_6" name="__tabbed_6" type="radio" /><input id="__tabbed_6_7" name="__tabbed_6" type="radio" /><input id="__tabbed_6_8" name="__tabbed_6" type="radio" /><input id="__tabbed_6_9" name="__tabbed_6" type="radio" /><input id="__tabbed_6_10" name="__tabbed_6" type="radio" /><input id="__tabbed_6_11" name="__tabbed_6" type="radio" /><input id="__tabbed_6_12" name="__tabbed_6" type="radio" /><div class="tabbed-labels"><label for="__tabbed_6_1">Java</label><label for="__tabbed_6_2">C++</label><label for="__tabbed_6_3">Python</label><label for="__tabbed_6_4">Go</label><label for="__tabbed_6_5">JS</label><label for="__tabbed_6_6">TS</label><label for="__tabbed_6_7">C</label><label for="__tabbed_6_8">C#</label><label for="__tabbed_6_9">Swift</label><label for="__tabbed_6_10">Zig</label><label for="__tabbed_6_11">Dart</label><label for="__tabbed_6_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -4560,12 +4560,12 @@
|
|||
</div>
|
||||
</div>
|
||||
<h3 id="3">3. 先左旋后右旋<a class="headerlink" href="#3" title="Permanent link">¶</a></h3>
|
||||
<p>对于下图中的失衡节点 3,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋,即先对 <code>child</code> 执行「左旋」,再对 <code>node</code> 执行「右旋」。</p>
|
||||
<p>对于下图中的失衡节点 3,仅使用左旋或右旋都无法使子树恢复平衡。此时需要先左旋后右旋,即先对 <code>child</code> 执行“左旋”,再对 <code>node</code> 执行“右旋”。</p>
|
||||
<p><img alt="先左旋后右旋" src="../avl_tree.assets/avltree_left_right_rotate.png" /></p>
|
||||
<p align="center"> 图:先左旋后右旋 </p>
|
||||
|
||||
<h3 id="4">4. 先右旋后左旋<a class="headerlink" href="#4" title="Permanent link">¶</a></h3>
|
||||
<p>同理,对于上述失衡二叉树的镜像情况,需要先右旋后左旋,即先对 <code>child</code> 执行「右旋」,然后对 <code>node</code> 执行「左旋」。</p>
|
||||
<p>同理,对于上述失衡二叉树的镜像情况,需要先右旋后左旋,即先对 <code>child</code> 执行“右旋”,然后对 <code>node</code> 执行“左旋”。</p>
|
||||
<p><img alt="先右旋后左旋" src="../avl_tree.assets/avltree_right_left_rotate.png" /></p>
|
||||
<p align="center"> 图:先右旋后左旋 </p>
|
||||
|
||||
|
@ -5002,7 +5002,7 @@
|
|||
</div>
|
||||
<h2 id="753-avl">7.5.3 AVL 树常用操作<a class="headerlink" href="#753-avl" title="Permanent link">¶</a></h2>
|
||||
<h3 id="1_2">1. 插入节点<a class="headerlink" href="#1_2" title="Permanent link">¶</a></h3>
|
||||
<p>「AVL 树」的节点插入操作与「二叉搜索树」在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,<strong>我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡</strong>。</p>
|
||||
<p>AVL 树的节点插入操作与二叉搜索树在主体上类似。唯一的区别在于,在 AVL 树中插入节点后,从该节点到根节点的路径上可能会出现一系列失衡节点。因此,<strong>我们需要从这个节点开始,自底向上执行旋转操作,使所有失衡节点恢复平衡</strong>。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="8:12"><input checked="checked" id="__tabbed_8_1" name="__tabbed_8" type="radio" /><input id="__tabbed_8_2" name="__tabbed_8" type="radio" /><input id="__tabbed_8_3" name="__tabbed_8" type="radio" /><input id="__tabbed_8_4" name="__tabbed_8" type="radio" /><input id="__tabbed_8_5" name="__tabbed_8" type="radio" /><input id="__tabbed_8_6" name="__tabbed_8" type="radio" /><input id="__tabbed_8_7" name="__tabbed_8" type="radio" /><input id="__tabbed_8_8" name="__tabbed_8" type="radio" /><input id="__tabbed_8_9" name="__tabbed_8" type="radio" /><input id="__tabbed_8_10" name="__tabbed_8" type="radio" /><input id="__tabbed_8_11" name="__tabbed_8" type="radio" /><input id="__tabbed_8_12" name="__tabbed_8" type="radio" /><div class="tabbed-labels"><label for="__tabbed_8_1">Java</label><label for="__tabbed_8_2">C++</label><label for="__tabbed_8_3">Python</label><label for="__tabbed_8_4">Go</label><label for="__tabbed_8_5">JS</label><label for="__tabbed_8_6">TS</label><label for="__tabbed_8_7">C</label><label for="__tabbed_8_8">C#</label><label for="__tabbed_8_9">Swift</label><label for="__tabbed_8_10">Zig</label><label for="__tabbed_8_11">Dart</label><label for="__tabbed_8_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
|
|
@ -3494,7 +3494,7 @@
|
|||
|
||||
|
||||
<h1 id="74">7.4 二叉搜索树<a class="headerlink" href="#74" title="Permanent link">¶</a></h1>
|
||||
<p>「二叉搜索树 Binary Search Tree」满足以下条件:</p>
|
||||
<p>「二叉搜索树 binary search tree」满足以下条件:</p>
|
||||
<ol>
|
||||
<li>对于根节点,左子树中所有节点的值 <span class="arithmatex">\(<\)</span> 根节点的值 <span class="arithmatex">\(<\)</span> 右子树中所有节点的值。</li>
|
||||
<li>任意节点的左、右子树也是二叉搜索树,即同样满足条件 <code>1.</code> 。</li>
|
||||
|
|
|
@ -3508,7 +3508,7 @@
|
|||
|
||||
|
||||
<h1 id="71">7.1 二叉树<a class="headerlink" href="#71" title="Permanent link">¶</a></h1>
|
||||
<p>「二叉树 Binary Tree」是一种非线性数据结构,代表着祖先与后代之间的派生关系,体现着“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含:值、左子节点引用、右子节点引用。</p>
|
||||
<p>「二叉树 binary tree」是一种非线性数据结构,代表着祖先与后代之间的派生关系,体现着“一分为二”的分治逻辑。与链表类似,二叉树的基本单元是节点,每个节点包含:值、左子节点引用、右子节点引用。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="1:12"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><input id="__tabbed_1_4" name="__tabbed_1" type="radio" /><input id="__tabbed_1_5" name="__tabbed_1" type="radio" /><input id="__tabbed_1_6" name="__tabbed_1" type="radio" /><input id="__tabbed_1_7" name="__tabbed_1" type="radio" /><input id="__tabbed_1_8" name="__tabbed_1" type="radio" /><input id="__tabbed_1_9" name="__tabbed_1" type="radio" /><input id="__tabbed_1_10" name="__tabbed_1" type="radio" /><input id="__tabbed_1_11" name="__tabbed_1" type="radio" /><input id="__tabbed_1_12" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">Java</label><label for="__tabbed_1_2">C++</label><label for="__tabbed_1_3">Python</label><label for="__tabbed_1_4">Go</label><label for="__tabbed_1_5">JS</label><label for="__tabbed_1_6">TS</label><label for="__tabbed_1_7">C</label><label for="__tabbed_1_8">C#</label><label for="__tabbed_1_9">Swift</label><label for="__tabbed_1_10">Zig</label><label for="__tabbed_1_11">Dart</label><label for="__tabbed_1_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -3648,29 +3648,29 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p>节点的两个指针分别指向「左子节点」和「右子节点」,同时该节点被称为这两个子节点的「父节点」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树」,同理可得「右子树」。</p>
|
||||
<p><strong>在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树</strong>。例如,在以下示例中,若将“节点 2”视为父节点,则其左子节点和右子节点分别是“节点 4”和“节点 5”,左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。</p>
|
||||
<p>每个节点都有两个引用(指针),分别指向「左子节点 left-child node」和「右子节点 right-child node」,该节点被称为这两个子节点的「父节点 parent node」。当给定一个二叉树的节点时,我们将该节点的左子节点及其以下节点形成的树称为该节点的「左子树 left subtree」,同理可得「右子树 right subtree」。</p>
|
||||
<p><strong>在二叉树中,除叶节点外,其他所有节点都包含子节点和非空子树</strong>。在以下示例中,若将“节点 2”视为父节点,则其左子节点和右子节点分别是“节点 4”和“节点 5”,左子树是“节点 4 及其以下节点形成的树”,右子树是“节点 5 及其以下节点形成的树”。</p>
|
||||
<p><img alt="父节点、子节点、子树" src="../binary_tree.assets/binary_tree_definition.png" /></p>
|
||||
<p align="center"> 图:父节点、子节点、子树 </p>
|
||||
|
||||
<h2 id="711">7.1.1 二叉树常见术语<a class="headerlink" href="#711" title="Permanent link">¶</a></h2>
|
||||
<p>二叉树涉及的术语较多,建议尽量理解并记住。</p>
|
||||
<ul>
|
||||
<li>「根节点 Root Node」:位于二叉树顶层的节点,没有父节点。</li>
|
||||
<li>「叶节点 Leaf Node」:没有子节点的节点,其两个指针均指向 <span class="arithmatex">\(\text{None}\)</span> 。</li>
|
||||
<li>节点的「层 Level」:从顶至底递增,根节点所在层为 1 。</li>
|
||||
<li>节点的「度 Degree」:节点的子节点的数量。在二叉树中,度的范围是 0, 1, 2 。</li>
|
||||
<li>「边 Edge」:连接两个节点的线段,即节点指针。</li>
|
||||
<li>二叉树的「高度」:从根节点到最远叶节点所经过的边的数量。</li>
|
||||
<li>节点的「深度 Depth」 :从根节点到该节点所经过的边的数量。</li>
|
||||
<li>节点的「高度 Height」:从最远叶节点到该节点所经过的边的数量。</li>
|
||||
<li>「根节点 root node」:位于二叉树顶层的节点,没有父节点。</li>
|
||||
<li>「叶节点 leaf node」:没有子节点的节点,其两个指针均指向 <span class="arithmatex">\(\text{None}\)</span> 。</li>
|
||||
<li>「边 edge」:连接两个节点的线段,即节点引用(指针)。</li>
|
||||
<li>节点所在的「层 level」:从顶至底递增,根节点所在层为 1 。</li>
|
||||
<li>节点的「度 degree」:节点的子节点的数量。在二叉树中,度的取值范围是 0, 1, 2 。</li>
|
||||
<li>二叉树的「高度 height」:从根节点到最远叶节点所经过的边的数量。</li>
|
||||
<li>节点的「深度 depth」 :从根节点到该节点所经过的边的数量。</li>
|
||||
<li>节点的「高度 height」:从最远叶节点到该节点所经过的边的数量。</li>
|
||||
</ul>
|
||||
<p><img alt="二叉树的常用术语" src="../binary_tree.assets/binary_tree_terminology.png" /></p>
|
||||
<p align="center"> 图:二叉树的常用术语 </p>
|
||||
|
||||
<div class="admonition tip">
|
||||
<p class="admonition-title">高度与深度的定义</p>
|
||||
<p>请注意,我们通常将「高度」和「深度」定义为“走过边的数量”,但有些题目或教材可能会将其定义为“走过节点的数量”。在这种情况下,高度和深度都需要加 1 。</p>
|
||||
<p>请注意,我们通常将“高度”和“深度”定义为“走过边的数量”,但有些题目或教材可能会将其定义为“走过节点的数量”。在这种情况下,高度和深度都需要加 1 。</p>
|
||||
</div>
|
||||
<h2 id="712">7.1.2 二叉树基本操作<a class="headerlink" href="#712" title="Permanent link">¶</a></h2>
|
||||
<p><strong>初始化二叉树</strong>。与链表类似,首先初始化节点,然后构建引用指向(即指针)。</p>
|
||||
|
@ -3954,31 +3954,31 @@
|
|||
</div>
|
||||
<h2 id="713">7.1.3 常见二叉树类型<a class="headerlink" href="#713" title="Permanent link">¶</a></h2>
|
||||
<h3 id="1">1. 完美二叉树<a class="headerlink" href="#1" title="Permanent link">¶</a></h3>
|
||||
<p>「完美二叉树 Perfect Binary Tree」除了最底层外,其余所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 <span class="arithmatex">\(0\)</span> ,其余所有节点的度都为 <span class="arithmatex">\(2\)</span> ;若树高度为 <span class="arithmatex">\(h\)</span> ,则节点总数为 <span class="arithmatex">\(2^{h+1} - 1\)</span> ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。</p>
|
||||
<p>「完美二叉树 perfect binary tree」除了最底层外,其余所有层的节点都被完全填满。在完美二叉树中,叶节点的度为 <span class="arithmatex">\(0\)</span> ,其余所有节点的度都为 <span class="arithmatex">\(2\)</span> ;若树高度为 <span class="arithmatex">\(h\)</span> ,则节点总数为 <span class="arithmatex">\(2^{h+1} - 1\)</span> ,呈现标准的指数级关系,反映了自然界中常见的细胞分裂现象。</p>
|
||||
<div class="admonition tip">
|
||||
<p class="admonition-title">Tip</p>
|
||||
<p>在中文社区中,完美二叉树常被称为「满二叉树」,请注意区分。</p>
|
||||
<p>请注意,在中文社区中,完美二叉树常被称为「满二叉树」。</p>
|
||||
</div>
|
||||
<p><img alt="完美二叉树" src="../binary_tree.assets/perfect_binary_tree.png" /></p>
|
||||
<p align="center"> 图:完美二叉树 </p>
|
||||
|
||||
<h3 id="2">2. 完全二叉树<a class="headerlink" href="#2" title="Permanent link">¶</a></h3>
|
||||
<p>「完全二叉树 Complete Binary Tree」只有最底层的节点未被填满,且最底层节点尽量靠左填充。</p>
|
||||
<p>「完全二叉树 complete binary tree」只有最底层的节点未被填满,且最底层节点尽量靠左填充。</p>
|
||||
<p><img alt="完全二叉树" src="../binary_tree.assets/complete_binary_tree.png" /></p>
|
||||
<p align="center"> 图:完全二叉树 </p>
|
||||
|
||||
<h3 id="3">3. 完满二叉树<a class="headerlink" href="#3" title="Permanent link">¶</a></h3>
|
||||
<p>「完满二叉树 Full Binary Tree」除了叶节点之外,其余所有节点都有两个子节点。</p>
|
||||
<p>「完满二叉树 full binary tree」除了叶节点之外,其余所有节点都有两个子节点。</p>
|
||||
<p><img alt="完满二叉树" src="../binary_tree.assets/full_binary_tree.png" /></p>
|
||||
<p align="center"> 图:完满二叉树 </p>
|
||||
|
||||
<h3 id="4">4. 平衡二叉树<a class="headerlink" href="#4" title="Permanent link">¶</a></h3>
|
||||
<p>「平衡二叉树 Balanced Binary Tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。</p>
|
||||
<p>「平衡二叉树 balanced binary tree」中任意节点的左子树和右子树的高度之差的绝对值不超过 1 。</p>
|
||||
<p><img alt="平衡二叉树" src="../binary_tree.assets/balanced_binary_tree.png" /></p>
|
||||
<p align="center"> 图:平衡二叉树 </p>
|
||||
|
||||
<h2 id="714">7.1.4 二叉树的退化<a class="headerlink" href="#714" title="Permanent link">¶</a></h2>
|
||||
<p>当二叉树的每层节点都被填满时,达到「完美二叉树」;而当所有节点都偏向一侧时,二叉树退化为「链表」。</p>
|
||||
<p>当二叉树的每层节点都被填满时,达到“完美二叉树”;而当所有节点都偏向一侧时,二叉树退化为“链表”。</p>
|
||||
<ul>
|
||||
<li>完美二叉树是理想情况,可以充分发挥二叉树“分治”的优势。</li>
|
||||
<li>链表则是另一个极端,各项操作都变为线性操作,时间复杂度退化至 <span class="arithmatex">\(O(n)\)</span> 。</li>
|
||||
|
|
|
@ -3415,12 +3415,12 @@
|
|||
<p>从物理结构的角度来看,树是一种基于链表的数据结构,因此其遍历方式是通过指针逐个访问节点。然而,树是一种非线性数据结构,这使得遍历树比遍历链表更加复杂,需要借助搜索算法来实现。</p>
|
||||
<p>二叉树常见的遍历方式包括层序遍历、前序遍历、中序遍历和后序遍历等。</p>
|
||||
<h2 id="721">7.2.1 层序遍历<a class="headerlink" href="#721" title="Permanent link">¶</a></h2>
|
||||
<p>「层序遍历 Level-Order Traversal」从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。</p>
|
||||
<p>层序遍历本质上属于「广度优先搜索 Breadth-First Traversal」,它体现了一种“一圈一圈向外扩展”的逐层搜索方式。</p>
|
||||
<p>「层序遍历 level-order traversal」从顶部到底部逐层遍历二叉树,并在每一层按照从左到右的顺序访问节点。</p>
|
||||
<p>层序遍历本质上属于「广度优先遍历 breadth-first traversal」,它体现了一种“一圈一圈向外扩展”的逐层遍历方式。</p>
|
||||
<p><img alt="二叉树的层序遍历" src="../binary_tree_traversal.assets/binary_tree_bfs.png" /></p>
|
||||
<p align="center"> 图:二叉树的层序遍历 </p>
|
||||
|
||||
<p>广度优先遍历通常借助「队列」来实现。队列遵循“先进先出”的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。</p>
|
||||
<p>广度优先遍历通常借助“队列”来实现。队列遵循“先进先出”的规则,而广度优先遍历则遵循“逐层推进”的规则,两者背后的思想是一致的。</p>
|
||||
<div class="tabbed-set tabbed-alternate" data-tabs="1:12"><input checked="checked" id="__tabbed_1_1" name="__tabbed_1" type="radio" /><input id="__tabbed_1_2" name="__tabbed_1" type="radio" /><input id="__tabbed_1_3" name="__tabbed_1" type="radio" /><input id="__tabbed_1_4" name="__tabbed_1" type="radio" /><input id="__tabbed_1_5" name="__tabbed_1" type="radio" /><input id="__tabbed_1_6" name="__tabbed_1" type="radio" /><input id="__tabbed_1_7" name="__tabbed_1" type="radio" /><input id="__tabbed_1_8" name="__tabbed_1" type="radio" /><input id="__tabbed_1_9" name="__tabbed_1" type="radio" /><input id="__tabbed_1_10" name="__tabbed_1" type="radio" /><input id="__tabbed_1_11" name="__tabbed_1" type="radio" /><input id="__tabbed_1_12" name="__tabbed_1" type="radio" /><div class="tabbed-labels"><label for="__tabbed_1_1">Java</label><label for="__tabbed_1_2">C++</label><label for="__tabbed_1_3">Python</label><label for="__tabbed_1_4">Go</label><label for="__tabbed_1_5">JS</label><label for="__tabbed_1_6">TS</label><label for="__tabbed_1_7">C</label><label for="__tabbed_1_8">C#</label><label for="__tabbed_1_9">Swift</label><label for="__tabbed_1_10">Zig</label><label for="__tabbed_1_11">Dart</label><label for="__tabbed_1_12">Rust</label></div>
|
||||
<div class="tabbed-content">
|
||||
<div class="tabbed-block">
|
||||
|
@ -3706,7 +3706,7 @@
|
|||
<p><strong>时间复杂度</strong>:所有节点被访问一次,使用 <span class="arithmatex">\(O(n)\)</span> 时间,其中 <span class="arithmatex">\(n\)</span> 为节点数量。</p>
|
||||
<p><strong>空间复杂度</strong>:在最差情况下,即满二叉树时,遍历到最底层之前,队列中最多同时存在 <span class="arithmatex">\(\frac{n + 1}{2}\)</span> 个节点,占用 <span class="arithmatex">\(O(n)\)</span> 空间。</p>
|
||||
<h2 id="722">7.2.2 前序、中序、后序遍历<a class="headerlink" href="#722" title="Permanent link">¶</a></h2>
|
||||
<p>相应地,前序、中序和后序遍历都属于「深度优先遍历 Depth-First Traversal」,它体现了一种“先走到尽头,再回溯继续”的遍历方式。</p>
|
||||
<p>相应地,前序、中序和后序遍历都属于「深度优先遍历 depth-first traversal」,它体现了一种“先走到尽头,再回溯继续”的遍历方式。</p>
|
||||
<p>如下图所示,左侧是深度优先遍历的示意图,右上方是对应的递归代码。深度优先遍历就像是绕着整个二叉树的外围“走”一圈,在这个过程中,在每个节点都会遇到三个位置,分别对应前序遍历、中序遍历和后序遍历。</p>
|
||||
<p><img alt="二叉搜索树的前、中、后序遍历" src="../binary_tree_traversal.assets/binary_tree_dfs.png" /></p>
|
||||
<p align="center"> 图:二叉搜索树的前、中、后序遍历 </p>
|
||||
|
|
File diff suppressed because one or more lines are too long
BIN
sitemap.xml.gz
BIN
sitemap.xml.gz
Binary file not shown.
Loading…
Reference in a new issue