1. 程式人生 > >【演算法】大數乘法問題及其高效演算法

【演算法】大數乘法問題及其高效演算法

題目

編寫兩個任意位數的大數相乘的程式,給出計算結果。比如:

題目描述: 輸出兩個不超過100位的大整數的乘積。
輸入: 輸入兩個大整數,如1234567 和 123
輸出: 輸出乘積,如:151851741

或者

1234567891011121314151617181920 * 2019181716151413121110987654321 的乘積結果

分析

所謂大數相乘(Multiplication algorithm),就是指數字比較大,相乘的結果超出了基本型別的表示範圍,所以這樣的數不能夠直接做乘法運算。

參考了很多資料,包括維基百科詞條Multiplication algorithm

,才知道目前大數乘法演算法主要有以下幾種思路:

  1. 模擬小學乘法:最簡單的乘法豎式手算的累加型;
  2. 分治乘法:最簡單的是Karatsuba乘法,一般化以後有Toom-Cook乘法;
  3. 快速傅立葉變換FFT:(為了避免精度問題,可以改用快速數論變換FNTT),時間複雜度O(N lgN lglgN)。具體可參照Schönhage–Strassen algorithm
  4. 中國剩餘定理:把每個數分解到一些互素的模上,然後每個同餘方程對應乘起來就行;
  5. Furer’s algorithm:在漸進意義上FNTT還快的演算法。不過好像不太實用,本文就不作介紹了。大家可以參考維基百科Fürer’s algorithm

解法

我們分別實現一下以上演算法,既然不能直接使用乘法做運算,最簡單最容易想到的辦法就是模擬乘法運算。

1、模擬乘法手算累加

      7 8 9 6 5 2
×         3 2 1 1
-----------------
      7 8 9 6 5 2   <---- 第1趟 
    7 8 9 6 5 2     <---- 第2趟 
   ..........       <---- 第n趟 
-----------------
  ? ? ? ? ? ? ? ?   <---- 最後的值用另一個數組表示 

如上所示,乘法運算可以分拆為兩步:

  • 第一步,是將乘數與被乘數逐位相乘;
  • 第二步,將逐位相乘得到的結果,對應相加起來。

這有點類似小學數學中,計算乘法時通常採用的“豎式運算”。用Java簡單實現了這個演算法,程式碼如下:

/**
 * 大數相乘 - 模擬乘法手算累加
 */
public static Integer[] bigNumberMultiply(int[] arr1, int[] arr2){
    ArrayList<Integer> result = new ArrayList<>();  //中間求和的結果

    //arr2 逐位與arr1相乘
    for(int i = arr2.length - 1; i >= 0; i--){
        int carry = 0;
        ArrayList<Integer> singleList = new ArrayList<>();

        //arr2 逐位單次乘法的結果
        for(int j = arr1.length - 1; j >= 0; j--){
            int r = arr2[i] * arr1[j] + carry;
            int digit = r % 10;
            carry = r / 10;

            singleList.add(digit);
        }
        if(carry != 0){
            singleList.add(carry);
        }

        int resultCarry = 0, count = 0;
        int k = 0;
        int l = 0;
        int offset = arr2.length - 1 - i;       //加法的偏移位
        ArrayList<Integer> middleResult = new ArrayList<>();

        //arr2每位乘法的結果與上一輪的求和結果相加,從右向左做加法並進位
        while (k < singleList.size() || l < result.size()) {
            int kv = 0, lv = 0;
            if (k < singleList.size() && count >= offset) {
                kv = singleList.get(k++);
            }
            if (l < result.size()) {
                lv = result.get(l++);
            }
            int sum = resultCarry + kv + lv;
            middleResult.add(sum % 10);     //相加結果從右向左(高位到低位)暫時儲存,最後需要逆向輸出
            resultCarry = sum / 10;
            count++;
        }
        if(resultCarry != 0){
            middleResult.add(resultCarry);
        }
        result.clear();
        result = middleResult;
    }

    Collections.reverse(result);    //逆向輸出結果
    return result.toArray(new Integer[result.size()]);
}

看了以上的程式碼,感覺思路雖然很簡單,但是實現起來卻很麻煩,那麼我們有沒有別的方法來實現這個程式呢?答案是有的,接下來我來介紹第二種方法。

