Hoppz算法笔记
前言
2023_02_18
还是太菜了,笔记基于
《算法导论》 && 《数据结构与算法分析 C++描述》
,想着为复试准备(虽然很大程度上今年是考不上了),就开始重看算法导论,前几年大致翻过几页,感觉数学证明太多直接放弃了,想着学了一点微积分,线代,概率论的知识(虽然学的不好,但总是有点基础了)就又开始看了
1、基础知识
1.1、排序算法
1.1.1、插入排序(insertionsort)
插入排序与我们整理扑克牌类似。
开始时,我们左手为空并且桌子上的牌面向下(意思就是我们并不会在意牌出现的顺序)。然后,我们每次从桌子上拿走一张牌并将它插入左手正确的位置。
《算法导论》p10
void insertion_sort_ascend(vector<int> &vec)
{
if( vec.size() == 1 ) return ;
for(int j = 1; j < vec.size(); j++){
int key = vec[j];
// insert a[j] intro sorted sequence a[1...j-1];
int i = j - 1;
while( i >= 0 && vec[i] > key ){
vec[i + 1] = vec[i];
i--;
}
vec[i + 1] = key;
}
}
void insertion_sort_descend(vector<int> &vec)
{
if( vec.size() == 1 ) return ;
for(int j = 1; j < vec.size(); j++){
int key = vec[j];
int i = j - 1;
while( i >= 0 && vec[i] < key ){
vec[i + 1] = vec[i];
i--;
}
vec[i+1] = key;
}
}
1.1.2、冒牌排序(bubblesort)
在插入排序(Ascend版本)中 unsorted
数组中的 key
会与 sorted
中的数字交换,找到它在 sorted
数组中的正确位置。冒泡排序则是把unsorted
数组中的最大值交换到 j-1
的位置且 \([j-1,n]\) 形成新的 sorted
数组。
void Bubble_Sort(vector<int> &a)
{
for(int i = a.size() - 1; i > 0 ; i--){
for(int j = 1 ; j <= i ; j++){
if( a[j-1] > a[j] ) swap(a[j-1],a[j]);
}
}
}
1.1.3、选择排序(selectionsort)
首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
void Selection_Sort(vector<int> &a)
{
for(int i = 0; i < a.size() ; i++ ){
int mi_loc = i;
for(int j = i + 1; j < a.size() ; j++){
if( a[j] < a[mi_loc] ) mi_loc = j;
}
swap(a[i],a[mi_loc]);
}
}
1.1.4、希尔排序
1.1.5、归并排序(megersort)
许多有用的算法在结构上是递归的:为了解决一个 给定的问题,算法一次或多次递归地调用其自身以解决紧密相关的若干子问题。
这些算法典型地遵循分治的思想:
将原问题分解为几个规模较小但类似于原问题的子问题,递归的求解这些子问题,然后再合并这些子问题的解来建立原问题的解。
分治模式在每层递归时都有三个步骤:
- 分解原问题为若干子问题,这些子问题都是原问题的规模较小的实例。
- 解决这些子问题,递归地求解各子问题。然而,若子问题的规模足够小,则直接求解。
- 合并这些子问题的解成原问题的解。
归并排序算法完全遵循分治模式。直观上其操作如下:
- 分解:分解待排序的 \(n\) 个元素的序列成各具 \(\frac{n}{2}\) 个元素的两个子序列
- 解决:使用归并排序递归的排序两个序列
- 合并:合并两个已排序的子序列以产生排序答案
当排序的序列长度为1时,递归
开始回升
,在这种情况下不需进行任何操作,因为长度为 \(1\) 的每个序列都已排好序。
详细操作见 《算法导论》-p17
带哨兵的merge
注意: 使用 vector
初始化第一个迭代器不用 +1
,后面一个迭代器要 +1
const int inf = 0x3f3f3f3f;
void merge(vector<int> &a,int p, int q,int r)
{
vector<int> v1(a.begin()+p,a.begin()+q + 1);
vector<int> v2(a.begin()+q+1,a.begin()+r + 1);
v1.push_back(inf),v2.push_back(inf);
int i = p,v1_loc = 0, v2_loc = 0;
while(i <= r){
if( v1[v1_loc] <= v2[v2_loc] ){
a[i++] = v1[v1_loc++];
} else {
a[i++] = v2[v2_loc++];
}
}
}
void merge_sort(vector<int> &a,int p, int r)
{
if(p < r){
int q = (p + r) >> 1;
merge_sort(a,p,q);
merge_sort(a,q+1,r);
merge(a,p,q,r);
}
}
不带哨兵的 merge
void merge(vector<int> &a,int p, int q,int r)
{
vector<int> v1(a.begin()+p,a.begin()+q + 1);
vector<int> v2(a.begin()+q+1,a.begin()+r + 1);
int i = p,v1_loc = 0, v2_loc = 0;
while(i <= r && v1_loc < v1.size() && v2_loc < v2.size() ){
if( v1[v1_loc] <= v2[v2_loc] ){
a[i++] = v1[v1_loc++];
} else {
a[i++] = v2[v2_loc++];
}
}
if( v1_loc != v1.size() ) while( i <= r) a[i++] = v1[v1_loc++];
if( v2_loc != v2.size() ) while( i <= r) a[i++] = v2[v2_loc++];
}
2.1.2.1、归并排序解决逆序对问题
假设 \(A[1..n]\) 是一个有 \(n\) 个不同数的数组。若 \(i<j\) 且 \(A[i]>A[j]\),则对偶 \((i,j)\) 称为 \(A\) 的一个 逆序对
-
由集合 \(\{1,2,···,n\}\) 构成的什么数组具有最多逆序对?
- 降序排列的数组具有最多的逆序对,共 \(\frac{n(n-1)}{2}\) 对。
-
插入排序的运行时间与输入数组中逆序对的数量之间是什么关系?
- 插入排序的主要思想讲数组分为 sorted 数组和 unsorted 数组,当我们处理到
j
元素时while
循环中 \(a[i]\) 与 \(a[i+1]\) 交换的次数即为逆序对的数量
- 插入排序的主要思想讲数组分为 sorted 数组和 unsorted 数组,当我们处理到
-
使用归并排序来处理逆序对问题
-
逆序对的核心思想是,找到这个数之前有多少个比它大的数。
在一次
merge
操作中(已没有哨兵的版本为例),在归并的过程中右边的数组
存在比左边数组
小的时候,那么在左边数组中剩下的元素都是此时右边的这个数的逆序数,所有我们在代码中只用加一个计数器就可以了。
-
1.1.6、堆排序(heapsort)
与归并排序一样,但不同于插入排序的是,堆排序的时间复杂度是 \(\Theta(n \lg_{}{n})\) 。
而与插入排序一样,但不同于归并排序的是,堆排序同样具有具有原址空间性:任何时候都只需要常数个额外的元素空间储存临时的数据。因此,堆排序是集合了我们前面两种排序算法优点的一种排序算法。
-
(二叉)堆是一个数组,可以被看成一个近似的完全二叉树。
-
二叉堆可以分为两种形式:最大堆和最小堆
最大堆:除了根节点都要满足
\[A[Parent(i)]\ge A[i] \]最小堆:除了根节点都要满足
\[A[Parent(i)]\le A[i] \]在堆排序中,我们使用的是最大堆,最小堆通常用于构造优先队列
int heap_size;
void Max_Heapify(int a[],int i)
{
int l_son = i<<1;
int r_son = i<<1|1;
int max_loc;
if( l_son <= heap_size && a[i] < a[l_son] ) max_loc = l_son;
else max_loc = i;
if( r_son <= heap_size && a[max_loc] < a[r_son] ) max_loc = r_son;
if(max_loc != i){
swap(a[max_loc],a[i]);
Max_Heapify(a,max_loc);
}
}
void Build_Heap(int a[])
{
for(int i = (heap_size>>1)+1; i > 0 ; i-- ) Max_Heapify(a,i);
}
void Heap_Sort(int a[])
{
Build_Heap(a);
for(int i = heap_size ; i >= 2; i--){
swap(a[1],a[i]);
heap_size--;
Max_Heapify(a,1);
}
}
1.1.6.1、最大优先队列
int heap_size;
int Heap_Maximum(int a[]){return a[1];}
void Max_Heapify(int a[],int i)
{
int l_son = i<<1;
int r_son = i<<1|1;
int max_loc;
if( l_son <= heap_size && a[l_son] > a[i] ) max_loc = l_son;
else max_loc = i;
if( r_son <= heap_size && a[r_son] > a[max_loc] ) max_loc = r_son;
if( max_loc != i ){
swap(a[i],a[max_loc]);
Max_Heapify(a,max_loc);
}
}
int Heap_Extract_Max(int a[])
{
if( heap_size < 1 ) return -0x3f3f3f3f;
int Max = a[1];
a[1] = a[heap_size--];
Max_Heapify(a,1);
return Max;
}
void Heap_Increase_Key(int a[],int i,int key)
{
if( key < a[i] ) return ;
a[i] = key;
int parent_loc = i >> 1;
// 插入排序的思想
while( parent_loc >= 1 && a[i] > a[parent_loc] ){
swap(a[i],a[parent_loc]);
i = parent_loc;
parent_loc = i >> 1;
}
}
void Heap_Max_Insert(int a[],int key)
{
a[++heap_size] = -0x3f3f3f3f;
Heap_Increase_Key(a,heap_size,key);
}
1.1.6.2、最小优先队列
int heap_size;
int Heap_Minimum(int a[]){return a[1];}
void Min_Heapify(int a[],int i)
{
int l_son = i<<1;
int r_son = i<<1|1;
int max_loc;
if( l_son <= heap_size && a[l_son] < a[i] ) max_loc = l_son;
else max_loc = i;
if( r_son <= heap_size && a[r_son] < a[max_loc] ) max_loc = r_son;
if( max_loc != i ){
swap(a[i],a[max_loc]);
Min_Heapify(a,max_loc);
}
}
int Heap_Extract_Min(int a[])
{
if( heap_size < 1 ) return -0x3f3f3f3f;
int Max = a[1];
a[1] = a[heap_size--];
Min_Heapify(a,1);
return Max;
}
void Heap_Decrease_Key(int a[],int i,int key)
{
if( key > a[i] ) return ;
a[i] = key;
int parent_loc = i >> 1;
while( parent_loc >= 1 && a[i] < a[parent_loc] ){
swap(a[i],a[parent_loc]);
i = parent_loc;
parent_loc = i >> 1;
}
}
void Heap_Min_Insert(int a[],int key)
{
a[++heap_size] = 0x3f3f3f3f;
Heap_Decrease_Key(a,heap_size,key);
}
1.1.7、快速排序(quicksort)
与归并排序一样,快速排序也使用了分治的思想。下面是对一个典型的子数组 \(A[l..r]\) 进行快速排序的三步分治过程:
- 分解:数组 \(A[l..r]\) 被划分为两个(可能为空)子数组 \(A[l..mid]\) 和 \(A[mid+1..r]\),两数组要满足第一个数组要小于等于一个给定的
key(一般为 a[mid] or a[l] or a[r])
,第二个数组大于等于key
。 - 解决:通过递归调用快速排序,对子数组 \(A[l..mid]\) 和 \(A[mid+1..r]\) 进行排序。
- 合并:因为子数组都是原址排序的(没有产生额外的空间),所以不需要合并操作。
下面算法与书上的代码不同,书上的算法当数据全为同一个数时,无法进一步划分数组。(思考
7-2
)下面代码采用的是思考
7-1
的Hoare
划分
int Partition_Hoare(int a[],int l,int r)
{
int i = l - 1, j = r + 1,key = a[ (l+r)>>1 ];
while(true ){
// 这里不能写 <= 以及 >= 如 3 3 1 2 2
// 当最小值为 key 时,第一轮 j 会一直减到 -1
do{i++;}while( a[i] < key );
do{j--;}while( a[j] > key );
if( i < j ) swap(a[i],a[j]);
else return j;
}
}
void Quick_Sort(int a[],int l,int r)
{
if( l < r ){
int mid = Partition_Hoare(a,l,r);
Quick_Sort(a,l,mid);
Quick_Sort(a,mid+1,r);
}
}
1.1.7.1、快速选择排序
相当于
nth_elment()
在 \(\Theta(n)\) 的时间内返回这个数组第 \(k\) 小的数
Quick_Sort()
中的递归边界非常重要!!
int Partition_Hoare(int a[],int l,int r)
{
int i = l - 1, j = r + 1, key = a[ (l+r)>>1 ];
while(true){
do{i++;}while( a[i] < key );
do{j--;}while( a[j] > key );
if( i < j ) { swap( a[i],a[j] ); }
else return j;
}
}
int Quick_Sort(int a[],int l,int r,int k)
{
if( l >= r ) return a[l];
int j = Partition_Hoare(a,l,r);
// 如果 k 在前段内
if( j - l + 1 >= k ) return Quick_Sort(a,l,j,k);
else return Quick_Sort(a,j+1,r, k - (j - l + 1) );
}
1.1.8、计数排序(countsort)
计数:记录比数小的数有多少个
计数排序的基本思想是:
对每一个输入元素 \(x\) ,确定小于 \(x\) 的元素的个数。
利用这一信息,就可以直接 \(x\) 放到它在输出数组中的位置上了。当有几个元素相同时u,这一方案要略做修改。
我们需要首先给出当前数组的最大值以及数组的大小,还需要两个辅助数组。
void Count_Sort(int a[],int Max,int Size)
{
// 记录大小为 a[i] 的数出现了多少次
for(int i = 1 ; i <= Size ; i++) cnt[ a[i] ]++;
// 有多少小于等于这个数的数
for(int i = 1 ; i <= Max ; i++) cnt[i] += cnt[i-1];
// 如果是正序的遍历是正确的,但不稳定
for(int i = Size; i >= 1 ; i-- ){
ans[ cnt[ a[i] ] ]= a[i];
--cnt[ a[i] ];
}
}
1.1.9、基数排序
1.1.10、桶排序
1.1.11、排序后记
插入排序、归并排序、堆排序及快速排序都是比较排序算法:它们都是通过对元素进行比较操作来确定输入数组的有序次序。
我们可以证明任意比较排序算法排序 \(n\) 个元素的最坏情况运行时间的下界为 \(\Theta(n\lg_{}{n} )\) ,从而证明堆排序和归并排序是渐近最优的比较排序算法。
1.2、分治算法
分治方法将问题划分为互不相交的子问题,递归地求解子问题,再将它们的解组合起来,求出原问题的解。
在分治策略中,我们递归地求解一个问题,在每层递归中应用如下三个步骤:
分治模式在每层递归时都有三个步骤:
- 分解原问题为若干子问题,这些子问题都是原问题的规模较小的实例(分解到不可再分的基问题,也就是递归的边界)。
- 解决这些子问题,递归地求解各子问题。然而,若子问题的规模足够小,则直接求解。
- 合并这些子问题的解成原问题的解。
当子问题足够大,需要递归求解时,我们称之为递归情况。当子问题变得足够小,不再需要递归时,我们说递归已经触底
,进入了基本情况。
传统上,在其代码中至少含有两个递归调用的例程叫作分治算法,而代码中只含一个递归调用的例程不是分治是分治算法。我们一般坚持子问题是不相交的(即基本上不重叠)。
1.2.1、最大子数组
在数组 \(A[1···n]\) 中寻找和最大的非空连续子数组
暴力算法
对于一段 \(n\) 天的日期,我们需要检查 \(\binom{n-1}{2} =\Theta (n^2)\) 个子数组,分别求和比较其值,求和的过程可以用前缀和数组来优化到 \(\Theta(n)\) 的初始化,每一次查询是 \(O(1)\) 的,依然是 \(\Theta(n^2)\) 的算法。
使用分治策略的求解方法
使用分治策略意味着我们要将子数组划分为两个规模尽量相等的子数组。也就是说,找到子数组的中央位置 \(mid\) ,考虑求解两个子数组 \(A[l,mid],A[mid+1,r]\)。 \(A[l,r]\) 的所有连续子数组所处的位置必然属于下面三种情况之一:
- 完全位于 \(A[l,mid]\) 中
- 完全位于 \(A[mid+1,r]\) 中
- 跨越了中点的子数组。
所以,最大子数组也只会属于这三种情况之一。前两种情况可以递归的求解。
对于第三种情况,对于左边的数组,右端点一定是 \(mid\) ,左端点为 \(mid\) 到 \(l\) 其中的任意一个数,所以这一段的值就可以线性求解了。对于右边也是同样的情况。
int Find_Max_Crossing_Subarray(int a[],int l,int r)
{
int mid = l + r >> 1;
int l_max = -0x3f3f3f3f,r_max = -0x3f3f3f3f,l_sum = 0,r_sum =0;
// find max subarray in left side
for(int i = mid ; i >= l ; i --) {
l_sum += a[i];
l_max = max(l_max,l_sum);
}
// find max subarray in right side
for(int i = mid+1 ; i <= r; i ++){
r_sum += a[i];
r_max = max(r_max,r_sum);
}
return l_max+r_max;
}
int Find_Maximum_Subarray(int a[],int l,int r)
{
if( l >= r ) return a[l];
int mid = l + r >> 1;
int mid_sum = Find_Max_Crossing_Subarray(a,l,r);
int l_sum = Max_Sequence(a,l,mid);
int r_sum = Max_Sequence(a,mid+1,r);
return max({mid_sum,l_sum,r_sum});
}
1.3、贪婪算法
1.4、动态规划
动态规划 Dynamic Programming
与分治方法相似,都是通过组合子问题的解来求解原题(这里,Programming
指的是一种表格法,并非编写计算机程序),与分治的问题划分方法将问题划分为互不相交的子问题
不同的是,动态规划应用于子问题重叠的情况,即不同的子问题具有公共的子子问题(子问题的求解是递归进行的,将其划分为更小的子子问题)。