0%

1. 多项式回归

最近高数刚复习到无穷级数这一章,里面提到了非常多的展开式,即用多项式去近似那些我们不容易求的函数,以此简化对实际复杂问题的计算,sinx 就是其中之一。
$$
\begin{gather*}
sinx = \sum\limits_{n=0}^\infty(-1)^n\frac{x^{2n+1}}{(2n+1)!}
\end{gather*}
$$

公式 1:sinx的泰勒展开式

image

图 1:sinx在不同项数下的展开

而今天的主角——多项式回归,有些类似于无穷级数。它是利用了多项式来拟合数据集做出预测,算是线性回归的增强版本,拟合曲线方程为 $y = \sum\limits_{i=0}^{m}\sum\limits_{j=0}^{m-i}W_{im+j}X_1^iX_2^j$(这里出于简便只给出了两个特征时的情况)。

2. 简单实现

观察下图,这是一个由曲线 $y=2.6x^3-3.14x^2-8.8x$ 生成的数据集,当然是人为地加了些呈正态分布的噪声,红色为实际生成曲线。

image

图 2.1:生成的非线性且带噪声的数据集

1
2
3
4
5
6
7
# 数据生成代码
x = np.arange(-10, 10, 0.1)
y1 = 2.6 * x ** 3 - 3.14 * x ** 2 - 8.8 * x + np.random.normal(0, 800, (200, ))
y2 = 2.6 * x ** 3 - 3.14 * x ** 2 - 8.8 * x
plt.scatter(x, y1)
plt.plot(x, y2, c="red")
plt.show()

对于这样的数据集,已知其最高次为三次,那我们可以直接设拟合曲线方程为 $y = w_1 * x^3 + w_2 * x^2 + w_3 * x + b$,采用梯度下降法来拟合曲线(梯度方向上变化率最大,往负梯度方向移动可以减少 y)。

==注:用预测值减去真实值 $w_1 * x^3 + w_2 * x^2 + w_3 * x + b - y^*$ 得到预测误差,将所有误差绝对值相加就成了损失函数(MAE)。==

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 使用MSE作为损失函数
def f(X_b, y, theta):
return np.mean((X_b @ theta - y) ** 2)

# 损失函数的导数
def df(X_b, y, theta):
return X_b.T @(X_b @ theta - y) * 2 / len(y)

# 梯度下降
def gd(X_b, y, theta, eta = 0.01, n_iters = 1e4, epsilon = 1e-6):
i_iter = 0

while i_iter < n_iters:
# 绘制拟合过程
if i_iter < 10:
display.clear_output(wait=True)
plt.scatter(x, y1)
plt.plot(x, (X_b @ theta).reshape(x.shape), c="green", label = 'predict')
plt.plot(x, y2, c="red", label = 'truth')
plt.show()
plt.pause(0.5)

gradient = df(X_b, y, theta)
last_theta = theta
theta = theta - eta * gradient # -=会连同last_theta一起更改
if abs(f(X_b, y, last_theta) - f(X_b, y, theta)) < epsilon:
break
i_iter += 1

return theta

我们先对原始数据进行预处理,其实本质上还是线性回归,但这里我们将 x 的高次幂也当做额外特征进行运算,从单维特征拓展成了多维特征。

==还有个偏置b,所以多加了一列全1列。==

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
X = x.reshape([-1, 1])
X_b = np.hstack([X ** 3, X ** 2, X ** 1, np.ones_like(X)])
print(X_b[:10])

Output:
# x^3 x^2 x^1 x^0
[[-1000. 100. -10. 1. ]
[ -970.299 98.01 -9.9 1. ]
[ -941.192 96.04 -9.8 1. ]
[ -912.673 94.09 -9.7 1. ]
[ -884.736 92.16 -9.6 1. ]
[ -857.375 90.25 -9.5 1. ]
[ -830.584 88.36 -9.4 1. ]
[ -804.357 86.49 -9.3 1. ]
[ -778.688 84.64 -9.2 1. ]
[ -753.571 82.81 -9.1 1. ]]
1
2
3
4
5
6
7
8
9
initial_theta = np.zeros_like(X_b[1])	# 初始权重方便起见都设为0
theta = gd(X_b, y1, initial_theta, eta = 1e-6, n_iters = 1e5)
print(theta)

Output:
[ 2.46963363 -2.44184783 -4.11097282 7.58382791]

Expected:
[2.6 -3.14 -8.8 0]

123

图 2.2:非线性曲线拟合过程

因为绘图比较费时,我只绘制了前十次梯度下降的过程,可以看出仅仅十次更新,训练的曲线已经和真正的曲线相差无几了。

