Matrix Operations
By the end of this lesson you will be able to solve a system of linear equations Ax = b the way real numerical libraries do: factor A once into PA = LU, then finish with two cheap triangular sweeps. You will trace lupSolve, luDecomposition and lupDecomposition line by line on small worked matrices (with the pivot swaps visible), see exactly why a zero pivot breaks plain LU and a singular matrix breaks everything, invert a matrix as n solves, and fit a curve through noisy data with the normal equations. Every algorithm is implemented in idiomatic Dart and checked against residuals A·x − b and random matrices.
0. Linear systems, triangular systems, and a note on rounding
Small example. Take x₁ + 2x₂ = 3 and 3x₁ + 4x₂ = 7. Subtract 3 × (equation 1) from equation 2: (4 − 6)x₂ = 7 − 9, i.e. −2x₂ = −2, so x₂ = 1; put that back into equation 1: x₁ = 3 − 2 = 1. The answer is x = (1, 1). Everything in this chapter is a careful, computer-friendly way of doing exactly this — clearing entries below a diagonal and then reading off the unknowns from the bottom up.
a[i][j] is row i, column j. Σ (“sigma”) means “add up”: Σ(j < i) l[i][j]·y[j] = l[i][0]·y[0] + … + l[i][i−1]·y[i−1], and it is an empty sum (= 0) when i = 0. Θ(n²) and Θ(n³) describe how the number of arithmetic steps grows with the size n of the matrix (see the growth-of-functions lesson); the nested loops used throughout are the ones from insertion sort. Matrix multiplication and its divide-and-conquer speed-ups are met again in the divide-and-conquer lesson. The Schur complement and “symmetric positive-definite” are defined where they first matter (the LU and SPD sections).Computing A⁻¹ and multiplying is a bad way to solve a system (slower and less accurate). The trick is to notice that some systems are trivially easy: in a lower-triangular matrix (zeros above the diagonal) equation 0 has one unknown, equation 1 then has one new unknown, and so on — you solve top to bottom (forward substitution). In an upper-triangular matrix you solve bottom to top (back substitution). Each costs only Θ(n²).
pi in code) whose values are row numbers 0 … n−1: row i of PA is row π[i] of A, so (Pb)[i] = b[π[i]].double), but every number displayed is rounded to at most 3 decimal places with trailing zeros dropped (so 0.6 and 5 stay short, and 0.214285… shows as 0.214). Nothing is rounded between steps — only the printing is. The worked matrices on this page are chosen to be exact in decimals, so they display exactly.Reading π. The array pi says which original row sits in each row of PA: row i of PA is row pi[i] of A, and the same reordering is applied to b. Dart matrices are List<List<double>>. The two small helpers below show how pi is applied.
Every check below compares vectors and matrices with a few small helper functions; this is their complete code (plain Dart lists, no packages):
Forward substitution: solving L y = Pb
Small example. L = [[1, 0], [2, 1]] and Pb = (3, 8). Row 0 reads y[0] = 3. Row 1 reads 2·y[0] + y[1] = 8, so y[1] = 8 − 2·3 = 2. The answer is y = (3, 2) — each row used only values that were already found.
This is the first half of lupSolve. The panel below gives it as a procedure of its own so it can be traced alone. Because L is unit lower-triangular (1s on its diagonal) no division is needed: y[i] = b[π[i]] − Σ(j < i) l[i][j]·y[j]. Watch three things: π chooses which entry of b starts each row; the terms l[i][j]·y[j] use only y-values that are already final; and the last frame multiplies L·y to prove that it equals Pb.
The second example is the degenerate case: with L = I every product is 0 and y is just b reordered by π. The third lets you type any system; lupDecomposition is run first to supply L and π.
Input size → what is feasible: n ≤ 2000 → n(n−1)/2 ≈ 2·106 multiplications (instant); n = 105 → 5·109 multiplications and a dense 1010-entry matrix (impossible), so a structured matrix (banded, see the tridiagonal question) is needed.
Dart implementation (it expects the permuted right-hand side, so call it as forwardSubstitution(l, [for (final i in pi) b[i]]), because pi[i] is the original row that row i of PA came from):
Back substitution: solving U x = y
Small example. U = [[2, 1], [0, 3]] and y = (5, 6). Row 1 reads 3·x[1] = 6, so x[1] = 2. Row 0 reads 2·x[0] + 1·x[1] = 5, so x[0] = (5 − 1·2)/2 = 1.5. The answer is x = (1.5, 2). This time each row divides by its diagonal entry u[i][i].
This is the second half of lupSolve: x[i] = (y[i] − Σ(j > i) u[i][j]·x[j]) / u[i][i], computed for i = n − 1 down to 0. The division is where a zero on the diagonal of U becomes fatal: it means A was singular.
Input size → what is feasible: n ≤ 2000 → n(n−1)/2 ≈ 2·106 multiplications and 2,000 divisions (instant); n = 105 → 5·109 steps and 1010 stored entries (impossible for a dense U).
Dart implementation (0-indexed; y is the vector produced by forward substitution):
lupSolve(l, u, pi, b)
Small example. A = [[1, 2], [3, 4]], b = (5, 11). The biggest first-column entry is 3 (row 1), so lupDecomposition swaps: pi = [1, 0], L = [[1, 0], [1/3, 1]], U = [[3, 4], [0, 2/3]]. Forward: y[0] = b[1] = 11, then y[1] = b[0] − (1/3)·11 = 4/3. Back: x[1] = (4/3)/(2/3) = 2, then x[0] = (11 − 4·2)/3 = 1. So x = (1, 2), and indeed 1 + 2·2 = 5 and 3 + 4·2 = 11.
Inputs are an already-computed L, U, π (from the sections below) and any right-hand side b. Below, the system A = [[3,6,6],[4,4,2],[3,9,4]], b = (9,4,2) is solved. Watch three things: π picks which entry of b starts each forward step; each y[i] subtracts the terms already known; and the residual check at the end.
Input size → what is feasible: n ≤ 2000 → n(n−1) ≈ 4·106 multiplications per solve (instant), so 1,000 right-hand sides cost 4·109 — still better than re-factoring each time; n = 104 → 108 per solve but each factor holds 108 doubles = 800 MB.
Dart implementation (0-indexed; pi[i] holds a 0-based row number):
luDecomposition(a)
Small example. A = [[2, 1], [4, 5]]. The pivot is u[0][0] = 2 and the multiplier is l[1][0] = 4/2 = 2, which clears the 4. The Schur complement is the single number 5 − 2·1 = 3, so u[1][1] = 3. Result: L = [[1, 0], [2, 1]], U = [[2, 1], [0, 3]], and L·U = [[2, 1], [4, 5]] = A.
Precisely: split A = [[a₀₀, wᵀ], [v, A′]]. Then A = [[1, 0], [v/a₀₀, I]]·[[a₀₀, wᵀ], [0, S]] with S = A′ − v wᵀ/a₀₀. The pseudocode is this recursion with the tail call turned into the loop for k. The loop invariant: before iteration k (counting from 0), rows and columns 0 … k−1 of L and U are final, and the working array's lower-right (n−k)×(n−k) block is the current Schur complement.
| n | 2 | 3 | 4 | 5 | 10 |
|---|---|---|---|---|---|
| multiply-subtracts (line 10) | 1 | 5 | 14 | 30 | 285 |
Input size → what is feasible: n ≤ 500 → n³/3 ≈ 4·107 multiply-subtracts (instant); n = 2000 → 2.7·109 (tens of seconds in Dart); n = 104 → 3.3·1011 (hopeless). Use LU only for SPD or diagonally dominant matrices.
Dart implementation (returns a record (l, u); the throw is an added guard for the zero-pivot case that the pseudocode leaves out):
lupDecomposition(a)
Small example. A = [[0, 1], [1, 1]]. Plain LU would divide by a[0][0] = 0. Partial pivoting scans column 0 = (0, 1), finds the larger |entry| in row 1 and swaps the rows (pi = [1, 0]): the matrix becomes [[1, 1], [0, 1]], the multiplier is 0/1 = 0, and the Schur complement is 1 − 0·1 = 1. So L = I and U = [[1, 1], [0, 1]], with PA = LU.
The procedure works in place: when it finishes, the array a holds L's multipliers below the diagonal (L's unit diagonal is implied) and U on and above the diagonal, and pi records the row order. Note that line 12 swaps whole rows, including the multipliers already stored to the left, so earlier steps stay consistent with the new order.
Read every animation the same way: first the pivot choice (scan the column, remember the biggest absolute value), then the row swap (π and whole rows move together), then the multipliers and the update, and finally a frame where the lower-right block is highlighted as the Schur complement — the smaller matrix of the same kind that the next iteration factors. The Schur complement of the pivot a[k][k] is S = A′ − v·wᵀ / a[k][k], where v is the column below the pivot, wᵀ the row to its right and A′ the remaining block; its entries are exactly the numbers written by lines 16–17.
Input size → what is feasible: n ≤ 500 → n³/3 ≈ 4·107 multiply-subtracts plus n(n+1)/2 = 125,250 pivot comparisons (instant); n = 2000 → 2.7·109 (tens of seconds); n ≥ 104 → use a library (BLAS/LAPACK) or exploit structure.
Dart implementation (the row-swap step becomes a swap of whole row references; the singular check uses the tolerance eps = 1e-12 instead of exact zero because floating-point subtraction rarely gives exactly 0. The animations on this page use the scale-free version of the same test, pivot ≤ 10⁻¹⁰·max|aij|, so that a singular matrix with entries near 10⁶ is still caught; the fixed eps is fine for the modest entries used in the Dart examples):
Why pivoting matters: zero and tiny pivots
Small example. A = [[0, 1], [1, 1]] is invertible (det = −1), yet its first pivot is 0 and plain elimination stops immediately (the previous section's pitfall). Exchanging the two rows fixes it. Now imagine the 0 is replaced by 10⁻²⁰: the matrix is still fine mathematically, but the computer now divides by a number that is almost zero.
The system below is A = [[e, 1], [1, 1]] with right-hand side b. Its exact solution has a closed form (x[0] = (b[0] − b[1])/(e − 1), x[1] = b[1] − x[0]), which serves as an unrounded reference. Each player runs the same five arithmetic lines twice on real 64-bit doubles: once with the row exchange switched off, once with partial pivoting on. Only the numbers differ.
if (pivot != 0)) still lets tiny pivots through, and tiny pivots destroy accuracy long before they reach zero. The rule is to pick the largest candidate, which is why lupDecomposition compares absolute values instead of testing for zero. (Pivoting protects against unstable elimination; it cannot rescue a matrix that is itself extremely sensitive — a different issue called ill-conditioning.)Input size → what is feasible: the 2×2 experiment is O(1); on an n × n matrix pivoting adds only n(n+1)/2 comparisons and at most n row swaps to the Θ(n³) work, so n ≤ 500 stays instant — there is never a reason to skip pivoting on a general matrix.
Dart version of the two-pass experiment (the same operations, in the same order, as the animation; the verify program checks the numbers quoted above):
Inverting a matrix with LUP
Small example. A = [[1, 2], [3, 4]] has the factors from the lupSolve example. Column 0 of A⁻¹ solves Ax = (1, 0): forward gives y = (0, 1), back gives x[1] = 1/(2/3) = 1.5 and x[0] = (0 − 4·1.5)/3 = −2, so column 0 is (−2, 1.5). Column 1 solves Ax = (0, 1) and gives (1, −0.5). Hence A⁻¹ = [[−2, 1], [1.5, −0.5]].
The panel below writes the method as steps. Cost: one Θ(n³) decomposition plus n solves of Θ(n²) each: Θ(n³) overall. (Inversion and matrix multiplication have the same asymptotic difficulty: multiplying reduces to inverting a block matrix, and inverting reduces to multiplying via the Schur complement — both reductions are coded and checked in the panel below.)
Input size → what is feasible: n ≤ 300 → (4/3)·n³ ≈ 3.6·107 multiplications (instant); n = 1000 → 1.3·109 (about 15 s); a single system Ax = b never needs it — lupSolve costs one factorization plus n(n−1).
Dart implementation (with the small helper that splits the in-place result into L and U):
Least squares and the normal equations
Small example. Points (0, 1), (1, 3), (2, 2) and a line F(x) = c[0] + c[1]·x. Then A = [[1,0],[1,1],[1,2]], AᵀA = [[3, 3], [3, 5]] and Aᵀy = (6, 7). Solving [[3,3],[3,5]]·c = (6,7) gives c = (1.5, 0.5), the line 1.5 + 0.5x. Its misses are ε = (0.5, −1, 0.5): they add up to 0 and are perpendicular to both columns of A, exactly what the normal equations demand.
Model F(x) = c[0] + c[1]·x + … + c[n−1]·xn−1. Stack the m data points into the m × n matrix A with A[i][j] = x[i]j, so the prediction vector is Ac and the miss vector is ε = Ac − y (m > n, so this is overdetermined). Setting the derivatives of ‖ε‖² to zero gives the normal equations AᵀA c = Aᵀy. If A has full column rank, AᵀA is symmetric positive-definite (SPD), hence nonsingular, and c = (AᵀA)⁻¹Aᵀy = A⁺y where A⁺ is the pseudoinverse. The panel below is this lesson’s own step-by-step summary of the method. In practice you do not form the inverse: multiply out AᵀA and Aᵀy, decompose the n × n system and call lupSolve. (For SPD matrices plain LU never meets a zero pivot, as the SPD section shows, but LUP is harmless and safer, so the panel uses it.)
The last frame of each example also draws the result: orange dots are the data, the blue curve is the fitted F, and red vertical segments are the misses ε (zero-length when the fit is exact).
Input size → what is feasible: m ≤ 105 points with n ≤ 10 coefficients → m·n² ≈ 107 multiply-adds (instant); n = 1000 coefficients → n³ = 109, and AᵀA squares the condition number, so use QR instead.
Dart implementation (needs import 'dart:math' as math;; the powers use math.pow(x, j) for xj):
Symmetric positive-definite matrices
Two words to define. A matrix is symmetric if A = Aᵀ (its entries mirror across the diagonal: a[i][j] = a[j][i]). It is positive-definite if xᵀAx > 0 for every nonzero vector x. SPD matrices are the ones you meet in least squares (AᵀA with independent columns), in physics (energy) and statistics (covariance). Four facts about them: they are nonsingular, every leading submatrix is SPD, the Schur complement of an SPD matrix is SPD, and therefore luDecomposition never divides by zero on them — every pivot is strictly positive.
Small example. A = [[2, 1], [1, 2]]. The first pivot is 2 > 0; the Schur complement is 2 − 1·1/2 = 1.5, so the second pivot is 1.5 > 0. Both pivots are positive, so A is SPD, and det A = 2·1.5 = 3. Contrast [[1, 2], [2, 1]]: its Schur complement is 1 − 2·2/1 = −3 < 0, so it is symmetric but not positive-definite. The players below trace exactly this test: symmetric check, then pivot, column v, and the new Schur complement, step by step.
The procedure in the code panel turns those facts into a test. It uses the fact that a symmetric matrix is positive-definite exactly when plain LU produces only positive pivots, and it never needs row swaps — which is why LU (and even the square-root variant, the Cholesky factorization A = LLᵀ, whose diagonal is the square roots of the pivots) is safe here. The least-squares matrix AᵀA of the previous section is SPD whenever A has independent columns, which is why the normal equations can always be solved.
Input size → what is feasible: n ≤ 500 → the LU pivot test is n³/3 ≈ 4·107 steps and Cholesky (n−1)n(n+1)/6 ≈ 2·107 (both instant); n = 5000 → 4·1010 / 2·1010 (minutes), so use sparse or iterative methods for huge SPD systems.
Dart implementation of the test (same idea, run on a copy of A; eps is the same 1e-12 tolerance used for singular pivots):
Quiz
Interview questions
Cheat sheet
| Procedure | Time | Space | Needs pivoting? | Fails when | When to use |
|---|---|---|---|---|---|
| Forward substitution (Ly = Pb) | Θ(n²): n(n−1)/2 mult | O(n) | n/a | never (unit diagonal) | Lower-triangular systems; first half of lupSolve |
| Back substitution (Ux = y) | Θ(n²): n(n−1)/2 mult + n div | O(n) | n/a | uii = 0 (singular) | Upper-triangular systems; second half of lupSolve |
| lupSolve | Θ(n²) | O(n) (x, y) | n/a (uses π) | uii = 0 (singular) | Every solve after a factorization; many right-hand sides |
| luDecomposition | Θ(n³) ≈ n³/3 mult-subs | Θ(n²) (L, U) | No (cannot swap) | Any zero pivot, even if A nonsingular | SPD or diagonally dominant matrices |
| lupDecomposition | Θ(n³) | Θ(n²) in place + π | Yes (partial) | Whole pivot column is 0 → "singular matrix" | Default for general square A |
| Inversion via LUP | Θ(n³) | Θ(n²) | Yes | Singular A | Only when all of A⁻¹ is needed |
| Pivoting (partial) | O(n²) extra comparisons and swaps | O(n) for π | — | whole column zero → singular | Always for general matrices; keeps |lij| ≤ 1 |
| Least squares (normal equations) | Θ(mn² + n³) | Θ(n² + mn) | Optional (AᵀA is SPD) | Columns of A dependent (AᵀA singular) | Curve fitting with few parameters, well-conditioned data |
| SPD test / Cholesky | LU test ≈ n³/3, Cholesky ≈ n³/6; symmetry check Θ(n²) | Θ(n²) | No (pivots stay positive) | Nonpositive pivot → not positive-definite | Covariance / AᵀA / energy matrices; fastest factorization for SPD |