Chapter 17

FFT의 분할 정복

DFT를 빠르게 만드는 짝수/홀수 분해
핵심 질문

왜 짝수 번째와 홀수 번째로 나누면 빨라질까?

FFT의 분할 정복

핵심 질문

DFT 계산을 어떻게 훨씬 빠르게 줄일 수 있을까?

교과서 설명

DFT를 그대로 계산하면 모든 와 모든 을 비교해야 하므로 대략 번 계산한다.

이 장에서는 가장 기본적인 radix-2 FFT를 다룬다. 따라서 배열 길이 은 계속 반으로 나눌 수 있는 의 거듭제곱이라고 가정한다.

여기서 꼭 구분해야 한다. FFT는 DFT와 다른 결과를 만드는 새 변환이 아니다. FFT는 DFT를 빠르게 계산하는 알고리즘일 뿐이다. 따라서 FFT 결과는 같은 길이와 같은 정규화 약속을 쓰는 DFT 결과와 같아야 한다.

합성곱에서 필요한 zero padding 조건은 마지막 장에서 자세히 다룬다. 이 장에서는 먼저 길이가 의 거듭제곱이라고 두고, 왜 짝수/홀수 분해만으로 같은 DFT 값을 더 적은 계산으로 얻는지에 집중하자.

FFT는 입력을 짝수 번째와 홀수 번째로 나누어 같은 문제를 절반 크기로 다시 푼다.

이 식은 다항식의 항을 짝수 차수와 홀수 차수로 나누면 나온다.

짝수 차수만 모으면 이고, 홀수 차수만 모으면 이다. 그래서 같은 문제를 에 대해 더 작은 크기로 다시 풀 수 있다.

작은 예로 보면 구조가 더 선명하다. 길이 다항식

를 생각하자. 짝수 차수 항만 모으면

이고, 홀수 차수 항에서 앞의 를 하나 떼어 내면

이다. 여기서 로 두면

가 된다. 즉 “짝수 번째 계수 배열”과 “홀수 번째 계수 배열”은 각각 절반 길이의 새 다항식이고, 원래 다항식은 이 둘을 다시 끼워 맞춘 것이다.

DFT에서는 검사하는 점들이 단위원 위의 특수한 회전값이다. 길이가 일 때 기본 회전값을 다음처럼 쓴다.

는 다항식 에 대입한 값으로 볼 수 있다.

위의 짝수/홀수 분해식에 를 넣으면 다음처럼 된다.

그런데 단위원근에는

가 성립한다. 그래서 개의 단위원근에서 평가하면 되고, 이것이 바로 절반 크기 DFT다.

부터 까지 움직이면 개의 값만 두 번 반복한다. 실제로

이다. 그래서 절반 크기 DFT 결과 , 를 아래쪽 출력에서도 다시 사용할 수 있다.

마지막으로 라는 짝 관계 때문에 한 번 계산한 절반 크기 DFT 결과를 위쪽 출력과 아래쪽 출력에 함께 재사용할 수 있다.

조금 더 엄밀하게 계산량을 쓰면, 길이 짜리 FFT 시간 은 길이 짜리 FFT 두 번과 합치는 일 으로 이루어진다.

각 단계에서 전체 합치는 일은 이고, 반으로 나누는 단계가 개 있으므로 전체 계산량은 다음처럼 줄어든다.

분할 정복 유도 자세히 보기

입력 배열을 다항식의 계수라고 보고

라고 하자. 짝수 차수와 홀수 차수를 분리하면

이고, 라고 두면

가 된다. 이때 는 원래 다항식보다 항의 개수가 절반이다. 따라서 이 둘을 단위원근에서 평가하는 일은 길이 짜리 DFT와 같은 모양이 된다.

이제 를 넣으면

이다. 여기서 핵심은 입력점이 였는데, 짝수/홀수 다항식에는 그 제곱인 가 들어간다는 점이다. 그래서 원래 길이 DFT를 직접 계산하지 않고, 짝수 계수 다항식의 길이 DFT 결과를 , 홀수 계수 다항식의 길이 DFT 결과를 라고 놓을 수 있다.

그러면 위 식은

가 된다. 즉 짝수 계수 다항식과 홀수 계수 다항식을 절반 크기 DFT로 계산한 뒤, 회전 인자 만 곱해 합치면 된다.

반대편 출력도 동시에 나온다. 를 넣으면

이고, 동시에 제곱된 입력점은 그대로다.

따라서 는 다시 쓰고, 앞에 붙는 회전 인자의 부호만 바뀐다.

가 된다. 그래서 한 쌍의 출력은

로 동시에 만들어진다. 이 한 쌍의 결합이 butterfly다.

구현으로 옮길 때

처음 FFT를 코드로 옮길 때는 재귀형이 가장 이해하기 쉽다. 입력을 짝수 인덱스 배열과 홀수 인덱스 배열로 나누고, 두 작은 FFT 결과를 butterfly 공식으로 다시 합치면 된다.

