本篇文章内容如下,请耐心观看,将持续更新。
-
简单数组
-
简单栈
-
简单队列
-
简单链表
-
简单二叉树
-
简单集合
-
图的基本概念
-
二叉堆
-
线段树
-
树状数组与字典树
-
线段树进阶
简单数组:
-
STL可变数组 vector
" 我们首先要知道这个容器有什么特性,然后它是咋创建的、然后要知道这个东西最常见的功能,访问,查找,删除,修改,添加……是如何实现的。再接着,我们尽可能了解一些这个容器的常见函数的使用,还要知道它的时间复杂度。那么这个容器,你就算大概了解了。"
vector 是一个“ 可变长度 ” 数组。 一般是数组,定义的时候需要同时定义长度。
有些时候,我们不知道应该定义多长,或者定义过长会出现浪费的情况
那么我们就希望 有一个弹性的数组,需要用多少就有多长。
vector 就是一个这样的东西
“ 建立一个可变长度数组v,内部元素类型是int , 该可变数组最开始有 n 个元素, 每个元素被初始化为 m 。如果省略m,那么这个可变数组有n个元素,每个元素初始化为0.”
vector<int> v(n,m)
"我们也可以省略上面的 (n,m),此时的 v 长度就是0。 “
vector<int > v
“并且,v容器内的元素还可以是其他的数据类型。”
vector<string> v;
vector<char> v;
对于vector 数组的 访问 或 编辑 ,我们可以像普通数组一样使用方括号进行索引(也就是说下标这个概念是存在于vector中的),比如 v [1 0 ] ,就是访问下标为10的元素。
不过需要注意的是: 如果使用下标进行访问,那么需要注意vector 的长度是不是足够,否则就会造成越界。
其实,因为vector 是可变长的,所以如果空间可能会不够的话,我们可以实时地给它增长长度。
就可以用 push_back ( ) , 或者 resize ( ) 函数,进行增长。
然后要想知道这个数组的长度的话,我们就可以用 size()函数。
vector <int > v ;
1、v.push_back(a);
2、v.resize(n,m);
3、v.size();
1、v.push_back(a) 指的是 向容器里添入一个元素,添入成功后,自然v的长度也就变长了 。
2、v.resize(n,m)指的是 “重新调整数组长度为n” ,如果当前vector 的长度小于n ,那么他的长度在原来的基础上就会增加到n,而新增加的元素,就会被初始化为m。
倘若vector 的长度 大于n ,那么就会删除多余的部分。
3、v.size() 指的是 " 返回v数组的长度"。
来一道简单的例题,上手实践一下这个新容器吧?
P3156 【深基15.例1】询问学号 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P3156
#include<iostream>
#include<vector>
using namespace std;
int main()
{
vector<int> arr;
int n,m;
cin >> n>>m;
for (int i = 0; i < n; i++)
{
int temp;
cin >> temp;
arr.push_back(temp);
}
for (int i = 0; i < m; i++)
{
int temp;
cin >> temp;
cout << arr[temp-1];
if(i!=m-1)cout<<endl;
}
}
看到这里,我们想想还有什么没讲?
- 上面说了增删改查的第一种形式,就是使用数组下标,那么还有没有另一种方式呢?
- 上面介绍了一维vector , 我们都知道数组也有二维,那么vector 有没有二维呢?
- vector 还有其他有用的函数吗? 这些函数对比常规的静态数组有什么优势呢?
接下来的内容我们就围绕这些问题来讲述:
增删改查的第二种形式: 迭代器。
vector <int > v;
vector <int > :: iterator it;
vector <char > :: iterator ii;
定义一个迭代器 i t,这个迭代器只能指向 vector<int >。
定义一个迭代器 i i ,这个迭代器只能指向 vector<char >。
由上面的代码,我们足以看出一些东西了。
首先 迭代器一定是有对象的(悲,我没有 ),并且这个迭代器 是为一类容器服务的,不是为了某一个具体的容器。所以我们后续可能会有一些方法,使得这个迭代器变成某个具体容器的专门迭代器。
其次,迭代器的定义也太特么长了吧!! (说不定存在某些东西可以让我们简写)
最后,迭代器肯定和一般的int,char 这种数据类型不一样,它肯定是一种特殊的数据类型,比如指针这种。所以使用它的时候,我们不能以看待常见的数据类型的眼光去看它……。
相信,再给一段代码,这段代码代表,遍历容器内所有的元素,你一定能解决上面的问题吧!喵
vector<int > v;
vector<int > :: iterator it;
for( it = v.begin() ; it != v.end() ; it++ ){
cout << *it<<' ' ;
}
” 如果你是第一次看见, 你可能会说, 卧槽,这一坨东西是啥,为什么我每个字母都看得懂,连起来我就不知道是啥了 啊 呜呜呜 “ 。
别急别急,我们细细分析, 在上面我们说到,it 迭代器是为一类容器服务的(比如容器内部是int的元素算一类 ) 但是为啥 it 能用来遍历 v容器(一个具体的容器)啊? 噢!
因为我们让 it 等于了 v.begin() 这个东西。 所以it 就暂时为 v容器服务了。
我们还能知道,因为it是迭代器类型, 一个东西能直接给它赋值, 那么这俩玩意,一定是互通的,或者说,这俩是同一种类型 。
哎哟卧槽,这岂不是说
v.begin( ) 应该就是 专属于 v的东西(因为它开头已经被打上了标记v
并且,v.begin( ) (我们可以看出他应该是个函数) 这个函数的返回值必然是 一个 迭代器类型的东西!!!。
bingo !!恭喜你少年,你猜对了!!。
那么 v.end () 肯定跟 v.begin ( ) 是差不多的东西辣。
结合他俩的英文单词,再加上我说这串代码是遍历容器v,所以所以所以!!!
v.begin ( ) 返回的是一个指向 v容器开头的迭代器,
v.end( ) 返回的是指向v容器末尾的迭代器。
我们的临时工 it ,从v容器的开头,遍历到v容器的结尾, 这特么可不就是遍历吗!!!!
但是,你可能还有一个疑问,这和我们常规的数组遍历不一样啊,咋回事啊?
for ( int i=0 ;i< v.size() ;i ++ ) {
cout << v[i]<<' ';
}
vector<int > :: iterator it;
for ( it = v.begin() ;it!= v.end(); it++ )
cout<< *it<<' ';
}
(为啥一个 是 i<v.size() , 一个是 it != v.end( ) )
(为啥一个是 cout<< i , 一个是cout<< *it )
等等,华生,你发现了盲点!!!cout后面的内容,居然是输出 *it , 那么岂不是说明,这个迭代器,在某些层面上和指针是一样的!!!
嗷嗷嗷嗷嗷,你彻底懂了, 所以 it 不能写成 it< v.end() ,为啥,这特么是地址啊,只有等于或不等于的情况,没有大于小于的情况。
那么也就明白了, it ++ 是什么意思, 也就是指向下一个元素 地址。总之,这一方面,就是指针的内容。
好好好 , 你已经彻底懂了迭代器是啥,也知道begin,end 这俩函数是啥,你已经掌握一大半了。
你也许会问,为啥,it != v.end() , end 难道不是最后一个元素吗?
其实。。 end不是指最后一个元素,它指的是最后一个元素的再后面一个位置,这个位置是未知的,所以千万要小心,不能输出 *end。
然后,就剩最后一个问题了,二维的vector是咋样的呢?
首先,我先不告诉你二维的vector 是啥样的。我们不妨来想象一下吧!
对于一个这样的数组:
int a[10][10] ;
我们都知道它可以表示成:
{ ………………} ,
{………………},
…………
{………………},
一共有是10 层,每一层都有 10个元素。
也就是说,如果这个数组是二维的,那么它的一维下标代表有多少层,二维下标代表这一层有多少个数字。(等等,我们为啥说是数字呢?因为,这个数组的数据类型是 int )
好,那么我们参照二维普通数组的概念。
那么可变二维vector 我们可以猜测: 它的第一维表示有多少层,这个层数是不定长的,二维表示每一层有多少元素,这个元素也是不定个数的。
不等层数,每一层不定元素的初始化:
vector< vector<int > > v;
定层数,但是每一层元素不固定的初始化:(最常用)
vector< vector<int > > v(5);
或者也可以写成:
vector< int > v[5]
这也是二维数组,代表有五个vector
定层数,定元素的初始化:
vector< vector<int > > v (5,vector<int>(4,0) );
那么我们由彼及此,由于二维vector 的 第二维也是一个vector ,那么 v是一个vector , v[i] 自然也是一个vector咯。
v[ 1 ].push_back(b), 就代表在第一层,添加一个元素;
看到这里你可能会想:那第一种不定层数,不定元素的 二维vector 应该怎么加入层数呢?
我们观察它的初始化,不难发现,最外层的vector 的元素的数据类型 也是 vector,所以要想增加层数,则我们只要push_back( ) vector类型就行了。
现在你已经知道 了二维vector , 那么就来做一道题巩固一下吧!!!
传送门:P3613 【深基15.例2】寄包柜 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P3613
题解:
#define _CRT_SECURE_NO_WARNINGS #include<iostream> #include<cstdio> #include<cmath> #include<string> #include<cstring> #include<string> #include<algorithm> #include<vector> #include<cctype> #include<map> #include<set> #include<queue> #include<numeric> #include<iomanip> using namespace std; int n, q, i, j, k, ops; int main() { cin >> n >> q; vector< vector<int> > v(n+10); while (q--) { cin >> ops; if (ops == 1) { cin >> i >> j>>k; if (v[i].size() <= j) { v[i].resize(j + 1); } v[i][j]=k; } else { cin >> i >> j; cout << v[i][j] << endl; } } }
现在你已经大致了解了 简单vector 的用法。下面我们来介绍一些好用的vector 函数,这些函数可以给我们解题带来极大的遍历。(待更新中……)
简单栈:
“ 啥是栈啊? ”
你可以把它想象成一个深坑。 嗯。。没了,这就是栈。
你任意在这个坑里面放一些元素(元素不能融合!)
你能想到的任何对这些元素的操作,都是栈操作。(不要杠我
栈的最常见的操作就是,压入,取出。
你可以想象,我们将一些物品用1~n标号,逐个放入这个坑中。我们想将1取出,那么必然要将1上面的物品取出,要想取出1上面的物品,那就要取出1上面的上面的物品…………
直到这个物品上方为空(即没有任何物体)
我们可以发现,这样的坑,有个明显的特点” 后进先出,先进后出 “,”只能从一端进行增删的操作“。
于是,聪明的数学家们,将满足上面性质的东西,称作栈结构。
所以啊,我们便不必局限于是不是一个坑了。任何满足这些性质的东西,我们都可以将它抽象成一个栈结构。
对于一个栈结构,我们一般会对其进行以下的操作:
stack < int > s;
s. push(a) 将a压入栈中
s.pop () 弹出栈顶元素
s.top ()查询栈顶元素
s.size ()查询栈内元素个数
s.empty ()查询栈是否为空
我们再用一个例子来描述一下栈的简单操作流程:
假设现在有 1,2,3 三个数 , 我们依次将其压入栈,然后压入之后,再一个一个取出来。
stack <int > s;
s.push(1);
s.push(2);
s.push(3);
cout<<s.top()<<endl;
s.pop();
cout<<s.top()<<endl;
s.pop();
cout<<s.top()<<endl;
s.pop()
输出结果: 3 2 1
在我们初步了解了栈的基本操作之后,我们就应该试图手写一个栈。
ps:(栈虽然直接用stl方便,但是如果我们不打开 O2优化的话,就会有一点慢。
在一些非常需要追求运行速度的情况下,往往需要自己手写栈。)
手写栈:
#include<iostream>
using namespace std;
const int MAXN = 1e5;
int stack[MAXN];
int p = 0; //栈顶指针
void push(int x) {
if (p >= MAXN) {
cout << "Stack overflow" << endl;
return;
}
p++;
stack[p] = x;
return;
}
void pop() {
if (p == 0) {
cout << "Stack is empty";
return;
}
p--;
}
int top() {
if (p == 0) {
cout << "Stack is empty";
return 0;
}
return stack[p];
}
int size() {
if (p == 0) {
cout << "Stack is empty";
return 0;
}
return p;
}
bool empty() {
if (p == 0)return true;
else return false;
}
好啦,现在你已经大概知道了栈是如何工作的。
那么我们就做几道题来巩固一下吧。
例题1、
Parentheses Balance - UVA 673 - Virtual Judgehttps://vjudge.net/problem/UVA-673
输入一个包含“()”和“[]”的括号序列,判断是否合法。 具体规则:
- 空串合法;
- 如果A和B合法,那么AB合法;
- 如果A合法(A)和[A]都合法
输入输出样例:
输入 #1复制3 ([]) (([()]))) ([()[]()])()输出 #1复制
Yes No Yes
#include<bits/stdc++.h>
using namespace std;
char reverse(char ch){
if(ch==')')return '(';
if(ch==']')return'[';
else return ' ';
}
int main(){
stack<char> s;
int sum;
cin>>sum;
cin.ignore();
string t;
while(sum--){
while(!s.empty())s.pop();
getline(cin,t);
for(int i=0;i<t.size();i++){
if(s.empty()){s.push(t[i]);continue;}
if(reverse(t[i])==s.top())s.pop();
else s.push(t[i]);
}
if(s.empty())cout<<"Yes"<<endl;
else cout<<"No"<<endl;
}
}
例题2、后缀表达式 - 洛谷1449https://www.luogu.com.cn/problem/P1449
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
using namespace std;
stack<int > S;
void todo(char ch,int first,int second) {
if (ch == '*')S.push(first * second);
else if (ch == '+')S.push(first + second);
else if (ch == '-')S.push(second - first);
else if (ch == '/')S.push(second / first);
return;
}
int main() {
string s;
getline(cin, s);
int first, second;
int t = 0;
for (int i = 0; i < s.size(); i++) {
while (isdigit(s[i])) {
t = t * 10 + s[i] - '0';
i++;
}
if (s[i] == '.') {
S.push(t);
t = 0;
continue;
}
else if (s[i] == '@')break;
else {
first = S.top();
S.pop();
second = S.top();
S.pop();
todo(s[i], first, second);
}
}
cout << S.top();
}
简单队列:
这玩意,我们需要解释吗?啥是队列,超市买东西排队这就是队列。一端付完钱直接走人,叫队头。
一端挑完东西,加入队列,叫队尾。
所以,一端进入一端出去,先进先出就是队列。
如果我们看到一道题目满足这样的性质,我们就可以用队列来模拟。
那么,对于这样的队列,我们有啥操作呢?其实和栈是差不多了。
- queue< int > q ;
- 入队 (加入队尾) q.push()
- 出队 (从队首出队)q.pop()
- 查询队首 q. front()
- 查询队尾 q.back()
- 查询元素个数 q. size()
- 是否为空 q.empty ()
这样光说,肯定是枯燥的,那么我们就列举一个生活中常见的例子吧。
超市排队:(编写一个程序模拟收银过程)
超市排队模拟器:
int queue[MAXN]; // 用数组模拟队列,MAXN 表示队伍一次性能加入的人数
int head=0; // 队头指针
int tail=0; // 队尾指针
有人挑完东西,加入正在付款的队伍:
void push(int x){
if( tail>MAXN ) cout<<" Queue overflow "<<endl;
else {
queue[tail]=x;
tail++;
}
}
队伍最前面的人已经买完了东西,现在要走人
void pop(){
if(head==tail){
cout<<"Queue is empty"<<endl;
}
else{
head++;
}
}
查询队伍最前面的人是谁
int front(){
if(head==tail){
cout<<"Queue is empty"<<endl;
}
else{
return queue[head];
}
查询队伍最后面的人是谁
int back(){
if(head=tail){
cout<<"Queue is empty"<<endl;
}
else{
return queue[tail-1];
}
查询队伍人数:
int size(){
return tail-head;
}
查询队伍是否为空:
bool empty(){
if(head==tail)return 1;
else return 0;
}
现在,你应该对队列的操作熟悉了吧?那下面来写一道经典的习题吧!!
P1996 约瑟夫问题 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1996ps(这道题可以优化成一个队列的写法,就是每次数到的数字整除m,先输出,再删除,,否则,先将数字加入队尾,然后再删除。
退出循环的条件就是这个队列为空
#include<bits/stdc++.h>
using namespace std;
queue<int > id;
queue<int > temp;
queue<int > out;
//先将所有的人加入到id队列
// 将id队列的人一个一个出队,出到 temp临时队列
// 每次当数到的数字能够整除m,此时的人出队,进入到out队列。
// 然后循环完了一遍,将temp队列重新赋值到id队列,再将temp队列清空
// 结束条件就是out队列的数量大于等于 n
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++)id.push(i);
int count = 1;
while (out.size()<=n) {
if (out.size() >= n)break;
int len = id.size()+count-1;
for (; count <= len; count++) {
if (count % m == 0)out.push(id.front());
else temp.push(id.front());
id.pop();
}
while (!temp.empty()) {
id.push(temp.front());
temp.pop();
}
}
while (!out.empty()) {
cout << out.front() << ' ';
out.pop();
}
}
简单链表 :
啥是链表?链表是一种和上面我们讲过的栈,队列,数组 相似的线性的,储存元素排列顺序的表。
让我们再接触链表之前,先和之前一样从生活实际开始模拟。
假设n名同学排成一排,解散后,现在要求每个人重新排成原来的样子,但是没有人知道原来是怎么排的,好在每个同学都记得自己后面的第一个人是谁。利用这些信息,你能还原出初始的队列吗?
假如有4名同学编号从1~4,他们后面的同学分别是:4,3,0,2 (0代表后面无人)
(已经知道1号同学站在第一位)
不难写出代码:
int Next[MAXN];
for(int i=1;i<=MAXN;i++){
cin>>Next[i];
}
for(int i=1;i!=0;i=Next[i]){
cout<<i<<' ' ;
}
从这个问题,我们不难知道链表具有怎样的性质:
如果你知道每个元素的前面后面是谁,那么你就可以恢复整个表的顺序。
也就是说,链表,其实就是一种储存了每个元素前驱和后继的表。
如此,我们就得到了链表的重要特性,利用这个特性,我们可以做什么呢?
下面通过另一个问题来回答这个问题。
有n名同学正在排队,但是来了一位恶霸(y号)插队到了x号的后面,其余的同学顺序不变,求,插队之后,队伍是什么样的顺序?
int Next[MAXN];
void insert(int x,int y) {
int temp = Next[x]; // 先储存x的后继
Next[x] = y; // x的后继为y
Next[y] = temp; // y的后继是原来x 的后继
}
通过这样的方式,我们就可以快速地实现插入这一步骤了,即时数据量再大也不怕捏。
那么再来思考一下这个:
假如,x同学的后面一个同学忍不住,先离开了,那么现在的队列应该是什么顺序呢?
void restore(int x) {
Next[x] = Next[Next[x]];
}
通过以上的两种方法,我们可以只要耗费O(1)的时间复杂度就完成一次维护。
这是单链表的使用方法,但其实上,我们有很多类型的链表需要学习:(目前只了解单双链表即可)
- 单链表(每一个结点记录自己的后继,只能单向移动)
- 双链表(每一个结点记录自己的前驱和后继,可以双向移动)
- 循环单链表(尾部的后继是头部)
- 循环双链表
- 块状链表
- 跳表
老规矩下面进行一个排队的模拟(假设队伍里最开始只有编号1这一个人):
这是排队的程序应该有的功能
- ins_back(x,y); 将元素y插入到x的后面
- ins_bcak(x,y); 将元素y插入到x的前面
- ask_back(x); 询问x的后继是谁
- ask_front(x); 询问x的前驱是谁
- del(x); 从列表中删除元素,不改变其他元素的先后顺序。
这题,我们就可以用双向链表来维护:
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
using namespace std;
const int MAXN = 1e6 + 7;
struct node {
int pre,nex,val; // 前驱,后继,值
}a[MAXN] = {0};
int n, pls;//相对位置
int flag[MAXN]; //标记数组,标记是否存在
int index1[MAXN];//用来记录每个结点编号
void ins_pre(int x, int y) {
//首先找到x的结点编号
int now =
pls++; //新增一个编号给y用
a[pls].nex = now; // y的后缀等于 x的结点编号now
a[pls].val = y; // y的值等于y。
a[pls].pre = a[now].pre; // y的前驱是x原来的前驱
a[a[now].pre].nex = pls; // x原来的前驱的后继变成y的编号 pls
a[now].pre = pls; // x的前驱是pls。
index1[y] = pls;
}
//将y元素插在x 的后面
void ins_back(int x, int y) {
int now = index1[x];
pls++;
a[pls].pre = now; // 先把y的前驱改成x
a[pls].nex = a[now].nex;//再把y的后继改成x的后继
a[pls].val = y;//再把y的值附上
a[a[now].nex].pre = pls;//然后改变x的后继的前驱
a[now].nex = pls;//最后改变x的后继
index1[y] = pls;
}
//询问x的后继的值
int ask_back(int x) {
return a[a[index1[x]].nex].val;
// 先查询x的结点编号,然后返回x的后继的val,这里跳步了直接return
}
//查询x 的前驱的值
int ask_pre(int x) {
return a[a[index1[x]].pre].val;
}
//从链表中删除元素
void del(int x) {
int now = index1[x]; // 找到x的结点编号
a[a[now].pre].nex = a[now].nex;// 将x前驱的后继改成x的后继
a[a[now].nex].pre = a[now].pre;// 将x后继的前驱改成x的前驱
}
int main() {
a[0].val = 0;
a[0].pre = 0;
a[0].nex = 0;
index1[0] = 0;
ins_back(0,1);
}
(如果数据过大,index1可能存不下那么多,那么可以用hash或map来优化)
相信看完前面,你对链表也已经有了一些理解,下面来几道简单的例题练练手吧!
例题1:
P1160 队列安排 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1160
#include<bits/stdc++.h>
using namespace std;
const int MAXN = 1e6 + 7;
struct node {
int pre,nex,val; // 前驱,后继,值
}a[MAXN] = {0};
int n, pls;//相对位置
int flag[MAXN]; //标记数组,标记是否存在
int index1[MAXN];//用来记录每个结点编号
void ins_left(int x, int y) {//把y插入到x的左边
int now =index1[x];
pls++;
a[pls].val = y;
a[pls].nex = now;
a[pls].pre = a[now].pre;
a[a[now].pre].nex=pls;
a[now].pre = pls;
index1[y] = pls;
}
void ins_right(int x, int y) {
int now = index1[x];
pls++;
a[pls].val = y;
a[pls].pre = now;
a[pls].nex = a[now].nex;
a[a[now].nex].pre = pls;
a[now].nex = pls;
index1[y] = pls;
}
void del(int x) {
int now = index1[x];
int Nex = a[now].nex;
int Pre = a[now].pre;
a[Nex].pre = Pre;
a[Pre].nex = Nex;
index1[x] = 0;
}
int main() {
cin >> n;
a[0].val = 0;
a[0].pre = 0;
a[0].nex = 0;
ins_right(0, 1);
for (int i = 2; i <= n; i++) {
int k, p;
cin >> k >> p;
if (p == 0) {
ins_left(k, i);
}
else ins_right(k, i);
}
int m;
cin >> m;
while (m--) {
int temp;
cin >> temp;
if (index1[temp])del(temp);
}
int now = a[0].nex;
while (now) {
cout << a[now].val<<' ';
now = a[now].nex;
}
}
ps(虽然我没写注释,但是这道题和我们上面的模拟排队是几乎一样的,所以看懂一个就行 了。)
例题2:
P1996 约瑟夫问题 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1996
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
using namespace std;
const int MAXN = 1e6 + 7;
struct node {
int pre,nex,val; // 前驱,后继,值
node(int _pre=0, int _nex=0 , int _val=0 ) {
pre = _pre, nex = _nex, val = _val;
}
}a[MAXN] ;
int n, pls;//相对位置
int flag[MAXN]; //标记数组,标记是否存在
int index1[MAXN];//用来记录每个结点编号
void ins_pre(int x, int y) {
//首先找到x的结点编号
int now = index1[x];
pls++; //新增一个编号给y用
a[pls] = node(a[now].pre, now, y);
a[a[now].pre].nex = pls; // x原来的前驱的后继变成y的编号 pls
a[now].pre = pls; // x的前驱是pls。
index1[y] = pls;
}
//将y元素插在x 的后面
void ins_back(int x, int y) {
int now = index1[x];
pls++;
a[pls] = node(a[now].nex, now, y);
a[a[now].nex].pre = pls;//然后改变x的后继的前驱
a[now].nex = pls;//最后改变x的后继
index1[y] = pls;
}
//询问x的后继的值
int ask_back(int x) {
return a[a[index1[x]].nex].val;
// 先查询x的结点编号,然后返回x的后继的val,这里跳步了直接return
}
//查询x 的前驱的值
int ask_pre(int x) {
return a[a[index1[x]].pre].val;
}
//从链表中删除元素
void del(int x) {
int now = index1[x]; // 找到x的结点编号
int Nex = a[now].nex;
int Pre = a[now].pre;
a[Nex].pre = Pre;
a[Pre].nex = Nex;
index1[x] = 0;
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
index1[i] = i;
a[i] = node(i - 1, i + 1, i);
}
//搞一个循环双链表。然后循环的次数大一点,一定是可以删除干净的。
//其实循环单链表好像也可以。
a[1] = node(n, 2, 1);
a[n] = node(n - 1, 1, n);
int m;
cin >> m;
int now = index1[1];
while (n--) {
for (int i = 1; i <= m; i++) {
if (i == m) {
cout << now << ' ';
del(now);
}
now = a[now].nex;
}
}
}
好啦,上面是手写链表的过程,那么接下来我就介绍一下链表(stl):list
下面给出链表的常用函数:
定义一个int类型的链表
list<int > a;
我们可以用这样的方式来给链表初始化。
int arr[5] = {1,2,3} ; list<int > a(arr.arr+3 ) ;
返回链表的节点数量:
a.size();
定义一个迭代器:
list<int >:: iterator it ;
链表的开头,和末尾( 返回的是迭代器)
a.begin() , a.end() ;
在链表的开头或者末尾插入元素x
a.push_front (x)
a.push_back(x) ;
在链表某一位置的前面插入元素x:
a.insert( it,x ) ; it 表示这个位置的迭代器
在链表开头或结尾删除元素
a.pop_front () , a.pop_bcak();
删除链表某一位置的元素
a.erase(it) it表示这一位置的迭代器
遍历整个链表
for( it=a.begin() ; it!=a.end() ; it++)
有了上面的那些函数,我们就可以实现如下功能
- ins_back(x,y); 将元素y插入到x的后面
- ins_bcak(x,y); 将元素y插入到x的前面
- ask_back(x); 询问x的后继是谁
- ask_front(x); 询问x的前驱是谁
- del(x); 从列表中删除元素,不改变其他元素的先后顺序。
const int MAXN = 1e6 + 7; list<int > a; list<int >::iterator index1[MAXN]; // 迭代器数组,用来代替find void ins_front(int x, int y) { //y插入x的前面 auto it = index1[x]; a.insert(it, y); index1[y] = --it; // y的迭代器就是it的前一个位置 } void ins_back(int x, int y) { //yc插入 x的后面 auto it = index1[x]; it++; a.insert(it, y); index1[y] = --it; } void del(int x) { //删除x if (index1[x] == a.end())return; auto it = index1[x]; a.erase(it); index1[x] = a.end(); } int ask_front(int x){ auto it=index1[x]; return *(--it); } int ask_back(int x){ auto it=index1[x]; return *(++it); } void print() { for (auto it = a.begin(); it != a.end(); it++) { cout << *it<<' '; } }
P1996 约瑟夫问题 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1996
#include<bit/stdc++.h>
using namespace std;
const int MAXN = 1e6 + 7;
list<int > a;
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) {
a.push_back(i);
}
list<int >::iterator it, now;
it = a.begin();
int count = 0;
while (!a.empty()) {
count++;
now=it;
if (++it == a.end())it = a.begin();
if (count % m == 0) {
cout << *now << ' ';
a.erase(now);
}
}
}
P1160 队列安排 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1160
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int MAXN = 1e6 + 7;
list<int > a;
list<int >::iterator index1[MAXN];
void ins_front(int x, int y) {
auto it = index1[x];
a.insert(it, y);
index1[y] = --it; // y的迭代器就是it的前一个位置
}
void ins_back(int x, int y) {
auto it = index1[x];
it++;
a.insert(it, y);
index1[y] = --it;
}
void del(int x) {
if (index1[x] == a.end())return;
auto it = index1[x];
a.erase(it);
index1[x] = a.end();
}
void print() {
for (auto it = a.begin(); it != a.end(); it++) {
cout << *it<<' ';
}
}
int main() {
int n;
cin >> n;
a.push_back(1);
index1[1] = a.begin();
for (int i = 2; i <= n; i++) {
int k, p;
cin >> k >> p;
if (p == 0)ins_front(k, i);
else ins_back(k,i);
}
int m;
cin >> m;
while (m--) {
int temp;
cin >> temp;
del(temp);
}
print();
}
简单二叉树:
二叉树,顾名思义是一种树。不过,我们在数据结构中研究的树,一般都是“倒过来的”
就像这样:
所以,越上面越接近根部,越下面越接近叶子。
那么最上面的点,就叫做根结点,其他的点就是普通结点,最下面的点(就是没有任何分支的点)
叫做叶子节点。
那么二叉树是什么呢?就是每一个结点的分支不超过两个的树。
如上面那张图片,每个结点都有两个分支,我们管一个结点的左右分支叫(左右子树)
这样的结构就是完美二叉树。
那么对于完美二叉树而言:
如果从根部开始标号1,然后下一层从左到右开始往下标号。
我们会发现这样的规律:
假设某一结点的编号为x ,那么其左子树的根节点编号为:2*x,右子树的根节点为:2*x+1
若有n层 ,那么总结点的数量就是:2^n-1
下面给出一个思考问题:
假如有 2^n (n<=7 )个国家参加淘汰赛,要角逐出冠军。现在,他们两两进行pk,赢的国家进入下一环节的淘汰赛,如此往复,直到出现冠军。现在给你每个国家的编号,和能力值,
对于编号:1号和2号pk 、3号和4号pk……如此以往。
对于能力值:能力值高的一方获胜。
那么我们对于这个问题,很明显画出来是一个满二叉树的结构,先把能力值放在叶子节点,然后从叶子结点开始比较,能力值大的将信息传递给上一层的根节点,直到得到根节点。
我们不难发现这是一个有规律的行为(其实就是递归
并且我们如果回到了根节点的话,必然是把整棵树都扫描了一遍。
我们把这种行为叫做二叉树的遍历(就是用dfs
ok , 现在我们已经得到了一种简单的性质,我们需要将它运用在实战之中:
例题1:
P4715 【深基16.例1】淘汰赛 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P4715题解1:
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
struct tree {
int winner, power, id;
}tree1[N];
int n;
void dfs(int x) {
if(x>=(1<<n))return ;
dfs(2 * x);
dfs(2 * x + 1);
int left = 2*x , right = 2*x+1;
if (tree1[left].power > tree1[right].power) {
tree1[x].winner = tree1[left].winner;
tree1[x].power = tree1[left].power;
}
else {
tree1[x].winner = tree1[right].winner;
tree1[x].power = tree1[right].power;
}
return;
}
int main() {
cin >> n;
int ans = 1;
for (int i = 1<<n; i < 1<<(n+1); i++) {
cin >> tree1[i].power;
tree1[i].winner = ans;
ans++;
}
dfs(1);//从根节点开始搜索
tree1[2].power > tree1[3].power ? cout << tree1[3].winner : cout << tree1[2].winner;
}
例题2:
P4913 【深基16.例3】二叉树深度 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P4913
using namespace std;
const int N = 1e6 + 7;
struct tree {
int left, right, id;
}tree1[N];
int n;
int dfs(int x) {
int lans = 0;
int rans = 0;
if (tree1[x].right == 0 and tree1[x].left == 0)return 0;
lans++;
rans++;
lans+=dfs(tree1[x].left);
rans+=dfs(tree1[x].right);
return max(lans,rans);
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
tree1[i].id = i;
cin >> tree1[i].left >> tree1[i].right;
}
cout<<dfs(1)+1;
}
由上面两道题我们可以看出,二叉树的遍历确实是和dfs有关,下面我们将介绍二叉树的三种遍历方式:前序遍历,中序遍历,后序遍历。
我们先建立一个二叉树:
口诀:
前序遍历: 根左右
const int N = 1e6 + 7;
struct tree {
int left, right;
}tre[N];
void dfs(int x) {
if (x == 0)return;
cout << x << ' ';
dfs(tre[x].left);
dfs(tre[x].right);
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++)
cin >> tre[i].left >> tre[i].right;
dfs(1);
// 7 2 3 4 5 0 0 6 7 0 0 0 0 0 0
}
遍历结果:1 2 4 6 7 5 3
中序遍历:左根右
using namespace std;
const int N = 1e6 + 7;
struct tree {
int left, right;
}tre[N];
void predfs(int x) {
if(tre[x].left)predfs(tre[x].left);
cout << x << ' ';
if(tre[x].right)predfs(tre[x].right);
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> tre[i].left >> tre[i].right;
}
predfs(1);
// 7 2 3 4 5 0 0 6 7 0 0 0 0 0 0
}
遍历结果:6 4 7 2 5 1 3
后序遍历:左右根
const int N = 1e6 + 7;
struct tree {
int left, right;
}tre[N];
void dfs(int x) {
if (x == 0)return;
dfs(tre[x].left);
dfs(tre[x].right);
cout << x << ' ';
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++)
cin >> tre[i].left >> tre[i].right;
dfs(1);
// 7 2 3 4 5 0 0 6 7 0 0 0 0 0 0
}
遍历结果:6 7 4 5 2 3 1
我们已经了解了二叉树的三种遍历方式,那么就可以做一道简单的例题练手了:
P1827 [USACO3.4] 美国血统 American Heritage - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1827这道题其实就是找规律来做:
先列举出前中后序遍历:
前序遍历:CBADEFGH
中序遍历:ABEDFCHG
后序遍历:AEFDBHGC
明显地,由于前序遍历是根左右的顺序,所以,前序遍历的第一个字母,就是当前树的根节点
首先拿出C,然后由于中序遍历是左根右的形式,我们找到中序遍历中C的位置,就可以分出左右两个子树。
左子树的中序遍历:ABEDF ,右子树的中序遍历:HG
同样地可以找到左子树的前序遍历和右子树的前序遍历:
分别是: BADEF , GH
那么对于左子树(把它当成一棵树
我们又有了它的 前序遍历和中序遍历,我们会发现这是一个递归的操作。。。
我们再次回想到上面的求后序遍历的代码,先遍历左子树在遍历右子树最后输出根。
所以我们这里也可以进行这样的操作
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
string sum;
void dfs(string pre, string mid) {
if (pre.empty())return; //如果这棵树是空的就返回
string left_pre, right_pre, left_mid, right_mid;
int pos1 = mid.find(pre[0]); 先找到根所在的位置,就是前序遍历中的第一个
left_pre = pre.substr(1, pos1); 然后求左子树的前序遍历
right_pre = pre.substr(pos1+1); 然后求右子树的前序遍历
left_mid = mid.substr(0, pos1); 求左子树的中序遍历
right_mid = mid.substr(pos1 + 1); 求右子树的中序遍历
dfs(left_pre, left_mid); 递归左子树
dfs(right_pre, right_mid); 递归右子树
cout << pre[0]; 输出根
}
int main() {
string pre, mid;
cin >> mid;
cin >> pre;
dfs(pre, mid);
}
dfs后面要输出pre[0] ,因为pre[0]代表的是根结点
你只要观察一下上述的三种遍历方式,你就会发现,它们也是输出的根结点,
至于为什么,因为递归的原因。
例如你是后序遍历,你放在两个dfs后面输出的话,就会先输出左叶子节点,然后退回上一层递归,进入右叶子结点,发现右叶子结点之下没有分支了,然后再输出右叶子结点,退回上一层,此时左右都递归完了,当然输出根结点。
(因为即时没有分支,这个点也是某种意义上的根节点)。
那么,我们现在了解了 前序遍历+中序遍历 可以得到 后序遍历。
那么前序加后序可以得到中序吗?
中序和后序可以得到前序吗?
P1030 [NOIP2001 普及组] 求先序排列 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1030中BADC 左根右-------左 B 右DC
后BDCA 左右根 左B, 右DC
我们然后就可以分成左子树的中序遍历和左子树的后序遍历。
右子树的中序遍历,右子树的后序遍历。
因为很好找根,所以每次只需要输出根之后,再依次遍历左子树和右子树就可以了
基本上和上一题的告诉前序遍历和中序遍历求后序遍历是一样的。
然后注意一点,不要随意使用getline,不然会全WA.
我就是这样调了半年才发现的。
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
int flag[200];
void dfs(string mid, string last) {
if (mid.empty())return;
string left_mid, left_last, right_mid, right_last;
auto it = --last.end();
char root = *it;
last.erase(it);
int pos=mid.find(root);
left_mid = mid.substr(0, pos);
right_mid = mid.substr(pos + 1);
left_last = last.substr(0, pos);
right_last = last.substr(pos);
if (root != ' ')
cout << root;
dfs(left_mid, left_last);
dfs(right_mid, right_last);
}
int main() {
string mid, last;
cin >> mid;
cin >> last;
dfs(mid, last);
}
好的,现在我们只剩下一个问题,就是,是否能由前序遍历,后序遍历来求出中序遍历?
答案是否定的。
因为一个前序遍历和后序遍历可能有多种对应的中序遍历。
原因是,只知道前序遍历和后序遍历,我们无法区分左子树和右子树。
所以当一个根如果只有一个叶子结点的时候,那么这个叶子结点可以在左边或者在右边。
所以,当我们存在只有一个结点的子树的时候,中序遍历不唯一。
怎么找只有一个结点的子树呢?(前提是默认没有重复的结点)
只有一个结点的子树+它的根 一共为两个元素。
两个元素的前序遍历: AB
后序遍历: BA
所以也就是说,当前序遍历出现AB,后序遍历出现BA 那么就证明存在子树只有一个结点的情况。
我们要找出所有的这些特殊点有多少个。
因为一个特殊点可以有两种选择,称为左子树或称为右子树,所以,根据乘法原则,如果有x个特殊点,那么有2^x 个答案。
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
int main() {
string pre, last;
cin >> pre;
cin >> last;
int ans = 0;
for (int i = 0; i < pre.size()-1; i++) {
for (int j = 0; j < last.size(); j++) {
if (pre[i] == last[j]) {
if (pre[i + 1] == last[j - 1])ans++;
}
}
}
cout << (1 << ans);
}
现在来到了二叉树的综合运用:
第一个、需要给大家讲述一下什么是二叉搜索树。
二叉搜索树是一种特定的二叉树,我们规定:
1、对于根节点,其左子树的所有结点的权值都小于根节点,右子树的所有结点的权值都大于根节点。
2、对于任意一个根节点,其左右子树也分别都是二叉搜索树。
3、没有两个权值相等的结点。
二叉搜索树有什么作用?
作用是维护一个集合。
这个集合里面有一些数字,并且这个集合支持以下的几种操作:
告诉你一个数的值,求他在集合中的排名
告诉你一个数的排名,求他在集合中的值
告诉你一个数的值,求他的前驱和后继
插入数据,使这个集合仍然具有这些功能。
那么我们该怎么使用这样的一种数据结构来维护这样的集合?
1、查询x数的排名:
我们只需要把x和最开始的根节点进行比较,如果x的值大于根节点的值,
那么我们就继续进入根节点的右子树进行比较,一旦他进入右子树,说明他比左子树和根节点都大,所以我们把左子树的size,和根节点出现的次数 记录答案即可。
如果x的值小于根节点的值,那么我们就进入根节点的左子树。
如果x的值小于根节点,我们就返回根节点的出现的次数和左子树的size.
现在考虑边界。
如果进入左子树或右子树之后,发现左孩子节点或右孩子节点没有值怎么办?
那么我们直接返回1 即可,
2、知道排名,求值。
还是一样的思路,将排名和左子树的size 比较,若小于等于左子树的size ,那么进入左子树
如果等于左子树的size + 根节点出现的次数,说明值就是根节点
如果大于左子树的size+根节点出现的次数,那么我们把排名减去他俩,然后进入右子树。
现在还是思考边界,如果根不存在怎么办? 那么我们直接返回0即可。
3、求前驱后继,这个简单,只要先求出x的排名,然后前驱就是排在x上一个的数,后继就是排在x的下一个数的排名。
得到排名后,我们在用排名去求这个数就可以了。
4、插入一个数,我们将这个数和根节点比较,如果大于根节点说明要插入右子树。
如果小于根节点说明插入左子树,否则根节点的次数++,
然后考虑边界,如果不存在这个节点怎么办,那我们就创建一个节点用来放这个数就好了。
然后更新一下整棵树的信息。
下面给出一道一模一样的例题,可以练习一下:
P5076 【深基16.例7】普通二叉树(简化版) - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
struct tree {
int left, right, size, num,value;
tree(int l = 0, int r = 0, int s = 0, int v = 0) {
left = l, right = r, size = s, value = v;
num = 1;
}
}t[N];
int cnt;
void update(int root) {
t[root].size = t[t[root].left].size + t[t[root].right].size+t[root].num;
}
void insert(int x, int root) {
if (t[root].value > x) { //如果这个数小于根结点,那么进入左子树
if(t[root].left)
insert(x, t[root].left);
//考虑边界,如果root没有左右孩子,那么新建一个左孩子
else {
t[t[root].left=++cnt] = tree(0, 0, 1, x);
}
}
else if (t[root].value < x) {
if (t[root].right)insert(x, t[root].right);
else {
t[t[root].right = ++cnt] = tree(0, 0, 1, x);
}
}
else {
t[root].num++;
}
update(root);
}
//知道排名,查询数字
int search(int x, int root) {
if (x <= t[t[root].left].size) {//如果排名小于等于左子树,那他必然在左子树
if (t[root].left)
return search(x, t[root].left);
else return -1;
}
else if (x == t[t[root].left].size + t[root].num) {
return t[root].value;
}
else {
if (t[root].right)
return search(x - t[t[root].left].size - t[root].num, t[root].right);
else
return -1;
}
}
//知道值,查询排名
int Rank(int x, int root) {
if (root) {
if (x < t[root].value) {
return Rank(x, t[root].left);
}
else if (x == t[root].value) {
return t[t[root].left].size + 1;
}
else {
return Rank(x, t[root].right) + t[root].num + t[t[root].left].size;
}
}
else return 1;
}
int main() {
int n,root,opt,x;
cin >> n;
root = 1;
t[1] = tree(0, 0, 1, 2147483647);
cnt = 1;
while (n--) {
cin >> opt >> x;
if (opt == 1) {
cout << Rank(x, root) << endl;
}
else if (opt == 2) {
cout << search(x, root) << endl;
}
else if (opt == 3) {
int temp1 = search(Rank(x, root)-1 , root);
if (temp1 == -1)cout << -2147483647 << endl;
else cout << temp1 << endl;
}
else if (opt == 4) {
int temp1 = search(Rank(x + 1, root), root);
if (temp1 == -1)cout << 2147483647 << endl;
else cout << temp1 << endl;
}
else insert(x, root);
}
}
提示,查询前驱后继的时候,查询前驱简单,因为我们的排名都是不大于这个数的最大的数的排名+1,所以即时这个数有很多个重复也没关系。直接-1就可以了。
但是查询后继的时候,就需要考虑这个数出现了多少次,所以我们直接把x+1,这样一定能查到后继的排名。
然后就是为什么开头要插入一个 INT_MAX?
若我们后续要插入很多数字,那么就会出现1种情况,一开始根的值是0(就是没有任何数据
第一次插入1,ok 插入了根0的左边,
第二次插入-1,插入了根0的右边,
那么这就变成两棵树了,实际上我们希望的是,-1应该是1的左子树,意思是最先插入的那个数作根节点。
所以我们可以提前插入一个INT_MAX 也就是int里最大的数,这样我们保证所有的数都小于根节点,那么都会进入同一边。
此时有人会说,为什么不插入INT_MIN ,这样也可以进入同一边啊。
我当时也是这样想的,其实这道题有些很恶心的数据点。
比如给你 1 , 1 ,2
就是不插入任何数据,查询2的排名
如果你插入了 INT_MIN,那么就会发现这个排名变成了2
实际上,如果不插入任何数,那么输入一个数,这个数在里面的排名只能是1.
所以我们要保证输入的数,一查询就直接return 1
至此,我们必须要提前插入一个INT_MAX
第二道例题:
传送门:P1364 医院设置 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1364
最简单的写法就是爆搜每个点到这个点的距离。
比如对于4这个点。
它对答案的贡献就是 它的左子树和右子树和父节点到他的距离(此处的距离指的是距离乘以权值)
然后依次遍历三种情况就行了。
为了确保每个点只被搜索一次,那么我们只需要再开一个数组,来标记被搜过的数字就行了。
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
int flag[200];
struct tree {
int left, right, val,father;
}t[200];
int _dfs(int x, int d) { // 当前节点,当前节点到搜点的距离。
if (x == 0)return 0;
if (flag[x])return 0;
flag[x] = 1;
return _dfs(t[x].left, d + 1) + _dfs(t[x].right, d + 1) + _dfs(t[x].father, d + 1) + t[x].val * d;
}
int ans = 0x7fffffff;
int n, w, u, v;
int main() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> t[i].val >> t[i].left >> t[i].right;
t[t[i].left].father = i;
t[t[i].right].father = i;
}
for (int i = 1; i <= n; i++) {
memset(flag, 0, sizeof(flag));
ans=min(ans,_dfs(i, 0));
}
cout << ans;
}
其实啊,对于树这种结构,如果我们给他一个回头的机会,他就会变成一个图。
我们只要记录每个点的父节点是谁,那么我们就可以把它当作一个图了。
集合
- 并查集
- 哈希表
- set、map的运用
并查集:
- 什么是并查集
- 并查集的性质
- 并查集怎么写
- 并查集怎么做题
哈希表
- 啥是hash
- hash的性质
- hash咋写
- hash怎么做题
set、map
- 这俩是啥
- 有啥性质
- 咋用
并查集
就是一种集合。它可以支持两种功能。
1、合并
2、查询
什么意思呢?
举个例子:现在有很多个孤立的城市,我们每次可以选择两个城市然后在他们之间 建立一条路。
使得这两个城市能够连通起来。
也就是把这两个孤立的集合合并成一个集合。
这就是合并的过程。
如果我建立很多条路,那么就有很多个城市被连通起来。
通过不断的合并操作,最终有很多城市能够互通。
这些相互之间能够互通的城市,就可以看作一个集合。
那么无法互通的城市,就是不同的集合。
查询的定义是多样的。
最简单的就是查询某个元素是属于哪个集合的。
如果我们想知道任意两个城市是不是互通的,我们只需要判断他们的集合是不是同一个就可以了
这样的一种结构,我们就把他叫做并查集。一般使用数组来维护这样的集合。
好的,现在你已经知道了并查集的大概性质。
那么我们应该如何构建并查集?
原始状态应该就是每个元素都是孤立的集合。
然后每次输入两个数,代表这两个数被连接起来了。
其实并查集的精髓就在于查询。
查询这个元素属于哪个集合。
怎么理解呢?
我们只要给他们假想一个祖先,如果这两个元素的祖先是同一个的话,那么就是同一集合的元素。
所以我们的查询函数的功能就是查询这个元素的祖先是谁。
如果某两个元素的祖先相同,那么他们就是同一个集合的元素
否则不是。
所以,我们连接的函数就可以这样写,比如连接a,b
就把 a的祖先的祖先令为b的祖先就好了。 或者把 b的祖先的祖先令为a的祖先就好了。
那么好,我们现在上两个板子题:
P1536 村村通 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1536
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
int father[10005];
int search(int a) { //查询函数,就是查询一个元素的祖先是谁。也就是它属于哪个集合
if (a == father[a])return a;
return father[a]=search(father[a]);
}
void Link(int a, int b) { // 连接a城市,连接b城市
int x = search(a), y = search(b);
father[x] = y; // 否则我们就让其中一个人的祖先的祖先等于另一个人
}
set<int > ans;
int main() {
int n, m;
while (1) {
cin >> n;
if (n == 0)return 0;
cin >> m;
memset(father, 0, sizeof(father));
ans.clear();
for (int i = 1; i <= n; i++) {
father[i] = i;
}
while (m--) {
int a, b;
cin >> a >> b; // 输入俩城市,代表这两个城市被连接
// if (a == 0 or b == 0)continue; //如果a,b有0的话,直接跳过
Link(a, b);
}
for (int i = 1; i <= n; i++) {
ans.insert(search(i));
}
cout << ans.size() - 1 << endl;
}
}
例题2、
P1551 亲戚 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1551
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
int father[10005];
int search(int a) { //查询函数,就是查询一个元素的祖先是谁。也就是它属于哪个集合
if (a == father[a])return a;
return father[a]=search(father[a]);
}
void Link(int a, int b) { // 连接a城市,连接b城市
int x = search(a), y = search(b);
father[x] = y; // 否则我们就让其中一个人的祖先的祖先等于另一个人
}
set<int > ans;
int main() {
int n, m,p;
cin >> n >> m >> p;
for (int i = 1; i <= n; i++) {
father[i] = i;
}
while (m--) {
int x, y;
cin >> x >> y;
Link(x, y);
}
while (p--) {
int x, y;
cin >> x >> y;
search(x) == search(y) ? cout << "Yes": cout << "No";
cout << endl;
}
}
哈希表
啥是哈希表啊?
哈希表就是一种散列表,它是一个通过键值来搜索值的表。可以减少搜索的时间复杂度。
啥意思?
就比如说字典吧,这也算一种hash表。
如果我们只有一个数组,也就是单列表。
我们把所有的数字放入数组里,那么如果我们要查询这个数的话。只能从第一个开始依次遍历。
那么如果我们把这些字赋予一些属性,是不是就能更快查找呢?
比如说,10笔画以上的字的键值为A
10笔画以下的键值为B
当我们查询的时候,我们只要判断一下这个字属于A还是属于B就行了。
但是这样还是很慢,对吧。
因为十笔画以上的字太多了,这种就是哈希碰撞。
如果你的哈希值(也就是这里的A,B)设置的不够好,就会有很多的重复,那么查询的时间复杂度仍然很高
所以字典把所有的字都用拼音来查找。
先找第一个字母,第二个……一直到所有拼音。
这时候哈希碰撞就没有那么大了。
因为只有同音字才会在同一个地方被找到。
有人会问,为啥不能设置哈希碰撞为0的值呢?
当然可以,毕竟哈希表是以空间换时间的表。
就比如,如果字典的哈希碰撞为0,那么查询的复杂度就是O(1),但。。。
所需要的空间就是所有汉字的数量了。。。
现在字典的空间差不多是所有拼音的数量。
所以我们最好是找一个最优的哈希值,这样可以使得时间复杂度和空间复杂度都住在我们的可接受范围之内了。
好啦,说了这么多,我们开始一道例题吧?
P3370 【模板】字符串哈希 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P3370
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
const int MOD = 1e4 + 7;
vector< vector<string> > _hash(N);
const int base = 227;
bool HASH(string s) {
// 第一步想好我们应该这么创建hash值
int Hash = 1;
for (int i = 0; i < s.size(); i++) {
Hash = (Hash * base + s[i]) % MOD; //这里采取 选取基数取模的方法,模数应该保证hash值不会太大。基数大些可以保证hash值冲突减少
}
// 查找 , 理论上hash值取的好,查找时间为O(1),也就是只有相同的字符串才会分在一起,
不同的字符串一个字符的差距在你取的base适当的情况下会有巨大的不同
for (int i = 0; i < _hash[Hash % MOD].size(); i++) {
if (s == _hash[Hash % MOD][i])return 0;
}
然后就是将没有重复的字符串插入。
_hash[Hash % MOD].push_back(s);
return 1;
}
int main() {
int n;
cin >> n;
int ans = 0;
for (int i = 1; i <= n; i++) {
string s;
cin >> s;
if(HASH(s)==1)ans++; // 存入hash表
}
cout << ans;
}
第二道例题:
这个题的思路很简单。首先你只要循环插入每个城市的哈希值就可以了。
然后用同时记录每个城市出现的次数。
最后在每次查询,每个城市倒过来出现了几次。
然后由于是求有多少对
所以我们把答案除以2 就可以了
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
const int MOD = 100007;
vector< vector<pair<string,int> > > _hash(MOD+2);
const int base = 26;
long long ans = 0;
int get_hash(string s) {
int hash = 1;
for (int i = 0; i < s.size(); i++) {
hash = (hash * base + s[i]-'A') % MOD;
}
return hash;
}
void HASH(int hash,string s) {
for (int i = 0; i < _hash[hash % MOD].size(); i++) {
if (s == _hash[hash % MOD][i].first) {
_hash[hash % MOD][i].second++;
break;
}
}
_hash[hash % MOD].push_back(pair<string,int>(s, 1));
}
int search(int hash,string s) {
for (int i = 0; i < _hash[hash % MOD].size(); i++) {
if (s == _hash[hash % MOD][i].first)return _hash[hash % MOD][i].second;
}
return 0;
}
struct CITY {
string city, id, temp;
int hash1,hash2;
}a[N];
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i].city>>a[i].id;
a[i].temp = a[i].city.substr(0, 2);
a[i].hash1=get_hash(a[i].temp + a[i].id);
a[i].hash2 = get_hash(a[i].id + a[i].temp);
HASH(a[i].hash1, a[i].temp + a[i].id);
}
for (int i = 1; i <= n; i++) {
if (a[i].id != a[i].temp) {
ans += search(a[i].hash2, a[i].id + a[i].temp);
}
}
cout << ans/2;
}
然后简单介绍以下set、map容器的相关知识点:
下面给几道例题:
P3405 [USACO16DEC] Cities and States S - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P3405P3370 【模板】字符串哈希 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)
https://www.luogu.com.cn/problem/P3370
P5250 【深基17.例5】木材仓库 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P5250
P5266 【深基17.例6】学籍管理 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P5266
P1102 A-B 数对 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1102
下面是集合这一章的一些例题,有兴趣的可以做一下啊:
1、P1918 保龄球 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1918
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
typedef long long ll;
map<long long,int > bottle; // 键代表瓶子数量,值代表位置
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
ll temp;
cin >> temp;
bottle[temp] = i;
}
int q;
cin >> q;
while (q--) {
int need;
cin >> need;
cout << bottle[need] << '\n';
}
}
2、 P1525 [NOIP2010 提高组] 关押罪犯 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1525
这道题呢,还是有些难度的。
我简要说几点吧:
1、我们需要用到结构体来储存一些信息,例如有冲突的两个犯人a,b,和它们的冲突值
2、分析题目,我们发现它要使得最大的冲突事件尽可能小。所以我们想到贪心做法。
先把冲突值排好序,对于最大的冲突值的两个犯人,我们不断把他们两个分开,分到不同的监狱。
然后最大的分完了,分第二大的,也分到两个不同的监狱。这样的目的是使得会产生的冲突值不断减小。然后一直持续这个操作。直到第一次出现了某一对犯人已经在同一监狱里了。此时他俩的冲突值就是答案。
这就是贪心,只关注当前的最优解,进而不断接近全局最优解
3、好,第三步就是如何分的问题了,分到同一监狱很好操作,就是用并查集,随便选一个人做监狱的老大就行。判断俩人在不在同一监狱,就看监狱老大是不是同一个人。
然后,我们应该分谁?将谁和谁分到同一监狱?
由于贪心的原则,当处理最大的冲突值的俩人的时候,我们直接把它们设置为互相最大的敌人,并不将作为敌人的两人分到同一监狱。所以第一对我们是已经默认分开了的。
假如第一对是 1 , 2
第二对是 1,3
因为 maxemepy [ 1] = 2 , maxemepty2 ] = 1
且 maxemepty [3 ] =1
对于3而言,能与他产生最大的冲突值的是1,所以3只能与 2合并,也就是 与 maxempty[1] 合并
这就是我们说的,敌人的敌人是朋友。
那么假如第三对是: 2 ,3
经过前两对的操作,2,3已经在一起了,所以直接输出它们的冲突值。
我们也可以就此验证贪心:每个人尽可能不和自己最大的敌人在一起,如果迫不得已,应该选择冲突值小的最大的敌人。
2的最大的敌人是1,3的最大的敌人是1. 所以,3与2组队,冲突值最小
上代码:
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N=1e6 + 7;
// 由于这个题目只需要我们求冲突事件最大的最小值。
//所以我们尽可能地让冲突值比较大的事件不发生。直到无法避免。
//开个结构体,记录是哪两个犯人发生冲突和其冲突值
struct confliction {
int p1,p2,clash; // 犯人一,犯人二,冲突值
}cflict[N];
bool cmp(confliction a, confliction b) {
return a.clash > b.clash;
}
int fa[N];
int enemy[N];
int search(int x) {
if (x == fa[x])return x;
return fa[x] = search(fa[x]);
}
void link(int a, int b) {
int t1 = search(a), t2 = search(b);
fa[t1] = t2;
}
int main() {
int n, m;
cin >> n; //n个罪犯
cin >> m; //m对冲突
for (int i = 1; i <= m; i++) {
cin >> cflict[i].p1 >> cflict[i].p2;
cin >> cflict[i].clash;
fa[cflict[i].p1] = cflict[i].p1;
fa[cflict[i].p2] = cflict[i].p2;
}
sort(cflict + 1, cflict + 1 + m, cmp);
for (int i = 1; i <= m; i++) {
int p1 = cflict[i].p1, p2 = cflict[i].p2;
if (search(p1) == search(p2)) {
cout << cflict[i].clash;
return 0;
}
if (enemy[p1] == 0)enemy[p1] = p2;
else link(enemy[p1], p2);
if (enemy[p2] == 0)enemy[p2] = p1;
else link(enemy[p2], p1);
}
cout << 0;
}
3、P1892 [BOI2003] 团伙 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1892
和上面的题是一样的,如果写出来了 上面的题,那么这一道题就是纯纯的板子送分。。。。
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N=1e6 + 7;
typedef long long ll;
int fa[N],enemy[N];
int search(int x) {
if (x == fa[x])return x;
return fa[x] = search(fa[x]);
}
void add(int x, int y) {
int t1 = search(x), t2 = search(y);
fa[t1] = t2;
}
set<int > og;
int main() {
int n;
cin >> n;
int m;
cin >> m;
// pre work
for (int i = 1; i <= n; i++) {
fa[i] = i;
}
while (m--) {
char opt;
int p, q;
cin >> opt >> p >> q;
if (opt == 'F') {
add(p, q);
}
else {
int t1, t2;
t1 = enemy[p],t2=enemy[q];
if (t1 != 0)add(q, t1);
if (t2 != 0)add(p, t2);
enemy[p] = q;
enemy[q] = p;
}
}
for (int i = 1; i <= n; i++) {
og.insert(search(i));
}
cout << og.size();
}
// 1 4 enemy
// 3 5 friend
// 4 6 friend
// 1 2 enemy
4、
P4305 [JLOI2011] 不重复数字 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P4305
5、
P2814 家谱 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P2814
图:
图的概念和建立:
图的遍历:
对于有向无环图:
我们应该如何建立图?
一般情况呢,题目的输入就是,直接告诉你起点终点还有边权,这时候我们构建一个结构体,和一个二维vector就行。
struct road {
int begin, to, value;
road(int t = 0, int v = 0) { //一个赋值函数,=0不能去掉
to = t, value = v;
}
};
int main01() {
vector<vector< road> > Map(N);
int n;
cin >> n;
int m;
cin >> m;
while (m--) {
int b,t,v; // 起点、终点、以及边权
cin >> b >> t >> v;
Map[b].push_back({ //一个小技巧,将终点和边权都赋值给一维是起点的vector数组。
road(t,v)
});
}
}
深度优先遍历:
不要照抄代码,dfs很简单,懂概念自己打一遍就能写出。
然后我们新开一个bool数组,用来计算一下某个点是否被遍历到即可
bool use[N];
void dfs(int x) {
if (x == 0)return;
if (use[x] == 0) {
use[x] = 1;
for (int i = 0; i < Map[x].size(); i++) {
dfs(Map[x][i].to);
}
}
}
广度优先遍历:
怎么舒服怎么打即可。
void bfs() {
queue<int > _bfs;
_bfs.push(1);
use[1]=1;
while (!_bfs.empty()) {
int x = _bfs.front();
_bfs.pop();
for (int i = 0; i < Map[x].size(); i++) {
int temp = Map[x][i].to;
if (use[temp] == 0)
_bfs.push(temp);
use[temp] = 1;
}
}
}
嗯。。。因为确实太简单了 ,没什么好细讲的,所以直接上题目:
P5318 【深基18.例3】查找文献 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P5318
这题就是上面的板子,深搜加广搜即可,不过题目要求排序。
我写的比较多,但是变量名你们应该能看懂,就不再赘述了,多打练习手感就行
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N=1e6 + 7;
typedef long long ll;
struct node {
int begin, to, val;
node( int t = 0) {
to = t, val = 1;
}
};
vector< node > document[N];
bool ifuse[N];
void dfs(int x) {
if (x == 0)return;
if (ifuse[x] == 0) {
ifuse[x] = 1;
cout << x << ' ';
for (int i = 0; i < document[x].size(); i++) {
dfs(document[x][i].to);
}
}
}
bool cmp(node a,node b){
return a.to<b.to;
}
int main() {
int n;
cin >> n;
int m;
cin >> m;
while (m--) {
int begin, to;
cin >> begin >> to;
document[begin].push_back({
node(to)
});
}
for (int i = 1; i <= n; i++) {
sort(document[i].begin(), document[i].end(),cmp);
}
dfs(1);
cout << endl;
queue<int > statement;
statement.push(1);
memset(ifuse, 0, sizeof(ifuse));
ifuse[1] = 1;
while (!statement.empty()) {
int temp = statement.front();
statement.pop();
cout << temp << ' ';
for (int i = 0; i < document[temp].size(); i++) {
if(ifuse[document[temp][i].to]==0)
statement.push(document[temp][i].to);
ifuse[document[temp][i].to] = 1;
}
}
}
// 1-> 2.1->3
//
然后来一道比较难的题目:
P3916 图的遍历 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P3916题解:
DAG与拓扑排序:
其实与上面也没有什么本质区别,上面我们研究的是图。
这里我们研究的也是图,不过是一种有向无环图。也就称为DAG
嗯。。。没了,这就是知识点。。。
其实你如果做了上面那道题P3916的话,你应该懂,如果你想要用深搜+动规去解决那道题,
前提就是这张图里是没有环的。
( 深搜,对于每个点u,搜它的到达的点v,v有很多个,所以我们只要进行 max( num[u] , dfs(v) )不断取最大值即可 )
but,如果这张图有环呢?
like this:
num【2】正在等待4的答案,num【4】正在等待3的答案,num【3】正在等待2的答案。。。。
闭环了。。
所以对于有环图,我们不能简单无脑处理。
but。。
我们现在研究的是DAG,也就是无环图,这不就简单了么。。。。
来,我们上一道和刚刚的题类似的DAG练练手。
P1113 杂务 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1113//所有杂务被完成所需的最短的时间
//所有杂物被完成 ----- 木桶效应------只取最短的木板
//所以所有杂物被完成-----取决于最长的一条任务链 (任务链的长度指的是 各任务边权长度之和)
// 对于每个点,设一个num【x】 数组,记录从1开始到x所花费的最长时间。
//然后找num【x】里的最大值即可
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N=1e6 + 7;
typedef long long ll;
struct node {
int begin, to, val;
node( int t = 0) {
to = t, val = 1;
}
};
const int MAXN = 1e4 + 7;
vector<int> work[10000 + 7];
int Time[10000 + 7];
int use[MAXN];
int sum[MAXN];
int dfs(int x) {
if (x == 0)return 0;
if (sum[x] != 0)return sum[x];
for (int i = 0; i < work[x].size(); i++) {
sum[x] = max(sum[x], dfs(work[x][i]));
}
sum[x] += Time[x];
return sum[x];
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
int to,time;
cin >> to>>time;
Time[to] = time; //记录点值,
while (1) {
int pre;
cin >> pre;
if (pre == 0)break;
else {
work[pre].push_back(to); //建边
}
}
}
cout << dfs(1);
}
拓扑排序是啥?
拓扑排序是一种针对于DAG的顶点进行排序的算法。
举个栗子,如果存在一条顶点A到顶点B的有向边,那么在拓扑排序中A应该出现在B前面
换句话说,拓扑排序可以用来将图中的顶点以线性顺序进行排序,使得所有有向边,均从排在前面的点指向排在后面的点。
再举个栗子,如果一组任务,它们任务与任务之间有明显的依赖关系,例如,任务A必须在任务B之前完成,那么就可以用拓扑排序将任务A排在任务B前面,以确保任务正确完成。
拓扑排序算法的基本思想就是:
通过不断删除图中入度为0的顶点,并删除以该顶点为起点的边,直到图为空,或途中不存在入度为0的顶点为止。
到这里,我们会发现,如果图中存在环的话,是无法进行拓扑排序的,所以这就是为什么拓扑排序只针对DAG了。
另外,拓扑排序的时间复杂度一般是O(V+E),其中V代表点的个数,E代表边的个数。
我们先学着自己写一个拓扑排序的板子:
- 建图
- 计算每个顶点的入度
- 将所有入度为0的顶点存入一个队列
- 然后将队列的队首不断取出加入到结果序列内。
- 取出的过程中,需要将该顶点能到的所有顶点入度-1
- 如果期间它的邻居节点入度为0了,那么加入队列
- 重复以上操作,直到队列为空。
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
vector<int > toposort(vector<vector<int> >& graph) {
int n = graph.size(); //记录有多少个起点
vector<int > inDegree(n); //开一个长度为n的数组,记录每个点的入度
for (auto& neighbors : graph) { //二维vector的第一维,表示每个起点
for (auto& neighbor : neighbors) {//第二维,表示起点能到达的终点
inDegree[neighbor]++; //每个终点的入度+1;
}
}
queue<int> q;
for (int i = 1; i <= n; i++) { //先将现有的图中入度为0的点存入队列
if (inDegree[i] == 0) {
q.push(inDegree[i]);
}
}
vector<int > result;
while (!q.empty()) {
int node = q.front();
q.pop();
result.push_back(node);
for (int neighbor : graph[node]) {
inDegree[neighbor]--; //将node 的邻居节点入度-1
if (inDegree[neighbor] == 0) { //如果减为0了,那么存入队列中
q.push(neighbor);
}
}
}
return result;
}
int main() {
int n, m; //n个点,m条边
cin >> n >> m;
vector<vector<int> >graph(N);
//存图
while (m--) {
int begin, to; //起点、终点
cin >> begin >> to;
graph[begin].push_back(to); //建边
}
vector<int > result = toposort(graph); //将toposort结果存入result
if (result.empty()) {
cout << "存在环" << endl;
}
else {
for (int node : result) { //输出拓扑排序内容
cout << node << ' ';
}
cout << endl;
}
return 0;
}
现在我们已经知道了板子怎么写,来道板子题吧。
P4017 最大食物链计数 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P4017
拓扑排序法:
可以这样想:对于样例的这张图
5是入度为0的点,1是出度为0的点,我们设num[x]= 从入度为0的点到x这个点有多少种走法
对于这张图,很显然num[1]就是答案.
那么num[1]等于什么? 等于从5走到2的路径数量+5走到3的路径数量
num[2]等于什么,等于从5走到2的路径数量。
num[5]等于什么?等于从5走到5的路径数量,就是1种。
所有的答案最终都会归结于num[5]。
其实我们只需要看每个点的入度就行了。
首先删除5,那么5的所有能去的地方答案加上num[5].
此时num[2]=1,num[3]=1,num[4]=1,
然后接着删掉下一个入度为0的点。
也就是4,然后把所有4能到达的地方答案+num[4],
此时 num[3] = 2
解着又删除num[3],
然后此时的num[2] =1+num[3] = 3 , num[1] =num[3] =2
最后删除2,
num[1]= 2+3=5.
其实这就是一个拓扑排序的过程,然后加了一点dp?
只要状态转移方程写对了就行。
上代码:
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
//设sum[5]为以5为起点的食物链条数
// sum[5]=sum[4]+sum[3]+sum[2] 这些点都是它的邻居节点
//而sum[4]= sum[3]的邻居节点
//sum[3]=sum[2]+sum[1]
// sum[2]=sum[1]
// sum[1]=1
// 可以深搜+记忆化来求
int sum[N];
const int MOD = 80112002;
void toposort(vector<vector<int> >& graph,int n ) {
vector<int > inDgree(N);
for (auto& neighbors : graph) {
for (auto& neighbor : neighbors) {
inDgree[neighbor]++;
}
}
queue<int >q;
for (int i = 1; i <= n; i++) {
if (inDgree[i] == 0) {
q.push(i);
sum[i] = 1;
}
}
while (!q.empty()) {
int node = q.front();
q.pop();
for (auto i : graph[node]) {
if (inDgree[i] != 0) {
sum[i] =(sum[i]+ sum[node])%MOD;
inDgree[i]--;
if (inDgree[i] == 0)q.push(i);
}
}
}
}
int main() {
int n, m;
cin >> n >> m;
vector<vector<int> > graph(N);
while (m--) {
int begin, to;
cin >> to >> begin;
graph[begin].push_back(to); //建图的同时计算每个点的入度
}
toposort(graph,n);
int ans = 0;
for (int i = 1; i <= n; i++) {
if (!graph[i].size())ans = (ans + sum[i]) % MOD;
}
cout << ans%MOD;
}
深搜记忆化法:
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
//设sum[5]为以5为起点的食物链条数
// sum[5]=sum[4]+sum[3]+sum[2] 这些点都是它的邻居节点
//而sum[4]= sum[3]的邻居节点
//sum[3]=sum[2]+sum[1]
// sum[2]=sum[1]
// sum[1]=1
// 可以深搜+记忆化来求
vector<vector<int> > graph(N);
vector<int> outDgree(N);
int sum[N];
const int MOD = 80112002;
int dfs(int x) {
int ans = 0;
if (graph[x].empty())return sum[x] = 1;
if (sum[x] != 0)return sum[x];
for (int i : graph[x]) {
ans =(ans+ dfs(i))%MOD;
}
return sum[x] = ans%MOD;
}
int main() {
int n, m;
cin >> n >> m;
while (m--) {
int begin, to;
cin >> begin >> to;
graph[to].push_back(begin);;
}
vector<int > inDgree(n+1);
for (auto neighbor : graph) {
for (auto neighbors : neighbor) {
inDgree[neighbors]++;
}
}
int all = 0;
for (int i = 1; i <= n; i++) {
if (inDgree[i] == 0) {
all=(all+dfs(i))%MOD;
}
}
cout << all;
}
那么到这里,简单的图论就完了。
后面再遇见图的话,就是一些高阶的内容了。希望以上的部分能为您打下良好的基础。
下面给出一些简单图论的例题:
P1807 最长路 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1807
由于是基础部分,这里只给出洛谷黄题难度,剩下的可以到官方题单里面刷完。
【数据结构1-4】图的基本应用 - 题单 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/training/116#problems
二叉堆:
首先,我们先要了解堆是什么?
堆:是一种高级树状数据结构,是一种完全二叉树。
(完全二叉树指的是,除了叶子节点,每个节点均有左右两个子节点的树状结构)
而,二叉堆是堆的最常见的实现方式。
二叉堆又可以分为:大根堆,小根堆。(可以用c++ 的 stl实现)
大根堆:每一个节点,大于等于其子节点。(从堆顶到堆底不严格递增)小根堆:每一个节点,小于等于其子节点。(从堆顶到堆底不严格递减)
那么对于二叉堆,我们是需要手动去实现一些它的一些基本操作。
- 向下调整
- 向上调整
- 插入一个元素
- 求堆中最大值/最小值(堆顶)
- 删除堆中最大值/最小值
下面先实现最大堆/大根堆的操作:
1.使用vector容器实现:
//定义一个最大堆,先给里面装填一个空元素,使得后续插入的元素下标一一对应:
//意思是,第一个数的下标就是 1,而不是0 ,同时也是 size() -1
vector<int > big(1);
2.向上调整
void upp(int pos) {
while (pos > 1) { // 循环直到节点到达堆顶
if (big[pos] > big[father]) { // 如果当前节点的值大于其父节点的值
swap(big[pos], big[father]); // 交换当前节点与父节点的值
}
else break; // 如果不满足最大堆性质,终止循环
pos = father; // 更新当前节点的位置为父节点
}
}
3.向下调整
void down(int pos) {
int size = big.size(); // 获取堆的大小
while (2*pos <= size-1) { // 当前节点有至少一个子节点时循环
int son;
if (rson <= size - 1 and big[lson] < big[rson]) { // 如果当前节点有右子节点且右子节点的值大于左子节点的值
son = rson; // 则选取右子节点作为子节点
}
else son = lson; // 否则选取左子节点作为子节点
if (big[pos] < big[son])swap(big[son], big[pos]); // 如果当前节点的值小于子节点的值,则交换它们的位置
else break; // 如果不满足最大堆性质,终止循环
pos = son; // 更新当前节点的位置为子节点
}
}
4.插入一个数:
void insert(int val) {
big.push_back(val); // 将元素 val 添加到堆的末尾
upp(big.size() - 1); // 调用 upp 函数,以维护最大堆性质
}
5.删除最大值:
void earse_big() {
if (big.size() > 1) { // 如果堆中有至少两个元素
big[1] = big[big.size() - 1]; // 将第一个元素用最后一个元素覆盖
big.pop_back(); // 删除最后一个元素
down(1); // 对堆顶元素进行向下调整,以满足最大堆性质
}
}
6.返回最大值:
int get_max() {
if (big.size() > 1) {
return big[1];
}
}
以上就是二叉堆中的最大堆实现的过程。
不过在实际的写题中,我们不需要每次手写一个二叉堆。
可以直接用现成的stl 容器,priority_queue;
下面简单介绍以下stl的用法:
priority_queue<int> bigheap; //priority_queue 默认大根堆
priority_queue<int, vector<int>, greater<int> > littleheap; // 如果要定义小根堆,就要写全参数
// priority_queue 的参数为: 数据类型、容器类型、定义类型。
//如果是小根堆, 我们在第三个参数那里改成: greater<int>
//如果是大根堆:完整的写法就是: priority_queue<int , vector<int> , less<int> > 堆的名字
然后,下面是一些堆的函数:
priority_queue<int> big;
priority_queue<int, vector<int>, greater<int> > little;
int main() {
big.push(1); //插入的同时自动调整位置
big.pop();//删除堆顶元素
big.top()//返回堆顶元素 最大值/最小值
}
下面给一道堆的模板题:
P3378 【模板】堆 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P3378答案:
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
#define ll long long
#define lson pos<<1
#define rson (pos<<1)|1
#define father pos>>1
const int N = 1e6 + 7;
//二叉堆:
// 最大堆,最小堆
// 最大堆要满足一个性质:任意一个节点,如果它的子节点存在的话,这个节点的值是要大于等于它的子节点的任意的值
//
// 1、向下调整的函数
// 2、向上调整的函数
// 3、向一堆数据中插入一个元素
// 4、在一堆数据中删除一个元素(最大值)
// 5、求出一堆数据里面的最大值。
//
//
vector<int > heap(1);
void heapup(int pos) { //node 指的是vector下标
while (pos > 1) {
if (heap[pos] < heap[father]) {
swap(heap[pos], heap[father]);
}
else {
break;
}
pos = father;
}
}
//第一个问题: 为什么heapdown函数中 循环的条件要取等
void heapdown(int pos) {
int size = heap.size(); //实际上堆里面的元素为 size-1, size指的是一个空的下标
while ( lson < size) {
int son;
if (rson<size and heap[lson] > heap[rson]) {
son = rson;
}
else son = lson;
if (heap[pos] < heap[son])break;
else {
swap(heap[pos], heap[son]);
}
pos = son;
}
}
void insert(int val) {
heap.push_back(val);
int size = heap.size();
heapup(size - 1);
}
int get_min() {
return heap[1];
}
void earse_min() {
if (heap.size() > 1) {
heap[1] = heap[heap.size() - 1];
heap.pop_back();
heapdown(1);
}
}
bool empty() {
if (heap.size() > 1)return true;
else return false;
}
int main() {
int n;
cin >> n;
while (n--) {
int op;
cin >> op;
if (op == 1) {
int x;
cin >> x;
insert(x);
}
else if (op == 2) {
cout<<get_min();
cout << '\n';
}
else {
earse_min();
}
}
}
下面讲一下,二叉堆的综合运用:
1. 对顶堆
先给一个模板题,来看看对顶堆的使用场景
P1801 黑匣子 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1801然后,来介绍一下对顶堆:
堆顶堆由两个堆组成,一个大根堆,一个小根堆。
比如一遍往堆里插入元素,一遍问第i大的元素是哪个?
我们可以这样写:
它问第i大的元素是哪个?
我们就可以构造出一个这样的形状
不断往小根堆中插入元素,直到插满i个元素,此后的话,执行这样一个操作:
先往小根堆里插入元素,然后取出小根堆的堆顶,加入上面的大根堆,然后删除小根堆的堆顶。
这样就实现了一个目的:
小根堆内的元素仍然是i个,但在新元素插入后,调整了大小关系,仍然使得小根堆的堆顶的元素是当前的第i大的元素(即时现在有超过i个元素,大于第i大的元素,都被放到了大根堆里)
如果i开始变化,如i变成i+1,那么我们直接把当前大根堆的堆顶的元素加入小根堆中,这个元素一定会在小根堆的堆顶,然后我们在删除大根堆的堆顶,使之调整结构
那么对于求第i小的元素,我们也是同样的道理,只要下面放大根堆,上面放小根堆,维护大根堆内的元素为i个,那么大根堆的堆顶就是在当前两个堆中,第i小的那个元素:
上题解:
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
#define ll long long
#define lson pos<<1
#define rson (pos<<1)|1
#define father pos>>1
const int N = 2e6 + 7;
priority_queue<int> bigheap;
priority_queue<int, vector<int>, greater<int> > littleheap;
int a[N];//元素
int opt[N];//操作
int main() {
int m, n; //元素个数,操作个数
cin >> m >> n;
for (int i = 1; i <= m; i++) {
cin >> a[i];
}
for (int j = 1; j <= n; j++) {
cin >> opt[j];
}
int tot = 1, j = 1;
for (int i = 1; i <= m; i++) {
bigheap.push(a[i]);
if (bigheap.size() >= tot) {
littleheap.push(bigheap.top());
bigheap.pop();
}
while (i == opt[j]) {
cout << littleheap.top() << endl;
bigheap.push(littleheap.top());
littleheap.pop();
j++;
tot++;
}
}
}
2.二叉堆加链表
一天在写洛谷的一道题的时候,我想出来大概思路,但是有几步我想破头也无法实现。
后来看了题解,发现原来结构体可以这样使用。
比如,现在有一个结构体:
struct person {
char gender;
int age, high, height;
};
它表示的是一个人的一些信息。
然后又给你一个vector容器,里面装的是person类型的元素
vector<person> a;
嗯。。如果我们要将gender,age,high,height都导入进容器里面应该怎么做?
vector<person> a;
int main() {
a.push_back(person{ 'F', 18, 180, 150});
person t = a[0];
cout << t.age;
}
只需要按照这个格式:
push_back( 结构体名字{ // 按照成员变量定义的顺序写你想要赋的值// } )
切记,一定要按照顺序结构体里的顺序赋值。
好的,现在我们给出那道题目:
P1878 舞蹈课 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)编辑https://www.luogu.com.cn/problem/P1878
简述一下,我第一次想的思路是将 (异性 and 相邻的差值 )插入最小堆里面
因为,题目要求,如果差值相等,输出最左边的一对,所以我们需要记录每个人站的编号。
然后又因为,如果出列了一对,那么这一对的左右两边的人要自动补齐,所以这样就又多了一对的数据,把这一对数据插入最小堆即可。 这一对的数据很好得到,就是出列的那一对的左边和右边,我们应该怎么把它们得到?所以我当时想到了链表。
嗯。。。不过我足足调试了两小时,因为初步的内容很粗糙,细节不够到位。
- 编号为0和编号为n+1的点怎么办?
- 怎么实现如果两数相等让左边的先出列
- 出列的终止条件是什么
最后我想了一个很绝的方法来杜绝0,n+1,直接给0,n+1性别赋值z
然后如果 前面的后面的加起来 == N+B 或者相减== N-B
再加上本地ide背锅(明明最后的答案是对的,但是编辑器不给过,就浪费了好久时间,后面无奈交了,发现对了。。无语)
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
#define ll long long
#define lson pos<<1
#define rson (pos<<1)|1
#define father pos>>1
const int N = 2e6 + 7;
struct person {
char gender;
int pos, pre, nxt, val; //一个人的位置,前驱,后继,舞蹈技术,性别
}a[N];
struct heap {
int margin, left, right;//差值
};
bool operator < (heap a, heap b) {
if (a.margin != b.margin) {
return a.margin > b.margin;
}
return a.left > b.left;
}
priority_queue< heap > dance;
bool flag[N];
char gender[N];
int skill[N];
int r[N], l[N];
int main() {
int n; cin >> n;
for (int i = 1; i <= n; i++)cin >> gender[i];
for (int i = 1; i <= n; i++)cin >> skill[i];
//gender[n + 1] = 'q';
//gender[0] = 'a';
for (int i = 1; i <= n; i++) {
a[i].pre = i - 1;
a[i].nxt = i + 1;
a[i].pos = i;
a[i].val = skill[i];
a[i].gender = gender[i];
if (gender[i + 1] + gender[i] == 'B' + 'G' and i < n)
dance.push(heap{ abs(skill[i] - skill[i + 1]), i, i + 1 });
}
int cnt = 0;
while (!dance.empty()) {
heap t = dance.top();
dance.pop();
if (flag[t.left] == 0 and flag[t.right] == 0) {
cnt++;
l[cnt] = t.left;
r[cnt] = t.right;
flag[t.left] = 1;
flag[t.right] = 1;
a[a[t.left].pre].nxt = a[t.right].nxt;
a[a[t.right].nxt].pre = a[a[t.left].pre].pos;
int pre = a[a[t.left].pre].pos;
int nxt = a[a[t.right].nxt].pos;
if ((int)(a[pre].gender + a[nxt].gender) == int('G' + 'B') and (flag[pre] == 0 and flag[nxt] == 0)) {
int margin = abs(a[pre].val - a[nxt].val);
dance.push(heap{ abs(margin), pre, nxt });
}
}
}
cout << cnt << endl;
for (int i = 1; i <= cnt; i++) {
cout << l[i] << ' ' << r[i] << endl;
}
}
//初始化堆:将“异性之间的舞蹈技术差值的绝对值、左边的人是谁、右边的人是谁” 插入堆中.
// 令一个heap变量 t = 堆顶, 那么t的left,right 就是需要出列的人的位置
// 然后用链表把这两个人连接起来
// 然后我们需要判断 t.left 的前面一个人(t.left .pre) .gender
// 和
// t.right 的后面一个人 (t.right.nex).gender
// 是不是异性。
// 如果是异性的话,我们就把这个信息插入到堆里面。
//
//
/*8
BGBGBGBG
1 1 1 8 7 1 7 1
*/
线段树:
什么是线段树?
先用一个问题来引出线段树的论述:
给你一段区间,然后给你 q次询问,每次询问让你输出这个区间的最大值。
乍一看,这不是很简单吗?只需要这样这样再那样那样就好了。
nonono
如果q=1e6次呢? 那么这就暗示你需要一个O(1)的算法来解决这道题目。
而线段树就是解决这一类问题的好方法。
那么回来了,线段树是什么? 我们只知道线段树是一种数据结构,它能处理上面的问题。还有么?
其实,线段树的用途很广泛,他能作用大多数的区间查询问题。
例如求区间和、求区间最值、求区间内满足某种条件的元素个数等等。
你可能已经迫不及待地想要学习线段树了。
我们先抛开线段树这三个字。对于上面的问题,我们可以这样思考:
由于询问次数高达1e6次方,所以必须使用O(1)的算法。
这也就意味着,我们必须对这个问题进行预处理。只有这样我们才能在每次询问的时候直接得到答案。
假设num[l][r] 表示的是这个区间里的最大值
单在这个区间较长的情况下,我们无法将答案预处理到一个二维数组里面
因为空间复杂度将爆炸。
所以我们可以构建一个函数,这个函数的参数有我们需要查询的左右两个端点。返回值就是这个区间的最大值。
然后每次调用函数能以非常短的次数得到答案。
ok,现在的问题就是,怎么用很低的时间复杂度找到某一区间内的最大值呢?
对,没错就是二分。
一个区间的最大值,取决于 这个区间左半边的最大值,和区间右半边的最大值
然后一直递归下去,直到边界,也就是这个区间长度为1.
因为: “每一个单位长度为1的区间,其最大值就是本身。”
递归到边界之后,直接返回边界值,然后根据刚刚说的:
“ 这个区间的最大值,取决于 这个区间左半边的最大值,和区间右半边的最大值 ”
最后将每个区间的最大值存入一个数组。。。。
于是。。。
神乎其技! 我们有了这整个线段的最大值。
其实我们仔细看看这个思路,会发现,哎哟我去,这不是分治吗。或者是:哎哟我去,这不是二叉树吗。
是的,于是我们可以用二叉树来完成上面的操作:
- 首先构建一颗二叉树,二叉树的每个结点表示一段区间。而叶子结点就表示一个个长度是1的区间,再上一层就是长度为2的区间。。。以此类推(类似这样)
[1,8] (最大值: 8)
/ \
[1,4] [5,8]
(最大值: 4) (最大值: 8)
/ \ / \
[1,2] [3,4] [5,6] [7,8]
(2) (4) (6) (8)
- 然后写一个查询函数,search(当前结点编号,当前结点编号对应区间,需要查询的区间)
这就是经典的二分了,查询这个区间是在哪个结点处,如果这个区间能把当前结点对应的区间包含住,那么我们直接返回这个结点的值就可以了。
否则的话,根据这个区间出现在左半边还是右半边进行二分查找即可
(有人可能会问,如果这个区间,出现在结点区间的中间怎么办)
(ps:黄色是目标区间,红色是当前结点区间,绿色是二分的位置。)
难办?其实是一样的,如果在中间的话,我们还是能继续二分啊,
只不过我们继续二分的话,会分别二分到黄色区间的左端点,和右端点。
当结点区间的左端点为目标区间的左端点的时候(结点区间的右端点是绿色处)
那么我们不就求出了左黄部分的最大值了吗。
右端点同理啊。
而一个区间的最大值不就是可以分为这个区间以某点为分界线的左边最大值和右边最大值的最大值吗?
所以就求完了啊。
而第三种情况就是,当你的目标区间和结点区间完全没有交集的时候,我们直接返回0就可以了。
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
int a[N];//已知区间
int tree[4 * N];//用来存储树的信息
//建立线段树
//可以用二叉树来构建,二叉树的左右子节点分别是2n,2n+1
void build_segment_tree(int root,int l,int r) {
if (l == r) { //如果区间长度是1,那么该结点时叶子结点,存放的是区间[l,l]的最大值
tree[root] = a[l];
return;
}
int mid = (l + r) >> 1; //不断二分将整个区间都拆分到这棵树里
build_segment_tree(root * 2, l, mid); //构建左子树
build_segment_tree(root * 2 + 1, mid + 1, r);//构建右子树
tree[root] = max(tree[root * 2], tree[root * 2 + 1]);//父节点取两个子结点中的最大值
}
int search_max(int root, int l, int r, int ql, int qr) { //查询[ql,qr]最大值
if (ql <= l and qr >= r) { //如果[ql,qr] 将 [l,r] 包裹,那么直接返回结点信息。其实l,r两端中的某一端已经与ql,qr重合了,所以可以直接返回。
return tree[root];
}
if (ql > r or qr < l) { //如果完全没有交集,那么就不需要继续二分下去,直接返回一个非法的值
return 0;
}
//二分
int mid = (l + r) >> 1;
int left=search_max(root * 2, l, mid, ql, qr);
int right=search_max(root * 2 + 1, mid + 1, r, ql, qr);
return max(left, right);//一段区间的最大值,一定是左区间和右区间的最大值。
}
/*void update(int root, int l, int r, int pos, int val) { //更新pos点的值为val
if (l == r) {
tree[root] = val;
return;
}
int mid = (l + r) >> 1;
if (pos <= mid) {
update(root * 2, l, mid, pos, val);
}
else {
update(root * 2 + 1, mid + 1, r, pos, val);
}
tree[root] = max(tree[root * 2 + 1], tree[root * 2]);
}*/
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++)cin >> a[i];
build_segment_tree(1, 1, n); //根节点是1,左端点是1,右端点是n
int q;
cin >> q;
while (q--) {
int l, r;
cin >> l >> r;
cout << search_max(1, 1, n, l, r)<<endl;
}
}
总结一下线段树:
- 线段树是一种用于高效处理区间查询的数据结构,通常用于解决数组或线性数据结构上的区间查询问题。
- 线段树的基本思想是将一个线性的区间划分成若干个小区间,对每个小区间维护一个值,然后通过递归的方式建立一棵树状结构,使得每个节点代表一个区间,并且这些区间两两不重叠,同时完全覆盖整个线性区间。这样,就可以在每个节点上记录该区间的一些信息,比如最大值、最小值、区间和等等,以便快速地进行区间查询和更新操作。
- 线段树的建立过程和查询过程都是基于递归的思想,可以利用二叉树的结构来表示。对于一个线性区间 [l, r],可以将其划分为 [l, m] 和 [m+1, r] 两个子区间,然后分别递归地构建左右子树,直到区间长度为1时停止递归。线段树的查询操作也是通过递归地向下搜索树的节点,并结合区间的位置关系和需要的信息进行计算得出结果。
- 线段树在解决一些区间查询问题上有着良好的效果,比如求区间最大值、最小值、区间和、区间内满足某种条件的元素个数等。
上面只是一个非常简单的线段树模板。
并且只涉及到线段树的:单点增删改查,建树过程,区间查询。
那么我们下面再讲述一个操作:
线段树的区间更新。
例如,我们如果要将某一段区间都加上一个val值应该如何操作?
这就会用到一个很有用的技巧:lazy-tag 懒惰标记
什么意思呢?听我娓娓道来:
假如,我们要给一段指定的区间内的每个元素加上val。
那么对于线段树的结构来说(假如现在我们规定线段树的每个结点代表对应区间的和)
有人可能会想:
我们从上到下遍历,先查找出这个区间的位置。
然后直接给这个区间原来的值加上 val*区间长度 不就好了。
嗯。。。。
说的很好,但是,你这样的话是有隐患的,因为你只改变了这个区间以及这个区间以上的部分。
这个区间以下的部分你没有改变。
就比如,我们要给区间[7,8]里的每个数+val , 当你找到[7,8]位置是,不会再递归下去了,所以[7],[8]这俩区间没有被加上val。说明这个算法是有错的。
那么问题又来了,我们要给每个单位区间都加上val ,那么我们就需要遍历每个结点吧。。
是的,这样的话,每次修改的时间就比较长。
ok,现在有请我们的主角! lazy-tag
它的原理就是,给每一个打上标记的点,对其左右子节点进行某项操作,然后再把这个父节点的标记去除。
然后下次我们更新的话,只要提前查询以下这个结点是否被标记,如果被标记了,说明是之前的更新操作留下来的标记( 留下标记代表什么?代表从这个结点之后的每个值在那一次更新操作中都需要被更新为某个值,如加上val这种)
主打的就是一个懒惰,如果之后的查询中,这段区间再也没被查到,或者这段区间的子区间再也没被查到的话,那么那个懒惰标记就永远留在那里了。。。
上模板:
#define _CRT_SECURE_NO_WARNINGS
#include<iostream>
#include<cstdio>
#include<cmath>
#include<string>
#include<cstring>
#include<string>
#include<algorithm>
#include<vector>
#include<cctype>
#include<map>
#include<set>
#include<queue>
#include<numeric>
#include<iomanip>
#include<stack>
#include<list>
using namespace std;
const int N = 1e6 + 7;
typedef long long ll;
ll a[N]; // 存储已知区间的数组
ll tree[4 * N]; // 用来存储树的信息的数组
ll lazy[4 * N]; // 用来存储懒惰标记的数组
// 建立线段树
void built(int root, int l, int r) {
if (l == r) {
tree[root] = a[l];
return;
}
int mid = (l + r) >> 1;
built(root * 2, l, mid); // 建立左子树
built(root * 2 + 1, mid + 1, r); // 建立右子树
tree[root] = tree[2 * root] + tree[root * 2 + 1]; // 更新当前节点的值为左右子树值的和
}
// 下推懒惰标记
void pushdown(int root, int l, int r) {
if (lazy[root] != 0) { // 如果该节点有懒惰标记
int mid = (l + r) >> 1; // 计算中点位置
// 更新左右子树的值和懒惰标记
tree[2 * root] += lazy[root] * (mid - l + 1);
tree[2 * root + 1] += lazy[root] * (r - mid);
lazy[2 * root] += lazy[root];
lazy[2 * root + 1] += lazy[root];
lazy[root] = 0; // 清空当前节点的懒惰标记
}
}
// 更新线段树中某个区间的值
void update(int root, int l, int r, int ql, int qr, int k) {
if (ql <= l && qr >= r) { // 如果要更新的区间完全包含在当前节点表示的区间内
tree[root] += (r - l + 1) * k; // 更新当前节点的值
lazy[root] += k; // 设置当前节点的懒惰标记
return;
}
int mid = (l + r) >> 1; // 计算中点位置
pushdown(root, l, r); // 下推懒惰标记
if (ql <= mid) {
update(root * 2, l, mid, ql, qr, k); // 更新左子树
}
if (qr > mid) {
update(root * 2 + 1, mid + 1, r, ql, qr, k); // 更新右子树
}
tree[root] = tree[root * 2] + tree[root * 2 + 1]; // 更新当前节点的值为左右子树值的和
}
// 查询线段树中某个区间的值
ll search(int root, int l, int r, int ql, int qr) {
if (ql <= l && qr >= r) { // 如果要查询的区间完全包含在当前节点表示的区间内
return tree[root]; // 返回当前节点的值
}
int mid = (l + r) >> 1; // 计算中点位置
pushdown(root, l, r); // 下推懒惰标记
ll sum = 0;
if (ql > r || qr < l) {
return 0; // 如果要查询的区间与当前节点表示的区间没有交集,返回0
}
if (ql <= mid) {
sum += search(root * 2, l, mid, ql, qr); // 查询左子树
}
if (qr > mid) {
sum += search(root * 2 + 1, mid + 1, r, ql, qr); // 查询右子树
}
return sum;
}
int main() {
int n, m;
cin >> n >> m;
for (int i = 1; i <= n; i++) {
cin >> a[i]; // 输入已知区间的数值
}
built(1, 1, n); // 建立线段树
while (m--) {
int opt, l, r;
cin >> opt >> l >> r;
if (opt == 1) {
int k;
cin >> k;
update(1, 1, n, l, r, k); // 更新线段树中的某个区间
}
else if (opt == 2) {
cout << search(1, 1, n, l, r) << endl; // 查询线段树中的某个区间的和
}
}
return 0;
}
嗯。。这个代码就是下面这道题的题解。
P3372 【模板】线段树 1 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P3372
线段树怎么做都不嫌多,再来一道吧!
P1253 扶苏的问题 - 洛谷 | 计算机科学教育新生态 (luogu.com.cn)https://www.luogu.com.cn/problem/P1253https://www.luogu.com.cn/problem/P1253
这题比较有意思,篇板子一点,重要的是怎么将板子巧妙结合一下?
这道题主要让我们实现三个功能:
- 将给定区间的所有值都替换为x
- 将给定区间的所有值都加上x
- 求给定区间的最大值
虽然,三个功能的板子我们都会写,不过呢,由于懒标记的延迟性,我们执行操作1、2的时候,要考虑到顺序。
假设,我们在之前已经执行过一次替换操作了,我们现在要执行加操作,应该注意到,执行加操作应该是在替换操作的基础上执行的。
先判断这一层有没有替换操作的懒标记,如果有,我们就要先进行替换操作,然后再进行加操作。
那么假设我们执行加操作之后,又需要执行替换操作。我们是否要像前面一样判断是否加操作的标记呢?
实际上是不需要的,如果这一层需要既被加操作标记了,而后又需要进行替换操作,我们直接将加操作标记清除即可。
然后再维护最大值就好了。
然后这道题,注意要开long long。
并且,这道题还有一些很坑的数据点!
首先,x可以取0,和0以下的值。 那么替换操作的懒标记就不能初始化为0.
要不然就忽视了将区间数全部替换为0的情况。
并且,还能替换成负数。这个是值得注意的。
然后,加操作的懒标记是不需要改的,0就相当于没加。
还有一定要记得!!!开longlong!!!特么的,我没开longlong被卡了两三个小时。
嗯,然后记得写快读,或者关同步流。。。
(警钟长鸣!!!我宏定义了一个常量叫node,然后写了一个函数叫research。。。然后疯狂报错,调了半天才知道撞定义了)
下面上代码:
#define _CRT_SECURE_NO_WARNINGS //禁用警告
#include<iostream> //输入输出流库
#include<cstdio> //C风格输入输出库
#include<cmath> //数学函数库
#include<string> //字符串库
#include<cstring> //C风格字符串库
#include<algorithm> //常用算法库
#include<vector> //向量库
#include<cctype> //字符处理库
#include<map> //映射库
#include<set> //集合库
#include<queue> //队列库
#include<numeric> //数字操作库
#include<iomanip> //输入输出格式库
#include<stack> //栈库
#include<list> //链表库
using namespace std;
//定义常量
#define ll long long
#define lson root<<1
#define rson (root<<1)|1
//定义全局变量
const long long N = 5e6 + 7; //数组大小
const long long nod = -1145141919810; //标记值
const long long inf = 1e15; //无穷大值
ll a[N]; //储存原始数据
ll lazy_add[4 * N]; //懒惰标记,记录区间加法操作
ll lazy_rev[4 * N]; //懒惰标记,记录区间赋值操作
//线段树结构体
struct Tree {
ll maxn; //区间最大值
} tree[4 * N];
//更新结点的区间最大值
void get_max(ll root) {
tree[root].maxn = max(tree[lson].maxn, tree[rson].maxn);
}
//建立线段树
void built(ll root, ll l, ll r) {
if (l == r) {
tree[root].maxn = a[l];
return;
}
ll mid = (l + r) >> 1;
built(lson, l, mid), built(rson, mid + 1, r);
get_max(root);
}
//下传区间赋值操作
void pushdown_revise(ll root, ll l, ll r) {
if (lazy_rev[root] != nod) {
lazy_add[rson] = lazy_add[lson] = 0;
tree[lson].maxn = tree[rson].maxn = lazy_rev[root];
lazy_rev[lson] = lazy_rev[rson] = lazy_rev[root];
lazy_rev[root] = nod;
}
}
//下传区间加法操作
void pushdown_add(ll root, ll l, ll r) {
if (lazy_add[root] != 0) {
tree[lson].maxn += lazy_add[root];
tree[rson].maxn += lazy_add[root];
lazy_add[lson] += lazy_add[root];
lazy_add[rson] += lazy_add[root];
lazy_add[root] = 0;
}
}
//下传懒惰标记
void pushdown(ll root, ll l, ll r) {
pushdown_revise(root, l, r);
pushdown_add(root, l, r);
}
//区间赋值操作
void revise(ll root, ll l, ll r, ll ql, ll qr, ll k) {
if (ql <= l and qr >= r) {
lazy_add[root] = 0;
tree[root].maxn = k;
lazy_rev[root] = k;
return;
}
ll mid = (l + r) >> 1;
pushdown(root, l, r);
if (ql <= mid) revise(lson, l, mid, ql, qr, k);
if (qr > mid) revise(rson, mid + 1, r, ql, qr, k);
get_max(root);
}
//区间加法操作
void add(ll root, ll l, ll r, ll ql, ll qr, ll k) {
if (ql <= l and qr >= r) {
pushdown_revise(root, l, r);
tree[root].maxn += k;
lazy_add[root] += k;
return;
}
ll mid = (l + r) >> 1;
pushdown(root, l, r);
if (ql <= mid) add(lson, l, mid, ql, qr, k);
if (qr > mid) add(rson, mid + 1, r, ql, qr, k);
get_max(root);
}
//查询区间最大值
ll search1(ll root, ll l, ll r, ll ql, ll qr) {
if (ql <= l and qr >= r) {
return tree[root].maxn;
}
ll mid = (l + r) >> 1;
pushdown(root, l, r);
ll ret = -inf;
if (ql <= mid) ret = max(ret, search1(lson, l, mid, ql, qr));
if (qr > mid) ret = max(ret, search1(rson, mid + 1, r, ql, qr));
return ret;
}
int main() {
ios::sync_with_stdio(0), cin.tie(0), cout.tie(0);
ll n, q;
cin >> n >> q;
//读取原始数据
for (ll i = 1; i <= n; i++) {
cin >> a[i];
}
//初始化懒惰标记
for (ll i = 1; i <= 4 * n; i++) {
lazy_rev[i] = nod;
}
//建立线段树
built(1, 1, n);
//处理查询和操作
while (q--) {
int opt;
cin >> opt;
if (opt == 1) {
ll l, r, x;
cin >> l >> r >> x;
revise(1, 1, n, l, r, x);
} else if (opt == 2) {
ll l, r, x;
cin >> l >> r >> x;
add(1, 1, n, l, r, x);
} else {
ll l, r;
cin >> l >> r;
cout << search1(1, 1, n, l, r) << '\n';
}
}
}