Chengyuan Ma's Blog

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

0%

前几天我在知乎上看到了一道数列题:

a_n=\begin{cases}
2, & n=1\\
\frac{a_{n-1}^2+1}{2a_{n-1}}, & n\ge2
\end{cases} \quad (n\in{\mathbb{N^{\ast}}})
这一道题基于的背景显然是运用牛顿迭代求解平方根。牛顿迭代法的原理无论是从其直观意义亦或是从微分的角度都是很容易理解的,但是其分析就不然了。我记得自己三年以前试着分析过一次,但是因为当时自己太菜所以最后啥都没分析出来(悲)。

通项

对于计算\sqrt k的牛顿迭代法,其递推式如下(基于求取x^2=k的零点)

x_{n+1} = x_n - \frac{x_n^2-k}{2x_n} = \frac{x_n^2+k}{2x_n}
或者根据巴比伦人当时的更直观的写法
x_{n+1} = \frac{1}{2}\left(x_n + \frac{k}{x_n}\right)
递推式的式子很简单,但是求解的方法并不trivial,在等式两边同时加上以及减去\sqrt{k}
\begin{aligned}
x_{n+1} - \sqrt k &= \frac{\left(x_n-\sqrt k\right)^2}{2x_n} \\
x_{n+1} + \sqrt k &= \frac{\left(x_n+\sqrt k\right)^2}{2x_n} \\
\end{aligned}
上下两式相除(很显然,除非x_1=-\sqrt {2}分母不可能为0
\frac{x_{n+1} - \sqrt k}{x_{n+1} + \sqrt k} = \left(\frac{x_{n} - \sqrt k}{x_{n} + \sqrt k}\right)^2
那么非常显然地,可以得出
\frac{x_{n} - \sqrt k}{x_{n} + \sqrt k} = \left(\frac{x_1 - \sqrt k}{x_1 + \sqrt k}\right)^{2^{n-1}}
基于上式求解x_n
x_n=\sqrt k \cdot \frac{1+\left(\frac{x_1 - \sqrt k}{x_1 + \sqrt k}\right)^{2^{n-1}}}{1-\left(\frac{x_1 - \sqrt k}{x_1 + \sqrt k}\right)^{2^{n-1}}}
这个递推式的通项就求出来了。最初的两步是我觉得是很难想到的,非常妙。

收敛性

基于这一通项,牛顿迭代法的收敛性也就显然取决于\left|\frac{x_1 - \sqrt k}{x_1 + \sqrt k}\right|,比如说当

\left|\frac{x_1 - \sqrt k}{x_1 + \sqrt k}\right| <1 \Rightarrow x_1 > 0
\{x_n\}迭代收敛至\sqrt k,当x_1<0时收敛至-\sqrt k,这和直觉是相符的。

收敛速率

不妨令\alpha=\frac{x_1 - \sqrt k}{x_1 + \sqrt k},在|\alpha| < 1时,牛顿迭代法的误差就可以写作:

x_n-\sqrt k =\sqrt k \cdot \frac{2\alpha^{2^{n-1}}}{1-\alpha^{2^{n-1}}}
相邻项误差之比为:
\frac{\left|x_{n+1}-\sqrt k\right|}{\left|x_n - \sqrt k\right|} =\left|\frac{\alpha^{2^n}(1-\alpha^{2^{n-1}})}{\alpha^{2^{n-1}}(1-\alpha^{2^{n}})}\right| = \frac{\alpha^{2^{n-1}} - \alpha^{2^n}}{1-\alpha^{2^{n}}}
考虑极限情况:
\begin{aligned}
\lim_{n\to \infty}\frac{\left|x_{n+1}-\sqrt k\right|}{\left|x_n - \sqrt k\right|} &= \lim_{n\to \infty}\frac{\alpha^{2^{n-1}} - \alpha^{2^n}}{1-\alpha^{2^{n}}} \\
&= \frac{\lim_{n\to \infty}\alpha^{2^{n-1}}-\lim_{n\to \infty}\alpha^{2^{n}}}{1-\lim_{n\to \infty}\alpha^{2^{n}}} \\
&= 0
\end{aligned}
这说明牛顿迭代法的收敛速率是超线性的。

当然,有更强的结论:

\begin{aligned}
\lim_{n\to \infty}\frac{\left|x_{n+1}-\sqrt k\right|}{\left|x_n - \sqrt k\right|^2} &= \lim_{n\to \infty}\frac{\left(1-\alpha^{2^{n-1}}\right)^2}{1-\alpha^{2^{n}}} \\
&= 1
\end{aligned}
也就是说牛顿迭代法的收敛速率是平方级(quadratic)的,也就是说,每迭代一次,结果的有效位数就会翻倍

而与此相对地,二分法的收敛速率显然是线性的,这也就是为什么牛顿迭代法在实践中比二分不知道高到哪里去了。

更快速收敛的迭代法?

根据以上论证,不难观察发现,牛顿迭代法的收敛速率之所以是平方级的,是因为\alpha^{2^n}当中的2的作用。那如果把2换成3呢?

也就是说,如果我们构造x_n,使得

\frac{x_{n+1} - \sqrt k}{x_{n+1} + \sqrt k} = \left(\frac{x_{n} - \sqrt k}{x_{n} + \sqrt k}\right)^3
也就是
x_{n+1} = \frac{x_n\left(x_n^2+3k\right)}{3x_n^2+k}
那收敛速度不就是三次方了吗?看起来很不错的样子!但是实用吗?

在硬件层面,计算一次牛顿迭代所需要的基本操作是(不计各类访存):

  1. 计算x_n^2——一次乘法
  2. 计算x_n^2+k——一次加法
  3. 计算2x_n——一次乘法
  4. 计算x_{n+1}——一次除法

即总共两次乘法,一次除法,一次加法,其中2x_n的乘法还可以转化为一次加法,对硬件是非常友好的。

而相对地,计算一次上面的三次方收敛的迭代所需要的基本操作是:

  1. 计算x_n^2——一次乘法
  2. 计算x_n^2+3k——一次加法(如果我们预先算出了3k
  3. 计算x_n(x_n^2+3k)——一次乘法
  4. 计算3x_n^2——一次乘法(如果我们缓存了x_n^2
  5. 计算3x_n^2+k——一次加法
  6. 计算x_{n+1}——一次除法

即总共三次乘法,一次除法,两次加法。看起来比牛顿法没有多多少,但是考虑到访存等,估计在硬件上并不划算。当然,在渐进意义上讲,如果一次迭代所需要的时间比一次牛顿迭代所需要时间的\log_23倍要少,那么这个算法还是“实用”的。

类似地对于指数的底数是4,5,6,7,8的情况,我们还有如下的递推式:

\begin{aligned}
x_{n+1} &= \frac{x_n^4+6kx_n^2+k^2}{4x_n\left(x_n^2+k\right)}\\
x_{n+1} &= \frac{x_n\left(x_n^4+10kx_n^2+5k^2\right)}{5x_n^4+10kx_n^2+k^2}\\
x_{n+1} &= \frac{\left(x_n^2+k\right)\left(x_n^4+14kx_n^2+k^2\right)}{2x_n\left(3x_n^2+k\right)\left(x_n^2+3k\right)}\\
x_{n+1} &= \frac{x_n \left(x_n^6+21 k x_n^4+35 k^2 x_n^2+7 k^3\right)}{7 x_n^6+35 k x_n^4+21
   k^2 x_n^2+k^3} \\
x_{n+1} &= \frac{x_n^8+28 k x_n^6+70 k^2 x_n^4+28 k^3 x_n^2+k^4}{8 x_n
   \left(x_n^2+k\right) \left(x_n^4+6 k x_n^2+k^2\right)}   
\end{aligned}
上面的式子一个比一个收敛得快。

比如说用最后一个八次方收敛的,初值为1计算\sqrt 2

第一次迭代:前5位相同。

第二次迭代:前48位相同。

第三次迭代:前391位相同。

第四次迭代:前3135位相同。

每迭代一次正确位数翻八倍!

啊!我刚刚注意到最后的八次方收敛算法就是把三步牛顿迭代缩成一步化简了而已,自己实在被自己蠢到了

但是不管怎么样三次方,五次方的收敛算法还是比较non-trivial的?

在前几天用扫描全能王的时候发现它似乎有自动拍书功能的(悲),似乎还可以自动识别,连标定都不要了。官方看到这个小博客的可能性微存?

但是那个是付费的会员功能!而且不管怎么样自己写一遍总归是有收获的。

进展!

过去的一周一直在和各种工程问题作斗争。目前的进展如下:

Snipaste_2020-01-01_12-00-03.jpg

让这些探究只停留在Jupyter Notebook上我觉得太可惜了,加上我觉得自己经常会用,所以我准备撸一个App传到博客上去。

然后某人悲催地发现自己Web开发的技艺约等于0。

花了一个小时速成Vue和Vuetify,花了一个小时复习了一遍Node和JS,花了一个小时重装了Webstorm,Vue CLI开一个SPA直接开搞。

在Stack Overflow和Google和MDN里面转悠了五天之后自己终于写出了还算像样的雏形。现在标定功能已经写完了!

上图当中的黄框表示页面平面,绿框悬浮在黄框上面营造出一种透视立体的感觉(其实是调试完不想删掉了),上下两条蓝色的曲线表示书页的弯曲曲线。所有红色的点都是可以拖动的,拖动时还会在边角显示两倍放大后的特写便于精准定位,就像这样

2020-01-01-12-12-46-image.png

功能上其实比上一篇文章没有多出多少(虽然在工程方面取得了巨大的突破)除了——曲线,这一条曲线其实没有那么简单。

反向投影

从上图可以看到,图中的曲线形状是由中间五个节点决定的,但是基于的是节点的三维坐标而不是所看到的二维坐标。这当中就牵涉到一个反向投影的问题——知道一个点在二维平面上投影点的坐标,如何在一定的约束条件下求取其三维坐标呢?

这个时候前一篇文章所做的页面平面重建的结果就变得重要了起来。这五个关键点都在书页的下边缘上,在数学上,五个小红点都在平面ABCD底边AD所在的,且垂直于平面ABCD的平面上。首先我们要确定这个平面,不妨称之为平面P

平面的方程式Ax+By+Cz=D(A,B,C)就是这个平面的法向量,我们已经知道了两个P上的点(点A和点D),如果我们知道了平面的法向量,那么把A点代入,方程当中的D就出来了,平面就确定了。

在理想情况下,法向量就是\vec{AB},因为理想情况下\vec{AD}\perp\vec{AB}

但是现实比较残酷,由于计算的一点点小误差,算出来的\vec{AD}并不总是垂直于\vec{AD},比如说夹角是89.94^{\circ}。这个\vec{AB}就不是法向量了,这是很坑的。我一开始偷懒把\vec{AB}当法向量写到程序里面去,结果调试的时候一直有小误差,心态都快崩了。

然后我才意识到果然不能偷懒,正确的法向量就是\vec{AD}\times(\vec{AD}\times\vec{AB})

然后对于在平面P上的任意一个点Q(x, y,z),已知其投影是Q^{\ast}(x^{\ast},y^{\ast})。根据方程

\begin{cases}
    Ax+By+Cz=D\\
    x=x^{\ast}(1+z/d)\\
    y=y^{\ast}(1+z/d)
\end{cases}

就能把这个点的坐标算出来了。

之后还需要再做一步,把\vec{AQ}分解:

\vec{AQ}=a\vec{AD}+b\frac{\vec{AD}\times\vec{AB}}{\left|\vec{AD}\times\vec{AB}\right|}\quad a\in[0,1]

之后得到的(a,b)才是一个相对归一化了的坐标。使用这个坐标进行插值的话,A,B,C,D四个点小范围内的运动都不会改变曲线的形状,这相当于增加了我们算法的鲁棒性。

插值

使用什么曲线插值比较好呢?

如果在计算机的背景下最先想到的肯定是贝塞尔曲线,但是贝塞尔曲线不经过它控制点,这似乎在界面上就不是那么直观。

如果采用高阶多项式的插值呢?那么Runge现象又会非常严重,可以预见效果会很差。

于是乎我采用的是三次样条插值。对于三次样条插值:

  1. 假设有n个节点,那么就有n-1个区间,对应n-1条曲线,4(n-1)个参数。

  2. 对于一条三次曲线,其两个端点的值是确定的,那么就有2(n-1)个约束条件。

  3. 中间的n-2个节点上相邻的三次曲线必须一阶导连续且二阶导连续,那么就是2(n-2)个约束条件。

  4. 为了能够在大多数情况下求出所有参数的准确值,还需要2个约束条件。

似乎最常见的边界条件被称为自然边界条件。也就是样条曲线两端的二阶导为0Wiki上介绍的算法也是针对这种边界条件设计的,但是在我们这个情境下自然边界似乎不适用,因为书页的一侧总是向下凹向书脊,书脊起了固定作用。我一开始照抄Wiki上的算法结果曲线对不上翻车了。

这个情景应该用的似乎是夹持边界条件。也就是人为地给出曲线两端的一阶导。也就是我的界面右侧出现"Left Slope"和"Right Slope"两个slider的原因。这个边界条件试下来似乎和实际吻合得还是相当不错的。问题主要出现在算法这里,我虽然从矩阵的形式推断一定存在像Wiki上处理自然边界条件一样显式线性的算法,但是自己还是懒得去推了直接用mathjs的lusolve函数(Well 我相信mathjs这种大库的消元一定有优化……),速度还是非常令人满意的,但是代码可读性就是一个比较大的问题了:

let n = nodes.length - 1; // nodes.length knots, so nodes.length - 1 ranges and piecewise curves

let A = zeros(4 * n, 4 * n); // 4 parameters for each curve
let b = zeros(4 * n);
for (let i = 0; i < n; i++) {
    // Equation 4i and 4i + 1 ensures continuity of the spline
    b.set([4 * i], nodes[i][1]);
    for (let j = 0; j < 4; j++)
        A.set([4 * i, 4 * i + j], pow(nodes[i][0], j));
    b.set([4 * i + 1], nodes[i + 1][1]);
    for (let j = 0; j < 4; j++)
        A.set([4 * i + 1, 4 * i + j], pow(nodes[i + 1][0], j));
    if (i < n - 1) {
        // Equation 4i + 2 and 4i + 3 ensures continuity of both the 1st and 2nd derivative
        A.set([4 * i + 2, 4 * i + 1], 1);
        A.set([4 * i + 2, 4 * i + 2], 2 * nodes[i + 1][0]);
        A.set([4 * i + 2, 4 * i + 3], 3 * pow(nodes[i + 1][0], 2));
        A.set([4 * i + 2, 4 * i + 5], -1);
        A.set([4 * i + 2, 4 * i + 6], -2 * nodes[i + 1][0]);
        A.set([4 * i + 2, 4 * i + 7], -3 * pow(nodes[i + 1][0], 2));
        A.set([4 * i + 3, 4 * i + 2], 2);
        A.set([4 * i + 3, 4 * i + 3], 6 * nodes[i + 1][0]);
        A.set([4 * i + 3, 4 * i + 6], -2);
        A.set([4 * i + 3, 4 * i + 7], -6 * nodes[i + 1][0]);
    }
}
// Equation 4n - 2 and 4n - 1 set constraint on slope on both ends
A.set([4 * n - 2, 1], 1);
A.set([4 * n - 2, 2], 2 * nodes[0][0]);
A.set([4 * n - 2, 3], 3 * pow(nodes[0][0], 2));
b.set([4 * n - 2], this.leftSlope);
A.set([4 * n - 1, 4 * n - 3], 1);
A.set([4 * n - 1, 4 * n - 2], 2 * nodes[n][0]);
A.set([4 * n - 1, 4 * n - 1], 3 * pow(nodes[n][0], 2));
b.set([4 * n - 1], this.rightSlope);
let sln = lusolve(A, b);

虽然加了注释但是代码只能用悲剧来形容了(悲)。

全怪mathjs的API,我个人认为如果用Julia来写的的话一定没有那么多事情(确信)。

最后的效果倒确实是极好的。

问题

自从Office Lens,扫描全能王等App问世以来,人们越来越习惯使用手机代替扫描仪的功能来拍摄文档,而这些App对于一般的平面文档也确实起到了很好的效果。但是对于比较厚重的书本,由于页面向书籍弯折,拍摄到的页面往往是弯曲的,不仅影响美观也影响阅读体验。这些App也均没有针对这一情形进行特殊的优化。

例如:

如何在算法层面解决这个问题呢?

先前研究

这个问题的英文似乎是Page Dewarping。以这个为关键词Google了一番,确实有一些相关的研究,但是几乎都是通过判断字的走向以及表格中直线的走向判断来判断纸的弯曲方向的。

除此以外,据我所知是有专门扫书的扫描仪的。原理是通过打一束激光,通过平直激光束在页面上的投影判断纸的弯曲方向。

但是我的手机没有激光发射器。我也不想识别文字方向,因为这大概率在实现上是一项艰苦的体力劳动。

能不能通过判断纸的边缘来计算纸的弯曲情况呢?

事情要一步一步来。

这一篇文章,我们就先探究如何从一副二维的图像重建纸面几个关键定点的三维坐标

数学基础:透视投影

真的无论什么时候都是近大远小的吗?

众所周知,将三维空间当中的物体转变为二维空间中的像的过程被称作投影

拍照显然就是一个投影的过程,因此在开始解决问题之前,我们先要确定投影的方式。

我们的眼睛,包括摄像头,获取到的画面之所以有着近大远小的特征,是因为无论是我们的眼睛,还是相机,采用的投影都是透视投影的缘故。透视投影的示意图如下:

Projection.png

如图所示,透视投影需要定义一个视点,即我们的眼睛或者相机感光元件所在的位置。然后所有远处物体反射到达视点的光线与一特定平面的交点,就是这一点所对应的投影点。

透视投影虽然是最符合直觉,也是最自然的投影方式,但是这并不意味着这就是唯一的投影方式。另一个较为常见的投影被称作正交投影。可以想象为透视投影中视点在无限远处的情形。(在正交投影当中,远近等大。也正是因为这个原因,正交投影广泛地被CAD软件所采用)因此,在对问题下手之前确定投影的类型是十分重要的。

OK,回到透视投影。之前的文字只给出了透视投影的定性叙述,这对于算法来说显然是不够的。透视投影可以在数学上可以如下定义:

假设投影平面是平面z=0,投影点是(0,0,-d)

那么,对于三维空间中的点(x,y,z),透视投影将其唯一投影到二维平面中的(x^{\ast},y^{\ast})。其中

\begin{aligned}
    x^{\ast}&=\frac{x}{1+z/d} \\
    y^{\ast}&=\frac{y}{1+z/d}    
\end{aligned}

在现实当中,投影平面的大小是有限的,如果投影平面的大小是w\times h(且以z轴为中心),那么\theta_w=2\arctan\frac{w}{2d}被称作水平方向上的视场(FoV),垂直方向上的视场定义类似。对于一个相同的摄像头,其视场一般可以看做一个常数(不同的焦距可能会导致些许变化)。以像素为单位,我的手机画幅是3024\times 4032d\approx 3800。那么水平视场大概就在45^{\circ}左右。

在清楚透视投影的定义之后,这一篇文章的任务就变成了:

寻找满足一定约束条件的若干顶点的三维坐标,使其作为书页的边缘,其透视投影点恰对应图片上的点。

假设

为了适当简化问题,在此做出以下的假设:

  1. 纸页竖直的两边等长

  2. 纸页竖直的两边平行。(注: 实际情况并不总是如此,但是让纸满足这个条件比把书压平总是容易得多了)

  3. 用户已经人工以某种形式标记出了图上一些关键点的坐标。即暂时不考虑自动检测书页(这也是一个大坑)。

明确问题

如下图所示:

我们已经手动标记出页面四角A,B,C,D的投影点A^{\ast}(x_1,y_1),B^{\ast}(x_2,y_2),C^{\ast}(x_3,y_3),D^{\ast}(x_4,y_4)。在这篇文章当中我们的目标是反推出A,B,C,D以及平面。

假设O是坐标原点,以后会一直用\vec{OP}表示点P的坐标向量。

解决问题

算法直觉乱搞流

第一想法:啊?四个三维点?那就是12个参数咯?上启发式算法乱搞一波模拟退火遗传粒子群似乎就可以解决?当场否决

然后我们注意到投影点为定点的三维点的轨迹一定是一条过视点P的直线。 所以其实只有4个参数——启发式算法成功可能性微存?

启发式优化算法毕竟不靠谱,我们还是一步一步来,比如说我们假设已经确定了Az坐标z_1(显然A的其他坐标也因此确定)。怎么确定我们暂且不管,反正最后大不了枚举。

然后呢?似乎还是没有头绪?

那我们继续假设已经确定了Bz坐标z_2。怎么确定我们一样暂且不管。

现在我们已经可以算出\vec{BA}。显然\vec{CD}=\vec{BA},因此接下来只要我们知道C的坐标,D的坐标随之确定。我们可以通过计算D投影点{D^{\ast}}'和实际D^{\ast}的距离,得到我们解的误差E,即

E={D^{\ast}}'D^{\ast}

那么怎么算出C的坐标呢?

这个时候直觉是一个好东西。我断定:误差E关于Cz坐标z_3单峰。

为什么?因为C的轨迹不是过P的直线吗,因此D经过C平移\vec{CD}=\vec{AB}的轨迹也是一条直线。直线的投影还是直线。 因此,当z_3增加,C在直线上向后移动,D也在直线上向后移动,其投影点{D^{\ast}}'也在投影平面的直线上也一定沿某一特定方向移动。而二维平面内以个沿直线固定方向移动的点和直线外一定点D^{\ast}的距离显然是单峰的。证毕。

既然是单峰的,那么C的坐标就可以直接三分查找了呢,不用枚举nice。

那么B的坐标呢?

直觉还是一个好东西。我再次断定:C最优的情况下,误差E关于Bz坐标z_2单峰。

为什么?之前不是得到D的轨迹也是直线吗?当B前后移动时,\vec{BA}也在变化。随着\vec{BA}的变化,经过C平移\vec{BA}得到的D的轨迹直线也在沿着某一方向平移(这个方向就是直线BP的方向)。那么{D^{\ast}}'在投影平面上的轨迹直线也在沿着某一方向平移。而二维平面内沿着一个特定方向移动的直线到一定点的D^{\ast}的距离显然是单峰的。证毕。

哦豁,那么B的坐标也好三分了咯,又一次避免了枚举,nice。

为了三分的快一点,可以把三分的定比分点设成\frac{3-\sqrt 5}{2},这样可以减少一半的计算次数(当然这就属于比较细节的优化了)。

接下来只剩下A了,A需要枚举吗?

需要,也不需要。

直觉上来说A,B,C,D的坐标应当存在某种线性的关系,如下所示:

Linear.png

即无论z_1是多少,平面ABCD的总是不会发生变化的,因此我们只要随便选取一个A的坐标,通过二重三分把B,C,D算出来就行了。

看着不错,计算量也不高。但是……似乎依赖直觉的地方还是太多了。

之前论证单调性的过程的insight似乎暗示这问题的解析解应该不难?于是……

数学

\vec{OA}=\begin{pmatrix}
\lambda_1x_1\\
\lambda_1y_1\\
(\lambda_1-1)d
\end{pmatrix},
\vec{OB}=\begin{pmatrix}
\lambda_2x_2\\
\lambda_2y_2\\
(\lambda_2-1)d
\end{pmatrix},
\vec{OC}=\begin{pmatrix}
\lambda_3x_3\\
\lambda_3y_3\\
(\lambda_3-1)d
\end{pmatrix}

则有:

\vec{BA}=\begin{pmatrix}
\lambda_1x_1-\lambda_2x_2\\
\lambda_1y_1-\lambda_2y_2\\
(\lambda_1-\lambda_2)d
\end{pmatrix}\Rightarrow\vec{OD}=\vec{OC}+\vec{BA}=\begin{pmatrix}
\lambda_3x_3-\lambda_2x_2+\lambda_1x_1\\
\lambda_3y_3-\lambda_2y_2+\lambda_1y_1\\
(\lambda_3-\lambda_2+\lambda_1-1)d
\end{pmatrix}

那么其二维投影:

\vec{OD^*}=\begin{pmatrix}
\frac{\lambda_3x_3-\lambda_2x_2+\lambda_1x_1}{\lambda_3-\lambda_2+\lambda_1}\\
\frac{\lambda_3y_3-\lambda_2y_2+\lambda_1y_1}{\lambda_3-\lambda_2+\lambda_1}
\end{pmatrix}=\begin{pmatrix}
x_4\\
y_4
\end{pmatrix}

然后,就可以列方程:

\begin{cases}
\lambda_3x_3-\lambda_2x_2+\lambda_1x_1=(\lambda_3-\lambda_2+\lambda_1)x_4\\
\lambda_3y_3-\lambda_2y_2+\lambda_1y_1=(\lambda_3-\lambda_2+\lambda_1)y_4
\end{cases}

整理一下:

\begin{cases}
(x_2-x_4)\lambda_2-(x_3-x_4)\lambda_3=(x_1-x_4)\lambda_1\\
(y_2-y_4)\lambda_2-(y_3-y_4)\lambda_3=(y_1-y_4)\lambda_1
\end{cases}

也就是:

\begin{pmatrix}
\vec{OB}-\vec{OD} &\vec{OD}-\vec{OC}
\end{pmatrix}
\begin{pmatrix}
\lambda_2\\
\lambda_3
\end{pmatrix}=
(\vec{OA}-\vec{OD})\lambda_1

最后只要解一下这个二元线性方程就可以了。

看来这个问题其实没有我想象的那样复杂啊。一开始直觉告诉我这东西解析解算起来很麻烦甚至不存在解析解,所以先从算法角度分析了一波。第二天开始想几个直觉结论的证明的时候才意识到似乎解析解不难算,后来发现正是如此。

结果

最后的结果如下所示,加了法向量组成长方体证明确实这个建模没有问题。

Marked.jpg

下一篇文章大概会写如何基于这些信息计算纸面弯曲的参数方程吧……

但是手动标点这种东西四个点一个一个画图上查然后输入还可以忍受,标定横向边缘好几个点实在是懒啊……或许会先写一个标点的工具吧……

矩阵向量相乘的意义

这部分和LU分解没有多大关系,但是GS的书里一直强调这种理解,想必是极有用的。

当矩阵左乘一个列向量,其结果等同于矩阵每一列以列向量为权的线性组合,例如

\begin{pmatrix}
    1&2&3\\
    4&5&6\\
    7&8&9\\
\end{pmatrix}
\begin{pmatrix}
1\\
2\\
3
\end{pmatrix}
=
1\begin{pmatrix}
1\\4\\7
\end{pmatrix}
+2\begin{pmatrix}
2\\5\\8
\end{pmatrix}
+3\begin{pmatrix}
3\\6\\9
\end{pmatrix}
类似地,当矩阵右乘一个行向量,其结果等于矩阵每一行以行向量为权的线性组合,例如
\begin{pmatrix}
1&2&3
\end{pmatrix}
\begin{pmatrix}
    1&2&3\\
    4&5&6\\
    7&8&9\\
\end{pmatrix}
=\begin{matrix}
1\begin{pmatrix}
1&2&3
\end{pmatrix}\\
+\\
2\begin{pmatrix}
4&5&6
\end{pmatrix}\\+\\
3\begin{pmatrix}
7&8&9
\end{pmatrix}
\end{matrix}
多列,多行的情况也可以以此类推。

初等行变换的矩阵表示

注意到初等行变换可以使用矩阵进行表示,例如在一个3\times 3的矩阵当中把第1行的3倍加到第二行就可以表示为:

\begin{pmatrix}
1&0&0 \\
3&1&0 \\
0&0&1
\end{pmatrix}
\begin{pmatrix}
1&2&3 \\
4&5&6 \\
7&8&9
\end{pmatrix}
=\begin{pmatrix}
1&2&3 \\
7&11&15 \\
7&8&9
\end{pmatrix}
注意行变换的矩阵是左乘的,右乘就是列变换了!

具体来说,如果你把第i行的k倍加到第j行上,那么这个变换对应的矩阵就是在以单位矩阵为基础上a_{j,i}=k

初等行变换的矩阵常用符号E表示。

由于初等行变换的意义,很容易就可以看出其逆矩阵就是把对应的元素取反,加上的我们就减回去:

E=\begin{pmatrix}
1&0&0 \\
3&1&0 \\
0&0&1
\end{pmatrix}\Rightarrow 
E^{-1}=\begin{pmatrix}
1&0&0 \\
-3&1&0 \\
0&0&1
\end{pmatrix}

矩阵的LU分解

在学习初等行变换的矩阵表示之后再回头考虑我们对着n\times n矩阵A做消元得到上三角阵U的过程,我们就会发现,在消元过程不涉及交换两行的时候,消元过程可以表示为:

E_{n,n-1}\cdots E_{31}E_{21}A=U
其中E_{21}表示我们把a_{21}消为0所运用的行变换。

注意到在消元的时候我们是按照E_{21}, E_{31},\cdots这个顺序消过来的,但是因为行变换是左乘,因此上式当中从左到右序号是反的。

如果A确实可以顺顺利利地消成U,那说明E_{n,n-1}\cdots E_{31}E_{21}是可逆的,即:

\begin{aligned}
A&=(E_{n,n-1}\cdots E_{31}E_{21})^{-1}U\\ 
&=E_{21}^{-1}E_{31}^{-1}\cdots E_{n,n-1}^{-1}U
\end{aligned}
不妨令L=E_{21}^{-1}E_{31}^{-1}\cdots E_{n,n-1}^{-1},则矩阵A可以写作:
A=LU
这就被称为矩阵ALU分解。

其中U是上三角阵无疑,而L,其字母暗示我们这是一个下三角阵

不仅如此,比如说对于3阶方阵消元共三步:

E_{21}^{-1} = \begin{pmatrix}
1&0&0\\
a&1&0\\
0&0&1
\end{pmatrix}
,
E_{31}^{-1} = \begin{pmatrix}
1&0&0\\
0&1&0\\
b&0&1
\end{pmatrix}
,
E_{32}^{-1} = \begin{pmatrix}
1&0&0\\
0&1&0\\
0&c&1
\end{pmatrix}
计算L可得
L=\begin{pmatrix}
1&0&0\\
a&1&0\\
0&0&1
\end{pmatrix}\begin{pmatrix}
1&0&0\\
0&1&0\\
b&0&1
\end{pmatrix}\begin{pmatrix}
1&0&0\\
0&1&0\\
0&c&1
\end{pmatrix}=\begin{pmatrix}
1&0&0\\
a&1&0\\
b&c&1
\end{pmatrix}
L在形式上就是把所有行变换矩阵“拼起来“!矩阵乘法平常都会把矩阵里面的数字形式破坏掉,这次为什么那么奇妙呢?

从消元的角度,可以这么理解:

我们能够把两个行变换矩阵“拼”起来,当且仅当一个变换的结果不会改变另一个变换的源。

什么意思?如果E_1把第2行的3倍加到第2行,E_2把第1行的2倍加到第2行。

如果我们先做E_1再做E_2E_2E_1,那么这个时候我们是可以直接拼起来的。因为先做的E_1没有改变E_2所依赖的第2行。但是先做E_2再做E_1E_1E_2就不能直接拼起来,因为先做的E_2E_1所依赖的第2行改掉了。

然后我们发现在LU分解里面我们是先改第n行,再改第n-1行这样从下到上地变换上去的。改变第n行的变换只会依赖于前n-1行,因此出不了问题。

从纯计算的角度,可以这么解释:

考虑矩阵乘法L=EL',其中E是一个行变换矩阵且假设其中E_{ik}=aL'是下三角阵。

则考虑Li行第j列的值L_{ij}(除了第i行,结果的其他行显然等于L'),必然是E的行向量与L'列向量之点乘:

L_{ij}=\begin{pmatrix}
0 \\
\vdots \\
a\\
\vdots \\
\color{red}1\\
\vdots\\
0
\end{pmatrix}
\cdot
\begin{pmatrix}
0 \\
\vdots \\
\color{blue}1 \\
\vdots \\
b \\
c \\
\vdots \\
\end{pmatrix}

由于三角阵主对角线上都是1,在上式中左边向量里面的\color{red}1就是从上往下数第i个,右边向量里面的\color{blue}1就是从上往下数第j个。因为是下三角阵\color{red}1的下面都是0\color{blue}1的上面也都是0。接下来讨论\color{red}1\color{blue} 1的位置关系。

  • i<j时,\color{red}1\color{blue}1的上面。右侧\color{blue} 1下面非零的元素对着左边的都是0,左侧\color{red}1上面的非零a对到右边也是0,因此L_{ij}=0
  • i=j时,\color{red} 1正对着\color{blue}1,因此L_{ij}=1\times 1=1
  • i>j时,\color{red} 1\color{blue}1的下面。a是左侧从上往下数第k个。
    • j>k时,\color{blue}1a下面,a对着0L_{ij}=L'_{ij}
    • j=k时,a对着\color{blue}1\color{red}1对着L'_{ij}L_{ij}=a+L'_{ij}
    • j<k时,a对着L'_{kj}\color{red}1对着L'_{ij}L_{ij}=aL'_{kj}+L'_{ij}

显然,当我们向左边一个一个行变换矩阵乘过来的时候,L'_{ij}=0,且L'_{kj}=0\quad \forall j<k。于是乎

L_{ij} = \begin{cases}
0, & i<j\\
1, & i=j\\
L'_{ij}, &k<j<i \\
a, & j=k\\
0, & j<k\\
\end{cases}
这就是把a直接放到L'的对应位置里面。结合数学归纳法,可以导出LU分解的结果确实就是几个行变换矩阵“拼起来”。

矩阵的LDU分解

注意到在A=LU当中,我们虽然保证了L的主对角线是上1,但是U的主对角线上就未必了。

假设U的主对角线上的数是d_1,d_2,\cdots,d_n,那么不妨令:

U=DU'
其中
D=\begin{pmatrix}
d_1&0&\cdots&0\\
0&d_2&\cdots&0\\
\vdots&\vdots&\ddots&\vdots\\
0&0&\cdots&d_n\\
\end{pmatrix}
U'U每一行分别除以d_1,d_2,\cdots,d_n的结果。

这样一来分解就变成了

A=LDU'

这种分解就被称作矩阵ALDU分解。

最近折腾服务器把Aria2、H5AI、Jellyfin、Jupyter等集合到一个端口访问(因为开端口映射实在是一件麻烦事),于是需要经常用到Nginx的反向代理。之前的配置文件的效果一直都不是最完美,瑕疵体现在redirect之后就会忽略到子路径导致404。在网上多方搜索之后我终于找到了一个总体靠谱的写法(感觉Nginx的流行度还是比不上Apache),记下来留自己备用。

location /SUB_PATH {
    return 302 $scheme://$host:$port/SUB_PATH/;
}
location /SUB_PATH/ {
    proxy_pass xxxxx;
    proxy_pass_request_headers on;
    proxy_redirect ~^/(.*) $scheme://$host:$port/SUB_PATH/$1;        
    proxy_set_header Host $host;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header X-Forwarded-Proto $scheme;
    proxy_set_header X-Forwarded-Host $http_host;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection $http_connection;
    # proxy_buffering off; 
}

The enemy knows the system. ——Claude Shannon

闷声大发财,这是坠吼的! ——?

程序是一个月以前写的。决定重新启用博客之后就把想法心得写一写吧,顺便把灵敏度分析补上。

OI的奇怪知识点可真多~

什么是SOCKS5?

SOCKS5是RFC1928定义的一类网络代理协议。

理论上来说,由于其简洁性,理论上SOCKS5是开销最低的代理协议。

然而由于其过于简单,数据可以明文传输的特点,其流量特征非常明显,容易被干扰。因此在某些地区从很久以前就不能有效使用了。

话虽如此,由于其协议简单,用作学习用途真是极好的。

什么是NTT?

NTT是离散傅里叶变换在\mathbb{Z}/p\mathbb{Z}(其中p是素数)上的推广。

具体的NTT以及FFT算法可以看之前的笔记NTT笔记

协议设计

因为一个字节是8个比特,我们使用\mathbb{Z}/257\mathbb{Z}下长度为256的序列NTT。

但是\mathbb{Z}/257\mathbb{Z}毕竟不是\mathbb{Z}/256\mathbb{Z},如果尝试把256放进字节里面就会溢出。考虑到这一点,定义如下的协议结构来储存NTT的结果。

意义: 溢出数 溢出下标 NTT 序列模256
字节数: 1 溢出数 256

对于一段数据流,我们一次至多取255字节,把这一段的长度放在最前面组成共256个字节的数据块,NTT之后在放到上面这张表当中拼成至少257字节的数据块。(理论上可以通过Padding省掉储存长度的字节)

解码类似,先从上表规定的数据块复原出NTT序列,然后再INTT以下获得原序列,就行了。

由于被NTT过的数据特征已经和明文的数据特征大不一样,因此可以规避一些现有的检测。

但是根据本文一开头的Shannon's Maxim(又称Kerckhoff Principle),NTT无疑会带来新的数据特征,因而其obscurity源于对方对使用了NTT这一事实本身的不知晓或忽视,这和加密还是不一样的。

实现

这种网络应用程序果然还是应该用Go写啊~

你看这goroutine,它不香吗?

因为NTT是成块处理的,无论是在写还是在读的时候都需要额外的buffer来缓存长度不够的块,就这一部分需要一点细节,总体按照上述协议实现一个套NTT的ReadWriter还是很简单的。写好的NTTReadWriter不仅可以套在Conn上,还可以套在文件上实现文件NTT之类的(虽然似乎没啥卵用)。

在网络方面,客户端就留一个relay把本地监听的SOCKS请求NTT之后forward到服务端,然后服务端处理请求之后再forward回去就行了。用goroutine写这些还是很容易的。

最后要在服务端实现SOCKS协议,这个似乎Go没有现成库的样子(现成的都很难和NTT耦合),于是只能自己写。最后的成品支持RFC1928中规定的Connect和Bind请求,功能基本上全了。

在性能方面,我写的NTTReadWriter本身的性能测试下来应该在30\mathrm{MB/s}左右,虽然不高但是已经超过网速了。最后在VPS上的综合测试结果也是能够跑满网速,8K不卡,非常顺滑。

代码?不存在的。

优缺点

优点

  1. 把NTT用在网络通讯上的骚操作似乎我还没有听说过。(或许是我见识粗鄙了)
  2. NTT对于微小的扰动很敏感,因此数据特征应该会出现很大的变化。(会在下一个部分展示)
  3. NTT因为使用了大量的模运算,计算的开销比较大,块大小也不小。这对许多基于流模型的检测方法提出了挑战。

缺点

  1. 太慢了。(虽然也足以跑满带宽)
  2. 一般来说一个NTT块至多只有两个溢出,也就表明数据流中平均每256-258个字节就会有一个很小的数,这不失为一个比较明显的特征。
  3. 溢出位的记录导致额外的流量开销。(大概在1\%的水平,还是相当可以接受的)

灵敏度分析

直觉告诉我NTT对于源数据的微小变化应该是非常敏感的,就算是一点点微扰在一层层处理之后也会让最后的结果面目全非。这也是我开脑洞选择用NTT隐藏SOCKS特征的原因(而且事实就是成功了(喜))。

但是直觉总是不靠谱的。还是需要具体数据。

测试环境如下:

# 这似乎不是最Pythonic的写法(悲)
import numpy as np
import matplotlib.pyplot as plt

MOD = 257
G = 3
LOG = 8
LEN = 1 << LOG

def qpow(x, y):
    ret = 1
    while y != 0:
        if y & 1 == 1:
            ret = ret * x % MOD
        x = x * x % MOD
        y >>= 1
    return ret

pos = np.zeros(LEN, dtype=int)
for i in range(1, LEN):
    pos[i] = ((i & 1) << (LOG - 1)) | (pos[i >> 1] >> 1)
    
def ntt(x, d=1):
    y = np.array([x[pos[i]] for i in range(LEN)])
    for k in range(LOG):
        half, span = 1 << k, 1 << (k + 1)
        w_ = qpow(G, (MOD - 1) // span)
        if d == -1:
            w_ = qpow(w_, MOD - 2)
        for l in range(0, LEN, span):
            mid = l + half
            w = 1
            for i in range(half):
                t = w * y[mid + i] % MOD
                y[mid + i] = (y[l + i] - t) % MOD
                y[l + i] = (y[l + i] + t) % MOD
                w = w * w_ % MOD
    return qpow(LEN, MOD - 2) * y % MOD if d == -1 else y    

不同位置,相同扰动

x = np.random.randint(MOD, size=LEN)
ys = []
for i in range(LEN):
    x_ = np.copy(x)
    x_[i] = (x_[i] + 1) % MOD
    ys.append(ntt(x_))
plt.matshow(np.array(ys))

即测试在原数列不同位置加1微扰后对于NTT后数列的影响。结果如下:

除了位置(例如中间,大概是直流分量?)看起来都还不错!

同一位置,不同扰动

x = np.random.randint(MOD, size=LEN)
p = np.random.randint(LEN)
ys = []
for i in range(LEN):
    x_ = np.copy(x)
    x_[p] = (x_[p] + i) % MOD
    ys.append(ntt(x_))
plt.matshow(np.array(ys))

即测试在原数据相同位置施加不同扰动对于NTT结果的影响,结果也还不错:

行列式值的排列定义

A_{n\times n} = \sum_{\pi \in S_n} (-1)^{\sigma(\pi)}\prod_{i=1}^na_{i, \pi(i)}

其中S_n表示所有n排列的集合,\pi表示一个排列,\sigma(\pi)表示\pi的逆序对数,即

\sigma(\pi)=\sum_{i<j}[\pi(i) > \pi(j)]

等价性证明

使用归纳证明。(自己想的,觉得可能绕了很大的弯路。)

  1. n=1,2,3时显然。

  2. 假设n=k \quad (k \in \mathbb{N^{\ast}})时成立,考虑n=k+1时的情况。

    不失一般性,考虑第i行展开:

    A_{(k + 1) \times (k + 1)} = \sum_{j=1}^{k+1} a_{i,j}(-1)^{i+j}M_{i,j}
    其中M_{i, j}k阶行列式。考虑如何计算M_{i, j},由归纳假设。
    M_{i,j} = \sum_{\pi \in S_k}(-1)^{\sigma(\pi)}\prod_{l=1, l \neq i}^{k+1} a_{l, \pi'(l)}

    (后面所有推导用到的i,j,\pi都指上式中的相关符号)其中

    \pi'(l) = \pi(l-[l > i]) + [\pi(l-[l > i])\ge j]
    定义\pi'是为了跳过被删去的第i行和第j列,不改变元素间的大小关系。

    接下来考虑排列

    \pi^{\ast}(l) = \begin{cases}
 j, &l=i \\
 \pi'(l), &l\neq i
\end{cases}
    显然\pi^{\ast}表示了把i \mapsto j“插入”\pi的结果。

    观察到

    \begin{aligned}
 \sigma(\pi^{\ast}) &= \sigma(\pi) + \sum_{l=1}^{i-1}[\pi'(l)>j] + \sum_{l=i+1}^{k+1}[\pi'(l)<j] \\
 &= \sigma(\pi) + \underbrace{\sum_{l =1}^{i-1}[\pi(l) \ge j] + \sum_{l=i}^k[\pi(l)<j]}_{\Delta\sigma}
\end{aligned}
    接下来只需要证明(-1)^{\Delta\sigma}=(-1)^{i + j}

    注意到,可以通过交换元素在任意两个排列之间变换(证明显然略,冒泡排序算法给出了进行此类交换的步骤构造)。为了简便起见,称l=1,2,3,\cdots,i为“前面”,l=i+1,i+2,\cdots,k为“后面”,\pi(l)<j为“小”,\pi(l) \ge j为"大"。

    那么显然,前面和前面,后面和后面的交换不改变\Delta\sigma。着重考虑前面后面之间交换的情况。

    然后注意到:

    1. 如果是前面的小元素和后面的大元素进行交换,交换后的\Delta\sigma项增加2
    2. 如果是前面的大元素和后面的小元素进行交换,交换后的\Delta\sigma项减少2

    综上,我们可以下结论:虽然在变换排列的过程中,\Delta\sigma项可能会改变,但是(-1)^{\Delta\sigma}的值是不会改变的。

    这等价于在(i, j)固定的情况下,对于任意的\pi(-1)^{\Delta\sigma}都是相同的。

    只需要考察一个特例,选取恒等排列\pi(l)=l

    此时

    \begin{aligned}
\Delta\sigma&=\sum_{l =1}^{i-1}[l \ge j] + \sum_{l=i}^k[l<j] \\
&= \min(i - j,0) + \min(j-i,0) \\
&= |i - j|
\end{aligned}
    显然(-1)^{|i-j|} = (-1)^{i+j}

    因此(-1)^{\Delta\sigma} = (-1)^{i+j}

    因此(-1)^{\sigma(\pi^{\ast})} = (-1)^{i+j}(-1)^{\sigma(\pi)}

    回到A的计算式:

    \begin{aligned}
 A_{(k + 1) \times (k + 1)} &= \sum_{j=1}^{k+1} a_{i,j}(-1)^{i+j}M_{i,j} \\
 &= \sum_{j=1}^{k+1} a_{i,j}(-1)^{i+j}\sum_{\pi \in S_k}(-1)^{\sigma(\pi)}\prod_{l=1, l \neq i}^{k+1} a_{l, \pi'(l)} \\
 &= \sum_{j=1}^{k+1} a_{i,j}\sum_{\pi \in S_k}(-1)^{i+j}(-1)^{\sigma(\pi)}\prod_{l=1, l \neq i}^{k+1} a_{l, \pi'(l)} \\
 &= \sum_{j=1}^{k+1} \sum_{\pi \in S_k}(-1)^{\sigma(\pi^{\ast})}a_{i,j}\prod_{l=1, l \neq i}^{k+1} a_{l, \pi'(l)} \\
 &= \sum_{j=1}^{k+1} \sum_{\pi \in S_k}(-1)^{\sigma(\pi^{\ast})}\prod_{l=1}^{k+1} a_{l, \pi^{\ast}(l)} \\
 &= \sum_{j=1}^{k+1} \sum_{\substack{\pi^{\ast}\in S_{k+1} \\ \pi^{\ast}(i) = j}}(-1)^{\sigma(\pi^{\ast})}\prod_{l=1}^{k+1} a_{l, \pi^{\ast}(l)} \\
 &= \sum_{\pi^{\ast}\in S_{k+1}}(-1)^{\sigma(\pi^{\ast})}\prod_{l=1}^{k+1} a_{l, \pi^{\ast}(l)} \\
 &= \sum_{\pi\in S_{k+1}}(-1)^{\sigma(\pi)}\prod_{i=1}^{k+1} a_{i, \pi(i)}
\end{aligned}
    即归纳命题成立,由数学归纳法,得证。

意义

  1. 证明了行列式两种方法的等价性。
  2. 证明了行列式挑选任意行/列做展开的结果是等价的,这在行列式按行列展开求值的定义当中并没有那么显然。

只有红茶可以吗? ——XXXX

起源

冬天果然还是热水泡红茶比较舒服啊。

但是就算是保温杯里面的茶也会很快冷下来,每次下课都要加热水。

那么问题来了

我应该喝多少茶,使得每次倒完热水之后的水温都是恒定的呢?

这种果然还是应该建个模啊~

假设

为了探究这个问题,我们不妨做以下假设:

  1. 将水看做一个整体,不考虑内部对流以及温度差异。
  2. 冷热水的热交换瞬 间 完 成。
  3. 热水冷却的过程符合牛顿冷却定律。
  4. 我以恒定的速率喝茶是不可能的(悲)

计算

不妨假设环境温度为T_a。牛顿冷却定律告诉我们

\dot{Q}=-k(T-T_a)
我们又知道:
\dot{Q}=cm\dot{T}
假设加完水时质量为m_0,喝水速度为v,则组合以上式子可以得到:
\dot{T}=-\frac{k}{c(m_0 - vt)}(T-T_a)
不妨设T'=T-T_a,直接分:
\begin{aligned}
    \frac{\dot{T'}}{T'} &= -\frac{k}{c(m_0-vt)} \\
    \Rightarrow \int \frac{\dot{T'}}{T'} \mathrm{d}t &= \int-\frac{k\mathrm{d}t}{c(m_0-vt)} \\
    \Rightarrow \ln T'&= \frac{k}{cv}\ln(m_0 - vt) + C_1 \\
    \Rightarrow T' &= C_2(m_0-vt)^{k\over cv} \\
    \Rightarrow T &= T_a + C_2(m_0-vt)^{k\over cv}
\end{aligned}
假设加完水t=0T=T_0。则求解得
\begin{aligned}
T_0&=T_a + C_2m_0^{\frac{k}{cv}}\\
\Rightarrow C_2 &= (T_0-T_a)m_0^{-\frac{k}{cv}}\\
\Rightarrow T&=T_a+\left(\frac{m_0-vt}{m_0}\right)^{\frac{k}{cv}}(T_0-T_a)
\end{aligned}
假设每t_f时间补加一次热水,那么最终的温度
T_f=T_a+\left(\frac{m_0-vt_f}{m_0}\right)^{\frac{k}{cv}}(T_0-T_a)
这个时候补充温度为T_h的热水,假设混合后的温度为T_0'
(m_0-vt_f)(T_0'-T_f)=vt_f(T_h-T_0')
解得
T_0'= \frac{(m_0-vt_f)T_f+vt_fT_h}{m_0}
为了保持恒温,我们需要T_0=T_0',即:
m_0T_0=(m_0-vt_f)\left[T_a+\left(\frac{m_0-vt_f}{m_0}\right)^{\frac{k}{cv}}(T_0-T_a)\right]+vt_fT_h
接下来只要解出v就行了!

可惜这个形式似乎没有初等的解析解(悲)Mathematica跑死机了都没跑出来

注:根据arXiv:math/9411224以及arXiv:1406.1948v2,或许可以使用超几何函数或者无穷根式求出方程的解,然而依然没有什么卵用。

但是通过一些直觉可以得出T_0'一定是随着v增加递增的,所以如果有现实数据的话直接二分或者牛顿迭代就行了。

Whatever,感觉还是挺有意思的,如果之后自己真的找时间测到了参数的现实值,大概会算一下吧。

决定把家里的旧的MBA改装成服务器。如此便要合上盖子。

但是合上盖子系统似乎就自动休眠了,改设置也没有什么很好的效果,即使可以使用caffeinate,重启之后还是会有问题。何况就算避免了休眠,在检测不到Display的时候据说显卡也是不工作的(虽然不知道核显是否也是如此)。网传常见的解决方法是“显卡欺骗器”,也就是Dummy Display,通过在显示端口模拟协议让系统检测到一个并不存在的Display,就行了。

但是Dummy Display一个现成的就要10块左右,而且对于MBA这种DP的接口还不是很好找。

于是自己从家里搜刮出了一些边角料糊了一个,效果还不错,成品如下:

使用的元件是一个DP转接头以及若干买Arduino送的电阻。

怎么做到的呢?

根据Wikipedia上的表述,下图VGA端口的1, 2, 3线分别表示RGB的模拟信号,6, 7, 8对应返线。

再根据Wiki上的资料,RGB线路的阻抗为75\mathrm{\Omega}。对于VGA这种老式模拟接口,通常通过线路负载确定是否连接,因此为了欺骗端口,我们只需要在1-6,2-7,3-8分别接上三个75\mathrm{\Omega}的电阻即可。

但是家里面只搜出5个220\mathrm{\Omega}的和5110\mathrm{\Omega}的电阻,于是乎就有了上图的蜜汁接线。具体来说:

  1. 三个220\mathrm{\Omega}并联
  2. 两个220\mathrm{\Omega}并上两个110\mathrm{\Omega}的串联
  3. 一个110\mathrm{\Omega}并上两个110\mathrm{\Omega}的串联

最后每个电路的等效电阻就是220/3\approx 73.33\mathrm{\Omega},再假装有线阻就是75\mathrm{\Omega}的水平,使用下来完全没问题,当然因为其他线没连,分辨率之类的信息是缺失的,我的电脑似乎默认1024\times768处理了。

不得不说还是VGA这种模拟的端口欺骗起来容易,HDMI这种数字端口就要复杂很多。

(后续万用表测试下来110\mathrm{\Omega}的实际电阻是

1\mathrm{k\Omega}
,大概是读色环的时候读反了我依然坚持认为就应该按照我的方向读,然而最后成品依然可以工作,只能认为是冗余比较高,或者是玄学了。)

(似乎装了Linux之后就可以通过设置避免自动休眠了?)

本地笔记整理,原文写于2019/8/22,2019/12/8上传


斯坦纳树是生成树的推广形式,只要求所有顶点的一个子集联通即可。

最小斯坦纳树

一般的所求的都是最小斯坦纳树问题,使用状压DP解决。定义\dpa[i][S]为从i点出发,与S集合当中的点保持联通的最小代价。首先我们有子集转移:

\dpa[i][S] = \min_{T\subset S}\left\{\dpa[i][T]+\dpa[i][S\setminus T]\right\}

但是这样有些边就会被多算,因此在这一步过后还要加上转移:

\dpa[i][S] = \min\left\{\dpa[i][S], \min_{i \to j} \left\{\dpa[j][S]+\len[i\to j]\right\}\right\}
这个转移就有后效性,但是可以使用SPFA解决。

最小斯坦纳森林

最小斯坦纳森林是最小斯坦纳树的升级版本,要求构造一棵最小权森林使得每一类顶点之间两两连通。

定义

\stf[S]=\min_{i\in V}\left\{\dpa[i][S]\right\}
表示S中节点都在一棵树上的最小值。

然后由类似的转移:

\stf[S] = \min_{T\subset S}\left\{\stf[T]+\stf[S\setminus T]\right\}
但是注意,转移时的ST要满足某一个种类的节点要么都在,要么都不在。