目录
1.什么是链表
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。链表由一系列结点(链表中每一个元素称为结点)组成,结点可以在运行时动态生成。每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。
2.链表的定义
1.n个节点离散分配
2.彼此通过指针相连
3.每个节点只有一个前驱节点,每个节点只有一个后续节点,首节点没有前驱节点,尾节点没有后续节点。
- 专业术语
首节点:第一个有效节点
尾节点:最后一个有效节点
头节点:第一个有效节点之前的那个节点;头结点不存放有效数据;头节点的作用是为了方便对链表的操作。
头指针:指向头结点的指针变量
尾指针:指向尾节点的指针变量
简图如下所示:
确定一个链表需要几个参数?
答:只需要一个参数,可以通过头指针可以推算出链表的其他所有信息。
3.链表的分类
- 单链表
- 双链表:每一个节点有两个指针域
- 循环链表:能通过任何一个节点找到其他所有的节点
- 非循环列表
4.动态内存分配
- 传统数组的缺点:
1.数组的长度必须事先制定,且只能是常整数,不能是变量
例子:
int a[5]; //正确
int len = 5; int a[len]; //错误
2.传统形式定义的数组,该数组的内存程序员无法手动释放,在一个函数运行期间,系统为该函数中的数组所分配的内存会一直存在,直到该函数运行完毕,数组的空间才会被系统释放。
3.数组的长度一旦确定,其长度不能更改,数组的长度不能在函数运行过程中动态的扩充或减小
4.A函数定义的数组,在A函数运行期间可以被其他函数使用,但A函数运行完毕之后,A函数中的数组将无法在其他函数使用,传统定义的数组不能跨函数使用。
例子:
-
为什么需要动态内存分配?
动态数组很好的解决了传统数组的这四个缺陷,传统数组也叫静态数组。
4.1.malloc函数
malloc是memory(内存)allocate(分配)的缩写!
动态内存分配举例-动态数组的构造
假设动态构造一个int型一维数组
int *p =(int *)malloc(int len);
1.本语句分配了两块内存,一块是动态分配的,总共len个字节,另一块是静态分配的。
2.(int *) 强制把首地址强制转换成 int * 类型,告诉你的机器,返回地址指向的数据占几个字节。
例子1:
int main(void)
{
int i = 5; //静态分配了四个字节的空间
int *p = (int *)malloc(4);
/*
1.使用malloc函数需要添加malloc.h头文件
2.malloc只有一个形参,且为整型
3.4,代表的是请求系统为本程序跟配4个字节的内存
4.malloc函数只能返回第一个字节的地址(注意是第一个字节,只有一个字节)
5.malloc函数所在的一行,一共分配了8个字节的空间,p指针变量占4个字节,p所指向的内存也占四个字节
6.p本身所占的内存也是静态分配的,p所指向的内存是动态分配的
*/
*p = 5; //p是一个int * 类型的,那么 *p 就是int 类型的,所以能进行赋值运算,且操作的正是动态分配的内存
free(p); //free(p)表示把p所指向的内存释放掉,注意p本身的内存是静态的,不能由程序员手动释放。
printf("hello\n");
system("pause");
return 0;
}
例子2:
void f(int * q)
{
//*p =200; //错误,*p不存在
//q =200; //错误,q是int * 类型的
*q = 200; //OK
free(q); //把q所指向的内存释放掉
}
int main(void)
{
int * p = (int *)malloc(sizeof(int));//sizeof(int)返回值是int所占的字节数,为4
*p = 10; //把10赋值给以p的内容为地址的变量,即为动态分配的内存
printf("%d\n", *p);
f(p);
printf("%d\n", *p);
system("pause");
}
预测一下这个程序的执行结果:
可以看到*p既然成了一个垃圾值,而不是200?为什么?
分析:
4.2.动态数组的构造
1.malloc只有一个int型的形参,表示要求系统分配的字节数
2.malloc函数的功能是请求系统len个字节的内存空间,如果请求分配成功,则返回第一个字节的地址,如果分配不成功,返回NULL.
注意:malloc函数能且只能返回第一个字节的地址,所以我们需要把这个无任何实际意义的第一个字节的地址(称为干地址)转化成一个有实际意义的地址,因此,malloc前面必须加(数据类型 *),表示把这个无实际意义的第一个字节的地址转化成相应类型的地址。如:
int *p = (int *)malloc(50);
表示将系统分配好的50个字节的第一个字节的地址转化成int *类型的地址,更精确的说是把第一个字节的地址转化成四个字节的地址,这样p就指向了第一个四个字节,p+1就指向了第2个四个字节,p+i就指向了第i+1个的第4个字节。p[0]就是第一个元素,p[i]就是第i+1个元素。
double *p =(double *)malloc(80);
表示将系统分配好的80个字节的第一个字节的地址转化成double *类型的地址,更精确的说是把第一个字节的地址转化成8个字节的地址,这样p就指向了第一个8个字节,p+1就指向了第2个8个字节,p+i就指向了第i+1个的第8个字节。p[0]就是第一个元素,p[i]就是第i+1个元素。
例子:
4.3.动态内存和静态内存的比较
- 静态内存是系统自动分配的,由系统自动释放。
- 静态内存是在栈内分配的。
- 动态内存是由程序员手动分配的,手动释放。
- 动态内存是在堆分配的
跨函数使用内存的问题:
- 静态内存不可以跨函数使用
- 所谓静态内存不可以跨函数使用准确的说法:静态内存在函数执行期间可以被其他函数使用,静态内存在函数执行完毕之后就不能被其他函数使用了
- 动态数组可以跨函数使用:动态内存在函数执行完毕之后任然可以被其他函数使用。
4.链表的创建
首先创建一个结构体用于存放链表一些静态内存分配的数据,如下:
typedef struct Node
{
int data; //数据域
struct Node * pNext;//指针域
}NODE,* PNODE; //NODE等价于struct Node,PNODE 等价于 struct Node *
链表的创建代码如下:
PNODE create_list(void) //创建链表,返回一个PNODE类型的值,为头结点指针
{
int len;
int i;
int val; //用来存放临时节点的数值
//分配了一个不存放数据的头结点
PNODE Phead=(PNODE)malloc(sizeof(NODE)); //创建一个头节点
if(NULL==Phead) //
{
printf("内存分配失败,程序终止!\r\n");
exit(-1);
}
PNODE Ptail=Phead; //建立一个中间节点,然后然头节点指向空
Ptail->pNext=NULL;
printf("请输入要创建链表节点的个数 len= ");
scanf("%d",&len);
for(i=0;i<len;i++)
{
printf("请输入第%d个节点的值",i+1);
scanf("%d",&val);
PNODE Pnew = (PNODE)malloc(sizeof(NODE));
if(NULL==Pnew)
{
printf("内存分配失败,程序终止!\r\n");
exit(-1);
}
Pnew->data=val; //给新的块的数据赋值,其地址为Pnew
Ptail->pNext=Pnew; //Ptail 第一次保存的是Phead ,然后下一次
Pnew->pNext=NULL; //保存的是Phead->PNext(也是Pnew)
Ptail=Pnew; //以此类推,每一次会把当前的地址更新
}
return Phead;
}
代码分析:
首先使用malloc函数动态分配内存,创建一个头结点:
//分配了一个不存放数据的头结点
PNODE Phead=(PNODE)malloc(sizeof(NODE)); //创建一个头节点
if(NULL==Phead) //
{
printf("内存分配失败,程序终止!\r\n");
exit(-1);
}
动态分配一个siziof(NODE) ,也就是NODE结构体大小的动态内存,然后强制转换成 (NODE *) 类型,即为:PNODE 类型
返回分配内存的首地址给Phead。而Phead的数据类型是PNODE的,其存放的是NODE类型变量的地址。
在这里需要注意,此处分配了两块内存,一个是静态分配的,一个是动态分配的。
Phead 就是静态分配的,数据类型是PNODE, malloc分配是出来的内存是动态的,大小是NODE结构体大小。
那么Phead所占的大小是多少?
一个指针变量到底占用几个字节的内存空间?
预备知识
使用函数:sizeof(数据类型)
功能:返回值就是该数据类型所占的字节数。
例子:
sizeof(int) =4 sizeof(char) =1 sizeof(double) =8
sizeof(变量名)
功能:返回值是该变量所占的字节数。
假设p指向char类型变量(一个字节),q指向int类型变量(4个字节),r指向double类型变量(8个字节)
请问:p q r本身所占的字节数是否有区别?
结论:一个指针变量,无论它指向的变量占几个字节,该指针变量本身只占四个字节,一个变量的 地址是用该变量首字节的地址来表示。
值得注意的是,在硬件中每个地址都有一个编号,而且都是一个字节一个编号的。
为什么一个变量的地址用首字节表示,那为什么指针变量需要使用四个字节?
举例:
房子的大小和房子的编号是没有关系的,就像是变量的大小和变量地址所占内容大小是没有关系的,因为一个变量的地址仅仅用首地址来表示。
- 如果现在我的房子只有100间的话,那么我的房间编号用8个位(一个字节)表示就够咯,如上(0-255)。无论在哪个位置我都可以用一个字节来表示你的位置。
- 但是如果你的房间有2的32次方(等于4G的空间)那么大,你的一个字节还够表示吗?一个字节最大只能表示255啊,后面的房间编号就表示不了了,所以此时你需要的房间编号数量应该大于或者等于房间的数量吧,那么就应该就是2的32次方个编号咯,转换成字节就是4个字节。所以用四个字节表示地址,最大的内存是4G,就是这个道理!
注意:通过上面知道,无论表示哪一个房间号都应该用四个字节的地址,即使表示的是第一个,例如第1个表示的地址为:0x0001,第16个:0x000F。
接着执行下面的语句,创建出首节点,然后对首节点赋值,接着,把头结点和首节点相连起来
这一个循环执行完了,如果还有下一次循环呢,这个程序是如何运行的呢?
下一次循环又是执行下面的语句:
PNODE Pnew = (PNODE)malloc(sizeof(NODE));
if (NULL == Pnew)
{
printf("内存分配失败,程序终止!\r\n");
exit(-1);
}
//给新的块的数据赋值,其地址为Pnew
Pnew->data = val;
//Ptail 第一次保存的是Phead ,然后下一次
Ptail->pNext = Pnew;
//保存的是Phead->PNext(也是Pnew)
Pnew->pNext = NULL;
//以此类推,每一次会把当前的地址更新
Ptail = Pnew;
5.遍历链表
链表的遍历就是把所有链表里面的数据逐一输出。
当我们成功创建了一个链表之后,则数据应该是如下面简图所示:
头结点是不存放任何数据的,所以我们只需要判断头结点的指针域(pNext)是否为NULL,如果不为空说明有数据,那就输出,如果为NULL,说明已经到了尾节点,就不输出了。
代码如下:
void traveser_list(PNODE Phead) //遍历链表
{
PNODE p = Phead->pNext;
while (NULL != p) //链表不为空
{
printf("%d ", p->data);
p = p->pNext;
}
printf("\n");
return;
}
执行流程,如下简图所示:
6.链表的插入
先说一说节点插入的一个算法和思路:
- 算法1:
假如我们现在的链表简图如下:
现在想在序号为①的块后面插入一个块(假设p指向这个块)
既然需要在①和②之间插入一个块的话,就需要把它们断开,如果直接断开的话,你就找不到咯,因为最后还是要把它们连接起来的,所以找一个中间变量 r 把它保存起来。
然后把①的指针域指向p指向的这个块也就是想要插入的块咯;
这样就剩下最后一步,把插入的块(p指向的块)的指针域指向块②就OK了。
这样子就把一个块插入我们想要的位置了,但是这种算法显得复杂一点,我们也可以使用另一种算法,先把想要插入的块的指针域保存块②(指向块②)的地址,然后把块①的指针域指向要插入的块。
- 算法二
算法二相对算法一来说会简介很多。
整体代码如下:
bool insert_list(PNODE Phead,int pos,int val)//pos从1开始
{
int i=0;
PNODE p=Phead;
while(NULL!=p->pNext && i<pos-1)//此函数的目的是进行指向定位
//定位到插入元素前面一个元素
{
p=p->pNext;
i++; //统计pos前面
}
if(i>pos-1 ||p->pNext==NULL)
return false;
PNODE Pnew=(PNODE)malloc(sizeof(NODE)); //分配一个新的块
if(Pnew==NULL)
{
printf("内部分配失败,终止运行\n");
exit(-1);
}
Pnew->data=val; //给新元素赋值
Pnew->pNext=p->pNext;
p->pNext=Pnew;
return true;
}
此函数需要三个参数,第一个是指令哪个链表的头指针,第二个是插入的位置,第三个是插入的值。
首先,在函数中新建一个指针变量p保存链表的头指针:PNODE p=Phead;
其次,也是比较重要的一个步骤,就是定位到我们需要插入块的前面的序号
假如此时我想插入一个块到第3个位置,也就是在pos=2这个位置的后边,那我调用的函数就是
insert_list(Phead,3,10); //在3的位置,插入一个值为10的数据
然后再进行循环判断,这两个条件仍然是成立的,继续
运行完这一段后,i已经是等于2了,不满足i<2这个条件了,所以跳出while循环,继续向下执行
if(i>pos-1 ||p->pNext==NULL)
return false;
对 i 的值和p->pNext的值进行判断是否能进行插入工作
此时 i=2 不满足 i >pos-1=2,此时p->pNext 应该是pos=3这个位置,所以也是不成立的,所以不返回false,继续向下执行。
假设现在我只有三个存放数据的块,但是我却想插入位置为pos=4的块可以吗?
可以接着上面的分析
可以看到此时while里面的两个条件仍然是成立的,所以可以继续执行循环语句
执行完上面的一次,两个条件都不成立了,那么就退出循环了,接着向下执行
if(i>pos-1 ||p->pNext==NULL) return false;
此时i的值为3,不满足i>pos-1=3,但是p->pNext已经是NULL了,所以这个条件是成立的,直接就返回false了。
所以说如果只有三个有效节点的话,想插入第四个的话肯定是不成功的。
同理,可以看一看能否插入7的位置,答案肯定是不能的,因为根本没地方插进去,还是看看程序分析的逻辑吧
此时可以继续往下执行:
因为此时p->pNext 已经为NULL,所以循环不成立,所以跳出循环了,接着往下执行程序
if(i>pos-1 ||p->pNext==NULL) return false;
此时i=3,不满足i>pos-1=6 ,但是满足p->pNext=NULL,所以返回的是false。不能进行插入工作
如果确认了可以插入块可,那么就动态分配一个新的数据块
PNODE Pnew=(PNODE)malloc(sizeof(NODE)); //分配一个新的块
if(Pnew==NULL)
{
printf("内部分配失败,终止运行\n");
exit(-1);
}
然后对新的块进行赋值
Pnew->data=val; //给新元素赋值
使用如上的插入算法2进行插入
Pnew->pNext=p->pNext;
p->pNext=Pnew;
通过上面我们已经把指针变量 p 定位到要插入位置的前一个块上
插入过程如下图所示:
7.链表节点的删除
链表节点的删除思路和链表的插入差不多,只要熟悉了链表的插入的逻辑,理解删除相对来说会容易很多。
删除节点的思路,我们同样是需要三个参数,第一个:操作的链表;第二个:删除节点的位置;第三个:被删除的数值。
首次使用一个指针变量,定位到需要删除节点的上一个节点,判断是否能进行删除操作:
程序如下:
bool delete_list(PNODE Phead,int pos,int *pval)
{
int i=0;
PNODE p=Phead;
while(NULL!=p->pNext && i<pos-1) //此函数的目的是进行指向定位
//定位到删除元素前面一个元素
{
p=p->pNext;
i++; //统计pos前面
}
if(i>pos-1 ||p->pNext==NULL)
return false;
PNODE r=p->pNext; //先记住删除的节点,待会释放
*pval=r->data; //记住删除的值
p->pNext=p->pNext->pNext;
free(r);
r->pNext=NULL;
return true;
}
前面一段进行定位和判断是否能删除节点,详细看上一节吧
while(NULL!=p->pNext && i<pos-1) //此函数的目的是进行指向定位
//定位到删除元素前面一个元素
{
p=p->pNext;
i++; //统计pos前面
}
if(i>pos-1 ||p->pNext==NULL)
return false;
因为我们删除的是p所指向的块的下一个节点,此时我们需要去记住我们要删除的节点,当我们操作完成之后再释放这块被我们删除的内存(这也是动态内存分配的好处)。
使用 r指针变量,记住将被删除块的地址:r = p->pNext;
然后为了验证,把删除的值也传出来 :*pval=r->data;
然后使用语句: p->pNext = p->pNext->pNext; 删除一个节点
接着执行:free(r); 释放r所指向的内存空间
此时还有一句:r->pNext=NULL; 在这里要注意,free(r);释放的是r所指向的空间,不是释放r的控件,r本身是系统静态分配的,程序员无法手动释放,只能在系统运行结束之后自动释放。