解空间的概念,识别解空间范围大小,选择合适时间复杂度的遍历算法

登录以参加训练计划

3585(洛谷P2118)这道题(NOIP 2014 普及组 T2)虽然看起来像是数学题,但结合数据范围来看,其实是一道非常适合暴力枚举的题目。

关键理解:问题的解空间是所有 1AL1 \le A' \le L1BL1 \le B' \le L 的组合。

以下是解题思路提示:

1. 突破口:数据范围

请仔细看题目最后的数据范围说明:

1L1001 \le L \le 100

这一行非常关键!LL 最大只有 100。这意味着,AA'BB' 的取值范围都在 1 到 100 之间。 如果我们写一个双重循环,把 AA' 从 1 枚举到 100,把 BB' 从 1 枚举到 100,总共只运算 100×100=10,000100 \times 100 = 10,000 次。这就好比在一秒钟内数一万个数,对计算机来说简直是“秒杀”。

2. 核心逻辑:打擂台

既然可以把所有可能的分子分母组合都试一遍,我们就可以用“打擂台”的方法找最佳答案。

  • 准备工作:定义两个变量 ans_aans_b 用来存当前找到的最好的分子和分母。初始值可以设得比较离谱(比如让比值非常大),或者直接用 1 和 1(如果题目保证答案存在)。
  • 循环枚举
    • 外层循环 i 代表分子(1 到 LL)。
    • 内层循环 j 代表分母(1 到 LL)。
  • 判断条件(只要满足以下三个条件,就有资格挑战擂主):
    1. 互质:题目要求 gcd(i, j) == 1
    2. 不小于原值:题目要求 ijAB\frac{i}{j} \ge \frac{A}{B}
      • 小技巧:为了避免浮点数除法的精度问题,你可以把除法变成乘法:i * B >= A * j(注意数据范围,106×10010^6 \times 100 会超过 int,建议用 long longdouble)。
    3. 更接近:当前的差值 ijAB\frac{i}{j} - \frac{A}{B} 比之前记录的最小差值还要小。
  • 更新答案:如果满足上述条件,就把 ans_a 更新为 ians_b 更新为 j

3. 细节处理

  • 浮点数:这道题涉及比大小,最简单的方法是直接把分子分母强转成 double 进行计算。因为 LL 很小,精度误差通常不会影响结果。
  • 互质函数:你需要写一个 gcd 函数,或者使用 C++ 自带的 __gcd(a, b)(在 <algorithm> 头文件中)。
  • 初始值:一开始的“擂主”要设得足够“烂”,比如设一个比值很大的数,确保第一个合法的组合能替换掉它。

4. 代码结构框架

int ans_a = 1000000, ans_b = 1; // 初始设一个很大的比值
for (int i = 1; i <= L; i++) {
    for (int j = 1; j <= L; j++) {
        // 1. 检查互质
        // 2. 检查 i/j >= A/B
        // 3. 检查 i/j - A/B < ans_a/ans_b - A/B
        // 如果都满足 -> 更新 ans_a, ans_b
    }
}
输出 ans_a, ans_b

抓住 LL 很小这个特点,大胆地去枚举吧!加油!


请用启发式的方式详细讲解“解空间”的概念。

关键理解:问题的解空间是所有 1AL1 \le A' \le L1BL1 \le B' \le L 的组合。

你好!这是一个非常棒的思维切入点。在计算机科学和算法竞赛(OI)中,“解空间”(Solution Space) 是一个核心概念。

把“解题”想象成“找东西”。 解空间就是:哪怕是最笨的方法,你也知道答案一定在这个圈子里。这个“圈子”的所有可能性,就叫解空间。

为了让你彻底理解,我们用**“淘金”**的例子来启发式地拆解它。


第一步:划定“金矿”的边界(定义解空间)

题目让你找两个数 AA'BB',这就好比你要去挖两块金子。 虽然世界上有很多金子,但题目给了一个强硬的限制:上限 LL

  • AA' 的可能性:只能是 1,2,3,,L1, 2, 3, \dots, L 中的一个。
  • BB' 的可能性:只能是 1,2,3,,L1, 2, 3, \dots, L 中的一个。

什么是解空间? 想象一个巨大的棋盘或者表格

  • 横坐标是 AA',从 1 到 LL
  • 纵坐标是 BB',从 1 到 LL
  • 这个棋盘上每一个格子 (A,B)(A', B'),都是一个**“候选人”**。

