C220818C 城市游历
C220818 城市游历
【题目描述】
Alice 将在城市中旅游 q 天,城市中景点的数目为 n,每一个景点有一个特征值 𝑐𝑖,有 m 条双向道路连接这 n 个景点使得从任意一个景点出发都可以到达其他的景点。
每一条道路都有其困难系数𝑘𝑖,Alice 每天会从起点 x 出发做若干次出行。第 i 天接受程度初始为𝑙𝑖,每次出行结束后会增加 1,直到接受程度大于𝑟𝑖后不再出行。每一次出行Alice 可以经过𝑘𝑖 ≤当前接受程度的道路,Alice 也会去到所有能去的景点。
一次出行的愉悦值为到达的景点种类数,相同特征值的景点视作同一种,一天的愉悦值为当天所有出行的愉悦值之和。请你帮助 Alice 计算第 1 到 q 天每天她所能获得的愉悦值。
【输入格式】
第一行三个整数 n,m,q,x,代表景点数目、道路数目、游历天数与起点。
第二行 n 个整数,代表每一个景点的特征值𝑐𝑖。
接下来 m 行每行两个整数 i,j,k,代表一条连接(i,j),困难系数为 k 的边。
接下来 q 行每行两个整数 l,r,代表这一天的最小与最大接受程度。
【输出格式】
对于每个询问输出一行,代表每天所有出行的愉悦值总和。
【样例输入】(tour.in)
3 2 2 1
1 2 3
1 2 2
1 3 3
1 2
1 3
【样例输出】(tour.out)
3
6
【样例说明】
第一天第一次出行只能到 1,第二次可以到 1,2,愉悦值为 3
第二天第一次可以到 1,第二次可以到 1,2,第三次可以到 1,2,3,愉悦值为 6
【数据范围】
对于 30%的数据,𝑛, 𝑚, 𝑞, 𝑘𝑖, 𝑐𝑖, 𝑙𝑖, 𝑟𝑖 ≤ 100
对于另 20%的数据,𝑚 = 𝑛 − 1
对于另 20%的数据,𝑙𝑖, 𝑟𝑖, 𝑘𝑖 ≤ \(10^5\)
对于 100%的数据,𝑛, 𝑚 ≤ \(5 × 10^5\), 𝑞 ≤ \(10^5\), 1 ≤ 𝑐𝑖 ≤ 600,0 ≤ 𝑘𝑖,𝑙𝑖, 𝑟𝑖 ≤ \(10^9\)
Solution
膜你赛的 T3,因为忘了把 #define int long long
的注释去掉然后怒丢 \(40pts\),警钟敲烂。赛后看了下给的 std,我的做法貌似还爆标了,所以就来写篇博客纪念下。
看到数据范围,图是 \(5e5\) 这一级别的,并且还有 \(1e5\) 的询问,询问的值域到了 \(1e9\),因为并不好想到一些在图上的倍增一类的 \(\log\) 级的算法,所以我就想的是预处理出答案然后询问的时候直接输出,于是开始思考如何记录答案。
观察样例,不难发现询问的区间 \([l,r]\) 的答案是具有前缀和性质的,这也就是说可以预处理出 \([1,l-1]\) 和 \([1,r]\) 两个区间的答案然后相减得到。但是因为值域是在 \(1e9\) 这一级别,所以肯定是不能直接开一个数组 \(sum_x\) 来存 \([1,x]\) 的答案的,观察到题目中的 \(n\) 是 \(5e5\) 的,也就是说 \(k\) 的取值只有 \(5e5\) 种,用 \(vec_x\) 来表示在起点接受程度为 \(x\) 时可以到达的最多点数,那么 \(vec_x\) 的值一定也是与 \(k\) 相关的,因此我们只记录这 \(5e5\) 个值(可以理解做离散化),并在询问时根据 \(vec_x\) 来推算答案。
因为每一次都是从起点出发,然后每次出行的接受程度 \(curk\) 都是固定的,所以预处理的时候可以扫一遍图,每次选择 \(k\) 值最小的边进行拓展,直到将整张图扫完(用一个小根堆实现),具体方法参考代码进行讲解:
void prework()
{
int cnt[605],totans=1,curk=0;mem(cnt,0);
//cnt是桶,用于统计走过的不同点,totans内存储走过的不同点数,curk表示要到达当前点最少需要的接受程度
cnt[c[s]]=1;vis[s]=1;//无论如何起点都是可以到达的,所以将起点加入贡献
queue<int> q;//搜索队列
q.push(s);//加入起点
vec.push_back(make_pair(0,1));//表示当接受程度为0的时候可以走到一个不同的点
while (!eq.empty() || !q.empty())//eq是用来记录边的小根堆,两个任意有一个非空就需要进行搜索
{
if (!q.empty())
{
int x=q.front();q.pop();//取出队头
for (int i=head[x];i;i=edge[i].nxt)
{
if (vis[edge[i].to]) continue;//如果前进的点已经去过了就不需要再次去了
eq.push(make_pair(-edge[i].len,i));//priority_queue默认大根堆,将k值取负数就成了小根堆
}
}
if (!eq.empty())//如果还有没拓展的边
{
int curedge=eq.top().second;//取出当前k值最小的边的编号
eq.pop();
curk=max(curk,edge[curedge].len);//如果要经过这条边到达另一端至少需要有的接受程度,以此更新curk
if (!cnt[c[edge[curedge].to]]) totans++;//如果这个新的点的类型不同,累加进totans内
cnt[c[edge[curedge].to]]=1;//标记
vec.push_back(make_pair(curk,totans));//接受程度为curk的时候的答案为totans,具体为什么不需要去重,在后面询问的时候会处理的
if (!vis[edge[curedge].to])//如果新的点还没去过就加入搜索队列
q.push(edge[curedge].to),vis[edge[curedge].to]=1;
}
}
}
关于时间复杂度,很明显,因为每个点最多进队出队一次,每个边进队出队一次,所以预处理的时间复杂度是 \(\mathcal O(n+m\log m)\) 的。并且由于 \(curk\) 每次都是做的取 \(\max\) 的运算,因此 \(vec.first\) 是单调不减的。
PS. 其实这好像就是用 Prim 算法跑了个最小生成树,但是我在膜你赛的时候完全都没往最小生成树方面去想(
继续看题,会发现只求出一个 \(vec\) 数组其实是不够用的,因为询问的内容显然是前缀和一样的东西,所以我们将 \(vec\) 数组的前缀和储存进一个 \(sum\) 中。此时求前缀和的操作是非常特殊的,因为编号是不一致的,所以需要用到特殊的方法进行处理。首先 \(sum_x.first\) 的第一维上显然就是 \(vec_x.first\),接受程度肯定是不发生改变的,对于 \(second\) 的前缀和的求解则需要与 \(first\) 的值挂钩。
假设有一个接受程度 \(t\),而这个 \(t\) 并不是 \(vec\) 中所存有的任意特殊值,假设 \(vec.first\) 中最接近 \(t\) 且小于 \(t\) 的值是 \(val\),那么因为 \(t>val\) 并且 \(t\) 没有更多的接受程度来经过更多的点,因此接受程度为 \(t\) 时的答案一定也是接受程度为 \(val\) 时的答案,也就是说 \(vec\) 中存储的两个特殊点之间的所有值都是相同的。据此可以推导出 \(sum_x.second\) 的求解方式(下面将 \(first\) 简写做 \(F\),\(second\) 简写做 \(S\)):
解释一下这个式子的含义,前缀和的求解仍然建立在递推的基础上,相邻两个特殊的 \(F\) 值之间没有计算到的 \(vec\) 的个数为 \(vec_x.F-vec_{x-1}.F-1\)(因为 \(vec_{x-1}.S\) 已经被计算在了 \(sum_{x-1}.S\) 中,并且上一个 \(vec\) 的值并不包含当前 \(x\) 这个),然后因为是要计算前缀和,所以要将当前这个数的 \(vec_x.S\) 值加上。这样就求解出了 \(vec\) 的前缀和数组 \(sum\)。之后询问的时候就可以利用这个式子进行一些变形就可以计算了。
然后就是询问部分。刚才提到了 \(vec.first\) 具有单调性,因此 \(sum.first\) 也具有单调性。所以当询问 \([1,l]\) 的时候,可以在 \(sum.first\) 中二分到最靠右的那个小于等于 \(l\) 的位置,然后利用求前缀和相同的方式求出答案,具体还是结合代码讲解:
int Get(int x)//这个函数的作用就是查找到最靠右的那个小于等于x的位置
{
int l=0,r=sum.size()-1;//定义左右边界
while (l<=r)
{
int mid=l+r>>1;
if (sum[mid].first<=x) l=mid+1;
else r=mid-1;//需要知道,进入此部分分支的时候mid的值一定是在x右侧的,当循环结束的时候,mid将会停留在最靠左的大于x的位置上,此时r=mid-1,就是恰好最靠右的那个小于等于x的位置
}
return sum[r].second+(x-sum[r].first)*vec[r].second;//先是获得前r的前缀和,然后[r,x]之间的数之和就是这部分的vec值乘上这部分值的个数
}
这时候就可以来说说为什么不用去重了。我们二分到的最终位置是最靠右的小于等于 \(x\) 的位置,因为要最靠右,所以左边一系列的重复的值都会被舍弃掉,只保留最右边那个,因此正确性是可以保证的(其实如果去重的话代码效率可能还会有提升,只不过提升多少随数据而定)。
询问要求区间 \([l,r]\) 的答案,所以答案就是 \([1,r]-[1,l-1]\)。
询问部分时间复杂度是 \(\mathcal O(q\log m)\) 的(因为有 \(m\) 条边所以 \(sum\) 的长度就是 \(m\))。
综合来看,这种解法的时间复杂度是与 \(c\) 的值域无关的,为 \(\mathcal O(n\log)\) 这一级别,而 std 的时间复杂度是 \(\mathcal O(q\max\{c_i\})\) 的,不过因为这道题 \(c\) 的值域只有 \(600\),所以两种做法的耗时还是差不多的(而且我人傻常数大,说不定还跑得更慢),不过如果加大 \(c\) 的值域,那么 std 的做法就没法胜任了,而对本篇题解的做法的影响,只有在预处理的时候 \(cnt\) 数组该开到函数外还是函数内的区别了。
Code
最主要的两个函数 prework
和 Get
如果明白了那主函数里面应该都不需要要提了吧(
#include<bits/stdc++.h>
#define mem(a,b) memset(a,b,sizeof a)
#define int long long
using namespace std;
template<typename T> void read(T &k)
{
k=0;T flag=1;char b=getchar();
while (!isdigit(b)) {flag=(b=='-')?-1:1;b=getchar();}
while (isdigit(b)) {k=k*10+b-48;b=getchar();}
k*=flag;
}
template<typename T> void write(T k) {if (k<0) {putchar('-'),write(-k);return;}if (k>9) write(k/10);putchar(k%10+48);}
template<typename T> void writewith(T k,char c) {write(k);putchar(c);}
vector<pair<int,int> > vec,sum;//f[i]=j -> pair,sum of vec
priority_queue<pair<int,int> > eq;//edge to go,first is len of edge,second is num of edge
const int _SIZE=5e5;
struct EDGE{
int nxt,to,len;
}edge[(_SIZE<<1)+5];
int tot,head[_SIZE+5];
void AddEdge(int x,int y,int len)
{
edge[++tot]=(EDGE){head[x],y,len};
head[x]=tot;
}
int n,m,q,s,c[_SIZE+5];
bool vis[_SIZE+5];
void prework()
{
int cnt[605],totans=1,curk=0;mem(cnt,0);
//cnt是桶,用于统计走过的不同点,totans内存储走过的不同点数,curk表示要到达当前点最少需要的接受程度
cnt[c[s]]=1;vis[s]=1;//无论如何起点都是可以到达的,所以将起点加入贡献
queue<int> q;//搜索队列
q.push(s);//加入起点
vec.push_back(make_pair(0,1));//表示当接受程度为0的时候可以走到一个不同的点
while (!eq.empty() || !q.empty())//eq是用来记录边的小根堆,两个任意有一个非空就需要进行搜索
{
if (!q.empty())
{
int x=q.front();q.pop();//取出队头
for (int i=head[x];i;i=edge[i].nxt)
{
if (vis[edge[i].to]) continue;//如果前进的点已经去过了就不需要再次去了
eq.push(make_pair(-edge[i].len,i));//priority_queue默认大根堆,将k值取负数就成了小根堆
}
}
if (!eq.empty())//如果还有没拓展的边
{
int curedge=eq.top().second;//取出当前k值最小的边的编号
eq.pop();
curk=max(curk,edge[curedge].len);//如果要经过这条边到达另一端至少需要有的接受程度,以此更新curk
if (!cnt[c[edge[curedge].to]]) totans++;//如果这个新的点的类型不同,累加进totans内
cnt[c[edge[curedge].to]]=1;//标记
vec.push_back(make_pair(curk,totans));//接受程度为curk的时候的答案为totans,具体为什么不需要去重,在后面询问的时候会处理的
if (!vis[edge[curedge].to])//如果新的点还没去过就加入搜索队列
q.push(edge[curedge].to),vis[edge[curedge].to]=1;
}
}
}
int Get(int x)//这个函数的作用就是查找到最靠右的那个小于等于x的位置
{
int l=0,r=sum.size()-1;//定义左右边界
while (l<=r)
{
int mid=l+r>>1;
if (sum[mid].first<=x) l=mid+1;
else r=mid-1;//需要知道,进入此部分分支的时候mid的值一定是在x右侧的,当循环结束的时候,mid将会停留在最靠左的大于x的位置上,此时r=mid-1,就是恰好最靠右的那个小于等于x的位置
}
return sum[r].second+(x-sum[r].first)*vec[r].second;//先是获得前r的前缀和,然后[r,x]之间的数之和就是这部分的vec值乘上这部分值的个数
}
signed main()
{
read(n),read(m),read(q),read(s);
for (int i=1;i<=n;i++) read(c[i]);
for (int i=1;i<=m;i++)
{
int u,v,c;read(u),read(v),read(c);
AddEdge(u,v,c);AddEdge(v,u,c);
}
prework();
sum.push_back(vec[0]);
for (int i=1;i<vec.size();i++) sum.push_back(make_pair(vec[i].first,sum[i-1].second+vec[i].second+vec[i-1].second*(vec[i].first-vec[i-1].first-1)));
for (int i=1;i<=q;i++)
{
int l,r;read(l),read(r);
int ans1=Get(l-1);
int ans2=Get(r);
writewith(ans2-ans1,'\n');
}
return 0;
}
数组版:
#pragma GCC optimize(2)
#include<bits/stdc++.h>
#define mem(a,b) memset(a,b,sizeof a)
#define int long long
using namespace std;
template<typename T> void read(T &k)
{
k=0;T flag=1;char b=getchar();
while (!isdigit(b)) {flag=(b=='-')?-1:1;b=getchar();}
while (isdigit(b)) {k=k*10+b-48;b=getchar();}
k*=flag;
}
template<typename T> void write(T k) {if (k<0) {putchar('-'),write(-k);return;}if (k>9) write(k/10);putchar(k%10+48);}
template<typename T> void writewith(T k,char c) {write(k);putchar(c);}
const int _SIZE=5e5;
pair<int,int> vec[_SIZE+5],sum[_SIZE+5];//f[i]=j -> pair,sum of vec
int tv=0,ts=0;
priority_queue<pair<int,int> > eq;//edge to go,first is len of edge,second is num of edge
struct EDGE{
int nxt,to,len;
}edge[(_SIZE<<1)+5];
int tot,head[_SIZE+5];
void AddEdge(int x,int y,int len)
{
edge[++tot]=(EDGE){head[x],y,len};
head[x]=tot;
}
int n,m,q,s,c[_SIZE+5];
bool vis[_SIZE+5];
void prework()
{
int cnt[605],totans=1,curk=0;mem(cnt,0);
cnt[c[s]]=1;vis[s]=1;
queue<int> q;
q.push(s);
vec[++tv]=make_pair(0,1);
while (!eq.empty() || !q.empty())
{
if (!q.empty())
{
int x=q.front();q.pop();
for (int i=head[x];i;i=edge[i].nxt)
{
if (vis[edge[i].to]) continue;
eq.push(make_pair(-edge[i].len,i));
}
}
if (!eq.empty())
{
int curedge=eq.top().second;
eq.pop();
curk=max(curk,edge[curedge].len);
if (!cnt[c[edge[curedge].to]]) totans++;
cnt[c[edge[curedge].to]]=1;
vec[++tv]=make_pair(curk,totans);
if (!vis[edge[curedge].to])
q.push(edge[curedge].to),vis[edge[curedge].to]=1;
}
}
}
int Get(int x)
{
int l=1,r=ts;
while (l<=r)
{
int mid=l+r>>1;
if (sum[mid].first<=x) l=mid+1;
else r=mid-1;
}
return sum[r].second+(x-sum[r].first)*vec[r].second;
}
signed main()
{
read(n),read(m),read(q),read(s);
for (int i=1;i<=n;i++) read(c[i]);
for (int i=1;i<=m;i++)
{
int u,v,c;read(u),read(v),read(c);
AddEdge(u,v,c);AddEdge(v,u,c);
}
prework();
sum[++ts]=vec[1];
for (int i=2;i<=tv;i++) sum[++ts]=make_pair(vec[i].first,sum[i-1].second+vec[i].second+vec[i-1].second*(vec[i].first-vec[i-1].first-1));
for (int i=1;i<=q;i++)
{
int l,r;read(l),read(r);
int ans1=Get(l-1);
int ans2=Get(r);
writewith(ans2-ans1,'\n');
}
return 0;
}
代码实现的时候遇到的一些问题: 在预处理的时候需要取出 \(eq\) 中的边的时候记得判断 \(eq\) 是否非空,我在第一次写这部分的时候没有判断,结果发现 \(eq.size()\) 因为是 unsigned int
类型,直接溢出飞到 \(2^{32}-1\) 去了,然后一看内存占用直接飙升到 \(98\%\)(还好关的快,不然绝对死机)。
又在想该放到什么标签下面了……