FFT 이해하기

고속 푸리에 변환

문제 풀이 판에서 고속 푸리에 변환 (Fast Fourier Transfrom, FFT) 는 매우 중요하고 한 번은 짚고 넘어가야 하는 주제입니다.

예전부터 이걸 이해를 하고 넘어가자 생각하면서 미루고 미뤄오다 한 번 실제로 코드로 옮기면서, 중요한 개념들을 정리하고자 합니다.

이산 푸리에 변환

FFT는 이름처럼 푸리에 변환을 빠르게 하는 알고리즘입니다. 그렇기에 푸리에 변환이 무엇인지부터 알아야 합니다.

푸리에 변환 (Fourier Transform) 은 어떤 신호를 주파수 성분으로 분해하는 방법입니다. 예를 들어, 음악 신호는 여러 주파수의 소리로 이루어져 있습니다. 푸리에 변환을 통해 이 신호를 각 주파수 성분으로 나눌 수 있습니다.

원래 푸리에 변환은 연속적인 신호에 적용됩니다. 하지만 컴퓨터는 이산적인 데이터를 다루기 때문에, 이산 푸리에 변환 (Discrete Fourier Transform, DFT) 을 사용합니다. DFT는 다음과 같이 정의됩니다:

$$X_k = \sum_{n=0}^{N-1} x_n e^{-2\pi i kn/N}$$

왜 이런 식이 나오는지는 3B1B 유튜브 영상 등 좋은 참고 자료가 많으니 참고하시기 바랍니다.

하지만 이 식을 그대로 계산하면 딱 보기에도 $O(N^2)$ 의 시간 복잡도를 가집니다. 이걸 더 빠르게, 구체적으로는 $O(N \log N)$ 으로 계산하는 방법이 바로 FFT입니다.

FFT의 아이디어

FFT의 핵심 아이디어는 분할 정복 (Divide and Conquer, DnC) 입니다. DFT의 계산을 절반으로 나누어 재귀적으로 계산하는 방식입니다.

구체적으로 이를 실행하는 방법 중 하나는 쿨리-튜키 알고리즘 (Cooley-Tukey algorithm) 입니다. 이 알고리즘은 다음과 같은 단계를 거칩니다:

  1. 입력 신호를 짝수 인덱스와 홀수 인덱스로 나누기
  2. 각각의 부분 신호에 대해 DFT를 재귀적으로 계산하기
  3. 결과를 결합하여 최종 DFT를 계산하기

재귀 구현

이런 재귀적인 아이디어의 과정은 코드로 다음과 같이 표현할 수 있습니다:

Ruby 구현

  # Cooley-Tukey FFT algorithm
  def recursive_fft(data)
    n = data.length
    return data if n <= 1
  
    # Divide and conquer
    even = recursive_fft(data.each_slice(2).map(&:first))
    odd = recursive_fft(data.each_slice(2).map(&:last))
 
    combined = Array.new(n)
    (0...n / 2).each do |k|
      t = Math::E**(-2.0i * Math::PI * k / n) * odd[k] # Twiddle factor
      combined[k] = even[k] + t
      combined[k + n / 2] = even[k] - t
    end
    combined
  end

C 구현

void fft_recursive(_complex *x, int N) { // _complex is a struct for complex numbers, and c_add, c_sub, c_mul, c_exp are functions for complex arithmetic
  if (N <= 1)
    return; // Base case
 
  // Divide
  _complex even[N / 2];
  _complex odd[N / 2];
  for (int i = 0; i < N / 2; i++) {
    even[i] = x[i * 2];
    odd[i] = x[i * 2 + 1];
  }
 
  // Conquer
  fft_recursive(even, N / 2);
  fft_recursive(odd, N / 2);
 
  // Combine
  for (int k = 0; k < N / 2; k++) {
    _complex t = c_exp(-2 * M_PI * k / N); // e^(-2πik/N)
    t = c_mul(t, odd[k]);
    x[k] = c_add(even[k], t);
    x[k + N / 2] = c_sub(even[k], t);
  }
}

여기서 보이듯, 각각의 구현은 재귀적으로 입력 데이터를 절반으로 나누는 과정에서 $O(\log N)$ 의 깊이를 가지며, 각 단계에서 $O(N)$ 의 작업을 수행하므로 전체 시간 복잡도는 $O(N \log N)$ 이 됩니다.

비재귀 구현

재귀 구현이 일반적으로 직관적이지만, 많은 문제들의 경우 비재귀 구현을 기준으로 세팅된 제한을 가집니다.

비재귀 구현의 경우에는 비트 반전 순서 (bit-reversed order) 로 데이터를 재배열하는 과정이 필요합니다.
비트 반전 순서는 인덱스의 이진 표현을 뒤집는 것을 의미합니다. 예를 들어, 길이가 8인 배열에서 인덱스 3 (011)은 비트 반전 후 6 (110)이 됩니다.
이 과정을 통해 데이터를 재배열한 후, 반복문을 통해 FFT를 수행할 수 있습니다.

Ruby 구현

# Helper function for bit-reversed order permutation
def bit_reversed_indices(n)
  indices = (0...n).to_a
  bits = Math.log2(n).to_i
  indices.map do |i|
    reversed = 0
    bits.times do |j|
      reversed = (reversed << 1) | ((i >> j) & 1)
    end
    reversed
  end
end
 
