Chengyuan Ma's Blog

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

0%

这篇笔记写于2019/2/15,原供稿班级公众号,在某人决定博客重更之后重新整理发上来~2019/12/8上传

[TOC]

为什么会有这篇文章?

最近网上也都在吹爆《流浪地球》这部科幻片啊……

然后我自己前几天也慕名去电影院看了一下,感觉特效之类的着实过瘾。但是作为一部定位为硬科幻的科幻片,除去画面以外,科学的严谨性还是很重要的。但是这部片子里面的一些科学设定在一个KSP玩家看来似乎……

这不科学

还是有些槽点呢……当然瑕不掩瑜。这篇文章就稍微指出一点我发现的《流浪地球》当中的一些Bug,这些Bug主要是轨道力学上的,也就当给同学们做一个科普吧。

自己也是半瓶水,所以,欢迎指正。

定性吐槽

电影里面的运动学乍一看是非常符合直觉的。比如说:

  • 地球的发动机一关,整个地球马上就往木星上靠过去了,轨道一下子拐了一个弯
  • 空间站发动机一开,整个空间站马上就直线飞离地球了,轨道上看得一清二楚
  • 要引爆空间站,就直接对准目标直线冲刺过去了

似乎没啥问题咯?

Uhmmmmmm……

这么说的话以下二位的棺材板就要压不住了:

Newton

(牛顿)

Kepler

(开普勒)

为啥嘞?

开普勒第一定律

回忆一下我们第一学期学的牛顿第一定律:

牛顿第一定律:假若某物体所受合外力为零,则该物体的运动速度不变,即保持静止或做匀速直线运动。

如果所受合力不是零,而是一直受到一个定质点的引力作用,物体的运动状态又会是怎样呢?

开普勒结合牛顿的万有引力定律对牛顿第一定律进行了拓展,得出了开普勒第一定律:

开普勒第一定律:每一个行星都沿各自的椭圆轨道环绕太阳,而太阳则处在椭圆的一个焦点中。

开普勒定律刻画的行星在只受太阳引力作用下的稳定运动轨道,但是真的只有椭圆这一种吗?

其实定律实际推导出的轨道是圆锥曲线,即圆,椭圆,抛物线或双曲线中的任意一种,只是对于普通行星来说,后面两种轨道意味着飞掠而不是围绕,不符合实际,因此开普勒在定律表述里面便只说“椭圆”。

那知道这个定律有啥用呢?

发动机不需要一直点火!

首先,如果地球的行星发动机的推力够足,理论上是用不着像电影里面一直点火的,我们只需要在地球轨道附近把地球加速到足够高速然后熄火,那么根据开普勒定律,地球自己会沿一条圆锥曲线被甩离太阳,并在木星附近被木星捕获,沿以木星为焦点的抛物线或双曲线掠过木星进行引力弹弓加速,然后离开木星被甩出太阳系!这段过程中理想情况下地球只受星体引力影响,不需要发动机一直点火,除了进行一定的轨道修正!‘

这个操作有没有实例呢?

且看旅行者二号:

Voyager 2

它在被火箭加速到足够高的速度之后就放飞自我了,之后走的轨道是什么样的呢?

k03DaR.gif

(绿色为木星轨道)

在离开地球的十多年里,旅行者2号几乎没有启用过自己的轨道发动机,包括在飞掠火星木星等星体进行引力加速的时候也是如此。如果它是靠电影中的一直加速才飞到那么远的话,试问这么小的机体燃料放哪里呢?

所以看到地球上的行星发动机一直燃烧了十七年,Emmm...编剧开心就好。(当然不排除是加速度过低导致需要持续加速的情况,这种情况下轨道确实比较难算,出偏差也是正常的,因此这其实不算一个特别大的Bug)

熄火/点火之后的轨道不科学!

看过电影的同学应该记得,在行星发动机一半熄火的时候,地球马上就拐了一个弯,从飞掠木星变成栽进木星了,就好像有什么东西让它大幅度减速了一样。编剧在这里似乎混淆了速度和加速度的概念,就算地球发动机全部停机,地球几乎只受木星引力作用,地球也会沿着熄火前最后一刻所处的抛物线轨道继续运行:

k03BZ9.jpg

(图找的不是熄火的那个场景,只做演示用)

也就是说,地球会继续按照“原轨道”的红线运行,而不是像影片中的那样当场轨道一拐拐到蓝色轨道上去了。

也就是说,其实发生影片中所谓“危机”的概率会小非常多。

什么?你说抛物线和双曲线本来就有一个拐点?那和影片当中的形状和位置都对不上好吗?

同样的,空间站准备开溜时候的轨道也注定不是一条直线,它同时受着地球和木星的引力影响,做的是三体运动,轨迹怎么会那么漂亮呢?

因为这样,空间站要开到地木之间引爆,也绝非一个“前进三!”的直线冲刺能够与解决的,复杂着呢!

定量吐槽

小马在看完电影的当晚估算了一下:

k03wqJ.png

然后第二天就有同学告诉我我仿佛漏算了什么……确实是这样的,那这个定量的估算到底是怎么来的呢?

Tsiolkovsky公式

在1903年,俄罗斯科学家齐奥尔科夫斯基推导出了著名的火箭公式:

