Chapter1 Basic Algorithm
刷题小技巧: 如果输入数据量大于 1000000 的话建议使用 scanf 和 printf 进行输入输出
- 任何有规律的重复运算都能被化简
- 模板是为了节省思考的时间。
- 算法靠多写,不用想着理解算法,多写就会了。
- 主要记住算法的思路。
- 基于实用主义,哪个经常用就只用哪个,最后能解题就行。用到再学,不用先不管。因为不是考察知识点而是实际解决问题。
- 一定要贯彻空间换时间的思想。
本笔记对应代码链接:https://github.com/PommesPeter/Algorithm-Developement/tree/master/Algorithm-Basic-Tutorial
排序
quicksort (快速排序)
一般现场手写的会考
算法流程
- 确定分界点
- 直接取左边界
- 直接取右边界
- 取中间
- 随机
- 调整区间
- 区间左边的数都小于等于 x,区间右边的数都大于等于 x
- 递归处理左右两段区间
- 一次处理左边的区间,然后再处理右边的区间
- 因为每次调用快排函数都会对其进行排序,也就是调用顺序是一直先排序左边,然后在回溯的时候再排右边
不需要开辟额外空间<使用两个指针来扫数组>
算法特点
- 注意传参的边界
- x 为选择出来的分界点
- 两个指针 i, j,i 在左边,j 在右边。
- i 的左边的数都是小于 x 的,j 右边的数都是大于 x 的
理解:
- 从左往右扫描,找到第一个比分界点要大的数;
- 从右往左扫描找到一个比分界点要小的数;
- 交换两个数
- 递归调用,调用顺序是一直先排序左边,然后在回溯的时候再排右边。
调用顺序示意图:
mergesort (归并排序)
算法流程
- 先把数组分成多个区间,通过多次递归区间长度的中间
- 递归到最底部后开始回溯的时候就开始合并数组
- 合并数组的时候在不同区间各取一个元素进行比较,把小的放先放到临时数组
- 比较完毕后再把剩下没放完的元素放到临时数组里
- 最后把临时数组里面的数放回原数组
算法特点
- 第一次递归是长度 n 的区间,第i次递归的区间则是长度 n/i*2 的区间
- 每次都是 logn,递归了 n 次,所以复杂度是 nlogn
高精度问题
一般笔试可能会考
存储大整数
- 思路:把数的某一位存到一个数组里
- 存储的时候第0位存个位比较好
- 因为做进位的时候在数组末尾补上一个数比较方便
- 普通数组要在前面添加数,所有元素都要往后移一位,在末尾添加不用移位
加法
算法流程
- 核心思路:逐位相加(A[i] + B[i] + t)的时候要把每一位相加的结果的十位和个位分开,个位作为当前的位数,十位作为下一次计算的进位(也就是需要加到 A[i+1] + B[i+1])(这里不是累加,而是直接保存当前的进位)
注意区分 A<=10 和 len(A)<=10,一个指的是数,另一个指的是位数
- 逐位相加
- 对于每一位来说,都是由 组成,t 为进位。(在个位 t = 0,其他看情况)
- 将 的结果取出其个位作为当前的位数,也就是压入数组
- 将 的结果取出其十位作为下一位运算的进位
vector<int> add(vector<int> &A, vector<int> &B) {
vector<int> res;
int t = 0;
for (int i = 0; i < A.size() || i < B.size(); i++) {
if (i < A.size()) t += A[i];
if (i < B.size()) t += B[i];
res.push_back(t % 10);
t /= 10;
}
if (t) res.push_back(1);
return res;
}
减法
算法流程
- 核心思路:逐位相减(A[i] - B[i] - t)的时候要判断减得的结果,如果小于0则需要往前面借位,即下一次运算的时候要提前先减1,如果没有产生借位就正常计算。此时当前计算位的值为 ,因为 t>=0 时就是t本身,t<0 时那么就是借 10 过来,也就是在原来的基础 +10 即可
- 逐位相减
- 比较数的大小确定是否添加符号
- 对于减法而言,如果相减不够减的时候就要向前面借一位出来
- 从这里对比加法可以看出,每一位的组成是分两种情况:
- 如果,则
- 如果,则在原来的基础上加上10,则
- 对于减法而言,A和B不同的顺序会导致最后计算的值不一样,有两种情况:
- ,直接减即可
- ,先计算B-A,再添加负号
- 总的来说,计算减法主要是把先计算的值,再根据原来A-B的大小判断是否添加负号
- 从这里对比加法可以看出,每一位的组成是分两种情况:
- 计算完所有位数后处理前导0。
bool cmp(vector<int> &A, vector<int> &B) {
if (A.size() > B.size()) return A.size() > B.size();
for (int i = A.size() - 1; i >= 0; i --) {
if (A[i] != B[i]) return A[i] > B[i];
}
return true;
}
vector<int> sub(vector<int> &A, vector<int> &B) {
vector<int> C;
for (int i = 0; i < A.size(); i++) {
t = A[i] - t;
if (i < B.size()) t -= B[i];
C.push_back((t + 10) % 10);
if (t < 0) t = 1;
else t = 0;
}
while (C.size() > 1 && C.back() == 0) C.pop_back(); // 处理前导0。
return C;
}
乘法
算法流程(乘数为1位)
- 核心思路:
- 乘数与被乘数的每一位相乘
- 确定每一位数的值,与加法类似,其值为 ,进位则是
vector<int> mul(vector<int> &A, int b) {
vector<int> C;
int t = 0;
for (int i = 0; i < A.size() || t; i++) {
if (i < A.size()) t += A[i] * b;
C.push_back(t % 10);
t /= 10;
}
return C;
}
除法
算法流程
- 核心思路:
- 将数划分为 ,从 开始。
- 求 和
- 计算第二个数,余数先变成,求
- 循环直到计算到低位结束
vector<int> div(vector<int> &A, int b, int &r) {
vector<int> C;
r = 0;
for (int i = A.size() - 1; i >= 0;i --) {
r = r * 10 + A[i];
C.push_back(r / b);
r %= b;
}
reverse(C.begin(), C.end());
while (C.size() > 1 && C.back() == 0) C.pop_back();
return C;
}
二分
有单调性一定可以二分,但是二分并不是单调性。
二分的本质:其实是一个边界问题,只要找到某种性质,使得整个区间能够被一分为二,使得区间的一边是满足性质的,另一边是不满足性质的。二分就是寻找这个性质的边界。在数轴上的表现就是边界点。
整数二分
- 情况1(查找不满足性质的边界值)
mid = l + r + 1>> 1
- if (check(mid)) (区间被划分成 [l, mid] 和 [mid + 1, r] ) (左边为不满足性质的,右边满足性质)
- true:如果 check 返回 true,说明在红色区间找到了 mid,因为我们要找的是不满足性质的边界,若 mid 已经落在了红色的区间,那么相当于已经满足了条件,如果想要进一步缩小区间就要从 [mid, r] 开始找,所以要在 [mid, r] 查答案,更新左边界为 l = mid; 缩小区间。
- false:如果 check 返回 false,说明在绿色区间找到了 mid,因为我们要找的是不满足性质的边界,当前 mid 没有落在不满足性质的区间,所以要进一步缩小范围的话要从 [l, mid - 1] 开始找, 更新右边界为 r = mid - 1。
- 情况2(查找满足性质的边界值)
mid = l + r >> 1
- if (check(mid)) (区间被划分成[l, mid - 1]和[mid, r]) (左边为不满足性质的,右边满足性质)
- true: 如果 check 返回 true,说明在绿色区间找到了 mid,因为我们要找的是满足性质的边界,若 mid 已经落在了绿色的区间,那么相当于已经满足了条件,如果要进一步缩小区间就要从 [l, m] 寻找边界值,更新左边界为 r = mid ; 缩小区间。
- false: 如果 check 返回 false,说明在红色区间找到了 mid,因为我们要找的是满足性质的边界,当前 mid 没有落在满足性质的区间,所以要进一步缩小区间要从 [mid + 1, r] 开始寻找边界,更新边界为 l = mid + 1;
会产生这两种情况是因为mid落在正确区间的解不一样,二分的方向不一样。
- 思考顺序: 确定边界,写好模板,然后根据问题的性质,根据图形(画个数轴)得到二分的边界,性质是什么,再写判断,再用图对比。(check 是 true 的话,答案应该在哪个边界,如果是 false又在哪个边界。)(一定要判断好用什么性质来划分,也就是确定 check 函数的具体定义)
- 每次都是选择答案所在的那个区间,然后进行下一步处理。每一次二分都保证包含有答案。二分模板一定有解
- 为什么需要 +1? 原因是如果不加上 1,那么mid得到的是下取整的数,那么有可能 [m,r] 更新过后 m 会一直等于 m(m+1 == r的情况)会陷入死循环。 二分一定有解,出现无解是因为题目的条件,从题目的条件找到无解的条件。
模板:
#include <iostream>
using namespace std;
const int N = 1e6 + 10;
int n, m;
int q[N];
int main() {
cin >> n >> m;
for (int i =0 ; i < n;i ++) cin >> q[i];
while (m --) {
int x;
cin >> x;
int l = 0, r = n - 1;
while (l < r) {
int mid = l + r >> 1;
if (q[mid] >= x) r = mid;
else l = mid + 1;
}
if (q[l] != x) cout << "-1 -1" << endl;
else {
cout << l << " ";
int l = 0, r = n - 1;
while (l < r) {
int mid = l + r + 1>> 1;
if (q[mid] <= x) l = mid;
else r = mid - 1;
}
cout << l << endl;
}
}
return 0;
}
浮点数二分
模板:
#include <iostream>
using namespace std;
int main() {
double x;
cin >> x;
double l = 0, r = x;
while (r - l > 1e-8) {
double mid = l + r >> 1;
if (mid * mid >= x) r = mid;
else l = mid;
}
cout << l << endl;
return 0;
}
前缀和与差分
前缀和
一维前缀和
前缀和本质上就是一个公式,假设输入一个列表a, 里面元素有,则前缀和为:.
前缀和的作用: 能够快速求出原数组中一段数的和,如果给定一个区间的两个端点,则这个区间内的数的和为,用一次计算就能计算出一段和,原本需要的复杂度。
void prefix_sum_1d() {
int n, m;
for (int i = 1 ; i <= n; i++) cin >> q[i];
for (int i = 1; i <= n;i ++) sum[i] = sum[i - 1] + q[i];
while (m --) {
int l, r;
cin >> l >> r;
cout << sum[r] - sum[l - 1];
}
}
二维前缀和
可以快速求出一个子矩阵里面的和。其中表示为,表示左上角的子矩阵的和,也就是表示前 行和前 列的区域内的元素的和。计算公式为:
计算边界为的公式为:
- 绿色:
- 红色:
- 交集:
void prefix_sum_2d() {
int n, m, q;
cin >> n >> m >> q;
for (int i = 1 ;i <= n; i++) {
for (int j = 1 ; j <= m; j++) {
cin >> a[i][j];
}
}
for (int i = 1; i <= n;i ++) {
for (int j = 1; j <= m; j++) {
s[i][j] = s[i - 1][j] + s[i][j - 1] - s[i - 1][j - 1] + a[i][j];
}
}
while (q --) {
int x1, y1, x2, y2;
cin >> x1 >> y1 >> x2 >> y2;
cout << s[x2][y2] - (s[x1 - 1][y2] + s[x2][y1 - 1] - s[x1 - 1][y1 - 1]) << endl;
}
}
差分
差分实际上就是求前缀和的逆运算,公式表达为:。求解差分可以用,其中a为前缀和。
差分作用: 对差分数组求前缀和就能得到原数组;指定某个区间的数全部相加同一个数,但是不能使用 的复杂度,要求使用 的复杂度。这样就可以达到优化时间复杂度的效果。
注意:b数组是空想出来的,假设是一个满足这个性质的数组。假想一个数组,a数组是b数组的前缀和,b数组就是差分数组。
void insert(int l, int r, int c) {
b[l] += c;
b[r + 1] -= c;
}
void differential_ops_1d() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) cin >> a[i];
for (int i = 1; i <= n; i++) insert(i, i, a[i]);
while (m --) {
int l, r, c;
cin >> l >> r >> c;
insert(l, r, c);
}
for (int i = 1; i <= n; i++) b[i] += b[i - 1];
for (int i = 1; i <= n; i++) cout << b[i] << endl;
}
void differential_ops_2d() {
cin >> n >> m >> q;
for (int i = 1; i <= n;i ++)
for (int j = 1; j <= m; j++)
cin >> a[i][j];
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
insert2d(i, j, i, j, a[i][j]);
while (q --) {
int x1, y1, x2, y2;
cin >> x1 >> y1 >> x2 >> y2 >> c;
insert(x1, y1, x2, y2, c);
}
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
b[i][j] += b[i - 1][j] + b[i][j - 1] - b[i - 1][j - 1];
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++)
cout << b[i][j] << " ";
cout << endl;
}
}
双指针算法
用的比较多
平时常用归并排序、快速排序都是双指针算法。双指针算法主要有两种应用:
双指针维护两个数组;
双指针维护一个数组。
常规的双重循环暴力遍历复杂度为 ,双指针算法的目的就是为了优化这种暴力遍历的算法,一般优化到
常规代码结构:
for (int i = 0, j = 0; i < n; i++) {
while (j < i && check(i, j)) j++;
// ... 每道题目的代码的具体逻辑
}
- 双指针算法的核心思想:常规暴力算法优化时间复杂度。本质上减少一些不必要的指针移动,两个指针之间相互依附。找两个之间的规律
- 双指针算法一般可以从暴力的角度去想,然后对暴力算法进行优化。
位运算
基础
n的二进制表示中第k位是几?常见题目
- 先把第k位移到最后一位,右移运算
n >> k
- 检查个位是几,
x & 1
- 最后得
n >> k & 1
// 输出一个十进制数的二进制
int main() {
int n = 10;
for (int k = 3; k >= 0; k--) cout << (n >> k & 1);
return 0;
}
// 输出 1010
n >> k & 1
跟 1 进行与运算取出的是二进制数的个位,因为 1 的二进制只有个位是 1,其他位是 0,当一个数与 1 进行与运算时,其他位就会变成 0,只剩下个位(同理,如果与 1,2,4,8 .... 等数就会得到对应)
lowbit
表示一个二进制数x的最后一位1。通过这个算法能够找到一个数的二进制中,从左往右数的最后一位1,返回这个数,前导0忽略,1后面的0保留。
int lowbit(int x ) {
return x & -x;
}
- 当每次都减去最后一位 1 之后,减了多少个 1,就标识二进制数当中有多少个 1。
离散化(Integer)
注意
- 该离散化是有序的,保序的。
- 学会根据数据规模分析自己需要开多大的数组,该题需要开30万的数组是因为插入操作10w,查询操作左右各10w,一共就30w
- 离散化的本质就是从大量的数据当中找到稀疏分散的答案。(答案值域大,但答案个数少),操作本质就是映射,将所需要答案映射到一个比较小的区间
离散化主要解决的问题特点是输入规模可能比较大,值域比较大 (0-10\^9),但是个数比较少 (10\^5),答案的分布比较离散。把这么大规模的数映射到一个从 0 开始的自然数。也就是将一个数组里的数映射到一个从 0 开始的自然数的数组。可能有重复的元素,所以需要去重。
- 数组元素去重 常用写法如下,重点!!!!
#include <iostream>
using namespace std;
int main() {
vector<int> v;
sort(v.begin(), v.end());
v.erase(unique(v.begin(), v.end()), v.end());
return 0;
}
- unique函数实现原理——双指针算法: 排完序之后:
- 他是第一个;
- 他和前面一个数不一样,
a[i] != a[i - 1]
。只要满足这两个性质就把这个数拿出来就行
vector<int>::iterator unique(vector<int> &a) {
//第一个指针遍历所有的数,第二个指针存储存到了第几个不同的数。j <= i
int j = 0;
for (int i = 0; i < a.size(); i++)
if (!i || a[i] != a[i - 1]) a[j++] = a[i];
// a[0] - a[j - 1] 所有a中不重复的数
return a.begin() + j;
}
- 如何计算一个数组 a 离散化之后的结果是多少? 可以使用二分,找到需要进行离散化的元素,然后进行映射。
区间合并
有多个区间,如果彼此之间有交集的话就进行合并,合并成同一个区间,并集作为一个区间。(两个蓝色的区间可以合并成绿色的区间)
整体思想: 对左端点把区间进行排序,然后开始扫描,扫描的时候有两个指针 st, ed,扫描的时候从第一个区间开始,判断第一个区间和第二个区间有没有公共点,如果有,就把第一个区间和第二个区间进行合并(合并的时候只需要把下一个区间的右端点赋值给 ed 即可,ed 表示第 i 个区间的右端点),判断完之后再看下一个区间(从这里可以看出需要有一个 pair 存区间的左右端点)。如果不产生任何交集就到此为止了。然后依次往后做。
- 有交集,合并到第一个区间,产生一个新的区间继续寻找交集,找下一个区间。
- 没有交集,跳过,找下一个区间。
按区间左端点进行排序所有给出的区间
扫整个数组,有两个指针 st, ed;
(这里会出现三种情况,一种区间 B 在区间 A 的内部,一种是区间 B 与 区间 A 有交集,一种是区间 B 在区间 A 的外面)
- 区间 B 在区间 A 的内部
- 区间 B 与 区间 A 有交集
- 区间 B 在区间 A 的外面
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
typedef pair<int, int > PII;
const int N = 1e5 + 10;
int n;
int a[N];
vector<PII> segs;
void merge(vector<PII> &segs) {
vector<PII> res;
sort(segs.begin(), segs.end()); //按照左端点排序
int st = -2e9, ed = -2e9;
for (auto seg : segs) {
if (ed < seg.first) { // 判断标记区间末尾的位置和下一个区间的位置的位置的关系,如果该条件成立,说明此时区间不用合并,是单独的一个区间
if (st != -2e9) {
res.push_back({st, ed});
}
st = seg.first, ed = seg.second; // 移动指针位置
} else { // ed > seg.first 说明区间需要合并,此时末尾的位置就是看哪个区间比较长,取max即可
ed = max(ed, seg.second); // 延长区间
}
}
if (st != -2e9) res.push_back({st, ed});
segs = res;
}
int main() {
cin >> n;
for (int i =0 ; i <n ; i++ ) {
int l, r;
cin >> l >> r;
segs.push_back({l, r});
}
merge(segs);
cout << segs.size() << endl;
return 0;
}