345. 矩阵和(Matrix Sum)

我们把一个矩阵的“矩阵和”定义为:在矩阵中选取若干元素,使得任意两个被选元素不在同一行、也不在同一列,并且这些被选元素的和尽可能大时得到的最大和。

例如,下方矩阵的矩阵和为 \(3315\)(即 \(863+383+343+959+767\)):

$$ \begin{bmatrix} 7 & 53 & 183 & 439 & 863\\ 497 & 383 & 563 & 79 & 973\\ 287 & 63 & 343 & 169 & 583\\ 627 & 343 & 773 & 959 & 943\\ 767 & 473 & 103 & 699 & 303 \end{bmatrix} $$

求下列 \(15\times 15\) 矩阵的矩阵和:

$$ \begin{bmatrix} 7&53&183&439&863&497&383&563&79&973&287&63&343&169&583\\ 627&343&773&959&943&767&473&103&699&303&957&703&583&639&913\\ 447&283&463&29&23&487&463&993&119&883&327&493&423&159&743\\ 217&623&3&399&853&407&103&983&89&463&290&516&212&462&350\\ 960&376&682&962&300&780&486&502&912&800&250&346&172&812&350\\ 870&456&192&162&593&473&915&45&989&873&823&965&425&329&803\\ 973&965&905&919&133&673&665&235&509&613&673&815&165&992&326\\ 322&148&972&962&286&255&941&541&265&323&925&281&601&95&973\\ 445&721&11&525&473&65&511&164&138&672&18&428&154&448&848\\ 414&456&310&312&798&104&566&520&302&248&694&976&430&392&198\\ 184&829&373&181&631&101&969&613&840&740&778&458&284&760&390\\ 821&461&843&513&17&901&711&993&293&157&274&94&192&156&574\\ 34&124&4&878&450&476&712&914&838&669&875&299&823&329&699\\ 815&559&813&459&522&788&168&586&966&232&308&833&251&631&107\\ 813&883&451&509&615&77&281&613&459&205&380&274&302&35&805 \end{bmatrix} $$

分析:这是一个标准“指派问题”。每一行必须选一个元素,且列不能重复,本质是最大化

$$ \sum_{i=0}^{n-1} a_{i,\pi(i)} $$

其中 \(\pi\) 是列的一个排列。对于 \(n=15\),状态压缩动态规划可以在极短时间内得到最优解。

一、数学背景

把“选元素且不同行不同列”看成二分图匹配: - 左侧顶点是行; - 右侧顶点是列; - 边权是矩阵值。

题目等价于求最大权完美匹配。
虽然可以用匈牙利算法,但本题规模固定且较小,使用位掩码 DP 更直接。

二、算法设计

mask 是一个二进制集合,表示已经使用过的列。定义

$$ dp[\text{mask}] = \text{前 }k=\mathrm{popcount}(\text{mask})\text{ 行完成分配时的最大和} $$

转移时,为第 \(k\) 行选择一个未被使用的列 \(c\)

$$ dp[\text{mask}\cup\{c\}] = \max\left(dp[\text{mask}\cup\{c\}],\ dp[\text{mask}] + a_{k,c}\right) $$

初值:dp[0]=0
终值:dp[(1<<n)-1]

这个定义保证: - 任一合法选法都对应一条从 0 到全满掩码的路径; - 转移只会选未使用列,因此天然满足列互异; - 对每个状态取最大值,最终得到全局最优。

三、复杂度分析

\(n=15\) 时,计算量非常小,实测远低于 60 秒要求。

四、代码实现与说明

"""Project Euler 345 - Matrix Sum."""

from __future__ import annotations

from time import perf_counter

SAMPLE_MATRIX: tuple[tuple[int, ...], ...] = (
    (7, 53, 183, 439, 863),
    (497, 383, 563, 79, 973),
    (287, 63, 343, 169, 583),
    (627, 343, 773, 959, 943),
    (767, 473, 103, 699, 303),
)
SAMPLE_EXPECTED = 3315

