FFT(高速フーリエ変換)を完全に理解する話

FFT(Fast Fourier Transform),高速フーリエ変換についての記事です。
非常に理解が難しいアルゴリズムとして有名なので、どうにかして理解したいという方に向けての記事です。
説明はなるべく丁寧に行っていますので、わからないところがあったらコメント欄へどうぞ。
数Ⅱまでの知識を前提にして、それ以降は個別に説明しているつもりですが、抜けているところがあれば言ってください。
この記事を書くにあたり、AtCoderの高速フーリエ変換を参考にさせていただきました。AtCoder社の方々、ありがとうございます。

高速フーリエ変換とは

離散フーリエ変換という処理を高速に行うアルゴリズムです。これを利用して、多項式乗算が高速に行えます。ここでは、多項式乗算を行う方法論をメインに、高速フーリエ変換の解説をします。また、ふたつの多項式の次数は便宜上同じとします。(違う場合、高次部分の係数を 0 にするなどして合わせればよいです。)

求めるべきもの

f(x)=i=0NAixi,g(x)=i=0NBixi

を乗算することを考えます。
この積は、

f(x)g(x)=i=0Nj=0NAiBjxi+j

であり、An から A2n1, Bn から B2n1 を便宜上 0 として、

Ck=i=0kAiBki

とすると、

f(x)g(x)=i=02NCixi

となります。
この Ci を、0i2N を満たすすべての i について求めるのが今回の目標です。
それぞれ愚直に求めると、f,g の全項を組み合わせて参照することになるので、 O(N2) です。これをどうにかして高速化します。

多項式補間

愚直な乗算は難しそうなので、Ci の値を、多項式補間を用いて算出することを考えます。
多項式補間とは、多項式の変数に実際にいくつかの値を代入し、多項式を計算した値から、多項式の係数を決定する手法です。
たとえば、f(x)=ax+b という 1 次関数があるとします。
ab の値は分かりませんが、f(3)=5,f(7)=3 がわかっているものとします。
実際に 3,7 を代入してみると、

  • 3a+b=5
  • 7a+b=3

と、連立方程式が立ち、a,b の値が求められます。
一般的には、n 次多項式について、n+1 個の異なる変数値においての値が定まっていれば、多項式を決定できることが知られています。
ガウスの消去法などを使って愚直に連立方程式を解くと O(n3) かかりますが、これはなんとかしてあとで改善します。
以上より、高速な多項式乗算の全体的な流れは次のようになります。

  1. f,g の次数の和より大きい適当な値 n をとり、n 個の点 x0,x1xn1 をうまく選ぶ。
  2. f(x0),f(x1)f(xn1),g(x0),g(x1)g(xn1) を計算する。
  3. f(x0)g(x0),f(x1)g(x1)f(xn1)g(xn1) を計算する。
  4. 3.で求めた値をもとに、なんとかして Ci(0i2n) を復元する。

高速フーリエ変換を用いて、2. と4. を高速に行うのが実際のアルゴリズムです。

評価する点の選び方

さて、多項式補間に使う n と点 xi(0i<n) はどう選べばいいのでしょうか。
結論から言うと、n はなんらかの2べきの数とし、xi には、 1n 乗根を使います。
多くの解説だと(たぶん)数Ⅲの知識が前提にされているのでこのあたりは軽く飛ばされますが、今回はその説明も詳細にやります。

複素平面

1 を虚数単位 i として定め、a+bi として表される数が複素数と呼ばれるのは、ご存じだと思います。
x,y が実数のとき、x+yi を単なる実数の組としてみなし、平面の直交座標 (x,y) の点に対応付けるのです。
この複素数を、平面上の点、または位置ベクトルに対応させるとき、この平面を複素平面と呼びます。
複素平面上では、複素数に対する代数的演算が、平面上での幾何的な操作と対応付けられます。
たとえば、加法は平行移動、実数倍は原点中心の拡大・縮小に対応するなどします。

複素平面と積