最后在训练了 1e5 次后,系数最终被更新成 **[ 2.46963363 -2.44184783 -4.11097282 7.58382791]**,与原先设定的 [2.6 -3.14 -8.8 0] 差距不大,最后拟合完毕的曲线如下。

image

图 2.3:最终拟合结果

3. 多项式特征

在上述实现中,我们事先知道了这是个一元三次函数,因此将待拟合方程设成了一元三次方程的标准方程。但在正常情况下,我们不可能知道手头的数据集应该是一个几次的方程。所以这也就成为了我们在训练模型前需要额外设置的一个超参数。

而当存在多个初始特征时,多项式回归还能帮助我们寻找到特征之间的关联(这也是较线性回归增强的部分)。例如有两个特征 ab,当次数为 3 时,多项式特征不仅把它们扩展成 $a^3$, $a^2$,$a$, $b^3$, $b^2$,$b$,还会添加这两个特征的组合,$a^2b$, $ab^2$ 和 $ab$。在初始特征 ab 与 **y ** 看似没太大关系时,这些新增的特征反而更能体现与 y 的关系。

1
2
3
4
5
6
7
8
9
10
11
12
13
# 生成多项式特征
def generatePolynomialFeatures(X, degree):
def dfs(X, result, start, col_val, degree):
result.append(col_val)
if(degree == 0):
return

for i in range(start, X.shape[1]):
dfs(X, result, i, col_val * X[:, i].reshape([-1, 1]), degree - 1)

result = []
dfs(X, result, 0, np.ones([len(X), 1]), degree)
return np.squeeze(np.array(result), -1).T

测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
X = np.arange(1,19, 1).reshape([-1, 3])
X_b = generatePolynomialFeatures(X, 2)
print(X)
print(X_b)

# a b c
[[ 1 2 3]
[ 4 5 6]
[ 7 8 9]
[10 11 12]
[13 14 15]
[16 17 18]]

# a b c a^2 ab ac b^2 bc c^2 1
[[ 1. 2. 3. 1. 2. 3. 4. 6. 9. 1.]
[ 4. 5. 6. 16. 20. 24. 25. 30. 36. 1.]
[ 7. 8. 9. 49. 56. 63. 64. 72. 81. 1.]
[ 10. 11. 12. 100. 110. 120. 121. 132. 144. 1.]
[ 13. 14. 15. 169. 182. 195. 196. 210. 225. 1.]
[ 16. 17. 18. 256. 272. 288. 289. 306. 324. 1.]]

==注:n元m次会扩展到 $\frac{(n+d)!}{n!d!}$,这可比指数爆炸恐怖多了,组合爆炸!(还会产生其它问题,后面我们再讲)因此需要谨慎选择 degree 这个超参数。==

4. 封装成类

最后将上述代码封装成一个多项式回归的类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
class PolynomialRegession:

# 生成多项式特征
def generatePolynomialFeatures(self, X):
def dfs(X, result, start, col_val, d):
result.append(col_val)
if(d == 0):
return

for i in range(start, X.shape[1]):
dfs(X, result, i, col_val * X[:, i].reshape([-1, 1]), d - 1)

result = []
dfs(X, result, 0, np.ones([len(X), 1]), self.degree)
return np.squeeze(np.array(result), -1).T

# 均值方差归一化 -> avg = 0, std = 1
def standardization(self, X):
tmp = X[:,1:]
self.mean = np.mean(tmp, axis=0)
self.std = np.std(tmp, axis = 0)
return np.hstack([np.ones([len(X), 1]), (tmp - self.mean) / self.std])

# 使用MSE作为损失函数
def f(self, X_b, y, theta):
return np.mean((X_b @ theta - y) ** 2)

# 损失函数的导数
def df(self, X_b, y, theta):
return X_b.T @(X_b @ theta - y) * 2 / len(y)

# 梯度下降
def gd(self, X_b, y, theta, eta, n_iters, epsilon, plot_process):
i_iter = 0

while i_iter < n_iters:
if (plot_process and (i_iter + 1) % 1000 == 0) and (i_iter + 1) <= 10000 or (i_iter + 1) == n_iters:
self.paint(X_b, theta)

gradient = self.df(X_b, y, theta)
last_theta = theta
theta = theta - eta * gradient
if abs(self.f(X_b, y, last_theta) - self.f(X_b, y, theta)) < epsilon:
self.paint(X_b, theta)
break
i_iter += 1

return theta

