模拟算法之高精度计算

高精度计算,也被称作 大整数 计算,或者直接叫 大数 计算。

在任何语言中,变量的存储空间是有限的,这也就意味着变量范围也是有限的。

例如,在 Javaint 类型变量变化范围为 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ − 2 31 , 2 31 − 1 ] [ -2^{31}, 2^{31}-1] </math>[−231,231−1], 如果超出这个范围我们可以使用 long 类型,但是 long 类型也是有范围的,其范围为 <math xmlns="http://www.w3.org/1998/Math/MathML"> [ − 2 63 , 2 63 − 1 ] [ -2^{63}, 2^{63}-1] </math>[−263,263−1]。那如果超过 long 类型范围呢?

幸运的是, 在 Java 核心类库为我们提供了 BigInteger / BigDecimal 类用于处理大数。对于不允许使用该类或者不提供内置处理大数功能语言场景,就需要我们自己去实现了。

首先,这里约定参加计算的两个数都为 非负数 ,如果为负数可以转化为 非负数 进行计算。

加法

我们先看下如何模拟计算两个大整数相加,力扣题目 ------ 字符串相加

给定两个字符串形式的非负整数 num1num2 ,计算它们的和并同样以字符串形式返回。 你不能使用任何內建的用于处理大整数的库(比如 BigInteger), 也不能直接将输入的字符串转换为整数形式。

巧妇难为无米之炊,在进行加法计算前,如何表示两个加数?对于大整数,我们就不能再使用基础类型表示了,为了方便输入输出等,我们通常使用 字符串 输入 / 存储大整数,例如 字符串相加 接直接给出两个字符串表示大整数。

我们在做竖式加法时遵循先 "低位对齐,低位到高位依次按位相加" 原则,对于保存到字符串中的两个加数,例如 num1="45678"num2="66",如下图

在上图中可以发现如下两个问题

  1. 数组是从左往右依次存储的,而左边对应着整数的高位,即 num1num2 是高位对其的。如何解决呢?我们可以逆转存储大整数的字符串,就可以实现低位对齐。
  2. num1 / num2 的每一位都为 字符 类型,如果直接参加算术运算,则是其 ASCII 码在运算。这倒好办,我们只需将 num1 / num2 每一位使用 Character.getNumericValue() 函数转换成 int 类型并转存到数组即可。

经过以上两个步骤后,两个大整数如下图

这样我们就可以按位相加了。但还要注意

  1. num1 / num2 逆转过后低位在左边, 低位到高位依次相加 需要从左往右依次相加。
  2. 我们可以新建一个 int 数组存储结果,也可以直接将结果存储到 n1[] / n2[] 中。
  3. n1[] / n2[] 按位相加时,总共需要加 num1 / num2 二者最长长度 ------ maxLength 次,为了避免数组越界,n1[] / n2[] 长度定义为 maxLength ,但最高位可能产生进位,所以存储结果数组长度要比 maxLength1
  4. 在处理进位时,如果 k 进制相加,则逢 k1 ,为了统一处理,通过该位除以 k 计算逢了多少次 k ,然后进到相邻高位,再用该位对 k 取余,计算不足 k 不能产生进位剩余数,留在本位。
  5. 结果在数组中也是逆序存放,需要逆序存储为字符串。

最终代码如下

