一、绪论
五种常见的算法
贪心
百度百科
贪心算法(又称贪婪算法)是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,算法得到的是在某种意义上的局部最优解。
贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择。
贪心算法一般按如下步骤进行:
- 建立数学模型来描述问题。
- 把求解的问题分成若干个子问题。
- 对每个子问题求解,得到子问题的局部最优解。
- 把子问题的解局部最优解合成原来解问题的一个解。
动态规划
百度百科
动态规划算法通常用于求解具有某种最优性质的问题。在这类问题中,可能会有许多可行解。每一个解都对应于一个值,我们希望找到具有最优值的解。动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。 若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。这就是动态规划法的基本思路。 具体的动态规划算法多种多样,但它们具有相同的填表格式。
动态规划是把问题分解成子问题,这些子问题可能有重复,可以记录下前面子问题的结果防止重复计算。动态规划解决子问题,前一个子问题的解对后一个子问题产生一定的影响。在求解子问题的过程中保留哪些有可能得到最优的局部解,丢弃其他局部解,直到解决最后一个问题时也就是初始问题的解。动态规划是从下到上,一步一步找到全局最优解。(各子问题重叠)
分治法
百度百科
分治算法的基本思想是将一个规模为N的问题分解为K个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解。即一种分目标完成程序算法, 简单问题可用二分法完成。
当我们求解某些问题时,由于这些问题要处理的数据相当多,或求解过程相当复杂,使得直接求解法在时间上相当长,或者根本无法直接求出。对于这类问题,我们往往先把它分解成几个子问题,找到求出这几个子问题的解法后,再找到合适的方法,把它们组合成求整个问题的解法。如果这些子问题还较大,难以解决,可以再把它们分成几个更小的子问题,以此类推,直至可以直接求出解为止。这就是分治策略的基本思想。
分治法(divide-and-conquer):将原问题划分成n个规模较小而结构与原问题相似的子问题;递归地解决这些子问题,然后再合并其结果,就得到原问题的解。(各子问题独立)
分治模式在每一层递归上都有三个步骤:
分解(Divide):将原问题分解成一系列子问题;
解决(conquer):递归地解各个子问题。若子问题足够小,则直接求解;
合并(Combine):将子问题的结果合并成原问题的解。
例如归并排序
回溯
百度百科
回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。 回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。许多复杂的,规模较大的问题都可以使用回溯法,有“通用解题方法”的美称。
分支定界
百度百科
通常,把全部可行解空间反复地分割为越来越小的子集,称为分支;并且对每个子集内的解集计算一个目标下界(对于最小值问题),这称为定界。在每次分枝后,凡是界限超出已知可行解集目标值的那些子集不再进一步分枝,这样,许多子集可不予考虑,这称剪枝。这就是分枝定界法的主要思路。
用循环比递归的效率高吗?
循环和递归两者是可以互换的,不能决定性的说循环的效率比递归高。
递归的优点是:代码简洁清晰,容易检查正确性;缺点是:当递归调用的次数较多时,要增加额外的堆栈处理,有可能产生堆栈溢出的情况,对执行效率有一定的影响。
循环的优点是:结构简单,速度快;缺点是:它并不能解决全部问题,有的问题适合于用递归来解决不适合用循环。
算法的定义
算法可以理解为由基本运算及规定的运算顺序所构成的完整的解题步骤。
一个算法的优劣可以用空间复杂度与时间复杂度来衡量。
算法的特性:
- 有穷性
- 确定性
- 输入
- 输出
- 可行性
算法的设计目标(什么是一个好的算法)
正确性、可读性、健壮性、算法效率
递归
链接
递归的两大要素:
边界条件、递归方程
百度百科
程序调用自身的编程技巧称为递归( recursion)。递归作为一种算法在程序设计语言中广泛应用。 一个过程或函数在其定义或说明中有直接或间接调用自身的一种方法,它通常把一个大型复杂的问题层层转化为一个与原问题相似的规模较小的问题来求解,递归策略只需少量的程序就可描述出解题过程所需要的多次重复计算,大大地减少了程序的代码量。递归的能力在于用有限的语句来定义对象的无限集合。
二、线性表
顺序表(数组)和链表的区别
链接
顺序表是一个数据结构;数组是一个数据类型;数据类型的变量里存储一种数据结构;
数组变量只是用来存储线性表结构的数据的;准确的说,是一维数组。
- 存取(读写)方式
顺序表可以顺序存取,也可以随机存取。
链表只能从表头顺序存取元素。例如在第i个位置上执行存或取的操作,顺序表仅需一次访问,而链表则需从表头开始依次访问i次。 - 逻辑结构与物理结构
采用顺序存储时,逻辑上相邻的元素,对应的物理存储位置也相邻。
而采用链式存储时,逻辑上相邻的元素,物理存储位置则不一定相邻,对应的逻辑关系是通过指针链接来表示的。 - 查找、插入和删除操作
对于按值查找,顺序表无序时,两者的时间复杂度均为O(n); 顺序表有序时,可采用折半查找,此时的时间复杂度为O(log2n) 。
对于按序号查找,顺序表支持随机访问,时间复杂度仅为0(1), 而链表的平均时间复杂度为O(n) 。
顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需修改相关结点的指针域即可。由于链表的每个结点都带有指针域,故而存储密度不够大。 - 空间分配
顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,若再加入新元素,则会出现内存溢出,因此需要预先分配足够大的存储空间。预先分配过大,可能会导致顺序表后部大量闲置;预先分配过小,又会造成溢出。动态存储分配虽然存储空间可以扩充,但需要移动大量元素,导致操作效率降低,而且若内存中没有更大块的连续存储空间,则会导致分配失败。链式存储的结点空间只在需要时申请分配,只要内存有空间就可以分配,操作灵活、高效
三、栈和队列
栈和队列的区别
队列的特点
先进先出(FIFO),是一种操作受限的线性表,只允许在队头删除元素,在队尾插入元素。
队列是允许在一段进行插入另一端进行删除的线性表。队列顾名思义就像排队一样,对于进入队列的元素按“先进先出”的规则处理,在表头进行删除在表尾进行插入。由于队列要进行频繁的插入和删除,一般为了高效,选择用定长数组来存储队列元素,在对队列进行操作之前要判断队列是否为空或是否已满。如果想要动态长度也可以用链表来存储队列,这时要记住队头和队尾指针的地址。
栈的特点
先进后出(FILO),栈只允许在栈顶进行插入或删除操作。
栈是只能在表尾进行插入和删除操作的线性表。对于插入到栈的元素按“后进先出”的规则处理,插入和删除操作都在栈顶进行,与队列类似一般用定长数组存储栈元素。由于进栈和出栈都是在栈顶进行,因此要有一个size变量来记录当前栈的大小,当进栈时size不能超过数组长度,size+1,出栈时栈不为空,size-1。
四、串
模式匹配算法
bili 王道
串的模式匹配:在主串中找到与模式串相同的子串,并返回其所在位置。
简单模式匹配算法
① 匹配成功的较好情况
较好的情况:每个子串第一个字符就与模式串不匹配(此时指向主串的i变量无需回溯)
② 匹配成功的最好情况、匹配失败的最好情况
若模式串长度为m,主串长度为n,则:
匹配成功的最好复杂度:O(m)。
匹配失败的最好复杂度:O(n - m + 1) = O(n - m) = O(n)。
③ 匹配成功/失败的最坏情况
KMP
KMP算法相对于简单模式匹配算法的改进:
主串指针不回溯,只有模式串指针回溯。
求模式串的next数组
KMP算法的改进——nextval数组
五、树
二叉树
定义
在一般的树的定义下,再加入如下两个限制条件:
- 每个结点最多只有两颗子树,即二叉树中结点的度最能为0、1、2。
- 子树有左右顺序之分,不能颠倒。
二叉树的五种基本形态
- 空二叉树
- 只有根结点
- 只有左子树,右子树为空
- 只有右子树,左子树为空
- 既有左子树,又有右子树
满二叉树 ※
在一棵二叉树中,如果所有的分支结点都有左孩子和右孩子结点,并且叶子结点都集中在二叉树的最下一层,则这样的二叉树称为满二叉树。
完全二叉树 ※
通俗地说,一棵完全二叉树一定是由一棵满二叉树从右至左从下至上,挨个删除结点所得到的。
满二叉树一定是完全二叉树,完全二叉树不一定是满二叉树。
六、图
基本概念
图由结点的有穷集合V和边的集合E组成。
- 若 有向图 中有 n 个顶点,则最多有 n(n-1) 条边(图中任意两个顶点都有两条边相连),将具有 n(n-1) 条边的有向图称为 有向完全图 。
- 若 无向图 中有 n 个顶点,则最多有 n(n-1)/2 条边(任意两个顶点之间都有一条边),将具有 n(n-1)/2 条边的无向图称为 无向完全图 。
图的遍历算法
深度优先搜索遍历(DFS)
图的深度优先搜索遍历(DFS)类似于二叉树的先序遍历。它的基本思想是:首先访问出发点v,并将其标记为已访问过;然后选取与v邻接的未被访问的任意一个顶点w,并访问它;再选取与w邻接的未被访问的任一顶点并访问,以此重复进行。当一个顶点所有的邻接顶点都被访问过时,则依次退回到最近被访问过的顶点,若该顶点还有其他邻接顶点未被访问,则从这些未被访问的顶点中取一个并重复上述访问过程,直至图中所有顶点都被访问过为止。
存储结构:邻接表
栈
二叉树的先序遍历是对于每个结点要递归地访问两个分支,图的深度优先搜索遍历则是要递归地访问多个分支。
广度优先搜索遍历(BFS)
图的广度优先搜索遍历(BFS)类似手树的层次遍历。它的基本思想是:首先访问起始顶点v,然后选取与v邻接的全部顶点w1,,…,wn 进行访问,再依次访问与w1,…,wn 邻接的全部顶点(已经访问过的除外),以此类推,直到所有顶点都被访问过为止。
广度优先搜索遍历图的时候,需要用到一个队列(二叉树的层次遍历也要用到队列),算法执行过程可简单概括如下:
- 任取图中一个顶点访问,入队,并将这个顶点标记为已访问;
- 当队列不空时循环执行:出队,依次检查出队顶点的所有邻接顶点,访问没有被访问过的邻接顶点并将其入队;
- 当队列为空时跳出循环,广度优先搜索即完成。
存储结构:邻接表
最小(代价)生成树
下面两个算法都是针对 无向图 的。
普里姆算法(Prim)
从图中任意取出一个顶点,把它当做一棵树,然后从与这棵树相接的边中选取一条最短(权值最小)的边,并将这条边及其所连接的顶点也并入这棵树中,此时得到了一颗有两个顶点的树。(然后从与这棵树相接的边中选取一条最短的边,并将这条边及其所连接顶点并入当前树中,得到一颗具有三个顶点的树。)以此类推,直到图中所有顶点都被并入树中为止,此时得到的生成树就是最小生成树。
克鲁斯卡尔算法(Kruskal)
将图中按照权值从小到大排序,然后从最小边开始扫描各边,并检测当前边是否为候选边,即是否该边的并入会构成回路,如不构成回路,则将该边并入当前生成树中,直到所有边都被检测完为止。
每次找出候选边中权值最小的边,就将改边并入生成树中。重复此过程直到所有边都被检测完为止。
最短路径
迪杰斯特拉算法(Dijkstra)
求图中某一顶点到其余各顶点的最短路径。
设有两个顶点集合S和T,集合S中存放图中已找到最短路径的顶点,集合T存放图中剩余顶点。
初始状态时,集合S中只包含源点v0,然后不断从集合T中选取到顶点v0路径长度最短的顶点vu并入到集合S中。集合S每并入一个新的顶点vu,都要修改顶点v0到集合T中顶点的最短路径的长度值。 不断重复此过程,直到集合T的顶点全部并入到S中为止。
三个辅助数组:
- dist[]:存储起点到其余各顶点的最短路径的长度
- path[]:存储当前点在最短路径上的前一个顶点
- set[]:标记了当前顶点是否已经被并入了最短路径
时间复杂度:O(n^2)
弗洛伊德算法(Floyd)
求图中任意一对顶点的最短路径。
动态规划
设置两个辅助矩阵(二维数组):
- A[][]:用来记录当前已经求得的任意两个顶点最短路径的长度。
- Path[][]:用来记录当前两顶点间最短路径上要经过的中间顶点。
一般过程
- 设置两个矩阵 A 和 Path,初始时将图的邻接矩阵赋值给 A,将矩阵 Path 中元素全部设置为 -1
- 以顶点 k 为中间顶点,k 取 0 ~n-1(n为图中顶点个数),对图中所有顶点对{i, j}进行如下检测与修改:
如果 A[i][j] > A[i][k] + A[k][j],则将 A[i][j] 更新为 A[i][k] + A[k][j]的值,将 Path[i][j] 更新为 k,否则什么都不做。
时间复杂度:O(n^3)
拓扑排序——AOV网
活动在顶点上的网(Activiity On Vertex network, AOV)是一种可以形象地反映出整个工程中各个活动之间的先后关系的有向图。
AOV网是一种以顶点表示活动、以边表示活动的先后次序且没有回路的有向图。
栈
拓扑排序序列可能不唯一
一般过程
在一个有向图中找到一个拓扑排序序列的过程如下:
- 从有向图中选择一个没有前驱(入度为0)的顶点输出
- 删除①中的顶点,并且删除从该顶点发出的全部边
- 重复上述两步,直到剩余的图中不存在没有前驱的顶点为止
AOV与AOE的异同
相同点
都是 有向无环图
不同点
AOV网的顶点表示活动,边无权值,边代表活动之间的先后关系。
AOE网的边表示活动,边有权值,边代表活动持续时间;顶点表示事件,事件是图中新活动开始或者旧活动结束的标志。
对于一个表示工程的AOE网,只存在一个入度为0的顶点,称为源点,表示整个工程的开始;也只存在一个出度为0的顶点,称为汇点,表示整个工程的结束。
关键路径——AOE网
活动在边上的网(Activity On Edge network, AOE)
有向无环图
关键路径
在AOE网中,从源点到汇点的所有路径中,具有最大路径长度的路径称为关键路径。
完成整个工期的最短时间就是关键路径长度所代表的时间。关键路径上的活动称为关键活动。
关键路径是个特殊的概念,它既代表了一个最短又代表了一个最长,它是图中的最长路径,又是整个工期所完成的最短时间。
一般过程
① 根据图求出拓扑有序序列a和逆拓扑有序序列b。
② 根据序列a和b分别求出每个事件的最早发生时间和最迟发生时间,求解方法如下:
- 一个事件的最早发生时间为指向它的边(假设为a)的权值加上发出 a 这条边的事件的最早发生时间。如果有多条边,则逐一求出对应的时间并选其中最大的结果作为当前事件的最早发生时间。
- 一个事件的最迟发生时间为由它所发出的边(假设为b)所指向的事件的最迟发生时间减去b条边的权值。如果有多条边,则逐一求出对应的时间并选其中最小的结果作为当前事件的最迟发生时间。
③ 根据 ② 中结果求出每个活动的最早发生时间和最迟发生时间。
④ 根据 ③ 中结果找出最早发生时间和最迟发生时间相同的活动,即为关键活动。由关键活动所连成的路径即为关键路径。
七、查找
查找分为静态查找表和动态查找表。
静态查找表包括:顺序查找、折半查找、分块查找;
动态查找包括:二叉排序树和平衡二叉树。
顺序查找法
顺序查找法是一种最简单的查找方法。它的基本思路是:从表的一端开始,顺序扫描线性表,依次将扫描到的关键字和给定值k比较,若当前扫描的关键字与k相等,则查找成功;若扫描结束后,仍未发现关键字等于k的记录,则查找失败。
由以上可知,顺序查找法对于顺序表和链表都是适用的:
- 对于顺序表,可以通过数组下标递增来顺序扫描数组中的各个元素
- 对于链表,则可通过表结点指针(假设为p)反复执行p = p->next;来扫描表中各个元素。
时间复杂度:O(n)
缺点:效率太低
折半查找法
在计算机科学中,折半搜索(英语:half-interval search),也称二分搜索(英语:binary search)、对数搜索(英语:logarithmic search),是一种在有序数组中查找某一特定元素的搜索算法。
搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
优点:
是比较次数少,查找速度快,平均性能好;
缺点:
要求待查表为有序表(且为顺序表),且插入删除困难。
因此,折半查找方法适用于不经常变动而查找频繁的有序列表。
分块查找法
先把查找表分为若干子表,要求每个子表的元素都要比他后面的子表的元素小,也就是保证块间是有序的(但是子表内不一定有序),把各子表中的最大关键字构成一张索引表,表中还包含各子表的起始地址。
特点:
块间有序,块内无序,查找时块间进行索引查找,块内进行顺序查找。
分块查找是折半查找和顺序查找的一种改进方法,分块查找由于只要求索引表是有序的,对块内节点没有排序要求,因此特别适合于节点动态变化的情况。
二叉排序树
二叉排序树(BST)的定义:
二叉排序树或者是一棵空树,或者是满足以下性质的树:
- 若它的左子树不空,则左子树上所有关键字的值均小于根关键字的值。
- 若它的右子树不空,则右子树上所有关键字的值均大于根关键字的值。
- 左右子树又各是一棵二叉排序树。
显然,要找的关键字要么在左子树上,要么在右子树上,要么在根结点上。由二叉排序树的定义可以知道。根结点中的关键字将所有关键字分成了两部分,即大于根结点中关键字的部分和小于根结点中关键字的部分。 可以将待查关键字先和根结点中的关键字比较,如果相等则查找成功;如果小于则到左子树中去查找,无须考虑右子树中的关键字;如果大于则到右子树中去查找,无须考虑左子树中的关键字。如果来到当前树的子树根,则重复上述过程;如果来到了结点的空指针域,则说明查找失败。 可以看出这个过程和折半查找法十分相似,实际上折半查找法的判定树就是一棵二叉排序树。
在查找时可以进行动态的插入,插入节点要符合二叉排序树的定义,这也是动态查找和静态查找的区别,静态查找不能进行动态插入。
平衡二叉树
概念
平衡二叉树又称为AVL树,是一种特殊的二叉排序树。其左右子树都是平衡二叉树,且左右子树高度之差的绝对值不超过1。一句话表述为:以树中所有结点为根的树的左右子树高度之差的绝对值不超过1。
树越矮查找效率越高
平衡因子:是指左子树的高度减去右子树的高度的差,它的值只能为1,0,-1。
平衡二叉树的建立
建立平衡二叉树的过程和建立二叉排序树的过程基本一样,都是将关键字逐个插入空树中的过程。所不同的是,在建立平衡二叉树的过程中,每插入一个新的关键字都要进行检查,看是否新关键字的插入会使得原平衡二叉树失去平衡,即树中出现乎衡因子绝对值大于1的结点。如果失去平衡则需要进行平衡调整。
平衡调整
假定向平衡二叉树中插入一个新结点后破环了平衡二叉树的平衡性,则首先要找出插入新结点后失去平衡的最小子树,然后再调整这棵子树,使之成为平衡子树。值得注意的是,当失去平衡的最小子树被调整为平衡子树后,无须调整原有其他所有的不平衡子树,整个二叉排序树就会成为一棵平衡二叉树。所谓失去平衡的最小子树是以距离插入结点最近,且以平衡因子绝对值大于1的结点作为根的子树,又称为最小不平衡子树。
当然,平衡调整必须保持排序二叉树左小右大的性质,平衡调整有4种情况,分别为LL型(右单旋转调整)、RR型(左单旋转调整)、LR型(先左后右双旋转调整)和RL型(先右后左双旋转调整)。
B树(B-树)
B树是平衡m叉查找树,但限制更强,要求所有叶结点在同一层。
B+树
散列表(散列表)
根据给定的关键字来计算出关键字在表中的地址。
常用Hash函数的构造方法
- 直接定址法
- 数字分析法
- 平方取中法
- 除留余数法
常用的Hash冲突处理方法
- 开放定址法
- 线性探查法
- 平方探查法
- 伪随机序列法
- 双Hash函数法
- 链地址法
八、排序
简介
演示
排序的时间、空间复杂度
稳定性
稳定性是指当带排序序列中有两个或两个以上相同的关键字时,排序前和排序后这些关键字的相对位置,如果没有发生变化就是稳定的,否则就是不稳定的。
插入类排序
直接插入排序
void InsertSort(int R[], int n)
{
int i, j;
int temp;
for ( i = 1; i < n; ++i)
{
// 将待插入关键字暂存于 temp 中
temp = R[i];
j = i - 1;
// 下面这个循环完成了从待排关键字之前的关键字开始扫描,如果大于待排关键字,则后移一位。
while ( j >= 0 && temp < R[j])
{
R[j + 1] = R[j];
--j;
}
// 找到插入位置,将 temp 中暂存的待排关键字插入
R[j + 1] = temp;
}
}
算法性能分析
时间复杂度分析
- 最坏情况:整个序列为逆序的,O(n^2)
- 最好情况:整个序列为有序的,O(n)
- 平均时间复杂度:O(n^2)
空间复杂度分析
- 算法所需的辅助空间不随待排序列规模的变化而变化,是个常量,因此空间复杂度为 O(1)。
说明
对于直接插入排序,一趟排序后并不能确保使一个关键字到达其最终位置。这是插入类排序算法的共同特点。
折半插入排序
算法性能分析
时间复杂度分析
- 最坏情况:O(n^2nlog2n)
- 最好情况:O(nlog2n)
- 平均时间复杂度:O(n^2)
空间复杂度分析
- O(1)
交换类排序
冒泡排序
void BubbleSort(int array[], int n)
{
bool flag;
int i, j;
for (i = 0; i < n - 1; i++)
{
flag = false;
for (j = 0; j < n - 1 - i; j++)
{
if (array[j] > array[j + 1])
{
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
flag = true;
}
}
if (flag == false)
return;
}
}
冒泡排序算法结束的条件是:在一趟排序过程中没有发生关键字交换。
算法性能分析
时间复杂度分析
- 最坏情况:整个序列为逆序的,O(n^2)
- 最好情况:整个序列为有序的,O(n)
- 平均时间复杂度:O(n^2)
空间复杂度分析
- 额外辅助空间只有一个 temp,因此空间复杂度为 O(1)。
快速排序
void QuickSort(int R[], int low, int hight)
{
int temp;
int i = low, j = high;
if (low < high)
{
temp = R[low];
while(i < j)
{
while(j > i && R[j] >= temp)
--j;
if(i < j )
{
R[i] = R[j];
++i;
}
while(i < j && R[i] < temp)
++i;
if (i < j)
{
R[j] = R[i];
--j;
}
}
R[i] = temp;
QuickSort(R, low, i - 1);
QuikcSort(R, i + 1, hight);
}
}