前言
前几天看到一道题目,是关于“对整数 n 开平方,不使用Math.sqrt实现”,感觉蛮有意思的,其解法用到了牛顿迭代法(Newton’s Method)。
就顺便研究了一下该解法和其他一些解法,特来分享记录一下。
正文
题目是非常容易理解的,我们直接来看相关解法吧。
牛顿迭代法
因为对没有求根公式的函数,求解它的零点是非常困难的,因此就发明了 牛顿迭代法(Newton‘s Method) 来逼近该函数的零点。具体方法如下图所示:
设 $r$ 是 $f(x)=0$ 的根,选取 $x_0$ 作为 $r$ 的初始近似值,过点 $(x_0,f(x_0))$ 做曲线 $y=f(x)$ 的切线 $L$,$L:y=f(x_0)+f’(x_0)(x-x_0)$ ,则 $L$ 与 $x$ 轴交点的横坐标 $x_1=x_0-\frac{f(x_0)}{f’(x_0)}$,称 $x_1$ 为 $r$ 的一次近似值。过点 $(x_1,f(x_1))$ 做曲线 $y=f(x)$ 的切线,并求该切线与 $x$ 轴交点的横坐标 $x_2=x_1-\frac{f(x_1)}{f’(x_1)}$,称 $x_2$ 为 $r$ 的二次近似值。重复以上过程,得 $r$ 的近似值序列,其中,$x_{n+1}=x_n-\frac{f(x_n)}{f’(x_n)}$ 称为 $r$ 的 $n+1$ 次近似值,上式称为牛顿迭代公式。
用牛顿迭代法解非线性方程,是把非线性方程 $f(x)=0$ 线性化的一种近似方法。把 $f(x)$ 在点 $x_0$ 的某邻域内展开成泰勒级数
$$f(x)=f(x_0)+f’(x_0)(x-x_0)+\frac{f’’(x_0)(x-x_0)^2}{2!}+…+\frac{f^{(n)}(x_0)(x-x_0)^n}{n!}+R_n(x)$$
,取其线性部分(即泰勒展开的前两项),并令其等于 $0$,即 $f(x_0)+f’(x_0)(x-x_0)=0$,以此作为非线性方程 $f(x)=0$ 的近似方程,若 $f’(x)\not ={0}$,则其解为 $x_1=x_0-\frac{f(x_0)}{f’(x_0)}$, 这样,得到牛顿迭代法的一个迭代关系式: $x_{n+1}=x_n-\frac{f(x_n)}{f’(x_n)}$。
已经证明,如果是连续的,并且待求的零点是孤立的,那么在零点周围存在一个区域,只要初始值位于这个邻近区域内,那么牛顿法必定收敛。 并且,如果不为0, 那么牛顿法将具有平方收敛的性能。 粗略的说,这意味着每迭代一次,牛顿法结果的有效数字将增加一倍。
说了这些,那牛顿迭代法为什么会跟 “对整数n开平方” 有关呢?
若我们另 $f(x)=x^2-n$,则 $f(x)$ 的零点即为 $\sqrt{n}$ ,此时 $f’(x)=2x$,则迭代公式如下:
$$x_{n+1}=x_n-\frac{f(x_n)}{f’(x_n)}=x_n-\frac{x_n^2-n}{2x_n}=\frac{x_n^2+n}{2x_n}=\frac{1}{2}(x_n+\frac{n}{x_n})$$
相关代码实现如下:
1 | /** |
输出:
1 | 2.0 |
二分查找法
相比于快速的牛顿迭代法,二分查找法也是可以实现开方需求的。不过相比牛顿迭代法其速度较慢。
这种方法十分好理解,就是上界初始化为数字本身,下界初始化为0.0,这样用二分,判断中间数字的平方和目标数字比较,再修改上界和下界,直到小于一定的阈值。需要注意结束条件和精度判断。
相关代码如下:
1 | /** |
输出:
1 | 2.0 |
Java源码中的开方
开始一直以为Java源码中Math.sqrt
方法使用的是牛顿迭代法来实现的。最近研究源码,发现并不是这样。特地研究记录一下。
我们跟踪Math.sqrt
源码,会发现它其实调用的StrictMath
的sqrt
方法,此方法为native
方法。
1 | public static native double sqrt(double a); |
因此我们需要查找它的具体实现了,这就需要找到openjdk
源码了,其实现位于openjdk\jdk\src\share\native\java\lang\fdlibm\src\e_sqrt.c
路径下。
当然我们也可以在线查看,文件如下 e_sqrt.c。
我们可以看到对于开方的操作,其源码实际是使用了一种叫Bit by bit method
,我这儿称为逐位法。
根据上图,我们来看下该方法的优势及特点吧。
Bit by bit method using integer arithmetic. (Slow, but portable)
源码中提到该方法虽然“慢”但合适,其相关原理如下。
归一化
在 $[1,4)$ 中以 $2$ 的偶数次幂缩放 $x$ 到 $y$ :
求一个整数 $k$,使 $1 \leq (y=x * 2^{2k}) < 4$ ,
即 $\sqrt{x} = 2^k * \sqrt{y}$
逐位计算
设 $q_i =\sqrt{y}$ 在二进制点($q_0 = 1$)后截断到 $i$ 位,
$s_i=2q_i$ 并且 $y_i=2^{i+1}(y-q_i^2)$. (1)
要从 $q_{i+1}$ 计算 $q_i$,首先要检查是否
$(q_i+2^{-(i+1)})^2 \leq y$. (2)
如果 (2) 式结果为 false
,就有 $q_{i+1}=q_i$,否则 $q_{i+1}=q_i+2^{-(i+1)}$.
通过一些代数运算,不难看出 (2) 式等价于
$s_i+2^{-(i+1)} \leq y_i$. (3)
变为 (3) 式的优点是,$s_i$ 和 $y_i$ 可以用递归式计算:
如果 (3) 式为false
$s_{i+1}=s_i$,$y_{i+1}=y_i$; (4)
否则
$s_{i+1}=s_i+2^{-i}$,$y_{i+1}=y_i-s_i-2^{-(i+1)}$; (4)
用归纳法可以很容易地证明 (4) 和 (5)。因为 (3) 的左边只包含 $i+2$ 位,所以 (3) 中不需要进行完整的(53-bit)比较。
最终
在生成53位结果后,我们再计算一个位。连同余数,我们可以确定结果是正确的,大于1/2ulp,还是小于1/2ulp(它永远不会等于1/2ulp)。
四舍五入可以通过检查 huge
+ tiny
是否等于 huge
,以及对于某个浮点数“huge”和“tiny”,huge
- tiny
是否等于 huge
来检测。
上面的算法我们可以通过一个简单例子来理解。
假设现在我们要求 $\sqrt{36}$ 的根。
根据第一步得到,$1 \leq y = 36*2^{-4}=2.25_{2(10.01)}<4$。
迭代:
$q_0=1_{2(1)}$,初始化
$q_1=1.5_{2(1.1)}$,$(1+0.5)^2 \leq 2.25$
$q_2=1.5_{2(1.1)}$,$(1.5+0.25)^2 > 2.25$
$q_3=1.5_{2(1.1)}$,$(1.5+0.125)^2 > 2.25$
……
最终 $\sqrt{y} = 1.5$, $\sqrt{36}=1.5*2^2= 6$。
可以看出这种方法不同于牛顿迭代法,它将带求解的数映射于 $[1,4)$ 范围内,通过逐位计算,逐步缩小解的精度,逼近结果。
因为以上迭代过程涉及到平方的操作,为了优化这一点,在逐位计算这一步,使用归纳法消除了平方操作。
可以知道这种逐位计算的方法求解收敛速度某些时候或许比不上牛顿迭代法,但避免了许多乘法和除法操作,所以鲁棒性很好。
源码处理的double数据有52位有效位,在处理时将其分成了高位和低位分开处理,涉及到许多位运算,我们这儿不做详细讨论。
1 |
|
其他开方算法
openjdk
源码中除了上述方法外,在注释中还提供了两种开方方法,第一种部分使用了牛顿迭代,涉及四个部分。第二种方法使用reciproot迭代来避免除法,但是需要更多的乘法。
感兴趣的同学可以查看
等论文进行了解。
总结
本篇文章我们了解了一些开方方法,并分析了一些源码,可以发现数学与编程及算法的巧妙之处。对我们今后的工作学习都是有较大帮助的。