Skip to content

LU分解

線形代数において、LU分解は行列を下三角行列(Lower triangular matrix)と上三角行列(Upper triangular matrix)の積に分解する手法である。この分解は連立一次方程式の解法、逆行列の計算、行列式の計算など、数値線形代数の基礎となる重要な技術であり、競技プログラミングにおいても行列演算を効率的に実行するための強力な道具となる。

LU分解の数学的定義

n×nの正方行列Aに対して、下三角行列Lと上三角行列Uが存在し、A=LUと分解できるとき、これをLU分解と呼ぶ。ここで、下三角行列Lは対角成分より上の要素がすべて0であり、上三角行列Uは対角成分より下の要素がすべて0である行列を指す。

LU分解が存在するための必要十分条件は、行列Aのすべての主小行列式(leading principal minors)が非零であることである[1]。しかし、実際の数値計算では、この条件が満たされない場合でも、行の交換を伴うLU分解(部分ピボット選択付きLU分解)を用いることで、ほぼすべての非特異行列に対して分解を実行できる。

Doolittleアルゴリズム

LU分解を計算する最も基本的なアルゴリズムはDoolittleアルゴリズムである。このアルゴリズムでは、Lの対角成分を1に固定し(lii=1)、ガウスの消去法を行列の形で記録していく。

アルゴリズムの基本的な考え方は、行列Aに対してガウスの消去法を適用する際の操作を、下三角行列Lに記録することである。具体的には、A(i,j)要素を消去するために使用した乗数をL(i,j)要素として保存する。

具体的な計算手順を3×3行列で示すと、元の行列Aから始めて、各ステップで行基本変形を適用していく:

A=(a11a12a13a21a22a23a31a32a33)

第1ステップでは、第1列の第2行以下を0にする。このとき、m21=a21/a11m31=a31/a11を乗数として使用し、これらをLの対応する位置に記録する。

部分ピボット選択

数値計算において、LU分解の安定性は極めて重要である。ピボット(対角要素)が小さい値になると、丸め誤差が増幅され、計算結果の精度が著しく低下する。この問題を回避するため、部分ピボット選択(partial pivoting)を導入する。

部分ピボット選択では、各ステップで現在の列の絶対値最大の要素を持つ行と交換してから消去を行う。これにより、分解はPA=LUの形になり、Pは置換行列(permutation matrix)となる。

部分ピボット選択の重要性を示す典型的な例として、以下の行列を考える:

A=(ϵ111)

ここでϵは非常に小さな正の数である。ピボット選択なしでLU分解を行うと、L(2,1)要素は1/ϵという巨大な値になり、数値的不安定性を引き起こす。一方、行を交換してから分解すれば、すべての要素が適切な大きさに収まる。

計算量と数値的性質

LU分解の計算量はO(n3)である。より具体的には、n×n行列に対して約2n33回の浮動小数点演算が必要となる[2]。これはガウスの消去法と同じ計算量であるが、LU分解の利点は、一度分解を計算すれば、異なる右辺ベクトルに対する連立方程式をO(n2)で解けることにある。

数値安定性の観点から、部分ピボット選択付きLU分解の成長因子(growth factor)は最悪の場合2n1となることが知られている。しかし、実際の応用では、このような極端な成長はほとんど観測されず、通常はO(n1/2)からO(n2/3)程度に収まる[3]

前進代入と後退代入

LU分解の主要な応用は連立一次方程式Ax=bの解法である。A=LUと分解できれば、問題は以下の2つの三角連立方程式に帰着される:

  1. Ly=bを前進代入(forward substitution)で解く
  2. Ux=yを後退代入(backward substitution)で解く

前進代入では、Lが下三角行列であることを利用して、上から順に未知数を求めていく:

yi=bij=1i1lijyj

同様に、後退代入では下から順に求める:

xi=1uii(yij=i+1nuijxj)

これらの代入操作はそれぞれO(n2)の計算量で実行できる。

競技プログラミングにおける実装

競技プログラミングでは、LU分解は主に以下の場面で活用される:

  1. 連立一次方程式の高速解法: 同じ係数行列に対して複数の右辺ベクトルを処理する場合
  2. 行列式の計算: det(A)=det(L)det(U)=i=1nuii(置換の符号を考慮)
  3. 逆行列の計算: 各単位ベクトルを右辺として解くことで逆行列の列を求める

実装上の注意点として、浮動小数点演算の誤差を考慮する必要がある。ピボットがゼロかどうかの判定には、厳密な比較ではなく、適切な許容誤差(通常は109程度)を設定する。

