数据结构与算法(AaDS)复习知识点
复习重点:
一、简答题(5×8')。
-
\(O(f(n))\)、(\(\Omega f(n))\)、\(\Theta (f(n))\)的定义及时间、空间复杂度的计算(重点是时间复杂度)
定义:
计算:
-
递归算法的设计步骤
-
用回溯法搜索子集树的一般模式,用回溯法遍历排列树的一般模式。
-
最优子结构性质
-
分支限界法的设计思想
二、计算题(2×5')。
给出两个代码块,要求计算时间复杂度。
不考\(O(1)\)、\(O(n)\)、三层及以上的复杂循环。
三、设计题(5×10')。
描述出思路、将解题过程中涉及的图、树画出、将过程表达清楚即可。
另外,需要计算算法的复杂度。
-
给出一个连通图,求最小生成树。
该算法中,一般从0号节点开始生成。
算法可二选一:、克鲁斯卡尔算法
-
单源最短路径。给一个有向图、给一个起点,求出从该起点到其他各顶点的最短路径。
注意,该算法中的起点不一定是0号点,而是由用户指定。如果发现到某个点不可达,那就是不可达,不是计算错误。
算法可二选一:迪杰斯特拉算法、分支限界法、优先级队列的分支限界法。
-
霍夫曼树(编码问题):给出一个报文系统,根据字符出现的概率,设计霍夫曼编码,画出霍夫曼树。
-
最小堆:给定一个数组,将其调整成最小堆。
数组对应一个完全二叉树,那么从最后一个分支开始,逐步调整即可。
-
经典算法:二分查找法,设计查找一个有向数组的方法。
分治法。
-
经典算法:快速排序,将每次partition的过程描述出来。
分治法。
注意,该算法的复杂度是统计出来的。
-
经典算法:求数组中第k小的元素。
与快速排序较相似。
-
经典算法:回溯法求解0-1背包问题。
-
经典算法:分支界定法求解老鼠迷宫问题。
-
经典算法:动态规划,求三角形从顶向下的路径、最长递增子序列问题。
-
经典算法:贪心算法求解活动安排问题。
算法复杂度及其计算
算法分析是分析算法占用计算机资源的情况。所以算法分析的两个主要方面是分析算法的时间复杂度和空间复杂度。
时间复杂度
概念及计算步骤
一个算法是由控制结构(顺序、分支和循环3种)和原操作(指固有数据类型的操作)构成的,算法的运行时间取决于两者的综合效果。
渐进符号(\(O\)、\(\Omega\)、\(\Theta\))
大\(O\)符号
定义1(大\(O\)符号),\(f(n)=O(g(n))\)(读作“\(f(n)\)是\(g(n)\)的大\(O\)”)当且仅当存在正常量\(c\)和\(n_0\),使当\(n≥n_0\)时,\(f(n)≤cg(n)\),即\(g(n)\)为\(f(n)\)的上界。
大\(O\)符号用来描述增长率的上界,表示\(f(n)\)的增长最多像\(g(n)\)增长的那样快,也就是说,当输入规模为\(n\)时,算法消耗时间的最大值。
一个算法的时间用大\(O\)符号表示时,总是采用最有价值的\(g(n)\)表示,称之为“紧凑上界”或“紧确上界”。
一般地,如果\(f(n)=a_mn^m+a_{m-1}n^{m-1}+…+a_1n+a_0\),有\(f(n)=O(n^m)\)。
大\(\Omega\)符号
定义2(大\(\Omega\)符号),\(f(n)=\Omega(g(n))\)(读作“\(f(n)\)是\(g(n)\)的大\(\Omega\)”)当且仅当存在正常量\(c\)和\(n_0\),使当\(n≥n_0\)时,\(f(n)≥cg(n)\),即\(g(n)\)为\(f(n)\)的下界。
大\(\Omega\)符号用来描述增长率的下界,表示\(f(n)\)的增长最少像\(g(n)\)增长的那样快,也就是说,当输入规模为\(n\)时,算法消耗时间的最小值。
一个算法的时间用大\(\Omega\)符号表示时,总是采用最有价值的\(g(n)\)表示,称之为“紧凑下界”或“紧确下界”。
一般地,如果\(f(n)=a_mn^m+a_{m-1}n^{m-1}+…+a_1n+a_0\),有\(f(n)=\Omega(n^m)\)。
大\(\Theta\)符号
定义3(大\(\Theta\)符号),\(f(n)=\Theta(g(n))\)(读作“\(f(n)\)是\(g(n)\)的大\(\Theta\)”)当且仅当存在正常量\(c_1\)、\(c_2\)和\(n_0\),使当\(n≥n_0\)时,有\(c_1g(n)≤f(n)≤c_2g(n)\),即\(g(n)\)与\(f(n)\)同阶。
大\(\Theta\)符号比大\(O\)符号和大\(\Omega\)符号都精确,\(f(n)=\Theta(g(n))\),当且仅当\(g(n)\)既是\(f(n)\)的上界又是\(f(n)\)的下界。
计算性质
简化的规则如下:
-
\(f(n)=O(g(n))\),\(g(n)=O(h(n))\),则:
\(f(n)=O(h(n))\);
-
\(f(n)=O(kg(n))\),\(k>0\),则:
\(f(n)=O(g(n))\);
-
两段代码顺序执行:\(f_1(n)=O(g_1(n))\),\(f_2(n)=O(g_2(n))\),则:
\((f_1+f_2)(n)=O(max(g_1(n),g_2(n)))\);
-
两段代码嵌套:\(f_1(n)=O(g_1(n))\),\(f_2(n)=O(g_2(n))\),则:
\(f_1(n)f_2(n)=O(g_1(n)g_2(n))\)。
最好、最坏和平均情况
例题
非递归例题
递归例题
递归的主方法及例题
主方法(master method)提供了解如下形式递归方程的一般方法:
其中\(a≥1\),\(b>1\)为常数,该方程描述了算法的执行时间,算法将规模为\(n\)的问题分解成\(a\)个子问题,每个子问题的大小为\(n/b\)。
空间复杂度
一个算法的存储量包括形参所占空间和临时变量所占空间。在对算法进行存储空间分析时,只考察临时变量所占空间。
递归算法的设计步骤
递归算法设计先要给出递归模型,再转换成对应的C/C++语言函数,获取递归模型的步骤如下:
- 对原问题\(f(s_n)\)进行分析,抽象出合理的“小问题”\(f(s_{n-1})\)(与数学归纳法中假设\(n=k-1\)时等式成立相似);
- 假设\(f(s_{n-1})\)是可解的,在此基础上确定\(f(s_n)\)的解,即给出\(f(s_n)\)与\(f(s_{n-1})\)之间的关系(与数学归纳法中求证\(n=k\)时等式成立的过程相似);
- 确定一个特定情况(如\(f(1)\)或\(f(0)\))的解,由此作为递归出口(与数学归纳法中求证\(n=1\)或\(n=0\)时等式成立相似)。
回溯法搜索子集树、排列树
回溯法的一般结构:
void backtrack (int t)
{
if (t>n) output(x);
else
for (int i=f(n,t);i<=g(n,t);i++) {
x[t]=h(i);
if (constraint(t)&&bound(t))
backtrack(t+1);
}
}
回溯法搜索子集树
用回溯算法搜索子集树的一般模式:
从根节点开始搜索,终结在叶子节点。到达叶子节点,则输出。否则分为选择这个或不要这个,然后进行下一步搜索。
最典型:0-1背包问题
void search(int m)
{
if(m>=n) //递归结束条件
output(); //相应的处理(输出结果)
else
{
a[m]=0; //设置状态:0表示不要该物品
search(m+1); //递归搜索:继续确定下一个物品
a[m]=1; //设置状态:1表示要该物品
search(m+1); //递归搜索:继续确定下一个物品
}
}
}
回溯法遍历排列树
回溯法遍历排列树:
所有的叶子节点代表一种排列。到叶子节点,则输出一种排列。否则,n个节点中有1个节点轮番作为固定的头部,对n-1个节点进行全排。对n-1时,轮番有1个节点作为固定的头部,对n-2个节点进行全排……直到只有一个元素,相当于自身的全排列。
swap()
函数用于将某个节点交换到头部。注意:每次结束后,还必须换回。
legal()
函数用于剪枝,将不满足要求的直接淘汰。
void backtrack (int t)
{
if (t>n) output(x);
else
for (int i=t;i<=n;i++) {
swap(x[t], x[i]);
if (legal(t)) backtrack(t+1);
swap(x[t], x[i]);
}
}
最优性原理与最优子结构
最优性原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优性原理。
分支限界法的设计思想
与回溯法的区别
- 求解目标:回溯法的求解目标是找出解空间树中满足约束条件的所有解,而分支限界法的求解目标则是找出满足约束条件的一个解,或是在满足约束条件的解中找出在某种意义下的最优解。
- 搜索方式的不同:回溯法以深度优先的方式搜索解空间树,而分支限界法则以广度优先或以最小耗费优先的方式搜索解空间树。
分支限界法基本思想
分支限界法常以广度优先或以最小耗费(最大效益)优先的方式搜索问题的解空间树。【将现实问题抽象成解空间树,随后按BFS的方法遍历】
在分支限界法中,每一个活结点只有一次机会成为扩展结点。活结点一旦成为扩展结点,就一次性产生其所有儿子结点。在这些儿子结点中,导致不可行解或导致非最优解的儿子结点被舍弃,其余儿子结点被加入活结点表中。
此后,从活结点表中取下一结点成为当前扩展结点,并重复上述结点扩展过程。这个过程一直持续到找到所需的解或活结点表为空时为止。
给出连通图,求最小生成树
在对无向图进行遍历时,对于连通图,从图中任一顶点出发,进行深度优先搜索或广度优先搜索,便可访问到图中所有顶点。
在我们对连通图进行搜索时,搜索过程中经历的所有边和图中所有的顶点构成了连通图的一个极小连通子图,即连通图的生成树,称由深度优先搜索得到的生成树为深度优先生成树,而由广度优先搜索得到的生成树称广度优先生成树。
具有n个顶点的图的生成树的数目是很多的, 我们的目标是要选择一棵具有最小代价的生成树(Minimum Cost Spanning Tree)(简称最小代价生成树)。一棵生成树的代价就是该树上所有边的权值之和。
最小生成树普利姆算法
题目描述: 给定一个无向连通图,该图包含 n 个节点和 m 条边,每条边都有一个权重。请使用普利姆算法找到该图的最小生成树(MST)。最小生成树是一个包含所有节点且边权和最小的树。
算法思想:从\(U=\{u1\} (u1 \in V)\), \(TE=\{ \}\)开始,重复执行如下操作:在所有\(u \in U\),\(v \in V-U\)的边\((u,v)\in E\)找一条代价最小的边\((u_1, v_1)\)并入集合\(TE\),同时\(v_1\)并入\(U\),直到\(U=V\)为止。此时\(TE\)中必有\(n-1\)条边,\(T=(V, \{TE\})\)即为\(N\)的最小生成树。
示例:
算法描述:
- 初始化:从任意一个节点(通常选择节点0)开始,将其标记为已访问,并将该节点与其他未访问节点之间的边加入最小堆(优先队列)。
- 选择最小边:从最小堆中取出权重最小的边,如果该边连接的两个节点之一不在最小生成树中,则将其加入生成树,并将该边的另一个节点标记为已访问。
- 更新节点:将新加入生成树的节点与其他未访问节点之间的边加入最小堆。
- 重复步骤2和步骤3:直到所有节点都已访问,生成树包含 n−1 条边。
- 输出:生成树的总权重。
算法复杂度:
【课本】迭代次数\(O(n)\),每次迭代将\(2e/n\)条边插入到最小堆中,\(e\)条边从堆中删除,堆插入和删除的时间复杂度为\(O(log_2e)\),故总的时间复杂度为\(O(elog_2e)\)。
最小生成树克鲁斯卡尔算法
题目描述: 给定一个无向连通图,使用克鲁斯卡尔算法找到该图的最小生成树。克鲁斯卡尔算法通过选择权重最小的边构建生成树,并确保不会形成环,直到所有节点都被连接。
算法思想:设有一个有 \(n\) 个顶点的连通网络 \(N = \{ V, E \}\), 最初先构造一个只有 \(n\) 个顶点, 没有边的非连通图 \(T = \{ V, \empty \}\), 图中每个顶点自成一个连通分量。当在 \(E\) 中选到一条具有最小权值的边时, 若该边的两个顶点落在不同的连通分量上,则将此边加入到 \(T\) 中; 否则将此边舍去,重新选择一条权值最小的边。如此重复下去, 直到所有顶点在同一个连通分量上为止。
算法实现:
算法的框架利用最小堆(MinHeap)和并查集(DisjointSets)来实现克鲁斯卡尔算法。
-
首先, 利用最小堆来存放E中的所有的边, 堆中每个结点的格式为
-
从最小堆中取出权值最小的边,利用并查集的运算检查依附一条边的两顶点\(tail\)、\(head\) 是否在同一连通分量 (即并查集的同一个子集合) 上, 是则舍去这条边;否则将此边加入 \(T\), 同时将这两个顶点放在同一个连通分量上;
-
随着各边逐步加入到最小生成树的边集合中, 各连通分量也在逐步合并, 直到形成一个连通分量为止。
最小堆:
最小堆的下标计算:
将一组用数组存放的任意数据调整成最小堆:
siftDown(int start)
完全二叉树只有数组下标小于或等于 \((data.length) / 2 - 1\) 的元素有孩子节点,从下到上遍历这些结点;
for (int i = (data.length) / 2 - 1; i >= 0; i--)
获取左右孩子节点中最小的值,与跟节点相比较,如果都小于根节点,不做处理;反之,交换最小值与根节点的值,最小节点的下标为
smallest
;如果第2步做了交换,则需要对以新交换下来的值为根节点的子树再运行一次调整算法(递归调用该算法)。
siftDown(int smallest)
调整示例:
并查集:并查集是一种树型的数据结构,用于处理一些不相交集合的合并及查询问题。
并查集的思想是用一个数组表示了整片森林(parent),树的根节点唯一标识了一个集合,我们只要找到了某个元素的的树根,就能确定它在哪个集合里。
并查集的主要操作:
Union (Root1, Root2)
//并操作;要求两个集合互不相交为避免产生退化的树,改进方法是先判断两集合中元素的个数,如果以$ i$ 为根的树中的结点个数少于以$ j \(为根的树中的结点个数,即\)parent[i] < parent[j]\(,则让\)j \(成为\)i \(的双亲,否则,让\)i\(成为\)j$的双亲。
Find (x)
//搜索操作;逐级查到根节点,看根节点指向的集合名是否相同,即可判断是否在一个并查集中。
UFSets (s)
//构造函数。并查集示例:
算法描述:
- 边排序:将图中的所有边按权重从小到大排序(最小堆(MinHeap))。
- 初始化集合:将每个节点初始化为一个独立的集合。
- 选择边:从权重最小的边开始,检查边的两个端点是否属于不同的集合。
- 若是,将这条边加入生成树,并将两个端点的集合合并。
- 若否,忽略这条边,避免形成环。
- 重复步骤3:直到生成树包含 n−1 条边。
- 输出:生成树的总权重。
示例过程:
算法复杂度:
【PPT】
- 建立最小堆检测邻接矩阵\(O(n^2)\)
- \(e\)次堆插入操作\(O(elog_2e)\)
- \(e\)次出堆操作\(O(elog_2e)\)
- \(2e\)次并查集find操作\(O(elog_2n)\)
- \(n-1\)次union操作\(O(n)\)
- 复杂度为\(O(elog_2e+elog_2n+n^2+n)\)
【课本】
- 对连通图来说,复杂度为\(O(elog_2e+n^2)\),如果使用邻接表而不是邻接矩阵建立最小堆,则复杂度为\(O(elog_2e+n)\)
有向图单源最短路径
迪杰斯特拉算法求有向图单源最短路径
题目描述: 给定一个有向图,使用迪杰克斯拉算法计算从起始节点到所有其他节点的最短路径。图中的所有边权均为非负。
算法描述:
-
初始化:将起始节点的距离设为0,其他所有节点的距离设为无穷大(\(\infty\))。
-
选择未访问节点:从所有未访问节点中选择当前距离最小的节点,将其标记为已访问。
-
更新邻居节点距离:对于该节点的每个在未访问节点中的邻居节点,如果通过当前节点到达邻居节点的路径更短,则更新邻居节点的距离。
关于为什么不需要更新已在最短路径节点列表中的节点,有以下简单的证明:
假设:找到的最短路径顺序是a-->b-->c-->d,但找到d后,却使得经过d到c的距离,比原先查找的到c的最短路径还短(\(d1+d3+d4<d1+d2\)),这可能吗?
证明:等于是证明\(d3+d4<d2\),从三角形两边之和大于第三边可知,这不可能。
如果直接证明:如果\(d3+d4<d2\),那么\(d3<d2\)必然成立,则在查找到b后,加入最短节点的就是d,而不是c。显然,出现了矛盾,原结论不成立。
-
重复步骤2和步骤3:直到所有节点都被访问。
-
输出:从起始节点到所有其他节点的最短路径距离。
算法中,下一条最短路径必然经过已在最短路径节点列表中的节点,为什么?可以证明如下:
证明:下一条最短路径(设终点为\(x\))或者是弧\((v, x)\),或者是中间只经过S中的顶点而最后到达顶点\(x\)的路径。假设此路径上有一个顶点不在\(S\)中,则说明存在一条终点不在\(S\)而长度比从\(v\)到\(x\)的路径长度更短的路径。但这是不可能的,因为我们是按路径长度递增的次序来产生各最短路径的。故长度比此路径短的所有路径均已产生,它们的终点必定在\(S\)中。
图示证明:
从图中阐述,即欲证明:\(d[v]\ge [y]+dis\_y\_v\),而由于每次从剩余集合中找距离最短的点,即满足\(d[v]\le d[y]\),由此得出\(d[v]< d[y]+dis\_y\_v\),出现矛盾,假设不成立,证毕。
示例1:
示例2:
-
以A点为起始点,求A点到其他点
B C D E F
5个点的最短路径,最后得出A到其他点的最短路径。首先计算A直接到达各点的距离,并将A加入已访问列表。
A B C D E F 0 10 无穷大 4 无穷大 无穷大 -
从剩下的点中选择最短的一个是D。将D加入已访问列表,以
A-D
的距离为最近距离【简单讲,就是通过到该点(D)的最短路径去访问其他节点】,更新A点到所有点的距离。即相当于A点经过D点,计算A到其他点的距离。【按理说,此时不需要再考虑已在最短节点列表中的节点】
A B C D E F 0 6 19 4 10 无穷大 -
下一步,将上表与之前的最短距离相比较,到相同点取最小值。更新
B C E
的距离,得到如下新的最短距离数组:A B C D E F 0 6 19 4 10 无穷大 -
重复2、3,直到所有节点都已访问:
访问B并更新,路线A-D-B:
A B C D E F 0 6 14 4 10 无穷大 访问E并更新,路线A-D-E:
A B C D E F 0 6 11 4 10 22 访问C并更新,路线A-D-E-C:
A B C D E F 0 6 11 4 10 16 访问F并更新,路线A-D-E-C-F:
A B C D E F 0 6 11 4 10 16 -
此时,可以输出结果:
A B C D E F 0 6 11 4 10 16
算法复杂度:
- 辅助数组的初始化\(O(n)\)
- 最短路径的求解,二层
for
嵌套(第一层遍历节点,第二层求解距离并判断更新)\(O(n^2)\)
故:总的复杂度为\(O(n^2)\)。
分支限界法求有向图单源最短路径
算法思想:分支界定法的思想在于,每一个活结点只有一次机会成为扩展结点。活结点一旦成为扩展结点,就一次性产生其所有儿子结点。在这些儿子结点中,导致不可行解或导致非最优解的儿子结点被舍弃,其余儿子结点被加入活结点表中。
因此,在本例中,从根节点开始,使用广度优先的方法,一次性产生所有儿子节点,但剪枝的原则是,仅当通过此时的扩展节点(父节点)到儿子节点的距离小于目前到儿子节点的最短距离时,才将其加入队列。
随后,按照FIFO的顺序,依次使其出队,直到队列为空。
示例:
采用优先队列式分枝限界法:按结点的length成员值越小越优先出队。
示例:
报文系统的霍夫曼编码
树的路径长度
示例:
性质:
带权路径长度:
哈夫曼树及其构造算法
- 带权路径长度达到最小的扩充二叉树即为Huffman树。
- 在Huffman树中,权值大的结点离根最近。
示例:
哈夫曼编码
必须注意:可不一定都长成右侧重的这个样子!这取决于合并时的根节点的值。看例题:
【例题】
算法复杂度:
构建哈夫曼树的算法有一个双重for
循环的嵌套,根据循环次数,为\(O(nlog_2n)\)。
将数组调整成最小堆
关键码(key):假定在各个数据记录(或元素)中存在一个能够标识数据记录(或元素)的记录项,并将依据该数据项对数据进行组织,则称此数据项为关键码。
最小堆:如果有一个关键码的集合\(K=\{k_0,k_1,\cdots,k_{n-1}\}\),把它所有元素按照完全二叉树的顺序存储方式存放在一个一维数组中,且满足\(k_i\le k_{2i+1}\)且\(k_i\le k_{2i+2}\),则称其为最小堆。
最小堆的下标计算:
将一组用数组存放的任意数据调整成最小堆:siftDown(int start)
-
完全二叉树只有数组下标小于或等于 \((data.length) / 2 - 1\) 的元素有孩子节点,从下到上遍历这些结点;
for (int i = (data.length) / 2 - 1; i >= 0; i--)
-
获取左右孩子节点中最小的值,与跟节点相比较,如果都小于根节点,不做处理;反之,交换最小值与根节点的值,最小节点的下标为
smallest
; -
如果第2步做了交换,则需要对以新交换下来的值为根节点的子树再运行一次调整算法(递归调用该算法)。
siftDown(int smallest)
调整示例:
算法复杂度:
-
siftdown
的复杂度是\(O(log_2n)\) -
【课本】把所有分支都要调整,需要 n/2次, 所以算法的复杂度是\(O(nlog_2n)\)。 -
但是!!!
目前Prof认可这个说法,以\(O(n)\)为准。
复杂度:\(\sum_{i=1}^{log_n}(i-1)\frac{n}{2^i}=O(n)\)。
二分查找法
分治法
对于一个规模为n 的问题:若该问题可以容易地解决(比如说规模n 较小),则直接解决;否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。
这种算法设计策略叫做分治法。
二分法
人们从大量实践中发现,在用分治法设计算法时,最好使子问题的规模大致相同。换句话说,将一个问题分成大小相等的k个子问题的处理方法是行之有效的。
二分查找法
二分查找是针对有序数组的快速查找算法。
算法思路与算法设计:
算法实现:
算法复杂度:
由此得到:设\(n=2^k\),
\[C(n)&\le& C(\lfloor n/2\rfloor)+1\\ &\le& C(\lfloor n/4\rfloor)+2\\ &\le& C(\lfloor n/2^k\rfloor)+k\\ &\le& k+1 \]则\(C(n)\le \lfloor log_2n \rfloor+1\)。
也可以直接利用递推公式,用主方法得到此结果。
快速排序法
快速排序也是利用的分治法思想。
基本思想:在待排序的n个元素中任取一个元素(通常取第一个元素)作为基准,把该元素放入最终位置后,整个数据序列被基准分割成两个子序列,所有小于基准的元素放置在前子序列中,所有大于基准的元素放置在后子序列中,并把基准排在这两个子序列的中间,这个过程称作划分。然后对两个子序列分别重复上述过程,直至每个子序列内只有一个记录或空为止。
算法设计:
核心算法:
注意:划分算法中,来回切换两个指针从右往左、从左往右扫描的目的,是为了始终有一个指针指向划分基准tmp
,而另一个去扫描剩余元素。两个指针相遇,代表已扫描完。
示例:
算法复杂度:
方法一:每次递归时需要进行划分操作,其复杂度为\(O(Partition(n))=O(n)\),递归公式:\(T(n)=2T(n/2)+Partition(n)\),运用主方法,复杂度为\(O(nlog_2n)\)。
方法二:分析法。
求数组第k小元素
类似于快速排序的思想。
问题描述:
问题思路:
算法实现:
int QuickSelect(int a[ ],int s,int t,int k)
//在a[s..t]序列中找第k小的元素
{ int i=s,j=t,tmp;
if (s<t)
{ tmp=a[s];
while (i!=j) //从区间两端交替向中间扫描,直至i=j为止
{ while (j>i && a[j]>=tmp) j--;
a[i]=a[j]; //将a[j]前移到a[i]的位置
while (i<j && a[i]<=tmp) i++;
a[j]=a[i]; //将a[i]后移到a[j]的位置
}
a[i]=tmp;
if (k-1==i) return a[i];
else if (k-1<i) return QuickSelect(a,s,i-1,k);
//在左区间中递归查找
else return QuickSelect(a,i+1,t,k);
//在右区间中递归查找
}
else if (s==t && s==k-1) //区间内只有一个元素且为a[k-1]
return a[k-1];
}
算法复杂度:
可以直接用主方法得出平均复杂度。最好和最坏情况,需要记忆。
回溯法求解0-1背包
回溯法
对于有些最优解问题,没有任何的理论也无法采用精确的数学公式来帮助我们找到最优解,我们只能用穷举算法。在这里我们介绍一种系统化的穷举搜索技术,称为回溯技术。
所谓回溯技术就是像人走迷宫一样,先选择一个前进方向尝试,一步步试探,在遇到死胡同不能再往前的时候就会退到上一个分支点,另选一个方向尝试,而在前进和回撤的路上都设置一些标记,以便能够正确返回,直到达到目标或者所有的可行方案都已经尝试完为止。
在通常的情况下,我们使用递归方式来实现回溯技术,也就是在每一个分叉点进行递归尝试。在回溯时通常采用栈来记录回溯过程,使用栈可使穷举过程能回溯到所要的位置,并继续在指定层次上往下穷举所有可能的解。
回溯法在问题的解空间树中,按深度优先策略,从根结点出发搜索解空间树。算法搜索至解空间树的任意一点时,先判断该结点是否包含问题的解。如果肯定不包含,则跳过对该结点为根的子树的搜索,逐层向其祖先结点回溯;否则,进入该子树,继续按深度优先策略搜索。
生成问题状态的基本方法:
回溯法求解0-1背包问题
问题描述:
算法设计:
算法的基本形式是很简单的。但是,要考虑如何对树进行剪枝,以提升效率。以下讨论用tw
表示装入背包的总重量(不含正在考虑的物品),用rw
表示剩余物品的总重量(含正在考虑的物品),tv
表示装入背包的总价值(不含正在考虑的物品),rv
表示剩余物品的总价值(含正在考虑的物品)。
背包必须装满
- 左剪枝函数:因为左孩子对应要放入该物品,因此,左剪枝函数为,如果放入物品 i 后,背包不超重:
if(tw+w[i]<=W)
,满足条件才能继续。 - 右剪枝函数:因为右孩子对应不放入该物品,因此,右剪枝函数为,如果不放入物品 i ,背包能装满:
if(tw+rw-w[i])>=W
,满足条件才能继续。
背包无需装满
- 左剪枝函数:与上一小节相同。
- 右剪枝函数:因为右孩子对应不放入该物品,因此,右剪枝函数为,如果不放入物品 i ,背包最后的价值上界大于现有的最大值
if(tv+单位价值从高到低排列,能放进去的价值+剩下的重量*放不下的那个物品的单位价值 > V)
,满足条件才能继续。
算法复杂度:最差情况下, n 个物品的搜索树为\(2^{n+1}-1\)个节点,每个节点的计算为\(O(n)\),因此最差复杂度为\(O(n\times 2^n)\)。
分支界定法求解老鼠迷宫问题
问题描述:有一只电子老鼠被困在如下图所示的迷宫中。这是一个12*12单元的正方形迷宫,黑色部分表示建筑物,白色部分是路。电子老鼠可以在路上向上、下、左、右行走,每一步走一个格子。现给定一个起点S和一个终点T,求出电子老鼠最少要几步从起点走到终点。
算法步骤:
- 读入占用情况矩阵,起点、终点坐标,从起点开始,设置此时的最短步数为\(num=0\),将起点加入队列;
- 从队列中取出一个元素,并将其从对列中删除。判断能否移动到四周的节点(判断周围位置的占用矩阵是否为空:\(a[r][c]==0?\)),并带回坐标\((r,c)\)。如果得到目标节点,返回\(num+1\),否则,将到该点的步数记录为\(num+1\),然后将其加入到open表中;
- 当队列非空时,重复2。
数据结构:
-
占用情况,矩阵
a[12][12]
,a存放迷宫,0表示空格,-2表示墙。广搜时,未找到目标以前到达的空格,填上到达该点的最小步数。 -
存放open表,数组
open[20]
-
open表位置与矩阵的位置的转换:
void addtoopen(int row, int col) { int u; u=row*n+col; open[tail++]= u; tail=tail%openlen; }
-
是否应该入队?
int canmoveto(int row, int col, int *p, int *q, int direction) { int r, c; r=row; c=col; switch(direction) { case 0: c--; //左 break; case 1: r++; //下 break; case 2: c++; //右 break; case 3: r--; //上 break; } *p=r; *q=c; if(r<0||r>=n||c<0||c>=n) //如果越界返回0 return(0); if(a[r][c]==0) //如果是空格返回1,因此,有距离的节点因a[r][c]为num(不是0)而返回0,实现不许重复走一个节点。 return(1); return(0); //其余情况返回0 }
算法复杂度:
- 读数据:双层循环读矩阵,\(O(n^2)\)
- 初始化:\(O(1)\)
- 广搜并返回步数:每个节点最多入队1次,因此,广搜最多执行\(n^2\)次,每次执行4次判断,\(O(n^2)\)
复杂度应该为\(O(n^2)\)。
动态规划
动态规划概述
动态规划是一种解决多阶段决策问题的优化方法,把多阶段过程转化为一系列单阶段问题,利用各阶段之间的关系,逐个求解。
能求解的三个性质:
求解的基本步骤:
动态规划求三角形路径
问题描述:
问题求解:
算法复杂度:
动态规划求最长递增子序列
问题描述:
问题求解:
算法实现:
算法复杂度:
贪心算法求活动安排问题
贪心算法
贪心算法总是做出在当前看来最好的选择。
贪心法的基本思路是在对问题求解时总是做出在当前看来是最好的选择,也就是说贪心法不从整体最优上加以考虑,所做出的仅是在某种意义上的局部最优解。
人们通常希望找到整体最优解,所以采用贪心法需要证明设计的算法确实是整体最优解或求解了它要解决的问题。
贪心法求解的问题应具有的性质:
贪心法的求解过程:
贪心算法求活动安排
问题描述:
问题求解:
算法复杂度:
STL中的sort()
函数使用快速排序,故复杂度为\(O(nlog_2n)\)。