跳转至

GESP 6级

P10250 [GESP样题 六级] 下楼梯

每次下1、2或3个台阶,求走完n个台阶的方案数。
提示1-看最后一步

看全部数据范围,\(n\le 60\)。

我先不管前面怎么走,只看最后一步:它只能走 1、2 或 3 个台阶。

提示2-把三种情况相加

最后一步走几级确定后,前面就是一个更小的同类问题。

我把三种情况的方案数相加,得到 \(\operatorname{dp}_{i}\)。

\(\operatorname{dp}_{0}=1\):什么也不走,也有一种方案。

答案用 long long。

比如正在算第i级,先试最后一步走j级。前面已有的方案接上这一步:

for (int j = 1; j <= 3 && j <= i; j++) {
    dp[i] += dp[i - j];
}
代码-满分

时间 \(O(n)\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 65;

long long dp[N];

int main() {
    int n;

    cin >> n;

    // 我从最后一步倒着想,所以先给“零个台阶”留一种方案。
    dp[0] = 1;
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= 3 && j <= i; j++) {
            dp[i] += dp[i - j];
        }
    }

    cout << dp[n];

    return 0;
}

P10262 [GESP样题 六级] 亲朋数

统计数字串中,数值能被p整除的连续子串数量,允许前导零。
提示1-先想暴力

看全部数据范围,\(L\le 10^{6}\),\(2\le p\le 128\)。

我先想枚举每个子串,但百万长度肯定做不完。

再看 \(p\) 只有 128,我会试着按余数分组,而不是保存子串的数值。

提示2-按余数分组

我把以同一位置结尾的子串放在一起看:新子串要么只有当前数字,要么是在旧子串后面接上这个数字。

旧余数为 \(r\),接上 \(x\) 后,新余数就是 \((10r+x)\bmod p\)。

提示3-只留上一层

转移只用到上一位置,我就不存整张表,只留 \(\operatorname{last}\)、\(\operatorname{cur}\) 两层。

每层把余数为 0 的数量加入答案。

子串数量最多为 \(\frac{L(L+1)}{2}\),所以答案用 long long。

读到数字x后,先清空cur,再放入只有当前一位的子串。下面才把上一轮所有余数接上x:

cur[x % p] = 1;
for (int r = 0; r < p; r++) {
    cur[(r * 10 + x) % p] += last[r];
}
代码-满分-滚动数组

时间 \(O(Lp)\),空间 \(O(p)\),不必保存百万行状态。

#include <bits/stdc++.h>

using namespace std;

const int M = 130;

int last[M], cur[M];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int p;
    string s;

    cin >> p >> s;

    // 我只用到上一层的余数统计,不需要保存整张二维表。
    long long ans = 0;
    for (char c : s) {
        int x = c - '0';
        fill(cur, cur + p, 0);
        cur[x % p] = 1;

        for (int r = 0; r < p; r++) {
            cur[(r * 10 + x) % p] += last[r];
        }

        ans += cur[0];
        for (int r = 0; r < p; r++) last[r] = cur[r];
    }

    cout << ans;

    return 0;
}

B3873 [GESP202309 六级] 小杨买饮料

每种饮料最多买一瓶,求总容量至少为L的最小花费。
提示1-小范围枚举

40分做法,\(n\le 20\),\(L\)、容量\(\le 100\)。

70分做法,容量\(\le 100\)。

看全部数据范围,\(n\le 500\),\(L\le 2000\),价格、容量\(\le 10^{6}\)。

每瓶只有买或不买两种选择,我会先用子集枚举解决 \(n\le 20\) 的情况,再想大范围怎么合并状态。

提示2-容量超过 L 还需要区分吗

容量太大,不能按所有容量开数组。

我注意到:只要达到 \(L\),买到多少都算够了。

于是把超过 \(L\) 的容量都记成 \(L\),\(\operatorname{dp}_{j}\) 只记录达到这个容量的最小费用。

提示3-每瓶只能买一次

我最怕同一瓶被算两次,所以容量从大到小更新。

\(\operatorname{dp}_{0}=0\),其余状态先设为不可达。

这里的j从L倒着枚举,volume和cost是当前瓶的容量、价格。超过目标的容量统一落到L:

int nxt = min(L, j + volume);
dp[nxt] = min(dp[nxt], dp[j] + cost);
代码-40分-枚举子集