# 拟合
def fit(self, X, y, degree, eta = 1e-2, n_iters = 1e5, epsilon = 1e-6, plot_process = True, standard = True):
self.standard = standard
self.degree = degree
self.X_b = self.generatePolynomialFeatures(X)

if standard:
self.X_b = self.standardization(self.X_b)
# initial_theta = np.random.normal(size=(self.X_b[1].shape))
initial_theta = np.zeros_like(self.X_b[1])
self.theta = self.gd(self.X_b, y, initial_theta, eta=eta, n_iters=n_iters, epsilon=epsilon, plot_process=plot_process)
self.coefficient = self.theta[1:]
self.intercept = self.theta[0]

# 预测,归一化用的是训练时的平均值和标准差!
def predict(self, X):
pfX = self.generatePolynomialFeatures(X)
if self.standard:
pfX = np.hstack([(pfX[:, :-1] - self.mean) / self.std, np.ones([len(X), 1])])
return pfX @ self.theta

# 预测分析
def score(self, X, y):
return np.mean((predict(X) - y) ** 2)

def paint(self, X_b, theta):
display.clear_output(wait=True)
plt.scatter(x, y1)
plt.plot(x, (X_b @ theta).reshape(x.shape), c="green", label = 'predict')
plt.plot(x, y2, c="red", label = 'truth')
plt.legend()
plt.title("degree = " + str(self.degree))
plt.xlim(np.min(x) - 0.3, np.max(x) + 0.3)
plt.ylim(np.min(y1) - 5, np.max(y1) + 5)
plt.show()
plt.pause(0.5)

在上述实现中,新增了一个名为 standardization(均值方差归一化)的方法,这是因为多项式化采用了特征组合的方式,在 degree 很大时,例如 100,那么新增特征中的最高次就会是 100 次,这是一个十分恐怖的数,而最低次只是常数级,各个特征维度之间的跨度非常大!这就导致 eta 必须非常小,但凡稍大一点,高次项系数更新就会溢出,而过小的 eta 会导致低次项的系数根本更新不动(需要数以千万计次的更新)!

由此我们便加入了 standardization,在多项式化之后将各个特征放缩到均值为 0,方差为 1 的范围上来,把有量纲的表达式改成无量纲的表达式,将数据的所有特征都映射到一个统一的尺度下。

而且归一化后可以加速训练,**图 4.1 **左侧因为进行了归一化,因此等高线几乎是正圆,等高线上的法线指向圆心,因此能快速收敛到最小值,而右图没有进行归一化则需进行多次迭代。

==注:由于进行了归一化,我们的模型都是按照归一化的数据进行训练的,因此测试数据也要进行归一化,否则没有意义了。==

图 4.1:归一化与未归一化(图源来自机器学习实战)

okay,下面让我们来测试一下这个多项式回归类!

1
2
3
4
5
6
7
x = np.arange(-5, 5, 0.1)
# 这次换一个四次函数
y1 = -0.2 * x ** 4 + 5 * x ** 2 + np.random.normal(0, 10, (100, ))
y2 = -0.2 * x ** 4 + 5 * x ** 2
plt.scatter(x, y1)
plt.plot(x, y2, c="red")
plt.show()

image

图 4.2:四次函数数据样本

1
2
3
pr = PolynomialRegession()
X = x.reshape(-1, 1)
pr.fit(X, y1, 4, 1e-5)

222

图 4.3:四次函数拟合过程

诶,那这个时候又有人要问了啊,诶虽然现在是拟合了数据集,但这不还是事先知道了这是个四个方程才设的多项式方程吗?嗯没错,接下来就看一下,如果 degree 取其它值会发生什么情况?

image

图 4.4:degree的不同取值(未加归一化)

image

图 4.5:degree的不同取值(加归一化)

degree 较小时,其拟合曲线也较为简单,并不能很好地反映样本的趋势,也就是==欠拟合==。

而当 degree 逐渐增加时,其拟合的曲线便会更加 复杂,有更多的 “==弯==”,也就意味着当 degree 足够大时,其完全可以将整个样本给 “记住”,以达到在训练集上几乎百分百的预测率,这就是 ==过拟合==。但这根本不是我们所需要的!我们希望训练出来的模型不仅能在训练集上表现良好(当然喽),还要能对未来中那些没有见过的数据给出正确的预测,不然就是在自欺欺人罢了!

**图 4.4 ** 后面两张就是因为 eta 过小,训练不充分还没有完全拟合,看不出来过拟合的效果(PS:已经迭代 100W 次了,在往上耗时太长了)。

那么当给出训练集样本之外的数据时(这里训练集之外的样本指 [-100, 100] 的曲线),这些在训练集上表现 “貌似还不错” 的模型的表现又会怎么样呢?

