Fast Fourier Transform notes

💡
Có thể nói Fast Fourier Transform là một trong những thuật toán tuyệt vời nhất trong nhành khoa học máy tính từ trước đến nay. Thuật toán này đươc ứng dụng và làm nền tảng trong vô số các ngành khoa học tính toán khác nhau, từ xử lý tính hiệu, phân tích phổ, … đến thậm chí cả mật mã. Trong note này, mình sẽ ghi lại một số ý tưởng chính của thuật toán này.

Các bạn có thể theo dõi Youtube minh họa tuyệt vời này.

Vấn đề của bài toán nhân hai đa thức

Giả sử ta có hai đa thức \(f(x)=x+2\) và \(g(x)=x^2+3x+1\), để nhân hai đa thức này, thông thường, chúng ta phải khai triển phép nhân đa thức như sau:

$$\begin{align*} f(x)g(x)&=(x+2)(x^2+3x+1) \\ &=x(x^2+3x+1) + 2(x^2+3x+1) ()\\ &=(x^3+3x^2+x) + (2x^2+6x+2)\\ &=x^3 + 5x^2 + 7x + 2 \end{align*}$$

Để ý rằng, việc khai triển chính là lấy từng hệ số của đa thức \(f(x)\) rồi nhân với toàn bộ các hệ số của đa thức \(g(x)\). Việc nhân đa thức như vậy sẽ làm cho độ phức tạp của bài toán nhân đa thức trở thành \(O(n^2)\).

Fast Fourier Transform là một phương pháp biến đổi nhanh một đa thức thành một tập hợp các điểm thuộc đa thức ấy, cho phép ta giảm độ phức tạp của bài toán nhân đa thức trở thành \(O(n\log n)\). Bây giờ chúng ta cùng xem xét từng bước của thuật toán này!

Xác định đa thức thông qua các điểm

Định lý: Có duy nhất một đa thức bậc \(k\) đi qua \(k+1\) điểm cho trước.

Mình sẽ không chứng minh định lý này vì các bạn có thể tìm thấy các chứng minh này dễ dàng với từ khoá “nội suy Lagrange“, chỉ xin đưa vài ví dụ để bạn dễ hiểu:

  • Có lẽ ai cũng biết đến tiên đề Euclide: “qua hai điểm cho trước trên mặt phẳng, chỉ có thể kẻ một và chỉ một đường thẳng qua hai điểm đã cho“. Chúng ta cũng biết rằng phương trình đường thẳng là một đa thức bậc một có dạng \(y=f(x)=ax+b\), rõ ràng, chỉ cần hai điểm là ta xác định được phương trình đường thẳng này.

  • Mở rộng hơn, nếu ta có 3 điểm: \((0,1),(-1,-1),(1,5)\)thì đa thức bậc 2 dạng \(f(x)=ax^2+bx+c\) duy nhất đi qua ba điểm đó là \(f(x)=x^2+3x+1\).

Tổng quát, nếu ta có đa thức dạng \(f(x) = a_kx^k + a_{k-1}x^{k-1}+\dots+a_1x+a_0\) ,và \(k+1\) điểm phân biệt:\((x_0,y_0),(x_1,y_1),\dots,(x_k,y_k)\). Để xác định \(f(x)\) ta cần giải hệ phương trình sau:

$$\begin{cases} a_kx_0^k + a_{k-1}x_0^{k-1}+\dots+a_1x_0+a_0 &= y_0\\ a_kx_1^k + a_{k-1}x_1^{k-1}+\dots+a_1x_1+a_0 &= y_1\\ \dots \\ a_kx_k^k + a_{k-1}x_k^{k-1}+\dots+a_1x_k+a_0 &= y_k \end{cases}$$

Hệ phương trình trên có thể viết lại dưới dạng ma trận như sau:

$$\underbrace{ \begin{bmatrix} 1 & x_0 & \cdots & x_0^k \\ 1 & x_1 & \cdots & x_1^k \\ \vdots & \vdots & \ddots & \vdots \\ 1 & x_k & \cdots & x_k^k \end{bmatrix}}{X} \times \underbrace{ \begin{bmatrix} a_0\\ a_1\\ \vdots \\ a_k \end{bmatrix}}{A} = \underbrace{ \begin{bmatrix} y_0\\ y_1\\ \vdots \\ y_k \end{bmatrix}}_{Y}$$

Suy ra: \(A = X^{-1}Y\).

💡
Ý tưởng này khởi nguồn cho Fast Fourier Transform.

Nghĩa là thay vì ta tính trực tiếp \(p(x)=f(x)g(x)\) bằng cách khai triển như trên, thay vào đó, ta sẽ đi tính \(k+1\) điểm với \(k\) là bậc của \(p(x)\) rồi từ đó tìm ngược lại các hệ số của \(p(x)\). Nhưng tại sao lại phải rườm rà như vậy? Đó là vì chúng ta có cách tiết kiệm tính toán khi phải tính giá trị của các điểm thuộc đa thức \(f(x), g(x)\).

