西电网信院数据结构与算法分析期末复习--朋辈讲堂
This document was prepared on December 12, 2024 and is intended to be used as a final review handout
Time is short, if there is any mistake, please criticize and correct
前言
以下是笔者希望能够完成的所有章节:
目的:
- 作为期末速成课,串联知识点
笔试部分与基础:
- 从整体宏观角度讲抽象数据的组成方式和基于该抽象数据的算法逻辑
- 讲抽象数据类型在内存中的组织形式与基于该种组织形式的算法实现逻辑
- 讲书中所学的算法(经典算法策略)
机试部分(开卷,不再讲书中的代码实现,可以翻书,侧重于讲其他内容):
- c语言复习
- c的输入输出函数,头文件,常用函数
- c和cpp的区别
- 结构体,函数声明与定义
- 调试与debug
- devc,code::blocks等机房可以使用的ide环境
- 调试代码,设置断点与信息输出
- 看信息的报错情况
- xdoj环境特性
考试提醒
在算法和数据结构中,根据功能抽象出数据类型并根据数据类型和要实现的功能寻找合适的内存组织形式,可以根据该种内存组织形式的数学性质编写算法,故数学是个很重要的学科
笔试部分
绪言
具体内容及基本要求--来自某年课程大纲
软件工程的概念
数据结构的概念
程序设计的关键技术
- 基本要求
- 熟练掌握软件概念、程序设计技术。
- 熟练掌握数据结构的概念、名词和术语。
- 重点、难点
- 重点:数据结构的概念、名词和术语。
- 难点:数据结构的概念。
知识点
- 软件包括计算机程序和与之相关的文档资料的总和。
- • 文档是指编制程序所使用的技术资料和使用该程序的说明性资料,
- 如使用说明书等,即开发、使用和维护程序所需的一切资料。
- 软件生存期
- 数据结构的基本概念
- 数据
- 描述客观事物的数、字符以及所有能输入到计算机中并被计算机程序处理的符号的集合。
- 具有可识别性、可加工处理性和可存储性等特征。
- 数据元素
- 数据的基本单位,即数据这个集合中的一个个体(客体)。
- 一个数据元素可由若干个数据项组成。
- 数据项
- *数据的最小单位。
- 数据对象
- 具有相同特性的数据元素的集合。**
- 数据
- 数据结构研究的主要内容
- ① 数据元素间的逻辑关系**——** 逻辑结构
- ② 数据元素及其关系在计算机存储器内的表示—— 存储结构
- ③ 数据的运算 —— 对数据施加的操作
- 数据逻辑结构分为:线性和非线性
- 线性结构的逻辑特征是:有且仅有一个开始结点和一个终端结点,且所有结点都最多只有一个直接前趋和一个直接后继。
- 非线性结构的逻辑特征是一个结点可能有多个直接前趋和直接后继。
- 数据的存储结构
- 顺序存储方法
- 该方法是把逻辑上相邻的结点存贮在物理位置上相邻的存贮单元里,结点间的逻辑关系由存贮单元的邻接关系来体现。由此得到的存贮表示称为顺序存贮结构。
- 该方法主要应用于线性的数据结构,非线性的数据结构。
- 链接存储方法
- 该方法不要求逻辑上相邻的结点在物理位置上亦相邻,结点间的逻辑关系是由附加的指针字段表示的。由此得到的存贮表示称为链式存贮结构。
- 索引存储方法
- 该方法建立附加的索引表指示数据的存储位置。
- 索引表中的每一项称为索引项,一般形式是:
- (关键字,地址)
- 关键字是能惟一标识一个结点或一组结点的那些数据项
- 地址是存储数据的位置
- (关键字,地址)
- 散列存储方法
- 该方法的基本思想是根据结点的关键字直接计算出该结点的存贮地址。
- Address=H(KeyWord)
- 该方法的基本思想是根据结点的关键字直接计算出该结点的存贮地址。
- ADT--抽象数据类型
- 数据对象D
- 数据关系R
- 操作集合--函数或方法
- 算法的基本概念:
- 特点:输入性,输出性,有穷性,确定性,可行性
- 衡量指标:时间复杂度,空间复杂度,可读性
- 时间复杂度T(n)与大O记号
- 与问题规模有关
- 空间复杂度S(n)与大O记号
线性结构
线性表(向量),栈,队列,串,数组
ADT--线性表(linear list)--第二章

线性表的存储实现方式包括顺序存储实现(顺序表)和链式存储实现(链表)
顺序表
- 数据组织:(支持随机存储)
- c语言代码定义:
- typedef int datatype; /* datatype可为任何类型 */
#define maxsize 1024 /* 顺序表可能的最大长度 */
typedef struct
{
datatype data[maxsize];
int last;
} SequenList; ![fig:]()
- 运算:
- 初始化
- 插入
- 删除
- 查询
- 输出
- 时间复杂度:插入与删除算法分析
- 插入一个元素时所需移动元素次数的期望值(平均次数)为
- 线性表中删除一个元素所需移动元素次数的期望值(平均次数)为
- 顺序表的优缺点
- 优点
- 随机存取:表中任意元素可以随机存取。
- 存储位置直观:存储位置可以用一个简单直观的公式表示。
- 存储密度高:顺序表的存储密度较高。
- 缺点
- 插入或删除效率低:在进行插入或删除运算时,需要移动大量元素。
- 预先分配最大空间:线性表按最大空间预先分配,可能导致空间浪费。
- 容量难以扩充:表的容量难以扩充,不灵活。
- 优点
链表
单链表,循环链表,双链表
单链表
- 链表是用一组任意的存储单元来存放线性表的元素,这组存储单元既可以是连续的,也可以是不连续的。
- 存储数据元素信息的域称作数据域;存储后继元素存储位置的域称作指针域,指针域中存储的信息称指针或链。
- 链表的每个结点中只包含一个指针域,故将这种链表称为单链表。
![fig:]()
- 一般图示法
![fig:]()
- 代码定义:
- typedef int dataype;
typedef struct node /* 结点类型 */
{
datatype data;
struct node *next;
}Linklist,*pLinklist;
/* 指针类型说明(以下三种声明等价) */
struct node *head,*p; // 形式一
Linklist *head, *p; // 形式二
pLinklist head, p; //形式三
- 运算:
- 初始化
- 建表
- 头插法
- 尾插法(或带头节点)
- 查找
- 按序号查找
- 按值查找[平均时间复杂度]
- 插入[平均时间复杂度]
- 前插
- 后插
- 删除[平均时间复杂度]
循环链表
- 循环链表是表中最后一结点的指针域指向头结点,整个链表形成一个环。
- 特点:从表中任意结点出发均可找到表中其他结点。
- 循环链表和单链表的区别:
- 循环链表的运算和单链表基本一致,差别仅在于算法中对最后一个结点的循环处理上有所不同。
- 判别链表中最后一个结点的条件不再是"后继是否为空",而是"后继是否为头结点"。

- 在用头指针表示的单循环链表中:
- 找开始结点 的时间是 。
- 然而要找到终端结点 ,则需从头指针开始遍历整个链表,其时间是 。
- 改用尾指针 rear 来表示单循环链表:
- 查找开始结点 和终端结点 都很方便。
- 它们的存储位置分别是 和 。
- 显然,查找时间都是 。
- 因此常使用尾指针表示单循环链表

双向链表
typedef int dataype;
typedef struct node
{
datatype data;
struct node *next, *prior; // 新增存储前继节点的指针空间*prior
}Linklist,*pLinklist;
基于循环链表,可以改为双向循环链表
- [例--一元多项式相加(链式)]--时间复杂度分析
- [例--一元多项式相乘(链式)]--时间复杂度分析
线性表存储结构的选择依据
- 存储空间:
- 顺序存储结构:
- 静态分配,造成浪费和空间溢出。
- 链式存储结构:
- 动态分配,不会发生空间溢出。
- 存储密度:
- 存储密度越大,存储空间的利用率越高。
- 顺序存储结构的存储密度接近1。
- 链式存储结构的存储密度小于1。
- 存储密度越大,存储空间的利用率越高。
- 顺序存储结构:
- 运算时间:
- 顺序存储结构:
- 是随机存取结构。
- 链式存储结构:
- 不是随机存取结构。
- 顺序表的插入和删除:
- 需要移动大量元素。
- 链表的插入或删除:
- 只修改相应的指针及进行一定的查找。
- 对于只进行查找操作而很少做插入和删除操作时:
- 采用顺序存储结构为宜。
- 对于频繁地进行元素插入和删除操作的线性表:
- 采用链式存储结构为宜。
- 顺序存储结构:
- 程序设计语言:
- 根据语言是否提供指针类型确定。
栈和队列是运算受限的线性表
ADT--栈(stack)
- 抽象数据类型定义:
- ADT Stack{
- 数据对象 D: D = { a_i | 1 ≤ i ≤ n, n ≥ 0 }
- 数据关系 R: R = { <a_1, a_2>, <a_2, a_3>, ..., <a_(n-1), a_n> } 若 n > 0
- 操作集合:
- SetNull(S)
- 初始条件: 栈S已存在
- 操作结果: 将栈S置位空栈
- Empty(S):
- 初始条件: 栈S已存在
- 操作结果: 若栈S为空则返回真,否则返回假
- Push(S, e):
- 初始条件: 栈S已存在
- 操作结果: 在栈S的栈顶插入数据元素e
- Pop(S, x):
- 初始条件: 栈S已存在,且S不为空
- 操作结果: 删除栈S的栈顶数据元素,并返回栈顶的数据元素
- GetTop(S):
- 初始条件: 栈S已存在,且S不为空
- 操作结果: 读取栈S的栈顶数据元素,并返回该元素。操作完成后,栈S的状态不变
- SetNull(S)
- }
- 基本概念:
- 栈是限定仅在表尾进行插入和删除运算的线性表。
- 表尾称为栈顶。
- 表头称为栈底。
- 当栈中没有数据元素时称为空栈。
![fig:]()
- 对于栈来说:
- 最后进栈的数据元素,最先出栈。
- 后进先出(LIFO - Last In First Out)或先进后出(FILO - First In Last Out)
- 出栈顺序组成的序列与栈中元素数目组成的关系:
顺序栈
- 定义:
- 栈的顺序存储结构定义为:
- struct Stack {
datatype elements[maxsize];
int Top;
}; - 其中:
- maxsize 是栈的容量。
- datatype 是栈中数据元素的数据类型。
- Top 指示当前栈顶位置。
- 运算(5种)
- 两个栈共享空间
- 多个栈共享空间(了解)
链栈
- 定义:
- 栈的链式存储结构称为链栈。它是运算受限的单链表,其插入和删除操作仅在表头进行。
- 链栈定义如下:
- struct Node {
datatype element;
struct Node *next;
};
struct Node *top; - 其中:
- Node 结构体表示链栈中的节点,包含数据元素 element 和指向下一个节点的指针 next。
- top 指针指向链栈的栈顶节点。
- 操作实现(5种)
栈的应用
满足LIFO原则的问题
- 递归--Fibonacci序列
- 回溯--地图染色,n皇后问题
- 表达式求值
ADT--队列(queue)
- 抽象数据类型表述:
- ADT Queue{
- 数据对象 D: D = { a_i | 1 ≤ i ≤ n, n ≥ 0 }
- 数据关系 R: R = { <a_1, a_2>, <a_2, a_3>, ..., <a_(n-1), a_n> } 若 n > 0
- 操作集合:
- SetNull(Q)
- 初始条件: 队列Q已存在
- 操作结果: 将队列 Q置为空队列
- Empty(Q):
- 初始条件: 队列Q已存在
- 操作结果: 若队列Q为空则返回真,否则返回假
- EnQueue(Q, x):
- 初始条件: 队列Q已存在
- 操作结果: 将元素x插入队列Q的队尾,简称为入队列
- DeQueue(Q):
- 初始条件: 队列Q已存在,且Q不空
- 操作结果: 删除队列Q的队头元素,简称为出队列,并返回原队头元素
- Front(Q):
- 初始条件: 队列Q已存在,且Q不空
- 操作结果: 读取队头Q的队头元素,并返回该元素。队列中元素保持不变
- SetNull(Q)
- }
- 定义:
- 队列也是一种运算受限的线性表。
- 它只允许在表的一端进行插入,该端称为队尾(Rear)。
- 它只允许在表的另一端进行删除,该端称为队头(Front)。
- 队列亦称作先进先出(First In First Out)的线性表。
- 当队列中没有元素时称为空队列。
顺序队列
- 定义:
- 队列的顺序存储结构称为顺序队列。
- 顺序队列的形式说明如下:
- struct sequeue {
datatype data[maxsize];
int front, rear;
}; /* 顺序队列的类型 */
struct sequeue *sq; /* sq 是顺序队列的指针 */ ![fig:]()
假溢出:顺序队列中存在未用的存储单元,但不能继续进行入队操作。
- 移动
循环队列
- 在顺序队列的基础上,使用模运算
- sq→rear=(sq→rear+1)%maxsize
无法区分空和满
- 设置空/满标志
- 牺牲一个存储单元

链队列
- 定义:
- 队列的链式存储结构简称为链队列,它是限制仅在表头删除和表尾插入的单链表。
- 解决了假溢出问题,同时采用了一种新的存储方法。
- 链队列的形式说明:
- typedef struct {
linklist *front, *rear;
} linkqueue;
linkqueue *q; /* q是链队列指针 */

