一、SVM简述
SVM支持向量机(英文全称:support vector machine)是一个分类算法, 通过找到一个分类平面, 将数据分隔在平面两侧, 从而达到分类的目的。
其实SVM同其他机器学习算法一样,直观解释并不难理解。简单来说,SVM就是间隔最大化分类。如下图所示,其实能正确对正负样本进行分类的分隔线其实是很多的,通常的分类算法只是找到了其中一条线对样本进行分类:
考虑到这种情况,我们很自然可以想到那么我们可不可以找到一条最优的线来分隔样本呢?这就是SVM考虑的问题。
二、超平面
我们现在要考虑最优的线来对我们的样本进行分类,但线这个概念其实是二维的,当我们的数据是三维时,这就变成了面。但如果我们的样本不止二维(这种情况很常见,很少有数据集只有这么少的特征),那么此时就需要提出超平面的概念。
我们对“平面”概念的理解,一般是定义在三维空间中的:
A、B和C与x、y和z的关系其实可以用向量的形式来表示:
这个平面由两个性质定义:
- 方程是线性的,是由空间点的各分量的线性组合
- 方程数量是1。这个平面是建立在“三维”上的。
如果我们撇开“维度”这个限制,那么就有了超平面的定义。实际上,超平面是纯粹的数学概念,不是物理概念,它是平面中的直线、空间中的平面的推广,只有当维度大于3,才称为“超”平面。
百度百科上对超平面的数学定义是这样的:超平面H是从n维空间到n-1维空间的一个映射子空间,它有一个n维向量和一个实数定义。简单来说就是二维空间里的超平面为一条直线. 一维空间里超平面为数轴上的一个点。
定义完超平面,我们接下来就要考虑距离的问题,我们高中就学过三维空间中点到面的距离公式为:
换成向量的表示形式就是:
那么可以想象,超平面上点到超平面的距离用公式表示也不外乎就是A、B、C之类的参数个数变多而已,所以完全可以用上式来表示超平面上点到超平面的距离。
三、间隔最大化
解决了距离的表示方法,我们就可以回到我们原来的问题上来,我们想要找到一个超平面最大程度地分隔数据。考虑到我们对每个点都求到超平面的距离然后再对这个问题进行优化显然是不可行的,那么我们可以将问题转化为距离超平面最近的样本到超平面的距离最大,也就是说我们先对下面的式子求一个使得该式子最小的,然后再对其求使得该式子最大的和:
但是这样的问题显然还是难以处理,为了能在现实中应用,我们希望能对其做一些简化, 使其变得便于求解的优化问题。
观察距离公式我们可以发现和同乘一个数变成 和 ,对上面这个距离也是没什么影响的,那么我们干脆找一个和使得上面需要变化为1,也就是我们强制使得下面这个式子成立:
另外,考虑到最大化其实也就是最小化,然后为了求导相消的方便,前面补一个1/2,这样我们的问题就变成了下面这个形式:
这种带最小值的约束条件我们是不喜欢的,能不能换个形式呢?比如:
我们用反证法可以证明:
假设我们现在已经求解得满足且使得最小的最优值 (, ),这个最优值 (, )在等号不成立,即 ,那么此时应该存在一个(, )使得成立。因为,所以可以想象应该满足0 < < 1的条件。但这样的话,,这显然与假设是不符合的,所以,我们的问题可以等价为:
对于这个带约束条件的问题,我们最直接的想到的方法应该就是高中学过的构造拉格朗日函数的方法,所以,先用一个小节叙述下构造拉格朗日函数。
四、拉格朗日函数和对偶问题
转化成拉格朗日函数之后,我们自然知道我们要对取最小值,那么 , 应该怎么办呢?我们进行如下操作:
我们对原问题可以这么理解,我们一定要使得约束条件成立,也就是我们求得的u绝对会满足约束条件。那么怎么保证这一点呢?那就是一旦u不满足约束条件,那么其就应该导致这个u不可能会被 选中,转化成如拉格朗日函数一样的形式的话就变成如下表示:
如果我们将 看成是惩罚的话,那么原问题的惩罚就是一旦不符合约束条件,惩罚就是∞。但这样的表达在数学上不美观,那应该怎么表示呢?
我们观察g,当不符合g的条件时,,当符合的时候,。那么我们不就可以用来表示吗?当不符合g的条件,时,只要取为∞,就能使得取到最大值,也就是∞,同理也可以得到当符合的时候时,只要取为0,就能使得取到最大值0。所以我们得到了原问题的等价形式:
那么问题又来了,这个等价问题并不解。所以,我们提出了一个对偶问题:
其实对偶问题从原问题出发非常好理解,我们先抛弃掉我们得到的原问题的等价形式,回到原问题。
我们将原问题转化为拉格朗日函数后,必然第一反应是对其求最小值:
那么 , 怎么办呢?
假设我们现在有一个最优的满足约束条件并且使得f(u)最小,我们将其带入上式就会发现我们并不能保证是最优时,为0,由于 >= 0,又因为当满足约束条件 <= 0,所以我们最多只能保证下面这个不等式:
那么怎么使得它和原问题尽可能地接近,最明显的方式就是对其,当然同上面惩罚的道理,也需要对也进行相同的操作,这样就得到了所谓的原问题的对偶问题:
既然是求最大值,那么最大的问题就是这个等号是不是能成立。
- Slater条件
存在一个最优解 ,使得不等式约束≤0严格成立,即 != 0。
当原问题为一凸优化问题,且满足Slater条件时,有d* = p*,这样就原问题和对偶问题的解一致,求解对偶问题即可。显然,Slater是凸优化问题与其对偶问题等价的一个充分条件。
- KTT条件
考虑一般优化问题(不一定是凸优化),如果有d* = p*,则:
由于d* = p*,所以上面推导过程中所以的不等号“≤ ”应该取到等号。第一个等号得到,这说明 是 的一个极值点,所以 在 处的偏导为零,
另外在取得等号 ,除去本来就应该为0的项之外,现在还需要等于0
综合以上两点,在加上原来的约束,可以得到KKT条件:
尽管KKT条件是d* = p*的必要条件,但当原问题是凸优化问题时,它就升级为充要条件,也就是只要找到,,满足以上五个条件,那么原问题就和对偶问题就有相同的解,分别在和处取得。
注:另外,根据KKT条件中的可以得到, ⇒ ,反过来说,只有,才有可能不为0,这是之后SVM中会用到的一个重要性质。
五、间隔最大化后续
我们将线性支持向量机的拉格朗日函数写出:
其对偶问题为:
我们对于这个对偶问题的求解可以分成两部分:
- 固定α,令拉格朗日函数分别对(w, b)求偏导数并令其等于零:
将结果带入原式子:
回带后的结果:
- 求对α的极大
改变一下符号,使得其变为求最小的问题
那么α怎么求解呢?最笨的方法当然是一一带入去试求,未引入软间隔时,α使用这种方法还是好求的,但加入软间隔后,α的限制范围变多了,就只能寻求更高效的方法,例如SMO算法
假设我们已经得到最优的,那么根据KKT条件可得:
KKT条件所以我们可以推出:
由第三行KKT 条件还可以得到:
所以支持向量机的参数 (w, b) 仅由支持向量决定, 与其他样本无关(最理想的情况当然是我们完美地得到了所有的支持向量,然后得到下面的式子):
其中 SV 代表所有支持向量的集合
b同样可以由上述条件得出:
线性支持向量机的假设函数可表示为:
六、序列最小最优化算法SMO
SMO算法是一种启发式算法,其基本思路是:如果所有的变量的解都满足此最优问题的KKT条件,那么这个优化问题的解就能得到。也就是说由于KKT条件是该优化问题的充分必要条件,所以只有当所有的a都满足KKT条件时,这些a就是该优化问题的解。
但是a的数量众多,并且还需要满足如下等式:
所以,对所有的a直接进行调整使其符合KKT条件显然是一件相当困难的事情。
就该问题,SMO 选择每步同时选择两个变量 和 进行优化,,并固定其他参数,以保证不违背约束。
也就是说我们通过如下方法同时更新和:
此时我们每步的优化目标为(就是把原来的式子中和和无关的去掉了):
注:这里是加了映射函数的意思,去掉不影响,注意这里的c和下面的C不同,下面的C指的是这里的。是松弛因子的意思,具体的推导可以参考:松弛因子
在不考虑其他的约束条件的情况下,我们可以通过用来表示的方式使得本来有两个变量的函数变成只有一个,这个时候其实就可以求偏导并令其为 0得到更新值。
注意:下面推导中中1,2和我们这里的i,j是对应的,仅仅是假设i,j为1,2而已,K是核函数的意思,去掉不影响。
详细的推导可以参考:
结果为:
接着我们就要考虑其他的约束条件:
把SMO中对于同时更新和过程看成线性规划来理解来理解的话,那么下图所表达的便是约束条件:`
根据和同号或异号,可得出α的上下界分别为:
根据这里得到的上下界对上面最近求偏导所得到的α进行剪裁。
最后可以通过其求另一个α的值:
到这里为止,其实SMO算法已经可以基本可以结束了。但是还有一个关键性的问题要解决,那就是和 的选取。
理论上讲, 每步优化时 和 可以任意选择, 但实践中通常取 为违背 KKT 条件最大的α, 而 取对应样本与 对应样本之间间隔最大的变量,也就是选择使| E1 - E2 | 最大的 。 具体操作可以参考:SMO变量选取
SMO的主要步骤如下:
-
第一步选取一对 和 ,选取方法使用启发式方法
先“扫描”所有乘子,把第一个违反KKT条件的作为更新对象,令为 (还有其他一些启发式的方法)。在所有不违反KKT条件的α中,选择使最大的进行更新,使得能最大限度增大目标函数的值 -
固定除和 的其他参数,用表示,进行更新迭代
最后,每次更新完两个乘子的优化后,都需要再重新计算b,及对应的值。
七、机器学习实战中的python实现
#coding=utf-8
import numpy as np
#数据读取
def loadDataSet(fileName):
dataMat = []; labelMat = []
fr = open(fileName)
for line in fr.readlines():
lineArr = line.strip().split('\t')
dataMat.append([float(lineArr[0]), float(lineArr[1])])
labelMat.append(float(lineArr[2]))
return dataMat,labelMat
#该过程即为已经确定了一个alphas,在剩下的中选取另一个alphas构成SMO算法中选择两个来优化的过程
#:已经选择的alphas的下标;全部alphas的数码(要-1,毕竟下标从0算起)
#r:选择的下标
def selectJrand(i,m):
j=i
while (j==i):
j = int(np.random.uniform(0,m))
return j
#剪辑aj,即使得得到的aj在[L,H]的范围内;具体方法参考统计学习方法127页7.108
#:需要剪辑的a;上限H;下限L
#r:剪辑后的aj
def clipAlpha(aj,H,L):
if aj > H:
aj = H
if L > aj:
aj = L
return aj
#使用SMO算法进行SVM
#:数据列表;标签列表;权衡因子(增加松弛因子而在目标优化函数中引入了惩罚项);容错率;最大迭代次数
#r:返回最后的b值和alpha向量
def smoSimple(dataMatIn, classLabels, C, toler, maxIter):
dataMatrix = np.mat(dataMatIn)
labelMat = np.mat(classLabels).transpose()
b = 0
m,n = np.shape(dataMatrix)
alphas = np.mat(np.zeros((m,1)))
iter = 0
while (iter < maxIter):
alphaPairsChanged = 0
for i in range(m):
#计算预测值,利用的公式是alphas*y*(x*x)+b,可参考李航统计学方法中的7.56
fXi = float(np.multiply(alphas,labelMat).T*(dataMatrix*dataMatrix[i,:].T)) + b
#计算出预测值与实际值之间的误差
Ei = fXi - float(labelMat[i])
#如果不满足KKT条件,即labelMat[i]*fXi<1(labelMat[i]*fXi-1<-toler)
#and alpha<C 或者labelMat[i]*fXi>1(labelMat[i]*fXi-1>toler)and alpha>0
#那么就对其进行优化
if ((labelMat[i]*Ei < -toler) and (alphas[i] < C)) \
or ((labelMat[i]*Ei > toler) and (alphas[i] > 0)):
#以下过程即挑选另一个alphasj并计算相应需要的参数
j = selectJrand(i,m)
fXj = float(np.multiply(alphas,labelMat).T*(dataMatrix*dataMatrix[j,:].T)) + b
Ej = fXj - float(labelMat[j])
alphaIold = alphas[i].copy()
alphaJold = alphas[j].copy()
#这里的理解可参考统计学习方法第126页,也就是求出alphas的上下界
if (labelMat[i] != labelMat[j]):
L = max(0, alphas[j] - alphas[i])
H = min(C, C + alphas[j] - alphas[i])
else:
L = max(0, alphas[j] + alphas[i] - C)
H = min(C, alphas[j] + alphas[i])
if L==H: print "L==H"; continue #如果上下界没有变化,就表示不需要更新
# 根据公式计算未经剪辑的alphaj
eta = 2.0 * dataMatrix[i,:]*dataMatrix[j,:].T \
- dataMatrix[i,:]*dataMatrix[i,:].T \
- dataMatrix[j,:]*dataMatrix[j,:].T
if eta >= 0: print "eta>=0"; continue #如果eta>=0,跳出本次循环
#该公式可参考统计学习方法127页7.106
alphas[j] -= labelMat[j]*(Ei - Ej)/eta
#剪辑alphas
alphas[j] = clipAlpha(alphas[j],H,L)
# 如果改变后的alphaj值变化不大,跳出本次循环
if (abs(alphas[j] - alphaJold) < 0.00001): print "j not moving enough"; continue
# 否则,计算相应的alphai值
alphas[i] += labelMat[j]*labelMat[i]*(alphaJold - alphas[j])#update i by the same amount as j
# 再分别计算两个alpha情况下对于的b值
b1 = b - Ei- labelMat[i]*(alphas[i]-alphaIold)*dataMatrix[i,:]*dataMatrix[i,:].T - labelMat[j]*(alphas[j]-alphaJold)*dataMatrix[i,:]*dataMatrix[j,:].T
b2 = b - Ej- labelMat[i]*(alphas[i]-alphaIold)*dataMatrix[i,:]*dataMatrix[j,:].T - labelMat[j]*(alphas[j]-alphaJold)*dataMatrix[j,:]*dataMatrix[j,:].T
# 如果0<alphai<C,那么b=b1
if (0 < alphas[i]) and (C > alphas[i]): b = b1
# 否则如果0<alphai<C,那么b=b1
elif (0 < alphas[j]) and (C > alphas[j]): b = b2
# 否则,alphai,alphaj=0或C
else: b = (b1 + b2)/2.0
# 如果走到此步,表面改变了一对alpha值
alphaPairsChanged += 1
print "iter: %d i:%d, pairs changed %d" % (iter,i,alphaPairsChanged)
# 最后判断是否有改变的alpha对,没有就进行下一次迭代
if (alphaPairsChanged == 0): iter += 1
else: iter = 0
print "iteration number: %d" % iter
# 返回最后的b值和alpha向量
return b,alphas
数据可从此处下载:链接:https://pan.baidu.com/s/1BIuZxz16UPiMWfHYX_lknQ 密码:90al
八、sklearn实现
sklearn中一共有SVC
, NuSVC
和 LinearSVC
三种实现,它们都能在数据集中实现多元分类。
SVC
和 NuSVC
是相似的方法, 但是接受稍许不同的参数设置并且有不同的数学方程(在这部分看 数学公式). 另一方面, LinearSVC
是另一个实现线性核函数的支持向量分类. 记住 LinearSVC
不接受关键词 kernel
, 因为它被假设为线性的. 它也缺少一些 SVC
和 NuSVC
的成员(members) 比如 support_
。
在实践过程中,NuSVC
没怎么用过就暂且不提,但是LinearSVC
确实比直接在SVC
中把kernel设为线性核要快,当然效果也相对差一点。
一般主要还是使用SVC
:
class sklearn.svm.SVC(C=1.0, kernel='rbf', degree=3, gamma='auto', coef0=0.0, shrinking=True, probability=False, tol=0.001, cache_size=200, class_weight=None, verbose=False, max_iter=-1, decision_function_shape='ovr', random_state=None)
Parameters:
-
C : float类型,默认值为1.0
错误项的惩罚系数。C越大,即对分错样本的惩罚程度越大,因此在训练样本中准确率越高,但是泛化能力降低,也就是对测试数据的分类准确率降低。相反,减小C的话,容许训练样本中有一些误分类错误样本,泛化能力强。对于训练样本带有噪声的情况,一般采用后者,把训练样本集中错误分类的样本作为噪声 -
str类型,默认为‘rbf’
算法中采用的核函数类型,可选参数有:‘linear’:线性核函数;‘poly’:多项式核函数;‘rbf’:径像核函数/高斯核;‘sigmod’:sigmod核函数;‘precomputed’:核矩阵,precomputed表示自己提前计算好核函数矩阵,这时候算法内部就不再用核函数去计算核矩阵,而是直接用你给的核矩阵 -
degree: int类型,默认为3
这个参数只对多项式核函数有用,是指多项式核函数的阶数n,如果给的核函数参数是其他核函数,则会自动忽略该参数 -
gamma: float类型,默认为auto
核函数系数,只对‘rbf’,‘poly’,‘sigmod’有效。如果gamma为auto,代表其值为样本特征数的倒数,即1/n_features -
coef0: float类型,默认为0.0
核函数中的独立项,只有对‘poly’和‘sigmod’核函数有用,是指其中的参数c -
probability: bool类型,默认为False
是否启用概率估计,这必须在调用fit()之前启用,并且会fit()方法速度变慢 -
shrinking: bool类型,默认为True
是否采用启发式收缩方式 -
tol: float类型,默认为1e^-3
svm停止训练的误差精度 -
cache_size: float类型,默认为200
指定训练所需要的内存,以MB为单位,默认为200MB -
class_weight: 字典类型或者‘balance’字符串,默认为None
给每个类别分别设置不同的惩罚参数C。如果没有给,则会给所有类别都给C=1,即前面参数指出的参数C。如果给定参数‘balance’,则使用y的值自动调整与输入数据中的类频率成反比的权重 -
verbose : bool类型,默认为False
是否启用详细输出。 此设置利用libsvm中的每个进程运行时设置,如果启用,可能无法在多线程上下文中正常工作。一般情况都设为False,不用管它
max_iter : int类型, -
默认为-1
最大迭代次数,如果为-1,表示不限制 -
random_state: int类型,默认为None
伪随机数发生器的种子,在混洗数据时用于概率估计 -
decision_function_shape:str类型,默认是’ovr’
用来选择如何构造多类的SVM分类器。SVM算法最初是为二值分类问题设计的,当处理多类问题时,就需要构造合适的多类分类器。 具体可以参考:decision_function_shape解释
Attributes:
-
support_:各类的支持向量在训练样本中的索引
-
n_support_:各类各有多少个支持向量
-
support_vectors_:各类所有的支持向量
-
dual_coef_: 对偶系数,即支持向量在决策函数中的系数,在多分类问题中,这个会有所不同
-
coef_: 每个特征系数(重要性),只有核函数是Linear的时候可用
-
intercept_: 决策函数中的常数项,和coef_共同构成决策函数的参数值
Method:
-
fit(X, y): 在数据集(X,y)上拟合SVM模型
-
predict(X): 预测数据值X的标签
-
score(X,y): 返回给定测试集和对应标签的平均准确率
-
decision_function(X): 获取数据集中样本X到分离超平面的距离
-
get_params([deep]): 获取模型的参数
使用例子:
import numpy as np
from sklearn.svm import SVC
# 自己构造的小数据
X = np.array([[-1, -1], [-2, -1], [1, 1], [2, 1]])
y = np.array([1, 1, 2, 2])
# 加载SVC模型(一般来说可能前面还有一个标准化的步骤,这边暂时省掉)
clf = SVC()
# 训练该模型
clf.fit(X, y)
>> SVC(C=1.0, cache_size=200, class_weight=None, coef0=0.0,
decision_function_shape='ovr', degree=3, gamma='auto', kernel='rbf',
max_iter=-1, probability=False, random_state=None, shrinking=True,
tol=0.001, verbose=False)
print(clf.predict([[-0.8, -1]]))
>> [1]
九、SVM相关面试题
这部分还是不断更新:
面试好像对手推SVM和各个参数看得比较重,这部分还是要好好地推和背。
1. SVM的原理是什么?
SVM是一种二类分类模型。它的基本模型是在特征空间中寻找间隔最大化的分离超平面的线性分类器。(间隔最大是它有别于感知机)
- 当训练样本线性可分时,通过硬间隔最大化,学习一个线性分类器,即线性可分支持向量机
- 当训练数据近似线性可分时,引入松弛变量,通过软间隔最大化,学习一个线性分类器,即线性支持向量机
- 当训练数据线性不可分时,通过使用核技巧及软间隔最大化,学习非线性支持向量机
注:以上各SVM的数学推导应该熟悉:硬间隔最大化(几何间隔)---学习的对偶问题---软间隔最大化(引入松弛变量)---非线性支持向量机(核技巧)
2. SVM为什么采用间隔最大化?
当训练数据线性可分时,存在无穷个分离超平面可以将两类数据正确分开。感知机利用误分类最小策略,求得分离超平面,不过此时的解有无穷多个。线性可分支持向量机利用间隔最大化求得最优分离超平面,这时,解是唯一的。另一方面,此时的分隔超平面所产生的分类结果是最鲁棒的,对未知实例的泛化能力最强。
3. 为什么要将求解SVM的原始问题转换为其对偶问题?
-
是对偶问题往往更易求解(当我们寻找约束存在时的最优点的时候,约束的存在虽然减小了需要搜寻的范围,但是却使问题变得更加复杂。为了使问题变得易于处理,我们的方法是把目标函数和约束全部融入一个新的函数,即拉格朗日函数,再通过这个函数来寻找最优点。)
-
自然引入核函数,进而推广到非线性分类问题
4. 为什么SVM要引入核函数?
当样本在原始空间线性不可分时,可将样本从原始空间映射到一个更高维的特征空间,使得样本在这个特征空间内线性可分。在学习预测中,只定义核函数K(x,y),而不是显式的定义映射函数ϕ。因为特征空间维数可能很高,甚至可能是无穷维,因此直接计算ϕ(x)·ϕ(y)是比较困难的。相反,直接计算K(x,y)比较容易(即直接在原来的低维空间中进行计算,而不需要显式地写出映射后的结果)。
核函数的定义:K(x,y)=<ϕ(x),ϕ(y)>,即在特征空间的内积等于它们在原始样本空间中通过核函数K计算的结果。
5. svm RBF核函数的具体公式?
Gauss径向基函数则是局部性强的核函数,其外推能力随着参数σ的增大而减弱。
这个核会将原始空间映射为无穷维空间。不过,如果 σ 选得很大的话,高次特征上的权重实际上衰减得非常快,所以实际上(数值上近似一下)相当于一个低维的子空间;反过来,如果 σ 选得很小,则可以将任意的数据映射为线性可分——当然,这并不一定是好事,因为随之而来的可能是非常严重的过拟合问题。不过,总的来说,通过调控参数σ ,高斯核实际上具有相当高的灵活性,也是使用最广泛的核函数之一。
6. 为什么SVM对缺失数据敏感?
这里说的缺失数据是指缺失某些特征数据,向量数据不完整。SVM没有处理缺失值的策略(决策树有)。而SVM希望样本在特征空间中线性可分,所以特征空间的好坏对SVM的性能很重要。缺失特征数据将影响训练结果的好坏。
7. SVM如何处理多分类问题?
一般有两种做法:一种是直接法,直接在目标函数上修改,将多个分类面的参数求解合并到一个最优化问题里面。看似简单但是计算量却非常的大。
另外一种做法是间接法:对训练器进行组合。其中比较典型的有一对一,和一对多。
一对多,就是对每个类都训练出一个分类器,由svm是二分类,所以将此而分类器的两类设定为目标类为一类,其余类为另外一类。这样针对k个类可以训练出k个分类器,当有一个新的样本来的时候,用这k个分类器来测试,那个分类器的概率高,那么这个样本就属于哪一类。这种方法效果不太好,bias比较高。
svm一对一法(one-vs-one),针对任意两个类训练出一个分类器,如果有k类,一共训练出C(2,k) 个分类器,这样当有一个新的样本要来的时候,用这C(2,k) 个分类器来测试,每当被判定属于某一类的时候,该类就加一,最后票数最多的类别被认定为该样本的类。
参考:
- https://www.cnblogs.com/dreamvibe/p/4349886.html
- 支持向量机通俗导论(理解SVM的三层境界)
- 统计机器学习
- https://www.cnblogs.com/steven-yang/p/5658362.html
- https://blog.csdn.net/on2way/article/details/47730367
- https://www.cnblogs.com/zy230530/p/6901277.html
- 机器学习实战
- sklearn官方手册
- https://www.cnblogs.com/solong1989/p/9620170.html
- https://blog.csdn.net/github_39261590/article/details/75009069
- https://blog.csdn.net/szlcw1/article/details/52336824
- https://blog.csdn.net/szlcw1/article/details/52259668
网友评论