时间 \(O(n2^{n})\),空间 \(O(n)\),只适用于 \(n\le 20\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 25;
const long long INF = 1LL << 60;

int cost[N], volume[N];

int main() {
    int n, target;

    cin >> n >> target;
    for (int i = 0; i < n; i++) cin >> cost[i] >> volume[i];

    long long ans = INF;
    // 每瓶只有买和不买两种选择,我先把所有集合试一遍。
    for (int mask = 0; mask < (1 << n); mask++) {
        long long sum = 0, money = 0;
        for (int i = 0; i < n; i++) {
            if ((mask >> i) & 1) {
                sum += volume[i];
                money += cost[i];
            }
        }
        if (sum >= target) ans = min(ans, money);
    }

    if (ans == INF) cout << "no solution";
    else cout << ans;

    return 0;
}
代码-满分-容量封顶的 01 背包

时间 \(O(nL)\),空间 \(O(L)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 2005;
const long long INF = 1LL << 60;

long long dp[N];

int main() {
    int n, target;

    cin >> n >> target;
    fill(dp, dp + target + 1, INF);
    dp[0] = 0;

    for (int i = 1; i <= n; i++) {
        int cost, volume;
        cin >> cost >> volume;

        // 达到目标后就不用区分容量;我倒序更新,避免一瓶买两次。
        for (int j = target; j >= 0; j--) {
            if (dp[j] == INF) continue;
            int nxt = min(target, j + volume);
            dp[nxt] = min(dp[nxt], dp[j] + cost);
        }
    }

    if (dp[target] == INF) cout << "no solution";
    else cout << dp[target];

    return 0;
}

B3874 [GESP202309 六级] 小杨的握手问题

按入场顺序,统计前面学号更小的同学数量之和。
提示1-先写两层循环

30分做法,\(n\le 100\)。

看全部数据范围,\(n\le 3\times 10^{5}\)。

我先把握手翻译成一句话:前面的人学号更小,就计一次。

这样两层循环就能解决小范围。

提示2-归并时成批统计

逐对比较太慢,我就想:能不能一次数出一批?把两段按降序排好后,左边当前数若更小,左边剩下的数就全都更小。

归并时一次加上这批数量,答案用 long long。

归并时两段都已降序。若左边当前数还小于右边当前数,左段从i到mid的数就都能与它握手:

if (a[i] < a[j]) {
    ans += mid - i + 1;
    tmp[k++] = a[j++];
}
代码-30分-两层循环

时间 \(O(n^{2})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 105;

int a[N];

int main() {
    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    long long ans = 0;
    // 我先按入场顺序逐对检查,确认什么时候发生握手。
    for (int i = 1; i <= n; i++) {
        for (int j = i + 1; j <= n; j++) {
            if (a[i] < a[j]) ans++;
        }
    }

    cout << ans;

    return 0;
}
代码-满分-归并统计

时间 \(O(n\log n)\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 300005;

int a[N], tmp[N];
long long ans;

void merge_sort(int l, int r) {
    if (l >= r) return;

    int mid = (l + r) / 2;
    merge_sort(l, mid);
    merge_sort(mid + 1, r);

    int i = l, j = mid + 1, k = l;
    while (i <= mid && j <= r) {
        if (a[i] < a[j]) {
            // 左段已经降序排好,我发现剩下的数都更小,可以一起计数。
            ans += mid - i + 1;
            tmp[k++] = a[j++];
        } else {
            tmp[k++] = a[i++];
        }
    }
    while (i <= mid) tmp[k++] = a[i++];
    while (j <= r) tmp[k++] = a[j++];
    for (int i = l; i <= r; i++) a[i] = tmp[i];
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    merge_sort(1, n);
    cout << ans;

    return 0;
}

P10108 [GESP202312 六级] 闯关游戏

从第0关出发,每次选择一种前进距离,离开关卡时得分,求通关最高分。
提示1-先看只有一条通道

20分做法,\(m=1\)。

40分做法,\(n\le 20\)、\(m\le 2\)。

看全部数据范围,\(n\le 10^{4}\)、\(m\le 100\)。

只有一条通道时,我直接按唯一的路线走。

有多条通道时,到同一关可能走出不同分数,我只保留最高的那个。

提示2-分数可能为负

我看到分数可能是负的,就不能把每个状态都初始化成 0:那等于凭空多了一条没走过的路。

\(\operatorname{dp}_{0}=0\),其余设为负无穷。

离开 \(i\) 时加 \(b_{i}\),到达或越过 \(n\) 都记为通关。

处理关卡i时,先确认dp[i]可达。选择第j条通道,得分记在离开的关卡上:

int nxt = min(n, i + a[j]);
dp[nxt] = max(dp[nxt], dp[i] + b[i]);
代码-满分

时间 \(O(nm)\),空间 \(O(n+m)\)。同一套转移即可覆盖所有范围。

#include <bits/stdc++.h>

using namespace std;

const int N = 10005, M = 105;
const long long NEG = -(1LL << 60);

int a[M], b[N];
long long dp[N];

int main() {
    int n, m;

    cin >> n >> m;
    for (int i = 0; i < m; i++) cin >> a[i];
    for (int i = 0; i < n; i++) cin >> b[i];

    // 分数可能为负,我不能让没到过的关卡凭空拥有0分。
    fill(dp, dp + n + 1, NEG);
    dp[0] = 0;
    for (int i = 0; i < n; i++) {
        if (dp[i] == NEG) continue;
        for (int j = 0; j < m; j++) {
            int nxt = min(n, i + a[j]);
            dp[nxt] = max(dp[nxt], dp[i] + b[i]);
        }
    }

    cout << dp[n];

    return 0;
}

P10109 [GESP202312 六级] 工作沟通

每次给出参与合作的员工,找出能管理所有参与者的最大编号员工。
提示1-沿父亲往上找

50分做法,\(n\le 50\)。

看全部数据范围,\(n\le 300\),\(q\le 100\)。

我先列出谁能管理一个员工:他自己、父亲、父亲的父亲……沿父亲链就能找全。

规模只有 300,不必急着用复杂算法。

提示2-统计共同祖先

每个参与者都给自己的祖先加一次计数,我就能找出所有共同祖先,再取编号最大的。

我会再读一遍输出要求:这里要最大编号,不能直接当成最近公共祖先。

对于本次合作的一个员工u,我沿父亲链逐个计数,直到根的父亲-1。每次询问前清空cnt:

while (u != -1) {
    cnt[u]++;
    u = parent[u];
}
代码-满分-统计祖先

每次询问 \(O(mn)\),空间 \(O(n)\)。规模较小,无需复杂的倍增算法。

#include <bits/stdc++.h>

using namespace std;

const int N = 305;

int parent[N], cnt[N];

int main() {
    int n, q;

    cin >> n;
    parent[0] = -1;
    for (int i = 1; i < n; i++) cin >> parent[i];
    cin >> q;

    for (int t = 0; t < q; t++) {
        int m;
        cin >> m;
        fill(cnt, cnt + n, 0);

        for (int i = 0; i < m; i++) {
            int u;
            cin >> u;
            // 我把这个员工能找到的领导全部记一次,包括他自己。
            while (u != -1) {
                cnt[u]++;
                u = parent[u];
            }
        }

        int ans = 0;
        for (int i = 0; i < n; i++) {
            if (cnt[i] == m) ans = i;
        }
        if (t > 0) cout << '\n';
        cout << ans;
    }

    return 0;
}

P10376 [GESP202403 六级] 游戏

每轮减去a或b,减到不大于c时立即结束,求操作序列数量。
提示1-终点可以合并

20分做法,\(a=b=c=1\),\(n\le 30\)。

40分做法,\(c=1\),\(n\le 1000\)。

看全部数据范围,\(n\le 2\times 10^{5}\)。

我先看游戏什么时候结束:减到不大于 \(c\) 就停。

既然后面都不能再操作,这些终点可以合并到一个状态 \(\operatorname{dp}_{c}\)。

提示2-终点不再转移

数值只会变小,我就从 \(n\) 往下算,只处理 \(i>c\)。

两种操作分别转移到 \(\max(c,i-a)\)、\(\max(c,i-b)\)。

这里我会特别检查 \(a=b\):题目仍把它们算作两种操作。

\(n=c\) 时,不操作也是一种方案。

在i大于c的倒序循环里,两种操作分别累加。你看,哪怕a和b相同,也要加两次:

int x = max(c, i - a);
int y = max(c, i - b);
dp[x] = (dp[x] + dp[i]) % MOD;
dp[y] = (dp[y] + dp[i]) % MOD;
代码-满分

时间 \(O(n)\),空间 \(O(n)\),每次相加后取模。

#include <bits/stdc++.h>

using namespace std;

const int N = 200005, MOD = 1000000007;

int dp[N];

int main() {
    int n, a, b, c;

    cin >> n >> a >> b >> c;

    dp[n] = 1;
    // 我让转移停在c,不能从终点继续走,否则会多算操作。
    for (int i = n; i > c; i--) {
        int x = max(c, i - a);
        int y = max(c, i - b);
        dp[x] = (dp[x] + dp[i]) % MOD;
        dp[y] = (dp[y] + dp[i]) % MOD;
    }

    cout << dp[c];

    return 0;
}

P10377 [GESP202403 六级] 好斗的牛

安排好斗的牛,使任何牛的左右攻击范围内都没有其他牛,求最短牛棚长度。
提示1-数据很小

20分做法,\(n=2\)。

另外20分做法,\(n=3\)。

80分做法,\(n\le 8\)。

看全部数据范围,\(n\le 9\),攻击范围\(\le 1000\)。

看到最多只有 9 头牛,我会先考虑枚举排列。

先定左右顺序,再想这个顺序下怎么放得最紧。

提示2-固定顺序后怎么放

相邻两头牛不能互相攻击,我分别看左牛的右范围和右牛的左范围,距离就至少是 \(\max(b_u,a_v)+1\)。

更远的牛只会离得更远,所以检查相邻牛就够了。

首尾外面不用留空棚。

正在把牛i放到第pos个位置时,order[pos-1]是它左边的牛。已有长度为length,这一步只增加两牛间的距离:

int nxt = length;
if (pos > 1) nxt += max(b[order[pos - 1]], a[i]) + 1;
代码-20分-两头牛

只适用于 \(n=2\),比较两种左右顺序。

#include <bits/stdc++.h>

using namespace std;

const int N = 5;

int a[N], b[N];

int main() {
    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) cin >> b[i];

    // 只有两头牛,我直接比较谁在左、谁在右。
    int ans = min(max(b[1], a[2]), max(b[2], a[1])) + 2;
    cout << ans;

    return 0;
}
代码-满分-枚举排列

时间 \(O(n\cdot n!)\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 15, INF = 1000000000;

int a[N], b[N], order[N];
bool used[N];
int n, ans = INF;

void dfs(int pos, int length) {
    if (pos > n) {
        ans = min(ans, length);
        return;
    }

    for (int i = 1; i <= n; i++) {
        if (used[i]) continue;
        int nxt = length;
        // 顺序定好后,我只给相邻牛留出刚好安全的距离。
        if (pos > 1) nxt += max(b[order[pos - 1]], a[i]) + 1;

        used[i] = true;
        order[pos] = i;
        dfs(pos + 1, nxt);
        used[i] = false;
    }
}

int main() {
    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) cin >> b[i];

    dfs(1, 1);
    cout << ans;

    return 0;
}

P10721 [GESP202406 六级] 计算得分

选择不重叠的若干段连续abc,按每段的abc数量计分,求最高总分。
提示1-特殊性质能简化吗

20分做法,价格随段长不增。

另外40分做法,\(n\le 3\)。

看全部数据范围,\(n\le 20\),\(m\le 10^{5}\),\(a_{i}\le 1000\)。

我先看价格不增的特殊性质:合并一段不会比拆成多个 abc 更划算,单独计分就够了。

一般价格下,这个办法不一定对。

提示2-枚举最后一段

遇到“若干段不能重叠”,我会先盯住最后一段。

\(\operatorname{dp}_{i}\) 表示前 \(i\) 个字符的最高分。

可以跳过当前字符,也可以从后往前每次检查一个 abc。

检查失败就停,最多检查 \(n\) 次。

外层正在算前i个字符,k是末段abc的个数。确认该段完整匹配后,只能接前l-1个字符的答案:

int l = i - 3 * k + 1;
if (l < 1) break;
if (s[l] != 'a' || s[l + 1] != 'b' || s[l + 2] != 'c') break;
dp[i] = max(dp[i], dp[l - 1] + a[k]);
代码-20分-每个 abc 单独计分

时间 \(O(m)\),空间 \(O(m)\)。只保证覆盖价格不增的子任务。

#include <bits/stdc++.h>

using namespace std;

const int N = 25;

int a[N];

int main() {
    int n, m;
    string s;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    cin >> m >> s;

    // 价格不增时,我把每个abc分开算,合并不会赚得更多。
    int ans = 0;
    for (int i = 0; i + 2 < m; i++) {
        if (s[i] == 'a' && s[i + 1] == 'b' && s[i + 2] == 'c') {
            ans += a[1];
        }
    }

    cout << ans;

    return 0;
}
代码-满分-前缀 DP

时间 \(O(mn)\),空间 \(O(m+n)\)。新段前的状态是 \(\operatorname{dp}_{l-1}\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005, M = 25;

int a[M], dp[N];

int main() {
    int n, m;
    string s;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    cin >> m >> s;
    s = ' ' + s;

    for (int i = 1; i <= m; i++) {
        dp[i] = dp[i - 1];
        for (int k = 1; k <= n; k++) {
            int l = i - 3 * k + 1;
            if (l < 1) break;
            if (s[l] != 'a' || s[l + 1] != 'b' || s[l + 2] != 'c') break;
            // 我把最后一段单独拿出来,前面的字符只能算到l-1。
            dp[i] = max(dp[i], dp[l - 1] + a[k]);
        }
    }

    cout << dp[m];

    return 0;
}

P10722 [GESP202406 六级] 二叉树

每次翻转一棵子树的颜色,求所有操作结束后的颜色串。
提示1-先看两类部分分

20分做法,父亲为 \(i-1\),是一条链。

另外40分做法,\(n,q\le 1000\)。

看全部数据范围,\(n,q\le 10^{5}\)。

我先按题意每次翻转整棵子树,能拿到小树的分。

链上的子树是后缀,这也提醒我:操作可以先记在起点。

提示2-只需要奇偶性

同一个节点翻转两次就恢复,我只需要记奇偶。

把操作记在子树根上,再把父亲的累计翻转传给孩子。

异或正好表示这个奇偶变化。

我用队列从根往下走,长链也不用担心递归太深。

根的累计翻转先确定,再依次访问孩子。对当前父子u、v,要把父亲的变化接到孩子本身的操作上:

flip[v] ^= flip[u];
q.push(v);
代码-40分-逐次翻转

时间 \(O(nq)\),空间 \(O(n)\)。覆盖 \(n,q\le 1000\) 的子任务。

#include <bits/stdc++.h>

using namespace std;

const int N = 1005;

vector<int> child[N];

int main() {
    int n, q;
    string s;

    cin >> n;
    for (int i = 2; i <= n; i++) {
        int p;
        cin >> p;
        child[p].push_back(i);
    }
    cin >> s >> q;

    // 小树我先照题意做:每次把选中节点的整棵子树翻一遍。
    while (q--) {
        int root;
        cin >> root;
        queue<int> q;
        q.push(root);
        while (!q.empty()) {
            int u = q.front();
            q.pop();
            s[u - 1] = (s[u - 1] == '0' ? '1' : '0');
            for (int v : child[u]) q.push(v);
        }
    }

    cout << s;

    return 0;
}
代码-满分-累计翻转

时间 \(O(n+q)\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

vector<int> child[N];
int flip[N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n, queries;
    string s;

    cin >> n;
    for (int i = 2; i <= n; i++) {
        int p;
        cin >> p;
        child[p].push_back(i);
    }
    cin >> s >> queries;
    while (queries--) {
        int u;
        cin >> u;
        flip[u] ^= 1;
    }

    queue<int> q;
    q.push(1);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        if (flip[u]) s[u - 1] = (s[u - 1] == '0' ? '1' : '0');
        for (int v : child[u]) {
            // 我只关心翻转奇偶,孩子还要接上从父亲传来的变化。
            flip[v] ^= flip[u];
            q.push(v);
        }
    }

    cout << s;

    return 0;
}

P11246 [GESP202409 六级] 小杨和整数拆分

把n拆成若干完全平方数之和,求最少使用几个平方数。
提示1-这是求最少个数

看全部数据范围,\(n\le 10^{5}\)。

我先把状态的含义说清:\(\operatorname{dp}_{i}\) 是凑出 \(i\) 最少要几个平方数,不是有多少种拆法。

提示2-枚举最后一个平方数

我先假设最后放的是 \(j^2\),去掉它,剩下的就是 \(i-j^2\)。

所以枚举所有 \(j^2\),取最小值:

\[ \operatorname{dp}_i=\min_{1\le j^2\le i}\left(\operatorname{dp}_{i-j^2}+1\right) \]

\(\operatorname{dp}_0=0\),同一个平方数可以重复用。

从小到大算i,先设一个足够大的初值。内层试所有不超过i的平方数,dp[0]为0:

for (int j = 1; j * j <= i; j++) {
    dp[i] = min(dp[i], dp[i - j * j] + 1);
}
代码-满分

时间 \(O(n\sqrt{n})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

int dp[N];

int main() {
    int n;

    cin >> n;

    for (int i = 1; i <= n; i++) {
        dp[i] = i;
        // 我假设最后放的是j*j,剩下的数就交给前面的答案。
        for (int j = 1; j * j <= i; j++) {
            dp[i] = min(dp[i], dp[i - j * j] + 1);
        }
    }

    cout << dp[n];

    return 0;
}

P11247 [GESP202409 六级] 算法学习

选题让每种算法掌握程度至少为k,且相邻题目知识点不同,求最少题数。
提示1-先选每类最有用的题

前两个子任务各 30 分,均有 \(n\le 9\)。

看全部数据范围,\(m,n,k,b_{i}\le 10^{5}\)。

我先暂时放下排列顺序。

每类都优先选提升最多的题,就能求出这一类至少要做几题。

总提升不够 \(k\),直接无解。

提示2-题选够了,能排开吗

题够了还不一定能排开。

我拿最多的一类做检查:若有 \(\operatorname{mx}\) 题,就需要其他类至少 \(\operatorname{mx}-1\) 题来隔开。

设必选题总数为 \(\operatorname{sum}\),能排开的条件就是 \(\operatorname{mx}\le \operatorname{sum}-\operatorname{mx}+1\)。

提示3-不够就补隔板

如果隔板不够,我会补选其他类的题,直到它们达到 \(\operatorname{mx}-1\) 题。

这样总数就是 \(2\operatorname{mx}-1\)。

其他类全部题目都不够时,才是真的无解。

这里不必实际排出顺序。

total是各类必选题之和,most是其中最多的一类,kind记录这类编号。下面只是在必选题都够分之后,决定隔板是否够用:

if (most <= total - most + 1) ans = total;
else if (n - (int)problem[kind].size() >= most - 1) ans = 2 * most - 1;
代码-60分-枚举选题集合

时间 \(O((n+m)2^{n})\),空间 \(O(n+m)\)。覆盖两个 \(n\le 9\) 子任务。

#include <bits/stdc++.h>

using namespace std;

const int N = 15;
const int INF = 1000000000;

int a[N], b[N], cnt[N], score[N];

int main() {
    int m, n, k;

    cin >> m >> n >> k;
    for (int i = 0; i < n; i++) cin >> a[i];
    for (int i = 0; i < n; i++) cin >> b[i];

    int ans = INF;
    for (int mask = 0; mask < (1 << n); mask++) {
        fill(cnt, cnt + m + 1, 0);
        fill(score, score + m + 1, 0);
        int total = 0, most = 0;
        for (int i = 0; i < n; i++) {
            if ((mask >> i) & 1) {
                cnt[a[i]]++;
                score[a[i]] += b[i];
                total++;
            }
        }

        bool ok = true;
        for (int i = 1; i <= m; i++) {
            if (score[i] < k) ok = false;
            most = max(most, cnt[i]);
        }
        // 选够题后,我还要检查最多的一类能不能被其他题隔开。
        if (ok && most <= total - most + 1) ans = min(ans, total);
    }

    cout << (ans == INF ? -1 : ans);

    return 0;
}
代码-满分-分类贪心

时间 \(O(n\log n+m)\),空间 \(O(n+m)\)。补选题只用作隔开相同知识点。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

int a[N];
vector<int> problem[N];

bool cmp(int x, int y) {
    return x > y;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int m, n, k;

    cin >> m >> n >> k;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) {
        int b;
        cin >> b;
        problem[a[i]].push_back(b);
    }

    int total = 0, most = 0, kind = 0;
    bool ok = true;
    for (int i = 1; i <= m; i++) {
        sort(problem[i].begin(), problem[i].end(), cmp);
        long long score = 0;
        int need = 0;
        for (int b : problem[i]) {
            if (score >= k) break;
            score += b;
            need++;
        }
        if (score < k) ok = false;
        total += need;
        if (need > most) {
            most = need;
            kind = i;
        }
    }

    int ans = -1;
    if (ok) {
        if (most <= total - most + 1) ans = total;
        // 必选题不够当隔板,我再检查其他类全部题目是否够用。
        else if (n - (int)problem[kind].size() >= most - 1) ans = 2 * most - 1;
    }

    cout << ans;

    return 0;
}

P11375 [GESP202412 六级] 树上游走

在无限二叉树上执行U、L、R,求最后的节点编号,最终编号不超过10^12。
提示1-中途可能很大

40分做法,\(n\le 50\)、\(s\le 10\)。

看全部数据范围,\(n\le 10^{6}\)、\(s\le 10^{12}\)。

我会先直接乘 2、除 2 来模拟小范围。

但“最后编号小”并不保证“中途编号小”,连续往下走可能让整数溢出。

提示2-用二进制表示节点

我换成二进制看看:去左儿子就是末尾加 0,去右儿子就是加 1,向上就是删末位。

于是用字符串保存路径,最后才转回整数。

根的二进制是 1,不能把它删空。

path是根到当前位置的二进制编号,首位1不能删。处理一个操作c时,不再对可能溢出的整数乘二:

if (c == 'U') {
    if (path.size() > 1) path.pop_back();
} else {
    path.push_back(c == 'L' ? '0' : '1');
}
代码-40分-直接模拟

时间 \(O(n)\),空间 \(O(n)\)。\(n\le 50\)、\(s\le 10\) 时,中间值能用 long long 保存。

#include <bits/stdc++.h>

using namespace std;

int main() {
    int n;
    long long pos;
    string s;

    cin >> n >> pos >> s;

    // 这个小范围的中间编号也存得下,我先直接按规则走。
    for (char c : s) {
        if (c == 'U') pos = max(1LL, pos / 2);
        else if (c == 'L') pos *= 2;
        else pos = pos * 2 + 1;
    }

    cout << pos;

    return 0;
}
代码-满分-二进制路径

时间 \(O(n+\log s)\),空间 \(O(n+\log s)\),中途不会发生整数溢出。

#include <bits/stdc++.h>

using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    long long pos;
    string s, path;

    cin >> n >> pos >> s;
    while (pos > 0) {
        path.push_back(char('0' + pos % 2));
        pos /= 2;
    }
    reverse(path.begin(), path.end());

    // 我把编号换成二进制路径,向下添一位,向上删一位。
    for (char c : s) {
        if (c == 'U') {
            if (path.size() > 1) path.pop_back();
        } else {
            path.push_back(c == 'L' ? '0' : '1');
        }
    }

    long long ans = 0;
    for (char c : path) ans = ans * 2 + c - '0';
    cout << ans;

    return 0;
}

P11376 [GESP202412 六级] 运送物资

把货车分配到有容量限制的运输站,最小化每天往返两座城市的总路程。
提示1-先拆开一辆车的代价

前两个子任务各 20 分,均有站点容量为 1。

看全部数据范围,\(n,m\le 10^{5}\),\(x\le 10^{8}\),次数、容量\(\le 10^{5}\)。

我先写出一辆车放在 \(p\) 的代价,再展开:\(2bx+2(a-b)p\)。

第一项固定,真正影响位置选择的是 \(a-b\)。

提示2-谁应该靠左

我试着交换两辆车的位置,发现 \(a-b\) 越大的车越应该靠左。

这样排序的理由就有了。

正差值从左分,负差值从右分,都先安排绝对差值大的车。

提示3-别忘了站点容量

分配时我把站点看成有多个车位的格子:满了就一直往下找,不能只跳过一个。

总容量保证足够。

总路程可能接近 \(2\times 10^{18}\),乘法一开始就要用 long long。

站点已按位置升序排列。安排左侧的一辆车t前,可能有连续多个站已满,所以这里必须用while:

while (station[l].capacity == 0) l++;
int p = station[l].pos;
ans += 2LL * t.a * p + 2LL * t.b * (x - p);
station[l].capacity--;
代码-满分-排序分配

时间 \(O(n\log n+m\log m)\),空间 \(O(n+m)\)。容量为 1 也使用同一算法。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

struct Station {
    int pos, capacity;
};

struct Truck {
    int a, b;
};

Station station[N];
Truck left_truck[N], right_truck[N];
int left_count, right_count;

bool cmp(Station a, Station b) {
    return a.pos < b.pos;
}

bool cmp_left(Truck a, Truck b) {
    return a.a - a.b > b.a - b.b;
}

bool cmp_right(Truck a, Truck b) {
    return a.b - a.a > b.b - b.a;
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n, m, x;

    cin >> n >> m >> x;
    for (int i = 0; i < n; i++) cin >> station[i].pos >> station[i].capacity;
    sort(station, station + n, cmp);

    for (int i = 0; i < m; i++) {
        Truck t;
        cin >> t.a >> t.b;
        if (t.a >= t.b) left_truck[left_count++] = t;
        else right_truck[right_count++] = t;
    }
    // 交换两辆车的位置后,我发现差值大的更应该靠近A。
    sort(left_truck, left_truck + left_count, cmp_left);
    sort(right_truck, right_truck + right_count, cmp_right);

    long long ans = 0;
    int l = 0, r = n - 1;
    for (int i = 0; i < left_count; i++) {
        Truck t = left_truck[i];
        while (station[l].capacity == 0) l++;
        int p = station[l].pos;
        ans += 2LL * t.a * p + 2LL * t.b * (x - p);
        station[l].capacity--;
    }
    for (int i = 0; i < right_count; i++) {
        Truck t = right_truck[i];
        while (station[r].capacity == 0) r--;
        int p = station[r].pos;
        ans += 2LL * t.a * p + 2LL * t.b * (x - p);
        station[r].capacity--;
    }

    cout << ans;

    return 0;
}

P11962 [GESP202503 六级] 树上漫步

在树上走偶数步后结束,求每个起点能到达多少个终点,允许重复经过节点。
提示1-先从每个点搜索

40分做法,\(n\le 1000\)。

看全部数据范围,\(n\le 2\times 10^{5}\)。

我先从每个点 BFS,统计偶数距离,得到一个能验证的小范围办法。

走零步也合法,所以自己要算进去。

提示2-来回走不会改变奇偶

重复走路看起来很麻烦,但树上绕出去还要沿原路回来,多走的步数总是偶数。

于是只用看距离的奇偶。

我从根按深度奇偶分两组,同组之间距离为偶数。

每个点的答案就是自己那组的人数。

从u第一次走到v,深度奇偶恰好改变一次。统计完两类人数cnt后,节点i只取自己所属类的数量:

color[v] = color[u] ^ 1;
// 遍历全部结束后,节点i的答案是cnt[color[i]]。
代码-40分-逐点 BFS

时间 \(O(n^{2})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 1005;

vector<int> g[N];
int dist[N];

int main() {
    int n;

    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }

    // 我先逐个起点搜索,把距离为偶数的终点数出来。
    for (int start = 1; start <= n; start++) {
        fill(dist, dist + n + 1, -1);
        queue<int> q;
        q.push(start);
        dist[start] = 0;
        int ans = 0;
        while (!q.empty()) {
            int u = q.front();
            q.pop();
            if (dist[u] % 2 == 0) ans++;
            for (int v : g[u]) {
                if (dist[v] != -1) continue;
                dist[v] = dist[u] + 1;
                q.push(v);
            }
        }
        if (start > 1) cout << ' ';
        cout << ans;
    }

    return 0;
}
代码-满分-按奇偶分组

时间 \(O(n)\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 200005;

vector<int> g[N];
int color[N], cnt[2];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;

    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        g[u].push_back(v);
        g[v].push_back(u);
    }

    fill(color, color + n + 1, -1);
    queue<int> q;
    q.push(1);
    color[1] = 0;
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        cnt[color[u]]++;
        for (int v : g[u]) {
            if (color[v] != -1) continue;
            // 每走一条边奇偶就变一次,我用两种颜色记录这个规律。
            color[v] = color[u] ^ 1;
            q.push(v);
        }
    }

    for (int i = 1; i <= n; i++) {
        if (i > 1) cout << ' ';
        cout << cnt[color[i]];
    }

    return 0;
}

P11963 [GESP202503 六级] 环线

在环上选一段非空连续车站,每站至多经过一次,求最大快乐值之和。
提示1-枚举起点和长度

20分做法,\(n\le 200\)。

40分做法,\(n\le 2000\)。

看全部数据范围,\(n\le 2\times 10^{5}\),\(|a_{i}|\le 10^{9}\)。

我先固定起点,再把行程一站一站延长。

边走边累加,不用每次重新求和,平方级就能拿到 40分。

提示2-跨过首尾怎么处理

不跨首尾时,我想到普通最大子段和。

跨首尾时,两头都选了,中间有一段没选。

我就反过来找这段:总和减去最小子段和,同时保留普通最大子段和来比较。

提示3-必须至少选一个

最后我会拿全负数试一下:总和减掉整个数组,会得到不合法的空段。

全负时直接选最大的单个数。

其余情况比较两种答案,计算用 long long。

扫描到新值x,max_end和min_end分别记以当前位置结尾的最大、最小非空段和。要么接在前段后面,要么从x重新开始:

max_end = max(x, max_end + x);
min_end = min(x, min_end + x);
best = max(best, max_end);
worst = min(worst, min_end);
代码-40分-枚举环段

时间 \(O(n^{2})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 2005;
const long long NEG = -(1LL << 60);

long long a[N];

int main() {
    int n;

    cin >> n;
    for (int i = 0; i < n; i++) cin >> a[i];

    long long ans = NEG;
    // 我固定起点逐站延长,每次只加新经过的一站。
    for (int start = 0; start < n; start++) {
        long long sum = 0;
        for (int len = 1; len <= n; len++) {
            sum += a[(start + len - 1) % n];
            ans = max(ans, sum);
        }
    }

    cout << ans;

    return 0;
}
代码-满分-最大与最小子段和

时间 \(O(n)\),额外空间 \(O(1)\)。

#include <bits/stdc++.h>

using namespace std;

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    long long x;

    cin >> n >> x;
    long long total = x, max_end = x, min_end = x;
    long long best = x, worst = x;
    for (int i = 2; i <= n; i++) {
        cin >> x;
        total += x;
        max_end = max(x, max_end + x);
        min_end = min(x, min_end + x);
        best = max(best, max_end);
        worst = min(worst, min_end);
    }

    long long ans = best;
    // 我用全负数检查边界:不能把整个数组减掉,选出空段。
    if (best >= 0) ans = max(ans, total - worst);
    cout << ans;

    return 0;
}

P13015 [GESP202506 六级] 学习小组

把所有同学分组,每组价值只由人数决定,求价值总和最大值。
提示1-只关心各组人数

40分做法,\(n\le 10\)。

看全部数据范围,\(n\le 1000\),\(a_{i}\le 10^{4}\)。

我先看价值由什么决定:只看人数,不看具体是谁。

所以不用安排同学名单,只需要决定每组几个人。

提示2-最后一组有几人

假设最后一组有 \(j\) 人,前面的 \(i-j\) 人也在做同一个分组问题。

于是 \(\operatorname{dp}_{i}\) 从 \(\operatorname{dp}_{i-j}+a_{j}\) 中取最大值。

这就是我找到转移的方法。

外层人数i从1递增。假设最后一组有j人,前i-j人的最佳分值已经算好,dp[0]为0:

for (int j = 1; j <= i; j++) {
    dp[i] = max(dp[i], dp[i - j] + a[j]);
}
代码-满分-人数 DP

时间 \(O(n^{2})\),空间 \(O(n)\)。同一种组大小可以重复使用。

#include <bits/stdc++.h>

using namespace std;

const int N = 1005;

int a[N], dp[N];

int main() {
    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];

    for (int i = 1; i <= n; i++) {
        // 我只决定最后一组有几人,剩余同学的最优值已经算好了。
        for (int j = 1; j <= i; j++) {
            dp[i] = max(dp[i], dp[i - j] + a[j]);
        }
    }

    cout << dp[n];

    return 0;
}