image

图 4.6:部分模型的预测情况

图 4.6,只有 degree = 4 时和原曲线还算是接近,其余的一个比一个离谱,degree 越大,误差越大。

至于如何减少过拟合呢,通俗来讲就是对模型进行==约束==。模型所拥有的 自由度 越少,那么其就越不会过拟合。

通常我们可以减少方程的 “容量”,交叉验证,dropout (不过在只有一层的传统机器学习中就相当于减少方程容量了)等,又或者是下面要介绍的正则化。

5. 正则化

正则化,一言以蔽之就是 限制 模型的 ==权重==,一般有 岭回归(L2),Lasso 回归(L1)和 **弹性网络 **等。

那么该怎么限制权重呢?很简单,把权重也一起加入到损失函数中就行了,这会迫使梯度下降在更新的时候不仅需要考虑如何让模型尽量拟合测试数据,还要考虑如何让权重尽可能地小。

5.1 岭回归

公式 5.1.1:岭回归损失函数
$$
\begin{equation*}
\begin{split}
Ridge(\theta)
& = loss(\theta) + \frac{\alpha}{2}\sum\limits_{i=1}^{n}\theta^2_i\
& = \frac{1}{m}(X\theta-y)^T(X\theta-y) + \frac{\alpha}{2}\theta^T\theta
\end{split}
\end{equation*}
$$
公式 5.1.2:岭回归导函数
$$
\begin{equation*}
\begin{split}
\frac{dRidge(\theta)}{d\theta}
& = \frac{dloss(\theta)}{d\theta} + \alpha\theta\
& = \frac{2}{m}X^T(X\theta-y) + \alpha\theta
\end{split}
\end{equation*}
$$
公式5.1中,不仅将权重加入到了损失函数中,还设置了一个超参数 α 用以调节正则化权重的==占比==。

α = 0 时,其就变为了普通的多项式回归,岭回归不会再起作用。α 越大,各特征的权重值就越小,体现在图像上则为曲线越光滑越平坦。当 **α -> $+\infty$**,为了使损失函数尽可能地小,梯度下降只能将权重尽可能地都更新为 0,也就变成了常值函数($y = \overline{y}$)。

因此 alpha 的取值还是比较考究的。

==注1:公式中 i 从 1 开始,即不包含偏置项,因为偏置项只用来调整曲线位置,并不会扭曲其图像。==

==注2:使用岭回归之前,一定一定要归一化数据!!!(血的教训)大多数正则化模型都对输入特征的缩放十分敏感!!!==

下面给出岭回归的部分代码实现,这里只给出了较多项式回归增加的部分,其余基本一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
class RidgeOfPolynomialRegession:

# Ridge
def f(self, X_b, y, theta):
return (X_b @ theta - y) @ (X_b @ theta - y) / len(y) + theta[1:] @ theta[1:] * self.alpha / 2

# dRidge
def df(self, X_b, y, theta):
return X_b.T @(X_b @ theta - y) * 2 / len(y) + np.hstack([self.alpha * theta[1:], np.zeros(0)])

# 正规方程解
def math(self, X_b, y):
return np.linalg.inv(X_b.T @ X_b + self.alpha * np.eye(X_b.shape[1])) @ X_b.T @ y

为了保证严谨,还是用的之前的样本,对比一下用了岭回归和没用岭回归的差别。

image

图 5.1.1:四次函数样本图像
1
2
3
4
5
6
7
x = np.arange(-1, 1, 0.15)
X = x.reshape(-1, 1)
y1 = x ** 2 - 3 * x + np.random.normal(0, 0.3, size = x.shape)
y2 = x ** 2 - 3 * x
plt.scatter(x, y1)
plt.plot(x, y2, c="red")
plt.show()

图 5.2 所示,由于曲线次数比较高,导致表达能力过强了,会有一种曲线就是在把样本连起来的感觉,学习拟合变成了连线游戏。

image

图 5.1.2:没有岭回归
1
2
3
4
5
plt.plot(x, y2, c = 'red', label = "truth")
plt.scatter(x, y1, c = 'blue')
plt.plot(x, y_test_1, c = "green", label = "degree = 20")
plt.legend()
plt.title("WithoutRidge")

而在加上岭回归之后,明显曲线就变光滑很多了,不是纯粹地只记住了样本,与原函数相比还是挺接近的,可以进行一定程度的预测。

image

