The Algorithms - Python Python TheAlgorithms/Python

Linear Algebra Algorithms in TheAlgorithms/Python

Seven direct solvers and iterative methods, from Gaussian elimination to conjugate gradient, each exposing a different trade-off in numerical linear algebra.

7 stops ~14 min Verified 2026-05-05
What you will learn
  • How Gaussian elimination builds an augmented matrix and reduces it to upper triangular form by subtracting scaled rows
  • How LU decomposition factors a square matrix into lower and upper triangular parts in one nested loop
  • How Jacobi iteration vectorizes the per-variable update by masking out the diagonal and computing all new values in one NumPy expression
  • Why matrix inversion wraps np.linalg.inv but catches LinAlgError and raises a more descriptive ValueError
  • How conjugate gradient tracks a residual vector and search direction to converge to the solution of a symmetric positive definite system
  • How power iteration normalizes the result vector after each matrix-vector product to converge to the dominant eigenvector
  • How Gaussian elimination applied to a matrix, combined with row-swap fallback, counts linearly independent rows to determine rank
Prerequisites
  • Familiarity with matrix multiplication and the concept of a system of linear equations
  • Basic NumPy: arrays, np.dot, np.zeros, np.shape
  • Understanding of what an eigenvalue and eigenvector are conceptually
1 / 7

Gaussian elimination: reduce to upper triangular form, then back-substitute

linear_algebra/gaussian_elimination.py:46

Gaussian elimination reduces an n-by-n system to upper triangular form by subtracting a scaled pivot row from every row below it.

Gaussian elimination is the foundation of direct linear system solvers. The function builds an augmented matrix by concatenating the coefficient matrix and the right-hand-side vector side by side, then uses two nested loops to drive it to upper triangular form. The outer loop advances the pivot row; the inner loop visits every row below the pivot. For each lower row, it computes a scale factor equal to the element in the pivot column divided by the pivot element, then subtracts that multiple of the pivot row. This zeroes out the pivot column below the diagonal without changing the system's solution. After the elimination phase, retroactive_resolution (lines 11-43) performs back-substitution: starting from the last row and working upward, it solves each variable from the already-solved variables below it. The doctest confirms the three-variable system produces [2.3, -1.7, 5.55].

Key takeaway

Gaussian elimination zeroes entries below each pivot by subtracting a scaled pivot row, leaving an upper triangular system that back-substitution can solve directly.

def gaussian_elimination(
    coefficients: NDArray[float64], vector: NDArray[float64]
) -> NDArray[float64]:
    """
    This function performs Gaussian elimination method

    Examples:
        1.
            * 1x1 - 4x2 - 2x3 = -2
            * 5x1 + 2x2 - 2x3 = -3
            * 1x1 - 1x2 + 0x3 = 4
        2.
            * 1x1 + 2x2 = 5
            * 5x1 + 2x2 = 5

    >>> gaussian_elimination([[1, -4, -2], [5, 2, -2], [1, -1, 0]], [[-2], [-3], [4]])
    array([[ 2.3 ],
           [-1.7 ],
           [ 5.55]])
    >>> gaussian_elimination([[1, 2], [5, 2]], [[5], [5]])
    array([[0. ],
           [2.5]])
    """
    # coefficients must to be a square matrix so we need to check first
    rows, columns = np.shape(coefficients)
    if rows != columns:
        return np.array((), dtype=float)

    # augmented matrix
    augmented_mat: NDArray[float64] = np.concatenate((coefficients, vector), axis=1)
    augmented_mat = augmented_mat.astype("float64")

    # scale the matrix leaving it triangular
    for row in range(rows - 1):
        pivot = augmented_mat[row, row]
        for col in range(row + 1, columns):
            factor = augmented_mat[col, row] / pivot
            augmented_mat[col, :] -= factor * augmented_mat[row, :]

    x = retroactive_resolution(
        augmented_mat[:, 0:columns], augmented_mat[:, columns : columns + 1]
    )

    return x
2 / 7

LU decomposition: factor once, solve many times

linear_algebra/lu_decomposition.py:92

LU decomposition factors a square matrix into lower and upper triangular parts; any right-hand-side can then be solved by two triangular substitutions.

LU decomposition is preferred over Gaussian elimination when the same coefficient matrix is solved against multiple right-hand-side vectors, because the factorization is paid once. The implementation follows Doolittle's algorithm: L has ones on the diagonal and zeros above it, while U has the elimination multipliers on and above the diagonal. The outer loop advances i from 0 to n-1. For each i, the inner j loop below i fills L entries by subtracting the dot product of previously computed L and U slices from the original entry and dividing by the U diagonal. After setting the L diagonal to 1, a second j loop fills the corresponding U row. The zero-pivot check on line 101 catches cases where no decomposition exists, which occurs when a leading principal minor is zero. The doctest for a 3x3 matrix confirms the L and U factors.

Key takeaway