java 复制代码
public String addStrings(String num1, String num2) {

    // ① 翻转
    String reversed1 = new StringBuffer(num1).reverse().toString();
    String reversed2 = new StringBuffer(num2).reverse().toString();

    
    // 为了避免按位相加时数组越界, 将 num1 / num2 都定义为最长长度
    // ② 长度预处理
    int maxLength = Math.max(num1.length(), num2.length());

    // 计算结果存到 n1[] 中, 计算结果可能比 num1 / num2 最长长度多一位
    int n1[] = new int[maxLength + 1]; 
    int n2[] = new int[maxLength];

    // ③ 转存
    for (int i = 0; i < reversed1.length(); i++) {
        n1[i] = Character.getNumericValue(reversed1.charAt(i));
    }
    for (int i = 0; i < reversed2.length(); i++) {
        n2[i] = Character.getNumericValue(reversed2.charAt(i));
    }

    for (int i = 0; i < maxLength; i++) {  // ④ 按位相加
        // 将相加结果存储到 n1[], 也可新建数组专门用于存储结果
        n1[i] += n2[i]; 
        // 处理进位, 十进制逢十进一, 逢多少十就需要进多少一,  
        // 注意⚠️, 如果是 k 进制, 进位多少除以 k 即可, 即 n1[i + 1] += n1[i] / k
        n1[i + 1] += n1[i] / 10;  
        // 不足十不能进位, 留在本位
        // 如果是 k 进制, 不足 k 留在本位 , 即 n1[i] %= k
        n1[i] %= 10;  
    }
    
    // 检查最高位是否产生进位, 这将对结果位数产生影响
    int length = n1[n1.length - 1] > 0 ? n1.length : n1.length - 1;
    StringBuilder ans = new StringBuilder();
    for (int i = length - 1; i >= 0; i--) {   // ⑤ 结果逆序
        ans.append(n1[i]);
    }
    return ans.toString();
}

以上代码稍作修改(进位改成 n1[i] / 2n1[i] %= 2 )可以完成力扣 二进制求和

洛谷可练习如下 大整数加法 题目

  1. P1601 A+B Problem(高精)
  2. P1604 B进制星球k 进制下大整数加法
  3. P1255 数楼梯, 大整数加法 + 斐波那契数列

减法

有加法自然有减法,那两个大整数相减又如何模拟计算呢?

大整数减法其实和大整数加法运算差不多,我们在做竖式减法时同样遵循 "低位对齐,低位到高位依次按位相减" 原则,所以和加法一样也需要 翻转转存,但是也有一些不同地方。

  1. 减法可能小减大,也有可能大减小,小减大时会产生负数。如果小减大,我们可以先输出负号,然后交换减数和被减数,这样统一处理为大减小。
  2. 相减结果最多为 num1 / num2 二者最长长度 ------ maxLength 位,且最高位不会产生进位,但是结果位数不确定,需要检测最高位是否为零。
java 复制代码
public String subtractStrings(String num1, String num2) {

    StringBuilder ans = new StringBuilder();

    // 同一处理为 num1 - num2 且 num1 > num2
    // 如果num1 < num2 则交换二者, num1 存储的数小于 num2 存储的数有两种情况
        // 1. num1 位数比 num2 少
        // 2. num1 和 num2 位数相同, 但是字典序比 num2 小
    if ( num1.length() < num2.length() 
            || (num1.length() == num2.length() && num1.compareTo(num2) < 0)) {
        String temp = num1;
        num1 = num2;
        num2 = temp;
        ans.append("-");  // 先输出负号
    }

    // ① 翻转
    String reversed1 = new StringBuffer(num1).reverse().toString();
    String reversed2 = new StringBuffer(num2).reverse().toString();

    //
    // ② 长度预处理
    int maxLength = num1.length();

    //  为了避免按位相加时数组越界, 将 num1 / num2 都定义为最长长度
    int n1[] = new int[maxLength];
    int n2[] = new int[maxLength];

    // ③ 转存
    for (int i = 0; i < reversed1.length(); i++) {
        n1[i] = Character.getNumericValue(reversed1.charAt(i));
    }
    for (int i = 0; i < reversed2.length(); i++) {
        n2[i] = Character.getNumericValue(reversed2.charAt(i));
    }

    for (int i = 0; i < maxLength; i++) {  // ④ 按位相减
        if (n1[i] < n2[i]) { // 不够减, 向相邻高位借位
            // 注意⚠️, 如果是 k 进制, 这里相邻高位借一相当于 k, 需要改为 n1[i] += k
            n1[i] += 10;
            n1[i + 1]--; // 高位被借一
        }
        n1[i] -= n2[i];
    }

    // 结果位数不确定, 需要依次检测最高位是否为零以确定位数, 但至少一位
    while (maxLength > 1 && n1[maxLength - 1] == 0) {
        maxLength--;
    }

    for (int i = maxLength - 1; i >= 0; i--) {   // ⑤ 结果逆序
        ans.append(n1[i]);
    }
    return ans.toString();
}

洛谷可练习如下 大整数减法 题目

  1. P2142 高精度减法

乘法