图 5.1.3:岭回归 α = 1
1
2
3
4
5
plt.plot(x, y2, c = 'red', label = "truth")
plt.scatter(x, y1, c = 'blue')
plt.plot(x, y_test_2, c = "green", label = "degree = 20 & α = 1")
plt.legend()
plt.title("WithRidge")

5.2 LASSO 回归

讲完岭回归之后,LASSO 回归就很简单了,全名 Least Absolute Shrinkage and Selection Operator Regression,中文名最小绝对收缩和选择算子回归。

与岭回归类似,只是新增项变成了 L1 范式 —— $\alpha\sum\limits_{i=1}^{n}|\theta_i|$。

公式 5.2.1:LASSO 回归损失函数
$$
\begin{equation*}
\begin{split}
LASSO(\theta)
& = f(\theta) + \alpha\sum\limits_{i=1}^{n}|\theta_i| \
& = \frac{1}{m}(X\theta-y)^T(X\theta-y) + \alpha\sum\limits_{i=1}^{n}|\theta_i|
\end{split}
\end{equation*}
$$
公式 5.2.2:LASSO 回归导函数
$$
\begin{equation*}
\begin{split}
\frac{dLASSO(\theta)}{d\theta}
& = \frac{dloss(\theta)}{d\theta} + \alpha sign(\theta)\
& = \frac{2}{m}X^T(X\theta-y) + \alpha sign(\theta)
\end{split}
\end{equation*}
$$
绝对值函数==在零点处不可导==同时也是极小值,因此要手动让其等于 0。而函数值大于 0 时导数为 1,小于 0 时为 -1,这不就是符号函数(sign)了嘛。

LASSO 的新增代码。

1
2
3
4
5
6
7
8
class LASSOOfPolynomialRegession:
# LASSO
def f(self, X_b, y, theta):
return (X_b @ theta - y) @ (X_b @ theta - y) / len(y) + np.sum(np.abs(theta[1:])) * self.alpha

# dLASSO
def df(self, X_b, y, theta):
return X_b.T @(X_b @ theta - y) * 2 / len(y) + self.alpha * np.hstack([np.zeros(1), np.sign(theta[1:])])

同岭回归类似,下面只给出拟合结果。

image

图 5.2.1:没有LASSO回归

image

图 5.2.2:LASSO回归 α = 0.1

5.3 对比

既然岭回归和 LASSO 回归都是通过正则化来降低过拟合,那么它们两个有什么区别呢?

答:区别是肯定有的,请看下面两张图。(α 为[0.1, 1, 5, 10, 20])

image

图 5.3.1:岭回归下不同α的图像

image

图 5.3.2:LASSO 回归下不同α的图像

通过上两张图可以发现,在 α 逐渐增大时,二者的表现还是有所不同的。岭回归会越来越==平坦==(但始终还是曲线),而 LASSO 回归则会越来越==直==。

首先,α 越大,损失函数中关于参数权重正则化的那部分比重也就越大,由此我们可以忽略前面的 MSE

岭回归的导函数也就可以近似为 αθ,同理 LASSO 回归也可以近似为 **sign(θ)**。

sign(θ) 只有三个值:-1, 1, 0。因此只能按照非常规则的方式更新,而在这个过程中就会有一些权重值提前来到零点,反映在图像上就会越来越接近直线。这也是它名字的由来——==选择算子回归==,将没用的特征权重设为零(当然也可能是有用的),留下另一些特征。

image

图 5.3.3:LASSO 梯度下降

αθ 则会根据每个特征赋予不同权重更新,如 **图 5.3.4 **更新轨迹呈一个曲线型,权重一般不会更新成 0,反映在图像上也就会越来越平坦,像==山岭==一样。

image

图 5.3.4:岭回归 梯度下降

5.4 弹性网络

弹性网络 将上述两个正则化结合在了一起,算是进行了折中。

公式 5.4.1:弹性网络损失函数
$$
\begin{equation*}
\begin{split}
LASSO(\theta)
& = f(\theta) + r\alpha\sum\limits_{i=1}^{n}|\theta_i| + \frac{1-r}{2}\alpha\sum\limits^n_{i=1}\theta_i^2\
& = \frac{1}{m}(X\theta-y)^T(X\theta-y) + r\alpha\sum\limits_{i=1}^{n}|\theta_i| + \frac{1-r}{2}\alpha\sum\limits^n_{i=1}\theta_i^2
\end{split}
\end{equation*}
$$
弹性网络 新增了一个超参数 r,而 α 的含义仍旧没变。我们可以通过调节 r 来控制 LASSORidge 的==混合比==。

r = 1 时,该弹性网络等效于 LASSO 回归;当 r = 0 时,该弹性网络等效于 岭回归

