Đây là một bài trong series Algorithms.
Mở bài
Chào các bạn, mình lại bắt đầu một series mới không liên quan gì đến chuyên ngành Lần này là các thuật toán nhân nhanh được sử dụng trong (phần cứng/mềm) máy tính nhé Không chần chừ gì nữa, hãy vào bài thôi!
Bài này có liên quan đến một bài rank S trong Viblo Code nhé
Thuật toán nhân cổ điển
Bình thường bạn nhân các số như thế nào? Ví dụ như 123 x 456? Như chúng ta được học ở trên trường, thì phải nhân dần các chữ số, rồi cộng lại theo cột đúng không?
123
x 456
----- 738 615
492
-----
56088
Trong đó, tạm coi phép nhân từng chữ số có thể được coi là phép cộng. Coi phép cộng là đơn vị cơ bản, chúng ta có thể thấy thuật toán này sử dụng phép tính toán. Tạm thời bỏ qua các tối ưu liên quan đến caching trong lúc cộng dồn nhé.
Bây giờ để tránh việc phải nhân các số đơn kia, chúng ta chuyển sang tính dưới dạng nhị phân:
1111011 x 111001000
---------------- 0000000 0000000 0000000 1111011 0000000 0000000 1111011 1111011 1111011
----------------
1101101100011000
Ở đây chúng ta mất 9 phép cộng. Trong trường hợp xấu nhất, chúng ta cần cộng (chú ý rằng khi tính asymptotic complexity chúng ta tính với kích cỡ bài toán/số bit của đầu vào nhé). Vậy có cách nào nhân nhanh hơn không?
Thuật toán Karatsuba
Thuật toán này thực ra rất đơn giản. Là một dạng divide-and-conquer, Karatsuba chia nhỏ bài toán của chúng ta ra để tính lẻ:
Chú ý công thức trên đang ở hệ nhị phân, và các số sinh ra khi chúng ta rightshift higher-order bits của các thừa số. Từ một phép nhân 2 số 9-bit, chúng ta ra được 4 phép nhân 2 số 5-bit kèm 3 phép cộng. Điểm hay ở thuật toán này là chúng ta có thể sử dụng đệ quy và tiếp tục dùng Karatsuba cho các phép toán nhỏ hơn! Cứ như vậy chúng ta sẽ chỉ còn ra các phép cộng, và phép cộng thì nhanh hơn phép nhân (duh.)
Thuật toán này còn có thể tối ưu hơn nữa. Không dùng số nữa mà thay bằng chữ cho ngắn, ta có:
Với công thức mới này chúng ta chỉ phải sử dụng 3 phép nhân và 6 phép cộng.
Vậy độ phức tạp của thuật toán này là gì? Tạm bỏ qua các phép cộng (hì), cứ mỗi lần chia đôi kích cỡ bài toán số lần nhân bị gấp 3. Từ đó số phép toán của chúng ta là
Nhưng mà từ từ bạn ơi, thuật toán cơ bản kia tuyến tính với kích cỡ đầu vào mà, cái này là polynomial-time rồi???
Đúng là thuật toán cơ bản kia (có tên gọi là shift-and-add) nhanh hơn Karatsuba thật. Tuy nhiên, bây giờ chúng ta sẽ gian lận và thay đổi bài toán một tí nhé: thay vì chỉ nhân 2 số, chúng ta cần nhân 2 đa thức thì sao? Thay hệ thập phân bằng các đơn thức , ta có bài toán mới:
Bùm, ngay lập tức thế trận khác ngay. Do không còn là số nữa, bạn không thể sử dụng shift-and-add, mà phải phá tung phép nhân ra như thế này. Coi phép nhân số là phép toán cơ bản, cách tính trên mất phép toán, trong khi sử dụng Karatsuba bạn chỉ mất thôi.
Ơ thế bạn đổi bài toán rồi thì còn nói làm gì nữa???
Vấn đề là nhiều lúc bài toán nhân của bạn sẽ phải làm với các số rất lớn. Bình thường kết quả nhân của các bạn có thể vừa một biến 64-bit, thì shift-and-add nhanh là đúng rồi. Thế nếu bây giờ các số của bạn rất lớn thì sao? Ví dụ, khi sử dụng thuật toán RSA trong mã hóa, mỗi số nguyên tố thường là một số 4096-bit, và nhân 2 số như thế với nhau thì chắc chắn là mệt Những số to như vậy sẽ được lưu ở nhiều địa chỉ khác nhau (mỗi địa chỉ chứa một giá trị 64-bit), và phép toán xuyên các địa chỉ trong bộ nhớ sẽ tốn thời gian và không gian. Thay vào đó, chúng ta sử dụng thuật toán Karatsuba trên với và các coefficient là các giá trị được lưu trong từng địa chỉ đó.
Rồi tạm chịu, vậy phép nhân đa thức còn được dùng để làm gì nữa?
Phép nhân đa thức được sử dụng khá nhiều đó nhé Ví dụ như trong vật lý/3D modeling trong các game bạn hay chơi chẳng hạn, các particle/model di chuyển theo các đa thức trên hệ trục tọa độ. Hay trong các hệ mật mã (một phần khác mà mình hay tìm hiểu), các đa thức được sử dụng khá nhiều (như RSA có khá nhiều thuật toán phá sử dụng polynomial, hay elliptic curve sử dụng finite field extensions). Nói chung là nhiều lắm.
Thuật toán Fast Fourier Transform
Chú ý: RẤT NHIỀU TOÁN!
Có một thuật toán nhân khác còn nhanh hơn Karatsuba nữa, và nó sử dụng một thuật toán bạn có thể không ngờ tới: đó là Fast Fourier Transform! Thuật toán này sử dụng các phép biến đổi trong một Ring để tăng tốc độ tính toán.
Ring là gì?
Đơn giản thì ring là một tập hợp các số (có thể vô hạn) và 2 phép toán, tạm gọi là và , thỏa mãn khá nhiều tính chất sau đây:
- Tồn tại các identity cho các phép toán: với mọi :
- Cả 2 phép tính có tính kết hợp: với mọi :
- Phép cộng có tính giao hoán và nghịch đảo: với mọi :
- Có tính phân phối: với mọi :
Các số có multiplicative inverse trong một ring được gọi là unit. Ngoài ra, các số được gọi là zero-divisors trong R.
Chúng ta có thể thấy rằng tập số nguyên hay tập số thực đều là các trường hợp đặc biệt của một Ring.
Integers modulo a prime
Tuy nhiên, nếu làm trong tập số nguyên thì không nhanh được Chúng ta sẽ sử dụng ring với các phép toán modulo. Ví dụ, trong , thì , và (do sử dụng modulo 5).
Lựa chọn số nguyên tố nào cho bài toán này? Thực ra bạn có thể chọn bất cứ số nguyên tố nào lớn hơn kích cỡ bài toán, thì kết quả của phép toán sẽ không đổi: ví dụ như trong , thì giống như phép nhân trong .
Tuy nhiên, để dễ dàng chúng ta có thể chọn Fermat prime: đó là các số nguyên tố có dạng . Một ví dụ là khi , khi đó . Tại sao?
Primitive root of unity
là một -th root of unity trong ring nếu . Ngoài ra, là một primitive root of unity nếu:
- là một unit (tồn tại multiplicative inverse)
- Với mọi ước nguyên tố của , khác 0 và không phải một zero-divisor.
Để sử dụng thuật toán FFT, chúng ta cần một primitive -th root of unity với lớn hơn bậc của kết quả phép nhân. Với Fermat prime, tất cả các số trong ring đó đều là -th ring of unity theo định lý Fermat nhỏ (không phải primitive!) Còn nếu muốn tìm primitive root thì chắc chỉ có cách mò/tìm các số có sẵn trên mạng
Discrete Fourier Transform
DFT là một -linear mapping tính các giá trị của đa thức tại các giá trị mũ của :
Chúng ta có thể biểu diễn phép biến đổi tuyến tính này bằng phép toán ma trận: với ,
Trong đó ma trận to đùng bên trái được gọi là Vandemonde matrix .
Để tính Inverse DFT, chúng ta chỉ cần nhân với nghịch đảo của nó:
Cách tìm nghịch đảo của 1 số modulo thì sử dụng Extended Euclidean Algorithm, các bạn có thể đọc tại đây.
Tích chập của 2 đa thức
Xin lỗi người đọc vì mấy đoạn này khá khô khan Với 2 đa thức và , chúng ta có với
Từ công thức trên, chúng ta có thể thấy , chứng minh để dành cho bạn đọc Vì vậy, do chúng ta đã đặt là bậc của kết quả, nên .
Chú ý quan trọng! Một tính chất quan trọng của DFT mà sẽ giúp chúng ta là:
trong đó là elementwise multiplication.
Ghép tất cả mọi thứ lại...
Từ 2 đa thứ , chúng ta làm các bước đơn giản sau:
Vậy độ phức tạp của thuật toán này là gì?
- Bước tích chập: tổng tích là
- Bước elementwise multiplication 2 kết quả DFT: phép nhân là
- DFT và IDFT: 2x rất lâu ().
Vậy thì bằng với nhân bình thường rồi... Nhưng đừng quên chúng ta có FFT!
Fast Fourier Transform
Phát minh bởi Cooley-Tukey, thuật toán này được sử dụng với các polynomial có bậc là bằng phương pháp divide-and-conquer. Chúng ta chia nhỏ đa thức thành 2 đa thức nhỏ hơn, nhưng khác với Karatsuba, chúng ta cắt so le:
Có thể thấy và đều là các đa thức bậc . Chúng ta sẽ đệ quy tính FFT của các hàm đó rồi ráp lại: với
và
Chúng ta ra được kết quả cuối
bằng công thức:
Phép cộng và trừ kia còn được gọi là butterfly, vì 2 phép tính kia chéo nhìn giống cánh bướm. Bạn có thể nhìn hình tượng cánh bướm ở dưới đây (cẩn thận xem xong quên luôn cả bài, lần đầu tiên mình nhìn cái ảnh này mình thấy hết hiểu tất cả những gì thầy vừa dạy luôn):
Vậy độ phức tạp của thuật toán này là gì? Có bước đệ quy, và ở mỗi bước có phép FFT con, mỗi phép cần phép toán. Nhân nhân cộng cộng lại chúng ta có độ phức tạp của thuật toán này là .
Để ý rằng độ phức tạp của toàn bộ phép nhân này bị dominated bởi DFT/FFT (2 bước kia đều nhanh hơn FFT), nên độ phức tạp của thuật toán nhân đa thức này bằng độ phức tạp của FFT.
Fun fact: độ phức tạp này chỉ đúng khi chúng ta nhân các số nguyên, còn nếu nhân số thực thì thực tế là ; nếu bạn muốn tìm hiểu thêm tại sao, hay đọc thêm tại link này nhé.
Bonus
Thuật toán này ngoài việc có asymptotic complexity thấp hơn Karatsuba, còn có 1 điểm cộng: chúng ta có thể dễ dàng tính mũ của một đa thức rất nhanh, bằng cách trong quá trình elementwise multiplication, chúng ta thay bằng elementwise exponentiation. Hàm mũ của một số có thể tính nhanh bằng phương pháp cứ bình phương rồi nhân lại với nhau, gần giống với phương pháp shift-and-add
Bạn có thể tham khảo implementation của FFT tại đây, mình copy từ đây trong bài Viblo Code của mình :">
Kết bài
Bây giờ bạn đã biết nhân các đa thức nhanh như thế nào rồi đó! Đi làm câu rank S kia đi
Thực ra lĩnh vực này khá liên quan đến deep learning, không biết viết xong bài này mình có nghĩ ra gì hay ho không?
And last but not least, thank you Dr. Turner for teaching me this crap, tolerated me fucking up spectacularly in your class, and still give me an A and a distinction in Math. You're the man.