加法や実数倍と同じように、複素数同士の積も、複素平面上で幾何的な操作として扱えます。
複素数 z=x+yi を、r(cosθ+isinθ) として表すことを考えます。この表示を z の極形式と呼びます。
このとき、r=x2+y2 が成り立ちます。つまり、rz の絶対値となります。
また、θz の偏角と呼び、 argz と表されます。
r,θ から z は一意に定まります。具体的には、z は複素平面上で、(r,0)θ 回転して得られます。
ふたつの複素数 z,w の極形式をそれぞれ r(cosα+isinα), s(cosβ+isinβ) とすると、三角関数の加法定理より、
(cosα+isinα)(cosβ+isinβ)=cos(α+β)+isin(α+β)
が成り立つので、
zw=rs(cos(α+β)+isin(α+β)) となり、
zw は、点 z を、原点を中心に β 回転、s 倍に拡大して得られる点だとわかります。
このように、複素数の積は、複素平面上で、点の回転と拡大を組み合わせて表すことができます。
また、互いに共役な複素数は実軸に対して対称な位置にある、などの性質もあります。

1 の n 乗根

ここまでの話を踏まえると、1n 乗根は次のように考えられます。
nθ=2mπ(mZ,0θ<2π) をみたすような θn 個存在し、
z=cosθ+isinθ を満たす z は、1n 乗根である。
ここで、 θ=2πn であるときの zζn とすると、1n 乗根全体は ζni(0i<n) で表されます。

1 の n 乗根の性質

ζn は次のような性質を持ちます。

