基础知识
算法复杂度分析
1、非递归算法
结构 | 复杂度 |
---|---|
for/while循环 | 循环体内计算时间×循环次数 |
嵌套循环 | 循环体内计算时间×所有循环次数 |
顺序语句 | 各语句计算时间相加 |
if-else语句 | if语句计算时间和else语句计算时间的较大 |
2、递归算法
代入法、递归树、主定理
- 递归树
归并排序算法复杂度计算过程如下:
- 主定理方法
主定理方法应用于如下的递归形式:T(n)=aT(n/b)+f (n)。其中,a≥1,b>1,f 是渐近正的。
3、摊还分析
聚合分析、核算法、势能法
算法
一、分治与递归
分治策略:将一个原始问题分解为k个规模更小的相同子问题。
分治的基本过程:
1)通过分解将问题规模变小;
2)递归的处理小规模问题
3)将小规模问题的解合并为原始问题的解
递归算法:直接或间接地调用自身的算法
递归函数:用函数自身给出定义的函数
边界条件与递归方程是递归函数的二要素,递归函数只有具备了这两个要素,才能在有限次计算后得出结果。
二、动态规划
动态规划基本步骤:
1. 找出最优解的性质,并刻划其结构特征
2. 递归地定义最优值
3. 以自底向上的方式计算出最优值
4. 根据计算最优值时得到的信息,构造最优解
动态规划算法的基本要素:
最优子结构性质和重叠子问题性质
问题的最优解包含着其子问题的最优解,这种性质称为最优子结构性质 ;递归算法求解问题时,每次产生的子问题并不总是新问题,有些子问题被反复计算多次,这种性质称为子问题的重叠性质。
0-1背包问题
给定 n 种物品和一背包,物品 i 的重量是 wi,其价值为 vi,背 包的容量为 C。问:应如何选择装入背包的物品,使得装入背 包中物品的总价值最大?
- 二维dp数组
1、定义dp数组
dp[i][j] 表示从下标为[0-i]的物品里任意取,放进容量为j的背包,价值总和最大是多少。
2、递归方程
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i])
3、dp数组初始化
vector<vector<int>> dp(w.size(), vector<int>(C + 1, 0));
for (int j = w[0]; j <= C; j++) {
dp[0][j] = v[0];
}
4、代码实现
vector<int> w = { 1, 3, 4 };
vector<int> v = { 15, 20, 30 };
int C = 4;
// 二维数组
vector<vector<int>> dp(w.size(), vector<int>(C + 1, 0));
// 初始化
for (int j = w[0]; j <= C; j++) {
dp[0][j] = v[0];
}
// weight数组的大小 就是物品个数
for (int i = 1; i < w.size(); i++) { // 遍历物品
for (int j = 0; j <= C; j++) { // 遍历背包容量
if (j < w[i])
dp[i][j] = dp[i - 1][j];
else
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);
}
}
cout << dp[w.size() - 1][C] << endl;
- 一维dp数组
1、定义dp数组
dp[j]表示:容量为j的背包,所背的物品价值可以最大为dp[j]。
2、递归方程
dp[j] = max(dp[j], dp[j - weight[i]] + value[i])
3、dp数组初始化
假设物品价值都是大于0的,所以dp数组初始化的时候,都初始为0就可以了。
4、代码实现
vector<int> w = { 1, 3, 4 };
vector<int> v = { 15, 20, 30 };
int C = 4;
// 初始化
vector<int> dp(C + 1, 0);
for (int i = 0; i < w.size(); i++) { // 遍历物品
for (int j = C; j >= w[i]; j--) { // 遍历背包容量
dp[j] = max(dp[j], dp[j - w[i]] + v[i]);
}
}
cout << dp[C] << endl;
三、贪心算法
贪心算法总是作出在当前看来最好的选择,也就是说贪心算法并不从整体最优考虑,它所作出的选择只是在某种意义上的局部最优选择。当然,希望贪心算法得到的最终结果也是整体最优的。
虽然贪心算法不能对所有问题都得到整体最优解,但对许多问题它能产生整体最优解,如:单源最短路经问题、最小生成树问题等。在一些情况下,即使贪心算法不能得到整体最优解,其最终结果却是最优解的很好近似。
贪心算法求解的问题具有的两个重要性质:贪心选择性质(归纳法)和最优子结构性质(反证法)。
- 贪心选择性质:
贪心选择性质是指所求问题的整体最优解可以通过一系列局部最优的选择,即贪心选择来达到——这是贪心算法可行的第一个基本要素,也是贪心算法与动态规划算法的主要区别。
动态规划算法通常以自底向上的方式解各子问题,而贪心算法则通常以自顶向下的方式进行,以迭代的方式作出相继的贪心选择,每作一 次贪心选择就将所求问题简化为规模更小的子问题。
对于一个具体问题,要确定它是否具有贪心选择性质,必须证明每一步所作的贪心选择最终导致问题的整体最优解。
- 最优子结构性质
当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质 ——问题的最优子结构性质是该问题可用动态规划算法或贪心算法求解的关键特征。
动态规划 | 贪心算法 |
---|---|
最优子结构性质、重叠子问题性质 | 最优子结构性质、贪心选择性质 |
自底向上 | 自顶向下 |
1、活动安排问题
由于输入的活动以其完成时间的非减序排列,所以贪心算法每次总是选择具有最早完成时间的相容活动加入集合 A 中。直观上,按这种方法选择相容活动为未安排活动留下尽可能多的时间,也就是说,该算法的贪心选择的意义是使剩余的可安排时间段极大化,以便安排尽可能多的相容活动。若被检查的活动 i 的开始时间 si 小于最近选择的活动 j 的结束时间 f i,则不选择活动 i,否则选择活动 i 加入集合 A 中。
void GreedySelector(int n, vector<int> s, vector<int> f, vector<int> A)
{//各活动的起始时间和结束时间存储于数组 s 和 f 中且按结束时间的非减序排列
//数组A记录该活动是否被选中
A[1] = true;
int j = 1;
for (int i = 2; i <= n; i++) {
if (s[i] >= f[j]){
A[i] = true;
j = i;
}
else
A[i] = false;
}
}
2、哈夫曼编码
问题:【哈夫曼编码算法用字符在文件中出现的频率表来建立一个用 0,1 串表示各字符的最优表示方式。给出现频率高的字符较短的编码,出现频率较低的字符以较长的编码,以缩短总码长。
前缀码:对每一个字符规定一个 0,1串作为其代码,并要求任一字符的代码都不是其它字符代码的前缀,这种编码称为前缀码。
最优前缀码:表示最优前缀码的二叉树总是一棵完全二叉树(树中 任一结点都有 2 个儿子结点)。平均码长定义为: ,使平均码长达到最小的前缀码编码方案称为给定编码字符集 C 的最优前缀码。
哈夫曼编码:哈夫曼提出构造最优前缀码的贪心算法,由此产生的 编码方案称为哈夫曼编码。】
方法:
哈夫曼算法以自底向上的方式构造表示最优前缀码的二叉树 T。 算法以 |C| 个叶结点开始,执行 |C|-1次的“合并”运算后产生最终所要求的树 T。
3、单源最短路径
经典题目
排序算法(时间下界为O(nlogn))
1、插入排序——O()
算法描述:
- 把待排序的数组分成已排序和未排序两部分,初始的时候把第一个元素认为是已排好序的。
- 从第二个元素开始,在已排好序的子数组中寻找到该元素合适的位置并插入该位置。
- 重复上述过程直到最后一个元素被插入有序子数组中。
适用场景:
插入排序由于O( n2 )的复杂度,在数组较大的时候不适用。但是,在数据比较少的时候,是一个不错的选择,一般做为快速排序的扩充。
2、归并排序——O(nlogn)
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
算法描述:
- 递归法(Top-down)
- 申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
- 设定两个指针,最初位置分别为两个已经排序序列的起始位置
- 比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
- 重复步骤3直到某一指针到达序列尾
- 将另一序列剩下的所有元素直接复制到合并序列尾
- 迭代法(Bottom-up)
- 将序列每相邻两个数字进行归并操作,形成ceil(n/2)个序列,排序后每个序列包含两/一个元素
- 若此时序列数不是1个则将上述序列再次归并,形成ceil(n/4)个序列,每个序列包含四/三个元素
- 重复步骤2,直到所有元素排序完毕,即序列数为1
适用场景:
归并排序在数据量比较大的时候也有较为出色的表现(效率上),但是,其空间复杂度O(n)使得在数据量特别大的时候(例如,1千万数据)几乎不可接受。而且,考虑到有的机器内存本身就比较小,因此,采用归并排序一定要注意。
3、快速排序——平均O(nlogn)、最坏 O()
在快速排序中,记录的比较和交换是从两端向中间进行的,关键字较大的记录一次就能交换到后面单元,关键字较小的记录一次就能交换到前面单元,记录每次移动的距离较大,因而总的比较和移动次数较少。
算法描述:
- 从数列中挑出一个元素,称为"基准"(pivot),
- 重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任何一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
- 递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
稳定性:
快速排序并不是稳定的。这是因为我们无法保证相等的数据按顺序被扫描到和按顺序存放。
适用场景:
快速排序通常是归并排序速度的两倍;快速排序在缓存和虚拟内存上同样工作良好 —— 原址的。快速排序的使用空间是O(1);其主要的空间复杂都在递归上,最优的情况下空间复杂度为:O(logn) ;每一次都平分数组的情况;最差的情况下空间复杂度为:O( n ) ;退化为冒泡排序的情况。
快速排序在大多数情况下都是适用的,尤其在数据量大的时候性能优越性更加明显。但是在必要的时候,需要考虑下优化以提高其在最坏情况下的性能。
改进——随机快速排序
在快速排序算法的每一步中,当数组还没有被划分时,可以在a[p:r]中随机选出一个元素作为划分基准,这样可以使划分基准的选择是随机的,从而可以期望划分是较对称的。
删除字符串中的空格
#include<iostream>
using namespace std;
void foo(char* s) {
int i = 0, j = 0;
while (s[i] != '\0') {
if (s[i] == ' ') {
++j;
while (s[++i] == ' ');
}
s[++j] = s[++i];
}
s[j] = '\0';
}
int main() {
char s[] = "s d f h k";
foo(s);
cout << s;
return 0;
}