LU decomposition fills L below the diagonal and U on and above it simultaneously, computing each entry from dot products of already-complete L rows and U columns.

    lower = np.zeros((rows, columns))
    upper = np.zeros((rows, columns))

    # in 'total', the necessary data is extracted through slices
    # and the sum of the products is obtained.

    for i in range(columns):
        for j in range(i):
            total = np.sum(lower[i, :i] * upper[:i, j])
            if upper[j][j] == 0:
                raise ArithmeticError("No LU decomposition exists")
            lower[i][j] = (table[i][j] - total) / upper[j][j]
        lower[i][i] = 1
        for j in range(i, columns):
            total = np.sum(lower[i, :i] * upper[:i, j])
            upper[i][j] = table[i][j] - total
    return lower, upper
3 / 7

Jacobi iteration: vectorize the update with NumPy masking

linear_algebra/jacobi_iteration_method.py:158

Jacobi iteration isolates the diagonal, masks it out, and computes all variable updates simultaneously as a single matrix-vector product scaled by the diagonal.

Jacobi iteration solves a strictly diagonally dominant system by repeatedly updating each variable using only the other variables' previous values. The naive implementation has a Python inner loop over variables, but the vectorized version (lines 158-165) eliminates it entirely. Before the iteration loop, the code precomputes three structures: denominator holds the diagonal entries, no_diagonals is the coefficient matrix with zeros on the diagonal (extracted via a boolean mask), and ind is an index array that maps row positions to off-diagonal column indices. Inside each iteration, np.take(init_val, ind) gathers current variable values for broadcasting, and a single NumPy sum expression computes all weighted off-diagonal contributions at once. Dividing by the diagonal gives the next approximation. The function requires a strictly diagonally dominant matrix, verified by strictly_diagonally_dominant before the loop.

Key takeaway

The vectorized Jacobi loop uses a precomputed off-diagonal mask and np.take to compute all variable updates simultaneously without a Python inner loop.

    # Iterates the whole matrix for given number of times
    for _ in range(iterations):
        arr = np.take(init_val, ind)
        sum_product_rows = np.sum((-1) * no_diagonals * arr, axis=1)
        new_val = (sum_product_rows + val_last) / denominator
        init_val = new_val

    return new_val.tolist()
4 / 7

Matrix inversion: a thin wrapper that translates NumPy errors into descriptive ones

linear_algebra/matrix_inversion.py:4

Wrapping np.linalg.inv in a try/except converts the numeric LinAlgError into a domain-level ValueError with a clearer message for callers.

Matrix inversion has a narrow valid domain: the matrix must be square and have a non-zero determinant. NumPy's linalg.inv raises LinAlgError when those conditions fail, but that name is specific to NumPy's numeric layer. This file wraps the call to catch LinAlgError and raise ValueError with the message "Matrix is not invertible". Callers who do not import NumPy can handle the error by type alone, and the message is a domain-level description rather than a NumPy internal signal. The function also converts input and output between plain Python lists and NumPy arrays, keeping the interface free of NumPy types. This wrapper pattern is common in scientific Python where a library-level function must present a stable interface independent of the numeric backend. The doctest documents both the happy path and the singular matrix case.

Key takeaway

Catching np.linalg.LinAlgError and raising ValueError keeps the public interface free of NumPy-specific exceptions and provides a clearer error message.

def invert_matrix(matrix: list[list[float]]) -> list[list[float]]:
    """
    Returns the inverse of a square matrix using NumPy.

    Parameters:
    matrix (list[list[float]]): A square matrix.

    Returns:
    list[list[float]]: Inverted matrix if invertible, else raises error.

    >>> invert_matrix([[4.0, 7.0], [2.0, 6.0]])
    [[0.6000000000000001, -0.7000000000000001], [-0.2, 0.4]]
    >>> invert_matrix([[1.0, 2.0], [0.0, 0.0]])
    Traceback (most recent call last):
        ...
    ValueError: Matrix is not invertible
    """
    np_matrix = np.array(matrix)

    try:
        inv_matrix = np.linalg.inv(np_matrix)
    except np.linalg.LinAlgError:
        raise ValueError("Matrix is not invertible")

    return inv_matrix.tolist()
5 / 7

Conjugate gradient: iterate residuals and search directions to solve SPD systems

linear_algebra/src/conjugate_gradient.py:119

Conjugate gradient solves symmetric positive definite systems by constructing A-conjugate search directions that converge in at most n iterations for exact arithmetic.

The conjugate gradient method exploits the structure of symmetric positive definite (SPD) systems to converge in at most n iterations without storing a full factorization. The four update equations in the loop implement the core algorithm. First, alpha is the step size along the current search direction, computed as the squared residual norm divided by the directional curvature. Second, the solution guess advances by alpha times p0. Third, the residual shrinks by the same step. Fourth, beta is the ratio of consecutive squared residual norms, weighting the previous search direction when building the next one. The matrix-vector product w = A @ p0 is computed once per iteration and reused for both alpha and the residual update, halving the matrix multiplications. The outer validation asserts the matrix is SPD before the loop, since CG is only valid for SPD systems.

