In mathematics you don't understand things, you just get used to them.

标签 dp 下的文章

讲课讲得非常清楚啊,我绝赞膜拜。节奏可以,思路清晰,解法自然,为讲师点赞。

第一个题是 loj3282 / joisc2020 - Treatment Project。原问题由 $\left(S, t, w\right)$ 三个维度构成,分别表示村民被治疗的状态,时间,花费。比较自然的思路是针对 $t$ 做规划,但是这样有一个问题——$t$ 和 $S$ 是息息相关的,若从 $t$ 入手则几乎不得不记录 $S$,这是不被接受的。考虑直接 $S$ 入手。如果我可以考虑一段连续的区间而不是子序列,是不是复杂度一定会降?那么考察前缀,具体,设 $f_i$ 表示将 $[1, i]$ 的村民治好的最小花费。进一步地,不妨将 $f_i$ 重新描述为将 $[1, r_i]$ 的村民治好的最小花费。

为什么这样设计状态就不需要考虑时间的影响了(不是说整体,仅仅是在设计状态的时候)?我个人的理解是这样做将村民,或者说 $S$,作为第一个研究的对象,而时间则作为后续求解中对 $f$ 的限制,所以放到后面考虑即可。

转移方程即为 $\displaystyle f_i\gets\min_{1\leqslant j<i,r_j-l_i+1\geqslant|t_i-t_j|}\{f_j\}+w_i$。将绝对值拆开,这里以 $t_i\geqslant t_j$ 为例。那么转移的条件就成了 $r_j+t_j+1\geqslant t_i+l_i$。那么现在转移有二维偏序的关系,即 $t_i\geqslant t_j,r_j+t_j+1\geqslant t_i+l_i$。在优化之前,首先注意到这是一个最短路模型,转移条件即连边的条件,考虑用最优的 $f_j$ 去松弛 $f_i$,那么每个 $f_i$ 就只会被松弛一次(几乎是自明的),于是按 $t$ 排序后用线段树维护即可。

#include <bits/stdc++.h>
using namespace std;
// implementation: time-ordered
using ll = long long;
using pli = pair<ll, int>;
const ll inf = 1e18;
int n, m;
ll dp[100100];
struct node {
    int l, r, t;
    ll w;
} a[100100];
priority_queue<pli, vector<pli>, greater<pli>> q; // slacked
struct segment_tree {
    ll mix[400100], miy[400100];
    void pull(int now) {
        mix[now] = min(mix[now*2], mix[now*2+1]);
        miy[now] = min(miy[now*2], miy[now*2+1]);
    }
    void ins(int pos, ll x, ll y, int now=1, int l=1, int r=n) {
        if (l == r) {
            mix[now] = x, miy[now] = y;
            return;
        }
        int mid = (l+r)/2;
        if (mid >= pos) ins(pos, x, y, now*2, l, mid);
        else ins(pos, x, y, now*2+1, mid+1, r);
        pull(now);
    }
    void slackx(int lq, int rq, ll lim, ll dlt, int now=1, int l=1, int r=n) {
        if (lq > rq || l > rq || r < lq || mix[now] > lim) return;
        if (l == r) {
            dp[l] = dlt+a[l].w;
            q.emplace(dp[l], l);
            mix[now] = miy[now] = inf;
            return;
        }
        int mid = (l+r)/2;
        slackx(lq, rq, lim, dlt, now*2, l, mid);
        slackx(lq, rq, lim, dlt, now*2+1, mid+1, r);
        pull(now);
    }
    void slacky(int lq, int rq, ll lim, ll dlt, int now=1, int l=1, int r=n) {
        if (lq > rq || l > rq || r < lq || miy[now] > lim) return;
        if (l == r) {
            dp[l] = dlt+a[l].w;
            q.emplace(dp[l], l);
            mix[now] = miy[now] = inf;
            return;
        }
        int mid = (l+r)/2;
        slacky(lq, rq, lim, dlt, now*2, l, mid);
        slacky(lq, rq, lim, dlt, now*2+1, mid+1, r);
        pull(now);
    }
} sgt;
signed main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> m >> n;
    for (int i=1;i<=n;++i) {
        dp[i] = inf;
        cin >> a[i].t >> a[i].l >> a[i].r >> a[i].w;
    }
    sort(a+1, a+n+1, [&](node a, node b) {
        return a.t < b.t;
    });
    for (int i=1;i<=n;++i) {
        if (a[i].l == 1) {
            dp[i] = a[i].w;
            q.emplace(dp[i], i);
            sgt.ins(i, inf, inf);
        }
        else {
            sgt.ins(i, a[i].l-a[i].t, a[i].l+a[i].t);
        }
    }
    while (!q.empty()) {
        // use nodes slacked to slack other nodes
        int x = q.top().second;
        q.pop();
        sgt.slackx(1, x-1, a[x].r-a[x].t+1, dp[x]);
        sgt.slacky(x+1, n, a[x].r+a[x].t+1, dp[x]);
    }
    ll ans = inf;
    for (int i=1;i<=n;++i) {
        if (a[i].r == m) {
            ans = min(ans, dp[i]);
        }
    }
    if (ans == inf) cout << "-1\n";
    else cout << ans << "\n";
    return 0;
}

