一、小技巧
1、计算多项式
// 实现f(x)=a0+a1*x+a2*x^2+...+an*x^n
// 可化简为f(x)=a0+x*(a1+x*(...(an-1+x*an)...))
double f(double *a,int n,double x)
{
double p = a[n];
for (int i=n;i>0;i--) {
p = a[i-1] + x*p;
}
return p;
}
2、计算最大连续子序列和
// 实现f(i,j)=max{0,∑(k=i,j)a[k]}
int a[];
int MaxSubseqSum(int n)
{
int MaxSum,ThisSum;
MaxSum = ThisSum = 0;
for (int i=0;i<n;i++) {
ThisSum += a[i];
/*如果当前序列之和大于最大序列和就更新*/
if ( ThisSum > MaxSum ) {
MaxSum = ThisSum;
}
/*如果序列之和是负数,说明以后的元素被加上后也无法达到最大*/
else if ( ThisSum < 0 ) {
ThisSum = 0; //更新当前序列之和
}
}
return MaxSum;
}
二、线性表
1、十字链表的使用思路
矩阵可以使用二维数组进行存储,但是有以下缺陷:
<1> 必须事先知道矩阵的大小
<2> 若矩阵是稀疏的,则会造成巨大的空间浪费
因此,我们可以使用十字链表来只存储非0项
用十字链表建立结点:数据域分为 行Row、列Col、值Value,指针域分为 行指针Right、列指针Down
其中第一个结点表示行数和列数以及非0项的个数,此后每行每列都构成一个循环链表
由于Term和Head具有共性,所以可以用Union把他们合并起来
2、特殊的线性表——堆栈
<1> 堆栈的顺序存储
若用一个数组模拟两个堆栈,要求最大限度利用数组,有空间就能入栈。我们可以将数组对半分开,以中间为切入点,则下标为-1或Maxsize的地方就为栈底。
若两个堆栈为空,则Top1=-1,Top2=Maxsize
若两个堆栈均满,说明两个指向顶端的标记挨在一起,所以Top2-Top1=1
若用链表来模拟堆栈,则应该以头结点作为Top,在每次入栈时,就把新的元素加到原头结点的前面,使其成为新的头结点。链表模拟的堆栈在入栈时,不需要考虑是否满栈,因为链表可以无限延长;而数组模拟的链表在入栈时需要判断是否满栈,因为数组的大小时固定的。
利用表达式求值时,如果遇到右括号,则右括号不需要入栈,而是作为标识符。
<2> 队列的顺序存储
用链表模拟队列,应该以头结点作为front
3、多项式的加法
其中Rear是指针,使用&Rear传递的原因是,函数是值传递,因此要改变Rear的值,必须传递它的地址。
4、多项式乘法的思路
<1> 设有两个多项式P1,P2,将P1的第一项乘以P2,储存在链表中
<2> 往后的P1的若干项,先与P2的某一项相乘,然后加到储存的链表中。在插入链表时,需判断指数的大小关系。假设多项式指数是递减,那么需要找到当插入的结点的指数刚好不大于某一结点的位置,此时再判断指数是否相等;若相等,再判断系数相加后是否为0,若为0,则将该节点删除,若不为0,则合并该结点。若不相等,则说明小于该结点的指数,此时只需要插入结点即可。
<3> 完成后将结点的头空结点删除,返回头结点即可。
三、树
1、判定树
n个结点的判定树深度为[log2(N)]+1,深度的数代表查询几次。
2、树的定义
3、树的基本术语
4、树的表示
一般的树,可以用儿子-兄弟表示法,采用链表的结构将他们表示出来。
像结点最多有两个子树,即结点的度为2,树的度也为2,这种树称之为二叉树。二叉树跟一般的度为2的树的区别就是:二叉树有左右之分。
5、二叉树
<1> 二叉树的定义
<2> 特殊的二叉树
满二叉树的叶结点必须是在同一深度
<3> 二叉树的重要性质
关于第三个结论的证明:(先假设n1为度为1的结点个数)
根据二叉树的性质可知:总边数 = 结点数n - 1 = n1 + n2 + n0 - 1
由上至下观察二叉树,可知 总边数 = 0×n0 + 2×n2 + 1×n1
所以可知:n0 = n2 + 1
<4> 二叉树的存储结构
① 顺序存储结构
用数组存储二叉树,最方便被存储的二叉树类型是完全二叉树。将完全二叉树的每个结点从上至下、从左至右进行编号,然后将对应编号放入数组中。于是便可以得到以下关系:
一般的二叉树如果用数组存储,只需要将这种二叉树补齐成完全二叉树进行存储即可,被补齐的结点在数组留下空位。但是这种操作会造成数组空间的浪费。
② 链表存储结构
一般的二叉树可以使用儿子-兄弟链表结构来进行存储。
<5> 二叉树的遍历
① 先序中序后序遍历
遍历的原则:每遇到一个结点,就将其作为根结点进行遍历。遇到叶结点时直接访问。
另,中序遍历的非递归遍历:
二叉树的非递归遍历一般用堆栈实现。
根据遍历顺序的定义,可以知道先序遍历的非递归写法:
若用堆栈的方式实现后序遍历,则原理是:若右子树为空,则访问该结点。但由于这样会造成死循环,所以应该用一个指针来记录上一次访问的结点。若此时访问的结点与上次访问的结点相同,说明此结点的根结点左右子树均已遍历,应访问该根结点。
② 层序遍历
层序遍历可以利用队列来实现。原理是:将结点入队,循环地令结点出队,完成遍历,再把它的左右子结点入队。
通过这样的操作,就能实现二叉树一层一层的遍历。
<6> 二叉树遍历的应用
二元运算表达式树的原理是:叶结点为数据,其余为运算符,当某结点作为根结点时,形式为:左子节点 根结点 右子节点。
利用中序遍历输出时,若想不受运算符优先级的影响,则需要在第一次输出左子树时输出左括号,在左子树为空时输出右括号。
<7> 二叉树的同构
同构的定义:
判断是否同构:
<8> 二叉搜索树
<9> 平衡二叉树
平衡二叉树是二叉搜索树的一类
另,平衡二叉树的调整:
<10> 判断是否为同一二叉搜索树
PS:线性结构习题——逆转单链表
四、堆
1、什么是堆
可以用完全二叉树来表示堆:
<2> 堆的插入
<3> 堆的删除
堆的删除,就是删除根结点的元素。因此有以下步骤:
① 将最后一个元素与第一个元素替换,此时根结点的左右子树均为堆
② 将根结点与左右子树结点进行比较,找出较大的结点,再与根结点比较。若根结点比它大,则不必替换;若根结点比它小,则与它进行交换后再以其为根结点进行相同的交换步骤,直至各个结点有序。
<4> 堆的建立
堆的建立与堆的删除中的重要步骤原理一致
构建成完全二叉树后,找到最后一个仅有一个儿子的结点,以此为基准,对其以前的每个结点的子树进行大小替换,使其变成堆。待到除根结点以外的所有结点变成堆后,接下来的操作便和堆的删除后调整其有序性的操作完全一致。
五、哈夫曼树与哈夫曼编码
1、哈夫曼树的定义
2、哈夫曼树的构造
3、哈夫曼编码
六、集合及运算
1、集合的表示
2、集合运算
由于合并后会增加树的高度导致查找函数效率降低,因此在合并前需要比较一下根结点元素的个数,将高度较小的数并入高度较大的树,防止查找效率降低
负数代表根结点,负数的绝对值代表元素个数。这是一个比较巧妙的表示方法。
七、堆中的路径
八、File Transfer
1、按秩归并
当合并两个集合时,需要把小的树往大的树合并。实现这种操作由两种方法:
① 记录树的高度并比较
② 记录树的规模(结点个数)并比较
2、路径压缩
九、图
1、什么是图
<1> 定义
有关图论的常见术语非常多,所以这里只介绍最常见的三个概念
<2> 邻接矩阵表示法
该邻接矩阵的特点是:
① 对角线上的元素都为0(因为不存在自回路)
② 矩阵是对称的(因为是无向图)
出度入度的情况存在于有向图
完全图是指任意两个顶点都存在边的图。
浪费时间是因为稀疏图中的边很少,而要知道稀疏图有多少条边,就需要扫描一遍稀疏图
<3> 邻接表表示法
对有向图来说,若要计算“入度”,可以构造指向矩阵的列的数组,来实现计算“入度”
2、图的遍历
<1> DFS
用邻接表存储图,遍历顶点时,需要访问N个顶点,E条边,所以是O(N+E);
用邻接矩阵存储图,遍历顶点时,每访问一个顶点,就要访问访问一行或一列,即N个元素,所以是O(N^2);
<2> BFS
BFS类似于树的层序遍历
用邻接表和邻接矩阵存储图时,使用BFS遍历方式的时间复杂度和使用DFS时相同,原理也一样
<3> 为什么需要两种遍历
BFS擅长解决最小问题
DFS擅长解决遍历或者所有问题
<4> 图连不通怎么办
弱连通图:将有向图的有向边替换为无向边,得到的图成为基图;若有向图的基图是连通的,则该有向图成为弱连通图
3、最短路径问题
<1> 概述
<2> 无权图的单源最短路
path[W] = V(V为W的前一个顶点)
<3> 有权图的单源最短路
存在负值圈的有权图不适用大多数算法
<4> 多源最短路算法
利用path二维数组记录顶点位置,便于打印最短路径。实现方法是:递归地调用函数先打印出i到k的路径,再打印k到j的路径。
4、最小生成树问题
<1> Prim算法
最小生成树存在和图连通是充分必要关系
Prim算法和Dijkstra算法类似,左是Dijkstra算法,右是Prim算法
该算法处理稠密图时效果较佳
Prim算法主要关注于顶点
<2> Kruskal算法
原理:不断收录最小边,当构成最小生成树时停止
Kruskal算法主要关注于边
5、拓扑排序
<1> 拓扑排序
以下是经过拓扑排序之后的拓扑序:
以下是实现拓扑排序的伪代码:
处理未输出的入度为0的顶点时,O(|V|)的算法是效率不高的
以下是处理未输出的入度为0的顶点时较好的方法:
DAG为有向无环图
<2> 关键路径
虚线的存在是因为:要执行6、7,就必须等待4、5完成,因此将5连接一条虚线到4,形成一个小集合。
3、Dijkstra算法的应用
<1> 数最短路径数问题:
① 初始化:由起点到结点V的最短路径数为1条
② 找到更短路时:起点到V的邻接点W的最短路径数跟到V的最短路径数相同
③ 找到等长路时:起点到V的邻接点W的距离跟到V的距离相同,那么最短路径数就是起点到V的路径数+起点到W的路径数
<2> 求边数最少的最短路
① 初始化:从起点到自己的边为0,所以初始化为0
② 找到更短路或等长路时:边数可以理解为权重为1的边,在处理时比较方便。此时该结点的边数+1
十、排序
1、简单排序
<1> 冒泡排序的优化
可以用一个标记flag来判断在某次循环时是否发生数据的交换,若无,则说明此时是有序的,因此可以接下来的循环可以不用进行,直接退出即可。
<2> 插入排序
<3> 时间复杂度下界
2、希尔排序
3、堆排序
<1> 选择排序
以上是实现选择排序的伪代码。其中要实现ScanForMin()函数,需要快速找到最小元,可以用最小堆来实现。于是就引出了堆排序。
<2> 堆排序
堆排序适用于队列中存在优先级的情况
算法2的实现代码:
#include <iostream>
#include <algorithm>
using namespace std;
void max_heapify(int arr[], int start, int end)
{
//建立父节点指标和子节点指标
int dad = start;
int son = dad * 2 + 1;
while (son <= end) //若子节点指标在范围内才做比较
{
if (son + 1 <= end && arr[son] < arr[son + 1]) //先比较两个子节点大小,选择最大的
son++;
if (arr[dad] > arr[son]) //如果父节点大於子节点代表调整完毕,直接跳出函数
return;
else //否则交换父子内容再继续子节点和孙节点比较
{
swap(arr[dad], arr[son]);
dad = son;
son = dad * 2 + 1;
}
}
}
void heap_sort(int arr[], int len)
{
//初始化,i从最後一个父节点开始调整,len/2-1即为最后一个父节点
for (int i = len / 2 - 1; i >= 0; i--)
max_heapify(arr, i, len - 1);
//先将第一个元素和已经排好的元素前一位做交换,再从新调整(刚调整的元素之前的元素),直到排序完毕
for (int i = len - 1; i > 0; i--)
{
swap(arr[0], arr[i]);
max_heapify(arr, 0, i - 1);
}
}
int main()
{
int arr[] = { 3, 5, 3, 0, 8, 6, 1, 5, 8, 6, 2, 4, 9, 4, 7, 0, 1, 8, 9, 7, 3, 1, 2, 5, 9, 7, 4, 0, 2, 6 };
int len = (int) sizeof(arr) / sizeof(*arr);
heap_sort(arr, len);
for (int i = 0; i < len; i++)
cout << arr[i] << ' ';
cout << endl;
system("pause");
return 0;
}
4、归并排序
归并排序是稳定的排序算法,时间复杂度为O(nlogn)
归并排序一般用于外排序而非内排序
<1> 有序子列的归并
对于两段有序子序列,可以使用归并排序的算法:
/* A[]是存放两段有序子序列的数组,TmpA[]是存放排序后数据的数组,
L是左子序列的起始位置,R是右子序列的起始位置,RightEnd是右子序列的终点位置 */
void Merge(int A[], int TmpA[], int L, int R, int RightEnd)
{
int LeftEnd = R - 1; // 左子序列的终点位置就是右子序列的起始位置的前一个
int Tmp = L; // 新数组的起始位置和左子序列的起始位置一样
int NumElements = RightEnd - L + 1; // 数组的总个数
/* 以下操作类似于 多项式相加 */
/* 如果左边或右边的子序列还没到达它的终点位置 */
while ( L <= LeftEnd && R <= RightEnd ) {
if ( A[L] <= A[R] ) TmpA[Tmp++] = A[L++]; // 赋值后指针向后移一位
else TmpA[Tmp++] = A[R++];
}
/* 如果有其中之一的子序列还没扫描完 */
while ( L <= LeftEnd ) TmpA[Tmp++] = A[L++];
while ( R <= RightEnd ) TmpA[Tmp++] = A[R++];
/* 将排序后的序列复制给原来的数组 */
for (int i=0; i<NumElements; i++, RightEnd--) {
A[RightEnd] = TmpA[RightEnd];
}
}
<2> 递归算法
统一函数接口的归并排序的递归算法:
/*/* A[]是存放两段有序子序列的数组,TmpA[]是存放排序后数据的数组,
L是左子序列的起始位置,R是右子序列的起始位置,RightEnd是右子序列的终点位置 */
void Merge(int A[], int TmpA[], int L, int R, int RightEnd)
{
int LeftEnd = R - 1; // 左子序列的终点位置就是右子序列的起始位置的前一个
int Tmp = L; // 新数组的起始位置和左子序列的起始位置一样
int NumElements = RightEnd - L + 1; // 数组的总个数
/* 以下操作类似于 多项式相加 */
/* 如果左边或右边的子序列还没到达它的终点位置 */
while ( L <= LeftEnd && R <= RightEnd ) {
if ( A[L] <= A[R] ) TmpA[Tmp++] = A[L++]; // 赋值后指针向后移一位
else TmpA[Tmp++] = A[R++];
}
/* 如果有其中之一的子序列还没扫描完 */
while ( L <= LeftEnd ) TmpA[Tmp++] = A[L++];
while ( R <= RightEnd ) TmpA[Tmp++] = A[R++];
/* 将排序后的序列复制给原来的数组 */
for (int i=0; i<NumElements; i++, RightEnd--) {
A[RightEnd] = TmpA[RightEnd];
/* 由于不知道起始位置,而终点位置是已知的,因此可以从右往左进行赋值 */
}
}
void MSort(int A[], int TmpA[], int L, int RightEnd)
{
int Center = (L+RightEnd) / 2;
if ( L < RightEnd ) {
MSort(A,TmpA,L,Center);
MSort(A,TmpA,Center+1,RightEnd);
Merge(A,TmpA,L,Center+1,RightEnd);
}
}
void Merge_sort(int A[], int N)
{
/* 临时数组不能在进行排序操作时建立,否则会进行多次申请释放空间 */
int *TmpA = (int*)malloc(N*sizeof(int));
if ( TmpA != NULL ) {
MSort(A,TmpA,0,N-1);
free(TmpA);
}
else puts("Insufficient Space!");
}
<3> 非递归算法
实现原理:先把每个元素看成长度为1的有序子序列,归并之后,有序子序列长度增加,再进行循环归并。
Merge1和Merge的区别是前者不需要从TmpA归并到A
统一接口函数:
5、快速排序
<1> 算法概述
每次递归时中分的情况时最快的,时间复杂度为O(NlogN)
<2> 主元的选取
主元的选取很关键。
<3> 子集划分
<4> 算法实现
int GetPivot(int A[], int Left, int Right)
{
int Center = (Left+Right) / 2;
if ( A[Left] > A[Center] ) swap(A[Left],A[Center]);
if ( A[Left] > A[Right] ) swap(A[Left],A[Right]);
if ( A[Center] > A[Right] ) swap(A[Center],A[Right]);
swap(A[Center],A[Right-1]);
return A[Right-1];
}
void Qsort(int A[], int Left, int Right)
{
if ( Right-Left+1 < 2 ) return;
int Pivot = GetPivot(A,Left,Right);
int i = Left;
int j = Right - 1;
while ( true ) {
while ( A[++i] < Pivot );
while ( A[--j] > Pivot );
if ( i < j ) {
swap(A[i],A[j]);
}
else break;
}
swap(A[i],A[Right-1]);
Qsort(A,Left,i-1);
Qsort(A,i+1,Right);
}
void quick_sort(int A[], int N)
{
Qsort(A,0,N-1);
}
6、表排序
<1> 算法概述
<2> 物理排序
在每次遍历环的时候就让table[i]=i,这样判断环结束时就比较方便
7、基数排序
<1> 桶排序
<2> 基数排序
<3> 多关键字的排序
答案是否,哪个可能性较多,就以哪个为基准建桶。
8、排序算法的比较
十一、散列查找
1、散列表
<1> 散列的基本思路
例子:编译处理时,涉及变量及属性的管理:
① 插入:新变量定义
② 查找:变量的引用
于是,这个问题也就是 动态查找问题
假设用搜索树进行变量的管理,两个变量名比较的效率不高,无法做到像整数那样只比较一次。因此可以把变量名转换成数字,再进行比较。这就是散列查找的思想。
<2> 什么是散列表
设计这样的散列函数,在不考虑冲突的情况下,函数值共有26种情况,再考虑发生冲突情况,所以创建一个26*2的二维数组来存储这些字符串。
2、散列函数的构造方法
<1> 数字关键词的散列函数构造
p取质数的原因是:这样的因子更少,分布广泛性得到提升。
atoi函数的作用是把字符串转换为数字
18位的身份证号码中,有6位的情况是比较随机的,因此取他们作为关键字来构造散列函数。
折叠法使数字经过散列函数后,分布更加广泛
平方取中法使数字不受低位数字的影响,分布更加广泛
<2> 字符串关键词的散列函数构造
显然1、2的方法都是有缺陷的。其中2的27进制是因为考虑到了空格。
要计算以下的式子,算法和 计算多项式 类似。
3、冲突处理方法
<1> 开放定址法
<2> 线性探测
ASLu的计算方法:根据余数的情况来计算,若冲突则往后移一位,如果遇到空,则说明找不到,此时停止并记录移动次数。除以11是因为对11取余,一共有11种情况。
<3> 平方探测法
<4> 平方探测法的实现
Info的作用是作为标识,来判断是否已经有存放元素
再散列时,所有元素的存放地址需重新计算。
<5> 分离链接法
4、散列表的性能分析
十二、综合习题选讲
1、Insert or Merge
要判断某一序列是否为归并排序的中间序列,可以用一个for循环来解决。例如,若要判断该归并段长度为l,则判断l/2处是否有序,若每个l/2处都有序,则说明该归并段长度为l。
2、Hashing-Hard Version
根据以上的思路,可以构造出各个元素的输入顺序层。在处理输出结果时,会发现其实这是拓扑排序。因此可以说明:在遇到有关元素的先后顺序问题时,可以想到拓扑排序。
3、串的模式匹配(KMP算法)
<1> 算法思路
match函数:寻找头尾相同的字符中前一段字符的最后一个字符的下标。
<2> 算法实现
<3> BuildMatch的编程实现