储存大整数是一件很简单的事情,只需要使用足够长的数组即可。但是将储存好的大整数提取出来用于计算却很麻烦。对于编程来说,常用的数学函数基本上已经内置了,但这些函数都要求使用特定的数据类型,而这些数据类型的数值范围很有限,因此无法应用大整数。本文就来探究一下 大整数相除
的问题。
假设有这样的一个情景。有两个大整数,已经开发出来大整数之间的 和
、差
、求整数商
、求余数
运算。现在来求它们之间的数学除法运算,并将结果转化为 double 类型。并假设这两个大整数,它们超出了 double 类型的范围,但是它们的商在 double 类型的范围之内。
这两个大整数超出了范围,但是它们它们的商没有超出范围,为什么会有这样的场景呢?这源自笔者很早之前编写的一款开源 多功能计算器
的项目。为了解决浮点数运算的累积误差问题,该项目使用了一种 分数
数据结构来替代 double
数据结构。分数
数据结构也就是使用两个大整数来分别表示分子、分母,这样就能实现无误差运算。但问题在于,分数
在不断地运算中,无法约分的情况越来越多。这样就导致了其分子、分母的无限增大,但其值其实不大。而当需要将最终结果转化为 double 类型时,如果直接使用分子、分母相除的方式,则会因为分子、分母超出一般整数的范围的原因溢出。
-
关于开源
多功能计算器
的项目,可见笔者的另一篇博客:Windows 多功能计算器:
https://blog.csdn.net/wangpaiblog/article/details/122592093
问题回顾
先来回顾一下问题。
已有条件:
- 两个大整数
- 支持大整数的和、差运算
- 支持大整数的求整数商、求余数运算
需要解决的问题:
- 求这两个大整数之间的数学除法运算,并将结果转化为 double 类型
不能直接进行的操作:
- 不能直接将大整数转化为 double 类型,这会发生溢出。
下面就来解决这个问题。
大整数相除防溢出快速算法
由于整数可以拆成商与余数的形式,因此可以实现如下数学推导:
(这里,
m
m
m 是一个普通整数类型中,能允许出现的最大整数。如对于 Java 中的 long
类型,
m
m
m 可以是 Long.MAX_VALUE
。)
这种算法的核心思想是:
-
先把大整数拆成一些小整数。由于这种拆分通过一种
求余
运算即可实现,因此是可行的。 -
然后根据分数的性质,分子、分母同时除以一个数,其结果不变。所以再将分子、分母同时除以一个数就可以让分子、分母都变小。
这种除法对大整数而言是一种
求整数商
运算,因此是可行的。这种除法对小整数而言是一种在浮点数范围内的浮点数除法运算,因此也是可行的。
这样一来,原本很大的整数 a 1 a_1 a1、 a 2 a_2 a2,就变成了较小的 b 1 b_1 b1、 b 2 b_2 b2、 c 1 c_1 c1、 c 2 c_2 c2。程序代码如下:
-
其中所有涉及的外部函数、数据类型,均源自笔者的开源项目:
public static double rational2doubleQuickly(Rational rational) {
Figure dividend = rational.getNumerator(); // dividend:被除数
Figure divisor = rational.getDenominator(); // divisor:除数
final long maxLong = Long.MAX_VALUE;
final Figure max = new Figure(maxLong);
if (FigureOperation.greaterOrEqual(dividend, max) || FigureOperation.greaterOrEqual(divisor, max)) {
Figure[] a1 = FigureOperation.divideAndRemainder(dividend, max);
long b1 = a1[0].getInteger().longValueExact();
double c1 = (double) (a1[1].getInteger().longValueExact());
Figure[] a2 = FigureOperation.divideAndRemainder(divisor, max);
long b2 = a2[0].getInteger().longValueExact();
double c2 = (double) (a2[1].getInteger().longValueExact());
return (b1 + c1 / maxLong) / (b2 + c2 / maxLong);
} else {
// 将分子、分母中较大的那个数转换为类型 double 来运算,因为 double 比 long 的范围大
if (rational.isProperFraction()) {
return rational.getNumerator().getInteger().longValueExact()
/ (double) (rational.getDenominator().getInteger().longValueExact());
} else {
return (double) (rational.getNumerator().getInteger().longValueExact())
/ rational.getDenominator().getInteger().longValueExact();
}
}
}
改进
上面已经实现了“以大化小”的效果,但数学直觉敏锐的读者可能马上意识到,尽管对大整数 a 1 a_1 a1、 a 2 a_2 a2 进行了如下拆分,但是不是可能拆分之后的 b 1 b_1 b1、 b 2 b_2 b2 依然很大呢?
确实可能如此,下面就用 递归
的思想改进上述方案。
大整数相除防溢出递归算法
上面已经实现了“以大化小”,只是“化小”的结果依然很大。因此只需要一直“以大化小”,直到“化小”的结果令人满意为止。这种思想很适合使用 递归
或者 循环
来实现,只不过前者通常可读性更强。
要想实现这一点,需要构造一种封闭的情形,也就是使运算之后的结果与运算之前在结构上相同。为此,需要进行一些数学推导:
这里,要说明的是(以分子为例):
-
m m m 是一个普通整数类型中,能允许出现的最大整数。如对于 Java 中的
long
类型, m m m 可以是Long.MAX_VALUE
。 -
a 1 a_1 a1、 c 1 c_1 c1 是大整数类型, d 1 d_1 d1 是普通整数类型,而 b 1 b_1 b1、 e 1 e_1 e1、 f 1 f_1 f1 是 double 类型。
-
普通整数类型可以直接转化为 double 类型。
-
在最开始时, b 1 b_1 b1 为 0。
这样一来,经过一轮运算之后,
a
1
a_1
a1、
b
1
b_1
b1 变成了
c
1
c_1
c1、
f
1
f_1
f1,且
a
1
a_1
a1 比
c
1
c_1
c1 小。然后只要通过 尾递归
的方式将运算结果不断迭代就能将
a
1
a_1
a1、
a
2
a_2
a2 不断减少,直至变成一个合适的值。
那么,递归应该在什么结束呢?很简单,只需要每次在运算之前检查 a 1 a_1 a1、 a 2 a_2 a2 值就可以了。只要它们都小于等于 m m m,就停止递归。只要它们其中有一个大于 m m m,就执行运算并进行递归。
程序代码如下:
-
其中所有涉及的外部函数、数据类型,均源自笔者的开源项目:
public static double dividedBetweenBigIntegers(Figure a1, double b1, Figure a2, double b2) {
final long maxLong = Long.MAX_VALUE;
final Figure max = new Figure(maxLong);
if (FigureOperation.greaterOrEqual(a1, max) || FigureOperation.greaterOrEqual(a2, max)) {
Figure[] a1Group = FigureOperation.divideAndRemainder(a1, max);
Figure c1 = a1Group[0];
double d1 = a1Group[1].getInteger().longValueExact() + b1;
Figure[] a2Group = FigureOperation.divideAndRemainder(a2, max);
Figure c2 = a2Group[0];
double d2 = a2Group[1].getInteger().longValueExact() + b2;
return dividedBetweenBigIntegers(c1, d1 / maxLong, c2, d2 / maxLong);
} else {
return (a1.getInteger().longValueExact() + b1) / (a2.getInteger().longValueExact() + b2);
}
}
质疑
数学直觉敏锐的读者可能很快能意识到一个问题,上面的一轮运算,让 a 1 a_1 a1 变小为 c 1 c_1 c1,但让 b 1 b_1 b1 变大成了 f 1 f_1 f1。随着 a 1 a_1 a1 不断减少,有没有可能让 f 1 f_1 f1 变得超过 m m m 呢?如果发生了这种情况,上面的递归算法就没有意义了。
下面就从数学上探究一下 f 1 f_1 f1 的取值范围。
严谨性验证
由前面的探讨,可以得到如下式子:
现在的问题就是要研究图中的那个求和式。如果直接精确计算它的值,这个问题相当棘手。因为它的各个和因子几乎各不相同,且是通过求余运算得到的。幸好我们只需要确认它不会超过 m m m 就行。
我们知道这些和因子都是通过求余运算得到了,所以它们一定都小于
m
m
m。我们可以干脆假设它们等于
m
m
m,计算这种最坏情况下求和式的值。这样就能得到求和式的值的上界。而且,由于 轮换对称性
,我们只需要计算上面那个求和式的值的上界。计算结果如下:
这是一个等比级数,数学上已经有它们的和公式了,因此可以直接得出结果。
而现在这个式子放缩起来非常方便:
因此,上面的那个求和式不管有多少项,它的值也是小于 2 的,而
m
m
m 往往是远大于 2 的,所以上面的 大整数相除防溢出递归算法
是不会有尾项溢出问题的。
为什么这么巧这个求和式恒小于 2 呢?现在回过头想想,其实很好理解。因为 大整数相除防溢出递归算法
本质上是不断地将分子、分母同除以一个数
m
m
m。在这种情况下,分子、分母将各自不断减少。因此,分子、分母的各项正拆分式也不可能会很大。
总结
大整数相除防溢出算法
的核心思想如下:
-
先把大整数拆成一些小整数。由于这种拆分通过一种
求余
运算即可实现,因此是可行的。 -
然后根据分数的性质,分子、分母同时除以一个数,其结果不变。所以再将分子、分母同时除以一个数就可以让分子、分母都变小。
这种除法对大整数而言是一种
求整数商
运算,因此是可行的。这种除法对小整数而言是一种在浮点数范围内的浮点数除法运算,因此也是可行的。
-
使用递归的方法重复【1】、【2】的操作,直至分子、分母均位于 double 类型的范围内。
-
将分子、分母转化为 double 类型,然后使用浮点数除数运算得出结果。