队列应用
FIFO
划分子集问题
ADT--串(string)
- ADT String
- 数据对象: D = { a_i | a_i ∈ CharacterSet, i = 0, 1, ..., n-1, n ≥ 0 }
- 数据关系: R = { <a_i-1, a_i> | a_i-1, a_i ∈ D, i = 1, ..., n-1 }
- 操作集合:
- StrAssign(&T, chars)
- 初始条件: chars是字符串常量
- 操作结果: 生成一个其值等于chars的串T
- StrCopy(&T, S)
- 初始条件: 串S存在
- 操作结果: 由串S复制到串T
- StrEmpty(S)
- 初始条件: 串S存在
- 操作结果: 若S为空串,则返回TRUE,否则返回FALSE
- StrCmp(S, T)
- 初始条件: 串S、T存在
- 操作结果: 若S>T,则返回值>0; 若S=T,则返回值=0; 若S<T,则返回值<0
- StrLen(S)
- 初始条件: 串S存在
- 操作结果: 返回S的元素个数,称为串的长度
- StrCat(&T, S)
- 初始条件: 串T和S存在
- 操作结果: 用T返回T和S连接而成的新串
- SubStr(&Sub, S, pos, len)
- 初始条件: 串S存在,0≤pos<StrLength(S)且0<len≤StrLength(S)-pos+1
- 操作结果: 用Sub返回串S的第pos个字符起长度为len的子串
- StrIndex(S, T, pos)
- 初始条件: 串S和T存在,T是非空串,0≤pos≤StrLength(S)-1
- 操作结果: 若主串S中存在与串T值相同的子串,则返回它在主串S中第pos个字符之后第一次出现的位置;否则函数返回0
- Replace(&S, T, V)
- 初始条件: 串S、T、V存在,T为非空串
- 操作结果: 用串V替换主串S中出现的所有与T相等的不重叠的子串
- StrInsert(&S, pos, T)
- 初始条件: 串S、T存在,0≤pos≤StrLength(S)
- 操作结果: 在串S的第pos个字符之前插入串T
- StrDelete(&S, pos, len)
- 初始条件: 串S存在,0≤pos≤StrLength(S)-len
- 操作结果: 从串S中删除第pos个字符起长度为len的子串
- StrAssign(&T, chars)
- 基本概念
- 串是由零个或多个字符组成的有限序列。一般记为 S="a1a2…an",其中S是串名,用两个双引号括起的字符序列是串值;
- 可以是字母、数字或其它字符;
- 串中所包含的字符个数成为该串的长度;
- 长度为零的串称为空串,它不包含任何字符;
- 串中任意个连续的字符组成的子序列称为该串的子串;
- 包含子串的串称为主串;
- 子串的第一个字符在主串中的序号,定义为子串在主串中的位置(或序号);
- 特别地,空串是任意串的子串,任意串是其自身的子串。
顺序串--顺序存储方式
- 串的顺序存储结构定义:
- #define maxsize 100 /* 假设串可能的最大长度为100 */
typedef struct {
char ch[maxsize]; /* 存放串值 */
int len; /* 串的长度 */
} seqstring;

链串--链式存储方式
- 串的链式存储结构定义:
- typedef struct linknode {
char data; //可以改为char[]
struct linknode *next;
} linkstring; /* 定义链串类型 */
linkstring *S; /* S是链串的指针 */
索引存储方式
- 在索引存储中,除了存放串值外,还要建立一个串名和串值之间对应关系的索引表
- 链式存储方式下的索引表:
- 索引表中要含有串名及存储串值的链表的头指针。
- 顺序存储方式下的索引表:
- 索引表中要含有串名以及指示串值存放的起始地址的首指针和指明串值存放结束的末地址。
- 末地址信息可以是串值末尾结束地址、串长等。
- 链式存储方式下的索引表:
- 索引存储的分类
![fig:]()
- 带长度的索引表
- 顺序存储方式下的索引表结构定义:
- typedef struct {
char name[maxsize]; /* 串名 */
int length; /* 串长 */
char *stadr; /* 串值存入的起始地址 */
}
- 带末指针的索引表
- 结构定义:
- typedef struct {
char name[maxsize]; /* 串名 */
char *stadr; /* 串值存入的起始地址 */
char *enadr; /* 串值存放的末地址 */
} enode;
- 带特征位的索引表
- 结构定义:
- typedef struct {
char name[maxsize]; /* 串名 */
int tag; /* 特征位,用于指示stadr域中是指针还是串值 */
union {
char *stadr; /* 指向串值的指针 */
char value[4]; /* 存放串值的空间 */
} uval;
} tagnode;
- 带长度的索引表
- 索引存储中串的动态分配
- 一个较大的向量 store[maxsize] 表示可供动态分配用的连续的存储空间。
- 使用一个指针 free 指示尚未分配存储空间的起始位置,其初值为0。
- 程序执行过程中每产生一个新串:
- 就从 free 指针起进行存储分配。
- 在索引表中建立一个相应的结点。
- 在该结点中填入新串的名字、分配到的串值空间的起始位置、串值的长度等信息。
- 然后修改 free 指针,指向新的未分配空间的起始位置。
顺序串运算实现
- 串连接
- 求字串
- 字串定位模式匹配
- 朴素的模式匹配算法/布鲁特-福斯算法
- 最好情况下算法的平均时间复杂度为O(n+m)
- 由于,则该算法在最坏情况下的时间复杂度为。
![fig:]()
- KMP算法(了解)
- 朴素的模式匹配算法/布鲁特-福斯算法
ADT--多维数组(array)
数组是一种线性的数据结构,可看作是线性表的扩充
一维数组->顺序存储
- 数组的定义:
- 数组是由值与下标构成的有序对,结构中的每一个数据元素都与其下标有关。
- 数组结构的性质:
- 数据元素数目固定:一旦说明了一个数组结构,其元素数目不再有增减变化;
- 数据元素具有相同的类型;
- 数据元素的下标关系具有上下界的约束并且下标有序。
- 数组的运算:
- 给定一组下标,存取相应的数据元素;
- 给定一组下标,修改相应数据元素中的某个数据项的值。
- 一般采用顺序存储,故具有随机读写的特性
- ADT Array{
- 数据对象:
- ,其中 , 称为数组的维数, 是数组第 维的长度, 是数组元素的第 维下标, , 为元素集合。
- 数据关系:
- 操作集合:
- InitArray(&A, n, bound1, ..., boundn)
- 初始条件: 无
- 操作结果: 若维数 和各维长度合法,则构造相应的数组 ,并返回 。
- DestroyArray(&A)
- 初始条件: 存在
- 操作结果: 销毁数组
- Value(A, &e, index1, ..., indexn)
- 初始条件: 是 维数组, 为元素变量,随后是 个下标值
- 操作结果: 若各下标不越界,则将 中指定的元素赋值给 ,并返回
- Assign(&A, e, index1, ..., indexn)
- 初始条件: 是 维数组, 为元素变量,随后是 个下标值
- 操作结果: 若下标不越界,则将 的值赋给所指定 的元素,并返回
- InitArray(&A, n, bound1, ..., boundn)
- }
- 数据对象:
数组的顺序存储结构
- 次序约定问题:行主序或列主序
二维数组 的地址计算公式(逻辑上)
c语言中,数组下标的下界是0.本处计算公式指的是在数学逻辑上上,即下标下界为1
- 行优先存储:
- 列优先存储:
三维数组 的地址计算公式(行优先)(逻辑上)
矩阵(二维数组)的压缩存储
矩阵压缩存储是指矩阵中多个值相同的元素只分配一个空间,对零元素不分配空间
特殊矩阵
利用其数学性质或分布规律进行压缩
- 对角矩阵:所有的非零元素都集中在以主对角线为中心的带状区域中

- 三角矩阵
- 元素存储方式:
- 相同元素占用一个单元,若为0则不存储。
- 存储矩阵的非零元素:
- 使用数组存储矩阵中的个非零元素。
- 常数占最后一个单元。
- 数组元素 A[k] 与 aij 的关系:
- 需要根据特定的存储顺序(例如行优先或列优先)来确定 A[k] 与矩阵中元素 aij 之间的对应关系。
![fig:]()
- 需要根据特定的存储顺序(例如行优先或列优先)来确定 A[k] 与矩阵中元素 aij 之间的对应关系。
- 元素存储方式:
- 对称矩阵
- 对称矩阵的定义:
- 在 阶方阵 中,若 中的元素满足 (其中 ),则称 是对称矩阵。
- 对称矩阵的存储:
- 对称的元素共享一个存储空间,要存储的元素总数为 。
- 与 对应关系:
- 当 时, 。
- 当 时, 。
- 统一的 的对应关系为:
- 对称矩阵的定义:
稀疏矩阵
- 稀疏矩阵的定义:
- 含有非零元素及较多的零元素,但非零元素的分布没有任何规律,这种矩阵称为稀疏矩阵。
- 对于稀疏矩阵 ,其中有 个非零元素, 个零元素,若 ,则称 为稀疏矩阵。
- 稀疏矩阵压缩存储方法:
- 三元组表:使用三元组表来存储稀疏矩阵中的非零元素及其位置。
- 十字链表:通过十字链表来存储稀疏矩阵的非零元素,同时记录行和列的连接信息。
- 三元组表
- 稀疏矩阵的三元组表定义:
- 将稀疏矩阵的非零元素用三元组按行优先(或列优先)的顺序排列(跳过零元素),则得到一个其结点均是三元组的线性表。
- 三元组:是稀疏矩阵的一种顺序存储结构。
- 行优先:三元组 = (行 ,列 ,非零元素值)
- 列优先:三元组 = (列 ,行 ,非零元素值)
- 数据结构描述:
- #define smax 16 /* 最大非零元素个数的常数 */
typedef int datatype;
typedef struct {
int i, j; /* 行,列号 */
datatype v; /* 元素值 */
} node;
typedef struct {
int m, n, t; /* 行数, 列数, 非零元素个数 */
node data[smax]; /* 三元组表 */
} spmatrix; /* 稀疏矩阵类型 */
- 稀疏矩阵的三元组表定义:
- 三元组表下稀疏矩阵的转置

