双指针循环匹配金额——打款记录与账单的自动对账算法

场景

在财务对账场景中,我们常常需要将打款记录账单进行金额匹配。一条打款记录可能对应多条账单(一笔打款分多次抵扣),一条账单也可能被多条打款记录逐步覆盖(分期到账)。这就需要一种"逐条消耗"的匹配策略。

核心思路

把打款记录和账单分别看作两个有序队列 ,用两个指针 ij 分别指向当前正在比较的打款和账单。每次比较双方的"剩余金额":

复制代码
打款剩余金额 == 账单剩余金额  →  刚好匹配,双方都前进
打款剩余金额  >  账单剩余金额  →  账单被完全消化,打款带着余额继续匹配下一条账单
打款剩余金额  <  账单剩余金额  →  打款被完全消化,账单带着余额继续匹配下一条打款

任一方走到队尾,匹配结束。

用一张流程图来看:
#mermaid-svg-KeVSQC6S99FdAtU1{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-KeVSQC6S99FdAtU1 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-KeVSQC6S99FdAtU1 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-KeVSQC6S99FdAtU1 .error-icon{fill:#552222;}#mermaid-svg-KeVSQC6S99FdAtU1 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-KeVSQC6S99FdAtU1 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-KeVSQC6S99FdAtU1 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-KeVSQC6S99FdAtU1 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-KeVSQC6S99FdAtU1 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-KeVSQC6S99FdAtU1 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-KeVSQC6S99FdAtU1 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-KeVSQC6S99FdAtU1 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-KeVSQC6S99FdAtU1 .marker.cross{stroke:#333333;}#mermaid-svg-KeVSQC6S99FdAtU1 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-KeVSQC6S99FdAtU1 p{margin:0;}#mermaid-svg-KeVSQC6S99FdAtU1 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-KeVSQC6S99FdAtU1 .cluster-label text{fill:#333;}#mermaid-svg-KeVSQC6S99FdAtU1 .cluster-label span{color:#333;}#mermaid-svg-KeVSQC6S99FdAtU1 .cluster-label span p{background-color:transparent;}#mermaid-svg-KeVSQC6S99FdAtU1 .label text,#mermaid-svg-KeVSQC6S99FdAtU1 span{fill:#333;color:#333;}#mermaid-svg-KeVSQC6S99FdAtU1 .node rect,#mermaid-svg-KeVSQC6S99FdAtU1 .node circle,#mermaid-svg-KeVSQC6S99FdAtU1 .node ellipse,#mermaid-svg-KeVSQC6S99FdAtU1 .node polygon,#mermaid-svg-KeVSQC6S99FdAtU1 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-KeVSQC6S99FdAtU1 .rough-node .label text,#mermaid-svg-KeVSQC6S99FdAtU1 .node .label text,#mermaid-svg-KeVSQC6S99FdAtU1 .image-shape .label,#mermaid-svg-KeVSQC6S99FdAtU1 .icon-shape .label{text-anchor:middle;}#mermaid-svg-KeVSQC6S99FdAtU1 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-KeVSQC6S99FdAtU1 .rough-node .label,#mermaid-svg-KeVSQC6S99FdAtU1 .node .label,#mermaid-svg-KeVSQC6S99FdAtU1 .image-shape .label,#mermaid-svg-KeVSQC6S99FdAtU1 .icon-shape .label{text-align:center;}#mermaid-svg-KeVSQC6S99FdAtU1 .node.clickable{cursor:pointer;}#mermaid-svg-KeVSQC6S99FdAtU1 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-KeVSQC6S99FdAtU1 .arrowheadPath{fill:#333333;}#mermaid-svg-KeVSQC6S99FdAtU1 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-KeVSQC6S99FdAtU1 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-KeVSQC6S99FdAtU1 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KeVSQC6S99FdAtU1 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-KeVSQC6S99FdAtU1 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KeVSQC6S99FdAtU1 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-KeVSQC6S99FdAtU1 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-KeVSQC6S99FdAtU1 .cluster text{fill:#333;}#mermaid-svg-KeVSQC6S99FdAtU1 .cluster span{color:#333;}#mermaid-svg-KeVSQC6S99FdAtU1 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-KeVSQC6S99FdAtU1 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-KeVSQC6S99FdAtU1 rect.text{fill:none;stroke-width:0;}#mermaid-svg-KeVSQC6S99FdAtU1 .icon-shape,#mermaid-svg-KeVSQC6S99FdAtU1 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-KeVSQC6S99FdAtU1 .icon-shape p,#mermaid-svg-KeVSQC6S99FdAtU1 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-KeVSQC6S99FdAtU1 .icon-shape .label rect,#mermaid-svg-KeVSQC6S99FdAtU1 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-KeVSQC6S99FdAtU1 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-KeVSQC6S99FdAtU1 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-KeVSQC6S99FdAtU1 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} R == B
R > B
R < B


