线性表之单链表

线性表之单链表

这篇文章来讲述线性表中的单链表,完整的实现请自行导航到gitee托管仓库。
传送门:https://gitee.com/prexer/data-structure-and-algorithms
所有的样例,都可以通过如下方式进行编译和检测:

$ git clone https://gitee.com/prexer/data-structure-and-algorithms.git
$ cd data-structure-and-algorithms/liner_list/single_linked_list
$ make run

单链表数据类型的抽象

事实上,任何程序的构建都是建立在一定的约束下的,这个约束通常称为环境。

同样的,我们构建单链表的时候,也对它进行一定的约束,相关属性如下:

  1. 单链表中的元素是有序的,且从小到大
  2. 表中的元素允许重复

接下来需要说明,任何数据类型在抽象时,都会分为两部分:

  1. 属性描述域
  2. 操作该类型对象的多种函数,或者叫做方法

然而,在C语言中,并没有类似Java语言这样的class关键字来统筹数据类型的属性和方法。
而且,通常数据类型的属性和对应的方法是分开实现的,只不过会在同一个源文件中定义。

看看我们如何抽象单链表的数据类型属性:

typedef int ItemType;
typedef struct NODE {
    struct Node * next;
    ItemType value;
} Node;

注.注意指针域的位置,放在结构体的第一个元素有助于快速定位,因为指针的地址和结构的地址是一样的。

对应的数据类型的操作方法:

Node ** sll_locate(Node **, ItemType);

int sll_insert(Node **, ItemType);

int sll_modify(Node *, ItemType, ItemType);

int sll_delete(Node **, ItemType);

void sll_show(Node *);

void sll_destroy(Node **);

我们把链表的每个元素称为节点,而每个节点中都会含有两个区分域(只是通常的叫法而已):

  1. 数据域,value,我们进行了抽象,如果你喜欢可以自己定义想要的类型
  2. 指针域,next,这是用来找到后继节点的唯一方法

由于我们的操作可以应用于通用的排序链表,所以你会发现,链表的头指针你可以自己定义,只要你保护好它,不让它丢失,那你就可以很方便的拥有一个链表结构。

以后的文章都是以先描述,然后看实现,最后根据实现来讲述一些知识点和分析总结

插入操作的实现

描述:插入操作函数,应该接收一个头指针的指针和一个待插入的元素的值,并返回是否成功的区别码。

实现细节:

int sll_insert(Node ** linkp, ItemType new_value){
    Node * current = *linkp;
    while (current != NULL && current->value < new_value){
        linkp = &(current->next);
        current = *linkp;
    }

    Node * new = malloc(sizeof(Node));
    if (new == NULL)
        return ERROR;
    new->value = new_value;

    *linkp = new;
    new->next = current;

    return OK;
}

思考:插入操作有三中情况,在头部插入,在中间插入,在尾部插入,分情况考虑:

  1. 在头部插入,就表示我们的头指针需要修改,头指针需要修改,那就需要传入指针的指针
  2. 在中间插入,由于是单向链表,所以后继节点根本不会知道自己是否有前驱节点,所以真正敏感的操作是前驱节点和新元素
  3. 在尾部插入,这就涉及到遍历的边界,通常,链表最后如果没有元素了,那它的next应该保存NULL

根据我们的分析,其实函数的原型就已经确定了,这里比较特殊的就是头指针的指针

由于链表的特性,它是需要内存动态分配机制配合动态装填来完成的,那么它的步骤应该是:

  1. 使用malloc类似的内存分配函数,在堆内存中为节点数据结构分配存储空间
  2. 判断空间是否可以获得
  3. 动态填充数据域的值

再思考下,由于前面我们已经分析了,我们传入了头指针的指针,它是指针的指针,在C语言中有了它意味着什么呢?

  1. 首先,考虑C语言中,所有的函数调用都是值传递,叫做call-by-value,所以,如果形参定义的是指针的指针,那么我们就可以放心的修改传进来的指针的指针,它不会影响实参的值,但请注意,你不能修改它解引用后的值,毕竟你拿的可是指针。
  2. 模式的相似性,如:指针的指针=>指针=>节点空间=>利用&节点空间->next再次获得指针的指针,然后循环,如果你不明白,在纸上画出来就懂了,自己试试。

其实,我们可以通过前面的分析,将函数中的current变量去掉,因为指针的指针可以表示很多层含义,比如我们可以写成:

int sll_insert(Node ** linkp, ItemType new_value){
    while (*linkp != NULL && (*linkp)->value < new_value){
        linkp = &((*linkp)->next);
    }
    
    Node * new = malloc(sizeof(Node));
    if (new == NULL)
        return ERROR;
    new->value = new_value;
    
    new->next = *linkp;
    *linkp = new;
    
    return OK;
}

只不过,它的可读性会很差,所以,还是引入中间变量来方便表达。

然后,到了链表插入阶段,这里要注意一个细节,就是current,这是一个临时变量,他是运行时在栈内存空间分配的,它是一个指针的拷贝,如果你要修改真真的指针,一定要使用指针的指针,也就是linkp,而不是current,而用current给其他指针赋值是可以的。

*linkp = new;
new->next = current;

其他操作的重点分析

其实其他操作,和插入操作比起来还是简单一些。

这里主要讲解这些操作的重点细节。

对于索引操作ssl_locate,我们返回的是指向找到节点指针的指针,为什么呢?
其实它是为删除操作ssl_delete而准备的,对于单向链表而言,如果你找到一个元素,然后返回指向这个元素的指针没有有用,因为它无法找到前驱节点,你得到的指针,只不过是前驱节点的next指针的拷贝而已,自己思考下。

对于修改操作ssl_modify,你要注意,因为我们是有序链表,不能因为你修改了一个节点的值,然后导致链表变得无序,一个比较有效的方法是使用排序算法,进行内部调整,而对于我们简单的数据结构来看,其实只需要先通过旧值找到对应的节点,然后删除这个节点,最后将新节点插入链表,这样就可以简单的实现有序操作了,不过这里要注意,这会引入一个bug,看笔者的实现,这里应该判断ssl_delete的返回值,这很重要。

还有一个链表销毁操作,由于进程退出的时候,需要释放我们手动申请的内存,所以销毁链表是必要的。
它的基本思想是:

  1. 使用去头法,挨个将链表头上的节点去掉
  2. 需要一个变量来存储从链表上切下来的节点,用于后续释放
  3. 需要一个变量来更新头指针,并非一定要有这个变量,如果引入能够更清晰,还是值得的
  4. 最后,让头指针指向NULL,表示空表

说一些其他的关联话题:

链表的存储空间是使用操作系统提供的动态分配函数完成的,实际上,他们的位置在堆空间内,这是进程在内存镜像中的一种描述形式而已,而真正规划他们的是编译器和操作系统。
通过指针连接的节点,使得让节点的空间不是连续的,这和数组不同

通常,链表和数组存储的节点元素都是相同结构类型的数据(其实链表可以更加灵活,如果一个结构继承了另一个结构(我们称之为父结构),那么添加结构的子结构一样可以完美的使用链表,当然,他们的父子关系并没有面向对象语言那么丰富,但也足够表达一些东西,只不过,对于C语言来说要进行强制类型转换)。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值