Chengyuan Ma's Blog

幽居默处而观万物之变,尽其自然之理,而断之于中

0%

拟阵是很多贪心算法的基础,也可以帮助我们验证自己的贪心算法的正确性,接下来是我从《算法导论》中整理出来的一些关于拟阵的性质:

定义

吐槽:虽然说拟阵叫拟阵,但是这个“阵”和矩阵的阵几乎一点关系都没有(名字的由来纯粹是历史遗留问题),拟阵和矩阵的关系就像JavaScript和Java,雷锋和雷峰塔一样……

拟阵:拟阵是一个二元组M = (S, I),其中S为一个有限集,IS的一个族(即子集的集合)并满足如下性质:

  1. 遗传性:B \in IA \subseteq B,则A \in I
  2. 交换性:A \in I, B \in I|A| < |B|,那么存在元素x \in B \setminus A,使得A \cup \{x\} \in I。(又是一个误导性的名字……)

I中的集合成为S独立子集

图拟阵:对于一个图G = (V, E),我们定义它的图拟阵为M_G = (S_G, I_G),当中S_G为边集,而E的子集A \in I_G的当且仅当A中无环(森林)。图拟阵与最小生成树有关(这也解释了为什么最小生成树的算法都是贪心)。由于森林的子图还是森林,因此该拟阵的遗传性显然。而该拟阵的交换性也在算法导论中有详细的证明,简单讲起来就是对于森林A, B|A| < |B|,我们知道A中有|V| - |A|棵树,B中有|V| - |B|棵树比A少,因此一定存在一条边(u, v)使得u, vA中分别属于两棵树,而在B中却在一棵树上,显然A \cup \{(u, v)\} \in I_G,证毕。

拓展:对于集合A,若存在元素x使得A \cup \{x\} \in I,则称xA的一个拓展。

最大独立子集:不存在拓展的独立子集。

定理:所有的最大独立子集等大小。

证明:反证, 假设存在最大独立子集A, B使得|A| < |B|,由交换性,必然存在x \in B \setminus AA的一个拓展,证毕。

加权拟阵:若存在一个权函数w为所有S的元素定义了严格正的权值,则称M为加权拟阵,定义独立子集A的权值为:

w(a) = \sum_{x \in A} w(x)

和贪心的关系

很多贪心问题可以转化为在拟阵上求最大权独立子集,或者称为拟阵的最优子集

例:最小生成树算法当中我们可以对于边e定义权重w(e) = l_{max} - l(e),其中l(e)为边的长度,l_{max}为一个极大的数。在图拟阵上最大化独立子集权重必然等价于在图上最小化生成树边权(因为生成树的边数总是固定的)。

求解最优子集的算法:S的所有元素x按照w(x)从大到小排序,初始化A = \emptyset,然后依次从大到小考虑每个元素x,并尽可能地x去拓展A,最后得到的集合必定是最优子集。

这个算法的贪心本质暴露无遗,接下来我们证明这个算法的正确性:

引理1(贪心选择):若将S的元素从大到小排序,令x为排序后第一个使得\{x\} \in I的元素,那么必然存在一个包含x的最优子集。

证明:如果不存在x,则最优子集为空,显然。

否则,假设我们有一个不含x的最优子集B,显然由遗传性,对于y \in B都有\{y\} \in I,又因为我们选择x的方式显然由w(x) \ge w(y)。我们初始化A = \{x\},利用和B交换性不断拓展A直至|A| = |B|,此时必定存在另一元素y,使得A \setminus \{x\} = B \setminus \{y\},又因为w(x) \ge w(y),那么A一定不会比B差,得证。

引理2:若元素xA的一个拓展,那么\{x\} \in I,换而言之,x\emptyset的拓展。

证明:由定义,A \cup \{x\} \in I,由遗传性显然。

推论(逆否命题):x不是\emptyset的拓展,那它也一定不是其他非空独立子集的拓展。

引理3(最优子结构):在如上算法中选择完x之后,在M = (S, I)中寻找最优子集等价于在M'= (S', I')中寻找最优子集,其中:

\begin{aligned}
S' &= \{y \mid \{x, y\} \in S\} \\
I' &= \{B \mid B \subseteq S \setminus \{x\}, B \cup \{x\} \in I\}
\end{aligned}
我们可以忽略x继续我们的算法。

证明:如果我们在M'当中选出了最优子集A',则显然A' \cup \{x\}M的独立子集,并且由x的选择方式可知也是最优子集,证毕。

贪心正确性的证明:由引理2的推论,我们知道贪心算法没能一开始用来拓展的x一定在后面也没有用。由引理3,选择完x之后我们把问题缩小,还可以进一步贪心,此时贪心实质上是在M'上寻找最优子集。证毕。

从以上讨论我们可以看出,对于一类问题,如果我们可以以某种方式把它规约为在拟阵上求取最优子集,那你一定可以使用贪心解决,这给很多贪心算法(例如Kruskal)提供了理论支持。

概况

由于VEX中PWM频率偏低,因此马达的响应并不是线性的,例如马达指令在较小时存在死区,在较大时对转速的影响可以忽略不计等等。以269马达的数据为例(393也是类似):

那么如何让马达的响应变得线性呢?从数学角度出发,我们需要试图找到,或者逼近以上马达曲线f反函数g = f^{-1},对于输出我们应用反函数再应用到马达上,由于马达曲线是单调的,因此在理想状况下,这波操作以后马达的响应就是线性的了,这一过程也称作马达线性化

出于简单起见,我们规定这个g的定义域和值域都是[-127, 127],这样的话,理论上大部分原来没有使用线性化的PID算法可以较为简单地加上线性化(不然的话就需要从零开始调参)。显然由马达曲线的单调性可得g(127) = 127,以及g(-127) = -127

