数据结构概述
什么是数据结构
- 数据结构是一门研究非数值计算程序设计中的操作对象,以及这些对象之间的关系和操作的学科
- 定义:特定的数据类型和特定的存储结构,研究个体、个体之间的关系如何存储
- 数据结构 = 个体 + 个体之间的关系
- 算法 = 对存储数据的操作
衡量算法的标准
- 时间复杂度
程序要大概执行的次数,而不是执行的时间(依赖于硬件) - 空间复杂度
算法大概所占用的最大内存 - 难易程度
- 健壮性
主要取决于时间复杂度和空间复杂度的大小
数据结构的特点
程序 = 算法 + 数据结构
指针
int * p; //p 就是个指针变量,int * 表示该 p 变量只能存储 int 类型变量的地址
地址:内存单元的编号,编号不能重复,是从 0 开始的非负整数,范围是 0 ~ FFFFFFFF
如图所示,CPU 和内存之间有 3 根线,分别是地址线、控制线和数据线。其中,地址线为 32 位
- 指针就是地址,地址就是指针
- 指针的变量是存放内存单元地址的变量
- 指针的本质是一个操作受限的非负整数
p = &i ;
j = *p;
//等价于 j = i
*p = i; //等价于 i = i
int * p = &i ; //等价于 int * p; p = &i ;
//p 存放了 i 的地址,p 指向了 i,*p 就是变量 i 本身
- 指针变量也是变量,它只能存放内存单元的地址,不能是内存单元的内容
- 普通变量前不能加 *
- 常量表达式前不能加 &
局部变量仅在函数内部使用:
void function(int * p)
{//不是定义了一个名为 *p 的形参,而是定义了一个形参,该形参的名字叫做 p,类型是int *(整型变量地址)
*p = 100;
}
如何通过调用函数修改主调函数中的普通变量的值?
1、实参为相关变量的地址
2、形参为以该变量的类型为类型的指针变量
3、在被调函数中通通过 *形参变量名
的方式
数组和指针
- 一维数组名是个指针变量,它存放的是一维数组第一个元素的地址,它的值不能改变。
- 一维数组名指向的是数组第一个元素
下标和指针的关系(重要)
a[i] 等价于 *(a+i)
a[3] == *(a+3);
*a+3; //等价于 a[0]+3
一个整型占 4 个字节
指针变量的运算:
指针变量不能相加、相乘、相除;
如果两个指针变量属于同一数组,则可以相减
所有的指针变量只占 4 个字节,用第一个字节的地址表示整个变量的地址
结构体
例如:
struct Student
{
int sid;
char name[200];
int age;
};
如何使用结构体?(重点)
两种方式:第一种是通过结构体变量名;第二种是通过指向结构体变量的指针
动态内存的分配和释放(必须手动释放)
/* molloc() 函数:只有一个形参;只返回第一个字节地址;
*/int * 的作用是指定第一个字节地址的类型
int * pArr = (int *)malloc(sizeof(int) * len);
/*把 pArr 所代表的动态分配的字节的内存释放,
释放 pArr 所指向的内存,而不是释放 p 本身是所占用的内存*/
free(pArr);
molloc() 函数的功能是请求系统分配 len 长度字节的内存空间
数组(连续存储)
线性结构:把所有的节点用一根直线穿起来
两种常见应用:栈和队列
定义:元素类型相同,大小相等
struct Arr
{
int * pBase; //存储的是数组的第一个元素的地址
int len; //数组所能容纳的最大元素的个数
int cnt; //当前数组有效元素的个数
}
- 数组的初始化
void init_arr(struct Arr * pArr, int length); - 数组的显示
void show_arr(struct Arr * pArr); - 插入元素
bool insert_arr(struct Arr * pArr, int pos, int val)
{
for(i = pArr->cnt-1; i >= pos-1; --i)
{
pArr->pBase[i+1] = pArr->pBase[i]; //元素往后移
}
pArr->pBase[pos-1] = val; //将 val 赋值给下标为 pos-1 的元素
}
- 删除元素
bool delete_arr(struct Arr * pArr, int pos, int * pVal)
{
* pVal = pArr->pBase[pos-1]; //删除前先保存值
for(i = pos; i < pArr->cnt; ++i)
{
pArr->pBase[i-1] = pArr->pBase[i]; //元素往前移
}
pArr->cnt--;
return ture;
}
- 倒置
void inversion_arr(struct Arr * pArr)
{
int i = 0;
int j = pArr->cnt-1;
while(i < j)
{
t = pArr->pBase[i];
pArr->pBase[i] = pArr->pBase[j];
pArr->pBase[j] = t;
++i;
--j;
}
}
- 排序(冒泡升序)
void sort_arr(struct Arr * pArr)
{
int i,j,t;
for(i = 0; i < pArr->cnt; ++i)
{
for(j = i+1; j< pArr->cnt; ++j)
{
if(pArr->pBase[i] > pArr->pBase[j])
{
t = pArr->pBase[i];
pArr->pBase[i] = pArr->pBase[j];
pArr->pBase[j] = t;
}
}
}
}
链表(离散存储)
typedef 的用法
typedef struct Student
{
int sid;
char name[200];
char sex;
}ST; //ST 为 struct Student 数据类型的别名
或 * PST; // PST 等价于 struct Student *
链表的定义
- 不连续的存储
- 通过指针连接
- 每一个节点只有一个前驱结点和一个后续节点
- 首节点无前驱结点,尾节点无后续节点
示意图:
- 头结点并不存放有效数据
- 链表增加头结点的作用是方便对链表的操作
- 头指针:指向头结点的指针变量,是指向链表中第一个节点的指针
- 首节点:指链表中存储第一个数据元素的节点
确定一个链表需要哪些参数?
只需要一个参数,头指针,通过头指针可以推算出链表的其他所有信息(首地址,最大长度,有效元素的个数)
每一个链表节点的数据类型
struct Node
{
int data; //数据域
struct Node * pNext; //指针域
}NODE, *PNODE;
链表的分类
- 单链表:每一个节点中只包含一个指针域
- 双向链表:每一个节点有两个指针域
- 循环链表:能通过任何一个节点找到其他所有的节点
非循环单链表插入节点伪算法
方法一:
r = p->pNext;
p-pNext = q;
q->pNext = r;
q->pNext:表示 q 指向的 pNext 结构体的成员(重要)
方法二:
q->pNext = p->pNext;
p->pNext = q; //不能倒过来
删除节点伪算法
r = p->pNext; //r 指向 p 后面的那个节点
p->pNext = p->pNext->p->pNext;
free(r);
链表创建和链表遍历算法
链表排序算法
链表插入和删除算法
数组和链表的比较
- 数组是连续存储的数据结构,它的优点是存取速度快。缺点是插入和删除元素很慢;事先必须知道数组的长度;需要大块连续的内存单元
- 链表是离散存储的数据结构,它的优点是空间没有限制,插入和删除元素很快。缺点是存储速度很慢
栈
概述
动态内存分配:堆
静态内存分配:栈
- 定义
一种可以实现“先进后出”的存储结构 - 栈的分类
栈分为静态栈和动态栈(只能在栈顶插入和删除元素) - 栈可以执行哪些操作
出栈,入栈
栈的创建和遍历算法
栈的压栈和入栈算法
清空栈的算法
栈的应用
- 函数调用
- 中断
- 表达式求值
- 内存分配
- 缓冲处理
- 迷宫
队列
概述
- 定义
一种可以实现“先进先出”的存储结构。队头出队,队尾入队 - 分类
队列可以分为链式队列(用链表实现)和静态队列(数组实现,通常都必须是循环队列)
静态队列为什么必须是循环队列?
原因是:静态队列是用数组实现的,入队和出队操作,front、rear 都只能增,不能减,浪费内存地址空间
front 指向队列中的第一个元素,即队列头元素;rear 指向最后一个有效元素的下一个元素,即队列尾元素的下一个位置
循环队列需要有几个参数来指定
- 2 个参数:front、rear
- 不同的场合有不同的含义:
初始化创建空队列时:front 和 rear 的值都是 0
队列非空时:front 指向队列头元素,rear 指向队列尾元素的下一个位置
队列空时:front 和 rear 的值相等,但不一定是 0
入队和出队伪算法
- 入队
分为 2 步:
1、将值存入 rear 所指向的位置
2、rear = (rear+1)%size; //对 rear 取余
- 出队
front = (front+1)%size;
判断循环队列是否为空,满伪算法
如何区别队满还是队空?
通常有以下两种处理方法:
1、少用一个元素空间,即队列空间大小为 m 时,有 m-1 个元素就认为是队满
队空的条件:front == rear;
队满的条件:(rear+1)%size == front;
2、另设一个标志位以区别队列是“空”还是“满”
循环队列程序
队列的具体应用
所有和时间有关的操作都与队列有关,例如操作系统中的作业排队
递归
概述
不同函数之间的调用
- 递归的定义
一个函数自己直接或间接调用自己 - 递归的例子
求阶乘
求前 n 项和
汉诺塔问题
一个函数为什么可以自己调用自己
原因是在计算机程序中,函数 A 调用函数 A 和函数 A 调用 B 是没有任何区别的。遵循“先调用后返回”的原则
递归必须满足的 3 个条件
1、递归必须要有一个明确的终止条件
2、该函数所处理的数据规模必须在递减,但递归的值可以递增
3、递归层次过多会导致栈溢出,且效率不高
递归过程
- 当一个函数的运行期间调用另一个函数时,在运行被调用函数之前,系统需先完成 3 件事:
1、将所有的实参、返回地址等信息传递给被调用函数保存
2、为被调用函数的局部变量分配存储区
2、将控制转移到被调用函数的入口
- 从被调用函数返回调用函数之前,系统也应完成 3 件事:
1、保存被调用函数的计算结果
2、释放被调用函数的数据区
3、依照被调用函数保存的返回地址将控制转移到调用函数
- 函数之间的的信息传递和控制转移必须通过“栈”来实现
循环和递归的比较
递归 | 循环 | |
---|---|---|
优点 | 易于理解 | 速度快,浪费存储空间小 |
缺点 | 速度慢,浪费存储空间大 | 不易理解 |
汉诺塔
将塔座 A 上的 n 个圆盘移至塔座 C 上,并仍按同样顺序叠排,圆盘移动时必须遵循以下规则:
1、每次只能移动一个
2、大的永远在小的下面
假设现在塔座 A 上有 3 个圆盘,要借助 塔座 B 按要求移至塔座 C 上,如何使用递归的方法实现移动圆盘的操作呢?
伪算法:
if(n > 1)
{
先把 A 柱子上的前 n-1 个盘子从 A 借助 C 移到 B
再将 A 柱子上的第 n 个盘子直接移到 C
最后再将 B 柱子上的 n-1 个盘子借助 A 移到 C
}
1: A->C 2: A->B 1: C->B
3: A->C
1: B->A 2: B->C 1: A->C
总共需要 7 步完成
递归的应用
- 树
- 森林
- 图
- 数学公式推导
- 菲波那切数列
- …
树
树的定义
- 有且仅有一个称之为根的结点
- 除根结点之外有若干个互不相交的子树
树的专业术语
- 结点:树中的一个独立单元
- 结点的度:结点拥有的子树数
- 树的度:树内各结点度的最大值
- 叶子:度为 0 的结点
- 堂兄弟:双亲在同一层的结点
- 树的深度:树中结点的最大层次
树的分类
- 一般树:任意一个结点的子结点的个数都不受限制
- 二叉树:每个结点至多只有两颗子树,子树的位置不可互换
- 森林:n 个互不相交的树的集合
二叉树的分类
- 一般二叉树:无序
- 满二叉树:深度为 k 且含有 2^k - 1 个结点的二叉树(在不增加树的层数的前提下,无法再多添加一个结点)
- 完全二叉树:如果只是删除了满二叉树最底层的最右边的连续若干个结点的二叉树
二叉树连续存储(完全二叉树)
- 特点:
1、不能只存有效结点
2、树是非线性结构,但要用线性结构去存储,需要指定转换方式(先序遍历、中序遍历、后序遍历) - 完全二叉树的性质:
优点:
1、从结点个数可得出树的层数;
2、任何一个结点,可知它的父结点和子结点(查找某个结点的父结点和子结点速度很快)
缺点:消耗内存空间过大
二叉树的链式存储
- 至少包括 3 个域:数据域和左、右指针域
普通树的存储
- 双亲表示法
- 孩子表示法
- 双亲孩子表示法
- 二叉树表示法,或称孩子兄弟法,二叉链表表示法:
便于将一般的树结构转换为二叉树进行处理
转换方法:设法保证任意一个结点的左指针域指向它的第一个孩子结点,右指针域指向它的兄弟结点。
一个普通树转换成的二叉树一定没有右子树
森林的存储
- 先将森林转换成二叉树,再存储二叉树
- 结点的两个指针域分别指向该结点的第一个孩子结点和它下一个兄弟结点
二叉树的 3 种遍历方式
- 先序遍历(先访问根结点)
- 中序遍历(中间访问根结点)
- 后序遍历(最后访问根结点)
- 口诀:先序后序定树根,中序区分左和右
已知两种遍历序列求原始二叉树(常考)
- 通过先序和中序或者中序和后序,可以还原出原始的二叉树,但是通过先序和后序是无法还原出原始的二叉树的
- 只有通过先序和中序或者中序和后序,才可以唯一的确定一颗二叉树
已知先序序列和中序序列求后序序列(常考)
先序:ABCDEFGH
中序:BDCEAFHG
分析:
1、由先序遍历特征,根结点必在先序序列头部,即根结点是 A;
2、由中序遍历特征,根结点必在其中间,而且其左边必全部是左子树子孙(BDCE),其右子树必全部是右子树子孙(FHG);
3、根据先序中的 BCDE 子树可确定 B 为 A 的左孩子,根据 FGH 子树可确定 F 为 A 的右孩子;以此类推,就可以唯一的确定一颗二叉树
由此可得,它的后序序列为:DECBHGFA
已知中序序列和后序序列求先序序列
中序:BDCEAFHG
后序:DECBHGFA
分析:
1、由后序遍历特征,根结点必在后序序列尾部,即根结点是 A;
2、由中序遍历特征,根结点必在其中间,而且其左边必全部是左子树子孙(BDCE),其右子树必全部是右子树子孙(FHG);
3、根据后序中的 BCDE 子树可确定 B 为 A 的左孩子,根据 FGH 子树可确定 F 为 A 的右孩子;以此类推,就可以唯一的确定一颗二叉树
由此可得,它的先序序列为:ABCDEFGH
树的应用
- 树是数据库中数据组织的一种重要方式
- 操作系统中子父进程的关系本身就是一棵树
- 面向对象语言中类的继承关系
- 哈夫曼树