Introduction

Most equations cannot be solved exactly. This is a fact that surprises people who stopped at algebra, where every problem has a clean symbolic answer. But the world is full of equations that resist closed-form solutions: polynomials of degree five and higher (Abel–Ruffini theorem), transcendental equations like $x = \cos(x)$, systems of nonlinear equations, and most differential equations arising from physics and engineering.

Numerical methods are algorithms that find approximate solutions to these problems. They trade symbolic exactness for computational precision—and in practice, eight digits of accuracy is more than enough. The methods here fall into four categories:

  • Root finding — given $f(x)$, find $x$ where $f(x) = 0$
  • Direct linear solvers — solve $Ax = b$ exactly (up to floating-point error)
  • Iterative linear solvers — solve $Ax = b$ by successive approximation
  • ODE solvers — given $x'(t) = f(x, t)$ and an initial condition, approximate $x(t)$

Each method below includes the mathematical idea, the algorithm, and a Python implementation. The code is real and functional—not pseudocode. These were written for actual use.

Part 1: Root Finding

Root finding is the oldest problem in numerical analysis. Given a continuous function $f$, find a value $x$ such that $f(x) = 0$. This is equivalent to asking: where does the curve cross the x-axis?

The methods progress from the most reliable but slowest (bisection) to the fastest but most fragile (Newton’s method). The tradeoff between robustness and speed is the central tension in all numerical work.

Bisection Method
The most reliable root-finding method. Guaranteed to converge. Slow but never fails.

The idea: If $f(a)$ and $f(b)$ have opposite signs, then by the Intermediate Value Theorem, there must be a root between $a$ and $b$. Cut the interval in half. Check the sign of $f$ at the midpoint. The root is in whichever half has a sign change. Repeat. Each iteration halves the interval, so after $n$ iterations the error is at most $\frac{b - a}{2^n}$.

Convergence: Linear. Each step gains roughly one binary digit of accuracy.
Error after n steps: $|\text{error}| \le \frac{b - a}{2^n}$
Steps needed for tolerance $\epsilon$: $n = \lceil \log_2((b - a)/\epsilon) \rceil$

Strengths: Guaranteed convergence. No derivatives needed. Works on any continuous function. Weakness: Slow. To get 10 decimal digits from an interval of width 1, you need about 33 iterations.

def bisection(f, a, b, e): """ f: function to find root of a, b: interval bounds (must bracket a root) e: maximum acceptable error """ if f(b) < f(a): a, b = b, a n = int(np.log2(np.abs(b - a)) - np.log2(e) + 1) for i in range(n): c = (a + b) / 2 if f(c) == 0: return c elif f(c) > 0: b = c else: a = c return c

Note how the number of iterations is computed in advance from the interval width and the desired tolerance. There is no convergence check inside the loop—bisection is so predictable that you know exactly how many steps you need.

False Position (Regula Falsi)
Like bisection, but smarter about where to cut. Uses linear interpolation instead of the midpoint.

The idea: Instead of always taking the midpoint, draw a straight line between $(a, f(a))$ and $(b, f(b))$ and find where it crosses the x-axis. This “false position” is usually a better estimate of the root than the midpoint, especially when the function is nearly linear near the root.

The interpolation formula:
$$c = \frac{a \cdot f(b) - b \cdot f(a)}{f(b) - f(a)}$$ This is derived by finding the x-intercept of the line through $(a, f(a))$ and $(b, f(b))$.

False position retains the bracketing guarantee of bisection (one endpoint always stays fixed), but converges faster for well-behaved functions. The downside: one endpoint can get “stuck,” leading to slow convergence in certain cases. Variants like the Illinois method fix this.

def false_position(f, a, b, n): """ f: function to find root of a, b: interval bounds n: maximum number of iterations """ if f(b) < f(a): a, b = b, a c = (a + b) / 2 for i in range(n): c = (a * f(b) - b * f(a)) / (f(b) - f(a)) if f(c) == 0: return c elif f(c) > 0: b = c else: a = c return c
Newton’s Method
The workhorse of scientific computing. Quadratic convergence—the number of correct digits roughly doubles each iteration.

The idea: At your current guess $x_n$, approximate the function by its tangent line. Follow the tangent line to where it crosses the x-axis. That crossing is your next guess. Formally:

