(一)并查集
并查集是一种比较精巧而且实用的数据结构,它主要处理一些不相交集合的合并问题。这个算是我这些天来掌握的最好的一个数据结构了。通常用“帮派”的例子说明并查集的应用场景。一个城市中有n个人,他们分成不同的帮派。同属于一个帮派的人互相之间也是朋友,朋友的朋友是朋友。给出一些人的关系,如,1号和2号是朋友,1号和3号也是朋友,那么2号和3号也是朋友 其实是比较好理解的 那么对于程序来讲 我们的代码应该从何下手呢?
(1)寻找x的根节点:find函数
众所周知,每个根节点都会有一个上级 就比如像树那样的 子节点 最上面的根节点就是它自己
就如上图 3的上级是2 2的上级是1 那么3的上级就可以看作为1 同理4的上级是1 那么拥有共同上级的他们就可以视作拥有共同的朋友1 那么1 2 3 4就是都认识 那接下来的问题就是怎么写代码 模板是:
int find(int x) //查找x的教主
{
while(pre[x] != x) //如果x的上级不是自己(则说明找到的人不是教主)
x = pre[x]; //x继续找他的上级,直到找到教主为止
return x; //教主驾到~~~
}
如果想进一步优化 那么久多写一层递归 也就是
int find(int x) //查找结点 x的根结点
{
if(pre[x] == x) return x; //递归出口:x的上级为 x本身,即 x为根结点
return pre[x] = find(pre[x]); //此代码相当于先找到根结点 rootx,然后pre[x]=rootx
}
(2)合并子集:join()函数
当我们有两个数的时候 我们是不是第一步判断他们的头头是不是一样的 如果是一样的 那就不用操作 但如果是不一样的 那我们是不是得把一方容纳为另一方的部下
如果要把这两个集合给合并的话 那么就是:
那这个怎么用函数写出来呢?
void join(int x,int y)
{
int fx=find(x), fy=find(y);
if(fx != fy)
pre[fx]=fy;
}
当然这个是基本的模板 如果有需要可以根据题目来选择 我们之后会讲到。
最后就是在每次循环之前 在最一开始对每个值的根节点进行赋值 赋值的大小是它根本身,就比如这样:
for(int i = 0; i < n; i++){
pre[i] = i; //每个结点的上级都是自己
}
基本结构都讲完了 那接下来我们就看几个例题:
1.亲戚(洛谷p1551)
这个也是基本的并查集 这个也是挺简单的 代码如下:
#include<iostream>
using namespace std;
const int N = 5 * 1e3 + 5;
int pre[N];
int rank[N];
int find(int x) {
if (pre[x] == x)return x;
return pre[x]=find(pre[x]);
}
void join(int x, int y) {
int fx = find(x);
int fy = find(y);
if (fx != fy) {
pre[fx] = fy;
}
}
int main() {
int n, m, p;
cin >> n >> m >> p;
for (int i = 1; i <= n; i++) {
pre[i] = i;
}
for (int i = 1; i <= m; i++) {
int a, b;
cin >> a >> b;
join(a, b);
}
for (int i = 0; i < p; i++) {
int a, b;
cin >> a >> b;
if (find(a) == find(b)) {
cout << "Yes" << endl;
}
else {
cout << "No" << endl;
}
}
}
2.朋友(洛谷p2078)
这个题目有点不同的就是 小红的朋友都是女的 小明的朋友都是男的 分别用正负数表示 我们唯一要关注的就是如何处理复数 然后判断能够配成多少对情侣 因为是包括他们自己 所以最多对情侣就是双方人数较小的那一个 取min就行了 那用代码表示的话就是 看认识男的人数有ans1 认识女的人数有ans2 然后最后的答案就是min(ans1,ans2) 具体代码如下:
#define _CRT_SECURE_NO_WARNINGS 1
#include<bits/stdc++.h>
#include<iostream>
#include<string.h>
using namespace std;
const int N =20005;
int n, m;
int a[N];
int pre[N];
int rank[N];
int find(int x) {
if (pre[x] == x)return x;
return pre[x]=find(pre[x]);
}
void join(int x, int y) {
int fx = find(x);
int fy = find(y);
if (fx != fy) {
if (fx < fy) {
swap(fx, fy);
}
pre[fx] = fy;
}
}
int main() {
int n, m, p, q;
cin >> n >> m >> p >> q;
for (int i = 1; i <= n; i++) {
pre[i] = i;
}
for (int i = 1; i <= p; i++) {
int a, b;
scanf("%d%d", & a, &b);
join(a, b);
}
long long ans1 = 0;
for (int i = 1; i <= n; i++) {
if (find(i) == 1) {
ans1++;
}
}
memset(pre, 0, sizeof(pre));
for (int i = 1; i <= m; i++) {
pre[i] = i;
}
for (int i = 1; i <= q; i++) {
int a, b;
scanf("%d%d", &a, &b);
join(-a, -b);
}
long long ans2 = 0;
for (int i = 1; i <= m; i++) {
if (find(i) == 1) {
ans2++;
}
}
cout <<min(ans1,ans2)<<endl;
return 0;
}
3.修复公路(洛谷p1111)
这个题目也是在并查集的基础上添加了一个时间t的元素 它是想找出最小的时间公路互相连通 那我们就在原来的基础上改一个结构体然后根据时间t来排大小 每次都去判断一下是否联通了 那么具体代码如下:
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
#include<string.h>
#include<algorithm>
using namespace std;
const int N = 1e5 + 5;
int n, m;
int pre[N];
struct stu {
int x;
int y;
int t;
}a[N];
bool cmp(stu x, stu y) {
return x.t < y.t;
}
int find(int x) {
if (pre[x] == x)return x;
return pre[x]=find(pre[x]);
}
void join(int x, int y) {
int fx = find(x);
int fy = find(y);
if (fx != fy) {
if (fx < fy) {
swap(fx, fy);
}
pre[fx] = fy;
}
}
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) {
pre[i] = i;
}
for (int i = 1; i <= m; i++) {
cin >> a[i].x >> a[i].y >> a[i].t;
}
sort(a+1, a + m+1, cmp);
int flag = 0;
for (int i = 1; i <= m; i++) {
join(a[i].x, a[i].y);
for (int i = 1; i <= n; i++) {
if (find(i) != 1) {
flag = 1;
}
}
if (flag == 0) {
cout << a[i].t;
return 0;
}
flag = 0;
}
cout << -1;
return 0;
}
4.一中校运会之百米跑(洛谷p2256)
这个也是一个基本的并查集的一个题目,但有一点不同的是这在每个数中的值不是int类型的 而是string类型的数据,那这里就得我们来进行一个巧妙的转化,我们按照顺序把每个字符串转换为1 2 3........之类的数字 具体代码如下:
#include<iostream>
using namespace std;
const int N = 2 * 1e4 + 5;
int n, m;
string a[N];
string pre[N];
int rank[N];
int fname(string name) {
for (int i = 1; i <= n; i++) {
if (name == a[i])return i;
}
}
string find(string x) {
if (pre[fname(x)] == x)return x;
return pre[fname(x)]=find(pre[fname(x)]);
}
int main() {
int k;
cin >> n >> m;
for (int i = 1; i<= n ; i++) {
cin >> pre[i];
a[i] = pre[i];
}
for (int i = 1; i <= m; i++) {
string a, b;
cin >> a >> b;
pre[fname(find(a))] = find(b);
}
cin >> k;
for (int i = 1; i <= k; i++) {
string a, b;
cin >> a >> b;
if (find(a)==find(b)) {
cout << "Yes." << endl;
}
else {
cout << "No." << endl;
}
}
return 0;
}
(二)线段树
这个线段树也是一个高级数据结构 ,它是一种二叉搜索树,而二叉搜索树,首先满足二叉树,即每个结点最多有两颗子树,并且是一颗搜索树,我们要知道,线段树的每个结点都存储了一个区间,也可以理解成一个线段,而搜索,就是在这些线段上进行搜索操作得到你想要的答案。
看上面这幅图,红色的字代表区间,蓝色的字代表在这个区间里面的最大值,那么我们可以显而易见的得知,父节点是两个子节点的和 你看不论是区间和还是max 区间和是两个儿子合起来 父节点的最大值是两个儿子之间的最大值的那个 所以理解起来还是比较好理解的
我们再看这个图 和上面有区别的就i是多有了一些绿色的下标 这个下标就是代表每个节点的下标 我们可以看到 比如最下面一排的那个[4,4]区间的下标是12 而旁边的[2,2]下标是9而不是11 是不是感到有些奇怪 其实10 11这两个下标应该是[3,3]的子节点的两个下标 只不过[3,3]没有子节点罢了 但还是要给他的 这样以后我们可以看出 每个父节点下面两个子节点的两个下标分别为 2*i(左) 和2*i+1(右) 解释这么多 那我们肯定有疑惑了 那我们怎么建树呢?哎他这就来了 :
void build(int id, int l, int r) {//构建线段树
tree[id].l = l;
tree[id].r = r;
if (l == r) {//如果左端点等于右端点 那么就直接赋值
tree[id].sum = a[l];
return;
}
int mid = (l + r) / 2;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
//当上面的递归完成之后 合并当前区间
tree[id].sum=tree[id*2].sum+tree[id*2+1].sum; //这个是加上这两个区间
//tree[id] = min(tree[id * 2], tree[id * 2 + 1]);//这个是取两个之间的最小值
}
这个是最基本的求和线段树 它这个树建出来就是求某一个区间的值的和,这也是一个最基本的建树。那我们建完树之后 我们能做什么? 先来第一步吧 区间查询一下 就是查询一个区间的区间和;
int search(int id, int l, int r) {
if (tree[id].l >= l && tree[id].r <= r) {
return tree[id].sum;
}
//当这个区间被目标区间完全覆盖的时候 那么我们就直接返回sum;
if (tree[id].r<l || tree[id].l>r) {
return 0;
}
//当这个区间与目标区间完全没有关系的时候 直接返回0;
int s = 0;
if (tree[id * 2].r >= l) s += search(id * 2, l, r);//如果目标区间与区间的左儿子有交集 那么就搜索左儿子
if (tree[id * 2 + 1].l <= r) s += search(id * 2 + 1, l, r);//如果目标区间与区间的右儿子有交集,那么就搜索右儿子
return s;
}
那有时候我们想在某一个点更新一下区间的时候我们的代码又应该怎么写呢?
void add(int id, int dis, int k) {
if (tree[id].l == tree[id].r) {
tree[id].sum += k;
return;
}
if (dis <= tree[id * 2].r) {
add(id * 2, dis, k);
}
else {
add(id * 2 + 1, dis, k);
}
tree[id].sum = tree[id * 2].sum + tree[id * 2 + 1].sum;
return;
}
那我们更新之后的区间之后再用我们上面的查找能找到我们想要的区间和吗?答案当然是不行的,不信的小伙伴可以自己去试一下 所以这个时候我们就应该优化一下我们之前的函数 这里就要引进一个lazy变量 然后模板如下:
void add(int id, int l, int r, int k) {
if (tree[id].r <= r && tree[id].l >= l) {
tree[id].sum += k * (tree[id].r - tree[id].l + 1);
tree[id].lz += k;
return;
}
push_down(id);
if (tree[id*2].r >= l) {
add(id * 2, l, r, k);
}
if (tree[id*2+1].l <= r) {
add(id * 2 + 1, l, r, k);
}
tree[id].sum = tree[id * 2].sum + tree[id * 2 + 1].sum;
}
void push_down(int id) {
if (tree[id].lz != 0) {
tree[id * 2].lz += tree[id].lz;
tree[id * 2 + 1].lz += tree[id].lz;
int mid = (tree[id].l + tree[id].r) / 2;
tree[id * 2].sum += tree[id].lz * (mid - tree[id * 2].l + 1);
tree[id * 2 + 1].sum += tree[id].lz * (tree[id * 2 + 1].r - mid);
tree[id].lz = 0;
}
return;
}
int search(int i, int l, int r) {
if (tree[i].l >= l && tree[i].r <= r)
return tree[i].sum;
if (tree[i].r<l || tree[i].l>r) return 0;
push_down(i);
int s = 0;
if (tree[i * 2].r >= l) s += search(i * 2, l, r);
if (tree[i * 2 + 1].l <= r) s += search(i * 2 + 1, l, r);
return s;
这个就是一个基本的线段树的模板 我在下面也把代码都整理在一起了:
#include<iostream>
using namespace std;
const int N = 1e4;
struct node {
int l, r,lz;
int sum;
}tree[N*4];
int a[N];
void build(int id, int l, int r) {//构建线段树
tree[id].l = l;
tree[id].r = r;
if (l == r) {//如果左端点等于右端点 那么就直接赋值
tree[id].sum = a[l];
return;
}
int mid = (l + r) / 2;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
//当上面的递归完成之后 合并当前区间
//tree[id].sum=tree[id*2].sum+tree[id*2+1].sum; //这个是加上这两个区间
//tree[id] = min(tree[id * 2], tree[id * 2 + 1]);//这个是取两个之间的最小值
}
//这个时候树就构造好了
//当题目是最基础的那种 就是让我们搜索(l,r)这个区间的线段树之和 那么多加一个search函数;
int search(int id, int l, int r) {
if (tree[id].l >= l && tree[id].r <= r) {
return tree[id].sum;
}
//当这个区间被目标区间完全覆盖的时候 那么我们就直接返回sum;
if (tree[id].r<l || tree[id].l>r) {
return 0;
}
//当这个区间与目标区间完全没有关系的时候 直接返回0;
int s = 0;
if (tree[id * 2].r >= l) s += search(id * 2, l, r);//如果目标区间与区间的左儿子有交集 那么就搜索左儿子
if (tree[id * 2 + 1].l <= r) s += search(id * 2 + 1, l, r);//如果目标区间与区间的右儿子有交集,那么就搜索右儿子
return s;
}
//当我们要修改区间单点的时候 再来构造一个函数
void add(int id, int dis, int k) {
if (tree[id].l == tree[id].r) {
tree[id].sum += k;
return;
}
if (dis <= tree[id * 2].r) {
add(id * 2, dis, k);
}
else {
add(id * 2 + 1, dis, k);
}
tree[id].sum = tree[id * 2].sum + tree[id * 2 + 1].sum;
return;
}
//上述讲了一个单点更新的add 以及区间查找的search 那么我们想做到区间更新
// 难道是两个函数加一下?当然不是 我们这边要优化我们之前的函数内容 要加一个lazy
/*void add(int id, int l, int r, int k) {
if (tree[id].r <= r && tree[id].l >= l) {
tree[id].sum += k * (tree[id].r - tree[id].l + 1);
tree[id].lz += k;
return;
}
push_down(id);
if (tree[id*2].r >= l) {
add(id * 2, l, r, k);
}
if (tree[id*2+1].l <= r) {
add(id * 2 + 1, l, r, k);
}
tree[id].sum = tree[id * 2].sum + tree[id * 2 + 1].sum;
}
void push_down(int id) {
if (tree[id].lz != 0) {
tree[id * 2].lz += tree[id].lz;
tree[id * 2 + 1].lz += tree[id].lz;
int mid = (tree[id].l + tree[id].r) / 2;
tree[id * 2].sum += tree[id].lz * (mid - tree[id * 2].l + 1);
tree[id * 2 + 1].sum += tree[id].lz * (tree[id * 2 + 1].r - mid);
tree[id].lz = 0;
}
return;
}
int search(int i, int l, int r) {
if (tree[i].l >= l && tree[i].r <= r)
return tree[i].sum;
if (tree[i].r<l || tree[i].l>r) return 0;
push_down(i);
int s = 0;
if (tree[i * 2].r >= l) s += search(i * 2, l, r);
if (tree[i * 2 + 1].l <= r) s += search(i * 2 + 1, l, r);
return s;
}*/
//主函数就根据具体题目需要来写;
int main() {
}
这里面是最基本的线段树的模板 有单点修改求区间和 以及求区间修改求区间和 其实单点修改就可以看成区间修改 只不过区间修改的那个区间的左右两个值相同罢了
1.simple problem with integers
这个题目是一个最基本的线段树的模板 主要意思就是 当输入Q的时候 查询区间并输出
#define _CRT_SECURE_NO_WARNINGS 1
#include<iostream>
#include<math.h>
#include<stdio.h>
using namespace std;
const int N = 1*1e5 +10;
struct node {
long long l, r,lz;
long long sum;
}tree[N*4+2];
long long a[N+2];
void push_down(int id) {
if (tree[id].lz != 0) {
tree[id * 2].lz += tree[id].lz;
tree[id * 2 + 1].lz += tree[id].lz;
int mid = (tree[id].l + tree[id].r) / 2;
tree[id * 2].sum += tree[id].lz * (mid - tree[id * 2].l + 1);
tree[id * 2 + 1].sum += tree[id].lz * (tree[id * 2 + 1].r - mid);
tree[id].lz = 0;
}
return;
}
void build(int id, int l, int r) {//构建线段树
tree[id].l = l;
tree[id].r = r;
if (l == r) {//如果左端点等于右端点 那么就直接赋值
tree[id].sum = a[l];
return;
}
int mid = (l + r) / 2;
build(id * 2, l, mid);
build(id * 2 + 1, mid + 1, r);
//当上面的递归完成之后 合并当前区间
tree[id].sum=tree[id*2].sum+tree[id*2+1].sum; //这个是加上这两个区间
//tree[id] = min(tree[id * 2], tree[id * 2 + 1]);//这个是取两个之间的最小值
}
void add(long long id, long long l, long long r, long long k) {
if (tree[id].r <= r && tree[id].l >= l) {
tree[id].sum += k * (tree[id].r - tree[id].l + 1);
tree[id].lz += k;
return;
}
push_down(id);
if (tree[id*2].r >= l) {
add(id * 2, l, r, k);
}
if (tree[id*2+1].l <= r) {
add(id * 2 + 1, l, r, k);
}
tree[id].sum = tree[id * 2].sum + tree[id * 2 + 1].sum;
}
long long search(long long i, long long l, long long r) {
if (tree[i].l >= l && tree[i].r <= r)
return tree[i].sum;
if (tree[i].r<l || tree[i].l>r) return 0;
push_down(i);
long long s = 0;
if (tree[i * 2].r >= l) s += search(i * 2, l, r);
if (tree[i * 2 + 1].l <= r) s += search(i * 2 + 1, l, r);
return s;
}
int main() {
long long n, m;
while (~scanf("%lld%lld", &n, &m)) {
for (int i = 1; i <= n; i++) {
scanf("%lld", &a[i]);
}
build(1, 1, n);
for (int i = 1; i <= m; i++) {
char x;
cin >> x;
if (x != 'C') {
long long y, z;
scanf("%lld%lld", &y, &z);
cout << search(1, y, z) << endl;
}
else {
long long y, z, k;
scanf("%lld%lld%lld", &y, &z, &k);
add(1, y, z, k);
}
}
}
return 0;
}
(三)树状数组
这个树状数组是个什么玩意儿呢 先看下面的那个图片:
这个也是一个树的数据结构 它算是线段树的一个下级 树状数组能做出来的题目线段树都能写 但是反过来线段树能写的题目树状数组不一定能写出来 那么有人就会问了 那我们还学树状数组干嘛呢 线段树学通了不就完事了我们经过线段树的初步学习我们也可以大致了解到 线段树的代码很长 有建树build 有维护有查找等等 而树状数组就相对来说比较清爽 而且如果遇到比较恶心的出题者它可能在题目的数据上面会卡着你 让你不能用线段树写只能用树状数组写
ok介绍了那么多树状数组的内容 可能看了之后还是有点糊里糊涂 我这边建议可以前往树状数组 - OI Wiki (oi-wiki.org)https://oi-wiki.org/ds/fenwick/
这边详细的解释了树状数组的结构等等 包括待会我要介绍的一些基本的树状数组的模板(毕竟我今天刚刚接触到这个树状数组的内容 只有一点点自己的理解 你让我解释等等我还是比较困难的)
ok接下来就当大家都明白了树状数组是什么玩意了 那之后在讲之前得给大家介绍一个东西 就是
x&-x这个东东 这个是什么东西呢?
看到上面的解析如果还不懂的话 那我再举几个例子 就比如8&-8是多少? 答案是8 因为8的二进制是1000 那由此我们可以看出 ②的次方的所有数在经过这个操作之后都是它本身 因为它二进制数组里面就只有一个1
ok知道这个原理之后那我们先创造一个函数为lowbits(i) 它的作用就是返回x&-x的值 :
int lowbits(int x) {
return x & -x;
}
然后我们要记住每个c (看第一个图) 不是相当于一个a的前缀和的那种感觉 就比如c[4]=a[1]+...a[4] 那这个c[n]=a[l]+....+a[r]这个l和r是怎么取的 这边有个规律 l=n-lowbits(l)+1,r=n;
拿c[5]来举例子 l=5-lowbit(5)+1=5-1+1=5 r=5;那么c[5]=a[5]; 所有的c都是保持这种规律。
(1)单点修改 区间查询
当我修改了一个一个单点的值的时候 我该如何操作?就如同线段树里面的add一样 一个一个加起来呗 代码模板如下:
void add(int id, int c) {
for (int i = id; i < N; i += lowbits(i)) {
tree[i] += c;
}
}
查找么也是大差不差的
int search(int l, int r) {//查找区间[l,r]的区间和;
int ans = 0;
for (int i = l - 1; i; i -= lowbits(i));
ans -= tree[i];
for (int i = r; i; i -= lowbits(i));
ans += tree[i];
return 0;
}
比如我想查找 [3,5]的区间和 那我就是[1,5]的区间和减去[1,3]的区间和 因为所有外面的c都是从1开始的
(2)区间修改 单点查询
上述我们是对单点的修改 那我们怎么对区间进行修改?这边我们就要用到一个差分的思想 我们需要构造出原数组的差分数组 我们且就叫他为数组b 然后修改区间 比如说 修改[l,r]这个区间 都增加k 因为是差分 我们只要在一开始加k 然后在最后减k 就行 看代码:
int update(int pos, int k) {
for (int i = pos; i <= N; i += lowbits(i)) {
b[i] += k;
return 0;
}
}
update(l, k);
update(r + 1, -k);
(此时的b[i]已经是差分数组) 然后我们求单点pos的值 跟上面一样:
//对于单点Pos的值 求出b[pos]的前缀和就行了 因为 a[pos]=b[1]+b[2]+.....+b[pos};
int ask(int pos) {
int ans = 0;
for (int i = pos; i; i -= lowbits(i)) {
ans += b[i];
}
return ans;
}//返回区间pos到1的总和 当然这个总和是差分数组的总合 那这样就完成了单点查询
(3)区间修改 区间查询
对于区间修改 区间查询的概念是什么 在上面那个链接也有讲述 ok 当大家都懂了原理 那我们接下来是不是要维修两个树 一个树是di 一个树是di*k 具体代码如下 :
#include<iostream>
#include<algorithm>
using namespace std;
typedef long long ll;
const int N = 1e6 + 5;
ll A[N];
ll s1[N], s2[N];
int lowbit(int x) {
return x & -x;
}
void Modify(int pos, ll val) {
for (int i = pos; i <= N; i += lowbit(i)) {
s1[i] += val, s2[i] += pos * val;
}
}
void modify(int l, int r, int val) {
Modify(l, val), Modify(r + 1, -val);
}
ll Query(int x) {
ll ans = 0;
for (int i = x; i >= 1; i -= lowbit(i)) {
ans += (x + 1) * s1[i] - s2[i];
}
return ans;
}
ll query(int l, int r) {
return Query(r) - Query(l - 1);
}
int main() {
}
.