2、模擬乘法累加 - 改進

簡單來說,方法二就是先不算任何的進位,也就是說,將每一位相乘,相加的結果儲存到同一個位置,到最後才計算進位

例如:計算98×21,步驟如下

        9  8
×       2  1
-------------
       (9)(8)  <---- 第1趟: 98×1的每一位結果 
  (18)(16)     <---- 第2趟: 98×2的每一位結果 
-------------
  (18)(25)(8)  <---- 這裡就是相對位的和,還沒有累加進位 

這裡唯一要注意的便是進位問題,我們可以先不考慮進位,當所有位對應相加,產生結果之後,再考慮。從右向左依次累加,如果該位的數字大於10,那麼我們用取餘運算,在該位上只保留取餘後的個位數,而將十位數進位(通過模運算得到)累加到高位便可,迴圈直到累加完畢。

核心程式碼如下:

/**
 * 大數相乘方法二
 */
public static int[] bigNumberMultiply2(int[] num1, int[] num2){
    // 分配一個空間,用來儲存運算的結果,num1長的數 * num2長的數,結果不會超過num1+num2長
    int[] result = new int[num1.length + num2.length];

    // 先不考慮進位問題,根據豎式的乘法運算,num1的第i位與num2的第j位相乘,結果應該存放在結果的第i+j位上
    for (int i = 0; i < num1.length; i++){
        for (int j = 0; j < num2.length; j++){
            result[i + j + 1] += num1[i] * num2[j];  // (因為進位的問題,最終放置到第i+j+1位)
        }
    }

    //單獨處理進位
    for(int k = result.length-1; k > 0; k--){
        if(result[k] > 10){
            result[k - 1] += result[k] / 10;
            result[k] %= 10;
        }
    }
    return result;
}

!!注意:這裡的進位有個大坑,因為result[]陣列是從左到右記錄相對位的和(還沒有進位),而最後的進位是從右向左累加進位,這樣的話,如果最高位,也就是最左側那一位的累加結果需要進位的話,result[]陣列就沒有空間存放了。

而正好result[]陣列的最後一位空置,不可能被佔用,我們就響應地把num1的第i位與num2的第j位相乘,結果應該存放在結果的第i+j位上的這個結果往後順移一位(放到第i+j+1位),最後從右向左累加時就多了一個空間。

3、分治 - Karatsuba演算法

以上兩種模擬乘法的手算累加型演算法,他們都是模擬普通乘法的計算方式,時間複雜度都是O(n^2),而這個Karatsuba演算法,時間複雜度僅有 O(nlog23) 。下面,我就來介紹一下這個演算法。

Karatsuba於1960年發明在 O(nlog23) 步驟內將兩個n位數相乘的Karatsuba演算法。它反證了安德雷·柯爾莫哥洛夫於1956年認為這個乘法需要 Ω(n2) 步驟的猜想。

首先來看看這個演算法是怎麼進行計算的,見下圖:

Karatsuba Multiplication Algorithm步驟

圖中顯示了計算5678 * 1234的過程,首先是拆分成abcd四個部分,然後分別計算ac, bd, (a+b)*(c+d),最後再用第三個算式的結果減去前面兩個(其實得到的就是bc+ad,但是減少了乘法步驟),然後,計算式1後面加4個0,計算式2後面不加,計算式3後面加2個0,再把這三者相加,就是正確結果。

Karatsuba演算法證明

我們假設要相乘的兩個數是x * y。我們可以把x,y寫成:

x=a10n/2+b y=c10n/2+d

這裡的n是數字的位數。如果是偶數,則a和b都是n/2位的。如果n是奇數,則你可以讓a是n/2+1位,b是n/2位。(例如a = 12,b = 34;a = 123,b = 45),那麼x*y就變成了:

xy=(a10n/2+b)(c10n/2+d)

進一步計算,

xy=ac10n+(ad+bc)10n/2+bd

對比之前的計算過程。結果已經呼之欲出了。這裡唯一需要注意的兩點就是:

  1. (a * d + b * c)的計算為了防止兩次乘法,應該使用之前的計算
  2. 這些乘法在演算法裡應該是遞迴實現的,數字很大時,先拆分,然後拆分出來的數字還是很大的話,就繼續拆分,直到a * b已經是一個非常簡單的小問題為之。這也是分治的思想。