单调栈

单调栈,顾名思义,在栈中维持了一个单调性,再配合其特有的先进后出的性质,可以很容易求出在某个数的左边和右边离它最近的大于(或小于)它的数

递增栈

这里使用递增的栈作为例子,递减同理。

有数组 **arr = [3, 5, 6, 2, 9, 4, 1]**,现在要求出距离每个点左边和右边最近的小于它的数的下标。

开始时,栈空

image

秉承着递增的规则,只要当前数大于栈顶元素,直接入栈。

==PS:说是这么说,但实际上都是放入该数的下标,因为下标所蕴含的信息可比单单一个数多,下标只需要通过原数组就能转换为其中的元素了。==

当遍历到 2 的时候,栈顶元素为 6(arr[2]) ,此时若将 2 入栈则不满足单调递增,也就是说,这个 2 是栈顶元素 6 的右边离他最近且比他小的数。

image

将栈顶元素弹出,并在 right 数组中记录 right[2] = 3,因为我们是依据递增性入栈,栈顶元素最大,在其被弹出后,新的栈顶成为了最大的元素,也就是老栈顶左边的离它最近且小于它的元素,记 left[2] = 1

image

之后新上位的 5 接着和 2 作比较,循环往复,直至可以将 2 入栈且能维持递增性。

==注意:当栈顶元素弹出后,栈空,说明左边没有比其更小的数了,用 -1 表示。==

image

**right[i] **很好理解,毕竟只要遇见降序就会被记录下来,可 left[i] 要怎么理解呢?怎么能保证次栈顶是栈顶左侧离它最近且小于它的数呢?

可以看一下下面的图,绿线处代表出现递减,在其左侧且高于绿线的点会被弹出,并记录答案,而低于绿线的点因为还没有发现右侧离它最近且小于它的点,按兵不动。

image

将该点从栈中弹出后并不会对后续答案产生纰漏,因为在该点以前的点都比该点小(该点以前的点不会拿该点当 right),且在该点后面位置出现了比该点更小的数(该点以后的点不会拿该点当 left),而该点也找到了其左右答案。

image-20220305110114472

后续同理。

image

最后当数组遍历完成,而栈中还有元素时,说明此时栈中的元素右侧都没有比它小的数了,左侧比它小的数就是被其压在下面的数。

image

==同理,递减栈可用于求出距离每个元素最近且大于其的元素。==

Manacher

Manacher 俗称“马拉车”,可以以 $O(n)$ 的代价求出以每一个位置为回文中心时的回文半径。

比较常见的暴力算法,即枚举以每一个点为回文中心向外拓展,直至不满足回文条件,时间复杂度为 $O(n^2)$。

Manacher 又是通过什么样的方式将复杂度降到了线性阶呢?

1. 预处理数组

众所周知,回文串可以看成是一个对称的字符串,即正着念倒着念都一样,但这就会引发一个问题,回文串长度为奇数和偶数时,其回文中心可能会落在某个字符上(奇数时),也可能落在两个字符中间(偶数时),诚然分类讨论可以解决,但代码量就是两倍,比较冗长。

这时便出现了预处理数组,具体的预处理手段说来也很简单,以一个==不会出现在字符串的字符==来分隔原字符串。

image

这样就不会出现回文半径在两个字符之间的情况了,能够很好地将奇偶情况结合起来,最后将预处理数组求出来的 回文半径/2 就是原来字符串的回文半径。

1
2
3
4
5
6
7
8
// 在预处理字符串的头和尾插入不同的字符可以不用额外判断越界
// 当然插入头尾的这两个字符也不能是字符串中出现过的
StringBuilder sb = new StringBuilder("$#");
for (int i = 0; i < len; i++) {
sb.append(s.charAt(i) + "#");
}
len = sb.length();
sb.append("?");

2. 求回文半径

在求回文半径之前,要引入两个新的变量,一个是 right (拓展到过的最右下标),一个是 center (最右下标对应的回文中心)。

对于任何一个字符(下标为 i),必然满足以下两种情况。

  • i > right,没有办法优化,只能暴力拓展。

  • i <= right,设 i’ 为该点关于 center 对称的点,radius 为其最大回文半径。

    • i’ 为回文中心的回文串在 right 内,即 i + radius < right

      image

      显然,此时 i 的最大回文半径等于 i’ 的最大回文半径,即 radius。

    • i’ 为回文中心的回文串在 right 外,即 i + radius > right

      image

      此时,i 的最小回文半径为 right - i,至于多大,需要向外暴力拓展

    • i’ 为回文中心的回文串在 right 上 ,即 i + radius = right

      image

      此时,i 的最小回文半径为 radius,至于多大,需要向外暴力拓展。

