这六天,学了什么呢?
目录
下面就来复习一下吧:
一、分治
分治(divide and conpuer)的全称为“分而治之”,也就是说,分治法将原问题划分成若干个规模较小而结构与原问题相同或相似的子问题,然后分别解决这些子问题,最后合并子问题的解,即可得到原问题的解。
上面的定义体现出分之分治发的三个步骤:
- 分解:将原问题分解为若干和原问题拥有相同或相似结构的子问题。
- 解决:递归求解所有子问题。如果存在子问题的规模小到可以直接解决,那就直接解决它。
- 合并:将子问题的解合并为原问题的解。
需要指出的是,分治法分解出的子问题应当是相互独立、没有交叉的。如果存在两个子问题有相交部分,那么不应该使用分治法解决。(通常用动态规划)
从广义上来说,分治法分解出的子问题个数只要大于0即可。但是从严格意义上来讲,一般把子问题个数为1的情况称为减治(decrease and conquer),而把子问题个数大于1的情况称为分治,不过通常情况下不必在意这些区别。另外,分治法通常用递归的手段去实现,但也可以通过非递归的手段去实现,可以视具体情况而定。
一道例题:循环比赛
通过挖样例找规律发现,对于每个边长为
2
n
2^n
2n的子矩阵,其左上的子矩阵和右下角的子矩阵相等,右上的子矩阵和左下的子矩阵相等。这道题明显能用分治解决。那就用递归吧:
void contest(int x, int y, int l, int s) { // x,y为左上角元素坐标 l 为指数,s为左上角元素的值
if(l == 0) { // 分到矩阵中只有一个元素了,直接将s赋值给答案
a[x][y] = s;
return ;
}
int t = pow(2, l); // 矩阵的边长
contest(x, y, l - 1, s); // 左上
contest(x, y + t / 2, l - 1, s + t / 2); // 右上
contest(x + t / 2, y, l - 1, s + t / 2); // 左下
contest(x + t / 2, y + t / 2, l - 1, s); // 右下
}
调用:
contest(1, 1, n, 1); //a[1][1] = 1;
下面是一些分治思想的典型应用:
1. 归并排序(merge sort)
归并排序是一种基于“归并”思想上的排序方法,这里介绍最基本的2-路归并排序。2-路归并排序的原理是,将序列两两分组,将序列归并为 ⌈ n 2 ⌉ \left \lceil \frac{n}{2} \right \rceil ⌈2n⌉ 个组,组内单独排序;然后将这些组再两两归并,生成 ⌈ n 4 ⌉ \left \lceil \frac{n}{4} \right \rceil ⌈4n⌉ 个组,组内再单独排序;以此内推,直到剩下一个组为止。
归并排序的时间复杂度为 O ( n O(n O(nlog n ) n) n)。
下面来看一个例子。
要将序列 { 66 , 12 , 33 , 57 , 64 , 27 , 18 } \left \{ 66,12,33,57,64,27,18 \right \} {66,12,33,57,64,27,18} 进行2-路归并排序。
- 第一趟。两两分组,得到四组: { 66 , 12 } \left \{ 66,12 \right \} {66,12}、 { 33 , 57 } \left \{ 33,57 \right \} {33,57}、 { 64 , 27 } \left \{ 64,27 \right \} {64,27}、 { 18 } \left \{ 18 \right \} {18},组内单独排序,得到新序列 { { 12 , 66 } , { 33 , 57 } , { 27 , 64 } , { 18 } } \left \{\left \{ 12,66 \right \},\left \{ 33,57 \right \},\left \{ 27,64 \right \},\left \{ 18 \right \} \right \} {{12,66},{33,57},{27,64},{18}}。
- 第二趟。将四个组继续两两分组,得到两组: { 12 , 66 , 33 , 57 } \left \{12,66,33,57 \right \} {12,66,33,57}、 { 27 , 64 , 18 } \left \{27,64,18 \right \} {27,64,18},组内单独排序。得到新序列 { { 12 , 33 , 57 , 66 } , { 18 , 27 , 64 } } \left \{\left \{ 12,33,57,66 \right \},\left \{ 18,27,64 \right \} \right \} {{12,33,57,66},{18,27,64}}。
- 第三趟。将两个组继续两两分组,得到一组: { 12 , 33 , 57 , 66 , 18 , 27 , 64 } \left \{12,33,57,66,18,27,64 \right \} {12,33,57,66,18,27,64},组内单独排序,得到新序列 { 12 , 18 , 27 , 33 , 57 , 64 , 66 } \left \{12,18,27,33,57,64,66 \right \} {12,18,27,33,57,64,66}。算法结束。
排序过程:
代码实现:
void Merge_Sort(int s, int e) {
if(s == e) return ;
int mid = (s + e) / 2;
int i = s, j = mid + 1, k = s;
Merge_Sort(s, mid); // 分
Merge_Sort(mid + 1, e);
int r[Maxn];
while(i <= mid and j <= e) // 合
if(a[i] <= a[j]) r[k++] = a[i++];
else r[k++] = a[j++];
while(i <= mid) r[k++] = a[i++]; // 处理剩余元素
while(j <= e) r[k++] = a[j++];
for(int i = s;i <= e; ++i) a[i] = r[i];
}
例题:归并排序
时间: 217 m s 217 ms 217ms
再来亿道例题:求逆序对
逆序对的定义:当 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j] 且 i < j i < j i<j 时, a [ i ] a[i] a[i]和 a [ j ] a[j] a[j] 互为逆序对
解析:
在2-路归并排序的过程中,我们只需要在
else r[k++] = a[j++];
后面进行答案的统计即可:
else r[k++] = a[j++], ans += mid - i + 1; // a[i] ~ a[mid] 的所有元素都与 a[j] 互为逆序对
经过前面的排序,已经将 a [ i ] a[i] a[i]所在的子序列排为递增序列了,并且此时 i i i恒小于 j j j。那么若此时 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j],则其后从 a [ i + 1 ] a[i + 1] a[i+1]~ a [ m i d ] a[mid] a[mid]的每个元素都比 a [ i ] a[i] a[i]大,则它们都与 a [ j ] a[j] a[j]互为逆序对,所以此时就有了 m i d − i + 1 mid - i + 1 mid−i+1个逆序对。
我们发现,2-路归并逆序对于逆序对的计算,是很方便的。所以,2-路归并是常用于计算逆序对的算法。
2.快速排序(quick sort)
快速排序是对冒泡排序(bubble sort)的一种改进。
快速排序的时间复杂度为 O ( n O(n O(nlog n ) n) n)。
定义确实没有了,直接来看具体操作:
快速排序首先需要解决这样一个问题:对于一个序列 A [ 1 ] A[1] A[1]、 A [ 2 ] A[2] A[2]、…、 A [ n ] A[n] A[n],调整序列中元素的位置,使得 A [ 1 ] A[1] A[1](原序列的 A [ 1 ] A[1] A[1],下同)的左侧所有元素都不超过 A [ 1 ] A[1] A[1]、右侧所有元素都大于 A [ 1 ] A[1] A[1]。例如对序列 { 3 , 1 , 4 , 5 , 9 , 6 } \left\{3,1,4,5,9,6\right\} {3,1,4,5,9,6}来说,可以调整序列中元素的位置,形成序列 { 3 , 1 , 4 , 5 , 9 , 6 } \left\{3,1,4,5,9,6\right\} {3,1,4,5,9,6},这样就让 A [ 1 ] = 5 A[1]=5 A[1]=5左侧的所有元素都不超过它、右侧的所有元素都大于它。
对这个问题来说可能会有多种方案,所以只需要提供其中一种方案。下面给出速度最快的做法,思想就是双指针(two pointers):
- 先将 A [ 1 ] A[1] A[1]设为基准数存至某个临时变量 t e m p temp temp,并令两个下标 l e f t left left、 r i g h t right right分别指向序列首尾(令 l e f t = 1 left = 1 left=1、 r i g h t = n right = n right=n)。
- 只要 r i g h t right right指向的元素 A [ r i g h t ] A[right] A[right]大于 t e m p temp temp,就将 r i g h t right right不断左移;当某个时候 A [ r i g h t ] < = t e m p A[right]<=temp A[right]<=temp时,将元素 a [ r i g h t ] a[right] a[right]挪到 l e f t left left指向的元素 A [ l e f t ] A[left] A[left]处。
- 只要 l e f t left left指向的元素 A [ l e f t ] A[left] A[left]大于 t e m p temp temp,就将 l e f t left left不断右移;当某个时候 A [ l e f t ] > t e m p A[left]>temp A[left]>temp时,将元素 a [ l e f t ] a[left] a[left]挪到 r i g h t right right指向的元素 A [ r i g h t ] A[right] A[right]处。
- 重复2、3,直到 l e f t left left与 r i g h t right right相遇,把 t e m p temp temp(原 A [ 1 ] A[1] A[1])放到相遇的地方。
4步走完之后,只需要以同样的方式快速排序 a [ l e f t ] a[left] a[left]$a[i-1]$和$a[i+1]$ a [ r i g h t ] a[right] a[right]两个区间即可。
排序过程:
代码实现:
void Quick_Sort(int left, int right) {
int i = left, j = right;
if(left >= right) return ;
int temp = a[i]; // 基准数 // 1
while(i < j) {
while(a[j] >= temp and i < j) --j; // 2
while(a[i] <= temp and i < j) ++i; // 3
swap(a[i], a[j]);
}
a[left] = a[i]; // 4
a[i] = temp;
Quick_Sort(left, i - 1); // 继续往下分
Quick_Sort(i + 1, right);
}
问:为什么要先动right先动而不是left?
答:因为先动left可能导致其走过它的位置,请举例理解。
例题:快速排序
时间: 206 m s 206 ms 206ms
注:前两个排序算法的用时均测试于常用排序法(可能有评测机波动)
3. 二分
(1) 二分查找(binary search)
二分查找也称折半查找,它是一种效率较高的查找方法。是基于有序序列的查找方法。注意,使用二分查找算法就必须使序列有序!
方法:
该算法一开始令 [ l e f t , r i g h t ] \left[left,right \right] [left,right]为整个序列的下标区间,然后每次测试当前 [ l e f t , r i g h t ] \left[left,right \right] [left,right]的中间位置 m i d = ( l + r ) / 2 mid=(l+r)/2 mid=(l+r)/2,判断 A [ m i d ] A[mid] A[mid]与与查询的元素 x x x的大小:
- 如果 A [ m i d ] = = x A[mid]==x A[mid]==x,说明查找成功,退出查询。
- 如果 A [ m i d ] > x A[mid]>x A[mid]>x,说明元素 x x x在 m i d mid mid位置的左边,因此往左子区间 [ l e f t , m i d − 1 ] [left,mid-1] [left,mid−1]继续查找。
- 如果 A [ m i d ] < x A[mid]<x A[mid]<x,说明元素 x x x在 m i d mid mid位置的右边,因此往右子区间 [ m i d + 1 , r i g h t ] [mid+1,right] [mid+1,right]继续查找。
二分查找的高效之处在于,每一步都可以去除当前区间中的一半元素,因此其时间复杂度是 O ( O( O(log n ) n) n),这是十分优秀的。
查找过程:
例题:二分查找
代码实现:
(1)while循环
// 二分区间为左闭右闭的[left,right],初值为[1,n]
#include <cstdio>
using namespace std;
const int Maxn = 2e6 + 5;
int n, x; // x为欲查询的数
int a[Maxn]; // a[]为严格递增序列
int main() {
scanf("%d", &n);
for(int i = 1;i <= n; ++i) scanf("%d", &a[i]);
scanf("%d", &x);
int left = 1, right = n; // left为二分上界,right为二分下界
while(left <= right) { // 如果left>right就没办法形成闭区间了
int mid = left + right >> 1; // mid为left和right的中点
// left + right >> 1 是位运算,等于
// (left + right) >> 1 和
// (left + right) / 2
if(a[mid] == x) { // 找到x
printf("%d", mid); // 输出下标
return 0;
}
else if(a[mid] > x) // 中间的数大于x
right = mid - 1; // 往左子区间[left,mid-1]查找
else // 中间的数小于x
left = mid + 1; // 往右子区间[mid+1,right]查找
}
puts("-1"); // 查找失败,输出-1
return 0;
}
(2)递归
#include <cstdio>
using namespace std;
const int Maxn = 2e6 + 5;
int x;
int a[Maxn];
int dg(int left, int right) {
int mid = (left + right) / 2;
if(left > right) return -1;
if(a[mid] == x) return mid;
else if(a[mid] > x) return dg(left, mid - 1);
else return dg(mid + 1, right);
}
int main() {
int n;
scanf("%d", &n);
for(int i = 1;i <= n; ++i) scanf("%d", &a[i]);
scanf("%d", &x);
printf("%d", dg(1, n));
return 0;
}
这是二分查找最常用的两种写法。(可以说是模版吧)
相信这两道题的代码大家都能很轻松地打出来。
但是这里介绍一种很简单不简单的方法 #_#
STL里的 u p p e r upper upper_ b o u n d bound bound和 l o w e r lower lower_ b o u n d bound bound函数
请食用这篇帖子
我还是自己复制讲一下:
lower_bound(begin, end, num);
从 A [ b e g i n ] A[begin] A[begin]到 A [ e n d − 1 ] A[end-1] A[end−1]二分查找第一个大于或等于 n u m num num的数字,找到返回该数字的地址,不存在则返回 e n d end end。
upper_bound(begin, end, num);
从 A [ b e g i n ] A[begin] A[begin]到 A [ e n d − 1 ] A[end-1] A[end−1]二分查找第一个大于 n u m num num的数字,找到返回该数字的地址,不存在则返回 e n d end end。
头文件: a l g o r i t h m algorithm algorithm
注意:这两个函数都是返回元素的地址,而不是元素的下标。那怎么才能得到下标呢?只需要将返回的地址减去 A A A即可。
代码实现:
// 二分查找下界
#include <cstdio>
#include <algorithm>
using namespace std;
const int Maxn = 100 + 5;
int n, x;
int a[Maxn];
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
scanf("%d", &x);
printf("%d", lower_bound(a + 1, a + n + 1, x) - a);
/*
lower : >=
upper : >
*/
return 0;
}
// 二分查找上界
#include <cstdio>
#include <algorithm>
using namespace std;
const int Maxn = 100 + 5;
int n, x;
int a[Maxn];
int main() {
scanf("%d", &n);
for (int i = 1; i <= n; ++i) scanf("%d", &a[i]);
scanf("%d", &x);
printf("%d", upper_bound(a + 1, a + n + 1, x) - a - 1);
// upper_bound是求大于x的第一个位置,即小于或等于x的最后一个位置的下一位
return 0;
}
自从我自学这两个函数被gm表扬后,同学们都夸我很聪明。
(2)二分应用(拓展)
上面讲解的都是整数情况下的二分查询问题,事实上二分法的应用远不止如此,下面介绍几个相关的例子。
如何计算 2 \sqrt{2} 2的近似值 ( 2 \sqrt{2} 2 是无理数,只能获得近似值,这里精确到 1 0 − 5 10^{-5} 10−5)
对 c h e c k ( x ) = x 2 check(x)=x^{2} check(x)=x2 来说,在 x x x ε \varepsilon ε $[1,2] $ 范围内, c h e c k ( x ) check(x) check(x) 是随着 x x x 的增大而增大的,这就给使用二分法创造了条件,既可以采用如下策略来逼近 2 \sqrt{2} 2 的值.
令浮点型 l e f t left left和 r i g h t right right的 初值分别为 1 1 1和 2 2 2,然后根据 l e f t left left和 r i g h t right right的中点 m i d mid mid处 c h e c k ( x ) check(x) check(x)的值与 2 2 2的大小来选择子区间进行逼近:
-
如果 c h e c k ( m i d ) > 2 check(mid)>2 check(mid)>2,说明 m i d > 2 mid>\sqrt{2} mid>2,应当在 [ l e f t , m i d ] [left,mid] [left,mid]的范围内继续逼近,故令 r i g h t = m i d right=mid right=mid。
-
如果 c h e c k ( m i d ) < 2 check(mid)<2 check(mid)<2,说明 m i d < 2 mid<\sqrt{2} mid<2,应当在 [ m i d , r i g h t ] [mid,right] [mid,right]的范围内继续逼近,故令 l e f t = m i d left=mid left=mid。
上面两个步骤当 r i g h t − l e f t < 1 0 − 5 right-left<10^{-5} right−left<10−5时结束。显然当 l e f t left left与 r i g h t right right的距离已经小于 1 0 − 5 10^{-5} 10−5时已经满足精度要求, m i d mid mid即为所求的近似值。
代码实现:
#include <cstdio>
using namespace std;
const double eps = 1e-5; // 精度
double check(double x) { return x * x; }
double Binary_Sqrt() {
double left = 1, right = 2, mid; // 答案在1和2之间
while(right - left > eps) {
mid = (left + right) / 2; // 浮点数不能用位运算
if(check(mid) > 2) right = mid; // 错误写法:right = mid - 1;
else left = mid; // 错误写法:left = mid + 1
}
return mid;
}
int main() {
printf("%.5lf", Binary_Sqrt());
return 0;
}
例题:二分法求函数的零点
经过上面的讲解之后,你应该能很快地打出正确代码。
首先,根据题意写一个 c h e c k check check 函数:
double f(double x) {
return pow(x, 5.0) - pow(x, 4.0) * 15.0 + x * x * x * 85.0 - 225.0 * x * x + 274.0 * x - 121.0;
}
然后二分:
void Binary_Search() { // 二分x的值
double left = 1.5, right = 2.4, mid; // 从1.5到2.4的根
while(left < right) {
mid = (left + right) / 2.0;
if(f(mid) == 0) break;
else if(f(mid) > 0) left = mid;
else right = mid;
}
printf("%.6lf", mid);
return ;
}
再看看这道应用题(NOIP):跳石头
思路:我们只需要二分答案即可。而难点是其中的 c h e c k check check 函数。我们令当前两个岩石间的最短距离为 l l l,上一块石头的位置为 k k k,那么当当前岩石 i i i 与上一块岩石之间的距离小于 l l l 时,这块石头就要被拿开。否则就把 a [ i ] a[i] a[i] 赋值给 k k k,代表下一次判断中 a [ i ] a[i] a[i] 成了上一块岩石。
c h e c k check check 函数:
int check(int x) {
int sum = 0, k = 0;
for (int i = 1; i <= n; ++i)
if (a[i] - k < x) sum++;
else k = a[i];
return sum;
}
二、STL(初级)
1. 队列
完成度:70%
首发时间:2022-01-30 15:50:21
最新更新时间: 2022-02-12
完结时间:未完
图片来源:102大佬的《关于『基本算法』:常见八大排序(完结撒花)》