TARGET_MATRIX: tuple[tuple[int, ...], ...] = (
    (7, 53, 183, 439, 863, 497, 383, 563, 79, 973, 287, 63, 343, 169, 583),
    (627, 343, 773, 959, 943, 767, 473, 103, 699, 303, 957, 703, 583, 639, 913),
    (447, 283, 463, 29, 23, 487, 463, 993, 119, 883, 327, 493, 423, 159, 743),
    (217, 623, 3, 399, 853, 407, 103, 983, 89, 463, 290, 516, 212, 462, 350),
    (960, 376, 682, 962, 300, 780, 486, 502, 912, 800, 250, 346, 172, 812, 350),
    (870, 456, 192, 162, 593, 473, 915, 45, 989, 873, 823, 965, 425, 329, 803),
    (973, 965, 905, 919, 133, 673, 665, 235, 509, 613, 673, 815, 165, 992, 326),
    (322, 148, 972, 962, 286, 255, 941, 541, 265, 323, 925, 281, 601, 95, 973),
    (445, 721, 11, 525, 473, 65, 511, 164, 138, 672, 18, 428, 154, 448, 848),
    (414, 456, 310, 312, 798, 104, 566, 520, 302, 248, 694, 976, 430, 392, 198),
    (184, 829, 373, 181, 631, 101, 969, 613, 840, 740, 778, 458, 284, 760, 390),
    (821, 461, 843, 513, 17, 901, 711, 993, 293, 157, 274, 94, 192, 156, 574),
    (34, 124, 4, 878, 450, 476, 712, 914, 838, 669, 875, 299, 823, 329, 699),
    (815, 559, 813, 459, 522, 788, 168, 586, 966, 232, 308, 833, 251, 631, 107),
    (813, 883, 451, 509, 615, 77, 281, 613, 459, 205, 380, 274, 302, 35, 805),
)


def validate_square_matrix(matrix: tuple[tuple[int, ...], ...]) -> None:
    """Ensure the input matrix is non-empty and square."""
    if not matrix:
        raise ValueError("Matrix must not be empty.")
    size = len(matrix)
    for row in matrix:
        if len(row) != size:
            raise ValueError("Matrix must be square.")


def max_matrix_sum(matrix: tuple[tuple[int, ...], ...]) -> int:
    """Compute the maximum assignment sum using bitmask dynamic programming."""
    validate_square_matrix(matrix)
    n = len(matrix)
    full_mask = (1 << n) - 1
    negative_inf = -10**18
    dp = [negative_inf] * (1 << n)
    dp[0] = 0

    for mask in range(1 << n):
        row = mask.bit_count()
        if row >= n or dp[mask] == negative_inf:
            continue
        current = dp[mask]
        for col in range(n):
            if mask & (1 << col):
                continue
            next_mask = mask | (1 << col)
            candidate = current + matrix[row][col]
            if candidate > dp[next_mask]:
                dp[next_mask] = candidate

    return dp[full_mask]


def solve_once() -> tuple[int, float]:
    """Run one full solve on the target matrix and return answer with elapsed time."""
    start = perf_counter()
    answer = max_matrix_sum(TARGET_MATRIX)
    elapsed = perf_counter() - start
    return answer, elapsed


def benchmark(runs: int = 3) -> tuple[int, list[float]]:
    """Run the solver multiple times for stable timing measurements."""
    times: list[float] = []
    answer = -1
    for _ in range(runs):
        answer, elapsed = solve_once()
        times.append(elapsed)
    return answer, times


def main() -> None:
    """Validate the sample, solve the target case, and print benchmark metrics."""
    sample_answer = max_matrix_sum(SAMPLE_MATRIX)
    print(f"sample_matrix_sum = {sample_answer}")
    assert sample_answer == SAMPLE_EXPECTED, (
        f"Sample check failed: got {sample_answer}, expected {SAMPLE_EXPECTED}"
    )

    answer, times = benchmark(runs=3)
    print(f"matrix_sum_15x15 = {answer}")
    print(f"timings_seconds = {[round(t, 6) for t in times]}")
    print(f"timing_avg_seconds = {sum(times) / len(times):.6f}")
    print(f"timing_max_seconds = {max(times):.6f}")


if __name__ == "__main__":
    main()

代码说明(按执行顺序):

常量区

validate_square_matrix

max_matrix_sum

solve_once

benchmark

main