您当前的位置: 首页 >  链表

壹小俊

暂无认证

  • 1浏览

    0关注

    885博文

    0收益

  • 0浏览

    0点赞

    0打赏

    0留言

私信
关注
热门博文

07 | 链表(下):如何轻松写出正确的链表代码?

壹小俊 发布时间:2022-07-06 17:54:30 ,浏览量:1

上一节我讲了链表相关的基础知识。学完之后,我看到有人留言说,基础知识我都掌握了,但是写链表代码还是很费劲。哈哈,的确是这样的。

想要写好链表代码并不是容易的事儿,尤其是那些复杂的链表操作,比如链表反转、有序链表合并等,写的时候非常容易出错。从我上百场面试的经验来看,能把“链表反转”这几行代码写对的人不足10%。

为什么链表代码这么难写?究竟怎样才能比较轻松地写出正确的链表代码呢?

只要愿意投入时间,我觉得大多数人都是可以学会的。比如说,如果你真的能花上一个周末或者一整天的时间,就去写链表反转这一个代码,多写几遍,一直练到能毫不费力地写出 Bug free 的代码。这个坎还会很难跨吗?

当然,自己有决心并且付出精力是成功的先决条件,除此之外,我们还需要一些方法和技巧。我根据自己的学习经历和工作经验,总结了几个写链表代码技巧。如果你能熟练掌握这几个技巧,加上你的主动和坚持,轻松拿下链表代码完全没有问题。

技巧一:理解指针或引用的含义

事实上,看懂链表的结构并不是很难,但是一旦把它和指针混在一起,就很容易让人摸不着头脑。

所以,要想写对链表代码,首先就要理解好指针。

我们知道,有些语言有“指针”的概念,比如 C语言;有些语言没有指针,取而代之的是“引用”,比如 Java、Python。不管是“指针”还是“引用”,实际上,它们的意思都是一样的,都是存储所指对象的内存地址。

接下来,我会拿 C语言中的“指针”来讲解,如果你用的是 Java 或者其他没有指针的语言也没关系,你把它理解成“引用”就可以了。

实际上,对于指针的理解,你只需要记住下面这句话就可以了:

将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

这句话听起来还挺拗口的,你可以先记住。我们回到链表代码的编写过程中,我来慢慢给你解释。

在编写链表代码的时候,我们经常会有这样的代码:p->next=q。这行代码是说,p 结点中的 next指针存储了 q 结点的内存地址。

还有一个更复杂的,也是我们写链表代码经常会用到的:p->next=p->next->next。这行代码表示,p结点的 next 指针存储了 p 结点的下下一个结点的内存地址。

掌握了指针或引用的概念,你应该可以很轻松地看懂链表代码。恭喜你,已经离写出链表代码近了一步!

技巧二:警惕指针丢失和内存泄漏

不知道你有没有这样的感觉,写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。所以,我们在写的时候,一定注意不要弄丢了指针。

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

如图所示,我们希望在结点 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 往后的所有结点都无法访问到了。

对于有些语言来说,比如 C语言,内存管理是由程序员负责的,如果没有手动释放结点对应的内存空间,就会产生内存泄露。所以,我们插入结点时,一定要注意操作的顺序,要先将结点 x的 next指针指向结点 b,再把结点 a 的 next 指针指向结点 x,这样才不会丢失指针,导致内存泄漏。所以,对于刚刚的插入代码,我们只需要把第 1 行和第 2 行代码的顺序颠倒一下就可以了。

同理,删除链表结点时,也一定要记得手动释放内存空间,否则,也会出现内存泄漏的问题。当然,对于像 Java 这种虚拟机自动管理内存的编程语言来说,就不需要考虑这么多了。

技巧三:利用哨兵简化实现难度

首先,我们先来回顾一下单链表的插入和删除操作。如果我们在结点 p 后面插入一个新的结点,只需要下面两行代码就可以搞定。

new_node->next = p->next;

p->next = new_node;

但是,当我们要向一个空链表中插入第一个结点,刚刚的逻辑就不能用了。我们需要进行下面这样的特殊处理,其中 head 表示链表的头结点。所以,从这段代码,我们可以发现,对于单链表的插入操作,第一个结点和其他结点的插入逻辑是不一样的。

if (head == null) {

head = new_node;

}

我们再来看单链表结点删除操作。如果要删除结点 p 的后继结点,我们只需要一行代码就可以搞定。

p->next = p->next->next;

但是,如果我们要删除链表中的最后一个结点,前面的删除代码就不 work 了。跟插入类似,我们也需要对于这种情况特殊处理。写成代码是这样子的:

if (head->next == null) {

head = null;

}

从前面的一步一步分析,我们可以看出,针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。这样代码实现起来就会很繁琐,不简洁,而且也容易因为考虑不全而出错。如何来解决这个问题呢?

技巧三中提到的哨兵就要登场了。哨兵,解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑。

还记得如何表示一个空链表吗?head=null 表示链表中没有结点了。其中 head 表示头结点指针,指向链表中的第一个结点。如果我们引入哨兵结点,在任何时候,不管链表是不是空,head 指针都会一直指向这个哨兵结点。

我们也把这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表。

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

实际上,这种利用哨兵简化编程难度的技巧,在很多代码实现中都有用到,比如插入排序、归并排序、动态规划等。这些内容我们后面才会讲,现在为了让你感受更深,我再举一个非常简单的例子。代码我是用 C语言实现的,不涉及语言方面的高级语法,很容易看懂,你可以类比到你熟悉的语言。

代码一:

// 在数组 a 中,查找 key,返回 key 所在的位置

// 其中,n 表示数组 a 的长度

int find(char* a, int n, char key) {

// 边界条件处理,如果 a 为空,或者 n            
关注
打赏
1664335782
查看更多评论
0.1249s