我們舉例來嘗試一下這種演算法,比如計算12345 * 6789,我們讓a = 12b = 345。同時c = 6d = 789。也就是:

12345=121000+3456789=61000+789

那麼a*cb*d的結果如下:

z2=ac=12×6=72z0=bd=345×789=272205z1=(12+345)×(6+789)z2z0=28381572272205=11538

最終結果就是:

result=z21023+z1103+z0result=72106+11538103+272205=83810205.

以上就是使用分治的方式計算乘法的原理。上面這個演算法,由 Anatolii Alexeevitch Karatsuba 於1960年提出並於1962年發表,所以也被稱為 Karatsuba 乘法。

根據上面的思路,實現的Karatsuba乘法程式碼如下:

/**
 * Karatsuba乘法
 */
public static long karatsuba(long num1, long num2){
    //遞迴終止條件
    if(num1 < 10 || num2 < 10) return num1 * num2;

    // 計算拆分長度
    int size1 = String.valueOf(num1).length();
    int size2 = String.valueOf(num2).length();
    int halfN = Math.max(size1, size2) / 2;

    /* 拆分為a, b, c, d */
    long a = Long.valueOf(String.valueOf(num1).substring(0, size1 - halfN));
    long b = Long.valueOf(String.valueOf(num1).substring(size1 - halfN));
    long c = Long.valueOf(String.valueOf(num2).substring(0, size2 - halfN));
    long d = Long.valueOf(String.valueOf(num2).substring(size2 - halfN));

    // 計算z2, z0, z1, 此處的乘法使用遞迴
    long z2 = karatsuba(a, c);
    long z0 = karatsuba(b, d);
    long z1 = karatsuba((a + b), (c + d)) - z0 - z2;

    return (long)(z2 * Math.pow(10, (2*halfN)) + z1 * Math.pow(10, halfN) + z0);
}

總結:

Karatsuba 演算法是比較簡單的遞迴乘法,把輸入拆分成 2 部分,不過對於更大的數,可以把輸入拆分成 3 部分甚至 4 部分。拆分為 3 部分時,可以使用下面的Toom-Cook 3-way 乘法,複雜度降低到 O(n^1.465)。拆分為 4 部分時,使用Toom-Cook 4-way 乘法,複雜度進一步下降到 O(n^1.404)。對於更大的數字,可以拆成 100 段,使用快速傅立葉變換FFT,複雜度接近線性,大約是 O(n^1.149)。可以看出,分割越大,時間複雜度就越低,但是所要計算的中間項以及合併最終結果的過程就會越複雜,開銷會增加,因此分割點上升,對於公鑰加密,暫時用不到太大的整數,所以使用 Karatsuba 就合適了,不用再去弄更復雜的遞迴乘法。

測試程式

public class LeetcodeTest {

    public static void main(String[] args) {
//        String a = "1234567891011121314151617181920";
//        String b = "2019181716151413121110987654321";

//        String a = "999999999999";
//        String b = "999999999999";

//        String a = "24566";
//        String b = "452053";

        String a = "98";
        String b = "21";

        char[] charArr1 = a.trim().toCharArray();
        char[] charArr2 = b.trim().toCharArray();

        // 字元陣列轉換為int[]陣列
        int[] arr1 = new int[charArr1.length];
        int[] arr2 = new int[charArr2.length];
        for(int i = 0; i < charArr1.length; i++){
            arr1[i] = charArr1[i] - '0';
        }
        for(int i = 0; i < charArr2.length; i++){
            arr2[i] = charArr2[i] - '0';
        }

        // 開始計算
        int[] result = LeetcodeTest.bigNumberMultiply2(arr1, arr2);
        System.out.println(a + " * " + b + " = " + Arrays.toString(result).replace(", ", ""));
    }
}

最後,是測試用例輸出結果:

1234567891011121314151617181920 * 2019181716151413121110987654321 = [02492816912877266687794240983772975935013386905490061131076320]

999999999999 * 999999999999 = [999999999998000000000001]

24566 * 452053 = [11105133998]

98 * 21 = [2058]

參考資料