目前机器人社实践过的线性化算法有:

  1. 去除死区的简单线性化:只比没有线性化好一点,简单来说就是对输出的指令加一个bias,这样就不会有死区的问题。
  2. 三段线性插值线性化:就是被WSJ吹爆的所谓模型,本质是把马达曲线分为三段并分别用线性函数近似,效果很好,缺点是调参较为复杂。
  3. 二次函数线性化:我们发现马达曲线的形状有点像f(x) = \sqrt x的图像,因此我们使用二次函数为反函数进行线性化,效果一般。
  4. 反双曲正切线性化:本篇主题,效果较好。
  5. 真·模型线性化:使用马达模型直接计算马达曲线进行线性化,效果最好,可以针对不同的电量,场地状况,扭矩实时给出不同的曲线,缺点是计算慢。

数学推导

直觉上,有没有觉得马达曲线的图像很像\tanh函数的图像?

因此我们选择使用双曲正切函数的反函数来进行线性化:

\tanh^{-1}(x) = \frac{1}{2}\ln\left(\frac{1 + x}{1 - x}\right)
为了适应死区,以及满足上文中定义域和值域的要求,我们魔改拓展一下上式:
g(x) = a\operatorname{sgn}(x) + \frac{127 - a}{2b}\ln\left(\frac{c + x}{c - x}\right)
解释一下各参数的意义:

  1. a是死区,一般来说5 \le a \le 20
  2. b是我叫做“缩放因子”的参数,其图像意义是把原双曲正切函数自变量[-b, b]这一段的图像看做马达函数图像形状上的近似。从图像上可以看到,一般2 \le b \le 3
  3. c是一个依赖于a, b的参数,由g(127) = 127,我们反解得c = 127\frac{e^{2b} + 1}{e^{2b} - 1}

就完了,不难吧?

代码

注意变量名可能和推导中用到的符号并不一致:

typedef struct {
    float a, b;
    float alpha;
} TanhLinearizer;

void initTanhLinearizer(TanhLinearizer *t, float deadband, float scale = 2.333333) {
    t->b = scale * 2;
    t->a = 127 - deadband;
    float x = exp(2 * scale);
    t->alpha = 127 * (x + 1) / (x - 1);
}

int getTanhLinearizedOutput(TanhLinearizer *t, int cmd) {
    if (cmd > 127) cmd = 127;
    if (cmd < -127) cmd = -127;
    int s = sgn(cmd);
    cmd = fabs(cmd);
    return round(t->a / t->b * log((t->alpha + cmd) / (t->alpha - cmd)) + s * (127 - t->a));
}

效果

马达曲线的反函数应该原本是这个样子的(就是原图像转一转):

然后在a = 8, b = 3时我们的线性化算法给出的反函数是这个样子的:

是不是很像呢?实测的效果确实也是极好的。

更多

其实马达函数的图像不只是像\tanh啦,其实观察一下你也可以说它像\arctan之类的,自然也可以发展出一套线性化的方法,从的来说看图像猜函数只能说是一种近似,最好自然是找出马达曲线精确的计算方法,也就是建模。但是这类线性化方法的一大好处就是计算量很小,很快,因此往往更多地得到应用。

概念

众所周知,BST在特定的插入顺序下常常会退化成链,从而使单次查找的时间复杂度退化为O(n),这是很糟糕的,因此出现了一箩筐的平衡树,在保证BST性质的同时,尽量保持BST的平衡。

Treap就是当中概念比较简单而且比较好写的一种真的吗?。Treap是Tree和Heap的合成,即“树堆”,其精髓就在于通过维护,使得一棵树在保有BST性质的同时,也具有大根堆的性质。为此,每个节点都会随机生成一个额外的权值用于堆性质的维护:

struct node {
    int l, r; // 左右子树
    int cnt, sz; // 当前键值副本树与子树(包括自身)大小
    int val, w; // 键值与随机权值
} t[N];

int newnode(int val) {
    t[++tot].val = val;
    t[tot].cnt = t[tot].sz = 1;
    t[tot].l = t[tot].r = 0;
    t[tot].w = rand();
    return tot;
}

void pushup(int p) { // 维护sz
    t[p].sz = t[t[p].l].sz + t[t[p].r].sz + t[p].cnt;
}

为了防止讨论边界条件,我们插入两个值为\pm \infty的节点:

void build() {
    tot = 0;
    rt = newnode(-INF);
    t[rt].r = newnode(INF);
    pushup(rt);
}

旋转

改变BST的形状而不破坏其性质的操作是二叉树的旋转,分为右旋(zig)和左旋(zag)两种,以右旋为例,右旋把一个节点p的左子树q绕其“向右(顺时针)”旋转到原来p的位置,并且让p成为q的右子树,q原来的右子树成为p的左子树,左旋类似:

落实在代码上,就是:

void zig(int &p) {
    int q = t[p].l;
    t[p].l = t[q].r, t[q].r = p;
    p = q; pushup(t[p].r); pushup(p); 
}

void zag(int &p) {
    int q = t[p].r;
    t[p].r = t[q].l, t[q].l = p;
    p = q; pushup(t[p].l); pushup(p); 
}

注意pushup的顺序。

排名相关查询

我们接下来考虑平衡树的前两个查询操作,分别是查排名和按排名查数,两个都相对简单,可以递归实现:

int rank(int p, int val) {
    if (p == 0) return 0;
    if (val == t[p].val) return t[t[p].l].sz + 1;
    if (val < t[p].val) return rank(t[p].l, val);
    return t[t[p].l].sz + t[p].cnt + rank(t[p].r, val);
}

int query(int p, int rk) {
    if (p == 0) return INF;
    if (t[t[p].l].sz >= rk) return query(t[p].l, rk);
    if (t[t[p].l].sz + t[p].cnt >= rk) return t[p].val;
    return query(t[p].r, rk - t[t[p].l].sz - t[p].cnt);
}

注意这里查询的结果包含\pm \infty,要去掉。

前驱后继查询