Đa thức bậc chẵn và đa thức bậc lẻ

Đa thức bậc chẵn là đa thức chỉ chứa giá trị biến \(x\) ở bậc chẵn, ngược lại đa thức bậc lẻ là đa thức chỉ chứa giá trị biến \(x\) ở bậc lẻ. Ví dụ: \(f(x)=x^4+3x^2+5\) là đa thức bậc chẵn, ngược lại \(g(x) = -x^3 + 2x\) là đa thức bậc lẻ.

Dễ dàng thấy đối với đa thức bậc chẵn ta luôn có \(f(x)=f(-x)\) và với đa thức bậc lẻ thì \(f(x) = -f(-x)\). Điều này giúp chúng ta tiết kiệm khá nhiều tính toán, vì chỉ cần tính \(f(x)\) ta có thể suy ra ngay giá trị của \(f(-x)\) mà ko cần tính lại. Ví dụ: \(f(x)=x^2+1\) thì \(f(1) = 2\) có thể suy ra ngay \(f(-1)=2\) mà không cần tính toán.

Một nhận xét nữa là mọi đa thức đều có thể phân tích thành dạng tổng của một đa thức bậc chẵn và một đa thức bậc lẻ. Ví dụ: \(f(x) = 3x^6-4x^5+5x^2+2x-7\) có thể tách thành hai đa thức, một bậc chẵn\(f_e(x) = 3x^6+5x^2-7\) và một bậc lẻ \(f_o(x)=-4x^5+2x\). Đa thức bậc lẻ \(f_o(x)\) lại có thể biểu diễn thành một đa thức bậc chẵn bằng cách tách biến \(x\) ra làm thừa số chung, ta có\(f_o(x)=-4x^5+2x = x(-4x^4+2)\), rõ ràng \(-4x^4+2\) là đa thức bậc chẵn.

Ví dụ: Đa thức\(f(x) = 3x^6-4x^5+5x^2+2x-7\), ta biến đổi như sau:

$$\begin{align*} f(x)&=3x^6-4x^5+5x^2+2x-7\\ &=(3x^6+5x^2-7) + (-4x^5+2x) \\ &=\underbrace{((3x^2)^3+5x^2-7)}{f_1(x^2)} + x\underbrace{(-4(x^2)^2+2)}{f_2(x^2)} \\ \implies f(x)&=f_1(x^2)+xf_2(x^2) \\ \implies f(-x) &= f_1(x^2) - xf_2(x^2) \end{align*}$$

Có thể thấy rằng quá trình tính toán \(f(x)\) và \(f(-x)\) có sự trùng lặp các thành phần \(f_1(x^2)\) và \(f_2(x^2)\) và từ đó có thể sử dụng lại kết quả mà không cần tính lại. Nếu xem \(x^2\) là một biến \(t\) thì \(f_1(x^2) \) trở thành một đa thức \(g\) theo biến \(t\) và từ đó lại tiếp tục có thể áp dụng công thức như trên \(g(t)=g_1(t^2) +t.g_2(t^2)\) và cứ tiếp diễn như vậy… Như vậy, nếu có một dãy các giá trị \(x_1,x_2,\dots,x_n\) thỏa \(x_{k+1} = x_k^2\) thì việc tính \(f(x_1)\) sẽ được tính thông qua \(f(x_2)\), \(f(x_2)\) lại được tính thông qua \(f(x_3)\)… và sẽ trở thành một bài toán toán đệ quy như hình dưới:

Như vậy đối với đa thức bậc \(n\), ta cần tính \(\log n\) tầng đệ quy, từ đó, độ phức tạp sẽ là \(O(\log n)\) để tính giá trị cho đa thức tại một giá trị của \(x\). Chúng ta cần có \(n+1\) giá trị \(x\) để có thể nội suy, từ đó ta có thể kết luận độ phức tạp thuật toán là \(O(n\log n)\).

Căn đơn vị

Nhắc lại phương trình Euler vĩ đại: \(\boxed{\displaystyle e^{i\alpha} = \cos \alpha + i\sin \alpha} \)

Thuật toán Fast Fourier Transform cho đa thức \(f\) với bậc \(k\) để tính toán giá trị tại \(n\) điểm với \(n\) là số tự nhiên nhỏ nhất thỏa \(n \ge 2^{\log k}\). Để tiện lợi, ta đặt căn đơn vị \(w^n = 1 = e^{\frac {2 \pi i} n}\) và tính giá trị của đa thức tại \(n\) điểm tương ứng \(w^0, w^1,\dots,w^{n-1}\).

Thuật toán FFT

