07.链表(下)

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

markdown文件已上传至github

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

有些语言有“指针”的概念,如C语言;

有些语言没有指针的概念,取而代之的是’'引用“,如Java、Python;

不管是“指针”还是“引用”,它们的意思都是一样的,都是存储所指对象的内存地址。

将某个遍历赋值给指针,实际上就是将这个变量的地址复制给指针。指针中存储了这个变量的内存地址,通过指针就能找到这个变量。

p–>next =q,指p结点中的next指针存储了q结点的内存地址。

p–>next =p–>next–>next,指p结点的next指针存储了p结点的下下一个结点的内存地址。

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

指针是如何弄丢的?

如下图,我们需要在a,b之间插入结点x。指针p指向a。

img

下方的代码实现就会发生指针丢失。

p->next = x 
x->next = p->next

p->next指针在完成第一步后已经不指向结点b,而指向了x。第2行代码相当于将x赋值给x->next,自己指向自己,整个链表断成了两半,从结点b往后的所有结点都无法访问到了。将第一二行代码换下顺序就正确了。

有些语言,如C语言,内存管理是由程序员负责的,如果没有手动释放结点对应的内存空间,就会导致内存泄漏(指程序中己动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果)。**所以删除结点时,一定要记得手动释放内存空间。**对于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;

但是如果我们要删除链表的最后一个结点,上面的删除代码就不work了,这时代码如下:

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

从以上可以看出,对链表的插入和删除操作,需要对插入第一个结点和删除最后一个结点的情况进行特殊处理。

这样代码实现起来会很繁琐,不简洁,而且容易因为考虑不全而出错。这个问题可由哨兵解决。

**哨兵:**解决的是国家之间的边界问题。同理,这里说的哨兵也是解决“边界问题”的,不直接参与业务逻辑。

还记得的如何表示一个空链表吗?head = null 表示链表中没有结点了,head表示头结点指针,指向链表的第一个结点。

如果引入哨兵结点,在任何时候,不管链表是不是空,head指针都会一直指向这个哨兵结点。这种带有哨兵结点的链表夹带头链表,没有哨兵结点的链表叫做不带头链表

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

img

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

下面举一个非常简单的例子:

代码一:


// 在数组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。在代码编写过程中和完成后,要检查边界条件是否考虑全面,以及在边界条件下是否能正确运行。

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

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

当写完链表代码后,除了看写的代码在正常情况下能否工作,还要看在上面列举的几个边界条件下能否正常工作。

5.举例画图,辅助思考。

对于稍微复杂的链表操作,指针一会指这一会指那,可以把它画在纸上,释放一些脑容量,留更多给逻辑思考。

例如:往单链表中插入一个数据,画出插入前和插入后的链表变化。

img

这样看图写代码就简单多了。

6.多写多练,没有捷径。

7.参考

这个是我学习王争老师的《数据结构与算法之美》所做的笔记,王争老师是前谷歌工程师,该课程截止到目前已有87244人付费学习,质量不用多说。

在这里插入图片描述

截取了课程部分目录,课程结合实际应用场景,从概念开始层层剖析,由浅入深进行讲解。本人之前也学过许多数据结构与算法的课程,唯独王争老师的课给我一种茅塞顿开的感觉,强烈推荐大家购买学习。课程二维码我已放置在下方,大家想买的话可以扫码购买。

在这里插入图片描述

本人做的笔记并不全面,推荐大家扫码购买课程进行学习,而且课程非常便宜,学完后必有很大提高。

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/supreme_1/article/details/107593660