接下来考虑查询x的前驱/后继的操作,以前驱为例,我们初始化答案ans- \infty,有以下几种情况:

  1. 没有找到x节点:那么答案就在经过的节点上。
  2. 找到x节点,可惜是叶子:同上,答案在经过的节点上。
  3. 找到x节点,不是叶子:若x节点有左子树p,从p开始一路往右走直到不存在右子树为止,此时节点对应的值即为前驱。

代码:

int querypre(int val) {
    int ans = 1; // t[1].val = -inf
    int p = rt;
    while (p) {
        if (val == t[p].val) {
            if (t[p].l) {
                p = t[p].l;
                while (t[p].r) p = t[p].r;
                ans = p;
            }
            break;
        }
        if (t[p].val < val && t[p].val > t[ans].val) ans = p;
        p = val < t[p].val ? t[p].l : t[p].r;
    }
    return t[ans].val;
}

int querynxt(int val) {
    int ans = 2; // t[2].val = inf
    int p = rt;
    while (p) {
        if (val == t[p].val) {
            if (t[p].r) {
                p = t[p].r;
                while (t[p].l) p = t[p].l;
                ans = p;
            }
            break;
        }
        if (t[p].val > val && t[p].val < t[ans].val) ans = p;
        p = val < t[p].val ? t[p].l : t[p].r;
    }
    return t[ans].val;
}

插入

递归进行,同时注意维护大根堆性质即可:

void insert(int &p, int val) {
    if (p == 0) {
        p = newnode(val);
        return;
    }
    if (val == t[p].val) {
        t[p].cnt++;
        pushup(p);
        return;
    }
    if (val < t[p].val) {
        insert(t[p].l, val);
        if (t[p].w < t[t[p].l].w) zig(p);
    } else {
        insert(t[p].r, val);
        if (t[p].w < t[t[p].r].w) zag(p);
    }
    pushup(p);
}

删除

我们利用旋转一路把要删的节点转到叶子的位置,然后直接删除即可,同时一路上维护堆的性质:

void remove(int &p, int val) {
    if (p == 0) return;
    if (val == t[p].val) {
        if (t[p].cnt > 1) {
            t[p].cnt--;
            pushup(p);
            return;
        }
        if (t[p].l || t[p].r) {
            if (t[p].r == 0 || t[t[p].l].w > t[t[p].r].w)
                zig(p), remove(t[p].r, val);
            else
                zag(p), remove(t[p].l, val);
            pushup(p);
        } else p = 0;
        return;
    }
    val < t[p].val ? remove(t[p].l, val) : remove(t[p].r, val);
    pushup(p);
}

完整代码

以下是通过普通平衡树模板题的全部代码,共计150行左右,感觉平衡树这个东西代码量还是有点恐怖,而且需要注意非常多的细节,需要结合理解记忆:

#include <cstdio>
#include <cstdlib>
#include <ctime>

using namespace std;
const int N = 100010;
const int INF = 0x3f3f3f3f;
struct node {
    int l, r;
    int cnt, sz;
    int val, w;
} t[N];
int n, tot = 0, rt;

int newnode(int val) {
    t[++tot].val = val;
    t[tot].cnt = t[tot].sz = 1;
    t[tot].l = t[tot].r = 0;
    t[tot].w = rand();
    return tot;
}

void pushup(int p) {
    t[p].sz = t[t[p].l].sz + t[t[p].r].sz + t[p].cnt;
}

void zig(int &p) {
    int q = t[p].l;
    t[p].l = t[q].r, t[q].r = p;
    p = q; pushup(t[p].r); pushup(p); 
}

void zag(int &p) {
    int q = t[p].r;
    t[p].r = t[q].l, t[q].l = p;
    p = q; pushup(t[p].l); pushup(p); 
}

void build() {
    tot = 0;
    rt = newnode(-INF);
    t[rt].r = newnode(INF);
    pushup(rt);
}

int rank(int p, int val) {
    if (p == 0) return 0;
    if (val == t[p].val) return t[t[p].l].sz + 1;
    if (val < t[p].val) return rank(t[p].l, val);
    return t[t[p].l].sz + t[p].cnt + rank(t[p].r, val);
}

int query(int p, int rk) {
    if (p == 0) return INF;
    if (t[t[p].l].sz >= rk) return query(t[p].l, rk);
    if (t[t[p].l].sz + t[p].cnt >= rk) return t[p].val;
    return query(t[p].r, rk - t[t[p].l].sz - t[p].cnt);
}

int querypre(int val) {
    int ans = 1;
    int p = rt;
    while (p) {
        if (val == t[p].val) {
            if (t[p].l) {
                p = t[p].l;
                while (t[p].r) p = t[p].r;
                ans = p;
            }
            break;
        }
        if (t[p].val < val && t[p].val > t[ans].val) ans = p;
        p = val < t[p].val ? t[p].l : t[p].r;
    }
    return t[ans].val;
}

int querynxt(int val) {
    int ans = 2;
    int p = rt;
    while (p) {
        if (val == t[p].val) {
            if (t[p].r) {
                p = t[p].r;
                while (t[p].l) p = t[p].l;
                ans = p;
            }
            break;
        }
        if (t[p].val > val && t[p].val < t[ans].val) ans = p;
        p = val < t[p].val ? t[p].l : t[p].r;
    }
    return t[ans].val;
}

void insert(int &p, int val) {
    if (p == 0) {
        p = newnode(val);
        return;
    }
    if (val == t[p].val) {
        t[p].cnt++;
        pushup(p);
        return;
    }
    if (val < t[p].val) {
        insert(t[p].l, val);
        if (t[p].w < t[t[p].l].w) zig(p);
    } else {
        insert(t[p].r, val);
        if (t[p].w < t[t[p].r].w) zag(p);
    }
    pushup(p);
}

