考研_数据结构
绪论
1.算法原地工作是指辅助空间不随着数据规模的增大而增大,不是说不需要辅助空间
2.栈和队列属于逻辑结构而非存储结构,它们的实现才属于存储结构
3.数据元素是数据的基本单位,数据项是数据的最小单位
4.程序需要算法和数据结构结合在一起才能实现,仅仅把算法用某种计算机语言来描述不能称之为程序
- 逻辑结构:数据的组织形式,即数据元素之间逻辑关系的总体,逻辑关系指数据元素之间的关联方式
- 存储结构(物理结构):数据元素的表示 和 数据元素间关系 的表示
线性表
1.链表头节点的作用:如果没有头节点,那么在头部插入一个节点和在中间插入一个节点的操作是不一样的,如果加上头节点就可以去除这样的判断,代码更加简化了。
2.线性表最开始的节点没有前驱,最后一个节点没有后继
3.循环链表的优点:从任一节点出发都可以访问到链表中的每一个元素
4.循环链表不同于循环队列,循环队列如果头指针和尾指针在最后重叠到了一起,无法判断队列已满,所以决定牺牲一个位置,让尾指针实际上为尾后指针
栈和队列
1.涉及到表达式的题目,关键就在于考虑运算符的优先级,当前符号会把符号栈中优先级更高的元素全部执行完,然后自己进栈,这是不难理解的
2.队列删除元素是在队首,添加元素是在队尾
3.输出受限的双向队列意思是只能一端输出,两端都可输入,输入受限是一个意思
4.尾递归和单向递归可以使用循环来消除递归。尾递归就是递归语句在最后,当前环境的情况不需要保留。即使保留了也没什么用,所以不需要单独开辟堆栈来进行下一次递归,下一次的递归直接在本次递归的空间进行即可。注意这里说的是能够用循环消除递归的条件。不是说消除递归的条件,事实上任何递归都可以转化为非递归
5.n个数的合法出栈序列种类数是卡特兰数
6.一般在队列中,头指针指向的都是有效元素,尾指针是最后一个有效元素的后继指针
7.在共享空间的栈中,指针指向的好像都是有效数据,但在一个栈中尾指针应该是尾后指针
8.假溢出:头指针没有指向最开始的位置,尾指针指向最后位置,呈现出队满的假象,所以引入循环队列
9.循环队列应该是用顺序存储机构,所以头尾指针的变化都是通过数的运算得来的,没有什么next指针
循环队列中的一些关键点
- 空队列条件:\(rear = front\)
- 队满:$ (rear + 1) % MAXSIZE = front $
- 插入元素从rear端,弹出元素从front端
- rear是尾后指针
数组和广义表
-
我觉得首地址的意思是前一个元素的地址,地址是当前元素的地址,
在已知某元素的地址BA时,求从开始起第i个元素的地址的公式为BA + (i - 1)* 数据元素大小
-
题型1:已知首地址为BA,求另一元素元素的首地址:BA + (当前元素前元素的个数) * 数据元素大小
-
题型2:
-
三对角矩阵:只有主对角线,低对角线,高对角线非0,其余元素均为0
-
因为三元组是为了压缩稀疏矩阵,所以原矩阵的所有信息都需要保留,所以它除了存储数据还需要存储原矩阵的行数,列数,总元素个数,所以计算存储空间时要注意
-
广义表在head()和 tail()时,如果是head就是直接取出第一个元素(原子或者子表)即可,如果是tail(),则实际上是用()把除了第一个元素外的其它元素括起来。因为广义表的定义就是最后的元素一定是子表,不管是原子还是子表,都要统一成一个子表(官方讲就是广义表除非是空表,否则一定存在表头和表尾,而且表尾一定是一个广义表
-
广义表的长度:数据元素的个数(原子和子表都算一个元素)
广义表的深度:从左向右左括号的数量 或 从右向左右括号的数量(就是嵌套的层数)
-
解析中的第三点不太懂
一个稀疏矩阵Am*n 采用三元组形式表示,若把三元组中有关行下标与列下标的值互换,并把m和n的值互换,则就完成了Am*n 的转置运算(B)。
A.对
B.错
解析:三元组转置: (1)将数组的行列值相互交换 (2)将每个三元组的i和j相互交换 (3)重排三元组的之间的次序便可实现矩阵的转置。
-
三维数组如何计算个数:如果是行优先,那么策略是首先是第一行,然后是第一列,此时如果坐标都是从0开始,指向的就是二维平面中【0,0】这个点,此时会把z轴上的所有元素都进行填充,之后到【0,1】,再把这个点对应的z轴上的元素进行填充,以此类推,进行完第一行进行第二行。。。如果是列优先,那么也是先确定列和行从而确定一点然后填充z轴
树和二叉树
xx序表达式的两种可能
- 树的xx序遍历:前序(preorder):根左右;中序(inorder):左根右;后序(postorder):左右根
- 表达式的xx序表达式:我发现数学表达式之间的转化既可以使用下面的方法也可以使用树中的方法,答案是一样的,不知道是巧合还是确实可以,有待商榷
投影法确定二叉树的三种遍历
-
中序遍历:中序遍历就像在无风的情况下,太阳直射,将所有的结点投影到地上。顺序为左子树、根、右子树。如图 所示。图中的二叉树,其先序序列投影如图所示。中序遍历序列为:DBEAFGC
-
先序遍历:先序遍历就像在左边大风的情况下,将二叉树树枝刮向右方,且顺序为根、左子树、右子树,太阳直射,将所有的结点投影到地上。图中的二叉树,其先序序列投影如图所示。先序遍历序列为:ABDECFG
-
后序遍历:后序遍历就像在右边大风的情况下,将二叉树树枝刮向左方,且顺序为左子树、右子树、根,太阳直射,将所有的结点投影到地上。图中的二叉树,其后序序列投影如图所示。后序遍历序列为:DEBGFCA
表达式的 中序表达式 -> 前序表达式 || 后序表达式
-
中序转后序:
策略:从前向后遍历中序表达式
1.字母:直接输出到答案中
2.(:进栈
3.符号;弹栈,直到找到优先级比它低的符号或者到(不再弹栈(弹出的是>=此符号优先级的)
4.);弹栈,直到(,将(弹栈
#include <iostream>
#include <vector>
#include <stack>
#include <map>
using namespace std;
int main()
{
string sequenceBefore, sequenceAfter;
cin >> sequenceBefore;
stack<char> sta;
map<char, int> priority = {
{'+', 0}, {'-', 0},
{'*', 1}, {'/', 1}
};
for (int i = 0; i < sequenceBefore.size(); ++i)
{
char ch = sequenceBefore[i];
if (ch == '(') sta.push(ch);
else if (ch == ')')
{
while(!sta.empty() && sta.top() != '(')
{
sequenceAfter += sta.top();
sta.pop();
}
sta.pop();
}
else if (ch == '+' || ch == '-' || ch == '*' || ch == '/')
{
while (!sta.empty() && sta.top() != '(' && priority[sta.top()] >= priority[ch])
{
sequenceAfter += sta.top();
sta.pop();
}
sta.push(ch);
}
else sequenceAfter += ch;
}
while (!sta.empty())
{
sequenceAfter += sta.top();
sta.pop();
}
cout << sequenceAfter << endl;
return 0;
}
原理:优先级高的元素应该先输出,栈顶始终维护当前优先级最高的符号,能够保证最先输出
-
中序转前序
策略:从后向前遍历中序表达式
1.字母:直接输出到答案中
2.):进栈
3.符号;弹栈,直到找到优先级比它低或相等的符号或者到)不再弹栈(弹出的是优先级>此符号的)
4.( : 弹栈,直到),将)弹栈
最后一步翻转现有答案序列
-
注:一定要注意转后序和前序时,符号弹栈优先级的区别。
表达式的 前序表达式 || 后序表达式 的计算
-
后序表达式
和前序表达式的计算方法类似,但是是从左到右扫描表达式,将离运算符最近的两个数据进行运算,然后填充到原位置,直到扫描完成。
-
前序表达式
对于一个前序表达式的求值而言,首先要从右至左扫描表达式,从右边第一个字符开始判断,如果当前字符是数字则一直到数字串的末尾再记录下来,如果是运算符,则将右边离得最近的两个“数字串”作相应的运算,以此作为一个新的“数字串”并记录下来。一直扫描到表达式的最左端时,最后运算的值也就是表达式的值。例如,前序表达式“- 1 + 2 3“的求值,扫描到3时,记录下这个数字串,扫描到2时,记录下这个数字串,当扫描到+时,将+右移做相邻两数字串的运算符,记为2+3,结果为5,记录下这个新数字串,并继续向左扫描,扫描到1时,记录下这个数字串,扫描到-时,将-右移做相邻两数字串的运算符,记为1-5,结果为-4,所以表达式的值为-4。
树中概念总结
- 节点的度:一个节点含有的子树的个数成为该节点的度
- 树的度:一棵树中,最大的节点度成为树的度
- 兄弟节点:具有相同父节点的节点互称为兄弟节点
- 节点层次:根为1层,依次递增
- 堂兄弟节点:父节点在同一层的节点互为堂兄弟
- 节点祖先:从根到该节点所经分支上的所有节点,从根节点到此节点的路径中除了此节点本身,其余节点均为此节点的祖先节点,(儿子->父亲->父亲的父亲->...->根)
- 子孙节点:以该节点为根的子树中的任一节点均为该节点的子孙节点
- 深度:对于任意节点n,n的深度为从根到n的唯一路径长度,根的深度定为0
- 高度:对于任意节点n,n的高度为从n到叶节点的最长路径长度,所有叶节点的高度为0
- 森林:由m(m>=0)棵互不相交的树的集合成为森林
树的表示方法
- 双亲表示法
-
孩子表示法
3.孩子兄弟表示法
二叉树,树,森林转化
-
树转化为二叉树:留长子,养兄弟
- 在所有兄弟节点之间画一条线
- 遍历所有节点,只保留其与第一个子节点的连线,断开其与其它子节点的连线
- 调整结构
特点:根节点一定没有右子树,因为只留下了一个长子,而且经过结构调整之后即使在右面的孩子也跑到了左边,原来互为兄弟的节点变化之后就变成了父子节点
-
森林转化为二叉树
-
把森林中的每棵树都先转化为二叉树
-
后一颗二叉树的根节点作为前一课二叉树根节点的右孩子
-
-
二叉树转化为树
-
遍历所有节点,如果此节点有左孩子,则将此左孩子的右孩子,此左孩子的右孩子的右孩子。。。都和此节点连线
-
去除所有节点和其右孩子的连线
-
调整结构
-
-
二叉树转化为森林
这里需要判断一下此时的二叉树到底是转化为树还是转化为森林,如果此时的二叉树是由树转化而来的,那么根节点一定是没有右子树的,所以只需判断根节点是否有右子树即可。如果有,则可转化为森林,如果没有,则转化为树
- 把根节点和其右孩子的连线删除,继续看分离下来的右子树的根节点,进行相同的操作,一直递归解决下去,直到所有右孩子连线都删除为止,得到多棵二叉树
- 将得到的二叉树转化为树即可
二叉树性质
- 二叉树第i层上的结点数目最多为 $ 2^{i-1} $ (i≥1)
- 深度为k的二叉树至多有 \(2^{k}-1\) 个结点 (k≥1)
- 包含n个结点的二叉树的高度至少为\(log_2 (n+1)\)
- 总节点数 \(n = n_0 + n_1 + n_2 = B + 1\) ,其中B为总边数 \(B = n_0 + n_1 + 2 * n_2\) ,当树不是二叉树时,对应着进行变化即可
- 具有n个节点的二叉树,一共有\(\frac{c_{2n}^{n}}{n + 1}\)种(卡特兰数)
- 任何一棵二叉树的叶子结点在三种遍历中的相对次序不变。因为三种遍历只是根的位置在发生变化,左总是在右的左边。
- 任意二叉树,度为1的结点个数没限制。
- 在任意一棵树中,叶子结点个数\(n_0 = 1 + (2 - 1)n_2 + (3 - 1)n_3 + (4 - 1)n_4 + … + (n-1)n_n\)
完全二叉树的性质
- 具有n个节点的完全二叉树深度为\(\lfloor log_2^n \rfloor + 1\)
证明:
深度为k的完全二叉树节点数最少的情况是深度为 \(k-1\) 的满二叉树加上第k层的1个 节点,此时节点个数为 \(2 ^ {k - 1}\)
最多的情况为深度为k的满二叉树,此时节点个数为 \(2 ^ k - 1\)
所以可得,深度为k的完全二叉树的节点个数n满足,\(2 ^ {k - 1} <= n <= 2 ^ k - 1\)
可得:\(log_2(n + 1) <= k <= log_2n + 1\)
所以k取\(\lfloor log_2n \rfloor + 1\)
- 一棵完全二叉树叶子结点数为k,最后一层结点数>2,则该二叉树的高度为\(\lceil log_2k \rceil + 1\) ,不知道怎么证明
- 完全二叉树,度为1的结点个数才至多为1
哈夫曼树性质
一般所说的哈夫曼树只有度为0和2的节点
度为m的哈夫曼树:只有度数为有0和m的节点
哈夫曼树不一定是完全二叉树
树的性质
-
树有先根遍历和后根遍历两种方法,先根遍历:先访问树的根节点,再依次先根遍历子树;后根遍历:先依次后根遍历子树,再访问树的根节点。之所以没有中序遍历是因为树未必就是二叉树,子树有多个,可选择的序列也就有多个(比如根节点下有3个子树,分别为a,b,c,不考虑根节点的遍历顺序,仅这3个子树就是 \(3!\) 种遍历顺序),所以仅考虑是先遍历子树还是先遍历根节点这两种方式。而树的先根遍历与对应二叉树的中序遍历结果是一样的,理由我还没想懂。
-
若二叉树采用二叉链表存储结构,要交换其所有分支结点左、右子树的位置,利用(C)遍历方法最合适。A.前序 B.中序 C.后序 D.按层次
这道题现在不会
二叉链表相关题目
-
具有N个结点的二叉树,采用二叉链表存储,共有___n+1___个空链域
n个结点的二叉链表中,有2n个链域,每一条非空链域对应一条树枝,而树支的个数为n-1,因此,空节点个数为2n-(n-1)=n+1
易错点
- m叉树并不意味着所有节点的度都必须是m,只是度数最大为m
- 树的二叉链表,就是孩子兄弟表示法(左指针域:孩子节点;右指针域:兄弟节点),树的双亲表示法也叫顺序表示法,树的孩子表示法也叫链接表表示法。
-
二叉树是n个有限元素的集合,该集合或者为空、或者由一个称为根(root)的元素及两个不相交的、被分别称为左子树和右子树的二叉树组成,是有序树。
二叉树的左右子树必须是不相交的,所以
每个结点至多有两棵子树的树就是二叉树
上面这种说法是错误的,因为没有指出左右子树是不相交的。
其次,二叉树是有序树,即二叉树的分支具有左右次序,不能随意颠倒,但是这个“有序”我还没太明白是怎么个有序法。
-
不能说二叉树是树的特殊形式,因为我们在说A是B的特例时,必须保证B能够满足A的所有性质,A只是确定了某些条件之后的B,但是树的性质并不能包含二叉树的一部分性质,比如二叉树节点的最大度数为2,树则没有这个限制,二叉树的节点存在左右之分,但树则不存在,两者在定义上都存在差异。
图
图的概念和性质
-
完全图:
-
完全无向图:每一对不同的顶点都只有一条边相连,边数 = \(C_n^2\)
-
完全有向图:每一对不同的顶点都只有一对边相连(每个方向各一个),边数 = \(n * (n - 1)\),因为每个点都会和其余n-1个点相连
-
-
连通分量:无向图的极大连通子图。任何连通图的连通分量只有一个,即是其自身
-
强连通分量:有向图的极大强连通子图
-
路径长度:路径上边或弧的数量
图的存储结构
-
邻接矩阵:
-
邻接表:
-
逆邻接表:
-
十字链表:有向图的存储结构。
-
邻接多重表:无向图的存储结构。邻接表 + 十字链表,更加复杂了
引入的原因:使用邻接表存储无向图时,对图中某节点进行操作进行修改,修改操作两个节点效率较低
关键路径
关键路径不唯一时,一条关键路径上的活动提前完成只能使得这条关键路径变为非关键路径,其它关键路径没受到影响,所以整个工程未必就会提前完成。
不确定或不懂的知识点
- 在图G的最小生成树G1中,可能会有某条边的权值超过未选边的权值。某些解释说算法中需要避免环路,因此可能会有某条边的权值超过未选边的权值
易错点
-
强连通分量是有向图的概念;连通分量是无向图的概念
-
邻接多重表是无向图的概念,它的引入就是为了解决邻接表存储无向图时的一条边的重复存储的问题
-
十字链表是有向图的存储结构
-
连通图才存在生成树,非连通图可以生成森林
-
不同的求最小生成树的方法最后得到的生成树是相同的,这句话是错的,原因是如果存在相同权值的边,结果未必唯一。如果能保证权值均不相同则结果唯一
-
求最小生成树的普里姆(Prim)算法中边上的权可正可负。这道题的结果存在异议。
-
AOV(A:Activity活动 O:on位于 V:Vertex顶点)AOV网中,结点表示活动,边表示活动间的优先关系,边无权值,即拓扑排序网络
AOV网特点:有且只有一个表示开始状态的初始点和一个表示结束的终点,始点入度为0,终点出度为0
AOE(E:edge边) 中,结点表示活动开始或结束的状态,边上的权表示活动进行所需时间,关键路径就是AOE网中的概念
查找
常见平均查找长度
-
顺序查找
-
查找成功:$ \frac {n + 1}{2}$
每个元素被查找的概率都是$ \frac {1} {n}\(,从第一个元素都第n个元素需要的查找次数依次为1,2,3,... n,所以加在一起就是\) \frac {n (n + 1)} {2}\(,所以平均查找长度就是\) \frac {n + 1}{2}$
-
查找失败:\(n\)
只扫一遍序列,比较n次即结束了查找,所以是n
-
-
折半查找
此类问题会给定数据的个数(n),折半搜索的过程类似一个二叉树向下分叉的过程,我们知道n个的节点的完全二叉树的深度为$ \lfloor log_2^n \rfloor + 1$,所以我们首先可以确定这颗树的高度,然后确定最后一层有几个数。
-
查找成功:如果查找的是第一层的元素,只有一个节点,那么比较次数就是1,如果查找的是第二层的元素,一共两个元素,每个元素的比较次数是2次,总共就是2 * 2 = 4次,以此类推可以算出总共的比较次数,在每个元素的查找概率相同的情况下,总比较次数\数据个数即为答案
(下面图的查找成功时的平均查找长度)
在查找成功时,是有一个公式的,在做题时可以用于近似计算
公式计算过程:
折半搜索总节点数与对应判定树高度关系 \(n = 2 ^ h - 1\)
由此可得 \(h = log_2(n + 1)\)
根据查找成功时的计算方法可得 ASL = $ \frac{1}{n} \sum_{i = 1}{h}(2 * i)$
这里需要把求和公式展开得到Sn,然后左右两边同时乘以2得到2Sn,上下相减即可求得Sn
计算的时候注意区分h和n,最终算出来就是上面图片中的结果
-
查找失败:我们需要把整个二叉树画出来,就如同下面这样。查找失败也就是查找的是那些空格的平均查找长度,计算方法和上面是一样的。
-
-
哈希查找
需要根据实际情况进行计算,和折半查找一样根据题目去做即可。
但是做法是固定的,
-
查找成功平均查找长度的计算,首先需要计算出类似下面这样的一张表格,其中的探测次数就是包含第一次索引和后面解决冲突时的比较次数加在一起的总次数。平均查找长度即把所有的探测次数求和除以数据总个数即可。
-
查找失败需要注意的一点就是一次查找应何时结束,当我们找到的数指和待找值不相等不能直接判定元素不存在,因为待找值有可能为了解决哈希冲突所以后移了,还需要往后去找。如果找到空的位置了,查找就可以停止了,说明这个待找值是不存在的,因为如果是为了解决冲突,既然这个位置是空的,那么待找值完全可以填充到这里,既然没有填充说明它是不存在的。
所以计算的方法就是:假设我们的待找值通过哈希函数依次到0 ~ 12的地址处,如果这个位置为空,那么比较次数就是0,直接进行下一个位置,如果这个位置不为0,那么需要一直往后走,直到为空或遍历完所有位置,加上比较次数,往后一直这么进行下去。
-
-
分块查找
假设n个数据分为m个数据块,每个块内有t个数据,即\(m * t = n\)
如果对索引采用顺序查找则\(ASL = \frac {m + 1}{2} + \frac {t + 1}{2}\),根据不等式的性质,当\(m = t = \sqrt{n}\) 时,ASL取得最小值\(\sqrt{n} + 1\)
注:关注二分查找失败和哈希查找失败时ASL的计算过程可以发现一个区别,判定元素不存在的条件都是找到空位置,但是哈希查找的比较次数中是包含了判定是否为空的那一次,但是二分查找就没有包含。这是因为二分查找当左右指针重合时就停止了,空位置的空是通过左右指针相遇来体现的,所以并没有最后判空那一次比较,但是哈希查找的判空是真实存在的。
哈希函数的方法
- 直接定址法:例如使用一个简单的线性函数hash(key) = key - 1000,由于key是没有冲突的,按照这种方法计算处的hash(key)自然不会产生冲突,这也是它的优点,缺点是随着key范围的增大,hash(key)的增大不能满足现实需求
- 数字分析法
- 折叠法:适用范围是事先不知道关键字分布,且位数较多
- 除留取余法:关键讨论的方法
冲突解决方法
-
开放地址法 \((Hash(Key) + d_i) mod m\)
- 线性探测 \(d_i = 1, 2, 3 ... m - 1\)
- 二次探测 \(d_i = 1^2, -1^2, 2^2 -2^2,...+-k^2(k <= \frac {m} {2})\)
易错点:1. 注意mod的是m(表长),而不是求哈希值mod的那个东西了 2. 公式是基于hash(key)进行变化的,不是对原数进行变化
-
再哈希法:就是对出现的重复的哈希值再进行一次运算
-
链地址法:类似邻接表的形式,把所有映射值相同的元素都放到内部一个链表中
-
公共溢出区法:把出现冲突的数据直接放在另一个空间中存储起来
概念
-
AVL树:自平衡二叉查找树,一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树
节点的平衡因子:节点的左子树的高度减去它的右子树的高度
-
装填因子:\(α = \frac{填入表中记录个数}{哈希表长度}\),α越大,产生冲突的可能性越大,平均查找长度也就越大,这也就解释了“不懂的知识“中的第二个问题
-
二叉排序树:若左子树不为空,则左子树上节点的值均小于根节点,若右子树不为空,则右子树上节点的值均大于根节点,左右子树均为二叉排序树(这是一个递归的定义)
平衡二叉树
- 出现的意义:为了解决二叉排序树树高增长过快的问题。所以平衡二叉树是一个二叉排序树
不懂的知识点
- 若需在哈希表中删去一个元素,不管用何种方法解决冲突都只要简单的将该元素删去即可,这种说法是错误的,但是我还没想明白为什么
- 散列法的平均检索长度不随表中结点数目的增加而增加,而是随装填因子的增大而增大
- 在哈希函数H(key)=key%p中,p值最好取小于等于表长的最大素数或不包含小于20的质因子的合数,对于这个结论我一直的态度是理所相当然,但一直没有经过数学上的证明。
易错点
- 对于顺序查找,有序表和无序表查找成功的平均长度是一样的,查找失败的平均查找长度是不一样。原因是:查找成功时,有序表中所有元素的查找长度加在一起和无序表是相等的,注意是所有元素加在一起而不是某个元素;查找失败时,无序表肯定是扫一遍整个序列,但是有序表到比待寻值大的数时就停止了搜索,因为后续的数更大了,此时没出现后续也不可能出现了,所以未必会扫描整个序列
- \(带权路径长度WPL = \sum_{i = 1}^{n}(W_iL_i)\) 其中\(W_i\)是叶子节点的权值,\(L_i\)是叶子节点到根节点的路径长度,即叶子节点到根节点经过的边的数量,使用的并不是叶子节点的层次数
折半查找代码
// 二分的本质在于边界而非单调性,存在单调性的可以用二分,但是没有单调性的未必就不能用二分
#include <iostream>
using namespace std;
const int N = 1e5 + 10;
int a[N];
int main()
{
int n, x;
cin >> n >> x;
for (int i = 0; i < n; ++ i) cin >> a[i];
//-------------------------------------------寻找>=x中位置最靠前的那一个数据(最后结果是>=x的)
int l = 0, r = n - 1;
while (l < r)
{
int mid = l + r >> 1;
if (a[mid] >= x) r = mid;
else l = mid + 1;
}
if (a[l] == x) cout << l << endl;
else cout << "不存在" << endl;
//--------------------------------------------------------------------------------------
//-------------------------------------------寻找<=x中位置最靠后的那一个数据(最后结果是<=x的)
l = 0, r = n - 1;
while (l < r)
{
int mid = l + r + 1 >> 1;
if (a[mid] <= x) l = mid;
else r = mid - 1;
}
if (a[l] == x) cout << l << endl;
else cout << "不存在" << endl;
//--------------------------------------------------------------------------------------
return 0;
}
排序
排序算法分类
插入排序: 直接插入;希尔排序
选择排序: 直接选择;堆排序;
交换排序: 冒泡排序;快速排序;
归并排序
基数排序
时间复杂度总结
不同的地方找到的都不一样,但基本就这些了,不妨结合着来看。
与数组初始状态无关的算法
-
时间复杂度和初始状态无关:堆,归并,选择,基数(一堆乌龟选基友)
-
比较次数和初始状态无关:选择,基数
-
移动次数和初始状态无关:归并,基数
情况2、3是必定包含在情况1中间的
-
堆排序
思想:首先对初始数组建立最小堆,然后取堆顶元素与堆尾交换,再此堆元素(不含堆尾)再重新构建最小堆,依次循环。
分析:由于建立最小堆其实就是将初始元素按照规定的准则进行一系列排序(包括层级向下比较、交换),所以如果元素一开始就已经是最小堆则不需要此时的交换且大大减少向下比较次数,
所以堆排序不属于情况二也不属情况三
-
归并排序
思想:将初试数组划分成N个子数组,两两进行合并排序,然后结果再和其他同级合并后的数组合并知道合并完所有。
分析:外层递归与初始无关,主要思考合并排序中的比较和交换即可。合并排序思想:将数组A第一个与数组B第一个比较,较小的那一个直接进入result数组并且指针向下移动再与对面数组第一个比较,依次类推,然后将还有剩余的数组内元素全放入result,最后用result将原数组中对应的值一一替换。因此,假设初始数组就是有序的,那么每次合并排序的时的比较次数都仅仅是一个待合并的数组的长度,因此比较次数与初始状态有关,归并排序不属于情况二
然而,不论一开始的状态如何,最后都是两个数组进入result,移动次数都为两个待合并数组的长度和,然后再将result内元素全部移动到原来数组进行替换。所以元素移动次数与初始状态无关,归并排序属于情况三
-
选择排序
思想:i 从头开始,每次遍历之后所有的元素,k 从 i 开始,向后标记最小的元素,循环后如果大于 i ,则与 i 位置元素交换,一直到最后。
分析:比较次数都是N-1的阶乘,与初始状态无关,所以选择排序属于情况二
交换次数当全部已经排序好时则不发生交换,所以选择排序不属于情况三
-
基数排序
思想:将数组从低位到高位,每到一位对应分入10个桶(0-9)中,依次到最高位,由于每上升一位,处于“0号桶”中的数据都会将此位之前的数字排好,以此达到排序效果。
分析:基数排序中并不发生任何元素之间的比较,所以基数排序属于情况二
不论初始数组如何排列,都是从个位开始,各自进入自己个位对应的位置,之后也都是一样,所以元素移动次数一样,所以基数排序属于情况三
一些排序算法的特征
- 插入排序:可能会出现在最后一趟开始之前,所有元素都不在其最终的位置上,如果序列最小值在最后,那么就符合这种情况
- 快速排序:要排序的数据已基本有序的情况下最不易发挥其长处,每次划分能得到两个长度相等的子文件的情况下最有利于发挥长处
不知正确与否的知识点
- 当待排序的元素很大时,为了交换元素的位置,移动元素要占用较多的时间,这是影响时间复杂度的主要因素。有的说对,有的说错
代码实现
以下代码均为C++实现,但是并不会采用STL(Standard Template Library)中的容器,所以其它语言较易迁移实现
-
插入排序
-
直接插入排序
#include <iostream> #include <vector> using namespace std; void direct_insert(vector<int> &a) { int size = a.size(); for (int i = 0; i < size; ++ i) { int pos = i; for (int j = i - 1; j >= 0; -- j) { if (a[i] >= a[j]) break; // 等号保证了排序的稳定性 pos = j; } if (pos != i) { int tmp = a[i]; for (int j = i; j > pos; -- j) a[j] = a[j - 1]; a[pos] = tmp; } } } int main() { int n; cin >> n; vector<int> a(n); for (int i = 0; i < n; ++ i) cin >> a[i]; direct_insert(a); for (int i = 0; i < n; ++ i) cout << a[i] << " "; cout << endl; return 0; }
-
希尔排序
-
-
选择排序
-
简单选择排序
/** * 算法本身不难理解,但难点在于如何优化 * * 优化就是说在序列变为有序后如何及时判断出来并停止后续的无效遍历 * 不管怎么搞,我们一定会始终维护一个最小值,我第一次想的策略是从前向后如果在后面找到 * 一个比最小值小的元素说明序列一定是无序的,但是我忽略了一个问题,如果后续没有比当前最小值 * 更小的元素,那么后面的序列即使无序我们也是判断不出来的,出现这个问题原因是我们始终能够判断的是 * 当前值和最小值的相对关系,没有判断当前值和当前值到最小值中间这些数的相对关系 * * 所以采用从后向前遍历,我们可以保证如果当前遍历到的位置如果不是当前最小值说明序列仍无序,否则更新当前最小值 * 这么说起来有些抽象,看看代码或许就懂了 */ #include <iostream> #include <algorithm> using namespace std; const int N = 1e6 + 10; int n; int a[N]; void direct_choose(int a[], int l, int r) { int len = r - l + 1; for (int i = 0; i < len; ++ i) { bool sorted = true; int min_pos = len - 1; for (int j = len - 1; j >= i; -- j) { if (a[j] > a[min_pos]) sorted = false; else min_pos = j; } if (sorted) break; if (i != min_pos) swap(a[i], a[min_pos]); } } int main() { cin >> n; for (int i = 0; i < n; ++ i) cin >> a[i]; direct_choose(a, 0, n - 1); for (int i = 0; i < n; ++ i) cout << a[i] << " "; return 0; }
-
堆排序
/** * 递归版堆排序 * 思路: * 我们采用小根堆,也就是上面的数据小下面的数据大的结构 * 每次把剩余数中的最小值和最后一个数据交换一下,然后序列长度减1,维护小根堆 * 假设序列长度为n,进行n次上面的过程完成排序 * 结果是一个递减序列 */ #include <iostream> #include <algorithm> using namespace std; const int N = 1e5 + 10; int n; int h[N]; void down(int h[], int len, int u) { int t = u; if (u * 2 <= len && h[u * 2] < h[t]) t = u * 2; if (u * 2 + 1 <= len && h[u * 2 + 1] < h[t]) t = u * 2 + 1; if (t != u) { swap(h[u], h[t]); down(h, len, t); } } int main() { cin >> n; for (int i = 1; i <= n; ++ i) cin >> h[i]; for (int i = n >> 1; i >= 1; -- i) down(h, n, i); //O(N)的建堆操作:从最后一个非叶节点开始下沉操作 int t = n; while (t) { swap(h[1], h[t --]); down(h, t, 1); } for (int i = n; i >= 1; -- i) cout << h[i] << " "; return 0; }
-
-
交换排序
-
冒泡排序
#include <iostream> #include <algorithm> using namespace std; const int N = 1e6 + 10; int n; int a[N]; void bubble_sort(int a[], int l, int r) { int len = r - l + 1; for (int i = 0; i < len - 1; ++ i) { bool flag = true; //实现优化 for (int j = 0; j < len - i - 1; ++ j) { if (a[j] > a[j + 1]) // 不含=保证排序的稳定性 { flag = false; swap(a[j], a[j + 1]); } } if (flag) break; } } int main() { cin >> n; for (int i = 0; i < n; ++ i) cin >> a[i]; bubble_sort(a, 0, n - 1); for (int i = 0; i < n; ++ i) cout << a[i] << " "; return 0; }
-
快速排序:下面的两种写法,前者是考试时问到第几轮排序后的结果是多少对应的程序,后者是算法题中惯用的写法,不知道为什么在acwing的平台上前者在某些数据点会超时,虽然我觉得这些写法在常数上相差不大,应该不会出现这种问题。
// 数据结构课程中的写法 #include <iostream> #include <algorithm> using namespace std; const int N = 1e6 + 10; int n; int a[N]; void quick_sort(int a[], int l, int r) { if (l >= r) return ; // 横线中的就是一般书籍中另写的函数内容,这里为了简洁和减少函数调用所耗时间省略了 //------------------------------------------------------------------------ int pivotPos = l, pivot = a[pivotPos], i = l, j = r; while (i < j) { while (a[j] >= pivot && i < j) -- j; a[i] = a[j]; while (a[i] <= pivot && i < j) ++ i; a[j] = a[i]; } a[i] = pivot; //------------------------------------------------------------------------ quick_sort(a, l, i - 1); quick_sort(a, i + 1, r); } int main() { cin >> n; for (int i = 0; i < n; ++ i) cin >> a[i]; quick_sort(a, 0, n - 1); for (int i = 0; i < n; ++ i) cout << a[i] << " "; return 0; }
// 非数据结构课程中的写法 #include <iostream> #include <algorithm> using namespace std; const int N = 1e6 + 10; int n; int a[N]; void quick_sort(int a[], int l, int r) { if (l >= r) return ; int x = a[l + r >> 1], i = l - 1, j = r + 1; while (i < j) { while (a[++ i] < x); while (a[-- j] > x); if (i < j) swap(a[i], a[j]); } quick_sort(a, l, j); quick_sort(a, j + 1, r); } int main() { cin >> n; for (int i = 0; i < n; ++ i) cin >> a[i]; quick_sort(a, 0, n - 1); for (int i = 0; i < n; ++ i) cout << a[i] << " "; return 0; }
-
-
归并排序
#include <iostream> using namespace std; const int N = 1e6 + 10; int n; int a[N], tmp[N]; void merge_sort(int a[], int l, int r) { if (l >= r) return ; int mid = l + r >> 1; merge_sort(a, l, mid); merge_sort(a, mid + 1, r); int i = l, j = mid + 1, k = 0; while (i <= mid && j <= r) if (a[i] <= a[j]) tmp[k ++] = a[i ++]; else tmp[k ++] = a[j ++]; while (i <= mid) tmp[k ++] = a[i ++]; while (j <= r) tmp[k ++] = a[j ++]; for (int i = l, j = 0; i <= r; ++ i, ++ j) a[i] = tmp[j]; } int main() { cin >> n; for (int i = 0; i < n; ++ i) cin >> a[i]; merge_sort(a, 0, n - 1); for (int i = 0; i < n; ++ i) cout << a[i] << " "; return 0; }
-
基数排序