NOI2014 归程

北城以北 提交于 2019-11-28 05:36:11

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\),而不是删去该点后最大连通块最小的点。其次,在每一次删除时,我们必须把这个重心和它的兄弟都删除,只保留它的父亲。否则,任何其他情况都可能导致递归层数过大而栈溢出。

当然,如果你对这道题背后的数学模型了解得足够透彻,这些注意事项就没有阅读的必要了。任何一种需要特判的情况,不应该是实验得出的,而是基于理论模型推导的。

标签
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!