技术笔记:算法1-3 暴力枚举(NOIP经典真题解析)
暴力枚举是算法竞赛中最基础、最直接的解题思路,它通过穷举所有可能的情况来寻找答案,尤其适合解决那些规模不大、或者可以通过剪枝优化的问题。本文将通过四道洛谷经典真题,讲解暴力枚举在不同场景下的应用和优化技巧。
一、棋盘方格(数学推导优化暴力)
题目核心要求
给定一个 n×m 的棋盘,计算其中包含的正方形总数和长方形(不包含正方形)的总数。
解题思路
这道题看似是暴力枚举,但可以通过数学公式直接推导,避免了低效的双重循环:
- 正方形数量 :对于边长为 k 的正方形,在 n×m 的棋盘上有
(n-k+1) * (m-k+1)个。因此,总数是对 k 从 1 到min(n,m)的求和,即sum_{k=1 to min(n,m)} (n-k+1)*(m-k+1)。 - 长方形数量 :所有矩形(包括正方形)的总数是
n*(n+1)/2 * m*(m+1)/2。用这个值减去正方形的数量,即可得到纯长方形的数量。
完整Java代码
java
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
long n = sc.nextLong();
long m = sc.nextLong();
sc.close();
// 计算正方形的数量
long squares = 0;
long min = Math.min(n, m);
for (long k = 1; k <= min; k++) {
squares += (n - k + 1) * (m - k + 1);
}
// 计算所有矩形(含正方形)的数量
long rectanglesTotal = (n * (n + 1) / 2) * (m * (m + 1) / 2);
// 长方形数量 = 所有矩形 - 正方形
long rectangles = rectanglesTotal - squares;
System.out.println(squares + " " + rectangles);
}
}
关键注意点
- 数据类型溢出 :由于 n 和 m 最大为 5000,计算结果会非常大,必须使用
long类型来存储中间结果和最终答案。 - 数学公式的效率:使用数学公式将时间复杂度从 O(n*m) 降低到 O(min(n,m)),效率大幅提升。
- 问题转换:将求"长方形"的问题转换为求"所有矩形"减去"正方形",是解题的关键技巧。
二、比例三位数(全排列 + 剪枝)
题目核心要求
将 1-9 这九个数字分成三组,组成三个三位数,使它们的比例为 A:B:C。输出所有满足条件的解,若无解则输出 No!!!。
解题思路
这是一个典型的暴力枚举问题,但可以通过比例关系进行剪枝,避免无效的枚举:
- 枚举第一个数:遍历所有可能的第一个三位数 i。
- 计算另外两个数 :根据比例关系计算出另外两个数 j = iB/A 和 k = iC/A。
- 合法性检查 :
- 检查 j 和 k 是否为有效的三位数。
- 检查 i, j, k 这三个数的拼接是否恰好包含 1-9 每个数字一次,没有重复或遗漏。
- 输出结果:如果检查通过,则输出这三个数。
完整Java代码
java
import java.util.Scanner;
public class Main2 {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int A = sc.nextInt();
int B = sc.nextInt();
int C = sc.nextInt();
sc.close();
boolean found = false;
// 遍历第一个三位数
for (int i = 100; i < 1000; i++) {
// 剪枝:如果不能整除,直接跳过
if (i * B % A != 0 || i * C % A != 0) continue;
int j = i * B / A;
int k = i * C / A;
// 检查另外两个数是否也是三位数
if (j >= 1000 || k >= 1000) continue;
// 检查数字 1-9 是否恰好出现一次
String s = "" + i + j + k;
if (s.length() != 9) continue;
boolean[] digits = new boolean[10];
boolean valid = true;
for (char c : s.toCharArray()) {
int num = c - '0';
if (num == 0 || digits[num]) {
valid = false;
break;
}
digits[num] = true;
}
if (valid) {
System.out.println(i + " " + j + " " + k);
found = true;
}
}
if (!found) {
System.out.println("No!!!");
}
}
}
关键注意点
- 剪枝优化 :在计算 j 和 k 之前,先检查
i * B % A != 0,可以过滤掉大量不可能的情况,显著提高效率。 - 数字唯一性验证:使用一个布尔数组来跟踪 1-9 是否都被使用过,比字符串包含检查更高效。
- 输出顺序:由于 i 是从小到大枚举的,输出的解自然是按第一个数升序排列的,满足题目要求。
三、组合数的输出(递归生成组合)
题目核心要求
从自然数 1, 2, ..., n 中任取 r 个数,按字典序输出所有组合。
解题思路
这是一个典型的组合生成问题,可以用递归的方式进行暴力枚举:
- 递归定义:定义一个递归函数,参数包括当前已选数字的位置、下一个可选数字的起始值,以及存储当前组合的数组。
- 递归终止条件:当已选数字的数量等于 r 时,输出当前组合。
- 递归过程:从起始值开始,依次选择一个数字加入当前组合,然后递归调用函数,选择下一个数字(起始值加一)。
完整Java代码
java
import java.util.Scanner;
public class Main3 {
public static void main(String[] args) {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
int r = sc.nextInt();
sc.close();
generate(n, r);
}
public static void generate(int n, int r) {
int[] result = new int[r];
find(n, r, 0, 1, result);
}
public static void find(int n, int r, int pos, int begin, int[] result) {
// 递归终止条件:选够了r个数
if (pos == r) {
for (int num : result) {
System.out.printf("%3d", num);
}
System.out.println();
return;
}
// 递归过程:从begin开始选下一个数
for (int i = begin; i <= n; i++) {
result[pos] = i;
find(n, r, pos + 1, i + 1, result);
}
}
}
关键注意点
- 避免重复组合 :通过
begin参数确保每次选择的数字都比前一个大,从而保证组合的唯一性和字典序。 - 格式化输出 :使用
printf("%3d", num)确保每个数字占三个字符的宽度,满足题目对输出格式的严格要求。 - 递归的效率:这种方法直接生成符合要求的组合,时间复杂度为 O(C(n,r)),在题目给定的 n ≤ 20 的约束下是完全可行的。
四、全排列(回溯法生成排列)
题目核心要求
按字典序输出自然数 1 到 n 的所有不重复的全排列。
解题思路
这是一个经典的回溯法问题,通过暴力枚举所有可能的排列来解决:
- 回溯定义:定义一个递归函数,参数包括当前排列的位置、存储当前排列的数组,以及标记数字是否被使用过的布尔数组。
- 回溯终止条件:当当前排列的位置等于 n 时,输出当前排列。
- 回溯过程:遍历所有数字,如果该数字未被使用,则将其加入当前排列,标记为已使用,然后递归调用函数填充下一个位置。递归返回后,回溯(取消标记),尝试下一个数字。
完整Java代码
java
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.util.Scanner;
public class Main4 {
// 使用 BufferedWriter 进行高效输出
private static final BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(System.out));
public static void main(String[] args) throws IOException {
Scanner sc = new Scanner(System.in);
int n = sc.nextInt();
sc.close();
int[] current = new int[n];
boolean[] used = new boolean[n + 1];
backtrack(n, 0, current, used);
// 确保所有数据都被写出
writer.flush();
writer.close();
}
private static void backtrack(int n, int pos, int[] current, boolean[] used) throws IOException {
// 回溯终止条件:生成了一个完整的排列
if (pos == n) {
StringBuilder lineSb = new StringBuilder();
for (int num : current) {
lineSb.append(" ").append(num);
}
writer.write(lineSb.toString());
writer.newLine();
return;
}
// 回溯过程:尝试所有未被使用的数字
for (int i = 1; i <= n; i++) {
if (!used[i]) {
used[i] = true;
current[pos] = i;
backtrack(n, pos + 1, current, used);
used[i] = false; // 回溯
}
}
}
}
关键注意点
- 输出效率 :由于 n 最大为 9,会生成 362880 个排列。使用
BufferedWriter替代System.out可以大幅提高输出效率,避免超时。 - 回溯的核心 :
used[i] = false这一步是回溯的关键,它确保在尝试完一个分支后,能够回到上一步,尝试其他可能的选择。 - 字典序保证:由于我们是从小到大遍历数字,因此生成的排列自然是按字典序排列的,满足题目要求。