P13016 [GESP202506 六级] 最大因数

整数的父亲是它最大的真因数,多次询问两个整数在这棵树上的距离。
提示1-父亲是什么

60分做法,\(x,y\le 1000\)。

看全部数据范围,\(q\le 1000\),\(x,y\le 10^{9}\)。

我先拿 12 试一试:它的父亲是 6,也就是除以最小质因数 2。

一般整数也满足这个规律。

两数不同时,让较大的那个往上走,直到相遇,就能求距离。

提示2-每次重新试除太慢

每上跳一次都重新找因数有些浪费。

我把质因数从小到大列出来,依次除掉,就能一次得到整条祖先链。

然后比较两条链,每次推进数值较大的那条。

每次除掉的质因数至少为2,\(10^9\) 以内的祖先链不超过31个节点,用长度35的静态数组就够了。

静态数组path按从大到小保存祖先,len记录已存项数。试到质因数p后,每除一次就是沿父亲边上跳一次:

while (x % p == 0) {
    x /= p;
    path[len++] = x;
}
代码-60分-逐次找父亲

每次上跳都试除找最小质因数。适用于 \(x,y\le 1000\)。

#include <bits/stdc++.h>

using namespace std;

int parent(int x) {
    // 我从小到大找因数,第一个因数就是最小质因数。
    for (int p = 2; p <= x / p; p++) {
        if (x % p == 0) return x / p;
    }

    return 1;
}