取打款i的剩余金额 R
R 与账单j剩余金额 B 比较
记录匹配,i++, j++
记录匹配金额=B,R=R-B,j++
记录匹配金额=R,B=B-R,i++
i < 打款数 && j < 账单数?
匹配结束

精简代码示例

实体定义

java 复制代码
@Data
@AllArgsConstructor
public class Receipt {
    private String id;
    private String amount;  // 打款金额
}

@Data
@AllArgsConstructor
public class Bill {
    private String billId;
    private String amount;  // 账单金额
}

@Data
public class MatchResult {
    private String receiptId;
    private String billId;
    private String matchAmount;  // 本次匹配金额
}

匹配算法

java 复制代码
public static List<MatchResult> match(List<Receipt> receipts, List<Bill> bills) {
    if(receipts.isEmpty() || bills.isEmpty()){
        return new ArrayList<>();
    }
    int i = 0;  // 打款指针
    int j = 0;  // 账单指针
    BigDecimal remainingReceipt = BigDecimal.ZERO;
    BigDecimal remainingBill = BigDecimal.ZERO;
    List<MatchResult> results = new ArrayList<>();

    while (i < receipts.size() && j < bills.size()) {
        // 打款剩余金额为0,说明上一条已消耗完,取新的
        if (remainingReceipt.compareTo(BigDecimal.ZERO) == 0) {
            remainingReceipt = new BigDecimal(receipts.get(i).getAmount());
        }
        // 账单剩余金额为0,同理
        if (remainingBill.compareTo(BigDecimal.ZERO) == 0) {
            remainingBill = new BigDecimal(bills.get(j).getAmount());
        }

        MatchResult result = new MatchResult();
        result.setReceiptId(receipts.get(i).getId());
        result.setBillId(bills.get(j).getBillId());

        int cmp = remainingReceipt.compareTo(remainingBill);
        if (cmp == 0) {
            // 情况1:金额刚好相等,双方都匹配完
            result.setMatchAmount(remainingReceipt.toPlainString());
            results.add(result);
            remainingReceipt = BigDecimal.ZERO;
            remainingBill = BigDecimal.ZERO;
            i++;
            j++;
        } else if (cmp > 0) {
            // 情况2:打款 > 账单,账单被完全消化,打款带余额继续
            result.setMatchAmount(remainingBill.toPlainString());
            results.add(result);
            remainingReceipt = remainingReceipt.subtract(remainingBill);
            remainingBill = BigDecimal.ZERO;
            j++;  // 账单前进,打款不动
        } else {
            // 情况3:打款 < 账单,打款被完全消化,账单带余额继续
            result.setMatchAmount(remainingReceipt.toPlainString());
            results.add(result);
            remainingBill = remainingBill.subtract(remainingReceipt);
            remainingReceipt = BigDecimal.ZERO;
            i++;  // 打款前进,账单不动
        }
    }
    return results;
}

测试数据与运行结果

java 复制代码
public static void main(String[] args) {
    List<Receipt> receipts = Arrays.asList(
        new Receipt("R1", "1000"),   // 打款1000
        new Receipt("R2", "500"),    // 打款500
        new Receipt("R3", "800")     // 打款800
    );

    List<Bill> bills = Arrays.asList(
        new Bill("B1", "600"),       // 账单600
        new Bill("B2", "700"),       // 账单700
        new Bill("B3", "1000")       // 账单1000
    );

    List<MatchResult> results = match(receipts, bills);
    for (MatchResult r : results) {
        System.out.println(r.getReceiptId() + " -> " + r.getBillId() 
            + " : " + r.getMatchAmount());
    }
}

