CategoryResourceRepost/极客时间专栏/数据结构与算法之美/基础篇/07 | 链表(下):如何轻松写出正确的链表代码?.md
louzefeng d3828a7aee mod
2024-07-11 05:50:32 +00:00

249 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<audio id="audio" title="07 | 链表(下):如何轻松写出正确的链表代码?" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/3b/7a/3b15db06c3825429bace496b7319b97a.mp3"></audio>
上一节我讲了链表相关的基础知识。学完之后,我看到有人留言说,基础知识我都掌握了,但是写链表代码还是很费劲。哈哈,的确是这样的!
想要写好链表代码并不是容易的事儿尤其是那些复杂的链表操作比如链表反转、有序链表合并等写的时候非常容易出错。从我上百场面试的经验来看能把“链表反转”这几行代码写对的人不足10%。
为什么链表代码这么难写?究竟怎样才能比较轻松地写出正确的链表代码呢?
只要愿意投入时间我觉得大多数人都是可以学会的。比如说如果你真的能花上一个周末或者一整天的时间就去写链表反转这一个代码多写几遍一直练到能毫不费力地写出Bug free的代码。这个坎还会很难跨吗
当然,自己有决心并且付出精力是成功的先决条件,除此之外,我们还需要一些方法和技巧。我根据自己的学习经历和工作经验,总结了**几个写链表代码技巧**。如果你能熟练掌握这几个技巧,加上你的主动和坚持,轻松拿下链表代码完全没有问题。
## 技巧一:理解指针或引用的含义
事实上,看懂链表的结构并不是很难,但是一旦把它和指针混在一起,就很容易让人摸不着头脑。所以,要想写对链表代码,首先就要理解好指针。
我们知道有些语言有“指针”的概念比如C语言有些语言没有指针取而代之的是“引用”比如Java、Python。不管是“指针”还是“引用”实际上它们的意思都是一样的都是存储所指对象的内存地址。
接下来我会拿C语言中的“指针”来讲解如果你用的是Java或者其他没有指针的语言也没关系你把它理解成“引用”就可以了。
实际上,对于指针的理解,你只需要记住下面这句话就可以了:
**将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。**
这句话听起来还挺拗口的,你可以先记住。我们回到链表代码的编写过程中,我来慢慢给你解释。
在编写链表代码的时候我们经常会有这样的代码p-&gt;next=q。这行代码是说p结点中的next指针存储了q结点的内存地址。
还有一个更复杂的也是我们写链表代码经常会用到的p-&gt;next=p-&gt;next-&gt;next。这行代码表示p结点的next指针存储了p结点的下下一个结点的内存地址。
掌握了指针或引用的概念,你应该可以很轻松地看懂链表代码。恭喜你,已经离写出链表代码近了一步!
## 技巧二:警惕指针丢失和内存泄漏
不知道你有没有这样的感觉,写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。所以,我们在写的时候,一定注意不要弄丢了指针。
指针往往都是怎么弄丢的呢?我拿单链表的插入操作为例来给你分析一下。
<img src="https://static001.geekbang.org/resource/image/05/6e/05a4a3b57502968930d517c934347c6e.jpg" alt="">
如图所示我们希望在结点a和相邻的结点b之间插入结点x假设当前指针p指向结点a。如果我们将代码实现变成下面这个样子就会发生指针丢失和内存泄露。
```
p-&gt;next = x; // 将p的next指针指向x结点
x-&gt;next = p-&gt;next; // 将x的结点的next指针指向b结点
```
初学者经常会在这儿犯错。p-&gt;next指针在完成第一步操作之后已经不再指向结点b了而是指向结点x。第2行代码相当于将x赋值给x-&gt;next自己指向自己。因此整个链表也就断成了两半从结点b往后的所有结点都无法访问到了。
对于有些语言来说比如C语言内存管理是由程序员负责的如果没有手动释放结点对应的内存空间就会产生内存泄露。所以我们**插入结点时,一定要注意操作的顺序**要先将结点x的next指针指向结点b再把结点a的next指针指向结点x这样才不会丢失指针导致内存泄漏。所以对于刚刚的插入代码我们只需要把第1行和第2行代码的顺序颠倒一下就可以了。
同理,**删除链表结点时,也一定要记得手动释放内存空间**否则也会出现内存泄漏的问题。当然对于像Java这种虚拟机自动管理内存的编程语言来说就不需要考虑这么多了。
## 技巧三:利用哨兵简化实现难度
首先我们先来回顾一下单链表的插入和删除操作。如果我们在结点p后面插入一个新的结点只需要下面两行代码就可以搞定。
```
new_node-&gt;next = p-&gt;next;
p-&gt;next = new_node;
```
但是当我们要向一个空链表中插入第一个结点刚刚的逻辑就不能用了。我们需要进行下面这样的特殊处理其中head表示链表的头结点。所以从这段代码我们可以发现对于单链表的插入操作第一个结点和其他结点的插入逻辑是不一样的。
```
if (head == null) {
head = new_node;
}
```
我们再来看单链表结点删除操作。如果要删除结点p的后继结点我们只需要一行代码就可以搞定。
```
p-&gt;next = p-&gt;next-&gt;next;
```
但是如果我们要删除链表中的最后一个结点前面的删除代码就不work了。跟插入类似我们也需要对于这种情况特殊处理。写成代码是这样子的
```
if (head-&gt;next == null) {
head = null;
}
```
从前面的一步一步分析,我们可以看出,**针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理**。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。如何来解决这个问题呢?
技巧三中提到的哨兵就要登场了。哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑。
还记得如何表示一个空链表吗head=null表示链表中没有结点了。其中head表示头结点指针指向链表中的第一个结点。
如果我们引入哨兵结点在任何时候不管链表是不是空head指针都会一直指向这个哨兵结点。我们也把这种有哨兵结点的链表叫**带头链表**。相反,没有哨兵结点的链表就叫作**不带头链表**。
我画了一个带头链表,你可以发现,哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑了。
<img src="https://static001.geekbang.org/resource/image/7d/c7/7d22d9428bdbba96bfe388fe1e3368c7.jpg" alt="">
实际上这种利用哨兵简化编程难度的技巧在很多代码实现中都有用到比如插入排序、归并排序、动态规划等。这些内容我们后面才会讲现在为了让你感受更深我再举一个非常简单的例子。代码我是用C语言实现的不涉及语言方面的高级语法很容易看懂你可以类比到你熟悉的语言。
代码一:
```
// 在数组a中查找key返回key所在的位置
// 其中n表示数组a的长度
int find(char* a, int n, char key) {
// 边界条件处理如果a为空或者n&lt;=0说明数组中没有数据就不用while循环比较了
if(a == null || n &lt;= 0) {
return -1;
}
int i = 0;
// 这里有两个比较操作i&lt;n和a[i]==key.
while (i &lt; n) {
if (a[i] == key) {
return i;
}
++i;
}
return -1;
}
```
代码二:
```
// 在数组a中查找key返回key所在的位置
// 其中n表示数组a的长度
// 我举2个例子你可以拿例子走一下代码
// a = {4, 2, 3, 5, 9, 6} n=6 key = 7
// a = {4, 2, 3, 5, 9, 6} n=6 key = 6
int find(char* a, int n, char key) {
if(a == null || n &lt;= 0) {
return -1;
}
// 这里因为要将a[n-1]的值替换成key所以要特殊处理这个值
if (a[n-1] == key) {
return n-1;
}
// 把a[n-1]的值临时保存在变量tmp中以便之后恢复。tmp=6。
// 之所以这样做的目的是希望find()代码不要改变a数组中的内容
char tmp = a[n-1];
// 把key的值放到a[n-1]中此时a = {4, 2, 3, 5, 9, 7}
a[n-1] = key;
int i = 0;
// while 循环比起代码一少了i&lt;n这个比较操作
while (a[i] != key) {
++i;
}
// 恢复a[n-1]原来的值,此时a= {4, 2, 3, 5, 9, 6}
a[n-1] = tmp;
if (i == n-1) {
// 如果i == n-1说明在0...n-2之间都没有key所以返回-1
return -1;
} else {
// 否则返回i就是等于key值的元素的下标
return i;
}
}
```
对比两段代码在字符串a很长的时候比如几万、几十万你觉得哪段代码运行得更快点呢答案是代码二因为两段代码中执行次数最多就是while循环那一部分。第二段代码中我们通过一个哨兵a[n-1] = key成功省掉了一个比较语句i&lt;n不要小看这一条语句当累积执行万次、几十万次时累积的时间就很明显了。
当然,这只是为了举例说明哨兵的作用,你写代码的时候千万不要写第二段那样的代码,因为可读性太差了。大部分情况下,我们并不需要如此追求极致的性能。
## 技巧四:重点留意边界条件处理
软件开发中代码在一些边界或者异常情况下最容易产生Bug。链表代码也不例外。要实现没有Bug的链表代码一定要在编写的过程中以及编写完成之后检查边界条件是否考虑全面以及代码在边界条件下是否能正确运行。
我经常用来检查链表代码是否正确的边界条件有这样几个:
<li>
如果链表为空时,代码是否能正常工作?
</li>
<li>
如果链表只包含一个结点时,代码是否能正常工作?
</li>
<li>
如果链表只包含两个结点时,代码是否能正常工作?
</li>
<li>
代码逻辑在处理头结点和尾结点的时候,是否能正常工作?
</li>
当你写完链表代码之后,除了看下你写的代码在正常的情况下能否工作,还要看下在上面我列举的几个边界条件下,代码仍然能否正确工作。如果这些边界条件下都没有问题,那基本上可以认为没有问题了。
当然,边界条件不止我列举的那些。针对不同的场景,可能还有特定的边界条件,这个需要你自己去思考,不过套路都是一样的。
实际上,不光光是写链表代码,你在写任何代码时,也千万不要只是实现业务正常情况下的功能就好了,一定要多想想,你的代码在运行的时候,可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对,这样写出来的代码才够健壮!
## 技巧五:举例画图,辅助思考
对于稍微复杂的链表操作,比如前面我们提到的单链表反转,指针一会儿指这,一会儿指那,一会儿就被绕晕了。总感觉脑容量不够,想不清楚。所以这个时候就要使用大招了,**举例法**和**画图法**。
你可以找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。比如往单链表中插入一个数据这样一个操作,我一般都是把各种情况都举一个例子,画出插入前和插入后的链表变化,如图所示:
<img src="https://static001.geekbang.org/resource/image/4a/f8/4a701dd79b59427be654261805b349f8.jpg" alt="">
看图写代码是不是就简单多啦而且当我们写完代码之后也可以举几个例子画在纸上照着代码走一遍很容易就能发现代码中的Bug。
## 技巧六:多写多练,没有捷径
如果你已经理解并掌握了我前面所讲的方法,但是手写链表代码还是会出现各种各样的错误,也不要着急。因为我最开始学的时候,这种状况也持续了一段时间。
现在我写这些代码,简直就和“玩儿”一样,其实也没有什么技巧,就是把常见的链表操作都自己多写几遍,出问题就一点一点调试,熟能生巧!
所以我精选了5个常见的链表操作。你只要把这几个操作都能写熟练不熟就多写几遍我保证你之后再也不会害怕写链表代码。
<li>
单链表反转
</li>
<li>
链表中环的检测
</li>
<li>
两个有序的链表合并
</li>
<li>
删除链表倒数第n个结点
</li>
<li>
求链表的中间结点
</li>
## 内容小结
这节我主要和你讲了写出正确链表代码的六个技巧。分别是理解指针或引用的含义、警惕指针丢失和内存泄漏、利用哨兵简化实现难度、重点留意边界条件处理,以及举例画图、辅助思考,还有多写多练。
我觉得,**写链表代码是最考验逻辑思维能力的**。因为链表代码到处都是指针的操作、边界条件的处理稍有不慎就容易产生Bug。链表代码写得好坏可以看出一个人写代码是否够细心考虑问题是否全面思维是否缜密。所以这也是很多面试官喜欢让人手写链表代码的原因。所以这一节讲到的东西你一定要自己写代码实现一下才有效果。
## 课后思考
今天我们讲到用哨兵来简化编码实现,你是否还能够想到其他场景,利用哨兵可以大大地简化编码难度?
欢迎留言和我分享,我会第一时间给你反馈。
我已将本节内容相关的详细代码更新到GitHub[戳此](https://github.com/wangzheng0822/algo)即可查看。