void remove(int &p, int val) {
    if (p == 0) return;
    if (val == t[p].val) {
        if (t[p].cnt > 1) {
            t[p].cnt--;
            pushup(p);
            return;
        }
        if (t[p].l || t[p].r) {
            if (t[p].r == 0 || t[t[p].l].w > t[t[p].r].w)
                zig(p), remove(t[p].r, val);
            else
                zag(p), remove(t[p].l, val);
            pushup(p);
        } else p = 0;
        return;
    }
    val < t[p].val ? remove(t[p].l, val) : remove(t[p].r, val);
    pushup(p);
}

int main() {
    srand(time(0)); build();
    scanf("%d", &n);
    while (n--) {
        int op, x;
        scanf("%d%d", &op, &x);
        switch (op) {
        case 1: insert(rt, x); break;
        case 2: remove(rt, x); break;
        case 3: printf("%d\n", rank(rt, x) - 1); break;
        case 4: printf("%d\n", query(rt, x + 1)); break;
        case 5: printf("%d\n", querypre(x)); break;
        case 6: printf("%d\n", querynxt(x)); break;
        }
    }
    return 0;
} 

经典的LIS问题不再赘述,利用DP的思想可以做到O(n^2),利用单调队列优化可以做到O(n\log n),然而单调队列的做法对于我等蒟蒻来说还是有点复杂的,因此这里介绍一种更初等的,更直接的,更好写的基于BIT的做法。

跳出DP的框架,我们定义p[x]x结尾的LIS的长度,我们从左到右地扫,同时更新p[x]的值,最后p当中的最大值即为所求的答案。

假设我们扫到数x,很明显它可以接在任何最后一个数小于x的上升子序列的后面,因此我们做如下的更新:

p[x] = \max \begin{cases}
p[x] \\
\max_{1 \le i < x} \{p[i]\} + 1
\end{cases}
我们看到,更新操作需要查询前缀最大值,又因为我们发现p[x]的更新一定是单调不降的,因此得以使用BIT维护(以上条件任意一个不满足都只能使用线段树了),思路较单调队列的直接,之于x范围的问题,离散化一下即可,时间复杂度自然也是O(n \log n),代码如下:

#include <cstdio>
#include <algorithm> 

using namespace std;
#define max(a, b) ((a) > (b) ? (a) : (b))
const int N = 10010;
int n, ans, tot, a[N], d[N], bit[N];

inline int query(int x) {
    int ret = 0;
    for (; x; x -= x & -x) ret = max(ret, bit[x]);
    return ret;
}

inline void update(int x, int d) {
    for (; x <= tot; x += x & -x) bit[x] = max(bit[x], d);
}

int main() {
    scanf("%d", &n); ans = 0;
    for (int i = 1; i <= n; i++) scanf("%d", &a[i]);
    copy(a + 1, a + 1 + n, d + 1);
    sort(d + 1, d + 1 + n);
    tot = unique(d + 1, d + 1 + n) - d - 1;
    for (int i = 1; i <= n; i++) {
        int x = lower_bound(d + 1, d + 1 + tot, a[i]) - d;
        update(x, query(x - 1) + 1);
    }
    printf("%d\n", query(tot));
    return 0;
}

题面

题面,给定一个n个节点的无根树,边带权,求树上长度不超过k的简单路径的数量。

n \le 10^5

解法

这道题所使用到的思想是分治,假设我们现在要处理一颗根为u的树,很明显这棵树的路径x \to y有两种:

  1. 如果x, yu的同一棵子树里面,那么x \to y一定不经过u,只在某棵子树里面。
  2. 如果x, yu的不同子树里面,那么x \to y一定经过u

很明显第一种情况我们可以递归在子树里面做,因此我们主要处理第二种情况,准确来说,我们从u开始DFS一波,求出d[x]xu的距离(深度),那么我们要求的答案就变成了x, y不属于u的同一棵子树,且d[x] + d[y] \le k(x, y)的个数。

我们先忽略“不属于同一棵子树”这个限制,考虑后面的条件,给定d​,如何求d[x] + d[y] \le k​(x, y)​的个数?

我们使用双指针的方法,把d排序,然后维护两个指针i, j,假设有cnt个元素,初始化i = 1, j = cnt。很明显随着i的增加,j必定单调不增!而对于任意时刻的满足d[i] + d[j] \le ki, j,显然对于i < x \le j,都有d[i] + d[x] \le k,因此我们把答案累加上j - i。总体复杂度为O(cnt \log cnt),代码很短:

sort(d + 1, d + 1 + cnt);
int ret = 0;
for (int i = 1, j = cnt; i < j; ) {
    if (d[i] + d[j] <= k) ret += j - i, i++;
    else j--;
}

我们接下来考虑如何加上”不属于同一棵子树“的限制,假设我们递归计算的函数为calc(u),一开始初始化d[u] = 0,然后统计了一波,假设我们多统计了都在子树v里面的路径的数量,这是多少呢?很显然这些多统计的路径数等价于初始化d[v] = w_{uv}之后calc(v)的值,减去即可:

...
ans += calc(u);
for (int e = fst[u]; e; e = nxt[e]) {
    int v = to[e];
    if (vis[v]) continue;
    dis[v] = len[e]; ans -= calc(v); // 减去多算的
    ...
}

最后,还留下一个问题:从哪里开始分治呢?注意到如果我们出发点取得不好,选到了链的一端,那么复杂度就退化了,因此我们最好从无根树的重心开始分治,求重心是O(n)的:

void dfs_rt(int u, int p) { // sz[0] 是总结点数,p是父节点
    sz[u] = 1, f[u] = 0;
    for (int e = fst[u]; e; e = nxt[e]) {
        int v = to[e];
        if (vis[v] || v == p) continue;
        dfs_rt(v, u);
        sz[u] += sz[v];
        f[u] = max(f[u], sz[v]);
    }
    f[u] = max(f[u], sz[0] - sz[u]);
    if (f[u] < f[rt]) rt = u;
}