这个棋盘上的所有格子加起来,就是这道题的全解空间。 它的大小是:L×LL \times L

第二步:评估“矿场”的大小(复杂度分析)

作为 OIer,拿到题目的第一反应应该是:“这个矿场大不大?我能不能把每块石头都翻一遍?”

  • 如果 L=100L = 100: 解空间大小 =100×100=10,000= 100 \times 100 = 10,000。 对于计算机(每秒运算 10810^8 次)来说,翻遍 1 万个格子,只需要 0.0001 秒。 结论:矿场很小,我们可以暴力地把每个格子都检查一遍!

  • 如果 L=100,000L = 100,000: 解空间大小 =105×105=1010= 10^5 \times 10^5 = 10^{10}(100亿)。 计算机要跑几分钟甚至几小时。 结论:矿场太大,不能暴力枚举,需要更聪明的算法(比如数学推导或二分)。

这道题之所以是“普及组”难度,就是因为它的解空间(L100L \le 100)非常小,允许我们进行全解空间搜索。

第三步:在解空间里“筛选”(约束条件)

现在我们知道答案一定在 这 L×LL \times L 个格子里。虽然里面有 10000 个组合,但绝大多数都是“废石”。我们需要拿着筛子(题目条件)去过滤。

让我们把解空间里的点分为三类:

  1. 废点(不合法)

    • 题目要求 AA'BB' 互质。比如 (2,4)(2, 4),最大公约数是 2,不是 1。扔掉!
    • 题目要求 ABAB\frac{A'}{B'} \ge \frac{A}{B}。如果算出来的比例比原比例小,扔掉!
  2. 可行解(合法,但不一定最好)

    • 比如 (5,3)(5, 3)(10,6)(10, 6) 假设都满足互质和比例要求。它们都是“金子”,先留着。
  3. 最优解(我们要找的那个)

    • 在所有“可行解”中,我们要找 ABAB\frac{A'}{B'} - \frac{A}{B} 最小 的那个。

第四步:代码实现的本质

你的代码里的双重循环,本质上就是遍历整个解空间的过程:

// 这一步是在遍历解空间的每一行
for (int i = 1; i <= L; i++) { 
    // 这一步是在遍历解空间的每一列
    for (int j = 1; j <= L; j++) {
        
        // 现在的 (i, j) 就是解空间里的一个具体坐标点
        
        // --- 下面是筛选器 ---
        if (gcd(i, j) == 1 && (double)i/j >= (double)A/B) {
            // --- 下面是打擂台找最优解 ---
            if (这一块金子成色更好) {
                更新答案;
            }
        }
    }
}

总结

“解空间”思维对你的启发应该是:

  1. 先看边界:变量的范围决定了空间的维度和大小。
  2. 算算大小L100L \le 100 意味着 10410^4 的空间。
  3. 决定策略
    • 空间小 \rightarrow 暴力枚举(遍历整个空间)。
    • 空间大 \rightarrow 剪枝/贪心/二分(只去空间里最有可能藏着答案的角落找)。

在这道题里,理解了解空间是 L×LL \times L 的矩阵,你就完全不用担心“我是不是漏掉了什么情况”,因为你已经把所有可能的 A,BA', B' 组合全部一网打尽了!这就是暴力算法的完备性


比赛时如何根据解空间大小规模来筛选算法时间复杂度?

这是一个非常关键的实战技巧!在考场上,“看数据范围猜算法” 是每个 OIer 必须掌握的生存技能。它能帮你快速排除错误的思路,锁定正确的解法。

核心基准:计算机 1 秒钟大约能运行 12×1081 \sim 2 \times 10^8(1亿到2亿)次基本运算。

为了保险起见,我们通常把目标控制在 1075×10710^7 \sim 5 \times 10^7 次运算以内。


🌟 速查表:数据范围与时间复杂度对应关系

请把这张表刻在脑子里,拿到题目先看 NN 的大小:

数据规模 (NN) 推荐时间复杂度 对应算法/技巧举例 运算次数估算
N20N \le 20 O(2N)O(2^N)O(N!)O(N!) DFS 搜索、状态压缩 DP 2201062^{20} \approx 10^6 (安全)
N100N \le 100 O(N3)O(N^3) Floyd 最短路、区间 DP、高斯消元 1003=106100^3 = 10^6 (非常安全)
N1000N \le 1000 O(N2)O(N^2) 普通 DP、朴素最短路、二维前缀和 10002=1061000^2 = 10^6 (非常安全)
N104N \le 10^4 O(N2)O(N^2) (勉强) 同上,但要注意常数 10810^8 (危险,可能会卡)
N105N \le 10^5 O(NlogN)O(N \log N) 二分、归并/快速排序、线段树/树状数组、堆、Set/Map、Dijkstra 1.7×1061.7 \times 10^6 (非常安全)
N106N \le 10^6 O(N)O(N) 双指针、KMP、线性筛、单调队列、Hash 10610^6 (安全)
N107N \le 10^7 O(N)O(N) (常数要小) 线性筛、O(1) 读入优化 10710^7 (稍紧,不能有复杂逻辑)
N109N \ge 10^9 O(N)O(\sqrt{N}) 判断质数、分解质因数、BSGS 10931622\sqrt{10^9} \approx 31622 (安全)
N1018N \ge 10^{18} O(logN)O(\log N)O(1)O(1) 快速幂、GCD、数论结论、矩阵快速幂 log101860\log 10^{18} \approx 60 (秒杀)

🔧 实战演练:如何推导?

拿到题目后,按照以下步骤思考:

第一步:看题目给出的最大数据范围

通常在题目底部的“说明/提示”部分。

  • 案例 1:P2118 [比例简化]

    • 题目写着:L100L \le 100
    • 分析:解空间涉及两个数 A,BA', B'。如果是双重循环,复杂度是 O(L2)O(L^2)
    • 验证:1002=10,000100^2 = 10,000
    • 对比基准:10,00010810,000 \ll 10^8
    • 结论:可以直接暴力枚举,甚至三层循环 O(L3)O(L^3) 都能过。
  • 案例 2:P8813 [乘方]

    • 题目写着:a,b109a, b \le 10^9
    • 分析:如果写 for(int i=0; i<b; i++),复杂度是 O(b)O(b)
    • 验证:10910^9 次运算。
    • 对比基准:109>10810^9 > 10^8
    • 结论:暴力循环绝对 TLE(超时)。
    • 修正:必须找 O(logb)O(\log b) 的算法(快速幂)或者利用题目特殊性质(剪枝)变成 O(1)O(1)
  • 案例 3:常见的排序题

    • 题目写着:N105N \le 10^5
    • 分析:如果你写冒泡排序 O(N2)O(N^2)
    • 验证:(105)2=1010(10^5)^2 = 10^{10}
    • 对比基准:101010810^{10} \gg 10^8
    • 结论:冒泡排序必死。必须用 std::sort (NlogNN \log N) 或者桶排序 (NN)。

第二步:估算 1 秒能跑多少次

在脑子里默念:“一秒一亿次”

  • 把你的 NN 代入公式算一下。
  • 如果算出来的数是 10510710^5 \sim 10^7稳过
  • 如果算出来的数是 10810^8危险,可能需要卡常数(优化代码细节)或者看评测机快不快。
  • 如果算出来的数是 10910^9死定了,必须换算法。

⚠️ 两个重要的“坑”

1. 多个变量的乘积

有时候题目不只一个 NN,比如图论里的点数 VV 和边数 EE

  • Floyd 算法O(V3)O(V^3)。如果 V=1000V=1000,就是 10910^9,会 TLE。
  • SPFA / Dijkstra:跟边数 EE 有关。如果 V=105,E=105V=10^5, E=10^5,用 ElogVE \log V 没问题。

2. “多组数据” (Multi-testcases)

题目可能会说:“输入第一行有一个整数 TT,表示有 TT 组数据”。

  • 这时候你的总复杂度是:T×单次复杂度T \times \text{单次复杂度}
  • 如果 T=10,N=105T=10, N=10^5,你的算法是 O(N)O(N),总运算 10×105=10610 \times 10^5 = 10^6稳过
  • 如果 T=1000,N=103T=1000, N=10^3,你的算法是 O(N2)O(N^2),总运算 1000×(103)2=1091000 \times (10^3)^2 = 10^9超时

总结口诀

20 枚举搜状压 (N<=20 用 DFS/状压) 100 只要三次方 (N<=100 可以用 N^3) 千位两方万平方 (N<=1000 可以 N^2,N=1万 N^2 危险) 十万 N log 很正常 (N=10万 必须 N log N) 百万千万 O N 刚 (N=100万~1000万 必须 O(N)) 更大只能 log 狂 (N再大 只能 O(log N) 或 O(1))

