// Copyright (C) 2026 Kiyotsugu Arai // SPDX-License-Identifier: LGPL-3.0-or-later // balance.hpp // // Matrix balancing / equilibration preprocessing // // This file provides 2 kinds of diagonal scaling: // // (1) balance_matrix — balancing by a similarity transform (= equivalent to LAPACK dgebal) // A → P^T D^{-1} A D P (D = diag(2^k), P = permutation). Preserves eigenvalues and, by // equalizing the off-diagonal norms of each row and column, improves the numerical stability of Hessenberg / Schur / QR iterations. // Used as preprocessing for eigenvalue computations such as eigen() / power_method / Riccati Schur. // // Steps: // Step A (permutation): push isolated eigenvalues (= rows/columns that reference only themselves) // to the ends, extracting the nontrivial block [ilo, ihi]. // Step B (scaling) : equalize the row norms and column norms over the // [ilo, ihi] interval with the iterative power-of-2 scaling of Parlett-Reinsch (1969). // Powers of 2 are exact in floating-point arithmetic, with zero error. // // Inverse transforms (= equivalent to dgebak): // unbalance_eigenvectors(V, info) — return columns to A's eigenvector space. // unbalance_vector(v, info) — inverse transform of a single vector. // rebalance_invariant_subspace(Z, info) — for invariant subspaces (LQR Laub form). // // (2) equilibrate_matrix — independent left/right scaling for Ax = b (= equivalent to LAPACK dgeequ) // A → D_l A D_r, b → D_l b. The solution is recovered by x = D_r y. Effective as preprocessing // for iterative solvers (CG / GMRES / BiCGSTAB) (improves diagonal dominance and the convergence rate). // // References: // Parlett, B. N. & Reinsch, C. (1969) "Balancing a matrix for calculation of // eigenvalues and eigenvectors", Numer. Math. 13, 293-304. // LAPACK Users' Guide 3rd ed. §4.10 (balancing) / §4.11 (equilibration). // Anderson et al. 1999, LAPACK source dgebal.f / dgebak.f / dgeequ.f / dlaqge.f. #ifndef SANGI_BALANCE_HPP #define SANGI_BALANCE_HPP #include #include #include #include #include #include #include #include #include namespace sangi { namespace algorithms { //===================================================================== // BalanceInfo — output of balance_matrix (used by dgebak) //===================================================================== /** * @brief Output information of balance_matrix * * Both the permutation and the scaling are encoded into a single array perm using the same sign * convention as LAPACK (= dgebal compatible): * - i < ilo : Step A left-side push-out permutation partner = perm[i] (size_t) * - ihi < i : Step A right-side push-out permutation partner = perm[i] * - ilo <= i <= ihi : Step B scaling factor D(i,i) (perm[i] is unused, set to 0) * * scale[i] holds D(i, i) = scale[i] over the [ilo, ihi] interval. Outside it is 1. * Both are powers of 2 (exact in floating-point arithmetic, with zero error). */ template struct BalanceInfo { std::size_t n = 0; ///< size of the original matrix std::size_t ilo = 0; ///< start of the nontrivial block (0-based) std::size_t ihi = 0; ///< end of the nontrivial block (0-based, inclusive) std::vector perm; ///< Step A permutation partners (size n, meaningful only outside) std::vector scale; ///< Step B diagonal scaling (size n, nontrivial only over [ilo,ihi]) /// Build a no-op BalanceInfo corresponding to the identity matrix for all elements. static BalanceInfo identity(std::size_t n) { BalanceInfo info; info.n = n; info.ilo = 0; info.ihi = (n == 0) ? 0 : n - 1; info.perm.assign(n, 0); info.scale.assign(n, T(1)); return info; } }; //===================================================================== // balance_matrix — A → P^T D^{-1} A D P (similarity transform) //===================================================================== /** * @brief Balancing of a general n×n matrix (in-place, equivalent to LAPACK dgebal) * * @param A in/out square matrix. Rewritten to P^T D^{-1} A D P. * @return BalanceInfo (Step A permutation + Step B scaling) * * @note Eigenvalues are preserved because this is a similarity transform. * @note Step A (permutation), as in LAPACK, pushes to the ends the columns/rows for which * "row i has no off-diagonal elements" or "column i has no off-diagonal elements". * This excludes diagonally dominant isolated eigenvalues from the QR iteration, improving the operation count and numerical stability. * @note Step B (scaling) iterates the Parlett-Reinsch (1969) procedure with threshold 0.95 until no further improvement is seen. */ template BalanceInfo balance_matrix(BaseMatrix& A) { const std::size_t n = A.rows(); if (A.cols() != n) { throw DimensionError("balance_matrix: A must be square"); } BalanceInfo info; info.n = n; info.perm.assign(n, std::size_t{0}); info.scale.assign(n, T(1)); if (n == 0) { info.ilo = 0; info.ihi = 0; return info; } if (n == 1) { info.ilo = 0; info.ihi = 0; return info; } std::size_t ilo = 0; std::size_t ihi = n - 1; //--------------------------------------------------------------- // Step A: push isolated eigenvalues outside [ilo, ihi] via permutation // // Right-side scan: if "all off-diagonal elements are 0 in column j (ilo<=j<=ihi)" // → swap column j to the ihi position, ihi-- // Left-side scan: if "all off-diagonal elements are 0 in row i (ilo<=i<=ihi)" // → swap row i to the ilo position, ilo++ // If either is found, re-scan (= the same deflation loop as LAPACK dgebal) //--------------------------------------------------------------- // Helper: swap row i with row j, and column i with column j auto swap_rows_cols = [&](std::size_t i, std::size_t j) { if (i == j) return; for (std::size_t k = 0; k < n; ++k) { std::swap(A(i, k), A(j, k)); } for (std::size_t k = 0; k < n; ++k) { std::swap(A(k, i), A(k, j)); } }; // Right-side scan: does the column have no off-diagonal elements? auto col_isolated = [&](std::size_t col) { for (std::size_t r = ilo; r <= ihi; ++r) { if (r != col && A(r, col) != T(0)) return false; } return true; }; // Left-side scan: does the row have no off-diagonal elements? auto row_isolated = [&](std::size_t row) { for (std::size_t c = ilo; c <= ihi; ++c) { if (c != row && A(row, c) != T(0)) return false; } return true; }; // Right-side deflation: push isolated columns toward the ihi side bool found = true; while (found && ihi > ilo) { found = false; // Scan from the back (decrement ihi as soon as one is found) for (std::size_t j = ihi + 1; j-- > ilo; ) { if (col_isolated(j)) { // Swap column j with the ihi position, and record the permutation partner in perm swap_rows_cols(j, ihi); info.perm[ihi] = j; // "the original position of ihi was j" if (ihi == 0) break; --ihi; found = true; break; } if (j == ilo) break; // loop terminator } } // Left-side deflation: push isolated rows toward the ilo side found = true; while (found && ilo < ihi) { found = false; for (std::size_t i = ilo; i <= ihi; ++i) { if (row_isolated(i)) { swap_rows_cols(i, ilo); info.perm[ilo] = i; // "the original position of ilo was i" ++ilo; found = true; break; } } } info.ilo = ilo; info.ihi = ihi; //--------------------------------------------------------------- // Step B: Parlett-Reinsch scaling // // For each row i over the [ilo, ihi] interval // c = Σ_{j != i, j in [ilo,ihi]} |A(j, i)| (off-diagonal norm of column i) // r = Σ_{j != i, j in [ilo,ihi]} |A(i, j)| (off-diagonal norm of row i) // If c and r are strongly asymmetric, find a scaling factor f = 2^k and // multiply row i of A by f^{-1} and column i by f (= the similarity transform D^{-1} A D) // Iterate with threshold 0.95 until improvement stops (= converges in O(n)). //--------------------------------------------------------------- if (ihi > ilo) { const T B = T(2); const T BSQ = B * B; bool changed = true; std::size_t max_iter = (ihi - ilo + 1) * 4 + 32; while (changed && max_iter-- > 0) { changed = false; for (std::size_t i = ilo; i <= ihi; ++i) { T c = T(0), r = T(0); for (std::size_t j = ilo; j <= ihi; ++j) { if (j == i) continue; c += std::abs(A(j, i)); r += std::abs(A(i, j)); } if (c == T(0) || r == T(0)) continue; const T s = c + r; T f = T(1); T g = r / B; while (c < g) { c *= BSQ; f *= B; } g = r * B; while (c >= g) { c /= BSQ; f /= B; } if ((c + r) / f < T(0.95) * s) { const T inv_f = T(1) / f; info.scale[i] *= f; for (std::size_t j = 0; j < n; ++j) { A(i, j) *= inv_f; A(j, i) *= f; } changed = true; } } } } return info; } //===================================================================== // unbalance_* — recover A's eigenvectors / invariant subspaces (equivalent to dgebak) //===================================================================== namespace detail_balance { // Inverse-transform column j by Step B (scaling): V(i, j) *= D(i, i) (i ∈ [ilo, ihi]) template void apply_scaling_to_column(V& M, std::size_t j, const BalanceInfo& info) { for (std::size_t i = info.ilo; i <= info.ihi; ++i) { const T s = info.scale[i]; if (s == T(1)) continue; M(i, j) = M(i, j) * s; } } // Inverse-transform by Step A (permutation): for column j, swap row i (i outside) with perm[i] template void apply_permutation_to_column(V& M, std::size_t j, const BalanceInfo& info) { // Same order as dgebak: ilo side (left, reverse order from ilo-1) → ihi side (right, from ihi+1) // namely a full reverse replay of the order in which balance_matrix recorded the swaps // (the left side was recorded in ilo++ order → reverse replay is ilo--) const std::size_t n = info.n; if (info.ilo > 0) { for (std::size_t k = info.ilo; k-- > 0; ) { const std::size_t p = info.perm[k]; if (p != k) { auto tmp = M(k, j); M(k, j) = M(p, j); M(p, j) = tmp; } } } if (info.ihi + 1 < n) { for (std::size_t k = info.ihi + 1; k < n; ++k) { const std::size_t p = info.perm[k]; if (p != k) { auto tmp = M(k, j); M(k, j) = M(p, j); M(p, j) = tmp; } } } } } // namespace detail_balance /** * @brief Inverse balance of an eigenvector matrix (per column, equivalent to dgebak) * * @param V in/out column j = eigenvector v_b of balanced A → eigenvector v of A * inverse transform: v = P D v_b * @param info return value of balance_matrix */ template void unbalance_eigenvectors(BaseMatrix& V, const BalanceInfo& info) { if (V.rows() != info.n) { throw DimensionError("unbalance_eigenvectors: row count must match BalanceInfo::n"); } const std::size_t cols = V.cols(); for (std::size_t j = 0; j < cols; ++j) { detail_balance::apply_scaling_to_column(V, j, info); detail_balance::apply_permutation_to_column(V, j, info); } } /// Overload for complex matrices (for the eigenvectors returned by eigen()) template void unbalance_eigenvectors(BaseMatrix>& V, const BalanceInfo& info) { if (V.rows() != info.n) { throw DimensionError("unbalance_eigenvectors: row count must match BalanceInfo::n"); } const std::size_t cols = V.cols(); for (std::size_t j = 0; j < cols; ++j) { detail_balance::apply_scaling_to_column(V, j, info); detail_balance::apply_permutation_to_column(V, j, info); } } /** * @brief Inverse balance of a single eigenvector */ template void unbalance_vector(BaseVector& v, const BalanceInfo& info) { if (v.size() != info.n) { throw DimensionError("unbalance_vector: size must match BalanceInfo::n"); } // Same processing, treating the Vector as a 1-column Matrix // It is clearer to implement it directly, so we do that for (std::size_t i = info.ilo; i <= info.ihi; ++i) { const T s = info.scale[i]; if (s == T(1)) continue; v[i] *= s; } const std::size_t n = info.n; if (info.ilo > 0) { for (std::size_t k = info.ilo; k-- > 0; ) { const std::size_t p = info.perm[k]; if (p != k) std::swap(v[k], v[p]); } } if (info.ihi + 1 < n) { for (std::size_t k = info.ihi + 1; k < n; ++k) { const std::size_t p = info.perm[k]; if (p != k) std::swap(v[k], v[p]); } } } template void unbalance_vector(BaseVector>& v, const BalanceInfo& info) { if (v.size() != info.n) { throw DimensionError("unbalance_vector: size must match BalanceInfo::n"); } for (std::size_t i = info.ilo; i <= info.ihi; ++i) { const T s = info.scale[i]; if (s == T(1)) continue; v[i] = v[i] * Complex(s, T(0)); } const std::size_t n = info.n; if (info.ilo > 0) { for (std::size_t k = info.ilo; k-- > 0; ) { const std::size_t p = info.perm[k]; if (p != k) std::swap(v[k], v[p]); } } if (info.ihi + 1 < n) { for (std::size_t k = info.ihi + 1; k < n; ++k) { const std::size_t p = info.perm[k]; if (p != k) std::swap(v[k], v[p]); } } } /** * @brief Reverse balance of an invariant subspace (LQR Laub form) * * @param Z in/out Schur vectors of balanced A → invariant subspace of A * for each column j, Z(i, j) ← D(i, i) · Z(i, j) (scaling only) * + reverse-permute the rows by the Step A permutation * * @note The orthogonality of Z is lost, but it is correct as an invariant subspace. * Valid for the U_2 U_1^{-1} computation of LQR Laub. */ template void rebalance_invariant_subspace(BaseMatrix& Z, const BalanceInfo& info) { // The implementation is the same as unbalance_eigenvectors (per-column scaling + inverse permutation transform) unbalance_eigenvectors(Z, info); } //===================================================================== // balance_vector — convert an original-coordinate vector to balanced coordinates (reverse direction of dgebak) //===================================================================== /** * @brief Convert an original-coordinate vector v to balanced coordinates v_b (= forward balance) * * @param v in/out input is original coordinates, output is balanced coordinates * v_b = D^{-1} P^{-1} v, the exact opposite processing of unbalance_vector * * @note Used to apply a user-specified initial guess (eigenvalue iteration etc.) on the * balanced matrix. After computation, return to original coordinates with unbalance_vector. */ template void balance_vector(BaseVector& v, const BalanceInfo& info) { if (v.size() != info.n) { throw DimensionError("balance_vector: size must match BalanceInfo::n"); } // Step 1: apply P (permutation in original order) // Right side (= the first recorded swap group): descending from perm[n-1] to perm[ihi+1] const std::size_t n = info.n; if (info.ihi + 1 < n) { for (std::size_t k = n; k-- > info.ihi + 1; ) { const std::size_t p = info.perm[k]; if (p != k) std::swap(v[k], v[p]); } } // Left side (= the next recorded swap group): ascending from perm[0] to perm[ilo-1] if (info.ilo > 0) { for (std::size_t k = 0; k < info.ilo; ++k) { const std::size_t p = info.perm[k]; if (p != k) std::swap(v[k], v[p]); } } // Step 2: D^{-1} (= divide by the scaling factor) for (std::size_t i = info.ilo; i <= info.ihi; ++i) { const T s = info.scale[i]; if (s == T(1)) continue; v[i] = v[i] / s; } } template void balance_vector(BaseVector>& v, const BalanceInfo& info) { if (v.size() != info.n) { throw DimensionError("balance_vector: size must match BalanceInfo::n"); } const std::size_t n = info.n; if (info.ihi + 1 < n) { for (std::size_t k = n; k-- > info.ihi + 1; ) { const std::size_t p = info.perm[k]; if (p != k) std::swap(v[k], v[p]); } } if (info.ilo > 0) { for (std::size_t k = 0; k < info.ilo; ++k) { const std::size_t p = info.perm[k]; if (p != k) std::swap(v[k], v[p]); } } for (std::size_t i = info.ilo; i <= info.ihi; ++i) { const T s = info.scale[i]; if (s == T(1)) continue; v[i] = v[i] * Complex(T(1) / s, T(0)); } } //===================================================================== // EquilibrateInfo / equilibrate_matrix — independent left/right scaling for Ax = b //===================================================================== /** * @brief Output information of equilibrate_matrix * * D_l = diag(row_scale), D_r = diag(col_scale), A → D_l A D_r, b → D_l b. * The solution is recovered by x = D_r y. */ template struct EquilibrateInfo { std::vector row_scale; std::vector col_scale; T row_cond = T(1); ///< condition number of the row scaling (= max/min row_scale) T col_cond = T(1); ///< condition number of the column scaling T amax = T(0); ///< max|A(i,j)| before scaling bool row_equilibrated = false; bool col_equilibrated = false; }; /** * @brief Independent left/right row/column scaling (equivalent to LAPACK dgeequ + dlaqge) * * @param A in/out m×n matrix. Rewritten to D_l A D_r. * @return EquilibrateInfo * * @note row_scale[i] = 1 / max_j |A(i, j)| * col_scale[j] = 1 / max_i |D_l(i,i) A(i, j)| * The LAPACK way is to skip applying scaling if the condition-number improvement is small (cond < ~0.1), but * this implementation always applies it for simplicity. If needed, the caller can decide by looking at row_cond / col_cond. * * @note When solving Ax = b, after equilibrate_matrix(A), multiply b by row_scale, * and multiply the obtained y by col_scale to recover x (see unequilibrate_solution). */ template EquilibrateInfo equilibrate_matrix(BaseMatrix& A) { const std::size_t m = A.rows(); const std::size_t n = A.cols(); EquilibrateInfo info; info.row_scale.assign(m, T(1)); info.col_scale.assign(n, T(1)); if (m == 0 || n == 0) return info; // amax = max|A(i, j)| T amax = T(0); for (std::size_t i = 0; i < m; ++i) { for (std::size_t j = 0; j < n; ++j) { const T v = std::abs(A(i, j)); if (v > amax) amax = v; } } info.amax = amax; if (amax == T(0)) return info; // Row scale T r_max = T(0), r_min = std::numeric_limits::max(); for (std::size_t i = 0; i < m; ++i) { T row_amax = T(0); for (std::size_t j = 0; j < n; ++j) { const T v = std::abs(A(i, j)); if (v > row_amax) row_amax = v; } if (row_amax > T(0)) { info.row_scale[i] = T(1) / row_amax; if (row_amax > r_max) r_max = row_amax; if (row_amax < r_min) r_min = row_amax; } } info.row_cond = (r_max > T(0)) ? r_min / r_max : T(1); // Apply for (std::size_t i = 0; i < m; ++i) { const T s = info.row_scale[i]; if (s == T(1)) continue; for (std::size_t j = 0; j < n; ++j) { A(i, j) *= s; } } info.row_equilibrated = true; // Column scale T c_max = T(0), c_min = std::numeric_limits::max(); for (std::size_t j = 0; j < n; ++j) { T col_amax = T(0); for (std::size_t i = 0; i < m; ++i) { const T v = std::abs(A(i, j)); if (v > col_amax) col_amax = v; } if (col_amax > T(0)) { info.col_scale[j] = T(1) / col_amax; if (col_amax > c_max) c_max = col_amax; if (col_amax < c_min) c_min = col_amax; } } info.col_cond = (c_max > T(0)) ? c_min / c_max : T(1); for (std::size_t j = 0; j < n; ++j) { const T s = info.col_scale[j]; if (s == T(1)) continue; for (std::size_t i = 0; i < m; ++i) { A(i, j) *= s; } } info.col_equilibrated = true; return info; } /** * @brief Apply row_scale to the equilibrated right-hand side b (b → D_l b) */ template void equilibrate_rhs(BaseVector& b, const EquilibrateInfo& info) { if (!info.row_equilibrated) return; if (b.size() != info.row_scale.size()) { throw DimensionError("equilibrate_rhs: size mismatch"); } for (std::size_t i = 0; i < b.size(); ++i) { b[i] *= info.row_scale[i]; } } /** * @brief Recover the original x from the solution y obtained in the equilibrated system (x = D_r y) */ template void unequilibrate_solution(BaseVector& y, const EquilibrateInfo& info) { if (!info.col_equilibrated) return; if (y.size() != info.col_scale.size()) { throw DimensionError("unequilibrate_solution: size mismatch"); } for (std::size_t j = 0; j < y.size(); ++j) { y[j] *= info.col_scale[j]; } } } // namespace algorithms } // namespace sangi #endif // SANGI_BALANCE_HPP