int main() {
    int q;

    cin >> q;
    for (int t = 0; t < q; t++) {
        int x, y;
        cin >> x >> y;

        int ans = 0;
        while (x != y) {
            if (x > y) x = parent(x);
            else y = parent(y);
            ans++;
        }

        if (t > 0) cout << '\n';
        cout << ans;
    }

    return 0;
}
代码-满分-一次分解得到祖先链

每次询问时间 \(O(\sqrt{x}+\sqrt{y})\),空间 \(O(\log\max(x,y))\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 35;

int a[N], b[N];

int get_path(int x, int path[]) {
    int len = 0;
    path[len++] = x;

    for (int p = 2; p <= x / p; p++) {
        // 我依次除掉最小质因数,每除一次就往父亲走一步。
        while (x % p == 0) {
            x /= p;
            path[len++] = x;
        }
    }
    if (x > 1) path[len++] = 1;

    return len;
}

int main() {
    int q;

    cin >> q;
    for (int t = 0; t < q; t++) {
        int x, y;
        cin >> x >> y;
        get_path(x, a);
        get_path(y, b);

        int i = 0, j = 0;
        while (a[i] != b[j]) {
            if (a[i] > b[j]) i++;
            else j++;
        }

        if (t > 0) cout << '\n';
        cout << i + j;
    }

    return 0;
}

P14075 [GESP202509 六级] 划分字符串

把字符串切成字母不重复的子串,按子串长度计价值,求最大总价值。
提示1-枚举最后一段

40分做法,\(n\le 1000\)。

看全部数据范围,\(n\le 10^{5}\),\(a_{i}\le 10^{9}\)。

我先抓住最后一段:假设它从 \(j\) 开始、在 \(i\) 结束,前面就交给 \(\operatorname{dp}_{j-1}\)。

这样不会和前面的段重叠。

提示2-为什么只找 26 次

两层循环看着像平方级,但我再看字符范围:只有 26 种小写字母,合法的一段最多长 26。

从 \(j=i\) 往前找,重复就停,用 \(\operatorname{dp}_{j-1}+a_{i-j+1}\) 更新。

单个字符不能漏,答案用 long long。

代码把字符串下标从1开始存。固定右端点i,j从i向前走;used记录这一段已出现的字母,遇到重复就停止:

int c = s[j] - 'a';
if (used[c]) break;
used[c] = true;
dp[i] = max(dp[i], dp[j - 1] + a[i - j + 1]);
代码-满分-前缀 DP

时间 \(O(26n)\),空间 \(O(n)\)。小写字母限制使这套算法直接覆盖全部数据。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

int a[N];
long long dp[N];
bool used[26];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    string s;

    cin >> n >> s;
    for (int i = 1; i <= n; i++) cin >> a[i];
    s = ' ' + s;

    for (int i = 1; i <= n; i++) {
        fill(used, used + 26, false);
        // 我从单个字符开始向前扩展,遇到重复后,更长的段也不合法。
        for (int j = i; j >= 1; j--) {
            int c = s[j] - 'a';
            if (used[c]) break;
            used[c] = true;
            dp[i] = max(dp[i], dp[j - 1] + a[i - j + 1]);
        }
    }

    cout << dp[n];

    return 0;
}

