1.算法概念
算法特征:1输入2输出3确定性4可行性5有穷性
在一般的计算机系统中,基本的运算和操作有四类:
1)算术运算:主要包括加、减、乘、除等运算。
2)逻辑运算:主要包括与、或、非等运算。
3)关系运算:主要包括大于、小于、等于、不等于等运算。
4)数据传输:主要包括赋值、输入、输出等操作。
算法的描述工具:
(1)用自然语言描述算法
(2)用流程图表示算法最早使用的算法描述工具。 优点:简单,直观
(3)用N-S流程图表示算法 又称盒图。
(4)用伪代码描述算法。一种非正式的算法描述语言,它介于自然语言与编程语言之间,采用某一程序设计语言的基本语法,操作指令可以结合自然语言来设计。 算法的描述结构清晰 代码简单 可读性好类似于自然语言,便于人们理解和交流 在表达能力上类似于编程语言,容易向编程语言转换。
伪码中的一些关键字,这些关键字的描述刚好是程序中的一些结构:
赋值语句:<---
分支语句:if …then … [else…]
循环语句:while, for,repeat until
转向语句:goto
输出语句:return
调用:直接写过程的名字
注释://…
算法评价的基本原则:
1. 正确性 一个好的算法的前提就是算法的正确性。即对于任何合法的输入,算法都会得到正确的结果。
2. 可读性/可理解性 算法主要是为了人的阅读与交流,其次才是机器的执行。晦涩难懂的算法还可能隐藏一些不易发现的逻辑错误。
3. 健壮性和可靠性 健壮性是指算法对非法输入的抵抗能力,即对于错误的输入,算法应该能识别并做出处理,而不是产生错误动作或陷入瘫痪。 可靠性指一个程序在正常情况下能正确地工作,而在异常情况下也能做出适当处理。
4. 效率 算法效率包括时间效率和空间效率。好的算法应该具有较短的执行时间并占用较少的辅助空间。 对于规模较大的程序,算法的效率问题是算法设计必须面对的一个关键问题。
5. 简明性 算法应该思路清晰、层次分明、容易理解、利于编码和调试,即算法简单,程序结构简单。 简单的算法效率不一定高,要在保证一定效率的前提下力求得到简单的算法。
6. 最优性 指求解某类问题中效率最高的算法。即算法的执行时间已达到求解该类问题所需时间的下界。
影响程序运行时间的因素:
1. 程序所依赖的算法 2. 问题的规模和输入数据 3. 计算机系统的性能
算法分析方法:事前分析、事后分析
事后分析法:先将算法用程序设计语言实现,然后度量程序的运行时间。
事前分析法:算法的时间效率是问题规模的函数,假如,随着问题规模n的增长,算法执行时间的增长率和函数f(n)的增长率相同,则可记作 T(n)=○(f(n))。称T(n)为算法的渐进时间复杂度。简称时间复杂度。
算法的复杂度主要包括时间复杂度和空间复杂度。
1.0NP完全性理论
P类和NP类问题
P类:所有可在多项式时间内用确定性算法求解的判定问题的集合。
NP类:所有可在多项式时间内用不确定性算法求解的判定问题的集合。
显然,P⊆NP,因为确定性算法只是不确定性算法当Choice函数只有一种选择时的特例。但计算机科学界至今无法判断是否P=NP或者P≠NP 。
下面关于NP问题说法正确的是(B )
A NP问题都是不可能解决的问题
(NP问题是指那些可以在多项式时间内验证其解的正确性的问题,并不是说这些问题不可能解决。实际上,有些NP问题是可以解决的,只是目前没有已知的多项式时间算法。)
B P类问题包含在NP类问题中
C NP完全问题是P类问题的子集
D NP类问题包含在P类问题中。
意义何在?
如果一个问题已经证明是NP难度的,恐怕很难指望能找到一个多项式时间的有效算法。如果所求解问题的实例规模较大,那么明智的做法是选择其他算法设计策略,如采用启发式算法、随机算法和近似算法等。
如何证明一个问题Q是NP难度的?
(1)选择一个已经证明是NP难度问题Q* (2)求证Q∝Q*
1.1 算法的时间复杂度
针对指定基本运算,计数算法所做运算次数。 基本运算执行次数和它的输入规模有关系,因此算法时间复杂度是输入规模的函数。即时间复杂度=f(n),n是问题的规模。 对相同输入规模的不同实例,算法的基本运算次数也不同。
可定义三种时间复杂度: 最好时间复杂度 B(n) 最坏时间复杂度 W(n) 平均时间复杂度 A(n)
1. 查找操作
线性查找(Linear Search)
-
描述:在一个无序数组中查找一个特定的元素。
-
最好情况:元素在数组的第一个位置,时间复杂度为 𝑂(1)。
-
最坏情况:元素在数组的最后一个位置或不在数组中,时间复杂度为 𝑂(𝑛)。
-
平均情况:假设元素在数组中的任意位置,时间复杂度为 𝑂(𝑛)。
二分查找(Binary Search)
-
描述:在一个有序数组中查找一个特定的元素。
-
最好情况:元素在数组的中间位置,时间复杂度为 O(1)。
-
最坏情况:元素不在数组中,时间复杂度为 O(logn)。
-
平均情况:假设每次查找都能将搜索范围减半,时间复杂度为 O(logn)。
2. 排序操作
冒泡排序(Bubble Sort)
-
描述:通过重复遍历数组,比较相邻元素并交换它们的位置来排序。
-
最好情况:数组已经有序,只需一次遍历,时间复杂度为 O(n)。
-
最坏情况:数组完全逆序,需要进行 𝑛−1次遍历,时间复杂度为 O(n^2)。
-
平均情况:时间复杂度为 𝑂(𝑛^2)。
快速排序(Quick Sort)
-
描述:通过选择一个“基准”元素,将数组分成两部分,递归地排序每一部分。
-
最好情况:每次选择的基准元素都能将数组均匀地分成两部分,时间复杂度为 𝑂(𝑛log𝑛)。
-
最坏情况:每次选择的基准元素都是数组的最小或最大值,时间复杂度为 𝑂(𝑛^2)。
-
平均情况:时间复杂度为 𝑂(𝑛log𝑛)。
3. 插入操作
插入排序(Insertion Sort)
- 描述:通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
- 最好情况:数组已经有序,只需进行 𝑛−1次比较,时间复杂度为 𝑂(𝑛)。
- 最坏情况:数组完全逆序,需要进行 𝑛(𝑛−1)/2次比较和交换,时间复杂度为 𝑂(𝑛^2)。
- 平均情况:时间复杂度为 𝑂(𝑛^2)。
顺序检索算法:j=1, 将x与L[j]比较。如果 x= L[j] ,则算法停止,输出 j;如果不等,则把 j 加1,继续x与L[j]的比较,如果 j>n,则停机并输出0。
改进顺序检索算法: j=1, 将x与L[j]比较。如果 x= L[j] ,则算法停止,输出 j;如果x> L[j] ,则把 j 加1,继续x与L[j]的比较,如果 x< L[j] ,则停机并输出0。如果j>n, 则停机并输出0。
平均情况下: 假设x在L中每个位置与空隙的概率都相等。
1.2渐近表示法
考虑算法在输入规模趋向无穷时的效率分析就是渐近分析。在数量级上估计一个算法的执行时间。 渐近表示大大降低了分析算法的难度,免除了精确计数的负担,从而使算法的任务变得可以控制。 算法复杂性在渐近意义下的记号有:O、Ω、等,分别表示运行时间的上界、运行时间的下界、运行时间的准确界等。
1.运行时间的上界(大O符号) 定义:设 f(n) 和 g(n) 是定义在非负整数集合上的函数。如果存在正整数 n0 和正常数c,使得当 n≥n0时,有 成立,就称 f(n) 的渐近的上界是g(n) ,记做 f(n) = O(g(n))
2.运行时间的下界(大 Ω 符号) 定义:设 f(n) 和 g(n) 是定义在非负整数集合上的函数。如果存在正整数 n0 和正常数c,使得当 n≥n0时,有 成立,就称 f(n) 的渐近的下界是g(n) ,记做 f(n) = Ω (g(n))
3. 运行时间的准确界(符号)当f(n)=O(g(n))且 f(n)= Ω (g(n)) ,记作f(n)=
(g(n))。
这里要注意以下几点: 1. f(n)的阶与g(n)的阶相等。
4.小o符号 定义:设 f(n) 和 g(n) 是定义在非负整数集合上的函数。若对于任意正数c都存在 n0,使得当 n≥n0时,有 成立,则记作 f(n) = o(g(n))
注:和O的区别是<不是
这里要注意以下几点: 1. f(n) = o(g(n)),f(n)的阶低于g(n)的阶。 2. 对正数c必须是所有可能的正数都要成立,当c不同的时候, n0可能不一样。c越小n0 越大。 3. 对前面有限个n值可以不满足不等式。
5.小w符号 定义:设 f(n) 和 g(n) 是定义在非负整数集合上的函数。若对于任意正数c都存在 n0,使得当 n≥n0时,有 成立,则记作 f(n) = w(g(n))
注:这里也是一样
(1)当C≠0且为常数时,说明f(n)和g(n)同阶,计为f(n)=θ(g(n))
(2)当C=0时,说明f(n)比g(n)低阶,计为f(n)=O(g(n)) // f(n)<cg(n)
(3)当C=∞时,说明f(n)比g(n)高阶,计为f(n)=Ω(g(n)) //f(n)>cg(n)
说明:时间复杂性达到下界的算法称为最优算法。
2.递归
1. 基本思想
递归(Recursion)是一种通过函数调用自身来解决问题的编程技巧。其核心思想是将一个复杂的问题分解为规模更小的同类问题,直到问题的规模足够小,可以直接解决。递归过程通常分为两个阶段:递推和回归。
- 递推:将问题逐步分解为更小的子问题。
- 回归:当子问题解决后,逐步返回并合并子问题的解,得到原问题的解。
2. 使用条件
使用递归法需要满足以下条件:
- 子问题与原问题相同:问题可以分解为若干个与原问题相同但规模更小的子问题。
- 递归终止条件:必须有一个明确的递归结束条件,称为递归出口,以防止无限递归。
- 子问题的解可以合并:子问题的解可以组合成原问题的解。
3. 适用范畴
递归法适用于以下几类问题:
- 数据定义按递归定义:如斐波那契数列、阶乘等。
- 问题解法按递归实现:如回溯法、深度优先搜索等。
- 数据结构按递归定义:如树的遍历、图的搜索等。
4. 解空间规模搜索方式
递归法通常采用深度优先搜索(DFS)的方式来遍历解空间。具体步骤如下:
- 确定解空间:定义问题的解空间,确保解空间至少包含一个最优解。
- 扩展搜索规则:确定节点的扩展搜索规则。
- 深度优先搜索:以深度优先的方式搜索解空间,并在搜索过程中使用剪枝函数避免无效搜索。
5. 优缺点
优点
- 代码简洁:递归代码通常比迭代代码更简洁、易读。
- 符合思维习惯:递归思想符合人类的自然思维方式,容易理解。
- 适用于复杂问题:递归法适用于解决一些复杂的、具有递归性质的问题,如树的遍历、图的搜索等。
缺点
- 效率较低:递归调用会产生大量的函数调用开销,导致运行效率较低。
- 容易栈溢出:递归调用层次过深时,容易导致栈溢出。
- 重复计算:在没有优化的情况下,递归可能会进行大量的重复计算。
6. 改进措施
- 记忆化递归:使用缓存(如数组或哈希表)存储已经计算过的结果,避免重复计算。
- 尾递归优化:将递归转换为尾递归形式,某些编译器可以优化尾递归,减少栈空间的使用。
- 迭代替代:在可能的情况下,将递归算法转换为迭代算法,以提高效率和避免栈溢出。
2.1递推方程的求解方法: 迭代法
直接迭代:插入排序最坏情况下时间分析
换元迭代:二分归并排序最坏情况下时间分析
差消迭代:快速排序平均情况下的时间分析
迭代模型:递归树
主定理:递归算法的分析
2.11迭代法
1不断用递推方程的右部替换左部
2每次替换,随着n的降低在和式中多出一项
3直到出现初值停止迭代
4将初值带入并对和式求和
5可用数学归纳法验证解的正确性
插入排序:设基本运算是元素比较,对规模为n的输入最坏情况下的时间复杂度W(n)
迭代展开
通过直接迭代展开,我们可以得到:
证明:下述递推方程的解的正确性
方法:数学归纳法
证 n=1, W(1)=1×(1-1)/2=0
假设对于n,解满足方程,则
void InsertSort(int A[], int n) {
int i, j, x;
for (j = 2; j <= n; j++) {
x = A[j];
i = j - 1;
while (i > 0 && A[i] > x) {
A[i + 1] = A[i]; // 位置1(j-1)>位置2(x)
i--;
}
A[i + 1] = x;
}
}
2.12换元迭代
有时递推方程直接迭代会非常困难,我们就要进行换元。
1将对n的递推式换成对其他变元的k的递推式
2对k直接迭代
3将解(关于k的函数)转换成关于n的函数
二分归并排序算法的递推方程 对规模为n的输入最坏情况下的时间复杂度W(n)
注释:n-1是合并的比较次数,就是第一次的划分后的数组,是比较次数最多的一次
换元n递推方程如下:
注意:最后一步换n回去
2.13差消法化简高阶递推方程
递推方程的求解基本的方法是迭代。如果这个依赖关系比较复杂的时候,直接迭代会非常的困难。 对递推方程进行化简,把高阶的递推方程化简成一阶的。也就是时间复杂度T(n)只依赖前面的T(n-1),而和更前面的项没有关系,这种方法就叫做差消方法。 化简之后再迭代。
2.14递归树
递归树是迭代计算的模型。 递归树的生成过程与迭代过程一致。 递归树上所有项恰好是迭代之后产生和式中的项。 对递归树上的项求和就是迭代后方程的解。
迭代在递归树中的表示:如果递归树上某结点标记为W(m)
递归树的生成规则:
1初始,递归树只有根节点,其值为W(n)
2不断继续下述过程:
将函数项叶结点的迭代式W(m)表示成二层子树
用该子树替换该叶结点
3继续递归树的生成,替换这些叶结点,直到树中没有函数项(只有初值)为止。
二分归并排序算法的递推方程 对规模为n的输入最坏情况下的时间复杂度W(n)
按行相加求和。因为每一步迭代的时候都是跟原始那棵树的值是一样的,因此加起来的总和就是原来递推方程的解。
2.15主定理
a:规约后的子问题个数
n/b:规约后子问题的规模
f(n):规约过程及组合子问题的解的工作量
这符合主定理的情况1,。
这符合主定理的情况2,。
这符合主定理的情况3,。
2.2 代表性问题——八皇后问题
在8×8格的国际象棋棋盘上放置八个皇后,使得任意两个皇后不能互相攻击,即任何行、列或对角线上不得有两个或两个以上的皇后。这样的一个格局称为问题的一个解。
/* 11 13
22
31 33
(1,3) 和 (3,1),abs(1-3) 和 abs(3-1) 都会返回 2,
这不是反对角
1、因为x上按行一行一行来的,所以不用考虑行的冲突,只需要考虑列、正对角线,反对角线三个方向。
2、b2[x+i] 因为同一正角线的位置,行+列是相等的,如果我们设置了 行+列使用过了,
那么,其它再检查到同一对角线时,就会发现行+列已使用过
3、b3[x - i + 8] 因为同一反对角线的位置,行-列是相等的,但可能行>列,也可能列>行,
这要看它是最长对角线的右上方还是左下方,右上方x>y,左下方x<y 为了防止出现负数数组下标,所以,采用了加一个偏移量的办法,这样,不管是大于还是小于,都规划到一个下标大于零的位置上。
4、这里不能使用abs,因为 abs(x-y)与abs(y-x)不是一条反对角线!!!
为什么是8?就是因为n的范围是9,b3数组下标不越界即可!即1-9+8=0*/
#include<iostream>
using namespace std;
const int N = 1e5 + 10; // Q:为什么这里是110,还是最大是9吗?这是因为在下面的数组使用中,采用了+8的偏移策略,需要大一点,只要开不死,就往死里开!
int path[N];
int n;
int b1[N], b2[N], b3[N];
void dfs(int u) {
if (u == n + 1) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
if (j == path[i])
//cout << "*" << " ";
cout << "第" << i << "行" << "第" << j << "列";
/* else
cout << "." << " ";*/
}
cout << endl;
}
cout << endl;
return;
}
for (int i = 1; i <= n; i++) {
if (!b2[u + i] && !b1[i] && !b3[u - i + n]) {
b2[u + i] = b1[i] = b3[u - i + n] = 1;
path[u] = i;//第u个皇后的位置是i
dfs(u + 1);
b2[u + i] = b1[i] = b3[u - i + n] = 0;
}
}
}
int main() {
cin >> n;
dfs(1);
return 0;
}
1 每个结点有4个儿子,分别代表选择 1,2,3,4列位置
2第 i 层选择解向量中第 i 个分量的值
3最深层的树叶是解
4按深度优先次序遍历树,找到所有解
时间复杂度为:O(N!)
分析:使用一个数组记录每行放置的皇后的列下标,依次在每一行放置一个皇后。每次新放置的皇后都不能和已经放置的皇后之间有攻击:即新放置的皇后不能和任何一个已经放置的皇后在同一列以及同一条斜线上,并更新数组中的当前行的皇后列下标。当 N个皇后都放置完毕,则找到一个可能的解。当找到一个可能的解之后,将数组转换成表示棋盘状态的列表,并将该棋盘状态的列表加入返回列表。
由于每个皇后必须位于不同列,因此已经放置的皇后所在的列不能放置别的皇后。第一个皇后有 N 列可以选择,第二个皇后最多有 N−1 列可以选择,第三个皇后最多有 N-2 列可以选择(如果考虑到不能在同一条斜线上,可能的选择数量更少),因此所有可能的情况不会超过 N!种,遍历这些情况的时间复杂度是 O(N!)。
3.分治法
分治经典问题-屈婉玲教授的算法设计与分析课上例题-优快云博客
1. 基本思想
分治法的核心思想是“分而治之”,即将一个复杂的问题分解为若干个规模较小的相同或相似的子问题,递归地解决这些子问题,然后将子问题的解合并为原问题的解。具体步骤如下:
- 分解(Divide):将原问题分解成若干个规模较小的子问题。
- 解决(Conquer):递归地解决这些子问题。如果子问题足够小,则直接解决。
- 合并(Combine):将子问题的解合并为原问题的解。
2. 使用条件
分治法适用于以下条件的问题:
- 可分解(最优子结构性质):原问题可以分解为若干个规模较小的相同子问题。
- 子问题可独立求解(相互独立):分解出来的子问题可以独立求解,即子问题之间不包含公共的子子问题。
- 具有分解的终止条件:当问题的规模足够小时,能够用较简单的方法解决。
- 可合并:子问题的解可以合并为原问题的解,并且合并操作的复杂度不能太高,否则就无法起到减少算法总体复杂度的效果。
3. 适用范畴
分治法适用于以下几类问题:
- 排序问题:如快速排序、归并排序。
- 搜索问题:如二分查找。
- 数学问题:如大整数乘法、Strassen矩阵乘法。
- 几何问题:如最近点对问题。
- 图论问题:如图的连通性问题。
4. 解空间规模搜索方式
分治法通常采用递归的方式来遍历解空间。其时间复杂度可以通过递归关系式来表示和求解。一般形式为:𝑇(𝑛)=𝑎𝑇(𝑛𝑏)+𝑓(𝑛)其中,a 是子问题的个数,b 是子问题的规模相对于原问题的比例,𝑓(𝑛) 是分解和合并的时间复杂度。
5. 优缺点
优点
- 降低问题复杂度:通过将大问题分解为小问题,降低了问题的复杂度。
- 利于并行计算:子问题通常是独立的,可以并行计算。
- 代码简洁:递归实现的代码通常比迭代实现的代码更简洁、易读。
缺点
- 递归开销大:递归调用会产生大量的函数调用开销,导致运行效率较低。
- 容易栈溢出:递归调用层次过深时,容易导致栈溢出。
- 需要额外空间:某些分治算法(如归并排序)需要额外的存储空间。
6. 改进措施
- 记忆化递归:使用缓存(如数组或哈希表)存储已经计算过的结果,避免重复计算。
- 尾递归优化:将递归转换为尾递归形式,某些编译器可以优化尾递归,减少栈空间的使用。
- 迭代替代:在可能的情况下,将递归算法转换为迭代算法,以提高效率和避免栈溢出。
- 减少子问题个数
- 预处理
7. 代表性问题:归并排序、快速排序、二分查找、幂乘计算、选择问题、最大子段和问题、棋盘覆盖问题、循环赛日程安排问题、平面点集的凸包。
分治法所能解决的问题一般具有的几个特征是:
(1)该问题的规模缩小到一定的程度就可以容易地解决;
(2)该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质;
(3)利用该问题分解出的子问题的解可以合并为该问题的解;
(4)原问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子问题。
3.1 代表性问题——最大子段和问题
问题:给定由n个整数组成的序列(a1, a2, …, an) ,求出其中最大连续子序列的和。
如:序列(-20, 11, -4, 13, -5, -2)最大子段和为:20.
#include<iostream>
using namespace std;
int maxsubSum(int a[],int left,int right){
int sum=0;
//当只有一个元素
if(left==right){
return a[left];}
int center=(left+right)/2;
int leftsum=maxsubSum(a,left,center);
int rightsum=maxsubSum(a,center+1,right);
//从中间向两边散开
int s1=0,lefts=0;
for(int i=center;i>=left;i--){
lefts+=a[i];
if(lefts>s1) s1=lefts;
}
int s2=0,rights=0;
for(int i=center+1;i<=right;i++){
rights+=a[i];
if(rights>s2) s2=rights;
}
sum=max(leftsum,max(rightsum,s1+s2));
return sum;
}
int main(){
int a[] = {-2,11,-4,13,-5,-2,};
// left right
int n=sizeof(a)/sizeof(int);
for(int i= 0; i < 6; i++)
{
cout<<a[i]<<" ";
} cout<<endl;
cout<<"数组a的最大连续子段和为:" << maxsubSum(a,0,n-1)<<endl;
return 0;
}
3.2代表性问题——一般选择问题
问题:从给定的集合 L 中选择第 i 小的元素
3.3代表性问题——归并排序
4.贪心法
1. 基本思想
贪心算法的核心思想是通过一系列局部最优选择来期望达到全局最优解。具体来说,在解决问题时,贪心算法总是做出在当前看来是最好的选择,而不考虑全局情况。每一步的选择只依赖于当前状态,不依赖于未来的选择或子问题的解。
2. 使用条件
贪心算法适用于以下条件的问题:
-
贪心选择性质:问题的全局最优解可以通过一系列局部最优解(贪心选择)来得到。每次选择可以依赖以前作出的选择,但不依赖于将来的选择或子问题的解。
-
最优子结构性质:问题的最优解包含其子问题的最优解,即问题可以分解为若干个子问题,子问题的最优解可以组合成原问题的最优解。
3. 适用范畴
贪心算法适用于以下几类问题:
-
图论问题:如最小生成树(Prim算法、Kruskal算法)、最短路径(Dijkstra算法)。
-
数据压缩:如霍夫曼编码。
-
调度问题:如区间调度问题。
-
背包问题:如分数背包问题。
4. 解空间规模搜索方式
贪心算法通常采用迭代的方式来遍历解空间。每一步选择当前状态下的最优解,将问题规模逐步缩小,直到问题解决。贪心算法不需要回溯,因此其时间复杂度通常较低。
5. 优缺点
优点
-
简单高效:贪心算法的实现通常较为简单,代码量小,运行效率高。
-
空间复杂度低:由于不需要存储子问题的解,贪心算法的空间复杂度通常较低。
缺点
-
局限性大:贪心算法并不总能得到全局最优解,适用范围有限。
-
需要证明:在使用贪心算法之前,必须证明其贪心选择能够保证全局最优解,这往往需要严格的数学证明。
6. 改进措施
-
结合动态规划:在某些情况下,可以将贪心算法与动态规划结合使用,以提高算法的准确性和效率。
-
多次验证:在实际应用中,可以通过多次验证和测试来确保贪心算法的正确性和有效性。
7. 代表性问题
最小生成树
-
Prim算法:从一个顶点开始,逐步扩展生成树,每次选择权值最小的边。
-
Kruskal算法:每次选择权值最小的边,逐步构建生成树,避免形成环。
最短路径
-
Dijkstra算法:从起点开始,逐步选择距离最短的顶点,更新其邻接顶点的距离,直到所有顶点都被访问。
霍夫曼编码
-
霍夫曼编码:通过构建霍夫曼树,每次选择出现频率最低的两个节点合并,最终得到最优的无损数据压缩编码。
区间调度问题
-
区间调度:每次选择结束时间最早的任务,确保能够完成尽可能多的任务。
分数背包问题
-
分数背包:每次选择单位重量价值最高的物品,直到背包装满
4.1代表性问题——最小生成树
给定一个 n个点 m 条边的无向图,图中可能存在重边和自环,边权可能为负数。求最小生成树的树边权重之和,如果最小生成树不存在则输出 impossible。
输入格式
第一行包含两个整数 n 和 m 。接下来 m 行,每行包含三个整数 u , v , w表示点 u和点 v 之间存在一条权值为 w的边。
输出格式
共一行,若存在最小生成树,则输出一个整数,表示最小生成树的树边权重之和,如果最小生成树不存在则输出 impossible。
数据范围
1 ≤ n ≤ 50 , 1≤n≤500,1 ≤ m ≤ 10^5
图中涉及边的边权的绝对值均不超过 10000 。
输入样例:
4 5
1 2 1
1 3 2
1 4 3
2 3 2
3 4 4
输出:6
Prim算法:从一个顶点开始,逐步扩展生成树,每次选择权值最小的边。时间复杂度o(n^2).
例子
假设当前生成树包含顶点集合{A, B}
,并且我们选择了顶点C
加入生成树。此时需要更新所有未加入生成树的顶点到生成树的最小距离:
- 如果
dist[D]
当前值为10,而g[C][D]
为5,则更新dist[D]
为5,因为通过C
到D
的路径更短。
#include <vector>
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
const int N = 520, INF = 0x3f3f3f3f;
int n, m;
int g[N][N];
int dist[N]; // 点到集合的距离
bool st[N]; // 标记数组
vector<pair<int, int>> mst_edges; // 存储最小生成树的边
int pre[N]; // 存储每个点在MST中的前驱
int prim() {
memset(dist, 0x3f, sizeof dist);
memset(pre, -1, sizeof pre); // 初始化前驱数组
int res = 0;
for (int i = 0; i < n; i++) {
int t = -1;
for (int j = 1; j <= n; j++) {
if (!st[j] && (t == -1 || dist[j] < dist[t])) t = j;
}
if (i && dist[t] == INF) return INF; // 检查是否所有点都连通
if (i) {
res += dist[t];
mst_edges.push_back({ pre[t], t }); // 存储边
}
for (int j = 1; j <= n; j++) {
if (g[t][j] < dist[j]) {
dist[j] = g[t][j];
pre[j] = t; // 更新前驱节点
}
}
st[t] = true;
}
return res;
}
int main() {
cin >> n >> m;
memset(g, 0x3f, sizeof g);
while (m--) {
int a, b, c;
cin >> a >> b >> c;
g[a][b] = g[b][a] = min(g[a][b], c);
}
int t = prim();
if (t == INF) cout << "impossible" << endl;
else {
cout << "最小树权重和: " << t << endl;
cout << "最小树边:" << endl;
for (auto &edge : mst_edges) {
cout << edge.first << " - " << edge.second << endl;
}
}
return 0;
}
时间复杂度:O(n^2)
Kruskal算法:每次选择权值最小的边,逐步构建生成树,避免形成环。
贪心策略是每次都在连接两个不同连通分量的边中选权值最小的边。
基本思想:首先将图中所有顶点都放到生成树中,然后每次都在连接两个不同连通分量的边中选权值最小的边,将其放入生成树中,直到生成树中有n-1条边。
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;
const int N = 10010;
int n, m;
int p[N];
struct Edge {
int a, b, w;
bool operator < (const Edge &t) const {
return w < t.w;
}
}edges[N];
int find(int x) {
if (p[x] != x) p[x] = find(p[x]);
return p[x];
}
int main() {
cin >> n >> m;
for (int i = 0; i < m; i++) {
int a, b, w;
cin >> a >> b >> w;
edges[i] = { a,b,w };
}
sort(edges, edges + m);
for (int i = 1; i <= n; i++) p[i] = i;
int res = 0, cnt = 0;
for (int i = 0; i < m; i++) {
int a = edges[i].a, b = edges[i].b, w = edges[i].w;
a = find(a), b = find(b);
if (a != b) {
p[a] = b;
res += w;
cnt++;
}
}
if (cnt < n - 1) puts("impossible");//没有连通
else printf("%d\n", res);
}
时间复杂度:
(1)每个结点至多更新logn次,建立和更新FIND数组的总时 间为O(nlogn)
(2) 算法时间为 O(mlogm) + O(nlogn) + O(m) = O(mlogm)
边排序 FIND数组 其他
4.2代表性问题——区间调度
#include<iostream>
#include<algorithm>
using namespace std;
typedef struct {
int s;
int f;
}fs;
bool cmp(fs a,fs b){
return a.f<b.f;
}
int main(){
int t ,n;
cin>>t;
while(t--){
cin>>n;
fs d[1000];
for(int i=0;i<n;i++){
cin>>d[i].s>>d[i].f;
}
sort(d,d+n,cmp);
int cnt=1;
int endt=d[0].f;
for(int i=1;i<n;i++){
//结束时间<=下一个开始时间
if(endt<=d[i].s){
cnt++;
endt=d[i].f;
}
}
cout<<"最多可举办"<<cnt<<"个活动"<<endl;
}
}
/*
1 10
1 4
3 5
0 6
5 7
3 8
5 9
6 10
8 11
8 12
2 13
*/
算法时间复杂度:排序+活动选择 O(nlogn)+O(n)=O(nlogn)
5.动态规划
基本思想:
动态规划的基本思想与分治法类似,也是将待求解的问题分解为若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的。若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次。如果我们能够保存已解决的子问题的答案,而在需要时再找出已求得的答案,这样就可以避免大量的重复计算,节省时间。我们可以用一个表来记录所有已解的子问题的答案。不管该子问题以后是否被用到,只要它被计算过,就将其结果填入表中。 简单来说动态规划的实质是分治思想和解决冗余,因此,动态规划是一种将问题实例分解为更小的、相似的子问题,并存储子问题的解而避免计算重复的子问题,以解决最优化问题的算法策略。
按以下几个步骤进行:
(1)划分阶段
(2)选择状态
(3)确定决策并写出状态转移方程
(4)写出规划方程
使用条件:
- 最优化原理:问题的最优解所包含的子问题的解也是最优的。
- 无后效性:某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
- 有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。
适用范畴:
多阶段决策问题。动态规划常用于有重叠子问题和最优子结构性质的问题,比如求最短路径、求最长公共子序列等。把多阶段过程转化为一系列单阶段问题,每个阶段求解的问题是后面阶段求解问题的子问题,每步决策将依赖于以前步骤的决策结果。利用各阶段之间的关系,逐个求解,进行组合和合并,合并成原问题的解。
解空间规模搜索方式
动态规划通过构建一个表格(通常是二维数组)来存储子问题的解,从而避免重复计算。这种方法显著减少了时间复杂度,使得动态规划在处理大规模问题时非常高效
优点:
- 相对于分治法,动态规划不会产生重复计算,因而效率更高。
- 相对于贪心算法,动态规划可以保证求得全局最优解。
缺点:
- 动态规划算法实现起来可能相对复杂。
- 需要额外的空间来存储子问题的解。
改进措施:
- 优化状态转移方程,减少不必要的计算。
- 通过状态压缩等技术,减少额外空间的使用。
代表性问题:
- 最长公共子序列问题
- 矩阵链乘法问题
- 0-1背包问题
- 最短路径问题
- 编辑距离问题
1.请说明动态规划方法为什么需要最优子结构性质。
答:最优子结构性质是指大问题的最优解包含子问题的最优解。动态规划方法是自底向上计算各个子问题的最优解,即先计算子问题的最优解,然后再利用子问题的最优解构造大问题的最优解,因此需要最优子结构.
2. 请叙述动态规划算法与贪心算法的异同。
共同点:都需要最优子结构性质,都用来求有优化问题。
不同点:
-
子问题的重叠性质:
- 动态规划:适用于具有子问题重叠性质的问题,即同一个子问题会被多次计算。通过存储子问题的解(记忆化或表格法),动态规划避免了重复计算。
- 贪心算法:不需要子问题重叠性质。贪心算法通常不存储子问题的解,因为它们不需要重复计算子问题。
-
求解策略:
- 动态规划:通常采用自底向上的求解策略,从最小的子问题开始,逐步解决更大的子问题,直到解决原问题。
- 贪心算法:通常采用自顶向下的求解策略,从问题的初始状态开始,每一步都做出一个局部最优的选择,直到达到问题的目标状态。
-
适用条件:
- 动态规划:适用于所有具有最优子结构和子问题重叠性质的问题。
- 贪心算法:适用于具有最优子结构和贪心选择性质的问题。贪心选择性质意味着通过每一步的局部最优选择可以得到全局最优解。
-
适用性:
- 动态规划:在可以使用贪心算法的情况下,动态规划方法可能不适用,因为动态规划通常需要更多的计算和存储资源。
- 贪心算法:在可以使用动态规划方法的情况下,贪心算法可能不适用,因为贪心算法的局部最优选择不一定能保证全局最优解。
3.分治法与动态规划法的异同
相同点是:将待求解的问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解。
两者的不同点是:适合于用动态规划法求解的问题,经分解得到的子问题往往不是互相独立的。而用分治法求解的问题,经分解得到的子问题往往是互相独立的。
5.1代表性问题——多重背包问题
输入:物品有n种,每种物品重量向量W=< w1, w2, … , wn >, 每种物品价值向量V=< v1, v2, … , vn >, 背包重量最大为b.
输出:解向量< x1, x2, … , xn >, xi是装入背包的第i 种物品个数
#include <iostream>
using namespace std;
#include<algorithm>
const int N=1010;
int n,m;
int v[N],w[N];//v[i]是第i件物品的体积,w[i]是第i件物品的价值
int f[N];
int main(){
cin>>n>>m;//n是物品个数,m是背包容量
for(int i=1;i<=n;i++) cin>>v[i]>>w[i];
for(int i=1;i<=n;i++){//枚举第i件
for(int j=v[i];j<=m;j++){//枚举背包容量
//优化
// f[i][j]=f[i-1][j];
// if(j>=v[i]) f[i][j]=max(f[i][j],f[i][j-v[i]]+w[i]);
f[j]=max(f[j],f[j-v[i]]+w[i]);
}
}
cout<<f[m]<<endl;
return 0;
}
时间复炸度:O(nm)
5.2代表性问题——01背包问题
01背包数据如下表,求:能够放入背包的最有价值的物品集合。
物品 i | 重量 wi | 价值 vi | 承重量 W |
1 | w1=2 | v1=12 | W=5 |
2 | w2=1 | v2=10 | |
3 | w3=3 | v3=20 | |
4 | w4=2 | v4=15 |
我们将构建一个动态规划表格 𝑉(𝑖,𝑗),其中 𝑖表示前 𝑖 个物品,𝑗 表示当前背包的承重量。
5.3代表性问题——最长公共子序列
给定两个序列X={B,C,D,A},Y={A,B,C,B},请采用动态规划策略求出其最长公共子序列,要求给出过程。
5.4代表性问题—最大子段和
int MaxSum(int n, int a[])
{
int sum = 0;
int b = 0;//用于存储以当前元素结尾的最大子数组和。
for (int j = 1; j <= n; j++) {
if (b > 0) b+= a[j];
else b= a[j];
if (b > sum) sum = b;
}
return sum;
}
6.回溯法
基本思想
回溯法的基本思想是通过深度优先搜索(DFS)在解空间树中寻找问题的解。它从根节点出发,逐层深入搜索解空间树的每一个分支,当发现某个分支不能产生有效解时,立即回溯到上一个节点,继续搜索其他分支。这种方法通过剪枝技术避免了不必要的搜索,从而提高了效率。
使用条件:满足多米诺性质(如果当前结点不满足约束条件,能够推导出它的子结点也不满足约束条件,就可以放弃搜索它的子结点。)
回溯法适用于以下几类问题:
- 组合问题:如从N个数中选出K个数的组合。
- 排列问题:如N个数的全排列。
- 子集问题:如求一个集合的所有子集。
- 棋盘问题:如N皇后问题、数独等。
适用范畴
回溯法广泛应用于各种需要穷尽所有可能解的组合优化问题,特别是那些解空间较大且无法通过简单的递推公式解决的问题。
解空间规模搜索方式
回溯法通过构建解空间树(可能是n叉树、子集树、排列树等等)来组织解空间,并采用深度优先搜索的方式遍历解空间树。通过剪枝函数,可以有效地减少搜索空间,避免无效的搜索。
优缺点
优点
- 系统性:能够系统地搜索所有可能的解。
- 灵活性:适用于多种类型的组合优化问题。
- 可读性强:程序结构明确,易于理解和实现。
缺点
- 时间复杂度高:在问题规模较大时,回溯法的时间复杂度往往较高,可能导致计算时间过长。
- 空间复杂度高:需要存储解空间树,可能导致较高的空间复杂度。
改进措施
- 剪枝技术:通过约束函数和限界函数剪去不可能产生解的子树,从而减少搜索空间。
- 启发式搜索:结合启发式信息,优先搜索更有可能产生解的分支,提高搜索效率。
代表性问题
- 货郎问题(TSP)
- N皇后问题:在N×N的棋盘上放置N个皇后,使得任何两个皇后不在同一行、同一列和同一对角线上。
- 0-1背包问题:给定一组物品,每个物品有重量和价值,选择若干物品放入背包,使得背包内物品的总价值最大且总重量不超过背包容量。
- 数独:在9×9的网格中填入数字,使得每行、每列和每个3×3的小方格内的数字均为1到9且不重复。
回溯法中常见的两类典型的解空间树是子集树和排列树
子集树
定义:当所给的问题是从 𝑛 个元素的集合 S 中找出满足某种性质的子集时,相应的解空间树称为子集树。
特点
-
节点表示:每个节点表示一个部分解,即当前选择的子集。
-
分支:每个节点有两个分支,分别表示“选择当前元素”或“不选择当前元素”。
-
叶结点:子集树通常有 2^𝑛 个叶结点,因为每个元素有两种选择(选或不选),总共有 2^𝑛 种可能的子集。
-
时间复杂度:遍历子集树需要 O(2^n) 的计算时间。
示例:假设我们有一个集合 𝑆={𝑎,𝑏,𝑐},我们要找出所有的子集。对应的子集树如下:
{}
/ \
{a} {}
/ \ / \
{a,b} {a} {b} {}
/ \ / \ / \ / \
{a,b,c}{a,b}{a,c}{a}{b,c}{b}{c}{}
排列树
定义:当所给的问题是确定 𝑛 个元素满足某种性质的排列时,相应的解空间树称为排列树。
特点
-
节点表示:每个节点表示一个部分解,即当前选择的排列。
-
分支:每个节点有 𝑛−𝑘个分支,其中 𝑘是当前排列中已选择的元素个数,表示选择下一个未被选择的元素。
-
叶结点:排列树通常有 𝑛!个叶结点,因为n个元素的全排列有n! 种可能。
-
时间复杂度:遍历排列树需要 O(n!) 的计算时间。
示例:假设我们有一个集合 𝑆={𝑎,𝑏,𝑐},我们要找出所有的排列。对应的排列树如下:
{}
/ | \
{a} {b} {c}
/ \ / \ / \
{a,b}{a,c}{b,a}{b,c}{c,a}{c,b}
/ \ / \ / \ / \ / \ / \
{a,b,c}{a,c,b}{b,a,c}{b,c,a}{c,a,b}{c,b,a}
总结
- 子集树:用于从 𝑛个元素的集合中找出满足某种性质的子集。每个节点有两个分支,表示选择或不选择当前元素。子集树有 2^n 个叶结点,遍历子集树需要 O(2^n) 的计算时间。
- 排列树:用于确定 𝑛个元素的排列。每个节点有 𝑛−𝑘个分支,表示选择下一个未被选择的元素。排列树有 𝑛!个叶结点,遍历排列树需要O(n!) 的计算时间。
6.1代表性问题——着色问题
输入:无向连通图 G 和m种颜色
输出:所有可能的着色方案,如果不存在这样的方案,回答“No”
#include <bits/stdc++.h>
using namespace std;
const int N=100;
int n,m;
int g[N][N];
int sum;
int x[N];
bool dif(int i){
for(int j=1;j<i;j++)
if(g[i][j]==1&&x[j]==x[i]) return false;
return true;
}
void finddfsd(int i){
if(i>n){
sum++;
printf("第%ld个解: ", sum);
for (int k = 1; k <= n; k++)
printf("%d ", x[k]);
printf("\n");
return ;
}
for(int k=1;k<=m;k++){
x[i]=k;
if(dif(i)){
// printf("颜色%d可行, 深入一层, 将到达%d层\n", k, i + 1);
finddfsd(i + 1);
// printf("回溯一层到达第%d层\n", i);
}
// else{
// if (k == m) printf("当前层所有颜色选完, 没有可行颜色, 故将回溯一层到达第%d层\n", i - 1);
// else printf("颜色%d不可行, 继续选择颜色%d\n", k, k + 1);
// }
x[i]=0;
}
}
int main(){
cin>>n>>m;
for(int i=1;i<=n;i++)
for(int j=1;j<=n;j++)
cin>>g[i][j];
finddfsd(1);
if(sum) printf("图的 %d 着色方案共有 %d 种\n", m, sum);
else printf("No");
}
/*
5 4
0 1 1 1 0
1 0 1 1 1
1 1 0 1 0
1 1 1 0 1
0 1 0 1 0
*/
如何判断两个节点的颜色是否相同,通过dif函数。传入节点编号i,然后遍历之前的所有的点,判断是否相连,若相连则再判断颜色是否相同(x[i]==x[1……x-1]
最坏情况
-
递归调用树:
- 每个节点可以尝试
m
种颜色,总共有n
个节点。 - 在最坏情况下,每个节点都需要尝试所有
m
种颜色。 - 递归调用树的高度为
n
,每个节点在每一层最多有m
个子节点。 - 因此,递归调用树的总节点数为
m^n
。
- 每个节点可以尝试
-
每次递归调用的复杂度:
- 每次递归调用中,调用
dif
函数检查当前颜色分配是否有效。 dif
函数在最坏情况下需要O(n)
次操作。
- 每次递归调用中,调用
-
总时间复杂度:
- 总的递归调用次数为
m^n
。 - 每次递归调用中
dif
函数需要O(n)
次操作。 - 因此,总时间复杂度为
O(n * m^n)
。
- 总的递归调用次数为
7.分支限界法
基本思想
分支限界法的基本思想是将问题分解为更小的子问题,通过使用界限函数来排除不可能包含最优解的子问题。具体来说,分支限界法通过构建一个状态空间树来系统地枚举候选解,并在每个节点处计算上下界,以决定是否继续搜索该分支。
使用条件
分支限界法适用于以下条件的问题:
-
离散优化问题:问题的解空间是离散的。
-
组合优化问题:问题需要在大量可能的组合中找到最优解。
-
具有明确的上下界:可以为每个子问题计算上下界,以便进行剪枝操作。
适用范畴
分支限界法广泛应用于各种离散和组合优化问题,如旅行商问题、0-1背包问题、作业调度问题、整数规划问题等。
解空间规模搜索方式
宽度优先。分支限界法通过构建一个根节点表示整个解空间的决策树,并逐层分支生成子节点。每个子节点代表部分解空间。在每个节点处,计算当前解的上下界,并根据这些界限决定是否继续搜索该分支。如果某个分支的下界大于当前已知的最优解的上界,则该分支被剪枝。
优缺点
优点
- 系统性:能够系统地搜索所有可能的解,并保证找到最优解。
- 高效性:通过剪枝技术,可以显著减少需要搜索的节点数量,从而提高效率。
- 适用性广:适用于多种类型的离散和组合优化问题。
缺点
- 时间复杂度高:在最坏情况下,可能需要搜索整个解空间,导致时间复杂度较高。
- 空间复杂度高:需要存储整个状态空间树,可能导致较高的空间复杂度。
- 难以并行化:由于需要依赖全局的上下界信息,分支限界法难以进行并行化处理。
改进措施
- 根据树分支设计优先策略: 结点少的分支优先,解多的分支优先
- 利用搜索树的对称性剪裁子树
- 分解为子问题: 求解时间 f(n) = c2n,组合时间 T = O(2^(n/k)) 如果分解为 k 个子问题,每个子问题大小为 n/k。求解时间为
- 启发式搜索:结合启发式信息,优先搜索更有可能产生最优解的分支,提高搜索效率。
- 动态调整界限:在搜索过程中动态调整上下界,以便更早地进行有效的剪枝。
- 混合算法:结合其他优化算法,如动态规划或遗传算法,以提高整体效率。
代表性问题
- 旅行商问题:寻找一条经过所有城市且总距离最短的路径。
- 0-1背包问题:在给定的重量限制下,选择若干物品使得总价值最大。
- 作业调度问题:在给定的时间和资源限制下,安排作业顺序以最小化总完成时间。
用分支限界法设计算法的步骤是:
(1)针对所给问题,定义问题的解空间(对解进行编码);
(2)确定易于搜索的解空间结构(按树或图组织解) ;
(3)以广度优先或以最小耗费(最大收益)优先的方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。
分支限界法与回溯法的区别:
相同点:都是一种在问题的解空间树T中搜索问题解的算法。
不同点:(1)求解目标不同;
(2)搜索方式不同;
(3)对扩展结点的扩展方式不同;
(4)存储空间的要求不同。