簡體   English   中英

有效的方法來乘以一大組小數

[英]Efficient way to multiply a large set of small numbers

在一次采訪中詢問了這個問題。

你有一個小整數數組。 你必須倍增所有這些。 你不必擔心溢出,你有足夠的支持。 你能做些什么來加速機器上的乘法?

在這種情況下,多次添加會更好嗎?

我建議使用分而治之的方法進行乘法,但面試官並沒有留下深刻的印象。 什么是最好的解決方案呢?

以下是一些想法:

使用多線程進行分而治之 :將輸入分成n個不同的大小為b的塊,並遞歸地將每個塊中的所有數字相乘。 然后,遞歸地將所有n / b塊再次相乘。 如果您有多個內核並且可以並行運行部分內核,則可以節省大量時間。

單詞級並行 :讓我們假設你的數字都是從上面被一些數字U限制,這恰好是2的冪。 現在,假設您想要將a,b,c和d相乘。 通過計算開始(4U 2 a + b)×(4U 2 c + d)= 16U 4 ac + 4U 2 ad + 4U 2 bc + bd。 現在,請注意這個表達式mod U 2只是bd。 (由於bd <U 2 ,我們不需要擔心mod U 2步驟搞砸了)。 這意味着,如果我們計算這個產品並將其設為mod U 2 ,我們就會回到bd。 由於U 22的冪,因此可以使用位掩碼來完成。

接下來,請注意

4U 2 ad + 4U 2 bc + bd <4U 4 + 4U 4 + U 2 <9U 4 <16U 4

這意味着如果我們將整個表達式除以16U 4並向下舍入,我們最終將返回廣告。 這種划分可以通過比特移位來完成,因為16U 4是2的冪。

因此,通過一次乘法,您可以通過應用后續的bitshift和bitmask來獲取ac和bd的值。 一旦你有了ac和bd,你可以直接將它們相乘以獲得abcd的值。 假設位掩碼和位移比乘法快,這將所需的乘法次數減少了33%(這里有兩個而不是三個)。

希望這可以幫助!

你的分而治之的建議是一個好的開始。 它只需要更多的解釋來留下深刻的印象。

使用快速乘法算法來乘以大數(大整數),乘以類似大小的被乘數比一系列不匹配的大小更有效。

這是Clojure中的一個例子

