开平方是编程过程中常用的操作,几乎所有的编程语言,都内置实现了计算开平方的语法或者函数,更有甚者,有些芯片以硬件的形式实现了开平方的操作。或许开平方如此常用,又有好用的库函数相辅助,以至于我们认为开平方是如此的平平无奇,从未深入研究其是如何在计算机上实现的。在学习编程的时候,我常常在想,既然计算机指令只能实现加减乘除这四个基本的算术运算,那么像开方、乘幂、对数等操作又是如何实现的呢?不过那时受限于高等数学以及计算机算法水平,这些疑问始终没有尝试寻求答案。后来,机缘巧合的情况下,我接触高性能计算方面的工作,才逐步深入研究各种算法的底层原理。本文总结了四种直接实现开平方算法的原理和代码实现。
1、牛顿迭代法开平方
如图1所示,牛顿迭代法的原理是反复利用切线与坐标轴相交,逐步逼近方程的零点。这是一种快速、高精度的算法,是目前工业上主要采用的方法,硬件逻辑实现或者编程语言内置库函数,均是基于牛顿迭代法或者是基于牛顿迭代法的改进算法。

如图1 所示,从开始迭代,过
点作曲线
的切线(红色虚线),该切线方程为:
零点y=0,此时x的值即为的值,于是有:
化简得:
这就是牛顿迭代法的通用公式。对于大于零的数M进行开方,则可以构造以下方程:
求导数得
,于是得开平方的牛顿迭代公式如下:
牛顿迭代法通过选择一个初值,开始对迭代式进行迭代运算,直到精度满足要求后停止迭代。对于开平方而言,任意大于0的初值,迭代式总能收敛到指定精度,但是收敛的速度跟初值的选择有关。初值选择的原则:初值越接近真值,迭代的速度越快。这里介绍一种简单的初值计算方法(非最优的方法),当M大于等0小于等1时,初值设为1;当M大于1时,初值设为。
以下是C语言实现的牛顿迭代法开平方的代码:
double sqrt_newton(double x,double err=0.000001)
{
double x1,x2;
x2=(x>=1)?(0.5*x):x;//计算初值
do
{
x1 = x2;
x2 = 0.5*(x1 + x / x1);
}
while ((x2-x1)*(x2-x1)>err );//迭代精度达到设定的值err时停止迭代
return x2;
}
2、二分法开平方
二分法求方程的根,也是比较常用的方法,它的适应性比牛顿迭代法更强。对于开平方而言,二分法也总能收敛到指定的精度,只是计算的性能,比牛顿迭代法稍差。

