LU分解
線形代数において、LU分解は行列を下三角行列(Lower triangular matrix)と上三角行列(Upper triangular matrix)の積に分解する手法である。この分解は連立一次方程式の解法、逆行列の計算、行列式の計算など、数値線形代数の基礎となる重要な技術であり、競技プログラミングにおいても行列演算を効率的に実行するための強力な道具となる。
LU分解の数学的定義
LU分解が存在するための必要十分条件は、行列
Doolittleアルゴリズム
LU分解を計算する最も基本的なアルゴリズムはDoolittleアルゴリズムである。このアルゴリズムでは、
アルゴリズムの基本的な考え方は、行列
具体的な計算手順を
第1ステップでは、第1列の第2行以下を0にする。このとき、
部分ピボット選択
数値計算において、LU分解の安定性は極めて重要である。ピボット(対角要素)が小さい値になると、丸め誤差が増幅され、計算結果の精度が著しく低下する。この問題を回避するため、部分ピボット選択(partial pivoting)を導入する。
部分ピボット選択では、各ステップで現在の列の絶対値最大の要素を持つ行と交換してから消去を行う。これにより、分解は
部分ピボット選択の重要性を示す典型的な例として、以下の行列を考える:
ここで
計算量と数値的性質
LU分解の計算量は
数値安定性の観点から、部分ピボット選択付きLU分解の成長因子(growth factor)は最悪の場合
前進代入と後退代入
LU分解の主要な応用は連立一次方程式
を前進代入(forward substitution)で解く を後退代入(backward substitution)で解く
前進代入では、
同様に、後退代入では下から順に求める:
これらの代入操作はそれぞれ
競技プログラミングにおける実装
競技プログラミングでは、LU分解は主に以下の場面で活用される:
- 連立一次方程式の高速解法: 同じ係数行列に対して複数の右辺ベクトルを処理する場合
- 行列式の計算:
(置換の符号を考慮) - 逆行列の計算: 各単位ベクトルを右辺として解くことで逆行列の列を求める
実装上の注意点として、浮動小数点演算の誤差を考慮する必要がある。ピボットがゼロかどうかの判定には、厳密な比較ではなく、適切な許容誤差(通常は
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分解が有効である。行列を適切なサイズのブロックに分割し、各ブロックを小行列として扱うことで、キャッシュ効率を向上させ、並列化も容易になる。
ブロック分解では、まず
特殊な構造を持つ行列への応用
実際の問題では、係数行列が特殊な構造を持つことが多い。例えば、三重対角行列(tridiagonal matrix)の場合、Thomas法と呼ばれる
対称正定値行列に対しては、Cholesky分解という特殊なLU分解が存在する。これは
数値的安定性の詳細
LU分解の数値的振る舞いを理解するには、条件数(condition number)の概念が重要である。行列
部分ピボット選択は、分解過程での誤差の増大を抑制するが、元の行列の条件数自体は改善しない。条件数が大きい行列(悪条件行列)に対しては、どのような数値解法を用いても精度の良い解を得ることは困難である。
完全ピボット選択(complete pivoting)では、行と列の両方を交換して最大要素をピボットとする。これにより数値安定性はさらに向上するが、計算量が
実装における最適化技法
競技プログラミングでの実装では、以下の最適化が有効である:
- インプレース計算:
と を同じ配列に格納し、メモリ使用量を削減 - ループ展開: 内側のループを部分的に展開し、キャッシュ効率を向上
- SIMD命令の活用: 複数の演算を並列実行(コンパイラの自動ベクトル化に依存)
また、整数演算で厳密な解を求める場合は、分数演算を用いるか、十分大きな素数で剰余を取るモジュラー演算を使用する。後者の場合、中国剰余定理を用いて複数の素数での結果から元の解を復元する。
理論的背景と拡張
LU分解は、より一般的なQR分解やSVD(特異値分解)の特殊ケースとして理解できる。QR分解では直交行列
また、LU分解はガウスの消去法の行列表現であり、行基本変形を行列の積として表現したものと解釈できる。この観点から、LU分解は線形写像の分解として、より抽象的な数学的構造を持つ。
不完全LU分解(ILU: Incomplete LU decomposition)は、疎行列に対する前処理手法として重要である。分解の過程で小さな要素を0とみなすことで、疎性を保ちながら近似的な分解を得る。これは反復法の収束を加速する前処理行列として広く使用される。
誤差解析の詳細
LU分解における誤差の振る舞いを詳しく理解することは、数値計算の信頼性を確保する上で不可欠である。浮動小数点演算では、各演算ごとに丸め誤差が発生し、これが蓄積することで最終結果に大きな影響を与える可能性がある。
後退誤差解析(backward error analysis)の観点から、計算されたLU分解
ここで、
前進誤差(forward error)については、計算された解
この関係式は、悪条件行列(条件数が大きい行列)に対しては、たとえ後退誤差が小さくても、解の誤差が大きくなる可能性があることを示している。
競技プログラミングにおける具体的問題例
LU分解が効果的に適用できる競技プログラミングの問題をいくつか詳しく見ていく。
例1: 電気回路の解析
ここで、
例2: 確率的グラフ問題
ランダムウォークの定常分布や、マルコフ連鎖の吸収確率を求める問題では、
例3: 多項式補間
Vandermonde行列は条件数が非常に大きくなりやすいため、数値安定性に特に注意が必要である。
並列化アルゴリズム
現代の計算環境では、並列処理によるLU分解の高速化が重要である。主要な並列化手法として、以下のアプローチがある。
ブロック並列化
行列を
各ステップで、対角ブロックを分解し、その結果を使って
パイプライン並列化
列方向の依存関係を利用して、パイプライン的に処理を進める。第
実装の詳細と最適化
競技プログラミングで使用する完全な実装を示す。この実装には、前進代入と後退代入も含まれる。
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) - 置換の記録:
perm
配列で行の置換を記録 - 符号の追跡: 行列式計算のため置換の符号を記録
- エラーハンドリング: ピボットが小さすぎる場合は分解失敗を返す
メモリアクセスパターンの最適化
キャッシュ効率を向上させるため、内側のループでは列方向のアクセスを避け、行方向のアクセスを優先する。C++では行優先(row-major)でデータが格納されるため、この最適化は特に重要である。
// 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分解
競技プログラミングでは、浮動小数点誤差を完全に回避するため、有理数演算や剰余演算を用いることがある。
分数を用いた厳密計算
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);
}
};
モジュラー演算による実装
大きな素数
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)
数値計算を行う前に、どの位置に非零要素が現れるかを予測する。これにより、必要なメモリを事前に確保し、効率的なデータ構造を構築できる。
順序付け戦略
フィルインを減らすため、行と列の順序を最適化する。代表的な手法として:
- 最小次数順序付け(Minimum Degree Ordering): 各ステップで最も非零要素の少ない行/列を選択
- Nested Dissection: グラフ分割の考え方を用いて、再帰的に順序を決定
- Reverse Cuthill-McKee: 帯幅を最小化する順序付け
これらの順序付けにより、計算量と記憶容量を大幅に削減できる場合がある。