高精乘低精

在进行大整数乘大整数乘法之前,先看下另外一个高精度计算 ------ 低精度乘高精度。这种高精度计算出现频率比较多,比如求 n 的阶乘 <math xmlns="http://www.w3.org/1998/Math/MathML"> ! n !n </math>!n 就可以通过循环计算单精乘高精实现。

单精乘高精最直接思路就是把低精度数当作一个整体和高精度每一位相乘,下面给出证明。

假设,低精度 nums1=k ,大整数乘数 num2 为 <math xmlns="http://www.w3.org/1998/Math/MathML"> A n A n − 1 . . . A 2 A 1 A 0 A_{n}A_{n-1}...A_2A_1A_0 </math>AnAn−1...A2A1A0,可以表示位 <math xmlns="http://www.w3.org/1998/Math/MathML"> ∑ i = 0 n 1 0 i A i \sum_{i=0}^{n}10^i A_i </math>∑i=0n10iAi, num2 每一位为 <math xmlns="http://www.w3.org/1998/Math/MathML"> A i A_i </math>Ai。 根据乘法分配律 <math xmlns="http://www.w3.org/1998/Math/MathML"> n u m 1 × n u m 2 = k ∑ i = 0 n 1 0 i A i = ∑ i = 0 n 1 0 i A i k num1 \times num2 = k\sum_{i=0}^{n}10^i A_i = \sum_{i=0}^{n}10^i A_ik </math>num1×num2=k∑i=0n10iAi=∑i=0n10iAik, 相乘结果每一位为 <math xmlns="http://www.w3.org/1998/Math/MathML"> A i n u m 2 A_inum2 </math>Ainum2,即我们的思路是没有问题的。

下图展示了用该方法计算低精度乘以高精度过程

低精度数 num1=42 乘以高精度数 num2="1237" 时,用 num1 乘以 num2 的每一位,然后处理进位。需要注意的是, num1 乘以 num2 的每一位结果可能很大,这样进位可能不止是单位数,如果最高位产生的进位不是单位数,还需要依次往前进位,保证每一位都为单位数。例如,上图中, num2 最高位和 num1 相乘并加上进位后,需要向相邻高位进 3838 非单位数,还需要继续把 3 向上进位。

另外,由于最高位进位位数不确定原因,高精度乘数也需要逆序转存,例如上图,如果顺序转存 num2="1237"n2[]={9, 2, 3, 7} ,从后往前乘以低精度 num1 后,高位进位产生的位 3 / 8 会造成数组越界。

板子代码如下

java 复制代码
public String intMultipliedByString(int num1, String num2) {

    StringBuilder ans = new StringBuilder();

    // ① 翻转
    String reversed2 = new StringBuffer(num2).reverse().toString();

    int length = num2.length();

    // ② 长度预处理
    // 计算结果存到 n2[] 中, num1 最多 10 位,那么相乘结果最多 length + 10
    int n2[] = new int[length + 20];

    // ③ 转存
    for (int i = 0; i < reversed2.length(); i++) {
        n2[i] = Character.getNumericValue(reversed2.charAt(i));
    }
    
    int carry = 0;  // 进位
    for (int i = 0; i < length; i++) {  // ④ 单精乘以高精每一位
        n2[i] = n2[i] * num1 + carry;
        // 进位单独存放, 如果直接存放到相邻高位 n1[i+1] 中,那么进位也会被 num1 乘
        carry = n2[i] / 10; 
        n2[i] = n2[i] % 10;
    }
    
    // 最高位产生的进位可能是多位数, 需要依次向高位进
    while (carry > 0) {  
        n2[length++] = carry % 10;
        carry /= 10;
    }

    for (int i = length - 1; i >= 0; i--) {   // ⑤ 结果逆序
        ans.append(n2[i]);
    }
    return ans.toString();
}

有了单精乘高精板子后,便可以用其计算 n 的阶乘 !n

java 复制代码
String ans = "1";
for (int i = 2; i <= n; i++) {
    ans = intMultipliedByString(i, ans);  // 累乘
}

洛谷可练习如下 低精度乘以高精度 题目

  1. P1009 [NOIP1998 普及组] 阶乘之和 高精度加+低精度乘以高精度
  2. P1591 阶乘数码