第二个题是 codeforces - 1476F整个课上唯一一个自己想出来的题,还错了个细节。直接 dp 有着自明的后效性,说两种消除之的方法。第一个是「将 $i$ 点亮灯的能力挂到 $i$ 能够点亮的最右边的灯上」,这样一次操作只会影响前面的,这消除了后效性。第二个直接在 dp 状态上下手,也是正解的思路,即设 $f_i$ 表示以 $[1, i]$ 的灯能够点亮的最长前缀(完全可能超过 / 不足 $i$),这样相当于将「点灯的灯」和「被点的灯」隔离开了,影响就被消除了。

因为我写的第二个,所以说一下第二个(其实是差不多的)。先考虑 $i$ 向右照的情况,转移非常简单 $f_i\gets\max\{f_i, i+p_i\}$(当然 $f_i$ 要先继承 $f_{i-1}$,这也昭示了其单调性)。$i$ 向左照的情况比较复杂。考虑有这样一个 $j$,满足 $f_j\geqslant i-p_i-1$(保证维护的是前缀),那么让 $\forall t,s.t.j<t<i$ 向右照是最优的,贪心地想,我们就需要使得 $j$ 最小,由于 $f$ 有单调性,直接二分即可,然后 RMQ 找 $(j, i)$ 区间中最大的 $t+p_t$ 即可。

#include <bits/stdc++.h>
using namespace std;
int n, p[300100], dp[300100], dp2[20][300100], lgs[300100], pre[300100], isl[300100];
int get(int l, int r) {
    if (l > r) return 0;
    int k = lgs[r-l+1];
    return max(dp2[k][l], dp2[k][r-(1<<k)+1]);
}
void print(int now) {
    if (now == 0) return;
    print(pre[now]);
    if (isl[now]) {
        cout << "R";
        return;
    }
    for (int i=pre[now]+1; i<now; ++i) cout << "R";
    cout << "L";
}
void solve() {
    cin >> n;
    for (int i=1;i<=n;++i) {
        cin >> p[i];
        dp2[0][i] = i+p[i];
    }
    for (int i=1;(1<<i)<=n;++i) {
        for (int j=1;j+(1<<i)-1<=n;++j) {
            dp2[i][j] = max(dp2[i-1][j], dp2[i-1][j+(1<<(i-1))]);
        }
    }
    pre[1] = 0, isl[1] = 1;
    for (int i=2;i<=n;++i) {
        dp[i] = dp[i-1], pre[i] = i-1, isl[i] = 1;
        if (dp[i-1] >= i) {
            dp[i] = max(dp[i], i+p[i]);
        }
        int l = 0, r = i-1, j = -1, mid;
        while (l <= r) {
            mid = (l+r)/2;
            if (dp[mid] >= i-p[i]-1) r = mid-1, j = mid;
            else l = mid+1;
        }
        if (j == -1) continue;
        if (max(i-1, get(j+1, i-1)) >= dp[i]) {
            isl[i] = 0, pre[i] = j;
        }
        dp[i] = max({dp[i], i-1, get(j+1, i-1)});
    }
    if (dp[n] >= n) {
        cout << "YES\n";
        print(n);
        cout << "\n";
        return;
    }
    cout << "NO\n";
}
signed main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    int tt;
    for (int i=2; i<300100; ++i) {
        lgs[i] = lgs[i>>1]+1;
    }
    for (cin>>tt;tt--;) {
        solve();
    }
}

第三个题是 codeforces - 1523F。再次考虑这个题的维度 $(S, t, i, w)$,分别表示点亮传送塔的集合,时间,所处位置(传送塔或任务点),完成任务数量。据此可以写出一个朴素的 dp,$f_{S, i, w}$ 表示点亮传送塔的集合为 $S$,当前在位置 $i$,完成了 $w$ 个任务,所花费的最小时间。这里把题目中的「答案」完成任务数量作为状态并非不自然,是单纯因为时间太大忍不下。这里有两个重要的观察:

  • 处在传送塔时,不关心自己的位置;
  • 处在任务点时,不关心当前的时间。

正确性不必多言,关键在于观察到之的洞察力。于是我们可以优化状态(这里的根据是,传送塔 $\cup$ 任务点 $=$ 所有地址),分别定义 $f_{S, i}$,$g_{S, i}$ 表示点亮传送塔的集合为 $S$,完成了 $i$ 个任务,并且现在处于传送塔的最小时间,和点亮传送塔的集合为 $S$,在 $i$ 个地址,最大完成任务总数。转移分 传送塔 $\rightarrow$ 任务点、传送塔 $\rightarrow$ 传送塔、任务点 $\rightarrow$ 任务点、任务点 $\rightarrow$ 传送塔 讨论即可。同时还有转移顺序的问题,具体见代码。

#include <bits/stdc++.h>
#define cmin(x, y) x = min(x, y)
#define cmax(x, y) x = max(x, y)
using namespace std;
const int inf = 0x3f3f3f3f;
int n, m, ans = -inf;
int w[16484][120], up, f[16484][120], g[16484][120];
struct pnt {
    int x, y, t;
} a[120];
int dst(int i, int j) {
    return abs(a[i].x-a[j].x)+abs(a[i].y-a[j].y);
}
signed main() {
    ios::sync_with_stdio(0);
    cin.tie(0);
    cin >> n >> m;
    up = 1<<n;
    for (int i=0; i<n; ++i) {
        cin >> a[i].x >> a[i].y;
    }
    for (int i=n; i<n+m; ++i) {
        cin >> a[i].x >> a[i].y >> a[i].t;
    }
    sort(a+n, a+n+m, [&](pnt x, pnt y) {
        return x.t < y.t;
    });
    for (int s=0; s<up; ++s) {
        for (int i=0; i<n+m; ++i) {
            w[s][i] = inf;
            for (int j=0; j<n; ++j) {
                if (s&(1<<j)) cmin(w[s][i], dst(i, j));
            }
        }
    }
    for (int s=0; s<up; ++s) {
        for (int i=0; i<m; ++i) f[s][i] = inf, g[s][i] = -inf;
        f[s][m] = inf;
    }
    for (int i=0; i<n; ++i) f[1<<i][0] = 0;
    for (int i=0; i<m; ++i) g[0][i] = 1;
    for (int s=0; s<up; ++s) {
        for (int i=0; i<=m; ++i) {
            if (f[s][i] != inf) {
                for (int j=0; j<n; ++j) {
                    if ((s&(1<<j)) == 0) {
                        cmin(f[s|(1<<j)][i], f[s][i]+w[s][j]);
                    }
                }
                for (int j=0; j<m; ++j) {
                    if (f[s][i]+w[s][j+n] <= a[j+n].t) {
                        cmax(g[s][j], i+1);
                    }
                }
            }
        }
        for (int i=0; i<m; ++i) {
            if (g[s][i] >= 0) {
                for (int j=0; j<n; ++j) {
                    if ((s&(1<<j)) == 0) {
                        cmin(f[s|(1<<j)][g[s][i]], min(dst(i+n, j), w[s][j])+a[i+n].t);
                    }
                }
                for (int j=i+1; j<m; ++j) {
                    if (min(dst(i+n, j+n), w[s][j+n])+a[i+n].t <= a[j+n].t) {
                        cmax(g[s][j], g[s][i]+1);
                    }
                }
                cmax(ans, g[s][i]);
            }
        }
    }
    cout << ans << "\n";
    return 0;
}

link.

对于 pass 1, 你把他考虑成 $\frac{\sum x}{i}$ 的形式, 于是每次操作的贡献就是 $\frac{2}{i}$, 那么答案就是 $\sum_{i=2}^n\frac{2}{i}$.

对于 pass 2, 首先由全概率公式 $\textbf{E}(n)=\sum_{i=0}^{\infty}\textbf{P}(n\geqslant i)$. 那么我们考虑固定 $k=i$, 求出 $\textbf P(n\geqslant k)$. 其实直接求这个有点麻烦, 强化一下条件, 我们求 $\textbf P(n=k)$, 最后后缀和即可取得 $\textbf P(n\geqslant k)$.

设 $f(i,j)$ 表示共 $i$ 个叶结点, 树深度为 $j$ 时的概率. 对于二叉树我们有一种经典的考虑子结构的方式, 即考虑左子树的信息. 对于此题, 我们考虑左子树 (相对于根结点来说) 的叶结点数量 $k$. 那么有 $f(i,\max\{k,l\}+1)\gets f(i,\max\{k,l\}+1)+\frac{f(j,k)\times f(i-j,l)}{i-1}$. 其中 $i$ 为全树的叶子个数, $k$, $l$ 分别为左, 右子树的深度, $j$ 为左子树的叶结点个数. $\frac{1}{i-1}$ 是对于一个有 $i$ 个叶子的二叉树, 有 $k$ 个结点在左子树的概率, which 与 $k$ 无关. 至于为什么是 $\frac{1}{i-1}$, 你考虑操作次数为 $i-1$ 即可.

#include <bits/stdc++.h>
using namespace std;
typedef double db;
int Q, N;
db f[200][200];
signed main() {
  ios::sync_with_stdio(0), cin.tie(0);
  cin >> Q >> N;
  cout << fixed;
  cout << setprecision(6);
  if (Q == 1) {
    db ans = 0;
    for (int i = 2; i <= N; ++i) {
      ans += 2.0 / i;
    }
    cout << ans << "\n";
  } else if (Q == 2) {
    f[1][0] = f[2][1] = f[3][2] = 1;
    for (int i = 4; i <= N; ++i) {
      for (int j = 1; j < i; ++j) {
        for (int k = 0; k < j; ++k) {
          for (int l = 0; l < i - j; ++l) {
            f[i][max(k, l) + 1] += f[j][k] * f[i - j][l] / (i - 1);
          }
        }
      }
    }
    db ans = 0;
    for (int i = N; i >= 1; --i) {
      f[N][i] += f[N][i + 1];
    }
    for (int i = 1; i < N; ++i) {
      ans += f[N][i];
    }
    cout << ans << "\n";
  }
  return 0;
}

link。

朴素的做法就是二元组 $(a_i,b_i)$ 序列的 LIS dp,同时维护 LIS 的数量。$i$ 可在 $j$ 决策的条件是 $i>j,a_j<a_i,b_j<b_i$(将时间视为下标)。

于是联想到使用 cdq dac 优化,使用树状数组 / 线段树支持单点合并 dp 值,最大值。

然后,,第二问要维护反转过来的 dp 值,再跑一遍就行了。

说几个坑点,在 dac 时 $l=r$ 给 dp 赋初值时,dp 在 $l$ 上的点值不一定是第一次被访问,所以不能简单的赋为 $(1,1)$,要取 max(对着 xtw 的代码灵魂对照才找出来 憨笑)。

因为 $[l,mid]$ 的 dp 决策是对 $(mid,r]$ 的决策有影响的,所以不能先把左右两边跑完再计算跨区间贡献,而是应该先把 $[l,mid]$ 跑完,然后再跑右区间。这又带来一个细节,对左右区间的以 $a_i$ 为关键字的排序会使得原区间的 $t_i$(下标)无序,需要在递归之前排回来。

#include <bits/stdc++.h>
using namespace std;
#define cmin(x, y) x = min(x, y)
#define cmax(x, y) x = max(x, y)
#define fors(i, l, r, ...) for(int i = (l), REP##i = (r), ##__VA_ARGS__; i <= REP##i; ++i)
#define dfors(i, r, l, ...) for(int i = (r), REP##i = (l), ##__VA_ARGS__; i >= REP##i; --i)
int f1[50100], f2[50100];
double g1[50100], g2[50100];
int n, _n;
struct request {
    int t, x, y;
} req[50100], tmp[50100];
struct S {
    int x = 0;
    double p = 0;
} bit[50100];
void merge(S& x, const S& y) {
    if(x.x > y.x) x = x;
    else if(x.x < y.x) x = y;
    else x = (S){x.x, x.p+y.p};
}
void add(int x, const S& v) {
    for(; x; x -= x&-x) merge(bit[x], v);
}
void reset(int x) {
    for(; x; x -= x&-x) bit[x] = (S){0, 0};
}
S ask(int x) {
    S res;
    for(; x <= _n; x += x&-x) merge(res, bit[x]);
    return res;
}
void solve(const int l, const int r, int f[], double g[]) {
    if(l == r) {
        cmax(f[req[l].t], 1), g[req[l].t] += f[req[l].t] == 1;
        return;
    }
    int mid = (l+r)>>1;
    sort(req+l, req+r+1,
        [](const request& lhs, const request& rhs) { return lhs.t < rhs.t; });
    solve(l, mid, f, g);
    sort(req+l, req+mid+1,
        [](const request& lhs, const request& rhs) { return lhs.y > rhs.y; });
    sort(req+mid+1, req+r+1,
        [](const request& lhs, const request& rhs) { return lhs.y > rhs.y; });
    int j = l;
    fors(i, mid+1, r) {
        for(; j <= mid && req[j].y >= req[i].y; ++j) add(req[j].x, (S){f[req[j].t], g[req[j].t]});
        S ret = ask(req[i].x);
        if(ret.x == 0) continue;
        if(f[req[i].t] < ret.x+1) f[req[i].t] = ret.x+1,g[req[i].t] = ret.p;
        else if(f[req[i].t] == ret.x+1) g[req[i].t] += ret.p;
    }
    fors(i, l, j) reset(req[i].x);
    solve(mid+1, r, f, g);
}
signed main() {
    ios::sync_with_stdio(0), cin.tie(0);
    cin >> n;
    vector<int> v;
    fors(i, 1, n) {
        cin >> req[i].x >> req[i].y;
        req[i].t = i;
        v.emplace_back(req[i].x);
    }
    sort(v.begin(), v.end());
    v.erase(unique(v.begin(), v.end()), v.end());
    _n = static_cast<int>(v.size());
    fors(i, 1, n) req[i].x = lower_bound(v.begin(), v.end(), req[i].x)-v.begin()+1;
    solve(1, n, f1, g1);
    int max_f1 = *max_element(f1+1, f1+n+1);
    double sum_g1 = 0;
    fors(i, 1, n) sum_g1 += (max_f1 == f1[i])*g1[i];
    fors(i, 1, n) req[i].t = n-req[i].t+1,req[i].x = _n-req[i].x+1,req[i].y *= -1;
    solve(1, n, f2, g2);
    reverse(f2+1, f2+n+1);
    reverse(g2+1, g2+n+1);
    cout << max_f1 << "\n";
    fors(i, 1, n) cout << (f1[i]+f2[i]-1 == max_f1 ? g1[i]*g2[i]/sum_g1 : 0.0) << " \n"[i == n];
    return 0;
}

link。

理一下逻辑,主要讲一下我做题时的疑惑和其它题解没提到的细节。

首先容易看到,一个必然不劣的贪心策略是把尽量靠近根的层铺成同样的字符。也许会有疑惑,字符串是否本质不同的判定每个位置地位相等。然而在这题里面字符串个数的贡献是和结点所为根的子树大小有关的,所以这个贪心不劣。

设树的最大深度为 $d$,那么如果我们能实现每层都铺成同样的字符,答案就是不同长度的字符串个数,也就是 $d$。

考虑构造出答案为 $d+1$ 的情况,这时候贪心就跑不出来了,具体情况是,会出现一层既有 a 又有 b,其他层都是 pure a 或 pure b

主要问题就在于 miscellaneous level 放在哪里,才能让答案最小。直觉地,我们认为把它放在尽量低(离根更远)的 level 会更优。但是其实贡献结束的地方是叶子,所以其实我们应该把它放在叶子尽量多的 level。这个时候答案为 $d+1$。

根据以上的推理,我们得出一个论断:最优答案在 $d$ 和 $d+1$ 出现。剩下的问题就是如果判断是前者还是后者。

其实这是一个简单可达性 dp 可以解决的问题,将每一个 level 压成一个 item,其体积为当层的结点个数。但是这个是 $O(n^2)$ 的,要考虑优化。有两种解决的道路,分析后可以发现一个根号规模的结论,或者用 std::bitset 优化 dp。

注意 std::bitset 优化 dp 后状态略有不同……

#include<bits/stdc++.h>
#define cmin(x, y) x = std::min(x, y)
#define cmax(x, y) x = std::max(x, y)
#define fors(i, l, r, ...) for(int i = (l), REP##i = (r), ##__VA_ARGS__; i <= REP##i; ++i)
#define dfors(i, r, l, ...) for(int i = (r), REP##i = (l), ##__VA_ARGS__; i >= REP##i; --i)
// observation: amount different answers would be 2: maximum depth, or, it plus 1
int n, _x, dep[100100], nod[100100], leaf[100100], len, vol[100100];
// params: @nod[i]: amout of nodes at level i; @leaf[i]: amout of leaves at lv i;
//     #vol[i]: volume of i-th item
std::bitset<100100> f[2100]; // if can assign it to j perfectly within lefmost i items
std::vector<int> adj[100100], zip[100100];
void pre_dfs(const int x) {
    nod[dep[x]]++,leaf[dep[x]] += adj[x].empty();
    for(const int y : adj[x]) dep[y] = dep[x]+1,pre_dfs(y);
}
int pre_zip() {
    static int vis[100100], tot;
    fors(i, 1, len) {
        if(!vis[nod[i]]) vol[vis[nod[i]] = ++tot] = nod[i];
        zip[vis[nod[i]]].emplace_back(i);
    }
    return tot; // amount of items
}
signed main() {
    std::ios_base::sync_with_stdio(0);
    std::cin.tie(0);
    std::cin >> n >> _x;
    fors(i, 1, n-1, f) std::cin >> f,adj[f].emplace_back(i+1);
    pre_dfs(dep[1] = 1),len = *std::max_element(dep+1, dep+n+1); // processing basic information
    int m = pre_zip(); // compressing levels with same amout of nodes
    fors(i, f[0][0] = 1, m, tmp) {
        f[i] = f[i-1],tmp = zip[i].size();
        for(int j = 1; j <= tmp; j *= 2) tmp -= j,f[i] |= (f[i]<<(vol[i]*j));
        if(tmp > 0) f[i] |= (f[i]<<(vol[i]*tmp));
    }
    std::vector<bool> k(len);
    std::function<void(int, int)> find_path = [&](const int rem, int t) { // reviving way we DP through
        if(rem == 0) return;
        for(const int x : zip[rem]) {
            if(vol[rem] > t || f[rem-1][t]) break;
            t -= vol[rem],k[x-1] = 1;
        }
        find_path(rem-1, t);
    };
    if(f[m][_x]) { // when greedy strategy runs well
        std::cout << len << "\n";
        find_path(m, _x);
        fors(i, 1, n) std::cout << (k[dep[i]-1]?'a':'b');
        return std::cout << "\n", 0;
    }
    std::cout << len+1 << "\n"; // otherwise the answer would be maximum depth plus 1
    int tmp = std::numeric_limits<int>::max(), tmp1 = -1;
    dfors(i, _x, 0) if(f[m][i]) {
        tmp = i; break;
    }
    find_path(m, tmp);
    fors(i, 1, len) if(!k[i-1] && leaf[i] >= _x-tmp) {
        tmp1 = i; break;
    }
    fors(i, 1, n) {
        if(dep[i] == tmp1 && adj[i].empty()) std::cout << (tmp == _x?'b':(++tmp, 'a'));
        else std::cout << (k[dep[i]-1]?'a':'b');
    }
    return std::cout << "\n", 0;
}

link。

理一下逻辑,主要讲一下我做题时的疑惑和其它题解没提到的细节。

首先容易看到,一个必然不劣的贪心策略是把尽量靠近根的层铺成同样的字符。也许会有疑惑,字符串是否本质不同的判定每个位置地位相等。然而在这题里面字符串个数的贡献是和结点所为根的子树大小有关的,所以这个贪心不劣。

设树的最大深度为 $d$,那么如果我们能实现每层都铺成同样的字符,答案就是不同长度的字符串个数,也就是 $d$。

考虑构造出答案为 $d+1$ 的情况,这时候贪心就跑不出来了,具体情况是,会出现一层既有 a 又有 b,其他层都是 pure a 或 pure b

主要问题就在于 miscellaneous level 放在哪里,才能让答案最小。直觉地,我们认为把它放在尽量低(离根更远)的 level 会更优。但是其实贡献结束的地方是叶子,所以其实我们应该把它放在叶子尽量多的 level。这个时候答案为 $d+1$。

根据以上的推理,我们得出一个论断:最优答案在 $d$ 和 $d+1$ 出现。剩下的问题就是如果判断是前者还是后者。

其实这是一个简单可达性 dp 可以解决的问题,将每一个 level 压成一个 item,其体积为当层的结点个数。但是这个是 $O(n^2)$ 的,要考虑优化。有两种解决的道路,分析后可以发现一个根号规模的结论,或者用 std::bitset 优化 dp。

注意 std::bitset 优化 dp 后状态略有不同……

#include<bits/stdc++.h>
#define cmin(x, y) x = std::min(x, y)
#define cmax(x, y) x = std::max(x, y)
#define fors(i, l, r, ...) for(int i = (l), REP##i = (r), ##__VA_ARGS__; i <= REP##i; ++i)
#define dfors(i, r, l, ...) for(int i = (r), REP##i = (l), ##__VA_ARGS__; i >= REP##i; --i)
// observation: amount different answers would be 2: maximum depth, or, it plus 1
int n, _x, dep[100100], nod[100100], leaf[100100], len, vol[100100];
// params: @nod[i]: amout of nodes at level i; @leaf[i]: amout of leaves at lv i;
//     #vol[i]: volume of i-th item
std::bitset<100100> f[2100]; // if can assign it to j perfectly within lefmost i items
std::vector<int> adj[100100], zip[100100];
void pre_dfs(const int x) {
    nod[dep[x]]++,leaf[dep[x]] += adj[x].empty();
    for(const int y : adj[x]) dep[y] = dep[x]+1,pre_dfs(y);
}
int pre_zip() {
    static int vis[100100], tot;
    fors(i, 1, len) {
        if(!vis[nod[i]]) vol[vis[nod[i]] = ++tot] = nod[i];
        zip[vis[nod[i]]].emplace_back(i);
    }
    return tot; // amount of items
}
signed main() {
    std::ios_base::sync_with_stdio(0);
    std::cin.tie(0);
    std::cin >> n >> _x;
    fors(i, 1, n-1, f) std::cin >> f,adj[f].emplace_back(i+1);
    pre_dfs(dep[1] = 1),len = *std::max_element(dep+1, dep+n+1); // processing basic information
    int m = pre_zip(); // compressing levels with same amout of nodes
    fors(i, f[0][0] = 1, m, tmp) {
        f[i] = f[i-1],tmp = zip[i].size();
        for(int j = 1; j <= tmp; j *= 2) tmp -= j,f[i] |= (f[i]<<(vol[i]*j));
        if(tmp > 0) f[i] |= (f[i]<<(vol[i]*tmp));
    }
    std::vector<bool> k(len);
    std::function<void(int, int)> find_path = [&](const int rem, int t) { // reviving way we DP through
        if(rem == 0) return;
        for(const int x : zip[rem]) {
            if(vol[rem] > t || f[rem-1][t]) break;
            t -= vol[rem],k[x-1] = 1;
        }
        find_path(rem-1, t);
    };
    if(f[m][_x]) { // when greedy strategy runs well
        std::cout << len << "\n";
        find_path(m, _x);
        fors(i, 1, n) std::cout << (k[dep[i]-1]?'a':'b');
        return std::cout << "\n", 0;
    }
    std::cout << len+1 << "\n"; // otherwise the answer would be maximum depth plus 1
    int tmp = std::numeric_limits<int>::max(), tmp1 = -1;
    dfors(i, _x, 0) if(f[m][i]) {
        tmp = i; break;
    }
    find_path(m, tmp);
    fors(i, 1, len) if(!k[i-1] && leaf[i] >= _x-tmp) {
        tmp1 = i; break;
    }
    fors(i, 1, n) {
        if(dep[i] == tmp1 && adj[i].empty()) std::cout << (tmp == _x?'b':(++tmp, 'a'));
        else std::cout << (k[dep[i]-1]?'a':'b');
    }
    return std::cout << "\n", 0;
}