数据结构---链表结构体、指针深入理解(三)
文章目录
- 2.3 链表---实战练习
2.3 链表—实战练习
1、
- 对于找到倒数第K个位置的结点,因为只给出了头指针,那么我们需要做的就是先遍历这个链表,然后先明白这个链表是多长,只有确定了多长的链表才能找到倒数第K个位置,
- 怎么确定倒数第K个位置,上一步我们知道了这个链表的长度,那么倒数第K个位置就是n-k,由于没有演草纸,那我就直接在这里描述我的想法如果是1 2 3 4 5 6 7,倒数第二个位置是6,那么就是在6这个位置进行插入,那正序的位置是:(n-k)+1,我们虽然找到了正序的位置,但是我们需要知道这个位置的前一个结点,因为只有知道前一个位置的节点,才能找到这个位置的地址,因为倒数第K个位置的链接地址是在前一个位置中存储的,这样我们就能稍微的快速的插入。
- 然后进行插入,那么需要注意的是我们需要考虑边界条件吧。
#include <stdio.h>#include <stdlib.h>typedef int ElemType;typedef struct node
{ElemType data;struct node *next;}Node;Node* initList(){Node *Head = (Node*)malloc(sizeof(Node));/* 首先是申请一片内存,使用Head进行存储,并且需要声明这是一个什么类型的内存空间 */Head->data = 0;Head->next = NULL; return Head;}
关于这段代码的详细解读,以及在写的过程中遇到的问题:
-
sizeof(Node):这个地方是我们需要知道Node是多大的内存空间,使用sizeof编译器自动计算,这样我们就知道应该在mallcon中申请多大的内存去填充这个链表。
笔者写成 return * Head;
错误原因:* Head 表示对指针 Head 的解引用操作,返回的是 Head 指向的实际数据(例如结构体、整型等)。
并理解 return * Head 是返回值,而 return Head 是返回指针。
若函数声明的返回类型是指针(如 Node*),但实际返回的是值(如 Node),编译器会报类型错误(* Head)(解引用指针):获取指针指向的实际数据(值或结构体);若指针为 NULL 或未初始化,解引用会导致段错误;
int id = (* head).id; //等价于 head->id
Node *Head = (Node*)malloc(sizeof(Node));
首先我们需要理清一下
Node* initList()
Node *Head = (Node*)malloc(sizeof(Node))
这两个里面的Node * 和Node * Head 分别应该怎么理解?
我们需要从最开始看,
typedef struct node
{ElemType data;struct node *next;
}Node;
Node在这个结构体里面很好理解,
-
struct node
(结构体标签)**
用于定义结构体类型本身,在结构体内部声明指向自身的指针时(如next
指针),必须使用完整的struct node
。因为此时结构体类型尚未完全定义,编译器无法识别别名Node
。因为next存储的是下一个结构体的首地址,这个指针类型变量必须是struct node
这个结构性,因此必须使用struct node
进行声明。 -
Node
(类型别名)**
通过typedef
为struct node
创建的新别名,作用域与普通变量相同。在外部代码中可直接用Node
代替struct node
,简化声明。
接下来我们就回归我们的问题:这两个里面的Node * 和Node * Head 分别应该怎么理解?
Node*
与 Node *Head
的书写差异.
本质相同,风格不同
Node* initList()
→ 函数返回类型是Node*
(指向Node的指针)Node *Head = ...
→ 变量Head
的类型是Node*
(指向Node的指针)
两种写法中的*
均表示指针类型,以下形式完全等价:
Node* Head; // 风格1:强调 Head 的类型是 Node*
Node *Head; // 风格2:强调 *Head 是一个 Node 对象
Node * Head; // 风格3:空格不影响语义(不推荐)
推荐 Node *Head
避免多重指针声明时的歧义。例如:
Node* p1, p2; // p1 是指针,p2 是普通结构体(易错!)
Node *p1, *p2; // 明确 p1 和 p2 均为指针
Node *Head
:指针声明
Node *Head
表示声明一个指针变量 Head
,其类型为 Node*
(即指向Node
结构体的指针)。这里的 *
是类型声明的一部分,强调 Head
存储的是地址而非数据本身。
*Head
:解引用操作
当 Head
已定义为指针时,*Head
表示对指针进行解引用(Dereference),即访问 Head
指向的内存中的 Node
对象。
(*Head).data = 10; // 等价于 Head->data = 10
Head*
:为何非法?
C语言要求变量名必须是合法标识符(不能包含 *
)。Head*
会被编译器解析为名为 Head*
的变量,触发语法错误。
并且需要注意的是*a
是不合法的,明白这一点是很关键的,以下是具体解释:
在C语言中,声明指针时必须明确指定指针指向的数据类型,直接写成*a
是不合法的语法。这是因为指针的核心功能是通过内存地址间接操作特定类型的数据,而编译器需要根据类型信息决定相关关键行为。
内存访问的语义
指针解引用(*a
)时,编译器需知道读取/写入的内存范围。例如:
int *a
:解引用时读取sizeof(int)
字节(通常4字节)char *a
:解引用时读取sizeof(char)
字节(1字节)- 若未指定类型,编译器无法确定操作的内存大小。
指针运算的合法性
指针加减整数时(如a+1
),实际地址偏移量由指向类型的大小决定。
int *p = 0x1000;
p++; // 新地址 = 0x1000 + sizeof(int) = 0x1004
若类型未知,编译器无法计算偏移量。
类型安全校验
指定类型后,编译器可检查赋值兼容性。
int x = 10;
float *p = &x; // 警告:int* 到 float* 的类型不匹配
C语言是静态类型语言,所有变量(包括指针)必须在编译期确定类型。指针的类型信息决定了:
- 解引用的语义:
*a
应解释为何种数据类型 - 算术运算的步长:
a+1
的地址偏移量 - 内存对齐要求:某些架构要求特定类型地址对齐(如
int
需4字节对齐)
==核心原则== - 声明必带类型:
[类型] *[变量名]
是唯一合法形式(如int *a
或Node *a
)。 -
*
的双重角色**:- 声明时:修饰变量为指针类型(
int *a
) - 使用时:解引用操作符(
*a = 10
)
- 声明时:修饰变量为指针类型(
- 避免未定义行为:未指定类型的指针无法通过编译,从根源上防止了内存访问错误。
所以嵌入式开发或者是C语言我们常说的一句话就是对内存的理解使用,也就是指针的使用,如果使用内存我们就需要对内存读取或者写入的范围,那对于这个写入和读取的范围就需要我们对指针有清晰的认识,因为在C语言中指针变量是唯一一个桥梁,那我们怎么确认我们读取和写入的长度呐,一方面就是只能通过指针的声明来确定我们的读取和写入的长度,另一方面不就是通过其他的方式,有肯定是有的,但是现在还没有理解那么深刻,就不便多赘述,个人猜测会有通过数组、结构体等等,但是本质对于指针变量我们还是需要进行声明类型,因为不声明类型说白了,编译器也不知道这个类型什么,因为不同的类型是需要不同的操作手法,多以类型很关键。
结合以上理解我们回过头再看这行代码,就会有深刻的理解
Node *Head = (Node*)malloc(sizeof(Node));
sizeof(Node)
计算Node
结构体所需内存大小(包括data
和next
指针)。`-
malloc
功能
malloc(sizeof(Node))
在堆内存中分配一块大小为sizeof(Node)
字节的空间,返回该空间的起始地址(类型为void*
)。又因为malloc
返回void*
类型(通用指针),需通过(Node*)
显式转换为目标类型Node*
,以便指针操作时类型匹配。例如:(Node*)malloc(...)
将返回的地址标记为“指向Node
的指针”,使Head
能正确访问结构体成员(如Head->data
)。 Node *Head
就是前面刚刚叙述的C语言关于指针声明的说明。这样其实也能更好的说明我们为什么习惯性写成Node *Head;
,而不是写成Node* Head;
个人理解这是因为对于解引用的时候我们使用的是*Head;
而声明的时候其实就是相当于是在这个前面增加了一个类型关键字,所以为了避免花里胡哨的写法,我们就干脆写成Node *Head;
因为即时这样写也不会被认为是解引用,这样反而更能清晰的认识指针声明和解引用指针。
(Node*)malloc(sizeof(Node));
这里是一个强制转换,正常返回的是一个void*
类型指针,编译器是不知道这是一个结构体类型的,那么怎么办呐,那就只能将这个指针类型强制转换为结构体指针。
我们在强制转换之前其实已经知道了这个内存的范围并且返回的是起始地址,但是编译器还不知道怎么使用它,因为不知道他的类型,就不能进行操作,这是C语言的设定,操作指针类型变量必须知道类型,不然就直接罢工!!!!!! 所以就需要进行强制转换。
此外在这里补充一些地址相关的内容,这个地址存储的地址在哪里,也就是一级指针和二级指针,变量的地址从某种程度来说也是一个变量,那么这个变量的地址又会存到新的地址上面,也就是为什么指针变量有步长的原因,因为需要多个地址空间来存储这个变量。
int a = 10;int *pa = &a; // 存储a的地址到paprintf("a的地址:%p\n", (void*)&a); // 0000002ea01ffa3cprintf("pa的值:%p\n", sizeof(pa)); // 输出与上一行相同printf("指针 pa 自身的地址:%p\n", (void*)&pa);unsigned char *p = (unsigned char*)&pa;for (int i = 0; i < sizeof(pa); i++) {printf("字节 %d: %02x\n", i, p[i]);}// 逐字节打印pa内存中存储的内容(即a的地址)unsigned char *pp = (unsigned char*)&pa; // 将pa的地址转为字节指针for (int i = 0; i < sizeof(pa); i++) {printf("地址 %p 处的字节 %d: %02x\n", (void*)(&pp[i]), i, pp[i]);}
#include <stdio.h>int main() {int a[2] = {1, 8}; // 定义长度为2的整型数组int *pa = a; // 数组名a退化为首元素指针(等价于&a[0])// 打印数组各元素的地址和值for (int i = 0; i < 2; i++) {printf("a[%d]的地址:%p\n", i, (void*)&a[i]); printf("a[%d]的值:%d\n", i, a[i]); }unsigned char *byte_ptr = (unsigned char *)&a[0];for (int i = 0; i < sizeof(int); i++) {printf("字节 %d 的地址: %p \t 值: %02x\n",i, (void*)(byte_ptr + i), byte_ptr[i]);}// 打印指针pa存储的值(即a[0]的地址)printf("pa存储的值(数组首地址):%p\n", (void*)pa); // 打印指针pa自身的地址printf("指针pa自身的地址:%p\n", (void*)&pa); // 逐字节打印pa内存中存储的内容(即数组首地址)unsigned char *pp = (unsigned char*)&pa; // 将pa的地址转为字节指针for (int i = 0; i < sizeof(pa); i++) {printf("地址 %p 处的字节 %d: %02x\n", (void*)(&pp[i]), i, pp[i]);}return 0;
}
a[0]的地址:00000032be3ffda8
a[0]的值:1
a[0]的步长值:4
a[1]的地址:00000032be3ffdac
a[1]的值:8
a[1]的步长值:4
字节 0 的地址: 00000032be3ffda8 值: 01
字节 1 的地址: 00000032be3ffda9 值: 00
字节 2 的地址: 00000032be3ffdaa 值: 00
字节 3 的地址: 00000032be3ffdab 值: 00
pa存储的值(数组首地址):00000032be3ffda8
指针pa自身的地址:00000032be3ffda0
地址 00000032be3ffda0 处的字节 0: a8
地址 00000032be3ffda1 处的字节 1: fd
地址 00000032be3ffda2 处的字节 2: 3f
地址 00000032be3ffda3 处的字节 3: be
地址 00000032be3ffda4 处的字节 4: 32
地址 00000032be3ffda5 处的字节 5: 00
地址 00000032be3ffda6 处的字节 6: 00
地址 00000032be3ffda7 处的字节 7: 00
通过上述例子,可以更好的理解为什么需要强制转换,以及数组里面步长的原因,或者是结构体内部访问编译器进行的一些操作。
回归正题
return Head;
我们需要返回这个指针,那么因为上面我们已经阐述了写法问题,因此要想返回指针,就是这种写法。这个Head就相当于这个指针的标识符。
int a[2] = {1, 8}; // 定义长度为2的整型数组int *pa = a; // 数组名a退化为首元素指针(等价于&a[0])
数组名的退化机制
- 在C语言中,数组名在大多数表达式中会退化为指向其首元素的指针(类型为
int*
)。 - 因此
a
等价于&a[0]
,即数组第一个元素a[0]
的地址。 - 此行为是C语言标准定义的隐式转换,无需显式取地址操作。
指针赋值的合法性
int *pa = a;
将pa
初始化为指向a[0]
的地址,语法完全正确。- 通过
pa
可访问数组元素:*pa
或pa[0]
获取a[0]
(值为1
)*(pa+1)
或pa[1]
获取a[1]
(值为8
)
int *pa = a;
是合法且高效的写法,直接利用数组名退化机制。
需明确退化规则和指针运算逻辑,避免越界访问。
若需操作多维数组,需注意退化后类型不同(如二维数组退化为 int(*)[N]
)
类似于上述的表达这种,就是一个标识符,这个标识符存储的是这个结构体的起始地址。
双指针使用(快慢指针)
怎么说呐,如果我们找的是倒数第三个位置,那么我们就让快指针先走三步,接着我们在让两个指针同时走,这样快指针和慢指针始终就差三个位置,那么当快指针走到最后的时候,我们的慢指针指向的就是倒数第三个位置。回归到本题,我们需要的是倒数K个节点,那么只要快指针和慢指针相差K个距离,当快指针指向最后结点的时候,慢指针就指向了倒数第K个位置。
int findNodeFS(Node *L, int k){Node *fast = L->next;Node *slow = L->next;for(int i = 0; i<k; i++){fast = fast->next; /* 先让快指针走K步 */}while(fast != NULL){fast = fast->next;slow = slow->next;}}
链表这个访问真的很神奇,
while(fast != NULL){fast = fast->next;slow = slow->next;}
就这一个while代码,可以一直进行链表访问,为什么呐,就是指针和结构体的妙用。
真的很妙,很妙。
首先我们知道fast
是一个指针变量,并且还是一个Node类型的指针变量,而Node是什么类型:是一个结构类型,表达是一个链表的一个节点。并且这个结构体里面的next
成员保存的还是下一个节点的结构体的其实地址。
typedef struct node
{ElemType data;struct node *next;}Node;
因此我们使用fast = fast->next;
很巧妙的就进行了嵌套访问,使用fast->next
找到下一个节点的起始地址,我们赋值给fast
,然后编译器会自动的明白这个起始地址是一个结构体的起始地址,接着进行下一个循环,因为已经知道了这是一个结构体起始地址,那么就再次使用->这个符号进行成员访问,也就是fast = fast->next
,将这一轮的新的下一个结点的其实地址再次给fast
,然后就一直进行往复操作,真的是太妙了!!!。
#include <stdio.h>#include <stdlib.h>typedef int ElemType;typedef struct node{ElemType data;struct node *next;}Node;//初化链表Node* initList(){Node *head = (Node*)malloc(sizeof(Node));head->data = 0;head->next = NULL;return head;}//头插法int insertHead(Node* L, ElemType e){Node *p = (Node*)malloc(sizeof(Node));p->data = e;p->next = L->next;L->next = p;return 1;}//遍历void listNode(Node* L){Node *p = L->next;while(p != NULL){printf("%d ", p->data);p = p->next;}printf("\n");}//获取尾部结点Node* get_tail(Node *L){Node *p = L;while(p->next != NULL){p = p->next;}return p;}//尾插法Node* insertTail(Node *tail, ElemType e){Node *p = (Node*)malloc(sizeof(Node));p->data = e;tail->next = p;p->next = NULL;return p;}//指定位置插入int insertNode(Node *L, int pos, ElemType e){Node *p = L;int i = 0;while(i < pos-1){p = p->next;i++;if (p == NULL){return 0;}}Node *q = (Node*)malloc(sizeof(Node));q->data = e;q->next = p->next;p->next = q;return 1;}//删除节点int deleteNode(Node *L, int pos){Node *p = L;int i = 0;while(i < pos-1){p = p->next;i++;if (p == NULL){return 0;}}if(p->next == NULL){printf("要删除的位置错误\n");return 0;}Node *q = p->next;p->next = q->next;free(q);return 1;}//获取链表长度int listLength(Node *L){Node *p = L;int len = 0;while(p != NULL){p = p->next;len++;}return len;}//释放链表void freeList(Node *L){Node *p = L->next;Node *q;while(p != NULL){q = p->next;free(p);p = q;}L->next = NULL;}//查找倒数第k个节点int findNodeFS(Node *L, int k){Node *fast = L->next;Node *slow = L->next;for (int i = 0; i < k; i++){fast = fast->next;}while(fast != NULL){fast = fast->next;slow = slow->next;}printf("倒数第%d个节点值为:%d\n", k, slow->data);return 1;}int main(int argc, char const *argv[]){Node *list = initList();Node *tail = get_tail(list);tail = insertTail(tail, 10);tail = insertTail(tail, 20);tail = insertTail(tail, 30);tail = insertTail(tail, 40);tail = insertTail(tail, 50);tail = insertTail(tail, 60);tail = insertTail(tail, 70);listNode(list);findNodeFS(list, 3);return 0;}
- 以上是整体思路,但是有一个问题就是,我们在考虑这个题的时候,其实我有一个疑问就是题上又没有说明一共是多长,我们应该怎么确认呐,这个地方因为我不考研所以我就没有深究具体的解决思路或者说是得分规则,怎么说呐,这个题我个人觉得从技术角度,新增了双指针的使用规则,以及深入的理解了结点结构体的用法,我觉得这是最大的收获,至于怎么在项目中怎么使用,或者说更进进一步,等后面再刷Leecode的时候遇到类似的在仔细深究就行。
但是目前的整体思路就是这个样子,并且现在思路其实已经解决了这个题,换句话说我们就假设这个链表的节点是6个不就行了,这样这个题的答案不就是上述的代码并且思路已经详细拆解了。
文章源码获取方式:
如果您对本文的源码感兴趣,欢迎在评论区留下您的邮箱地址。我会在空闲时间整理相关代码,并通过邮件发送给您。由于个人时间有限,发送可能会有一定延迟,请您耐心等待。同时,建议您在评论时注明具体的需求或问题,以便我更好地为您提供针对性的帮助。
【版权声明】
本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议。这意味着您可以自由地共享(复制、分发)和改编(修改、转换)本文内容,但必须遵守以下条件:
署名:您必须注明原作者(即本文博主)的姓名,并提供指向原文的链接。
相同方式共享:如果您基于本文创作了新的内容,必须使用相同的 CC 4.0 BY-SA 协议进行发布。
感谢您的理解与支持!如果您有任何疑问或需要进一步协助,请随时在评论区留言。