随便写点(2)
本讲作业
例题
例1 【The Number Games】 CF-980E
例2 【Tree Shuffling】CF-1363E
例3 【删括号】 https://ac.nowcoder.com/acm/problem/21303
例4 【Company】 CF-1062E
例5 【Lightest language】 SP186
例6 【Tree】CF-468D (未考虑:字典序最小)
例7 【方差】DP讲解。
总结
复盘【删括号】和【Tree shuffling】思维流程
复盘【Tree】 CF468D 每一步如何往下思考的。
【Lightest language】 复现正确性证明。
- 思考另外一些贪心方法错误的原因(课上提到一个)。
- 如果要求 树是满 \(k\) 叉树,应该如何解决。请给出 \(\mathcal{O}(n log k)\) 算法!
作业
1【Big Bishops】 SGU-221 (区间DP 练习题)
2【圣诞树】 poj3013
3【黑白树】https://ac.nowcoder.com/acm/problem/13249
4【Teleporter】 agc004d
5【Royal Federation】SGU-216
6【宝藏 NOIP'17】 (状压dp)P3959
7 上次的【Ants in leaves】 需要自己完成证明。
例题
CF980E
trick:不会正攻时可以考虑反攻。
关键转化:注意到删点不好删,考虑选点。
首先,\(n\) 号节点是一定需要选择的。
然后观察到每个节点的权重是 $ 2^i$,而 \(2^i > \sum_{j = 1}^{j < i} 2^j\)。因此,我们考虑贪心的使得剩下的最大编号的点 \(x\) 能选就选。
可以用 树状数组 判断 \(x\) 是否能选。
/*******************************
| Author: DE_aemmprty
| Problem: The Number Games
| Contest: Luogu
| URL: https://www.luogu.com.cn/problem/CF980E
| When: 2024-09-12 21:39:50
|
| Memory: 250 MB
| Time: 3000 ms
*******************************/
#include <bits/stdc++.h>
using namespace std;
long long read() {
char c = getchar();
long long x = 0, p = 1;
while ((c < '0' || c > '9') && c != '-') c = getchar();
if (c == '-') p = -1, c = getchar();
while (c >= '0' && c <= '9')
x = (x << 1) + (x << 3) + (c ^ 48), c = getchar();
return x * p;
}
const int N = 1e6 + 7;
int n, k, dfn[N], siz[N], dep[N], tot, f[N];
vector <int> to[N];
bool vis[N];
namespace BIT {
int tr[N], n;
void init(int x) { n = x, fill(tr + 1, tr + n + 1, 0);}
int lowbit(int x) { return x & (-x);}
void update(int x, int y) {
for (; x <= n; x += lowbit(x))
tr[x] += y;
}
int query(int x) {
int res = 0;
for (; x; x -= lowbit(x))
res += tr[x];
return res;
}
}
void dfs(int u, int fa, int d) {
dfn[u] = ++ tot; f[u] = fa;
siz[u] = 1, dep[u] = d;
for (int v : to[u])
if (v != fa) {
dfs(v, u, d + 1);
siz[u] += siz[v];
}
}
void solve() {
n = read(), k = read();
for (int i = 1; i < n; i ++) {
int a = read(), b = read();
to[a].push_back(b);
to[b].push_back(a);
}
dfs(n, 0, 1);
BIT::init(n);
vis[n] = 1; int cnt = 1;
BIT::update(1, 1);
for (int i = n - 1; i >= 1 && cnt < n - k; i --) {
if (vis[i]) continue;
if (dep[i] - BIT::query(dfn[i]) + cnt <= n - k) {
int tmp = i;
while (tmp && !vis[tmp]) {
BIT::update(dfn[tmp], 1);
BIT::update(dfn[tmp] + siz[tmp], -1);
cnt ++, vis[tmp] = 1;
int x = tmp;
tmp = f[tmp];
f[x] = 0;
}
}
}
for (int i = 1; i <= n; i ++)
if (!vis[i]) cout << i << ' ';
}
signed main() {
int t = 1;
while (t --) solve();
return 0;
}
CF1363E
trick:无。
关键观察:显然,我们的策略一定是按照 \(a_i\) 从小往大去操作。
考虑最小的 \(a_i\),那么我们肯定先操作这个点 \(i\),使得 \(i\) 子树内尽量匹配。
那么以此类推,我们很容易发现只需要让 \(a_i\) 从小往大贪心即可。
又容易发现 \(b_i = c_i\) 的点是充数的,没有任何作用。
那么我们可以对每个点 \(i\),存储子树内 \(0 \rightarrow 1\) 和 \(1 \rightarrow 0\) 的个数。然后就可以快速操作了。
写代码的一个细节:虽然说是 \(a_i\) 从小往大选,但实际上你可以直接看 \(a_i\) 是否是 \(i\) 到 \(1\) 路径上所有 \(a_i\) 的最小值来判断是否需要操作 \(i\)。证明显然易见。
/*******************************
| Author: DE_aemmprty
| Problem: E. Tree Shuffling
| Contest: Codeforces - Codeforces Round 646 (Div. 2)
| URL: https://codeforces.com/problemset/problem/1363/E
| When: 2024-09-23 23:08:58
|
| Memory: 256 MB
| Time: 2000 ms
*******************************/
#include <bits/stdc++.h>
using namespace std;
long long read() {
char c = getchar();
long long x = 0, p = 1;
while ((c < '0' || c > '9') && c != '-') c = getchar();
if (c == '-') p = -1, c = getchar();
while (c >= '0' && c <= '9')
x = (x << 1) + (x << 3) + (c ^ 48), c = getchar();
return x * p;
}
const int N = 2e5 + 7;
int n;
long long a[N], b[N], c[N];
vector <int> to[N];
long long ans;
int cnt0[N], cnt1[N];
struct Node {
int p, q;
Node operator + (const Node &x) const {
return (Node) {p + x.p, q + x.q};
}
};
Node dfs(int u, int fa, long long mn) {
cnt0[u] = (b[u] == 0 && c[u] == 1);
cnt1[u] = (b[u] == 1 && c[u] == 0);
Node del = {0, 0};
for (int v : to[u]) {
if (v == fa) continue;
del = del + dfs(v, u, min(mn, a[u]));
cnt0[u] += cnt0[v];
cnt1[u] += cnt1[v];
}
if (mn > a[u]) {
int T = min(cnt0[u], cnt1[u]);
del = del + (Node) {T, T};
cnt0[u] -= T, cnt1[u] -= T;
ans += a[u] * T * 2;
}
return del;
}
void solve() {
n = read();
for (int i = 1; i <= n; i ++)
a[i] = read(), b[i] = read(), c[i] = read();
for (int i = 1, u, v; i < n; i ++) {
u = read(), v = read();
to[u].push_back(v);
to[v].push_back(u);
}
Node res = dfs(1, 0, 2e18);
if (cnt0[1] || cnt1[1]) {
cout << -1 << '\n';
} else {
cout << ans << '\n';
}
}
signed main() {
int t = 1;
while (t --) solve();
return 0;
}
牛客 21303
没账号,不做了/fn。
CF1062E
trick:区间 LCA 实际上只是两个点的 LCA。
关键知识点:会区间 \(\text{LCA}\)。
首先我们知道区间 \(\text{LCA}\) 是选择一些点中最左边的点和最右边的点的 \(\text{LCA}\)。
然后最左边和最右边的点可以用欧拉序加上 st 表解决。
笑点解析:到这里你就可以通过 DSU On Tree 解决了。但这实在太蠢了。
现在,题目要求你删掉一个点。容易发现如果删掉的点不是计算 \(\text{LCA}\) 的两点,对最终答案是没有影响的。
维护一下区间欧拉序最大,次大,最小,次小即可。
SP186
trick:遇到有关一个串是另一个串的前缀的题目时考虑字典树,从 naive 的贪心入手优化。
观察一:由于题目禁止一个字符串是另一个字符串的前缀,所以考虑字典树。
考虑 Trie。容易发现在题目条件的要求下,每个字符串的结尾一定是叶子节点。
转化题目:构造 \(n\) 个叶子节点的 \(k\) 叉树,使得所有叶子到根的边权之和最小。
关键转换:从上往下进行贪心,而不是从下往上。
发现从叶子一个个向上合并不太容易做,考虑从根向下去贪心。
容易发现一个非常 naive 的贪心:每次选择权值最小的一个叶子节点,然后给这个叶子节点加 \(k\) 个儿子。
这样很明显是错的。当一个字母的权值极大时,我们不如不选这种字母作为边,也就是删除这个边。
那什么贪心方法是正确的呢?
观察二:答案的字典树一定是一颗除叶子节点外塞满 \(k\) 个儿子的树删掉权值最大的一些叶子后得到的。
根据观察二,我们可以继续使用上面的贪心方法,但是在叶子节点达到 \(n\) 个之后还要继续贪心(这样可以删除一些权值极大的叶子节点,加入一些权值较小的叶子节点),直到扩展后不优时停止扩展。
考虑证明。
时间复杂度正确性
假设现在你需要对在所有 \(p\) 个叶子节点内最优的树内进行删点。
你删除的点数 \(p - n \leq p \times \frac{k - 2}{k}\)。
化简式子,得到 \(p \leq n \times \frac{k}{2}\)。
又由于 \(k \leq 26\),所以 \(p \leq 13 \times n\)。因此这棵树不会很大,从而时间复杂度是正确的。
算法正确性
由于这个算法的本质是对满儿子的 \(k\) 叉树进行删叶子节点,所以我们分两部分考虑。
Part. 1
如果现在我们必须对每个点选满儿子,那么考虑归纳。
假设现在所有叶子节点的权值排序后得到的序列 \(\{a_1, a_2, \cdots, a_p\}\) 是最优的。
如果我们不选择 \(a_1\) 进行更新,假设选择了 \(a_t(t > 1)\)。
容易发现有 \(a_t \times k + \sum_{i = 1}^k b_i \geq a_1 \times k + \sum_{i - 1}^k b_i\),显然不优。
Part. 2
由于每个点的本质相同,我们现在计算出了一个在所有 \(p\) 个叶子节点内最优的树之后,暴力删除权值前 \(q\) 大的叶子节点一定是最优的。
值得写代码。
/*******************************
| Author: DE_aemmprty
| Problem: LITELANG - The lightest language
| Contest: Luogu
| URL: https://www.luogu.com.cn/problem/SP186
| When: 2024-09-25 23:35:29
|
| Memory: 1 MB
| Time: 5000 ms
*******************************/
#include <bits/stdc++.h>
using namespace std;
long long read() {
char c = getchar();
long long x = 0, p = 1;
while ((c < '0' || c > '9') && c != '-') c = getchar();
if (c == '-') p = -1, c = getchar();
while (c >= '0' && c <= '9')
x = (x << 1) + (x << 3) + (c ^ 48), c = getchar();
return x * p;
}
const int N = 1e4 + 7;
int n, k;
long long a[N];
multiset <int> s;
void solve() {
n = read(), k = read();
long long ans = 2e18, res = 0, sum = 0;
s.clear();
for (int i = 1; i <= k; i ++) {
a[i] = read();
sum += a[i];
s.insert(a[i]);
}
res = sum;
while (true) {
if (res >= ans) break;
if ((int) s.size() == n)
ans = res;
auto it = s.begin();
long long val = (*it);
res += val * (k - 1) + sum;
s.erase(it);
for (int i = 1; i <= k; i ++)
s.insert(val + a[i]);
while ((int) s.size() > n) {
res -= (*s.rbegin());
s.erase(prev(s.end()));
}
}
cout << ans << '\n';
}
signed main() {
int t = read();
while (t --) solve();
return 0;
}
CF468D (未考虑:字典序最小)
trick:将路径之和转化为每条边经过的次数乘上边权,将重心提到根上。
关键转化:发现 \(d(i, p_i)\) 不太好算,转化为对于每一条边 \(w\) 乘上这条边被经过的次数。
第一步就被误导了。
我们发现 \(d(i, p_i)\) 不太好算,于是将题目转化为对于每一条边 \(w\) 乘上这条边被经过的次数。这时候,显然每一条边被经过的次数 \(x \leq 2 \times \min\{w_1, w_2\}\),其中 \(w_1, w_2\) 是这条边左右两边的连通块大小。
我们肯定是想把它取满的。但事实证明确实可以取满。以下是构造:
-
我们把重心提到根上。容易发现,当重心提到根上时,较小的一块连通块一定是在下面的。(重心的性质)
-
因此,我们可以把每个与根相邻的点的子树中的点连到外面的子树即可。
因此,答案就是上面这个式子。感觉很逆天。

浙公网安备 33010602011771号