P14076 [GESP202509 六级] 货物运输

在树上从1出发经过所有城市,终点不限,求最短总路程。
提示1-先看链

30分做法,\(n\le 8\)。

另外30分做法,树是一条链。

看全部数据范围,\(n\le 10^{5}\),边长\(\le 10^{9}\)。

我先在链上画一条路线:两边都要去,但最后停下的那一边不用返回。

我会选择把这次返回省在较远的一边。

提示2-哪些边不用返回

把这个想法放到树上:若回到 1,每条边都走两次。

最后停在 \(v\),就能省掉 1 到 \(v\) 的一次路程。

所以我先算边长总和的两倍,再减去从 1 出发的最长距离。

计算用 long long。

total记录所有边长之和,farthest记录从1出发的最远距离。最后保留的那条路无需返回:

cout << 2 * total - farthest;
代码-满分-求离起点最远的点

时间 \(O(n)\),空间 \(O(n)\)。链与一般树都用同一结论。队列遍历避免长链递归。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

vector<pair<int, int>> g[N];
long long dist[N];
int parent[N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;
    long long total = 0;

    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v, w;
        cin >> u >> v >> w;
        g[u].push_back({v, w});
        g[v].push_back({u, w});
        total += w;
    }

    long long farthest = 0;
    queue<int> q;
    q.push(1);
    while (!q.empty()) {
        int u = q.front();
        q.pop();
        farthest = max(farthest, dist[u]);
        for (auto edge : g[u]) {
            int v = edge.first, w = edge.second;
            if (v == parent[u]) continue;
            parent[v] = u;
            dist[v] = dist[u] + w;
            q.push(v);
        }
    }

    // 我先按每条边往返计算,再省掉到最终停点的那一次返回。
    cout << 2 * total - farthest;

    return 0;
}