$$x_{n+1} = x_n - \frac{f(x_n)}{f'(x_n)}$$ Convergence: Quadratic (near the root). If $x_n$ is off by $\epsilon$, then $x_{n+1}$ is off by roughly $\epsilon^2$.
Requires: The derivative $f'(x)$.

Newton’s method is astonishingly fast when it works. From a good starting guess, 5–6 iterations typically gives 15+ digits of accuracy. But it can fail spectacularly: if $f'(x_n)$ is near zero, the tangent line is nearly horizontal and the next guess flies off to infinity. It can also cycle, diverge, or converge to the wrong root.

The implementation includes safeguards: a minimum derivative threshold (min_dfx) to catch near-zero derivatives, and a convergence check based on the step size.

def newton(f, df, xn, n, e, min_dfx): """ f: function whose roots are to be found df: derivative of f xn: initial approximation n: maximum iterations e: convergence tolerance (stop when step < e) min_dfx: minimum |f'(x)| before warning """ fx = f(xn) dfx = df(xn) for i in range(n): if np.abs(dfx) < min_dfx: print("Derivative is too close to 0.") return xn d = fx / dfx xn = xn - d if np.abs(d) < e: return xn fx = f(xn) dfx = df(xn) return xn
Refined Newton’s Method
Newton’s method with step-size control. If the full Newton step makes things worse, take half a step instead.

The idea: Standard Newton’s method can overshoot. If the function value at the proposed new point is larger in magnitude than at the current point, the full step was too aggressive. This refined version checks: did the step actually improve things? If not, it halves the step. This simple modification significantly improves robustness without sacrificing much speed.

def refined_newton(f, df, xn, n, e, min_dfx): """Same parameters as newton(), with step-size control.""" fx = f(xn) fn = fx dfx = df(xn) for i in range(n): if np.abs(dfx) < min_dfx: print("Derivative is too close to 0.") return xn d = fx / dfx fn = f(xn - d) if np.abs(fn) > np.abs(fx): xn = xn - d / 2 # half step fn = f(xn) else: xn = xn - d # full step if np.abs(d) < e: return xn fx = fn dfx = df(xn) return xn

This is a simple form of line search—a technique that becomes essential in optimization and multidimensional root-finding. More sophisticated versions (Armijo rule, Wolfe conditions) are used in gradient descent and quasi-Newton methods.

Comparing Root-Finding Methods

Method Convergence Requires Guaranteed? Best For
Bisection Linear (1 bit/step) Bracketing interval Yes Safety-critical, rough estimates
False Position Superlinear (usually) Bracketing interval Yes Nearly linear functions
Newton Quadratic Derivative, good initial guess No Speed when derivative is available
Refined Newton Quadratic (with safeguard) Derivative, good initial guess No (but more robust) Newton + robustness

Part 2: Solving Linear Systems (Direct Methods)

A linear system is a set of equations of the form $Ax = b$, where $A$ is an $n \times n$ matrix, $b$ is a known vector, and $x$ is the unknown vector. Solving linear systems is arguably the most important problem in applied mathematics. It arises everywhere: finite element analysis, circuit simulation, computer graphics, machine learning, fluid dynamics, economic modeling.

Direct methods solve the system in a finite number of steps (ignoring floating-point roundoff). They contrast with iterative methods, which approach the solution through successive approximation.

Naive Gaussian Elimination
The textbook method. Forward elimination to upper triangular form, then back substitution.

The idea: Use row operations to eliminate entries below the diagonal, transforming $A$ into an upper triangular matrix $U$. Then solve $Ux = b'$ by back substitution (starting from the last equation and working up). This is the method taught in every linear algebra course.

Why “naive”? Because it doesn’t pivot. If a diagonal entry (the “pivot”) is zero or very small, the algorithm divides by it and either fails or produces wildly inaccurate results. Real implementations always use pivoting.

Complexity: $O(n^3)$ for forward elimination, $O(n^2)$ for back substitution.
Total: $\frac{2}{3}n^3 + O(n^2)$ floating-point operations.
def forward_elimination(A, b): """Transform A to upper triangular, modify b accordingly.""" n = np.shape(A)[0] for k in range(n - 1): for i in range(k + 1, n): m = A[i, k] / A[k, k] for j in range(k, n): A[i, j] = A[i, j] - m * A[k, j] b[i] = b[i] - m * b[k] def back_sub(A, b): """Solve Ux = b where U is upper triangular.""" n = np.shape(A)[0] x = np.zeros(n) for i in range(n - 1, -1, -1): s = b[i] for j in range(i + 1, n): s -= x[j] * A[i, j] x[i] = s / A[i, i] return x def naive_gauss(A, b): """Solve Ax = b via naive Gaussian elimination.""" forward_elimination(A, b) return back_sub(A, b)
Gaussian Elimination with Scaled Partial Pivoting
The production version. Handles numerical instability by choosing the best pivot row at each step.

The problem with naive elimination: Small pivots amplify rounding errors catastrophically. If $A[k,k]$ is $10^{-15}$, dividing by it magnifies errors by a factor of $10^{15}$.

Scaled partial pivoting: Before eliminating column k, find the row (among remaining rows) that has the largest relative entry in column k. “Relative” means divided by the maximum entry in that row—this prevents large rows from dominating. Swap that row to the pivot position. Use an index array to track swaps (cheaper than physically swapping rows).

The factored storage trick: Instead of processing $b$ during elimination, store the multipliers in the zeroed-out entries below the diagonal. This lets you factor $A$ once and then solve for any number of right-hand sides $b$ without repeating the $O(n^3)$ elimination.

def gauss(A): """ Factor A in-place with scaled partial pivoting. Returns index array l. Multipliers stored below diagonal. """ n = np.shape(A)[0] l = np.arange(n) s = np.zeros(n) # Scale vector: max |entry| in each row for i in range(n): s[i] = np.abs(A[i, 0]) for j in range(1, n): s[i] = max(s[i], np.abs(A[i, j])) # Forward elimination with pivoting for k in range(n - 1): # Find best pivot row maxr = 0 pivot = k for i in range(k, n): r = np.abs(A[l[i], k] / s[l[i]]) if r > maxr: maxr = r pivot = i l[k], l[pivot] = l[pivot], l[k] # Eliminate and store multipliers for i in range(k + 1, n): m = A[l[i], k] / A[l[k], k] A[l[i], k] = m # store multiplier for j in range(k + 1, n): A[l[i], j] -= m * A[l[k], j] return l
def solve(A, l, b): """ Solve Ax = b using pre-factored A and index array l. Run gauss(A) first, then call this for each new b. """ n = np.shape(A)[0] x = np.zeros(n) # Apply stored multipliers to b for k in range(n - 1): for i in range(k + 1, n): b[l[i]] -= A[l[i], k] * b[l[k]] # Back substitution for i in range(n - 1, -1, -1): x[i] = b[l[i]] for j in range(i + 1, n): x[i] -= A[l[i], j] * x[j] x[i] /= A[l[i], i] return x

This design is important: factoring $A$ costs $O(n^3)$, but each subsequent solve costs only $O(n^2)$. If you have 100 different right-hand sides for the same matrix, you save 99 full eliminations.

Matrix Inverse via Gaussian Elimination
Compute $A^{-1}$ by solving $Ax = e_j$ for each column of the identity matrix.

The idea: The $j$-th column of $A^{-1}$ is the solution to $Ax = e_j$, where $e_j$ is the $j$-th column of the identity matrix. Factor $A$ once with gauss(), then call solve() $n$ times with different identity columns.

A note on practice: In almost all applications, you should solve $Ax = b$ directly rather than computing $A^{-1}$ and then multiplying $A^{-1}b$. Computing the inverse is slower, less accurate, and rarely necessary. But there are situations (e.g., computing covariance matrices, certain control theory problems) where the inverse itself is needed.

def inverse(A): """Compute A inverse. Alters A in memory.""" n = np.shape(A)[0] l = gauss(A) A_inverse = np.zeros((n, n)) for j in range(n): b = np.zeros(n) b[j] = 1 x = solve(A, l, b) for i in range(n): A_inverse[i, j] = x[i] return A_inverse
Tridiagonal Solver (Thomas Algorithm)
Gaussian elimination optimized for banded matrices. $O(n)$ instead of $O(n^3)$.

The idea: Many problems (finite differences, spline interpolation, heat equations) produce matrices where only the diagonal and its immediate neighbors are nonzero. These tridiagonal systems have a special structure that Gaussian elimination can exploit: each row operation only affects one row below, so the entire elimination reduces to a simple forward sweep followed by back substitution.

Structure: The matrix has three diagonals: sub-diagonal a, diagonal d, super-diagonal c.
Complexity: $O(n)$ operations, vs. $O(n^3)$ for general Gaussian elimination.
Memory: Only three vectors of length $n$ needed (not a full $n \times n$ matrix).
def tri(a, d, c, b): """ Solve tridiagonal system. a: sub-diagonal, d: diagonal, c: super-diagonal, b: right-hand side. """ n = np.shape(d)[0] x = np.zeros(n) # Forward elimination for i in range(n - 1): m = a[i] / d[i] d[i + 1] -= m * c[i] b[i + 1] -= m * b[i] # Back substitution x[n - 1] = b[n - 1] / d[n - 1] for i in range(n - 2, -1, -1): x[i] = (b[i] - c[i] * x[i + 1]) / d[i] return x

The Thomas algorithm is one of the most elegant results in numerical linear algebra. A problem that would take millions of operations for a large dense matrix takes only a few thousand for a tridiagonal one.

Multi-RHS Tridiagonal Solver
Factor the tridiagonal system once, then solve for multiple right-hand sides cheaply.

Same idea as the factored Gaussian elimination above, adapted for the tridiagonal case. The forward elimination (which modifies d and produces multipliers) is done once. Then for each new right-hand side b, only the O(n) substitution phase runs.

def multi_b_forward(a, d, c): """Factor tridiagonal system. Returns multiplier vector.""" n = np.shape(d)[0] factors = np.zeros(n) for i in range(n - 1): m = a[i] / d[i] d[i + 1] -= m * c[i] factors[i] = m return factors def multi_b_solve(a, d, c, b, factors): """Solve with pre-factored tridiagonal and new b.""" n = np.shape(d)[0] x = np.zeros(n) # Apply stored multipliers to b for i in range(n - 1): b[i + 1] -= factors[i] * b[i] # Back substitution x[n - 1] = b[n - 1] / d[n - 1] for i in range(n - 2, -1, -1): x[i] = (b[i] - c[i] * x[i + 1]) / d[i] return x
Tridiagonal Solver with Partial Pivoting
Adds pivoting to the tridiagonal solver for numerical stability, but only swaps rows when it won’t destroy the banded structure.

Standard pivoting in a tridiagonal system is tricky: swapping rows can create fill-in entries that destroy the tridiagonal structure. This implementation is conservative—it only pivots when the swap won’t introduce new nonzeros (i.e., when the entry above the super-diagonal in the pivot row is already zero, or when we’re at the last row). This gives better numerical stability than no pivoting, while preserving the $O(n)$ complexity.

def tri_pivot(a, d, c, b): """Tridiagonal solve with selective partial pivoting.""" n = np.shape(d)[0] x = np.zeros(n) s = np.zeros(n) # Build scale vector s[0] = max(np.abs(d[0]), np.abs(c[0])) for i in range(1, n - 1): s[i] = max(np.abs(a[i-1]), np.abs(d[i]), np.abs(c[i])) s[n-1] = max(np.abs(a[n-2]), np.abs(d[n-1])) for i in range(n - 1): # Pivot only if it won't create fill-in if a[i]/s[i+1] > d[i]/s[i]: if (i < n-2 and c[i+1] == 0) or i == n-2: # Swap rows i and i+1 a[i], d[i] = d[i], a[i] d[i+1], c[i] = c[i], d[i+1] b[i], b[i+1] = b[i+1], b[i] m = a[i] / d[i] d[i+1] -= m * c[i] b[i+1] -= m * b[i] x[n-1] = b[n-1] / d[n-1] for i in range(n-2, -1, -1): x[i] = (b[i] - c[i] * x[i+1]) / d[i] return x
Bi-Diagonal Solver
Solves a specialized banded system where the upper half has sub-diagonal entries and the lower half has super-diagonal entries, meeting at a central row.

This handles a specific structure that arises in certain finite-difference discretizations and boundary value problems. The matrix has entries below the diagonal in the top half, entries above the diagonal in the bottom half, and a full three-entry row in the middle. The key insight is that the top half can be solved forward (top to bottom), the bottom half can be solved backward (bottom to top), and then the middle row uses both results.

def Bi_diagonal(n, a, d, b): """ n: number of rows (must be odd) a: off-diagonal entries (top-to-bottom, left-to-right order) d: diagonal entries b: right-hand side """ m = n // 2 x = np.zeros(n) # Top half: forward sweep x[0] = b[0] / d[0] for i in range(1, m): x[i] = (b[i] - a[i-1] * x[i-1]) / d[i] # Bottom half: backward sweep x[n-1] = b[n-1] / d[n-1] for i in range(n-2, m, -1): x[i] = (b[i] - a[i] * x[i+1]) / d[i] # Middle entry x[m] = (b[m] - a[m-1]*x[m-1] - a[m]*x[m+1]) / d[m] return x

Part 3: Iterative Linear Solvers

Direct methods solve $Ax = b$ exactly (up to roundoff) in a finite number of steps. Iterative methods start with an initial guess $x_0$ and produce a sequence $x_1, x_2, \ldots$ that (hopefully) converges to the true solution. Why bother?

  • Sparsity: For large sparse systems (millions of unknowns, but each equation involves only a few), direct methods fill in zeros and waste memory. Iterative methods only need the nonzero entries.
  • Approximate solutions: Sometimes you don’t need 15 digits. A few iterations might give 3–4 digits of accuracy, which is enough.
  • Preconditioning: Iterative methods can be dramatically accelerated by preconditioning (transforming the system so it’s easier to solve).

Convergence of all these methods depends on the matrix. They are guaranteed to converge when A is strictly diagonally dominant (each diagonal entry is larger in magnitude than the sum of all other entries in its row).

Jacobi Iteration
The simplest iterative method. Update each component using only values from the previous iteration.

The idea: Rearrange the $i$-th equation to solve for $x_i$:

$$x_i^{(\text{new})} = \frac{b_i - \sum_{j \neq i} A_{ij} \, x_j^{(\text{old})}}{A_{ii}}$$ Each new value uses only old values. This means all components can be updated independently—Jacobi is naturally parallelizable.
def jacobi(A, b, x0, tolerance, max_iter): """ A: coefficient matrix b: right-hand side x0: initial guess tolerance: stop when ||x_new - x_old|| < tolerance max_iter: maximum iterations """ n = np.shape(A)[0] x = x0.copy() for k in range(max_iter): x0 = x.copy() for i in range(n): s = b[i] for j in range(n): if j != i: s -= A[i, j] * x0[j] # uses OLD values x[i] = s / A[i, i] if np.linalg.norm(x - x0) < tolerance: return x return False
Gauss-Seidel Iteration
Like Jacobi, but uses each new value immediately. Typically converges about twice as fast.

The key difference from Jacobi: When computing $x_i^{(\text{new})}$, use the already-updated values $x_1^{(\text{new})}, \ldots, x_{i-1}^{(\text{new})}$ for the earlier components. This “immediate use” of new information typically accelerates convergence by a factor of about 2.

The tradeoff: Gauss-Seidel is inherently sequential (each update depends on the previous one), so it cannot be parallelized as easily as Jacobi.

def gauss_seidel(A, b, x0, tolerance, max_iter): n = np.shape(A)[0] x = x0.copy() for k in range(max_iter): x0 = x.copy() for i in range(n): s = b[i] for j in range(n): if j != i: s -= A[i, j] * x[j] # uses CURRENT values (new when available) x[i] = s / A[i, i] if np.linalg.norm(x - x0) < tolerance: return x return False

Look carefully at the difference: Jacobi uses x0[j] (old values), Gauss-Seidel uses x[j] (current values, which includes already-updated entries). One character difference, significant performance impact.

Successive Over-Relaxation (SOR)
Gauss-Seidel with a tunable acceleration parameter. Can converge dramatically faster with the right $\omega$.

The idea: Gauss-Seidel computes a “natural” update. SOR takes a weighted average between the old value and the Gauss-Seidel update, controlled by a relaxation parameter $\omega$:

$$x_i^{(\text{new})} = \omega \cdot x_i^{(\text{GS})} + (1 - \omega) \cdot x_i^{(\text{old})}$$ $\omega = 1$: Gauss-Seidel (no relaxation)
$1 < \omega < 2$: Over-relaxation (accelerates convergence for most problems)
$0 < \omega < 1$: Under-relaxation (slower but more stable)
$\omega \geq 2$: Diverges

Finding the optimal $\omega$ is problem-dependent. For some structured matrices (e.g., from discretizing Laplace’s equation), the optimal $\omega$ can be computed analytically and reduces the iteration count from $O(n)$ to $O(\sqrt{n})$. For a $1000 \times 1000$ system, that’s the difference between 1000 and 32 iterations.

def SOR(A, b, x0, tolerance, max_iter, w): """ Same as Gauss-Seidel but with relaxation factor w (omega). w = 1 is Gauss-Seidel. Typically 1 < w < 2 for over-relaxation. """ n = np.shape(A)[0] x = x0.copy() for k in range(max_iter): x0 = x.copy() for i in range(n): s = b[i] for j in range(n): if j != i: s -= A[i, j] * x[j] x[i] = w * s / A[i, i] + (1 - w) * x0[i] if np.linalg.norm(x - x0) < tolerance: return x return False

Comparing Iterative Methods

Method Speed Parallelizable? Parameters Best For
Jacobi Slowest Yes (naturally) None Parallel computing, GPU
Gauss-Seidel ~2x Jacobi No (sequential) None General-purpose iterative
SOR Up to $\sqrt{n}\times$ faster No $\omega$ (requires tuning) Structured problems (PDEs)

Part 4: Ordinary Differential Equation Solvers

An initial value problem (IVP) asks: given $x'(t) = f(x, t)$ and $x(a) = x_0$, find $x(t)$ for $t \in [a, b]$. Most differential equations cannot be solved analytically. Numerical ODE solvers approximate $x(t)$ at discrete time steps.

The methods below progress from the simplest (Euler) to the most widely used (Runge-Kutta 4). They all share the same structure: starting from a known value, take a “step” of size $h$ to estimate the function at the next time point. The difference is in how cleverly they estimate the step.

Euler’s Method
The simplest ODE solver. Follow the tangent line. First-order accurate.

The idea: At each step, approximate $x(t + h)$ by following the tangent line at $x(t)$:

$$x_{n+1} = x_n + h \cdot f(x_n, t_n)$$ Local error: $O(h^2)$ per step.
Global error: $O(h)$ — first-order method.
To halve the error, you must halve the step size (doubling the work).

Euler’s method is rarely used in practice because it’s inaccurate. But it’s the conceptual foundation for all higher-order methods. Understanding Euler is understanding the basic idea of numerical integration of ODEs.

def eulers_method(f, a, b, init, steps): """ f: derivative function f(x, t) = x'(t) a, b: time interval [a, b] init: initial value x(a) steps: number of time steps """ h = (b - a) / steps values = np.zeros(steps + 1) values[0] = init t = a for i in range(1, steps + 1): values[i] = values[i-1] + h * f(values[i-1], t) t = a + i * h return values
Taylor Method (Arbitrary Order)
Uses higher derivatives for higher accuracy. Order-$p$ method uses terms up to $h^p$.

The idea: Euler’s method uses the first term of the Taylor expansion of $x(t + h)$. The Taylor method uses more terms:

$$x_{n+1} = x_n + h \cdot x'(t_n) + \frac{h^2}{2!} \cdot x''(t_n) + \cdots + \frac{h^p}{p!} \cdot x^{(p)}(t_n)$$ Global error: $O(h^p)$ — $p$-th order method.
Catch: You need formulas for all derivatives up to order $p$. These must be derived analytically for each specific ODE.

The Taylor method is powerful but impractical for most real-world use because computing higher-order derivatives is tedious and problem-specific. The Runge-Kutta methods achieve the same accuracy without needing any derivatives beyond the first.

def taylor_method(order, f, a, b, init, steps): """ order: number of Taylor terms to use f: function where f(n, x, t) returns the nth derivative of x (must be written specifically for each ODE) a, b: time interval init: initial value x(a) steps: number of time steps """ h = (b - a) / steps values = np.zeros(steps + 1) values[0] = init t = a for i in range(1, steps + 1): values[i] = values[i-1] for n in range(1, order + 1): values[i] += (1/np.math.factorial(n)) * (h**n) * f(n, values[i-1], t) t = a + i * h return values
Runge-Kutta 4th Order (RK4)
The standard method for ODE solving. Fourth-order accuracy using only the first derivative. The workhorse of scientific computing.

The idea: Achieve 4th-order accuracy (matching the Taylor method of order 4) without computing any higher derivatives. Instead, evaluate $f$ at four carefully chosen points within each step and take a weighted average:

$$\begin{aligned} k_1 &= h \cdot f(x_n,\, t_n) \\ k_2 &= h \cdot f\!\left(x_n + \tfrac{k_1}{2},\, t_n + \tfrac{h}{2}\right) \\ k_3 &= h \cdot f\!\left(x_n + \tfrac{k_2}{2},\, t_n + \tfrac{h}{2}\right) \\ k_4 &= h \cdot f(x_n + k_3,\, t_n + h) \end{aligned}$$ $$x_{n+1} = x_n + \frac{k_1 + 2k_2 + 2k_3 + k_4}{6}$$ Local error: $O(h^5)$
Global error: $O(h^4)$ — fourth-order method.
Cost: 4 function evaluations per step.

Why these weights? The coefficients $(1, 2, 2, 1)/6$ come from Simpson’s rule for numerical integration. $k_1$ evaluates the slope at the left endpoint, $k_2$ and $k_3$ evaluate at the midpoint (using progressively better estimates), and $k_4$ evaluates at the right endpoint. The weighting gives the midpoint estimates double importance, which is characteristic of Simpson’s rule.

In practice: RK4 is the default choice for non-stiff ODEs. It’s simple, accurate, and reliable. For stiff equations (where solutions change on vastly different timescales), implicit methods like backward Euler or BDF methods are needed instead.

def runge_kutta_4(f, a, b, alpha, steps): """ f: derivative function f(x, t) = x'(t) a, b: time interval [a, b] alpha: initial value x(a) = alpha steps: number of time steps """ values = np.zeros(steps + 1) values[0] = alpha h = (b - a) / steps t = a for i in range(steps): k1 = h * f(values[i], t) k2 = h * f(values[i] + .5*k1, t + .5*h) k3 = h * f(values[i] + .5*k2, t + .5*h) k4 = h * f(values[i] + k3, t + h) values[i+1] = values[i] + (k1 + 2*k2 + 2*k3 + k4) / 6 t = a + (i + 1) * h return values

Comparing ODE Solvers

Method Order f evaluations/step Requires Best For
Euler 1st 1 f only Teaching, quick estimates
Taylor (order p) p-th 1 (but p derivatives) Derivatives up to order p When derivatives are cheap
Runge-Kutta 4 4th 4 f only General-purpose (standard choice)

To put the accuracy difference in perspective: for a step size of $h = 0.01$, Euler’s error is proportional to $0.01$, while RK4’s error is proportional to $0.01^4 = 10^{-8}$. That’s six orders of magnitude better for only $4\times$ the work per step.

Closing Thoughts

These methods were developed over three centuries. Bisection dates to antiquity. Newton’s method appeared in the 1660s (though Newton’s version looked different from what we use). Gaussian elimination was systematized in the early 1800s. Runge-Kutta methods were developed around 1900. SOR was formalized in the 1950s for early electronic computers.

What’s remarkable is how much stays the same. The core ideas—bracketing, linearization, elimination, iteration, weighted sampling—are as relevant today as they were decades or centuries ago. Modern scientific computing uses more sophisticated versions (adaptive step-size control, Krylov subspace methods, multigrid, spectral methods), but they all build on these foundations.

A few principles that recur across all numerical methods:

  • Speed vs. robustness: Faster methods (Newton, RK4) are less robust than slower ones (bisection, Euler). Know when you need reliability and when you need speed.
  • Exploit structure: A tridiagonal system can be solved in $O(n)$ instead of $O(n^3)$. Always look for structure in your problem—it can mean the difference between seconds and hours.
  • Factor once, solve many: If the same matrix appears with different right-hand sides, factor it once and reuse the factorization. This applies to Gaussian elimination, tridiagonal systems, and many other contexts.
  • Higher order is not always better: RK4 is better than Euler for smooth problems, but for stiff or discontinuous problems, implicit low-order methods can be more appropriate.
  • Convergence is everything: An algorithm that doesn’t converge is useless no matter how elegant it is. Always verify convergence.

On implementation: The Python code in this article is written for clarity, not performance. For production use, these methods are available in heavily optimized form through NumPy (numpy.linalg.solve), SciPy (scipy.integrate.solve_ivp, scipy.optimize.brentq), and LAPACK (the Fortran library underneath most numerical software). But understanding the algorithms is essential for knowing which tool to reach for and why it might fail.