因此,最后算法的总框架就是:

  1. 从重心u开始分治
  2. 处理完重心以后,删除u,递归处理其他的无根树。
void dfs(int u) {
    vis[u] = 1; dis[u] = 0;
    ans += calc(u);
    for (int e = fst[u]; e; e = nxt[e]) {
        int v = to[e];
        if (vis[v]) continue;
        dis[v] = len[e]; ans -= calc(v);
        rt = 0; sz[0] = sz[v];
        dfs_rt(v, 0); dfs(rt);
    }
}

易证递归的深度不超过O(\log n),算上双指针中排序的时间,算法的总复杂度为O(n\log ^2 n)

代码:

#include <cstdio>
#include <algorithm>

using namespace std;
const int N = 10010;
int n, k, fst[N], nxt[2 * N], to[2 * N], len[2 * N];
int tot, cnt, vis[N], f[N], sz[N], dis[N], d[N], rt;
int ans;

void link(int a, int b, int w) {
    nxt[++tot] = fst[a];
    fst[a] = tot; to[tot] = b;
    len[tot] = w;
}

void dfs_rt(int u, int p) {
    sz[u] = 1, f[u] = 0;
    for (int e = fst[u]; e; e = nxt[e]) {
        int v = to[e];
        if (vis[v] || v == p) continue;
        dfs_rt(v, u);
        sz[u] += sz[v];
        f[u] = max(f[u], sz[v]);
    }
    f[u] = max(f[u], sz[0] - sz[u]);
    if (f[u] < f[rt]) rt = u;
}

void dfs_d(int u, int p) {
    d[++cnt] = dis[u];
    for (int e = fst[u]; e; e = nxt[e]) {
        int v = to[e];
        if (vis[v] || v == p) continue;
        dis[v] = dis[u] + len[e];
        dfs_d(v, u);
    }
}

int calc(int u) {
    cnt = 0; dfs_d(u, 0);
    sort(d + 1, d + 1 + cnt);
    int ret = 0;
    for (int i = 1, j = cnt; i < j; ) {
        if (d[i] + d[j] <= k) ret += j - i, i++;
        else j--;
    }
    return ret;
}

void dfs(int u) {
    vis[u] = 1; dis[u] = 0;
    ans += calc(u);
    for (int e = fst[u]; e; e = nxt[e]) {
        int v = to[e];
        if (vis[v]) continue;
        dis[v] = len[e]; ans -= calc(v);
        rt = 0; sz[0] = sz[v];
        dfs_rt(v, 0); dfs(rt);
    }
}

int main() {
    f[0] = 0x3f3f3f3f;
    while (scanf("%d%d", &n, &k), n) {
        tot = 1; ans = 0;
        fill(vis + 1, vis + 1 + n, 0);
        fill(fst + 1, fst + 1 + n, 0);
        for (int i = 1; i <= n - 1; i++) {
            int a, b, w;
            scanf("%d%d%d", &a, &b, &w);
            link(a, b, w);
            link(b, a, w);
        }
        sz[0] = n; dfs_rt(1, 0);
        dfs(rt);
        printf("%d\n", ans);
    }
    return 0;
} 

觉得博弈论这种东西,知道结论代码巨短无比,不知道结论当场去世……

Nim 博弈

问题表述

n堆石子,第i堆石子有a_i个,两个玩家轮流操作,每个玩家每轮可以选择一堆石子,取走至少一个石子(也可以把一堆取光,但不能不取)。不能操作者判负。已知每方均采取最优策略,问先手还是后手必胜?

解法

定理:Nim博弈先手必胜当且仅当\bigoplus_{i = 1}^n a_i \neq 0

证明:\bigoplus_{i = 1}^n a_i = x \neq 0,且x的最高位为j,由异或的性质,显然存在一个a_i的第j位为1,且a_i \oplus x < a_i,我们选择第i堆,令a_i = a_i \oplus x,由异或的相消性,此时\bigoplus_{i = 1}^n a_i = 0,我们把这样一个局面留给了后手,而后手无论怎么操作异或和都会变成非零。而不能操作的局面,即全零的局面的异或和同样为0,又因为我们每次都可以那样地把锅推给对方,因此先手必胜。

阶梯博弈

问题表述

n阶阶梯从左到右从低到高排开,记地面为第0级,第i个阶梯上有a_i个石子,两个玩家轮流操作,每个玩家每轮可以选择一个阶梯,将其中的至少一个石子转移到左边的阶梯(或者地面)上。不能操作者判负。已知每方均采取最优策略,问先手还是后手必胜?

解法

定理:阶梯博弈等价于奇数阶的Nim博弈。

证明:如果上一步对方从一个偶数阶的阶梯移动若干个石子到奇数阶,那么我们就原封不动地在这一步把这些石子再移到左边的偶数阶上去,因此偶数阶的石子的移动不造成影响。而既然偶数阶不造成影响,那么从奇数阶转移到偶数阶的操作等价于从奇数阶取走了若干石子(如果我们不看偶数阶),这样最后偶数阶的石子会全部到地面上,而奇数阶无法操作者自然就输了,证毕。

例题:POJ 1704

线性基

定义:给定一组向量,构成一个线性空间,如果我们能够找到一组线性无关的向量,使得这些向量的线性组合能够构成整个线性空间,则这一组向量成为该向量空间的线性基

算法:计算线性基我们可以把一组向量排成矩阵,然后在矩阵上跑高斯消元法,消出来的非零行向量即为线性基,非零行向量数目为矩阵的行秩。

以下给出对于常规线性基的高斯消元代码:

void gauss() {
    int dim = 0; // 行秩
    for (int i = 1; i <= n; i++) {
        int piv = dim + 1;
        for (int j = dim + 1; j <= m; j++)
            if (abs(a[j][i]) > abs(a[piv][i]))
                piv = j;
        if (abs(a[piv][i]) < EPS) continue; // 自由元
        dim++;
        for (int j = 1; j <= n; j++)
            swap(a[dim][j], a[piv][j]);
        for (int j = 1; j <= m; j++) {
            if (j == dim) continue;
            if (abs(a[j][i]) < EPS) continue;
            double r = a[j][i] / a[dim][i];
            for (int k = 1; k <= n; k++)
                a[j][k] -= r * a[dim][k];
        }
    }
}