非线性结构
树和森林
- 树的基本概念:
- 结点:指树中的一个元素,包含数据项及若干指向其子树的分支。
- 结点的度:指结点拥有的子树个数。
- 树的度:指树中最大结点度数。
- 叶子:指度为零的结点,又称为终端结点。
- 孩子:一个结点的子树的根称为该结点的孩子。
- 双亲:一个结点的直接上层结点称为该结点的双亲。
- 兄弟:同一双亲的孩子互称为兄弟。
- 结点的层次:从根结点开始,根结点为第一层,根的孩子为第二层,根的孩子的孩子为第三层,依次类推。
- 树的深度:树中结点的最大层次数。
- 堂兄弟:双亲在同一层上的结点互称为堂兄弟。
- 路径:若存在一个结点序列 ,可使 到达 ,则称这个结点序列是 到达 的一条路径。
- 子孙和祖先:若存在 到 的一条路径 ,则 为 的祖先,而 为 的子孙。
- 森林: ( ) 棵互不相交的树的集合构成森林。当删除一棵树的根时,就得到子树构成的森林;当在森林中加上一个根结点时,则森林就变为一棵树。
- 有序树和无序树:若将树中每个结点的各个子树都看成是从左到右有次序的(即不能互换),则称该树为有序树;否则为无序树。
- 树的存储结构:
- 顺序存储时,首先必须对树形结构的结点进行某种方式的线性化,使之成为一个线性序列,然后将其存储。
- 链式存储时,使用多指针域的结点形式,每一个指针域指向一棵子树的根结点。
- 其他
树的存储结构(链式存储)
- 双亲表示法
- 树中每个结点的双亲是唯一的,可在存储结点信息的同时,为每个结点存储其双亲结点的地址信息。
- C语言逻辑描述为
- #define maxsize 32 /* 结点数目的最大值加1 */
typedef struct {
datatype data; /* 数据域 */
int parent; /* 双亲结点的下标 */
} ptree; - 使用静态链表ptree T[maxsize];
![fig:]()
- 孩子表示法/孩子双亲表示法
- 为树中每个结点建立一个孩子链表,类型说明如下:
- typedef struct cnode {
int child; /* 孩子结点序号 */
struct cnode *next;
} link; /* 孩子链表结点 */
typedef struct {
datatype data; /* 树结点数据 */
int parent; /* 双亲指针,双亲孩子表示法中定义 */
link *headptr; /* 孩子链表头指针 */
} ctree;
ctree T[maxsize]; ![fig:]()
- 孩子兄弟表示法:
- 在存储结点信息的同时,附加两个分别指向该结点最左孩子和右邻兄弟的指针域 first 和 next。
- 这种表示法与二叉链表表示类似,但在逻辑结构上有所不同,因为它可以表示多于两个孩子的结点。
typedef struct node {
datatype data; /* 结点数据 */
struct node *first; /* 指向最左孩子 */
struct node *next; /* 右邻兄弟 */
} node;
ADT--二叉树(binary tree)
- 二叉树的定义(递归定义):
- 二叉树是 ( )个结点的有限集,它或为空树( ),或由一个根结点及两棵互不相交的、分别称作这个根的左子树和右子树的二叉树构成。
- 二叉树是有序树
- 满二叉树的定义:
- 一棵深度为 且有 个结点的二叉树称为满二叉树。
- 满二叉树的特点是每一层的结点数都达到该层可具有的最大结点数。
- 不存在度数为1的结点。
- 完全二叉树的定义:
- 如果一个深度为 的二叉树,它的结点按照从根结点开始,自上而下,从左至右进行连续编号后,得到的顺序与满二叉树相应结点编号顺序一致,则称这个二叉树为完全二叉树。
![fig:]()
- ADT BinaryTree
- 数据对象 D: D是具有相同特性的数据元素的集合。
- 数据关系 R:
- 若D=Ø,则R=Ø,称BinaryTree为空二叉树。
- 若D≠Ø,则R={H},H是如下的二元关系:
- 在D中存在唯一的称为根的元素root,它在关系H下无前趋。
- 若D-{root}≠∅,则存在D-{root}={D1, D2},且D1∩D2=Ø。
- 若D≠Ø,则D1中存在唯一的元素x,<root,x>∈H,且存在D1上的关系H1⊆H;若D2≠Ø,则D2中存在唯一的元素x,<root,x>∈H,且存在D2上的关系H2⊆H;H={<root,x>,<root,x2>,H1,H2}。
- (D1,H1)是一棵符合定义的二叉树,称为根的左子树;(D2,H2)是一棵符合定义的二叉树,称为根的右子树。
- 操作集合:
- InitBiTree(&T)
- 初始条件:无
- 操作结果:构造空二叉树T
- DestroyBiTree(&T)
- 初始条件:二叉树T存在
- 操作结果:销毁二叉树T
- CreateBiTree(&T, definition)
- 初始条件:definition给出二叉树的定义
- 操作结果:按definition构造二叉树T
- ClearBiTree(&T)
- 初始条件:二叉树T存在
- 操作结果:将二叉树清为空树
- BiTreeEmpty(T)
- 初始条件:二叉树T存在
- 操作结果:若T为空二叉树,则返回TRUE;否则返回FALSE
- BiTreeDepth(T)
- 初始条件:二叉树T存在
- 操作结果:返回T的深度
- Root(T)
- 初始条件:二叉树T存在
- 操作结果:返回T的根
- Value(T, e)
- 初始条件:二叉树T存在,e是T中的某个结点
- 操作结果:返回e的值
- Assign(T, &e, value)
- 初始条件:二叉树T存在,e是T中的某个结点
- 操作结果:结点e赋值为value
- Parent(T, e)
- 初始条件:二叉树T存在,e是T中的某个结点
- 操作结果:若e是非根结点,则返回它的双亲;否则返回“空”
- LeftChild(T, e)
- 初始条件:二叉树T存在,e是T中的某个结点
- 操作结果:返回e的左孩子;若没有则返回“空”
- RightChild(T, e)
- 初始条件:二叉树T存在,e是T中的某个结点
- 操作结果:返回e的右孩子;若没有则返回“空”
- LeftSibling(T, e)
- 初始条件:二叉树T存在,e是T中的某个结点
- 操作结果:返回e的左兄弟;若没有左兄弟则返回“空”
- RightSibling(T, e)
- 初始条件:二叉树T存在,e是T中的某个结点
- 操作结果:返回e的右兄弟;若没有右兄弟则返回“空”
- InsertChild(T, p, LR, c)
- 初始条件:二叉树T存在,p指向T中的某个结点,LR为0或1,非空二叉树c与T不相交且右子树为空
- 操作结果:根据LR的值,插入c为T中p所指结点的左或右子树。p所指结点原来左或右子树则成为c的右子树
- DeleteChild(T, p, LR)
- 初始条件:二叉树T存在,p指向T中的某个结点,LR为0或1
- 操作结果:根据LR的值删除T中p所指结点的左或右子树
- PreOrderTraverse(T, Visit())
- 初始条件:二叉树T存在,Visit是对结点操作的应用函数
- 操作结果:先序遍历T,对每个结点调用函数Visit一次且仅此一次;一旦Visit失败,则操作失败
- InOrderTraverse(T, Visit())
- 初始条件:二叉树T存在,Visit是对结点操作的应用函数
- 操作结果:中序遍历T,对每个结点调用函数Visit一次且仅此一次;一旦Visit失败,则操作失败
- PostOrderTraverse(T, Visit())
- 初始条件:二叉树T存在,Visit是对结点操作的应用函数
- 操作结果:后序遍历T,对每个结点调用函数Visit一次且仅此一次;一旦Visit失败,则操作失败
- LevelOrderTraverse(T, Visit())
- 初始条件:二叉树T存在,Visit是对结点操作的应用函数
- 操作结果:层序遍历T,对每个结点调用函数Visit一次且仅此一次;一旦Visit失败,则操作失败
- InitBiTree(&T)
二叉树的数学性质
- 二叉树的性质1:
- 在二叉树的第 层上至多有 个结点( )。
- 二叉树的性质2:
- 深度为 的二叉树至多有 个结点( )。
- 二叉树的性质3:
- 对任何一棵二叉树,如果其终端结点数为 ,度为2的结点数为 ,则 。
- 二叉树的性质4:
- 具有 个结点的完全二叉树的深度为 或 。
- 其中, 表示不大于 的最大整数, 表示不小于 的最小整数。
二叉树的顺序存储
适用于满二叉树与完全二叉树
- 完全二叉树/满二叉树的编号性质:
- 若 ,则 结点是根结点;若 ,则 结点的双亲编号为 。
- 若 ,则 结点无左孩子, 结点是终端结点;若 ,则 是结点 的左孩子。
- 若 ,则 结点无右孩子;若 ,则 是结点 的右孩子。
- 若 为奇数且不等于1时,结点 的左兄弟是 。
- 若 为偶数且小于 时,结点 的右兄弟是结点 ,否则结点 没有右兄弟。
一般二叉树的顺序存储:
按照完全二叉树存储,通过虚结点补全占位映射
#define maxsize 1024
typedef int datatype;
typedef struct {
datatype data[maxsize]; /* 存储数据的数组 */
int last; /* 指示最后一个元素的位置 */
} sequenlist; /* 顺序表类型 */
二叉树的链式存储
- 二叉链表:
- 含有两个指针域来分别指向左孩子指针域(lchild)和右孩子指针域(rchild),以及结点数据域(data),故二叉树的链式存储结构也称为二叉链表。
- 二叉链表结点的C语言逻辑描述为:
- typedef int datatype;
struct TreeNode {
datatype data;
struct TreeNode *lchild, *rchild;
};
struct TreeNode *root; - 三叉链表:
- 在二叉链表上增加一个指向其双亲的指针域parent
![fig:]()
二叉树的算法描述(三叉链表)
- 三叉链表建立
- 遍历算法(二叉树->序列)
- 深度优先遍历(递归)
- 前序遍历DLR
- 中序遍历LDR
- 后序遍历LRD
- 广度优先遍历[按层次遍历]--FIFO--queue
- 深度优先遍历(非递归)--LDR--FILO--stack--O(n)
- 深度优先遍历(递归)
- 恢复二叉树(序列->二叉树)--已知DLR&LDR或者LDR&LRD可以唯一确定二叉树
- 已知DLR&LDR
- 已知LDR&LRD
- 统计一棵二叉树中的叶子结点数
- 求二叉树的深度
树、森林和二叉树之间的转换
- 树->二叉树(右子树为空)
- 在兄弟(非堂兄弟)之间增加一条连线:
- 将每个结点的所有兄弟(即同一双亲的孩子)通过一个链表连接起来。
- 对每个结点,除了保留与其左孩子的连线外,除去与其它孩子之间的连线:
- 每个结点只保留指向其最左孩子的指针,移除指向其它孩子的指针。
- 以树的根结点为轴心,将整个树顺时针旋转45度:
- 将树结构进行视觉转换,使得原来的上下结构变为左右结构,类似于将树“躺平”。
- 在兄弟(非堂兄弟)之间增加一条连线:
- 二叉树->树
- 若结点X是双亲Y的左孩子,则把X的右孩子,右孩子的右孩子…都与Y用连线相连:
- 这意味着将双亲结点Y与其左孩子结点X的右孩子链表进行连接,形成一个连续的右孩子链。
- 去掉原有的双亲到右孩子的连线:
- 移除双亲结点到其右孩子之间的直接连接,因为在转换后的二叉树结构中,每个结点的孩子将通过左孩子指针或右兄弟指针来表示。
- 若结点X是双亲Y的左孩子,则把X的右孩子,右孩子的右孩子…都与Y用连线相连:
- 森林->二叉树
- 先将森林中的每一棵树转换为二叉树:
- 按照孩子兄弟表示法,将森林中的每棵树独立转换为二叉树。在这种表示法中,每个结点的左孩子代表其第一个孩子,而右孩子代表其兄弟。
- 再将第一棵树的根作为转换后二叉树的根,第一棵树的左子树作为转换后二叉树根的左子树:
- 选取森林中第一棵树的根结点作为新二叉树的根结点,并将这棵树视为新二叉树的左子树部分。
- 第二棵树作为转换后二叉树的右子树,第三棵树作为转换后的二叉树根的右子树的右子树,如此类推下去:
- 森林中剩余的每棵树按照它们在森林中的顺序,依次成为前一棵树右子树的右孩子,形成兄弟关系。
- 先将森林中的每一棵树转换为二叉树:
线索二叉树
- 存储遍历信息,避免多次遍历,快速输出(恢复)线性序列
![fig:]()
![fig:]()
![fig:]()
- 二叉树->线索二叉树
- 访问线索二叉树
应用1--哈夫曼树及其编码译码
哈夫曼树
- 定义
- 若树中的两个结点之间存在一条路径,则路径的长度是指路径所经过的边(即连接两个结点的线段)的数目。
- 树的路径长度是树根到树中每一结点的路径长度之和。
- 树的带权路径长度为树中所有叶子结点的带权路径长度之和,记作:
- 其中 为树中叶子结点的数目, 为叶子结点 的权值, 为叶子结点 到根结点之间的路径长度。
- 在有 个带权叶子结点的所有二叉树中,带权路径长度WPL最小的二叉树被称为最优二叉树或哈夫曼树。
- 权值为 的 个叶子结点形成的二叉树,可以具有多种形态,其中能被称为哈夫曼树的二叉树并不是唯一的。
- 在叶子数和权值相同的二叉树中,完全二叉树不一定是最优二叉树。
- 一个有 个叶子结点的初始集合,要生成哈夫曼树共要进行 次合并,产生 个新结点。
- 最终求得的哈夫曼树共有 ( )个结点,并且哈夫曼树中没有度为1的分支结点。(我们常称没有度为1的结点的二叉树为严格二叉树。)
- 构建哈夫曼树的步骤:
- 根据给定的 个权值 构成 棵二叉树的集合 ,其中 中只有一个权值为 的根结点,左、右子树均为空。
- 在 中选取两棵根结点的权值最小的树作为左、右子树构造一棵新的二叉树,且置新的二叉树的根结点的权值为左、右子树上根结点的权值之和。
- 在 中删除这两棵权值最小的树,同时将新得到的二叉树加入 中。
- 重复步骤 2 和 3,直到 中仅剩一棵树为止。这棵树就是哈夫曼树。
- 存储结构
#define n 叶子数目
#define m 2*n-1 /* 结点总数 */
typedef char datatype;
typedef struct
{
float weight;
datatype data;
int lchild, rchild, parent;
} hufmtree;
hufmtree tree[m];

哈夫曼编码
- 从哈夫曼树根结点开始,对左子树分配代码0,右子树分配代码1,一直到达叶子结点为止,然后将从树根沿每条路径到达叶子结点的代码排列起来,便得到了哈夫曼编码。
- n个叶子结点的最大编码长度不会超过n-1。
- 编码数组结构描述如下:
typedef char datatype;
typedef struct
{
char bits[n]; /* 编码数组位串,其中n为叶子结点数目*/
int start; /* 编码在位串的起始位置 */
datatype data; /* 结点值 */
} codetype;
codetype code[n];
- 从叶子tree[i]出发,利用双亲地址找到双亲结点tree[p],再利用tree[p]的lchild和rchild指针域判断tree[i]是tree[p]的左孩子还是右孩子,然后决定分配代码的 '0' 还是代码 '1',然后以tree[p]为出发点继续向上回溯,直到根结点为止。
哈夫曼译码
- 定义:哈夫曼树译码是指由给定的代码求出代码所表示的结点值。
- 译码的过程是:从根结点出发,逐个读入电文中的二进制代码;若代码为0则走向左孩子,否则走向右孩子;一旦到达叶子结点,便可译出代码所对应的字符。然后又重新从根结点开始继续译码,直到二进制电文结束。
应用2--二叉排序树
- 定义:如果一棵二叉树的每个结点对应一个关键码,并且每个结点的左子树中所有结点的码值都小于该结点的关键码值,而右子树中所有结点的关键码值都大于该结点的关键码值,则这个二叉树称为排序二叉树。
- 性质:对二叉排序树进行中序遍历时,可以发现所得到的中序序列是一个递增有序序列。
- 二叉排序树的二叉链表存储结构结点结构描述如下:
typedef int keytype;
typedef struct node {
keytype key; /* 关键字项 */
datatype other; /* 其它数据数据项 */
struct node *lchild, *rchild; /* 左、右指针 */
} bstnode;
- 二叉排序树的构造是指将一个给定的数据元素序列构造为相应的二叉排序树。
- 对于任给的一组数据元素 ,可按以下方法来构造二叉排序树:
- 令 为二叉树的根;
- 若 ,令 为 左子树的根结点,否则 为 右子树的根结点;
- 对 结点也是依次与前面生成的结点比较以确定输入结点的位置。
- 二叉排序树的结点删除:删除的结点由p指出,其双亲结点由q指出,则二叉排序树中结点删除分三种情况考虑:
- 若p指向叶子结点,则直接将该结点删除。
- 若p所指结点只有左子树pL或只有右子树pR,此时只要使pL或pR成为q所指结点的左子树或右子树即可。
- 若p所指结点的左子树pL和右子树pR均非空,则需要将pL和pR链接到合适的位置上,即应使中序遍历该二叉树所得序列的相对位置不变。具体做法有两种:
- 令pL直接链接到q的左(或右)孩子链域上,而pR则下接到p结点中序前趋结点s上(s是pL最右下的结点);
- 以p结点的直接中序前趋或后继替代p所指结点,然后再从原二叉排序树中删去该直接前趋或后继。