cpp
const double EPS = 1e-9;

bool LUDecomposition(vector<vector<double>>& A, vector<int>& perm) {
    int n = A.size();
    perm.resize(n);
    iota(perm.begin(), perm.end(), 0);
    
    for (int k = 0; k < n; k++) {
        // Find pivot
        int pivot = k;
        for (int i = k + 1; i < n; i++) {
            if (abs(A[i][k]) > abs(A[pivot][k])) {
                pivot = i;
            }
        }
        
        if (abs(A[pivot][k]) < EPS) return false;
        
        // Swap rows
        if (pivot != k) {
            swap(A[k], A[pivot]);
            swap(perm[k], perm[pivot]);
        }
        
        // Elimination
        for (int i = k + 1; i < n; i++) {
            A[i][k] /= A[k][k];
            for (int j = k + 1; j < n; j++) {
                A[i][j] -= A[i][k] * A[k][j];
            }
        }
    }
    return true;
}

ブロックLU分解

大規模行列に対しては、ブロックLU分解が有効である。行列を適切なサイズのブロックに分割し、各ブロックを小行列として扱うことで、キャッシュ効率を向上させ、並列化も容易になる。

A=(A11A12A21A22)=(L110L21L22)(U11U120U22)

ブロック分解では、まずA11=L11U11を通常のLU分解で求め、次にL21=A21U111U12=L111A12を計算し、最後にSchur補行列S=A22L21U12L22U22に分解する。

特殊な構造を持つ行列への応用

実際の問題では、係数行列が特殊な構造を持つことが多い。例えば、三重対角行列(tridiagonal matrix)の場合、Thomas法と呼ばれるO(n)のアルゴリズムが存在する。帯行列(band matrix)の場合も、帯幅をkとするとO(nk2)で分解できる。

対称正定値行列に対しては、Cholesky分解という特殊なLU分解が存在する。これはA=LLTの形に分解するもので、計算量と記憶容量が通常のLU分解の約半分で済む。

数値的安定性の詳細

LU分解の数値的振る舞いを理解するには、条件数(condition number)の概念が重要である。行列Aの条件数κ(A)=AA1は、入力の相対誤差が出力の相対誤差にどの程度増幅されるかを示す指標である。

部分ピボット選択は、分解過程での誤差の増大を抑制するが、元の行列の条件数自体は改善しない。条件数が大きい行列(悪条件行列)に対しては、どのような数値解法を用いても精度の良い解を得ることは困難である。

完全ピボット選択(complete pivoting)では、行と列の両方を交換して最大要素をピボットとする。これにより数値安定性はさらに向上するが、計算量がO(n3)からO(n3)のままでも定数倍が大きくなるため、実用上は部分ピボット選択で十分なことが多い。

実装における最適化技法

競技プログラミングでの実装では、以下の最適化が有効である:

  1. インプレース計算: LUを同じ配列に格納し、メモリ使用量を削減
  2. ループ展開: 内側のループを部分的に展開し、キャッシュ効率を向上
  3. SIMD命令の活用: 複数の演算を並列実行(コンパイラの自動ベクトル化に依存)

また、整数演算で厳密な解を求める場合は、分数演算を用いるか、十分大きな素数で剰余を取るモジュラー演算を使用する。後者の場合、中国剰余定理を用いて複数の素数での結果から元の解を復元する。

理論的背景と拡張

LU分解は、より一般的なQR分解やSVD(特異値分解)の特殊ケースとして理解できる。QR分解では直交行列Qと上三角行列Rに分解し、数値的により安定だが計算量は約2倍になる。

また、LU分解はガウスの消去法の行列表現であり、行基本変形を行列の積として表現したものと解釈できる。この観点から、LU分解は線形写像の分解として、より抽象的な数学的構造を持つ。

不完全LU分解(ILU: Incomplete LU decomposition)は、疎行列に対する前処理手法として重要である。分解の過程で小さな要素を0とみなすことで、疎性を保ちながら近似的な分解を得る。これは反復法の収束を加速する前処理行列として広く使用される。

誤差解析の詳細

LU分解における誤差の振る舞いを詳しく理解することは、数値計算の信頼性を確保する上で不可欠である。浮動小数点演算では、各演算ごとに丸め誤差が発生し、これが蓄積することで最終結果に大きな影響を与える可能性がある。

後退誤差解析(backward error analysis)の観点から、計算されたLU分解L^U^は、わずかに摂動された行列(A+ΔA)の厳密な分解として解釈できる。つまり、L^U^=A+ΔAとなるΔAが存在し、その大きさは以下のように評価される:

ΔAAcn3uρ

ここで、cは小さな定数、uは機械イプシロン(倍精度で約2.2×1016)、ρは成長因子である。成長因子は分解過程で現れる要素の最大値と元の行列の要素の最大値の比として定義され、数値安定性の指標となる。

前進誤差(forward error)については、計算された解x^と真の解xの相対誤差が、条件数κ(A)と後退誤差の積で抑えられる:

xx^xκ(A)ΔAA

この関係式は、悪条件行列(条件数が大きい行列)に対しては、たとえ後退誤差が小さくても、解の誤差が大きくなる可能性があることを示している。

競技プログラミングにおける具体的問題例

LU分解が効果的に適用できる競技プログラミングの問題をいくつか詳しく見ていく。

例1: 電気回路の解析

n個のノードを持つ電気回路において、各ノードの電圧を求める問題を考える。キルヒホッフの法則を適用すると、以下の形の連立方程式が得られる:

Gv=i

ここで、Gはコンダクタンス行列、vは電圧ベクトル、iは電流ベクトルである。回路の構成要素が変化しない限りGは固定であるため、異なる電流源に対する解析にはLU分解が有効である。

例2: 確率的グラフ問題

ランダムウォークの定常分布や、マルコフ連鎖の吸収確率を求める問題では、(IP)x=bの形の方程式を解く必要がある。ここでPは推移確率行列である。複数の初期条件や境界条件に対して計算する場合、LU分解による前処理が計算時間を大幅に削減する。

例3: 多項式補間

n個の点(xi,yi)を通るn1次多項式を求める問題では、Vandermonde行列を係数とする連立方程式を解く:

(1x1x12x1n11x2x22x2n11xnxn2xnn1)(a0a1an1)=(y1y2yn)

Vandermonde行列は条件数が非常に大きくなりやすいため、数値安定性に特に注意が必要である。

並列化アルゴリズム

現代の計算環境では、並列処理によるLU分解の高速化が重要である。主要な並列化手法として、以下のアプローチがある。

ブロック並列化

行列をp×pのブロックに分割し、各プロセッサがブロックを担当する。通信パターンは以下のようになる:

各ステップで、対角ブロックを分解し、その結果を使ってLの列ブロックとUの行ブロックを計算、最後に残りのブロックを更新する。この手法の並列効率は、ブロックサイズと通信遅延のバランスに大きく依存する。

パイプライン並列化

列方向の依存関係を利用して、パイプライン的に処理を進める。第k列の消去が完了したら、即座に第k+1列の処理を開始できる部分がある。これにより、異なるプロセッサが異なる列の処理を同時に実行できる。

実装の詳細と最適化

競技プログラミングで使用する完全な実装を示す。この実装には、前進代入と後退代入も含まれる。

cpp
struct LUSolver {
    int n;
    vector<vector<double>> LU;
    vector<int> perm;
    int sign;
    const double EPS = 1e-9;
    
    LUSolver(const vector<vector<double>>& A) : n(A.size()), LU(A), perm(n), sign(1) {
        iota(perm.begin(), perm.end(), 0);
        decompose();
    }
    
    bool decompose() {
        for (int k = 0; k < n; k++) {
            // Partial pivoting
            int pivot = k;
            double maxval = abs(LU[k][k]);
            for (int i = k + 1; i < n; i++) {
                if (abs(LU[i][k]) > maxval) {
                    maxval = abs(LU[i][k]);
                    pivot = i;
                }
            }
            
            if (maxval < EPS) return false;
            
            if (pivot != k) {
                swap(LU[k], LU[pivot]);
                swap(perm[k], perm[pivot]);
                sign = -sign;
            }
            
            // Elimination
            for (int i = k + 1; i < n; i++) {
                LU[i][k] /= LU[k][k];
                for (int j = k + 1; j < n; j++) {
                    LU[i][j] -= LU[i][k] * LU[k][j];
                }
            }
        }
        return true;
    }
    
    vector<double> solve(const vector<double>& b) {
        vector<double> pb(n);
        for (int i = 0; i < n; i++) {
            pb[i] = b[perm[i]];
        }
        
        // Forward substitution
        vector<double> y(n);
        for (int i = 0; i < n; i++) {
            y[i] = pb[i];
            for (int j = 0; j < i; j++) {
                y[i] -= LU[i][j] * y[j];
            }
        }
        
        // Backward substitution
        vector<double> x(n);
        for (int i = n - 1; i >= 0; i--) {
            x[i] = y[i];
            for (int j = i + 1; j < n; j++) {
                x[i] -= LU[i][j] * x[j];
            }
            x[i] /= LU[i][i];
        }
        
        return x;
    }
    