ζni=ζnjij(modn)i=0n1ζni(jk)={nif jk(modn)0otherwise

上は自明でしょう。
下の式は証明します。
j=k のとき、偏角の合計は 2mπ(mN) で、絶対値の和は n なので、これが n と等しいことは明らかです。
そうでない場合、この値は、初項 1, 公比 ζnjk の等比数列の最初から n 項の和になります。
これを等比数列の和の公式、「初項 a, 公比 r の等比数列最初の n 項の和は a(1rn)/(1r)」に代入すると、r1n 乗根であることより、0 になるので、証明できました。
また、ζnζn1 で置き換えてもこれらの性質は変わりません。これは後に重要になってきます。

離散フーリエ変換

多項式 f(x) に対し、その離散フーリエ変換(DFT,Discrete Fourier Transform), f^(x)を次のように定義します。

f^(t)=i=0n1f(ζni)ti

つまり、f^(t) は、多項式 f(x)x1n 乗根を代入し、得られた値を係数にもつ多項式です。
Wikipediaなどを見に行くと、離散フーリエ変換は複素関数から複素関数への写像である、というふうな記述があると思いますが、これでずいぶん分かりやすくなったと思います。

離散フーリエ変換の性質

f(x)=i=0n1cjxj

とすると、その離散フーリエ変換、f^ は、

f^(t)=i=0n1f(ζni)ti=i=0n1(j=0n1cj(ζni)j)ti=j=0n1cji=0n1(ζnjt)i

になります。
ここで、tζnk を代入してみます。

f^(ζnk)=j=0n1cji=0n1(ζnjζnk)i=j=0n1cji=0n1ζni(jk)

となります。
ここで、前に挙げた ζn の性質を使います。

i=0n1ζni(jk)={nif jk(modn)0otherwise

と合わせて考えると、結局、

f^(ζnk)=nck

です。
よって、離散フーリエ変換をした関数に、ζnζn1 に置き換えて離散フーリエ変換を適用し、係数を 1n 倍するともとの多項式に戻ります。これを、離散フーリエ逆変換と呼びます。
また、離散フーリエ変換後の係数は、1n 乗根を多項式に代入した値なので、fg の積の離散フーリエ変換は、単純に f,g それぞれの離散フーリエ変換の、同次の係数を掛け合わせたものになります。
これで、離散フーリエ変換の性質を利用して方針が固められました。

  1. f,g の次数の和より大きい適当な値 n をとり、f,g それぞれについて、1n 乗根を使って離散フーリエ変換を計算する。
  2. 変換後の関数について同次項の係数を掛け合わせ、新しい多項式を構成する。これは、fg の積の離散フーリエ変換と一致する。
  3. これに対して離散フーリエ逆変換を行い、fg の積が復元できる。

こうして、離散フーリエ変換を高速に計算することで多項式乗算が行えることが示せました。FFTの本質はここからです。

高速フーリエ変換

ここまでで多項式乗算の準備が整いました。あとは離散フーリエ変換を高速に計算するだけです。
n2 の冪乗として、n1 次多項式 f(x)=i=0n1cixi に対し、f0,f1 を次のように定義します。

f0(x)=i=0n/21c2ixif1(x)=i=0n/21c2i+1xi

このとき、f(x)=f0(x2)+xf1(x2) が成り立ちます。
よって、f0(ζn0),f0(ζn2),f0(ζn2(n1))f1(ζn0),f1(ζn2),f1(ζn2(n1)) が求められれば、f(ζn0),f(ζn1),f(ζnn1) も求めることができます。
ζn2=ζn/2 なので、(これは明らかです)
f0(ζn/20),f0(ζn/21),f0(ζn/2n1)f1(ζn/20),f1(ζn/21),f1(ζn/2n1) が求められればいいことになります。
そして、ζn/2n/2 乗すると 1 になることも考えると、上に挙げたものはそれぞれ前半と後半が同じです。よって、求めなければいけないのは結局、
f0(ζn/20),f0(ζn/21),f0(ζn/2n/21)f1(ζn/20),f1(ζn/21),f1(ζn/2n/21) になります。
これは、元の問題のちょうど半分のサイズの全く同じ問題です。
よって、元の問題の半分のサイズの同じ問題を2つ解くと、O(n) で元の問題の答えも求まります。
こうして問題を再帰的に解いていけば、O(nlogn) で離散フーリエ変換が行えます!!!!!(マージソートなどと同じような仕組みの分割統治法なので、わからなければ調べてみましょう。)
これが高速フーリエ変換というアルゴリズムです。
これでここまでの長い解説もやっと終わり。O(nlogn) での多項式乗算が完成しました!
ここまでをまとめます。

  • 次数の和が n 以下の多項式 f,g の積を求めたい。
  • f,g 両方について 1n 乗根の値を代入し、出てきた値を求める。これは離散フーリエ変換で求められる。
  • 上で求めた値を掛け合わせると、f,g の積の離散フーリエ変換が求められる。
  • 離散フーリエ逆変換を f,g の積の離散フーリエ変換に行い、求めたい多項式を復元できる。

こうして、多項式乗算を O(NlogN) で行うことができました。お疲れさまでした。
サンプルコードは以下です。(ここでは、高速化のために自作した複素数クラスを用いています。その点では互換性がないので、基本的にはstd::complexを使っていただければ結構です。とはいえ、必要最低限の機能を持った複素数クラスを自作すると実行時間が半分程度まで削れるので、それもお勧めします。)

class FastFourierTransform {
private:
    static void dft(vector<mycomplex>& func, int inverse) {
        int sz = func.size();
        if (sz == 1)return;
        vector<mycomplex> veca, vecb;
        rep(i, sz / 2) {
            veca.push_back(func[2 * i]);
            vecb.push_back(func[2 * i + 1]);
        }
        dft(veca, inverse); dft(vecb, inverse);
        mycomplex now = 1, zeta = polar(1.0, inverse * 2.0 * acos(-1) / sz);
        rep(i, sz) {
            func[i] = veca[i % (sz / 2)] + now * vecb[i % (sz / 2)];
            now *= zeta;
        }
    }
public:
    template<typename T>
    static vector<double> multiply(vector<T> f, vector<T> g) {
        vector<mycomplex> nf, ng;
        int sz = 1;
        while (sz < f.size() + g.size())sz *= 2;
        nf.resize(sz); ng.resize(sz);
        rep(i, f.size()) {
            nf[i] = f[i];
            ng[i] = g[i];
        }
        dft(nf, 1);
        dft(ng, 1);
        rep(i, sz)nf[i] *= ng[i];
        dft(nf, -1);
        vector<double> res;
        rep(i, sz)res.push_back(nf[i].real() / sz);
        return res;
    }
};

おわりに

長い記事ですが、読んでいただけたでしょうか。
自分もこれを理解するまでには長い時間を要しましたが、理解できた時にはかなりすっきりとしました。
みなさんにも、これが理解の助けとなる時が来てほしい、という思いで記事を書きました。
今はわからなくても、これが少しでも理解に貢献できたなら幸いです。
そして、この記事の題名にもあるように、「完全に理解する」という意気で、さまざまなアルゴリズムに貪欲に挑戦していってほしいと思います。
ありがとうございました。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account