数据结构
数据结构概述
定义:
我们如何把现实中大量而复杂的问题以特定的数据类型和特定的存储结构保存到主存储器(内存)中,以及在此基础上为实现某个功能(比如查找某个元素,删除某个元素,对所有元素进行排序)而执行的相应操作,这个相应的操作也叫算法
狭义
数据结构是专门研究数据存储的问题
数据结构 = 个体 的存储+ 个体关系的储存
从某个角度而言,数据的存储最核心的就是个体关系的存储,个体的存储可以忽略不计
广义
数据结构包含数据的存储也包含数据的操作
对数据的操作就是算法
算法:
狭义:算法是和数据的存储方式密切相关
广义:算法和数据的存储方式无关,泛型思想
泛型:利用某种技术达到的效果就是:不同的存储方式,执行的操作是一样的
同一种逻辑结构,无论该逻辑结构内部物理存储是什么样子,我们可以对它执行相同的操作
解题的方法和步骤
衡量算法的标准
1. 时间复杂度
大概程序要执行的次数,而非执行的时间
2. 空间复杂度
算法执行过程中大概所占用的最大内存
3. 难易程度
4. 健壮性
数据结构的地位
数据结构是软件中最核心的课程
程序 = 数据的存储 + 数据的操作 + 可被计算机执行的语言
预备知识
1. 指针
指针的重要性:指针是C语言的灵魂
定义
地址:内存存储的编号
从0开始的非负整数
范围:0 – FFFFFFFF(4G-1)
指针:指针就是地址 地址就是指针
指针变量是存放内存单元地址的变量
指针的本质是一个操作受限的非负整数(不能加乘除,只能在某种特定情况下进行减法)
分类:
1) 基本类型的指针
p存放了i的地址,说明 ①p保存了i的地址,p指向i ②修改p或者i不会相互影响 ③ *p 就是i,i就是 *p
内存的基本单位是字节
1字节是8位
整数型
short :2个字节,16位 int :4个字节,32位 long:8个字节,64位;
浮点型:
float:4个字节,32 位 double:8个字节,64位
注:默认的是double类型,如3.14是double类型的,加后缀F(3.14F)则为float类型的。
字节类型:char:2个字节,16位
字节和编号是一一对应的,每个字节都有一个唯一确定的编号,一个编号对应一个字节,这个编号也叫地址
int i = 10;
int * p = &i; //相当于int * p ; p = &i;
如何通过被调函数修改主调函数中普通变量的值
①实参为相关变量的地址
②形参为以该变量的类型为类型的指针变量
③在被调函数中通过 *形参变量名 的方式就可以修改主函数的变量值
#include <stdio.h>
void f(int * i)
{
* i = 100;
}
int main(void)
{
int i = 9;
f(&i);
printf("%d\n", i); //i = 100
return 0;
}
2) 指针和数组的关系
一维数组名是个指针常量,它存放数组第一个元素的地址,它指向数组的第一个元素,值不能被改变
a[i] = *(a+i)
a[3] = *(a+3) = *(3+a) = 3[a]
printf("%p\n", a); //%p表示输出p的地址以16进制输出
# include <stdio.h>
void Show_Array(int * p, int len) //p是int * 类型,a传送给了p,a = &a[0],则p = &a[0]
{
p[0] =-1; // p[0] == *p == *a == a[0]
}
int main(void)
{
int a[5] = {1,2,3,4,5};
Show_Array(a, 5); //a本来就是int * 类型的,所以可以发送给 同类型 的p
printf("%d\n", a[0]);
return 0;
}
p+i 的值是p+i*(p所指向的变量所占的字节数)
如何通过被调函数修改主调函数中一维数组的内容
两个参数①存放数组首元素的指针变量 ②存放数组元素长度的整型变量
int i, * p;
p = &i; //p和i占8个字节,1字节1地址,但是p并非保存了i的8个地址,而是只存放其首地 址或者末地址
在64位计算机中,无论指针变量是int,char,double*类型,统统只占8个字节
2. 结构体
结构体是一种复合的数据类型,不是变量
结构体名字叫做struct student,有有限个成员
定义
struct Student
{
int age;
char name[100];
int score;
}; //分号不能省
定义了一个数据类型,该数据类型叫做struct Student,该数据类型有三个成员,没有定义变量
结构体的使用 重点:(3)
1)struct Student st = {50, “lisi”, 150};
2)struct Student st;
st.age = 50;
strcpy(st.name, “lisi”); //定义库<string.h> ,不能是st.name = “lisi”
st.score = 150;
3) struct Student st;
struct Student * pst; //pst占了4个字节
pst = &st;
pst->age = 50; //pst->age == (*pst).age
pst->name = “lisi”;
pst->score = 150;
pst->sid 表示pst所指向的结构体变量中的sid这个成员
注意事项
结构体变量不能加减乘除,只能相互赋值
普通结构体变量和结构体指针变量作为函数参数的传递
3. 动态内存的分配和释放
表达:int * p = (int *)malloc( sizeof(int)*len );
1)malloc只有一个int类型的参数
2)使主函数中指针类型p指向一个整型变量
#include <stdio.h>
#include <malloc.h>
void fun (int ** q)
{
/*
int s;
*q = &s;
*/ //并不能实现,fun函数调用完后s的使用权会被操作系统回收,因而主函数并不能使得p满足
*q = (int *)malloc( sizeof(int) ); //*q == p,malloc只在free释放
}
int main
{
int * p;
fun (&p); //把p的地址传给了q,不是*q等别的,因此*q = p
return 0;
}
模块一:线性结构
线性:把所有的结点用一根直线穿起来
连续存储[数组]
数组就是元素类型相同,大小相等的连续存储的序列
优点:存储速度快
缺点:事先必须知道数组的长度;插入删除元素很慢;空间通常是有限的;需要大块连续的内存块
离散存储[链表]
typedef的用法
typedef struct Student {
int sid;
in age;
}ST, * PST; //PST等价于struct Student *类型
定义
n个节点离散分配; 彼此通过指针相连; 每个节点只有一个前驱节点,每个节点只有一个后继节点;首节点没有前驱节点,尾节点没有后继节点
专业名词:首节点,尾节点,头结点,头指针,尾指针
函数对链表进行处理的参数
- 首节点可以通过头结点找到,非必须
- 尾节点可以通过判断是否指向空,非必须
- 头结点为参数时,当头结点存放的为1000个时,数据太大,非必须
综上所述:知道头结点的地址即头指针已知就可以找到整个链表,因此只需一个参数,知道头指针即可
链表的表示
struct Node {
int data; //数据域
struct Node * pNext; //指针域
}
分类
单链表
双链表:每个节点都有两个指针域
循环链表:能通过任何一个节点找到其他所有的节点(尾节点又指向了前面的节点)
非循环链表
有关算法
狭义的算法是与数据的存储方式密切相关
广义的算法是与数据的存储方式无关
泛型:利用某种技术达到的效果是:不同的存储方式,执行的操作是一样的
遍历
查找
清空
销毁
求长度
排序
删除节点 r = p->pNext;p->pNext = r->pNext; free®;
不能只写p->pNext = p->pNext->pNext;因为这样子会导致要删除的节点还在内存里面,没法释放;跳节点查找会导致内存泄漏,不可取
free r的含义:把r所指向的节点占用的内存释放,而不是释放r本身的内存
插入节点
q->pNext = p->pNext; p->pNext = q;
优点:空间没有限制;插入元素很快
缺点:存储速度很慢
线性结构 的两种常见应用之一 栈
凡是静态分配的都是栈分配的 int i = 10;
凡是动态分配的都是堆分配的 int p = (int)malloc(100);
定义
一种可以实现“先进后出“的存储结构
栈类似于箱子
分类
静态栈
动态栈
动态栈的添加只能从顶端添加,而不能从底部添加
初始化动态栈的含义
———————————————————————————————————————————————————————————
———————————————————————————————————————————————————————————
代码实现
void inti(PSTACK pS) {
pS->pTop = (PNODE)malloc(sizeof(NODE)); //动态分配一个PNODE节点,并将其地址发送给ps->pTop
if (NULL == pS->pTop) {
printf("动态内存分配失败!\n");
exit(-1);
}
pS->pBottom = pS->pTop; //将所分配的节点的地址也发送给pS->pBottom,完成了操作的第一步,即实现了空栈地创建
pS->pTop->pNext = NULL; //相当于pS->pBottom->pNext = NULL;
}
出栈操作
不能直接pS->pTop = pS->pTop->pNext; 因为pS->pTop所指向的节点是动态分配的,这样做根本释放不了内存
代码:PNODE r = pS->pTop; pS->pTop = pS->pTop->pNext; free®;
pS->pTop一般不要改,包括,遍历,清栈的功能(出栈可以)
算法
出栈
压栈
应用
函数调用
中断
表达式求值
内存分配
缓冲处理
迷宫
线性结构的两种常见应用之二 队列
定义
一种可以实现“先进后出”的存储结构
分类
链式队列——用链表实现
原理:
队列初始化的含义
创建队列中足够空间来为数组分配空间,并使得front = 0; rear = 0;
删除队列元素
增加队列元素
静态队列——用数组实现
静态队列通常都必须是循环队列
循环队列的讲解
-
静态队列为什么必须是循环队列
因为若是数组元素删除,f就要前进一位,但是其前进的空间并没有被使用,而且再也不能增加元素,这样造成了空间浪费 -
静态队列需要几个参数来确定
需要两个参数:front和rear -
循环队列各个参数的含义
2个参数不同场合有不同的含义,建议初学者先记住,再慢慢体会
1) 队列初始化
front和rear的值都是0
2) 队列非空
front代表的是队列的第一个元素
rear代表的是最后一个有效元素
3) 队列空
front和rear的值相等,但不一定是0 -
循环队列入队伪算法讲解(r+)
①将值存入r所代表的位置
②错误的写法:r = r+1
正确的写法:r = (r+1)%数组的长度 -
循环队列出队伪算法讲解(f+)
f = (f+1)%数组的长度 -
如何判断循环队列是否为空
如果front和rear的值相等,则一定为空 -
如何判断循环队列是否已满
预备知识:front的值可能比rear大,也可能比较小,也可能两者相等
两种方式:1.多增加一个标识参数
2.少用一个元素【通常使用第二种方式】
伪算法表示
如果和f的值紧挨着,则队列已满
if( (r+1)%数组长度==f )
已满
else
不满 -
如何计算循环队列的长度
(Q.rear - Q.front + Q.maxSize) % Q.maxSize
队列的具体应用
所有和时间有关的操作都有队列的影子
专题:递归
定义
一个函数直接或间接调用自己
函数的调用
- 当在一个函数的运行期间调用另一个函数时,在运行被调函数之前,系统需要完成三件事:
①将所有的;实际参数,返回地址等信息传递给被调函数保存
②为被调函数的局部变量(也包括形参)分配存储空间
③将控制转移到被调函数的入口 - 从被调函数返回主调函数之前,系统也要完成三件事:
①保存被调函数的返回结果
②释放被调函数所占的存储空间
③依照被调函数保存的返回地址将控制转移到调用函数 - 当有多个函数相互调用时,按照“后调用先返回”的原则,上述函数之间的信息传递和控制转移必须借助“栈”来实现,即系统将整个程序运行时所需的数据空间安排在一个栈中,每当调用一个函数时,就在栈顶分配一个存储区,进行压栈操作,每当一个函数退出时,就释放它的存储区,就行出栈操作,当前运行的函数永远都在栈顶位置
- A函数调用A函数和A函数调用B函数在计算机看来是没有任何区别的,只不过用我们日常的思维方式理解比较怪异而已!
递归满足的三个条件
- 递归必须得有一个明确的终止条件
- 该函数所处理的数据规模必须在递减
- 这个转化必须是可解的
循环和递归
递归:易于理解;速度慢;存储空间大
循环:不易理解;速度快;存储空间小
1.1+2+3+…+100
long sum(long n) {
if (1 == n)
return 1;
else
return n+sum(n-1);
}
2.求阶乘(假定m的值是>=1的值)
long f(long n) {
if (1 == n)
return 1;
else
return f(n-1)*n;
}
3.汉诺塔
如果 是1个盘子,直接将A柱子上的盘子从A移到C
否则 先将A柱子上的n-1个盘子借助C移到B;直接将A柱子上的盘子从A移到C;最后将B柱上的n-1个盘子借助A移到C
代码实现
void hannuota(int n, char A, char B, char C) {
if (1 == n){
printf("将编号为%d的盘子直接从%c柱子移到%c柱子\n", n, A, C);
}
else {
hannuota(n - 1, A, C, B);
printf("将编号为%d的盘子直接从%c柱子移到%c柱子\n", n, A, C);
hannuota(n - 1, B, A, C);
}
}
4.走迷宫
递归的应用
树和森林就是以递归的方式定义的
树和图的很多算法都是以递归来实现的
很多数学公式就是以递归的方式定义的,如斐波那契序列
模块二:非线性结构
树
定义
①有且只有一个称为根的结点;②有若干个互不相干的子树,这些子树本身也是一棵树
树由节点和边组成的;每一个节点只有一个父节点但可以有多个子节点;但有一个节点例外,该节点没有父节点,此节点称为根节点
专业术语:节点、父节点、 子节点、子孙、堂兄弟
深度:从根节点到最底层节点的层数称之为深度;根节点是第一层
叶子节点:没有子节点的节点
非终端节点:实际就是非叶子节点
度:子节点的个数称为度
分类
一般树:任意一个节点的子节点的个数都不受限制
二叉树:任意一个节点的子节点的个数最多是两个,且子节点的位置不可更改(有序树)
分类:①一般二叉树
②满二叉树:在不增加树的层数的前提下,无法再多添加一个节点的二叉树就是满二叉树
③完全二叉树:如果只是删除了满二叉树最底层最右边的连续若干个节点,这样形成的二叉树就是完全二叉树
森林:n个互不相交的树的集合
存储
二叉树的存储
连续存储【完全二叉树】
优点:查找某个节点的父节点和子节点(也包括判断有没有子节点)
缺点:耗用内存空间过大
链式存储
一般树的存储
- 双亲表示法:求父节点方便
- 孩子表示法:求子节点方便
- 双亲孩子表示法:求父节点和子节点都很方便
- 二叉树表示法:把一个普通树转化为二叉树来存储
具体转换方法:设法保证任意一个节点的 左指针域指向它的第一个孩子,右指针域指向它的第一个兄弟。只要能满足此条件,就可以把一个普通树转化为二叉树。
一个普通树转化为二叉树一定没有右子树
森林的存储
森林一般要转化为二叉树存储
操作
遍历
先序遍历
先访问根节点,再先序访问左子树,再先序遍历右子树【先访问根节点】
结果:ABQLCDGEPF
中序遍历
先中序遍历左子树,再访问根节点,再中序遍历右子树【中间访问根节点】
结果:QBLAGDCEFA
后序遍历
后序访问左子树,再后序访问右子树,再访问根节点【最后访问根节点】
结果:QLBGDPFECA
eg1:
先序:ABCDEFGLHI
中序:CDBAFLGEHI
后序:DCBLGFIHEA
已知两种遍历序列求原始二叉树
①通过先序和中序、中序和后序可以还原出原始二叉树,但是通过先序和后序是无法还原出原始的二叉树的
②只有通过先序和中序或者通过中序和后序才可以唯一确定二叉树
eg1:
先序:ABCDEFGH
中序:BDCEAFHG
求后序:DECBHGFA
eg2:
先序:ABDGHCEFI
中序:GDHBAECIF
求后序:GHDBEIFCA
eg3:
中序:BDCEAFHG
后序:DECBHGFA
求先序:ABCDEFGH
eg4:
中序:GDHBAECIF
后序:GHDBEIFCA
求先序:ABDGHCEFI
应用
树是数据库中数据组织一种重要形式
操作系统子父进程的关系本身就是一棵树
面向对象语言中类的继承关系本身就是一棵树
哈夫曼树
图
模块三:查找和排序
折半查找
排序
冒泡
插入
选择
快速排序(*)
# include <stdio.h>
void QuickSort(int*, int, int);
int FindPos(int*, int, int);
int main(void) {
int a[6] = { 2, 1, 0, 5, -4, 3 };
QuickSort(a, 0, 5);
//第二个参数表示第一个元素的下标,第三个元素表示最后一个元素的下标
for (int i = 0; i < 6; i++)
printf("%d ", a[i]);
printf("\n");
return 0;
}
void QuickSort(int* a, int low, int high) {
int pos;
if (low < high) {
pos = FindPos(a, low, high);
QuickSort(a, low, pos-1);
QuickSort(a, pos + 1, high);
}
}
int FindPos(int* a, int low, int high) {
int val = a[low];
while (low < high) {
while (low < high && a[high] >= val)
--high;
a[low] = a[high];
while (low < high && a[low] <= val)
++low;
a[high] = a[low];
}
a[low] = val;
return high; //或者return low;
}
归并排序
排序和查找的关系
①排序是查找的前提
②排序时重点