对于上述四种情况,我们可以统一一种写法。

1
2
3
4
5
6
// i > right,最小回文半径为1,即它自己
// i <= right:
// 如果以i'为回文中心的回文串在right内,radius[center - (i - center)] 会是最小的
// 如果以i'为回文中心的回文串在right外,right - i 会是最小的
// 如果以i'为回文中心的回文串在right上,两者相同
radius[i] = i <= right ? Math.min(radius[center - (i - center)], right - i) : 1;

对四种情况都进行暴力拓展,虽然情况 2.1 不需要向外拓展,但即使拓展了也只会拓展一次,失败就退出了,而好处却可以缩短代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int[] radius = new int[len];
int center = -1, right = -1;
for (int i = 1; i < len; i++) {
radius[i] = i <= right ? Math.min(radius[center - (i - center)], right - i) : 1;
// 如果未在首尾添加额外字符,这里需要判断越界
while (sb.charAt(i + radius[i]) == sb.charAt(i - radius[i])){
radius[i]++;
}
// 如果新拓展的位置超过了原来的右边界,更新
if (i + radius[i] - 1 > right){
right = i + radius[i] - 1;
center = i;
}
}

Dijkstra

Dijkstra 算法可以解决从一点出发,到所有其它点的最短路径问题,但只可用于没有负权重的图,这是一个基于贪心,广搜以及动态规划的算法

要点

每次从==未求出最短路径的节点==中取出距离起点最近的节点,然后以这个节点为桥梁向外拓展,求出新的最短路径。

举例如下,要求以 A 为原点,到其他点的最短路径。

image

  • result:已求出最小路径的顶点
  • notFound:为求出最小路径的顶点

开始时,result = {A(0)},notFound = {B(5),C(3),D($\infty$)},然后从 notFound 中选取 C 点,刷新 result = {A(0),C(3)}, notFound = {B(5),D(12)},以此类推。

时间复杂度:$O(n^2)$

空间复杂度:$O(n^2)$

证明

但是这个算法为什么是正确的呢?这里就简单证明一下。

在那儿之前,先来看看这样一道题:我们规定因数中只含2,3,5(当然也包括1和本身)的数为 α 数,请按顺序输出前 n 个α 数。

解题思路:建立一个小根堆,一开始往其中压入 1,之后每次从堆中弹出一个数 x 并输出(==注意去重==),并压入 2x3x5x,直到弹出 n 个数为止。

很好理解,排在后面的 α 数必然是由排在前面的数,或是乘2,或是乘3,或是乘5所得,而 1 满足 α 数的条件,其乘以 2,3,5 所产生的数也必然满足 α 数,再加上有小根堆的堆化排序,每次弹出的数就是下一个 α 数。

再回到 Dijkstra 这个问题中来,一条最短路径一定是由另一条最短路径拓展得来,假使 A -> D -> B -> CA -> C 的最短路径,那么 A -> D -> B 一定是 A -> B 的最短路径,可以用反证法来证明,如果存在一条路径 A -> X -> B 使得 A -> B 路径最短,那么 A -> D -> B -> C 就不是 A -> C 的最短路径,而是 A -> X -> B -> C 了。

一开始我们只有 A -> A = 0 这一条路径,借此拓展其他路径,在从这众多路径中选取距离原点最短的路径,这条路径就是最短路径,因为==非负权重==使得不可能再从其它点折返回来使得路径更低。

时间优化

先前提到过了,该算法的时间复杂度为 $O(n^2)$,每次选取一条最短路径,一共要选 n-1 次,而最短路径的经典做法的选取代价是 $O(n)$ ,但我们可以使用小根堆来优化这个过程,将选取最短路径的代价降低到 $O(logn)$,从而使得整体的时间复杂度为 $O(nlogn)$。

空间优化

采用邻接矩阵存储图的方式需要构建 $n *n$ 的矩阵,因此空间复杂度为 $O(n^2)$,但平方级别的代码,如果顶点数超过了 $10^4$,很容易 Out Of Memory,这时就可以使用==链式前向星存图==的方式,虽然名字中带有链式二字,但为了运行速度采用了数组的存储方式,不过思想还是链表

假设共有 n 个点,m 条边。

具体做法呢,需要开辟四个数组,head[n], to[m], next[m] 和 weight[m]。

**head[n]**:存放以 i 为起点的第一条边的下标。

**to[m]**:第 i 条边所指向的节点。

**next[m]**:第 i 条边的下一条边的下标。(起点相同)

