The LU Decomposition Algorithm#
An LU factorization writes a square matrix as
where \(L\) is lower triangular and \(U\) is upper triangular. We use the convention \(l_{ii}=1\), so \(L\) is unit lower triangular. This is also called the Doolittle convention.
Once the factors are available, solving \(Ax=b\) requires two triangular solves:
We first explain these solves, then derive LU as a sequence of rank-one updates. Throughout this section, we factor without interchanging rows. This requires nonzero pivots at the elimination steps; an invertible matrix need not satisfy this requirement.
Solving Triangular Systems#
Forward Substitution#
For a lower triangular system \(Ly=b\), the first equation determines \(y_1\), the second determines \(y_2\), and so on. For example,
gives
The general formula is
All entries on the right have already been computed. We need \(l_{ii}\neq0\); for the unit lower triangular factor in LU, every denominator is one.
Backward Substitution#
For an upper triangular system \(Ux=y\), start with the last equation and work upward:
This requires \(u_{ii}\neq0\). In both formulas, an empty sum is zero. The order of computation proves correctness: each step solves one equation whose other unknowns have already been determined.
The following functions assume square triangular NumPy arrays and matching one-dimensional right-hand sides. The output uses floating-point or complex storage even when the input right-hand side contains integers.
import numpy as np
def forward_substitution(L: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Solve Ly = b for a nonsingular lower triangular L."""
n = L.shape[0]
y = np.zeros(n, dtype=np.result_type(L.dtype, b.dtype, np.float64))
for i in range(n):
if L[i, i] == 0:
raise np.linalg.LinAlgError("Zero diagonal in lower triangular solve")
y[i] = (b[i] - L[i, :i] @ y[:i]) / L[i, i]
return y
def backward_substitution(U: np.ndarray, y: np.ndarray) -> np.ndarray:
"""Solve Ux = y for a nonsingular upper triangular U."""
n = U.shape[0]
x = np.zeros(n, dtype=np.result_type(U.dtype, y.dtype, np.float64))
for i in range(n - 1, -1, -1):
if U[i, i] == 0:
raise np.linalg.LinAlgError("Zero diagonal in upper triangular solve")
x[i] = (y[i] - U[i, i + 1:] @ x[i + 1:]) / U[i, i]
return x
For complex matrices, these are ordinary matrix products; no conjugation is needed.
Cost of the Two Solves#
We count each real addition, subtraction, multiplication, or division as one floating-point operation (flop). A multiply followed by an addition counts as two flops, even if a processor performs them in one fused instruction.
Row \(i\) of forward substitution uses \(i-1\) multiplications, \(i-1\) subtractions, and one division. Thus a general triangular solve costs
flops. For unit lower triangular \(L\), the divisions can be omitted, reducing the count to \(n(n-1)\). Together, forward and backward substitution cost approximately \(2n^2\) flops. Complex arithmetic has different constants but the same \(O(n^2)\) cost.
The factors depend only on \(A\). If several right-hand sides must be solved with the same matrix, we reuse \(L\) and \(U\) and repeat only the triangular solves.
Deriving LU through Outer Products#
Recall the outer-product view of matrix multiplication:
Here \(l_{:,k}\) is column \(k\) of \(L\) and \(u_{k,:}\) is row \(k\) of \(U\). Each product has rank at most one. The algorithm determines one such column-row pair at a time, then factors the remainder.
The First Column and Row#
Partition \(A\) as
where \(r\) is a row vector and \(c\) is a column vector. If \(a_{11}\neq0\), block multiplication verifies
The scalar \(a_{11}\) is the first pivot, and the entries of \(c/a_{11}\) are the elimination multipliers. The matrix \(S\) is the Schur complement of the pivot.
In outer-product terms, we have determined
Subtracting their outer product leaves
The first row and column have been accounted for. Only the smaller matrix \(S\) remains to be factored. If \(S=L_SU_S\), then
satisfy \(A=LU\). Repeating this argument proves the recursive construction whenever the required pivots are nonzero.
Connection to Elimination and Projections#
In Gaussian elimination, subtracting \((c_i/a_{11})\) times the first row from each lower row produces \(S\) in the trailing block. LU records those multipliers in \(L\) and the resulting pivot rows in \(U\).
The rank-one remainder also has an oblique projection interpretation. Set \(l=l_{:,1}\) and let \(e_1\) be the first coordinate vector. Since \(e_1^Tl=1\),
This projects onto the vectors whose first coordinate is zero, along the direction \(l\). It is generally not an orthogonal projection. This remainder discards the pivot row after saving it in \(U\); ordinary row elimination keeps that row.
The General Elimination Step#
Let \(A^{(0)}=A\) and define the mathematical remainder after \(k\) steps by
Its first \(k\) rows and columns are zero. At step \(k\), use the trailing block of \(A^{(k-1)}\) to compute
The remaining entries are updated by
Thus the work at each step is a division to obtain the multipliers, followed by a rank-one update of the trailing block. The pivot is taken from the current remainder, not from the original diagonal of \(A\).
At \(k=n\), only \(u_{nn}\) remains to be read off; there are no multipliers or trailing entries to update.
In-Place Implementation#
We can store both factors in one array:
The entries on and above the diagonal hold \(U\).
The entries below the diagonal hold the multipliers in \(L\).
The unit diagonal of \(L\) is implicit.
This packed array is different from the mathematical remainder \(A^{(k)}\). After storing the multipliers and pivot row, we update only the trailing block, so the factors already computed are preserved.
def lu_inplace(A: np.ndarray) -> np.ndarray:
"""Overwrite a square floating-point or complex array with packed LU.
No row pivoting is performed. Return the same array.
"""
if A.ndim != 2 or A.shape[0] != A.shape[1]:
raise ValueError("A must be square")
if not np.issubdtype(A.dtype, np.inexact):
raise TypeError("A must have a floating-point or complex dtype")
n = A.shape[0]
for k in range(n - 1):
if A[k, k] == 0:
raise np.linalg.LinAlgError("Zero pivot: row pivoting may be needed")
A[k + 1:, k] /= A[k, k]
A[k + 1:, k + 1:] -= np.outer(A[k + 1:, k], A[k, k + 1:])
return A
The function modifies its argument; pass a copy to retain the original matrix. Its zero-pivot check detects division by zero, but does not guarantee accuracy when pivots are nonzero. We examine the need for pivoting later in this chapter.
A Worked Factorization and Solve#
Consider
The first pivot is \(2\), with multipliers \(l_{21}=2\) and \(l_{31}=4\). The trailing block becomes
The second pivot is \(1\), so \(l_{32}=3\). The last pivot is \(5-3\cdot1=2\). Therefore,
For \(b=(3,7,17)^T\), forward substitution gives \(y=(3,1,2)^T\), and backward substitution gives \(x=(1,0,1)^T\).
Here is the complete computation using the functions above:
A = np.array([[2., 1., 1.],
[4., 3., 3.],
[8., 7., 9.]])
b = np.array([3, 7, 17])
packed = lu_inplace(A.copy())
L = np.tril(packed, k=-1) + np.eye(A.shape[0])
U = np.triu(packed)
y = forward_substitution(L, b)
x = backward_substitution(U, y)
assert np.allclose(L @ U, A)
assert np.allclose(A @ x, b)
Cost of LU Factorization#
For a dense real matrix, step \(k\) uses \(n-k\) divisions for the multipliers and \(2(n-k)^2\) flops for the trailing update. Summing gives
For \(s\) right-hand sides, factoring once and reusing the factors costs approximately
flops. The distinction is important: factorization costs \(O(n^3)\), while each additional solve costs \(O(n^2)\).
What If a Pivot Is Zero?#
The no-pivoting algorithm divides by \(u_{kk}\) for \(k=1,\ldots,n-1\). If one of these pivots is zero, the stated algorithm stops. This can happen even when \(A\) is invertible. For example,
has determinant \(-1\), but its first pivot is zero. Swapping the two rows removes the difficulty. Row interchanges lead to a factorization \(PA=LU\), where \(P\) is a permutation matrix; the triangular solves then use \(Ly=Pb\) and \(Ux=y\).
A zero last pivot is different: no elimination step divides by it. LU may still be completed, but \(U\) is singular and backward substitution cannot produce a unique solution for arbitrary \(b\). More generally, stopping on a zero pivot does not rule out all LU factorizations of a singular matrix.
The next section establishes the conditions for existence and uniqueness. We then study floating-point arithmetic and row pivoting, including why nonzero pivots alone do not ensure an accurate computation.