1. 分治算法
1.1 理论梳理
1.1.1 适用情况
分治法所能解决的问题一般具有以下几个特征:
- 该问题的规模缩小到一定的程度就可以容易地解决
- 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。
- 利用该问题分解出的子问题的解可以合并为该问题的解;
- 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题。
第一条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加;
第二条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用;、
第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法或动态规划法。
第四条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好。
1.1.2 基本步骤
step1 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
step2 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
step3 合并:将各个子问题的解合并为原问题的解。
1.1.3 复杂性分析
一个分治法将规模为n的问题分成k个规模为n/m的子问题去解。设分解阀值n0=1,且adhoc解规模为1的问题耗费1个单位时间。再设将原问题分解为k个子问题以及用merge将k个子问题的解合并为原问题的解需用f(n)个单位时间。用T(n)表示该分治法解规模为|P|=n的问题所需的计算时间,则有:
T(n)= k T(n/m)+f(n)
1.2 算法实例
1.2.1 快速排序
坐在马桶上看算法:快速排序:
对于序列a, 先任意找一个基准数, 然后利用上文中的哨兵方法把比基准数小的数移动到基准数左边, 把比基准数大的数移动到基准数右边, 然后对基准数左右的两个子序列重复这个过程.
复杂度: 快速排序的最差时间复杂度和冒泡排序是一样的都是O(N2),它的平均时间复杂度为O(NlogN)
1.2.2 归并排序
子问题: 要将两个有序数列合并,只要从比较二个数列的第一个数,谁小就先取谁,取了后就在对应数列中删除这个数。然后再进行比较,如果有数列为空,那直接将另一个数列的数据依次取出即可。
分解与合并: 归并排序的基本思路就是将数组分成二组A,B,如果这二组组内的数据都是有序的,那么就可以很方便的将这二组数据进行排序了.
下图源自:图解排序算法(四)之归并排序
复杂度:
设数列长为N,将数列分开成小数列一共要logN步,每步都是一个合并有序数列的过程,时间复杂度可以记为O(N),故一共为O(N*logN)。
1.2.3 二分搜索
复杂度: 时间复杂度无非就是while循环的次数:
总共有n个元素,渐渐跟下去就是n,n/2,n/4,….n/2^k(接下来操作元素的剩余个数),其中k就是循环的次数
由于你n/2^k取整后>=1
即令n/2^k=1
可得k=log2n,(是以2为底,n的对数)
所以时间复杂度可以表示O(h)=O(log2n)
1.2.4 汉诺塔
- 当n=1时,a柱子只有一个圆盘,直接移至c柱
- 当n>1时,根据规则1和2,将a柱子n-1个圆盘移动到b柱子,然后将a剩下的一个圆盘移动到c,接着再把b上暂时放着的n-1个圆盘移动到c
void Hanoi(int n, char a, char b, char c)
{
if(n == 1)
{
Move(a, c);
}
else
{
Hanoi(n-1, a, c, b); /*将a柱子n-1个圆盘移动到b柱子*/
Move(a, c); /*将a剩下的一个圆盘移动到c*/
Hanoi(n-1, b, a, c); /*再把b上暂时放着的n-1个圆盘移动到c*/
}
}
void Move(char a, char b)
{
printf("Move 1 disk: %c ---------> %c\n", a, b);
}
2. 动态规划
2.1 理论梳理
动态规划部分
动态规划把问题的求解过程变成一个多阶段的决策过程,每一步决策都将利用之前的决策结果
2.1.1 适用情况
最优子结构:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
性质3是动态规划与分治法在适用情景上的主要差别
2.1.2 基本步骤
- 问题建模,找出目标函数和约束条件
- 找出子问题的划分方法(子问题的边界),判断是否满足动态规划的基本要素
- 找出递推方程,明确目标函数与子问题间的依赖关系
- 找出最小子问题的优化方法(初值确定),从而明确递推方程的结束条件
2.2 算法实例
2.2.1 硬币找零
假设有几种硬币,如1、3、5,并且数量无限。请找出能够组成某个数目的找零所使用最少的硬币数。
题解:用待找零的数值k描述子结构/状态,记作sum[k],其值为所需的最小硬币数。对于不同的硬币面值coin[0…n],有递推方程:
sum[k] = min(sum[k-coin[0]] , sum[k-coin[1]], ...)+1
边界值: if(k <0) sum[k] = +∞
注意: K越小, 所需的硬币数未必越少, 所以不能用扣去最大面额硬币的贪心策略来缩小问题规模.
2.2.2 字符串相似度/编辑距离
对于序列S和T,它们之间距离定义为:对二者其一进行几次以下的操作(1)删去一个字符;(2)插入一个字符;(3)改变一个字符。每进行一次操作,计数增加1。将S和T变为同一个字符串的最小计数即为它们的距离。给出相应算法。
解法: 将S和T的长度分别记为len(S)和len(T),并把S和T的距离记为m[len(S)][len(T)]
将S和T的长度分别记为len(S)和len(T),并把S和T的距离记为m[len(S)][len(T)],有以下几种情况:
如果末尾字符相同,那么m[len(S)][len(T)]=m[len(S)-1][len(T)-1];
如果末尾字符不同,有以下处理方式
修改S或T末尾字符使其与另一个一致来完成,m[len(S)][len(T)]=m[len(S)-1][len(T)-1]+1;
在S末尾插入T末尾的字符,比较S[1…len(S)]和S[1…len(T)-1];
在T末尾插入S末尾的字符,比较S[1…len(S)-1]和S[1…len(T)];
删除S末尾的字符,比较S[1…len(S)-1]和S[1…len(T)];
删除T末尾的字符,比较S[1…len(S)]和S[1…len(T)-1];
总结为,对于i>0,j>0的状态(i,j), 有递推方程:
m[i][j] = min( m[i-1][j-1]+(s[i]==s[j])?0:1 , m[i-1][j]+1, m[i][j-1] +1)
这里的重叠子结构是S[1…i],T[1…j]
2.2.3 最长公共子序列(LCS)
参考资料: 最长公共子序列
一个字符串S,去掉零个或者多个元素所剩下的子串称为S的子序列。最长公共子序列就是寻找两个给定序列的子序列,该子序列在两个序列中以相同的顺序出现,但是不必要是连续的。例如X = {a, Q, 1, 1}; Y = {a, 1, 1, d, f}那么,{a, 1, 1}是X和Y的最长公共子序列
递推关系:
对于字符串x, y:
- 如果 xm=yn