浮点数开平方的几种算法

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

1、牛顿迭代法开平方

        如图1所示,牛顿迭代法的原理是反复利用切线与坐标轴相交,逐步逼近方程的零点。这是一种快速、高精度的算法,是目前工业上主要采用的方法,硬件逻辑实现或者编程语言内置库函数,均是基于牛顿迭代法或者是基于牛顿迭代法的改进算法。

图 1 牛顿迭代法

         如图1 所示,从x_{n}开始迭代,过(x_{n},f(x_{n}))点作曲线f(x)的切线(红色虚线),该切线方程为:

y-f(x_{n})=f^{'}(x_{n})(x-x_{n})

零点y=0,此时x的值即为x_{n+1}的值,于是有:

0-f(x_{n})=f^{'}(x_{n})(x-x_{n})

化简得:

x_{n+1}=x_{n}-\frac{f(x_{n})}{f^{'}(x_{n})}

这就是牛顿迭代法的通用公式。对于大于零的数M进行开方,则可以构造以下方程:

f(x)=x^{2}-M

f(x)求导数得f^{'}(x)=2x,于是得开平方的牛顿迭代公式如下:

x_{n+1}=\frac{1}{2}(x_{n}+\frac{M}{x_{n}})

        牛顿迭代法通过选择一个初值,开始对迭代式进行迭代运算,直到精度满足要求后停止迭代。对于开平方而言,任意大于0的初值,迭代式总能收敛到指定精度,但是收敛的速度跟初值的选择有关。初值选择的原则:初值越接近真值,迭代的速度越快。这里介绍一种简单的初值计算方法(非最优的方法),当M大于等0小于等1时,初值设为1;当M大于1时,初值设为M/2

以下是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 二分法

二分法和牛顿迭代法一样,需要构造一个方程f(x)=x^{2}-M(见图2),二分法求解方程零点的核心思想是:

        (1)首先给定一个初始的区间[a,b],使得方程的零点处在该区间之内;

        (2) 然后计算区间的中点m,m=(a+b)/2

        (3)以零点为分界线,判断中点m与区间哪个端点在同一侧,然后将该区间端点更新为m,如图2所示,中点m与端点b同在一侧,则令b赋值为m;

        (4)反复进行步骤1、2、3,直到a与b的误差达到指定范围之内。

        二分法虽然总是能够收敛,但是为了达到更好的计算速度,对于初始区间的选择很重要。这里介绍一种简单的初值计算方法,以4为分界点,以0为选择的下限,以M的一半为选择的上限。对于0\leq M\leq 4的情况,令区间的上下限a=0,b=2;对于4< M的情况,令区间的上下限a=2,b=M/2。

        如何判断m是和a还是和b是同一侧呢?这个判断简单,那就是判断中点和端点的函数值是否同号即可,如果f(m)与f(a)同号(f(m)*f(a)>0),那么m与a在零点的同一侧,否则m与b在零点的同一侧。即,该算法可描述为:如果f(m)*f(a)>0,则令a=m,b不变,否则a不变,而令b=m

        二分法开平方的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开平方,首先构造一个预测函数y=w^2-M,真值函数y\tilde{}=0,通过调整自变量w,使得预测函数的值逐渐趋近于真值。如果预测函数与真值的差值为0,那就是最好的,意味着我们找到了方程的精确根,但实际上总是有点误差,这个误差就称之为损失,为了后续计算的方便,这里把绝对误差的平方,作为预测函数与真值的损失函数,其表达式如下:

L(w)=|y-y\tilde{}|^2=|(w^2-M)-0|^2=(w^2-M)^2

显然,损失函数L是与w有关的函数。我们的目标是,通过调整w,使得L尽可能趋向于0,这样求得的w即为开平方的根。那么如何调整自变量w,才能使L以最快的速度趋向于0呢?答案是沿着损失函数L的梯度反方向调整w,于是w的调整表达式如下:

w=w-\frac{\partial L(w) }{\partial w}

上式中,\frac{\partial L(w)}{\partial w}是梯度,且\frac{\partial L(w)}{\partial w}=2\cdot (w^2-M)\cdot 2w=4\cdot (w^2-M)。通常,为了进一步控制w的变化幅度,会在梯度上加一个系数\alpha,这个系数称之为学习率。于是,w的变化公式变成如下:

w=w-\alpha \cdot 4w(w^2-M)

学习率的大小影响着收敛的速度和精度,因此学习率的选择很重要。一般情况下,为了防止迭代的结果产生较大波动的情况出现,学习率设置成一个比较小的数,比如本文直接把它设为\alpha =0.001

以下是使用梯度下降法求解开平方根的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、泰勒级数法

        泰勒级数恐怕是高等数学中除了微积分之外,第二重要的内容了,它可以将一个复杂的方程式,转变为一个线性多项式,从而将超越方程变成计算机可以求解的模式。将一个方程展开为泰勒级数,有以下通用公式:

f(x-x0)=f(x0)+\frac{f^1(x0)}{1!}(x-x0)+\frac{f^2(x0)}{2!}(x-x0)^2+...+\frac{f^n(x0)}{n!}(x-x0)^n

上式子中,x0为展开点,f^n(x0)为x0点处的n阶导数。针对M的平方根求解,先构造有个方程f(x)=\sqrt{x}=x^{\frac{1}{2}},然后求出该函数的各阶导数如下:

f(x)=x^\frac{1}{2}

f^1(x)=\frac{1}{2}\cdot x^{\frac{1}{2}-1}

f^2(x)=\frac{1}{2}\cdot -\frac{1}{2}\cdot x^{\frac{-3}{2}}

f^3(x)=\frac{1}{2}\cdot -\frac{1}{2}\cdot -\frac{3}{2}\cdot x^{\frac{1}{2}-3}

f^4(x)=\frac{1}{2}\cdot -\frac{1}{2}\cdot -\frac{3}{2}\cdot -\frac{5}{2}\cdot x^{\frac{1}{2}-4}

f^n(x)=(-1)^{n-1}\cdot (\frac{1}{2})^n\cdot \prod_{i=1}^{n-1}(2i-1)\cdot x^{\frac{1}{2}-n}

为了方便计算,通常选择一些特殊的点进行泰勒展开。由于x=0处,f(x)的各阶导数不存在,所以不能在x=0处展开,于是可以选择另一个特殊点x=1处展开,于是令x0=1,得到f(x)的泰勒展开式:

f(x)=1+\frac{1}{1!}(x-1)+\frac{(-1)^{1}\cdot (\frac{1}{2})^2\cdot 3)}{2!}(x-1)^2+...\frac{(-1)^{n-1}\cdot (\frac{1}{2})^n\cdot \prod_{i=1}^{n-1}(2i-1)}{n!}(x-1)^n

通过对泰勒展开式的余项进行分析,得知,该泰勒展开式的收敛半径为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;
}

猜你喜欢

转载自blog.csdn.net/qq_28249373/article/details/129124875
今日推荐