P14919 [GESP202512 六级] 路径覆盖

给树上若干节点染黑,使每条根到叶子的路径都有黑点,求最小代价。
提示1-先看小树和链

40分做法,\(n\le 16\)。

另外20分做法,父亲为 \(i-1\),是一条链。

看全部数据范围,\(n\le 10^{5}\),代价\(\le 10^{9}\)。

我先看最简单的链:只有一条根到叶子的路径,选代价最小的点就够了。

小树则可以枚举黑点集合。

提示2-一个子树有两种选择

我站在节点 \(u\) 想:染黑自己,下面所有路径都覆盖了。

不染自己,就得让每个孩子的子树分别完成覆盖。

这两种选择的代价分别是 \(c_{u}\) 和孩子答案之和,取小即可。

提示3-叶子必须染黑

我会单独检查叶子:它没有孩子,不能把空的孩子和当成零代价,它必须染黑。

题目保证父亲编号更小,我按编号倒序算,孩子就先算完了。

代价和用 long long。

倒序算节点u时,孩子已经把代价加进sum[u]。叶子没有孩子,但不能因此免费覆盖:

if (children[u] == 0) dp[u] = cost[u];
else dp[u] = min(1LL * cost[u], sum[u]);
if (u != 1) sum[parent[u]] += dp[u];
代码-40分-枚举黑点集合

