写这篇博客是为了介绍一些有趣的与现实生活息息相关的算法问题,以及解决方案。其中大部分都是我自己亲身经历过,并且自己独立求解的。这里写出分享给大家。
考虑这样一个问题,要求你设计一个函数random_set(N, K)
,其中$N\geq K$。要求你从$0,\ldots,N-1$的所有$2^N$种可能的组合中,只考虑其中包含$K$个元素的组合(总共有$N\choose K$种),并以等概率返回其中任意一个。
这个问题挑战点在于,我们希望最小化理论上的时间复杂度,由于至少要返回一个大小为$K$的集合,因此时间复杂度的下界是$O(K)$。
算法1:利用洗牌算法,把$0$到$N-1$共$N$个数先打乱并挑选前$K$个数,这样可以直接保证得到的结果是等概率的。算法的时间复杂度显然是$O(N)$的。
接下来考虑另外一个很简单的算法。
算法2:我们维护一个空集合。之后每次随机枚举一个值,如果值不在集合中,就加入,否则丢弃。重复这个过程直到集合大小达到$K$。
这个算法找到第$i$数的期望步数是$\frac{N}{N+1-i}$,如果我们用哈希表来维护这个集合,则时间复杂度为$O(N\sum_{i=1}^K\frac{1}{N+1-i})$,其结果大致为$O(N\ln \frac{N}{N-K})$。
这两个算法看上去都不太行,但是一旦结合在一起,就会产生非常有趣的效果。
考虑这样一个问题,你要把$N$元红包平均分给$K$个人,最小精度单位为分,设第$i$个人分到了$x_i$分(必须为整数),要求返回$x_1,\ldots,x_N$,且在所有满足$\sum_{i=1}^Kx_i=N$且$0\leq x_1,\ldots,x_N$的可能的组合中,每种组合返回的概率相同。其中$1\leq N\leq 10^8$,$1\leq K\leq 10^4$
由于单位不同,我们可以把$N$先乘上$100$,这样我们就是用的分为单位。
这个问题实际上它的组合解释是:有$N+K-1$个不同的球,从中选择$K-1$个不同的球,每种选法的概率相等。
这实际上就回到了随机数组合选取问题了。因此我们可以直接得到一个$O(K)$时间复杂度的算法。
这个问题是我当时在设计一个桌游rating板块的时候遇到的。这里我们需要根据玩家每一轮的胜负,来实时计算他的rating。一个好的rating算法应该满足下面要求
一个简单的方式是设计一组权重$w_1,\ldots,w_N$,其中$w_i$表示最近第$i$场比赛结果的权重,记$x_i$表示用户第$i$场比赛的胜负,胜利为$1$,失败为$-1$。总分为$\sum_{i=1}^Nw_ix_i$。
为了满足前面的要求,我们希望$w_1\geq w_2\geq \cdots \geq w_N\geq 0$。一个简单的选择就是$w_i=\frac{1}{T+i}$。其中$T$为一个固定常数。
设计是完成了,但是很显然它目前只满足要求1和2,下面我们证明它同时还满足要求3。
非常简单,假如$x_1=1$,设$I$为失败的场次的下标集合,那么新的总分为$\sum_{i=1}^Nw_i-2\sum_{i\in I}w_i$,而原先的总分为$\sum_{i=1}^{N-1}w_i-2\sum_{i\in I}w_{i-1}$。计算新老总分的差值可以得到
\[w_N+2\sum_{i\in I}w_{i-1}-w_i\]很显然上面式子的结果非负,因此新分一定不低于老分。对于$x_1=-1$的情况证明类似。
考虑我们现在有一副$H\times W$的灰度图,每一个点的灰度用$Q$位来表示。
现在我们希望压缩这个图像。具体的压缩算法非常简单,我们用调色板的方式,只支持特点的$K$种深度(从$2^Q$中选择),这样每个颜色只需要用$\log K$个bit来表示即可。设原来的灰度为$x$,则我们选择调色板中灰度最接近的那个颜色替代它。
但是提高了压缩效率,我们也不能过度失真。令$A_i$表示灰度图的第$i$个像素的灰度,$B_i$表示匹配的调色板颜色的灰度。共$N=HW$个像素。对于$i\geq 0$,我们定义如下惩罚函数
\[L_i=\|A-B\|^i\]下面我们考虑如何找到最合适的$K$个颜色,从小到大排列为$c_1,\ldots,c_K$。
我们不难用动态规划来求解。首先我们将所有颜色离散化(很显然只有颜色出现次数会影响损失函数的结果),设不同的颜色数为$C$。令$cost(l,r)$表示将灰度$l$到$r$之间的灰度映射到同一颜色的最少惩罚值。令$dp(i,j)$表示仅考虑前$j$小的颜色,用$i$个不同的颜色去替代,最少的惩罚函数值。很显然我们的结果是$dp(K,C)$,用回溯法可以找到最优解时所有选择的颜色。
\[dp(i,j)=\min_{k<j} dp(i-1,k)+cost(k+1,j)\]这个公式暴力求解的时间复杂度是$O(KC^2)$。在$C$比较大的时候求解难度很高。
我们可以用决策单调性来优化这一步骤,这样可以将时间复杂度降低到$O(KC\log C)$。
下面我们证明决策单调性的必要条件满足,即对于任意$P$范数惩罚函数,下面公式对于所有$k<j<i$满足
\[cost(k,i+1)-cost(k,i)\geq cost(j,i+1)-cost(j,i)\]我们记$opt(l,r)$表示让$cost(l,r)$最小化所采用的颜色。很显然有$opt(k,i)\leq opt(k,i+1)$和$opt(j,i)\leq opt(j,i+1)$,同时还有$opt(k,i)\leq opt(j,i)$和$opt(k,i+1)\leq opt(j,i+1)$。
实际上,我们仅考虑公式左边代表的增量的时候,且仅考虑颜色$k$到$i$,第一步从$opt(k,i)$到$opt(k,i+1)$带来的变化,我们记增量为$D_1$,之后再加入颜色$i+1$,带来的新的增量为$D_2$。
同理,我们考虑公式右边代表的增量的时候,且仅考虑颜色$j$到$i$,第一步从$opt(j,i)$到$\max(opt(k,i+1),opt(j,i))$带来的增量变化,记作$D'_1$,同理加入颜色$i+1$后带来的新的增量为$D'_2$。
很显然有$D'_1\leq D_1$且$D'_2\leq D_2$,因此我们有
\[cost(k,i+1)-cost(k,i)\geq cost'(j,i+1)-cost(j,i)\geq cost(j,i+1)-cost(j,i)\]]]>统计学习是指:从给定的、有限的、用于学习的训练数据集合出发,假设数据是独立同分布产生的;并且假设要学习的模型属于某个函数的集合,称为假设空间;应用某个评价准则,从假设空间中选取一个最优模型;最优模型的选取由算法实现。
按学习分类
按模型分类
按算法分类
按技巧分类
决策函数组成的假设空间
\[F=\left\{f_\theta|Y=f_\theta(X),\theta\in R^n\right\}\]条件概率组成的假设空间
\[F=\left\{P_\theta|P_\theta(Y|X),\theta\in R^n\right\}\]其中$\theta$是模型参数,它的取值范围$R^n$称为参数空间。
损失函数度量模型一次预测的好坏,风险函数度量平均意义下模型预测的好坏。
损失函数记作$L(Y,f(X))$。常用的有
损失函数越小,模型就越好。模型的输入$(X,Y)$遵循联合分布$P(X,Y)$,所以损失函数的期望为:
\[\begin{aligned} &R_{\exp}(f) \\=&E_P[L(Y,f(X))] \\=&\int_{X\times Y}L(y,f(x))P(x,y)dxdy \end{aligned}\]学习的目标是寻找期望风险最小的模型。但是由于联合分布是未知的,因此我们无法比较不同模型的期望风险。
对于训练数据集
\[T=\left\{(x_i,y_i)|i\in [1,N]\right\}\]模型$f(X)$关于$T$的平均损失称为经验风险
\[R_{\mathrm{emp}}(f)=\frac{1}{N}\sum_{i=1}^NL(y_i,f(x_i))\]根据大数定理,当样本数量$N$趋于无穷时,经验风险趋于期望风险。我们可以用经验风险来估计期望风险。
监督学习的基本策略有两种:
经验风险最小化选择经验风险最小的模型,它在样本容量足够大的情况下效果很好,但是样本容量较小的时候会发生过拟合。
结构风险最小化可以避免过拟合,结构风险是在经验风险的基础上加上表示模型复杂度的正则化项。
我们需要设计良好的算法,在假设空间中找到风险最小的模型。
随着模型的复杂度上升,训练误差会逐步降低,但是测试误差在选择模型复杂度低于真实模型复杂度的时候降低,高于真实模型复杂度的时候升高。
一种避免过拟合的方法是正则化,采用结构风险最小化策略。其一般形式为:
\[\min\limits_{f\in F}\frac{1}{N}\sum_{i=1}^NL(y_i,f(x_i))+\lambda J(f)\]正则化项可以采用参数向量的$L_1$或$L_2$范数。
从贝叶斯估计的角度来看,正则化项对应于模型的先验概率,假设越复杂的模型出现的概率越低。
另外一种方式是交叉验证。
如果给定的样本数量足够,可以将数据集随机地切分为三部分:训练集,验证集,测试集。其中训练集用于训练模型,验证集用于挑选模型,而测试集用于对模型的泛化能力进行评估。
但是实际上数据是不充足的,为了选择更好的模型,可以采用交叉验证的方式。
泛化误差用来评价学习方法的泛化能力。泛化误差等价于模型的期望风险,我们实践中一般用测试误差来估计泛化误差。
学习方法的泛化能力的比较一般通过研究泛化误差的概率上界进行,简称为泛化误差上界。
对于二类分类问题,如果假设空间是有限的且大小为$d$,对任意一个函数$f\in F$,至少以概率$1-\delta$使得下面不等式成立,其中$0<\delta<1$:
\[R(f)\leq \widehat{R}(f)+\sqrt{\frac{1}{2N}\log \frac{d}{\delta}}\]感知机是二类分类的线性分类模型。输出值为$\left\{+1,-1\right\}$。
感知机学习旨在求出将训练数据进行线性划分的分离超平面。
假设输入空间是$X\subseteq R^n$,输出空间是$Y=\left\{+1,-1\right\}$。输出$x\in X$表示实例的特征向量,对应于输入空间的点,输出$y\in Y$表示实例的类别。由输入空间到输出空间的如下函数:
\[f(x)=\mathrm{sign}(w\cdot x+b)\]称为感知机。其中$w$和$b$称为感知机模型参数,$w\in R^n$叫做权值向量,$b\in R$叫做偏置。
感知机的几何解释是线性方程$w\cdot x+b$是$R^n$中的一个超平面$S$,其中$w$是超平面的法向量,$b$是超平面的截距。这个超平面将特征空间划分为两个部分,两个部分中的点分别被分类为正负两类。而超平面$S$称为分离超平面。
感知机的损失函数的定义,一个自然的选择是误分类点的总数,但是这样的损失函数不是参数$w$和$b$的连续可导函数,不易优化。另外一个选择是使用误分类点到超平面$S$的总距离。
定义任一点$x_0$到超平面$S$的距离为:
\[\frac{1}{\|w\|}|w\cdot x_0+b|\]其次对误分类的数据$(x_i,y_i)$来说有:
\[-y_i(w\cdot x_i+b)>0\]因此误分类点到超平面$S$的距离是
\[-\frac{1}{\|w\|}y_i(w\cdot x_i+b)\]记所有误分类点的集合为$M$,那么总距离为
\[-\frac{1}{\|w\|}\sum_{x_i\in M}y_i(w\cdot x_i+b)\]定义损失函数为
\[L(w,b)=-\sum_{x_i\in M}y_i(w\cdot x_i+b)\]感知机学习算法是误分类驱动的,具体采用随机梯度下降法。首先任意选择一个超平面$w_0,b_0$,之后不断用梯度下降法极小化损失函数。极小化过程中不是一次使$M$中所有误分类点的梯度下降,而是一次随机选取一个误分类点并使其梯度下降。
假设误分类点集合$M$是固定的,那么损失函数$L(w,b)$的梯度由
\[\nabla_wL(w,b)=-\sum_{x_i\in M}y_ix_i\\ \nabla_bL(w,b)=-\sum_{x_i\in M}y_i\]随机选取一个误分类点$(x_i,y_i)$,对$w,b$进行更新:
\[w\leftarrow w+\eta y_ix_i\\ b\leftarrow b+\eta y_i\]其中$\eta$是步长(学习率),取值范围为$0<\eta\leq 1$。
重复上述流程直到不存在误分类点。
设训练数据集$T$是线性可分的,则:
(1) 存在满足条件$|\widehat{w}{opt}|=1$的超平面$\widehat{w}{opt}\cdot \widehat{x}=0$将训练数据完全正确分开,且存在$\gamma>0$,对于任意$1\leq i\leq N$,有
\[y_i(\widehat{w}_{opt}\cdot \widehat{x}_i)\geq \gamma\](2) 令$R=\max\limits_{1\leq i\leq N}\|\widehat{x}_i\|$,则感知机算法在训练数据集上的误分类次数$k$满足不等式:
\[k\leq (\frac{R}{\gamma})^2\]k近邻法(k-NN):给定一个训练数据集,训练数据被分到不同的类中。对新的输入,在训练数据集中找到与该实例最近的k个训练实例,这k个实例的多数属于某个类,就把该输入实例分为这个类。
对于输入点$x$,记最近$k$个训练点组成的集合为$N_k(x)$。之后在$N_k(x)$中根据分类决策规则来决定$x$的类别$y$。
当$k=1$的时候是特殊情况,这时候称为最近邻算法。
特征空间中,对每个训练实例点$x_i$,距离该点比其他点更近的所有点组成一个区域叫做单元(cell)。每个训练实例点都有一个单元,所有训练实例点的单元构成对特征空间的一个划分。
采用的距离可以是$L_p$距离
\[L_p(x_i,x_j)=(\sum_{l=1}^n|x_i^{(l)}-x_j^{(l)}|^p)^{\frac{1}{p}}\]$L_1$称为曼哈顿距离,$L_2$称为欧式距离,而$L_\infty$则计算的是所有坐标距离的最大值。
如果k值较小,则仅使用较小的邻域进行预测,容易受到噪音点的干扰,发生过拟合。
如果选择较大的k值,这时候较远的点也会对预测起作用,整个模型变得简单。当$k=N$的时候只有一种可能的分类。
k近邻法中的分类决策规则往往是多数表决,即由输入实例的k个邻近的训练实例的多数类决定输入实例的类。
k近邻可以通过kd树来实现高效的搜索。
考虑输入空间$X\subseteq R^n$,输出集合$Y=\left\{c_1,\ldots,c_K\right\}$。
设$P(X,Y)$是$X$和$Y$的联合概率分布。训练数据集$T$是由$P(X,Y)$独立同分布产生。
朴素贝叶斯法通过训练数据集学习联合概率分布$P(X,Y)$。具体的,学习以下先验概率分布及条件概率分布。先验概率分布:
\[P(Y=c_k)\\ P(X=x|Y=c_k)=P(X^{(1)}=x^{(1)},\ldots,X^{(n)}=x^{(n)}|Y=c_k)\]第二个式子我们需要存储$k\prod_{j=1}^nS_j$,其中$S_j$表示$x^{(j)}$中可能的取值数。
朴素贝叶斯法对条件概率分布作了条件独立性的假设。因此有
\[P(X=x|Y=c_k)=\prod_{j}^nP(X^{(j)}=x^{(j)}|Y=c_k)\]朴素贝叶斯法实际上学到生成数据的机制,所以属于生成模型。
对给定的输入$x$,学习到的后验概率为:
\[\begin{aligned} P(Y=c_k|X=x)&=\frac{P(X=x|Y=c_k)P(Y=c_k)}{\sum_{i}P(X=x|Y=c_i)P(Y=c_i)}\\ &=\frac{P(Y=c_k)\prod_jP(X^{(j)}=x^{(j)}|Y=c_k)}{\sum_{i}P(Y=c_i)\prod_jP(X^{(j)}=x^{(j)}|Y=c_i)} \end{aligned}\]朴素贝叶斯分类器可以写作:
\[f(x)=\argmax\limits_{c_k}P(Y=c_k|X=x)\]其中考虑到分母等价于$P(X=x)$,因此我们需要只是最大化分子:
\[f(x)=\argmax\limits_{c_k}P(Y=c_k)\prod_jP(X^{(j)}=x^{(j)}|Y=c_k)\]在使用0-1损失函数的时候,后验概率最大化等价于期望风险最小化。
要学习$P(Y=c_k)$和$P(X^{(j)}=x^{(j)}|Y=c_k)$。可以使用极大似然估计法估计想要的概率。
\[P(Y=c_k)=\frac{1}{N}\sum_{i=1}^NI(y_i=c_k)\]条件概率$P(X^{(j)}=a_{j,l}|Y=c_k)$的极大似然估计是
\[P(X^{(j)}=a_{j,l}\|Y=c_k)=\frac{\sum_{i=1}^NI(x_i^{(j)}=a_{j,l},y_i=c_k)}{\sum_{i=1}^NI(y_i=c_k)}\](1)先计算先验概率
\[P(Y=c_k)\\ P(X^{(j)}=x^{(j)}|Y=c_k)\](2)对于给定的实例$x$,获得
\[\argmax\limits_{c_k}P(Y=c_k)\prod_jP(X^{(j)}=x^{(j)}|Y=c_k)\]用极大似然估计中可能出现概率值为$0$的情况,这时候除法操作是未定义的。
可以用贝叶斯估计来解决这个问题,具体地
\[P_\lambda(X^{(j)}=a_{j,l}\|Y=c_k)=\frac{\sum_{i=1}^NI(x_i^{(j)}=a_{j,l},y_i=c_k)+\lambda}{\sum_{i=1}^NI(y_i=c_k)+S_j\lambda}\\ P_\lambda(Y=c_k)=\frac{\sum_{i=1}^NI(y_i=c_k)+\lambda}{N+K\lambda}\]其中$\lambda\geq 0$。当$\lambda=0$的时候就是极大似然估计。通常取$\lambda=1$,这时候称为拉普拉斯平滑。这时候有:
\[P_{\lambda}(X^{(j)}=a_{j,l}|Y=c_k)>0\\ \sum_{l=1}^{S_j}P(X^{(j)}=a_{j,l}|Y=c_k)=1\]分类决策树模型是一种描述对实例分类的树形结构。决策树由结点和有向边组成。结点分为内部结点和叶结点。内部结点表示一个特征或属性,叶结点表示一个类。
用决策树分类,从根结点开始,对实例的某一特征进行测试,根据测试结果,将实例分配到其子结点。重复测试直到抵达叶结点。
决策树于一个if-then规则集合对应,满足互斥且完备的特性。
决策树还表示概率分布,叶结点的分类表示满足所有内部结点测试的实例最可能属于的分类。
给定样本集$D$,其中$n$为特征个数,$Y=\left\{1,\ldots,K\right\}$,$N$为样本容量。
能正确处理样本集的决策树可能有多个,也可能一个没有。在损失函数确定后,我们要选择的是使损失函数最小化的决策树。选择最优决策树是NP完全问题,因此我们采用的是启发式方法,来近似求解。
决策树的构建算法是一个递归的流程。假设我们用一组训练数据构建根结点:
上面生成的决策树可能对训练数据有很好的分类能力,但是对测试数据不具有很好的分类能力,即发生过拟合。我们需要对树自上向下进行剪枝,使树变得更简单,从而提高泛化能力。具体就是删除内部结点下的所有后代,将其转换为根结点。
决策树生成时只需要考虑局部最优,但是决策树剪枝时考虑的是全局最优。
决策树的生成算法有ID3,C4.5,CART。
当存在多个可用特征的时候,该选择那个特征进行分类。信息增益用来衡量选择的特征的优劣。
信息增益表示得知特征$X$的信息而使类$Y$的信息的不确定性减少的程度。记$H(X)$表示随机分布$X$的信息熵,$H(Y|X)$表示条件概率下的信息熵。则特征$A$对训练数据$D$的信息增益定义为
\[g(D,A)=H(D)-H(D|A)\]每次我们选择拥有最大信息增益的特征进行分类。
计算信息增益的算法如下,设选择特征$A$拥有$n$个不同的取值,并将$D$划分为$N$个子集$D_i$。记$D_i$中属于类$C_j$的样本的集合为$D_{i,j}$,则算法如下:
使用信息增益作为特征的挑选准则,偏向于选择取值较多的特征的问题。使用信息增益比可以对这一问题进行校正。其定义如下:
\[g_R(D,A)=\frac{g(D,A)}{H_A(D)}\]其中
\[H_A(D)=-\sum_{i=1}^n\frac{|D_i|}{|D|}\log_2 \frac{D_i}{D}\]ID3算法用极大似然法进行概率模型的选择。
输入:训练数据集$D$,特征集$A$和阈值$\epsilon$; 输出:决策树$T$。
C4.5算法与ID3类似,只不过使用信息增益比来选择特征。
剪枝是从下向上计算的过程。记$C_\alpha(T)$表示树$T$的损失函数,如果将某个结点转换为叶结点可以得到更小的损失函数,那么就将其转换为叶结点。
设$T$表示决策树的叶子结点集合,对于叶子$t$,记$N_t$表示叶子中样本数,$N_{t,k}$表示叶子中类$C_k$的样本数,决策树的损失函数定义为
\[C_\alpha(T)=\sum_{i=1}^{|T|}N_tH_t(T)+\alpha|T|\]其中
\[H_t(T)=-\sum_{k}\frac{N_{t,k}}{N_t}\log \frac{N_{t,k}}{N_t}\]分类回归树(CART)既可以用于分类也可以用于回归。
CART用于计算$P(Y|X)$,它假设决策树为二叉树,内部结点特征的取值为“是否”值,左分支代表是,右分支代表否。这时候决策树等价于递归地二分每个特征,将输入空间即特征空间划分为有限个单元,并在这些单元上确定预测的概率分布。
生成决策树时,对回归树用平方误差作为损失函数,对分类树用基尼指数作为损失函数。
假设输入空间被划分为$M$个单元$R_1,\ldots,R_M$,并且在每个单元$R_m$上有一个固定的输出值,于是回归树模型可以表示为
\[f(x)=\sum_{m=1}^Mc_mI(x\in R_m)\]此时平方误差为
\[\sum_{x_i\in R_m}(y_i-f(x_i))^2\]易知$c_m$最优值$\widehat{c}_m$正好等于$R_m$上的所有输入实例$x_i$对应的$y_i$的均值。
至于如何选择划分输入空间,这里采用启发式的方法。假设选择的是第$j$个变量$x^{(j)}$和它取得值$s$,作为切分变量和切分点,并定义两个区域:
\[R_1(j,s)=\{x|x^{(j)}\leq s\}, R_2(j,s)=\{x|x^{(j)}>s\}\]至于寻找最优切分变量$j$和最优切分点$s$,等价于求解
\[\min_{j,s}[\min_{c_1}\sum_{x_i\in R_1(j,s)}(y_i-c_1)^2+\min_{c_2}\sum_{x_i\in R_2(j,s)}(y_i-c_2)^2]\]分类树用基尼指数作为损失函数。假设有$K$个类,样本点属于第$k$类得概率为$p_k$,则概率分布得基尼指数定义为:
\[Gini(p)=\sum_{k=1}^Kp_k(1-p_k)=1-\sum_{k=1}^Kp_k^2\]而对于给定的样本集合$D$,其基尼指数为
\[Gini(D)=1-\sum_{k=1}^K(\frac{|C_k|}{|D|})^2\]如果将样本集合$D$划分为$D_1$和$D_2$两部分,则
\[Gini(D,A)=\frac{|D_1|}{|D|}Gini(D_1)+\frac{|D_2|}{|D|}Gini(D_2)\]Logisitic分布:设$X$是连续随机变量,$X$服从Logistics分布是指$X$具有下列分布函数和密度函数
\[F(x)=P(X\leq x)=\frac{1}{1+e^{-(x-\mu)/\gamma}} f(x)=F'(x)=\frac{e^{-(x-\mu)/\gamma}}{\gamma(1+e^{-(x-\mu)/\gamma})^2}\]Logistics的分布函数是一条S形曲线,该曲线以点$(\mu,\frac{1}{2})$为中心对称,并且在中心附近增长快,在两端增长速度较慢。
二项Logistic回归模型是如下的条件概率分布:
\[P(Y=0|x)=\frac{1}{1+\exp(w\cdot x+b)}\\ P(Y=1|x)=1-P(Y=0|x)\]Logistic回归比较两个条件概率值得大小,将实例$x$分到概率值较大的分类。
一个事件发生的几率(odds)是指事件发生的概率除上事件不发生的概率。几率的对数值为:
\[\log\frac{P(Y=1|x)}{1-P(Y=1|x)}=w\cdot x\]线性函数$w\cdot x$越接近无穷,概率趋向于$1$,越接近负无穷,概率越接近$0$。
对于模型参数,可以使用极大似然估计法估计模型参数,设$\pi(x)=P(Y=1|x)$,对于样本集合$T=\left\{(x_1,y_1),\ldots,(x_n,y_n)\right\}$,有似然函数
\[\prod_{i=1}^N[\pi(x_i)]^{y_i}[1-\pi(x_i)] ^{1-y_i}\]其对数似然函数为:
\[L(w)=\sum_{i=1}^n[y_i(w\cdot x_i)-\log(1+\exp(w\cdot x_i))]\]假设共有$K$类,则
\[P(Y=k|x)=\frac{\exp(w_k\cdot x)}{1+\sum_{k=1}^{K-1}\exp(w_k\cdot x)},1\leq k< K\\ P(Y=K|x)=\frac{1}{1+\sum_{k=1}^{K-1}\exp(w_k\cdot x)}\]最大熵原理是概率模型学习的一个准则:在所有可能的概率模型中,熵最大的模型是最好的模型。
最大熵原理认为在没有足够的信息情况下,那些不确定的部分都是等可能的。
]]>定义:
考虑binary classify,其对应的概念为$c$,这时候$Y=\left\{0,1\right\}$。我们的目标是从$H$中找到与$c$误差最小的假说$h$。定义泛化误差为:
\[R(h)=\mathop{\mathrm{P}}\limits_{x\sim D}[h(x)\neq c(x)]=\mathop{\mathrm{E}}\limits_{x\sim D}[1_{h(x)\neq c(x)}]\]定义经验误差为:
\[\widehat{R}_S(h)=\frac{1}{m}\sum_{i=1}^m1_{h(x_i)\neq c(x_i)}\]始终满足
\[\mathop{\mathrm{E}}\limits_{x\sim D}[\widehat{R}_S(h)]=R(h)\]记$n$代表表示任意一个输入空间中元素所需的空间大小。记$size(c)$表示概念类$C$中需要最多空间表示的概念的需要的空间。
我们称概念类$C$是PAC-learnable,如果存在一个算法$A$,和一个多项式$poly$,满足对于任意$\epsilon>0$以及$\delta>0$,对于任意一个$X$上的概率分布$D$,以及任意目标概念$c\in C$,只要样本数量$m\geq poly(1/\epsilon,1/\delta,n,size(c))$,就有:
\[\mathop{\mathrm{P}}\limits_{S\sim D^m}[R(h_S)\leq \epsilon]\geq 1-\delta\]进一步的如果$A$能在$poly(1/\epsilon,1/\delta,n,size(c))$时间复杂度内结束,那么认为$C$是可以被有效PAC-learnable的,并认为算法$A$是$C$的PAC-learnable算法。
对于有限大小的假说集$H$,目标概念$c\in H$。且算法$A$能找到一个假说$h_S$满足$\widehat{R}S(h_S)=0$。那么对于任意$\delta>0$,一定满足$\mathrm{P}{S\sim D^m}[R(h_S)\leq \epsilon]\geq 1-\delta$,只要
\[m\geq \frac{1}{\epsilon}\ln \frac{|H|}{\delta}\]之前讨论的$h$是在$S$上是完全一致的($0$误差),但是实际上很多时候由于目标概念超出了我们假说的范畴,因此会存在一定的经验误差。
对于任意假说$h:X\rightarrow \left\{0,1\right\}$,由Hoeffding's inequality可以直接推出
\[\mathop{\mathrm{P}}\limits_{S\sim D^m}[|\widehat{R}_S(h)-R(h)|\geq \epsilon]\leq 2\exp(-2m\epsilon^2)\]继而可以推出对于任意$\delta>0$,下面不等式拥有至少$1-\delta$的置信度
\[R(h)\leq \widehat{R}_S(h)+\sqrt{\frac{1}{2m}\log \frac{2}{\delta}}\]对于任意有限假说集合$H$,以及任意$\delta>0$,都至少有$1-\delta$置信度,使得不等式满足:
\[\forall h\in H, R(h)\leq \widehat{R}_S(h)+\sqrt{\frac{1}{2m}\ln \frac{2|H|}{\delta}}\]上面提到的样本是$X\times Y$的子集,但是有一种场景是一个样本的标签是概率分布而不是精确值。比如给定身高和体重,标签是性别。PAC学习在这种场景下的自然扩展称作不可知(agnostic)PAC。
泛化误差为:
\[R(h)=\mathop{\mathrm{P}}\limits_{(x,y)\sim D}[h(x)\neq y]=\mathop{\mathrm{E}}\limits_{(x,y)\sim D}[h(x)\neq y]\]称$A$是不可知PAC学习算法,如果存在一个多项式$poly$,对于任意$\epsilon>0$以及$\delta>0$,对于所有$X\times Y$上的概率分布$D$,只要$m\geq poly(1/\epsilon,1/\delta,n,size(c))$,就有:
\[\mathop{\mathrm{P}}\limits_{S\sim D^m}[R(h_S)-\mathop{\min}\limits_{h\in H}R(h)\leq \epsilon]\geq 1-\delta\]如果$A$能在$poly(1/\epsilon,1/\delta,n,size(c))$实际复杂度内结束,那么称$A$是一个有效的不可知PAC学习算法。
定义贝叶斯误差为:
\[R^*=\mathop{\inf}\limits_{h} R(h)\]对于假说$h$,如果满足$R(h)=R^*$,那么称$h$为贝叶斯假说或贝叶斯分类器。
在确定的情况(只允许一个标签)下,满足$R^=0$,但是在随机的情况(允许多个标签)下,$R^\neq 0$,可以通过条件概率定义为:
\[\forall x\in X, h_{Bayers}(x)=\mathop{\argmax}\limits_{y\in \{0,1\}} \mathrm{P}[y|x]\]对于给定的$X\times Y$上的概率分布,定义在点$x$处的噪音为
\[noise(x)=\min\{\mathrm{P}[0|x],\mathrm{P}[1|x]\}\]满足
\[\mathrm{E}[noise(x)]=R^*\]对于点$x$,如果$noise(x)$接近$\frac{1}{2}$,那么这样的点称为噪音点,也是精确预判所会遇到的挑战。
对于任意损失函数$L:Y\times Y\rightarrow R$,定义关联与$H$的损失函数损失函数族为:
\[G=\{g:(x,y)\rightarrow L(h(x),y):h\in H\}\]对于样本集合$S=(z_1,\ldots,z_m)$,$G$的Rademacher经验复杂度为
\[\widehat{R}_S(G)=\mathop{\mathrm{E}}\limits_{\sigma}[\mathop{\sup}\limits_{g\in G}\frac{1}{m}\sum_{i=1}^m\sigma_ig(z_i)]\]其中$\sigma=(\sigma_1,\ldots,\sigma_m)^T$,其中$\sigma_i$是取值为$\left\{-1,+1\right\}$的均匀分布独立随机变量,称为Rademacher变量。记$g_S=(g(z_1),\ldots,g(z_m))^T$,那么可以重写为
\[\widehat{R}_S(G)=\mathop{\mathrm{E}}\limits_{\sigma}[\mathop{\sup}\limits_{g\in G}\frac{g_S\cdot\sigma}{m}]\]Rademacher泛化复杂度定义如下
\[R_m(G)=\mathop{\mathrm{E}}\limits_{S\sim D^m}[\widehat{R}_S(G)]\]如果损失函数取值范围为$\left\{0,1\right\}$,对于任意$\delta>0$,下面不等式至少拥有$1-\delta/2$的置信度:
\[\mathrm{E}[g(z)]\leq \frac{1}{m}\sum_{i=1}^mg(z_i)+2R_m(G)+\sqrt{\frac{1}{2m}\ln \frac{1}{\delta}}\\ \mathrm{E}[g(z)]\leq \frac{1}{m}\sum_{i=1}^mg(z_i)+2\widehat{R}_S(G)+3\sqrt{\frac{1}{2m}\ln \frac{2}{\delta}}\]如果$Y=\left\{-1,1\right\}$,且损失函数取值范围为$\left\{0,1\right\}$,考虑$S_X$是$S$在$X$上的投影,那么有:
\[\widehat{R}_S(G)=\frac{1}{2}\widehat{R}_{S_X}(H)\]对于任意空间$X$上的分布$D$,对于任意$\delta>0$,在样本$S$上对于任意$h\in H$,至少有$1-\delta$的置信度
\[R(h)\leq \widehat{R}_S(h)+R_m(H)+\sqrt{\frac{1}{2m}\ln \frac{1}{\delta}}\\ R(h)\leq \widehat{R}_S(h)+\widehat{R}_S(H)+3\sqrt{\frac{1}{2m}\ln \frac{2}{\delta}}\]对于假说集$H$,growth函数定义为:
\[\forall m\in N, \prod_H(m)=\mathop{\max}\limits_{\left\{x_1,\ldots,x_m\right\}\subseteq X}|\left\{(h(x_1),\ldots,h(x_m)):h\in H\right\}|\]令$G$表示一个值域为$\left\{-1,+1\right\}$函数族,那么下面的不等式始终成立:
\[R_m(G)\leq \sqrt{\frac{2\ln \prod_G(m)}{m}}\]可以通过growth函数来约束假说$h$的泛化误差:
\[R(h)\leq \widehat{R}_S(h)+\sqrt{\frac{2\ln \prod_H(m)}{m}}+\sqrt{\frac{\ln \frac{1}{\delta}}{2m}}\\ \mathop{\mathrm{P}}[|R(h)-\widehat{R}_S(h)|>\epsilon]\leq 4\prod_H(2m)\exp(-\frac{m\epsilon^2}{8})\]VC-dimension的定义为:
\[\mathrm{VCdim}(H)=\max\left\{m:\prod_H(m)=2^m\right\}\]]]>如果有N种语法和M种机器,那么我们需要实现NM个编译器,但是如果允许编译器前后端分离,那么只需要实现N个前端和M个后端即可。
编译器的分为前端和后端,前端输入是源代码,而输出是中间表示(IR),而后端的输入是中间表示,输出是可以执行的机器码。前端不需要考虑机器底层细节,而后端不需要考虑语法细节。
分词的作用是:
分词一般可以通过正则表达式来实现
正则表达式基本元素
a
, 字面量,匹配自身@
, 空字符串(正式写法是$\epsilon$,这里只是出于简化的目的)A|B
,A或BAB
,先A后BA*
,匹配任意多次A其余扩展元素可以通过基本元素的组合实现
A?
,等价于A|@
A+
,等价于AA*
[ABC]
,等价于A|B|C
[a-cA-B]
,等价于[abcAB]
给定一个正则表达式和一个输入字符串,可能存在不同的匹配方案。比如if8
可以匹配一个标识符,也可以先后匹配if
和8
。下面是为了确定匹配关系的规则:
可以通过有限自动机(finite automaton)来实现正则表达式。
自动机是一副有向图,每个状态对应图中的一个结点,如果存在符号x
使得从状态A
向状态B
迁移,则从A
到B
有一条标记x
的有向边。
自动机会有两个特殊状态:开始状态(start state)和中止状态(final state)。每次匹配都从开始状态出发,如果最终到达中止状态则匹配输入字符。
如果自动机中每个状态的出边上标记的符号都不同,那么自动机为确定有限自动机(deterministic finite automaton),否则为非确定有限自动机(nondeterministic finite automaton)。
构建NFA的算法可以采用Thompson算法。
在得到了NFA后,由于NFA是不确定的,因此在匹配的时候需要枚举所有可能性,会降低性能。观察可以发现对于一段输入,NFA可能会处于多个状态,换言之,处于某个状态集合,而对于某个符号,NFA会从某个特定的状态集合转移到另外一个特定的状态集合。因此如果我们将NFA的状态集合作为DFA的状态,那么就可以构建一个DFA。拥有$n$个状态的NFA的状态集合是$2^n$级别的,但是实践中可达的状态集合只有大约$n$个,因此这个方法是可行的。
语法分析的作用是判断某个字符串是否属于某一语言,它的输入是Token序列。
上下文无关语法拥有一系列下面形式的产生式:
symbol -> symbol ... symbol
左边是一个符号,右边可以是任意数目的符号。符号分为两类:
其中开始符S
是一个特殊的非终止符。同时为了方便解析,需要引入一个文件结束符号$
,以及新的开始符S'->S$
。
展开指从开始符展开为整个字符串。展开分为两类:
在展开的时候,每次替换非终止符时,都将原来的非终止符和展开的右式中的符号连接,形成的树为解析树。
如果一个语法可能将相同的字符串解析为两颗不同的解析树,那么这个语法是歧义的。一般有歧义的语法可以通过引入额外的非终止符来去除歧义,但是依旧存在一些语法无法消除歧义,这种语法一般不适合作为编程语言。
比如只有加法和乘法的语法定义如下:
S -> E$
E -> num
E -> E + E
E -> E * E
前者对于1+2*3
生成的解析树有两种可能:
E-------
| | |
1 + E----
| | |
2 * 3
和
E------
| | |
| * 3
|
E------
| | |
1 + 2
通过引入新的非终止符可以得到
S -> E$
M -> num
M -> M * M
E -> M
E -> E + E
它的解析树是唯一的,即
E-------
| | |
1 + M----
| | |
2 * 3
y
,定义FIRST(y)
表示所有可能从y
导出的字符串的第一个符号组成的集合。X
,nullable(X)
为真当且仅当X
能展开为空字符串X
,如果某个终止符t
,存在某种可能的展开式包含Xt
,那么t
属于FOLLOW(X)
。如果有多个满足上面条件的FIRST和FOLLOW集合,那么取值为最小的集合。
计算这些集合可以使用不动点算法。
For each terminal symbol Z
FIRST[Z] = {Z}
repeat
for each production X -> Y[1] Y[2] ... Y[k]
if Y[1] ... Y[k] are all nullable (or k = 0)
then nullable[X] = true
for each i from 1 to k, each j from i + 1 to k
if Y[1] ... Y[i - 1] are all nullable (or if i = 1)
then FIRST[X] = FIRST[X] + FIRST[Y[i]]
if Y[i + 1] ... Y[k] are all nullable (or if i = k)
then FOLLOW[Y[i]] = FOLLOW[Y[i]] + FOLLOW[X]
if Y[i + 1] ... Y[j - 1] are all nullable (or if i + 1 = j)
then FOLLOW[Y[i]] = FOLLOW[Y[i]] + FIRST[Y[j]]
until FIRST, FOLLOW, and nullable did not change in this iteration
对于一些简单的语法,如果我们可以通过后一个Token确定唯一的产生式,那么我们可以用递归下降的方式解析Token串。
递归下降能被使用的条件是对于所有的非终止符X
,以及给定的下一个输入符号t
,能唯一确定采用某个X
的产生式。即我们可以通过一个二维表来表达这样的转移关系,这个二维表称为预测解析表(predictive parsing table)。
构建预测解析表的算法:对于产生式X->y
和每一个FIRST(y)
中的元素T
,将产生式加入到预测解析表的第X
行第T
列。同样的,如果y
可空,那么对于每一个FOLLOW(y)
(这里应该是笔误,应该是FOLLOW(X)
)中的元素T
,将产生式加入到预测解析表的第X
行第T
列。
如果生成的预测解析表中没有重复表项,这样的语法称为LL(1)
(left-to-right parse, leftmost-derivation, 1-symbol lookahead)。如果我们通过k
个后继Token的信息构成一个k+1
维的预测解析表,且表中没有重复表项,这样的语法称为LL(k)
,LL(k-1)
语法一定也是LL(k)
语法。
语法如果出现左递归,则一定不是LL(1)
语法。
E -> Eb
E -> a
因为a
一定是FIRST(Eb)
的元素。可以发现E=ab*
,我们可以通过一些技巧来消除左递归:
E -> aD
D -> bD
D ->
如果语法中存在非终止符,存在两个产生式,拥有非空公共前缀,那么这样的语法一定不是LL(1)
语法。
S -> if E then S else S
S -> if E then S
我们可以使用提取公共部分的方法来消除问题
S -> if E then S X
X -> else S
X ->
一般如果遇到语法错误(输入串不存在于语言中),那么最简单的方式是抛出异常并结束编译。但是这样对用户不友好,因为每次只能报告一个编译错误。有两种可行的恢复编译的方式:
LL(k)解析技术的缺点是必须在只有k个输入Token的情况下就必须确定使用某个产生式。LR(k)是一种更加强大的解析技术,它在遇到整个产生式后,并额外读取k个后继Token后才确定使用某个产生式。LR(k)表示left-to-right parse, rightmost-derivation, k-token lookahead。
LR解析算法,维护一个栈和输入,其中输入的前k个Token可以被提前得知。基于栈中和输入的前k个Token的信息,解析器会执行下面两类操作:
X->ABC
,从栈中分别弹出C
,B
,A
,之后将X
入栈。初始时栈为空,输入为完整的源代码。如果对$
执行Shift操作,则解析器接受源代码并成功停止。
LR解析器如何知道该做什么操作,需要通过一个DFA来实现,并且由于DFA不具有解析上下文无关文法的能力,因此DFA解析的是栈上的数据,而不是输入中的数据。
DFA的边上是可能出现在栈中的符号(终止符和非终止符),通过一个二维表来表示。行为状态,列为input中下一个符号,单元格中保存的是具体执行的操作。操作有如下数类:
如果没有标识操作,则代表解析错误。
自动机是从栈底向栈顶解析的,因此如果遇到出栈的时候我们需要恢复自动机的状态,这可以通过记录每个栈中符号对应状态来实现。
在产生式中,我们向右边加入一个.
表示当前已经匹配的位置,比如S'->.S$
。这样形成新的表达式称为item。每个dfa的状态实际上都是一个item集合闭包,即这个集合中如果存在一个状态,.
在某个非终止符X
之前,那么X
的所有产生式对应的.
在最前的item也都属于这个集合。
需要定义两个关键操作Closure(I)
和Goto(I,X)
,前者表示生成I
的闭包,后者表示集合中所有item中,删除掉.
后不是X
的item,并将其余item的.
后移一位得到的新的集合的闭包。
Closure(I):
repeat
for any item A -> a.Xb in I
for any production X -> y
I = I + {X -> .y}
until I does not change
return I
Goto(I, X):
set J to the empty set
for any item A->a.Xb in I
add A -> aX.b to J
return Closure(J)
下面是构造LR(0)的DFA的算法,计算E集合的算法
T = {Closure({S'->.S$})}
E = {}
repeat
for each state I in T
for each item A->a.Xb in I
let J be Goto(I, X)
T = T + {J}
E = E + {(I, X, J)}
until E and T did not change in this iteration
计算R集合的算法
R = {}
for each state I in T
for each item A -> a. in I
R = R + {(I, A -> a)}
解析表如下:
S'->S.$
的状态I
,在(I,$)
放入accept操作。(I,Y)
中放入reduce n
操作,其中n是A->a
的编号。开始状态为Closure({S'->.S$})
。
和LL(k)类似,解析表不允许有重复表项,否则就不是一个合法的LR(0)语法。
SLR(simple LR)的表达能力比LR(0)更加强大。
SLR的解析表的构建于LR(0)基本没有区别,除了在构建R
的时候额外使用了FOLLOW集合。
R = {}
for each state I in T
for each item A->a. in I
for each token X in FOLLOW(A)
R = R + {(I, X, A->a)}
对于R中的任意元素(I, X, A->a),在(I,X)
中放入reduce n
操作,其中n是A->a
的编号。
SLR的解析表同样不允许有重复表项,否则不是合法的SLR语法。
LR(1)的item的定义较之LR(0)更加复杂,(A->a.b,x)
表示序列a处于栈顶,输入的前缀是bx
的展开式。同理LR(1)中的状态也是item的一个集合闭包。
Closure(I):
repeat
for any item (A->a.Xb, z) in I
for any production X->y
for any w in FIRST(bz)
I = I + {(X->.y, w)}
until I does not change
return I
Goto(I, X):
J = {}
for any item (A->a.Xb, z) in I
J = J + {(A->aX.b, z)}
return Closure(J)
开始状态为(S'->.S$,?)
,其中?
可以选择任意一个符号。
R = {}
for each state I in T
for each item (A->a., z) in I
R = R + {(I, z, A->a)}
构建E的算法于LR(0)是相同的。
LR(1)的解析表可能会非常庞大。如果我们通过将LR(1)中在不考虑lookahead符号的情况下相同的状态进行合并来减少解析表的大小,则可以得到LALR(1)解析技术(lookahead LR(1))。
LALR(1)相较于LR(1)的表达能力会有所下降,但是在实践上可以忽略不记,所有合理的编程语言都存在LALR(1)语法,然而在存储上LALR(1)会比LR(1)小很多。
一种简单的错误修复的方式是选择之前解析的15个Token,选择一个Token,尝试一次插入、删除、替换操作,并统计在执行了每个修复操作后最多能额外解析多少个新的Token,选择解析最多的那个方案。考虑总共有$N$种Token,则只需要尝试$15(2N+1)$种可能性而已。
语法分析环节只是判断字符串是否是语言的一部分,但是要理解代码,我们需要将字符串转化为更加强大结构,AST。
简单来说,要为每种符号创建一个类型,并且如果是非终止符则是抽象类,且还需要为每个相关的产生式创建一个子类。
并且我们需要遍历整个AST来获得有用信息,一般使用访问者模式。
语义指的是语言的含义。语义分析阶段负责连接变量的使用和定义,检查每个表达式拥有合适的类型,并将抽象语法转化为更适合生成机器码的表达。
我们用一张符号表来记录某个命名空间中所有的符号以及它们的类型。在进入新的作用域的时需要创建新的符号表,并用新作用域中的符号替代原来的符号,这里可以使用栈加哈希表来实现,用栈记录被替换的符号信息,在离开作用域的时候恢复。
有时候我们会在一个作用域中引入另外一个作用域中的符号,这意味着我们需要同时维护多张符号表,这时候我们需要使用持久化平衡树来记录符号。
在构建完成符号表后,我们需要对表达式执行类型检查,确保表达式中的操作元可以被操作。类型检查实际上只需要使用访问者模式遍历AST,并返回表达式的类型信息即可。
类似AST,IR是一种树形结构。一个好的IR需要同时满足下面要求:
AST的单独片段可以代表非常复杂的含义,比如数组下标,函数调用等。而IR树单独片段拥有更加简单的含义,每个IR代码都类似于若干个机器码的复合,支持诸如读写、基础运算,移动,跳转等基础的指令。
下面给出一个非常简单的指令集用作IR
表达式(expression)表示的是计算一些值,没有副作用
语句(statement)用于控制程序的执行,可能带副作用
在绝大部分情况下,E的子结点也是E,S的父结点一定是S,但是存在一种例外情况,这也是我们定义ESEQ的目的。
而如何从AST转化为IR呢,这里需要引入Syntax-directed翻译,即递归地对源语言进行遍历并翻译,而对于一个给定的源语言结构,翻译结果是唯一的。下面定义翻译函数:
对于标量(整数或布尔值)
对于运算
对于局部遍历或者函数参数
对于函数调用
语句的翻译
函数的翻译,设函数的格式为f(x1: t1, …, xn: tn) : t',函数体s翻译结果为
逻辑运算与跳转,比如a&b
,由于存在短路(如果a为真,则b不会被计算),因此必须特殊进行翻译
上面的翻译很冗杂,我们可以同过引入一种新的IR形式C(e,t,f),它表示如果e的结果为真,则跳转到t,否则跳转到f。这样我们可以递归的引入新的翻译
对于数组类型的,我们需要额外存储数组的长度属性(存在数组起始地址的前w个字节处),以保证可以执行越界检查。不带越界检查的翻译
加上越界检查后(这里ULT表示无符号小于操作,选择无符号的目的是为了避免处理负数)
数组元素赋值操作也是类似的
IR虽然提供了一套统一的中间表示,但是IR依旧太过复杂,不容易转化为机器代码。可以通过展开表达式和语句,获得一个更低层级的IR树(称为Canonical IR语法),它满足
从IR翻译为IR Lowering也是一种Syntax-directed翻译。
由于SEQ结点只出现为根结点,因此Canonical IR树实际上就是若干条语句的复合:s1;s2;...;sn
。
为了将IR翻译为Canonical IR,需要引入两个不同的翻译函数${\cal L}[![s]!]$和${\cal L}[![e]!]$,前者将语句翻译为多条语句,后者将表达式翻译为若干条语句和一条无副作用的表达式。记$\vec{s}=s_1; \dots; s_n$,那么${\cal L}[![e]!]=\vec{s};e'$。
先给出表达式的推导规则:
类型 | 前提 | 结果 |
---|---|---|
无副作用的语句 | $e = \textit{CONST}(i) ∨ e = \textit{NAME}(l) ∨ e = \textit{TEMP}(t)$ | ${\mathcal L}[![e]!] = •; e$ |
${\mathcal L}[![e]!] = \vec{s}; e'$ | ${\mathcal L}[![\textit{MEM}(e)]!] = \vec{s}; \textit{MEM}(e')\$ ${\cal L}[![\textit{JUMP}(e)]!] = \vec{s}; \textit{JUMP}(e')\$ ${\mathcal L}[![\textit{CJUMP}(e,l_1,l_2)]!] = \vec{s}; \textit{CJUMP}(e',l_1,l_2)\$ ${\mathcal L}[![\textit{ESEQ}(s,e)]!] = \vec{s}; \vec{s'}; e'\$ | |
函数调用 | ${\mathcal L}[![e_i]!] = \vec{s_i}; e_i'~~^{∀i∈0‥n}$ | ${\mathcal L}[![\textit{CALL}(e_0, e_1, \dots, e_n)]!] = \vec{s_0};\textit{MOVE}(\textit{TEMP}(t_0), e_0'); \vec{s_1}; \textit{MOVE}(\textit{TEMP}(t_1), e_1'); \dots \vec{s_n}; \textit{MOVE}(\textit{TEMP}(t_n), e_n'); \textit{MOVE}(\textit{TEMP}(t), \textit{CALL}(t_0, t_1, \dots, t_n)); \textit{TEMP}(t)$ |
二元运算 | ${\cal L}[![e_1]!] = \vec{s_1}; e_1'\$ ${\cal L}[![e_2]!] = \vec{s_2}; e_2'$ | ${\mathcal L}[![\textit{OP}(e_1, e_2)]!] = \vec{s_1}; \vec{s_2}; \textit{OP}(e_1', e_2')\$ ${\mathcal L}[![\textit{OP}(e_1, e_2)]!] =\vec{s_1};\textit{MOVE}(\textit{TEMP}(t_1), e_1');\vec{s_2}; \textit{OP}(\textit{TEMP}(t_1), e_2')$ |
这里有对于二元运算有两种不同的翻译,第一种更加简单且容易被编译器优化,但是要求$e_1$与$e_2$可以交换执行,比如$\vec{s}_2$修改了$e_1'$中的涉及项,而后者则不会有依赖问题。
再给出语句的翻译规则
类型 | 前提 | 结果 |
---|---|---|
SEQ | ${\mathcal L}[![\textit{SEQ}(s_1, \dots, s_n)]!] = {\mathcal L}[![s_1]!]; \dots; {\mathcal L}[![s_n]!]$ | |
EXP | ${\mathcal L}[![e]!] = \vec{s}; e'$ | ${\mathcal L}[![\textit{EXP}(e)]!] = \vec{s}\$ ${\mathcal L}[![\textit{JUMP}(e)]!] = \vec{s}; \textit{JUMP}(e')\$ ${\mathcal L}[![\textit{CJUMP}(e,l_1,l_2)]!] = \vec{s}; \textit{CJUMP}(e', l_1, l_2)\$ |
LABEL | ${\mathcal L}[![\textit{LABEL}(l)]!] = \textit{LABEL}(l)$ | |
寄存器赋值 | ${\mathcal L}[![e]!] = \vec{s'}; e'$ | ${\mathcal L}[![\textit{MOVE}(\textit{TEMP}(x), e)]!] = \vec{s'}; \textit{MOVE}(\textit{TEMP}(x), e')$ |
内存赋值 | ${\mathcal L}[![e_1]!] = \vec{s_1'}; e_1'\$ ${\mathcal L}[![e_2]!] = \vec{s_2'}; e_2'\$ | ${\mathcal L}[![\textit{MOVE}(\textit{MEM}(e_1), e_2)]!] = \vec{s_1'}; \vec{s_2'}; \textit{MOVE}(\textit{MEM}(e_1'), e')\$ ${\mathcal L}[![\textit{MOVE}(\textit{MEM}(e_1), e_2)]!] = \vec{s_1'}; \textit{MOVE}(\textit{TEMP}(t), e_1');\vec{s_2'}; \textit{MOVE}(\textit{MEM}(\textit{TEMP}(t)), e_2');\$ |
内存赋值也存在两种翻译,第一种要求$e_1$与$e_2$可以交换执行,而后者则不需要。
IR Lowering后的IR树依旧存在一个问题,CJUMP指令可以选择跳转两个地址,一般情况上是不存在匹配的机器码的。要解决这个问题,我们需要强制CJUMP(e,t,f)指令中f标签紧跟在CJUMP指令之后。
将语句分成三类:
一个基础块是若干条语句组成的序列,如果基础块的任意部分被执行,那么整个块都必须被执行。因此可以认为基础块是最小执行单元,对基础块的切分是没有意义的。
给定整个代码序列$s_1;\ldots; s_n$,一个基础块是一个子序列 $s_i;\ldots;s_j$,其中除了首尾语句外,中间语句都是普通语句,且$s_i$只能是普通语句或标签,而$s_j$只能是普通语句或跳转语句。当然如果基础块只包含一条语句,这条语句可以是任何类型的。
将基础块作为图中的结点,跳转作为边,构建一副有向图,可以发现每个结点的出度最多为2。形成的图称为control-flow graph(cfg)。
这里需要特别注意的是如果一个基础块不是以跳转结束的,这说明下一块是以标签开头的,那么应该在这个基础块的尾部插入一条跳转语句,跳转到下一块。
要重组基础块为一个整体,通常的技术是通过构建trace。trace指的是若干个基础块组成的序列,且前一块存在一条到后一块的边。一个好的重组应该创建大的trace。为了指令缓存性能,应该尽量将频繁执行的代码包含进来。
简单的贪心算法是选择任意一个没有被选过的块,之后找到任意一条只包含未选块的路径,将这个路径作为新的trace选择。
这里有两个优化点:
所谓的热点块可以是出现在循环中的代码。
构建了trace序列后,我们可以通过任意排列trace得到完整代码。接下来我们需要修复跳转问题。修复方式如下:
目前我们已经得到了低级的IR代码,指令选择阶段会将其翻译未抽象汇编代码,与实际汇编代码的区别在于可以支持无数个寄存器和任意复杂的表达式。
由于存在大量不同的汇编指令可以使用来完成我们的目标,因此这个阶段我们需要挑选合适的汇编指令替代IR指令。
ISA全称为instruction set architechture,即指令集合架构。
目前主流的ISA是x86-64,拥有16个64位寄存器和64位地址。x86-64是2-address CICS架构,即每次运算时传入两个地址,其中一个地址即是运算元也是结果放置的地址。
在intel
语法中,一般的二元运算是op dest, src
,而在AT&A
语法中,一般的二元运算是op src, dest
。为了简单,这里我们采用intel语法。
操作 | 样例 | 解释 |
---|---|---|
mov | mov dest, src | 从src拷贝到dest |
add,sub,mul,div | 算术运算 | |
inc,dec | 自增和自减 | |
and,or,xor,not | 二进制逻辑运算 | |
shl,shr,sar | 二进制移位运算 | |
jmp | 无条件跳转 | |
jz/je,jnz/jne | 根据参数是否为0条件跳转 | |
jl,jle,jg,jge | 根据比较结果条件跳转 | |
jb,jbe,ja,jae | 根据无符号参数比较结果条件跳转 | |
push,pop | 栈操作 | |
test,cmp | 执行ALU操作 | |
call | 调用子过程 | |
ret | 从子过程返回 |
x86-64的寄存器包括: rax,rbx,rcx,rdx,rsi,rdi,rsp,rbp,r9-r15。
运算元
Inter | AT&T | IR |
---|---|---|
17 | $17 | CONST(17) |
rax | %rax | TEMP(rax) |
[rax] | (%rax) | MEM(TEMP(rax)) |
[rbx+32] | 32(%rbx) | MEM(ADD(TEMP(rax), CONST(32))) |
[rax+rbx*8] | (%rax,%rbx,8) | MEM(ADD(TEMP(rax),MUL(CONST(4),TEMP(rbx)))) |
在Intel中,运算元的大小会通过寄存器自动推断出来。但是如果运算使用的是内存,则会出现问题,比如inc [rax]
,这时候需要说明具体的字节数,比如inc qword ptr [rax]
,其余可用的长度有byte
,word
,dword
。
同时运算最多允许一个运算元是地址。
用jcc代表所有条件跳转的指令,这些指令会基于一个特殊的寄存器——条件码寄存器(condition code register)中的内容判断是否跳转。而一般ALU中的运算会导致条件码的改变。
其中一些非常好用的用于改变条件码的指令:
有时候我们需要读出条件码寄存器中的内容,比如我们要根据复杂的逻辑运算得出跳转条件。这时候可以使用setcc中的指令。比如setz al
表示如果jumpz
条件满足,则将al
设置为1,否则为0。
ISA中的指令有时候不足以描述我们定义的IR中的一个指令,比如ISA中的指令不允许两个操作元都是内存,以及我们使用的是2-地址ISA,而IR中允许指定二元运算的结果地址。
我们需要定义一些tiles来匹配IR代码。每个tile都对应若干条ISA中的汇编代码,且其功能正好对应某些IR代码。
比如一个IR中的Add(t1,t2)
操作对应的tile可以是:
mov t3, t1
add t3, t2
但是并不是说一个tile只对应一个IR结点,实际上MEM(ADD(TEMP(a), MUL(CONST(8), TEMP(i))))
可以被映射为一个很简单的tile:mov dest, [a+8*i]
我们要设计的tile需要是完备的,即我们的tile能覆盖所有可能生成的IR树。同时tile要尽可能高效。
要从IR翻译为tile也是一种翻译函数,但是这个函数并不是syntax-directed。因为同样的IR结点可以有多种翻译。
由于存在多种可能的IR树的tile翻译,因此要选择其中最高效的一种。有两种算法。
一种是贪心算法,非常简单,我们将tile按优先级排序,每次处理IR树结点的时候,优先选择第一个匹配的tile。
还有一种方式是动态规划。首先预估每个tile的执行时间,之后记录每个子树的最优解,这样问题就变成动态规划问题。
动态规划的效率一般很高,但是实际上我们无法精确计算一个tile的执行时间,因为这与到指令的顺序相关。但是大致的估计一般就足够了。
在汇编层面,函数调用等价于过程调用,其原理是将下一条指令地址(rip)入栈保存,之后跳转到函数的开始地址。rsp指向栈顶,栈是向下增长的。
sub rsp, 8 #预留8 byte
mov [rsp], rip
jmp f
栈帧是函数调用在栈上预留的一段空间,用于保存传入的参数、临时变量等。
传入的前6个参数通过寄存器rdi,rsi,rdx,rcx,r8,r9传入。对于后续的参数,以逆序的方式存在栈底传入。
一个实现简单的a+b
操作的函数,它的汇编形式可能是
f: push rbp
mov rbp, rsp
sub rsp, 8*l
mov x, rdi
mov y, rsi
mov rax, x
add rax, y
mov rsp, rbp
pop rbp
ret
这里可以使用ISA提供的enter和leave操作来简化,前者会完成开始的3个指令,后者会完成倒数第3个和第2个指令。
f: enter 8*l, 0
mov x, rdi
mov y, rsi
mov rax, x
add rax, y
leave
ret
需要注意的是rsp
必须按照16byte对齐,即rsp
中的值应该能整除16,这是大量系统库的要求,否则调用其它过程会有问题。
由于过程可以继续调用其它过程,这意味着如果某个寄存器同时被二者使用,则需要一方负责恢复寄存器的状态。
寄存器分为caller-save和callee-save,前者由调用方负责备份,后者由被调用方负责备份。
caller-save的寄存器包括:rax,rcx,rdx,rsi,r8-r11。而其余寄存器则都是callee-save。
rbp一般用于备份rsp,即其存储的值是栈帧的开始地址。
但是如果我们可以在编译期计算过程需要的栈大小,那么我们就可以使用rsp减去栈大小得到栈帧开始地址。这样我们就能释放出rbp用于其它计算。
但是采用静态栈帧大小的缺点是我们不能在栈上分配动态长度的数组。
对于抽象汇编代码,如果我们可以选择一块足够大的内存来分配给每个抽象寄存器一个唯一地址,那么我们就完成了寄存器分配。
而由于指令最多会使用三个寄存器,因此只需要三个寄存器就可以完成所有的操作。
事实上gcc -O0
就使用了这种方式。
还有一种相对更好的方法-线性扫描寄存器分配。这种方法不仅性能远高于上面提到的方式,而且分配的性能很好。其原理就是在某个抽象寄存器第一次被使用的时候,分配一个实际的寄存器,在抽象寄存器被最后一次使用后,回收这个寄存器。
当然有可能多个抽象寄存器分配到了相同的寄存器,这时候就需要备份寄存器中的值来重复利用了。
优化的目的是提高程序的执行效率,但是不能改变程序的结果。
大量的代码可以通过空间和时间互换的技术来优化,比如循环展开中拷贝多份循环来增大代码空间,减少了循环带来的开销。
要执行代码分析,一般需要多种分析:
优化发生的阶段
阶段 | 结构 | 优化 |
---|---|---|
HIR | AST,IR | Inlining,Specialization,Constant folding, Constant progagation, Value numbering |
MIR | Canonical IR | Dead code elimination,Loop-invariant code motion,Common sub-expression elimination,Strength reduction,Constant folding&propagation(again),Branch prediction/optimization |
LIR | Abstract Assembly,Assembly | Register allocation,Loop unrolling,Cache optimization,Peephole optimizations |
下面简单介绍各种优化技术
技术 | 简介 | 例子 |
---|---|---|
Register allocation | 将抽象寄存器映射到6个真实寄存器上,如果mov发生的抽象寄存器被映射到相同寄存器,则操作可以被消除 | |
Constant folding | 如果操作元可以在编译期计算,则在编译期计算;如果分支条件的结果在编译期可知,则可以去除跳转命令 | 2+3+a=>5+a |
Algebraic simplification | 在代数层面上做Constant folding | 1*a=>a |
Unreachable code elimination | 在对起始基础块做搜索的时候,所有不可达的基础块都可以被删除(减少代码可以提高缓存命中) | return; … => return; |
Inlining | 用函数体替代函数调用 | |
Specialization | 创建特例化函数,用于接受特定类型的参数(比如参数是多态的基类时,可以提前确定一些信息) | |
Constant propagation | 如果变量始终是常量值,则可以将变量的使用替代为常量 | x=1;y=x*2;=>y=2; |
Dead code elimination | 如果语句的副作用永远不可能被观测到,则可以移除语句;变量定义后不再使用可以移除变量 | x=1;x=2;=>x=2 |
Redundancy Elimination | 提取公共的表达式 | |
Loop-invariant code motion | 提取不变的表达式 | |
Strength reduction | 用廉价操作+-替代昂贵操作*/ | |
Loop unrolling | 循环展开,减少分支跳转 |
要合理的分配寄存器,就需要能够得知每条语句执行后,哪些变量依旧存活(已经定义过的变量,在改变之前可能会被读取)。
存活变量组成的集合为live set。live variable analysis负责计算每个程序点(语句之间以及基础块之间的边上)的live set。
变量是否存活又时候是无法确定的,比如
x:int = y; //is x live here
f();
return x;
由于f可能是一个永远不会退出的函数,因此x
在定义后是否存活是无法得知的。在做存活性分析的时候应该做保守估计,即如果一个变量是否存活无从得知,应该始终认为是存活的。
数据流分析是一种计算信息的技术,比如计算存活变量。对于每条控制流中的边,都要计算信息。
CFG node | IR |
---|---|
$x\leftarrow e$ | MOVE(x,e) |
$[e_1] \leftarrow e_2$ | MOVE(MEM($e_1$),$e_2$) |
if e | CJUMP(e,t,f) |
start | LABEEL(f) |
return e | RETURN(e) |
CFG语法中变量对应IR中的寄存器,同样存在表达式,其中可以出现常量,变量,内存和运算符:
\[e::=k|x|e_1 \mathrm{OP} e_2|[e_1]\]大部分的数据流分析都是通过分析IR代码实现的,但是存活性分析是通过分析抽象汇编代码完成的。
记use[n]
表示在CFG的结点n处读取过的变量集合。def[n]
表示在结点n处写入过的变量集合。对于表达式e,记vars[e]
表示在表达式中出现过的所有变量组成的集合。
结点n | use[n] | def[n] |
---|---|---|
$x\leftarrow e$ | vars[e] | {x} |
$[e_1]\leftarrow e_2$ | $\mathrm{vars}[e_1]\cup \mathrm{vars}[e_2]$ | $\emptyset$ |
if e | $\mathrm{vars}[e]$ | $\emptyset$ |
start | $\emptyset$ | $\emptyset$ |
return e | $\mathrm{vars}[e]$ | $\emptyset$ |
一个变量x在边E上是存活的,当且仅当存在从E出发可达的结点N,use[N]
中包含x且中间没有结点修改过这个变量。
我们可以对每个变量的写入位置进行记忆化搜索,就可以算出变量在每条边上是否存活。
还有一种比较正式的方式,令in[n]
表示结点n
的所有入边的live set的并集,同理令out[n]
表示结点n
的所有出边的live set的并集。这样可以推出下面公式:
可以用不动点算法迭代计算上面公式。两者拥有相同的时间复杂度,都是O(VN)
,V是变量数,N是语句数(CFG结点数)
对于有向边$(a,b)$,它的存活集合是$out(a)\cap in(b)$。
借助CFG,我们可以以与存活性分析类似的方式实现可用副本分析。如果一个变量始终是另外一个变量的副本,那么可以移除副本的拷贝,并替换前者为后者。
定义类似,数据流中计算的值是类似于{x=y,t=z}
的集合。记gen[n]
表示结点引入的新的元素(等式),而kill[n]
表示结点移除的等式。
n | gen(n) | kill(n) |
---|---|---|
x=y | {x=y} | x=z,z=x for all z |
[$e_1$]=$e_2$ | $\emptyset$ | $\emptyset$ |
if e | $\emptyset$ | $\emptyset$ |
START | $\emptyset$ | {all nodes} |
EXIT | $\emptyset$ | $\emptyset$ |
寄存器分配阶段的任务是为每个变量分配一个真实的寄存器,并且希望能在其生命周期中始终占据这个寄存器(不需要备份到内存中)。一个寄存器可以分配给多个变量,但是这些变量不能同时存活。
存活性分析为我们提供了out[n]
,即离开结点n
后依旧存活的变量集合。任意两个处于相同存活集合中的变量不能分配到相同的寄存器上,这样的变量称为相互干扰的。
推断图(inference graph)中所有结点都对应某一个变量,如果两个变量相互干扰,则在它们之间连一条无向边。寄存器分配问题就变成了对推断图的图着色问题。
Kempe's算法是一个启发性的算法。如果我们希望对图进行k着色,则每次迭代选择一个度小于k的顶点从原图中删除。
如果迭代结束时所有顶点都被移除了,则图可以被k染色,染色的方式非常简单,我们只要将顶点按照它们删除顺序逆向贪心染色即可,每次选择任意一个可用的颜色。
但是这个算法的问题在于,即使图可以被k染色,kempe's算法不一定能找到一个正确的删除顺序。
当Kempe's算法未能成功删除所有顶点,则任意选择一个顶点标记为spilling(存储在栈上),并从图中移除。这里可以启发性的选择使用不频繁的变量(不是在循环中的)。
在染色的时候即使标记为spilling的结点最终也是有机会被染色的,我们可以优先尝试进行染色,如果不行则标记为spill并分配一个栈位置。
对于溢出的变量,在要使用的时候,我们需要生成额外的指令来从栈上加载或写入。
一种简单的方式是始终保留三个寄存器专用于溢出变量的加载和使用。
但是上面的方式会使我们损失三个寄存器,考虑到总共可用的寄存器就不多,因此并不合理。一种更好的方式是对于溢出变量的读写,都引入一个新的临时变量来辅助。
之后对于得到的新的代码,重新做存活性分析和寄存器分配。新引入的临时变量的生命周期都非常短暂,这意味着染色会变得更简单。我们不断重复直到所有顶点都能被正确染色。实践上一般只需要一到两次迭代即可。
有些寄存器有特殊用处
use(n)=rax
,且def(n)={rax,rdx}
。def(n)={rax,rcx,rdx,...}
要合理分配寄存器,我们要把这些特殊寄存器的使用视作是特殊的临时变量,作为预染色结点加入到推断图中。预染色结点n
与所有def(n)
中出现的变量都相互干扰。
预染色结点的出现,要求我们修改之前提到的染色算法。
a<-e
不能保存在某个寄存器t中,则认为变量a与寄存器t相互干扰对于caller-save寄存器,与所有生命周期跨越call指令的变量都相互干扰。
对于生命周期没有跨过call指令的变量,则应该分配caller-save寄存器。
对于callee-save寄存器,使用之前必须保存。我们可以在函数开始前将这些寄存器中的内容拷贝到临时变量中,在函数退出前在拷贝回来。如果后来发现这些寄存器确实会被用到,则溢出到栈上;如果并不需要,则给临时变量分配与callee-save相同的寄存器,通过mov消除的方式去除额外的保存和恢复操作。
对于生命周期跨国call指令的变量,应该分配callee-save寄存器。
对于mov a, b
操作,如果两个变量没有相互干扰,则可以合并两个变量为一个变量,这样就可以消除mov操作。
在合并变量(结点)的时候,可能会导致原本能k染色的图无法被k染色。这是由于创造了一些度数很大的顶点。而解决方案采用保守的态度,在合并的时候不允许合并后的结点度数超过k-1
。
保守态度可能会使我们失去合并不同变量的机会,但是却可以避免引入溢出。
整体流程分为:
Caused by: org.apache.spark.api.python.PythonException: System.IO.FileLoadException: Mixed mode assembly is built against version 'v2.0.50727' of the runtime and cannot be loaded in the 4.0 runtime without additional configuration information.
at [myjobName].<>c__DisplayClass0_0.<Run>b__0(String market, String offerBonds)
at Microsoft.Spark.Sql.PicklingUdfWrapper`3.Execute(Int32 splitIndex, Object[] input, Int32[] argOffsets)
at Microsoft.Spark.Worker.Command.PicklingSqlCommandExecutor.ExecuteCore(Stream inputStream, Stream outputStream, SqlCommand[] commands)
at Microsoft.Spark.Worker.TaskRunner.ProcessStream(Stream inputStream, Stream outputStream, Version version, Boolean& readComplete)
at org.apache.spark.api.python.BasePythonRunner$ReaderIterator.handlePythonException(PythonRunner.scala:503)
at org.apache.spark.sql.execution.python.PythonUDFRunner$$anon$2.read(PythonUDFRunner.scala:81)
at org.apache.spark.sql.execution.python.PythonUDFRunner$$anon$2.read(PythonUDFRunner.scala:64)
at org.apache.spark.api.python.BasePythonRunner$ReaderIterator.hasNext(PythonRunner.scala:456)
at org.apache.spark.InterruptibleIterator.hasNext(InterruptibleIterator.scala:37)
at scala.collection.Iterator$$anon$11.hasNext(Iterator.scala:489)
at scala.collection.Iterator$$anon$10.hasNext(Iterator.scala:458)
at scala.collection.Iterator$$anon$10.hasNext(Iterator.scala:458)
at org.apache.spark.sql.catalyst.expressions.GeneratedClass$GeneratedIteratorForCodegenStage2.processNext(Unknown Source)
at org.apache.spark.sql.execution.BufferedRowIterator.hasNext(BufferedRowIterator.java:43)
at org.apache.spark.sql.execution.WholeStageCodegenExec$$anon$1.hasNext(WholeStageCodegenExec.scala:729)
at scala.collection.Iterator$$anon$11.hasNext(Iterator.scala:489)
at scala.collection.Iterator$ConcatIterator.hasNext(Iterator.scala:222)
at scala.collection.Iterator$$anon$10.hasNext(Iterator.scala:458)
at org.apache.spark.sql.execution.datasources.v2.DataWritingSparkTask$.$anonfun$run$7(WriteToDataSourceV2Exec.scala:438)
at org.apache.spark.util.Utils$.tryWithSafeFinallyAndFailureCallbacks(Utils.scala:1411)
at org.apache.spark.sql.execution.datasources.v2.DataWritingSparkTask$.run(WriteToDataSourceV2Exec.scala:477)
at org.apache.spark.sql.execution.datasources.v2.V2TableWriteExec.$anonfun$writeWithV2$2(WriteToDataSourceV2Exec.scala:385)
at org.apache.spark.scheduler.ResultTask.runTask(ResultTask.scala:90)
at org.apache.spark.scheduler.Task.run(Task.scala:127)
at org.apache.spark.executor.Executor$TaskRunner.$anonfun$run$3(Executor.scala:446)
at org.apache.spark.util.Utils$.tryWithSafeFinally(Utils.scala:1377)
at org.apache.spark.executor.Executor$TaskRunner.run(Executor.scala:449)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
上面的[myjobName]是我们内部的包名。
网上的说法是需要向App.config文件增加额外的配置项:
<startup useLegacyV2RuntimeActivationPolicy="true">
<supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.6.1" />
</startup>
但是我这边的项目内部本来就有这段代码,所以这个方法并不是解决方案。
之后我通过二分法找到了错误的来源,一个C++/CLR dll,目标dotnet framework 3.5。
现在回到上面的方法,useLegacyV2RuntimeActivationPolicy="true"
表示的是用V2 dotnet运行环境来运行目标dotnet framework低于4.0的C++/CLR dll。
因此这个方法应该是可以生效的,但是为啥就失败了呢。注意到这个dll是在一个UDF调用的,我尝试在main函数中调用这个dll,发现main函数过程中不会抛出异常。
现在问题确定了,只有UDF中,App.config文件会失效。实际上当我们要执行某个dotnet编译的exe文件X.exe
时,它也会去加载对应的config文件,这个文件的名称是X.exe.config
。但是很显然调用UDF的入口并不是我们的main入口,因为UDF会被分发到不同的机器上执行,当然不可能重新执行一次完整的spark job,所以光修改我们main入口的config文件是不够的。
查询了一些关于spark和dotnet spark的资料后发现,存在一个名叫dotnet spark worker的项目(位置由环境变量DOTNET_WORKER_DIR
确定),它会负责驱动我们的UDF,因此我们需要修改Microsoft.Spark.Worker.exe.config
,并在其中加入上面提到的useLegacyV2RuntimeActivationPolicy="true"
。
记$n$表示线性递推关系的长度,即每一项都是前$n$项的线性组合。现在要求线性递推数列的第$k$项。
设递推数列为$a_i=\sum_{j=1}^n a_{i-j}c_j$。其特征多项式为$x^n-\sum_{j=1}^n c_jx_{n-j}$。
令$Q(x)=1-\sum_{j=1}^nc_jx^j$,$F(x)$表示线性递推数列的生成函数。则$P(x)=Q(x)F(x)$的度数小于$n$。
由于$P(x)=P(x)\mod x^n$,因此$P(x)=(Q(x)\mod x^n)(F(x)\mod x^n)$。
同理$F(x)=\frac{P(x)}{Q(x)}$。我们实际上要求的则是$[x^k]\frac{P(x)}{Q(x)}$。
\[\begin{aligned} [x^k]\frac{P(x)}{Q(x)}&=[x^k]\frac{P(x)}{Q(x)}\\ &=[x^k]\frac{P(x)Q(-x)}{Q(x)Q(-x)}\\ &=[x^k]\frac{A(x^2)+xB(x^2)}{C(x^2)} \end{aligned}\]如果$k$是奇数,则有:
\[[x^k]\frac{P(x)}{Q(x)}=[x^k]\frac{xB(x^2)}{C(x^2)}=[x^{\frac{k-1}{2}}]\frac{B(x)}{C(x)}\]否则$k$是偶数,则有
\[[x^k]\frac{P(x)}{Q(x)}=[x^k]\frac{A(x^2)}{C(x^2)}=[x^{\frac{k}{2}}]\frac{A(x)}{C(x)}\]上面的过程给出了一个$O(M(n)\log_2k+M(n))$时间复杂度的算法,并且只需要执行$2\log_2k$次的多项式卷积操作。
本文只是对《Rust 程序设计语言》的读书笔记。
数据可能分配在栈上和堆上。一个分配在栈上的数据可以通过默认的浅拷贝完整克隆,也更为廉价。而需要共享的数据更适合分配在堆上,这样只需要在栈上保留一个指向堆位置的指针即可,这样可以避免大量的拷贝操作。如果想要同时拷贝堆上的数据,可以用clone
方法。
栈上的数据在退栈的时候会被自动释放,但是堆上不同。堆上的数据由于可能被多个指针引用,不能轻易决定释放时机。
Rust中引入所有权的概念,一个数据的所有权被正好一个变量获得,当这个变量离开作用域的时候,它的数据也会被销毁。
let s1 = String::from("hello");
let s2 = s1; //s2接管s1的所有权,s1失去了所有权,不能再被使用
在栈上直接分配的数据实现了名为Copy
的traits
,在赋值给另外一个变量的时候并不会失去所有权,而是将数据完整拷贝给了另外一个变量。
let x = 5;
let y = x; //x并不会失去所有权
以下类型的数据实现了Copy
的traits
:
Copy
的traits
数据的元组fn take_ownership(x: String){ //
println!("{}", x);
}
fn main(){
let s = String::from("hello");
take_ownership(s); //这里s失去所有权
}
由于将数据作为函数参数传递也会使得变量失去所有权,因此我们必须将所有权从函数中传回。
fn take_ownership(x: String) -> String{ //
println!("{}", x);
x
}
fn main(){
let s = String::from("hello");
let s = take_ownership(s);
}
这样做很麻烦,尤其在参数比较多的情况下。我们可以用引用来优化这一过程。
fn borrow(x: & String){
println!("{}", x);
}
fn main(){
let s = String::from("hello");
borrow(& s);// 这里s不会失去所有权
}
引用也分为mut
和immutable
两种。前者会对数据加写锁,后者会对数据加读锁(这里并不真的在运行期加锁,实际上都是编译期的工作,这里只是为了方便理解)。因此一个数据最多有一个mut
类型的应用,或者多个immutable
类型的引用。之所有这么设计是为了防止race condition。
fn main(){
let mut s = String::from("hello"); //s得到所有权
let mut_ref_s = &mut s; //获得一个mut引用
mut_ref_s.push_str(" world"); //修改mut引用,由于这里是mut_ref_s的最后一次被使用,因此它的生命周期在此结束
let immutable_ref_s = &s; //创建一个immutable引用
println!("{}", immutable_ref_s);
}
如果所有权变量被销毁,那么所有存活的引用(这种引用称为悬置引用,dangling reference)都是不可用的,这时候再使用这些引用对象会引起编译错误。
fn wrong() -> &String {
let s = String::from("hello");
&s //在这语句后s会销毁,因此返回的引用也将非法
}
fn correct() -> String {
let s = String::from("hello");
s //返回所有权可以避免数据被销毁
}
引用的本质是指针。类似C++,我们也可以用*ref
来获取引用具体指向的值,但是rust编译器很多时候可以很聪明的推断出我们实际上使用的是引用指向的元素,因此这一步可以缺省。
fn main(){
let x = 5;
let y = &x;
assert_eq!(5, x);
assert_eq!(5, *y);
assert_eq!(&5, y);
}
Slice用来表示某个数据结构的连续的一部分,它不具有所有权,但是会作为原数据结构的immutable引用存在。
fn first_word(s: &String) -> &str{
for (i, &c) in s.as_bytes().iter().enumerate(){
if(c == b' '){
return &s[0..i]
}
}
&s[..]
}
fn main(){
let s = String::from("hello world");
let fw = first_word(& s);
s.clear(); // 调用s.clear()必须先获得s的一个mut引用,而fw是s的一个imutable引用,这里会报错
println!("{}", fw);
}
一些slice类型:
&[i32]
str
rust也支持用结构体来组织数据。
struct Rect{
width: u32,
height: u32
}
结构体的初始化非常简单:
let rect = Rect{
width: 100,
height: 200
};
如果我们有同名变量,可以省略初始化时候使用的字段名称
let width = 10;
let height = 200;
let rect = Rect{
width,
height
};
如果你希望从另外一个变量中拷贝大部分字段,但是覆盖其中少部分字段,rust通用提供了语法糖。注意这仅仅只是个语法糖,实际上本质上还是会把需要的属性逐一进行拷贝,这可能会导致所有权的变动。
let rect2 = Rect{
width: 200, //覆盖rect中的字段width
..rect //表示从rect复制字段,必须放在最后
};
有时候我们并不需要一个为每个字段提供一个名字,我们需要为tuple声明一个类型。注意不同的tuple struct类型,即使拥有相同的声明,它们的实例也是不能相互转换的。
struct Point (i32, i32);
fn main() {
let pt = Point(0, 0); //初始化
println!("{}", pt.0); //类似于tuple通过.下标来获取元素
}
一个struct允许没有任何字段,这样的struct称为unit-like struct。
struct AlwaysEqual;
let subject = AlwaysEqual;
我们可以为struct实现特有的函数,这类函数称为关联函数。关联函数的名称可以于struct的某个field相同。
struct Rectangle {
width: u32,
height: u32,
}
impl Rectangle {
fn area(&self) -> u32 { //不可变self引用
self.width * self.height
}
fn init(&mut self){ //可变self引用
self.width = 0;
self.height = 0;
}
}
impl Rectangle { //对于同一个struct,可以有多个impl块
fn give_back(self) -> Rectangle { //获得所有权
self
}
fn square(size: u32) -> Rectangle { //关联函数也可以没有self参数
Rectangle{
width:size,
height:size
}
}
}
fn main() {
let mut rect = Rectangle {
width: 30,
height: 50,
};
let area = rect.area();
rect.init(); //这个调用和下一行的调用是等价的,rust会自动创建引用作为第一个参数传入
(&mut rect).init();
rect = rect.give_back();
rect = Rectangle::square(32);
}
这里&self
是self: &Self
的缩写,其中Self
是impl后面接的类型在这个impl块中的别名。
rust中我们enum类型更像是一种类型的分组,它内部可以包含多个具有别名的类型。
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
impl Message{
fn distance(&self) -> i32{
match self {
Message::Move { x, y } => x + y,
default => 0
}
}
}
fn main() {
let msg = Message::Move{x : 1, y : 1};
println!("{}", msg.distance());
}
可以发现enum中包含多个子类型,并且enum是在栈上分配内存的,那么enum类型的大小必定是在编译期可知的。实际上rust会分配最大子类型的大小作为enum类型的大小。
match可以用来处理整数、枚举、字符串等。
let roll = 1;
let mut x = 0;
let res = match roll { //match可以带返回值
0 => 0,
1 => 1,
other => -1, //用other匹配所有情况
}
match roll {
0 => x += 1,
1 => x -= 1,
_ => () //_表示匹配任意值并丢弃,()表示什么都不做,{}也是相同作用
}
枚举类型也可以同样操作:
let max = Some(1);
match max {
Some(x) => println!("max = {}", x),
_ => ()
}
很多时候我们仅处理一种枚举类型,但是这要求我们总是加入_ => ()
行,这比较麻烦,还有一种if let
语法。
let max = Some(1);
if let Some(x) = max {
println!("max = {}", x);
} else { //else块是可选的
}
在rust中,用mod声明一个模块,其类似于其他语言的命名空间。模块内部可以定义其它模块,或者自定义类型、函数等。
默认情况下一个元素仅对于相同父模块及后代模块中的元素可用,要对外部模块可用,我们需要加上pub关键字。模块中如果我们声明某个定义的元素是pub,表示这个元素的所有祖先模块都能访问它。要使用其它模块中的元素,我们需要通过相对路径或者绝对路径来访问。这类似于类unix系统中的路径表示法,默认路径为相对路径,我们用crate表示根路径(即当前包名称),super表示当前元素所在mod的父mod。
每次都需要用冗长的路径来使用相同元素是很麻烦的,我们可以用use来在当前scope引入某个特定的名称。为了避免引入拥有相同名字,但是存在于不同mod下的元素,我们需要通过alias设置别名,默认别名就是元素的名称。use也有访问控制,默认这个别名是不能被mod外访问的,我们可以加入pub修饰符使得它能够被mod外访问。
mod department {
mod service {
use super::House; //使用别名
pub use super::House as H; //使用别名,并暴露mod外
use super::*; //引入父模块的所有名称
pub fn clean(house: &mut super::House){
}
}
pub struct House {
pub address: String,
pub opened: bool,
key: String, //私有field
key_type: KeyType
}
pub enum KeyType{
Physical,
Electric
}
impl House{
pub fn open(&mut self, key: &String) { //公有方法
if self.isKeyValid(key) {
self.opened = true;
}
}
pub fn newHouse() -> House{
House {
address: String::from("South"),
opened: false,
key: String::from("123"),
key_type: KeyType::Physical,
}
}
fn isKeyValid(&self, key: &String) -> bool { //私有方法
&self.key == key
}
}
pub fn simpleTest() {
let mut house = crate::department::House::newHouse();
let mut house = House::newHouse();
let mut house = super::department::House::newHouse();
service::clean(&mut house);
use service::clean; //引入clean
clean(&mut house);
}
}
要使用集成测试,我们需要先为src
目录建立一个同级目录tests
。这个目录中的文件仅在cargo test
的时候才会被编译,并且包内的元素并不处于crate下。
如果我们有公共的模块需要供测试使用,我们一般会专门放在一个文件中。但是这样会导致测试的时候这个文件也作为集成测试的一部分显示出来。要不显示我们需要使用一个trick,通过建立{modname}/mod.rs
,来讲公共方法放在这个文件中。
use adder::*;
mod common;
#[test]
fn it_adds_two() {
common::set_up();
assert_eq!(4, add_two(2));
}
我们可以很简单的用cargo实现模块管理。首先我们创建一个简单的项目your_lib
。
# cargo new --lib your_lib
之后创建如下文件,src\lib.rs
pub mod sample;
在创建新的文件src\sample\mod.rs
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
之后我们在your_lib
的父目录下建立一个项目practice
。
# cargo new practice
之后修改main.rs
文件
use your_lib::sample::add;
fn main(){
println!("1 + 2 = {}", add(1, 2));
}
但是我们还需要加入对sample项目的依赖,由于是本地依赖,我们需要修改Cargo.toml
依赖。
simple = {path = "./your_lib"}
rust提供了两种字符串类型,一种是str,一种是String。我们一般用到的字面量比如"hello"
就是str类型的,字面量数据硬编码在二进制文件中,我们应该用&str
类型来引用它们。String
类型可以认为是可变的str
类型。
两种类型可以互相转换:
let s: &str = "hello";
let s: String = s.to_string();
let s: &str = s.as_str();
下面演示如何修改String类型。
let mut s = "hello".to_string();
s.push(' ');
s.push_str("world");
s += &"!".to_string(); //等价于s = s + &"!".to_string();,这里s会追加"!",并返回新的所有权
s = s + "!";
println!("{}", s);
这里字符串的加法操作实际调用的是一个类似fn add(self, s: &str)
的函数。
在rust中,字符串类型采用utf8编码,因此每个字符的占用空间从1字节到4字节不等,这导致我们无法高效的获取字符串的第i个字符,因此rust禁用了对字符串下标取值。
let s = "hello";
let c = s[0]; //非法操作
在rust中字符串可以有三种表现形式,考虑字符串"नमस्ते"
.bytes()
方法获得:[224, 164, 168, 224, 164, 174, 224, 164, 184, 224, 165, 141, 224, 164, 164,
224, 165, 135].chars()
方法获得:['न', 'म', 'स', '्', 'त', 'े']那么我们在使用字符串切片的时候具体是使用哪种表现形式呢。实际上我们使用的是字节数组,s[0..4]
表示取s
字节数组的最前4个字符组成一个新的字符串。但是如果这四个字符无法组成一个合法的字符串呢?这时候程序会抛出运行时异常。所以在使用字符串切片要额外小心。
rust中错误分为可恢复错误和不可恢复错误。对于不可恢复错误,应该用painc!
宏来结束线程,而对于可恢复错误,应该将其包装为Result
枚举类返回给调用者,由调用者决定是否恢复。
下面演示如何用panic!
处理不可恢复异常。
fn get_file_0(s: &String) -> File {
let f = File::open(s);
match f {
Ok(x) => x,
Err(err) => panic!("{:?}", err),
}
}
fn get_file_1(s: &String) -> File {
let f = File::open(s);
f.unwrap()
}
fn get_file_2(s: &String) -> File {
let f = File::open(s);
f.expect("can't open file")
}
上面的三个函数拥有相同的效果,如果能正常打开文件则返回文件,否则结束线程。
下面演示如何处理可恢复异常:
fn get_file_0(s: &String) -> Result<File, std::io::Error> {
let f = File::open(s);
f
}
fn get_file_1(s: &String) -> Result<File, std::io::Error> {
let f = File::open(s)?; //?表示如果成功,则返回结果,否则将异常转换类型后作为当前函数返回值返回
Ok(f)
}
fn get_file_2(s: &String) -> Result<File, std::io::Error> {
let f = File::open(s);
match f {
Ok(x) => Ok(x),
Err(err) => Err(err)
}
}
上面的三个函数拥有相同的效果,返回一个Result
表示操作结果。
一般情况下main
函数没有返回值,但是你可以为其增加一个额外的返回值以返回Result
。
fn main() -> Result<(), Box<dyn Error>> {
let f = get_file_0(& "hello".to_string())?;
Ok(())
}
Rust支持泛型,类似于C++,泛型不会有运行时费用,所有工作都在编译期完成。
struct Point<T, U>{ //泛型类型
x: T,
y: U,
}
impl<T, U> Point<T, U> { //泛型类型的关联函数
fn x(&self) -> &T{
&self.x
}
fn combine<W, V>(self, pt: Point<W, V>) -> Point<T, V>{ //泛型关联函数
Point{
x: self.x,
y: pt.y,
}
}
}
fn y<T, U>(pt: &Point<T, U>) -> &U { //泛型函数
&pt.y
}
impl Point<f64, f64>{ //特化
fn distance(&self) -> f64{
(self.x * self.x + self.y * self.y).sqrt()
}
}
impl<T: PartialOrd> Point<T, T> { //只有T实现了偏序关系,才拥有这些方法
fn max_element(&self) -> &T{
if self.x > self.y {
&self.x
}else{
&self.y
}
}
}
泛型参数支持默认值:
pub trait Add<Rhs = Self> {
type Output;
fn add(self, rhs: Rhs) -> Self::Output;
}
trait类似于其它语言中的接口,它包含一组方法签名,任意类型都可以实现这些trait。但是不允许在当前包中为为一个外部包的类型实现某个外部包的trait,这样设计的好处是避免同一个类型将某个trait实现多次。
trait中也可以包含方法的默认实现。
pub trait Summary{
fn summarize(&self) -> String;
fn summarizeLong(&self) -> String { //方法可以有默认实现
self.summarize() + "..."
}
}
pub struct NewsArticle {
pub headline: String,
pub location: String,
pub author: String,
pub content: String,
}
impl Summary for NewsArticle {
fn summarize(&self) -> String {
format!("{}, by {} ({})", self.headline, self.author, self.location)
}
}
pub struct Tweet {
pub username: String,
pub content: String,
pub reply: bool,
pub retweet: bool,
}
impl Summary for Tweet {
fn summarize(&self) -> String {
format!("{}: {}", self.username, self.content)
}
}
pub fn notify(item: &impl Summary) { //impl Summary表示所有实现了Summary trait的类型
println!("Breaking news! {}", item.summarize());
}
fn main(){
let tweet = Tweet {
username: String::from("horse_ebooks"),
content: String::from(
"of course, as you probably already know, people",
),
reply: false,
retweet: false,
};
notify(&tweet);
}
考虑如下方法
pub fn notify(item1: &impl Summary, item2: &impl Summary) {
它表示接受两个实现了Summary trait的参数,但是不强制要求它们具有相同的类型。如果我们希望它们拥有相同的类型,需要借助泛型:
pub fn notify<T: Summary>(item1: &T, item2: &T) { //T必须实现Summary trait
如果要求类型同时实现多个trait:
//非泛型版本
pub fn notify(item: &(impl Summary + Display)) {
//泛型版本
pub fn notify<T: Summary + Display>(item: &T) {
很显然随着需要实现的trait数目的增多,会导致方法签名越来越复杂,rust提供了一个where语法糖来优化:
fn some_function<T, U>(t: &T, u: &U) -> i32
where T: Display + Clone,
U: Clone + Debug
{
我们可以将方法的返回值也改成trait,但是方法必须只能返回同一种类型的数据。
fn returns_summarizable(switch: bool) -> impl Summary {
我们也可以利用泛型为所有实现了某些trait的类型增加一些额外的方法,比如标准库中的ToString
trait。
impl<T: Display> ToString for T { //为所有实现了Display的元素实现to_string方法
// --snip--
}
由于不同泛型参数对应的是不同的类型,因此一个结构体可以实现多个不同泛型参数的相同trait。如果我们希望即拥有泛型的能力,又只希望同一个类型不能重复实现trait,那么就需要用到关联类型。比如标准库中的迭代器trait:
pub trait Iterator {
type Item;
fn next(&mut self) -> Option<Self::Item>;
}
我们类型可能实现了多个trait,一些trait中包含了完全相同签名的方法,这时候我们需要通过全限定名的方式来指定具体执行哪个方法。
struct E;
trait A {
fn go(&self);
}
trait B {
fn go(&self);
}
impl A for E {
fn go(&self) {
println!("A");
}
}
impl B for E {
fn go(&self) {
println!("B");
}
}
impl E {
fn go(&self) {
println!("E");
}
}
fn main() {
let e = E;
e.go();
A::go(&e);
B::go(&e);
E::go(&e);
}
上面能正常识别的前提是有&self作为参数,如果是静态函数如何指定具体调用哪个实现。
struct E;
trait A {
fn go();
}
trait B {
fn go();
}
impl A for E {
fn go() {
println!("A");
}
}
impl E {
fn go() {
println!("E");
}
}
如果我们直接调用A::go()
,编译器无法确定是使用哪个实现。我们需要新的写法<Type as Trait>::function
。
fn main() {
let e = E;
E::go();
<E as A>::go();
}
如果实现一个trait,需要类型必须先实现另外一个trait,后者称为前者的Supertrait,这种约束很容易表达:
trait OutlinePrint: fmt::Display { //实现OutlinePrint必须先实现fmt::Display
fn outline_print(&self) {
let output = self.to_string();
let len = output.len();
println!("{}", "*".repeat(len + 4));
println!("*{}*", " ".repeat(len + 2));
println!("* {} *", output);
println!("*{}*", " ".repeat(len + 2));
println!("{}", "*".repeat(len + 4));
}
}
impl OutlinePrint for u32{} //成功编译
impl OutlinePrint for Vec<u32>{} //无法编译
当我们在函数中返回引用的时候,rust编译器无法得知返回值的存活时间。这时候返回值一般和入参有关(因为在函数内部创建的元素都会在函数退出时被销毁),其生命周期也和入参挂钩。我们需要指定返回值具体依赖哪些入参的生命周期。
fn longest<'a>(s1: &'a String, s2: &'a String) -> &'a String { //增加生命周期类似于增加泛型参数,不过生命周期是由“'{名称}”格式组成
if(s1.len() < s2.len()){
s2
}else{
s1
}
}
上面编译器会理解返回值的生命周期不能超过s1引用的元素,也不能超过s2引用的元素。如果我们一旦在s1
或s2
生命周期结束后还使用这个函数的返回值,编译器能及时发现问题并报告。
同样如果一个结构体中包含引用,这时候我们需要对结构体的生命周期加以限制,保证它的生命周期不会大于任意一个引用成员的生命周期。
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
let i = ImportantExcerpt {
part: first_sentence,
};
}
注意生命周期仅仅是帮助编译器来确定程序是否合法,是否可能存在悬置引用。在以下情况下,如果我们省略生命周期,编译器会自动帮助我们填充生命周期,这使得我们写代码更加简单:
self
,那么所有引用返回值的生命周期会默认使用self
的生命周期。如果上面两个方法都不管用,编译器会报错提醒我们。
cargo内置了测试框架,我们可以通过cargo test
来执行所有的测试用例。
我们需要在测试函数上加上属性#[test]
来标注这个函数为测试用例。对于这类函数,rust会为每个函数启动一个线程来执行,如果在一个函数中调用panic!
或者返回Err
会视作测试失败。但是如果我们在函数上加上属性#[should_fail]
,那么只有在函数中调用panic!
或者返回Err
会视作测试成功。
由于默认情况下会使用多线程,因此我们需要保证代码不会产生竞争条件。或者我们可以用--test-threads=1
来指定最多同时运行一条线程。
如果我们只想测试一个函数,我们也可以追加这个函数名字来要求cargo只测这个函数,比如cargo test {name}
,这时候cargo会只测试函数名中包含{name}
的所有函数。
对于跑的很慢的测试用例,我们可以在上面加上#[ignore]
注解,这样在运行cargo test
的时候会忽略这些函数。我们可以用cargo test -- --ignored
来仅运行那些被忽略的函数。
pub fn greeting(name: &str) -> String {
format!("Hello! {}!", name)
}
#[cfg(test)] //#[cfg(test)]属性要求仅在cargo test的时候编译只部分代码,而在cargo build的时候忽略。
mod tests {
use super::*;
#[test]
fn a_new_name() {
let result = 2 + 2;
assert_eq!(result, 4); //assert_eq!在传入的两个参数不等的情况下会调用panic!
assert!(result == 4); //assert_eq!在传入的两个参数相等的情况下会调用panic!
}
#[test]
#[should_panic]
fn must_fail() {
panic!("fail by hand");
}
#[test]
#[ignore] //默认不运行这个函数
fn greeting_test(){
let res = greeting("Carol");
assert!(res.contains("Carol"), "Greeting didn't contain name, value was '{}'", res);
}
#[test]
fn it_works() -> Result<(), String> {
if 2 + 1 == 4 {
Ok(())
}else{
Err("two plus two does not equal four".to_string()) //返回Err等价于调用panic!
}
}
}
我们可以用下面方法创建一个闭包(匿名函数)。
let add = |x, y| x + y;
let add = |x, y| {x + y};
let add = |x: i32, y: i32| -> i32 {x + y};
在rust中我们不需要写出闭包的参数类型和返回值类型,编译器会自动替我们做出决定。这与函数不同,因为函数可能会暴露给包外,我们必须声明类型帮助编译器来确定函数的格式,而闭包只会作用在很小的作用域中,这足够让编译器做出决定。
闭包允许持有外部变量,一个闭包必定实现了Fn
,FnOnce
,FnMut
中的某个trait,它们的区别在于:
Fn
:以不可变引用的方式捕获外部变量FnMut
:以可变引用的方式捕获外部比那里FnOnce
:获得外部变量的拥有权,这种闭包只能被调用一次。默认所有闭包都实现了FnOnce
,如果闭包没有获得外部变量的所有权,那么它同样会实现FnMut
。如果闭包没有修改外部变量,则它会实现Fn
。同样的如果我们希望传递闭包,我们必须获得闭包的类型。闭包的类型可以用实现的trait来表示,比如上面的add
的闭包类型为Fn(i32,i32)->i32
。
struct Function {
f: Fn(i32, i32) -> i32
}
impl Function {
fn f(&self, x: i32, y: i32) -> i32{
(self.f)(x, y)
}
}
我们也可以强制闭包获得外部变量的所有权,这可以通过move关键字实现。这在将闭包交给新线程运行时会用到。
let x = vec![1, 2, 3];
let equal_to_x = move |z| z == x;
我们可以通过foreach遍历一个迭代器。
let arr = [1, 2, 3];
let iter = arr.iter();
for e in iter {
println!("{}", e);
}
迭代器在rust是一个trait,要实现这个trait我们需要实现它的next
方法。
struct Range {
l: i32,
r: i32,
}
impl Range {
fn new(l: i32, r: i32) -> Self{
Self{l, r}
}
}
impl Iterator for Range {
type Item = i32;
fn next(&mut self) -> Option<Self::Item> {
if(self.l <= self.r){
let res = Some(self.l);
self.l += 1;
res
}else{
None
}
}
}
rust编译器会把我们的迭代器自动优化从而达到接近手动循环的性能,因此可以放心食用。
cargo中有profile的概念,默认情况下cargo build
使用dev profile。cargo build --release
使用release profile。
cargo会为每个profile提供一个默认配置,我们也可以用通过在Cargo.toml
文件中加入[profile.*]
来覆盖它的默认设置。
[profile.dev]
opt-level = 0
[profile.release]
opt-level = 3
配置 | 解释 | 取值 |
---|---|---|
opt-level | 编译时优化级别 | 0-3,越大数字表示优化级别越高 |
文档注释和一般注释不同,文档注释有两种:
///
:注释接下来的元素//!
:注释包含这个注释的元素,比如mod、crate文档注释支持markdown风格,并且用一对```包含的样例代码块会作为doc test的一部分进行测试,保证文档和代码是同步的。如果你希望它不会被允许,可以加上no_run
属性。
/// ```ignore //忽略代码
/// fn foo() {
/// ```
/// ```should_panic //只有panic退出才算成功
/// assert!(false);
/// ```
/// ```no_run //只编译不允许
/// loop {
/// println!("Hello, world");
/// }
/// ```
/// ```compile_fail //应该编译失败
/// let x = 5;
/// x += 2; // shouldn't compile!
/// ```
# cargo doc //生成html文档
# cargo doc --open //生成并打开html文档
cargo.io是一个rust包仓库。
cargo.io的使用非常简单,首先注册账号。之后点击Account Setting -> API Access -> New Token获得一个新的token。
# cargo login {token} //登陆并记录token
要发布一个新的包:
# cargo publish
发布包需要保证下面几点:
下面提供一个Cargo.toml
的样例。
[package]
name = "minigrep-dalingtao"
version = "0.1.1"
edition = "2021"
license = "MIT"
description = "test project"
[dependencies]
如果我们更新了包的版本并重新上传后,希望禁用之前的包,我们会发现无法删除之前的包,因为这样做会break很多用户的Cargo.lock
。但是我们可以将之前的包标记为yank,这样可以原来依赖旧版本的项目可以继续拉取这个包,而新依赖不能拉取旧包。
在Rust中,智能指针包括:
智能指针实现了Deref
trait,因此我们可以直接像使用引用一样直接使用指针指向的值,而不需要先提取Box封装的指针。同时智能指针实现了Drop
trait,允许在被销毁前做一些额外的操作,这时候它会释放指针指向的堆内存。
在使用$x$的时候等价于$(x.deref())$。
rust还提供了deref coercoin
技术,简单讲就是如果一个结构体$A$实现了deref为$B$,那么rust可以自动将$A$的引用转换为$B$的引用。并且解引用的代码在编译期插入,没有额外的运行期费用。
let a = "hello".to_string();
let b = Box::new(a);
let z: &str = &b; //&Box<String> -> &String -> &str
为了提供可变指针,还有一个类似的trait:DerefMut
。
对于Drop
trait,它会在元素销毁之前被调用,来释放资源。如果我们希望提前释放对象,我们可以声明一个新的空函数,让它夺走传入变量的所有权,这个函数非常常见,因此rust标准库中包含了这个函数drop(x)
来释放对象x
。
Box用来存储一个指向堆上的指针。它和一般的指针类似,并没有额外的开销。
对于第一点,考虑我们要实现一个单向链表:
enum List {
Next(i32, List),
End
}
这样实现是不行的,因为要计算枚举List的大小,必须确定每个子类型的大小。这就会导致无穷递归的问题。我们可以用Box
指针来解决这个问题。
enum List {
Next(i32, Box<List>),
End
}
fn main() {
use List::*;
let x = Next(1, Box::new(Next(2, Box::new(End))));
}
Box智能指针存在一个问题,就是每个指针必须获得元素的所有权。是否有可能一个元素被多个元素所共享,类似于不可变引用。这可以通过Rc来实现。Rc是一个基于引用计数的指针,当克隆指针时计数加一,指针释放的时候计数减少一,如果计数为零则释放资源。
use std::{ops::Deref, rc::Rc};
struct Node {
adj: Vec<Rc<Node>>
}
impl Node {
fn new(adj: Vec<Rc<Node>>) -> Self {
Node { adj }
}
}
fn main() {
let a = Rc::new(Node::new(Vec::new()));
let b = Rc::new(Node::new(vec![Rc::clone(&a)]));
let c = Rc::new(Node::new(vec![Rc::clone(&a)]));
}
这里我们要拷贝Rc需要使用Rc::clone
,它会执行浅拷贝,会增加计数。但是Rc只能获得不可变引用。
Rust的borrow规则要求:
但是这时候我们会发现如果我们希望构造一个有环图会非常困难。由于一个顶点会被多个其它顶点的邻接表存储,因此我们必须用Rc来存储顶点信息。这导致我们不能修改顶点,自然也无法构造一个有环图。
上面失败的原因是无法通过编译期检测,但是实际上在运行期,只要没有多线程,这个程序完全是没有问题的,因为始终只需要维护一个可变引用。
RefCell里面包含了一些unsafe code,允许我们通过它的一个不可变引用得到它存储的可变引用。RefCell提供了两个方法,borrow_mut
以获得其中的可变引用,borrow
用于获得一个不可变引用。为了保证borrow规则,rust会额外维护一些计数器来保证运行时没有违背borrow原则,一旦违背会调用panic结束线程。因此会有一定的额外运行时开销。
use std::{ops::Deref, rc::Rc};
use std::cell::RefCell;
struct Node {
adj: Vec<Rc<RefCell<Node>>>
}
impl Node {
fn new() -> Self {
Node { adj:Vec::new() }
}
}
fn main() {
let a = Rc::new(RefCell::new(Node::new()));
let b = Rc::new(RefCell::new(Node::new()));
let c = Rc::new(RefCell::new(Node::new()));
a.borrow_mut().adj.push(Rc::clone(&b));
b.borrow_mut().adj.push(Rc::clone(&c));
c.borrow_mut().adj.push(Rc::clone(&a));
a.borrow();
}
RefCell演示了如何创建图论中的环形关系,但是可以发现引用计数+环形依赖会导致计数永远大于0,即环上的元素不能被正确释放。这时候我们等程序结束后由操作系统释放所有资源。
解决方案是依赖使用Weak指针来表达。Rc指针可以被降级为Weak指针,而Weak指针的存在不会导致计数值的变化,即即使存在Weak指针,其指向的元素可能也会被释放。这也导致我们不能直接获得Weak指针指向的地址,Weak指针会返回一个Option对象来表示指向的位置是否依旧可用。
fn main() {
let a = Rc::new(RefCell::new(Node::new()));
let b = Rc::new(RefCell::new(Node::new()));
let c = Rc::new(RefCell::new(Node::new()));
a.borrow_mut().adj.push(Rc::downgrade(&b)); //创建weak指针
b.borrow_mut().adj.push(Rc::downgrade(&c));
c.borrow_mut().adj.push(Rc::downgrade(&a));
a.borrow();
let bref: Rc<RefCell<Node>> = a.borrow().adj[0].upgrade().unwrap(); //判断元素是否存在
let should_be_b: &Node = &bref.borrow();
}
如大部分编程语言一样,rust也支持多线程。
use std::{thread, time::Duration};
fn main(){
let x = "new thread".to_string();
let t = thread::spawn(move || {
for i in 1..10 {
println!("hi {} from {}", i, x);
thread::sleep(Duration::from_millis(1));
}
});
for i in 1..5 {
println!("hi {} from current thread", i);
thread::sleep(Duration::from_millis(1));
}
t.join();
}
在rust中,线程之间通过管道而非共享内存进行通信。
use std::sync::mpsc;
use std::thread;
use std::time::Duration;
fn main() {
//mpsc表示multi-provider-single-consumer。
let (tx, rx) = mpsc::channel();
//利用clone方法获得新的provider
let tx1 = tx.clone();
thread::spawn(move || {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("thread"),
];
for val in vals {
//如果receiver一端已经被销毁,result会是Err
tx1.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
thread::spawn(move || {
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];
for val in vals {
tx.send(val).unwrap();
thread::sleep(Duration::from_secs(1));
}
});
//消费所有收到的数据,直到所有发送端都被销毁
for received in rx {
println!("Got: {}", received);
}
}
要在多线程之间共享元素,我们很自然可以想到用Rc
和RefCell
,但是它们都不是线程安全的。Rust提供了它们的线程安全版本:Arc
和Mutex
,前者通过原子性保证计数器的安全,后者通过加锁来防止race condition。
use std::sync::{Arc, Mutex, mpsc};
use std::thread;
use std::time::Duration;
fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("{}", *counter.lock().unwrap());
}
只有元素实现了Send
trait,这样的元素才能在不同线程间传递所有权。
在Rust中所有基础类型都实现了Send
trait,除了raw指针。一个结构体如果所有成员都实现了Send
,那么这个结构体也默认实现了Send
。
在Rust中,Rc没有实现Send。
如果一个类型的引用实现了Send,那么它默认实现了Sync trait。默认所有原生类型都实现了trait,一个结构体的所有成员都实现了Sync,那么它也会默认实现Sync。
在Rust中,Rc
,RefCell
,Cell
等类型没有实现Sync。
一般我们无法实现多态,比如说我们不能创建一个Vec<Drop>
,因为Drop是一个trait,在编译期无法确定它的具体实现类,自然也不知道它的具体大小。
一种简单的方式是我们使用枚举类:
enum NumType {
Integer(i32),
Float(f64)
}
let v = vec![Integer(1), Float(0.1)];
但是这种实现的弊端也是存在的,作为包发布的时候我们无法扩展它的内容,同时枚举类占用的内存可能比需要的多。
还有一种动态分发的方式。我们可以用dyn {Trait}
表示一个Trait Object,它们可以作为指针的泛型参数。
trait Go {
fn go(&self);
}
impl Go for i32 {
fn go(&self) {
println!("This is i32: {}", *self);
}
}
impl Go for f64 {
fn go(&self) {
println!("This is f64: {}", *self);
}
}
struct Container {
v: Vec<Box<dyn Go>>
}
fn main() {
let mut c = Container{v : Vec::new()};
c.v.push(Box::new(1));
c.v.push(Box::new(1.1));
for x in c.v.iter(){
x.go();
}
}
并不是所有trait都可以作为trait object的,这里有一些限制:
并且动态分发会导致编译期无法推断具体的类型,这会导致在编译期无法执行一些优化(比如内联代码),同时在运行期要确定具体类型也需要一些额外开销(用和C++类似的虚表实现)。
如果仅使用rust已有的功能,会发现很多功能不能实现,比如说底层编程的时候。rust允许我们使用unsafe关键字来实现很多超能力。
我们可以通过unsafe{}
块来给予某个代码块unsafe权限,也可以将unsafe加在fn前,给予函数unsafe权限。
一个标记为unsafe的函数,必须只能在unsafe块或其他unsafe函数中调用。
rust中也存在raw指针,一个指向i32的常量指针为*const i32
,而可变指针为*mut i32
。一般情况下我们不能直接解引用指针。
fn main() {
let mut x = 3;
let ptr: *mut i32 = &mut x;
println!("{}", *ptr); //这一行会编译报错
}
通过增加unsafe块后可以编译通过。
fn main() {
let mut x = 3;
let ptr: *mut i32 = &mut x;
unsafe{
println!("{}", *ptr);
}
}
我们可以通过unsafe权限调用外部代码。所有的外部代码都需要通过unsafe权限才能调用。
extern "C" {
fn abs(input: i32) -> i32;
}
fn main() {
unsafe {
println!("Absolute value of -3 according to C: {}", abs(-3));
}
}
rust中允许我们定义全局静态变量,但是要修改和读取全局静态变量需要unsafe权限。
static mut counter: u32 = 0;
fn add(x: u32) {
unsafe{
counter += x;
}
}
fn main() {
add(3);
add(2);
unsafe {
println!("{}", counter);
}
}
对于一些trait,比如Send和Sync,一般由编译器自动帮助实现。但是有些元素编译器并不能识别是否能在多线程之间共享,比如一个包含raw 指针的结构体,我们需要用unsafe impl关键字实现unsafe trait。
unsafe trait Foo {
// methods go here
}
unsafe impl Foo for i32 {
// method implementations go here
}
我们可以为某些类型创建别名,这在类型名冗长的时候非常有用。
type int = i32;
type Rs<T> = Result<T, std::io::Error>;
我们很容易表示函数类型,它们是一种具体类型,它的大小是在编译期可以确定的,因此我们可以作为参数、返回值、成员使用。函数本身就是一种不可变指针,因此可以在不涉及所有权的情况下自由传递。
fn apply(f: fn(i32) -> i32, x: i32) -> i32 {
f(x)
}
fn add(x: i32) -> i32 {
x + 1
}
fn main(){
let ans = apply(add, 3);
println!("ans = {}", ans);
}
要查看宏展开后的结果,需要先安装cargo-expand
。
# cargo install cargo-expand //安装工具
# cargo expand // 查看展开结果
现在有$n$个顶点形成一个环,顺时针编号为$0,1,\ldots,n-1$,相邻的两个顶点有连边。第$i$个顶点和$i+1$顶点的连边权重为$w_i$。现在希望有$q$个请求,每个请求指定两个顶点。现在要求保留环中总权重最小的一组边的子集,且每个请求中给出的两个顶点之间总是存在至少一条路径。
首先讲一个线段树的做法。很显然我们至少可以删除一条边,我们循环遍历删除的那条边,之后发现环成了一个序列,而每个请求变成了一段区间,我们不能删除被区间覆盖的边,但是能删除所有不被区间覆盖的边。很显然正常做的时间复杂度为$O(nq)$,但是实际上可以发现对于相邻的两条边,删除一条并恢复另外一条,实际上区间的总插入和删除次数是$O(q)$级别的,因此我们可以用线段树维护,总的时间复杂度为$O((n+q)\log_2n)$。
下面讲一下更快的$O(n+q)$做法。思路非常简单,就是我们可以把每个请求视作一段区间(从较小的顶点到较大的顶点),之后记$f(i)$表示覆盖边$(i,i+1)$的请求集合。可以发现,如果我们删除边$(i,i+1)$后,所有出现在$f(i)$中的请求必须经过$(n-1,0)$,而所有未出现在$f(i)$中的请求可以,不能经过$(n-1,0)$这条边,即一旦选择删除边$(i,i+1)$后,所有查询的连通方案都由$f(i)$给定的,并且总是有方案的。如果$f(i)\neq f(j)$,我们不能同时删除边$(i,i+1)$和$(j,j+1)$,因为二者的方案存在冲突。因此我们只需要维护$f(i)$,并按照它们代表的集合分组,找到分组总权最大的那个分组中的边删除即可。维护分组是很有难度的,我们可以用随机化的方式,为每个分组维护多重集合的哈希信息,之后判同就变成了$O(1)$的整数比较问题。如果使用哈希表来分组,时间复杂度会优化到$O(n+q)$。
题目1:一个环上有$n$个人,顺时针编号为$1,\ldots,n$。第$i$个人持有$a_i$个糖果。你每个回合可以指定某个人,将他手头的一个糖果传递给顺时针下一个人(前提被选人至少有一个糖果)。现在给定$b_1,\ldots,b_n$,保证$a_1+\ldots+a_n=b_1+\ldots+b_n$,问至少需要多少个回合,才能使得对于任意$1\leq i\leq n$,第$i$个人正好持有$b_i$个糖果。
记$c_i$表示第$i$个人向下一位总共传递的糖果数。可以发现$b_i=a_i-c_i+c_{i-1}$。可以发现如果我们令$c_1=x$,那么有$c_i=x-t_i$。而我们需要的回合数为$\sum_{i=1}^n|x-t_i|$。这个问题等价于在数轴$t_1,\ldots,t_n$上都有一个点,现在要在数轴上选择一个点$x$,它到所有存在的点的总距离最小。很显然我们选择所有存在的点的中位数就能达到最优。
总的时间复杂度为$O(n)$。(我们最后不需要排序,使用第k大算法即可)
题目2:一个环上有$n$个人,顺时针编号为$1,\ldots,n$。第$i$个人持有$a_i$个糖果。你可以对每个$i$指定一个$0\leq c_i\leq a_i$。之后第$i$个人会向顺时针下一个人传递$c_i$个糖果。设之后第$i$个人持有的糖果数为$b_i$,要求返回$b$序列总共有多少不同的可能值,结果对素数$p$取模。
可以发现如果$\min(c)>0$的时候,我们可以让所有$c_i-\min(c)$,$b$是不变的。因此我们可以认为$\min(c)=0$。
令$d_i=c_i-c_{i-1}$。那么有$b_i=a_i-d_i$。很显然有多少不同的$d$序列,就存在多少不同的$b$序列。
下面我们证明$c$和$d$序列一一对应。假设两个不同的序列$c$和$c'$,产生相同的$d$序列。分两种情况讨论:一种是存在$i$使得,$c_i=c'_i$,那么由于$d$相同,可以直接得出$c=c'$。还有一种情况是存在一个$i$,使得$c_i=0$且$c'_i>0$,这时候由于$d$相同,可以得出$c'_i$是$c'$中的最小值,而它不满足$\min(c')=0$。
因此我们的答案就变成了存在多少$c$序列,满足$0\leq c_i\leq a_i$,且$\min(c)=0$。答案为$\prod_{i=1}^n(a_i+1)-\prod_{i=1}^na_i$。
总的时间复杂度为$O(n)$。
题目1:给定$2n$个人,顺时针编号为$1,\ldots,2n$。现在要求让环上的两两拉一条绳子,且绳子之间的交点数最多(重复的交点也要多次统计)。
很显然让$i$和$i+n$共拉一条绳子,这时候任意两条绳子都有交点,达到理论最大值${n\choose 2}$。
总的时间复杂度为$O(n)$。
题目2:给定$2n$个人,顺时针编号为$1,\ldots,2n$。现在要求让环上的两两拉一条绳子,且绳子之间的交点数最多(重复的交点也要多次统计)。其中我们已经存在$k$条绳子,拉第$i$条绳子的人为$a_i$和$b_i$。
提供一道题目。
实际上我们把所有未匹配的人按照顺时针排序后,第$i$个人和第$i+n-k$个人拉一条绳子即可。
可以发现如果$(a,b)$和$(c,d)$形成的两条绳子无交点,那么$(a,d)$和$(c,b)$就一定有交点,且与其它绳子的交点总数是不会减少的。换言之最优解的情况下,未选择的绳子必须两两相交,而这时候只有一个唯一解,就是每个人和对面的人拉绳子。
总的时间复杂度为$O(n)$。
]]>对于一颗有根树,如果所有非叶子结点都至少有两个孩子(等价的不存在度数为$2$的结点),那么我们就可以用叶子数来约束整个树的大小,这样的树我们称其为无杆树。记$n$表示叶子数,$m$表示非叶子数,实际上可以发现$m\leq n$,因此总的树上结点数不超过$2n$。
证明非常简单,我们可以不断选择最深的一个叶子,将其父亲下的所有叶子全部删除。可以发现这样一次操作,至少会减少两个叶子,并出现一个新的叶子,因此总的叶子数至少减少了$1$,且重要的是操作后得到的新树依旧是无杆树。可以发现不断执行上面操作,直到只剩下一个根结点,我们总共最多执行$n$次操作,但是将$m$个非叶结点都转换成了叶子结点,因此有$m\leq n$成立。但是如果我们将根也视作一个叶子的话,可以发现有$m<n$成立。
上面这个技术在证明树形递归(无环)算法时间复杂度的时候非常有用。考虑一个经典的递归枚举子集算法:
void gen(int[] bits, int i, int sum){
if(i == -1){
//组合已经生成完毕,做一些操作
return;
}
gen(bits, i - 1, sum);
bits[i] = 1;
gen(bits, i - 1, sum + 1);
bits[i] = 0;
}
将每次调用视作树上的一个结点,我们发现时间复杂度恰好是$O(n+m)$。这里可以发现形成的树是无杆树,而叶子总数仅有$O(2^s)$个,其中$s$是全集的大小。因此总的时间复杂度为$O(2^s)$。由于递归遍历的时候可以维护选中的子集的摘要信息,因此实际上比一般暴力循环的$O(s2^s)$的时间复杂度会好上很多。
再考虑一个经典的递归枚举置换算法:
void gen(int[] perm, int i, boolean[] used) {
if(i == -1){
//排列已经生成,做些操作
return;
}
for(int j = 0; j < used.length; j++){
if(used[j]){
continue;
}
used[j] = true;
perm[i] = j;
gen(perm, i - 1, used);
used[j] = false;
}
}
设$p$为置换的长度。依旧我们将每次调用视作树上的一个结点,可以发现深度为$p$和$p-1$的调用次数都是$p!$,而深度小于等于$p-1$的部分是形成了一颗无杆树。因此树上结点总数为$O(p!)$。而每个结点(每次函数调用)消耗的时间复杂度为$O(p)$,因此总的时间复杂度为$O((p+1)!)$。
要遍历所有大小为$n$的置换,我们一般有递归和迭代两种方式。
递归方式可以用于处理全排列,时间复杂度为$O(n\cdot n!)$。好处是可以计算的时候附带处理一些约束条件,以及计算一些汇总信息。递归方式适用于生成大量的排列的情况,而它计算单个排列(比如计算某个排列的后继)的最坏时间复杂度为$O(n^2)$。这时候我们可以使用迭代的方式来优化。
迭代算法分成两类,计算某个置换的后继和前驱。我们先考虑后继,前驱版本可以逆向思维得到。
对于$P$,如何找到比$P$大且在这些置换中最小的置换呢$T$。我们应该保证$P$和$T$的公共前缀尽量长。因此我们要选择一个最短的后缀,通过调整后缀得到一个更大的置换。
很显然后缀是倒序的时候,我们不可能通过调整它得到更大的一个置换,考虑$i$为最大的下标,且满足$P_i<P_{i+1}$。我们先对$P_{i+1},\ldots,P_n$进行排序(考虑到它们是倒序的,因此只要翻转即可)。之后我们将$P_i$和排序完的$P_{i+1},\ldots,P_n$中最小的但是比$P_i$大的数交换,就可以得到后继了。
注意迭代算法是支持排列中出现相同的元素的。它计算单个排列的最坏时间复杂度为$O(n)$。但是当保证置换中不存在相同的值的情况下,我们可以得出更加好的平均时间复杂度,下面我们进行计算。
很显然单次操作的时间复杂度取决于具体翻转了多少个元素。可以发现如果我们当前操作翻转了最后$t$个元素,那么下一次翻转$t$或更多元素,必须在$t!$轮之后。即如果我们要找的是第$k$大的排列,那么这一轮中会翻转的元素等价于找到最大的一个$i$,满足$i!\mid k$,这一轮会翻转$i$个元素,记$i=f(k)$。
现在考虑$k$是均匀随机给定的,那么$\mathrm{Pr}(f(k)=i)=\frac{1}{i!}-\frac{1}{(i+1)!}=\frac{i}{(i+1)!}$。那么单次翻转的期望费用可以写作$E[\sum_{i=1}^n (i+1)x_i]$,其中$x_i$表示当前轮是否翻转$i$个元素。答案为$\sum_{i=1}^n (i+1)\mathrm{Pr}(f(k)=i)=\sum_{i=0}^{n-1}\frac{1}{i!}\leq e$。因此我们可以认为一次操作的平均时间复杂度为$O(e)$。
迭代算法中前驱和后继的计算逻辑相似,都是翻转尾部,以及一次交换操作。所以可以得出相同的时间复杂度,这里不赘述。
所谓的k大子集是正好包含k个元素的子集。
k大子集遍历的算法有不少。最简单的方式就是使用一个包含k个1的置换,并使用置换遍历算法来枚举所有的,但是时间复杂度不太清楚。
还有一个叫做Gosper's Hack
的算法。它的代码如下:
// find next k-combination
bool next_combination(unsigned long& x) // assume x has form x'01^a10^b in binary
{
unsigned long u = x & -x; // extract rightmost bit 1; u = 0'00^a10^b
unsigned long v = u + x; // set last non-trailing bit 0, and clear to the right; v=x'10^a00^b
if (v==0) // then overflow in v, or x==0
return false; // signal that next k-combination cannot be represented
x = v +(((v^x)/u)>>2); // v^x = 0'11^a10^b, (v^x)/u = 0'0^b1^{a+2}, and x ← x'100^b1^a
return true; // successful completion
}
上面的代码来自wiki。
很显然它是$O(1)$的,原理我也没去了解。
但是这个算法只能处理形如$S=2^n-1$这样的集合的子集。之前没有注意,一直以为可以处理任意形式的子集,结果某次Topcoder比赛上用了这个板子,最终成功FST了。
下面讲一下具体如果找任意集合的所有k大子集。原本是希望通过改造Gosper's Hack
算法来实现,但是里面用到下整除的黑魔法,搞不太定。下面讲一下我的做法。
我们先考虑$S=2^n-1$的形式,我们要从小到大枚举$S$的所有的k大子集。我们维护最低位1所在连通块的信息(如果两个1相邻,我们认为它们在一个连通块中),假设它们的范围从第l位开始,到第r位结束。
现在我们考虑要找到下一个k大子集。怎么找,根据取下一个置换的算法,实际上我们需要将第$r+1$位设置为$1$,将$l$到$r$位设置为$0$,之后将$0$到$r-1-l$位都设置为$1$。
由于只涉及一些二进制操作,实际上我们可以$O(1)$执行上面所有的操作。难点在于要维护$l$和$r$。如果$r-1\geq l$,那么上面的流程后我们能很容易维护新的$l',r'$。否则,新的$l'=r+1$,$r'$的话我们可以暴力从$l'$开始查找,直到找到第一个$0$位置。
上面涉及到暴力查找$r'$的过程是摊还$O(1)$的,可以认为我们每次我们右移一个$1$的时候(对应将$r$位置$0$,将$r+1$位置$1$),我们实际支付了两笔费用,一笔是移动的费用,一笔是支付后面用于扫描的费用。很显然一个被扫描的$1$之前一定被移动过,因此扫描的时候可以用预付的费用来支付。摊还时间复杂度为$O(1)$。
因此总的每次调用的时间复杂度均为$O(1)$。
现在你可能会问,这个算法和Gosper's Hack
解决的问题有什么不同吗?
解决的问题是一样的,但是这个新的算法很容易扩展。现在考虑任意的集合$S$,我们先将其中所有$1$出现的位置进行排序,记第$i$个$1$出现的位置为$p_i$,并假设其中出现了$n$个$1$。之后我们维护另外一个集合$T=2^n-1$。之后我们用上面提到的算法遍历$T$的所有k大子集$t$,同时我们为$S$维护对应的k大子集$s$。这样对所有$t$的操作,我们可以同步修改$s$,比如设置$t$的第$i$位,等价于设置$s$的$p_i$位,而反转$t$的$l,r$区间,等价于我们让$s$先异或上$2^{p_1}+\ldots+2^{p_r}$,之后再异或上$2^{p_1}+\ldots+2^{p_{l-1}}$,这部分可以通过前缀和计算。
因此我们得到了一个预处理$O(\log_2 S)$,且$O(1)$计算下一个k大集合的算法。
实际上这个算法非常容易扩展,考虑我们需要同时计算得到的k大集合的一个摘要(比如给集合每个元素赋予一个权重,要算子集的权重和)。一般的做法会每次计算下一个k大集合的时候时间复杂度提高到$O(k)$,但是实际上我们可以同步维护当前的摘要信息,只要它支持批量增加元素和批量删除元素。这样时间复杂度依旧不变,是$O(1)$。
]]>题目1:给定一个序列$a_1,\ldots,a_n$,要求回答$q$个请求,第$i$个请求给定三个参数$l_i,r_i,k_i$,要求找出区间$a_l,\ldots,a_r$中出现次数超过$\frac{r_i-l_i+1}{k_i}$的最小的数,如果不存在则输出$-1$。其中$n,q\leq 10^5$,$1\leq k\leq 5$
这个问题有很多做法。
第一种就是用莫队。莫队内部维护一个统计表,统计每个数在区间中出现的次数。且由于每次每个数出现次数变化最多为$1$,因此对于每个分块,分配一个链表数组,第$i$个元素记录该分块中所有出现次数为$i$的元素组成的链表。很显然这样我们不管某个数出现次数是加$1$还是减$1$,都可以$O(1)$完成,且最重要的是我们可以实时统计每个分块中的最大值。在回答请求的时候,我们可以利用分块中的最大值先找满足条件的编号最小的分块,之后在分块中暴力查找。因此总的时间复杂度为$O((n+q)\sqrt{n})$。
莫队的做法优点是与$k$无关,缺点是常数比较大,且必须离线请求。
第二种做法就是发现,对于某个回答,我们可以不断从区间中选择$k$个不同的数,之后删除。最后留下来的数不超过$k-1$个,我们可以证明结果一定在这留下来的数中。这种区间消除的方式,我们可以用线段树实现,这样每次线段树的查询和修改的时间复杂度都是$O(k^2\log_2n)$。之后如果验证结果呢,这个我们可以通过持久化技术加差分或者直接上倍增完成,校验一个数的时间复杂度为$O(\log_2n)$,总的时间复杂度为$O((q+n)k^2\log_2n)$。
第二种做法的优点是支持在线(我们可以为每个数都开一个稀疏线段树),缺点就是$k$不能过大。
还有一种非常简单的做法就是直接上持久化线段树+差分的技术。线段树的每个结点记录区间中每个数出现的次数。之后对于每个请求,我们暴力扫描线段树,加上一旦发现区间中总数少于阈值就直接退出的剪枝。可以证明一次请求,线段树每一层最多有$k$个顶点被访问(因为阈值决定了每一层最多有$k$个条件满足条件)。总的时间复杂度为$O((n+kq)\log_2n)$。
第三种做法可以充分利用$k$很小的特性。但是$k$比较大的时候还是用莫队比较好。
一道题目。
问题2:给定一个序列$a_1,\ldots,a_n$,以及$q$个请求,每个请求指定一个区间$l,r$,要求找到序列$a_l,\ldots,a_r$中的众数(出现次数最多的数),如果有多个,就输出其中最小的。这里$n,q\leq 10^5$,要求在线处理请求
一道题目。
先离散化数据。
这道题目的核心是发现,要计算$A\cup B$的区间众数,其仅可能为$A$的区间众数,或者是$B$中的某个元素。
上面这个观察允许我们用分块解决这个问题。按照大小$\sqrt{n}$进行分块。
之后考虑一次请求,可以知道答案要么出现在区间两端的块中,要么就是区间中间部分的众数。
我们可以维护两个数组$c$和$p$。其中$c(i,j)$表示前$i$块中$j$的出现次数。这样我们可以利用差分的技巧来快速计算某个数字在连续块中总的出现次数。而$p(i,j)$表示第$i$块到第$j$块中的众数。数组$c$可以直接暴力计算每一块的信息最后统计前缀和,而$p$可以暴力枚举两端块,之后通过$p(i,j-1)$快速转移。预处理的时间复杂度和空间复杂度均为$O(n^{1.5})$。
每次请求我们也能以$O(\sqrt{n})$求解,枚举每个可能的众数即可(总共只有$O(\sqrt{n})$种可能)。
总的时间复杂度为$((n+q)\sqrt{n})$。
问题3:给定一个序列$a_1,\ldots,a_n$,找到一段最长的子数组(从头部和尾部删除若干个元素得到),满足子数组中众数非唯一,如果不存在这样的子数组,返回$0$。其中$1\leq n\leq 10^5$。
昨天比赛的一道题目。
称满足众数非唯一的数组称为好数组。假设$1$为整个数组的一个众数,我们可以直接证明任意最长的好数组一定包含$1$,否则我们可以扩大数组两端得到以$1$为众数的另外一个更长的好数组。
接下来,我们来考虑如何求这样一个好数组。我们可以暴力枚举另外一个众数$x$。找到$x$出现次数大于等于$1$出现次数的子数组,我们可以发现这样的数组一定可以扩展为好数组。当然这样做时间复杂度会变成$O(n^2)$,一种方式就是使用分块。我们只枚举出现次数超过$\sqrt{n}$的元素。而对于出现次数不超过$\sqrt{n}$的元素,我们知道这样的区间中$1$的出现次数也不会超过$\sqrt{n}$。于是乎我们可以分别对$1$出现次数为$1,2,\ldots,\sqrt{n}$暴力遍历数组$a$,每次遍历都使用双指针,整体的时间复杂度为$O(n\sqrt{n})$。
一个连续子序列称为是好的,当且仅当其中有一个数出现超过半数以上,这个数称为众数。
现在给定一个长度为$n$的序列$a_1,\ldots,a_n$,要求统计有多少连续子序列是好的。
提供一道题目。
一种简单的思路是使用分块。先考虑所有出现次数不超过$\sqrt{n}$的数,它们能形成的连续子序列长度不会超过$2\sqrt{n}$。因此我们枚举所有长度不超过$2\sqrt{n}$的连续子序列,如果它里面存在众数,且总数全局出现次数不超过$\sqrt{n}$。
而对于出现次数超过$\sqrt{n}$的数$x$,我们可以构建一个序列$b$,其中如果$a_i=x$,则$b_i=1$,否则$b_i=-1$。问题就变成了有多少子数组和是正数。这个问题咋看之下需要维护一个树状数组来解决,但是实际上由于前缀和只会+1和-1,因此可以通过维护一个普通计数数组就可以了,于是这个新问题的时间复杂度为$O(n)$。而$x$最多取$\sqrt{n}$个数,因此总的时间复杂度为$O(n\sqrt{n})$。
下面讲另外一种思路,可以将时间复杂度优化到$O(n)$。
具体就是我们不分块处理,对于每个不同的数$x$,我们都构建$b$序列,并处理。很显然这样做的时间复杂度为$O(nD)$,$D$为$a$序列中的不同数数目。
假设$x$在$a$中出现$k$次,我们可以尝试将时间复杂度优化到$O(k)$。具体思路是我们可以把$b$中连续的-1合并为到一起。假设有一块连续的-1左边界为$l$,右边界为$r$,且以$l-1$为右边界的最大子数组和为$L$,以$r+1$为左边界的最大子数组和为$R$,如果$L+R-(r-l+1)\leq 0$,这意味着不可能有好数组包含这一整块-1。因此我们可以把这一段-1删除到只剩下$R+L$个,这不会影响我们的结果。可以发现这样做,会导致从左向右扫描的时候每个1最多会激活一个-1,同理从右往左扫描的时候每个1最多会激活一个-1。因此这样处理完后最多有$2k$个$-1$,我们需要处理的$b$序列长度仅为$3k$,用同样的算法,我们可以$O(3k)$处理。
总的时间复杂度为$O(n)$。
]]>