\Delta v = v_e \ln \left(\frac{m_0}{m_1}\right)
(详细推导见这里

其中v_e表示火箭工质的喷出速度,m_0表示火箭的初始质量,m_1表示火箭的燃烧后的末质量,\Delta v表示火箭在发动机加速过程当中所获得的速度增量。

什么叫工质喷出速度呢?“工质”就是火箭发动机喷嘴喷出的物质的代称,也是火箭发动机推力的来源:发动机给工质一个力将工质高速推离火箭,反作用力同时给火箭一个巨大的动力。

如果假设地球的质量是m_e,石头的质量是m_f,那么在我们的情况下,公式可以写作:

\Delta v = v_e \ln \left(\frac{m_e}{m_e-m_f}\right)
我们可以非常简单地估算出(或者借助网络查阅到)m_e的值,因此只要我们知道了v_e\Delta v,我们就可以由上式反解\frac{m_f}{m_e}。从而算出作为燃料的石头的重量!

计算\Delta v

直接逃逸

我们首先计算如果不借助木星的引力弹弓效应需要的最小\Delta v大概是多少:

首先我们计算在地球轨道上相对太阳的逃逸速度,取太阳的标准重力参数\mu = GM = 1.327124 \times 10^{20} \mathrm{m^3/s^2},取地日平均距离r_e = 1\mathrm{au} = 1.496 \times 10^{11} \mathrm{m},若假设轨道偏心率极小可以忽略不计,则根据机械能守恒逃逸速度可以计算如下:

\begin{aligned}
v_{es} &= \sqrt{\frac{2\mu}{r_e}} \\
&= \sqrt{\frac{2\times 1.327124 \times 10^{20} \mathrm{m^3/s^2}}{1.496 \times 10^{11} \mathrm{m}}} \\
&\approx 42.126\mathrm{km/s}
\end{aligned}
我们当然不用取\Delta v = 42.126\mathrm{km/s},因为地球绕太阳公转本来就有一定的线速度,地球的轨道速度如下:
v_o = \sqrt{\frac{\mu}{r_e}} = \frac{v_{es}}{\sqrt 2} \approx 29.784\mathrm{km/s}
如果行星发动机顺着地球的公转方向加速,则\Delta v = v_{es} - v_o = 12337.1 \mathrm{m/s}就够了。

借助木星

我们接下来估算一下如果借助木星的引力弹弓效应加速大约需要多少\Delta v

因为没有给定地木之间的位置关系,直接算引力弹弓难度极大不会,但是我们可以估算一下\Delta v的下界:我们假设地球只需要在地球轨道和木星轨道之间执行一次霍曼转移来进行交会。即让地球的椭圆轨道与木星轨道内切。基于Vis-viva公式,取木星轨道半径为近日点的r_j = 7.405 \times 10^{11}\mathrm{m},则在地球轨道上加速所需要的\Delta v为:

\begin{aligned}
\Delta v &= \sqrt{\frac{2\mu r_j}{r_e(r_e + r_j)}} - v_o \\
&= \sqrt{\frac{2\times 1.327 \times 10^{20} \mathrm{m^3/s^2} \times 7.405 \times 10^{11}\mathrm{m}}{1.496 \times 10^{11} \mathrm{m}\times (1.496 \times 10^{11} \mathrm{m} + 7.405 \times 10^{11}\mathrm{m})}} - 29.784\mathrm{km/s} \\
&\approx 8634.71 \mathrm{m/s}
\end{aligned}
(假设地木轨道共面,不然还要修正轨道倾角)

也就是说,就算地球不是想借助木星加速,只是想碰到木星的轨道,都需要这么多的\Delta v,要借助木星加速一般轨道会更大一点,因此我们估算出来的是\Delta v的下界。

发动机种类

我们已经算出了\Delta v的大小,接下来让我们估一下v_e的大小,我们同时会计算出来所需要燃料的质量。

在下文中,取地球质量为m_e = 5.97\times10^{24}\mathrm{kg},岩石密度为\rho = 3000\mathrm{kg/m^3}

目前科技

目前人类能够造出的v_e最高(比冲最高)的化学燃料发动机大概是液氢液氧发动机,v_e \approx 4400\mathrm{m/s}。(根据资料比冲最高的发动机其实是欧空局的DS4G(电推),v_e \approx 210000\mathrm{m/s},可惜推力只有2.5\mathrm{N},还贼费电。)

因此,在直接逃逸的情况下我们有:

\begin{aligned}
&\quad \Delta v = v_e \ln \left(\frac{m_e}{m_e-m_f}\right) \\
&\Rightarrow \ln \left(\frac{m_e}{m_e-m_f}\right) = \frac{\Delta v}{v_e} \\
&\Rightarrow \frac{m_e-m_f}{m_e} = e^{-\frac{\Delta v}{v_e}} \\
&\Rightarrow m_f = \left(1 - e^{-\frac{\Delta v}{v_e}}\right)m_e
\end{aligned}
取相关数据带入,得m_f = 5.608 \times 10^{24}\mathrm{kg} \approx 94\%m_e……

Ummm……不用算了,要把地球挖空的。

在借助木星引力加速的情况下其实也好不到哪里去,计算可以得到m_f = 5.131 \times 10^{24}\mathrm{kg} \approx 86\%m_e

这个好一点,思考一下人类靠14\%的地球可以干嘛吧,这还没算上后续的加速和减速,估计流浪到最后变成小行星了。细思极恐

以上计算其实反映出一点:因为逆用公式算出的只是燃油质量和火箭质量的比值,这个比值无论大到地球小到火箭都适用,也就是说大家看到巨大的火箭一般都有95\%以上的质量是燃料,而剩下的质量包括了外壳,发动机,管线,最后才是载荷。

外星科技

既然影片当中的发动机叫“重聚变发动机”(先不纠结为啥重元素可以不吸能聚变),那推进方式肯定不是垃圾的氢氧机可以比拟的。这样吧,假设工质可以加速到光速(顺便压住了爱因斯坦的棺材板),取v_e = c = 299792458\mathrm{m/s}。会怎么样呢?(关于相对论效应:齐奥尔科夫斯基公式只有在被推进物接近光速(Relativistic Rocket)的时候才需要修正,对于工质速度接近光速的情况则不用。)

在直接加速的情况下,我们有m_f = 2.45673 \times 10^{20}\mathrm{kg} \approx 4.1\times 10^{-5}m_e。似乎还可以接受哦?

体积是多少呢?

V_f = \frac{m_f}{\rho} = 8.19\times10^{16}\mathrm{m^3}\approx (434.256\mathrm{km})^3
也就是说所需要的燃料可以装满一个边长434\mathrm{km}的立方体!作为参考,国际空间站轨道高度为400\mathrm{km}左右。

如果嫌这个不够直接,取陆地地壳平均厚度33\mathrm{km},则需要挖掉整个面积为2.5 \times 10^{12}\mathrm{m^2}大小的土地,而全球陆地面积才只有1.49\times 10^{14}\mathrm{m^2}!这意味着:

  • 大概要把全球陆地都下挖550\mathrm{m}左右,比环球金融中心高一点。
  • 把边长为1575\mathrm{km}的一块正方形地壳挖光,作为参考,这约占我国国土的四分之一。

利用木星加速需要多少燃料呢?带入可得m_f = 1.71947 \times 10^{20}\mathrm{kg},可以装满边长为385\mathrm{km}的立方体。需要把全球陆地都下挖400\mathrm{m}左右,如果打算把地壳挖光,则需要挖去的地壳面积为我国国土的五分之一左右。

Emm……Emm……

质能转化角度

换种思想,假设人类已经解锁了一个无敌恐怖的科技点,即把燃料的所有静能量转化为地球的机械能,那会怎么样呢?

根据著名的质能方程式E=mc^2,我们可以列式:

m_fc^2 = \frac{1}{2}m_e(v_o + \Delta v)^2 - \frac{1}{2}m_ev_o^2
如果直接逃逸,解得燃料质量为m_f = 2.94629 \times 10^{16} \mathrm{kg}。获取这么多燃料需要把整个上海市区的地壳挖空。

如果借助木星。解得燃料质量为m_f = 1.95593 \times 10^{16} \mathrm{kg}。获取这么多燃料也需要把整个上海中心城区的地壳挖空。

这样开起来稍微可行一点了吧……似乎开掘一万座地下城挖剩的石料就够用了?

但是这个视角的问题在于我们的假设,如果静能量全部转化成机械能,则:

  • 燃料必须在相互反应当中全部湮灭,作为对比,核聚变和核裂变当中湮灭的仅仅是反应物和产物之间的微小质量差(可能是每个原子一两个中子/质子),目前能够达到全部湮灭的方法只有把当中一半的燃料转化为反物质。
  • 湮灭产生的巨大能量必须全部收集,不能产生耗散。
  • 收集的能量必须全部转化成地球的机械能,这意味着不能有喷出的工质,因为这相当于有能量转化成工质机械能,同时整个体系必须不能发热,也发动机必须全部常温,不然会有热量作为热能耗散。

这当中做到任何一点都是科技的极大进步,而但凡物质不能全部湮灭(而是采取核聚变),能量难以完全收集与转化,则能量的利用率只会千万倍地往下降,消耗的燃料也必将回归上一小节当中的恐怖水平。

结语

经过上文的分析,我们发现其实流浪地球的世界观本身还是有一点不科学的,尤其是地球发动机,简直是Bug一般的存在。但是这并不是说流浪地球就不是一部好片子,因为几乎每一部科幻片都有能让人杠一杠的地方。因此写这篇文章只是为了分享一下自己想法,也帮助科普一下一些轨道力学的知识。当然在推式子的过程中也和同学们复习了一下指对数函数的知识,真是极好的。

安利

什么?你问我自己怎么会想到这些?

因为本人是Kerbal Space Program玩家啊#(滑稽)

k03aMF.jpg

在这里给大家安利一下这款“游戏”,游戏本身非常硬核,需要玩家自己设计并且发射航天器进入轨道,自己规划轨道计算燃料,自己操控交会对接,模拟了近乎真实的物理系统,内有一个类似太阳系的星系。上述\Delta v的计算,霍曼转移,燃料量的计算都是游戏当中的必修课是的现在玩游戏都要用计算器。个人认为比一些纯粹娱乐向的手游之类的更能从中学到知识,但同时因为这款游戏玩起来消耗大量脑细胞与时间,一般每周末玩两个小时就够了。

k03rI1.jpg

(我自己搭的Jool飞掠器的截图,Jool对应太阳系的木星)

从动手设计到发射成功现实当中花了一个半小时,游戏当中是两年半,失败五次。本质新手劝退向游戏

当然,虽然这款游戏Steam上要七十多块,但是破解版烂大街。欢迎有兴趣的同学来玩。

数据来源

大部分公式,数据均来源于英文维基百科,少部分来自NASA官网。

公式全部基于万有引力定律与能量守恒定律,绝大部分的公式可以在高中知识下进行推导。

简介

后缀数组是字符串当中常见的一种数据结构,它将一个字符串S的所有后缀进行排序,得到以下三个数组:

  1. \operatorname{sa}[i]:排序后排名为i的后缀的起始位置
  2. \operatorname{rank}[i]:起始位置为i的后缀的排名
  3. \operatorname{height}[i]:起始位置\operatorname{sa}[i]的后缀和起始位置\operatorname{sa}[i - 1]的后缀的最长公共前缀长度

利用这三个数组我们可以有力地解决字符串当中的大部分问题。

构造

显然如果我们朴素地去构造长度为n的字符串的后缀树组的话,时间复杂度为O(n^2\log n)。这对于长字符串来说是完全无法接受的。然而利用倍增的思想我们可以在O(n\log n)之内快速构造后缀树组,虽然像DC3,SAIS之类的O(n)算法也存在,但是倍增的编码难度更低,也足以通过大部分的题目(毕竟可能很多时候瓶颈不在于构造)。

O(n\log n)的构造代码如下:

void build_sa() {
    static int a[N], b[N], d[N], tmp[N], buc[N];
    copy(s + 1, s + 1 + n, d + 1);
    sort(d + 1, d + 1 + n);
    int *end = unique(d + 1, d + 1 + n);
    for (int i = 1; i <= n; i++) a[i] = lower_bound(d + 1, end, s[i]) - d;
    for (int i = 1; i <= n; i++) buc[a[i]]++;
    for (int i = 1; i <= n; i++) buc[i] += buc[i - 1];
    for (int i = 1; i <= n; i++) rk[i] = buc[a[i] - 1] + 1;
    for (int t = 1; t <= n; t *= 2) {
        copy(rk + 1, rk + 1 + n, a + 1);
        for (int i = 1; i <= n; i++) b[i] = i + t > n ? 0 : rk[i + t];
        
        fill(buc, buc + n + 1, 0);
        for (int i = 1; i <= n; i++) buc[b[i]]++;
        for (int i = 1; i <= n; i++) buc[i] += buc[i - 1];
        for (int i = n; i; i--) tmp[buc[b[i]]--] = i;

        fill(buc, buc + n + 1, 0);
        for (int i = 1; i <= n; i++) buc[a[i]]++;
        for (int i = 1; i <= n; i++) buc[i] += buc[i - 1];
        for (int i = n; i; i--) sa[buc[a[tmp[i]]]--] = tmp[i];

        bool flag = true;
        for (int i = 1, last = 0; i <= n; i++) {
            int p = sa[i];
            if (!last) rk[p] = 1;
            else if (a[p] == a[last] && b[p] == b[last])
                flag = false, rk[p] = rk[last];
            else rk[p] = rk[last] + 1;
            last = p;
        }
        if (flag) break;
    } 
}

其思想如下:如果我们先把所有后缀按照它们前2^{i - 1}个字符进行排序,那基于排完序的结果我们就可以使用双关键字排序对所有后缀按照前2^{i}个字符进行排序(原因是后缀的后缀还是后缀),当然在实践过程中我们常使用桶排来加速这一个过程。

代码的最后一段是在\operatorname{rank}互异的时候直接跳出,这是一个小优化。

计算\operatorname{height}[]

除了朴素比较之外,\operatorname{height}数组的计算可以在O(n)时间内完成。

观察:h[i] = \operatorname{height}[\operatorname{rank}[i]],则我们有:

h[i] \ge h[i - 1] - 1
证明:

  1. h[i - 1] = 0:Trivial
  2. h[i - 1] > 0:设k = \operatorname{sa}[\operatorname{rank}[i - 1]-1]表示\operatorname{sa}数组当中排在以i - 1为起始的后缀前的后缀的起始位置,以k开始的后缀与以i - 1开始的后缀的LCP为h[i - 1]。考虑统一截掉开头的一个字符,得到起始位置为k + 1i的两个后缀,其LCP为h[i - 1] - 1k +1\operatorname{sa}[]里面肯定排在i前面,又因为后缀数组的性质,i\operatorname{sa}[\operatorname{rank}[i] - 1]的LCP一定不会小于ik + 1的LCP,因此不等式成立。

基于以上的观察,计算\operatorname{height}[]的代码如下:

for (int i = 1, k = 0; i <= n; i++) {
    if (rk[i] == 1) k = 0;
    else {
        if (k > 0) k--;
        int j = sa[rk[i] - 1];
        while (i + k <= n && j + k <= n && s[i + k] == s[j + k]) k++;
    }
    ht[rk[i]] = k;
}

NTT的思想与FFT类似,但是避免了浮点数运算带来的精度问题。

原根

定义:m > 1\gcd(a, m) = 1,使得a^d \equiv 1 \pmod m的最小d称为am,记作d = \delta_m(a)

定理:显然,对于所有a^d \equiv 1 \pmod m,都有\delta_m(a) \mid d

同时根据欧拉定理我们有

a^{\varphi(m)} \equiv 1 \pmod m
因此有\delta_m(a) \mid \varphi(m)

定义:\delta_m(g) = \varphi(m)则称g为模m意义下的原根

m​存在原根当且仅当m = 2,4,p^n, 2p^n​,其中p​为奇素数,n \in \mathbb{Z}​

原根的意义:如果m存在原根g,那g^0, g^1, g^2, \cdots, g^{\varphi(m) - 1}恰能表示所有\varphi(m)个与m互素的数,且构成m的简化剩余系。

原根的计算方法:原根只有\varphi(\varphi(m))个,因此只要枚举即可。

NTT

NTT中使用单位原根代替单位复数根进行计算,单位原根定义为:

g_n = g^{\varphi(p) / n} = g^{(p - 1)/n}
其中g为模素数p的原根。

显然g_n有与单位负数根类似的性质:

g_n^n = g^{p - 1} \equiv 1 \pmod p
又因为\left(g_n^{n / 2}\right)^2 \equiv 1 \pmod p以及一些二次剩余的知识,g_n^{n / 2} \bmod p只可能是\pm 1,又因为原根的性质
g_n^{n / 2} \equiv -1 \pmod p
同时,观察g_n的定义,易证g_{2n}^{2k} = g_n^k(折半引理),原根具备所有FFT中利用的单位复数根的性质,因此也可以使用单位原根进行FFT,得到的算法就是NTT(Number Theoretic Transform),NTT适用于整数序列的变换,且精度与数值稳定性较FFT更优。

代码:

#include <cstdio>
#include <algorithm>

using namespace std;
const int MOD = 998244353;
const int N = 270000;
const int G = 3, INVG = 332748118;
typedef long long ll;
int n, m; 
ll a[N], b[N];

ll qpow(ll x, ll y) {
    ll ret = 1;
    for (; y; y >>= 1) {
        if (y & 1) ret = ret * x % MOD;
        x = x * x % MOD;
    }
    return ret;
}

void ntt(int len, ll *x, int dir = 1) {
    static ll X[N];
    for (int i = 0, k = 0; i < len; i++) {
        X[i] = x[k];
        int y = len >> 1;
        while (y & k) k ^= y, y >>= 1;
        k |= y;
    }
    for (int s = 2; s <= len; s <<= 1) {
        ll w_ = qpow(G, (MOD - 1) / s);
        if (dir == -1) w_ = qpow(w_, MOD - 2);
        for (int l = 0; l < len; l += s) {
            int mid = l + (s >> 1);
            ll w = 1;
            for (int i = 0; i < s >> 1; i++) {
                ll t = w * X[mid + i] % MOD;
                X[mid + i] = (X[l + i] - t) % MOD;
                X[l + i] = (X[l + i] + t) % MOD;
                w = w * w_ % MOD;
            }
        }
    }
    copy(X, X + len, x);
}

int main() {
    scanf("%d%d", &n, &m);
    for (int i = 0; i <= n; i++) scanf("%lld", &a[i]);
    for (int i = 0; i <= m; i++) scanf("%lld", &b[i]);
    int sz = 1; while (sz < n + m + 1) sz <<= 1;
    ntt(sz, a); ntt(sz, b);
    for (int i = 0; i < sz; i++) printf("%lld %lld\n", a[i], b[i]);
    for (int i = 0; i < sz; i++) a[i] = a[i] * b[i] % MOD;
    ntt(sz, a, -1); ll inv = qpow(sz, MOD - 2);
    for (int i = 0; i < n + m + 1; i++)
        printf("%lld ", (a[i] * inv % MOD + MOD) % MOD);
    puts("");
    return 0;
} 

常用的NTT素数

NTT所用的素数应该满足什么条件呢?观察g_n的定义显然我们期望对于一定范围内的k(比如说2^k \le 2\times 10^5)都最好有2^k \mid p-1。即p = a\times 2^k +1的形式。

常见的NTT素数为:

  1. 998244353 = 7\times 17\times 2^{23} + 1​
  2. 1004535809 = 479 \times 2^{21} + 1
  3. 469762049 = 7 \times 2^{26} + 1
  4. 2281701377 = 17 \times 2^{27} + 1
  5. 167772161 = 5 \times 2^{25} + 1

这些素数的原根都包含3

任意模数NTT

有些时候题目要求任意模数的NTT变换,这个时候我们可以选取三个上述列表中模数分别进行NTT,然后通过CRT进行合并。具体上来说,对于一个数x,若有:

\begin{aligned}
x &\equiv b_1 &\pmod{p_1} \\
x &\equiv b_2 &\pmod{p_2} \\
x &\equiv b_3 &\pmod{p_3} 
\end{aligned}
我们考虑两两合并,先求解b_1 + k_1p_1 \equiv b_2 \pmod {p_2}​,不难得到k_1 = p_1^{-1}(b_2 - b_1)​(逆元在模p_2​意义下)。

x' = b_1 + k_1p_1,再求解x = x' + k_2p_1p_2 \equiv b_3 \pmod {p_3},此时有k_2 = (p_1p_2)^{-1}(b_3 - x')(逆元在模p_3意义下)。

在计算的最后再对于指定的模数取模即可。

代码:

#include <cstdio>
#include <algorithm>

using namespace std;
typedef long long ll;
const ll G = 3;
const ll MOD1 = 998244353, MOD2 = 1004535809, MOD3 = 469762049;
const int N = 270000;
int n, m, P;

int qpow(int x, int y, int mod) {
    int ret = 1;
    for (; y; y >>= 1) {
        if (y & 1) ret = (ll)ret * x % mod;
        x = (ll)x * x % mod;
    }
    return ret;
}

struct mint {
    int m1, m2, m3;
    mint() {}
    mint(int x) : m1(x % MOD1), m2(x % MOD2), m3(x % MOD3) {}
    mint(int m1, int m2, int m3) : m1(m1), m2(m2), m3(m3) {}
    mint reduce() { 
        return mint(m1 + (m1 >> 31 & MOD1), // m1 >> 31 是符号位
                    m2 + (m2 >> 31 & MOD2), 
                    m3 + (m3 >> 31 & MOD3));
    }
    mint operator +(const mint &x) const {
        return mint(m1 + x.m1 - MOD1, 
                    m2 + x.m2 - MOD2, 
                    m3 + x.m3 - MOD3).reduce();
    }
    mint operator -(const mint &x) const {
        return mint(m1 - x.m1, m2 - x.m2, m3 - x.m3).reduce();
    }
    mint operator *(const mint &x) const {
        return mint((ll)m1 * x.m1 % MOD1, 
                    (ll)m2 * x.m2 % MOD2, 
                    (ll)m3 * x.m3 % MOD3);
    }
    mint inv() {
        return mint(qpow(m1, MOD1 - 2, MOD1), 
                    qpow(m2, MOD2 - 2, MOD2), 
                    qpow(m3, MOD3 - 2, MOD3));
    }
    int get() {
        const int INV_1 = qpow(MOD1, MOD2 - 2, MOD2);
        const int INV_2 = qpow((ll)MOD1 * MOD2 % MOD3, MOD3 - 2, MOD3);
        ll x = (ll)(m2 - m1 + MOD2) % MOD2 * INV_1 % MOD2 * MOD1 + m1;
        return ((ll)(m3 - x % MOD3 + MOD3) % MOD3 * INV_2 % MOD3 
                * ((ll)MOD1 * MOD2 % P) % P + x) % P;
    }
    static mint unit(int s) {
        return mint(qpow(G, (MOD1 - 1) / s, MOD1), 
                    qpow(G, (MOD2 - 1) / s, MOD2), 
                    qpow(G, (MOD3 - 1) / s, MOD3));
    }
} a[N], b[N];

void ntt(int len, mint *x, int dir = 1) {
    static mint X[N];
    for (int i = 0, k = 0; i < len; i++) {
        X[i] = x[k];
        int y = len >> 1;
        while (y & k) k ^= y, y >>= 1;
        k |= y;
    }

    for (int s = 2; s <= len; s <<= 1) {
        mint w_ = mint::unit(s);
        if (dir == -1) w_ = w_.inv();
        for (int l = 0; l < len; l += s) {
            int mid = l + (s >> 1);
            mint w = 1;
            for (int i = 0; i < s >> 1; i++) {
                mint t = w * X[mid + i];
                X[mid + i] = X[l + i] - t;
                X[l + i] = X[l + i] + t;
                w = w * w_;
            }
        }
    }
    if (dir == -1) {
        mint inv = mint(len, len, len).inv();
        for (int i = 0; i < len; i++) X[i] = X[i] * inv;
    }
    copy(X, X + len, x);
}

int main() {
    scanf("%d%d%d", &n, &m, &P);
    for (int i = 0; i <= n; i++) { int tmp; scanf("%d", &tmp); a[i] = tmp; }
    for (int i = 0; i <= m; i++) { int tmp; scanf("%d", &tmp); b[i] = tmp; }
    int sz = 1; while (sz < n + m + 1) sz <<= 1;
    ntt(sz, a); ntt(sz, b);
    for (int i = 0; i < sz; i++) a[i] = a[i] * b[i];
    ntt(sz, a, -1);
    for (int i = 0; i < n + m + 1; i++) printf("%d ", a[i].get());
    puts("");
    return 0;
}

问题

求满足:

\begin{aligned}
x &\equiv b_1 &\pmod{a_1} \\
x &\equiv b_2 &\pmod{a_2} \\
x &\equiv b_3 &\pmod{a_3} \\
&\ \ \vdots &\\
x &\equiv b_n &\pmod{a_n}
\end{aligned}
x的值。

ExCRT

a_1, a_2, \cdots, a_n互素的时候显然可以使用中国剩余定理(CRT)解决,时间复杂度为O(n\log n)

考虑不互素的情况,假设已经解决了前i - 1个方程,得到的解为x_{i - 1}\operatorname{lcm}(a_1, \cdots, a_{i - 1}) = m

那么我们考虑解决

x_{i - 1} + tm \equiv b_i \pmod{a_i}
易证得到的解x_i = x_{i - 1} + tm不会再和前i个解产生冲突。

稍微移项一下:

tm \equiv b_i - x_{i - 1} \pmod{a_i}
用ExGCD解出t即可。

这套方法称为ExCRT(拓展中国剩余定理),复杂度依旧是O(n\log n)全面优于CRT。

代码:

#include <cstdio>

using namespace std;
typedef long long ll;
const int N = 100010;
int n;
ll a[N], b[N];

ll exgcd(ll a, ll b, ll &x, ll &y) {
    if (b == 0) return x = 1, y = 0, a;
    ll d = exgcd(b, a % b, y, x);
    return y -= a / b * x, d;
}

ll smul(ll x, ll y, ll mod) {
    if (y < 0) return -qmul(x, -y, mod);
    ll ret = 0;
    for (; y; y >>= 1) {
        if (y & 1) ret = (ret + x) % mod;
        x = (x + x) % mod;
    }
    return ret;
}

ll excrt() {
    ll lcm = a[1], t = b[1], x, y;
    for (int i = 2; i <= n; i++) {
        ll d = exgcd(lcm, a[i], x, y);
        x = smul(x, (b[i] - t) / d, a[i]);
        t += x * lcm;
        lcm = lcm / d * a[i];
        t = (t % lcm + lcm) % lcm;
    }
    return t;
}

int main() {
    scanf("%d", &n);
    for (int i = 1; i <= n; i++) scanf("%lld%lld", &a[i], &b[i]);
    printf("%lld\n", excrt());
    return 0;
}

上次更新是在十月份?我到底摸了多久

卷积

对于两个序列\{x\}, \{y\}​,定义它们的卷积\{x \ast y\}​为:

(x \ast y)_k = \sum_{j = 0}^k x_jy_{k- j}

单位根

定义复平面单位圆上的n​次单位根

\omega_{n} = e^{\frac{2\pi i}{n}} = \cos\left(\frac{2\pi}{n}\right) + i\sin\left(\frac{2\pi}{n}\right)
易证其有如下的性质:

  1. \omega_{2n}^{2k} = \omega_n^k
  2. \omega_n^k = \omega_n^{k +n}
  3. \omega_n^k = - \omega_{n}^{k + n / 2}

DFT

对于一长度为n的序列\{x\},定义其离散傅里叶变换(DFT)\mathcal{F}\{x\} = \{X\}为:

\mathcal{F}\{x\}_k = X_k = \sum_{j = 0}^{n - 1} x_i\omega_n^{jk}
则有其离散傅里叶逆变换(IDFT)为:
x_k = \frac{1}{n}\sum_{j = 0}^{n - 1} X_i\omega_n^{-jk}
(标准定义里面\omega_n^{jk}​\omega_n^{-jk}​应该互换,但是不影响)

暴力证明:考虑\mathcal{F}\{x_n\}的计算式:

\begin{pmatrix}
1 &1 &1 &\cdots &1 \\
1 &\omega_n &\omega_n^2 &\cdots &\omega_n^{n - 1} \\
1 &\omega_n^2 &\omega_n^4 &\cdots &\omega_n^{2(n-1)} \\
\vdots &\vdots &\vdots &\ddots &\vdots \\
1 &\omega_n^{n - 1} &\omega_{n}^{2(n-1)} &\cdots &\omega_n^{(n-1)^2}
\end{pmatrix}
\begin{pmatrix}
x_1 \\x_2 \\x_3 \\ \vdots \\x_{n - 1}
\end{pmatrix}
=
\begin{pmatrix}
X_1 \\X_2 \\X_3 \\ \vdots \\X_{n - 1}
\end{pmatrix}
设左边矩阵为V​,接下来从充分性上证明V^{-1}_{jk} = \frac{\omega_n^{-jk}}{n}​
\left(VV^{-1}\right)_{jk} = \sum_{l = 0}^{n - 1}\omega_n^{jl}\cdot\frac{\omega_n^{-lk}}{n}
= \frac{1}{n}\sum_{l = 0}^{n - 1}\omega_n^{l(j - k)}
显然当j = k​时有\left(VV^{-1}\right)_{jk} = 1​,而j \neq k​时,由等比数列求和公式:
\begin{aligned}
\frac{1}{n}\sum_{l = 0}^{n - 1}\omega_n^{l(j - k)} &= \frac{1}{n} \cdot \frac{1 - \omega_n^{n(j - k)}}{1 - \omega_n^{j - k}} = 0
\end{aligned}
因此VV^{-1} = I​,得证。

上面的DFT有如下意义:

  1. \{x_n\}​作为一组正交基\{\omega_n^0, \omega_n^1, \cdots\}, \{\omega_n^0, \omega_n^{2}, \cdots\}​的系数,计算和向量。(在此意义下逆变换就是将向量进行分解,因此逆变换的意义显然,无需上述证明)
  2. \{x_n\}作为以个多项式的系数,计算该多项式在\omega_n^0, \omega_n^1,\cdots点上的取值。

DFT是线性的,即和的DFT等于DFT的和,且常数倍的DFT是DFT的常数倍。

同时DFT的另一个重要性质被称为卷积性,即

\mathcal{F}\{x\}_k\mathcal{F}\{y\}_k = \mathcal{F}\{x \ast y\}_k
证明:把式子写出来:
\left(\sum_{j = 0}^{n - 1} x_j \omega_n^{jk}\right)\left(\sum_{j = 0}^{n - 1} y_j \omega_n^{jk}\right) \overset{?}{=} \sum_{j = 1}^{n - 1}\left(\sum_{l = 0}^jx_ly_{j - l}\right)\omega_n^{jk}
显然左边式子展开对应的任意一项x_ay_b\omega_n^{(a + b)k}都唯一地出现在右式j = a+b内层求和的展开式中,因此两个式子总是等价的。

DFT将卷积O(n^2)的操作转化为O(n)的操作,因此可以用于加速卷积。

FFT

因为DFT对应点相乘这一操作本身是O(n)​的,因此我们只要找到快速的DFT/IDFT算法,就可以避免O(n^2)​地计算卷积。FFT就是这样一种基于分治的快速DFT算法,时间复杂度为O(n\log n)​

首先假设我们需要计算一个序列\{x\}的DFT(假设n2的整数次幂),我们按照下标的奇偶性将这个序列分为\{a\}, \{b\}两部分,其中a_j = x_{2j}b_j = x_{2j + 1}。那我们有:

\begin{aligned}
\mathcal{F}\{x\}_k &= \sum_{j = 0}^{n - 1} x_j\omega_n^{jk} \\
&= \sum_{j = 0}^{\frac{n}{2} - 1} \left[(x_{2j}\omega_n^{2jk} + x_{2j + 1}\omega_n^{(2j + 1)k}\right] \\
&= \sum_{j = 0}^{\frac{n}{2} - 1} x_{2j}\omega_n^{2jk} + \omega_n^k \sum_{j = 0}^{\frac{n}{2} - 1} x_{2j + 1}\omega_n^{2jk} \\
&= \sum_{j = 0}^{\frac{n}{2} - 1} a_j\omega_{n / 2}^{jk} + \omega_n^k \sum_{j = 0}^{\frac{n}{2} - 1} b_j\omega_{n / 2}^{jk} \\
&= \mathcal{F}\{a\}_k + \omega_{n}^k\mathcal{F}\{b\}_k
\end{aligned}
看起来好像没有问题?我们最后一步推导其实是有一点小问题的,因为当\mathcal{F}\{a\}依据我们的定义只有\frac{n}{2}项,因此上面的推导只能让我们计算0 \le k < \frac{n}{2}的值。对于k \ge \frac{n}{2}的情况要如何处理呢?

首先,注意到:

\omega_n^{2jk} = \omega_n^{2jk - jn} = \omega{_n^{2j(k - n / 2)}}
因此我们稍微变一下:
\begin{aligned}
\mathcal{F}\{x\}_k &= \sum_{j = 0}^{n - 1} x_j\omega_n^{jk} \\
&= \sum_{j = 0}^{\frac{n}{2} - 1} \left[x_{2j}\omega_n^{2j(k - n /2)} + x_{2j + 1}\omega_n^{2j(k - n /2) + k}\right] \\
&= \sum_{j = 0}^{\frac{n}{2} - 1} x_{2j}\omega_n^{2j(k - n /2)} + \omega_n^k \sum_{j = 0}^{\frac{n}{2} - 1} x_{2j + 1}\omega_n^{2j(k - n /2)} \\
&= \sum_{j = 0}^{\frac{n}{2} - 1} a_j\omega_{n / 2}^{j(k - n /2)} + \omega_n^k \sum_{j = 0}^{\frac{n}{2} - 1} b_j\omega_{n / 2}^{j(k - n /2)} \\
&= \mathcal{F}\{a\}_{k - n / 2} - \omega_{n}^{k - n / 2}\mathcal{F}\{b\}_{k - n / 2}
\end{aligned}
(其实本质上就是DFT的序列其实是可以周期拓展的)

这使得我们得以计算\frac{n}{2} \le k < n时DFT的值。因此,最为朴素的FFT实现就是基于递归不断将\{x_n\}进行分治,并通过上式对于分治结果进行合并。时间复杂度自然就是O(n\log n)

然而事实一定没有那么简单。递归写法的FFT常数很大,很大程度上是因为合并本身不是in-place的,需要额外申请空间。更快的in-place FFT实现,Cooley–Tukey算法,是基于这样一个思路的:

假设每次分出来的\{a\}向左走,\{b\}向右走,那递归版的FFT就会画出一棵很漂亮的二叉树,其中叶子节点对应的下标和序列本身的下标顺序是有很大差别的。我们可以预先快速计算出叶子节点的顺序,然后直接从叶子开始就地一层一层地合并上去。

那叶子节点的顺序遵循怎样的规律呢?其实我们依据奇偶分组本质上是在依据二进制的末位分组,在这样的思想下,我们脑补一下发现这就是叶子节点的下标把原来序列的下标的二进制翻转一下(低位变高位,高位变低位)(即\{00, 01,10, 11\}到叶子节点变成\{00, 10, 01, 11\})(可以结合画图理解)。这样的话我们维护一个从高位到低位的二进制加法器就行了。

代码如下:

#include <cstdio>
#include <complex>
#include <cmath>

using namespace std;
typedef complex<double> cplx;
const double PI = 3.1415926535897932384626;

void fft(int len, cplx *x, cplx *X, int dir = 1) {
    for (int i = 0, k = 0; i < len; i++) {
        X[i] = x[k];
        int y = len >> 1;
        while (y & k) k ^= y, y >>= 1; // 维护倒序加法器
        k |= y; 
    }
    for (int s = 2; s <= len; s <<= 1) {
        cplx w_(cos(2 * PI / s), dir * sin(2 * PI / s));
        for (int l = 0; l < len; l += s) {
            int mid = l + (s >> 1);
            cplx w = 1;
            for (int i = 0; i < s >> 1; i++) { // 逐层向上合并(一次蝴蝶操作)
                cplx t = w * X[mid + i];
                X[mid + i] = X[l + i] - t;
                X[l + i] = X[l + i] + t;
                w *= w_; 
            }
        }
    }
}

电路图

绿色箭头为PWM开段电流方向,红色为闭段电流方向。

模型

符号定义

符号 含义
f_{\text{pwm}} \approx 1250\text{Hz} PWM频率
t_{\text{pwm}} \approx 1 / f_{\text{pwm}} PWM周期
t_{\text{on}} PWM高电位时间
t_{\text{off}} PWM低电位时间
I(t) 电流(随时间变化)
I_0 PWM周期开始时的电流
I_{\text{max}} PWM峰值电流
I_{\text{final}} PWM周期结束时的电流
U_\text{b} \approx 7.4\text V 电池电压
U_\text{bemf} 反电动势
U_\text L 电感电压
U_\text D \approx 0.75 \text V 二极管压降
L \approx 6.5\times 10^{-4}\text H 电感
R \approx 1.609\Omega 马达内阻
R_\text s \approx 0.28\Omega 系统内阻

部分参数可以自行测定,测定方法见文末。

开段分析

首先,由基尔霍夫定律和欧姆定律,可得:

U_\text b - I(t)(R+R_\text s) - U_\text L - U_\text {bemf} = 0
对上式进行整理,并结合电感电压公式:
U_\text b = L\frac{dI(t)}{dt} + I(t)(R + R_\text s) + U_\text {bemf}
我们把它看做一个关于I的线性微分方程,初始条件为I(0) = I_0解得:
I(t) = e^{-\frac{t(R + R_\text s)}{L}} \left( I_0 - \frac{U_\text b - U_\text {bemf}}{R + R_\text s}\right) + \frac{U_\text b - U_\text {bemf}}{R + R_\text s}
我们知道电流在高电压段逐步变大,因此I_\text{max} = I(t_\text{on}),即:
I_\text{max} = e^{-\frac{t_\text{on}(R + R_\text s)}{L}} \left( I_0 - \frac{U_\text b - U_\text {bemf}}{R + R_\text s}\right) + \frac{U_\text b - U_\text {bemf}}{R + R_\text s}
出于简化式子的需要,令
\begin{aligned}
c_\text{on} &= -\frac{t_\text{on}(R + R_\text s)}{L} \\
e_\text{on} &= e^{c_\text{on}} = e^{-\frac{t_\text{on}(R + R_\text s)}{L}} \\
I_\text{on} &= \frac{U_\text b - U_\text {bemf}}{R + R_\text s}
\end{aligned}
注意到I_\text{on}\frac{dI(t)}{dt} \to 0时的电流,因此也称作稳态电流

整理得到:

I_\text{max} = e_\text{on}I_0 + (1 - e_\text{on})I_\text{on}
为了之后平均电流的计算需要我们顺便计算一波I(t)的积分:
\int^{t_\text{on}}_0 I(t)dt = t_\text{on}\left(I_\text{on} - \frac{(1 - e_\text{on})(I_0 - I_\text{on})}{c_\text{on}}\right)

闭段分析

再次由基尔霍夫定律及欧姆定律,有:

I(t)R + U_\text L + U_\text {bemf} + U_\text D = 0
同样整理:
I(t)R + L \frac{dI(t)}{dt}+ U_\text {bemf} + U_\text D = 0
以初始条件I(0) = I_\text{max}求解这个微分方程解得:
I(t) = e^{-\frac{tR}{L}}\left(I_\text{max} + \frac{U_\text {bemf} + U_\text D}{R}\right) - \frac{U_\text {bemf} + U_\text D}{R}
类似地:
I_\text{final} = e^{-\frac{t_\text{off}R}{L}}\left(I_\text{max} + \frac{U_\text {bemf} + U_\text D}{R}\right) - \frac{U_\text {bemf} + U_\text D}{R}
我们也令:
\begin{aligned}
c_\text{off} &= -\frac{t_\text{off}R}{L}\\
e_\text{off} &= e^{c_\text{off}} = e^{-\frac{t_\text{off}R}{L}} \\
I_\text{off} &= - \frac{U_\text {bemf} + U_\text D}{R}
\end{aligned}
可见I_\text{off}也是\frac{dI(t)}{dt} \to 0时的电流,也是稳态电流。

整理得到:

I_\text{final} = e_\text{off}I_\text{max} + (1 - e_\text{off})I_\text{off}
为了后文计算平均电流的需要,我们也计算积分:
\int^{t_\text{off}}_0 I(t)dt = t_\text{off}\left(I_\text{off} - \frac{(1 - e_\text{off})(I_\text{max} - I_\text{off})}{c_\text{off}}\right)

注意到以上式子都依赖于t_\text{on}t_\text{off},前者就是占空比乘以PWM总周期长度,而后者则有些棘手,因为t_\text{off} = t_\text{pwm} - t_\text{on}并不总是成立

接下来的分析有两种情况:

  1. 连续电流:PWM周期中的电流是连续的,即I_0 = I_\text{final}
  2. 不连续电流:电流在PWM闭段下降至0被二极管截断,即I_0 = I_\text{final} = 0

两种情况需要分类讨论。

连续电流

I_0 = I_\text{final},将I_\text{final}展开:

I_0 = I_\text{final} = e_\text{off}(e_\text{on}I_0 + (1 - e_\text{on})I_\text{on}) + (1 - e_\text{off})I_\text{off}
解出I_0
I_0 = \frac{e_\text{off}(I_\text{off} + (e_\text{on} - 1)I_\text{on}) - I_\text{off}}{e_\text{off}e_\text{on} - 1}

不连续电流

这个时候我们知道I_0 = I_\text{final} = 0,由I_\text{final}的计算式我们可以反解出t_\text{off}

t_\text{off} = -\frac{L}{R}\ln\left(-\frac{I_\text{off}}{I_\text{max} - I_\text{off}}\right)
从实现上来说,我们先假定t_\text{off} = t_\text{pwm} - t_\text{on}(即假设没有二极管),计算出I_\text{final},检查它的正负性,来判断电流是否被截断

平均电流计算

最后,我们结合计算出来的$ t_, t_$,以及开闭段计算出来的两个积分,我们终于可以计算PWM过程中的平均电流:

\overline{I} = f_\text{pwm}\left(t_\text{on}\left(I_\text{on} - \frac{(1 - e_\text{on})(I_0 - I_\text{on})}{c_\text{on}}\right) + t_\text{off}\left(I_\text{off} - \frac{(1 - e_\text{off})(I_\text{max} - I_\text{off})}{c_\text{off}}\right)\right)
这也是我们模型计算的最终结果。

在实际的实现当中需要考虑更多的细节,比如说转速的方向,命令的方向等等……并不总是如以上的公式那么简单,是一项艰苦的体力劳动,具体可以参考In The Zone下的model.c

逆向计算

很多时候我们不仅需要计算马达一定情况下的电流,更需要计算马达在某一状态下为了达到某一指定电流(扭矩)所需要的指令大小。例如:

  1. 计算空载情况下马达到达某一速度所需要的指令值(精确的马达线性化)
  2. 把PID的输出看做目标扭矩使用模型再反解出指令。

针对这个需求,因为马达曲线直观上总是单调的,因此理论上你总可以根据数学反推推出一个公式,但是由于模型当中牵涉到了分类讨论(电流的连续性),因此较为复杂。利用马达曲线的单调性,二分不失为一个更好的做法。由于指令的范围不大,我们至多只需要二分七八次即可。

注意:模型的计算在Cortex上还是较为耗时间的,实际当中根据当前电压计算一次完整的马达曲线需要4秒左右,优化常数,提高模型计算的效率一直是一个大问题,这也是为什么在实际应用中也许tanh线性化等计算更快的线性化的方法会更好的原因。

结果

这是真实的速度-指令曲线(空载):

这是使用模型计算的速度-指令曲线:

两者几乎一致,说明建模正确。

参数测定

马达内阻

使用伏安法,我们首先需要测量出马达在127命令下堵转电流I_\text{stall},127命令是为了尽可能消除电感的影响(即PWM全开),堵转是为了避免电能转化为机械能消耗掉,则根据开段分析当中的

U_\text b - I(t)(R+R_\text s) - U_\text L - U_\text {bemf} = 0
此时U_\text L = U_\text{bemf} = 0, I(t) = I_\text{stall},可反解出
R = \frac{U_\text b}{I_\text{stall}} - R_\text s

反电动势常数

我们知道U_\text{bemf} = K_\text e\omega,其中\omega是转速, K_\text e为反电动势常数,为了测定其值,我们测量出马达在127命令无负载运转电流I_\text{free}和此时的转速\omega_\text{free}(单位不论,但是需要保持统一),由

U_\text b - I(t)(R+R_\text s) - U_\text L - U_\text {bemf} = 0
此时U_\text L = 0, I(t) = I_\text{free},解得
U_\text{bemf} = U_\text b - I_\text{free}(R + R_\text s)
那么
\begin{aligned}
K_\text e &= \frac{U_\text b - I_\text{free}(R + R_\text s)}{\omega_\text{free}} \\
&= \left(1 - \frac{I_\text{free}}{I_\text{stall}}\right)\frac{U_\text b}{\omega_\text{free}}
\end{aligned}

简介

成套方法(repertoire method)可以用来求解一大类的递归式的封闭形式,由于和式也可以化为特殊的递归式,因而该方法对于处理和式也有很好的效果,具体而言,对于如下类似形式的递归式:

f(n) = \begin{cases}
    \alpha & n = 0 \\
    f(n - 1) + \beta + \gamma n + \delta n^2 \cdots & n > 0
\end{cases}
我们总是可以大胆地猜想:f(n)或许有着如下的封闭形式:
f(n) = A(n)\alpha + B(n)\beta + C(n)\gamma + D(n)\delta \cdots
成套方法的精髓就是,试图用几组特殊的f(n) (可以让我们相对容易地解出\alpha,\beta等的值),来推测A(n), B(n)等之间的关系,由于在上面的式子当中A(n),B(n)等都是线性组合的,因而最后我们会得到一组线性方程,求解这一组线性方程我们可以得出A(n),B(n)\cdots从而解出递归式的特殊形式。这是一个特殊→一般→特殊→一般→特殊的方法,十分巧妙。

例子:求解\sum_{k=1}^n k^2

这是一个和式,但是不打紧,我们可以把它处理成递归式。

f(n) = \sum_{k = 1}^n k^2,那么有等价定义

f(n) = \begin{cases}
    0 & n = 0 \\
    f(n - 1) + n^2 & n > 0
\end{cases}
可以看到从和式到递归式的转化是十分自然的。

这个递归式自然是满足最前面的形式的,其中\alpha = \beta = \gamma = 0, \delta = 1,我们先着手求解一般情况,选取几组“特殊的”f(n)

首先令f(n) = 1,代入最开始的定义:

\begin{cases}
1 &= \alpha \\
1 &= 1 + \beta + \gamma n + \delta n^2 
\end{cases}
显然有\alpha = 1, \beta = \gamma = \delta = 0,再把他们带入我们认为的“通用形式”:
f(n) = 1 = A(n)\cdot 1 + B(n)\cdot 0 + C(n)\cdot 0 + D(n)\cdot 0 \\
\Rightarrow A(n) = 1
很好,我们现在得到一组A(n),B(n),C(n),D(n)的线性关系了,还需要三组!

f(n) = n

\begin{cases}
0 &= \alpha \\
n &= n - 1 + \beta + \gamma n + \delta n^2 \\
\end{cases} \\
\begin{aligned}
&\Rightarrow \alpha = \gamma = \delta = 0, \beta = 1 \\
&\Rightarrow B(n) = n
\end{aligned}
f(n) = n^2
\begin{cases}
0 &= \alpha \\
n^2 &= (n - 1) ^ 2 + \beta + \gamma n + \delta n^2 \\
&= n^2 - 2n + 1 + \beta + \gamma n + \delta n^2
\end{cases} \\
\begin{aligned}
&\Rightarrow \alpha = \delta = 0, \beta = -1, \gamma = 2 \\
&\Rightarrow -B(n) + 2C(n) = n^2
\end{aligned}
最后,令f(n) = n^3
\begin{cases}
0 &= \alpha \\
n^3 &= (n - 1) ^ 3 + \beta + \gamma n + \delta n^2 \\
&= n^3 - 3n^2 + 3n - 1 + \beta + \gamma n + \delta n^2
\end{cases} \\
\begin{aligned}
&\Rightarrow \alpha = 0, \beta = 1,\gamma = -3, \delta = 3 \\
&\Rightarrow B(n) - 3C(n) + 3D(n) = n^3
\end{aligned}
最后,把这四组关系结合起来:
\left\{\begin{aligned}
A(n) && && && &= 1 \\
&&B(n) &&&& &= n \\
&-&B(n) &+& 2C(n) && &= n^2 \\
&&B(n) &-& 3C(n) &+& 3D(n) &= n^3
\end{aligned}\right.
非常简单的线性方程组,解得:
\begin{cases}
A(n) = 1 \\
B(n) = n \\
C(n) = \frac{n^2 + n}{2} \\
D(n) = \frac{2n^3 + 3n^2 + n}{6}\\
\end{cases}
这个时候,回到我们要解的递归式,其中\alpha = \beta = \gamma = 0, \delta = 1,因此
\begin{aligned}
f(n) = \sum_{k = 1}^n k^2 = D(n) &= \frac{2n^3 + 3n^2 + n}{6} \\
&= \frac{n(n + 1)(2n + 1)}{6}
\end{aligned}
求解到此结束,可以看到这个公式和我们的常识一致。虽然以上过程看似繁琐,但是注意我们已经求解了一大类的问题,因为A(n),B(n),C(n),D(n)是不会因\alpha, \beta, \gamma, \delta而变化的!,现在,对于任何的二次多项式的和式或者递归式我们都可以飞快地计算出其封闭形式。更有用的是,不仅仅是二次三项式,如果我们以后要处理三次多项式(意味着我们要加入\varepsilonE(n)),之前的A(n),B(n),C(n),D(n)的解也不会变化,因而只要继续带入f(n) = n^4即可! (这是显然的)

注意:为了找出线性关系,我们也可以先找出\alpha, \beta等的一组特解,然后基于这个解计算f(n),这和代入特殊f(n)然后反解\alpha, \beta是等价的!

思考:为什么可以这样做 ?

为什么几个看似毫不相关的f(n)可以指导我们正在处理的特殊情形,注意到我们的步骤:

  1. 将特殊f(n)带入递归式的定义,解出\alpha, \beta, \gamma, \delta\cdots
  2. 将1的解带入假设的封闭形式,求出A(n), B(n), C(n), D(n)\cdots的关系

注意到,我们在第一步解出了\alpha, \beta等的值,说明此时我们的特殊f(n)本来就是以上的递归式定义可以表出的,而我们假设的通用形式是对任何一组参数通用的,因此求解出来的线性关系也是通用的。

思考:真的“万能”和“机械化”吗?

成套方法从刚刚的示例可以看到似乎是非常机械化的,但是其实不然,《具体数学》中有言,成套方法对于线性的递归式最为有效,因此f(n - 1)前是不能有系数的(不然直觉上通用形式里就会出现棘手的指数项),但是这不妨碍我们处理和式,其次,对于一开始的“通用形式”的构造和特殊f(n)的构造其实不是那么简单的工作,对于多项式,我们可以很简单地构造多项式形式的“通用形式”,但是如果是:

\sum_{k = 1}^n (-1)^k k^2
最上面的通用形式就不管用了!我们需要自己寻找适合的通用定义和特殊函数值进行求解(这也是《具体数学》当中的习题),虽然找到之后的过程倒是非常地机械化没错。

思考:作为算法实现的复杂度?

我们仅在这里思考以上方法在多项式情况下的特例。

成套方法分为三步走:

  1. 构造线性方程
  2. 解线性方程
  3. 回代

第一步,由于是多项式的情况,观察之前的特例并结合二项式定理可以得到, 如果我们带入f(n) = n^k的情况,对于对应n^m的多项式系数(类似A(n), B(n)),其系数为(在m \neq k时,在m = k时系数自然就是0了):

-\binom{k}{m}(-1)^{k - m}
因此,配合杨辉三角递推组合数,这一部分的时间复杂度可以做到O(n^2)

第二步,考虑到我们解的线性方程当中含有多项式,因此需要采用系数向量的表示方法,基本算数运算代价变为O(n),但是注意到线性方程组列出时已经是简化阶梯形式,因此不需要高斯消元,只需要代入消元,时间复杂度是O(n^2)\cdot O(n) = O(n^3)

第三步的复杂度自然是O(n^2)

因此,基于成套方法的算法的综合复杂度为O(n^3)

题意

给定a_1,a_2,\cdots a_n以及b_1,b_2,\cdots b_n,要求最大化:

\frac{\sum_{i = 1}^n a_ix_i}{\sum_{i = 1}^n b_ix_i}
其中\forall 1\le i \le n, x_i \in \{0, 1\}

解法

我们可以二分答案L​,这样这个问题就转变为:能否求出一组解x​,使得\frac{\sum_{i = 1}^n a_ix_i}{\sum_{i = 1}^n b_ix_i} \ge L​

对不等式进行变形:

\begin{aligned}
\frac{\sum_{i = 1}^n a_ix_i}{\sum_{i = 1}^n b_ix_i} &\ge L \\
\sum_{i = 1}^n a_ix_i &\ge L\sum_{i = 1}^n b_ix_i \\
\sum_{i = 1}^n a_ix_i - L\sum_{i = 1}^n b_ix_i &\ge 0 \\
\sum_{i = 1}^n (a_i - Lb_i)x_i &\ge 0
\end{aligned}
我们计算\sum_{i = 1}^n (a_i - Lb_i)x_i的最大值,即构造x使x_i = 1当且仅当a_i - Lb_i > 0,并判断最大值的正负性,在此基础上不断二分答案即可。时间复杂度为O(n\log L_{max})

其实这个并没有什么卵用,基本没有人会卡。

对于求单个数的逆元我们有O(\log n)的算法,因此,如果我们要求[1, n]的所有数的逆元的话,朴素的做法是可以做到O(n\log n)的,但是其实我们有线性递推的算法,假设我们要求i在模P意义下的逆元,且[1, i - 1]的逆元已经求出:

P = ki + r,其中k = \left\lfloor \frac{P}{i} \right\rfloor, r = P \bmod i < i,我们有:

ki + r \equiv 0 \pmod{P}
两边同时乘上i^{-1}r^{-1}
\begin{aligned}
kr^{-1} + i^{-1} &\equiv 0 &\pmod{P} \\
i^{-1} &\equiv -kr^{-1} &\pmod{P} \\
i^{-1} &\equiv -\left\lfloor\frac{P}{i}\right\rfloor (P \bmod i)^{-1} &\pmod{P}
\end{aligned}
由于我们之前已经求得了P \bmod i的逆元,i的逆元依上式可以直接递推,代码也很短:

inv[1] = 1;
for (int i = 2; i <= n; i++)
    inv[i] = (-(P / i) * inv[P % i] % P + P) % P;

但是真的没有卵用啊,P动不动就是998244353或者1 \times 10^9 + 7这种,就算你能线性递推时间和空间也要炸糊的。

简介

目标:对于一个n位二进制表示的子集x,我们希望枚举其子集。

朴素枚举其子集的代码,复杂度O(2^{n})

for (int s = 0; s <= x; s++)
    if (s | x == x)
        // s 表示的二进制是x的子集

快速枚举其子集的代码,复杂度O(2^{|X|}),其中Xx表示的集合:

for (int s = x; s > 0; s = (s - 1) & x)
    // s表示的二进制是x的子集
// 以上算法不!会!处!理!空!集!,额外处理!

正确性证明:

代码中的& x保证s一定是s的子集,而(s - 1)保证枚举出来的s递减至0,详细证明不在此赘述脑补即可.

拓展

在很多的状压DP当中,我们不仅要枚举一个集合的子集,而是要枚举[0, 2^n)的所有二进制数表示的集合的子集,如果使用朴素地暴力做法,那么复杂度显然是O(4^n),可以过n \le 10的数据。

那么如果我们使用刚刚介绍的优化方法,复杂度会变成多少呢?

分析:总体复杂度为:

\sum_{i = 0}^n \binom{n}{i}2^i = \sum_{i = 0}^n \binom{n}{i}2^i1^{n - i}
逆用二项式定理:
\sum_{i = 0}^n \binom{n}{i}2^n = (2 + 1)^n = 3^n
因此,算法的复杂度为O(3^n)(而且是最优的),可以通过n \le 15的数据。