대회 코드에서는 새 배열을 계속 만들면 느려질 수 있어서 반복문 형태의 FFT를 쓰기도 한다. 이때 입력을 먼저 bit reversal 순서로 재배치한다. 예를 들어 이면 인덱스 은 이진수로 이고, 이를 뒤집으면 이므로 번 위치와 연결된다.

bit reversal은 새로운 수학 원리가 아니라, 작은 butterfly들이 차례대로 만날 수 있도록 배열 순서를 미리 정리하는 구현 기법이다. 이 장에서는 재귀형으로 원리를 이해하고, 반복문 구현은 같은 butterfly를 배열 안에서 직접 수행하는 방식이라고 보면 충분하다.

역 FFT를 구현할 때는 회전 방향을 반대로 쓰고, 마지막에 전체 길이 으로 나눈다. 정수 계수 합성곱에서는 계산 결과가 처럼 나올 수 있으므로 마지막에 가까운 정수로 반올림한다. 이 오차는 복소수 실수 계산에서 생기는 작은 오차이지, 합성곱 공식이 달라졌다는 뜻은 아니다.

직관 비유

큰 시험지를 한 장씩 다 확인하는 대신, 짝수 번호 문제 묶음과 홀수 번호 문제 묶음으로 나누어 동시에 채점하는 느낌이다.

예제

길이 배열은 , , 크기로 계속 나뉜다. 나뉜 결과를 다시 합칠 때 회전 인자들이 사용된다.

합치는 단계에서는 짝수 쪽 결과 와 홀수 쪽 결과 를 다음처럼 섞는다.

반대편 출력은 홀수 쪽에서 온 회전 항의 부호만 바뀐다.

길이 숫자 예시를 보자.

짝수 인덱스와 홀수 인덱스로 나누면 다음과 같다.

길이 DFT를 하면

길이 의 기본 회전값은 이다. 따라서 합치는 과정은 다음처럼 된다.

위아래 두 결과가 한 번에 만들어지는 모양이 나비처럼 보여서 이 결합을 butterfly라고 부른다.

이 예시에서 , 를 만들 때 함께 쓰이고, , 를 만들 때 함께 쓰인다. 작은 DFT 결과를 다시 계산하지 않고 두 출력에 재사용하는 것이 FFT의 속도 이득이다.

손풀이 체크

  1. 의 길이 DFT는?

    답 보기

    [12,2][12,-2]

  2. 에서 even 배열과 odd 배열은?

    답 보기

    even은 [1,3][1,3], odd는 [2,4][2,4]

  3. 의 값은?

    답 보기

    i-i

  4. 위 예시에서 , 이면 는?

    답 보기

    X[0]=4+6=10X[0]=4+6=10, X[2]=46=2X[2]=4-6=-2이다.

다음으로 이어지는 생각

FFT로 DFT를 빠르게 하면 합성곱과 다항식 곱셈도 빠르게 할 수 있다.

이번 장에서 기억할 3문장

  1. FFT는 새로운 변환이 아니라 DFT를 빠르게 계산하는 알고리즘이다.
  2. 짝수 인덱스와 홀수 인덱스로 나누면 절반 크기 DFT 두 개가 생긴다.
  3. butterfly는 E[k]E[k]O[k]O[k]를 한 번 계산해 위쪽과 아래쪽 출력을 동시에 만드는 결합이다.

C++ Practice

C++로 확인하기

재귀 FFT로 짝수/홀수 분해 X[k]=E[k]+ωNkO[k]X[k]=E[k]+\omega_N^kO[k] 확인하기

#include <cmath>
#include <complex>
#include <iomanip>
#include <iostream>
#include <vector>

using namespace std;

using Complex = complex<double>;

void fft(vector<Complex>& a) {
    int N = static_cast<int>(a.size());
    if (N == 1) {
        return;
    }

    vector<Complex> even(N / 2), odd(N / 2);
    for (int i = 0; i < N / 2; ++i) {
        even[i] = a[2 * i];
        odd[i] = a[2 * i + 1];
    }

    fft(even);
    fft(odd);

    const double pi = acos(-1.0);
    Complex omega = polar(1.0, -2.0 * pi / N);
    Complex power = 1;

    for (int k = 0; k < N / 2; ++k) {
        Complex t = power * odd[k];
        a[k] = even[k] + t;
        a[k + N / 2] = even[k] - t;
        power *= omega;
    }
}

int main() {
    vector<Complex> x = {1, 2, 3, 4};
    fft(x);

    cout << fixed << setprecision(2);
    for (const auto& value : x) {
        cout << value.real() << " + " << value.imag() << "i\n";
    }
}

연습: x=[5,7]x=[5,7] 또는 x=[1,0,1,0]x=[1,0,-1,0]으로 바꾸고 직접 DFT 결과와 비교해 보자.