ADT--图(graph)
本书中不探讨存在图中顶点到自身的边和两点间多条相同边的情况
- 基本概念:
- 图(G)是一种非线性数据结构,它由两个集合V(G)和E(G)组成,形式上记为X G=(V, E),其中:
- V(G)是顶点(Vertex)的非空有限集合。
- E(G)是V(G)中任意两个顶点之间的关系集合,又称为边(Edge)的有限集合。
- 有向图
- 当且仅当图G的每条边有方向时,图G为有向图。
- 有向边记为“起始顶点 → 终止顶点”,有向边又称为弧。
- 因此,弧的起始顶点就称为弧尾,终止顶点称为弧头。
- 顶点数为 ,边数为 的有向图满足 的关系。
- 无向图
- G为无向图当且仅当G中的每条边是无方向的。
- 无向图用两个顶点组成的无序对表示,即(顶点1, 顶点2)。
- 顶点数为 ,边数为 的无向图满足 的关系。
- 完全无向图:边数 的无向图。
- 完全有向图:边数 的有向图。
- 稀疏图:边数 的图。
- 稠密图:边数 的图。
- 子图:对于两个同类型的图 和 ,如果存在关系 和 ,则称 是 的子图。
- 无向图G中:
- 若边 ,则称顶点 和 相互邻接,互为邻接点。
- 并称边 关联于顶点 和 ,或称边 与顶点 和 相关联。
- 有向图G中:
- 若边 ,则称为顶点 邻接到 或 邻接于 。
- 并称边 关联于顶点 和 ,或称边 与顶点 和 相关联。
- 在无向图中,关联于某一顶点 的边的数目称为 的度,记为 。
- 在有向图中:
- 把以顶点 为终点的边的数目称为 的入度,记为 。
- 把以顶点 为起点的边的数目称为 的出度,记为 。
- 把顶点 的度定义为该顶点的入度和出度之和,即 。
- 在有 个顶点 条边的无向图 中,每个顶点的度为 ( ),则存在以下关系:
- 权:如果图的边或弧具有一个与它相关的数时,这个数就称为该边或弧的权。
- 网络:图中的每条边都具有权时,这个带权图被称为网络,简称为网。
- 路径:若从顶点 出发,沿着一些边经过顶点 到达顶点 ,则称顶点序列 为从 到 的一条路径。
- 简单路径:若路径中的顶点不重复出现,则这条路径被称为简单路径。
- 简单回路:对起点和终点相同并且路径长度 ≥ 2 的简单路径被称为简单回路或者简单环。
- 有根图:有向图中,若存在一个顶点 ,从该顶点有路径可到达图中其它的所有顶点,则称这个有向图为有根图, 称为该图的根。
- 无向图G中:
- 若顶点 和 ( )有路径相通,则称 和 是连通的。
- 如果 中的任意两个顶点都连通,则称G是连通图,否则为非连通图。
- 无向图G中的极大连通子图称为G的连通分量。
- 注:对任何连通图而言,连通分量就是其自身;对非连通图可有多个连通分量。
- 有向图G中:
- 若从 到 ( )和从 到 都存在路径,则称 和 是强连通的。
- 若有向图 中的任意两个顶点都是强连通的,则称该图为强连通图。
- 有向图中的极大连通子图称作有向图的强连通分量。
- 图(G)是一种非线性数据结构,它由两个集合V(G)和E(G)组成,形式上记为X G=(V, E),其中:
- 图的抽象数据类型的定义如下:
- ADT Graph{
数据对象 D: D是具有相同特性的数据元素的集合,称为顶点集。
数据关系 R: R={<v,w> | v,w∈D且P(v,w), <v,w>表示从v到w的弧,谓词 P(v,w)定义了弧<v,w>的意义或信息}
操作集合:- CreateGraph(G)
- 初始条件:图G的顶点集和弧的集合
- 操作结果:按顶点和弧的定义构造图G
- CreatAdjlist(ga)
- 初始条件:图的顶点集合和弧的集合
- 操作结果:按顶点和弧的定义构造图的邻接表 ga
- DestroyGraph(G)
- 初始条件:图G存在
- 操作结果:销毁图G
- InsertVex(G, v)
- 初始条件:图G存在,v和图中顶点有相同特征
- 操作结果:在图G中增添新顶点
- DeleteVex(G, v)
- 初始条件:图G存在,v是G中某个顶点
- 操作结果:删除G中顶点v及其相关的弧
- DFSA(G, i)
- 初始条件:图的邻接矩阵,i是图中某个顶点
- 操作结果:从顶点i起深度优先遍历图
- DFSL(ga, i)
- 初始条件:图的邻接表,i是图中某个顶点
- 操作结果:从顶点i起深度优先遍历图
- BFSA(G, k)
- 初始条件:图的邻接矩阵,k是图中某个顶点
- 操作结果:从顶点k起广度优先遍历图
- BFSL(ga, k)
- 初始条件:图的邻接表,k是图中某个顶点
- 操作结果:从顶点k起广度优先遍历图
- CreateGraph(G)
- } ADT Graph
- ADT Graph{
图的存储方法诸多,本书中仅介绍邻接矩阵和邻接表两种
邻接矩阵存储(顺序存储)
- 在邻接矩阵存储方法中使用一个一维数组来存放图中每个结点的数据信息,和使用一个二维数组(又称为邻接矩阵)来表示图中各顶点之间的关系。
对一个有n个顶点的图G,将使用一个n×n的矩阵来表示顶点间的关系,矩阵的每一行和每一列都对应一个顶点。矩阵中的元素A[i, j]可按以下规则取值:- 如果i = j,则A[i, j] = 0;
- 如果存在边<v_i, v_j>属于E(G),则A[i, j] = 1;
- 否则,A[i, j] = 0。
- 即:
$$A[i,j]= \begin{cases} 0&\text{if}\space i=j\\ 1&\text{if}\space<v_i,v_j>\in E(G)\\ 0&\text{otherwise} \end{cases} \space\space\space\space\space\space\space,其中1 ≤ i, j ≤ n。$$
- 行和列中的非零元素个数分别对应于的顶点的出度和入度;行或列非零元素的个数对应于该顶点的度。
- 若为网络,则存储为权值;0与无穷均定义为0
- 结构c语言描述:
- #define n 图的顶点数
#define e 图的边数
typedef char vextype; /* 顶点的数据类型 */
typedef float adjtype; /* 边权值的数据类型 */
typedef struct {
vextype vexs[n]; /* 顶点数组 */
adjtype arcs[n][n]; /* 邻接矩阵 */
} graph;
- 无向网络邻接矩阵建立算法--无向图/有向网络/有向图
邻接表(顺序+链式)
- 邻接表存储方法是一种顺序存储与链式存储相结合的存储方法。
- 顶点表:结构体数组
- 顶点域(vertex):用来存放顶点本身的数据信息。
- 指针域(link):用来存放依附于该顶点的边所组成的单链表的表头结点的存储位置。-->邻接链表
- 邻接链表:
- 与顶点域中的指针域相连,其中的结点有两个域:
- 邻接点域(adjvex):用来存放与 vi 相邻接的顶点 vj 的序号 j(可以是顶点 vj 在顶点表中所占数组单元的下标)。
- 链域(next):用来将邻接链表中的结点链接在一起。
- 与顶点域中的指针域相连,其中的结点有两个域:
- 顶点表:结构体数组
- c语言结构体声明:
- typedef char vextype; /* 定义顶点数据信息类型 */
typedef struct node { /* 邻接链表结点 */
int adjvex; /* 邻接点域 */
struct node *next; /* 链域 */
} edgenode;
typedef struct { /* 顶点表 */
vextype vex; /* 顶点域 */
edgenode *link; /* 指针域 */
} vexnode;
vexnode ga[n]; /* 顶点表 */
- 无向图邻接表的建立算法O(n+e)--两次插入--头插法
邻接矩阵和邻接表各有所长,具体体现在以下几点:
- 一个图的邻接矩阵表示是唯一的,而其邻接表表示不唯一。
- 判定 或 是否是图中的一条边:
- 在邻接矩阵表示中,只须判定矩阵中的第i行第j列的元素是否为零即可。
- 在邻接表中,则需要扫描对应的邻接链表,最坏的情况下需要O(n)时间。
- 求图中边的数目:
- 使用邻接矩阵必须检测了整个矩阵才能确定,所消耗的时间为。
- 在邻接表中只须对每个边表中的结点个数计数便可确定。当e时,使用邻接表计算边的数目可以节省计算时间。
深度优先遍历DFS
树先序遍历的推广
遍历序列不唯一
- 在假设初始状态是图中所有顶点都未被访问的前提下,这种方法从图中某一顶点出发,访问此顶点,并进行标记,然后依次搜索的每个邻接点:
- 若未被访问过,则对进行访问和标记,然后依次搜索的每个邻接点;
- 这个过程会一直进行,直到图中和有路径相通的顶点都被访问。
- 若图中尚有顶点未被访问过(非连通的情况下),则另选图中的一个未被访问的顶点作为出发点,重复上述过程,直到图中所有顶点都被访问为止。
- 邻接矩阵下算法的时间复杂度为,空间复杂度为。
- 邻接表下算法的时间复杂度为,空间复杂度为
广度优先算法BFS
- 在假设初始状态是图中所有顶点都未被访问的条件下:
这种方法从图中某一顶点出发,访问,然后依次访问的邻接点。在所有的都被访问之后,再访问的邻接点,……直到图中所有和初始出发点有路径相通的顶点都被访问为止。
若图是非连通的,则选择一个未曾被访问的顶点作为起始点,重复以上过程,直到图中所有顶点都被访问为止。 - FIFO--queue
- 对于连通图:
- 结束条件是队列为空。
- 对于非连通图:
- 结束条件是队列为空并且图的所有结点都被遍历。
- 邻接矩阵下算法的时间复杂度为,空间复杂度为.
- 邻接表下算法的时间复杂度为,空间复杂度为.
最小生成树--解决网络环路问题
- 基本概念:
- 生成树:一个连通图G的生成树指的是一个包含了G的所有顶点的树(树:无回路存在的连通图)
- 最小生成树:
- 对一个连通网络来构造生成树时,可以得到一个带权的生成树。我们把生成树各边的权值总和作为生成树的权,而具有最小权值的生成树构成了连通网络的最小生成树。
- 构造最小生成树就是在给定n个顶点所对应的权矩阵(代价矩阵)的条件下,给出代价最小的生成树。
- 构造最小生成树的依据:MST(Minimum Spanning Tree)性质指出,假设G=(V,E)是一个连通网络,U是V中的一个真子集,若存在顶点u∈U和顶点v∈V-U的边(u,v)是一条具有最小权的边,则必存在G的一棵最小生成树包括这条边(u,v)。
Prim算法
已知:设G(V,E)是有n个顶点的连通网络,构造G的生成树T=(U,TE),初始时U={φ},TE={φ}。
基本思想:
- 首先从V中取出一个顶点u0放入生成树的顶点集U中作为第一个顶点,此时T=({u0},{φ})。
- 从u∈U,v∈V-U的边(u,v)中找一条代价最小的边(u0,v)放入TE中,并将v放入U中,此时T=({u0,v}, {(u0,v)})。
- 如果U==V,则结束;否则转第二步。
这时T的TE中必有n-1条边,构成所要构造的最小生成树。
typedef struct {
int fromvex, endvex; /* 边的起点和终点 */
float length; /* 边的权值 */
} edge;
edge T[n-1]; /* 存储生成树的边 */
float dist[n][n]; /* 连通网络的带权邻接矩阵 */
Kruskal算法
已知:G=(V, E)是一个有n个顶点的连通图,构造最小生成树T=(U,TE),初值状态为只有n个顶点而无任何边的非连通图U=V,TE={Ø},此时图中每个顶点自成一个连通分量,共n个分量。
算法思想:
- 对于e∈E,若e的权值最小,且e的两个顶点依附于T中两个不同的连通分量,则TE=TE+{e},E=E-{e};否则E=E-{e}。
- 若T中所有顶点都在同一连通分量上,则结束;否则转1。
该算法中的关键是如何确定所选择的边的两个顶点是否属于不同连通分量。
采用邻接表便于查找最短边
typedef struct {
int fromvex, endvex; /* 边的起点和终点 */
float length; /* 边的权值 */
int sign; /* 该边是否已选择过的标志信息 */
} edge;
edge T[e]; /* e为图中的边数 */
int G[n]; /* 判断该边的两个顶点是否在同一个分量上的数组 */
Kruskal算法的时间复杂度约为,其中e为网中的边数。这种算法的时间复杂度与网中的边数有关,因此适合于求边稀疏网络的最小生成树。
Prim算法的时间复杂度为,其中n为网中的顶点数。这种算法与网中的边数无关,适合于边稠密网的最小生成树。
最短路径--解决网络路由问题
- 路径的开始点为源点(Source),路径的最后一个顶点为终点(Destination)。
- 最短路径意味着沿路径的各边权值之和最小。
Dijkstra算法--从某个源点到其余各顶点的最短路径
已知有n个顶点的有向连通网络G=(V, E),求最短路径网络S=(U,SE)的步骤如下:
- 从V中取出源点u0放入最短路径顶点集合U中,这时的最短路径网络S=(U,SE),其中U={u0},SE={}。
- 从u∈U和v∈V-U中找一条代价最小的边(u0,v)加入到S中去,此时S=({u0, v}, {(u0, v*)})。
- 对V-U中的各顶点的权值进行一次修正。若加进v做为中间顶点,使得从u0到其它属于V-U的顶点vi的最短路径比不加v时短,则修改u0到vi的权值,即以(u0, v)的权值加上(v, vi)的权值来代替原(u0, vi)的权值。否则不修改u0到vi的权值。
- 若U=V,则结束;否则转2。
- Dijkstra算法的时间复杂度为,占用的辅助空间是。
Floyd算法--每一对顶点之间的最短路径
Floyd算法的基本思想是:
- 假设v_i和v_j之间存在一条路径,但这并不一定是最短路径。
- 试着在v_i和v_j之间增加一个中间顶点v_k:
- 若增加v_k后的路径(v_i, v_k, v_j)比(v_i, v_j)短,则以新的路径来代替原路径,并且修改dist[i][j]的值为新路径的权值。
- 若增加v_k的路径比(v_i, v_j)更长,则维持dist[i][j]不变。
- 在修改后的dist矩阵中,另选一个顶点作为中间顶点,重复以上的操作,直到除v_i和v_j顶点的其余顶点都做过中间顶点为止。
Floyd算法通过迭代更新距离矩阵dist,其中dist[i][j]表示顶点v_i到顶点v_j的最短路径长度。算法的核心是三重循环,其中外层循环遍历所有顶点作为中间顶点,内层两个循环分别遍历所有顶点对(v_i, v_j),并检查是否通过当前的中间顶点v_k可以找到更短的路径:
$$\text{for } k = 1 \text{ to } n \\ \quad \text{for } i = 1 \text{ to } n \\ \quad \quad \text{for } j = 1 \text{ to } n \\ \quad \quad \quad \text{if } dist[i][k] + dist[k][j] < dist[i][j] \\ \quad \quad \quad \quad \text{then } dist[i][j] = dist[i][k] + dist[k][j]$$
拓扑排序与关键路径--解决项目进度安排问题
拓扑排序算法
- 基本概念:
- 顶点表示活动网络(AOV网):按一定顺序进行的活动可以使用顶点表示活动,顶点之间的有向边表示活动间的先后关系的有向图来表示。这种有向图称为顶点表示活动网络(Activity On Vertex network),简称AOV网。
- 限制活动只能串行进行:将AOV网中的所有顶点排列成一个线性序列 ,并且这个序列同时满足关系:若在AOV网中从顶点 到顶点 存在一条路径,则在线性序列中 必在 之前。称这个线性序列为拓扑序列。
- 把对AOV网构造拓扑序列的操作称为拓扑排序。
- 基本思想:
- (1) 从网中选择一个入度为0的顶点并且输出它;
(2) 从网中删除此顶点及所有由它发出的边;
(3) 重复上述两步,直到网中再没有入度为0的顶点为止。 - 注:以上的操作会产生两种结果:
- 网中的全部顶点都被输出,整个拓扑排序完成;
- 网中顶点未被全部输出,剩余的顶点的入度均不为0,则说明网中存在有向环。
- 拓扑排序算法邻接矩阵思想--算法的时间复杂度
- 拓扑排序算法邻接表思想--算法的时间复杂度为O(n+e)
- typedef int vextype;
typedef struct node {
int adjvex; /* 邻接点域 */
struct node *next; /* 链域 */
} edgenode; /* 边表结点 */
typedef struct {
vextype vex; /* 顶点信息 */
int id; /* 入度域 */
edgenode *link; /* 边表头指针 */
} vexnode; /* 顶点表结点 */
vexnode ga[n]; /* 顶点表 */
关键路径
- 基本概念
- AOE网络:
- 结点表示状态,边表示活动的带权有向网络,即边表示活动的网络。
- AOE网络中只有一个入度为0的顶点,称作源点,表示开始。
- 只有一个出度为0的顶点,称为汇点,表示结束。
- AOE网络应该是不存在回路,并相对源点连通的网络。
- 关键路径(Critical path):
- 从源点到汇点的具有最大路径长度的路径称为关键路径。
- 从源点 到 的最长路径长度称为事件 可能的最早发生时间,记为 。
- 也是以 为起点的出边 所表示的活动 的最早开始时间 ,所以 。
- 事件 允许的最迟发生时间,记为 ,它等于汇点 的最早发生时间 减去 到 的最长路径长度。
- 活动 的最迟开始时间 等于 减去 的持续时间,即 。
- 将 的活动定义为关键活动。关键活动就是关键路径上的各个活动。
- AOE网络:
- 确定关键活动算法的思想:
- 确定由边 表示的活动 是否为关键活动,需要判断 是否等于 。为了求得 和 ,则要先求出 和
- 计算 :
-
其中 是网络中以 为终点的入边集合, 是有向边 上的权值。 - 计算 :
-
其中 是网络中以 为起点的出边集合。 - 计算 :
- 计算 :
- 如果 ,则活动 为关键活动。
- 步骤组成:
- 对AOE网进行拓扑排序,同时按拓扑排序顺序求出各顶点事件的最早发生时间ve。若网中有回路,则算法终止;否则执行(2)。
- 按拓扑序列的逆序求出各顶点事件的最迟发生时间vl。
- 根据ve和vl的值求出ai的最早开始时间e(i)和最迟开始时间l(i)。若l(i)=e(i),则ai为关键活动。
索引结构与散列技术--查找运算
索引表
- 索引结构包括两部分:索引表和数据表。
- 指明结点与其存储位置之间的对应关系的表就叫做索引表;数据表是存储结点信息的,索引结构中常用的数据表是线性表。
- 索引表中的每一项称作索引项,索引项的一般形式是:(关键字,地址)。
- 其中关键字是能唯一标识一个结点的那些数据项。
- 如果数据表中的记录按关键字顺序排列,这时的索引结构称索引顺序结构。
- 反之,若数据表中的数据未按关键字顺序排列时,则称索引非顺序结构。
- 线性索引:
- 稠密索引:一个索引项对应数据表中一个对象-->存储位置
- 稀疏索引:一组记录建立一个索引项-->存储的起始位置
- 检索:在索引表上查询判断有无获得存储地址-->访问数据表或返回空值
- 顺序查找/折半查找
- 分块查找
索引结构分块查找(索引顺序查找)
性能介于顺序查找和二分查找之间
分块查找=顺序查找+二分查找
- 构造方法:
- 将数据表R[n]均分成b块,前b-1块中记录个数为S=「n/b」,第b块的记录数小于等于S;
- 前一块中的最大关键字必须小于后一块中的最小关键字,即要求表是“分块有序”的;
- 抽取各块中的最大关键字及其起始位置构成一个索引表ID[i],即ID[i](0≤i<b)中存放着第i块的最大关键字及该块在表R中的起始位置。
由于表是分块有序的,所以索引表是递增有序表。
- 分块查找的基本思想:
- 查找索引表,因为索引表是有序表,故可采用顺序查找或折半查找,以确定待查找的记录在哪一块;
- 在已确定的那一块中进行顺序查找。
- 分块查找的c语言表示:
- typedef struct { /* 索引表的结点类型 */
keytype key; /* 关键字 */
int addr; /* 地址 */
} Idtable;
Idtable ID[b]; /* 索引表 */
散列表
不用比较而直接计算出记录所在地址,从而可直接进行存取的方法。
散列表查找的基础是散列存储结构。
- 散列法/关键字——地址转换法:以结点的关键字k为自变量,通过一个确定的函数关系f,计算出对应的函数值,把这个值解释为结点的存储地址,将结点存入f(k)所指的存储位置上。该方法称为。
- 用散列法存储的线性表叫散列表(Hash Table)或哈希表。
- 函数f()称为散列函数或哈希函数,f(k)的值则称为散列地址或哈希地址。
通常散列表的存储空间是一个一维数组,散列地址是数组的下标,在不致于混淆之处,我们将这个一维数组空间就简称为散列表。
- 散列函数是一个一对一的函数。
- 装填因子:散列表空间大小为m,填入表中的结点数是n,则称α=n/m为散列表的装填因子。实用时,常在区间[0.65,0.9]上取α的适当值。
- 散列函数的选取原则是:
- 运算应尽可能简单;
- 函数的值域必须在表长的范围之内;
- 尽可能使得关键字不同时,其散列函数值亦不相同。
- 冲突:若某个散列函数H对于不相等的关键字key1和key2得到相同的散列地址(即H(key1)=H(key2)),则将该现象称为冲突,而发生冲突的这两个关键字则称为该散列函数H的同义词。
散列法查找必须解决下面两个主要问题:
(1) 选择一个计算简单且冲突尽量少的“均匀”的散列函数-->除留余数法
(2) 确定一个解决冲突的方法,即寻求一种方法存储产生冲突的同义词。
除留余数法--散列表的构造
- 基本思想:
- 选择适当的正整数p,用p去除关键字,取所得余数作为散列地址,即:
- 一般地选p为小于或等于散列表长度m的某个最大素数比较好。
解决冲突的方法
- 开放地址法
基本思想:
- 开放地址法解决冲突的做法是:当发生冲突时,使用某种方法在散列表中形成一个探查序列,沿着此序列逐个单元进行查找,直到找到一个空的单元时将新结点放入。
- 拉链法
基本思想:
- 拉链法解决冲突的方法是:将所有关键字为同义词的结点链接到同一个单链表中。若选定的散列函数的值域为0到m-1,则可将散列表定义为一个由m个头指针组成的指针数组HTP[m],凡是散列地址为i的结点,均插入到以HTP[i]为头指针的单链表中。
开放地址法
当发生冲突时,使用某种方法在散列表中形成一个探查序列,沿着此序列逐个单元进行查找,直到找到一个空的单元时将新结点放入
可能会产生堆积现象
- 探查序列包括:
- 线性探查法:按照一定的线性序列(例如)探查新的空单元。
- 开放地址法中的线性探查公式为:
其中 。
- 二次探测法:按照二次序列(例如)探查新的空单元。
- 二次探测法的探查序列依次为:1^2, -1^2, 2^2, -2^2, ... 等,即正负平方数序列。
- 发生冲突时,将同义词来回散列在第一个地址 d=H(key) 的两端。
- 发生冲突时,求下一个开放地址的公式为:
- (1 ≤ i ≤ (m-1)/2)
- 随机探测法:按照随机序列探查新的空单元。
- 采用一个随机数作为地址位移计算下一个单元地址。求下一个开放地址的公式为:
其中: , 是 的一个随机排列。- 实际应用中,常用移位寄存器序列代替随机数序列。
- 线性探查法:按照一定的线性序列(例如)探查新的空单元。
拉链法
- 将所有关键字为同义词的结点链接到同一个单链表中。
- 若选定的散列函数的值域为0到m-1,则可将散列表定义为一个由m个头指针组成的指针数组HTP[m],凡是散列地址为i的结点,均插入到以HTP[i]为头指针的单链表中。
- 拉链法的优点包括:
- 不会产生堆积现象,因而平均查找长度较短;
- 由于拉链法中各单链表的结点是动态申请的,故它更适合于造表前无法确定表长的情况;
- 在用拉链法构造的散列表中,删除结点的操作易于实现,只要简单地删去链表上相应的结点即可;
- 当装填因子α较大时,拉链法所用的空间比开放地址法多,但是α越大,开放地址法所需的探查次数越多,所以,拉链法所增加的空间开销是合算的。
- 顺序查找和折半查找所需进行的关键字比较次数仅取决于表长;
散列查找需进行的关键字比较次数和待查结点有关,在等概率情况下,散列表查找不成功的平均查找长度定义为对关键字需要执行的平均比较次数。 - 同一个散列函数、不同解决冲突方法构成的散列表,平均查找长度是不相同的。
![fig:]()
- 1)散列表的平均查找长度不是结点个数n或表长m的函数,而是装填因子α的函数。
- 2)α越大,说明表越满,再插入新元素时发生冲突的可能性就越大,但α过小,空间的浪费就会过多。
- 算法规模分析
算法
- 分治与递归
- 二分搜索技术:一种在有序数组中查找特定元素的搜索算法。
- 归并排序:采用分治法的一个典型应用,将数组分成两半,对每半分别排序,最后合并。
- 快速排序(分治):通过选择一个基准值将数据分为两部分,一部分数据比基准值小,另一部分数据比基准值大,然后递归地对这两部分数据进行排序。
- 动态规划/备忘录方法
- 矩阵连乘问题:动态规划的经典问题之一,用于计算多个矩阵相乘的最佳顺序,以最小化计算量。
- 贪心算法
- 背包问题:一个组合优化的问题,给定一组物品,每个物品有其重量和价值,在限定的总重量内选择物品,使得总价值最大。
分治与递归
- 递归:
- 一个直接或间接调用自身的算法称为递归算法。
一个函数是用自身给出定义的函数称为递归函数。 - 使用递归算法分两类:
1)自身的递归特性(采用递归方式给出定义),特别适合用递归方式描述,例如二叉树的操作和遍历。
2)本身没有明显的递归结构,但用递归策略求解会使设计出的算法易懂且易于分析。
- 一个直接或间接调用自身的算法称为递归算法。
- 分治:
- 分治法的设计思想是:
- 将一个规模为n的问题分解为k个规模较小的子问题,这些子问题相互独立且与原问题相同。
- 递归地解这些子问题,然后将子问题的解合并得到原问题的解。
- 用分治法设计算法时,最好使子问题的规模大致相同,即将一个问题分割成大小大致相等的k个子问题的处理方法行之有效。
- 分治法设计的程序一般是递归算法,因此可以用递归方程进行分析。
- 分治法的设计思想是:
二分搜索(Binary Search)
- 给定一排好序的n个元素R[0]~R[n-1],现要在这些元素中找出一特定元素x。
- 二分搜索算法的基本思想:
- 将n个元素分成个数大致相同的两部分,取R[n/2]与x进行比较;
- 如果R[n/2]与x相等,则找到x,算法终止;
- 如果x < R[n/2],则只在数组R的左半部分继续搜索x;如果x > R[n/2],则只在数组R的右半部分继续搜索x。
- 折半查找判定树-->便于查看各个位置在二分查找中的查找次数
- 用当前查找区间的中间位置上的记录作为根,左子表和右子表中的记录分别作为根的左子树和右子树,由此得到的二叉树称为折半查找判定树。
- 树中结点内的数字表示该结点在有序表中的位置。
- 折半查找的过程恰好是走了一条从根到被查找结点的路径
- 关键字进行比较的次数即为被查找结点在树中的层数。因此,折半查找成功时进行的比较次数最多不超过树的深度。
- 折半查找的平均查找长度为:
归并排序(merge sort)

