数据结构与算法之美07

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

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

看懂链表的结构并不难,但是一旦把它和指针混在一起,就很容易让人摸不着头脑。所以要想写对链表代码,首先要理解好指针。对于指针的理解,你只需要记住下面这句话就可以了:将某个变量赋值给指针,实际上就是将这个变量的地址赋值给指针,或者反过来说,指针中存储了这个变量的内存地址,指向了这个变量,通过指针就能找到这个变量。

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

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

指针都是怎么弄丢的呢?例如:结点a和下一个结点b相邻(a-b)a,在其之间插入结点x(a-x-b),假设当前指针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行代码的顺序颠倒一下就可以。

同理,删除链表结点时,也一定要记得手动释放内存空间。

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

首先,我们回顾一下单链表的插入和删除操作。如果我们在结点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<0和a[i]==key.
    while (i

代码二:

//在数组a中,查找key,返回key所在的位置
//其中,n表示数组a的长度
//举两个例子,可以走一下代码
//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 temp = a[n-1];
    //把key的值放入a[n-1]中,此时a={4,2,3,5,9,7}
    a[n-1] = key;

    int i = 0;
    //while循环比起代码一,少了 i

对比两段代码,在字符串a很长的时候,比如几万,几十万,你觉得哪段代码运行得更快呢?答案是代码二,因为两段代码中执行次数最多就是while循环那一部分。第二段代码中,我们通过一个哨兵a[n-1]=key,成功省掉一个比较词语i

技巧四:重点留意边界处理条件

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

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

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

技巧五:举例画图,辅助思考

对于稍微复杂的链表操作,例如前面提到的单链表反转,需要用举例法画图法

你可以找一个具体的例子,把它画在智商,释放一些脑容量,留更多的给逻辑思考,这样就会感觉到思路清晰很多。

技巧六:多练多写,没有捷径

精选5个常见的链表操作。你只要把这几个操作都能写熟练,不熟就多写几遍,我保证之后你再也不会害怕写链表代码。

  • 单链表反转
  • 链表中环的检测
  • 两个有序的链表合并
  • 删除链表倒数第n个结点
  • 求链表的中间结点

内容小结

这节主要讲了写正确链表代码的六个技巧。写链表代码时最考验逻辑思维能力的。因为,链表代码到处都是指针的操作、边界条件的处理,稍有不慎就容易产生bug。链表代码写得好坏,可以看出一个人写代码是否细心,考虑问题是否全面,思维是否缜密。所以,这也是很多面试官喜欢让人手写链表代码的原因。所以,这一节讲到的东西,一定要自己写代码实现以下,才有效果。

你可能感兴趣的:(数据结构与算法之美07)