C++ / ACM 自学笔记 · v5.0

(九) 建模与推导中的典型反例

程序可以正确执行每一行,却仍然回答了另一道题。检查模型时,先把它允许的方案与题目允许的方案放在一起,找出被多算、漏算或改写的部分。

1. 连续片段被改成任意选择

题目原文(自拟): 数组为 [5,-4,5]。选择连续且非空的一段,输出最大的区间和。程序无需输入。

看到负数就删除,会得到 5+5=10。但两个 5 中间隔着 -4,这个选择没有对应的连续区间。错误出在“允许选择哪些位置”,还没有进入算法快慢的比较。

连续区间的下标 实际选中的数 和
[0,0] 5 5
[0,1] 5,-4 1
[0,2] 5,-4,5 6
[1,1] -4 -4
[1,2] -4,5 1
[2,2] 5 5
跳过中间元素改变了连续片段的合法方案

用两个端点描述一个连续片段,就不会把中间位置跳过去。固定左端点后,让右端点逐个增加,每次把新元素加入当前和。

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> a{5, -4, 5};
    int answer = a[0]; // 从一个非空片段开始记录答案。
    for (int left = 0; left < static_cast<int>(a.size()); ++left) {
        int sum = 0;
        for (int right = left; right < static_cast<int>(a.size()); ++right) {
            sum += a[right]; // 此时 sum 是 a[left..right] 的和。
            answer = max(answer, sum);
        }
    }
    cout << answer << '\n'; // 6,对应整个数组。
}

代码解释: 每个连续非空片段有唯一的一对左右端点,两个循环恰好检查全部片段。推广到长度 n 时,检查次数为 n+(n-1)+…+1,时间为 \(O(n^2)\),额外空间为 \(O(1)\)。后面的线性 DP 会继续优化同一个模型。

练习: 洛谷 P1115 — 最大子段和(学习线性 DP 后完成)。

2. 两个下标被误写成两次取值

题目原文(自拟): 数组为 [3],统计满足 i<j 且 a[i]+a[j]=6 的下标对数量。程序无需输入。

“数组里有 3,另一个 3 也能找到”会让同一个位置参与两次。题目需要两个不同的位置;这个数组只有一个位置,所以答案是 0。

当前元素不能在查询时与自己配对
#include <iostream>
#include <vector>
using namespace std;

int main() {
    vector<int> a{3};
    int count = 0;
    for (int i = 0; i < static_cast<int>(a.size()); ++i) {
        // j 从 i+1 开始,同一位置不会出现,也不会反向重复统计。
        for (int j = i + 1; j < static_cast<int>(a.size()); ++j) {
            if (a[i] + a[j] == 6) {
                ++count;
            }
        }
    }
    cout << count << '\n'; // 0,内层循环没有候选位置。
}

代码解释: 值相同与位置相同是两件事。[3,3] 有一个合法下标对,[3,3,3] 有三个。枚举长度 n 的数组需要 \(O(n^2)\) 时间、\(O(1)\) 额外空间;后面的一题多模型会保留同一个下标要求,再减少检查次数。

3. 排序改变了不能重排的对象

题目原文(自拟): 四个任务的工作量依次为 [4,1,4,1]。按给定顺序切成恰好两个连续非空组,输出最大组内工作量的最小值。程序无需输入。

为了让大小相近的数放在一起而先排序,会把任务顺序改成 [1,1,4,4]。两个数组的可选切法不同:

切点 原顺序的两组与较大组和 排序后的两组与较大组和
第 1 项后 [4]、[1,4,1]:6 [1]、[1,4,4]:9
第 2 项后 [4,1]、[4,1]:5 [1,1]、[4,4]:8
第 3 项后 [4,1,4]、[1]:9 [1,1,4]、[4]:6
排序前后连续分组的候选集合发生变化

排序后的最优值 6 不能回答原题。原题只需枚举三个切点,得到答案 5。

#include <algorithm>
#include <iostream>
#include <vector>
using namespace std;

int bestSplit(const vector<int> &a) {
    int total = 0;
    for (int x : a) {
        total += x;
    }
    int leftSum = 0;
    int answer = total;
    for (int cut = 1; cut < static_cast<int>(a.size()); ++cut) {
        leftSum += a[cut - 1]; // 切点前的数保持原顺序,构成第一组。
        answer = min(answer, max(leftSum, total - leftSum));
    }
    return answer;
}

int main() {
    vector<int> a{4, 1, 4, 1};
    cout << bestSplit(a) << '\n'; // 5,是原题的答案。
    sort(a.begin(), a.end());
    cout << bestSplit(a) << '\n'; // 6,展示重排后变成了另一道问题。
}

代码解释: 选定切点后,左组和为 leftSum,右组和为 total-leftSum。逐个比较较大的组和,再取最小值。一次分组计算需要 \(O(n)\) 时间、\(O(1)\) 额外空间;示例中的排序用来展示模型被改变的结果。

练习: 洛谷 P1182 — 数列分段 Section II(学过二分答案后完成)。

4. 当前收益被当成全局最优

题目原文(自拟): 面值为 1、3、4 的硬币各有足够多枚。凑出金额 6,输出最少硬币数。程序无需输入。

“先拿最大面值”得到 4+1+1,需要三枚;3+3 只需两枚。选择最大硬币确实让本次剩余金额减少最多,但题目的目标是所有选择结束后的总枚数。

4 元枚数 3 元枚数 1 元枚数 总枚数
0 0 6 6
0 1 3 4
0 2 0 2
1 0 2 3
最大面值的局部选择与总枚数的比较

先写清模型:枚数 x、y、z 是非负整数,满足 4*x+3*y+z=6,要最小化 x+y+z。枚举 x、y 后,z 由剩余金额唯一确定。

#include <algorithm>
#include <iostream>
using namespace std;

int main() {
    int amount = 6;
    int answer = amount; // 全用 1 元硬币是一份可直接写出的方案。
    for (int fours = 0; fours * 4 <= amount; ++fours) {
        for (int threes = 0; fours * 4 + threes * 3 <= amount; ++threes) {
            int ones = amount - fours * 4 - threes * 3;
            // 每种面值的数量确定后,计算完整方案的枚数。
            answer = min(answer, fours + threes + ones);
        }
    }
    cout << answer << '\n'; // 2,对应两个 3 元硬币。
}

代码解释: 一份硬币方案只有一组对应的三种枚数,枚举没有重复。推广到金额 A 时,两个枚数的候选数量随 A 增长,时间为 \(O(A^2)\),额外空间为 \(O(1)\)。学习完全背包后,可以把重复的剩余金额计算合并起来。

练习: CF 996A — Hit the Lottery(学习贪心后完成)。

前三个反例分别改写了合法位置、对象身份和原有顺序;硬币例题则把本次剩余金额减少最多当成了总枚数最少的理由。出现 WA 时,把失败输入代回这些具体要求,再检查每一步选择的依据。

查阅笔记正文与全部题解 · Ctrl / ⌘ K