**weight[m]**:第 i 条边的权重。

在读入数据时需要填入上述四个数组中。

1
2
3
4
5
6
7
8
9
10
int cnt = 0;
for (int i = 1; i <= m; i++) {
int f = readInt(); // 起点
int t = readInt(); // 终点
int v = readInt(); // 权重
next[++cnt] = head[f]; // 将第i条边的下一条边指向以f为起点第一条边
head[f] = cnt; // 将f为起点的第一条边设置为当前边
to[cnt] = t; // 第i条边的终点是t
weight[cnt] = v; // 第i条边的权重是v
}

举个具体的例子,n = 4, m = 3。

数组初始化如下,head[n] 中的 0 不像其他三个数组,是有意义的,代表了没有以 i 开头的边的信息,这也是后面更新路径跳出循环的判断条件。

image

第一条边:1 -> 2 = 6

to[1] = 2weight[1] = 6:分别代表了第一条边的终点以及权重。

head[1] = 1:代表以节点 1 为起点的第一条边是所有边中的第一条边

next[1] = 0:代表第一条边的下一条边是第 0 条边,也就是没有下一条边。

image

第二条边:3 -> 2 = 4

to[2] = 2weight[2] = 4:分别代表了第二条边的终点以及权重。

head[3] = 2:代表以节点 3 为起点的第一条边是所有边中的第二条边

next[2] = 0:代表第二条边的下一条边是第 0 条边,也就是没有下一条边。

image

第三条边:1 -> 4 = 3

to[3] = 4weight[3] = 3:分别代表了第三条边的终点以及权重。

head[1] = 3:代表以节点 1 为起点的第一条边是所有边中的第三条边,被覆盖的边的信息需要先存储到 next 中,不然就丢失了,也就是将这条边的下一条边指向原先以 1 为起点的第一条边,类似链表的头插法。

next[3] = 1:代表第三条边的下一条边是第 1 条边。

image

那么该如何访问以某个节点为起点的边呢?这里我们举例以 1 为起点的边。

head 中存储了以 i 为起点的第一条边,因此 head[1] 就是起始位置,而下一条边就是 **next[head[1]]**。

1
2
3
for (int i = head[start]; i != 0; i = next[i]) {
// complete the loop
}

虽然说这样我们就无法用 $O(1)$ 的代价去找到某一条边,但在 Dijkstra 中我们并没有这样的需求,甚至其还帮我们过滤掉了不可达的点。

空间复杂度:$O(m + n)$

==注:如果存储无向边需要多开一倍的空间。==

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
import java.io.*;
import java.util.Arrays;
import java.util.Comparator;
import java.util.PriorityQueue;

public class Dijkstra {
static StreamTokenizer st = new StreamTokenizer(new BufferedReader(new InputStreamReader(System.in)));

public static int readInt() {
try {
st.nextToken();
} catch (IOException e) {
e.printStackTrace();
}
return (int) st.nval;
}

public static void main(String[] args) {
int n = readInt();
int m = readInt();
int s = readInt();
int cnt = 0;
int[] head = new int[n + 1];
int[] next = new int[m + 1];
int[] to = new int[m + 1];
int[] weight = new int[m + 1];
for (int i = 1; i <= m; i++) {
int f = readInt(); // from
int t = readInt(); // to
int v = readInt(); // weight
next[++cnt] = head[f];
head[f] = cnt;
to[cnt] = t;
weight[cnt] = v;
}

PriorityQueue<Node> queue = new PriorityQueue<>(new Comparator<Node>() {
@Override
public int compare(Node o1, Node o2) {
return o1.weight - o2.weight;
}
});

queue.offer(new Node(s, 0));
boolean[] isVisited = new boolean[n + 1];
int[] distance = new int[n + 1];
Arrays.fill(distance, Integer.MAX_VALUE);
distance[s] = 0;
while (!queue.isEmpty()) {
Node node = queue.poll();
int t = node.to;
if (isVisited[t]) {
continue;
}
isVisited[t] = true;
for (int i = head[t]; i != 0; i = next[i]) {
if (distance[to[i]] > distance[t] + weight[i]){
distance[to[i]] = distance[t] + weight[i];
}
if (!isVisited[to[i]]){
queue.offer(new Node(to[i], distance[to[i]]));
}
}
}

StringBuilder sb = new StringBuilder();
for (int i = 1; i <= n; i++) {
sb.append(distance[i] + " ");
}
System.out.println(sb);
}

static class Node {
int to;
int weight;

public Node(int to, int weight) {
this.to = to;
this.weight = weight;
}
}
}