高精乘高精

然后看下高精度乘以高精度,力扣上有这么一道题目 ------ 字符串相乘

给定两个以字符串形式表示的非负整数 num1num2,返回 num1num2 的乘积,它们的乘积也表示为字符串形式。

注意: 不能使用任何内置的 BigInteger 库或直接将输入转换为整数。

假设大整数乘数 num1 为 <math xmlns="http://www.w3.org/1998/Math/MathML"> A 3 A 2 A 1 A 0 A_{3}A_2A_1A_0 </math>A3A2A1A0, num2 为 <math xmlns="http://www.w3.org/1998/Math/MathML"> B 1 B 0 B_1B_0 </math>B1B0, 看下竖式乘法过程。

在做竖式乘法时,我们用 num2 的每一位去乘 num1 每一位,然后再将积累加到结果对应位(暂不考虑进位)。关键是如何确定 num2 的一位 <math xmlns="http://www.w3.org/1998/Math/MathML"> B j B_j </math>Bj 乘 num1 一位 <math xmlns="http://www.w3.org/1998/Math/MathML"> A i A_i </math>Ai 之积在结果中处于哪一位,从上图可以看出 <math xmlns="http://www.w3.org/1998/Math/MathML"> A i B j A_iB_j </math>AiBj 刚好属于结果中 <math xmlns="http://www.w3.org/1998/Math/MathML"> i + j i+j </math>i+j 位.

这样事情就变得简单了,我们只需要先枚举 num1 的每一位 <math xmlns="http://www.w3.org/1998/Math/MathML"> A i A_i </math>Ai ,然后枚举 num2 的一位 <math xmlns="http://www.w3.org/1998/Math/MathML"> B j B_j </math>Bj,再将它们之积 <math xmlns="http://www.w3.org/1998/Math/MathML"> A i B j A_iB_j </math>AiBj 累加到 <math xmlns="http://www.w3.org/1998/Math/MathML"> C i + j C_{i+j} </math>Ci+j, 最后统一处理进位。需要注意的是,

  1. 如果 num1num2 都不为零,且都不含前导零, 这它们之积至少 num1.length() + n2.length - 1 位,如上图, <math xmlns="http://www.w3.org/1998/Math/MathML"> B 0 B_0 </math>B0 乘以 num1 每一位后,结果位数和 num1 位数相同为 num1.length() , 后面 num2 每一位乘 num1 时, 结果位数多增加一位。除了 <math xmlns="http://www.w3.org/1998/Math/MathML"> B 0 B_0 </math>B0,num2 后面还有 n2.length - 1 位要乘 num1 ,所以结果要多增加 n2.length - 1 位,所以结果至少 num1.length() + n2.length - 1 位。
  2. num1num2 之积最多 num1.length() + n2.length 位。
java 复制代码
public String multiply(String num1, String num2) {

    StringBuilder ans = new StringBuilder();

    // ① 翻转
    String reversed1 = new StringBuffer(num1).reverse().toString();
    String reversed2 = new StringBuffer(num2).reverse().toString();

    // ② 长度预处理
    int n1[] = new int[num1.length()];
    int n2[] = new int[num2.length()];
    int c[] = new int[num1.length() + num2.length()];  // 结果
    int length = num1.length() + n2.length;

    // ③ 转存
    for (int i = 0; i < reversed1.length(); i++) {
        n1[i] = Character.getNumericValue(reversed1.charAt(i));
    }
    for (int i = 0; i < reversed2.length(); i++) {
        n2[i] = Character.getNumericValue(reversed2.charAt(i));
    }

    for (int i = 0; i < n1.length; i++) {   // ④ 按位相乘
        for (int j = 0; j < n2.length; j++) {
            c[i + j] += n1[i] * n2[j];
        }
    }
    
    // 统一处理进位
    for (int i = 0; i < length-1; i++) {
        c[i + 1] += c[i] / 10;
        c[i] %= 10;
    }
    
    // num1 / num2 可能为零, 结果就只有一位零
    while (c[length - 1] == 0 && length > 1) {
        length--;
    }
    for (int i = length - 1; i >= 0; i--) {   // ⑤ 结果逆序
        ans.append(c[i]);
    }
    return ans.toString();
}