在考场上,先看数据范围,再定算法,这能帮你省下大量走弯路的时间!



这里用到了 algorithm的 __gcd,请列表总结一下常用的algorithm方法和用法说明

你好!作为 OI 教练,我非常推荐你熟练掌握 <algorithm> 库。它是 C++ STL(标准模板库)中最强大的武器库之一。在考场上,手写快排、手写二分虽然是基本功,但直接调用这些经过千锤百炼、极致优化的标准函数,能帮你节省大量时间避免写出 Bug

需要注意的是,__gcd 实际上是 GCC 编译器的一个内建函数(非标准 C++,但在 CSP/NOIP 环境下通常可用),在 C++17 标准中,正式的写法是 std::gcd,包含在 <numeric> 头文件中。不过在竞赛中包含 <bits/stdc++.h> 后通常都能直接用。

下面为你总结竞赛中最常用的 <algorithm> 函数:

1. 数学与统计类(最常用)

函数名 用法示例 功能说明 复杂度
__gcd __gcd(a, b) 求最大公约数。如果需要最小公倍数,公式是 (a*b)/__gcd(a,b) O(logN)O(\log N)
max max(a, b) 返回较大值。也可以传多个:max({a, b, c, d}) (需C++11)。 O(1)O(1)
min min(a, b) 返回较小值。同上。
swap swap(a, b) 交换两个变量的值。比自己写 t=a; a=b; b=t; 快且不容易错。
abs abs(a) 求绝对值(注意:整数在 <stdlib.h>/<cmath>,浮点在 <cmath>,但在 <algorithm> 配合 <bits/stdc++.h> 通常都能混用)。

2. 数组极值查找(不用写循环了)

注意: 这些函数返回的是迭代器(指针),如果要获取数值需要加星号 *,如果要获取下标需要减去数组首地址。

函数名 用法示例 功能说明
max_element *max_element(a, a+n) 返回数组/容器中最大值
min_element *min_element(a, a+n) 返回数组/容器中最小值

教练演示:

int a[5] = {2, 5, 1, 9, 3};
int max_val = *max_element(a, a+5); // 结果是 9
int max_pos = max_element(a, a+5) - a; // 结果是 3 (下标)

3. 排序与去重(必考)

函数名 用法示例 功能说明 复杂度
sort sort(a, a+n) 默认从小到大排序。 O(NlogN)O(N \log N)
sort (cmp) sort(a, a+n, cmp) 自定义排序(如从大到小 greater<int>() 或 Lambda)。
reverse reverse(a, a+n) 将数组/字符串翻转(倒序)。 O(N)O(N)
unique unique(a, a+n) 去重(只能去除相邻的重复元素,所以必须先 sort)。

经典组合拳(排序+去重):

// 想要统计数组中有多少个不重复的数
sort(a, a+n); // 1. 先排序
int new_len = unique(a, a+n) - a; // 2. 去重,返回去重后的新长度
// 此时 a[0] 到 a[new_len-1] 就是不重复的元素

4. 二分查找(神器)

前提: 数组必须是有序的(通常是单调递增)。

函数名 用法示例 功能说明 返回值
binary_search binary_search(a, a+n, k) 判断 kk 是否存在。 bool (true/false)
lower_bound lower_bound(a, a+n, k) 第一个 k\ge k 的位置。 返回迭代器
upper_bound upper_bound(a, a+n, k) 第一个 >k> k 的位置。

教练演示(求下标):

int a[5] = {1, 2, 4, 4, 6};
// 找第一个 >= 4 的位置下标
int idx1 = lower_bound(a, a+5, 4) - a; // 结果是 2
// 找第一个 > 4 的位置下标
int idx2 = upper_bound(a, a+5, 4) - a; // 结果是 4

5. 全排列(暴力枚举帮手)

函数名 用法示例 功能说明 复杂度
next_permutation next_permutation(a, a+n) 将数组变成字典序下的下一个排列。 O(N)O(N)

典型用法:

int a[3] = {1, 2, 3};
do {
    // 输出当前排列:1 2 3 -> 1 3 2 -> ... -> 3 2 1
} while (next_permutation(a, a+3));

6. 赋值与填充