二分法和牛顿迭代法一样,需要构造一个方程(见图2),二分法求解方程零点的核心思想是:
(1)首先给定一个初始的区间[a,b],使得方程的零点处在该区间之内;
(2) 然后计算区间的中点m,;
(3)以零点为分界线,判断中点m与区间哪个端点在同一侧,然后将该区间端点更新为m,如图2所示,中点m与端点b同在一侧,则令b赋值为m;
(4)反复进行步骤1、2、3,直到a与b的误差达到指定范围之内。
二分法虽然总是能够收敛,但是为了达到更好的计算速度,对于初始区间的选择很重要。这里介绍一种简单的初值计算方法,以4为分界点,以0为选择的下限,以M的一半为选择的上限。对于的情况,令区间的上下限a=0,b=2;对于
的情况,令区间的上下限a=2,b=M/2。
如何判断m是和a还是和b是同一侧呢?这个判断简单,那就是判断中点和端点的函数值是否同号即可,如果f(m)与f(a)同号(),那么m与a在零点的同一侧,否则m与b在零点的同一侧。即,该算法可描述为:如果
,则令
,b不变,否则a不变,而令
。
二分法开平方的C语言代码如下:
double sqrt_bin(double x, double err = 0.000001)
{
double a,b,m;
//计算初值
if (x <= 4)
{
a=0;
b=2;
}
else
{
a=2;
b=0.5*x;
}
do
{
m=(a+b)*0.5;
if ((m*m-x)*(a*a-x) > 0)
{
a=m;
}
else
{
b=m;
}
} while ((b - a)*(b-a)>err);
return m;
}
3、梯度下降法(误差反向传播)
最近几年,人工智能方兴未艾,而人工智能模型训练最常用的权重更新方法就是梯度下降法。既然可以用梯度下降法,使得模型趋向于最优,那么能否使用梯度下降法使得方程趋于零点呢?答案是肯定的。
针对正数的M开平方,首先构造一个预测函数,真值函数
=0,通过调整自变量w,使得预测函数的值逐渐趋近于真值。如果预测函数与真值的差值为0,那就是最好的,意味着我们找到了方程的精确根,但实际上总是有点误差,这个误差就称之为损失,为了后续计算的方便,这里把绝对误差的平方,作为预测函数与真值的损失函数,其表达式如下:
显然,损失函数L是与w有关的函数。我们的目标是,通过调整w,使得L尽可能趋向于0,这样求得的w即为开平方的根。那么如何调整自变量w,才能使L以最快的速度趋向于0呢?答案是沿着损失函数L的梯度反方向调整w,于是w的调整表达式如下:
上式中,是梯度,且
。通常,为了进一步控制w的变化幅度,会在梯度上加一个系数
,这个系数称之为学习率。于是,w的变化公式变成如下:
学习率的大小影响着收敛的速度和精度,因此学习率的选择很重要。一般情况下,为了防止迭代的结果产生较大波动的情况出现,学习率设置成一个比较小的数,比如本文直接把它设为。
以下是使用梯度下降法求解开平方根的C语言代码:
double sqrt_gd(double x, double err = 0.000001,double lr=0.001)
{
double w;
w = (x >= 1) ? (0.5*x) : x;//计算初值
do
{
w=w-lr*4*w*(w*w-x);//更新权重
} while ((w*w-x)*(w*w-x)>err);
return w;
}
4、泰勒级数法
泰勒级数恐怕是高等数学中除了微积分之外,第二重要的内容了,它可以将一个复杂的方程式,转变为一个线性多项式,从而将超越方程变成计算机可以求解的模式。将一个方程展开为泰勒级数,有以下通用公式:
上式子中,x0为展开点,为x0点处的n阶导数。针对M的平方根求解,先构造有个方程
,然后求出该函数的各阶导数如下:
为了方便计算,通常选择一些特殊的点进行泰勒展开。由于x=0处,的各阶导数不存在,所以不能在x=0处展开,于是可以选择另一个特殊点x=1处展开,于是令x0=1,得到
的泰勒展开式:
通过对泰勒展开式的余项进行分析,得知,该泰勒展开式的收敛半径为1,即,利用以上泰勒展开式,只能求解(0,2]区间内的平方根。那么,求解其他更高区间的平方根,又该如何操作呢?一个可行的办法是,先在x=1处进行泰勒展开,求解x=2点处的平方根,然后在x=2处进行泰勒展开,从而求解[1,3]区间内的平方根,以此类推,可以逐步求解更高区间内的平方根。
泰勒级数法开平方并不是什么好算法,其缺点非常明显,作为算法研究把玩一下可以,实际用途不推荐选用。首先,计算一次泰勒级数本身就要花费大量的时间,且求解大数的开方需要计算若干次泰勒级数,数值越大,求解泰勒级数的次数越多,随着数值的增加,求解的时间基本就是无法容忍的长,实测计算10000的开方,由于递归次数太多,程序堆栈空间不够而自动退出了;其次,由于下一次求解泰勒级数需要用到上次一求解泰勒级数的结果,由于这个结果是有一定的误差的,所以基于此结果的泰勒展开就是不精确的,多次递归之后,结果可能误差很大,以至于开方的结果完全不可用,计算数值为1000的时候,其误差已经达到0.3的级别了。
以下是基于泰勒级数法的开方C代码:
double sqrt_taylar(double x, double err = 0.00000001)
{
double r,y,i,f0,m;
if(x==0)y= 0;
else if (x <= 2.0)
{
i = 0;
r = 0.5*(x - 1);
y =1;
do
{
y += r;
r = r*(-0.5 - i) / (i + 2)*(x - 1);
i++;
} while (r*r>err);
}
else
{
double n=x-1;
f0=sqrt_taylar(n);
i=0;
r=0.5*f0*(x-n)/n;
y=f0;
do
{
y+=r;
r=r*(-0.5-i)/(i+2)*(x-n)/n;
i++;
} while (r*r>err);
}
return y;
}