除法

高精除以低精

有高精度乘低精度,那有没有高精度除以低精度呢?答案是肯定的。

和高精度乘低精度一样,我们可以把低精度数当成一个整体去除高精度数每一位,但需要注意的是,除法运算是从被除数高位开始依次向低位除以除数,所以高精度数顺序转存即可。

java 复制代码
public String stringDividedByInt(String num1, int num2) {

    // 计算结果直接存到 n1 中
    long n1[] = new long[num1.length()];  

    // 顺序转存
    for (int i = 0; i < num1.length(); i++) {
        n1[i] = Character.getNumericValue(num1.charAt(i));
    }

    long carry = 0;  // 余数

    for (int i = 0; i < n1.length; i++) {
        n1[i] += carry * 10;  // 当前位加上上一位余数,上一位余数到这一位权相差 10
        carry = n1[i] % num2; 
        n1[i] /= num2; 
    }

    int index = 0;   // 取出结果高位前导零
    while (n1[index] == 0 && index < num1.length() - 1) {
        index++;
    }
    
    StringBuilder ans = new StringBuilder();

    for (int i = index; i < num1.length(); i++) {   // 结果顺序输出
        ans.append(n1[i]);
    }
    return ans.toString();
}

洛谷可练习如下 高精度除以低精度 题目

  1. # P1480 A/B Problem

高精除以高精

在模拟计算高精度除法前,先看下被除数为 num1=78646 与除数 num2=322 时,竖式除法计算过程

从上图可以看出,竖式除法大致过程为

  1. 除数和被除数高位对齐,截取被除数左边和除数相同长度作为新被除数 dividend
  2. 试商dividend / num2
  3. 求余dividend % num2
  4. 往右取被除数一位,和步骤三产生的余数组成新被除数。
  5. 重复步骤二到步骤四直到被除数向右取完。

新被除数最长长度可能 比除数长度多一位 ,因为步骤三余数可能和除数相同,拼接上往右取的一位被除数,最终就会比除数长度多一位。例如上图,新被除数 786 除以 322 后余 142 , 拼接上往右取的一位被除数 4 ,组成的新被除数为 1424 。另外,新被除数最少可能为 ,当步骤三产生的余数和往右取的一位被除数都为零时,新被除数为零。

如果大整数除法模拟以上过程, 试商求余 是不能直接计算,因为在大整数除法中,新被除数 dividend 和除数 num2 也为大整数,计算其商 dividend / num2 又用到到大整数除法,这不就是先有鸡还是先有蛋问题吗?

其实我们可以通过减法实现 试商 ,计算 dividend / num2 ,只要 dividend ≥ num2 情况下,我们就用 num2 减一次 dividend , 商加一,最后不够减的 dividend 就为余数。

那可能就有疑问了,为什么计算大整数除法 num1 / num2 不直接用如上减法实现呢?理论上是可行的,但是这样时间复杂度太大,例如 num1 = "2147483647"num2="1" 时, 岂不是需要做 2147483647 次减法,更何况 num1 可能远远不止这么大。 所以我们还是需要模拟竖式除法计算大整数除法。

当除数为 num1="78646" 与除数 num2="322" 时,模拟竖式除法计算大整数除法过程如下

因为 试商 通过减法实现,而减法需要逆序存储,所以计算大整数除法代码中,num1num2 也需要逆序转存,最终代码如下

java 复制代码
// 新被除数 ≥ 除数 ?
public boolean eg(int n1[], int n2[], int k) {
    if (k + n2.length < n1.length && n1[k + n2.length] != 0) { // 新被除数比除数多一位
        return true;
    }
    for (int i = n2.length - 1; i >= 0; i--) { // 从高位开始,依次比较大小
        if (n1[i + k] > n2[i]) {
            return true;
        }
        if (n1[i + k] < n2[i]) {
            return false;
        }
    }

    return true;
}


