数据结构
数据结构的概述:
- 定义:我们如何把显示中大量而复杂的问题以特定的数据类型和特定的存储结构保存到主存储器(内存)中,以及在此基础上为实现某个功能(比如查找某个元素,删除某个元素,对所有元素进行排序)而执行的相应操作,这个相应的操作也叫算法。
- 数据结构 = 个体 + 个体的关系
- 算法 = 对存储数据的操作
- 算法
- 解题的方法和步骤
- 衡量算法的标准
- 时间复杂度:大概程序要执行的次数,而非执行的时间
- 空间复杂度:算法执行过程中大概所占用的最大内存
- 难易程度
- 健壮性
- 数据结构的地址
- 数据结构是软件中最核心的课程
- 程序 = 数据的存储 + 数据的操作 + 可以被计算机执行的语言
准备知识
-
指针
-
指针的重要性:指针是C语言的灵魂
-
定义:
-
地址:
- 地址就是内存单元的编号
- 从0开始的非负整数
- 范围:0 – FFFFFFFF 【0 - 4G-1】
-
指针:
- 指针就是地址,地址就是指针
- 指针变量是存放内存单元地址的变量
- 指针的本质是一个操作受限的非负整数
-
分类:
-
基本类型的指针
-
基本概念:
int i = 10;
int *p = &i;//等价于 int *p; p = &i;
详解:
- p存放了i的地址,所以我们说p指向了i
- p和i是完全不同的两个变量,修改其中的任意一个变量的值都不影响另一个值
- p指向i,*p就是i变量本身。更形象的说所有出现 *p的地方都可以替换成&i,所有出现 &i的地方都可以换成 *p
注意
- 指针变量也是变量,只不过它存放的不能是内存单元的内容,只能存放内存单元的地址
- 普通变量前不能加 *
- 常亮和表达式前不能加&
-
指针变量不管指向的数据占多少字节,本身都只占四个字节
如何通过被调函数修改主调函数中普通变量的值
- 实参为相关变量的地址
- 形参为以该变量的类型为类型的指针变量
- 在被调函数中通过 *形参变量名的发哪个是就可以修改主函数中的实参
-
-
指针和数组的关系
- 指针和一维数组
- 数组名:一维数组名是个指针常量
- 它存放的是一维数组第一个元素的地址
- 它的值不能被改变
- 一维数组名指向的是数组的第一个元素
- 下标和指针的关系:a[i] <<==>> *(a+i)
- 假设指针变量的名字为p,则p+i的值是p+i*(p所指向的变量所占的字节数)
- 指针变量的运算
- 指针变量不能相加,不能相乘,不能相除
- 如果两指针变量属于同一数组,则可以相减
- 指针变量可以加减一整数,前提是最终结果不能超过指针允许指向的范围
- 数组名:一维数组名是个指针常量
- 指针和一维数组
-
-
-
结构体
- 为什么会出现结构体:为了表示一些复杂的数据,而普通的基本类型变量无法满足要求
- 什么叫结构体:结构体是用户根据实际需要自己定义的复合数据类型
- 如何使用结构体:
- 两种方式:
- struct Student st = {1000,“zhangsan”,20};
- struct Student * pst = &st;
- st.sid
- pst->sid pst所指向的结构体变量中的sid这个成员
- 两种方式:
- 注意事项:
- 结构体变量不能加减乘除,但可以相互赋值
- 普通结构体变量和结构体指针变量作为函数传参的问题
动态内存分配和释放
- 动态构造一维数组
- 假设动态构造一个int型数组
- int *p = (int *)malloc(int len);
- malloc只有一个int型的形参,表示要求系统分配的字节数
- 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个元素。
线性结构
- 连续存储[数组]
- 什么叫数组:元素相同,大小相等
- 数组的优缺点
- 优点:数组定义简单,而且访问很方便
- 缺点:
- 数组中所有元素类型都必须相同
- 数组大小必须定义时给出,而且大多数情况下,数组空间的大小一旦确定后就不能更改
- 数组的空间必须是连续的,这就造成数组在内存中分配空间时必须找到一块连续的内存空间。所以数组不可能定义得太大,因为内存中不可能有那么多大的连续的内存空间,而解决这个问题的方法就是使用链表
- 离散存储[链表]
- 定义:
- n个结点离散分配
- 彼此通过指针相连
- 每个结点只有一个前驱结点,每个结点只有一个后续结点
- 首结点没有前驱结点,尾结点没有后续结点
- 专业术语:
- 首结点:是一个有效结点
- 尾结点:最后一个有效结点
- 头结点:
- 第一个有效结点之前的那个结点
- 头结点并不存放有效数据
- 加头结点的目的主要是为了方便对链表的操作
- 头指针:指向头结点的指针变量
- 尾指针:指向尾结点的指针变量
- 确定一个链表只需要头指针
- 分类
- 单链表
- 双链表:每一个节点有两个指针域
- 循环链表:能通过任何一个结点找到其他所有的结点
- 非循环链表:
- 算法:
- 算法常用操作:
- 遍历
- 查找
- 清空
- 销毁
- 求长度
- 排序
- 删除结点
- 插入结点
- 算法:
- 狭义的算法是与数据的存储方式密切相关
- 广义的算法是于数据的存储方式无关
- 泛型:利用某种技术达到的效果就是:不同存储方式,执行的操作是一样的
- 算法常用操作:
- 定义:
- 线性结构的两种常见应用之一 栈
- 定义:一种可以实现”先进先出“的存储结构,类似于箱子
- 分类:
- 静态栈
- 动态栈
- 算法:
- 出栈
- 压栈
- 应用
- 函数调用
- 中断
- 表达式求值
- 内存分配
- 缓冲处理
- 迷宫
- 线性结构的两种常见应用之二 队列
- 定义:一种可以实现“先进先出”的存储结构
- 分类:
- 链式队列:用链表实现
- 静态队列:用数组实现。
- 静态队列通常都必须是循环队列
- 循环队列需要两个参数来确定:front和rear
- 循环队列各个参数的含义
- 2个参数不同场合有不同的含义
- 队列初始化:front和rear的值都是零
- 队列非空:
- front代表的是队列的第一个元素,
- rear代表的是队列的最后一个有效元素的位置
- 队列空:front和rear的值相等,但不一定是零
- 循环队列入队伪算法讲解:
- 两步完成:
- 将值存入r所代表的位置
- 正确的写法是:rear = (rear+1)% 数组的长度
- 两步完成:
- 循环队列出队伪算法讲解:
- front = (front+1)%数组的长度
- 如何判断循环队列是否为空:
- 如果front与rear的值相等,则该队列就一定为空
- 如何判断循环队列是否已满
- 预备知识:front的值可能比rear大或者小或者相等
- 两种方式(通常使用第二种方式):
- 多增加一个标识参数
- 如果r和f的值相等,则表明队列已满
- 2个参数不同场合有不同的含义
函数调用
- 当在一个函数的运行期间调用另一个函数时,在运行被调函数之前,系统需要完成三件事:
- 将所有的实际参数,返回地址等信息传递给被调函数保存。
- 为被调函数的局部变量(包括形参)分配存储空间
- 将控制转移到被调函数的入口
- 从被调函数返回主调函数之前,系统也要完成三件事
- 保存被调函数的返回结果
- 释放被调哈拿书所占的存储空间
- 依照被调函数保存的返回地址将控制转移到调用函数
- 当有多个函数互相调用时,按照“后调用先返回”的原则,上述函数之间信息传递和控制转移必须借助“栈”来实现,即系统将整个程序运行时所需的数据空间安排在一个栈中,每当调用一个函数时,就在栈顶分配一个存储区,进行压栈操作,每当一个函数退出时,就释放它的存储区,执行出栈操作,当前运行的函数永远都在栈顶位置
- A函数调用A函数和A函数调用B函数在计算机看来是没有任何区别的,只不过用我们日常的思维方式理解比较怪异而已。
递归
- 定义:一个函数自己直接或间接的调用自己
- 递归满足三个条件
- 递归必须得有一个明确的终止条件
- 该函数所处理的数据规模必须在递减
- 这个准话必须是可解的
- 循环和递归
- 递归:
- 易于理解
- 速度慢
- 存储空间大
- 循环:
- 不易理解
- 速度快
- 存储空间小
- 递归:
非线性结构
-
树
-
定义
- 专业定义:
- 有且只有一个称为根的结点
- 有若干个互不相交的子树,这些子树本身也是一棵树
- 通俗的定义:
- 树是由结点和边组成
- 每个结点只有一个父结点,但可以有很多个子节点
- 但有一个结点例外,该节点没有父结点,此节点称为根结点
- 专业定义:
-
专业术语:
- 结点 子节点 父结点
- 子孙 堂兄弟
- 深度:从根结点到最底层结点的层数称之为深度,根结点是第一层
- 叶子结点:没有子节点的结点
- 非终端结点:实际就是非叶子结点
- 度:子节点的个数称为度
-
树分类:
- 一般树:任意一个结点的子节点的个数都不受限制
- 二叉树:任意一个结点的字节滴啊安个数最多两个,且子节点的位置不可更改
- 分类:
- 一般二叉树
- 满二叉树:在不增加树的层数的前提下,无法再多添加一个结点的二叉树。
- 完全二叉树:如果只是删除了满二叉树最底层最右边的连续若干个结点,这样形成的二叉树就是完全二叉树。
- 分类:
-
树的存储
-
二叉树的存储
- 连续存储[完全二叉树]
- 优点:查找某个结点的父结点和子节点(也包括判断有没有值)
- 缺点:耗用空间内存过大
- 链式存储
- 连续存储[完全二叉树]
-
一般树的存储
-
双亲表示法
- 求父结点方便
-
孩子表示法
- 求子节点方便
-
双亲孩子表示法
- 求父结点和子节点都很方便
-
二叉树表示法
-
把一个普通树转化成二叉树来存储
-
具体转换方法:
-
设法保证任意一个结点的左指针域指向它的第一个孩子
右指针域指向它的下一个兄弟
-
只要能满足此条件,就可以把一个普通树转化为二叉树
-
-
-
-
-
森林的存储:先把森林转化为二叉树,再存储二叉树
-
二叉树操作:
- 遍历:
- 先序遍历 [先访问根结点]
- 先访问根结点
- 再先序访问左子树
- 再先序访问右子树
- 中序遍历 [中间访问根结点]
- 中序遍历左子树
- 再访问根结点
- 再中序遍历右子树
- 后序遍历 [最后访问根结点]
- 先中序遍历左子树
- 再终须遍历右子树
- 再访问根结点
- 先序遍历 [先访问根结点]
- 已知两种遍历序列求原始二叉树
- 通过先序和中序或者中序和后序我们可以还原出原始的二叉树
- 但是通过先序和后序是无法还原出原始的二叉树的
- 换种说法:
- 只有通过先序和中序,或通过中序和后序我们才可以唯一的确定一个二叉树
- 遍历:
-
应用
- 树是数据库中数据组织一种重要形式
- 操作系统子父进程的关系本身就是一棵树
- 面向对象语言中类的继承关系本身就是一棵树
- 赫夫曼树
-
数据结构补充
线索二叉树
-
线索二叉树结点结构:
-
ltag lchild data rchild rtag
-
标志域 l®tag:
- 0: l®child域指示结点的左(右)孩子
- 1: l®child域指示结点的前区(后继)
-
typedef struct ThreadNode{
ElemType data;
struct ThreadNode * lchild,* rchild;
int ltag, rtag;
}ThreadNode, * ThreadTree;
-
这种结点结构构成的二叉链表作为二叉树的存储结构,称为线索链表
-
-
中序线索二叉树
- 前驱节点
- 若左指针为线索,则其指向节点为前驱结点
- 若左指针为左孩子,则其左子树的最右侧结点为前驱结点
- 后驱结点
- 若右指针为线索,则其指向节点为后驱结点
- 若右指针为右孩子,则其右子树的最左侧结点为后驱结点
- 前驱节点
-
注意:中序线索二叉树不能被普通中序遍历。会出现无限循环
二叉排序树(BST)也称为二叉查找树
- 当二叉排序树为非空树时,有如下特点
- 若左子树非空,则左子树上所有结点关键字值均小于根结点的关键字
- 若右子树非空,则右子树上所有关键字值均大于根结点的关键字
- 左、右子树本身也分别是一颗二叉排序树
- 二叉排序树的中序遍历序列是一个递增有序序列
平衡二叉树(AVL)
-
任意结点的平衡因子的绝对值不超过一。
-
平衡因子:左子树高度-右子树高度。
-
平衡二叉树的判断:
- 利用递归的后序遍历过程:
- 判断左子树是一颗平衡二叉树
- 判断右子树是一颗平衡二叉树
- 判断以该节点为根的二叉树为平衡二叉树
- 判断条件:左右子树均为平衡二叉树,且左右子树高度差的绝对值小于等于1
- 利用递归的后序遍历过程:
-
平衡二叉树的四种旋转方式:
- LL平衡旋转:
- 原因:在结点A的左孩子的左子树上插入了新结点
- 调整方法:右旋操作:将A的左孩子B代替A,将A结点称为B的右子树根结点,而B的原右子树作为的左子树
- RR平衡旋转:
- 原因:在结点A的右孩子的右子树上插入了新结点
- 调整方法:左旋操作:将A的右孩子B代替A,将A结点称为B的左子树根结点,而B的原左子树则作为A的右子树
- LR平衡旋转:
- 原因:在结点AA的左孩子的右子树上插入了新结点
- 先左旋后右旋操作:将A的左孩子B的右孩子结点C代替B,然后再将C结点向上代替A的位置
- RL平衡旋转:
- 原因:在结点A的右孩子的左子树上插入新结点
- 调整方法:先右旋后左旋操作:将A的右孩子B的左孩子结点C代替B,然后再将C结点向上代替A的位置
哈夫曼树
-
带权路径长度:
-
路径长度:路径上所经历边的个数
-
结点的权:结点被赋予的数值
-
树的带权路径长度:WPL,树种所有叶结点的带权路径长度之和,记为:
W P L = ∑ n = 0 n w i l i WPL=\sum_{n=0}^{n}{wili} WPL=n=0∑nwili
-
-
哈弗曼树的构造算法:
- 将n个节点作为n颗仅含有一个根结点的二叉树,构成森林F;
- 生成一个新结点,并从F中找出根结点权值最小的两棵树作为他的左右子树,且新结点的权值为两颗子树根结点的权值之和;
- 从F中删除这两个树,并将新生成的树加入到F中;
- 重复2,3步骤,知道F中只有一棵树为止。
- LL平衡旋转: