NOI2014 购票
给一个形式化的描述:
给定一棵树,定义两个点之间的距离为两点路径上的边权和。指定\(1\)号节点为根节点。树上的每个节点\(i\)都有三个属性:\(P_i,Q_i,L_i\)。
可以从节点\(i\)出发,移动不超过\(L_i\)的长度,到达另一个节点。设移动的距离为\(d\),那么本次移动需要花费的代价为\(P_id+Q_i\)。
这道题对我来说难度确实还是有点大。但是由于点分治和斜率优化都是我最近在攻克的知识点,我相信这道题有一做的必要。由于本人只有\(NOIp\)级别的水平,要点的安排可能会不太平衡。
1 从链开始
考场需求的最基本技能就是先拿部分分,再拿全分。先考虑链上的做法。
设\(F(i)\)表示从点\(i\)走到点\(1\),所需的最少花费。我们可以选择一个合法的中继点\(j\),从而求得最小的花费。设\(d(i)\)表示点\(i\)到根节点\(1\)的距离,有:
\[
F(i) = \min_{0 \leq j < i, d(i) - d(j) \leq L_i}\{F(j) + P_i[d(j) - d(i)] + Q_i\}
\]
假设\(j_0\)是最优决策,有:\(F(i) = F(j_0)+P_id(j_0) - P_id(i) + Q(i)\)。
这里含有\(i\)和\(j\)的乘积项。令\(Y=F(j_0)\),\(X=d(j_0)\),有:
\[
Y = P_iX + F(i) - P_id(i) - Q_i
\]
令截距\(F(i)-P_id(i)-Q_i\)最小,我们就要维护一个下凸壳,并每次让直线和凸壳“相切”。由于\(P_i\)是非单调变化的,但恒为正值,我们只需要维护一个单调栈,每次二分查找最优决策就可以了。
关于\(d(i) - d(j) \leq L_i\)的限制,我寻思可以用二分找到第一个满足\(d(j) \geq d(i) - L_i\)的\(j_0\)。由于\(d(j)\)的单调性,我们可以在\(j_0\)到栈顶之间二分。
2 树的处理
树怎么处理?树上最麻烦的事情是如何维护凸壳。我们必须摒弃之前“单调队列”的思想,即算出一个新的\(F\)值后就将它马上加到决策集合中。
我们修改一下转移方程,然后观察规律:
\[
F(i) = \min_{j \in \text{anc}(i), d(i) - d(j) \leq L_i}\{F(j) + P_i[d(i) - d(j)] + Q_i\}
\]
相比链,对于一个状态\(F(i)\),我们只能从\(i\)的祖先转移过来。从另一个角度来看,对于一个决策\(j\),我们可以转移到它的子树中去。因此,我们选择“刷表法”,将当前的答案转移到子树里去,可以转移更多的答案。
更进一步,如果点\(i\)可以转移给\(i\)子树,点\(i\)的祖先也一定可以转移给\(i\)子树。只要枚举\(i\)的祖先,就可以更新几乎整个\(i\)子树。“几乎”二字是考虑到\(L_i\)的限制。我们可以用一个数\(c_i=d(i) - L_i\)来衡量点\(i\)对父亲的限制程度,只有当\(d(j) \geq c_i\)时,\(j\)才可以更新\(i\)。
因此,我们脑海中已经有了大致思路:找到若干条树上的“父亲-儿子“链,每次更新链末端所详解的子树。你也可以想象一下,每次找到一个”扫把”,然后用“扫把头“更新”扫把尾”。
3 算法流程
三句话:
- 找到当前子树的重心,递归计算重心以上部分的答案。
- 用斜率优化,找到重心以下节点的所有祖先,将祖先的答案传到重心以下去。
- 递归计算重心以下部分的答案。
如果对这三句话不能理解,可以在下面的斜体字中节选阅读,或者直接参考代码部分,看看是否会有所帮助。
在给定的一棵树上,我们可以用\((\rho,s)\)表示一个子树,其中\(\rho\)是这个子树的根,\(s\)是这个子树的大小。可以在这个子树上找到一个点\(\gamma\),将子树划分成\((\rho,s_1)\)和\((\gamma,s-s_1)\)两个部分。此时路径\(\rho,\cdots,\gamma\)上的所有点都是\((\gamma,s-s_1)\)的祖先,可以进行答案转移。为了尽可能多地转移答案,我们需要找到一个合适的点\(\gamma\),使得\(s_1 \simeq \frac{1}{2}s\),将子树分成较为均匀的两个部分。
如果想要计算一个子树\((\rho,s)\),我们就必须先计算\((\rho,\frac{1}{2}{s})\),然后用这部分答案更新\((\gamma,\frac{1}{2}s)\)。怎么更新呢?先要递归计算\((\rho,\frac{1}{2}s)\)的值。接着,我们遍历\((\gamma,\frac{1}{2}s)\)内的所有节点,并拷贝到sub
数组中。同理,我们将\(\text{father}(\gamma)\)到\(\rho\)上的路径拷贝到另一个数组anc
中。为了方便转移,对于anc
,我们按照\(d(x)\)大小从大到小排序。对于sub
,我们按照\(d(i) - L_i\)从大到小排序——这个指标反映了当前节点的对祖先的限制程度。
枚举每一个sub
中的元素。对于每一个元素\(i\),我们从anc
中取出若干个满足\(L_i\)限制的元素,导入到一个单调栈中,并维护栈的“凸性”。然后从当前栈中二分,找到一个位置\(mid\)使得其刚好与凸壳相切。最后用这个位置的\(F\)值更新\(i\)。
如果\((\rho,s)\)中部分答案还不能被更新,我们就只能留到\((\gamma,\frac{1}{2}s)\)中去更新。此时递归处理即可。
4 代码
建议直接从devide
函数部分开始阅读。注释都集中在那一块。
#include <cstdio> #include <cctype> #include <cstring> #include <algorithm> #include <vector> using namespace std; #define rg register #define fre(z) freopen(z".in", "r", stdin), freopen(z".out", "w", stdout) #define customize template<class type> inline typedef long long number; customize type read(type sample) { type ret = 0, sign = 1; char ch = getchar(); while(! isdigit(ch)) sign = ch == '-' ? -1 : 1, ch = getchar(); while(isdigit(ch)) ret = ret * 10 + ch - '0', ch = getchar(); return sign == -1 ? -ret : ret; }//快读 const number INF = 0x3f3f3f3f3f3f3f3f; const int MAXN = 201000; customize bool checkmax(type& var, type value) { if(value > var) { var = value; return true; } return false; } customize bool checkmin(type &var, type value) { if(value < var) { var = value; return true; } return false; } //这两个函数都是方便“更新”而设计的。 int N; number P[MAXN], Q[MAXN], L[MAXN]; int father[MAXN]; number F[MAXN]; number dis[MAXN]; int dataType; int head[MAXN]; struct Edge{ int next; int front, to; }edge[MAXN << 1]; int tot = 0; inline void append(int front, int to) { ++ tot; edge[tot] = (Edge){head[front], front, to}; head[front] = tot; } inline void connect(int front, int to) { append(front, to); append(to, front); } //链式前向星存边。 long double slope(int j1, int j2) { if(dis[j1] == dis[j2]) return F[j2] > F[j1] ? INF * 1.0 : -INF * 1.0; return (1.0 * F[j2] - F[j1]) / (1.0 * dis[j2] - dis[j1]); }//根据转移方程得到的斜率。 int q[MAXN]; number flen[MAXN]; int front, rear; int size[MAXN]; int minsize; int cursize; int gc; int sub[MAXN]; int subn, ancn; bool used[MAXN]; void getRoot(int cur) { size[cur] = 1; int maxpart = 0; for(rg int e = head[cur]; e; e = edge[e].next) { int to = edge[e].to; if(used[to]) continue; getRoot(to); size[cur] += size[to]; } checkmax(maxpart, size[cur]); checkmax(maxpart, cursize - size[cur]); if(checkmin(minsize, maxpart)) gc = cur; } int datatype; void record(int cur) { sub[++ subn] = cur; for(rg int e = head[cur]; e; e = edge[e].next) { int to = edge[e].to; if(used[to]) continue; record(to); } } bool cmp(int a, int b) { return dis[a] - L[a] > dis[b] - L[b]; } int stack[MAXN]; int top; void devide(int cur) { if(cursize <= 1) return; gc = cur; minsize = cursize;//gc = gravity center,重心 getRoot(cur);//找到当前节点的重心。请务必仔细核对重心的求法。 int fgc = father[gc];//重心的父亲。其下面的子树部分在下一次递归时都要“删除”。 for(rg int e = head[fgc]; e; e = edge[e].next) { int to = edge[e].to; used[to] = true;//懒惰删除法。只用在当前的根节点打一个标记,就不用访问整个子树了。 cursize -= size[to]; } devide(cur); subn = 0;//记录fgc子树的节点,并存到sub数组中。不含fgc。 for(rg int e = head[fgc]; e; e = edge[e].next) record(edge[e].to); sort(sub + 1, sub + subn + 1, cmp);//按照dis[i]-L[i]从大到小排序 top = 0; stack[++ top] = fgc; for(rg int i = 1, r = fgc; i <= subn; ++ i) { if(dis[sub[i]] - dis[r] > L[sub[i]]) continue;//由于已经排过序了,当前不存在一个祖先可以更新这个点,可以直接跳过。 while(r != cur && dis[sub[i]] - dis[father[r]] <= L[sub[i]]) { r = father[r]; while(top > 1 && slope(stack[top - 1], stack[top]) <= slope(stack[top], r)) -- top; stack[++ top] = r;//将所有可以作为候选决策的祖先加入单调栈中。同时注意维护栈的“凸”性。 } int left = 1, right = top; while(left < right) { int mid = (left + right) >> 1; if(slope(stack[mid + 1], stack[mid]) <= 1.0 * P[sub[i]]) right = mid; else left = mid + 1; }//二分找到和凸壳相切的点。所谓相切,是指直线Y=P[i]X + F[i] - P[i]*dis[i]-Q[i]仅存在于凸壳的一侧,不会穿过凸壳边缘。 int p = stack[left]; checkmin(F[sub[i]], F[p] + P[sub[i]] * (dis[sub[i]] - dis[p]) + Q[sub[i]]); } for(rg int e = head[fgc]; e; e = edge[e].next) { cursize = size[edge[e].to];//改变当前子树的大小 devide(edge[e].to);//递归计算剩下的部分 } } void initdis(int cur) { for(rg int e = head[cur]; e; e = edge[e].next) { int to = edge[e].to; dis[to] += dis[cur]; initdis(to); } } int main() { fre("P2305"); N = read(1); datatype = read(1); memset(F, 0x3f, sizeof(F)); for(rg int i = 2; i <= N; ++ i) { father[i] = read(1); dis[i] = read(1ll); append(father[i], i); P[i] = read(1ll); Q[i] = read(1ll); L[i] = read(1ll); } dis[1] = 0; initdis(1); F[1] = 0; cursize = N; devide(1); for(rg int i = 2; i <= N; ++ i) printf("%lld\n", F[i]); return 0; }
5 注意事项
关于斜率
这里切记不能用叉积判断斜率!虽然叉积可以避免精度误差,但是有可能会造成乘法溢出。叉积和斜率这两种方法,应该具体情况具体使用。
关于凸壳
注意到这里判断栈“凸性”的判断符号和二分的判断符号都和普通的下凸壳公式不符。请注意,在相空间\((\text{dis}(j), F(j))\)中,当前所有候选决策构成的点的确是呈“下凸”形状的。只不过由于我们是从大到小枚举\(\text{dis}(j)\)的,凸壳中每一个新的点都是从最左边插入的。凸壳仍然是下凸壳,只不过插入方向改变了,所以我们的判断符号会反过来。
关于重心
说实话,这里我也无法解释原因。首先,我们这里必须定义重心为满足\(\max\{\text{size}(i), N - \text{size}(i)\}\)最小的点\(i\),而不是删去该点后最大连通块最小的点。其次,在每一次删除时,我们必须把这个重心和它的兄弟都删除,只保留它的父亲。否则,任何其他情况都可能导致递归层数过大而栈溢出。
当然,如果你对这道题背后的数学模型了解得足够透彻,这些注意事项就没有阅读的必要了。任何一种需要特判的情况,不应该是实验得出的,而是基于理论模型推导的。