异或

简称XOR,数学记号为$$,C++中为^异或本质上是在模2意义下的加法运算。因此线性代数的很多算法也可以应用到异或这样的有限域上,异或的一个很重要的性质是相消性,即一个数异或两次等于没异或!(等同于乘2,在模2下就肯定没意义啦)。

异或的相消性很重要的一个应用是在图上使用DFS计算环上边的异或,我们只需要计算从DFS树根节点出发各个点到根上路径的异或和(记作dis(u))。如果我们在DFS上找到返祖边e = (u, v),环的异或就是dis(u) \oplus dis(v) \oplus w(e)。我们可以看到这个运算中u, vLCA以上的部分都被抵消掉了,因此最后得到的就是整个环的异或和。如果求图上路径异或和的话我们可以用这样的方法搞出神奇的效果

异或线性基

如何求出异或意义下的线性基呢?朴素的高斯消元肯定是可以的,但是对于异或我们首先可以压位减少常数,然后对于异或线性基我们还有一种求法,对于一个数x,我们可以这样把x插入一组线性基:

  1. 找到x的最高二进制位,记作i
  2. 查看第i位的线性基p[i]是否被插入。
  3. 如果没有被插入,插入第i位的线性基,结束。
  4. 如果已经被插入了,令x = x \oplus p[i]

正确性证明:如果当前位没被插入则插入操作显然正确,如果被插入,则x \oplus p[i]不仅消除了第i位,同时可以证明如果x \oplus p[i]可以被线性基表出,则x一定也可以被线性基表出。

一个二进制数在被插入线性基的过程中如果失败了,那么它就会变成0,暗示它可以由我们现有的线性基表出。

代码:

bool insert(int x) {
    for (int i = 30; i >= 0; i--) { // 最高位数
        if ((x >> i) & 1) {
            if (!p[i]) {
                p[i] = x;
                return true;
            } else
                x ^= p[i];
        }
    }
    return false;
}

题意

题面:假设有一个n+1m+1列的矩阵A,下标从0开始,给定A_{1,0}, A_{2, 0}, \cdots A_{n, 0}(即最左边一列的初始值),以及已知矩阵的第1行第i列为2\underbrace{333\cdots3}_{i + 1 个 3},如下所示:

\left(\begin{matrix}
0 &233 &2333 &23333 &\cdots \\
A_{1, 0} &A_{1, 1} &A_{1, 2} &A_{1, 3} &\cdots \\
A_{2, 0} &A_{2, 1} &A_{2, 2} &A_{2, 3} &\cdots \\
\vdots &\vdots &\vdots &\vdots &\ddots
\end{matrix}\right)
并且已知对于i, j \ge 1,有A_{i, j} = A_{i - 1, j} + A_{i, j - 1}。求A_{n, m}

n \le 10, m \le 10^9

解法

从数据范围可以得知我们必须使用矩阵快速幂加速递推,我们定义第i列的状态向量为一个n + 2行的列向量,并手动展开递推式消除同一列的项(非常重要,因为不然的话没办法推)得到转移矩阵:

\left(\begin{matrix}
1 \\
A_{i, 0} \\
A_{i, 1} \\
A_{i, 2} \\
A_{i, 3} \\
\vdots
\end{matrix}\right)
=
\left(\begin{matrix}
1 &0 &0 &0 &0 &\cdots\\
3 &10 &0 &0 &0 &\cdots\\
3 &10 &1 &0 &0 &\cdots\\
3 &10 &1 &1 &0 &\cdots\\
3 &10 &1 &1 &1 &\cdots\\
\vdots &\vdots &\vdots &\vdots &\vdots &\ddots
\end{matrix}\right)
\left(\begin{matrix}
1 \\
A_{i - 1, 0} \\
A_{i - 1, 1} \\
A_{i - 1, 2} \\
A_{i - 1, 3} \\
\vdots
\end{matrix}\right)
注意到我们怎么处理2\underbrace{333\cdots3}_{i + 1 个 3}的,我们发现2\underbrace{3\cdots3}_{i + 1 个 3} = 2\underbrace{3\cdots3}_{i 个 3} \times 10 + 3,为了凑出3我们在矩阵当中引入1这多余的一行(这是在矩阵加速递推时引入常数项的常用技巧)(其实在这里直接引入3也可以啦,但是1的话更通用),这就是系数3, 10的由来。然后使用快速幂加速矩阵乘法即可,时间复杂度为O(n^3\log m)

#include <cstdio>

using namespace std;
typedef long long ll;
typedef int mat[13][13];
typedef int vec[13];
const int MOD = 10000007;
int n, m;
vec a;
mat b;

void mul(mat &x, vec &y) {
    vec z;
    for (int i = 1; i <= n + 2; i++) {
        z[i] = 0;
        for (int j = 1; j <= n + 2; j++)
            z[i] = (z[i] + 1LL * x[i][j] * y[j]) % MOD;
    }
    for (int i = 1; i <= n + 2; i++)
        y[i] = z[i];
}

void sqr(mat &x) {
    mat y;
    for (int i = 1; i <= n + 2; i++) {
        for (int j = 1; j <= n + 2; j++) {
            y[i][j] = 0;
            for (int k = 1; k <= n + 2; k++)
                y[i][j] = (y[i][j] + 1LL * x[i][k] * x[k][j]) % MOD;
        }
    }
    for (int i = 1; i <= n + 2; i++)
        for (int j = 1; j <= n + 2; j++)
            x[i][j] = y[i][j];
}