public String divide(String num1, String num2) {

    // ① 翻转
    String reversed1 = new StringBuffer(num1).reverse().toString();
    String reversed2 = new StringBuffer(num2).reverse().toString();

    // ② 长度预处理
    int n1[] = new int[num1.length()];
    int n2[] = new int[num2.length()];
    
    // 结果长度最多和被除数长度相同
    int c[] = new int[num1.length()];  // 结果
    
    // ③ 逆序转存
    for (int i = 0; i < reversed1.length(); i++) {
        n1[i] = Character.getNumericValue(reversed1.charAt(i));
    }
    for (int i = 0; i < reversed2.length(); i++) {
        n2[i] = Character.getNumericValue(reversed2.charAt(i));
    }
    
    int last = n1.length - n2.length; // 第一位商位置

    for (int i = last; i >= 0; i--) {  // ④ 计算
        while (eg(n1, n2, i)) { // 新被除数 ≥ 除数
            for (int j = 0; j < n2.length; j++) {  // 新被除数 - 除数
                if (n1[i + j] < n2[j]) {
                    n1[i + j + 1]--;
                    n1[i + j] += 10;
                }
                n1[i + j] -= n2[j];
            }
            c[i]++;  // 对应位商+1
        }
    }

    int length = c.length;

    // 除去高位前导零
    while (c[length - 1] == 0 && length > 1) {
        length--;
    }

    StringBuilder sb = new StringBuilder();
    for (int i = length - 1; i >= 0; i--) {  // ⑤ 结果逆序
        sb.append(c[i]);
    }
    return sb.toString();
}

利用以上方法,我们完成力扣题目 两数相除

给你两个整数,被除数 dividend 和除数 divisor。将两数相除,要求 不使用 乘法、除法和取余运算。

整数除法应该向零截断,也就是截去(truncate)其小数部分。例如,8.345 将被截断为 8-2.7335 将被截断至 -2

返回被除数 dividend 除以除数 divisor 得到的

注意: 假设我们的环境只能存储 32 位 有符号整数,其数值范围是 [−231, 231 − 1] 。本题中,如果商 严格大于 231 − 1 ,则返回 231 − 1 ;如果商 严格小于 -231 ,则返回 -231

java 复制代码
public int divide(int dividend, int divisor) {

    // 将被除数和除数都转成正数
    // ① Integer.MIN_VALUE 转成正数可能超出 int 范围,故先 long 转存
    String num1 = String.valueOf(Math.abs(Long.valueOf(dividend)));  
    String num2 = String.valueOf(Math.abs(Long.valueOf(divisor)));

    Long quotient = Long.parseLong(divide(num1, num2));
    if (dividend > 0 && divisor < 0 || dividend < 0 && divisor > 0) {  // 异号?
        quotient = -quotient;
    }
    //  -2147483648 / -1 = 2147483648 
    if (quotient > Integer.MAX_VALUE) {  // ② 商超出 int 范围
        return Integer.MAX_VALUE;
    }
    // 其实这种情况不存在, 可以删除这个判断
    if (quotient < Integer.MIN_VALUE) {  // ③ 商超出 int 范围
        return Integer.MIN_VALUE;
    }

    return quotient.intValue();

}
相关推荐
好奇龙猫11 分钟前
【学习AI-相关路程-mnist手写数字分类-win-硬件:windows-自我学习AI-实验步骤-全连接神经网络(BPnetwork)-操作流程(3) 】
人工智能·算法
sp_fyf_20241 小时前
计算机前沿技术-人工智能算法-大语言模型-最新研究进展-2024-11-01
人工智能·深度学习·神经网络·算法·机器学习·语言模型·数据挖掘
香菜大丸1 小时前
链表的归并排序
数据结构·算法·链表
jrrz08281 小时前
LeetCode 热题100(七)【链表】(1)
数据结构·c++·算法·leetcode·链表
oliveira-time1 小时前
golang学习2
算法
南宫生2 小时前
贪心算法习题其四【力扣】【算法学习day.21】
学习·算法·leetcode·链表·贪心算法
懒惰才能让科技进步3 小时前
从零学习大模型(十二)-----基于梯度的重要性剪枝(Gradient-based Pruning)
人工智能·深度学习·学习·算法·chatgpt·transformer·剪枝
Ni-Guvara3 小时前
函数对象笔记
c++·算法
泉崎4 小时前
11.7比赛总结
数据结构·算法
你好helloworld4 小时前
滑动窗口最大值
数据结构·算法·leetcode