916数据结构额外考题
前言
宁波大学研究生考试专业课的数据结构(916)考额外的高级的设计与分析技术,主要有动态规划,贪婪算法,分摊分析。参考书是《算法导论》第二版,目录如下
动态规划
求出最优解永远都是要枚举所有的情况的,有些问题枚举起来时间复杂度非常高,这时候就需要动态规划。动态规划就是利用简单的思想,让原来指数时间的算法变成多项式时间的算法。动态规划(dynamic programming)要找内部的最优子结构,这也是能否使用动态规划的一个标志
装配线调度
问题描述看图片,我们这边就先只考虑两条装配线的问题,其实多了也一样,没啥区别,记住只有一个底盘在装配,不要想的太复杂。
通过工厂最快路线的结构
我们首先要描述最优解结构的特征,S(i,j)代表的是到达第i条装配线j加工步骤并完成加工的最快时间,假设底盘到S(1,j)是最快的,那它肯定是由前一步S(1,j-1)或S(2,j-1)来的(当然要加上运送时间),其中到达S(1,j-1)或者S(2,j-1)的时间也是最快的。这样依次类推就可以求出所有的最短时间。
通过S(1,j)的最快路线只能是以下两种选择:
·通过配件站S(1,j-1),然后直接到达S(1,j)。
·通过配件站S(2,j-1),然后从装配线2到装配线1,最后到达S(1,j)。
对于配件站S(2,j)的路径是对称的,方法也是相同的。
递归求解
确定底盘通过工厂的所有路线的最快时间f*,每条装配线的配件站的数目都是n,则有终点
起点f(1,1)和f(2,1)的推导为:
中间过程的S(i,j)为
其中f_i[j]就是子问题最优解的值,这样我们就求出到达到任意位置并完成加工的最快速度了,但是我们不知道路径,接下来我们就设置\(l_i[j]\)表示第i条流水线j位置是由第\(l_i[j]\)装配线过来的,\(l*\)的值则代表是由哪一条流水线出来的,比如上图4中\(l_1[6]=2\)则表示应该向前在装配线2找相应的配件站,找到\(l_2[5]=2\),接着到装配线2中 \(l_2[4]=1\),再找装配线1中 \(l_1[3]=1\),依次类推,就可以找到底盘的路径了= v =,代码如下
#include <stdio.h>
#include <cmath>
#include <algorithm>
using namespace std;
int e[3]={0,2,4}; //进入装配线的时间
int x[3]={0,3,2}; //退出装配线的时间
int f_outcome,l_outcome; // 最终结果
int f[3][7]; // 到达加工步骤的时间
int l[3][7]; // 底盘所经过的路径
int a[3][7]={{0,0,0,0,0,0,0},{0,7,9,3,4,8,4},{0,8,5,6,4,5,7}}; //给底盘加工所需时间
int t[3][7]={{0,0,0,0,0,0},{0,2,3,1,3,4},{0,2,1,2,2,1}}; //切换不同装配线所需要的时间
//开始加工
void dp()
{
//初始化
f[1][1]=a[1][1]+e[1];
f[2][1]=a[2][1]+e[2];
//开始dp
int j;
for(j=2;j<7;j++)
{
//第一条装配线
if(f[1][j-1]+a[1][j]<f[2][j-1]+a[1][j]+t[2][j-1])
{
f[1][j]=f[1][j-1]+a[1][j];
l[1][j]=1;
}
else
{
f[1][j]=f[2][j-1]+a[1][j]+t[2][j-1];
l[1][j]=2;
}
//第二条装配线
if(f[2][j-1]+a[2][j]<f[1][j-1]+a[2][j]+t[1][j-1])
{
f[2][j]=f[2][j-1]+a[2][j];
l[2][j]=2;
}
else
{
f[2][j]=f[1][j-1]+a[2][j]+t[1][j-1];
l[2][j]=1;
}
}
//最终结果
f_outcome=min(f[1][6]+x[1],f[2][6]+x[2]);
if(f[1][6]+x[1]<f[2][6]+x[2])
l_outcome=1;
else l_outcome=2;
//f[1][j]=min(f[1][j-1]+a[1][j],f[2][j-1]+a[1][j]+t[2][j-1]);
//f[2][j]=min(f[2][j-1]+a[2][j],f[1][j-1]+a[2][j]+t[1][j-1]);
}
//输出
void outcome()
{
int i,j;
printf("第一条装配线时间:");
for(i=1;i<7;i++)
printf("%d ",f[1][i]);printf("\n");
printf("第二条装配线时间:");
for(i=1;i<7;i++)
printf("%d ",f[2][i]);printf("\n");
printf("底盘装配路径:");
for(i=2;i<7;i++)
printf("%d ",l[1][i]);printf("\n");
printf("底盘装配路径:");
for(i=2;i<7;i++)
printf("%d ",l[2][i]);printf("\n");
printf("最终时间结果为%d:\n",f_outcome);
i=l_outcome;
printf("装配线为 %d,工序为 %d\n",i,6);
for(j=6;j>=2;j--)
{
i=l[i][j];
printf("装配线为 %d,工序为 %d\n",i,j-1);
}
}
int main()
{
dp();
outcome();
return 0;
}
自己写好了,写过一边就都懂了,不难的,其实我觉的这也算是贪心吧。
矩阵链乘法
定义
- 矩阵相乘:\(A_{m*n}*B_{n*s}=C_{m*s}\) 第一个矩阵的列数和第二个矩阵的行数相同时,两矩阵可以相乘,产生的新矩阵行列都和原来的矩阵不相同,新矩阵还要参与接下来的乘法。也就是说不同顺序的矩阵乘法它们的计算量是不同的,且差距巨大。
- 矩阵链乘法:给定一系列矩阵,目标是找到最有效的方法来乘以这些矩阵。 问题实际上不是执行乘法,而只是决定所涉及的矩阵乘法的顺序。找到最小的矩阵乘法消费,从而提高计算效率。
例如,如果我们有四个矩阵ABCD,我们计算找到每个(A)(BCD),(AB)(CD)和(ABC)(D)所需的成本,进行递归调用以找到最小成本计算ABC,AB,CD和BCD。然后我们选择最好的一个。
当我们计算AB的最佳成本的时候,其实也在计算ABC,ABCD的计算成本。我们将每次的计算保存下来,这样会极大的提高我们的运算效率。
最优括号化方案的结构特征
第一步是依旧是寻找最优子结构,然后就可以利用这种子结构从子问题的最优解构造出原问题的最优解。
改变矩阵运算顺序就是“加括号”,假设\(A(i)A(i+1)...A(j)\)的最优括号方案的分割点在A(k)和A(k+1)之间。那么,继续对“前缀”子链\(A(i)A(i+1)..A(k)\)接着找最优括号方案,不断递归直到不能加括号为止,后缀\(A(k)A(k+1)..A(j)\)一样,这样我们就找到最优运算顺序。 我们必须保证在确定分割点时,已经考察了所有可能的划分点,这样就可以保证不会遗漏最优解。
递归求解方案
对于 \(1 \leq i\leq j\leq n, A(i)A(i+1)...A(j)\) 我们用\(m[i,j]\)表示计算矩阵A(i..j)所需标量乘法次数的最小值,也就是所谓的代价。那么,原问题的最优解—计算A(1..n)所需的最低代价就是m[1,n]。
当只有一个矩阵的时候,不需要计算运算次数,即\(m[i,i]=0\)。当我们要求\(m[i,j]\)时候,我们将其分割,求其最优子结构来计算。我们假设A(i)A(i+1)...A(j)的最优括号化方案的分割点在矩阵A(k)和A(k+1)之间,其中\(i \leq k<j\)。那么,m[i,j]就等于计算A(i..k)和A(k+1..j)的代价加上两者相乘的代价的最小值。,设Ai的大小为p(i-1)*pi(就是p(i-1)行,pi列),则A(i..k)和A(k+1..j)相乘的代价为p(i-1)p(k)p(j)次标量乘法运算,我们可以发现
我们是不知道k的位置的,但是k只有j-i种可能,我们递归遍历一边就好了,递归求解公式如下
其中注意A(i)A(i+1)...A(j),我们认为其规模为链的长度j-i+1,我们再设一个辅助数组\(S(i,j)\)记录最优值\(m[i,j]\)对应的分割点k,用于输出最后的结果。我这边写一个长度为6的矩阵链乘法。
#include <stdio.h>
#include <cmath>
#include <algorithm>
using namespace std;
int inf=0x3f3f3f3f; //表示无穷大
int p[7]={30,35,15,5,10,20,25}; //6个矩阵的规模
int s[7][7]; //记录括号的位置,方便保留结果
int m[7][7]; //表示代价
//开始
void dp()
{
int l; //表示矩阵链的长度
int i; //表示矩阵链的起始位置
int j; //表示矩阵链的终止位置
int k; //表示分割点的位置
int q; //表示代价
for(l=2;l<7;l++)
{
for(i=1;i<7-l+1;i++)
{
m[i][i]=0;
j=l+i-1 ;// 其实就是 l=j-i+1
m[i][j]=inf;
for(k=i;k<j;k++)
{
q=m[i][k]+m[k+1][j]+p[i-1]*p[k]*p[j];
if(q<m[i][j])
{
m[i][j]=q;
s[i][j]=k; //保存分割点
}
}
}
}
}
// 递归找括号
void find(int i,int j)
{
if(i==j) printf("A%d",i);
else
{
printf("(");
find(i,s[i][j]);
find(s[i][j]+1,j);
printf(")");
}
}
//输出
void outcome()
{
printf("最小计算次数为:%d\n",m[1][6]);
printf("其中顺序为:");
find(1,6);
}
int main()
{
dp();
outcome();
return 0;
}
动态规划基础
书上没有介绍题目,我感觉就看看书理解一下概念就好了,如果后期有重点的话,我再好端端整理下。
最优子结构
如果问题的一个最优解中包含了子问题的最优解,则该问题具有最优子结构。当一个问题具有最优子结构时,动态规划就可能适用(贪心算法可能也适用)。
寻找最优子结构的时候,可遵循一种共同的模式
- 问题的一个解可以是做一种选择。例如,选择一个前一个装配线装配站(有两个选择);或者选择一个下标以在该位置分裂矩阵链(有j-i个选择)。
- 对一个给定的问题,假设已知的是一个导致最优解的选择。不必关心如何确定这个选择。
- 在已知这个选择后,要确定哪些子问题会随之发生,以及如何最好的描述所得到的的子问题控件。
- 利用剪贴技术,来证明在一个问题的最优解中,使用的子问题的解本身也是最优的。
简单来说就是通过求解子结构的最优解递推到全局的解
重叠子问题
问题与子问题之间,子问题与子自问题之间等,它们之间共享资源,即它们包含了公共的解,通过从下到上解保证这些公共解只解一次,用到时查看表中已有的解即可。动态规划算法总是充分利用重叠子问题,即通过每个子问题只解一次,把解保存在一个在需要时就可以查看的表中,而每次查表的时间为常数。动态规划要求子问题既要独立又要重叠。如果同一个问题的两个子问题不共享资源,则他们就是独立的。
简单来说就是有一些问题的子问题有相同的公共解,那我们先保存下来,这样下一次遇到就不用算,提高效率
备忘录方法
采用的也是自顶向下的思想,但是该方法维护了一个记录子问题解的表,虽然填表动作的控制结构更像递归方法,但是的确避免了子问题的重复求解。
简单来说就是记录重复运算的值,下一次可以直接调用
最长公共子序列
最长公共子序列LCS(Longest Common Subsequence)是一个在一个序列集合中(通常为两个序列)用来查找所有序列中最长子序列的问题。
比如说有两个序列 1,3,5,4,2,6,8,7和 1,4,8,6,7,5 其中序列1,8,7是它们的一个长度为3公共子序列
通俗的总结一下最长公共子序列(LCS):就是A和B的公共子序列中长度最长的(包含元素最多的)
比如上面两个序列,它们的最长公共子序列有1,4,8,7和1,4,6,7两种,但最长公共子序列的长度是4。由此可见,最长公共子序列(LCS)也不一定唯一。虽然LCS不一定唯一,但LCS的长度是一定的
求解方法
对于序列\(X= \left\{ x_1,x_2,...x_m \right\}\)和\(Y=\left\{y_1,y_2,...y_n\right\}\), 有子序列\(X_i\)和\(Y_j\) ,其中\(i\)代表序列X的\(i\)个元素的子序列,Y同理,定义\(C[i,j]\)为序列\(X_i\)和\(Y_j\)的一个LCS长度。当\(i=0\)或者\(j=0\),其中一个的序列长度为0,所以此时的LCS为0。
求\(C[i,j]\)时,当\(x_i=y_j\)时,这时候序列中的两个元素相同,我们只要考虑子问题\(X_{i-1}\)和\(Y_{j-1}\)的LCS的值,求出子问题的值加一就是\(C[i,j]\)的值,即\(C[i,j]=C[i-1,j-1]+1\)
当\(x_i \not= y_j\)时,就说明此时元素不相同,那我们要去找\(X_i\)和\(Y_j\)的子序列中最大LCS的值代替\(C[i,j]\),即\(C[i,j]=max(C[i,j-1],C[i-1,j])\),总结就是
#include <stdio.h>
#include <cmath>
#include <algorithm>
using namespace std;
char x[8]="ABCBDAB", y[7]="BDCABA"; //两个序列
int c[8][7]={0};
//开始
void dp()
{
int i,j;
for(i=1;i<8;i++)
{
for(j=1;j<7;j++)
{
if(x[i-1]==y[j-1]) c[i][j]=c[i-1][j-1]+1;
else c[i][j]=max(c[i-1][j],c[i][j-1]);
}
}
}
/*
void findlcs() //这个函数是求最长公共序列的
{
int i, j, z = 0;
char ans[8];
i = 7, j = 6;
while(i!=0 && j!=0)
{
if(x[i-1] == y[j-1])
{
ans[z++] = x[--i];
j--;
}
else if(c[i-1][j] < c[i][j-1])
j--;
else if(c[i][j-1] <= c[i-1][j])
i--;
}
printf("最长公共序列为:");
for(i=z-1; i>=0; i--)
printf("%c", ans[i]);
printf("\n");
}*/
//输出
void outcome()
{
printf("最长公共子序列为:%d\n",c[7][6]);
//findlcs();
}
int main()
{
dp();
outcome();
return 0;
}
最优二叉查找树
最优二叉查找树:
给定n个互异的关键字组成的序列K=< k1,k2,...,kn>,且关键字有序(k1< k2<... < kn),我们想从这些关键字中构造一棵二叉查找树。对每个关键字ki,一次搜索搜索到的概率为pi。可能有一些搜索的值不在K内,因此还有n+1个“虚拟键”d0,d1,...,dn,他们代表不在K内的值。具体:d0代表所有小于k1的值,dn代表所有大于kn的值。而对于i=1,2,...,n-1,虚拟键di代表所有位于ki和ki+1之间的值。对于每个虚拟键,一次搜索对应于di的概率为qi。要使得查找一个节点的期望代价(代价可以定义为:比如从根节点到目标节点的路径上节点数目)最小,就需要建立一棵最优二叉查找树。
树的结构如图
首先我们要明白,这棵树的关键字概率和虚拟键概率总和为1,就是所有的值去查找都有结果。其次当一棵树所有的深度加1,则其搜索代价加1,理由如下:
当上述E[T的搜索代价]最小时,则称这棵树为最优二叉查找树。
最优二叉查找树的结构
不用说,我们也知道一棵最优二叉查找树的子树必定也是最优二叉查找树,按照这个思路,对于给定的 \(k_i,k_{i+1},...,k_j\),我们假设 \(k_r\) 为最优二叉查找树的根(不要想当然,\(k_r\) 不一定是查找频率最高的),其左子树 \(k_i,k_{i+1},...,k_{r-1}\)和虚拟键\(d_{i-1},...d_{r-1}\),右子树则为\(k_{r},...,k_{j}\)和虚拟键\(d_r,d_{r+1},...d_{j}\),我们找到这样的\(k_r\)然后不断递归求解,最后就能求出最优二叉查找树。
其中,需要注意的是对于\(k_i,k_{i+1},...,k_j\),我们选择\(k_i\)为根,看起来它的左子树为空,实践上还是有虚拟键\(d_{i-1}\)。
递归求解方法
我们用\(e[i,j]\)来表示关键字\(k_i,k_{i+1},...,k_j\)最优二叉搜索树的期望搜索代价,则原问题就是求\(e[1,n]\)。
对于\(1\leq i,j \leq n\),其中\(j\geq i-1\),(当\(j=i-1\)时 ,子树只包含伪关键字\(d_{i-1}\),其代价就是\(q_{i-1}\))
当\(j\geq i\)时,就要找到\(k_r\),将\(k_i,k_{i+1},...,k_j\),分割为左子树和右子树,其左右子树的深度全部加1.我们定义\(w(i,j)=\sum_{l=i}^j p_l+\sum_{l=i-1}^j q_l\),那么
我们重新定义
则我们可以得到转移方程为
代码如下
#include <stdio.h>
#include <cmath>
#include <algorithm>
using namespace std;
double inf=0x3f3f3f3f;//无穷大
double p[6]={-1,0.15,0.1,0.05,0.10,0.20}; //关键字概率
double q[6]={0.05,0.10,0.05,0.05,0.05,0.10}; //虚拟键概率
double e[7][7]; //期望代价
double w[7][7];//概率总和
int root[6][6];//记录根节点
//开始
void dp()
{
int i,j,l,r;// i是起始位置,j是终止位置,l为长度 ,r为子树根的下标
//初始化只包括虚拟键的子树
for(i=1;i<7;i++)
{
w[i][i-1]=q[i-1];
e[i][i-1]=q[i-1];
}
for(l=1;l<6;l++)
{
for(i=1;i<=5-l+1;i++)
{
j=i+l-1;
e[i][j]=inf;
w[i][j]=w[i][j-1]+p[j]+q[j];
for(r=i;r<=j;r++)
{
double temp=e[i][r-1]+e[r+1][j]+w[i][j];
if(temp<e[i][j])
{
e[i][j]=temp;
root[i][j]=r;
}
}
}
}
}
void printfroot(int i,int j,int r)
{
if(j<i-1) return ;
if(j==i-1) //遇到虚拟键
{
if (j < r) printf("d%d是k%d的左孩子\n",j,r);
else printf("d%d是k%d的右孩子\n",j,r);
return ;
}
else //是内部节点
{
if(root[i][j]<r) printf("k%d是k%d的左孩子\n",root[i][j],r);
if(root[i][j]>r) printf("k%d是k%d的右孩子\n",root[i][j],r);
}
printfroot(i,root[i][j] - 1,root[i][j]);
printfroot(root[i][j] + 1,j,root[i][j]);
}
//输出
void outcome()
{
int i,j;
printf("最低期望为:%.2lf \n",e[1][5]);
printf("k%d是根\n",root[1][5]);
printfroot(1,5,root[1][5]);
}
int main()
{
dp();
outcome();
return 0;
}
背包问题
背包问题也算是动态规划的基础问题了,网上讲的好太多了,我这边放个 传送门,你们也可以根据自己需要百度百度,我觉的考试主要是考前二讲,分别01背包和完全背包问题,有时间有能力的背包九讲可以都看看。
01背包
for (int i = 1; i <= n; i++)
for (int j = V; j >= w[i]; j--)
f[j] = max(f[j], f[j - w[i]] + v[i]);
完全背包
for (int i = 1; i <= n; i++)
for (int j = w[i]; j <= V; j++)
f[j] = max(f[j], f[j - w[i]] + v[i]);
贪心算法
贪心算法(又称贪婪算法)是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,算法得到的是在某种意义上的局部最优解。
一个问题的整体最优解可通过一系列局部的最优解的选择达到,并且每次的选择可以依赖以前作出的选择,但不依赖于后面要作出的选择。这样的问题我们就可以使用贪心算法
活动选择问题
存在几个资源相互竞争的活动,它们都以独占的方式占用莫一公共资源,有开始时间和结束时间,要求你在规定时间内举行最多的活动。(比如学校里面不同时间开会,但教室不够用等等)
我们给出开始时间和结束时间,我们按照结束时间从小到大排序,让一个活动尽快结束为其他活动腾出时间(严谨的逻辑证明请看书)
#include <stdio.h>
#include <cmath>
#include <algorithm>
using namespace std;
// 开始时间和结束时间
int s[12]={0,1,3,0,5,3,5,6,8,8,2,12};
int f[12]={0,4,5,6,7,8,9,10,11,12,13,14};
// 排序自己写,sort cmp
int main()
{
int i,j,ans=0,time=0;
for(i=1;i<13;i++)
{
if(s[i]>=time)
{
ans++;
time=f[i];
// 这边可以记录时哪几种活动
}
}
printf("最多的活动数量为:%d\n",ans);
return 0;
}
平摊分析
背景
之前的简单分析(时间复杂度)可能会对算法复杂度的上界作出过大的估计。
例如,有一个空栈,连续对它进行n次操作(push,pop,连续pop),那么它的时间复杂度的上界是多少?简单的分析可以给出 \(O(n^2)\) 的上界,理由如下:
最坏情况下,每次操作取三种操作中上界最大者连续pop的上界 O(n) ,那么n次操作的上界显然搜索 \(O(n^2)\)。
分析过程没有错,但是上界还可以进一步收缩。因为我们知道在一个栈中,pop的次数取决于栈中的元素个数,一个栈是不能一直pop下去的,且若栈空间大小为n,那么pop的次数是不能超过插入的次数的,总时间约为插入次数n的两倍,,这说明时间复杂度上界是 O(n)。由此,引出了平摊分析(Amortized Analysis)的概念。
平摊分析
平摊分析是一种分析方法,其核心思想是:在进行一系列操作时,各个操作并非孤立,而是相关的。 其目的是得出每个操作的平摊代价,平摊代价一般是实际代价的上界。
我们需要学习三种方法:聚合分析(aggregate analysis),记账法(核算法,accounting method),势能法(potential method)。
其中聚合分析为基础,记账法和势能法是从不同角度出发的聚合分析。
聚合分析
简而言之,就是对数据结构依次进行n个操作\(a_1,a_2,...a_n\),则在最坏情况下,这n个操作序列是时间复杂度为T(n),那么每个操作的平摊代价\(T(n)/n\)。因此聚合分析的核心再去求出\(T(n)\),然后就可以求出每个操作的平摊代价。
以栈操作为例,n个操作序列中,
所以时间上界是 \(T(n)=2n\),每个操作的平摊代价为\(O(1)\)。
记账法
记账法(核算法)的核心思想是:选出一种(多种)易于统计次数的操作来,由它们来承担全部的代价
在栈操作的例子中,插入的次数是任意直到的,以插入为代表操作,由它来支付自己以及其他操作的费用。因为一个元素只能被插入并弹出一次,所以设插入的平摊代价为2,pop和multipop的操作代价为0,那么
所以每个操作的平摊代价为\(O(1)\)
二进制计数器中的操作:\(T(n)=3n\)
势能法
有时候记账法中某个操作的平摊时间比较难估计,所以引入了物理学中的势能这个概念。在平摊分析中的势能法的核心思想是:在一系列操作中,一些操作为整个数据结构积累势能,另一些操作消耗势能。