Key takeaway

Each CG iteration computes one matrix-vector product and uses it to update the solution, residual, and search direction via three scalar ratios derived from dot products.

    while error > tol:
        # Save this value so we only calculate the matrix-vector product once.
        w = np.dot(spd_matrix, p0)

        # The main algorithm.

        # Update search direction magnitude.
        alpha = np.dot(r0.T, r0) / np.dot(p0.T, w)
        # Update solution guess.
        x = x0 + alpha * p0
        # Calculate new residual.
        r = r0 - alpha * w
        # Calculate new Krylov subspace scale.
        beta = np.dot(r.T, r) / np.dot(r0.T, r0)
        # Calculate new A conjuage search direction.
        p = r + beta * p0
6 / 7

Power iteration: repeatedly multiply and normalize to isolate the dominant eigenvector

linear_algebra/src/power_iteration.py:59

Repeated matrix-vector multiplication inflates the dominant eigencomponent faster than all others; normalizing after each step prevents overflow and reveals the eigenvector.

Power iteration finds the largest eigenvalue and its corresponding eigenvector without computing the full eigendecomposition. The algorithm works because multiplying a matrix by a vector repeatedly amplifies the component along the dominant eigenvector faster than any other component. Dividing by the vector norm after each multiplication prevents the values from growing without bound and keeps the iteration numerically stable. The Rayleigh quotient v^T A v provides the eigenvalue estimate at each step: because v is normalized, the Rayleigh quotient equals the eigenvalue when v is the exact eigenvector. Convergence is checked by comparing the change in the Rayleigh quotient between iterations relative to its current magnitude. The comment notes the Rayleigh quotient is faster than usual because the normalization step is already done. The doctest for a 3x3 matrix returns eigenvalue 79.66 and the matching eigenvector.

Key takeaway

Power iteration multiplies, normalizes to prevent overflow, and computes the Rayleigh quotient to track convergence, finding the dominant eigenvalue without full decomposition.

    while not convergence:
        # Multiple matrix by the vector.
        w = np.dot(input_matrix, vector)
        # Normalize the resulting output vector.
        vector = w / np.linalg.norm(w)
        # Find rayleigh quotient
        # (faster than usual b/c we know vector is normalized already)
        vector_h = vector.conj().T if is_complex else vector.T
        lambda_ = np.dot(vector_h, np.dot(input_matrix, vector))

        # Check convergence.
        error = np.abs(lambda_ - lambda_previous) / lambda_
        iterations += 1

        if error <= error_tol or iterations >= max_iterations:
            convergence = True

        lambda_previous = lambda_
7 / 7

Rank computation: count independent rows by tracking pivot availability

linear_algebra/src/rank_of_matrix.py:59

Rank equals the number of rows that survive Gaussian elimination with a non-zero pivot; rows without a pivot indicate linear dependence.

The rank of a matrix is the number of linearly independent rows, and Gaussian elimination reveals it by counting pivot positions that survive the reduction. This implementation starts with rank = min(rows, columns), the maximum possible value, and decrements whenever a pivot column cannot be filled. When the diagonal element is non-zero, the pivot is usable and the code eliminates below it in the standard way. When it is zero, the code searches below for a non-zero element to swap in. If none exists, the column is all-zero at this step, meaning one row is linearly dependent; rank decrements and the algorithm moves on. The doctest confirms rank 2 for a matrix with dependent rows, rank 4 for a full-rank 4x4, and rank 0 for an empty row list.

Key takeaway

Rank counts pivot-capable rows: the algorithm eliminates below each valid pivot and decrements the rank counter whenever a column has no usable non-zero pivot element.

    rows = len(matrix)
    columns = len(matrix[0])
    rank = min(rows, columns)

    for row in range(rank):
        # Check if diagonal element is not zero
        if matrix[row][row] != 0:
            # Eliminate all the elements below the diagonal
            for col in range(row + 1, rows):
                multiplier = matrix[col][row] / matrix[row][row]
                for i in range(row, columns):
                    matrix[col][i] -= multiplier * matrix[row][i]
        else:
            # Find a non-zero diagonal element to swap rows
            reduce = True
            for i in range(row + 1, rows):
                if matrix[i][row] != 0:
                    matrix[row], matrix[i] = matrix[i], matrix[row]
                    reduce = False
                    break
            if reduce:
                rank -= 1
                for i in range(rows):
                    matrix[i][row] = matrix[i][rank]

            # Reduce the row pointer by one to stay on the same row
            row -= 1

    return rank
Your codebase next

Create code tours for your project

Intraview lets AI create interactive walkthroughs of any codebase. Install the free VS Code extension and generate your first tour in minutes.

Install Intraview Free