数据结构与算法——链表(二)

本专栏是学习王争老师的《数据结构与算法之美》的学习总结,详细内容可以去学习王争老师的专栏,希望大家都能够有所收获。同时也欢迎大家能够与我一起交流探讨!

写链表代码的几个技巧

1、理解指针或引用的含义

想要写对链表代码,首先需要理解指针。有些语言有“指针”的概念,比如C语言;有些语言没有指针,取而代之的是“引用”,比如Java、Python。不管是“指针”还是“引用”,实际上,实际上都是存储所指对象的内存地址。

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

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

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

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

2、警惕指针丢失和内存泄漏

写链表代码的时候,指针指来指去,一会儿就不知道指到哪里了。所以在写链表代码的时候,一定注意不要弄丢了指针。

指针往往都是怎么弄丢的呢?拿单链表的插入操作为例来分析一下。
在这里插入图片描述
上图中,我们希望在结点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,这样才不会丢失指针,导致内存泄漏。修改后:

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

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

3、利用哨兵简化实现难度

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

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

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

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

其次,在单链表结点删除操作。如果要删除结点 p 的后继结点,只需要一行代码就可以了。

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

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

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

从上述分析可以看出,**针对链表的插入、删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。**但这样代码实现起来很繁琐,不简洁,也容易因为考虑不全而出错。

为了解决上述的问题,可以使用哨兵。哨兵是“解决边界问题”, 不直接参与业务逻辑。

如何表示一个空链表?head = null 表示链表中没有节点。其中head表示头结点指针,指向链表中的第一个结点。如果引入哨兵结点。在任何时候,不管链表是不是空,head指针都会一直指向这个哨兵结点。这种有哨兵结点的链表叫带头链表。相反,没有哨兵结点的链表就叫作不带头链表

可以发现,哨兵结点是不存储数据的。因为哨兵结点一直存在,所以插入第一个结点和插入其他结点,删除最后一个结点和删除其他结点,都可以统一为相同的代码实现逻辑了。
在这里插入图片描述
实际上,这种利用哨兵简化编程难度的技巧,在很多代码实现中都有用到,比如插入排序、归并排序、动态规划等。

代码一:

// 在数组a中,查找key,返回key所在的位置
// 其中,n表示数组a的长度
int find(char* a, int n, char key) {
  // 边界条件处理,如果a为空,或者n<=0,说明数组中没有数据,就不用while循环比较了
  if(a == null || n <= 0) {
    return -1;
  }
  
  int i = 0;
  // 这里有两个比较操作:i<n和a[i]==key.
  while (i < 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 <= 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<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 < n,不要小看这一条语句,当累积执行万次、几十万次时,累积的时间就很明显了。

当然,这只是为了举例说明哨兵的作用,写代码的时候千万不要写第二段那样的代码,因为可读性太差了。大部分情况下,并不需要如此追求极致的性能。

4、重点留意边界条件处理

软件开发中,代码在一些边界或者异常情况下,最容易产生 Bug。链表代码也不例外。实现没有 Bug 的链表代码,一定要在编写的过程中以及编写完成之后,检查边界条件是否考虑全面,以及代码在边界条件下是否能正确运行

常用来检查链表代码是否正确的边界条件有如下几个:

  • 如果链表为空时,代码是否能正常工作?
  • 如果链表只包含一个结点时,代码是否能正常工作?
  • 如果链表只包含两个结点时,代码是否能正常工作?
  • 代码逻辑在处理头结点和尾结点时,是否能正常工作?

当写完链表代码后,除了检查代码在正常情况下能否工作,还需要看上述的边界条件下,代码能否仍然正常工作。如果这些边界条件下都没有问题,那基本上可以认为没有问题了。

当然,边界条件不止列举的那些。针对不同的场景,可能还有特定的边界条件,这个需要自己去思考。

实际上,不光光是写链表代码,你在写任何代码时,也千万不要只是实现业务正常情况下的功能就好了,一定要多想想,代码在运行的时候,可能会遇到哪些边界情况或者异常情况。遇到了应该如何应对,这样写出来的代码才够健壮!

5、举例画图,辅助思考

在实现链表代码时,若指针操作容易被绕晕,想不清楚,这时可以使用举例法画图法

可以找一个具体的例子,把它画在纸上,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。比如往单链表中插入一个数据这样一个操作,一般都是把各种情况都举一个例子,画出插入前和插入后的链表变化,如图所示:
在这里插入图片描述
通过看图写代码,可以简单化。而且,当我们写完代码之后,也可以举几个例子,画在纸上,照着代码走一遍,很容易就能发现代码中的 Bug。

6、多写多练,没有捷径

多写多练,把常见的链表操作都自己多写几遍,出问题就一点一点调试,熟能生巧!

以下常见的5个链表操作,只要把这几个操作写熟练,之后在写链表胆码时会得心应手。

  • 单链表反转
  • 链表中环的检测
  • 两个有序的链表合并
  • 删除链表倒数第 n 个结点
  • 求链表的中间结点
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值