多精度算术: 除法算法





0/5 (0投票)
归一化除法算法
本文是系列文章的一部分
多精度算术:一个有趣的爱好项目
长整数除法
在本章中,我们将专注于长除法,这是实现起来最困难的算术运算。
让我们回顾一下自然数 ℕ 中除法的定义:A 除以 B 意味着找到数 Q 和 R,使得 A = B * Q + R,并且 (R < B) 以及 (A, B, Q, R ∊ ℕ);通俗易懂的定义:除法 A / B 是一种运算,它将 A 个物品分成 B 组,每组有 Q 个元素,当分组后,剩下一些不足以给每组再分配一个的元素,这些物品被称为余数,简称 R。
示例:我们有一些扑克牌和一些玩家,我们从左边的玩家开始,顺时针给每个玩家发一张牌,直到我们没有足够的牌来完成新的一轮。这时手中剩下的牌的数量就是余数 R,最后每个玩家拥有的牌的数量就是商 Q。
为了加快计算出 Q 的过程而无需实际分配物品,我们将使用一种进位制数字系统和一个算法。让我们开始吧。
构建一个“简单除法”的基本操作
首先,我们需要一个足够容易计算的基本操作,我们将其定义为“简单除法”。
简单除法:操作 A / B,其中 A 和 B 满足……
- A >= B
- B 只有一个非零数字,A 有一个或两个数字,但当 A 有两个数字时,其最左边的数字小于 B 且不为零。
...这就是一个简单除法。
示例:在十进制下,4/3、37/8、9/8 是简单除法。
示例:在十进制下,47/3、3/8、97/8 不是简单除法。
Q 和 R 可以通过在 b 进制乘法九九表中进行逆向查找来找到。
十进制九九表逆向查找示例:让我们计算简单除法 37/8。在九九表的第 8 列中,搜索小于 37 的最大值,即 32。对应的行索引是 4,所以 37/8=4,余数是 37-32=5。
长整数除法
在小学,孩子们学习一种有用的算法来计算长除法,该算法利用十进制表示法和对乘法九九表的了解,来简化较大数字的除法工作。
我们将使用相同的算法,但我们会对细节有点吹毛求疵,因为 CPU 不像一个 8 岁的孩子那样聪明。
边界情况
由于本文是关于整数除法的,所以存在一些平凡解
- 当 B = 0 时,结果未定义,这是一个错误。
- 当 A < B 时,那么 A / B=0 并且余数 R =A。
- 当 A 等于 B 时,那么 Q = 1 并且 R = 0。
- 当 A = 0 时,那么 Q = 0 并且余数 R = 0。
数字计算机上的长整数除法
大多数 n 位 CPU 可以将一个 2n 位的数除以一个 n 位的数,当这个 2n 位数的较高位字小于除数时,否则它会设置一些硬件错误标志,这正是我们根据上面的简单定义所需要的。
因此,我们可以使用硬件除法来替代九九表的查找,作为我们构建计算机长除法算法的“简单除法”。
正如我们在前几章中所做的,我们需要将 8 岁孩子使用的十进制与 CPU 理解的 register_size
进制进行类比:我们可以将一个 CPU 寄存器想象成一个数字,将内置的除法指令想象成九九表的逆向查找;我们在学校学到的相同算法就可以应用于 CPU,只需稍作修改。
回顾:我们可以将小学算法用于数字计算机,使用 CPU 集成的除法指令来查找 2register_size 进制的九九表:网上你找到的一些算法使用二进制(这对于在硬件上实现它很有好处),但我们将避免这样做,以有效地利用现有硬件。
算法
这个想法是将除法分解为一系列较小的除法,形式为 Ai = B * Qi + Ri,其中 Qi 是一个单位数。使用较小除法的目的是为了最小化猜测正确 Q 所花费的时间。
流程图将设定基本思路。为简单起见,算法的第一个版本要求输入 A > B,并且,猜测算法尚未设定。
该流程图是使用以下伪代码和一个在线工具创建的,不要被其表面上的单循环结构所迷惑,它可能让你认为这是线性复杂度。每次减法、乘法和比较都是在多位长数上进行的,临时数也是数字串,每次可能需要重新分配内存,我们将在查看 C 代码时看到这一点。
/inputs are: string A, string B/;
string Q = 0;
string R = 0;
string Ai = get as many digits from A, starting from most significant, until Ai >= B;
loop:
/*step 1 is the obscure part */
1) int Qi = find the greatest Qi that satisfies : (Qi * B) <= Ai;
2) Append Qi to Q;
3) temp = long_multiply(Qi, B);
4) Ai = long_subtract(Ai, temp);
if(have more digits on A?)
{
Append(Ai, next digit from A);
goto loop;
}
R = Ai;
return outputs are: Q and R;
现在让我们通过一个例子来逐步跟随算法(对于有足够耐心的人,其他人可以跳过)
假设我们有两个数 A=1234 和 B=56,我们想找出结果 Q(即 22)和余数 R(即 2)。
NOTE: In the below pseudocode, the comments precede the commented row
/A = "1234", B = "56" /;
Q="", R="";
//Ai will be "123", cause A="1234" and B = "56"
Ai = get as many digits from A, starting from most significant, until Ai >= B;
FIRST ITERATION:
loop:
//Qi become 2, because 2 is the greatest X such that X * 56 <= 123
1) int Qi = find the greatest Qi that satisfies : (Qi * B) <= Ai;
// append Qi to the left of Q, so Q will be "2"
2) Append Qi to Q;
// temp = "112"
3) temp = long_multiply(2, "56");
// Ai = "11"
4) Ai = long_subtract("123", "112");
// yes we have one more digit, A was "1234", "123" already used, we still have "4"
if(have more digits on A?)
{
//Ai = "114", because Ai was "11" and now we append to it "4"
Append(Ai, next digit from A);
goto loop;
}
SECOND ITERATION:
loop:
//Qi become 2, because "2" * "56" = "112", so "2" is
// the greatest X such that X * "56" <= Ai (Ai value is "114")
1) int Qi = find the greatest Qi that satisfies : (Qi * "56") <= Ai;
// append Qi to the left of Q, so Q will be "22"
2) Append Qi to Q;
// temp = "112"
3) temp = long_multiply(2, "56");
// Ai = "114"-"112" = "2"
4) Ai = long_subtract("114", "112");
// we run out of digits so we quit loop
if(have more digits on A?)
{
...
}
// R="2"
R = Ai;
return outputs are: "22" and "2";
如何找到正确的 QI
上述算法中晦涩的部分是循环中的第一行:猜测 Qi。
在步骤 1 中,我们找到了 Q1,它总是一位数长(很高兴知道这点)。我们首先猜测一个好的候选值,然后如果猜测错误就进行调整:在十进制中,只有 9 个候选值(这就是为什么小学老师不会过多深入研究猜测算法的原因),但在 2256 进制中,这可能是一个问题。
我的代数书说,最佳的第一个候选 QI 是这样找到的
嗯,我的代数书并不是这样说的,但这是从书中推导出来的。
开始前的一点说明……...我们不会在任意大小的 A 和 B 上运行猜测算法,我们是在循环内部(见上述算法)。假设我们处于第 i 步,因此我们正在猜测除法 Ai / B 的除数。你可能会注意到,算法确保 Ai 大于 B 但不会太大,它最多比 B 多一位数。但请记住,在第一次迭代之后,Ai 也可能小于 B(在这种情况下,我们的猜测将是 0)。
开始前的最后一点说明……:接下来的例子使用十进制。
如何猜测
- 设 Bm 为 B 的最左边一位数字。
- 设 An 为 Ai 的最左边一位数字。
- 设 Aguess 为 Ai 中(从最高位开始)满足 Aguess >= Bm 的最小数字组(1 或 2 位)。
- 示例 Ai=123, B=56, 所以 Bm 是 5, Ai 的最左边是 1, 1 小于 5, 那么我们必须从 Ai 中再取一位数字, 所以 Aguess=12。.
- 示例 Ai=123, B=11, 所以 Bm 是 1, Ai 的最左边是 1, 因为 1 等于 1, 那么 Aguess=1。.
- 示例 Ai=3, B=45, 所以 Bm 是 4, Ai 的最左边是 3, 但 Ai 小于 B, 所以 Aguess= 0。
标准猜测
我们这样找到我们的猜测值 Qi
- 边界情况 Ai < B
- Qi = 0
- 否则如果 An = Bm , 那么
- 如果 Ai 的位数与 B 相同,那么 Qi = 1
- 示例:Ai = 213 : B = 200, Qi = 1
- 示例:Ai = 2137 : B = 2122, Qi = 1
- Ai < B 的情况已经被前一个分支处理了……
- 否则(当 Ai 比 B 长 1 位时,没有其他可能的情况) Qi = base-1 (对于十进制字符串是 9,在 CPU 上是 0xFFFF....F)。
- 示例:Ai = 2137 : B = 220, Qi = 9
- 情况 Ai = 2237 : B = 220 不可能存在,因为: Ai 是 A 中大于 B 的最短最左边的数字组,在这种情况下 Ai 会是 223,因此这种情况应该由上面的分支处理
- 如果 Ai 的位数与 B 相同,那么 Qi = 1
- else
- Qi = simple_division(Aguess / Bm),其中如果我们手工计算,简单除法可以是逆向查找;在计算机上,则是 CPU 的 Div 指令。
- Qi = simple_division(Aguess / Bm),其中如果我们手工计算,简单除法可以是逆向查找;在计算机上,则是 CPU 的 Div 指令。
注意 Aguess 只能是 1 位或 2 位数字,当它是 2 位时,An < Bm:所以我们对 Qi = Aguess / Bm 的标准猜测可以使用内置的 CPU 指令来计算。
改进的猜测:稍后,我们将使用一些预处理:我们将摆脱一些分支判断,因为我们将预处理 A 和 B ,使得 Bm >= base/2。
距离真实的 QI 有多远?
现在我们需要确保我们猜对了 Qi :为此,我们必须运行长乘法算法 B * Qi ,例如 56 * 2 = 112,由于 112 <= 123,我们就算完成了。
等等:我们难道不应该检查我们的猜测是否太小吗?为什么我们不检查 56 * 3 < 123 呢?嗯,那是不可能的,我们的猜测总是正确或太大,绝不会太小。证明在附录 1。
有时我们的猜测太大了
如果我们不对数字进行预处理,我们可能会遇到麻烦。
6789:18
使用我们的标准猜测算法,Aguess 将是 6,6 : 1 = 6,但 6*18=108,这比 67 大得多。
在这种情况下,我们将尝试减少我们的猜测,直到找到正确的结果。我们将我们的 Qi 猜测值减 1 并尝试:5*18,然后是 4*18,最终我们发现 3*18 < 67,所以 Qi = 3。
与其将我们的猜测减一然后乘以 B,我们将使用我们的中间结果(108)并持续减去 B(18),直到 Qi 变得小于 67,因为这将节省少量计算时间:(我的猜想,没有证据支持)。
达到正确的 QI 需要多大成本?
- 108-18=90
- 90-18=72
- 72-18=54
我们不得不多做三次减法才找到正确的数字,我们最初的猜测相当错误,正确的数字是 108 的一半,而且那还是十进制。如果使用 2256 进制,试错过程将变得不切实际。
归一化除法
幸运的是,代数书上说,如果 \(B_m \geq \frac{base}{2}\),那么我们最多只需要做 2 次额外的减法。我不会为此提供证明,如果有人需要书名,我会推荐我的书。
假设我们没那么幸运,\(B_m <\frac{base}{2}\):原始除法是
e1: A = B * Q + R
我们可以预处理这个方程,我们可以合法地在两边都乘以 k,目的是使 Bm >= base/2。
e2: kA = kB * Q + kR
比较 e1 和 e2 表达式:Q 没有改变,改变的是 R,我们在返回前需要将 R 除以 k。
在我们的实际算法中,我们进行左移操作,直到最左边数字的最左边的位变为 1,而不是进行昂贵的乘法或除法。
回到例子
6789 / 18
Bm 是 1,所以我们不断乘以 2(在 CPU 上是左移),直到 Bm 大于 5(因为这个例子是十进制,在 CPU 上我们需要让 Bm 的最左边的位变为 1)。
- 18 << 1 = 36
- 36 << 1 = 72
我们需要 2 次移位(相当于乘以 4)。
同时也要移位被除数
6789 << 2 = 27156
现在 Bm 是 7,即 >= (10/2)。
原始除法是 6789/18 (Q=377, R=3),现在除法变成 6789*4/18*4 (Q=377, R=3*4),可以看作是 27156/72 (Q=377, R=12)。
代数书称之为归一化除法,Q 不变,R 在我们进行新的除法时被乘以了 4,但现在我们确定我们只需要做很少的猜测。
CPU 的“逆向位扫描”(Bit Scan Reverse)等功能在低级语言中可用,可以省去左移循环:我没有证据表明 CPU 的 BSR 指令比位移循环更快。
回顾算法,增加猜测算法并移除限制
现在我们将显式地将 FindQn
(猜测) 子程序添加到我们的算法中。如果 B 是归一化的,其内部循环的迭代次数最多为 2,但余数将被移位。由于输入是归一化的,我们也可以简化猜测例程。
修订后的算法处理了 A <= B 的情况。为简单起见,“Find Qn
”子程序被呈现为一个函数,但可以内联开发。它不包括归一化过程,让我们假装这是核心算法,而代码的另一部分将通过函数组合来处理归一化。
//TODO:在该图表中,缺少长乘法和测试。
使用伪代码的最终示例。
现在我们将逐步执行修订后的算法:我们将接收归一化的数字作为输入,以便 B 的最左边数字 >= base/2
因此,我们不是除以 6789 / 18,而是将两边都乘以 4,得到一个新的除法 27156 / 72,我们的返回值将是 R / 4 和 Q。(译者注:原文为 R * 4,应为笔误,根据上下文意为处理后的 R,在最后需要除以 4 得到最终余数)
[Q]=empty,
//An = 271 because that is the first group of digits on 27156 that is > 72
[An] = Select as many digits from [A] such that [An] >= [B];
while([An] > [B])
FIRST ITERATION:
loop:
Qn = 0;
if(An < B) //271 < 72 so continue
return zero;
Bn = Leftmost of B; //7
Aguess = Select as many digits from [An] such that Aguess >= Bn; //Aguess = 27
Qn = Aguess / Bn; //Qn = 3
Test = Qn\*B; //Test = 3*72 = 216
while(Test > An) //216 > 271 is false so leave subroutine
{
Qn--;
Test -= B;
}
return done;
//end of subroutine:
Append Qn to Q; //Q become 3
Remainder = An = An - Test; //remainder become 271 – 216 = 55
if(there are more digits in A) //there are more digits in A
{
Append next digit from A to An; //An become 555
goto loop; //continue iteration
}
SECOND ITERATION:
loop:
Qn = 0;
if(An < B) //555 < 72 false
return zero;
Bn = Leftmost of B; //7
Aguess = Select as many digits from \[An\] such that Aguess >= Bn; //Aguess = 55
Qn = Aguess / Bn; //Qn = 7
Test = Qn\*B; //Test = 7*72 = 504
while(Test > An) //504 > 555 is false so leave subroutine
{
Qn--;
Test -= B;
}
return done;
//end of subroutine:
Append Qn to Q; //Q become 37
Remainder = An = An - Test; //remainder become 555 – 504 = 51
if(there are more digits in A) //there are more digits in A
{
Append next digit from A to An; //An become 516
goto loop; //continue iteration
}
THIRD ITERATION:
loop:
Qn = 0;
if(An < B) //516 < 72 false
return zero;
Bn = Leftmost of B; //7
Aguess = Select as many digits from \[An\] such that Aguess >= Bn; //Aguess = 51
Qn = Aguess / Bn; //Qn = 7
Test = Qn\*B; //Test = 7*72 = 504
while(Test > An) //504 > 516 is false so leave subroutine
{
Qn--;
Test -= B;
}
return done;
//end of subroutine:
Append Qn to Q; //Q become 377
Remainder = An = An - Test; //remainder become 516 – 504 = 12
if(there are more digits in A) //there are no more digits in A so exit
{
Append next digit from A to An;
goto loop; //continue iteration
}
检查结果:377*72+12=27156
但我们想计算 6789 / 18,余数必须除以 4;所以检查结果
6789 = 18*377 + (12 / 4) = 18 * 377 + 3,这是正确的。
一个计算机算法
计算机算法可能会根据你决定如何表示一个长数而有所不同。
如果你选择使用无符号整数数组来存储一个大数,这里有一个该算法的 C 语言示例版本:很抱歉我没有直接粘贴在这里,因为它太大了。
让我对上面的代码稍作评论:困难的部分(关于除法的一切都很困难,但只要有耐心就可以解决),是简单除法算法的实现。由于我的编译器不支持 128 位整数,我不得不编写一个非常小的 x64 汇编例程来更好地利用硬件,这使得该函数无法移植到 x32,但由于该代码非常简单,你也可以为你的硬件编写该支持函数。
.data
.code
;qword cpu_divide(
; qword low, RCX
; qword high, RDX
; qword divisor, R8
; qword * R, R9
; );
; RAX, RCX, RDX, R8, R9, R10, R11 VOLATILE
; RAX used to store cpu flags
cpu_divide proc
mov rax, rcx
div r8
mov qword ptr[r9], rdx
ret
cpu_divide endp
end
因为以我的 x64 技能编写一个完整的 x64 实现会太耗时,我将只发布带有那段代码的文章;实际上我创建了两个 C 版本,第一个几乎不可读,因为我试图做“基于感觉的优化”,第二个更具可读性。
后果
以下是我使用上述实现得到的一些计时数据。
算法复杂度
有趣的是,除法比乘法慢得多,其速度主要取决于内部乘法的速度,否则该算法看起来是线性时间的,但事实并非如此。所有这些乘法都是用 1 位数乘以某个数来完成的,因此乘法算法总是以线性时间执行,即使使用简单的小学乘法算法也足够了……可能比使用任何其他算法都快。值得编写一个专门针对固定 N*1 大小并尽可能优化的算法,除法的速度取决于它。
所以上图的钟形曲线显示,当被除数大小为 N 且除数大小为 N/2 时,性能最差。
这可以解释为
当用 N 位数除以 1 位数时,你会得到(忽略减法)
- N 次 CPU 除法
- N 次大小为 (1 * 1) 的乘法
总计 N + N * 1 次操作 => O(N)
当用 N 位数除以 N 位数时,你会得到
- 1 次 CPU 除法
- 1 次大小为 1 * N = N 的乘法
总计 1 + N => O(N)
当用 N 位数除以 M 位数时 (M < N),你会得到
- N-M+1 次 CPU 除法
- N-M+1 次大小为 1*M=M 的乘法
总计 (N-M+1) + (N-M+1) * M => O((N-M)*M)
因为 M<=N,我们有当 M = N/2 时,(N-M)*M 达到最大值
因此我们可以说,最好情况的复杂度是 \(O \left( N \right)\),最坏情况是 \(O \left( \left( \dfrac{N}{2} \right) ^2 \right){\implies}O \left( N^2 \right) \)
目前,我不知道是否存在更好的算法,我订购了一些书希望能找到答案。
附录 A
本文提出的算法所猜测的 Qn 大于或等于正确的 Qn
由于我们讨论的是部分除法,我们只有两种情况
- A/B 的除法,其中 A 和 B 具有相同的长度
- A/B 的除法,其中 A 比 B 多 1 位数,但其最左边的数字 An 小于 B 的最左边的数字 Bn
这里是这两种可能情况的图示
只展示情况 1,情况 2 的证明类似。
案例 1
假设
- H.1 \(A > B\)
- H.2 \(A, B\) 具有相同数量的位数
- H.3 \(A_{n}\) 是 \(A\) 在 \(b\) 进制下的最左边的数字
- H.4 \(B_{n}\) 是 \(B\) 在 \(b\) 进制下的最左边的数字
- H.5 \(n\) 是 \(A\) 和 \(B\) 的位数
记住:
- R.1 \(A_{n} \geq B_{n}\) 根据 \eqref{def1} 和 \eqref{def2}
- R.2 \(A_{n} < b\) 根据 \(b\) 进制下数字的定义
- R.3 \(A_{n}, B_{n}\) 是非零的(最高有效位)
让我们将 \(A\) 和 \(B\) 展开为其位值表示法
我们的第一个猜测 \(Q\) 是简单除法 \(A_{n} / B_{n}\) 的单位数商,使得 \(A_{n}= B_{n} Q + R\)
论点
我们想证明,对于任何满足初始假设 H 的数对 \((A, B)\),如果我们使用提出的算法计算 \(Q\),那么 \((Q+1)B>A\)。
证明
我们必须证明:\((Q+1) B > A\)。只考虑前一个不等式的左边部分,并使用位值表示法重写 B。
上面的表示法在方程的右侧显示了长数 B 的字符串表示乘以 (Q+1),根据定义,(Q+1) 是一个单位数。
这等于(根据加法和乘法的分配律)
让我解释一下前面的表达式,这是一个为了简化符号的简化。我们得到了一个新的位值展开数字串,其中最右边最低位的数字是 \([B_{1} * (Q+1) )\ MOD\ b]\),我们将其重命名为 \([K_{1}]\)。
在进行内部乘法时,我们有一个余数向左传播,传播在表达式的最高有效部分结束,成为数字 \(C\),我们不知道它有多大,但我们知道它小于 \(b\)。
因此,(eq5) 的左边部分是一个要附加到长 \(K\) 字符串左边的两位数。乘法修改了所有的数字,我们不知道 \(K\) 的数字值,但重要的是我们有了一个新的数,它有一个新的展开,可以有 n 或 n+1 位。
现在我们得到了两个位值表示法字符串 (eq6) 和 (eq7)
将上面的 A 字符串与 B 字符串进行比较,为了证明我们的论点,我们需要证明表达式 (eq6) > (eq7),对于 \(\forall (A, B),\ H(A,B) \) 成立。
因为 \(0 \leq C < b\),我将忽略 \(C\),以得到一个更简单的表达式
如果你看 (eq6)、(eq7) 和 (eq8),你会注意到,如果我们证明 \((eq8) > (eq7) \Rightarrow (eq6) > (eq7) \)
因为 (eq8) 更容易证明,我们将证明它。
要证明 (eq8) \(>\) (eq7),有两种可能的情况
情况 1:如果
那么只需证明
情况2:否则,我们需要证明 (eq8) 的最左边部分严格大于 (eq7) 的最左边部分。在这种情况下,我们可以忽略最低有效位,只需要证明 (simpl1)。
鉴于 (情况 2) 比 (情况 1) 更具限制性,并且 \((情况 2) \Rightarrow (情况 1)\),只需证明 (情况 2) 就足够了。因此我们可以忽略 A 和 B 的最低有效位(在两个表达式中都移除最低有效位),我们只需证明 \((eq11)\)。
我们可以将 \((eq11)\) 重写为(根据分配律)
去掉一些括号,重新排序:\((eq13)\) 是我们想要证明的
现在比较这个不等式系统
\((eq14)\) 与 \((eq13)\) 相同,\((eq15)\) 是我们已知的,Q 是从除法 \(A_{n}\ /\ B_{n}\) 得到的,所以 \(A_{n} = Q \cdot B_{n} + R\)
但是,根据除法余数的定义,\(R < B_{n}\),所以 (proof1) 是真的,证毕。
历史
- 2022 年 6 月 11 日:在线发布