int main() {
    while (scanf("%d%d", &n, &m) != EOF) {
        a[1] = 1, a[2] = 23;
        for (int i = 3; i <= n + 2; i++)
            scanf("%d", &a[i]);
        b[1][1] = 1;
        for (int i = 2; i <= n + 2; i++) {
            b[i][1] = 3, b[i][2] = 10;
            for (int j = 3; j <= i; j++)
                b[i][j] = 1;  
        }
        for (; m; m >>= 1) {
            if (m & 1) mul(b, a);
            sqr(b);
        }
        printf("%d\n", a[n + 2]);
    }
    return 0;
}

定义

若数论函数f满足如下性质,我们称f为积性函数:

  1. f(1) = 1
  2. 对于任意互素正整数n, mf(nm) = f(n)f(m)
  3. (可选项)如果对于任意正整数n, m都有f(nm) = f(n)f(m),则称f为完全积性函数(没卵用)。

欧拉函数\varphi(n)

定义

\varphi(n)为不大于n的与n互素的数的个数,即:

\varphi(n) = \sum_{i = 1}^n \left[\gcd(i, n) = 1\right]

积性证明

我们证明对于任意正整数n, m\gcd(n, m) = 1\varphi(mn) = \varphi(m)\varphi(n)

首先我们把不大于mn的正整数排成mn列的表:

\begin{matrix}
1 &m + 1 &\cdots &(n - 1)m + 1\\
2 &m + 2 &\cdots &(n - 1)m + 2\\
\vdots &\vdots &\ddots &\vdots\\
m &2m &\cdots &nm
\end{matrix}
由定义,\varphi(mn)为这张表中与mn互素的数的个数。

因为表中的每一行模m同余,因此共有\varphi(m)行与m互素。

我们接下来证明:每一行的n个数模n两两不同余,因为假设在第r行存在两个数am+rbm + r,使得

\begin{aligned}
am + r &\equiv bm + r &\pmod n \\
(a - b)m &\equiv 0 &\pmod n
\end{aligned}
因为m,n互素,因此a - b \equiv 0 \pmod n,又因为a, b < n,因此a = b,矛盾。

因为每一行的n个数模n两两不同余,所以说虽然我不知道它们每个数模n是多少,但是我知道这些数模n一定构成了0, 1, \cdots, n - 1的一个排列。因此,因为0, 1, \cdots, n-1中有\varphi(n)个数与n互素,因此每一行中也有\varphi(n)个数与n互素。因此这\varphi(m)行与m互素的数中总共有\varphi(m)\varphi(n)个数与mn互素,因此\varphi(mn) = \varphi(m)\varphi(n),证毕。

在刚刚的证明中有一个非常重要的思想——原来有n个数,对它们进行一定的变换,如果我们能够证明变换的结果两两不同,而且没变出其他的数,那么变换的结果一定是原来n个数的一个排列!

计算

单点计算

要探究\varphi(n)的计算公式,我们先探究n = p^k,其中p为素数时的特例。在前p^k个正整数中共有\frac{p^k}{p} = p^{k -1}个数是p的倍数与p^k不互素,因此:

\varphi(p^k) = p^k - p^{k - 1} = p^k \left( 1 - \frac{1}{p}\right)
对于单个素数p,有\varphi(p) = p - 1

接下来,我们利用\varphi的积性把上述公式拓展到一般情况,设n可以被素因数分解成n = \prod_{i = 1}^r p_i^{c_i}的形式,那么我们就有:

\begin{aligned}
\varphi(n) &= \prod_{i = 1}^r \varphi(p_i^{c_i}) \\
&= \prod_{i = 1}^r p_i^{c_i} \left(1 - \frac{1}{p_i}\right) \\
&= \prod_{i = 1}^r p_i^{c_i} \prod_{i = 1}^r \left(1 - \frac{1}{p_i}\right) \\
&= n \prod_{i = 1}^r \left(1 - \frac{1}{p_i}\right)
\end{aligned}
这就是欧拉函数的单点计算式,时间复杂度取决于素因数分解的时间复杂度,一般用试除法可以达到O(\sqrt n)

[1, n]计算

使用欧拉筛计算任何积性函数我们需要知道这个函数在以下三种点的取值:

  1. 在素数点的取值:\varphi(p) = p - 1
  2. 对于合数n和它的素因子pp \mid \frac{n}{p}时的取值:由单点计算式,\varphi(n) = p\varphi(\frac{n}{p})
  3. 对于合数n和它的素因子pp \nmid \frac{n}{p}时的取值:由积性,\varphi(n) = \varphi(p)\varphi(\frac{n}{p}) = (p - 1)\varphi(\frac{n}{p})

知道这三点信息之后我们就可以用欧拉筛完成在O(n)时间内计算[1, n]内所有欧拉函数(或其他积性函数)的值:

bool v[N]; 
int phi[N];
int prime[P], tot = 0;
for (int i = 2; i <= n; i++) {
    if (!v[i]) {
        prime[++tot] = i;
        phi[i] = i - 1; // 第一条
    }
    for (int j = 1; i * prime[j] <= n; j++) {
        v[i * prime[j]] = true;
        if (i % prime[j] == 0) {
            phi[i * prime[j]] = phi[i] * prime[j]; // 第二条
            break;
        } else
            phi[i * prime[j]] = phi[i] * (prime[j] - 1); // 第三条
    }
}

前缀和计算

我们可以使用名为杜教筛的大佬专用工具在O(n^{2 \over 3})的时间复杂度计算出\sum_{i = 1}^n \varphi(i)的值。

欧拉定理

若正整数a, n互素,则a^{\varphi(n)} \equiv 1 \pmod n

证明:考虑与不大于n的与n互素的\varphi(n)个数:

x_1, x_2, \cdots, x_{\varphi(n)}
将它们同时乘以a
ax_1, ax_2, \cdots, ax_{\varphi(n)}
因为an互素,因此这些数依然和n互素。

