LeetCode 465 最优账单平衡


文章目录

摘要

《最优账单平衡》这道题,很多人第一次看到都会有点懵:
不就是转账吗,为什么会是困难题?

真正难的地方不在"算钱",而在于 如何用最少的转账次数,把一堆复杂的债务关系彻底清零

这类问题在现实中非常常见,比如:

  • 多人 AA 聚餐之后的结算
  • 团队项目里垫资、报销、代付
  • 公司内部多部门费用对冲

题目本身不复杂,但如果直接模拟转账,很容易陷入组合爆炸。

正确的解法核心只有一句话:

先把问题化简,再用回溯暴力,但要暴力得聪明。

描述

题目给了我们一组交易 transactions,每一条形如:

复制代码
[from, to, amount]

表示:
fromto 转了 amount 的钱。

最终目标是:
在不改变每个人最终盈亏结果的前提下,用最少的转账次数,把所有人的账清干净。

有几个关键约束需要先想清楚:

  1. 不关心原始交易顺序
  2. 只关心"每个人最终是多收了钱,还是多付了钱"
  3. 转账次数越少越好,金额大小无所谓

换句话说,我们不是要"复现原交易",而是要 重排债务关系

题解答案

整体思路可以分成两步。

计算每个人的净资产

我们先不管怎么转账,只统计结果:

  • 转出的钱:记为负数
  • 转入的钱:记为正数

最后每个人只会剩下一个数:

  • 正数:别人欠他钱
  • 负数:他欠别人钱
  • 0:已经结清,可以直接忽略

这一步非常关键,它能把问题规模大幅缩小。

在净资产数组上做最少匹配

接下来就变成了一个更抽象的问题:

给你一组正负数,正数和负数总和为 0,

用最少的"配对抵消"次数,让所有数都变成 0。

这个问题非常适合用 回溯 + 剪枝 来解决:

  • 每次找一个还没清零的债务
  • 尝试和后面符号相反的债务进行抵消
  • 把这一步算作一次转账
  • 递归处理剩余部分
  • 取最小次数

题解代码分析

下面是完整 Swift 实现,逻辑清晰、可以直接运行。

swift 复制代码
class Solution {
    func minTransfers(_ transactions: [[Int]]) -> Int {
        var balance = [Int: Int]()

        // 统计每个人的净资产
        for t in transactions {
            let from = t[0]
            let to = t[1]
            let amount = t[2]

            balance[from, default: 0] -= amount
            balance[to, default: 0] += amount
        }

        // 只保留不为 0 的债务
        var debts = balance.values.filter { $0 != 0 }

        return dfs(&debts, 0)
    }

    private func dfs(_ debts: inout [Int], _ start: Int) -> Int {
        // 跳过已经结清的
        while start < debts.count && debts[start] == 0 {
            return dfs(&debts, start + 1)
        }

        if start == debts.count {
            return 0
        }

        var minCount = Int.max

        for i in (start + 1)..<debts.count {
            // 只尝试符号相反的进行抵消
            if debts[start] * debts[i] < 0 {
                let original = debts[i]
                debts[i] += debts[start]

                minCount = min(minCount, 1 + dfs(&debts, start + 1))

                debts[i] = original

                // 剪枝:如果正好抵消,没必要再试别的
                if original + debts[start] == 0 {
                    break
                }
            }
        }

        return minCount
    }
}

核心逻辑拆解一下

为什么可以忽略已经为 0 的人?

因为他们已经"账平了",不需要再参与任何转账。

这一步对性能提升非常明显。

为什么只找符号相反的?
  • 一个是欠钱(负数)
  • 一个是收钱(正数)

只有这样才能发生真实有效的"抵消"。

两个正数或者两个负数,永远解决不了问题。

剪枝为什么成立?
swift 复制代码
if original + debts[start] == 0 {
    break
}

这表示:
当前这次配对已经是完美抵消,再继续尝试别的组合,只会增加转账次数,不可能更优。

示例测试及结果

示例 1

swift 复制代码
let solution = Solution()
let transactions = [
    [0, 1, 10],
    [2, 0, 5]
]

print(solution.minTransfers(transactions))
分析过程
  • 0:-10 + 5 = -5
  • 1:+10
  • 2:-5

债务数组变成:

复制代码
[-5, 10, -5]

最优方案:

  • 1 给 0 转 5
  • 1 给 2 转 5

总共 2 次转账

输出:

复制代码
2

示例 2

swift 复制代码
let transactions = [
    [0, 1, 10],
    [1, 0, 10]
]

print(solution.minTransfers(transactions))

两笔交易完全抵消,所有人最终余额都是 0。

输出:

复制代码
0

时间复杂度

这道题本质上是一个 NP 难问题

  • 最坏情况下,需要尝试所有债务组合
  • 回溯的时间复杂度接近 O(n!)

但题目限制了参与人数,一般不会超过 12 个非零债务节点。

再加上大量剪枝,实际运行非常快。

空间复杂度

主要消耗在:

  • debts 数组
  • 递归调用栈

空间复杂度为:

复制代码
O(n)

总结

《最优账单平衡》这道题的价值不在于"写代码有多复杂",而在于 建模能力

  1. 把原始交易压缩成"净资产"
  2. 把业务问题抽象成"正负数抵消"
  3. 用回溯解决"最少操作次数"问题
相关推荐
倒头就睡的小比特19 小时前
算法竞赛C++常用的STL
c++·算法
小羊没烦恼!19 小时前
初探性能优化——2个月到4小时的性能提升
java·开发语言·windows·算法·c#
猎头南楼21 小时前
知识社区推荐系统实践:新用户冷启动与长短期兴趣建模的挑战 资深推荐算法工程师
人工智能·深度学习·算法·机器学习
旖旎夜光1 天前
力控面试题 01.01: 判定字符是否唯一(位运算) —— 题解
c++·学习·算法·leetcode·力控
wzdark1 天前
大规模并行计算中的负载均衡算法研究4
算法
Because_of_Her11 天前
并查集-听课笔记
笔记·算法·并查集
码流子1 天前
高速公路安全监测实践:碰撞监测预警+物联网底座,从感知到处置的闭环
大数据·人工智能·物联网·算法·架构
another heaven1 天前
【算法/C++ MD5算法能否逆解码?原理、C++实现与同类哈希算法对比】
c++·算法·哈希算法
wzdark1 天前
从算法设计模式看编程思维的抽象能力4
算法
2601_962218611 天前
万象生鲜系统称重自动多退少补算法解决生鲜非标品痛点
大数据·数据库·人工智能·python·算法