- 归并排序基本思想(递归):
- 当n=1时,终止排序;
- 否则将待排序的元素分成大致相同的两个子集合,分别对两个子集合进行归并排序;
- 将排好序的子集合合并成所要求的排序结果。
- void MergeSort(DataType R[], int left, int right) {
if (left < right) { // 至少有两个元素
int i = (left + right) / 2; // 取中点
MergeSort(R, left, i); // 递归排序左半部分
MergeSort(R, i, right); // 递归排序右半部分
Merge(R, temp, left, i, right); // 归并到临时数组temp
Copy(R, temp, left, right); // 复制回原数组R
}
} - 可以从分治策略入手,消除算法中的递归。
- 二路归并排序算法
- 假设初始表含有n个记录,则可看成是n个有序的子表,每个子表的长度为1。然后两两归并,得到⌊n/2⌋个长度为2或1的有序子表。再两两归并,……如此重复,直至得到一个长度为n的有序子表为止。
![fig:]()
- void Merge(DataType R[], DataType R1[], int low, int mid, int high) {
/* R[low]到R[mid]与R[mid+1]到R[high]是两个有序子数组 */
/* 结果为一个有序数组在R1[low]到R1[high]中 */
int i, j, k;
i = low;
j = mid + 1;
k = low;
while ((i <= mid) && (j <= high)) {
if (R[i] <= R[j]) { /* 取小者复制 */
R1[k++] = R[i++];
} else {
R1[k++] = R[j++];
}
}
while (i <= mid) { /* 复制第一个子数组的剩余记录 */
R1[k++] = R[i++];
}
while (j <= high) { /* 复制第二个子数组的剩余记录 */
R1[k++] = R[j++];
}
} /* Merge,归并算法,实现了如何对两个数组归并 */
void MergePass(DataType R[], DataType R1[], int length) {
/* 对R做一趟归并,结果放在R1中 */
/* length是本趟归并的有序子数组的长度 */
int i, j;
i = 0; /* i指向第一对子文件的起始点 */
while (i + 2 * length - 1 < n) { /* 归并长度为length的两个子文件 */
Merge(R, R1, i, i + length - 1, i + 2 * length - 1);
i = i + 2 * length; /* i指向下一对子文件的起始点 */
}
if (i + length - 1 < n - 1) { /* 剩下两个子文件,其中一个长度小于length */
Merge(R, R1, i, i + length - 1, n - 1);
} else { /* 子文件个数为奇数 */
for (j = i; j < n; j++) R1[j] = R[j]; /* 将最后一个文件复制到R1中 */
}
} /* MergePass,一趟归并算法,实现了对整体子数组的各自归并 */
void MergeSort(DataType R[]) { /* 对R进行二路归并排序 */
int length;
length = 1;
while (length < n) {
MergePass(R, R1, length); /* 一趟归并,结果在R1中 */
length = 2 * length;
MergePass(R1, R, length); /* 再次归并,结果在R中 */
length = 2 * length;
}
} /* MergeSort,二路归并算法,实现了从关键词到整体的多趟归并 */ - 二路归并排序算法的时间复杂度为。
![fig:]()
快速排序(quick sort)
- 快速排序算法是基于分治策略的另一个排序算法。其基本思想是:对输入的数组R[l:h]按以下三个步骤进行排序:
- 步骤一:分解,以R[l]为基准元素将R[l:h]划分为三段R[l:q-1]、R[q]和R[q+1:h],使R[l:q-1]中任何元素不大于R[q];R[q+1:h]中任何一个元素大于R[q],下标q在划分过程中确定。
- 步骤二:递归求解,通过递归调用快速排序算法分别对R[l:q-1]和R[q+1:h]进行排序。
- 步骤三:合并,由于对R[l:q-1]和R[q+1:h]的排序是就地进行的,因此实际上不需要进一步的合并计算。
![fig:]()
- 可设置两个指针i和j,它们的初值分别为i=l和j=h。设基准为无序区中的第一个记录R[i](即R[l]),这时q = i。划分过程如下:
- 令j自h起向左扫描,直到找到第一个小于R[q]的元素R[j],将R[j]移至q所指的位置上(这相当于交换了R[j]和基准R[q]的位置,使小于基准元素的元素移到了基准的左边);
- 然后,令q = j,且i自i+1起向右扫描,直至找到第一个大于R[q]的元素R[i],将R[i]移至j指的位置上(这相当于交换了R[i]和基准R[q]的位置,使大于基准元素的元素移到了基准的右边);
- 接着令q = i,且j自j-1起向左扫描,如此交替改变扫描方向,从两端各自往中间靠拢;
- 直至i=j时,q = i = j便是基准x (=R[l])的最终位置,将x放在此位置上就完成了一次划分。
- 算法实现
- int Partition(DataType R[], int l, int h) { /* 返回划分后被定位的基准记录的位置 */
/* 对无序区R[l]到R[h]做划分 */
int i, j, q;
DataType x;
i = l;
j = h;
q = i; /* 初始化,q为基准 */
x = R[i];
do {
while ((R[j] >= x) && (i < j)) {
j--; /* 从右向左扫描,查找第一个小于x的元素 */
}
if (i < j) {
R[i++] = R[j]; /* 交换R[i]和R[j] */
}
while ((R[i] <= x) && (i < j)) {
i++; /* 从左向右扫描,查找第一个大于x的元素 */
}
if (i < j) {
R[j--] = R[i]; /* 交换R[i]和R[j] */
}
} while (i != j);
R[i] = x; /* 基准已被最后定位在i处 */
return i;
} /* Partition */
void QuickSort(DataType R[], int s1, int t1) { /* 对R[s1]到R[t1]做快速排序 */
int i;
if (s1 < t1) { /* 只有一个元素或无元素时无须排序 */
i = Partition(R, s1, t1); /* 对R[s1]到R[t1]做划分 */
QuickSort(R, s1, i - 1); /* 递归处理左区间 */
QuickSort(R, i + 1, t1); /* 递归处理右区间 */
}
} /* QuickSort */
- 快速排序有非常好的时间复杂度,它优于各种排序算法。对n个记录进行快速排序的平均时间复杂度为。
动态规划/备忘录方法
- 适用于动态规划法求解的问题,经分解得到的子问题往往不是互相独立的。基于动态规划法的算法设计通常按以下几个步骤进行:
- 找出最优解的性质,并描述其结构特征;
- 递归定义最优值;
- 以自底向上的方式计算最优值;
- 根据计算最优值时得到的信息构造一个最优解。
- 通常在步骤(3)计算最优值时,需要记录更多的信息,以便在步骤(4)中快速构造出一个最优解。
- 动态规划算法的两个基本要素——最优子结构性质和子问题重叠性质。
- 最优子结构:当问题的最优解包含了其子问题的最优解时,称该问题具有最优子结构性质。
- 重叠子问题:对每个子问题只解一次,然后将解保存在一个表格中,当再次需要解此问题时,只是简单地用常数时间查看所保存的结果即可。
- 备忘录方法是动态规划算法的一个变形。备忘录方法的递归方式是自顶向下,而动态规划算法是自底向上。具体算法思想:
- 1)每一个子问题建立一个记录项,初始化时将其赋值为特定值,表示该子问题尚未求解。
- 2)在求解过程中,对每个待求解的子问题首先查看其相应的记录项,若记录项中存储的值是初始化的值,则表示该子问题第一次遇到,就需要计算该子问题的解,并存入相应的记录项;否则表示该子问题已经求解,即不必再计算该子问题。
- 与动态规划算法一样,备忘录算法的复杂性是。当一个问题的所有子问题都至少需要求解一次时,则动态规划算法好于备忘录方法;当子问题空间中的部分子问题可以不必求解时,备忘录算法优于动态规划算法。
- 矩阵连乘积的最优计算次序问题可用自顶向下的备忘录算法或自底向上的动态规划算法在计算时间内求解。这两个算法都利用了子问题重叠性质。
贪心算法
- 贪心算法的基本思想是:
- 总是作出在当前看来是最好的选择,即贪心算法并不从整体最优上考虑,而只考虑当前(局部)最优。
- 贪心选择性质是指所求问题的整体最优解可以通过一系列局部最优解的选择来实现(贪心选择)。
证明的具体步骤是:
1)考察问题的一个全局最优解,并证明可修改该最优解,使其以贪心选择开始;
2)做贪心选择后,原问题简化为一个规模更小的类似子问题;
3)用数学归纳法证明,通过每一步作贪心选择,最终可导致问题的一个全局最优解。
- 最优子结构是指当一个问题的最优解包含其子问题的最优解性质。
在动态规划算法中,每步所作出的选择往往依赖于相关子问题的解,因此只有在解出相关子问题的解后才能做出选择;
而贪心算法所作的贪心选择仅在当前状态下做出的最好选择,即局部最优选择,然后再求解作出该选择后所产生的相应子问题的解。贪心算法所作出的贪心选择可以依赖于“过去”所作出的选择,但绝不依赖于将来所作出的选择,也不依赖于子问题的解。
贪心算法和动态规划算法都具有最优子结构性质。
0-1背包问题:
- 给定n种物品和一个背包,物品i的重量为wi,其价值为vi,背包的容量为c。
- 如何选择装入背包中的物品,使得装入背包中的物品的总价值最大?
- 在选择装入背包中的物品时,对每种物品i只有两种选择:装入或不装入,即不能将物品i多次装入背包,也不能只装物品i的一部分。
- 此问题的形式化描述为:给定c>0,wi>0,vi>0,1≤i≤n,要求找出一个n元向量(x1, x2, …, xn),xi∈{0, 1},1≤i≤n,使得:
且达到最大。
背包问题:
- 给定n种物品和一个背包,物品i的重量为wi,其价值为vi,背包的容量为c。
- 如何选择装入背包中的物品,使得装入背包中的物品的总价值最大?
- 在选择装入背包中的物品时,对每种物品i有三种选择:装入、不装入或部分装入。
- 此问题的形式化描述为:给定c>0,wi>0,vi>0,1≤i≤n,要求找出一个n元向量(x1, x2, …, xn),0≤xi≤1,1≤i≤n,使得:
且达到最大。
- 上述两个问题都具有最优子结构性质。
- 对于0-1背包问题,设A是能够装入容量为c的背包且具有最大价值的物品集合,则A_j = A - {j}是n-1个物品可装入容量为c - w_j的背包的具有最大价值的物品集合。
- 对于背包问题,若它的一个最优解包含物品j,则从该最优解中拿出所含物品j的那部分重量w,剩余的重量将是n-1个原重物品和重为w_j - w的物品j中可装入容量为c - w的背包且具有最大价值的物品。
- 背包问题可以用贪心算法求解,而0-1背包问题却不能使用贪心算法。
- 贪心算法求解背包问题的具体步骤:
- 计算每种物品的单位重量的价值 vi / wi;
- 根据贪心选择策略,将尽可能多的单位重量价值高的物品装入背包;
- 若将这种物品全部装入背包后,背包内的物品总重量未超过c,则选择单位重量价值次高的物品并尽可能多地装入背包;
- 以此类推,直到背包装满为止。
- void KnapSack(int n, float M, float v[], float w[], float x[]) {
Sort(n, w, v); // 各物品依其单位重量的价值大小从大到小排列
int i;
for (i = 1; i <= n; i++) x[i] = 0;
float c = M;
for (i = 1; i <= n; i++) {
if (w[i] > c) break;
x[i] = 1;
c -= w[i];
}
if (i <= n) x[i] = c / w[i];
}
c
<stdlib.h>
内存管理函数
malloc
- 函数原型
- :
- void* malloc(size_t size);
- 参数
- :
- size :要分配的字节数
- 返回值
- :
- 成功:指向分配内存的指针
- 失败:NULL
- 用法
- :
- int* arr = (int*)malloc(sizeof(int) * n);
if (arr == NULL) {
// 处理内存分配失败
}
free
- 函数原型
- :
- void free(void* ptr);
- 参数
- :
- ptr:指向之前由 malloc、calloc 或 realloc 分配的内存
- 返回值
- :
- 无返回值
- 用法
- :
- free(arr);
arr = NULL; // 避免悬指针
calloc
- 函数原型
- :
- void* calloc(size_t num, size_t size);
- 参数
- :
- num:要分配的元素个数
- size:每个元素的大小(字节数)
- 返回值
- :
- 成功:指向分配内存的指针(已初始化为零)
- 失败:NULL
- 用法
- :
- int* arr = (int*)calloc(n, sizeof(int));
if (arr == NULL) {
// 处理内存分配失败
}
realloc
- 函数原型
- :
- void* realloc(void* ptr, size_t new_size);
- 参数
- :
- ptr:指向之前分配的内存块
- new_size:新的大小(字节数)
- 返回值
- :
- 成功:指向重新分配内存的指针
- 失败:NULL(原内存不受影响)
- 用法
- :
- arr = (int*)realloc(arr, sizeof(int) * new_size);
if (arr == NULL) {
// 处理重新分配失败
}
<stdio.h>
输入输出函数
printf
- 函数原型
- :
- int printf(const char* format, ...);
- 参数
- :
- format:格式化字符串
- ...:可变参数,表示要输出的值
- 返回值
- :
- 成功:输出的字符数
- 失败:负值
- 用法
- :
- int x = 10;
printf("Value: %d\n", x);
scanf
- 函数原型
- :
- int scanf(const char* format, ...);
- 参数
- :
- format:格式化字符串
- ...:可变参数,表示输入的变量地址
- 返回值
- :
- 成功:成功读取的输入项数量
- 失败:负值
- 用法
- :
- int x;
scanf("%d", &x);
gets(不推荐使用)
- 函数原型
- :
- char* gets(char* str);
- 参数
- :
- str:目标缓冲区指针
- 返回值
- :
- 成功:指向目标缓冲区的指针
- 失败:NULL
- 注意
- :
- 不安全,可能导致缓冲区溢出
- 用法
- :
- char buffer[100];
gets(buffer);
fgets(推荐使用)
- 函数原型
- :
- char* fgets(char* str, int n, FILE* stream);
- 参数
- :
- str:目标缓冲区指针
- n:最多读取的字符数(包括终止符)
- stream:输入流(通常为 stdin)
- 返回值
- :
- 成功:指向目标缓冲区的指针
- 失败:NULL
- 用法
- :
- char buffer[100];
fgets(buffer, 100, stdin);
putchar
- 函数原型
- :
- int putchar(int char);
- 参数
- :
- char:要输出的字符
- 返回值
- :
- 成功:输出的字符
- 失败:EOF
- 用法
- :
- putchar('A');
getchar
- 函数原型
- :
- int getchar(void);
- 参数
- :
- 无
- 返回值
- :
- 成功:读取的字符
- 失败:EOF
- 用法
- :
- char c = getchar();
fprintf
- 函数原型
- :
- int fprintf(FILE* stream, const char* format, ...);
- 参数
- :
- stream:目标文件流
- format:格式化字符串
- ...:可变参数
- 返回值
- :
- 成功:输出的字符数
- 失败:负值
- 用法
- :
- FILE* file = fopen("output.txt", "w");
fprintf(file, "Value: %d\n", 42);
fclose(file);
fscanf
- 函数原型
- :
- int fscanf(FILE* stream, const char* format, ...);
- 参数
- :
- stream:输入文件流
- format:格式化字符串
- ...:可变参数
- 返回值
- :
- 成功:成功读取的输入项数量
- 失败:负值
- 用法
- :
- FILE* file = fopen("input.txt", "r");
int x;
fscanf(file, "%d", &x);
fclose(file);
<string.h> - 字符串操作函数
strchr
- 功能:查找字符在字符串中的第一次出现。
- 函数原型:
- char* strchr(const char* str, int c);
- 用法:
- const char* text = "Hello";
char* pos = strchr(text, 'e'); // 返回指向 'e' 的指针
strstr
- 功能:查找子字符串。
- 函数原型:
- char* strstr(const char* haystack, const char* needle);
- 用法:
- const char* text = "Hello, world!";
char* pos = strstr(text, "world"); // 返回指向 "world" 的指针
strncpy
- 功能:拷贝指定长度的字符串。
- 函数原型:
- char* strncpy(char* dest, const char* src, size_t n);
- 用法:
- char dest[10];
strncpy(dest, "Hello", 3); // dest 现在包含 "Hel"
strncmp
- 功能:比较指定长度的字符串。
- 函数原型:
- int strncmp(const char* str1, const char* str2, size_t n);
- 用法:
- if (strncmp("apple", "apricot", 3) == 0) {
// 前 3 个字符相等
}
strncat
- 功能:拼接指定长度的字符串。
- 函数原型:
- char* strncat(char* dest, const char* src, size_t n);
- 用法:
- char dest[20] = "Hello, ";
strncat(dest, "world!", 5); // dest 现在包含 "Hello, world"
sprintf
- 功能:格式化字符串。
- 函数原型:
- int sprintf(char* str, const char* format, ...);
- 用法:
- char buffer[50];
sprintf(buffer, "%d + %d = %d", 2, 3, 5); // buffer 包含 "2 + 3 = 5"
C++
<iostream> 中的输入输出函数
cin
- 功能:从标准输入读取数据。
- 用法
- :
- int x;
cin >> x; // 从用户输入中读取一个整数 - 支持链式输入
- :
- int a, b;
cin >> a >> b; // 依次读取两个整数
cout
- 功能:向标准输出打印数据。
- 用法
- :
- cout << "Hello, world!" << endl; // 输出字符串并换行
- 支持链式输出
- :
- int x = 10;
cout << "Value: " << x << endl; // 输出 "Value: 10"
getline
- 功能:从标准输入读取一整行。
- 用法
- :
- string line;
getline(cin, line); // 读取包括空格的整行输入
<cstring> - C 风格字符串函数
strlen
- 功能:计算 C 风格字符串的长度。
- 函数原型:
- size_t strlen(const char* str);
- 用法:
- const char* text = "Hello";
size_t length = strlen(text); // 返回 5
strcpy
- 功能:拷贝 C 风格字符串。
- 函数原型:
- char* strcpy(char* dest, const char* src);
- 用法:
- char dest[20];
const char* src = "Hello";
strcpy(dest, src); // dest 现在包含 "Hello"
strcmp
- 功能:比较两个 C 风格字符串。
- 函数原型:
- int strcmp(const char* str1, const char* str2);
- 用法:
- if (strcmp("apple", "banana") < 0) {
// "apple" 在字典序中在 "banana" 之前
}
strcat
- 功能:连接两个 C 风格字符串。
- 函数原型:
- char* strcat(char* dest, const char* src);
- 用法:
- char dest[20] = "Hello, ";
strcat(dest, "world!"); // dest 现在包含 "Hello, world!"
<string> - C++ 标准字符串函数
std::string::size
- 功能:返回字符串的长度。
- 函数原型:
- size_t size() const;
- 用法:
- std::string str = "Hello";
size_t len = str.size(); // 返回 5
std::string::substr
- 功能:获取字符串的子串。
- 函数原型:
- std::string substr(size_t pos = 0, size_t len = npos) const;
- 用法:
- std::string str = "Hello, world!";
std::string sub = str.substr(0, 5); // 返回 "Hello"
std::string::find
- 功能:查找子字符串。
- 函数原型:
- size_t find(const std::string& str, size_t pos = 0) const;
- 用法:
- std::string str = "Hello, world!";
size_t pos = str.find("world"); // 返回 7
std::string::replace
- 功能:替换子字符串。
- 函数原型:
- std::string& replace(size_t pos, size_t len, const std::string& str);
- 用法:
- std::string str = "Hello, world!";
str.replace(7, 5, "C++"); // str 现在包含 "Hello, C++!"
<stack> - 栈操作
std::stack::push
- 功能:将元素压入栈顶。
- 函数原型:
- void push(const T& value);
- 用法:
- std::stack<int> s;
s.push(10); // 将 10 压入栈
std::stack::pop
- 功能:移除栈顶元素。
- 函数原型:
- void pop();
- 用法:
- s.pop(); // 移除栈顶元素
std::stack::top
- 功能:访问栈顶元素。
- 函数原型:
- T& top();
- 用法:
- int val = s.top(); // 获取栈顶元素
std::stack::empty
- 功能:检查栈是否为空。
- 函数原型:
- bool empty() const;
- 用法:
- if (s.empty()) {
// 栈为空
}
std::stack::size
- 功能:获取栈的元素个数。
- 函数原型:
- size_t size() const;
- 用法:
- size_t count = s.size();
<queue> - 队列操作
std::queue::push
- 功能:将元素添加到队列尾部。
- 函数原型:
- void push(const T& value);
- 用法:
- std::queue<int> q;
q.push(10); // 将 10 添加到队列尾部
std::queue::pop
- 功能:移除队列头部元素。
- 函数原型:
- void pop();
- 用法:
- q.pop(); // 移除队列头部元素
std::queue::front
- 功能:获取队列头部元素。
- 函数原型:
- T& front();
- 用法:
- int val = q.front(); // 获取队列头部元素
std::queue::back
- 功能:获取队列尾部元素。
- 函数原型:
- T& back();
- 用法:
- int val = q.back(); // 获取队列尾部元素
std::queue::empty
- 功能:检查队列是否为空。
- 函数原型:
- bool empty() const;
- 用法:
if (q.empty()) {
// 队列为空
}
std::queue::size
- 功能:获取队列中的元素个数。
- 函数原型:
- size_t size() const;
- 用法:
- size_t count = q.size();
<algorithm> - 常用算法
std::sort
- 功能:对容器内的元素进行排序。
- 函数原型:
- template <class RandomIt>
void sort(RandomIt first, RandomIt last); - 用法:
- std::vector<int> v = {3, 1, 4, 1, 5};
std::sort(v.begin(), v.end()); // 对 v 中的元素进行排序
std::binary_search
- 功能:二分查找一个元素是否存在于已排序的容器中。
- 函数原型:
- template <class ForwardIt, class T>
bool binary_search(ForwardIt first, ForwardIt last, const T& value); - 用法:
- std::vector<int> v = {1, 3, 5, 7};
bool found = std::binary_search(v.begin(), v.end(), 5); // 查找是否存在 5
lower_bound
- 功能:找到第一个不小于给定值的元素。
- 函数原型:
template <class ForwardIt, class T>
ForwardIt lower_bound(ForwardIt first, ForwardIt last, const T& value);
- 用法:
std::vector<int> v = {1, 3, 3, 5, 7};
auto it = std::lower_bound(v.begin(), v.end(), 3); // 找到第一个不小于3的位置
upper_bound
- 功能:找到第一个大于给定值的元素。
- 函数原型:
template <class ForwardIt, class T>
ForwardIt upper_bound(ForwardIt first, ForwardIt last, const T& value);
- 用法:
std::vector<int> v = {1, 3, 3, 5, 7};
auto it = std::upper_bound(v.begin(), v.end(), 3); // 找到第一个大于3的位置
max_element
- 功能:找到范围内的最大元素。
- 函数原型:
template <class ForwardIt>
ForwardIt max_element(ForwardIt first, ForwardIt last);
- 用法:
std::vector<int> v = {3, 1, 4, 1, 5};
auto max_it = std::max_element(v.begin(), v.end()); // 找到最大元素5的位置
min_element
- 功能:找到范围内的最小元素。
- 函数原型:
template <class ForwardIt>
ForwardIt min_element(ForwardIt first, ForwardIt last);
- 用法:
std::vector<int> v = {3, 1, 4, 1, 5};
auto min_it = std::min_element(v.begin(), v.end()); // 找到最小元素1的位置
reverse
- 功能:反转范围内的元素。
- 函数原型:
template <class BidirectionalIt>
void reverse(BidirectionalIt first, BidirectionalIt last);
- 用法:
std::vector<int> v = {1, 2, 3, 4, 5};
std::reverse(v.begin(), v.end()); // 反转后变成 {5, 4, 3, 2, 1}
quicksort
- 功能:使用快速排序算法对容器内的元素进行排序。
- 函数原型:
template <class RandomIt>
void quicksort(RandomIt first, RandomIt last);
template <class RandomIt>
RandomIt partition(RandomIt first, RandomIt last);
- 用法:
std::vector<int> v = {3, 1, 4, 1, 5};
quicksort(v.begin(), v.end()); // 使用快速排序算法排序
block_search
- 功能:将数据分成若干块进行查找,每块内部可以无序,但块间必须有序。
- 函数原型:
template <typename RandomIt, typename T>
RandomIt block_search(RandomIt first, RandomIt last,
const std::vector<T>& block_max,
const std::vector<RandomIt>& block_ends,
const T& target);
- 用法:
std::vector<int> v = {2, 1, 3, 5, 4, 6, 8, 7, 9}; // 三个块:{2,1,3}, {5,4,6}, {8,7,9}
std::vector<int> block_max = {3, 6, 9}; // 每块最大值
std::vector<std::vector<int>::iterator> block_ends = {
v.begin() + 3, v.begin() + 6, v.end()
};
auto it = block_search(v.begin(), v.end(), block_max, block_ends, 5);
标准输入流[1]
C 标准输入
C语言使用标准输入输出函数,需要包含头文件<stdio.h>。而在 C++ 中,只要包含头文件<iostream>,就完全可以使用这些 C 中的输入输出函数。
标准输入流及对缓冲区的理解
stdin是一个文件描述符(Linux)或句柄(Windows),它在 C 程序启动时就被默认分配好。在 Linux 中一切皆文件,stdin也相当于一个可读文件,它对应着键盘设备的输入。因为它不断地被输入,又不断地被读取,像流水一样,因此通常称作输入流。
stdin是一种行缓冲I/O。当在键盘上键入字符时,它们首先被存放在键盘设备自身的缓存中(属于键盘硬件设备的一部分)。只有输入换行符时,操作系统才会进行同步,将键盘缓存中的数据读入到stdin的输入缓冲区(存在于内存中)。所有从stdin读取数据的输入流,都是从内存中的输入缓冲区读入数据。当输入缓冲区为空时,函数将被阻塞。
若无特殊说明,以下所有的“缓冲区”均是指内存中的stdin输入缓冲区。用户程序中自定义的buffer数组、str数组等,将称作“数组”、“变量”,以免产生混淆。
scanf()
按照特定格式从stdin读取输入。
用法示例:
char str[100];
int a;
scanf("%s %d", str, &a); // 注意,传入的一定是变量的地址
对空白字符的处理:
- 缓冲区开头:丢弃空白字符(包括空格、Tab、换行符),直到第一个非空白字符才认为是第一个数据的开始。
- 缓冲区中间:开始读取第一个数据后,一旦遇到空白字符(非换行符), 就认为读取完毕一次。遇到的空白字符残留在缓冲区,直到下一次被读取或刷新。例如输入字符串this is test,则会被认为是3个字符串。
- 缓冲区末尾:按下回车键时,换行符\n残留在缓冲区。换行符之前的空格可以认为是中间的空白字符,处理同上。
注意,格式控制符只会读取正确类型的变量。如果输入格式不正确,比如在%d处输入了一个字符a,则会使读取中断,即后续不读取任何变量。
格式控制符说明:
|
类型 |
类型输入 |
参数的类型 |
|---|---|---|
|
%d |
十进制整数 |
int * |
|
%u |
无符号十进制整数 |
unsigned int * |
|
%o |
八进制整数 |
int * |
|
%x |
十六进制整数 |
int * |
|
%f、%e、%g |
浮点数 |
float * |
|
%lf、%le、%lg |
双精度浮点数 |
double * |
|
%c |
单个字符(含空白字符) |
char * |
|
%s |
字符串 |
char * |
|
%% |
读 % 符号 |
注意,%c是一个比较特殊的格式符号,它将会读取所有空白字符,包括缓冲区开头的空格、Tab、换行符,使用时要特别注意。
scanf()的读取也没有边界,所以并不安全。C11 标准提供了安全输入scanf_s()。
scanf()对应的输出函数是printf()。
gets() - 不建议
按下回车键时,从stdin读取一行。
用法示例:
char str[100];
gets(str);
对空白字符的处理:
- 所有空格、Tab等空白字符均被读取,不忽略。
- 按下回车键时,缓冲区末尾的换行符被丢弃,字符串末尾没有换行符\n,缓冲区也没有残留的换行符\n。
注意,gets()不能指定读取上限,因此容易发生数组边界溢出,造成内存不安全。C11 使用了gets_s()代替gets(),但有时编译器未必支持,因此总体来说不建议使用gets()函数来读取输入。
gets()对应的输出函数是puts()。
fgets()
从指定输入流读取一行,输入可以是stdin,也可以是文件流,使用时需要显式指定。
读取文件流示例:
char str[100];
memset(str, 0, sizeof(str));
int i = 1;
FILE *fp = fopen("...test.txt", "r");
if (fp == NULL) {
printf("File open Error!\n");
exit(1);
}
while (fgets(str, sizeof(str), fp) != NULL)
printf("line%d [len %d]: %s", i++, strlen(str), str);
fclose(fp);
读取stdin示例:
char str[100];
memset(str, 0, sizeof(str));
int i = 1;
while (fgets(str, sizeof(str), stdin) != NULL)
printf("line%d [len %d]: %s", i++, strlen(str), str);
对空白字符的处理:
- 所有空格、Tab等空白字符均被读取,不忽略。
- 按下回车键时,缓冲区末尾的换行符也被读取,字符串末尾将有一个换行符\n。例如,输入字符串hello,再按下回车,则读到的字符串长度为6。
fgets()函数会自动在字符串末尾加上\0结束符。
第 2 个参数n指定了读取的最大长度。函数读到n-1个字符(包括换行符\n)就会停止,并在末尾加上\0结束符。剩余字符将残留在缓冲区。
建议使用fgets()完全替代gets()。
fgets()对应的输出函数是fputs()。
fgetc() & getc()
从指定输入流读取一个字符,输入可以是stdin,也可以是文件流,使用时需要显式指定。
这两个函数完全等效,getc()由fgetc()宏定义而来。不同的是,前述的gets()和fgets()相互之间没有关系。
用法示例:
char a, b;
a = fgetc(stdin);
b = getc(stdin);
对空白字符的处理:
- 所有空格、Tab、换行等空白字符,无论在缓冲区开头、中间还是结尾,均会被读取,不忽略。
- 因为只读取一个字符,所以如果输入多于1个字符(包括换行符),则它们均会残留在缓冲区。具体地说,如果什么字符都不输入,直接按下回车键,则读取到的是换行符\n,缓冲区无任何残留;如果输入一个字符如a,然后按下回车键,则读取到的是字符a,同时换行符\n残留在缓冲区。
fgetc()和getc()对应的输出函数是fputc()和putc()。
getchar()
从stdin读取一个字符。
getchar()实际上也由fgetc()宏定义而来,只是默认输入流为stdin。
用法示例:
char a;
a = getchar();
getchar()常常用于清理缓冲区开头残留的换行符。当知道缓冲区开头有\n残留时,可以调用getchar()但不赋值给任何变量,即可实现冲刷掉\n的效果。
getchar()对应的输出函数是putchar()。
C++ 标准输入
C++中使用标准输入输出需要包含头文件<iostream>。一般使用iostream类进行流操作,其封装很完善,也比较复杂,本文只介绍一部分。
cin
cin是 C++ 的标准输入流对象,即istream类的一个对象实例。cin有自己的缓冲区,但默认情况下是与stdin同步的,因此在 C++ 中可以混用 C++ 和 C 风格的输入输出(在不手动取消同步的情况下)。
cin与stdin一样是行缓冲,即遇到换行符时才会将数据同步到输入缓冲区。
cin的用法非常多,只列举常用的几种。最常用的就是使用>>符号(我认为该符号形象地体现了“流”的特点)。
用法示例:
int a, b;
cin >> a >> b;
char str[20];
cin >> str;
cin对空白字符的处理与scanf一致。即:跳过开头空白字符,遇到空白字符停止读取,且空白字符(包括换行符)残留在缓冲区。
如果不想跳过空白字符,可以使用流控制关键词noskipws(no skip white space),但这只对单个字符有效(类似于scanf中的%c)。
char c;
cin >> noskipws >> c;
注意,cin对象属于命名空间std,如果想使用cin对象,必须在 C++ 文件开头写using namespace std,或者在每次用到的时候写成std::cin。
cin.get()
读取单个或指定长度的字符,包括空白字符。
用法示例:
char a, b;
char str[20];
// 读取一个字符,读取失败时返回0,多余字符残留在缓冲区(包括换行符)
a = cin.get();
// 读取一个字符,读取失败时返回EOF,多余字符残留在缓冲区(包括换行符)
cin.get(b);
// 在遇到指定终止字符(参数3)前,至多读取n-1个(参数2)字符
// 当不指定终止字符时,默认为换行符\n
// 如果输入的字符个数小于等于n-1(不含终止字符),则终止字符不残留在缓冲区
// 如果输入的字符个数多于n-1(不含终止字符),则余下字符将残留在缓冲区
cin.get(str, sizeof(str), '\n');
cin.get()读取单个字符时,类似于 C 中的fgetc(),对空白字符的处理也与其一致。cin.get()读取的字符也可以赋值给整型变量。
cin.get()读取指定长度个字符时,类似于 C 中的fgets(),但在换行符的处理上不同。它们都不会使换行符残留在缓冲区,但fgets()会将缓冲区末尾的换行符\n也写入字符串,而cin.get()会丢弃缓冲区末尾的\n。即:当输入test时,用fgets()读取得到的字符串长度为5,用cin.get()读取得到的字符串长度为4。
cin.getline()
读取指定长度的字符,包括空白字符。
用法示例:
char str[20];
cin.getline(str, sizeof(str)); // 第3个参数也可以指定终止字符
cin.getline()与cin.get()指定读取长度时的用法几乎一样。区别在于,如果输入的字符个数大于指定的最大长度n-1(不含终止符),cin.get()会使余下字符残留在缓冲区,等待下次读取;而cin.getline()会给输入流设为 Fail 状态,在主动恢复之前,无法再进行正常输入。
getline()
getline()并不是标准输入流istream的函数,而是字符串流sstream的函数,只能用于读取数据给string类对象,使用时也需要包含头文件<string>。
如果使用getline()读取标准输入流的数据,需要显式指定输入流。
用法示例:
string str;
getline(cin, str);
getline()会读取所有空白字符,且缓冲区末尾的换行符会被丢弃,不残留也不写到字符串结尾。同时,由于string对象的空间是动态分配的,所以会一次性将缓冲区读完,不存在读不完残留在缓冲区的问题。
需要注意的是,假如缓冲区开头就是换行符(比如可能是上一次cin残留的),则getline()会直接读取到空字符串并结束,不会给键盘输入的机会。所以这种情况下要注意先清除开头的换行符。
总结
在 C 中,建议使用scanf()进行格式化读取,用fgets()读取整行,用fgetc()或getchar()读取单个字符。
在 C++ 中,建议使用cin >>进行格式化读取,而cin.get()、cin.getline、getline(string)有各自的适用情况。
注意fgets()和cin.get()在对换行符的清理方面有所区别。
标准输出流
C 标准输出
标准输出流及对缓冲区的理解
相应于输入流的stdin,输出流也有其默认的文件描述符stdout,对应着命令行终端(Windows 中称为控制台)的显示。此外,还有对应错误输出的stderr,默认也是终端的显示。它们都可以被重定向到文件中以便持久保存和查看,在此不作赘述。
stdout也是行缓冲I/O,它与stdin类似也有三者之间的数据同步:从用户程序到stdout的输出缓冲区,由用户程序决定;从stdout的输出缓冲区到终端的显示,只有缓冲区末尾遇到换行符\n才会进行。如果输出缓冲区末尾没有换行符\n,是不会打印显示输出的。
例如以下程序:
// 程序 1
int main(int argc, char* argv[])
{
printf("Hello World!\n");
while(1){}
return 0;
}
// 程序 2
int main(int argc, char* argv[])
{
printf("Hello World!");
while(1){}
return 0;
}
// 程序 3
int main(int argc, char* argv[])
{
printf("Hello World!")
return 0;
}
// 程序 1
int main(int argc, char* argv[])
{
printf("Hello World!\nABCDE");
while(1){}
return 0;
}
程序 1 中,printf()输出内容的最后有换行符\n,所以将在屏幕上输出Hello World!并换行,然后进入while(1)循环阻塞住。
程序 2 中,把\n去掉了,此时终端不会显示任何内容。因为程序进入死循环后,没有机会向stdout中写入\n使其清空缓冲。
程序 3 中,虽然没有写入换行符,但是依然能够在终端打印Hello World!(只是没有换行)。这是因为程序结束时会自动清空缓冲区。(除此之外,当缓冲区被填满时也会自动清空)
程序 4 能够进一步加深对行缓冲的理解。它在程序 1 的基础上,在换行符之后又加上了几个字符。运行可以发现终端只打印了Hello World!并换行,而没有打印ABCDE。
输出函数通常没有针对对空格、制表符的特殊行为,比输入要简单一些。特殊的处理一般只有换行符。
printf()
按照特定格式将stdout缓冲区的内容打印到终端。
用法示例:
printf("Number a = %d", a); // 十进制整数
printf("Number b = %.2f", b); // 浮点数,保留两位小数
printf("String s = %s", s); // 字符串
printf()的写法与scanf()十分相像。区别在于scanf()中一般只有格式控制字符,而没有其他普通字符,而printf()中常常是在一串字符中把要替换的内容写为格式控制字符,从而形成格式化输出的效果。
puts()
将字符串和一个尾随的换行符\n写入到stdout的缓冲区。根据行缓冲的性质,终端也会立即进行打印显示。
用法示例:
puts("hello"); // 立即输出hello并换行
puts()对换行符的处理与gets()“相反”。gets()会自动丢弃一个换行符,而puts()则是自动写入一个换行符。
fputs()
将字符串写入指定输出流,可以是文件流、stdout或stderr等。stderr是标准错误流,它是无缓冲的,会立即输出到屏幕,而不是等待换行符才输出。
用法示例:
fputs("hello world", stdout); // 不会立即输出
fputs("hello world\n", stdout); // 立即输出
fputs("hello world", stderr); // 立即输出
与fgets()一样,fputs()不会主动操作换行符。如果希望立即输出,需要自己加上换行符\n。
fputc() & putc()
将一个字符写入指定输出流,可以是文件流、stdout或stderr等。
用法示例:
char c = 'q';
fputc(c, stdout);
c = '\n';
putc(c, stdout);
fputc()和putc()只是把字符写入stdout,没有任何额外操作。因此如果希望立即输出,需要自己加上换行符\n。
putchar()
将一个字符写入到标准输出流stdout。
用法示例:
char c = 'x';
putchar(c);
同上,putchar()不操作换行符。如果希望立即输出,需要自己加上换行符\n。
fflush()
该函数的功能是强制刷新缓冲区,将数据立即写到对应的文件(或设备)。其参数可以是文件流指针,也可以是stdout。
用法示例:
fputs("Hello World!", stdout);
fflush(stdout);
while (1);
上面的程序在进入死循环前,会输出Hello World!字符串到屏幕。
注意:不能够将fflush()用于stdin!这可能导致不可预料的后果。
C++ 标准输出
cout
cout是ostream类的一个实例。cout是行缓冲的。
用法示例:
char str[] = "hello world";
cout << "str: " << str << endl;
插入endl对象时,将立即清空输出缓冲区并显示,然后输出一个换行符\n。
也有cout.put()等函数,不常用。
cerr
cerr是标准错误流,也是ostream类的一个实例,并默认输出设备为显示屏上的命令行终端。它默认与stderr同步。
cerr是非缓冲的,即插入数据时会立即输出。
用法示例:
char str[] = "File open FAILED!";
cerr << "[Error] " << str;
clog
clog是标准日志流,也是ostream类的一个实例,并默认输出设备为显示屏上的命令行终端。
clog是有缓冲的,但具体的刷新条件没有找到资料。实测以下代码是可以输出在屏幕的:
clog << "Failed!";
while(1){}
后记
推荐资料:
- b站up主"蓝不过海呀"的有关算法与数据结构动画系列
-
本部分内容引用自 C/C++标准输入输出函数终极最全解析(不全捶我) ↑




































浙公网安备 33010602011771号