mirror of
https://github.com/zhwei820/learn.lianglianglee.com.git
synced 2025-11-16 22:23:45 +08:00
fix img
This commit is contained in:
@@ -193,10 +193,10 @@ function hide_canvas() {
|
||||
<p><strong>复杂度是衡量代码运行效率的重要度量因素</strong>。在介绍复杂度之前,有必要先看一下复杂度和计算机实际任务处理效率的关系,从而了解降低复杂度的必要性。</p>
|
||||
<p>计算机通过一个个程序去执行计算任务,也就是对输入数据进行加工处理,并最终得到结果的过程。每个程序都是由代码构成的。可见,编写代码的核心就是要完成计算。但对于同一个计算任务,不同计算方法得到结果的过程复杂程度是不一样的,这对你实际的任务处理效率就有了非常大的影响。</p>
|
||||
<p>举个例子,你要在一个在线系统中实时处理数据。假设这个系统平均每分钟会新增 300M 的数据量。如果你的代码不能在 1 分钟内完成对这 300M 数据的处理,那么这个系统就会发生时间爆炸和空间爆炸。表现就是,电脑执行越来越慢,直到死机。因此,我们需要讲究合理的计算方法,去通过尽可能低复杂程度的代码完成计算任务。</p>
|
||||
<p><img src="assets/CgqCHl7CRGiAe-NpAR0S70dSC2M990.gif" alt="1.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7CRGiAe-NpAR0S70dSC2M990.gif" alt="png" /></p>
|
||||
<p>那提到降低复杂度,我们首先需要知道怎么衡量复杂度。而在实际衡量时,我们通常会围绕以下2 个维度进行。<strong>首先,这段代码消耗的资源是什么</strong>。一般而言,代码执行过程中会消耗计算时间和计算空间,那需要衡量的就是时间复杂度和空间复杂度。</p>
|
||||
<p>我举一个实际生活中的例子。某个十字路口没有建立立交桥时,所有车辆通过红绿灯分批次行驶通过。当大量汽车同时过路口的时候,就会分别消耗大家的时间。但建了立交桥之后,所有车辆都可以同时通过了,因为立交桥的存在,等于是消耗了空间资源,来换取了时间资源。</p>
|
||||
<p><img src="assets/CgqCHl7CRMaAO_oEAJfz6fjfMNQ403.gif" alt="2.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7CRMaAO_oEAJfz6fjfMNQ403.gif" alt="png" /></p>
|
||||
<p><strong>其次,这段代码对于资源的消耗是多少</strong>。我们不会关注这段代码对于资源消耗的绝对量,因为不管是时间还是空间,它们的消耗程度都与输入的数据量高度相关,输入数据少时消耗自然就少。为了更客观地衡量消耗程度,我们通常会关注时间或者空间消耗量与输入数据量之间的关系。</p>
|
||||
<p>好,现在我们已经了解了衡量复杂度的两个纬度,那应该如何去计算复杂度呢?</p>
|
||||
<p><strong>复杂度是一个关于输入数据量 n 的函数</strong>。假设你的代码复杂度是 f(n),那么就用个大写字母 O 和括号,把 f(n) 括起来就可以了,即 O(f(n))。例如,O(n) 表示的是,复杂度与计算实例的个数 n 线性相关;O(logn) 表示的是,复杂度与计算实例的个数 n 对数相关。</p>
|
||||
@@ -209,7 +209,7 @@ function hide_canvas() {
|
||||
<p>例如,你的代码处理 10 条数据需要消耗 5 个单位的时间资源,3 个单位的空间资源。处理 1000 条数据,还是只需要消耗 5 个单位的时间资源,3 个单位的空间资源。那么就能发现资源消耗与输入数据量无关,就是 O(1) 的复杂度。</p>
|
||||
<p>为了方便你理解不同计算方法对复杂度的影响,我们来看一个代码任务:对于输入的数组,输出与之逆序的数组。例如,输入 a=[1,2,3,4,5],输出 [5,4,3,2,1]。</p>
|
||||
<p>先看<strong>方法一</strong>,建立并初始化数组 b,得到一个与输入数组等长的全零数组。通过一个 for 循环,从左到右将 a 数组的元素,从右到左地赋值到 b 数组中,最后输出数组 b 得到结果。</p>
|
||||
<p><img src="assets/Ciqc1F7CRP6ARwDTAGHL-opG6Bk835.gif" alt="3.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7CRP6ARwDTAGHL-opG6Bk835.gif" alt="png" /></p>
|
||||
<p>代码如下:</p>
|
||||
<pre><code>public void s1_1() {
|
||||
int a[] = { 1, 2, 3, 4, 5 };
|
||||
@@ -226,7 +226,7 @@ function hide_canvas() {
|
||||
<p>这段代码的输入数据是 a,数据量就等于数组 a 的长度。代码中有两个 for 循环,作用分别是给b 数组初始化和赋值,其执行次数都与输入数据量相等。因此,代码的<strong>时间复杂度</strong>就是 O(n)+O(n),也就是 O(n)。</p>
|
||||
<p>空间方面主要体现在计算过程中,对于存储资源的消耗情况。上面这段代码中,我们定义了一个新的数组 b,它与输入数组 a 的长度相等。因此,空间复杂度就是 O(n)。</p>
|
||||
<p><strong>接着我们看一下第二种编码方法</strong>,它定义了缓存变量 tmp,接着通过一个 for 循环,从 0 遍历到a 数组长度的一半(即 len(a)/2)。每次遍历执行的是什么内容?就是交换首尾对应的元素。最后打印数组 a,得到结果。</p>
|
||||
<p><img src="assets/Ciqc1F7CR22AIbSuABc0Rwl-t3w666.gif" alt="4.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7CR22AIbSuABc0Rwl-t3w666.gif" alt="png" /></p>
|
||||
<p>代码如下:</p>
|
||||
<pre><code>public void s1_2() {
|
||||
int a[] = { 1, 2, 3, 4, 5 };
|
||||
|
||||
@@ -196,7 +196,7 @@ function hide_canvas() {
|
||||
<li>如果出现了,就需要对出现的次数加 1。</li>
|
||||
<li>如果没有出现过,则把这个元素新增到未知数据结构中,并且把次数赋值为 1。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F7LgHGAWFU1AFRNn2DsECQ738.gif" alt="4SfjILfGIwwUQxq2.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7LgHGAWFU1AFRNn2DsECQ738.gif" alt="png" /></p>
|
||||
<p>这里的数据操作包括以下 3 个。</p>
|
||||
<ul>
|
||||
<li><strong>查找:</strong> 看能否在数据结构中查找到这个元素,也就是判断元素是否出现过。</li>
|
||||
@@ -239,10 +239,10 @@ function hide_canvas() {
|
||||
<p>例 2,我们来看第二个例子,如果是链表,如何找到这个链表中的第二个元素并输出呢?</p>
|
||||
<p>链表和数组一样,都是 O(n) 空间复杂度的复杂数据结构。但其区别之一就是,数组有 index 的索引,而链表没有。链表是通过指针,让元素按某个自定义的顺序“手拉手”连接在一起的。</p>
|
||||
<p>既然是这样,要查找其第二个元素,就必须要先知道第一个元素在哪里。以此类推,链表中某个位置的元素的查找,只能通过从前往后的顺序逐一去查找。不难发现,链表因为没有索引,只能“一个接一个”地按照位置条件查找,在这种情况下时间复杂度就是 O (n)。</p>
|
||||
<p><img src="assets/CgqCHl7LgWmAGPLPAAuRsXjiFwo828.gif" alt="1.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7LgWmAGPLPAAuRsXjiFwo828.gif" alt="png" /></p>
|
||||
<p>例 3,我们再来看第三个例子,关于数值条件的查找。</p>
|
||||
<p>我们要查找出,数据结构中数值等于 5 的元素是否存在。这次的查找,无论是数组还是链表都束手无策了。唯一的方法,也只有按照顺序一个接一个地去判断元素数值是否满足等于 5 的条件。很显然,这样的查找方法时间复杂度是 O(n)。那么有没有时间复杂度更低的方式呢?答案当然是:有。</p>
|
||||
<p><img src="assets/CgqCHl7LgYaAR4ePAA0VgmU62hc753.gif" alt="2.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7LgYaAR4ePAA0VgmU62hc753.gif" alt="png" /></p>
|
||||
<p>在前面的课时中,我们遇到过要查找出数组中出现次数最多的元素的情况。我们采用的方法是,把数组转变为字典,以保存元素及其出现次数的 k-v 映射关系。而在每次的循环中,都需要对当前遍历的元素,去查找它是否在字典中出现过。这里就是很实际的按照元素数值查找的例子。如果借助字典的数据类型,这个例子的查找问题,就可以在 O(1) 的时间复杂度内完成了。</p>
|
||||
<p>例 4,我们再来看第四个例子,关于复杂数据结构中新增数据,这里有两个可能.</p>
|
||||
<ul>
|
||||
@@ -255,9 +255,9 @@ function hide_canvas() {
|
||||
<li>首先,通过查找操作找到数据结构中最后一个数据的位置;</li>
|
||||
<li>接着,在这个位置之后,通过新增操作,赋值或者插入一条新的数据即可。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F7LgZmAd__TABaiFDWAj4Q302.gif" alt="3.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7LgZmAd__TABaiFDWAj4Q302.gif" alt="png" /></p>
|
||||
<p>如果是在数据结构中间的某个位置新增数据,则会对插入元素的位置之后的元素产生影响,导致数据的位置依次加 1 。例如,对于某个长度为 4 的数组,在第二个元素之后插入一个元素。则修改后的数组中,原来的第一、第二个元素的位置不发生变化,第三个元素是新插入的元素,第四、第五个元素则是原来的第三、第四个元素。</p>
|
||||
<p><img src="assets/Ciqc1F7Lga6ALkKoAB9PicwXoPY849.gif" alt="4.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7Lga6ALkKoAB9PicwXoPY849.gif" alt="png" /></p>
|
||||
<p>我们再来看看删除。在复杂数据结构中删除数据有两个可能:</p>
|
||||
<ul>
|
||||
<li>第一个是在这个复杂数据结构的最后,删除一条数据。</li>
|
||||
@@ -271,7 +271,7 @@ function hide_canvas() {
|
||||
<li>第二步,删除第 1 个满足数值大于 6 的元素。这里包含查找和删除两个操作,即查找出第 1 个数值大于 6 的元素的位置,并删除这个位置的元素。</li>
|
||||
</ul>
|
||||
<p>因此,总共需要完成的操作包括,按照位置的查找、新增和按照数据数值的查找、删除。</p>
|
||||
<p><img src="assets/CgqCHl7LgdiALYfBAEvqDR0lHjM759.gif" alt="5.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7LgdiALYfBAEvqDR0lHjM759.gif" alt="png" /></p>
|
||||
<h3>总结</h3>
|
||||
<p>好的,这节课的内容就到这里了。这一节的内容在很多数据结构的课程中都是没有的,这是因为大部分课程设计时,都普遍默认你已经掌握了这些知识。但是,这些知识恰恰又是你学习数据结构的根基。只有在充分了解问题、明确数据操作的方法之后,才能设计出更加高效的数据结构类型。</p>
|
||||
<p>经过我们的分析,数据处理的基本操作只有 3 个,分别是增、删、查。其中,增和删又可以细分为在数据结构中间的增和删,以及在数据结构最后的增和删。区别就在于原数据的位置是否发生改变。查找又可以细分为按照位置条件的查找和按照数据数值特征的查找。几乎所有的数据处理,都是这些基本操作的组合和叠加。</p>
|
||||
|
||||
@@ -188,7 +188,7 @@ function hide_canvas() {
|
||||
<p>接下来,我将通过一个实际案例来帮助你更好地理解数据结构。假设你是一所幼儿园的园长,现在你们正在组织一场运动会,所有的小朋友需要在操场上接受检阅。那么,如何组织小朋友有序站队并完成检阅呢?</p>
|
||||
<p>几个可能的方式是,让所有的小朋友站成一横排,或者让小朋友站成方阵,又或者让所有的小朋友手拉手,围成一个大圆圈等等。很显然,这里有无数种可行的组织方式。具体选择哪个组织方式,取决于哪一种能更好地展示出小朋友们的风采。</p>
|
||||
<p>试想一下,当计算机要处理大量数据时,同样需要考虑如何去组织这些数据,这就是数据结构。类似于小朋友的站队方式有无数种情况,数据组织的方式也是有无数种可能性。</p>
|
||||
<p><img src="assets/CgqCHl7OXiqAWO_nAKvmbV2Jtk4891.gif" alt="xx.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7OXiqAWO_nAKvmbV2Jtk4891.gif" alt="png" /></p>
|
||||
<p>然而,在实际开发中,经过工程师验证并且能有效解决问题的高效率数据结构就比较有限了。事实上,只要我们把这些能真正解决问题的数据结构学会,就足以成为一名合格的软件工程师了。</p>
|
||||
<h3>什么是线性表</h3>
|
||||
<p>好了,铺垫完数据结构的基本概念后,我们就正式进入到这个课程中的第一个数据结构的学习,线性表。</p>
|
||||
@@ -197,32 +197,32 @@ function hide_canvas() {
|
||||
<li>第一是具体的数据值;</li>
|
||||
<li>第二是指向下一个结点的指针。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F7OUvaADhsnAAAMCBqMAPw012.png" alt="image002.png" /></p>
|
||||
<p><img src="assets/Ciqc1F7OUvaADhsnAAAMCBqMAPw012.png" alt="png" /></p>
|
||||
<p>在链表的最前面,通常会有个头指针用来指向第一个结点。对于链表的最后一个结点,由于在它之后没有下一个结点,因此它的指针是个空指针。链表结构,和小朋友手拉手站成一排的场景是非常相似的。</p>
|
||||
<p>例如,你需要处理的数据集是 10 个同学考试的得分。如果用链表进行存储,就会得到如下的数据:</p>
|
||||
<p><img src="assets/CgqCHl7OUzqAAxTsAABByswXNGY123.png" alt="image004.png" /></p>
|
||||
<p><img src="assets/CgqCHl7OUzqAAxTsAABByswXNGY123.png" alt="png" /></p>
|
||||
<p>仔细观察上图,你会发现这个链表只能通过上一个结点的指针找到下一个结点,反过来则是行不通的。因此,这样的链表也被称作单向链表。</p>
|
||||
<p>有时候为了弥补单向链表的不足,我们可以对结点的结构进行改造:</p>
|
||||
<ul>
|
||||
<li>对于一个单向链表,让最后一个元素的指针指向第一个元素,就得到了循环链表;</li>
|
||||
<li>或者把结点的结构进行改造,除了有指向下一个结点的指针以外,再增加一个指向上一个结点的指针。这样就得到了双向链表。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl7OU1uAEuxjAABPx98ZMKs566.png" alt="image006.png" /></p>
|
||||
<p><img src="assets/Ciqc1F7OU2qAaiymAAA-hJj3ddw282.png" alt="image008.png" /></p>
|
||||
<p><img src="assets/CgqCHl7OU1uAEuxjAABPx98ZMKs566.png" alt="png" /></p>
|
||||
<p><img src="assets/Ciqc1F7OU2qAaiymAAA-hJj3ddw282.png" alt="png" /></p>
|
||||
<p>同样的,还可以对双向链表和循环链表进行融合,就得到了双向循环链表,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl7OU3WAV7lDAAAsQ8fj2Gw000.png" alt="image010.png" /></p>
|
||||
<p><img src="assets/CgqCHl7OU3WAV7lDAAAsQ8fj2Gw000.png" alt="png" /></p>
|
||||
<p>这些种类的链表,都是以单向链表为基础进行的变种。在某些场景下能提高线性表的效率。</p>
|
||||
<h3>线性表对于数据的增删查处理</h3>
|
||||
<p>学会了线性表原理之后,我们就来围绕数据的增删查操作,来看看线性表的表现。在这里我们主要介绍单向链表的增删查操作,其他类型的链表与此雷同,我们就不再重复介绍了。</p>
|
||||
<p>首先看一下增加操作。如下有一个链表,它存储了 10 个同学的考试成绩。现在发现这样的问题,在这个链表中,有一个同学的成绩忘了被存储进去。假设我们要把这个成绩在红色的结点之后插入,那么该如何进行呢?</p>
|
||||
<p>其实,链表在执行数据新增的时候非常容易,只需要把待插入结点的指针指向原指针的目标,把原来的指针指向待插入的结点,就可以了。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8ESZuADqT5AABRo8Zc6TI733.png" alt="01.png" /></p>
|
||||
<p><img src="assets/CgqCHl8ESZuADqT5AABRo8Zc6TI733.png" alt="png" /></p>
|
||||
<p>代码如下:</p>
|
||||
<pre><code>s.next = p.next;
|
||||
p.next = s;
|
||||
</code></pre>
|
||||
<p>接下来我们看一下删除操作。还是这个存储了同学们考试成绩的链表,假设里面有一个成绩的样本是被误操作放进来的,我们需要把这个样本删除。链表的删除操作跟新增操作一样,都是非常简单的。如果待删除的结点为 b,那么只需要把指向 b 的指针 (p.next),指向 b 的指针指向的结点(p.next.next)。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8ESbaAJi2xAAA-XJSjXw4037.png" alt="02.png" /></p>
|
||||
<p><img src="assets/CgqCHl8ESbaAJi2xAAA-XJSjXw4037.png" alt="png" /></p>
|
||||
<p>代码如下:</p>
|
||||
<pre><code>p.next = p.next.next;
|
||||
</code></pre>
|
||||
@@ -232,7 +232,7 @@ p.next = s;
|
||||
</ul>
|
||||
<p>它和数组中的 index 是非常类似的。假设一个链表中,按照学号存储了 10 个同学的考试成绩。现在要查找出学号等于 5 的同学,他的考试成绩是多少,该怎么办呢?</p>
|
||||
<p>其实,链表的查找功能是比较弱的,对于这个查找问题,唯一的办法就是一个一个地遍历去查找。也就是,从头开始,先找到学号为 1 的同学,再经过他跳转到学号为 2 的同学。直到经过多次跳转,找到了学号为 5 的同学,才能取出这个同学的成绩。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F7OU6eABdMqACANMndwJA8082.gif" alt="image016.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7OU6eABdMqACANMndwJA8082.gif" alt="png" /></p>
|
||||
<ul>
|
||||
<li>第二种情况是按照具体的成绩来查找。</li>
|
||||
</ul>
|
||||
@@ -242,15 +242,15 @@ p.next = s;
|
||||
<li>如果是,则返回有人得分为 95 分;</li>
|
||||
<li>如果不是,则需要通过指针去判断下一个结点的值是否等于 95。以此类推,直到把所有结点都访问完。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl7OU8KAME-KABD6y8ZPI78129.gif" alt="image017.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7OU-KAAnFoADpVNB3lRQQ707.gif" alt="image018.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7OU8KAME-KABD6y8ZPI78129.gif" alt="png" /></p>
|
||||
<p><img src="assets/Ciqc1F7OU-KAAnFoADpVNB3lRQQ707.gif" alt="png" /></p>
|
||||
<p>根据这里的分析不难发现,链表在新增、删除数据都比较容易,可以在 O(1) 的时间复杂度内完成。但对于查找,不管是按照位置的查找还是按照数值条件的查找,都需要对全部数据进行遍历。这显然就是 O(n) 的时间复杂度。</p>
|
||||
<p>虽然链表在新增和删除数据上有优势,但仔细思考就会发现,这个优势并不实用。这主要是因为,在新增数据时,通常会伴随一个查找的动作。例如,在第五个结点后,新增一个新的数据结点,那么执行的操作就包含两个步骤:</p>
|
||||
<ul>
|
||||
<li>第一步,查找第五个结点;</li>
|
||||
<li>第二步,再新增一个数据结点。整体的复杂度就是 O(n) + O(1)。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl7OU_-AVy0YACM7QklhkuQ370.gif" alt="image019.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7OU_-AVy0YACM7QklhkuQ370.gif" alt="png" /></p>
|
||||
<p>根据我们前面所学的复杂度计算方法,这也等同于 O(n) 的时间复杂度。线性表真正的价值在于,它对数据的存储方式是按照顺序的存储。如果数据的元素个数不确定,且需要经常进行数据的新增和删除时,那么链表会比较合适。如果数据元素大小确定,删除插入的操作并不多,那么数组可能更适合些。</p>
|
||||
<p>关于数组的知识,我们在后续的课程中会详细展开。</p>
|
||||
<h3>线性表案例</h3>
|
||||
@@ -268,21 +268,21 @@ p.next = s;
|
||||
curr = next;
|
||||
}
|
||||
</code></pre>
|
||||
<p><img src="assets/Ciqc1F7OVEaAOjblAGtskMyw3Cc079.gif" alt="image020.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7OVEaAOjblAGtskMyw3Cc079.gif" alt="png" /></p>
|
||||
<p>例 2,给定一个奇数个元素的链表,查找出这个链表中间位置的结点的数值。</p>
|
||||
<p>这个问题也是利用了链表的长度无法直接获取的不足做文章,解决办法如下:</p>
|
||||
<ul>
|
||||
<li>一个暴力的办法是,先通过一次遍历去计算链表的长度,这样我们就知道了链表中间位置是第几个。接着再通过一次遍历去查找这个位置的数值。</li>
|
||||
<li>除此之外,还有一个巧妙的办法,就是利用快慢指针进行处理。其中快指针每次循环向后跳转两次,而慢指针每次向后跳转一次。如下图所示。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl7OVjSAOebFABlVpq6d7m0547.gif" alt="HXedFIfmxfCLqrRI.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7OVjSAOebFABlVpq6d7m0547.gif" alt="png" /></p>
|
||||
<pre><code>while(fast && fast.next && fast.next.next){
|
||||
fast = fast.next.next;
|
||||
slow = slow.next;
|
||||
}
|
||||
</code></pre>
|
||||
<p>例 3,判断链表是否有环。如下图所示,这就是一个有环的链表。</p>
|
||||
<p><img src="assets/CgqCHl9HaUOAWgIjAACUx2G0hrE005.png" alt="WechatIMG108.png" /></p>
|
||||
<p><img src="assets/CgqCHl9HaUOAWgIjAACUx2G0hrE005.png" alt="png" /></p>
|
||||
<p>链表的快慢指针方法,在很多链表操作的场景下都非常适用,对于这个问题也是一样。</p>
|
||||
<p>假设链表有环,这个环里面就像是一个跑步赛道的操场一样。经过多次循环之后,快指针和慢指针都会进入到这个赛道中,就好像两个跑步选手在比赛。#加动图#快指针每次走两格,而慢指针每次走一格,相对而言,快指针每次循环会多走一步。这就意味着:</p>
|
||||
<ul>
|
||||
|
||||
@@ -187,27 +187,27 @@ function hide_canvas() {
|
||||
<h3>栈是什么</h3>
|
||||
<p>你需要牢记一点,栈是一种特殊的线性表。栈与线性表的不同,体现在增和删的操作。具体而言,栈的数据结点必须后进先出。后进的意思是,栈的数据新增操作只能在末端进行,不允许在栈的中间某个结点后新增数据。先出的意思是,栈的数据删除操作也只能在末端进行,不允许在栈的中间某个结点后删除数据。</p>
|
||||
<p>也就是说,栈的数据新增和删除操作只能在这个线性表的表尾进行,即在线性表的基础上加了限制。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl7UyyiAOqRGAACdPSEyJAw292.png" alt="1.png" /></p>
|
||||
<p><img src="assets/CgqCHl7UyyiAOqRGAACdPSEyJAw292.png" alt="png" /></p>
|
||||
<p>因此,栈是一种后进先出的线性表。栈对于数据的处理,就像用砖头盖房子的过程。对于盖房子而言,新的砖头只能放在前一个砖头上面;而对于拆房子而言,我们需要从上往下拆砖头。</p>
|
||||
<p><img src="assets/CgqCHl7UyzCAZynsAA1ztbJtHZM075.gif" alt="2.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7UyzCAZynsAA1ztbJtHZM075.gif" alt="png" /></p>
|
||||
<p>宏观上来看,与数组或链表相比,栈的操作更为受限,那为什么我们要用这种受限的栈呢?其实,单纯从功能上讲,数组或者链表可以替代栈。然而问题是,数组或者链表的操作过于灵活,这意味着,它们过多暴露了可操作的接口。这些没有意义的接口过多,当数据量很大的时候就会出现一些隐藏的风险。一旦发生代码 bug 或者受到攻击,就会给系统带来不可预知的风险。虽然栈限定降低了操作的灵活性,但这也使得栈在处理只涉及一端新增和删除数据的问题时效率更高。</p>
|
||||
<p>举个实际的例子,浏览器都有页面前进和后退功能,这就是个很典型的后进先出的场景。假设你先后访问了五个页面,分别标记为 1、2、3、4、5。当前你在页面 5,如果执行两次后退,则退回到了页面 3,如果再执行一次前进,则到了页面 4。处理这里的页面链接存储问题,栈就应该是我们首选的数据结构。</p>
|
||||
<p><img src="assets/CgqCHl7Uy0KAVgCXAGp5lx2v7gs430.gif" alt="3.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7Uy0KAVgCXAGp5lx2v7gs430.gif" alt="png" /></p>
|
||||
<p>栈既然是线性表,那么它也包含了表头和表尾。不过在栈结构中,由于其操作的特殊性,会对表头和表尾的名字进行改造。表尾用来输入数据,通常也叫作栈顶(top);相应地,表头就是栈底(bottom)。栈顶和栈底是用来表示这个栈的两个指针。跟线性表一样,栈也有顺序表示和链式表示,分别称作顺序栈和链栈。</p>
|
||||
<h3>栈的基本操作</h3>
|
||||
<p>如何通过栈这个后进先出的线性表,来实现增删查呢?初始时,栈内没有数据,即空栈。此时栈顶就是栈底。当存入数据时,最先放入的数据会进入栈底。接着加入的数据都会放入到栈顶的位置。如果要删除数据,也只能通过访问栈顶的数据并删除。对于栈的新增操作,通常也叫作 push 或压栈。对于栈的删除操作,通常也叫作 pop 或出栈。对于压栈和出栈,我们分别基于顺序栈和链栈进行讨论。</p>
|
||||
<p><img src="assets/CgqCHl7Uy1mASD8_ABTJXLqysYw837.gif" alt="4.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7Uy1mASD8_ABTJXLqysYw837.gif" alt="png" /></p>
|
||||
<h4>顺序栈</h4>
|
||||
<p>栈的顺序存储可以借助数组来实现。一般来说,会把数组的首元素存在栈底,最后一个元素放在栈顶。然后定义一个 top 指针来指示栈顶元素在数组中的位置。假设栈中只有一个数据元素,则 top = 0。一般以 top 是否为 -1 来判定是否为空栈。当定义了栈的最大容量为 StackSize 时,则栈顶 top 必须小于 StackSize。</p>
|
||||
<p>当需要新增数据元素,即入栈操作时,就需要将新插入元素放在栈顶,并将栈顶指针增加 1。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl7Uy2mAZIutAABpJkFDBhc178.png" alt="5.png" /></p>
|
||||
<p><img src="assets/CgqCHl7Uy2mAZIutAABpJkFDBhc178.png" alt="png" /></p>
|
||||
<p>删除数据元素,即出栈操作,只需要 top - 1 就可以了。</p>
|
||||
<p>对于查找操作,栈没有额外的改变,跟线性表一样,它也需要遍历整个栈来完成基于某些条件的数值查找。</p>
|
||||
<h4>链栈</h4>
|
||||
<p>关于链式栈,就是用链表的方式对栈的表示。通常,可以把栈顶放在单链表的头部,如下图所示。由于链栈的后进先出,原来的头指针就显得毫无作用了。因此,对于链栈来说,是不需要头指针的。相反,它需要增加指向栈顶的 top 指针,这是压栈和出栈操作的重要支持。</p>
|
||||
<p><img src="assets/CgqCHl7Uy3aANCZjAABKDxPgTUQ478.png" alt="6.png" /></p>
|
||||
<p><img src="assets/CgqCHl7Uy3aANCZjAABKDxPgTUQ478.png" alt="png" /></p>
|
||||
<p>对于链栈,新增数据的压栈操作,与链表最后插入的新数据基本相同。需要额外处理的,就是栈的 top 指针。如下图所示,插入新的数据,则需要让新的结点指向原栈顶,即 top 指针指向的对象,再让 top 指针指向新的结点。</p>
|
||||
<p><img src="assets/CgqCHl7Uy4iAUXORAACjOoEAXFA016.png" alt="7.png" /></p>
|
||||
<p><img src="assets/CgqCHl7Uy4iAUXORAACjOoEAXFA016.png" alt="png" /></p>
|
||||
<p>在链式栈中进行删除操作时,只能在栈顶进行操作。因此,将栈顶的 top 指针指向栈顶元素的 next 指针即可完成删除。对于链式栈来说,新增删除数据元素没有任何循环操作,其时间复杂度均为 O(1)。</p>
|
||||
<p>对于查找操作,相对链表而言,链栈没有额外的改变,它也需要遍历整个栈来完成基于某些条件的数值查找。</p>
|
||||
<p>通过分析你会发现,不管是顺序栈还是链栈,数据的新增、删除、查找与线性表的操作原理极为相似,时间复杂度完全一样,都依赖当前位置的指针来进行数据对象的操作。区别仅仅在于新增和删除的对象,只能是栈顶的数据结点。</p>
|
||||
@@ -216,7 +216,7 @@ function hide_canvas() {
|
||||
<p>例 1,给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。有效字符串需满足:左括号必须与相同类型的右括号匹配,左括号必须以正确的顺序匹配。例如,{ [ ( ) ( ) ] } 是合法的,而 { ( [ ) ] } 是非法的。</p>
|
||||
<p>这个问题很显然是栈发挥价值的地方。原因是,在匹配括号是否合法时,左括号是从左到右依次出现,而右括号则需要按照“后进先出”的顺序依次与左括号匹配。因此,实现方案就是通过栈的进出来完成。</p>
|
||||
<p>具体为,从左到右顺序遍历字符串。当出现左括号时,压栈。当出现右括号时,出栈。并且判断当前右括号,和被出栈的左括号是否是互相匹配的一对。如果不是,则字符串非法。当遍历完成之后,如果栈为空。则合法。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F7Uy5WAaANiAAslJ0QN4bc832.gif" alt="8.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7Uy5WAaANiAAslJ0QN4bc832.gif" alt="png" /></p>
|
||||
<p>代码如下:</p>
|
||||
<pre><code>public static void main(String[] args) {
|
||||
String s = "{[()()]}";
|
||||
@@ -262,7 +262,7 @@ private static String isLegal(String s) {
|
||||
<p>例 2,浏览器的页面访问都包含了后退和前进功能,利用栈如何实现?</p>
|
||||
<p>我们利用浏览器上网时,都会高频使用后退和前进的功能。比如,你按照顺序先后访问了 5 个页面,分别标记为 1、2、3、4、5。现在你不确定网页 5 是不是你要看的网页,需要回退到网页 3,则需要使用到两次后退的功能。假设回退后,你发现网页 4 有你需要的信息,那么就还需要再执行一次前进的操作。</p>
|
||||
<p>为了支持前进、后退的功能,利用栈来记录用户历史访问网页的顺序信息是一个不错的选择。此时需要维护两个栈,分别用来支持后退和前进。当用户访问了一个新的页面,则对后退栈进行压栈操作。当用户后退了一个页面,则后退栈进行出栈,同时前进栈执行压栈。当用户前进了一个页面,则前进栈出栈,同时后退栈压栈。我们以用户按照 1、2、3、4、5、4、3、4 的浏览顺序为例,两个栈的数据存储过程,如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl7Uy5-ANiGoABFtWM1_uZU348.gif" alt="9.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7Uy5-ANiGoABFtWM1_uZU348.gif" alt="png" /></p>
|
||||
<h3>总结</h3>
|
||||
<p>好的,这节课的内容就到这里了。这一节的内容主要围绕栈的原理、栈对于数据的增删查操作展开。</p>
|
||||
<p>栈继承了线性表的优点与不足,是个限制版的线性表。限制的功能是,只允许数据从栈顶进出,这也就是栈后进先出的性质。不管是顺序栈还是链式栈,它们对于数据的新增操作和删除操作的时间复杂度都是 O(1)。而在查找操作中,栈和线性表一样只能通过全局遍历的方式进行,也就是需要 O(n) 的时间复杂度。</p>
|
||||
|
||||
@@ -235,7 +235,7 @@ function hide_canvas() {
|
||||
<p>当链式队列进行删除数据操作时,实际删除的是头结点的后继结点。这是因为头结点仅仅用来标识队列,并不存储数据。因此,出队列的操作,就需要找到头结点的后继,这就是要删除的结点。接着,让头结点指向要删除结点的后继。</p>
|
||||
<p>特别值得一提的是,如果这个链表除去头结点外只剩一个元素,那么删除仅剩的一个元素后,rear 指针就变成野指针了。这时候,需要让 rear 指针指向头结点。也许你前面会对头结点存在的意义产生怀疑,似乎没有它也不影响增删的操作。那么为何队列还特被强调要有头结点呢?</p>
|
||||
<p>这主要是为了防止删除最后一个有效数据结点后, front 指针和 rear 指针变成野指针,导致队列没有意义了。有了头结点后,哪怕队列为空,头结点依然存在,能让 front 指针和 rear 指针依然有意义。</p>
|
||||
<p><img src="assets/Ciqc1F74TT6AKxhrAADpi9uXKjg928.png" alt="001.png" /></p>
|
||||
<p><img src="assets/Ciqc1F74TT6AKxhrAADpi9uXKjg928.png" alt="png" /></p>
|
||||
<p>对于队列的查找操作,不管是顺序还是链式,队列都没有额外的改变。跟线性表一样,它也需要遍历整个队列来完成基于某些条件的数值查找。因此时间复杂度也是 O(n)。</p>
|
||||
<h3>队列的案例</h3>
|
||||
<p>我们来看一个关于用队列解决约瑟夫环问题。约瑟夫环是一个数学的应用问题,具体为,已知 n 个人(以编号 1,2,3...n 分别表示)围坐在一张圆桌周围。从编号为 k 的人开始报数,数到 m 的那个人出列;他的下一个人又从 1 开始报数,数到 m 的那个人又出列;依此规律重复下去,直到圆桌周围的人全部出列。这个问题的输入变量就是 n 和 m,即 n 个人和数到 m 的出列的人。输出的结果,就是 n 个人出列的顺序。</p>
|
||||
|
||||
@@ -203,7 +203,7 @@ function hide_canvas() {
|
||||
<li>字符串的顺序存储结构,是用一组地址连续的存储单元来存储串中的字符序列,一般是用定长数组来实现。有些语言会在串值后面加一个不计入串长度的结束标记符,比如 \0 来表示串值的终结。</li>
|
||||
<li>字符串的链式存储结构,与线性表是相似的,但由于串结构的特殊性(结构中的每个元素数据都是一个字符),如果也简单地将每个链结点存储为一个字符,就会造成很大的空间浪费。因此,一个结点可以考虑存放多个字符,如果最后一个结点未被占满时,可以使用 "#" 或其他非串值字符补全,如下图所示:</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F7gvwmAeOuQAACbWbwi7hs491.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F7gvwmAeOuQAACbWbwi7hs491.png" alt="png" /></p>
|
||||
<p>在链式存储中,每个结点设置字符数量的多少,与串的长度、可以占用的存储空间以及程序实现的功能相关。</p>
|
||||
<ul>
|
||||
<li>如果字符串中包含的数据量很大,但是可用的存储空间有限,那么就需要提高空间利用率,相应地减少结点数量。</li>
|
||||
@@ -233,7 +233,7 @@ function hide_canvas() {
|
||||
<li>如下图所示,s 的第1 个字符和 t 的第 1 个字符相等,则开始匹配后续。直到发现前三个字母都匹配成功,但 s 的第 4 个字母匹配失败,则回到主串继续寻找和 t 的第一个字符相等的字符。</li>
|
||||
<li>如下图所示,这时我们发现主串 s 第 5 位开始相等,并且随后的 6 个字母全匹配成功,则找到结果。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F7h-hmAFsw0ADCjkl8SW7M434.gif" alt="Lark20200611-171750.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7h-hmAFsw0ADCjkl8SW7M434.gif" alt="png" /></p>
|
||||
<p>这种匹配算法需要从主串中找到跟模式串的第 1 个字符相等的位置,然后再去匹配后续字符是否与模式串相等。显然,从实现的角度来看,需要两层的循环。第一层循环,去查找第一个字符相等的位置,第二层循环基于此去匹配后续字符是否相等。因此,这种匹配算法的时间复杂度为 O(nm)。其代码如下:</p>
|
||||
<pre><code>public void s1() {
|
||||
String s = "goodgoogle";
|
||||
|
||||
@@ -185,7 +185,7 @@ function hide_canvas() {
|
||||
<p>前面课时我们学习了线性表、栈、队列和数组。栈、队列都是特殊的线性表,数组可以看成是线性表的一种推广。根据学习,我们知道了这几种数据结构,在对数据的增删查操作上各有千秋。这一课时再来学习另一种从形式上看上去差异比较大的数据结构,树,以及如何用树和二叉树实现对数据的增删查的操作。</p>
|
||||
<h3>树是什么</h3>
|
||||
<p>树是由结点和边组成的,不存在环的一种数据结构。通过下图,我们就可以更直观的认识树的结构。</p>
|
||||
<p><img src="assets/CgqCHl7nVdOACaCRAAFIFEOq3NE138.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl7nVdOACaCRAAFIFEOq3NE138.png" alt="png" /></p>
|
||||
<p>树满足递归定义的特性。也就是说,如果一个数据结构是树结构,那么剔除掉根结点后,得到的若干个子结构也是树,通常称作子树。</p>
|
||||
<p>在一棵树中,根据结点之间层次关系的不同,对结点的称呼也有所不同。我们来看下面这棵树,如下图所示:</p>
|
||||
<ul>
|
||||
@@ -194,9 +194,9 @@ function hide_canvas() {
|
||||
<li>A 没有父结点,则可以称 A 为根结点。</li>
|
||||
<li>G、H、I、F 结点都没有子结点,则称 G、H、I、F 为叶子结点。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F7nVeCAYb0BAAChbrfNgQQ166.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F7nVeCAYb0BAAChbrfNgQQ166.png" alt="png" /></p>
|
||||
<p>当有了一棵树之后,还需要用深度、层来描述这棵树中结点的位置。结点的层次从根结点算起,根为第一层,根的“孩子”为第二层,根的“孩子”的“孩子”为第三层,依此类推。树中结点的最大层次数,就是这棵树的树深(称为深度,也称为高度)。如下图所示,就是一棵深度为 4 的树。</p>
|
||||
<p><img src="assets/Ciqc1F7nVfiAHZTqAAC7ANRZP1Q581.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F7nVfiAHZTqAAC7ANRZP1Q581.png" alt="png" /></p>
|
||||
<h3>二叉树是什么</h3>
|
||||
<p>在树的大家族中,有一种被高频使用的特殊树,它就是二叉树。在二叉树中,每个结点最多有两个分支,即每个结点最多有两个子结点,分别称作左子结点和右子结点。</p>
|
||||
<p>在二叉树中,有下面两个特殊的类型,如下图所示:</p>
|
||||
@@ -204,20 +204,20 @@ function hide_canvas() {
|
||||
<li>满二叉树,定义为除了叶子结点外,所有结点都有 2 个子结点。</li>
|
||||
<li>完全二叉树,定义为除了最后一层以外,其他层的结点个数都达到最大,并且最后一层的叶子结点都靠左排列。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F7nVgiAaAzDAACeT1A4his243.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F7nVgiAaAzDAACeT1A4his243.png" alt="png" /></p>
|
||||
<p>你可能会困惑,完全二叉树看上去并不完全,但为什么这样称呼它呢?这其实和二叉树的存储有关系。存储二叉树有两种办法,一种是基于指针的链式存储法,另一种是基于数组的顺序存储法。</p>
|
||||
<ul>
|
||||
<li>链式存储法,也就是像链表一样,每个结点有三个字段,一个存储数据,另外两个分别存放指向左右子结点的指针,如下图所示:</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl7nVhKAJVYKAABbMx2OS5o954.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl7nVhKAJVYKAABbMx2OS5o954.png" alt="png" /></p>
|
||||
<ul>
|
||||
<li>顺序存储法,就是按照规律把结点存放在数组里,如下图所示,为了方便计算,我们会约定把根结点放在下标为 1 的位置。随后,B 结点存放在下标为 2 的位置,C 结点存放在下标为 3 的位置,依次类推。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl7nVhyAF-yqAAFEIfF2-z4697.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl7nVhyAF-yqAAFEIfF2-z4697.png" alt="png" /></p>
|
||||
<p>根据这种存储方法,我们可以发现如果结点 X 的下标为 i,那么 X 的左子结点总是存放在 2 * i 的位置,X 的右子结点总是存放在 2 * i + 1 的位置。</p>
|
||||
<p>之所以称为完全二叉树,是从存储空间利用效率的视角来看的。对于一棵完全二叉树而言,仅仅浪费了下标为 0 的存储位置。而如果是一棵非完全二叉树,则会浪费大量的存储空间。</p>
|
||||
<p>我们来看如下图所示的非完全二叉树,它既需要保留出 5 和 6 的位置。同时,还需要保留 5 的两个子结点 10 和 11 的位置,以及 6 的两个子结点 12 和 13 的位置。这样的二叉树,没有完全利用好数组的存储空间。</p>
|
||||
<p><img src="assets/Ciqc1F7nVi2AVfUZAAFA7ZImLgI310.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F7nVi2AVfUZAAFA7ZImLgI310.png" alt="png" /></p>
|
||||
<h3>树的基本操作</h3>
|
||||
<p>接下来,我们以二叉树为例介绍树的操作,其他类型的树的操作与二叉树基本相似。</p>
|
||||
<p>可以发现,我们以前学到的数据结构都是“一对一”的关系,即前面的数据只跟下面的一个数据产生了连接关系,例如链表、栈、队列等。而树结构则是“一对多”的关系,即前面的父结点,跟下面若干个子结点产生了连接关系。</p>
|
||||
@@ -228,7 +228,7 @@ function hide_canvas() {
|
||||
<li>中序遍历,对树中的任意结点来说,先中序遍历它的左子树,然后打印这个结点,最后中序遍历它的右子树。</li>
|
||||
<li>后序遍历,对树中的任意结点来说,先后序遍历它的左子树,然后后序遍历它的右子树,最后打印它本身。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F7nVj-AAdDtAAELYCm71vU805.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F7nVj-AAdDtAAELYCm71vU805.png" alt="png" /></p>
|
||||
<p>通过前面的介绍,相信你已经了解了二叉树的三种遍历方式,下面我们再来分析一下代码的实现过程,如下所示:</p>
|
||||
<pre><code>// 先序遍历
|
||||
public static void preOrderTraverse(Node node) {
|
||||
@@ -265,7 +265,7 @@ public static void postOrderTraverse(Node node) {
|
||||
<li>在二叉查找树中,会尽可能规避两个结点数值相等的情况。</li>
|
||||
<li>对二叉查找树进行中序遍历,就可以输出一个从小到大的有序数据队列。如下图所示,中序遍历的结果就是 10、13、15、16、20、21、22、26。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl7nVlCAP5SrAACStyOKMQk846.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl7nVlCAP5SrAACStyOKMQk846.png" alt="png" /></p>
|
||||
<h4>二叉查找树的查找操作</h4>
|
||||
<p>在利用二叉查找树执行查找操作时,我们可以进行以下判断:</p>
|
||||
<ul>
|
||||
@@ -283,31 +283,31 @@ public static void postOrderTraverse(Node node) {
|
||||
<li>由于 14 小于 15,则聚焦在其左子树。</li>
|
||||
</ul>
|
||||
<p>因为此时左子树为空,则直接通过指针建立 15 结点的左指针指向结点 X 的关系,就完成了插入动作。</p>
|
||||
<p><img src="assets/CgqCHl7nVl2AGCqGAAXB0pVx-_0832.gif" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl7nVl2AGCqGAAXB0pVx-_0832.gif" alt="png" /></p>
|
||||
<p>二叉查找树插入数据的时间复杂度是 O(logn)。但这并不意味着它比普通二叉树要复杂。原因在于这里的时间复杂度更多是消耗在了遍历数据去找到查找位置上,真正执行插入动作的时间复杂度仍然是 O(1)。</p>
|
||||
<p>二叉查找树的删除操作会比较复杂,这是因为删除完某个结点后的树,仍然要满足二叉查找树的性质。我们分为下面三种情况讨论。</p>
|
||||
<ul>
|
||||
<li>情况一,如果要删除的结点是某个叶子结点,则直接删除,将其父结点指针指向 null 即可。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl7nVm-AdApcAAgmVCpx8jY016.gif" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl7nVm-AdApcAAgmVCpx8jY016.gif" alt="png" /></p>
|
||||
<ul>
|
||||
<li>情况二,如果要删除的结点只有一个子结点,只需要将其父结点指向的子结点的指针换成其子结点的指针即可。</li>
|
||||
</ul>
|
||||
<p><img src="assets/CgqCHl7nVn6ACTNEAAmR8p1hP4E398.gif" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl7nVn6ACTNEAAmR8p1hP4E398.gif" alt="png" /></p>
|
||||
<ul>
|
||||
<li>情况三,如果要删除的结点有两个子结点,则有两种可行的操作方式。</li>
|
||||
</ul>
|
||||
<p>第一种,找到这个结点的左子树中最大的结点,替换要删除的结点。</p>
|
||||
<p><img src="assets/Ciqc1F7nVpCAYOHzAA5XF5kRkGM004.gif" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F7nVpCAYOHzAA5XF5kRkGM004.gif" alt="png" /></p>
|
||||
<p>第二种,找到这个结点的右子树中最小的结点,替换要删除的结点。</p>
|
||||
<p><img src="assets/CgqCHl7oQIGAYpwKABBpD6_zh_c805.gif" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl7oQIGAYpwKABBpD6_zh_c805.gif" alt="png" /></p>
|
||||
<p><strong>树的案例</strong></p>
|
||||
<p><strong>我们来看一道例题:</strong></p>
|
||||
<p>输入一个字符串,判断它在已有的字符串集合中是否出现过?(假设集合中没有某个字符串与另一个字符串拥有共同前缀且完全包含的特殊情况,例如 deep 和 dee。)如,已有字符串集合包含 6 个字符串分别为,cat, car, city, dog,door, deep。输入 cat,输出 true;输入 home,输出 false。</p>
|
||||
<p>我们假设采用最暴力的办法,估算一下时间复杂度。假设字符串集合包含了 n 个字符串,其中的字符串平均长度为 m。那么新来的一个字符串,需要与每个字符串的每个字符进行匹配。则时间复杂度为 O(nm)。</p>
|
||||
<p>但在 nm 的复杂度中,显然存在很多的无效匹配。例如,输入 home 时,6 个字符串都没有 h 开头的,则不需要进行后续的匹配。因此,如果可以通过对字符前缀进行处理,就可以最大限度地减少无谓的字符串比较,从而提高查询效率。这就是“用空间换时间”的思想,再利用共同前缀来提高查询效率。</p>
|
||||
<p>其实,这个问题利用树结构也可以完成。我们对字符串建立一个的树结构,如下图所示,它将字符串集合的前缀进行合并,每个根结点到叶子结点的链条就是一个字符串。</p>
|
||||
<p><img src="assets/CgqCHl7nVuSASW8lAADCDPk2Zv0987.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl7nVuSASW8lAADCDPk2Zv0987.png" alt="png" /></p>
|
||||
<p>这个树结构也称作 Trie 树,或字典树。它具有三个特点:</p>
|
||||
<ul>
|
||||
<li>第一,根结点不包含字符;</li>
|
||||
@@ -319,13 +319,13 @@ public static void postOrderTraverse(Node node) {
|
||||
<li>第一步,根据候选字符串集合,建立字典树。这需要使用数据插入的动作。</li>
|
||||
<li>第二步,对于一个输入字符串,判断它能否在这个树结构中走到叶子结点。如果能,则出现过。</li>
|
||||
</ul>
|
||||
<p><img src="assets/Ciqc1F7nWKeAJpLCABmfZlb-Jaw490.gif" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F7nWKeAJpLCABmfZlb-Jaw490.gif" alt="png" /></p>
|
||||
<h3>总结</h3>
|
||||
<p>本课时的内容围绕着不同种类树的原理、二叉树对于数据的增删查操作展开。要想利用二叉树实现增删查操作,你需要熟练掌握二叉树的三种遍历方式。遍历的时间复杂度是 O(n)。有了遍历方式之后,你可以完成在指定位置的数据增删操作。增删操作的时间复杂度都是 O(1)。</p>
|
||||
<p>对于查找操作,如果是普通二叉树,则查找的时间复杂度和遍历一样,都是 O(n)。如果是二叉查找树,则可以在 O(logn) 的时间复杂度内完成查找动作。树结构在存在“一对多”的数据关系中,可被高频使用,这也是它区别于链表系列数据结构的关键点。</p>
|
||||
<h3>练习题</h3>
|
||||
<p>关于树结构,我们留一道习题。给定一棵树,按照层次顺序遍历并打印这棵树。例如:</p>
|
||||
<p><img src="assets/Ciqc1F7nWLqAXKf2AACTorL2-YQ429.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F7nWLqAXKf2AACTorL2-YQ429.png" alt="png" /></p>
|
||||
<p>则打印 16、13、20、10、15、22、21、26。请注意,这并不是前序遍历。</p>
|
||||
<p>练习题代码如下:</p>
|
||||
<pre><code>public static void levelTraverse(Node root) {
|
||||
|
||||
@@ -242,13 +242,13 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<p>即当一个关键字和另一个关键字发生冲突时,使用某种探测技术在哈希表中形成一个探测序列,然后沿着这个探测序列依次查找下去。当碰到一个空的单元时,则插入其中。</p>
|
||||
<p><strong>常用的探测方法是线性探测法。</strong> 比如有一组关键字 {12,13,25,23},采用的哈希函数为 key mod 11。当插入 12,13,25 时可以直接插入,地址分别为 1、2、3。而当插入 23 时,哈希地址为 23 mod 11 = 1。然而,地址 1 已经被占用,因此沿着地址 1 依次往下探测,直到探测到地址 4,发现为空,则将 23 插入其中。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F7p9paAVH4IADEh_1KYIxQ882.gif" alt="1.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7p9paAVH4IADEh_1KYIxQ882.gif" alt="png" /></p>
|
||||
<ul>
|
||||
<li>第二,链地址法</li>
|
||||
</ul>
|
||||
<p>将哈希地址相同的记录存储在一张线性链表中。</p>
|
||||
<p>例如,有一组关键字 {12,13,25,23,38,84,6,91,34},采用的哈希函数为 key mod 11。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl7p9riAPdv4ACylkQqBCr0627.gif" alt="2.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7p9riAPdv4ACylkQqBCr0627.gif" alt="png" /></p>
|
||||
<p>哈希表相对于其他数据结构有很多的优势。它可以提供非常快速的插入-删除-查找操作,无论多少数据,插入和删除值需要接近常量的时间。在查找方面,哈希表的速度比树还要快,基本可以瞬间查找到想要的元素。</p>
|
||||
<p>哈希表也有一些不足。哈希表中的数据是没有顺序概念的,所以不能以一种固定的方式(比如从小到大)来遍历其中的元素。在数据处理顺序敏感的问题时,选择哈希表并不是个好的处理方法。同时,哈希表中的 key 是不允许重复的,在重复性非常高的数据中,哈希表也不是个好的选择。</p>
|
||||
<h3>哈希表的基本操作</h3>
|
||||
@@ -274,9 +274,9 @@ function hide_canvas() {
|
||||
<p>H (9) = 6</p>
|
||||
<p>H (14) = 0</p>
|
||||
<p><strong>按关键字序列顺序依次向哈希表中填入,发生冲突后按照“线性探测”探测到第一个空位置填入。</strong></p>
|
||||
<p><img src="assets/CgqCHl7p9vKAZm8sABD1_Vye6xM491.gif" alt="3.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7p9vKAZm8sABD1_Vye6xM491.gif" alt="png" /></p>
|
||||
<p><strong>最终的插入结果如下表所示:</strong></p>
|
||||
<p><img src="assets/CgqCHl8IM5KADjjgAABYugcwKiI662.png" alt="Lark20200710-172310.png" /></p>
|
||||
<p><img src="assets/CgqCHl8IM5KADjjgAABYugcwKiI662.png" alt="png" /></p>
|
||||
<p><strong>接着,有了这个表之后,我们再来看一下查找的流程:</strong></p>
|
||||
<ul>
|
||||
<li>查找 7。输入 7,计算得到 H (7) = 0,根据哈希表,在 0 的位置,得到结果为 7,跟待匹配的关键字一样,则完成查找。</li>
|
||||
|
||||
@@ -214,11 +214,11 @@ function hide_canvas() {
|
||||
</ol>
|
||||
<p>在我们讲述树结构时,曾经用过递归去实现树的遍历。接下来,我们围绕中序遍历,再来看看递归在其中的作用。</p>
|
||||
<p>对树中的任意结点来说,先中序遍历它的左子树,然后打印这个结点,最后中序遍历它的右子树。可见,中序遍历是这样的一个问题,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F7wi5-AQ7X-AACey5P-Rqo687.png" alt="1.png" /></p>
|
||||
<p><img src="assets/Ciqc1F7wi5-AQ7X-AACey5P-Rqo687.png" alt="png" /></p>
|
||||
<p>当某个结点没有左子树和右子树时,则直接打印结点,完成终止。由此可见,树的中序遍历完全满足递归的两个条件,因此可以通过递归实现。例如下面这棵树:</p>
|
||||
<p><img src="assets/Ciqc1F7wi62AHGyNAACGyrE1oy4433.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F7wi62AHGyNAACGyrE1oy4433.png" alt="png" /></p>
|
||||
<p>当采用递归实现中序遍历时,程序执行的逻辑架构如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl7wi8WAX8o8ACxp60OXat8318.gif" alt="3.gif" /></p>
|
||||
<p><img src="assets/CgqCHl7wi8WAX8o8ACxp60OXat8318.gif" alt="png" /></p>
|
||||
<p>其中,每个蓝色的括号都是一次递归调用。代码如下所示:</p>
|
||||
<pre><code>// 中序遍历
|
||||
public static void inOrderTraverse(Node node) {
|
||||
@@ -235,7 +235,7 @@ public static void inOrderTraverse(Node node) {
|
||||
<p>下面我们通过一个古老而又经典的汉诺塔问题,帮助你理解复杂的递归问题。</p>
|
||||
<p>汉诺塔问题是源于印度一个古老传说的益智玩具。大梵天创造世界的时候做了三根金刚石柱子,在一根柱子上从下往上按照大小顺序摞着 64 片黄金圆盘。大梵天命令婆罗门把圆盘从下面开始按大小顺序重新摆放在另一根柱子上,并且规定,在小圆盘上不能放大圆盘,在三根柱子之间一次只能移动一个圆盘。</p>
|
||||
<p>我们可以把这个问题抽象为一个数学问题。如下图所示,从左到右有 x、y、z 三根柱子,其中 x 柱子上面有从小叠到大的 n 个圆盘。现要求将 x 柱子上的圆盘移到 z 柱子上去。要求是,每次只能移动一个盘子,且大盘子不能被放在小盘子上面。求移动的步骤。</p>
|
||||
<p><img src="assets/CgqCHl7wi--AaoWAAABKD6oIV5c850.png" alt="4.png" /></p>
|
||||
<p><img src="assets/CgqCHl7wi--AaoWAAABKD6oIV5c850.png" alt="png" /></p>
|
||||
<p>我们来分析一下这个问题。这是一个大规模的复杂问题,如果要采用递归方法去解决的话,就要先把问题化简。</p>
|
||||
<p>我们的原问题是,把从小到大的 n 个盘子,从 x 移动到 z。</p>
|
||||
<p>我们可以将这个大问题拆解为以下 3 个小问题:</p>
|
||||
@@ -245,7 +245,7 @@ public static void inOrderTraverse(Node node) {
|
||||
<li>再把从小到大的 n-1 个盘子,从 y 移动到 z。</li>
|
||||
</ol>
|
||||
<p><strong>首先,我们来判断它是否满足递归的第一个条件。</strong> 其中,第 1 和第 3 个问题就是汉诺塔问题。这样我们就完成了一次把大问题缩小为完全一样的小规模问题。我们已经定义好了递归体,也就是满足来递归的第一个条件。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F7wjAuAJ7yrAAzAObiXQfs227.gif" alt="5.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7wjAuAJ7yrAAzAObiXQfs227.gif" alt="png" /></p>
|
||||
<p><strong>接下来我们来看判断它是否满足终止条件</strong>。随着递归体不断缩小范围,汉诺塔问题由原来“移动从小到大的 n 个盘子”,缩小为“移动从小到大的 n-1 个盘子”,直到缩小为“移动从小到大的 1 个盘子”。移动从小到大的 1 个盘子,就是移动最小的那个盘子。根据规则可以发现,最小的盘子是可以自由移动的。因此,递归的第二个条件,终止条件,也是满足的。</p>
|
||||
<p>经过仔细分析可见,汉诺塔问题是完全可以用递归实现的。我们定义汉诺塔的递归函数为 hanio()。这个函数的输入参数包括了:</p>
|
||||
<ul>
|
||||
@@ -281,7 +281,7 @@ public void hanio(int n, String x, String y, String z) {
|
||||
<p>在主函数中,执行了 hanio(3, "x", "y", "z")。我们发现 3 比 1 要大,则进入递归体。分别先后执行了 hanio(2, "x", "z", "y")、"移动: x->z"、hanio(2, "y", "x", "z")。</p>
|
||||
<p>其中的 hanio(2, "x", "z", "y"),又先后执行了 hanio(1, "x", "y", "z")、"移动: x->y"、hanio(1, "z", "x", "y")。在这里,hanio(1, "x", "y", "z") 的执行结果是 "移动: x->z",hanio(1, "z", "x", "y")的执行结果是"移动: z->y"。</p>
|
||||
<p>另一边,hanio(2, "y", "x", "z") 则要先后执行 hanio(1, "y", "z", "x")、"移动: y->z"、hanio(1, "x", "y", "z")。在这里,hanio(1, "y", "z", "x") 的执行结果是"移动: y->x",hanio(1, "x", "y", "z") 的执行结果是 "移动: x->z"。</p>
|
||||
<p><img src="assets/Ciqc1F7wjD6AHleLAAmzm2nvvmw746.gif" alt="6.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7wjD6AHleLAAmzm2nvvmw746.gif" alt="png" /></p>
|
||||
<p><strong>最终梳理一下,代码执行的结果就是:</strong></p>
|
||||
<p>移动: x->z</p>
|
||||
<p>移动: x->y</p>
|
||||
|
||||
@@ -229,7 +229,7 @@ function hide_canvas() {
|
||||
<h3>分治法的案例</h3>
|
||||
<p>下面我们一起来看一个例子。<strong>在数组 { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 } 中,查找 8 是否出现过。</strong></p>
|
||||
<p>首先判断 8 和中位数 5 的大小关系。因为 8 更大,所以在更小的范围 6, 7, 8, 9, 10 中继续查找。此时更小的范围的中位数是 8。由于 8 等于中位数 8,所以查找到并打印查找到的 8 对应在数组中的 index 值。如下图所示。</p>
|
||||
<p><img src="assets/Ciqc1F7zEOSAElX7ABXXgmxI808203.gif" alt="Lark20200624-163712.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F7zEOSAElX7ABXXgmxI808203.gif" alt="png" /></p>
|
||||
<p>从代码实现的角度来看,我们可以采用两个索引 low 和 high,确定查找范围。最初 low 为 0,high 为数组长度减 1。在一个循环体内,判断 low 到 high 的中位数与目标变量 targetNumb 的大小关系。根据结果确定向左走(high = middle - 1)或者向右走(low = middle + 1),来调整 low 和 high 的值。直到 low 反而比 high 更大时,说明查找不到并跳出循环。我们给出代码如下:</p>
|
||||
<pre><code>public static void main(String[] args) {
|
||||
// 需要查找的数字
|
||||
|
||||
@@ -197,7 +197,7 @@ function hide_canvas() {
|
||||
<p>动态规划是候选人参加面试的噩梦,也是面试过程中的难点。虽然动态规划很难,但在实际的工作中,使用频率并不高,不是所有的岗位都会用到动态规划。</p>
|
||||
<h4>最短路径问题</h4>
|
||||
<p>接下来。<strong>我们来看一个非常典型的例子,最短路径问题</strong>。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F78bdmAGdktAADnlpYQrHk607.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F78bdmAGdktAADnlpYQrHk607.png" alt="png" /></p>
|
||||
<p>每个结点是一个位置,每条边是两个位置之间的距离。现在需要求解出一条由 A 到 G 的最短距离是多少。</p>
|
||||
<p>不难发现,我们需要求解的路线是由 A 到 G,这就意味着 A 要先到 B,再到 C,再到 D,再到 E,再到 F。每一轮都需要做不同的决策,而每次的决策又依赖上一轮决策的结果。</p>
|
||||
<p>例如,做 D2 -> E 的决策时,D2 -> E2 的距离为 1,最短。但这轮的决策,基于的假设是从 D2 出发,这就意味着前面一轮的决策结果是 D2。由此可见,相邻两轮的决策结果并不是独立的。</p>
|
||||
@@ -240,7 +240,7 @@ function hide_canvas() {
|
||||
<p>在这里,就是 <em>s**k</em>+1 = <em>u**k</em>(<em>s**k</em>)。</p>
|
||||
<h5>5. <strong>定目标</strong></h5>
|
||||
<p>别忘了,我们的目标是总距离最短。我们定义 <em>d**k</em>(<em>s**k</em>,<em>u**k</em>) 是在 sk 时,选择 uk 动作的距离。例如,<em>d</em>5(<em>E</em>1,<em>F</em>1) = 3。那么此时 n = 7,则有,</p>
|
||||
<p><img src="assets/CgqCHl78bqSAQBWuAAAmIGYXrUs078.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl78bqSAQBWuAAAmIGYXrUs078.png" alt="png" /></p>
|
||||
<p>就是最终要优化的目标。</p>
|
||||
<h5>6. <strong>寻找终止条件</strong></h5>
|
||||
<ul>
|
||||
@@ -253,20 +253,20 @@ function hide_canvas() {
|
||||
</ul>
|
||||
<h4>计算过程详解</h4>
|
||||
<p>好了,为了让大家清晰地看到结果,我们给出详细的计算过程。为了书写简单,<strong>我们把函数 Vk,7(s1=A, s7=G) 精简为 V7(G),含义为经过了 6 轮决策后,状态到达 G 后所使用的距离</strong>。我们把图片复制到这里一份,方便大家不用上下切换。</p>
|
||||
<p><img src="assets/CgqCHl78bpKAF2FWAADnlpYQrHk836.png" alt="image" /></p>
|
||||
<p><img src="assets/CgqCHl78bpKAF2FWAADnlpYQrHk836.png" alt="png" /></p>
|
||||
<p><strong>我们的优化目标为 min Vk,7(s1=A, s7=G),因此精简后原问题为,min V7(G)</strong>。</p>
|
||||
<p><img src="assets/Ciqc1F78bvCAD2QkAABAo0Sezlc723.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F79TfyAEbKKAAB2PY0Lb5U909.png" alt="5.png" /></p>
|
||||
<p><img src="assets/Ciqc1F78bx2AO3WTAACB1LuxHEo059.png" alt="imag" /></p>
|
||||
<p><img src="assets/Ciqc1F78bySAdLa-AACOk2cGokg643.png" alt="iage" /></p>
|
||||
<p><img src="assets/CgqCHl79TgmAfHtMAACROQbL6JE078.png" alt="2.png" /></p>
|
||||
<p><img src="assets/CgqCHl78bzKAQTrCAABoEJ4y5UM123.png" alt="imag" /></p>
|
||||
<p><img src="assets/Ciqc1F78bvCAD2QkAABAo0Sezlc723.png" alt="png" /></p>
|
||||
<p><img src="assets/Ciqc1F79TfyAEbKKAAB2PY0Lb5U909.png" alt="png" /></p>
|
||||
<p><img src="assets/Ciqc1F78bx2AO3WTAACB1LuxHEo059.png" alt="png" /></p>
|
||||
<p><img src="assets/Ciqc1F78bySAdLa-AACOk2cGokg643.png" alt="png" /></p>
|
||||
<p><img src="assets/CgqCHl79TgmAfHtMAACROQbL6JE078.png" alt="png" /></p>
|
||||
<p><img src="assets/CgqCHl78bzKAQTrCAABoEJ4y5UM123.png" alt="png" /></p>
|
||||
<p>因此,<strong>最终输出路径为 A -> B1 -> C2 -> D1 -> E2 -> F2 -> G,最短距离为 18</strong>。</p>
|
||||
<h4>代码实现过程</h4>
|
||||
<p>接下来,我们尝试用代码来实现上面的计算过程。对于输入的图,可以采用一个 m x m 的二维数组来保存。在这个二维数组里,m 等于全部的结点数,也就是结点与结点的关系图。而数组每个元素的数值,定义为结点到结点需要的距离。</p>
|
||||
<p><img src="assets/Ciqc1F78bz2ATtl4AADnlpYQrHk384.png" alt="imae" /></p>
|
||||
<p><img src="assets/Ciqc1F78bz2ATtl4AADnlpYQrHk384.png" alt="png" /></p>
|
||||
<p>在本例中,可以定义输入矩阵 m(空白处为0),如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl78b0mALhRHAABQnqgjMYc406.png" alt="imae" /></p>
|
||||
<p><img src="assets/CgqCHl78b0mALhRHAABQnqgjMYc406.png" alt="png" /></p>
|
||||
<p>代码如下:</p>
|
||||
<pre><code>public class testpath {
|
||||
public static int minPath1(int[][] matrix) {
|
||||
|
||||
@@ -252,7 +252,7 @@ private static int fun(int n) {
|
||||
<p>在利用二分查找时,更多的是判断,基本没有数据的增删操作,因此不需要太多地定义复杂的数据结构。</p>
|
||||
<p>分析到这里,解决方案已经非常明朗了,就是采用二分查找的方法,在 O(logn) 的时间复杂度下去解决这个问题。二分查找可以通过递归来实现。<strong>而每次递归的关键点在于,根据切分的点(最中间的那个数字),确定是向左走还是向右走。这也是这个例题中唯一的难点了</strong>。</p>
|
||||
<p>试想一下,在一个旋转后的有序数组中,利用中间元素作为切分点得到的两个子数组有什么样的性质。经过枚举不难发现,这两个子数组中,一定存在一个数组是有序的。也可能出现一个极端情况,二者都是有序的。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8Fi6eAVyX3AAAnk9vJF3c337.png" alt="Drawing 0.png" /></p>
|
||||
<p><img src="assets/CgqCHl8Fi6eAVyX3AAAnk9vJF3c337.png" alt="png" /></p>
|
||||
<p>对于有序的一边,我们是很容易判断目标值,是否在这个区间内的。如果在其中,也说明了目标值不在另一边的旋转有序组里;反之亦然。</p>
|
||||
<p>当我们知道了目标值在左右哪边之后,就可以递归地调用旋转有序的二分查找了。之所以可以递归调用,是因为,对于旋转有序组,这个问题和原始问题完全一致,可以调用。对于有序组,它是旋转有序的特殊情况(即旋转 0 位),也一定是可以通过递归的方法去实现查找的。直到不断二分后,搜索空间只有 1 位数字,直接判断是否找到即可。</p>
|
||||
<ul>
|
||||
@@ -345,14 +345,14 @@ private static int bs(int[] arr, int target, int begin, int end) {
|
||||
<p>这段分析对于初学者来说会非常难懂,接下来我们给一个实现的流程来辅助你理解。</p>
|
||||
<p><strong>我们在最短路径问题中,曾重点提到的一个难点是,对于输入的图,采用什么样的数据结构予以保存。最终我们选择了二维数组</strong>。</p>
|
||||
<p>在这个例子中也可以采用二维数组。每一行或每一列就对应了输入字符串 a 和 b 的每个字符,即 6 x 8 的二维数组(矩阵)为:</p>
|
||||
<p><img src="assets/Ciqc1F8Fkz6APcxAAAAepje1Jv8882.png" alt="Drawing 2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8Fkz6APcxAAAAepje1Jv8882.png" alt="png" /></p>
|
||||
<p>接着,每个可能的起点字符,都应该同时出现在字符串 a 和 b 中,例如"1"就是一个可能的起点。如果以"1"作为起点,那么它后面的字符就是阶段,显然下个阶段就是 a[1] = 3 和 b[1] = 2。而此时的状态就是当前的公共子串,即 "1"。</p>
|
||||
<p>决策的结果是,下一个阶段是否进入到公共子串中。很显然 a[1] 不等于 b[1],因此决策的结果是不进入。这也同时命中了终止条件。如果以"3"起点,则因为它之后的 a[2] 等于 b[3],则决策结果是进入到公共子串。</p>
|
||||
<p>因此状态转移方程 sk+1 = uk(sk),含义是在"3"的状态下决策"4"进入子串,结果得到"34"。我们的目标是寻找最大的公共子串,因此可以用从 1 开始的数字定义距离(子串的长度)。具体步骤如下:</p>
|
||||
<p>对于每个可能的起点,距离都是 1 (不可能的起点置为 0,图中忽略未写)。则有:</p>
|
||||
<p><img src="assets/CgqCHl8Fk3OAO0i4AAAfVRzJAfw184.png" alt="Drawing 4.png" /></p>
|
||||
<p><img src="assets/CgqCHl8Fk3OAO0i4AAAfVRzJAfw184.png" alt="png" /></p>
|
||||
<p>接着利用状态转移方程,去寻找最优子结构。也就是,如果 b[i] = a[j],则 m[i,j] = m[i-1,j-1] + 1。含义为,如果决策结果是相等,则状态增加一个新的字符,进行更新。可以得到:</p>
|
||||
<p><img src="assets/CgqCHl8Fk32AEA7dAAAgSbzrdRM560.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl8Fk32AEA7dAAAgSbzrdRM560.png" alt="png" /></p>
|
||||
<p>最终,检索这个矩阵,得到的最大数字就是最大公共子串的长度。根据其所在的位置,就能从 a 或 b 中找到最大公共子串。</p>
|
||||
<p>代码如下:</p>
|
||||
<pre><code>public static void main(String[] args) {
|
||||
@@ -390,7 +390,7 @@ public static void getCommenStr(String a, String b) {
|
||||
<p>主函数中定义了字符串 a 和字符串 b,随后调用动态规划代码。</p>
|
||||
<p>进入 getCommenStr() 函数中之后,首先在第 10 行定义了二维数组。此时二维数组的维数是 7 x 9 的。这主要的原因是,后续会需要用到第一行和第一列的全零向量,作为起始条件。</p>
|
||||
<p>接着,在第 11~16 行,利用双重循环去完成状态转移的计算。此时就得到了最关键的矩阵,如下所示:</p>
|
||||
<p><img src="assets/CgqCHl8Fk32AEA7dAAAgSbzrdRM560.png" alt="Drawing 6.png" /></p>
|
||||
<p><img src="assets/CgqCHl8Fk32AEA7dAAAgSbzrdRM560.png" alt="png" /></p>
|
||||
<p>随后的 17~26 行,我们从矩阵 m 中,找到了最大值为 3,在字符串 b 中的索引值为 4(此时 index 为 5,但别忘了我们之前额外定义了一行/一列的全零向量)。</p>
|
||||
<p>最后,27~30 行,我们根据终点字符串索引值 4 和最大公共子串长度 3,就能找到最大公共子串在 b 中的 2~4 的位置。即 "345"。</p>
|
||||
<h3>总结</h3>
|
||||
|
||||
@@ -198,7 +198,7 @@ function hide_canvas() {
|
||||
<p><strong>接下来定位问题</strong>。我们可以看到它对数据的顺序非常敏感,敏感点一是每个单词需要保证顺序;敏感点二是所有单词放在一起的顺序需要调整为逆序。我们曾学过的关于数据顺序敏感的结构有队列和栈,也许这些结构可以适用在这个问题中。此处需要逆序,栈是有非常大的可能性被使用到的。</p>
|
||||
<p><strong>然后我们进行数据操作分析</strong>。如果要使用栈的话,从结果出发,就需要按照顺序,把 This、is、a、good、example 分别入栈。要想把它们正确地入栈,就需要根据空格来拆分原始字符串。</p>
|
||||
<p><strong>因此,经过分析后,这个例子的解法为:用空格把句子分割成单词。如果发现了多余的连续空格,需要做一些删除的额外处理。一边得到单词,一边把单词放入栈中。直到最后,再把单词从栈中倒出来,形成结果字符串</strong>。</p>
|
||||
<p><img src="assets/Ciqc1F8MP8yAS72oABGrGx_blwA588.gif" alt="1.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F8MP8yAS72oABGrGx_blwA588.gif" alt="png" /></p>
|
||||
<p><strong>最后,我们按照上面的思路进行编码开发</strong>。代码如下:</p>
|
||||
<pre><code>public static void main(String[] args) {
|
||||
String ss = "This is a good example";
|
||||
@@ -242,7 +242,7 @@ private static String reverseWords(String s) {
|
||||
<p><strong>这段代码采用了一层的 for 循环,显然它的时间复杂度是 O(n)。相比较于比较暴力的解法,它之所以降低了时间复杂度,就在于它开辟了栈的存储空间。所以空间复杂度也是 O(n)</strong>。</p>
|
||||
<h4>例题 2:树的层序遍历</h4>
|
||||
<p><strong>【题目】</strong> 给定一棵树,按照层次顺序遍历并打印这棵树。例如,输入的树为:</p>
|
||||
<p><img src="assets/CgqCHl8MP_WAERuIAACStyOKMQk754.png" alt="3.png" /></p>
|
||||
<p><img src="assets/CgqCHl8MP_WAERuIAACStyOKMQk754.png" alt="png" /></p>
|
||||
<p>则打印 16、13、20、10、15、22、21、26。格外需要注意的是,这并不是前序遍历。</p>
|
||||
<p><strong>【解析】</strong> 如果你一直在学习这门课的话,一定对这道题目似曾相识。它是我们在 09 课时中留下的练习题。同时它也是高频面试题。仔细分析下这个问题,不难发现它是一个关于树的遍历问题。理论上是可以在 O(n) 时间复杂度下完成访问的。</p>
|
||||
<p>以往我们学过的遍历方式有前序、中序和后序遍历,它们的实现方法都是通过递归。以前序遍历为例,递归可以理解为,先解决根结点,再解决左子树一边的问题,最后解决右子树的问题。这很像是在用深度优先的原则去遍历一棵树。</p>
|
||||
@@ -254,7 +254,7 @@ private static String reverseWords(String s) {
|
||||
<p>分析到这里,你应该能找到一些感觉了吧。一个结果序列对顺序敏感,而且没有逆序的操作,满足这些特点的数据结构只有队列。所以我们猜测这个问题的解决方案,极有可能要用到队列。</p>
|
||||
<p>队列只有入队列和出队列的操作。如果输出结果就是出队列的顺序,那这个顺序必然也是入队列的顺序,原因就在于队列的出入原则是先进先出。而入队列的原则是,上层父节点先进,左孩子再进,右孩子最后进。</p>
|
||||
<p><strong>因此,这道题目的解决方案就是,根结点入队列,随后循环执行结点出队列并打印结果,左孩子入队列,右孩子入队列。直到队列为空</strong>。如下图所示:</p>
|
||||
<p><img src="assets/CgqCHl8MQA2AWELaAA_8m3_f-_Q592.gif" alt="2.gif" /></p>
|
||||
<p><img src="assets/CgqCHl8MQA2AWELaAA_8m3_f-_Q592.gif" alt="png" /></p>
|
||||
<p>这个例子的代码如下:</p>
|
||||
<pre><code>public static void levelTraverse(Node root) {
|
||||
LinkedList<Node> queue = new LinkedList<Node>();
|
||||
@@ -344,7 +344,7 @@ public class testj {
|
||||
<p>从第 14 行开始,Inset() 函数中,需要判断 count 的奇偶性:如果 count 是偶数,则新的数据需要先加入最小堆,再弹出最小堆的堆顶,最后把弹出的数据加入最大堆。如果 count 是奇数,则新的数据需要先加入最大堆,再弹出最大堆的堆顶,最后把弹出的数据加入最小堆。</p>
|
||||
<p>执行完后,count 加 1。然后调用 GetMedian() 函数来寻找中位数,GetMedian() 函数通过 27 行直接返回最大堆的对顶,这是因为我们约定中位数在偶数个的时候,选择偏左的元素。</p>
|
||||
<p>最后,我们给出插入 22 的执行过程,如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F8MQD2AGebMAAm_13LKPTk687.gif" alt="4.gif" /></p>
|
||||
<p><img src="assets/Ciqc1F8MQD2AGebMAAm_13LKPTk687.gif" alt="png" /></p>
|
||||
<h3>总结</h3>
|
||||
<p>这一课时主要围绕数据结构展开问题的分析和讨论。对于树的层次遍历,我们再拓展一下。</p>
|
||||
<p>如果要打印的不是层次,而是蛇形遍历,又该如何实现呢?蛇形遍历就是 s 形遍历,即奇数层从左到右,偶数层从右到左。如果是例题 2 的树,则蛇形遍历的结果就是 16、20、13、10、15、22、26、21。我们就把这个问题当作本课时的练习题。</p>
|
||||
|
||||
@@ -204,7 +204,7 @@ function hide_canvas() {
|
||||
<p>因此,解决方案上就是,一次循环嵌套查找完成。查找不可使用哈希表,但由于数组有序,时间复杂度是 O(1)。因此整体的时间复杂度就是 O(n)。</p>
|
||||
<p>我们来看一下具体方案。既然是一次循环,那么就需要一个 for 循环对整个数组进行遍历。每轮遍历的动作是查找 nums[i] 是否已经出现过。因为数组有序,因此只需要去对比 nums[i] 和当前去重数组的最大值是否相等即可。我们用一个 temp 变量保存去重数组的最大值。</p>
|
||||
<p>如果二者不等,则说明是一个新的数据。我们就需要把这个新数据放到去重数组的最后,并且修改 temp 变量的值,再修改当前去重数组的长度变量 len。直到遍历完,就得到了结果。</p>
|
||||
<p><img src="assets/CgqCHl8O4AaAIRWPAATuBR6nG1c878.gif" alt="1.gif" /></p>
|
||||
<p><img src="assets/CgqCHl8O4AaAIRWPAATuBR6nG1c878.gif" alt="png" /></p>
|
||||
<p><strong>最后,我们按照上面的思路进行编码开发,代码如下</strong>:</p>
|
||||
<pre><code>public static void main(String[] args) {
|
||||
int[] nums = {0,0,1,1,1,2,2,3,3,4};
|
||||
@@ -237,13 +237,13 @@ function hide_canvas() {
|
||||
<p>回想一下,二分查找适用的重要条件就是,原数组有序。恰好,在这个问题中 nums1 和 nums2 分别都是有序的。而且二分查找的时间复杂度是 O(logn),这和题目中给出的时间复杂度 O(log(m + n)) 的要求也是不谋而合。因此,经过分析,我们可以大胆猜测,此题极有可能要用到二分查找。</p>
|
||||
<p><strong>我们再来看一下数据结构方面</strong>。如果要用二分查找,就需要用到若干个指针,去约束查找范围。除此以外,并不需要去定义复杂的数据结构。也就是说,空间复杂度是 O(1) 。</p>
|
||||
<p>好了,接下来,我们就来看一下二分查找如何能解决这个问题。二分查找需要一个分裂点,去把原来的大问题,拆分成两个部分,并在其中一部分继续执行二分查找。既然是查找中位数,我们不妨先试试以中位数作为切分点,看看会产生什么结果。如下图所示:</p>
|
||||
<p><img src="assets/Ciqc1F8O4BWAJgOUAABMJW6Ihfk508.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8O4BWAJgOUAABMJW6Ihfk508.png" alt="png" /></p>
|
||||
<p>经过切分后,两个数组分别被拆分为 3 个部分,合在一起是 6 个部分。二分查找的思路是,需要从这 6 个部分中,剔除掉一些,让查找的范围缩小。那么,我们来思考一个问题,在这 6 个部分中,目标中位数一定不会发生在哪几个部分呢?</p>
|
||||
<p><strong>中位数有一个重要的特质,那就是比中位数小的数字个数,和比中位数大的数字个数,是相等的</strong>。围绕这个性质来看,中位数就一定不会发生在 C 和 D 的区间。</p>
|
||||
<p>如果中位数在 C 部分,那么在 nums1 中,比中位数小的数字就会更多一些。因为 4 < 5(nums2 的中位数小于 nums1 的中位数),所以在 nums2 中,比中位数小的数字也会更多一些(最不济也就是一样多)。因此,整体来看,中位数不可能在 C 部分。同理,中位数也不会发生在 D 部分。</p>
|
||||
<p>接下来,我们就可以在查找范围内,剔除掉 C 部分(永远比中位数大的数字)和 D 部分(永远比中位数小的数字),这样我们就成功地完成了一次二分动作,缩小了查找范围。然而这样并没结束。剔除掉了 C 和 D 之后,中位数有可能发生改变。这是因为,C 部分的数字个数和 D 部分数字的个数是不相等的。剔除不相等数量的“小数”和“大数”后,会造成中位数的改变。</p>
|
||||
<p>为了解决这个问题,我们需要对剔除的策略进行修改。一个可行的方法是,如果 C 部分数字更少为 p 个,则剔除 C 部分;并只剔除 D 部分中的 p 个数字。这样就能保证,经过一次二分后,剔除之后的数组的中位数不变。</p>
|
||||
<p><img src="assets/CgqCHl8O4CKAd3GfAAA88tCPFHQ522.png" alt="3.png" /></p>
|
||||
<p><img src="assets/CgqCHl8O4CKAd3GfAAA88tCPFHQ522.png" alt="png" /></p>
|
||||
<p>应该剔除 C 部分和 D 部分。但 D 部分更少,因此剔除 D 和 C 中的 9。</p>
|
||||
<p>二分查找还需要考虑终止条件。对于这个题目,终止条件必然是某个数组小到无法继续二分的时候。这是因为,每次二分剔除掉的是更少的那个部分。因此,在终止条件中,查找范围应该是一个大数组和一个只有 1~2 个元素的小数组。这样就需要根据大数组的奇偶性和小数组的数量,拆开 4 个可能性:</p>
|
||||
<p><strong>可能性一</strong>:nums1 奇数个,nums2 只有 1 个元素。例如,nums1 = [a, b, <strong>c</strong>, d, e],nums2 = [m]。此时,有以下 3 种可能性:</p>
|
||||
@@ -270,7 +270,7 @@ function hide_canvas() {
|
||||
<li>如果 n > d,m > d,则结果为 d。</li>
|
||||
</ol>
|
||||
<p>其中,4~6 可以合并为,如果 n > d,则返回 m < c ? c : (m < d ? m : d)。</p>
|
||||
<p><img src="assets/Ciqc1F8Odd-AdDghAAAd2xZYP1g802.png" alt="image" /></p>
|
||||
<p><img src="assets/Ciqc1F8Odd-AdDghAAAd2xZYP1g802.png" alt="png" /></p>
|
||||
<p><strong>可能性四</strong>:nums1 偶数个,nums2 有 2 个元素。例如,nums1 = [a, b, <strong>c</strong>, d, e, f],nums2 = [<strong>m</strong>,n]。此时,有以下 6 种可能性:</p>
|
||||
<ol>
|
||||
<li>如果 n < b,则结果为 b;</li>
|
||||
|
||||
@@ -259,7 +259,7 @@ public static boolean isUniquel(int[] arr) {
|
||||
<h4>例题 3:给定一个方格棋盘,从左上角出发到右下角有多少种方法</h4>
|
||||
<p><strong>【题目】</strong> 在一个方格棋盘里,左上角是起点,右下角是终点。每次只能向右或向下,移向相邻的格子。同时,棋盘中有若干个格子是陷阱,不可经过,必须绕开行走。</p>
|
||||
<p>要求用动态规划的方法,求出从起点到终点总共有多少种不同的路径。例如,输入二维矩阵 m 代表棋盘,其中,1 表示格子可达,-1 表示陷阱。输出可行的路径数量为 2。</p>
|
||||
<p><img src="assets/Ciqc1F8VUi2AFvluAAAd3YHGcpM960.png" alt="2.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8VUi2AFvluAAAd3YHGcpM960.png" alt="png" /></p>
|
||||
<p><strong>【解析】</strong> 题目要求使用动态规划的方法,这是我们解题的一个难点,也正是因为这一点限制才让这道题目区别于常见的题目。</p>
|
||||
<p>对于 O2O 领域的公司,尤其对于经常要遇到有限资源下,去最优化某个目标的岗位时,动态规划应该是高频考察的内容。我们依然是围绕动态规划的解题方法,从寻找最优子结构的视角去解决问题。</p>
|
||||
<p><strong>千万别忘了,动态规划的解题方法是,分阶段、找状态、做决策、状态转移方程、定目标、寻找终止条件</strong>。</p>
|
||||
|
||||
@@ -187,7 +187,7 @@ function hide_canvas() {
|
||||
<p>从本质来看,技术面试就是一次交流和讨论。你作为候选人一定不可以降低身份,表现出求着对方收留的那种感觉。面试很像是相亲,是一种双向选择的过程。如果交流下来,你不认可面试官,那么也可以重新寻找别的求职机会。</p>
|
||||
<p>面试通过与否取决于面试官。在面试的过程中,你一直在动态地维护一个你在面试官心中好感度的得分。你在面试的过程中,不断地展现出你的优秀,那么这个好感分就会越来越高;反之,则会越来越低。最终,面试结束之后,面试官会根据心中的好感分来决定候选人的去留。</p>
|
||||
<p>求职开始于简历,这是一个预面试的过程。只有简历通过了,才可能进入到面试环节。技术面试的时长在 60 分钟左右,一般可以拆解为自我介绍、项目介绍、技术考察、手写代码和开放性问题。因此,从流程来看,你必须在简历筛查、自我介绍、项目介绍、技术考察、手写代码和开放性问题,这六个环节都做到较好,才有机会通过面试。接下来,我们将对技术面试的涉及的 6 个环节逐一进行分析。</p>
|
||||
<p><img src="assets/Ciqc1F8YHiKAdFRKAACGzs8toxc328.png" alt="13.png" /></p>
|
||||
<p><img src="assets/Ciqc1F8YHiKAdFRKAACGzs8toxc328.png" alt="png" /></p>
|
||||
<h3>技术面试各个环节的能力分析</h3>
|
||||
<p>这里需要特别说明一下,以下分析只针对互联网大厂技术研发的求职。</p>
|
||||
<h4>简历筛查</h4>
|
||||
@@ -233,7 +233,7 @@ function hide_canvas() {
|
||||
<p>下面我们给出几个真实案例,带你进一步分析和运用本课时所讲的内容。</p>
|
||||
<h4>反面案例 1:简历</h4>
|
||||
<p><strong>【题目】</strong> 如下图所示,我们给出一段简历内容,要求你根据本课时学习到的知识,予以评价。</p>
|
||||
<p><img src="assets/CgqCHl8ax62AN-bjAACBRAY3SOM181.png" alt="WechatIMG52.png" /></p>
|
||||
<p><img src="assets/CgqCHl8ax62AN-bjAACBRAY3SOM181.png" alt="png" /></p>
|
||||
<p><strong>【分析】</strong> 不难发现,这段简历在简历 3 个要素上都出现了问题,具体分析如下:</p>
|
||||
<p><strong>首先,信息不完备</strong>。既然硕士阶段写了 GPA,本科阶段就应该写上与之对应的 GPA 或者加权平均分;或者统一都不写。</p>
|
||||
<p><strong>其次,信息冗余</strong>。师从 XX 教授,以及导师毕业于哪个大学,这些对你的求职又有什么用呢?写了也只是浪费纸张、浪费篇幅。</p>
|
||||
|
||||
Reference in New Issue
Block a user