链表
一、概念
对于顺序表的存储结构,如数组,最大的缺点就是:插入和删除的时候需要移动大量的元素。所以基于前人的智慧,我们有了链表
1、链表定义
链表是有一个个结点构成的,每个结点之间通过链接关系串联起来,每个结点都有一个后继结点,最后一个结点的后继结点为空
链表分为单向链表、双向链表、循环链表等
2、结点结构体的定义
typedef int DataType;
struct ListNode {
DataType data; // 数据域,可以是任意类型,这里用typedef将其与int同名
ListNode *next; //指针域,指向后继结点的地址
};
一个结点包含两部分:
3、结点的创建
ListNode *ListCreateNode(DataType data) {
ListNode *node = (ListNode *) malloc ( sizeof(ListNode) ); //利用库函数malloc分配一块内存空间,用来存放ListNode链表结点的对象
node->data = data; //将数据域设置为data
node->next = NULL; //将指针域设置为空
return node; //返回结点的指针
}
创建完毕以后
二、链表的创建——尾插法
1、算法描述
尾插法:从链表的尾部插入,即记录一个链表尾结点,然后遍历给定的数组,将元素依次插到链表的尾部,每插入一个结点,就将他更新为新的链表尾结点,需要注意的是:链表尾结点的初始情况为空
ListNode *ListCreateListByTail(int n, int a[]) {
ListNode *head, *tail, *p; // head春运出头结点的地址,tail存储尾结点的地址,
int i;
if(n <= 0)
return NULL; //数据元素为空时,返回空链表
idx = 0;
p = ListCreateNode(a[0]); //创建一个数据域为a[0]的链表结点
head = tail = p; //由于初始情况下只有一个结点,所以将链表头结点head和尾结点tail都设为p
while(++i < n) { //从数组中第一个元素开始,循环遍历数组
p = ListCreateNode(a[i]); //对除第0个元素以外的数据创建结点
tail->next = p; //将当前链表的尾结点的后继结点设为p
tail = p; //将最近创建的结点p最为新的尾结点
}
return head; //返回链表头结点
}
三、头插法
1、算法描述
从头结点前面插入元素,这样一来,我们就要将数据元素进行逆序,我们需要逆序访问数组元素达成插入操作
它的代码量会比尾插法少,时间复杂度也很低
ListNode *ListCreateListByHead(int n, int *a) {
ListNode *head = NULL, *p; //head存储头结点的地址,初始为空,p存储当前正在插入的结点地址
while(n--) { //总共需要插入n个结点,所以逆序循环n次
p = ListCreateNode(a[n]); //创建元素值为a[i]的链表结点
p->next = head; //将当前创建的结点的后继结点设为链表的头结点head
head = p; //将链表头节点head设为p
}
return head; //返回头结点
}
四、链表的索引
1、算法描述
给定一个链表头结点head,并且给定一个索引值为i(i>=0),求这个链表的第i个结点,链表的索引就类似于我们玩游戏闯关一样,一关一关的往后,直到你想要闯到的关卡为止,(需要注意的是和数组一样,同样要从下标为0开始)
ListNode *ListGetNode(ListNode *head, int i) {
ListNode *temp = head; //temp表示从链表头开始的游标指针,用于遍历操作
int j = 0; //j表示当前访问到的结点
while(temp && j < i) { //游标指针部位空并且j<i时,表示还没有访问到目标结点,继续循环
temp = temp->next; //将游标指针的后继结点作为新的游标指针
++j;
}
if(!temp || j > i) {
return NULL; //油表指针为空,或者j>i,说明索引的值超出链表结点,返回空结点
}
return temp; //最后返回找到的第i个结点
}
测试用例
void testListGetNode(ListNode *head) {
int i;
for(i = 0; i < 10; ++i) {
ListNode *node = ListGetNode(head, i);
if(!node)
printf("node(%d) :该索引值已超出链表结点数!!\n", i);
else
printf("node(%d) : %d.\n", i, node->data);
}
}
int main() {
int a[8] = {1,3,5,7,8,10, 2, 6};
ListNode *head = ListCreateListByHead(8, a);
testListGetNode(head);
return 0;
}
运行结果
node(0) : 1.
node(1) : 3.
node(2) : 5.
node(3) : 7.
node(4) : 8.
node(9) : 该索引值已超出链表结点数!!.
node(8) : 该索引值已超出链表结点数!!.
索引的时间复杂度最坏的情况下就是遍历整个链表,所以时间复杂度为O(n)
五、链表的查找
链表的查找基本上和索引一样,只是说所以是找下标,查找是找值,都需要遍历链表
ListNode *ListFindNodeByValue(ListNode *head, DataType v) {
ListNode *temp = head; //temp表示链表头开始遍历的油表指针
while(temp) { //游标指针非空,继续执行
if(temp->data == values) {
return temp; //发现数据域和给定的参数值相等时,返回对应的指针
}
temp = temp->next; //没有找到则将此时的游标指针作为新的游标指针继续迭代
}
return NULL; //若遍历结束大批没找到则返回NULL
}
测试用例
void testListFindNodeByValue(ListNode *head) {
int i;
for(i = 1; i <= 6; ++i) {
ListNode *node = ListFindNodeByValue(head, i);
if(!node)
printf("%d在链表中不存在", i);
else
printf("%d的位置在链表的第%d个位置!!", i,*node);
}
}
int main() {
int a[5] = {1, 3, 8, 2, 6};
ListNode *head = ListCreateListByHead(5, a);
testListFindNodeByValue(head);
return 0;
}
运行结果
1的位置在链表的第0个位置!! 8的位置在链表的第2个位置!! 4在链表中不存在. 5在链表中不存在.
六、链表结点的插入
1、算法描述
给定一个链表头head,并且给定一个位置 i(i≥0) 和 一个值 values,求生成一个值为 values 的结点,并且将它插入到 链表 第 i 个结点之后。
首先,我们需要找到第 i 个位置,可以利用上文提到的 链表结点的索引;然后,再执行插入操作,而插入操作分为两步:第一步就是 创建结点 的过程;第二步,是断开之前第 i 个结点 和 第 i+1 个结点之间的 "链",并且将创建出来的结点 "链接" 到两者之间。
ListNode *ListInsertNode(ListNode *head, int i, DataType v) {
ListNode *pre, *p, *aft; //先定义三个指针,当结点插入完毕后, pre -> p -> aft
int j = 0; // 定义一个计数器,当 j == i时,表明找到要插入的位置
pre = head; // 从 链表头结点 开始遍历链表
while(pre && j < i) { //如果还没有到链表尾,或者没有找到插入位置则继续循环
pre = pre->next; //将 游标指针 的 后继结点 作为新一轮的 游标指针
++j; //计数器加 1
}
if(!pre) {
return NULL; //元素个数不足,无法找到给定位置,返回 NULL
}
p = ListCreateNode(values); //创建一个值为values的结点
aft = pre->next;
p->next = aft;
pre->next = p; // 这三步就是为了将vtx插入到pre -> aft之间,插入完毕后pre -> p -> aft
return p; //最后,返回插入的那个结点
}
七、链表结点的删除
1、算法描述
给定一个链表头head,并且给定一个位置i(i≥0),将位置为 i 的结点删除,并且返回新链表的头结点(为什么要返回头结点?因为被删掉的有可能是原来的头结点)。
删除操作分三种情况:
(1) 空链表:无法进行删除,直接返回 空结点;(毕竟人家没有东西,你硬要人家拿出东西这种事情也不可能得对吧) (2) 非空链表删除头结点:缓存下 头结点 的 后继结点,释放 头结点 内存,再返回这个 后继结点; (3) 非空链表删除非头结点:通过遍历,找到 需要删除结点 的 前驱结点,如果 需要删除结点 自身为 空,则返回 链表头结点;否则,缓存 需要删除结点 以及它的 后继结点,将 前驱结点 指向 后继结点,然后再释放 需要删除结点 的内存,返回 链表头结点
ListNode *ListDeleteNode(ListNode *head, int i) {
ListNode *pre, *del, *aft;
int j = 0;
if(head == NULL) {
return NULL; //空链表,无法执行删除,直接返回
}
if(i == 0) { //需要删除链表第 0 个结点
del = head; //缓存第 0 个结点
head = head->next; //将新的 链表头结点 变为 当前头结点 的 后继结点
free(del); //调用系统库函数free释放内存
return head; //返回新的 链表头结点
pre = head; //从 链表头结点 开始遍历链表
while(pre && j < i - 1) { //找到将要被删除结点的 前驱结点pre
pre = pre->next;
++ j;
}
if(!pre || !pre->next) { //如果 前驱结点 为空,或者 需要删除的结点 为空,则直接返回当前 链表头结点
return head;
}
del = pre->next; //缓存需要删除的结点到del
aft = del->next; //缓存需要删除结点的后继结点到aft
pre->next = aft; //将需要删除的结点的前驱结点指向它的后继结点;
free(del); //释放需要删除结点的内存空间
return head; //返回链表头结点
}
八、链表的销毁
1、算法描述
链表的销毁,就是需要将 所有结点 的内存空间进行释放,并且需要将 链表的头结点 置空。
链表的销毁,可以理解成不断删除第 0 号结点的过程,直到链表头为空位置,只是一个循环调用
void ListDestroyList(ListNode **pHead) { //这里必须用二级指针,因为删除后需要将链表头置空
ListNode *head = *pHead; //通过 链表头结点的地址 获取 链表头结点
while(head) { //如果链表非空,则继续循环
head = ListDeleteNode(head, 0); //每次迭代,删除 链表头结点,并且返回其 后继结点 作为新的 链表头结点
}
*pHead = NULL; //最后,将 链表头结点 置空,这样当函数返回时,传参的head才能是NULL,否则外部会得到一个内存已经释放了的 野指针
}
九、链表的优缺点
1、优点
内存分配: 由于是链式存储,随时增加元素随时分配内存,不需要像数组那样进行预分配存储空间; 插入: 当拥有链表某个结点的指针时,在它 后继位置 插入一个新的结点的的时间复杂度为 O(1); 删除: 当拥有链表某个结点的指针时,删除它的 后继结点 的时间复杂度为 O(1);
2、缺点
索引: 索引第几个结点时,时间复杂度为 O(n); 查找: 查找是否存在某个结点时,时间复杂度为 O(n);