# Iterative FFT algorithm
def iterative_fft(data)
  n = data.length
  # Bit-reversed order permutation
  indices = bit_reversed_indices(n)
  permuted = indices.map { |i| data[i] }
  data = permuted
  m = Math.log2(n).to_i
 
  (1..m).each do |s|
    m_val = 1 << s
    wm = Math::E**(-2.0i * Math::PI / m_val)
    (0...n).step(m_val) do |k|
      w = 1
      (0...(m_val / 2)).each do |j|
        t = w * data[k + j + m_val / 2]
        u = data[k + j]
        data[k + j] = u + t
        data[k + j + m_val / 2] = u - t
        w *= wm
      end
    end
  end
  data
end

C 구현

// Helper function for bit-reversed order permutation
void bit_reversed_permutation(_complex *x, int N) {
  int bits = (int)log2(N - 1) + 1; // Number of bits needed, considering the floating-point precision
  for (int i = 0; i < N; i++) {
    int j = 0;
    for (int k = 0; k < bits; k++) {
      j = (j << 1) | ((i >> k) & 1);
    }
    if (j > i) {
      _complex temp = x[i];
      x[i] = x[j];
      x[j] = temp;
    }
  }
}
 
// Iterative FFT algorithm
void fft_iterative(_complex *x, int N) {
  bit_reversed_permutation(x, N);
  int m = (int)log2(N - 1) + 1;
  for (int s = 1; s <= m; s++) {
    int m_val = 1 << s;
    _complex wm = c_exp(-2 * M_PI / m_val);
    for (int k = 0; k < N; k += m_val) {
      _complex w = 1;
      for (int j = 0; j < m_val / 2; j++) {
        _complex t = c_mul(w, x[k + j + m_val / 2]);
        _complex u = x[k + j];
        x[k + j] = c_add(u, t);
        x[k + j + m_val / 2] = c_sub(u, t);
        w = c_mul(w, wm);
      }
    }
  }
}

역변환

FFT로 변환을 빠르게 하는 근본적인 목적은 일반적으로 컨볼루션 (Convolution) 을 빠르게 계산하기 위함입니다.
컨볼루션은 두 신호의 겹치는 부분을 곱하고 더하는 연산으로, 신호 $A$ 와 $B$ 의 컨볼루션 $C$ 는 $C[n] = \sum_{m=0}^{N-1} A[m] B[n-m]$ 로 정의됩니다.

일반적로으로 이는 성분별 곱셈과 덧셈을 통해 $O(N^2)$ 의 시간 복잡도를 가집니다. 행렬을 쓰면 편하게 표현할 수 있지만, 계산량은 동일합니다.
하지만 푸리에 변환의 중요한 성질 중 하나는 컨볼루션 정리 (Convolution Theorem) 입니다.

컨볼루션 정리에 따르면, 두 신호의 컨볼루션은 각각의 푸리에 변환을 취한 후 성분별 곱셈을 하고, 다시 역푸리에 변환을 취하는 것과 같습니다. 즉,

$$\begin{align*} C[n] &= A[n] * B[n] \\ \mathcal{F}\{C[n]\} &= \mathcal{F}\{A[n]\} \cdot \mathcal{F}\{B[n]\} \\ \mathcal{F}^{-1}\{\mathcal{F}\{C[n]\}\} &= \mathcal{F}^{-1}\{\mathcal{F}\{A[n]\} \cdot \mathcal{F}\{B[n]\}\} \\ C[n] &= \mathcal{F}^{-1}\{\mathcal{F}\{A[n]\}\} \cdot \mathcal{F}\{B[n]\} \end{align*}$$

이를 통해 컨볼루션을 $O(N \log N)$ 의 시간 복잡도로 계산할 수 있습니다.

여기서 사용되는 역푸리에 변환 (Inverse Fourier Transform, IFT) 은 다음과 같이 정의됩니다:

$$\mathcal{F}^{-1}{X[k]} = \frac{1}{N} \sum_{n=0}^{N-1} X[k] e^{2\pi i k n / N}$$

이 식은 DFT와 거의 동일하지만, 지수의 부호가 반대이고, 결과에 $1/N$ 을 곱하는 점이 다릅니다.
따라서, 이걸 FFT로 빠르게 계산하는 방법도 DFT와 거의 동일하며, 단지 지수의 부호만 반대로 바꾸고 결과를 $N$ 로 나누면 됩니다.

이를 코드로 표현하면 다음과 같습니다:

Ruby 구현

def iterative_ifft(data)
  n = data.length
  # Conjugate the complex numbers
  conjugated = data.map { |x| x.conj }
  # Forward FFT
  transformed = iterative_fft(conjugated)
  # Conjugate again and scale
  transformed.map { |x| x.conj / n }
end

C 구현

void ifft_iterative(_complex *x, int N) {
  // Conjugate the complex numbers
  for (int i = 0; i < N; i++) {
    x[i].imag = -x[i].imag;
  }
  // Forward FFT
  fft_iterative(x, N);
  // Conjugate again and scale
  for (int i = 0; i < N; i++) {
    x[i].imag = -x[i].imag;
    x[i].real /= N;
    x[i].imag /= N;
  }
}

활용도

실생활의 예로써는, 음악 신호 처리, 이미지 처리, 데이터 압축 등의 분야에서 FFT가 널리 사용됩니다.
여기서 본질적으로 들어가면, 아까 설명한 내용으로부터 컨볼루션을 빠르게 계산하는 것을 통해 여러 문제를 해결할 수 있습니다.

즉, 컨볼루션에 해당하는 다항식의 곱셈 연산, 행렬의 곱셈, 큰 수의 곱셈 등에서 FFT를 활용할 수 있습니다.

대표적으로 큰 수 곱셈 문제는 아예 C/C++ 로 FFT를 구현하라고 대놓고 요구하는 문제입니다.

참고 자료