场景
在财务对账场景中,我们常常需要将打款记录 和账单进行金额匹配。一条打款记录可能对应多条账单(一笔打款分多次抵扣),一条账单也可能被多条打款记录逐步覆盖(分期到账)。这就需要一种"逐条消耗"的匹配策略。
核心思路
把打款记录和账单分别看作两个有序队列 ,用两个指针 i 和 j 分别指向当前正在比较的打款和账单。每次比较双方的"剩余金额":
打款剩余金额 == 账单剩余金额 → 刚好匹配,双方都前进
打款剩余金额 > 账单剩余金额 → 账单被完全消化,打款带着余额继续匹配下一条账单
打款剩余金额 < 账单剩余金额 → 打款被完全消化,账单带着余额继续匹配下一条打款
任一方走到队尾,匹配结束。
用一张流程图来看:
#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. 为什么用"剩余金额"而不是直接移动指针?
因为一条打款可能跨多条账单,反之亦然。如果简单地一一比较然后双方都前进,就会丢失"余额"信息。引入 remainingReceipt 和 remainingBill 两个变量,相当于给当前记录加了一个临时状态,只有金额被完全消耗后,指针才前进。
2. 指针前进的条件
金额相等 → 双方都前进(都消耗完了)
打款 > 账单 → 账单前进(账单被消化完),打款不动
打款 < 账单 → 打款前进(打款被消化完),账单不动
核心原则:谁被完全消耗,谁前进;谁还有余额,谁留下。
3. 循环终止条件
while (i < receipts.size() && j < bills.size()) ------ 任一方走到队尾就结束。未匹配完的一方保留在各自系统中,等待后续处理。
4. 金额比较必须用 BigDecimal
金额字段通常是 String 或 double 类型,直接比较会出问题。务必转为 BigDecimal 进行 compareTo 比较和 subtract 计算,避免精度丢失。
总结
双指针循环匹配的本质就是贪心消耗:按顺序取出打款和账单,谁金额小谁先被"用完",金额大的一方带着差额继续和下一条匹配。算法时间复杂度为 O(m + n),其中 m 和 n 分别是打款和账单的数量,非常高效。
这种算法适用于所有"双方逐条抵消"的对账场景,如银行流水匹配、订单分笔支付核销、多对多费用分摊等。
发布日期:2026-08-05