    double determinant() {
        double det = sign;
        for (int i = 0; i < n; i++) {
            det *= LU[i][i];
        }
        return det;
    }
    
    vector<vector<double>> inverse() {
        vector<vector<double>> inv(n, vector<double>(n));
        vector<double> e(n);
        for (int i = 0; i < n; i++) {
            fill(e.begin(), e.end(), 0);
            e[i] = 1;
            vector<double> col = solve(e);
            for (int j = 0; j < n; j++) {
                inv[j][i] = col[j];
            }
        }
        return inv;
    }
};

この実装の特徴:

  1. インプレース分解: LUを同じ配列に格納(Lの対角成分は暗黙的に1)
  2. 置換の記録: perm配列で行の置換を記録
  3. 符号の追跡: 行列式計算のため置換の符号を記録
  4. エラーハンドリング: ピボットが小さすぎる場合は分解失敗を返す

メモリアクセスパターンの最適化

キャッシュ効率を向上させるため、内側のループでは列方向のアクセスを避け、行方向のアクセスを優先する。C++では行優先(row-major)でデータが格納されるため、この最適化は特に重要である。

cpp
// Cache-friendly version
for (int i = k + 1; i < n; i++) {
    double factor = LU[i][k] / LU[k][k];
    LU[i][k] = factor;  // Store L element
    // Access row i sequentially
    for (int j = k + 1; j < n; j++) {
        LU[i][j] -= factor * LU[k][j];
    }
}

整数演算によるLU分解

競技プログラミングでは、浮動小数点誤差を完全に回避するため、有理数演算や剰余演算を用いることがある。

分数を用いた厳密計算

cpp
struct Fraction {
    long long num, den;
    
    Fraction(long long n = 0, long long d = 1) : num(n), den(d) {
        if (den < 0) { num = -num; den = -den; }
        long long g = gcd(abs(num), den);
        num /= g; den /= g;
    }
    
    Fraction operator*(const Fraction& f) const {
        return Fraction(num * f.num, den * f.den);
    }
    
    Fraction operator-(const Fraction& f) const {
        return Fraction(num * f.den - f.num * den, den * f.den);
    }
    
    Fraction operator/(const Fraction& f) const {
        return Fraction(num * f.den, den * f.num);
    }
};

モジュラー演算による実装

大きな素数pで剰余を取ることで、整数演算として実行する。複数の素数での結果から、中国剰余定理で元の値を復元する。

cpp
const long long MOD = 998244353;

long long modinv(long long a, long long m = MOD) {
    return a == 1 ? 1 : m - modinv(m % a, a) * m / a;
}

bool LUDecompositionMod(vector<vector<long long>>& A) {
    int n = A.size();
    for (int k = 0; k < n; k++) {
        if (A[k][k] == 0) return false;
        
        long long inv = modinv(A[k][k]);
        for (int i = k + 1; i < n; i++) {
            A[i][k] = A[i][k] * inv % MOD;
            for (int j = k + 1; j < n; j++) {
                A[i][j] = (A[i][j] - A[i][k] * A[k][j] % MOD + MOD) % MOD;
            }
        }
    }
    return true;
}

疎行列に対するLU分解

競技プログラミングでは、グラフの隣接行列など、疎な構造を持つ行列を扱うことがある。疎行列に対しては、非零要素のみを格納し、フィルイン(fill-in、分解過程で新たに非零になる要素)を最小化する工夫が必要である。

記号的分解(Symbolic Factorization)

数値計算を行う前に、どの位置に非零要素が現れるかを予測する。これにより、必要なメモリを事前に確保し、効率的なデータ構造を構築できる。

順序付け戦略

フィルインを減らすため、行と列の順序を最適化する。代表的な手法として:

  1. 最小次数順序付け(Minimum Degree Ordering): 各ステップで最も非零要素の少ない行/列を選択
  2. Nested Dissection: グラフ分割の考え方を用いて、再帰的に順序を決定
  3. Reverse Cuthill-McKee: 帯幅を最小化する順序付け

これらの順序付けにより、計算量と記憶容量を大幅に削減できる場合がある。


  1. Golub, G. H., & Van Loan, C. F. (2013). Matrix computations (4th ed.). Johns Hopkins University Press. ↩︎

  2. Trefethen, L. N., & Bau, D. (1997). Numerical linear algebra. SIAM. ↩︎

  3. Higham, N. J. (2002). Accuracy and stability of numerical algorithms (2nd ed.). SIAM. ↩︎