输出结果:

复制代码
R1 -> B1 : 600       // R1(1000) 匹配 B1(600),R1剩余400
R1 -> B2 : 400       // R1(剩余400) 匹配 B2(700),R1用完,B2剩余300
R2 -> B2 : 300       // R2(500) 匹配 B2(剩余300),B2用完,R2剩余200
R2 -> B3 : 200       // R2(剩余200) 匹配 B3(1000),R2用完,B3剩余800
R3 -> B3 : 800       // R3(800) 匹配 B3(剩余800),刚好匹配完

逐步图解

用表格还原上面例子的每一步:

步骤 打款指针 打款剩余 账单指针 账单剩余 匹配金额 谁前进
1 R1(1000) 1000 B1(600) 600 600 B1→B2(打款剩400)
2 R1(剩余400) 400 B2(700) 700 400 R1→R2(账单剩300)
3 R2(500) 500 B2(剩余300) 300 300 B2→B3(打款剩200)
4 R2(剩余200) 200 B3(1000) 1000 200 R2→R3(账单剩800)
5 R3(800) 800 B3(剩余800) 800 800 双方都前进

可以看到,整个过程就像两条"传送带"上的物品在互相抵消------金额大的一方会"带着余额"留在原地,等待和下一条继续匹配。

关键设计要点

1. 为什么用"剩余金额"而不是直接移动指针?

因为一条打款可能跨多条账单,反之亦然。如果简单地一一比较然后双方都前进,就会丢失"余额"信息。引入 remainingReceiptremainingBill 两个变量,相当于给当前记录加了一个临时状态,只有金额被完全消耗后,指针才前进。

2. 指针前进的条件

复制代码
金额相等  → 双方都前进(都消耗完了)
打款 > 账单 → 账单前进(账单被消化完),打款不动
打款 < 账单 → 打款前进(打款被消化完),账单不动

核心原则:谁被完全消耗,谁前进;谁还有余额,谁留下。

3. 循环终止条件

while (i < receipts.size() && j < bills.size()) ------ 任一方走到队尾就结束。未匹配完的一方保留在各自系统中,等待后续处理。

4. 金额比较必须用 BigDecimal

金额字段通常是 Stringdouble 类型,直接比较会出问题。务必转为 BigDecimal 进行 compareTo 比较和 subtract 计算,避免精度丢失。

总结

双指针循环匹配的本质就是贪心消耗:按顺序取出打款和账单,谁金额小谁先被"用完",金额大的一方带着差额继续和下一条匹配。算法时间复杂度为 O(m + n),其中 m 和 n 分别是打款和账单的数量,非常高效。

这种算法适用于所有"双方逐条抵消"的对账场景,如银行流水匹配、订单分笔支付核销、多对多费用分摊等。

发布日期:2026-08-05

相关推荐
YDS8291 小时前
大营销平台 —— 架构解析和抽奖流程串联
java·springboot·ddd
小贤plus1 小时前
MyBatis‑Plus IPage 分页踩坑实战:分页与全量查询兼容方案
java
Java内核笔记1 小时前
BeanRegistrar:Spring Boot 4 最被低估的新特性,彻底改变 Bean 注册方式
java·后端
还是鼠鼠1 小时前
【Spring AI智能体实战 02】Spring Boot 3.4整合Spring AI:项目创建与依赖配置
java·人工智能·spring boot·maven·spring ai
搬砖小匠1 小时前
SpringBoot 通过自定义注解 + AOP 实现数据字典自动翻译(通用方案)
java·后端
今天AI了吗1 小时前
【AI智能体】Hermes Agent 从部署到项目实战操作详解
java·人工智能·python·milvus
旖旎夜光2 小时前
LeetCode 738:单调递增的数字(贪心算法) —— 题解
数据结构·c++·算法·leetcode·贪心算法
青山木2 小时前
Hot 100 --- 搜索二维矩阵
java·算法·leetcode·矩阵
旖旎夜光2 小时前
LeetCode 1262:可被三整除的最大和 (贪心算法)—— 题解
数据结构·c++·算法·leetcode·贪心算法