文章目录
前引
博主算法基础很差,算得上0基础,最近二个月的计划是好好学习数据结构和算法,但目前我还有个期末周要度过,故在这篇笔记中记录下一些算法的思想,即作为动手敲一遍的基础,也是为几天后的算法考试做准备。
一.分治法 Divide and Conquer
1.1 思想
一句话,分而治之 Divide and Conquer
具体来说,就是把一个大问题分解成相似的小问题,解决这些小问题,用小问题的解构造大问题的解。把大的问题分解成小问题的这个过程就叫 “分”,解决小问题的过程就叫 “治”,这跟递归的思想很类似,因此解决小问题的方法往往是递归。(分治法是一种思想,递归是一种编程技巧)
所以分治法的三大步骤是:
「分」:将问题划分为多个子问题,这些子问题是同一问题的较小实例。
「治」:通过递归来解决子问题。如果子问题的大小足够小,达到基线条件,则只需以直接的方式解决子问题。
「合」:将子问题的解决方案组合成整个问题的解决方案。
1.2 归并排序 merge_sort
给一个数组排序,等于给数组的两半边分别排序再合并。典型的先分再合。显然,基线条件:数组中仅剩一个元素
1 把一个数组分为左右两个子数组
2 用同样的方法解决子数组
3 用有序的子数组构造有序的数组
void merge_sort(Array)
if (only one element left) return; // Baseline condition
merge_sort(leftArray); //Left merge sort, so that the left subsequence is ordered
merge_sort(rightArray);//Right merge sort, so that the Right subsequence is ordered
merge(leftArray, rightArray);//Merge two sorted subarrays
}
每次合并操作的平均时间复杂度为O(n),而完全二叉树的深度为|log2n|。因此最好,最坏,平均时间复杂度均为O(nlogn)
参考:
图解排序算法(四)之归并排序 -博客园
史上最清晰的「归并排序」讲解 -思否
手撕的话可以学一下这篇(写完笔记才发现的好网站,先mark一下):
Merge Sort -GeeksforGeeks
1.3 快速排序 quick_sort
在数组中找到作为基准值的一个元素pivot
(通常取数组第一个元素),将数组分为两部分:<pivot
的元素放在左边,>pivot
的放在右边
这就是快排中的partition
操作,
这样我们得到了:
- 一个pivot值(有序)
- 在左边的,大小均小于pivot的子数组
- 在右边的,大小均大于pivot的子数组
接着继续对左右两个子数组执行partition
操作,直到数组中仅剩一个元素,达到基线条件。更深入的理解,如快速排序怎么体现了分治法思想,博主暂时还差点火候
quickSort(Array, low, high)
{
if (low < high) {
partition(Array, low, high);
quickSort(LeftArray); // left array
quickSort(RightArray); // right array
}
}
关于具体如何实现partition
操作: 定义两个用于标记的变量i ,j
,一个指向最左边,一个指向最右边。此时
int i = 0,j=Array.size()-1;
pivot = Array[i]; // pivot
然后,我们移动j
从最左边开始找第一个 <pivot
的元素,与r[i]
交换位置/直接覆盖,i++
;再移动i
从右边找到第一个 >pivot
的值,与r[j]
交换位置/直接覆盖,j--
;直到 i = j
,此时令 r[i] = piovt
时间复杂度:
每层要处理 n个元素故时间复杂度为 O(n),最好情况层数为log2n层,故最好时间复杂度为 O(nlogn);最坏情况层数为n层(数组有序且基准值为Array[0])故最坏时间复杂度为 O(n2)
参考:
快速排序算法——C/C++ -CSDN
史上最详细图解快速排序 -CSDN
手撕的话可以学一下这篇:
QuickSort -GeeksforGeeks
1.3 二分查找 binary_sort
二分查找每次都要舍弃一半,从留下的一半中寻找目标;而分治法把一个大问题分成多个小问题,递归地求这些小问题的解,最后再把它们合并起来。要说两者有什么联系,我的理解是,二分查找运用了分治法中简化大问题为小问题的思想,所以把二分查找归到减治法会更加合理。
待查找序列必须有序
步骤如下:
- 从整个数组的
middle
作为搜索键开始。 - 如果
target = middle
,则返回middle
的索引。 - 如果搜索键的值小于
middle
,则将区间缩小到下半部分。否则,将其缩小到上半部分。 - 从第二步开始反复检查,直到找到值或区间为空(
low>high
)。
因为这个算法比较简单,博主顺手写了一下它的递归实现:
int BinarySearch(const vector<int>&Array,int high,int low,int target)
{
while (low <= high)
{
int middle = (high + low) / 2;
if (Array[middle] == target)
return middle;
else if (Array[middle] < target)
return BinarySearch(Array, high, middle + 1, target);
else
return BinarySearch(Array, middle - 1, low, target);
}
return -1;
}
void Solution()
{
vector<int>Array = { 2,3,4,10,50 };
int high = Array.size() - 1;
int low = 0;
int target,index;
cout << "Value to Search by BinarySearch:";
cin >> target;
index = BinarySearch(Array, high, low, target);
if (index + 1)
cout << "The index of the value(0 base index):" << index;
else
cout << "Not Found!";
}
int main()
{
Solution();
}
时间复杂度:
二分查找与二叉判定树关系紧密
最坏情况:总共有n个元素,每次查找的区间大小就是n,n/2,n/4,…,n/2k,k = log2n ,所以时间复杂度为O(log n)
最好情况:O(1)
参考:
Binary Search -GeeksforGeeks
1.4 Strassen矩阵乘法
1.5 大整数乘法
大数据可以分解称高位和地位,比如1534268973可以表示为15342*10^5+68973,那么两个大数据都进行拆解后,可以通过十字相乘获得4个相对较小的乘法,将4个乘法的结果相加即可得到大数据相乘的结果。4个相对较小的乘法各自可以继续分解称4个更小的乘法,再进行合并。
n为奇数时高位补0
举个例子:
3278×41926
=(32×102+78)×(419×102+26)
=32×419×10^4 + 32 × 26 × 10^2 + 78×419×10^2 + 78×26
继续拆分:
32×419×10^4
=(3×10+2)×(41×10+9)×10^4
=3×41×10^6 + 3×9×10^5 + 2×41×10^5 + 2×9×10^4
=123×10^6 + 27×10^5 + 82×10^5 + 18×10^4
=13408×10^4
该部分转载自大整数乘法
二.减治法 Decrease and Conquer
2.1 思想
减治就是缩小问题的规模,比如说规模为 n 的缩小为n-1来求解,典型的如插入排序,对 n 个数的排序,就是先对 n-1 个数进行排序,最后缩小到基线条件,直接求解,然后再用小规模的解得到大规模的解。
对比之下,回看分治法:
分治是把问题划分成很多相似的子问题,比如 n 个数划分为 3 个 n/3 规模的数,子问题和原问题的类型相同,求解方式一样,故常递归来解。
减治法的三个主要变化形式:
1.减一个常量,常常是减1(例如插入排序)。
2.减一个常因子,常常是减去因子2(例如折半查找)。
3.减可变因子(例如欧几里得算法).
2.2 插入排序 insertion_sort
把对n个数的排序,运用减治法的思想,缩小为对n-1个数,n-2 … 个数的排序,基线条件:缩小为对一个数的排序
该算法把序列分成了发部分:有序部分,无序部分。在最开始,有序部分仅为序列的第一个元素,逐步的把无序部分的元素插入到有序部分中,最终在整体上形成一个有序序列
要按升序对大小为 N 的数组进行排序:
在数组上从
arr[1]
迭代到arr[N]
。
将待排序元素key
与前一个元素进行比较。
如果关键元素小于前一个元素,请将其与更前位的元素进行比较。在每一步比较中将较大的元素向上移动一位,为插入元素腾出空间。
该算法比较好写,博主顺手写了一下:
void InsertionSort(vector<int>& arr)
{
int len = arr.size();
int j, key;
for (int i = 1; i < len; i++)
{
int key = arr[i];
j = i - 1; //previous element
while (j >= 0 && arr[j] > key)//Compare the element to be sorted `key` with the previous element
{
arr[j + 1] = arr[j]; // Move the larger element up one place to make room for the inserted element
j--; //If the key element is less than the previous element, compare it with the previous element
}
arr[j + 1] = key;
}
}
void PrintArray(const vector<int>& arr)
{
for (int i = 0; i < arr.size(); i++)
cout << arr[i] <<" ";
cout << endl;
}
void Solution()
{
vector<int> arr = { 12, 11, 26, 5, 6, 15 };
InsertionSort(arr);
PrintArray(arr);
}
int main()
{
Solution();
}
时间复杂度:
最坏情况: 数组完全逆序,插入第2个元素时要考察前1个元素,插入第3个元素时,要考虑前2个元素,……,插入第N个元素,要考虑前 N - 1 个元素。因此,最坏情况下的比较次数是 1 + 2 + 3 + … + (N - 1),等差数列求和,结果为 N2/ 2,所以最坏情况下的复杂度为 O(N^2)。
最好情况: 数组已经是有序的,每插入一个元素,只需要考查前一个元素,因此最好情况下,插入排序的时间复杂度为O(N)
参考:
Insertion Sort -GeeksforGeeks 里面的动画很清晰
在这里mark一下线性查找中的哨兵变量:将数组的最后一个元素替换为要搜索的元素,然后对数组进行线性搜索,而不检查当前索引是否在数组的索引范围内,因为要搜索的元素肯定会在数组中找到Sentinel Linear Search
2.3 拓扑排序 topological_sort
用途:拓扑排序通常用来“排序”具有依赖关系的任务,达到以正确的顺序完成各项任务的目的。如果用一个DAG图来表示一个工程,其中每个顶点表示工程中的一个任务,用有向边 表示在做任务 B 之前必须先完成任务 A。故在这个工程中,任意两个任务要么具有确定的先后关系,要么是没有关系,绝对不存在互相矛盾的关系(环路),故拓扑排序仅用于有向无环图(DAG:Directed Acyclic Graph)。
步骤:
1 从 DAG 图中选择一个 没有前驱(即入度为0)的顶点并输出。
2 从图中删除该顶点和所有以它为起点的有向边。
3 重复 1 和 2直到当前的DAG 图为空或当前图中不存在无前驱的顶点为止。后一种情况说明有向图中必然存在环。
体现的减治法思想:把对n个有依赖关系的任务的排序,缩小为对n-1,n-2…个任务的排序,基线条件:一个仅依赖于其它任务,而不被其它任务依赖,在DAG中体现为入度为0的顶点。
对该有向无环图的拓扑排序可以是:5 4 2 3 1 0
因为在进行步骤一时可能有多个入度为0的顶点,故根据选择顺序的不同,拓扑排序可以有多种结果
好文:
什么是拓扑排序 -转载神奕博客
Topological Sorting -GeeksforGeeks
2.4 BFS&DFS in Grape
DFS:
- 选择一个结点作为起始结点(压入栈)
- 选择该结点的邻接结点中的一个访问
- 重复步骤二,直到一个顶点的邻接结点均被访问过
- 回溯到之前的顶点(弹出栈),如果该顶点有没被访问过的邻接结点,重复步骤二
用堆栈来实现,访问顶点则把它压入堆栈,目的是为了第四步的回溯
BFS:
- 选择一个结点作为起始结点(顶点入队)
- 访问该顶点所有的邻接顶点(该顶点出队,所有邻接顶点入队)
- 重复将步骤二用于队列的队首顶点
用队列实现,思路与层序遍历一致
2.5 欧几里得算法求最大公约数
欧几里得算法求最大公约数:两个整数的最大公约数是能够同时整除它们的最大的正整数。辗转相除法基于如下原理:两个整数的最大公约数等于其中较小的数和两数的相除余数的最大公约数。例如,252和105的最大公约数是21(252 = 21 × 12;105 = 21 × 5);因为252 / 105 = 2余42,所以105和42的最大公约数也是21。在这个过程中,较大的数缩小了,所以继续进行同样的计算可以不断缩小这两个数直至其中一个变成零。
三.动态规划 Dynamic Programming
3.1 思想
初步理解的思路: 先解决子问题,再逐步解决大问题,这看起来与前两个算法似乎差不多,但动态规划似乎强调了一点:它存储了中间子问题的答案,因为该类问题通过有重叠子问题因此优化了时间复杂度。
用于解决有以下两个特点的问题:
1.最优子结构
2. 重叠子问题
关于动态规划的经典问题 — 01背包问题,博主已经做过浅层次的理解: dp解决01背包问题
3.2 Floyd-Warshall算法
3.2.1 Warshall算法求传递闭包
传递闭包的概念:
在数学中,在集合X上的二元关系R 的传递闭包是包含R 的X 上的最小的传递关系。用于确定每个点是否能到达其他每个点。
用Warshall算法求传递闭包的步骤:
求一个图的邻接矩阵M的传递闭包A的步骤:
- 置新矩阵
A=M
- 置
k=1
(从第一列开始)- 对所有
i
(即所有的行),如果A[i,k]=1
(该列第i行元素为1),则对j=1…n(第1到n行)执行:A[i,j] = A[i,j]∨A[k,j]
(把第i行与第k行作逻辑加)k++
- 如果
k≤n
,则转到步骤(3),否则停止
例子可参考:Warshall算法求传递闭包 -CSDN
3.2.2 Floyd算法求多源最短路径
多源最短路径:给定一个图,求图中任意两个点之间的最短路径
求顶点i
到顶点j
的最短路径,先置最短路径为 dis[i,j]
,在路径中加入中间点k
,并比较dis[i,k]+dis[k,j]
与dis[i,j]
,使dis[i,j] = min(dis[i,k] +dis[k,j],dis[i,j])
,逐步增加中间点的个数,重复比较步骤,修改最短路径。
(基本思路就是我们从最开始的只允许经过 1 号顶点进行中转,接下来只允许 1,2 进行中转…允许经过 1~n 号所有的顶点进行中转,求任意两点的最短路径。)
for(int k = 1 ; k <= n ; k ++)
{
for(int i = 1 ; i <= n ; i ++)
{
for(int j = 1 ; j <= n ; j ++)
{
if(dis[i][j] > dis[i][k] + dis[k][j])
dis[i][j] = dis[i][k] + dis[k][j];
}
}
}
好文:
理解Floyd算法 -CSDN
十字交叉法求过程矩阵,得到结果集path
3.3 最优二叉查找树 Optimal Binary Search Tree
3.3.1 二叉查找树的代价
BST节点的代价是该节点的深度(指从根节点到该节点的最长简单路径边的条数)乘以其频率。规定根的深度为 1,所以根节点的代价为1*根节点频率
整个二叉查找树的代价为所有节点的代价之和
Input: keys[] = {10, 12}, freq[] = {34, 50}
10 12
\ /
12 10
I II
10 和 12 的搜索频率分别为 34 和 50。
树 I 的成本是 34*1 + 50*2 = 134
树 II 的成本是 50*1 + 34*2 = 118
求最优二叉树,就是对有序列<k1,k2,...,kn>
,频率为pi(i = 1~n)
求代价最小的二叉查找树
3.3.2 初步理解最优子结构
举个很容易理解的例子:假设你们学校有 10 个班,你已经计算出了每个班的最高考试成绩。那么现在我要求你计算全校最高的成绩,你会不会算?当然会,而且你不用重新遍历全校学生的分数进行比较,而是只要在这 10 个最高成绩中取最大的就是全校的最高成绩。
这个问题就符合最优子结构:可以从子问题的最优结果推出更大规模问题的最优结果。 让你算每个班的最优成绩就是子问题,你知道所有子问题的答案后,就可以借此推出全校学生的最优成绩这个规模更大的问题的答案。
因为显然没有重叠子问题,所以我们简单地求最值而用不着动态规划。
3.3.3 求解最优二叉查找树
二叉查找树中的最优子结构: 最优二叉查找树的子树,必然是最优二叉查找树 (以下以OBTS代替最优二叉查找树),反之,如果求出了所有子树的OBTS,则由所有子树构成的树必然是OBTS
由此我们得到求解OBTS的思路:
求有序序列 i 到j的OBTS,只需将 r (r从i到j变化)作为根节点,递归的求其左右子树的OBTS,公式如下
在这里插入图片描述
我们一个接一个地尝试所有节点作为根(r在第二项中从i到j变化), 当我们将第 r 个节点作为根节点时,我们递归地计算左子树(有序序列从 i 到 r-1) 和右子树(有序序列从 r+1 到 j) 的最优成本/代价。
第一项中将 i 到 j 的频率相加 的原因:
该项分为两部分:
对根结点
r
,深度为1
,显然节点代价是freq[r] * 1
对非根结点,在第二项代表的左右子树最小代价之和中,各节点的深度比在父树的深度少一,即除r之外的所有元素都比在子问题中计算的值下降1
个深度,所以在父问题中要统一的加上这个代价freq[k]*1 (k = i ~ r-1 、r-1 ~ j )
显然,将可以两部分合并为i 到 j 的频率之和:
参考:
Optimal Binary Search Tree | DP -GeekforGeeks
四. 贪婪算法 Greedy Algorithms
4.1 思想
在每一步都采取最优的做法,即每步都得到的是局部最优解,最后得到的就是全局最优解。它逐步构建解决方案,总是选择下一个提供最明显和直接好处的部分。因此,选择局部最优也导致全局解决方案的问题最适合贪婪算法。
4.2 最小生成树 Minimum Spanning Tree
什么是最小生成树
在实际问题中的体现:有N座城市,且已知这N座城市中任意两座城市之间建造道路所需要的费用,最少花费多少就可以使得任意两座城市都可以通过所建造的道路互相到达
对N个顶点,显然最小生成树(MST)应该有n-1条边
4.2.1 Prime算法
以上面的实例作为引例,把N座城市视为图的N个顶点,作为顶点集V,城市之间的建造道路的费用视为带权边,作为边集E。
Prime算法求最小生成树的做法如下:
- 初始化:Vnew= {x},其中
x
为顶点集V
中的任一节点(起始点),Enew= {}, 为空;- 在集合
E
中选取权值最小的边<u, v>
,其中u
为集合 Vnew中的元素,而v
不在 Vnew集合当中,并且v∈V
(如果存在有多条满足前述条件即具有相同权值的边,则可任意选取其中之一,),将v
加入集合 Vnew中,将<u, v>
边加入集合 Enew中- 重复下列操作,直到 Vnew= V(不能有回路)
显然,这里体现了贪婪的思想,我们在构造最小生成树时,每次都选取最近的顶点,做到每一步都是花费最小的,最终达到整体花费最少
邻接矩阵时间复杂度为 O(V^2)。如果输入图使用邻接表表示,那么 Prim 算法的时间复杂度可以在二叉堆的帮助下降低到 O(E log V)。
4.2.2 Kruskal算法
显然,Kruskal算法每次选取权重最小的边
时间复杂度:O(ElogE)
4.2.3 比较
Prim算法从顶点的角度出发,每次选择距离当前节点最近的节点加入,直到所有节点都加入。
Kruskal算法从边的角度出发,每次总是选择权重最小的边加入,直到加入n-1条边为止。(如果加入一条边后出现回路,则跳过这条边)。
Prim 算法在密集图中运行得更快。Kruskal 算法在稀疏图中运行得更快。
4.3 Dijkstra算法求单源最短路径
给定一个图和图中的一个源顶点v0
,找到从v0
到给定图中所有顶点的最短路径。
Dijkstra算法求最短路径与 Prim算法求最小生成树算法非常相似。与 Prim的MST一样,我们生成以给定源顶点v0
为根的 SPT(最短路径树)。我们维护两组顶点集S1
与S2
,S1
包含当前最短路径树中的顶点,S2
包含尚未包含在最短路径树中的顶点。在算法的每一步,我们都会找到S2中的一个顶点vi
,并且vi
与源顶点的距离相比S2
中其它所有顶点最小
步骤如下:
- 初始化:创建一个空集合
sptSet
(最短路径树集S1
),跟踪包含在最短路径树中的顶点。为图中的所有顶点分配一个距离值(源顶点v0
到顶点v
的距离d(v0,v),v∈S2
),将除v0
外的所有距离值初始化为INFINITE
(无限),显然源顶点v0
的距离值为0
- 选择顶点
v0
,将其包含在 sptSet 中。此时sptSet = {v0}
。将v0
包含到sptSet
后,更新其相邻顶点的距离值。- 选择一个顶点
u
,它在sptSet
中不存在并且具有最小距离值,将u
包含到sptSet
- 更新 u 的所有相邻顶点的距离值
d(v0,v)
(对于u
的每个相邻的顶点v
,如果u的距离值d(v1,u)
和边u-v的权重w(u,v)
之和小于v的距离值d(v0,v)
,则更新v
的距离值,反之则不变)。- 重复上述3、4步骤,直到
sptSet
包含给定图的所有顶点。最后,我们可以得到最短路径树(SPT)。
对步骤三,用一个公式描述:
d(v0,v)= min( d(v0,v) , d(v0,u) + d(u,v) )
显然,当v0与v没有直接相连时(即v不是v0的相邻节点),d(v0,v)为 INFINITE
,必然大于 d(vo,u) + d(u,v),此时d(v0,v)更新为d(vo,u) + d(u,v)。
比较 Dijkstra 与 Floyd:
Dijkstra算法是单源最短或 SSSP 算法的一个示例,即给定一个源顶点,它会找到从源顶点到所有其他顶点的最短路径。
Floyd Warshall算法是多源最短路径算法的一个示例,这意味着它计算任意两顶点间的最短路径。
Comparison of Dijkstra’s and Floyd–Warshall algorithms
参考:
Dijkstra’s shortest path algorithm -GeeksforGeeks
4.4 哈夫曼编码
哈夫曼编码为输入字符分配可变长度代码,分配代码的长度基于相应字符的频率。最频繁的字符得到最小的代码,最不频繁的字符得到最大的代码。任何一个字符的编码不能是另一个字符编码的前缀,否则会出现歧义
通过构造哈夫曼树得到哈夫曼编码:Huffman Coding | Greedy Algo-3
五. 蛮力法
5.1 选择排序
5.2 冒泡排序
六.算法基础概念
6.1 算法定义
算法是指一个被定义好的、计算机可施行其指示的有限步骤或次序
6.2 算法的五个特性
有穷性:算法在有限的步骤之后会自动结束而不会无限循环,并且每一个步骤可以在可接受的时间内完成
确定性:算法中的每一步都有确定的含义,不会出现二义性
可行性:算法的每一步都是可行的,也就是说每一步都能够执行有限的次数完成
输入: 算法具有0个或多个输入
输出: 算法至少有1个或多个输出
6.3.算法分析
如何比较算法?
为了比较算法,让我们定义一些客观的衡量标准:
执行时间:不是一个好的衡量标准,因为执行时间是特定于特定计算机的。
执行的语句数量:不是一个好的衡量标准,因为语句的数量因编程语言以及单个程序员的风格而异。
理想解决方案:假设我们将给定算法的运行时间表示为输入大小 n(即 f(n))的函数,并比较这些不同的函数对应于运行时间。这种比较与机器时间、编程风格等无关。
因此,可以使用理想的解决方案来比较算法。
在渐近分析中,我们根据输入大小评估算法的性能(我们不测量实际运行时间)。我们计算算法占用的时间(或空间)如何随着输入大小的增加而增加。
时间复杂度究竟是什么?
时间复杂度不是测量执行代码中每条语句所需的实际时间,而是考虑每条语句执行的次数。
f(n)的阶不高于g(n)的阶:f(n)=O(g(n));
f(n)的阶不低于g(n)的阶:f(n)=Ω(g(n));
f(n)与g(n)同阶:f(n)=θ(g(n))
时间复杂度,空间复杂度(辅助空间)
算法导论------渐近记号Θ、Ο、o、Ω、ω详解
如何理解时间复杂度 -知乎
七.P NP NPC NPH
好文:
复杂性类的类型 | P, NP, CoNP, NP hard 和 NP complete
什么是多项式时间 -知乎
.