CS101 2021Fall PA3,4 题解
PA3
PA3的题目描述真是四次PA中最令人一言难尽的一次,第一题和第二题一开始都有条件的缺失,尤其第二题的描述很不清楚,也没有样例解释,关于什么样的方案是本质不同的也没有准确严谨的定义,如果不是根据tag猜到了做法而是从题目本身入手去分析的话真的没法做。第三题的特殊输入方式也没有解释得很清楚。
3001
题意:给一张 n n n个结点、 m m m条边的无向图 G = ( V , E ) G=(V,E) G=(V,E),请选出 V V V的一个子集 U U U。如果某一条边两端的结点都在 U U U中,那么这条边的稳定度为 1 1 1;如果某条边两端的结点恰好有一个在 U U U中,那么这条边的稳定度为 − 1 -1 −1;其它边的稳定度都为 0 0 0。另外, U U U中的每个结点的稳定度都为 − 1 -1 −1,不在 U U U中的结点稳定度为 0 0 0。现在请确定一个子集 U U U的选择方案,使得整张图的稳定度之和最大,只要输出这个最大的稳定度即可。注意, U = ∅ U=\varnothing U=∅也是一种方案,其稳定度为 0 0 0。
可以证明:在每个连通块中,如果你要选若干结点,那么选择整个连通块的所有结点一定不会更差。因此答案就是每个连通块的答案之和,其中,在一个连通块 G ′ = ( V ′ , E ′ ) G^\prime=(V^\prime,E^\prime) G′=(V′,E′)中,选中全部结点 V ′ V^\prime V′得到的稳定度是 ∣ E ′ ∣ − ∣ V ′ ∣ |E^\prime|-|V^\prime| ∣E′∣−∣V′∣,所以只要求出每个连通块的点数和边数,然后将 max { ∣ E ′ ∣ − ∣ V ′ ∣ , 0 } \max\{|E^\prime|-|V^\prime|,0\} max{∣E′∣−∣V′∣,0}计入答案即可。
想到这里,做法就很多了。点数可以通过一次简单的dfs或bfs求得,而边数满足一个结论
2
∣
E
′
∣
=
∑
v
∈
V
′
deg
v
,
2|E^\prime|=\sum_{v\in V^\prime}\deg v,
2∣E′∣=v∈V′∑degv,
所以可以在dfs或bfs的时候顺便统计一下每个点的度数(点的度数在输入的时候就能算出来)。当然你也可以在dfs或bfs的时候给每个点标记它属于哪个连通块,然后再遍历一遍所有的边来算出每个连通块有多少条边。此外,这个问题也可以用并查集(disjoint-set)来做,我们把每个连通块中的点数以及度数之和记在并查集的树根上,再遍历一遍并查集来统计答案。
关于并查集,我提一点:课上讲了一种按高度合并的策略,其实还有一种所谓的“启发式合并”,也能在不路径压缩的情况下保证树高是 O ( log n ) O(\log n) O(logn)。这种策略是在每棵树的树根上记录这棵树的大小 s i z e ( x ) size(x) size(x),即包含的结点的数量;当合并两棵分别以 x x x和 y y y为根的树时,将 s i z e size size较小的那棵并到 s i z e size size较大的那棵上去。
int fa[maxn], size[maxn];
int findfa(int x) {
return x == fa[x] ? x : (fa[x] = findfa(fa[x]));
}
inline void union_set(int x, int y) {
x = findfa(x);
y = findfa(y);
if (x == y)
return;
if (size[x] < size[y]) {
fa[x] = y;
size[y] += size[x];
} else {
fa[y] = x;
size[x] += size[y];
}
}
这样做的正确性在于:考虑任何一个结点 x x x到其所在的树根的距离 d e p t h ( x ) depth(x) depth(x),这个距离会增加 1 1 1当且仅当这棵树被并到了另一棵更大的树上去,这意味着它所在的这棵树的 s i z e size size至少变为原来的两倍。而结点一共只有 n n n个,所以这个过程至多出现 log 2 n \log_2n log2n次,因此树高都是 O ( log n ) O(\log n) O(logn)。当然,带上路径压缩之后就飞快了。
3002
题意:给一张无向连通图,每个点有点权,定义每条边的边权等于两端结点的点权和,求最大生成树和(非严格)次大生成树。 n ⩽ 1000 , m ⩽ 100000 n\leqslant 1000,m\leqslant 100000 n⩽1000,m⩽100000,点权 ∈ ( 0 , 50000 ) \in(0,50000) ∈(0,50000)为整数。
这个题意是根据tag和所学知识猜出来的,因为题目描述本身根本就没说清楚什么样的方案是本质不同的。后来piazza上TA也直接摊牌了,说“不同的策略指的就是能span整张图的不同边集”。
一开始也没说点权是整数,结果一大群人当成浮点数用float
做。这里有必要提醒一句:在大多数情况下float
的精度都不够用,遇到浮点数请首选double
。并且你不必担心更高的精度是否导致更慢的运算速度,对此如果有疑问请去阅读C++ Primer第五版第2章第1节的"Advice: Deciding which Type to Use"。
先求一棵最大生成树,这只要用Kruskal或者Prim算法即可,当然我们一般首选Kruskal,因为Prim你还得写堆优化。Kruskal的时间复杂度主要在排序,排完序之后的时间复杂度是 O ( m α ( n ) ) O(m\alpha(n)) O(mα(n))。求出了一棵最大生成树之后,要求次大生成树,我们通常有以下两种做法:
算法1:考虑删除树上的某条边
我们可以枚举在最大生成树上的每一条边,考虑从图中将这条边删掉,再重新求最大生成树。由于树上的边只有 n − 1 n-1 n−1条,并且删除一条边之后你并不需要重新对边排序,只要跳过这条边即可,所以时间复杂度是 O ( n m α ( n ) ) O(nm\alpha(n)) O(nmα(n))。本题 n ⩽ 1000 , m ⩽ 100000 n\leqslant 1000,m\leqslant 100000 n⩽1000,m⩽100000,这个时间复杂度可以通过。注意,删除一条边可能会出现图不连通的情况,这时你的最大生成树将找不出 n − 1 n-1 n−1条边,这种情况要跳过。
#include <algorithm>
#include <cctype>
#include <climits>
#include <cstdio>
#include <functional>
#include <iterator>
template <typename T>
inline void read(T &x) {
int f = 0, c = getchar();
x = 0;
while (!isdigit(c)) {
f |= c == '-';
c = getchar();
}
while (isdigit(c)) {
x = x * 10 + c - 48;
c = getchar();
}
if (f)
x = -x;
}
template <typename T, typename... Args>
inline void read(T &x, Args &...args) {
read(x);
read(args...);
}
namespace gkxx {
template <typename ForwardIterator, typename Less>
void __merge(
ForwardIterator begin, ForwardIterator mid, ForwardIterator end,
typename std::iterator_traits<ForwardIterator>::difference_type dist,
Less less) {
ForwardIterator i = begin, j = mid;
using value_type = typename std::iterator_traits<ForwardIterator>::value_type;
value_type *tmp = new value_type[dist](), *k = tmp;
while (i != mid && j != end) {
if (less(*i, *j))
*k++ = *i++;
else
*k++ = *j++;
}
while (i != mid)
*k++ = *i++;
while (j != end)
*k++ = *j++;
k = tmp;
while (begin != end)
*begin++ = *k++;
delete[] tmp;
}
template <typename ForwardIterator, typename Less>
void merge_sort(ForwardIterator begin, ForwardIterator end, Less less) {
auto dist = std::distance(begin, end);
if (dist <= 1)
return;
ForwardIterator mid = std::next(begin, dist / 2);
merge_sort(begin, mid, less);
merge_sort(mid, end, less);
__merge(begin, mid, end, dist, less);
}
template <typename ForwardIterator>
inline void merge_sort(ForwardIterator begin, ForwardIterator end) {
merge_sort(begin, end, std::less<void>());
}
} // namespace gkxx
constexpr int maxn = 1e3 + 7, maxm = 1e5 + 7;
struct Edge {
int from, to, len;
Edge() : from(0), to(0), len(0) {}
};
int chosen[maxn], tot;
Edge edges[maxm];
int fa[maxn], size[maxn], val[maxn];
int n, m;
inline void set_init() {
for (int i = 1; i <= n; ++i) {
fa[i] = i;
size[i] = 1;
}
}
int findf(int x) {
return fa[x] == x ? x : (fa[x] = findf(fa[x]));
}
inline void union_set(int x, int y) {
int fx = findf(x), fy = findf(y);
if (fx == fy)
return;
if (size[fx] < size[fy]) {
size[fy] += size[fx];
fa[fx] = fy;
} else {
size[fx] += size[fy];
fa[fy] = fx;
}
}
int kruskal(int del = -1) {
set_init();
int ans = 0, cnt = 0;
for (int i = 1; i <= m; ++i)
if (i != del) {
int u = edges[i].from, v = edges[i].to, w = edges[i].len;
int fu = findf(u), fv = findf(v);
if (fu != fv) {
ans += w;
if (del == -1)
chosen[++tot] = i;
union_set(fu, fv);
if (++cnt == n - 1)
break;
}
}
return cnt == n - 1 ? ans : -1;
}
int main() {
read(n, m);
for (int i = 1; i <= n; ++i)
read(val[i]);
for (int i = 1; i <= m; ++i) {
read(edges[i].from, edges[i].to);
edges[i].len = val[edges[i].from] + val[edges[i].to];
}
gkxx::merge_sort(
edges + 1, edges + m + 1,
[](const Edge &a, const Edge &b) -> bool { return a.len > b.len; });
int best = kruskal();
int second = 0;
for (int i = 1; i <= tot; ++i) {
second = std::max(second, kruskal(chosen[i]));
if (second == best)
break;
}
printf("%.1lf\n", (best + second) / 2.0);
return 0;
}
算法2:枚举不在树上的边,考虑替换树上的边
枚举不在最大生成树上的每条边 ( u , v ) (u,v) (u,v),如果将这条边加入最大生成树显然会成环,我们得在最大生成树上 u u u到 v v v的路径上找一条边删掉,显然应该找边权最小的那条边。我们可以花 O ( n ) O(n) O(n)的时间在树上做一遍dfs或bfs,来求出 u u u到 v v v的路径上最小的边权是多少。不在最大生成树上的边总共有 m − ( n − 1 ) m-(n-1) m−(n−1)条,所以总的时间复杂度是 O ( n m ) O(nm) O(nm),可以通过。
这个算法能不能优化呢?注意到 n ⩽ 1000 n\leqslant 1000 n⩽1000,我们可以记 f ( x , y ) f(x, y) f(x,y)表示树上的结点 x x x与 y y y之间的路径上的边权最小值,在一开始就做 n n n遍dfs或bfs,花 O ( n 2 ) O(n^2) O(n2)的时间预处理整个二维数组 f f f。这样一来,之后单次查询时间复杂度就是 O ( 1 ) O(1) O(1),整个算法复杂度就是 O ( n 2 + m ) = O ( n 2 ) O(n^2+m)=O(n^2) O(n2+m)=O(n2)。
但是在竞赛中,像这样的一道题的数据范围就不会这么仁慈了,通常会给 n ⩽ 100000 n\leqslant 100000 n⩽100000而不是 1000 1000 1000。事实上,我们有很多种办法可以做到快速地预处理和快速地查询树上某条路径的边权最小值,可以使用倍增、Link-Cut-Tree或树链剖分+ST表做到 O ( n log n ) O(n\log n) O(nlogn)预处理、 O ( log n ) O(\log n) O(logn)单次查询,或者树链剖分+线段树做到 O ( n ) O(n) O(n)预处理、 O ( log 2 n ) O(\log^2n) O(log2n)单次查询,做法很多,有兴趣的同学可以去看这道题。
#include <algorithm>
#include <climits>
#include <cstdio>
#include <functional>
#include <iterator>
namespace gkxx {
template <typename ForwardIterator, typename Less>
void __inplace_merge(
ForwardIterator begin, ForwardIterator mid, ForwardIterator end,
typename std::iterator_traits<ForwardIterator>::difference_type dist,
Less less) {
ForwardIterator i = begin, j = mid;
using value_type = typename std::iterator_traits<ForwardIterator>::value_type;
value_type *tmp = new value_type[dist](), *k = tmp;
while (i != mid && j != end) {
if (less(*i, *j))
*k++ = *i++;
else
*k++ = *j++;
}
while (i != mid)
*k++ = *i++;
while (j != end)
*k++ = *j++;
k = tmp;
while (begin != end)
*begin++ = *k++;
delete[] tmp;
}
template <typename ForwardIterator, typename Less>
void merge_sort(ForwardIterator begin, ForwardIterator end, Less less) {
auto dist = std::distance(begin, end);
if (dist <= 1)
return;
ForwardIterator mid = std::next(begin, dist / 2);
merge_sort(begin, mid, less);
merge_sort(mid, end, less);
__inplace_merge(begin, mid, end, dist, less);
}
template <typename ForwardIterator>
inline void merge_sort(ForwardIterator begin, ForwardIterator end) {
merge_sort(begin, end, std::less<void>());
}
} // namespace gkxx
constexpr int maxn = 1007, maxm = 1e5 + 7;
struct Edge {
int from, to, weight;
};
Edge edges[maxm];
bool use[maxm];
int n, m;
int firmness[maxn];
int fa[maxn], size[maxn];
int findfa(int x) {
return fa[x] == x ? x : (fa[x] = findfa(fa[x]));
}
inline void union_set(int x, int y) {
x = fa[x];
y = fa[y];
if (x == y)
return;
if (size[x] < size[y]) {
fa[x] = y;
size[y] += size[x];
} else {
fa[y] = x;
size[x] += size[y];
}
}
struct Node {
int to, wei;
int next;
};
Node T[maxn * 2];
int total;
int head[maxn];
inline void add_edge(int x, int y, int w) {
T[++total].to = y;
T[total].wei = w;
T[total].next = head[x];
head[x] = total;
}
int kruskal() {
gkxx::merge_sort(edges + 1, edges + m + 1,
[](const Edge &lhs, const Edge &rhs) -> bool {
return lhs.weight > rhs.weight;
});
for (int i = 1; i <= n; ++i)
fa[i] = i;
int best = 0;
for (int i = 1; i <= m; ++i) {
int x = edges[i].from, y = edges[i].to, w = edges[i].weight;
if (findfa(x) != findfa(y)) {
union_set(x, y);
best += w;
add_edge(x, y, w);
add_edge(y, x, w);
use[i] = true;
}
}
return best;
}
int f[maxn][maxn];
void dfs(int x, int fa, int s) {
for (int i = head[x]; i; i = T[i].next) {
int v = T[i].to, w = T[i].wei;
if (v != fa) {
f[s][v] = std::min(f[s][x], w);
dfs(v, x, s);
}
}
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i)
scanf("%d", firmness + i);
for (int i = 1; i <= m; ++i) {
scanf("%d%d", &edges[i].from, &edges[i].to);
edges[i].weight = firmness[edges[i].from] + firmness[edges[i].to];
}
int best = kruskal();
for (int i = 1; i <= n; ++i) {
f[i][i] = INT_MAX;
dfs(i, 0, i);
}
int second = 0;
for (int i = 1; i <= m; ++i)
if (!use[i]) {
int cur = best + edges[i].weight - f[edges[i].from][edges[i].to];
if (cur > second)
second = cur;
}
printf("%.1lf\n", (best + second) / 2.0);
return 0;
}
简单提一下其它几种做法的思想。前面讲过ST表,它可以解决一个序列上的区间最值查询(或者类似的问题,例如之前的区间 gcd \gcd gcd查询)问题,能做到 O ( 1 ) O(1) O(1)的单次查询。现在面对树上路径最值查询问题,我们有一种称为“轻重链剖分”的算法,可以将树划分成若干条链,把一次路径查询转化为 O ( log n ) O(\log n) O(logn)次链查询,而链可以被视为区间,那么就能用ST表处理了。如果将ST表改成线段树,可以将预处理的时间复杂度由 O ( n log n ) O(n\log n) O(nlogn)降为 O ( n ) O(n) O(n),但会使得单次查询的时间复杂度变成 O ( log 2 n ) O(\log^2n) O(log2n),因为线段树上查询本身是 O ( log n ) O(\log n) O(logn)。
不过轻重链剖分在这里有点大材小用,写起来也麻烦。这里说一种更值得一提的思想,称为“倍增”,它和ST表类似。我们设
f
a
(
x
,
i
)
fa(x,i)
fa(x,i)表示从
x
x
x往上跳
2
i
2^i
2i条边能跳到的结点,这类似于一个动态规划,我们可以这样转移:
f
a
(
x
,
i
)
=
f
a
(
f
a
(
x
,
i
−
1
)
,
i
−
1
)
,
fa(x,i)=fa(fa(x,i-1),i-1),
fa(x,i)=fa(fa(x,i−1),i−1),
即,先从
x
x
x往上跳
2
i
−
1
2^{i-1}
2i−1条边,再继续跳
2
i
−
1
2^{i-1}
2i−1条边。注意,
i
i
i的取值只有
log
2
n
\log_2n
log2n种,因为树高不可能超过
n
n
n。这样一来,我们用一次dfs就可以在
O
(
n
log
n
)
O(n\log n)
O(nlogn)的时间里预处理出所有的
f
a
fa
fa值。在此基础上,令
m
n
(
x
,
i
)
mn(x,i)
mn(x,i)表示从
x
x
x开始往上的
2
i
2^i
2i条边中边权最小值是多少,类似地有如下转移
m
n
(
x
,
i
)
=
min
{
m
n
(
x
,
i
−
1
)
,
m
n
(
f
a
(
x
,
i
−
1
)
,
i
−
1
)
}
.
mn(x,i)=\min\{mn(x,i-1),mn(fa(x,i-1),i-1)\}.
mn(x,i)=min{mn(x,i−1),mn(fa(x,i−1),i−1)}.
它可以在计算
f
a
fa
fa的同时计算出来。现在假设我们要查询从
u
u
u到
v
v
v的路径边权最小值,假设
x
x
x的深度较大,我们先从
x
x
x开始跳到与
y
y
y深度相等的位置,但不是一步一步地跳。我们知道
d
e
p
t
h
(
x
)
−
d
e
p
t
h
(
y
)
depth(x)-depth(y)
depth(x)−depth(y)这个数有唯一的二进制表示,如果这个数用二进制表示时第
i
i
i位是
1
1
1(从低到高分别为第
0
,
1
,
⋯
0,1,\cdots
0,1,⋯位),我们就往上跳
2
i
2^i
2i步。这样一来,至多跳
log
2
n
\log_2n
log2n步就能跳到与
y
y
y等深,因为这个数的二进制位数至多是
log
2
n
\log_2n
log2n。跳到
x
x
x与
y
y
y等深之后,再继续从两个位置一起往上跳,可以从大到小枚举
i
i
i,只要
f
a
(
x
,
i
)
≠
f
a
(
y
,
i
)
fa(x,i)\neq fa(y,i)
fa(x,i)=fa(y,i)就说明往上跳
2
i
2^i
2i不会碰到一起,就可以往上跳,直到两个点碰到一起为止,这个点称为最近公共祖先(LCA)。在上述过程中,一边跳一边统计出答案,时间复杂度
O
(
log
n
)
O(\log n)
O(logn)。
#include <algorithm>
#include <climits>
#include <cstdio>
#include <functional>
#include <iterator>
namespace gkxx {
template <typename ForwardIterator, typename Less>
void __inplace_merge(
ForwardIterator begin, ForwardIterator mid, ForwardIterator end,
typename std::iterator_traits<ForwardIterator>::difference_type dist,
Less less) {
ForwardIterator i = begin, j = mid;
using value_type = typename std::iterator_traits<ForwardIterator>::value_type;
value_type *tmp = new value_type[dist](), *k = tmp;
while (i != mid && j != end) {
if (less(*i, *j))
*k++ = *i++;
else
*k++ = *j++;
}
while (i != mid)
*k++ = *i++;
while (j != end)
*k++ = *j++;
k = tmp;
while (begin != end)
*begin++ = *k++;
delete[] tmp;
}
template <typename ForwardIterator, typename Less>
void merge_sort(ForwardIterator begin, ForwardIterator end, Less less) {
auto dist = std::distance(begin, end);
if (dist <= 1)
return;
ForwardIterator mid = std::next(begin, dist / 2);
merge_sort(begin, mid, less);
merge_sort(mid, end, less);
__inplace_merge(begin, mid, end, dist, less);
}
template <typename ForwardIterator>
inline void merge_sort(ForwardIterator begin, ForwardIterator end) {
merge_sort(begin, end, std::less<void>());
}
} // namespace gkxx
constexpr int maxn = 1007, maxm = 1e5 + 7;
struct Edge {
int from, to, weight;
};
Edge edges[maxm];
bool use[maxm];
int n, m;
int firmness[maxn];
int par[maxn], size[maxn];
int findfa(int x) {
return par[x] == x ? x : (par[x] = findfa(par[x]));
}
inline void union_set(int x, int y) {
x = par[x];
y = par[y];
if (x == y)
return;
if (size[x] < size[y]) {
par[x] = y;
size[y] += size[x];
} else {
par[y] = x;
size[x] += size[y];
}
}
struct Node {
int to, wei;
int next;
};
Node T[maxn * 2];
int total;
int head[maxn];
inline void add_edge(int x, int y, int w) {
T[++total].to = y;
T[total].wei = w;
T[total].next = head[x];
head[x] = total;
}
int kruskal() {
gkxx::merge_sort(edges + 1, edges + m + 1,
[](const Edge &lhs, const Edge &rhs) -> bool {
return lhs.weight > rhs.weight;
});
for (int i = 1; i <= n; ++i)
par[i] = i;
int best = 0;
for (int i = 1; i <= m; ++i) {
int x = edges[i].from, y = edges[i].to, w = edges[i].weight;
if (findfa(x) != findfa(y)) {
union_set(x, y);
best += w;
add_edge(x, y, w);
add_edge(y, x, w);
use[i] = true;
}
}
return best;
}
int fa[maxn][22], mn[maxn][22], dep[maxn];
void dfs(int x) {
dep[x] = dep[fa[x][0]] + 1;
for (int i = 1; i <= 20; ++i) {
fa[x][i] = fa[fa[x][i - 1]][i - 1];
mn[x][i] = std::min(mn[x][i - 1], mn[fa[x][i - 1]][i - 1]);
}
for (int i = head[x]; i; i = T[i].next) {
int v = T[i].to, w = T[i].wei;
if (v != fa[x][0]) {
fa[v][0] = x;
mn[v][0] = w;
dfs(v);
}
}
}
int query(int x, int y) {
int ans = INT_MAX;
if (dep[x] < dep[y])
std::swap(x, y);
// 先跳到深度相等的位置
for (int i = 20; i >= 0; --i)
if (dep[fa[x][i]] >= dep[y]) {
if (mn[x][i] < ans)
ans = mn[x][i];
x = fa[x][i];
}
if (x == y)
return ans;
// 再一起往上跳
for (int i = 20; i >= 0; --i)
if (fa[x][i] != fa[y][i]) {
if (mn[x][i] < ans)
ans = mn[x][i];
if (mn[y][i] < ans)
ans = mn[y][i];
x = fa[x][i];
y = fa[y][i];
}
return std::min({ans, mn[x][0], mn[y][0]});
}
int main() {
scanf("%d%d", &n, &m);
for (int i = 1; i <= n; ++i)
scanf("%d", firmness + i);
for (int i = 1; i <= m; ++i) {
scanf("%d%d", &edges[i].from, &edges[i].to);
edges[i].weight = firmness[edges[i].from] + firmness[edges[i].to];
}
int best = kruskal();
dfs(1);
int second = 0;
for (int i = 1; i <= m; ++i)
if (!use[i]) {
int cur = best + edges[i].weight - query(edges[i].from, edges[i].to);
if (cur > second)
second = cur;
}
printf("%.1lf\n", (best + second) / 2.0);
return 0;
}
什么你问我Link-Cut-Tree?这个你问胡锦添更好,因为我们的Link-Cut-Tree都是看他的博客学的orzzz…
3003
题意:维护一个集合,支持:
- 插入一个数
- 删除一个数
- 查询某个数的排名
- 查询第 k k k小值
先解释一下这题的输入方式:为了考查大家的数据结构实现的效率究竟怎样,本题将数据规模卡到了极致的 1 0 6 10^6 106,并且稍稍开大了一点时限。在这种情况下,输入耗费的时间可能是不可忽略的。为了防止输入耗费大量的时间,本题不是将测试数据输入进来,而是把数据生成器及其调用方式给你,输入的是数据生成器的参数。最后也为了避免输出耗费大量时间,输出的是把每一次查询的答案xor起来的结果。
这是一道模板题,做法主要分为两种:平衡树派和非平衡树派。由于做法实在太多,这篇题解以拓宽大家的视野为主,代码实现就象征性地给两个,想进一步学习请移步洛谷这道题的题解区。
平衡树
平衡树,课上学过的有AVL和RB-Tree,不知道有没有勇士写了RB-Tree,我个人感觉RB-Tree基本上是几种平衡树中最难写的,当然了它的效率也相当高,STL的ordered associative containers,例如std::set
, std::map
,就是用RB-Tree实现的。AVL不算难写,能想到的比较麻烦的地方就是删除时需要旋转多次。但其实竞赛中用得最多的平衡树既不是AVL也不是RB-Tree,而是下面三位:Splay, Treap, Scapegoat。
Splay是基于旋转的。它的思想是:在访问了某个结点(插入、查询等)之后,就立刻把它转到根,于是它天生还具有一种“缓存”的效果。它这种可以将任意结点转到根的特点使得它非常灵活,可以支持许多厉害的操作。但它并不是保证树高始终是 O ( log n ) O(\log n) O(logn)的,它的时间复杂度分析比较复杂,是所谓的“均摊 O ( log n ) O(\log n) O(logn)”。
Treap=Tree+Heap,它的思想是:给每个结点额外赋予一个所谓的“优先级”(一个随机生成的数),使优先级之间满足堆性质,即父亲的优先级总是大于孩子的。当插入了某个结点使得堆性质被破坏时,就通过旋转来调整。这是一种基于旋转的实现方式,不过有一位叫做范浩强的巨佬提出了一种所谓的“无旋Treap”,它基于分裂与合并。它可以对于任何给定的数 x x x,在 O ( log n ) O(\log n) O(logn)的时间里将这棵Treap分裂成两棵,一棵全都小于等于 x x x,另一棵全都大于 x x x;当然也可以分裂成一棵恰有前 k k k小的结点,另一棵包含剩下的结点;它还可以在 O ( log n ) O(\log n) O(logn)的时间里将两棵Treap合并成一棵,只要保证其中一棵的所有结点都小于等于另一棵的。这样一来,其它的操作就非常简单,比如插入一个数 x x x,就先按 x x x分裂成两棵树,同时又将 x x x视为一棵仅有一个结点的树,将三棵树合并即可。
Scapegoat,替罪羊树,非常暴力:它有一种方式衡量某棵子树的“平衡程度”,如果发现某棵子树不太平衡了,就直接将这棵子树拍扁,重构成一棵完美的平衡树。
在实现中,有一些技巧是共通的。比如如何处理同一个数被插入多次的情况呢,我们可以在每个结点上记录出现次数,重复的插入就直接递增对应的出现次数,不用创建新的结点。删除结点的时候就递减出现次数,如果递减到零了就——递减到零了又有什么关系呢?不就是出现次数是 0 0 0嘛,其实根本没必要把它删掉,因为我们知道像AVL上删除一个结点是比较麻烦的,需要多次旋转。你把它留在那里,整棵树的结点数量的数量级没变,效率上不会有多大的差异。计算排名或 k k k小值的时候要小心一点,你得算某棵子树的“出现次数”之和,而不是结点数量。
假如你已经写好了一个查排名的函数,那么怎么查 k k k小值呢?一种偷懒的做法是直接二分:我猜 k k k小值是某个数,然后去查这个数的排名,来决定我猜大了还是猜小了。这个算法的时间复杂度是 O ( log n log A ) O(\log n\log A) O(lognlogA),其中 A A A是值域最大值。当然,正常的做法是在平衡树上从根节点往下走,每次看左子树的“出现次数”之和与 k k k的大小关系,来决定 k k k小值在左边还是在右边。
放一个无旋Treap的代码:
#include <iostream>
#include <tuple>
constexpr int maxn = 1e6 + 7;
struct Treap {
int ch[maxn][2], sz[maxn], val[maxn], pri[maxn];
int total, root;
int newNode(int v = 0) {
int x = ++total;
val[x] = v;
sz[x] = 1;
pri[x] = rand();
ch[x][0] = ch[x][1] = 0;
return x;
}
void update(int x) {
sz[x] = sz[ch[x][0]] + sz[ch[x][1]] + 1;
}
void split(int x, int v, int &l, int &r) {
if (!x) {
l = r = 0;
} else {
if (val[x] <= v) {
l = x;
split(ch[x][1], v, ch[x][1], r);
} else {
r = x;
split(ch[x][0], v, l, ch[x][0]);
}
update(x);
}
}
int merge(int x, int y) {
if (x == 0 || y == 0)
return x + y;
if (pri[x] < pri[y]) {
ch[y][0] = merge(x, ch[y][0]);
update(y);
return y;
} else {
ch[x][1] = merge(ch[x][1], y);
update(x);
return x;
}
}
void insert(int v) {
int x, y;
split(root, v, x, y);
root = merge(merge(x, newNode(v)), y);
}
void remove(int v) {
int x, y, z;
split(root, v - 1, x, z);
split(z, v, y, z);
y = merge(ch[y][0], ch[y][1]);
root = merge(merge(x, y), z);
}
int rank(int v) {
int x, y;
split(root, v - 1, x, y);
int res = sz[x];
root = merge(x, y);
return res;
}
int kth(int k) {
++k;
int x = root;
while (sz[ch[x][0]] + 1 != k) {
if (k <= sz[ch[x][0]])
x = ch[x][0];
else
k -= sz[ch[x][0]] + 1, x = ch[x][1];
}
return val[x];
}
int size() {
return sz[root];
}
};
int A, B, C, lfsr;
double P[4][4];
int lfsr_generator() {
auto ret = lfsr;
return (lfsr ^= lfsr << 13, lfsr ^= lfsr >> 17, lfsr ^= lfsr << 5, ret);
}
std::tuple<int, int> command() {
auto imm = lfsr_generator();
static int state = 0;
auto p = double(lfsr_generator() & 0x7fffffff) / INT32_MAX;
for (int i = 0; i < 4; i++)
if ((p -= P[state % 4][i]) < 0) {
state += 4 - state % 4 + i;
break;
}
return std::tuple<int, int>(state % 4,
(imm * A + state * B + C) & 0x7fffffff);
}
Treap t;
int main() {
int m;
std::ios::sync_with_stdio(false);
std::cin >> m >> lfsr >> A >> B >> C;
for (int i = 0; i < 4; i++)
for (int j = 0; j < 4; j++)
std::cin >> P[i][j];
int tans = 0;
for (int i = 1; i <= m; i++) {
int op, imm;
std::tie(op, imm) = command();
if (op == 0)
t.insert(imm);
if (op == 1)
t.remove(t.kth(imm % t.size()));
if (op == 2)
tans ^= t.rank(imm);
if (op == 3)
tans ^= t.kth(imm % t.size());
}
std::cout << tans << "\n";
return 0;
}
在介绍非平衡树派之前,先说一下什么是在线与离线。这种问题通常有两类做法,一类是先把所有的询问和操作都读进来,相当于预先得知了接下来要干些什么,这时你可以做一些预处理,然后再正式开始处理询问和操作,这种算法称为“离线算法”。另一类是读一个询问就立刻回答一个结果,读一个操作就立刻做这个操作,这称为“在线算法”。有些题目为了增加难度会想办法强制在线,比如让输入的每一个询问或操作都成为一种密码,而解密所需要的关键参数是上一次询问的答案。
但本题是允许离线的,也就是说我们是可以预先知道哪些数将会被插入的。那还写啥平衡树啊!直接把所有可能会被插入的数存下来排序,去除重复的,然后建成一棵perfect的binary search tree,一开始所有结点的“出现次数”都是零。什么旋转重构都不用干,树高永远是 O ( log m ) O(\log m) O(logm)。
非平衡树
非平衡树主要有三种:线段树,树状数组,01-Trie。01-Trie的Trie就是许多人在pa1里写的那个Trie,整数在二进制下可以看做一个字符集为 { 0 , 1 } \{0,1\} {0,1}的字符串,那么它就能用Trie树来维护了。不过它本质上和线段树没有区别,只是视角不同,我们不多讲。
线段树和树状数组都基于以下想法:假设所有被插入的数都是
[
0
,
N
]
[0,N]
[0,N]中的整数。令
f
(
x
)
f(x)
f(x)表示
x
x
x在集合中出现了多少次,
x
∈
[
0
,
N
]
∩
Z
x\in[0,N]\cap\Z
x∈[0,N]∩Z。那么插入
x
x
x就是递增
f
(
x
)
f(x)
f(x),删除
x
x
x就是递减
f
(
x
)
f(x)
f(x),查询有多少个数比
x
x
x小就是求和
∑
i
=
0
x
−
1
f
(
i
)
\sum_{i=0}^{x-1}f(i)
∑i=0x−1f(i),至于查询
k
k
k小值… 一会儿再说,大不了就二分,也能解决。像这样的在
f
f
f序列上的单点修改、区间求和问题,线段树和树状数组都能非常直接地搞定,但这里有个问题:
N
N
N可能很大。线段树和树状数组直接实现都需要
O
(
N
)
O(N)
O(N)的空间,如果
N
N
N有
1
0
9
10^9
109或者INT_MAX
就不灵了。
不过,虽然
N
N
N可能很大,但实际上有用的数肯定不会多于
m
m
m个,也就是说这些数在这个值域上的分布比较稀疏;并且我们其实只关注这些数之间的相对大小,并不关心它具体是多少。我们可以采用一种离线的做法,先将所有涉及到的数存入一个数组tmp
,排序,去除重复元素,这时每个数在tmp
数组中都有一个独一无二的下标
1
,
2
,
⋯
,
n
1,2,\cdots,n
1,2,⋯,n。这样我们就建立了从原来的每个数到
[
1
,
n
]
∩
Z
[1,n]\cap\Z
[1,n]∩Z的一一映射,并且
n
⩽
m
⩽
1
0
6
n\leqslant m\leqslant 10^6
n⩽m⩽106是一个不大的范围。假设现在要插入数
x
x
x,我们在tmp
数组中二分找到它的下标
x
′
x^\prime
x′,然后实际递增的是
f
(
x
′
)
f(x^\prime)
f(x′),而非
f
(
x
)
f(x)
f(x)。这个算法称为离散化。注意,查找下标一定要二分查找,别傻呵呵地顺序遍历,不然就
O
(
n
)
O(n)
O(n)了,你的树就白写了。
刚才还没说的 k k k小值问题,其实可以直接在线段树上二分,可以做到 O ( log n ) O(\log n) O(logn)。细节就不多说了。
线段树和树状数组的具体原理和实现,大家可以自己上网查,我就不讲了。下面放一个线段树的代码。
#include <algorithm>
#include <climits>
#include <iostream>
#include <tuple>
namespace gkxx {
template <typename ForwardIterator>
void __inplace_merge(
ForwardIterator begin, ForwardIterator mid, ForwardIterator end,
typename std::iterator_traits<ForwardIterator>::difference_type dist) {
ForwardIterator i = begin, j = mid;
using value_type = typename std::iterator_traits<ForwardIterator>::value_type;
value_type *tmp = new value_type[dist](), *k = tmp;
while (i != mid && j != end) {
if (std::less<value_type>()(*i, *j))
*k++ = *i++;
else
*k++ = *j++;
}
while (i != mid)
*k++ = *i++;
while (j != end)
*k++ = *j++;
k = tmp;
while (begin != end)
*begin++ = *k++;
delete[] tmp;
}
template <typename ForwardIterator>
void merge_sort(ForwardIterator begin, ForwardIterator end) {
auto dist = std::distance(begin, end);
if (dist <= 1)
return;
ForwardIterator mid = std::next(begin, dist / 2);
merge_sort(begin, mid);
merge_sort(mid, end);
__inplace_merge(begin, mid, end, dist);
}
template <typename ForwardIterator>
ForwardIterator unique(ForwardIterator begin, ForwardIterator end) {
if (begin == end)
return end;
ForwardIterator result = begin;
while (++begin != end) {
if (!(*result == *begin) && ++result != begin) {
*result = std::move_if_noexcept(*begin);
}
}
return ++result;
}
template <typename RandomAccessIterator>
RandomAccessIterator lower_bound(
RandomAccessIterator begin, RandomAccessIterator end,
typename std::iterator_traits<RandomAccessIterator>::value_type const
&value) {
auto dist = std::distance(begin, end);
while (dist > 1) {
auto mid = begin + dist / 2;
if (value < *mid)
end = mid;
else
begin = mid;
dist = std::distance(begin, end);
}
return begin;
}
} // namespace gkxx
constexpr int maxn = 1e6 + 7;
struct Command {
int op, imm;
explicit Command(int o = 0, int i = 0) : op(o), imm(i) {}
};
Command cmd[maxn];
int tmp[maxn], tot;
int sum[maxn << 1];
int all, M;
inline void build() {
M = 1;
while (M < all + 2)
M <<= 1;
}
inline void add(int x, int val) {
for (sum[x += M] += val, x >>= 1; x; x >>= 1)
sum[x] += val;
}
inline int less_than(int x) {
int ans = 0;
for (int s = M, t = x + M; s ^ t ^ 1; s >>= 1, t >>= 1) {
if (~s & 1)
ans += sum[s ^ 1];
if (t & 1)
ans += sum[t ^ 1];
}
return ans;
}
inline int query_kth(int k) {
int curr = 1;
while (curr < M) {
if (k <= sum[curr << 1])
curr <<= 1;
else {
k -= sum[curr << 1];
(curr <<= 1) |= 1;
}
}
return curr - M;
}
inline void insert(int v) {
v = gkxx::lower_bound(tmp + 1, tmp + all + 1, v) - tmp;
add(v, 1);
}
inline void remove(int v) {
v = gkxx::lower_bound(tmp + 1, tmp + all + 1, v) - tmp;
add(v, -1);
}
inline int kth(int k) {
return tmp[query_kth(k + 1)];
}
inline int rank(int v) {
v = gkxx::lower_bound(tmp + 1, tmp + all + 1, v) - tmp;
return less_than(v);
}
inline int size() {
return sum[1];
}
int A, B, C, lfsr;
double P[4][4];
int lfsr_generator() {
auto ret = lfsr;
return (lfsr ^= lfsr << 13, lfsr ^= lfsr >> 17, lfsr ^= lfsr << 5, ret);
}
std::tuple<int, int> command() {
auto imm = lfsr_generator();
static int state = 0;
auto p = double(lfsr_generator() & 0x7fffffff) / INT32_MAX;
for (int i = 0; i < 4; i++)
if ((p -= P[state % 4][i]) < 0) {
state += 4 - state % 4 + i;
break;
}
return std::tuple<int, int>(state % 4,
(imm * A + state * B + C) & 0x7fffffff);
}
int main() {
int m;
std::cin >> m >> lfsr >> A >> B >> C;
for (int i = 0; i < 4; i++)
for (int j = 0; j < 4; j++)
std::cin >> P[i][j];
int tans = 0;
for (int i = 1; i <= m; i++) {
int op, imm;
std::tie(op, imm) = command();
cmd[i].op = op;
cmd[i].imm = imm;
if (op == 0 || op == 2)
tmp[++tot] = imm;
}
gkxx::merge_sort(tmp + 1, tmp + tot + 1);
all = gkxx::unique(tmp + 1, tmp + tot + 1) - (tmp + 1);
build();
for (int i = 1; i <= m; ++i) {
int op = cmd[i].op, imm = cmd[i].imm;
if (op == 0)
insert(imm);
if (op == 1)
remove(kth(imm % size()));
if (op == 2)
tans ^= rank(imm);
if (op == 3)
tans ^= kth(imm % size());
}
std::cout << tans << std::endl;
return 0;
}
如果你学了线段树,看这份代码可能还是会一脸懵逼,因为这是一棵zkw线段树,它由一位名为张昆玮的巨佬发明,没错就是那位之前在豆瓣上征婚被喷“普信男”的。他的这个线段树常数小,跑起来快,写起来还非常短,有兴趣的同学可以去搜一搜他的著名课件《统计的力量》。(其实如果你不打竞赛也没必要看)
PA4
4001
题意:有一只青蛙在一个深度为 n n n米的井底,它需要跳到深度为 0 0 0的地面。如果它在深度为 i i i米处起跳,可以跳到深度为 j ∈ [ i − a i , i ] ∩ Z j\in[i-a_i,i]\cap\Z j∈[i−ai,i]∩Z米处,但紧接着它就会下滑到深度为 j + b j j+b_j j+bj米的位置。问至少跳多少步能跳到地面。 n ⩽ 3 × 1 0 5 n\leqslant 3\times 10^5 n⩽3×105,保证 0 ⩽ a i ⩽ i , 0 ⩽ b i ⩽ n − i 0\leqslant a_i\leqslant i,0\leqslant b_i\leqslant n-i 0⩽ai⩽i,0⩽bi⩽n−i。
算法1:线段树优化建图+最短路
首先,有一个最无脑的想法是将这个过程视为一张图,跑最短路。具体来说,我们为每一个深度 i i i建两个点 u i u_i ui和 v i v_i vi,对于所有 j ∈ [ i − a i , i ] ∩ Z j\in[i-a_i,i]\cap\Z j∈[i−ai,i]∩Z,从 u i u_i ui向 v j v_j vj连边,边权为 1 1 1,表示从 i i i能跳到 j j j。同时对每个 i i i,从 v i v_i vi向 u i + b i u_{i+b_i} ui+bi连边,边权为 0 0 0,表示在 i i i这个位置会立刻滑落到 i + b i i+b_i i+bi这个位置。注意,为每个深度建两个点是有必要的,因为我们必须把向上跳和向下滑分开,不允许跳完不滑就继续跳,也不允许连续下滑。然后我们在这张图上求出 u n u_n un到 v 0 v_0 v0的最短路就行了。
但这样做是有问题的,因为表示“跳”的那些边是每一个点向一个区间连边,如果暴力地连出所有边的话,边数会达到 O ( n 2 ) O(n^2) O(n2),无论是时间还是空间都不能接受。正所谓“智商不够,数据结构来凑”,这个时候我们又可以请线段树出马了:我们建一棵线段树,根节点表示的区间是 [ 0 , n ] [0,n] [0,n],然后在树上从上往下连边,边权都是 0 0 0,并且叶子其实就是 v 0 , ⋯ , v n v_0,\cdots,v_n v0,⋯,vn。对于每个 i i i,要从 u i u_i ui向区间 [ i − a i , i ] [i-a_i,i] [i−ai,i]连边,就将这个区间拆成线段树上的 O ( log n ) O(\log n) O(logn)个区间的不交并,对应的就是线段树上的 O ( log n ) O(\log n) O(logn)个结点,从 u i u_i ui向线段树上的这些结点连边,边权为 1 1 1。于是这些向区间连的边由 O ( n ) O(n) O(n)条被优化至 O ( log n ) O(\log n) O(logn)条。因为线段树的结点数是 O ( n ) O(n) O(n),线段树上连的边自然是 O ( n ) O(n) O(n)条。这样总边数就是 O ( n log n ) O(n\log n) O(nlogn)条,结点数也是 O ( n ) O(n) O(n),在这张图上跑最短路就可以了。其实也不需要写Dijkstra最短路,因为边权都是 0 0 0或 1 1 1,你可以把bfs魔改一下,变成所谓的“0-1 bfs”,或者直接写个SPFA其实也差不多。
代码里用了我自己造的gkxx::Vector
和gkxx::Queue
,就不粘了,都是按标准库规范造的,粘上来有一千多行…
#include "../../tools/Vector.hpp"
#include "../../tools/Queue.hpp"
#include <cctype>
#include <cstdio>
constexpr int maxn = 3e5 + 7;
constexpr int inf = 1e9;
gkxx::Vector<int> G[maxn * 3];
struct Node {
int lc, rc;
};
Node sgt[maxn * 3];
int tot, root;
int leaf[maxn];
int dist[maxn * 3];
int n;
inline void add_edge(int x, int y) {
G[x].emplace_back(y);
}
void build(int &o, int l, int r) {
o = ++tot;
if (l == r) {
leaf[l] = o;
return;
}
int mid = (l + r) >> 1;
build(sgt[o].lc, l, mid);
build(sgt[o].rc, mid + 1, r);
add_edge(o, sgt[o].lc);
add_edge(o, sgt[o].rc);
}
void add_edge(int o, int l, int r, int from, int tol, int tor) {
if (tol <= l && r <= tor) {
add_edge(from, o);
return;
}
int mid = (l + r) >> 1;
if (tol <= mid)
add_edge(sgt[o].lc, l, mid, from, tol, tor);
if (tor > mid)
add_edge(sgt[o].rc, mid + 1, r, from, tol, tor);
}
gkxx::Queue<int> q;
int main() {
scanf("%d", &n);
tot = n;
build(root, 0, n);
for (int i = 1; i <= n; ++i) {
int a;
scanf("%d", &a);
add_edge(root, 0, n, i, i - a, i);
}
for (int i = 1; i <= n; ++i) {
int b;
scanf("%d", &b);
add_edge(leaf[i], i + b);
}
for (int i = 0; i <= tot; ++i)
dist[i] = inf;
dist[n] = 0;
q.push(n);
while (!q.empty()) {
int x = q.front();
q.pop();
int w = x <= n ? 1 : 0;
for (auto v : G[x]) {
if (dist[v] > dist[x] + w) {
dist[v] = dist[x] + w;
q.push(v);
}
}
}
printf("%d\n", dist[leaf[0]] < inf ? dist[leaf[0]] : -1);
return 0;
}
算法2:DP
设
f
(
i
)
f(i)
f(i)表示跳到深度为
i
i
i的位置(还没往下滑)至少需要多少步。假设上一步是跳到了深度为
j
j
j的位置,又滑到了
j
+
b
j
j+b_j
j+bj的位置,那么要从
j
+
b
j
j+b_j
j+bj起跳跳到
i
i
i,意味着
i
∈
[
j
+
b
j
−
a
j
+
b
j
,
j
+
b
j
]
i\in\left[j+b_j-a_{j+b_j},j+b_j\right]
i∈[j+bj−aj+bj,j+bj]。于是我们有一个显然的转移方程
f
(
i
)
=
min
{
f
(
j
)
+
1
:
j
+
b
j
−
a
j
+
b
j
⩽
i
⩽
j
+
b
j
}
.
f(i)=\min\left\{f(j)+1:j+b_j-a_{j+b_j}\leqslant i\leqslant j+b_j\right\}.
f(i)=min{f(j)+1:j+bj−aj+bj⩽i⩽j+bj}.
但直接这么搞是没法做的,因为满足条件的
j
j
j散落在各个位置,有可能大于
i
i
i,也有可能小于
i
i
i,但是你在求
f
(
i
)
f(i)
f(i)的时候必须保证这些
f
(
j
)
f(j)
f(j)已经计算过了,那你该以什么顺序来计算这些
f
(
i
)
f(i)
f(i)?这是没法做的。当然,你可以将它视为一种转移有环的DP,针对这样的DP有一种策略是使用一个图论算法来求解,例如bfs或最短路,这样你就被带到算法1去了。
但是我们会发现:有用的
j
j
j一定大于
i
i
i,也就是说跳到比
i
i
i高的位置滑下来再跳到
i
i
i的情况不会更好。为什么?因为你最初是从最底层
n
n
n开始跳的,如果你是从比
i
i
i高的某个位置
j
j
j滑到了
j
+
b
j
j+b_j
j+bj,再从
j
+
b
j
j+b_j
j+bj跳到
i
i
i,那么之前一定存在某一步,你从比
i
i
i低的某个位置
k
k
k跳到了比
i
i
i高的位置,而在这一步你就可以选择直接跳到
i
i
i。因此我们得到
j
>
i
j>i
j>i,或者说
i
⩽
j
−
1
i\leqslant j-1
i⩽j−1,那么
i
⩽
min
{
j
−
1
,
j
+
b
j
}
=
j
−
1
i\leqslant\min\{j-1,j+b_j\}=j-1
i⩽min{j−1,j+bj}=j−1。于是转移方程变成了
f
(
i
)
=
min
{
f
(
j
)
+
1
:
j
+
b
j
−
a
j
+
b
j
⩽
i
⩽
j
−
1
}
.
f(i)=\min\left\{f(j)+1:j+b_j-a_{j+b_j}\leqslant i\leqslant j-1\right\}.
f(i)=min{f(j)+1:j+bj−aj+bj⩽i⩽j−1}.
我们从大到小枚举
i
i
i来计算这些
f
(
i
)
f(i)
f(i),转移就没有环了。转移的时间复杂度
O
(
n
)
O(n)
O(n),总复杂度
O
(
n
2
)
O(n^2)
O(n2)。
f[n] = 0;
for (int i = n - 1; i >= 0; --i) {
f[i] = inf;
for (int j = n; j > i; --j)
if (j + b[j] - a[j + b[j]] <= i && f[j] + 1 < f[i])
f[i] = f[j] + 1;
}
很可惜, O ( n 2 ) O(n^2) O(n2)过不了。但其实你离成功只差一步了,因为你已经注意到一个重要的性质:对任何一个位置 i i i来说,跳到比 i i i高的位置滑下来再跳到 i i i是没有意义的。
考虑任意两个位置 i 1 i_1 i1和 i 2 i_2 i2,假设 i 1 > i 2 i_1>i_2 i1>i2,即 i 1 i_1 i1更低一些,那么必然有 f ( i 1 ) ⩽ f ( i 2 ) f(i_1)\leqslant f(i_2) f(i1)⩽f(i2),即跳得越高步数越多。因为为了跳到更高的位置 i 2 i_2 i2,你必然要在某一步从比 i 1 i_1 i1低的位置 k k k跳到比 i 1 i_1 i1高的某个位置,而在这一步你就可以选择直接跳到 i 1 i_1 i1了。所以我们转移的时候完全没必要枚举 j j j,只要直接找到满足 j + b j − a j + b j ⩽ i j+b_j-a_{j+b_j}\leqslant i j+bj−aj+bj⩽i且 j > i j>i j>i的最大的 j j j就行了。
但是直接从 i i i找到最大的符合条件的 j j j是比较困难的,我们可以换个方向,先从大到小枚举每一个 j j j,将 f ( j ) f(j) f(j)当做已知,再枚举那些 j j j能转移到的 i i i,令 f ( i ) = f ( j ) + 1 f(i)=f(j)+1 f(i)=f(j)+1。注意,由前面的性质容易知道,在有解的情况下,已经计算过的状态总是从最底层往上连续的若干层,即总是存在一个 m m m满足 f ( n ) , f ( n − 1 ) , ⋯ , f ( m ) f(n),f(n-1),\cdots,f(m) f(n),f(n−1),⋯,f(m)都已经算过了。所以我们枚举 i i i的时候,没有必要考虑那些大于等于 m m m的 i i i。具体地,对每个 j j j来说,满足 i ∈ [ j + b j − a j + b j , j + b j ] i\in[j+b_j-a_{j+b_j},j+b_j] i∈[j+bj−aj+bj,j+bj]的所有 i i i都能转移到,而我们只要考虑满足 i ∈ [ j + b j − a j + b j , min { j + b j , m − 1 } ] i\in[j+b_j-a_{j+b_j},\min\{j+b_j,m-1\}] i∈[j+bj−aj+bj,min{j+bj,m−1}]的那些 i i i。并且在每次计算完之后都要记得更新 m m m。代码如下:
#include <algorithm>
#include <cstdio>
constexpr int maxn = 3e5 + 10;
constexpr int inf = 2e9;
int a[maxn], b[maxn], n;
int f[maxn];
int main() {
freopen("data.in", "r", stdin);
scanf("%d", &n);
for (int i = 1; i <= n; ++i)
scanf("%d", a + i);
for (int i = 1; i <= n; ++i)
scanf("%d", b + i);
for (int i = 0; i < n; ++i)
f[i] = inf;
int m = n;
for (int j = n; j > 0; --j) {
for (int i = j + b[j] - a[j + b[j]]; i <= std::min(j + b[j], m - 1); ++i)
f[i] = f[j] + 1;
if (j + b[j] - a[j + b[j]] < m)
m = j + b[j] - a[j + b[j]];
}
printf("%d\n", f[0] < inf ? f[0] : -1);
return 0;
}
这个算法时间复杂度是多少?看似有个双重循环,但实际上我们保证了每个状态都只被计算一次,所以复杂度是 O ( n ) O(n) O(n)。
4002
题意:给一张无向连通图,现在从 1 1 1号点出发,你可以在已经走过的点之间随便乱走,但每次碰到一个新的结点就会把这个结点的标号记在本子上。最后你的本子上会得到一个序列,求所有可能的序列中字典序最小的那个。 n ⩽ 500 n\leqslant 500 n⩽500。
一道巨弱智的贪心题,每次在所有能走到的结点中挑编号最小的即可。直接枚举找最小编号的时间复杂度是 O ( n 3 ) O(n^3) O(n3)。但这题给 n ⩽ 500 n\leqslant 500 n⩽500说实话我是不太能李姐的,因为我们其实有 O ( ( n + m ) log n ) O((n+m)\log n) O((n+m)logn)的做法:只要把bfs的队列换成最小堆就行了。
4003
题意:TSP
直接从 1 1 1号点出发,每次都走能走的边中最短的,这样贪心复杂度是 O ( n 2 ) O(n^2) O(n2),显然这是一个错误的贪心,它给出的解能获得54分。
如果你不从 1 1 1号点出发,而是枚举一下起点,把这个算法跑 n n n遍,取最优解,就可以69-72分了。
更高分的算法就不说了,有模拟退火、蚁群、遗传等等,也有人乱搞拿了87。自己上网研究研究吧。
我乱搞拿了81就玩别的去了。有一天晚上做梦梦见这题有两个怪物拿了90分,吓醒了,起来一看发现没有。