Chapter8——链表的常见问题

本文梳理了链表中的易混淆概念,如头指针、头结点、首结点的区别,以及传值和传址的概念,并通过实例加以说明。此外,还详细介绍了两种单链表逆置的方法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

写在前面

    这一章对于一些有一定算法经验或者一定编程基础的同学来说,是比较容易的。但是,回顾自己当时学这一章的时候,确实是走了不少的弯路,因此自己写这一章的目的,旨在记录链表的一些常见问题以及这些问题的分析思路和解决方案。


一、链表的一些易混淆概念

1.头指针 or 头结点 or 首结点?

    初学数据结构时,我对这几个概念也是分不清楚,所以开门见山,首先梳理这三个概念。

  • 对于有头结点的链表(^表示空):
    链表1
  • 对于无头结点的链表:
    链表2

    从上面的两张图可作以下总结:

  • 头指针的意义在于,在访问链表时,知道链表存储在什么位置(从何处开始访问),由于链表的特性(next指针),知道了头指针,那么整个链表的元素都能够被访问。
    • 若链表中含有头结点,则头指针指向链表的头结点
    • 若链表中不含有头结点,则头指针指向链表的首个数据节点
  • 引入头结点后,可以带来两个优点:
    • 由于开始结点的位置被存放在头结点的指针域中,所以在链表的第一个位置上的操作和在表的其他位置上的操作一致,无须进行特殊处理。
    • 无论链表是否为空,其头指针是指向头结点的非空指针(空表中头结点的指针域为空),因此空表和非空表的处理也就统一了。

从以上两点可以得知,头指针和头结点的区分:无论带不带头结点,头指针始终指向链表的第一个结点,而头结点是带头结点链表中的第一个节点,结点内通常不存储信息。
另外还有以下两点注意事项:
* 有些教材上可能还有首节点的说法,首节点一般是指链表中首个数据结点,也就是以上两图中所展示的。
* 对于链表求长度,链表逆置等操作,是不包括头结点的,也就是说,对于这些基本操作,一般都是对链表的数据结点进行操作的,而引入头结点的目的只是为了统一某些链表的操作(如上述优点)。


2.传值 or 传址?

    传值和传址是链表这一块要搞清楚的重要内容,下面先展示一个小例子。

#include <stdio.h>
void pointerTrans(int *tranPointer);
void valueTrans(int tranValue);

int main(int argc, char *argv[]) {
    int value = 3;
    int *pointer = &value;
    printf("pointer = %x\n", pointer);
    pointerTrans(pointer);
    printf("value's address is = %x\n", &value);
    valueTrans(value);
    return 0;
}

void pointerTrans(int *tranPointer) {
    printf("tranPointer = %x\n", tranPointer);
}

void valueTrans(int tranValue) {
    printf("tranValue's address is = %x\n", &tranValue);
}

输出结果:

pointer = e8cc6d5c
tranPointer = e8cc6d5c
value's address is = e8cc6d5c
tranValue's address is = e8cc6d3c

    从以上的结果可以看出,pointer和tranPointer的地址是一致的,value和tranValue的地址是不一致的,究其原因,其实就是传值和传址之间的区别所在。
图示理解:
传值与传址1
传值与传址2
总结说来:

  • 传址也就是指针之间地址的传递,从上述例子中,首先pointer指针中存储的是value变量在内存中的存储地址,而其通过函数pointerTrans将地址值传递给形参变量tranPointer,因此此时pointer指针和tranPointer指针中存储的均是value变量的地址。
  • 传值则是实参变量和形参变量之间值的传递,形参变量也就是实参变量复制了一份,在函数体中使用,在函数体内修改参数变量时修改的是实参的一份拷贝,而实参本身是没有改变的。
  • 传址的本质还是传值,只不过是指针变量之间值的传递,而传址和传值之间的本质区别在于,传址不产生实参变量的拷贝,传值产生实参变量的拷贝,对应于上述程序,实参变量为value。更多内容可参考:
    传值、传引用、传址

    回到链表这块,因为是链式结构,会经常涉及到对地址的操作,因此搞清楚上述问题显得尤为重要。一般来说,只要清楚上述内存模型,然后一步一步结合图形分析,这类问题并不难解决。有时候,问题往往集中在”形参的值发生了改变,而实参值并未发生变化”、”链表断链”等易错点上。自己在初学这块时,也遇到过上述的问题,详细可参考:
深入剖析指针传参——入门篇


二、例题分析

1.单链表逆置

    最后,以一道经典例题结束链表知识的梳理。

将带头结点的单链表就地逆置,所谓”就地”是指辅助空间复杂度为O(1)。

单链表逆置有两种方法,下面一一介绍。
方法一(头插法重新建立单链表的方法):
    将头结点摘下,然后从第一个数据结点开始,依次头插入头结点的后面(也就是头插法重新建立单链表),直到最后一个结点为止,这样就实现了链表的逆置,如下图所示:
链表逆置
(图片来源于单链表的就地逆置
故完整代码实现如下:

#include <stdio.h>
#include <stdlib.h>
typedef struct node {
    int data;
    struct node *next;
}Node;
Node *Init(Node *head);
void Create(Node *head);
void Reverse(Node *head);
void Output(Node *head);

int main(int argc, char *argv[]) {
    Node *head = Init(head);                  //链表初始化,创立头结点
    Create(head);
    printf("逆置前的链表为: ");
    Output(head);

    Reverse(head);
    printf("逆置后的链表为: ");
    Output(head);
    return 0;
}

Node *Init(Node *head) {
    head = (Node *)malloc(sizeof(Node));
    head -> next = NULL;
    return head;
}

void Create(Node *head) {                   //尾插法创建链表
    int i;
    Node *r = head, *s;                         //链表的尾指针
    for (i = 1;i <= 10;i++) {
        s = (Node *)malloc(sizeof(Node));
        s -> data = i;
        r -> next = s;
        r = s;
    }
    r -> next = NULL;
}

void Reverse(Node *head) {
    Node *p = head -> next;                 
    head -> next = NULL;        //初始化,也作为最后一个结点的指针域
    Node *q;                    //q指针用于保存后继,防止"断链"
    while (p) {
        q = p -> next;
        p -> next = head -> next;
        head -> next = p;
        p = q;
    }
}

void Output(Node *head) {
    Node *p = head -> next;
    while (p) {
        printf("%d ", p -> data);
        p = p -> next;
    }
    printf("\n");
}

输出结果:

逆置前的链表为: 1 2 3 4 5 6 7 8 9 10 
逆置后的链表为: 10 9 8 7 6 5 4 3 2 1

方法二(三指针法):
    该种方法主要介绍思想,由于图形不太好画,可以根据算法描述把图形手画出。
三指针就是使用三个指针分别记录操作每个结点的前驱结点pre本身结点p后继结点r。算法实现过程如下:

  • 令*p结点的next域指向*pre结点,而一旦调整指针的指向后,*p的后继结点的链就断开了,为此需要用指针r来记录p结点的后继结点。
  • 在处理第一个结点时,应将其next域置为NULL,而不是指向头结点(因为它将作为新表的尾结点)
  • 在处理完最后一个结点后,需要调节头结点的next域并指向它。

因此,从上述过程可知,具体实现代码应该分成三个部分处理,处理链表的第一个数据结点的next域处理链表的第二个数据结点到最后一个数据结点的next域处理链表的头结点的next域
故完整实现代码如下:

#include <stdio.h>
#include <stdlib.h>
typedef struct node {
    int data;
    struct node *next;
}Node;
Node *Init(Node *head);
void Create(Node *head);
void Reverse(Node *head);
void Output(Node *head);

int main(int argc, char *argv[]) {
    Node *head = Init(head);                  //链表初始化,创立头结点
    Create(head);
    printf("逆置前的链表为: ");
    Output(head);

    Reverse(head);
    printf("逆置后的链表为: ");
    Output(head);
    return 0;
}

Node *Init(Node *head) {
    head = (Node *)malloc(sizeof(Node));
    head -> next = NULL;
    return head;
}

void Create(Node *head) {                   //尾插法创建链表
    int i;
    Node *r = head, *s;                         //链表的尾指针
    for (i = 1;i <= 10;i++) {
        s = (Node *)malloc(sizeof(Node));
        s -> data = i;
        r -> next = s;
        r = s;
    }
    r -> next = NULL;
}

void Reverse(Node *head) {
    Node *pre, *p = head -> next, *r = p -> next;
    p -> next = NULL;                   //处理第一个结点
    while (r) {
        pre = p;
        p = r;                          //指针前移操作
        r = p -> next;
        p -> next = pre;                //指针反转操作
    }
    head -> next = p;                   //处理最后一个结点
}

void Output(Node *head) {
    Node *p = head -> next;
    while (p) {
        printf("%d ", p -> data);
        p = p -> next;
    }
    printf("\n");
}

关于更多图示解释,可参考:单链表的逆置–普通循环方法(详细图解)


4.小结

    关于链表部分,自己有以下小结:

  • 链表结构比较单一,但也要注意方法。一般来说,在做涉及链表的相关题目时,应该动手绘制出链表每一步应当如何操作,列出一二三步,然后再将其”翻译”成代码即可。
  • 链表相关算法主要涉及头插法,尾插法,逆置法,归并法,双指针法等方法求解,在实际求解问题时,还要灵活运用,另外注意链表中”传值和传址”、尾插法要处理最后一个结点的next域、头插法初始化链表并保存后继等易错点,要勤加总结。
  • 无论是上述知识点中提及的单链表还是单链表的各种变形(双链表,循环单链表,循环双链表等),都是以单链表为基础的。只有扎实的掌握了单链表的基本操作,多犯一点错误,然后分析并记录起来自己错误的原因,再来看各种变形也就不难了。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值