Thuật toán được phát biểu đệ quy theo chiều sâu (Depth First Search) qua function \(fft(f)\) như sau:

  • Tính \(n\) là số tự nhiên nhỏ nhất sao cho \(2^{\log k} \le n\)

  • Tách đa thức \(f\) thành hai đa thức \(f_e\) với các bậc chẵn và \(f_o\) với các bậc lẻ

  • Tính đệ quy \(y_e = fft(f_e)\) và \(y_o=fft(f_o)\)

  • Tính giá trị khởi tạo cho chuỗi căn đơn vị \(w = e^{\frac {2\pi i} n}\)

  • Cho \(\displaystyle i: 1 \to n\) và \(y\) là mảng trung gian chứa \(n\) phần tử

    • Tính \(y[i] = y_e[i]+w^iy_o[i]\) ứng với bước \(f(x)=f_1(x^2)+xf_2(x^2)\)

    • Tính \(y[i + \frac n 2] = y_e[i]-w^iy_o[i]\) ứng với bước \(f(-x)=f_1(x^2)-xf_2(x^2)\)

  • Return \(y\)

Dưới đây là code minh họa bằng javascript

function fft(p){
    if (p.length == 1) {
        return p;
    }

    // find the smallest power of 2 that is greater than or equal to the length of the polynomial
    const n = 2**Math.ceil(Math.log2(p.length));

    // calculate the nth root of unity via Euler's formula
    const re = Math.cos(-2 * Math.PI / n);
    const im = Math.sin(-2 * Math.PI / n);
    let w = new ComplexNumber(re, im);

    // pad the polynomial with zeros to make its length a power of 2
    const p_ = p.concat(Array(n - p.length).fill(0));
    const p_even = p_.filter((_, i) => i % 2 == 0);
    const p_odd = p_.filter((_, i) => i % 2 == 1);
    const y_even = fft(p_even);
    const y_odd = fft(p_odd);

    // combine the results of the subproblems
    const y = Array(n);
    let wi = new ComplexNumber(1, 0);
    for (let i = 0; i < n / 2; i++) {
        // combine the results of the subproblems
        // y[i] = y_even[i] + w^i * y_odd[i]
        y[i] = y_even[i].add(wi.mul(y_odd[i]));
        // y[i + n/2] = y_even[i] - w^i * y_odd[i]
        y[i + n / 2] = y_even[i].sub(wi.mul(y_odd[i]));

        // update the nth root of unity w^i
        wi = wi.mul(w);      
    }    

    return y;
}

Nội suy từ tập hợp điểm thành đa thức

Phần bên trên, chúng ta đã giải quyết xong vấn đề tính toán tập hợp điểm. Vấn đề còn lại là làm cách nào nội suy từ tập điểm này trở thành đa thức.

Như đã đề cập ở phần trên, ta có:

$$\underbrace{ \begin{bmatrix} 1 & x_0 & \cdots & x_0^k \\ 1 & x_1 & \cdots & x_1^k \\ \vdots & \vdots & \ddots & \vdots \\ 1 & x_k & \cdots & x_k^k \end{bmatrix}}{X} \times \underbrace{ \begin{bmatrix} a_0\\ a_1\\ \vdots \\ a_k \end{bmatrix}}{A} = \underbrace{ \begin{bmatrix} y_0\\ y_1\\ \vdots \\ y_k \end{bmatrix}}_{Y}$$

Ta có \(X \times A = Y\). Hiện tại chúng ta có tập hợp điểm và muốn xác định đa thức, nghĩa là tính \(A\), vậy \(A=X^{-1}Y\), nghĩa là chúng ta phải tính được \(X^{-1}\). May mắn thay, vì cách chọn chuỗi căn đơn vị với \(w=e^{\frac {2 \pi i} n}\) làm tập hợp các điểm cần tính nên ta có:

$$X = \begin{bmatrix} 1 & 1 & \dots& 1\\ 1 & w & \dots & w^{n-1} \\ 1 & w^2 & \dots & w^{2(n-1)} \\ \vdots & \vdots & \ddots & \vdots\\ 1 & w^{n-1} & \dots& w^{(n-1)(n-1)} \end{bmatrix}$$

Ma trận \(X\) có tính chất là mà trận nghịch đảo của nó cũng chính là ma trận chứa các thành phần liên hợp của các phần tử của \(X\)

$$X^{-1} = \frac 1 n \begin{bmatrix} 1 & 1 & \dots& 1\\ 1 & w^{-1} & \dots & w^{-(n-1)} \\ 1 & w^{-2} & \dots & w^{-2(n-1)} \\ \vdots & \vdots & \ddots & \vdots\\ 1 & w^{-(n-1)} & \dots& w^{(n-1)(n-1)} \end{bmatrix}$$

Từ đó, có thể tính nghịch đảo của FFT hay còn gọi là nội suy từ FFT một cách nhanh chóng.

Source code

// inverse FFT
function ifft(y){
    const n = y.length;
    const y_ = y.map(c => c.conj());
    const p_ = fft(y_).map(c => c.div(n));
    return p_;
}

Demo