数据结构主要可以分为两大模块:
- 线性结构
- 非线性结构
本文主要开始讲线性结构。
什么是线性结构
线性结构,顾名思义,就是这些数据所有节点都能被一根线(指针)联系起来的一种结构。
线性结构的存储方式:
- 连续存储:【数组】
- 离散存储:【链表】
线性结构的常见应用方式:
- 栈
- 队列
专题 :【递归】
数组和链表
本小节学习数组和链表,从底层去了解和实现数组与链表,并分析两者对应的优缺点
数组
数组是最常见的链式存储结构,它是一段连续的内存空间,在内存中我们可以简单表示为下图样式
通过上图我们可以把代码中
int arr[6] = {1,2,3,4,5,6};
执行的操作从内存中脑补出来,同时我们可以简单分析一下,数组应该有的一些基本使用。如
- 初始化、
- 添加新元素、
- 插入新元素、
- 删除某个元素、
- 判断是否为空数组、
- 是否是满数组、
- 排序
- 倒序
- 查询是否包含某个元素
- ······
本小节就带着你手把手实现一个简单的数组的封装,借此来了解数组的数据结构以及内部的一些基本算法知识。这里就简单的以一个 int 类型的数组来示例,后面学到泛型的时候便可更加好的理解数组的实现。
首先简单分析一下数组中基本的属性,我们有上面的数组内存中的逻辑图可以确定数组有对应的内存空间,有一个内存起始地址,因为系统分配内存的时候长度是一定的,所以数组中有一个表示最大长度的属性,数组中的元素也可能会根据实际个数不定,所以肯定有元素个数的标记,这个标记不仅可以拿来查看数组元素个数,也能确定了元素的下标。通过简单的分析我们可以总结数组中基本的属性如下:
- 【pBase】 数组首字节地址
- 【length】 数组长度(决定分配的内存大小)
- 【count】 数组中有效元素个数
下面就根据上面的分析来实现一个简单的数组,这里只说一下思路,完整代码请在这里下载
数组实现代码
// 定义个数组
typedef struct Array {
int length; // 数组长度
int count; // 数组当前元素数 count
int *pBase; // 数组的首字节地址
}* PMyArray,MyArray; //两个别名,PMyArray 类似java中类名,定义的对象不带 * , MyArray类似于OC中的类型,定义的对象带 * 。
// 要实现的一些基本方法
/** 初始化数组*/
void init_Arr(MyArray *pArr, int len);
/** 追加数组*/
bool append_Arr(MyArray *pArr, int value);
/** 插入数组*/
bool insert_Arr(MyArray *pArr, int index , int value);
/** 删除数组*/
bool delete_Arr(MyArray *pArr, int index , int * pVal);
/** 是否满载*/
bool is_full(MyArray *pArr);
/** 是否为空*/
bool is_empty(MyArray *pArr);
/** 排序数组*/
void sort_Arr(MyArray *pArr);
/** 展示数组*/
void show_Arr(MyArray *pArr);
/** 倒序数组*/
void inversion_Arr(MyArray *pArr);
/** 获取一个默认初始化的数组*/
MyArray * get_Arr(void);
int main(void) {
MyArray arr; // 声明
int len = 6; // 定义数组长度
init_Arr(&arr,len); // 初始化
// 添加元素
int time = 0;
while (time < len) {
append_Arr(&arr, 100 + time);
time++;
}
insert_Arr(&arr, 6, 200); // 插入元素
insert_Arr(&arr, 7, 300);
insert_Arr(&arr, arr.count, 400);
insert_Arr(&arr, 0, 500);
insert_Arr(&arr, 3, 600);
show_Arr(&arr); // 打印数组元素
int val;
//delete_Arr(&arr, 3 ,NULL); // 删除第三个元素
delete_Arr(&arr, 3 ,&val);
printf("被删除的元素是 %d \n",val);
show_Arr(&arr); // 打印数组元素
inversion_Arr(&arr); // 倒序排列一下
show_Arr(&arr); // 打印数组元素
sort_Arr(&arr); // 排序数组
show_Arr(&arr); // 打印数组元素
#pragma mark -- OOP
// 类似于 OC 中的面向对象用法
MyArray * array3 = get_Arr();
printf("array3 address is %p\n",array3);
printf("array3 address is %p\n",&array3);
show_Arr(array3);
// 类似于 java 中的面向对象用法
PMyArray myArray4 = get_Arr();
printf("array4 address is %p\n",myArray4);
printf("array4 address is %p\n",&myArray4);
show_Arr(myArray4);
return 0;
}
// 初始化数组,对内部元素基本初始化
void init_Arr(MyArray *pArr,int len){
(*pArr).pBase = (int *)malloc(sizeof(int) * len); // 先分配一段内存空间,长度为 len * int长度。
if(pArr->pBase == NULL)
{
printf("内存分配失败,初始化失败\n");
exit(-1); // 终止程序
}else
{
pArr->length = len;
pArr->count = 0;
}
return;
}
/**
实现数组插入
1. 判断插入下标是否越界
2. 如果插入前数组已经满了,就增大一个长度,重新插入
3. 如果插入前数组未满
3.1 当插入位置小于最大元素下标,先把要插入目标点后面的元素往后移动一个单位。 如在{1,2,4,5}中第3个位置插入3,结果为{1,2,3,4,5}
3.2 当插入位置大于等于最大元素下标,直接在原来数组后面add最后一个元素。 如在{1,2}中第5个位置插入 3 结果为{1,2,3}
@param pArr 要操作的数组的指针
@param index 要插入的index 即下标
@param value 要插入的值
@return 返回插入成功/失败
*/
bool insert_Arr(MyArray *pArr, int index , int value){
// 为减少篇幅,建议直接下载源码
}
/**
实现数组删除某个元素
并可以从外部获得被删除元素的值
1. 判断删除下标是否越界
2. 判断原数组是否为空
3. 如果删除前先保存对应 index 的值赋值给外部变量
把要删除目标点后面的元素往前移动一个单位。 如在{1,2,3,4,5}中第3个位置删除3,结果为{1,2,4,5}
@param pArr 要操作的数组的指针
@param index 要伤处的index 即下标
@param pVal 要删除值的地址
@return 返回删除成功/失败
*/
bool delete_Arr(MyArray *pArr , int index ,int *pVal)
{
// 为减少篇幅,建议直接下载源码
}
/**
往数组尾部添加元素
@param pArr 数组指针
@param value 要添加的元素值
@return 添加成功/失败
*/
bool append_Arr(MyArray *pArr,int value){
if (!is_full(pArr)) { // 判断是不是已经满了数组
// 数组未满,添加
pArr->pBase[pArr->count] = value;
pArr->count++;
return true;
}else{
printf("数组已经满了,无法从尾部追加元素,请尝试从前面插入\n");
return false;
}
}
// 为了控制篇幅,这里不再进行罗列,可以直接去下载源码对比查看
数组的内存结构是线性的,通过这个例子的实现也更加容易理解。数组是一种常用的也是固定内存排序线性结构。然而在很多情况下是数据在内存中并不会这样线性的去排布。
虽说C语言是面向过程的语言,事实上这只是一种表象,学了数据结构其实我们可以发现我们完全可以用 C语言 实现面向对象语言的特性。事实上几乎所有的操作系统内核、机器语言底层、编译器层面都是使用 C语言 来实现的。
链表
链表是一种元素内存空间离散排列的线性数据结构。它的内存存储示意图大致如下:
链表是整个数据结构的一个基础,后面的线性结构几乎都需用到链表的知识,下面就从这几个方面来介绍链表。
- 定义
- 分类
- 算法
- 优缺点
【链表定义/特点】
n个节点离散分配
彼此通过指针相连
每个节点只有一个前驱节点,每个节点只有一个后续节点
首节点没有前驱节点,尾节点没有后续节点
首节点:
第一个有效节点
尾节点:
最后一个有效节点
头结点:
第一个有效节点前面的那个节点,头结点并不存放有效数据,加头结点的目的主要是为了方便对链表的操作,头结点的数据类型和首节点一样
头指针:
指向头结点的指针变量
尾指针:
指向尾节点的指针变量
确定一个链表需要的最少的参数有哪些?
根据链表的示意图可以简单推出来所有该链表的信息。
实际上只需要一个关键信息:
头指针
因为我们通过头指针就可以推算出链表的其他所有信息。
【节点的表示】
// 代码的表示如下
typedef struct Node{
int data; //数据域
struct Node *pNext; // 指针域 - 指向相同类型的下一个节点
}*PNODE,NODE; // PNODE 等价于 struct Node * , NODE 等价于 struct Node
【只需要拿到 pNext 指针就能推算出整个链表】
【链表的分类】
链表可以分为如下类别:
- 单链表 — 上面的实例
-
双链表 — 每个节点有两个指针域,分别指向前后两个节点
-
循环链表 — 能通过任何一个节点找到其他所有节点的链表,环形链表
-
非循环链表 — 非环形普通列表
【关于链表的常见算法】
常见算法有:
- 创建链表
- 遍历
- 查找
- 清空
- 销毁
- 求长度
- 排序
- 删除节点
- 插入节点
- ·····
下面就逐步讲一下各个算法的思路,用为算法的方式来帮助理解
1 创建链表
创建链表,首先需要确定链表的长度 int length 然后根据长度创建对应个数的节点,并把新创建的节点挂到链表的最后。简单来说就是:
1 创建头结点
2 循环创建新节点挂到链表尾部
创建头节点,首先要创建一个没有存储值的头节点,我们本身定义了 NODE 结构体,可以根据NODE类型动态分配一块内存空间
// 创建一个值域为空的头结点,给头指针分配内存
PNODE pHead = (PNODE)malloc(sizeof(PNODE)); // 头指针
创建新节点,需要根据定义的长度循环,这里的难点就是如何把新节点挂到链表尾部【这里还是要明确指针和指针变量的概念,这样会更好理解一些】
为了能让新创建的节点挂到尾部,我们直接使用pHead->pNext = pNew;
是不行的,这样只能最后添加一个,并且其他的都会被泄漏。这里我们使用一个站位尾节点来表示尾节点
PNODE pTail = pHead; // 站位尾节点,直接赋值给 pTail,这是栈内存分配;
pTail->pNext = NULL;
for循环{
// 1.保存值和指针,变成完整新节点
pNew->data = val;
pNew->pNext = NULL;
// 2.当前新节点追加到原来尾节点后面
pTail->pNext = pNew;
// 3.新节点变成原来尾节点
pTail = pNew;
}
为了便于理解,请先弄清楚,指针和指针变量区别,下图逐步讲解
2 遍历链表
遍历链表思路:
遍历链表只能通过头结点的 pNext 指针找到下一个节点,并循环到 pNext == NULL 为止,分别打印 值域的值即可。
// 通过头节点遍历后面节点
PNODE p = pHead->pNext; //获得首节点
while (p != NULL) {
printf("data = %d \n",p->data);
p = p->pNext;
}
printf("遍历完毕,退出\n");
return;
3 判断是否为空链表
判断是够为空链表,直接拿头结点的 pNext 指针取值判断是否为 NULL 即可
bool isEmpty_list(PNODE pHead)
{
if ((*pHead).pNext == NULL) {
return false;
}else{
return true;
}
}
4 求链表长度
求链表长度类似于遍历链表,这里主要是计算有效节点的个数,这个个数就是长度
int length_list(PNODE pHead)
{
PNODE p = pHead->pNext;
int len = 0;
while (p->pNext == NULL) {
len++;
p = p->pNext;
}
return len;
}
5 插入节点
其实思路很简单,主要是吧 p指向节点的的pNext 指向 q,
然后q所指向的下个节点直接指向原来p所指向的节点。
伪代码如下:
temp = p->pNext;
p->pNext -> q;
q->pNext = temp;
或者直接
q->pNext = P->pNext;
P->pNext = q;
本方法代码实现
bool insert_list(PNODE pHead , int pos , int val){
// 判断位置是否合法
PNODE q = pHead; // 要插入的位置,如果是 <= 0,则直接插到最前面,如果是 pos = 1 就插入第一个后面,pos = n,就插到第 n 个后面
int i = 0;
while (i < pos) {
if (q->pNext == NULL) {
printf("要插入的位置不合法;大于链表长度\n");
break;
}else{
q = q->pNext; //要被插入的节点
i++;
}
}
// 插入操作
PNODE p = (PNODE)malloc(sizeof(NODE));
if (p == NULL) {
printf("动态分配内存失败\n");
exit(0);
}
p->data = val;
// 前一个节点为q, 直接添加到 q 后面。
p->pNext = q->pNext;
q->pNext = p;
return true;
}
6 删除节点
// 思路:
// 想要删除后面的节点,直接让 p 的指针域指向下下个节点就完成了
// 这里的删除其实很方便,但是有个容易造成内存泄漏的误区
// 因为直接删除之后,我们就无法在拿到 p 后面的节点的地址,然后无法释放,最终内存泄漏
// 最终实现:
temp = p->pNext; // 临时变量保存要删除节点地址
p->pNext = p->pNext-pNext; // p 的指针域指向下下个节点
free(temp); // 释放被删除节点内存
代码实现
bool delete_list(PNODE pHead , int pos , int * val){
// 找到要被删除的元素保存起来
PNODE p = pHead;
int i = 0;
while (i < pos - 1) { // 找到被删除节点的前一个节点
if (p->pNext == NULL) {
break;
}
p = p->pNext;
i++;
}
if (i < pos && p->pNext == NULL) {
printf("你要删除的位置非法,此位置不在链表长度范围之内\n");
return false;
}
// 找到了要被删除的那个节点
PNODE t = p->pNext;;
*val = t->data; // 把被删除节点的值传给外面
p->pNext = t->pNext; // 直接指向后面一个节点
free(t); // 释放被删除节点内存,防止内存泄漏
return true;
}
7 链表排序
链表排序同数组不同,但很相似,都是把所有元素遍历然后找到相邻或者相反的值互换位置。
参考数组排序我们可以看一下代码实现
void sort_list(PNODE pHead)
{
// 数组的排序算法 <-> 链表排序互相对比更好学习排序
// for (i = 0 ; i < len - 1; i++) { // 外围的排序次数 ,没执行完以此只能确定某一最大或最小值
// for (j = i + 1; j < len; j++) { // 内循环的排序次数 ,执行完一次把 n 个元素,对比 n-1 次并换位置
//
// if ( a[i] < a[j]) {
// t = a[i];
// a[i] = a[j];
// a[j] = t;
// }
// }
// }
// 排序的算法有很多种
int len = length_list(pHead);
int i , j , t;
PNODE p , q;
for (i = 0 , p = pHead->pNext; i < len - 1; i++ , p = p->pNext) { // 外围的排序次数 ,没执行完以此只能确定某一最大或最小值
for (j = i + 1 , q = p->pNext; j < len; j++ , q = q->pNext) { // 内循环的排序次数 ,执行完一次把 n 个元素,对比 n-1 次并换位置
if (p->data > q->data) { // 比较两个节点数值,并换位置
t = p->data;
p->data = q->data;
q->data = t;
}
}
}
return;
}
小结和感悟
数组和链表内部是不一样的,但是他们的排序的思路确实一样的,比如冒泡。
从排序的思路上都是前一个跟后一个元素比较,把小的放到前面,以此类推
算法:
狭义的算法:
与数据的存储方式密切相关
广义的算法:
与数据的存储方式无关
泛型:
利用某种技术达到同一种效果:不同的存储方式,执行的方式是一样的
如何学习算法?
- 看真题流程
- 分析每个语句的功能
- 试数
自己搞不定,就去看答案,把答案弄懂,实在不行就先记住答案。