函数名 用法示例 功能说明
fill fill(a, a+n, val) 将数组区间全部赋值为 val
memset memset(a, 0, sizeof(a)) 注意:<cstring> 头文件。只能赋值 0-1,或者字符。赋值其他整数请用 fill

教练的总结建议

  1. 头文件:考试时直接写 #include <bits/stdc++.h>,上面这些全都有,不用记具体在哪个头文件里。
  2. 迭代器理解sort, unique, lower_bound 等函数操作的都是左闭右开区间 [begin, end)。对于数组 a,开始是 a,结束是 a+n(下标为 n 的那个位置,实际取不到)。
  3. 一定要排序:用 uniquelower_boundbinary_search 之前,千万记得先 sort,否则结果是错的!

熟练使用这些函数,你的代码会从几十行缩减到几行,而且跑得飞快!


上面的心得还有什么可以补充的?

你的心得总结已经非常全面和详细了!我来提供一些可以补充的内容,让你的总结更加完整:

一、关于解空间的补充

  1. 解空间剪枝策略

    • 虽然在这道题中暴力枚举完全可行,但在更复杂的问题中,我们可以通过一些条件提前剪枝,比如对于每个分母j,我们可以直接计算最小的满足i/j >= a/b的i值,然后只检查这个i和附近的几个值,而不是枚举所有i
    • 例如:对于给定的j,最小的i是ceil((a*j)/b),这样可以将双重循环优化为接近O(L)的复杂度
  2. 解空间结构多样性

    • 除了本题的二维网格解空间外,不同类型问题有不同的解空间结构:
      • 排列问题:解空间是n!种可能
      • 子集问题:解空间是2^n种可能
      • 组合问题:解空间是C(n,k)种可能
      • 树问题:解空间是树形结构

二、关于精度问题的补充

  1. 浮点数比较的epsilon技巧

    • 当需要比较两个浮点数是否相等时,更好的做法是检查它们的差的绝对值是否小于一个很小的数(epsilon)
    • 例如:if(fabs(a - b) < 1e-9) 而不是 if(a == b)
  2. 整数运算与浮点数运算的权衡

    • 在本题中,使用交叉相乘进行比较(ib >= aj)是避免浮点数精度问题的最佳选择
    • 当必须使用浮点数时,可以采用高精度运算库或自定义分数类来完全避免精度问题

三、关于gcd函数的补充

  1. 自定义gcd函数实现

    • 提供一个可靠的自定义gcd函数实现,确保代码的可移植性
    • 可以加入对负数的处理(取绝对值后再计算)
  2. C++17标准gcd函数

    • C++17中提供了标准的std::gcd函数,位于<numeric>头文件
    • 使用方式:std::gcd(a, b),注意传入的参数应为非负数

四、关于题目解法的补充

  1. 更高效的枚举策略

    • 对于每个分母j,我们可以直接计算最小的满足i/j >= a/b且gcd(i,j)=1的i值
    • 这样可以减少内层循环的迭代次数,特别是当L较大时
  2. 数学方法:Farey序列

    • Farey序列是0到1之间的最简分数序列,按照递增顺序排列
    • 对于本题,可以考虑在Farey序列中寻找最接近a/b的分数
    • 这种方法在L较大时更高效

五、代码优化技巧

  1. 减少重复计算

    • 在循环中避免重复计算a*b等固定值
    • 可以将计算结果缓存起来
  2. 优化常数

    • 将内层循环的顺序调换(先j后i)可能会利用缓存的局部性原理
    • 避免在循环内进行不必要的函数调用或对象创建
  3. 处理边界情况

    • 添加对特殊输入的处理,如当a=0时
    • 确保初始值的设置正确,尤其是当没有任何满足条件的解时

六、算法思维拓展

  1. 贪心与暴力的结合

    • 在某些情况下,可以先用贪心策略缩小解空间,再用暴力枚举
    • 例如:先找到粗略的最优解,再在其附近进行精细搜索
  2. 问题转化思维

    • 思考如何将问题转化为更容易解决的形式
    • 如本题可以转化为寻找最小的i/j - a/b,这等价于寻找最小的(ib - aj)/(j*b)

这些补充内容应该能让你的总结更加全面和深入,特别是在算法思维和代码优化方面。你的心得已经写得非常好,这些补充只是起到锦上添花的作用。

章节 1. 解空间为数组

开放

题目 尝试 AC 难度
3585   比例简化 1 1 10
3810   龙虎斗 2 1 10