; Define a vector of 100K random integers between 2 and 100 inclusive
(def xs (vec (repeatedly 100000 #(+ 2 (rand-int 99)))))

; Naive multiplication accumulating linearly through the array
(time (def a1 (apply *' xs)))
"Elapsed time: 7116.960557 msecs"

; Simple Divide and conquer algorithm
(defn d-c [v] 
  (let [m (quot (count v) 2)] 
    (if (< m 3) 
      (reduce *' v)
      (*' (d-c (subvec v 0 m)) (d-c (subvec v m))))))

; Takes less than 1/10th the time.
(time (def a2 (d-c xs)))
"Elapsed time: 600.934268 msecs"

(= a1 a2) ;=> true (same result)

請注意,此改進不依賴於數組中整數大小的設置限制(100個任意選擇並演示下一個算法),但只是它們的大小相似。 這是一個非常簡單的划分征服。 隨着數字變得越來越大且成本越來越高,將更多時間花在按類似大小迭代分組上是有意義的。 在這里,我依賴於隨機分布和大小將保持相似的機會,但即使在最壞的情況下,它仍然會比天真的方法明顯更好。

正如Evgeny Kluev在評論中所建議的那樣,對於大量整數,會有很多重復,因此有效取冪也比天真乘法更好。 這取決於相對參數而不是分而治之,即數量必須足夠小,相對於計數足夠重復累積而煩惱,但肯定對這些參數表現良好(范圍2-中的100K數字) 100)。

; Hopefully an efficient implementation
(defn pow [x n] (.pow (biginteger x) ^Integer n))

; Perform pow on duplications based on frequencies
(defn exp-reduce [v] (reduce *' (map (partial apply pow) (frequencies v))))

(time (def a3 (exp-reduce xs)))
"Elapsed time: 650.211789 msecs"

請注意,在這個試驗中,非常簡單的分而治之,只是表現得更好,但如果預計會有更少的重復,那就更好了。

當然我們也可以將兩者結合起來:

(defn exp-d-c [v] (d-c (mapv (partial apply pow) (frequencies v))))

(time (def a4 (exp-d-c xs)))
"Elapsed time: 494.394641 msecs"

(= a1 a2 a3 a4) ;=> true (all equal)

注意,有更好的方法來組合這兩個,因為取冪步驟的結果將導致各種大小的被乘數。 增加復雜度的值取決於輸入中預期數量的不同數字。 在這種情況下,很少有不同的數字,因此增加很多復雜性是不值得的。

另請注意,如果有多個內核可以輕松並行化這兩個內核。

如果許多小整數出現多次,您可以從計算每個唯一整數開始。 如果c(n)是整數n的出現次數,則可以將乘積計算為

P = 2 ^ c(2) * 3 ^ c(3) * 4 ^ c(4) * ...

對於求冪步驟,您可以通過平方使用取冪,這可以顯着減少乘法次數。

如果數字的數量與范圍相比確實很大,那么我們已經看到了兩個漸近解決方案,以大大降低復雜性。 一個是基於連續平方來計算每個數字c的O(log k)時間中的c ^ k,如果最大數字是C則給出O(C mean(log k))時間,並且k給出每個數字之間的指數1如果每個數字出現的次數相等,則均值(log k)項最大化,因此如果你有N個數,則復雜度變為O(C log(N / C)),這非常依賴於N.基本上只是O(C),其中C指定數字范圍。

我們看到的另一種方法是按照它們出現的次數對數字進行排序,並跟蹤前導數字的乘積(從所有數字開始)並將其提升到一個冪,以便從陣列中刪除最不頻繁的數字,以及然后更新數組中剩余元素的指數並重復。 如果所有數字出現相同的次數K,則得到O(C + log K),這是對O的改進(C log K)。 但是,假設第k個數字出現2 ^ k次。 然后,如果C> log(N / C),這仍將給出O(C ^ 2 + C log(N / C))時間,這在技術上比先前的方法O(C log(N / C))更差。 因此,如果您沒有關於每個數字的出現均勻分布的良好信息,您應該采用第一種方法,只需通過使用連續平方獲取產品中出現的每個不同數字的適當功率,並采取結果的產物。 總時間O(C log(N / C))如果有C個不同的數字和N個總數。

要回答這個問題,我們需要以某種方式解釋OP的假設: need not worry about overflow 在這個答案的大部分內容,它被解釋為“忽略溢出”。 但我從幾個關於其他解釋的想法開始(“使用多精度算術”)。 在這種情況下,乘法過程可以大致分為3個階段:

  1. 將一小組小數字相乘,得到一大組不那么小的數字。 這里可以使用本答案第二部分的一些想法。
  2. 將這些數字相乘得到一組大數字。 可以使用平凡(二次時間)算法或Toom-Cook / Karatsuba(次二次時間)方法。
  3. 將大數字相乘。 可以使用Fürer或Schönhage-Strassen算法。 這給出了整個過程的O(N polylog N)時間復雜度。

二進制求冪可以提供一些(不是非常顯着的)速度改進,因為這里提到的大多數(如果不是每個)復數乘法算法的平方比兩個不等數的乘法更快。 我們也可以將每個“小”數分解,並僅對素因子使用二進制求冪。 對於均勻分布的“小”數,這將減少因子log(number_of_values)的指數數量,並略微改善平方/乘法的平衡。

當數字均勻分布時,分而治之。 否則(例如,當輸入數組被排序或使用二進制取冪時)我們可以通過將所有被乘數放入優先級隊列,按編號長度排序(可能近似排序)來做得更好。 然后我們可以將兩個最短的數字相乘並將結果放回隊列(此過程與霍夫曼編碼非常相似)。 無需使用此隊列進行平方。 我們也不應該使用它,而數字不夠長。

有關這方面的更多信息可以在A. Webb的答案中找到。


如果溢出可能被忽略,我們可以將數字乘以線性時間或更好的算法。

如果對輸入數組進行排序或將輸入數據表示為元組{值,出現次數}的集合,則可以使用子線性時間算法。 在后一種情況下,我們可以對每個值執行二進制求冪,並將結果相乘。 時間復雜度為O(C log(N / C)),其中C是數組中不同值的數量。 (另見這個答案 )。

如果輸入數組已排序,我們可以使用二進制搜索來查找值更改的位置。 這允許確定每個值在陣列中出現的次數。 然后我們可以對每個值執行二進制求冪,並將結果相乘。 時間復雜度為O(C log N)。 我們可以在這里使用單邊二分搜索做得更好。 在這種情況下,時間復雜度為O(C log(N / C))。

但是如果輸入數組沒有排序,我們必須檢查每個元素,因此O(N)時間復雜度是我們能做的最好的。 我們仍然可以使用並行性(多線程,SIMD,字級並行)來獲得一些速度提升。 在這里我比較幾種這樣的方法。

為了比較這些方法,我選擇了非常小的(3位)值,這些值非常緊湊(每8位整數一個值)。 並以低級語言(C ++ 11)實現它們,以便更輕松地訪問位操作,特定CPU指令和SIMD。

以下是所有算法:

  1. 從標准庫中accumulate
  2. 使用4個累加器進行簡單的實現。
  3. 用於乘法的字級並行,如templatetypedef的回答所述 對於64位字長,這種方法允許最多10位值(只有3次乘法而不是每次4次),或者它可以應用兩次(我在測試中做到了),最多5位值(僅需要每個8)中有5次乘法。
  4. 表查找。 在測試中,每個8中的7個乘法被單個表查找替換。 如果值大於這些測試中的值,則替換乘法的數量會減少,從而減慢算法的速度。 大於11-12位的值使這種方法變得無用。
  5. 二進制求冪(詳見下文)。 大於4位的值使這種方法無用。
  6. SIMD(AVX2)。 此實現最多可使用8位值。

以下是Ideone上所有測試的來源 請注意,SIMD測試需要Intel的AVX2指令集。 表查找測試需要BMI2指令集。 其他測試不依賴於任何特定的硬件(我希望)。 我在64位Linux上運行這些測試,使用gcc 4.8.1編譯,優化級別為-O2

以下是二進制求冪測試的更多細節:

    for (size_t i = 0; i < size / 8; i += 2)
    {
        auto compr = (qwords[i] << 4) | qwords[i + 1];
        constexpr uint64_t lsb = 0x1111111111111111;
        if ((compr & lsb) != lsb) // if there is at least one even value
        {
            auto b = reinterpret_cast<uint8_t*>(qwords + i);
            acc1 *= accumulate(b, b + 16, acc1, multiplies<unsigned>{});
            if (!acc1)
                break;
        }
        else
        {
            const auto b2 = compr & 0x2222222222222222;
            const auto b4 = compr & 0x4444444444444444;
            const auto b24 = b4 & (b2 * 2);
            const unsigned c7 = __builtin_popcountll(b24);
            acc3 += __builtin_popcountll(b2) - c7;
            acc5 += __builtin_popcountll(b4) - c7;
            acc7 += c7;
        }
    }
    const auto prod4 = acc1 * bexp<3>(acc3) * bexp<5>(acc5) * bexp<7>(acc7);

此代碼打包的值比輸入數組中的值更密集:每個字節兩個值。 每個值的低位有不同的處理:因為我們可以在這里找到32個零位后停止(結果為“零”),這種情況不能很大地改變性能,所以它由最簡單的(庫)算法處理。

在剩余的4個值中,“1”並不令人感興趣,因此我們只需計算“3”,“5”和“7”的出現次數,其中按位操作和“人口計數”固有。

結果如下:

  source size:    4 Mb:         400 Mb:
1. accumulate: 0.835392 ns    0.849199 ns
2.  accum * 4: 0.290373 ns    0.286915 ns
3. 2 mul in 1: 0.178556 ns    0.182606 ns
4. mult table: 0.130707 ns    0.176102 ns
5. binary exp: 0.128484 ns    0.119241 ns
6.       AVX2: 0.0607049 ns   0.0683234 ns

在這里我們可以看到accumulate庫算法非常慢:由於某種原因,gcc無法展開循環並使用4個獨立的累加器。

“手動”進行這種優化並不困難。 結果並不是特別快。 但是如果我們為此任務分配4個線程,CPU將大致匹配內存帶寬(2個通道,DDR3-1600)。

乘法的字級並行幾乎快兩倍。 (我們只需要3個線程來匹配內存帶寬)。

表查找更快。 但是當輸入數組不能適應L3緩存時,其性能會下降。 (我們需要3個線程來匹配內存帶寬)。

二進制求冪具有大致相同的速度。 但是對於較大的輸入,這種性能不會降低,甚至會略微提高,因為與值計數相比,取冪本身使用的時間更少。 (我們只需要2個線程來匹配內存帶寬)。

正如所料,SIMD是最快的。 當輸入陣列無法適應L3緩存時,其性能會略有下降。 這意味着我們接近單線程的內存帶寬。

我有一個解決方案。 讓我們與其他解決方案討論。

問題的關鍵部分是如何減少乘法次數。 整數很小但設置很大。

我的解決方案

  • 使用小數組記錄每個數字出現的次數。
  • 從數組中刪除數字1。 你不需要數數。
  • 找出出現次數最少的數字n。 然后乘以所有數字並得到結果K.然后計算K ^ n。
  • 刪除此編號(例如,您可以使用最后一個數組切換它並減少1的數組大小)。 所以下次你再也不會考慮這個數字了。 同時,其他數字的出現次數需要隨着刪除次數而減少。
  • 再一次得到出現次數最少的數字。 和第2步一樣。
  • 反復進行步驟2-4並完成計數。

讓我用一個例子來說明我們需要做多少乘法:假設我們有5個數字[1,2,3,4,5]。 數字1出現100次,數字2出現150次,數字3出現200次,數字4出現300次,數字5出現400次。

方法1:直接乘法或使用除法和征服我們需要100 + 150 + 200 + 300 + 400-1 = 1149乘以得到結果。

方法2:我們做(1 ^ 100) (2 ^ 150) (3 ^ 200) (4 ^ 300) (5 ^ 400) (100-1)+(150-1)+(200-1)+(300 -1)+(400-1)+4 = 1149. [與方法1相同]原因n ^ m將以m-1乘以契約。 此外,您需要時間來瀏覽所有數字,但這個時間很短。

這篇文章中的方法:首先,你需要時間來瀏覽所有數字。 與乘法時間相比,它可以被丟棄。

您正在進行的實際計數是:((2 * 3 * 4 * 5)^ 150)*((3 * 4 * 5)^ 50)*((4 * 5)^ 100)*(5 ^ 100)

然后你需要做3 + 149 + 2 + 49 + 1 + 99 + 99 + 3 = 405倍

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM