【基础算法】二分,贪心等 学习笔记
普及组基础算法
这些都是零零散散接触过的基础算法,写个笔记把这些整理到一起来。
线性降维技巧
之前在学校洛谷团队里看到一个题单,觉得这些技巧可能有用,就转存了。
前缀和 差分
前缀和是一种对区间求和问题进行降维的方法。具体地,对于给定数组 $ A[n] $,求出 \(A[l,r]\) 区间和这个问题,朴素的方法时间复杂度为 \(O(n)\),使用前缀和进行一次 \(O(n)\) 的预处理后,可以 \(O(1)\) 进行查询。
实现方法:
int n;
int a[100],f[100]; // f[] 为预处理数组,f[i] 表示 a[1] 到 a[i] 的和
cin >> n;
for (int i=1;i<=n;i++){
cin >> a[i];
f[i]=f[i-1]+a[i]; // 预处理:前缀和的递推计算方法
}
// 查询:a[l,r]的和是:f[r]-f[l-1]
前缀和也可以推广到二维。具体的,对于给定二维数组 $ A[n][m] $,求出其中 \(A[xl,xr][yl,yr]\) 的子矩阵和这个问题,朴素的方法时间复杂度为 \(O(nm)\), 也可以使用前缀和进行优化,需要用到容斥原理。
普及组不需要用到很复杂的数学知识,容斥原理只需要小学数学的那些就可以了,这里做一个对二维容斥原理的简单的介绍:给定集合 \(A\) 和 集合 \(B\),则有:\(|A \cup B| = |A| + |B| - |A \cap B|\)。
容斥原理不局限于集合大小,也可以帮助我们求前缀和。可以通过下面方法实现二维前缀和。
int n,m;
int a[100][100], f[100][100]; // f[i][j] 表示 f[1,i][1,j] 的和
cin >> n >> m;
for (int i=1;i<=n;i++)
for (int j=1;j<=m;j++){
cin >> a[i][j];
f[i][j]=f[i-1][j]+f[i][j-1]-f[i-1][j-1]+a[i][j];
}
int query(int xl, int xr, int yl, int yr) {
return f[xr][yr] - f[xr][yl - 1] - f[xl - 1][yr] + f[xl - 1][yl - 1];
}
差分可以视作前缀和的逆运算。具体地,对于给定数组 \(A[n]\),定义其差分数组为 \(d[n]\),其中 \(d[i]=a[i]-a[i-1],(d[1]=a[1])\)。前缀和被用于区间查询,而差分被用于区间修改。使用差分后,可以在 \(O(1)\) 时间内进行区间加减(将 \(a[i,j]\) 内所有值增加 \(k\)),具体代码如下:
洛谷 P2367 语文成绩(差分模板)
#include <bits/stdc++.h>
using namespace std;
int n,p;
int a[5000005],b[5000005],ans=200;
int x,y,z;
int main(){
ios::sync_with_stdio(0);
cin >> n >> p;
for (int i=1;i<=n;i++){ // 差分初始化,其中 b[] 为差分数组
cin >> a[i];
b[i]=a[i]-a[i-1];
}
for (int i=1;i<=p;i++){
cin >> x >> y >> z;
b[x]+=z,b[y+1]-=z; // 区间加
}
int s=0;
for (int i=1;i<=n;i++){
s+=b[i]; // 差分的前缀和数组就是原数组,所以称其为逆运算
ans=min(ans,s);
}
cout << ans << endl;
return 0;
}
例题
洛谷 P1387 最大正方形
求出原矩阵的二维前缀和 s[][]
,对于一个边长为 \(k\) 的子正方形矩阵 s[i,i+k-1][j,j+k-1]
,如果其和为 $ k^2 $ 则中间不含 $ 0 $。枚举边长和起点,时间复杂度为 $ O(n^3) $,可以通过给定数据。
代码实现如下:
#include <bits/stdc++.h>
using namespace std;
// #define int long long
int n, m;
int a[105][105], f[105][105];
int query(int xl, int xr, int yl, int yr) {
return f[xr][yr] - f[xr][yl - 1] - f[xl - 1][yr] + f[xl - 1][yl - 1];
}
signed main() {
#ifndef ONLINE_JUDGE
clock_t t0 = clock();
freopen("data.in", "r", stdin);
freopen("data.out", "w", stdout);
#endif
// keep your hard-working, %%% ftc
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++) {
cin >> a[i][j];
f[i][j] = f[i - 1][j] + f[i][j - 1] - f[i - 1][j - 1] + a[i][j];
}
int mn=min(m,n);
for (int k=2;k<=mn;k++){
bool flag=0;
for (int i=1;i+k-1<=n;i++){
for (int j=1;j+k-1<=m;j++){
flag|=(query(i,i+k-1,j,j+k-1)==k*k);
}
}
if (flag==0){
cout << k-1 << endl;
break;
}
}
// keep your hard-working, %%% ftc
#ifndef ONLINE_JUDGE
cerr << "Time used:" << clock() - t0 << "ms" << endl;
#endif
return 0;
}
洛谷 P3406 海底高铁
首先考虑何时买卡,设第 $ i $ 段铁路被乘坐了 $ x_i $ 次,当 $ x_i \times a[i] > x_i \times b[i] + c[i] $ 时,买卡可以节省 $ x_i \times a[i] - (x_i \times b[i] + c[i]) $ 元,此时应该买卡。
接着考虑如何计算 $ x_i $,当从第 \(a\) 站坐到第 \(b\) 站时($ a < b $),需要购买第 \(a\) 条铁路到第 \(b-1\) 条的车票各 $ 1 $ 张。区间加可以使用差分实现。
最后计算出全部买票的钱和节省的钱(全部买票的钱可以用前缀和优化计算),相减即为答案。
参考代码:
#include <iostream>
#include <algorithm>
#include <cmath>
#include <ctime>
using namespace std;
#define int long long
const int N = 1e5 + 5;
int n, m;
int p[N];
int a[N], b[N], c[N], fa[N];
int ct[N];
int ans;
signed main() {
#ifndef ONLINE_JUDGE
clock_t t0 = clock();
freopen("data.in", "r", stdin);
freopen("data.out", "w", stdout);
#endif
// keep your hard-working, %%% ftc
cin >> n >> m;
for (int i = 1; i <= m; i++) {
cin >> p[i];
}
for (int i = 1;i <= n - 1;i++) {
cin >> a[i] >> b[i] >> c[i];
fa[i] = fa[i - 1] + a[i];
}
for (int i = 2;i <= m;i++) {
int start = p[i - 1], to = p[i];
if (start < to) {
ct[start]++, ct[to]--;
ans += fa[to - 1] - fa[start - 1];
}
else /* start > to */ {
ct[to]++, ct[start]--;
ans += fa[start - 1] - fa[to - 1];
}
}
int x = 0;
for (int i = 1;i <= n - 1;i++) {
x += ct[i];
if (c[i] + x * b[i] < x * a[i]) {
ans -= (x * a[i] - (c[i] + x * b[i]));
}
}
cout << ans << endl;
// keep your hard-working, %%% ftc
#ifndef ONLINE_JUDGE
cerr << "Time used:" << clock() - t0 << "ms" << endl;
#endif
return 0;
}
离散化
离散化是排序算法对于线性降维的一个重要应用。通常情况下,如果一组数值域很大,但是只考虑他们的大小关系,则可以通过离散化的方式将值域降维到不重复元素个数。
比如:$ [1,20,300,4234,51234,64321,114514,1919810] $ 这组数,如果只考虑他们的大小关系,则和 $ [1,2,3,4,5,6,7,8] $ 等价。将数组排序后就可以用元素所在的位置代表这个元素。
离散化的实现方法:
int n, m = 1;
int a[], b[]; // a[]为原数组, b[]是离散化后的数组
void init() {
sort(a + 1, a + 1 + n); // 先对数组排序
b[1] = a[1];
for (int i = 2;i <= n;i++) {
if (a[i] != a[i - 1]) b[++m] = a[i];
}
// 或STL: m = unique(a + 1, a + 1 + n) - a;
}
int query(int x) {
return lower_bound(b + 1, b + m + 1, x) - b;
}
离散化还可以通过 STL:map 实现。
例题:CF670C Cinema
把语言离散化,统计懂第 \(i\) 种语言的科学家有几位,然后比较每个电影能让几位科学家愉悦,选出最多的即可。
参考代码(很久之前写的,很丑):
#include <bits/stdc++.h>
using namespace std;
int n,m,k,d,a[200005],b[200005],c[200005],l[600010],q[600010];
int cnt[600010];
int ans=1;
int query(int x){
return lower_bound(q+1,q+d+1,x)-q;
}
signed main(){
ios::sync_with_stdio(0);
cin >> n;
for (int i=1;i<=n;i++){
cin >> a[i];
l[++k]=a[i];
}
cin >> m;
for (int i=1;i<=m;i++){
cin >> b[i];
l[++k]=b[i];
}
for (int i=1;i<=m;i++){
cin >> c[i];
l[++k]=c[i];
}
sort(l+1,l+1+k);
for (int i=1;i<=k;i++){
if (l[i]!=l[i-1])q[++d]=l[i];
}
for (int i=1;i<=n;i++){
cnt[query(a[i])]++;
}
for (int i=1;i<=m;i++){
int bt=query(b[i]),ct=query(c[i]);
if (cnt[bt]>cnt[query(b[ans])]){
ans=i;
} else if (cnt[bt]==cnt[query(b[ans])]&&cnt[ct]>cnt[query(c[ans])]){
ans=i;
}
}
cout << ans << endl;
return 0;
}
有一道离散化和并查集结合的题:洛谷 P1955 [NOI2015] 程序自动分析
双指针
单调栈 单调队列
二分
二分算法可以解决的问题具有两段性的特点,直观地说,在一个区间中,如果存在一个性质,区间内一半数具有这个性质,另外一半没有,则可以通过二分法找出第一个(或最后一个,对于前半边具有性质)具有性质的数。二分算法主要有整数二分和实数二分两种。
整数二分
如果在整数区间 $ [l,r] $ 中,$ [l,x] $ 这一段具有某种性质而 $ [x+1,r] $ 不具有,或者 $ [x,r] $ 这一段具有某种性质而 $ [l,x-1] $ 不具有,则整数二分可以求出这个 \(x\) 的值。
代码模板
对于上述两种情况,给出以下两段代码:
bool check(int x); // 检验某个数是否具有某个性质
int solve_r(int l, int r) { // 右半边具有某种性质
while (l < r) {
int mid = l + r >> 1;
if (check(mid)) r = mid;
else l = mid + 1;
}
return l;
}
int solve_l(int l, int r) { // 左半边具有某种性质
while (l < r) {
int mid = l + r + 1 >> 1;
if (check(mid)) l = mid;
else r = mid - 1;
}
return l;
}
对于中间值是否具有性质,分成两种情况,分别缩小区间范围。每次缩小一半,则算法的时间复杂度为 $ O(\log n) $
例题
- 洛谷 P1873 [COCI2011-2012#5] EKO / 砍树
- P1182 数列分段 Section II
- P1314 [NOIP2011 提高组] 聪明的质监员
- P1083 [NOIP2012 提高组] 借教室
- P4343 [SHOI2015] 自动刷题机
实数二分
当答案要求精确到小数点的后 $ k $ 位时,通常取 $ eps = 10^{-(k+2)} $,然后类似整数二分,写出以下代码,就可以求出近似值。
bool check(double x); // 检验某个数是否具有某个性质
int solve(double l, double r) {
while (l + eps < r) {
int mid = (l + r)/2;
if (check(mid)) l = mid;
else r = mid;
}
return l;
}
还有一种固定循环次数的技巧,来实现实数二分,精确度也很高。
int solve(double l, double r) {
for (int i = 1; i <= 30; i++) {
int mid = (l + r)/2;
if (check(mid)) l = mid;
else r = mid;
}
return l;
}
例题
- 洛谷 P1024 [NOIP2001 提高组] 一元三次方程求解
二分查找
- 头文件:
<algorithm>
lower_bound(begin, end, val)
在[begin, end) 区间找 val 第一次出现的地址。upper_bound(begin, end, val)
在[begin, end) 区间找大于 val 的数第一次出现的地址(相当于 val 最后一次出现的地址的后一个)。
例题:洛谷 P1102 A - B = C
贪心
贪心是解决一类题目的思想,总体思路是用局部最优解推理全局最优解。贪心的思考方法有:反证法、数学归纳法等。