接下来我们用证明欧拉函数积性时用到的方法,证明这些数模n两两不同余,假设存在x_i,x_j,使得ax_i \equiv ax_j \pmod n,那么因为a, n互素,x_i \equiv x_j \pmod n,矛盾。

因为这些数模n还是那几个与n互素的余数,而又两两不同余,这些数一定是x_1, x_2, \cdots, x_{\varphi(n)}的一个排列,既然如此,我们有:

\begin{aligned}
\prod_{i = 1}^{\varphi(n)} x_i &\equiv \prod_{i = 1}^{\varphi(n)} ax_i &\pmod n \\
(a^{\varphi(n)} - 1) \prod_{i = 1}^{\varphi(n)} x_i &\equiv 0 &\pmod n\\
\end{aligned}
因为\prod_{i = 1}^{\varphi(n)} x_in互素,我们有:
\begin{aligned}
a^{\varphi(n)} - 1 &\equiv 0 &\pmod n \\
a^{\varphi(n)} &\equiv 1 &\pmod n
\end{aligned}
得证。

特例:n时素数时,\varphi(n) = n - 1,因此我们有:

a^{n - 1} \equiv 1 \pmod n
费马小定理

莫比乌斯函数\mu(n)

定义

设正整数n可以被素因数分解成n = \prod_{i = 1}^r p_i^{c_i}的形式,那么莫比乌斯函数的定义为:

\mu(n) = \begin{cases}
    0 & \exists 1 \le i \le r,  c_i > 1 \\
    (-1)^r & \forall 1 \le i \le r, c_i \le 1
\end{cases}
即若n包含平方素因子,则\mu(n) = 0,反之为-1的素因子个数次方。

积性证明

设正整数m, n互素,则:

  1. 若其中有一个包含平方素因子(一个的函数值为0),则它们的积必定也包含这个平方素因子,函数值也为0
  2. 若两个都不包含平方素因子,则由于m, n互素(各自有不同的素因子),它们的积必定不包含平方素因子。不同素因子个数也一定为m, n不同素因子个数的和。

证毕。

计算

单点计算

见定义。

[1, n]计算

我们依然可以用欧拉筛在O(n)时间内计算出不大于n的所有\mu的值,代码如下:

bool v[N]; 
int mu[N];
int prime[P], tot = 0;
for (int i = 2; i <= n; i++) {
    if (!v[i]) {
        prime[++tot] = i;
        mu[i] = -1; // 素数有一个素因子 (-1)^1
    }
    for (int j = 1; i * prime[j] <= n; j++) {
        v[i * prime[j]] = true;
        if (i % prime[j] == 0) {
            mu[i * prime[j]] = 0; // 平方素因子
            break;
        } else
            mu[i * prime[j]] = -mu[i]; // 多了一个素因子 
    }
}

除数函数\sigma(n)

定义

除数函数特指一大类的函数,对于正整数n

\sigma_x(n) = \sum_{d \mid n} d^x
\sigma_0(n)n的约数个数,\sigma_1(n)n的约数和。

结论:\sigma_0(n) \sim O(n^{1 \over \ln\ln n})

积性证明

n, m互素,则有:

\begin{aligned}
\sigma_x(mn) &= \sum_{d_m \mid m}\sum_{d_n \mid n} (d_nd_m)^x \\
&= \sum_{d_m \mid m} d_m^x \sum_{d_n \mid n} d_n^x \\
&= \sigma_x(m)\sigma_x(n)
\end{aligned}
注意这里我们在展开\sigma_x(mn)​的时候利用了m, n​互素这一点,如果m, n​不互素则在这种写法中mn​的某些因子会被计算多次,假设m, n​有非平凡公因子p​,则d_m = 1, d_n= p​d_m = p, d_n = 1​都可以令d_md_n = p​p​就被计算了两次,结果就不对了。

计算

\sigma这个东西在OI中存在感极低(说白了就是没卵用),因此对计算并不做要求。

离散对数问题

给定a, b以及素数p,求最小的x,使得a^x \equiv b \pmod p

大步小步(BSGS)算法

x = ti - j,其中t = \lceil \sqrt p\rceil, 0 \le j < t,则方程被转化为:

\begin{aligned}
a^{ti - j} &\equiv b &\pmod p \\
(a^t)^i &\equiv ba^j &\pmod p
\end{aligned}
我们首先对于所有的0 \le j < tba^j \bmod p插入一个哈希表。接下来我们枚举i的所有取值0, 1, 2, \cdots ,t,计算(a^t)^i \bmod p,查询哈希表中是否存在相应的j即可,复杂度变为O(\sqrt p)

板子

其实哈希表换成std::map也行,但是常数会大很多(手写一个哈希表又不是特别麻烦):

const int HASH = 1000007;
const int ELEM = 6000000;
typedef long long ll;
int tot, fst[HASH], nxt[ELEM];
ll key[ELEM], val[ELEM];

inline void put(ll k, ll v) {
    key[++tot] = k;
    val[tot] = v;
    k %= HASH;
    nxt[tot] = fst[k];
    fst[k] = tot;
}

inline ll lookup(ll k) {
    for (int i = fst[k % HASH]; i; i = nxt[i])
        if (key[i] == k) return val[i];
    return -1;
}

ll bsgs(ll a, ll b, ll p) {
    memset(fst, 0, sizeof(fst));
    tot = 0;
    int t = ceil(sqrt(p));
    ll tmp = b;
    for (int j = 0; j < t; j++, tmp = tmp * a % p) // 计算b*a^j
        put(tmp, j);
    a = qpow(a, t, p); // a^t
    if (a == 0) return b == 0 ? 1 : -1; // 特判
    tmp = 1;
    for (int i = 0; i <= t; i++, tmp = tmp * a % p) { // 计算(a^t)^i
        ll j = lookup(tmp);
        if (j >= 0 && i * t - j >= 0) return i * t - j;
    }
    return -1;
}