升级打怪之数据结构与算法 02 - 写链表代码的技巧

281 阅读7分钟

这是我参与8月更文挑战的第1天,活动详情查看: 8月更文挑战

前言

本篇主要总结数据结构中链表的书写技巧,包括理解指针的含义,内存泄露、哨兵思想、边界处理等。以及一些思路和体会的总结。

写链表代码技巧

理解指针或引用的含义

含义

将某个变量(对象)赋值给指针(引用),实际上就是将这个变量(对象)的地址赋值给指针(引用)。

示例

p->next=q 表示p节点的后继指针存储了q节点的内存地址。

p->next=p->next->next 表示p节点的后继指针存储了p节点的下下个节点的内存地址。

警惕指针丢失和内存泄漏

插入节点

指针往往都是怎么弄丢的呢?我拿单链表的插入操作为例来给你分析一下。

image.png

在结点 a 和相邻的结点 b 之间插入结点 x,假设当前指针 p 指向结点 a。如果我们将代码实现变成下面这个样子,就会发生指针丢失和内存泄露。

p->next = x; // 将p的next指针指向x结点;

x->next = p->next; //x的结点的next指针指向b结点;

如上的操作是 p->next 指针在完成第一步操作之后,已经不再指向结点 b 了,而是指向结点 x。第 2 行代码相当于将 x 赋值给 x->next,自己指向自己。因此,整个链表也就断成了两半,从结点 b 往后的所有结点都无法访问到了。

所以,在插入结点时,一定要注意操作的顺序,要先将结点 x 的 next 指针指向结点 b,再把结点 a 的 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。

正确的写法是2句代码交换顺序,即:

x—>next = p—>next; 
p—>next = x;

删除节点

在节点a和节点b之间删除节点b,b是a的下一节点,p指针指向节点a。所以示例是这样子的:

p—>next = p—>next—>next;

利用哨兵简化实现难度

什么是“哨兵”?

链表中的“哨兵”节点是解决边界问题的,不参与业务逻辑。

如果我们引入“哨兵”节点,则不管链表是否为空,head指针都会指向这个“哨兵”节点。我们把这种有“哨兵”节点的链表称为带头链表,相反,没有“哨兵”节点的链表就称为不带头链表。

未引入“哨兵”的情况

如果在p节点后插入一个节点,只需2行代码即可搞定:

new_node—>next = p—>next;
p—>next = new_node;

但,若向空链表中插入一个节点,则代码如下:

if(head == null){
    head = new_node;
}

如果要删除节点p的后继节点,只需1行代码即可搞定:

p—>next = p—>next—>next;

但,若是删除链表的最有一个节点(链表中只剩下这个节点),则代码如下:

if(head—>next == null){
    head = null;
}

从上面的情况可以看出,针对链表的插入、删除操作,需要对插入第一个节点和删除最后一个节点的情况进行特殊处理。这样代码就会显得很繁琐,所以引入 “哨兵”节点 来解决这个问题。

引入“哨兵”的情况

“哨兵”节点不存储数据,无论链表是否为空,head指针都会指向它,作为链表的头结点始终存在。

如下图的带头链表,你可以发现,哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个节点和插入其他节点,删除最后一个节点和删除其他节点都可以统一为相同的代码实现逻辑了。

image.png

“哨兵”的理解

哨兵可以理解为它可以减少特殊情况的判断,比如:

  • 判空;
  • 判越界;
  • 减少链表插入、删除中对空链表的判断;

越界可以认为是小概率情况,所以代码每一次操作都走一遍判断,在大部分情况下都会是多余的。

哨兵的巧妙之处是提前将这种情况去除。比如给一个哨兵结点,以及将key赋值给数组末元素,让数组遍历不用判断越界也可以因为相等停下来。

使用哨兵的指导思想:将小概率需要的判断先提前扼杀。

比如提前给他一个值让他不为null,或者提前预设值,或者多态的时候提前给个空实现,然后在每一次操作中不必再判断以增加效率。

“哨兵”的应用场景

利用哨兵简化编程难度的技巧,在很多代码实现中都有用到,比如插入排序归并排序动态规划等。

重点留意边界条件处理

经常用来检查链表是否正确的边界4个边界条件:

  1. 如果链表为空时,代码是否能正常工作?
  2. 如果链表只包含一个节点时,代码是否能正常工作?
  3. 如果链表只包含两个节点时,代码是否能正常工作?
  4. 代码逻辑在处理头尾节点时是否能正常工作?

针对不同的场景,可能还有特定的边界条件,这个需要去思考,不过套路都是一样的。

举例画图,辅助思考

核心思想:释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。

使用举例法和画图法。。比如往单链表中插入一个数据这样一个操作,把各种情况都举一个例子,画出插入前和插入后的链表变化,如图所示:

image.png

多写多练,没有捷径

5个常见的链表操作

  1. 单链表反转
  2. 链表中环的检测
  3. 两个有序链表合并
  4. 删除链表倒数第n个节点
  5. 求链表的中间节点

练习题LeetCode对应编号:206,141,21,19,876。大家可以去练习

思路与体会

  1. 函数中需要移动链表时,最好新建一个指针来移动,以免更改原始指针位置。

  2. 单链表有带头节点和不带头结点的链表之分,一般做题默认头结点是有值的。

  3. 链表的内存时不连续的,一个节点占一块内存,每块内存中有一块位置(next)存放下一节点的地址(这是单链表为例)。

  4. 链表中找环的思想:创建两个指针一个快指针一次走两步一个慢指针一次走一步,若相遇则有环,若先指向null则无环。

  5. 链表找倒数第k个节点思想:创建两个指针,第一个先走k-1步然后两个在一同走。第一个走到最后时则第二个指针指向倒数第k位置。

  6. 反向链表思想:从前往后将每个节点的指针反向,即.next内的地址换成前一个节点的,但为了防止后面链表的丢失,在每次换之前需要先创建个指针指向下一个节点。

  7. 两个有序链表合并思想:这里用到递归思想。先判断是否有一个链表是空链表,是则返回两一个链表,免得指针指向不知名区域引发程序崩溃。然后每次比较两个链表的头结点,小的值做新链表的头结点,此节点的next指针指向本函数(递归开始,参数是较小值所在链表.next和另一个链表)。

参考

  • 数据结构和算法之美