时间 \(O(n^{2} 2^{n})\),空间 \(O(n)\)。仅适用于 \(n\le 16\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 20;
const long long INF = 1LL << 60;

int parent[N], cost[N];
bool leaf[N];

int main() {
    int n;

    cin >> n;
    fill(leaf, leaf + n + 1, true);
    for (int i = 2; i <= n; i++) {
        cin >> parent[i];
        leaf[parent[i]] = false;
    }
    for (int i = 1; i <= n; i++) cin >> cost[i];

    long long ans = INF;
    for (int mask = 0; mask < (1 << n); mask++) {
        long long sum = 0;
        for (int i = 1; i <= n; i++) {
            if ((mask >> (i - 1)) & 1) sum += cost[i];
        }
        bool ok = true;
        // 我从每个叶子往上找,确认这条路径真的遇到了黑点。
        for (int i = 1; i <= n && ok; i++) {
            if (!leaf[i]) continue;
            int u = i;
            while (u && !((mask >> (u - 1)) & 1)) u = parent[u];
            if (u == 0) ok = false;
        }
        if (ok) ans = min(ans, sum);
    }

    cout << ans;

    return 0;
}
代码-满分-树形 DP

时间 \(O(n)\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

int parent[N], cost[N], children[N];
long long dp[N], sum[N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;

    cin >> n;
    for (int i = 2; i <= n; i++) {
        cin >> parent[i];
        children[parent[i]]++;
    }
    for (int i = 1; i <= n; i++) cin >> cost[i];

    for (int u = n; u >= 1; u--) {
        // 我先处理叶子:它必须染黑,不能套用空的孩子代价和。
        if (children[u] == 0) dp[u] = cost[u];
        else dp[u] = min(1LL * cost[u], sum[u]);
        if (u != 1) sum[parent[u]] += dp[u];
    }

    cout << dp[1];

    return 0;
}

P14920 [GESP202512 六级] 道具商店

每件道具最多买一次,在金币预算内最大化攻击力提升。
提示1-金币能作背包容量吗

60分做法,\(k\)、\(c_{i}\le 500\)。

看全部数据范围,\(n\le 500\),\(k\)、\(c_{i}\le 10^{9}\),\(a_{i}\le 500\)。

我第一反应是按金币做 01 背包,小范围确实能用。

但 \(k\) 到了十亿,这个维度就开不下了。

提示2-换一个较小的维度

我再找一个小的量:总攻击力最多只有 \(500\times 500\)。

于是把状态反过来,\(\operatorname{dp}_{v}\) 记录得到 \(v\) 点攻击力最少花多少钱。

倒序更新后,找费用不超过 \(k\) 的最大 \(v\)。

费用和用 long long。

当前道具加a点攻击力、花cost金币。total是已读道具攻击力之和,倒序保证本件只用一次;不可达状态不能转移:

for (int j = total; j >= a; j--) {
    if (dp[j - a] == INF) continue;
    dp[j] = min(dp[j], dp[j - a] + cost);
}
代码-60分-按金币背包

时间 \(O(nk)\),空间 \(O(k)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 505;

int dp[N];

int main() {
    int n, k;

    cin >> n >> k;
    for (int i = 1; i <= n; i++) {
        int a, cost;
        cin >> a >> cost;
        // 金币少时,我先用熟悉的01背包,倒序保证每件只买一次。
        for (int j = k; j >= cost; j--) {
            dp[j] = max(dp[j], dp[j - cost] + a);
        }
    }

    cout << dp[k];

    return 0;
}
代码-满分-按攻击力背包

设 \(V\) 为总攻击力,时间 \(O(nV)\),空间 \(O(V)\),\(V\le 250000\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 250005;
const long long INF = 1LL << 60;

long long dp[N];

int main() {
    int n, k;

    cin >> n >> k;
    fill(dp, dp + N, INF);
    dp[0] = 0;

    int total = 0;
    for (int i = 1; i <= n; i++) {
        int a, cost;
        cin >> a >> cost;
        total += a;
        // 总攻击力比金币小得多,我改为记录每种攻击力的最低花费。
        for (int j = total; j >= a; j--) {
            if (dp[j - a] == INF) continue;
            dp[j] = min(dp[j], dp[j - a] + cost);
        }
    }

    int ans = 0;
    for (int i = 0; i <= total; i++) {
        if (dp[i] <= k) ans = i;
    }
    cout << ans;

    return 0;
}

P15800 [GESP202603 六级] 选数

选择严格递增的下标,下一下标至少相隔b[当前下标],求所选a的最大和。
提示1-从选中的最后一个点转移

40分做法,\(n\le 1000\)。

看全部数据范围,\(n\le 10^{5}\),\(a_{i}\le 10^{9}\),\(0\le b_{i}\le n\)。

我先写一个直接的转移:枚举前一个选中的位置 \(j\),检查它能不能接到 \(i\)。

小范围先用它验证思路。

提示2-从后往前看

从前往后看,要找很多前驱。

我换到从后往前看,当前点就只有选和不选两种决定。

不选 \(i\),得到 \(\operatorname{dp}_{i+1}\)。

选 \(i\),得到 \(a_{i}+\operatorname{dp}_{\operatorname{next}}\)。

提示3-间隔为零也不能重复选

这里我会专门试 \(b_{i}=0\):下标仍要严格递增,不能又选自己。

所以 \(\operatorname{next}=\min(n+1,i+\max(1,b_i))\),\(\operatorname{dp}_{n+1}=0\) 表示后面没有数可选。

答案用 long long。

外层i从n倒着走。nxt至少是i+1,即使b[i]为0,也不会把自己再次选中:

int nxt = min(n + 1, i + max(1, b[i]));
dp[i] = max(dp[i + 1], a[i] + dp[nxt]);
代码-40分-枚举前一个位置

时间 \(O(n^{2})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 1005;

int a[N], b[N];
long long dp[N];

int main() {
    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) cin >> b[i];

    long long ans = 0;
    for (int i = 1; i <= n; i++) {
        dp[i] = a[i];
        // 我先检查前一个选中的位置是否允许跳到这里。
        for (int j = 1; j < i; j++) {
            if (i >= j + b[j]) dp[i] = max(dp[i], dp[j] + a[i]);
        }
        ans = max(ans, dp[i]);
    }

    cout << ans;

    return 0;
}
代码-满分-后缀 DP

时间 \(O(n)\),空间 \(O(n)\)。允许全为 0,此时选任意一个位置也能得到最优值 0。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

int a[N], b[N];
long long dp[N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> a[i];
    for (int i = 1; i <= n; i++) cin >> b[i];

    for (int i = n; i >= 1; i--) {
        // 即使间隔为0,我也得走到下一个下标,不能重复选自己。
        int next = min(n + 1, i + max(1, b[i]));
        dp[i] = max(dp[i + 1], a[i] + dp[next]);
    }

    cout << dp[1];

    return 0;
}

P15801 [GESP202603 六级] 完全二叉树

给定左右儿子,统计以各节点为根的子树中,完全二叉树的数量。
提示1-按层检查有没有空位

40分做法,\(n\le 500\)。

看全部数据范围,\(n\le 10^{5}\)。

我先按层从左到右观察完全二叉树:一旦出现空位,后面就不能再有节点。

逐棵 BFS 可以解决小范围。

提示2-大树要合并左右结果

逐棵检查会重复看同一批节点,我就想让孩子把检查结果告诉父亲。

除了是否完全,还要知道高度和是否每层都满。

空树高度为 0,视为满且完全,叶子也能直接按同一规则判断。

提示3-只有两种接法

我画图看最后一层该往哪边放:左右等高时,左边必须满、右边可以完全。

左边高一层时,左边可以完全、右边必须满。

其他高度关系都不行。

我先处理孩子再处理父亲,不假设编号就是遍历顺序。

倒序遍历order时,先读出左右孩子l、r。空树0号的full与complete都为真,高度为0。两种接法分别写,别把或误写成且:

complete[u] = (full[l] && complete[r] && height[l] == height[r])
           || (complete[l] && full[r] && height[l] == height[r] + 1);
代码-40分-逐棵 BFS

时间 \(O(n^{2})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 505;

int left_child[N], right_child[N];

bool check(int root) {
    queue<int> q;
    q.push(root);
    bool empty = false;

    while (!q.empty()) {
        int u = q.front();
        q.pop();
        if (u == 0) {
            empty = true;
            continue;
        }
        // 我按层扫描,空位之后又出现节点,就不可能是完全二叉树。
        if (empty) return false;
        q.push(left_child[u]);
        q.push(right_child[u]);
    }

    return true;
}

int main() {
    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> left_child[i] >> right_child[i];

    int ans = 0;
    for (int i = 1; i <= n; i++) {
        if (check(i)) ans++;
    }
    cout << ans;

    return 0;
}
代码-满分-合并左右子树

时间 \(O(n)\),空间 \(O(n)\)。先记录遍历顺序,再倒序计算,避免长链递归。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

int order[N];

int left_child[N], right_child[N], height[N];
bool full[N], complete[N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> left_child[i] >> right_child[i];

    int len = 0;
    order[len++] = 1;
    for (int i = 0; i < len; i++) {
        int u = order[i];
        if (left_child[u]) order[len++] = left_child[u];
        if (right_child[u]) order[len++] = right_child[u];
    }

    full[0] = complete[0] = true;
    int ans = 0;
    for (int i = n - 1; i >= 0; i--) {
        int u = order[i], l = left_child[u], r = right_child[u];
        height[u] = max(height[l], height[r]) + 1;
        full[u] = full[l] && full[r] && height[l] == height[r];
        // 我画了最后一层的两种接法,再把它们分别写成判断条件。
        complete[u] = (full[l] && complete[r] && height[l] == height[r])
                   || (complete[l] && full[r] && height[l] == height[r] + 1);
        if (complete[u]) ans++;
    }

    cout << ans;

    return 0;
}

P17012 [GESP202606 六级] 条形蛋糕

把整数长度的蛋糕分成若干块,按各块长度定价,求最大总售价。
提示1-不要只选单价最高的

看全部数据范围,\(n\le 1000\),\(p_{i}\le 10^{5}\)。

我会先试按单价贪心,但选完后可能还剩一截,补上它的价格也要算。

只比较单价,没把整个方案考虑全。

提示2-最后切下多长一块

我改成看最后切下的一块:长度为 \(j\) 时,剩余长度 \(i-j\) 的最高售价已经由 \(\operatorname{dp}_{i-j}\) 记录。

用 \(\operatorname{dp}_{i-j}+p_{j}\) 更新。

它和学习小组是同一个模型,同种长度可以重复切。

外层枚举总长度i,内层枚举最后一块长度j。整块不切也在j等于i时被考虑,dp[0]为0:

for (int j = 1; j <= i; j++) {
    dp[i] = max(dp[i], dp[i - j] + price[j]);
}
代码-满分-长度 DP

时间 \(O(n^{2})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 1005;

int price[N], dp[N];

int main() {
    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> price[i];

    for (int i = 1; i <= n; i++) {
        // 我枚举最后一块的长度,把剩余蛋糕交给已经算出的答案。
        for (int j = 1; j <= i; j++) {
            dp[i] = max(dp[i], dp[i - j] + price[j]);
        }
    }

    cout << dp[n];

    return 0;
}

P17013 [GESP202606 六级] 满二叉树

给定左右儿子,统计所有子树中,满二叉树的数量。
提示1-满二叉树要每层都满

40分做法,\(n\le 500\)。

看全部数据范围,\(n\le 10^{5}\)。

我先用一棵左右叶子不在同层的树试定义:即使每个非叶节点都有两个孩子,也不一定满。

还要每层都满,单个叶子也算。

提示2-左右子树也必须满

整棵树要每层都满,左右子树就必须都满,而且一样高。

我只让孩子传回这两个信息,父亲就能判断。

空树高度为 0、视为满。

先算孩子,再算父亲,叶子自然满足条件。

先算左右孩子,再算父亲u。空树0号视为满,下面两项缺一不可:

full[u] = full[l] && full[r] && height[l] == height[r];
height[u] = max(height[l], height[r]) + 1;
代码-40分-逐棵检查叶子深度

时间 \(O(n^{2})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 505;

int left_child[N], right_child[N];

bool check(int root) {
    queue<pair<int, int>> q;
    q.push({root, 1});
    int leaf_depth = -1;

    while (!q.empty()) {
        int u = q.front().first, depth = q.front().second;
        q.pop();
        int l = left_child[u], r = right_child[u];
        if (l == 0 && r == 0) {
            if (leaf_depth == -1) leaf_depth = depth;
            // 我同时检查叶子是否在同层,只有两个孩子还不够。
            if (leaf_depth != depth) return false;
        } else {
            if (l == 0 || r == 0) return false;
            q.push({l, depth + 1});
            q.push({r, depth + 1});
        }
    }

    return true;
}

int main() {
    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> left_child[i] >> right_child[i];

    int ans = 0;
    for (int i = 1; i <= n; i++) {
        if (check(i)) ans++;
    }
    cout << ans;

    return 0;
}
代码-满分-子树 DP

时间 \(O(n)\),空间 \(O(n)\)。按遍历顺序倒序计算,不依赖节点编号大小。

#include <bits/stdc++.h>

using namespace std;

const int N = 100005;

int order[N];

int left_child[N], right_child[N], height[N];
bool full[N];

int main() {
    ios::sync_with_stdio(false);
    cin.tie(0);

    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) cin >> left_child[i] >> right_child[i];

    int len = 0;
    order[len++] = 1;
    for (int i = 0; i < len; i++) {
        int u = order[i];
        if (left_child[u]) order[len++] = left_child[u];
        if (right_child[u]) order[len++] = right_child[u];
    }

    full[0] = true;
    int ans = 0;
    for (int i = n - 1; i >= 0; i--) {
        int u = order[i], l = left_child[u], r = right_child[u];
        height[u] = max(height[l], height[r]) + 1;
        // 我检查左右都满而且一样高,才能确认当前子树每层都满。
        full[u] = full[l] && full[r] && height[l] == height[r];
        if (full[u]) ans++;
    }

    cout << ans;

    return 0;
}

P17457 [GESP202609 六级] 数组划分

把数组分成非空连续段,最小化各段元素和的平方之和。
提示1-非负数可以直接拆

40分做法,\(0\le a_{i}\le 50\)。

看全部数据范围,\(n\le 2000\),\(-100\le a_{i}\le 100\)。

我先合并两个非负的段看看:\((x+y)^{2}\) 比 \(x^{2}+y^{2}\) 多了 \(2xy\)。

合并不会更好,所以非负子任务每个数单独一段即可。

提示2-负数会改变结论

再试 1 和 -1,合并后代价从 2 变成 0。

这个例子提醒我,有负数时不能沿用刚才的贪心。

我改成前缀 DP,枚举最后一段从哪里开始。

提示3-用前缀和算段和

最后一段若是 \(j+1\) 到 \(i\),我用前缀和马上得到段和 \(s_{i}-s_{j}\)。

用 \(\operatorname{dp}_{j}+(s_{i}-s_{j})^{2}\) 更新。

\(n\) 只有 2000,平方级足够,不必再找复杂优化。

平方用 long long。

sum是前缀和,dp[0]为0。外层i递增,内层j枚举最后一段前的位置;先算段和,再平方:

long long value = sum[i] - sum[j];
dp[i] = min(dp[i], dp[j] + value * value);
代码-40分-每个数单独一段

时间 \(O(n)\),额外空间 \(O(1)\),仅适用于所有元素非负。

#include <bits/stdc++.h>

using namespace std;

int main() {
    int n;
    long long ans = 0;

    cin >> n;
    for (int i = 1; i <= n; i++) {
        long long x;
        cin >> x;
        // 非负数合并不会更好,我把每个数单独算一段。
        ans += x * x;
    }

    cout << ans;

    return 0;
}
代码-满分-枚举最后一段

时间 \(O(n^{2})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 2005;
const long long INF = 1LL << 60;

long long sum[N], dp[N];

int main() {
    int n;

    cin >> n;
    for (int i = 1; i <= n; i++) {
        int x;
        cin >> x;
        sum[i] = sum[i - 1] + x;
    }

    for (int i = 1; i <= n; i++) {
        dp[i] = INF;
        // 负数可能抵消,我不再贪心,而是试遍最后一段的起点。
        for (int j = 0; j < i; j++) {
            long long value = sum[i] - sum[j];
            dp[i] = min(dp[i], dp[j] + value * value);
        }
    }

    cout << dp[n];

    return 0;
}

P17458 [GESP202609 六级] 分树规划

删掉树上一条边,分成两个连通块。
求两块节点数之差的绝对值最小是多少,不需要输出删哪条边。
提示1-40分:先真的试着删边

40分做法,\(2\le n\le500\)。

看全部数据范围,\(2\le n\le2\times10^4\)。

先别急着想优化,我先枚举删哪条边。搜索时跳过这条边,数出其中一块有 \(k\) 个节点,另一块就是 \(n-k\) 个。

这次删边的差值是 \(|k-(n-k)|=|n-2k|\)。试完所有边,取最小值。

每次搜索要扫一遍树,共有 \(n-1\) 条边,所以是 \(O(n^2)\)。大范围慢在反复统计同一批节点。

提示2-怎样不删边,也知道一块有多大

我任选节点 \(1\) 为根,给每个节点确定父亲。

对于非根节点 \(u\),删去它和父亲之间的边后,\(u\) 及其所有后代恰好分成一块,也就是以 \(u\) 为根的子树。

为什么不会从别处绕回父亲?因为树上两点之间只有一条路径。

所以我只需要求每个节点的子树大小 \(sz_u\),就能算出对应删边的差值 \(|n-2sz_u|\)。每个非根节点对应一条边,既不漏,也不重复。

提示3-子树大小怎样从孩子往上算

咱们先画一棵6个节点的树:\(1\) 的孩子是 \(2,3\);\(2\) 的孩子是 \(4,5\);\(3\) 的孩子是 \(6\)。

叶子 \(4,5,6\) 的子树都只有自己,大小为 \(1\)。

节点 \(2\) 的子树大小为 \(1+1+1=3\),节点 \(3\) 的为 \(1+1=2\)。这里第一个 \(1\) 是节点自己,不能漏掉。

删边 \(1\text{—}2\),两块就是 \(3\) 个和 \(6-3=3\) 个,差值为 \(0\);删边 \(1\text{—}3\),则是 \(2\) 个和 \(4\) 个,差值为 \(2\)。

我把这个计算推广到每个节点:自己的 \(1\),加上所有孩子的子树大小。你看,得先算孩子,再算父亲,顺序不能反过来。

提示4-代码为什么倒着遍历order

代码先从根出发,把节点放进 order,同时记录 parent。父亲一定先放进去,孩子后放进去;节点编号本身不保证这个顺序。

再倒着处理 order,孩子就一定先于父亲处理。每个节点先记大小为 \(1\),处理完它时,把完整的子树大小加给父亲。

例如节点 \(4,5\) 都处理完后,节点 \(2\) 的大小已经累加到 \(3\),这时才能计算删去它的父亲边会得到什么结果。

这里先看两句局部代码,sz[u]开始时是 \(1\)。倒着处理到节点 \(u\) 时,孩子已经把大小交给它了:

ans = min(ans, abs(n - 2 * sz[u]));
sz[parent[u]] += sz[u];

第一行算删父亲边的差值,第二行把自己的整棵子树交给父亲。这两句接在一起,才不会漏掉后代。

根没有父亲边,所以倒序循环不处理 order[0]。这样只遍历两遍树,时间降到 \(O(n)\),也避免长链递归过深。

提示5-静态数组怎样存边和遍历顺序

每条边要能双向走,所以输入一条边,要存两个方向。这里用静态邻接表:h[u]是节点u的第一条出边编号,e[j]是终点,ne[j]是下一条出边编号;编号 \(0\) 表示没有边。

下面是遍历节点u的邻居的局部写法。别被数组名绕住了,它做的事就是逐个取出邻居v:

for (int j = h[u]; j != 0; j = ne[j]) {
    int v = e[j];
    if (v == parent[u]) continue;
    parent[v] = u;
    order[len++] = v;
}

order也是静态数组,len记录已放入多少个节点。父亲先放入,孩子后放入,倒过来算就行。

树最多 \(20000\) 个节点、\(19999\) 条边,边数组留出两个方向的容量。这里不用额外添加输入加速。

代码-40分-枚举删边

时间 \(O(n^{2})\),空间 \(O(n)\)。

#include <bits/stdc++.h>

using namespace std;

const int N = 505;

int h[N], e[N * 2], ne[N * 2], idx;
int from[N], to[N];
bool used[N];
int q[N];

void add(int u, int v) {
    idx++;
    e[idx] = v;
    ne[idx] = h[u];
    h[u] = idx;
}

int main() {
    int n;

    cin >> n;
    for (int i = 1; i < n; i++) {
        cin >> from[i] >> to[i];
        add(from[i], to[i]);
        add(to[i], from[i]);
    }

    int ans = n;
    // 我先逐条试删边,再数一侧的节点,作为小范围办法。
    for (int i = 1; i < n; i++) {
        int a = from[i], b = to[i], count = 0;
        fill(used, used + n + 1, false);
        int hh = 0, tt = 0;
        q[tt++] = a;
        used[a] = true;
        while (hh < tt) {
            int u = q[hh++];
            count++;
            for (int j = h[u]; j != 0; j = ne[j]) {
                int v = e[j];
                if ((u == a && v == b) || (u == b && v == a)) continue;
                if (used[v]) continue;
                used[v] = true;
                q[tt++] = v;
            }
        }
        ans = min(ans, abs(n - 2 * count));
    }

    cout << ans;

    return 0;
}
代码-满分-统计子树大小

时间 \(O(n)\),空间 \(O(n)\)。先遍历,再倒序累计子树大小。

#include <bits/stdc++.h>

using namespace std;

const int N = 20005;

int h[N], e[N * 2], ne[N * 2], idx;
int parent[N], sz[N], order[N];

void add(int u, int v) {
    idx++;
    e[idx] = v;
    ne[idx] = h[u];
    h[u] = idx;
}

int main() {
    int n;

    cin >> n;
    for (int i = 1; i < n; i++) {
        int u, v;
        cin >> u >> v;
        add(u, v);
        add(v, u);
    }

    int len = 1;
    order[0] = 1;
    for (int i = 0; i < len; i++) {
        int u = order[i];
        sz[u] = 1;
        for (int j = h[u]; j != 0; j = ne[j]) {
            int v = e[j];
            if (v == parent[u]) continue;
            parent[v] = u;
            order[len++] = v;
        }
    }

    int ans = n;
    for (int i = n - 1; i > 0; i--) {
        int u = order[i];
        // 孩子已经把大小加给了我,此时sz[u]就是完整的子树大小。
        ans = min(ans, abs(n - 2 * sz[u]));
        // 我处理完自己的子树,再把大小交给父亲。
        sz[parent[u]] += sz[u];
    }

    cout << ans;

    return 0;
}