Gaussian Elimination — When Ill-Conditioned Matrices Lie
A condition number of 10^12 can produce residuals of 10^6 even when the solver reports success.
- Gaussian elimination solves Ax = b in two phases: forward elimination to upper triangular, then back substitution
- Partial pivoting swaps rows to keep multipliers ≤ 1 and bound error amplification
- Time complexity: O(n³) for elimination, O(n²) for back substitution
- LU decomposition stores elimination factors, enabling O(n²) solve for each new b
- Production rule: use LAPACK via numpy or scipy — never implement raw Gauss for dense systems
- Biggest mistake: assuming a non-zero pivot guarantees accuracy — check condition number
Gaussian elimination solves systems of linear equations by systematically eliminating variables. For n equations with n unknowns, you reduce the system to upper triangular form (each equation has one fewer variable), then back-substitute from the last equation upward. It is the same process you learned in high school algebra — just systematised for computers.
Gaussian elimination is the foundational algorithm for solving linear systems Ax = b. It runs in O(n³) and, with partial pivoting, is numerically stable for most practical problems. It is what numpy.linalg.solve, scipy.linalg.solve, and every numerical linear algebra library use under the hood (via LU decomposition).
Understanding Gaussian elimination means understanding why partial pivoting matters — without it, small pivots cause numerical catastrophe. It also means knowing how LU decomposition factorises A for efficient multiple right-hand-side solving, and why direct methods (Gauss) are preferred over iterative methods (Jacobi, Gauss-Seidel) for dense systems. Here's the thing: most engineers who reach for np.linalg.solve don't realise they're paying O(n³) every time. Factor once, solve many.
Forward Elimination with Partial Pivoting
Forward elimination reduces the augmented matrix [A|b] to upper triangular form. At each column, we first find the row with the largest absolute value in that column (partial pivoting) and swap it to the diagonal. This ensures all multipliers are ≤ 1, bounding error amplification. Then we subtract multiples of the pivot row from rows below to zero out the column. The process produces an upper triangular matrix U and a transformed right-hand side vector.
When the pivot is < 1e-12 after pivoting, the matrix is numerically singular and we raise an error. In production codes, the threshold is adjusted based on the matrix norm. Don't hardcode 1e-12 — it's a toy value. Real libraries scale the tolerance by the maximum element magnitude. If you're implementing this yourself, and you're in production, you're doing it wrong. Use LAPACK.
Why Partial Pivoting Matters
Without pivoting, Gaussian elimination fails when the pivot element is zero (division by zero) and becomes numerically unstable when the pivot is very small (amplifying rounding errors).
Example: A = [[0.001, 1],[1, 1]], b = [1, 2]. Without pivoting, the first pivot is 0.001 — the elimination multiplier is 1/0.001 = 1000. Floating point errors in the first row get amplified by 1000x. Partial pivoting swaps to use the row with the largest absolute value as pivot, keeping multipliers ≤ 1 and bounding error amplification. That's the difference between a correct answer and garbage. And don't think 'my matrix is well-conditioned' — it only takes one bad pivot to corrupt everything.
Back Substitution — Solving the Upper Triangular System
After forward elimination, we have an upper triangular system Ux = c. We solve from the last equation upward: x_n = c_n / U_{nn}, then for i from n-1 down to 1: x_i = (c_i - sum_{j=i+1}^{n} U_{ij} x_j) / U_{ii}. This is O(n²) — much cheaper than elimination. The numerical stability of back substitution is good because it solves a triangular system directly; however, a very small diagonal element in U can still cause large errors. Watch for tiny diagonals — they hint at singularity or extreme ill-conditioning.
LU Decomposition — Reusing the Factorization
Gaussian elimination actually computes an LU decomposition of A (with partial pivoting: PA = LU). The lower triangular matrix L contains the multipliers, and U is the upper triangular result. Once we have P, L, U, solving Ax = b becomes solving Ly = Pb (forward substitution) and Ux = y (back substitution). The forward elimination step (O(n³)) is done once; each new right-hand side costs only O(n²). This is how library solvers work internally.
When you call numpy.linalg.solve, it computes the LU decomposition and then solves. You can get the factors explicitly via scipy.linalg.lu. The real win: if you need to solve for 1000 different b vectors, you factor once and solve 1000 times. That's O(n³ + 1000n²) vs O(1000n³). The savings are massive. Don't be the engineer who calls solve in a loop.
- L stores the multipliers from each elimination step
- U is the final upper triangular matrix
- P records row swaps (pivoting history)
- With P,L,U you can solve for any b without re-eliminating
solve() repeatedly for different b, unknowingly paying O(n³) each time.Computational Complexity and When to Avoid Gaussian Elimination
Dense Gaussian elimination costs O(n³) flops. For n=1000, that's about 1 billion operations — fine on modern hardware. For n=10,000, a trillion operations takes minutes. Libraries like LAPACK are highly optimised (blocked algorithms, BLAS level 3) but the cubic growth remains.
For large sparse systems, iterative solvers (Conjugate Gradient, GMRES) exploit sparsity and converge in O(nnz * iterations) where nnz is number of nonzeros and iterations are often << n. The break-even point for dense vs sparse is usually around n=1000-5000, depending on sparsity. If your matrix is 99% zeros, Gaussian elimination ignores that and chews up memory storing zeros. Don't do that. Check sparsity first.
When Gaussian Elimination Fails: Ill-Conditioned Systems and Iterative Refinement
Even with partial pivoting, Gaussian elimination can produce inaccurate results for ill-conditioned systems. The condition number κ(A) = ||A|| ||A⁻¹|| measures how much errors in b or A amplify in the solution. When κ(A) ~ 10^t, you lose about t digits of precision. For double precision (16 digits), if κ=10^12, only 4 digits remain reliable.
Iterative refinement addresses this: after computing a first solution x₀ by Gaussian elimination, compute the residual r = b - Ax₀, solve Ad = r (using the same LU factors), and correct x₁ = x₀ + d. Repeat until the residual is small. This recovers full precision if the system is not too ill-conditioned. For extremely ill-conditioned systems (κ > 10^14), even iterative refinement fails, and you need regularisation (e.g., Tikhonov) or arbitrary-precision arithmetic.
In production, always compute the condition number before trusting a solve. LAPACK's DGESVX (expert driver) provides error bounds and iterative refinement automatically.
Silently Wrong Results from a Nearly Singular Matrix
- A non-error return from a solver does not guarantee a correct solution.
- Always check the condition number of A when the problem is ill-posed.
- Partial pivoting is necessary but not sufficient for very ill-conditioned systems.
- In production, compute residual after every solve — it's your safety net.
Key takeaways
Common mistakes to avoid
5 patternsImplementing Gaussian elimination without pivoting
Using dense Gaussian elimination on sparse systems
Ignoring condition number and assuming solution is correct
Repeatedly calling solve() for multiple b instead of factoring once
Hardcoding pivot tolerance without scaling to matrix norm
Interview Questions on This Topic
Why is partial pivoting necessary in Gaussian elimination?
Frequently Asked Questions
That's Linear Algebra. Mark it forged?
4 min read · try the examples if you haven't