跳转至

循环枚举

算法简介

在算法竞赛中,枚举(Enumeration)是一种基础而直观的方法,通过遍历所有可能的解空间来寻找满足条件的答案。

虽然枚举的时间复杂度往往较高,但在约束较小或加入剪枝(Pruning)后,枚举依然是解决诸多组合问题的利器。

示例场景

破解四位数字密码,可依次尝试 $0000\sim 9999$ 的所有组合;


循环枚举的基本思路

  • 确定变量与范围:明确需要枚举的变量数量及各自取值区间。

  • 设计嵌套循环:循环层数 = 变量个数,内层循环在外层每种取值下都要完整执行。

  • 验证/计算:在最内层循环中,使用当前组合进行判断、计数或累加等操作。

  • 剪枝优化(可选):若部分组合可提前判定不符合条件,可在循环中加入 if 语句跳过后续无效枚举,以降低实际运行次数。


例题1:鸡兔同笼

已知鸡和兔共 $35$ 只,脚的总数为 $94$。求鸡和兔的数量。

思路:设鸡 $x$ 只,兔 $y$ 只,则

\[\begin{cases} x + y = 35\\ 2x + 4y = 94 \end{cases}\]

枚举 $x,y$,并判断 $x+y=35,2x+4y=94$ 是否成立。

  • $x$ 的范围:$0 \leq x \leq 35$。
  • $y$ 的范围:$0 \leq y \leq 35$。

因此需要两个循环嵌套枚举所有 $x$ 和 $y$ 的组合。

for (int x = 0; x <= 35; x++) 
{
    for (int y = 0; y <= 35; y++) 
    {
        if (x + y == 35 && 2 * x + 4 * y == 94) 
        {
            cout << "鸡: " << x << ", 兔: " << y;
            return 0; // 找到解后退出
        }
    }
}

由此可见很多方程问题都是可以用枚举算法解决的。


应用:[GESP202406 二级] 平方之和

题意:判断 $n$ 个方程 $x^2+y^2=a_i$ 是否有正整数解。

  • $1\leq n\leq 10$,$1\leq a_i\leq 10^6$。

思路:

类似于鸡兔同笼,一共有两个未知数。因此考虑枚举 $x$ 和 $y$。

  • $x$ 的范围:$1 \leq x \leq a_i$。
  • $y$ 的范围:$1 \leq y \leq a_i$

这是朴素想法,但时间复杂度过高。这样做最坏时间复杂度为 $O(a_i^2)$ 最坏达到 $10^{12}$ 次计算,从而 TLE。

我们需要减少一些不必要的枚举,降低循环次数。

不难推出 $x^2\leq a_i\leq 10^6$,因此 $x$ 的范围可以缩小为 $x \leq 10^3$。同理推出 $y\leq 10^3$。

这样只需要循环 $10^6$ 次,可以在规定时间内解决答案。

应用:[COCI2017-2018#6] Davor

本题的本质也是一个解方程问题。

有两个未知数 $x,k$,需要解决的方程是:

\[\begin{aligned} &52\times (x+x+k+x+2k+x+3k+x+4k+x+5k+x+6k)=N\\ &52\times (7x + 21k)=N \end{aligned}\]

因此考虑枚举 $x,k$ 即可。

  • $x$ 的范围:$1 \leq x \leq 100$。(题目已知)
  • $k$ 的范围:$1\leq k\leq 200$。
    • $k$ 的上限可以根据数据范围推测得知:
    • $N$ 最多取值 $145600$,因此 $k$ 的上限为 $\frac{145600}{52 \times 21} \approx 133$。
    • 所以 $k$ 的上限其实很小,随便写一个即可。

注意 $x$ 尽可能大,$k$ 尽可能小。因此倒序枚举 $x$,正序枚举 $y$。


例题2:三位数回文数

找出所有三位数中正读和反读都相同的数字。

思路:三位数 $abc$ 是回文数当且仅当 $a = c$。

因此可以将所有的三位数 $100\sim 999$ 全部枚举出来,并判断百位和个位是否相等。

#include <bits/stdc++.h>
using namespace std;

int main() 
{
    for (int i = 100; i < 1000; ++i) 
    {
        int a = i / 100; // 百位
        int b = (i / 10) % 10; // 十位
        int c = i % 10; // 个位
        if (a == c) // 判断回文
        { 
            cout << i << " ";
        }
    }
    return 0;
}

应用:[ABC343C] 343

朴素做法

倒序枚举 $n\sim 1$ 的所有整数记作为 $x$。

  • 检验 $x$ 是不是立方数。
  • 检验 $x$ 是不是回文数。

显然会 TLE,即使可以做到 $O(1)$ 的时间复杂度检验立方数和回文数,也会因为枚举 $n\sim 1$ 的所有整数而超时。

优化做法

由于 $(10^6)^3=10^{18}$,且由于 $x$ 是立方数,必然存在一个正整数 $k$ 使得 $k^3=x$。

那么可以推出 $1\leq k\leq 10^6$。

因此枚举 $k$,通过 $k^3$ 算出 $x$ 才是解题的关键所在!

for (int k = 1; k <= 1e6; k++)
{
    long long x = 1ll * k * k * k;
    // 1ll 是 long long 类型的 1,避免三个 int 的 k 相乘爆 int 
    if (x >= n) break;
    // 接下来的工作只有检验 x 是不是回文数
}

接下来验证 $x$ 是否是回文数。

检验回文数可以通过数位拆分实现。

bool check(long long x)
{
    long long s = x, y = 0;
    while (x) 
    {
        y = y * 10 + s % 10;
        s /= 10;
    }
    return x == y;
}
  • s % 10 获取 $s$ 的个位。
  • y = y * 10 + s % 10 将个位添加到 $y$ 的末尾。

    • 首先乘以 $10$ 相当于将 $y$ 左移一位。例如:$12\times 10=120$,即创造一个个位。
    • 然后将个位添加到 $y$ 的末尾。例如:$120+3=123$。
  • 时间复杂度:$O(\log{x})$。

那么本题最终通过枚举 $k$ 和检验回文数,计算量大约为 $10^6\times \log{10^{18}}$ 不会 TLE。


常见的枚举套路

区间枚举型

示例问题一 给定一个长度为 $n$ 的序列 $a$,求有多少个长度为 $m$ 的区间,满足区间和是偶数。

  • $1\leq m\leq n\leq 1000$,$1\leq a_i\leq 10^9$。

解题思路

考虑枚举出所有的长度为 $m$ 的区间,并一一求和检验区间和是否为偶数即可。

对于一个长度为 $m$ 的区间,其左端点如果为 $i$,那么右端点 $j$ 则为 $i + m - 1$。

  • 因此一旦确定左端点 $i$,右端点 $j$ 也就确定了。

所以枚举左端点 $i$:

  • 左端点 $i$ 最小取值为 $1$,最大取值为 $n - m + 1$。

为何左端点的上限是 $n - m + 1$? 因为区间长度是 $k$,所以左端点不能超过 $n - m + 1$,否则右端点会超出边界。

for (int i = 1; i <= n - m + 1; i++)
{
    int j = i + m - 1; // 右端点
    // 准备计算区间和
}

接下来计算区间 $[i,j]$ 的和,然后判断其是否为偶数即可。

时间复杂度:$O(nm)$

int count = 0; // 计数器
for (int i = 1; i <= n - m + 1; i++)
{
    int j = i + m - 1; // 右端点
    long long sum = 0; // 初始化区间和
    for (int k = i; k <= j; k++) 
    {
        sum += a[k]; // 累加区间和
    }
    if (sum % 2 == 0) 
    {
        count++; // 如果区间和是偶数,计数器加一
    }
}

示例问题二 给定一个长度为 $n$ 的序列 $a$,求有多少个区间 $[l, r]$ 满足 区间的和 $a_l+a_l+...+a_r$ 是一个偶数。

  • $n\leq 100$,$1\leq a_i\leq 10^9$。

解题思路

这是典型的区间枚举问题。可以枚举所有可能的区间 $[l, r]$,然后判断该区间的和是否为偶数。

一个区间靠左右端点的值来进行确定。

具体来说:

  • 枚举左端点 $l$,从 $1$ 到 $n$。
  • 枚举右端点 $r$,从 $l$ 到 $n$。
    • 右端点 $r$ 需满足 $\geq l$。
for (int l = 1; l <= n; l++) 
    for (int r = l; r <= n; r++) 
  • 计算区间和 $a[l] + a[l+1] + ... + a[r]$。
    • 定义 $sum$ 初始化为 $0$。
    • 遍历区间 $[l,r]$,执行:$sum \leftarrow sum + a[i]$。
    • 判断 $sum$ 是否为偶数。
  • 若 $sum$ 为偶数,则计数器加一。

时间复杂度:$O(n^3)$。

for (int l = 1; l <= n; l++) 
{
    for (int r = l; r <= n; r++) 
    {
        int sum = 0; // 初始化区间和
        for (int i = l; i <= r; i++) 
        {
            sum += a[i]; // 累加区间和
        }
        if (sum % 2 == 0) 
        {
            count++; // 如果区间和是偶数,计数器加一
        }
    }
}

本题可以做到 $O(n)$ 感兴趣的同学可以自行研究。

应用:[GESP202403 三级] 完全平方数

这里所有下标组合 $(i,j)$ 类似于区间枚举。

  • 枚举左端点 $i$,从 $1$ 到 $n$。
  • 枚举右端点 $j$,从 $i+1$ 到 $n$。

    • 题目要求右端点 $j>i$。
  • 接下来令 $x=a_i+a_j$。判断 $x$ 是否为完全平方数。

如何判断完全平方数?

  • 使用 sqrt 函数判断 $x$ 是否为完全平方数。
bool check(int x)
{
    int y = sqrt(x);
    return y * y == x;
}

解析:

  • sqrt(x) 返回 $x$ 的平方根。并向下取证保存到 $y$ 中。
  • 若 $y^2=x$,则 $x$ 是完全平方数。
    • 说明 $\sqrt{x}$ 没有小数部分。

最终整体时间复杂度为:$O(n^2)$。


应用:[NOIP2014 普及组] 珠心算测验

题目要求有多少个数字等于另外两个(不同的)数之和。

等价于找到三个下标组合 $(i,j,k)$ 满足:

  • $i\not= j\not= k$
  • $a_i+a_j=a_k$ 或 $a_i+a_k=a_j$ 或 $a_j+a_k=a_i$

核心: 从小到大排序整个序列不会影响答案的求解。因此考虑升序排序整个序列 $a$。

则问题等价于求解:

  • $1\leq i<j<k\leq n$。
  • $a_i+a_j=a_k$

解题思路

  • 枚举 $i$,从 $1$ 到 $n$。
  • 枚举 $j$,从 $i+1$ 到 $n$。
  • 枚举 $k$,从 $j+1$ 到 $n$。
    • 判断 $a_i+a_j=a_k$。

注意:本题只是求解有多少个数可以被两外两个不同的数字加出来,一个数字可能被多次加出来,应当计算一次。

例如 $a=[1,2,3,4,5]$,$5=1+4$,$5=2+3$。这样会重复计算。

因此可以使用一个值域数组打标记,定义 bool vis[10005] 给每个算出来的数字打标记。

时间复杂度:$O(n^3)$。

\[ \begin{array}{ll} 1 & \textbf{sort(A)}\\ 2 & \textbf{for } i\gets 1\textbf{ to }n\\ 3 & \qquad \textbf{for } j\gets i+1\textbf{ to }n\\ 4 & \qquad \qquad \textbf{for } k\gets j+1\textbf{ to }n\\ 5 & \qquad \qquad \qquad \textbf{if } A[i]+A[j] = A[k] \textbf{ and } vis[A[k]] = 0\\ 6 & \qquad \qquad \qquad \qquad \textbf{cnt}\gets \textbf{cnt}+1\\ 7 & \qquad \qquad \qquad \qquad \textbf{vis[A[k]]}\gets 1 \end{array}\]

矩阵枚举型

示例问题一 给定一个 $n \times m$ 的矩阵,求有多少个 $a \times b$ 的子矩阵满其元素之和不超过 $k$。

其中 $1\leq a\leq n\leq 10^2,1\leq b\leq m\leq 10^2$,$1\leq k\leq 10^9$,$1\leq a_{i,j}\leq 10^7$。

解题思路

这是一个典型的矩阵枚举问题。可以枚举所有可能的 $a \times b$ 子矩阵,然后判断该子矩阵的元素之和是否不超过 $k$。

一个 $a\times b$ 的子矩阵一旦确定了左上角的坐标,那么其右下角的坐标也就确定了。

因此确定左上角的坐标是关键所在。(当然反过来确定其他角的坐标也可以)

步骤如下:

  • 枚举左上角坐标:枚举子矩阵的左上角坐标 $(i, j)$,其中 $1 \leq i \leq n - a + 1$,$1 \leq j \leq m - b + 1$。

为何左上角的上限是 $n - a + 1$ 和 $m - b + 1$? 因为子矩阵的大小是 $a \times b$,所以左上角的坐标不能超过 $n - a + 1$ 和 $m - b + 1$,否则子矩阵会超出边界。

for (int x1 = 1; x1 <= n - a + 1; x1++)
{
    for (int y1 = 1; y1 <= m - b + 1; y1++)
    {
        // 确定左上角坐标 (x1, y1)
        // 由于子矩阵大小为 a x b,右下角坐标为 (x1 + a - 1, y1 + b - 1)
        int x2 = x1 + a - 1;
        int y2 = y1 + b - 1;
    }
}
  • 计算子矩阵和:对于每个左上角坐标 $(x1, y1)$,计算对应的 $a \times b$ 子矩阵的元素之和。
long long sum = 0; // 初始化子矩阵和
for (int i = x1; i <= x2; i++)
{
    for (int j = y1; j <= y2; j++)
    {
        sum += A[i][j]; // 累加子矩阵元素
    }
}
if (sum <= k) // 判断子矩阵和是否不超过 k
{
    count++; // 如果满足条件,计数器加一
}
  • 时间复杂度:$O(n\cdot m\cdot a\cdot b)$,其中 $a$ 和 $b$ 分别是子矩阵的行数和列数。

示例问题二 给定一个 $n \times m$ 的矩阵,求有多少个 子矩阵满其元素之和不超过 $k$。

其中 $1\leq n,m\leq 10^2$,$1\leq k\leq 10^9$,$1\leq a_{i,j}\leq 10^7$。

解题思路

这是一个典型的矩阵枚举问题。可以枚举所有可能的子矩阵,然后判断该子矩阵的元素之和是否不超过 $k$。

和上一题的主要区别在于,这里不再限定子矩阵的大小,而是可以是任意大小的子矩阵。

因此可以枚举所有可能的左上角和右下角坐标。

步骤如下:

  • 枚举左上角坐标 $(x_1, y_1)$,其中 $1\leq x_1 \leq n$,$1 \leq y_1 \leq m$。
  • 枚举右下角坐标 $(x_2, y_2)$,其中 $x_1\leq x_2 \leq n$,$y_1 \leq y_2 \leq m$。

    • 保证右下角的行和列大于或等于左上角的。
  • 接下来遍历子矩阵,计算子矩阵的元素之和。

  • 判断子矩阵的和是否不超过 $k$,如果满足条件,则计数器加一。

时间复杂度:$O(n^3 \cdot m^3)$。

for (int x1 = 1; x1 <= n; x1++) 
{
    for (int y1 = 1; y1 <= m; y1++) 
    {
        for (int x2 = x1; x2 <= n; x2++) 
        {
            for (int y2 = y1; y2 <= m; y2++) 
            {
                long long sum = 0; // 初始化子矩阵和
                for (int i = x1; i <= x2; i++) 
                {
                    for (int j = y1; j <= y2; j++) 
                    {
                        sum += A[i][j]; // 累加子矩阵元素
                    }
                }
                if (sum <= k) // 判断子矩阵和是否不超过 k
                {
                    count++; // 如果满足条件,计数器加一
                }
            }
        }
    }
}

应用:[GESP202406 四级] 黑白方块

注意:输入的矩阵都是紧紧挨着一起的字符,因此需要使用 char 类型的二维数组。

这是一个典型的子矩阵枚举问题。题目并未规定子矩阵的大小。因此,我们可以枚举所有可能的子矩阵,并判断子矩阵中黑色格子与白色格子数量是否一致即可。

解法类似于 示例问题二。不再赘述。


应用:First Step

本题可以采用枚举法解决。

具体思路是:

  • 枚举所有空地 $(i,j)$。(通过双重循环和 if 语句枚举)
  • 歌唱队形要么是以 $(i,j)$ 向右站 $k$ 个人,要么以 $(i,j)$ 向下站 $k$ 个人。
    • 不考虑向上和向左,会重复计算。

因此进一步向右遍历,检查是否存在至少连续 $k$ 个 .

以及向下遍历检查是否存在至少连续 $k$ 个 .

若存在则 sum = sum + 1 即可。

注意:当 $k=1$ 向下向右会把一个格子连续算两次,需要对答案除以 $2$。

时间复杂度:$O(r\cdot c\cdot (r+c))$。

for (int i = 1; i <= r; i++)
{
    for (int j = 1; j <= c; j++)
    {
        if (a[i][j] == '#') continue;
        int sum = 0;
        for (int l = j; l <= c; l++)
        {
            if (a[i][l] == '#') break;
            sum++;
        }
        if (sum >= k) ans++;
        sum = 0;
        for (int l = i; l <= r; l++)
        {
            if (a[l][j] == '#') break;
            sum++;
        }
        if (sum >= k) ans++;
    }
}
if (k == 1) cout << ans / 2;
else cout << ans;

其余习题

[NOIP2018 普及组] 龙虎斗

可以发现 $s_1$ 个人出现在 $p_1$ 没什么用,直接令 c[p1] += s1 即可。

因此问题的关键在于 $s_2$ 个人派到何处,使得两边差距的绝对值最小。

注意到 $s_2$ 个人不管是派给龙还是中立还是派给虎。只是新增了一部分气势而已。

  • 例如派给龙,假设派到了 $i$ 号位置。
  • 则龙的气势在原有的基础上新增了 $s_2\times (m-i)$ 而已。

因此维护这个增量就可以了。

具体思路:

  • 初始化先求出 $s_2$ 个人没有派往任何一个地方时,龙和虎各自的势力值。
    • 通过循环遍历即可求得。
\[ \begin{array}{ll} 1 & c[p_1] \gets c[p_1] +s_1\\ 2 & \text{dragon} \gets 0,\text{tiger} \gets 0\\ 3 & \textbf{for } i\gets 1\textbf{ to }n\\ 4 & \qquad \textbf{if } i<m \\ 5 & \qquad \qquad \text{dragon}\gets \text{dragon}+c_i\times (m-i)\\ 6 & \qquad \textbf{else }\\ 7 & \qquad \qquad \text{tiger}\gets \text{tiger}+c_i\times (i-m) \end{array}\]

完成初始化工作后,接下来就是枚举 $s_2$ 个人派到每一个位置 $i$ 对龙和虎产生的影响来计算最终答案。

初始化 $ans=|\text{dragon}-\text{tiger}|$,以及 $\text{pos}=m$。

  • 分别记录双方差值的绝对值以及位置。(假设派到 $m$ 最优)
  • 求绝对值可以用 abs 函数。

接下来枚举 $i$,其中 $i\in [1,n]$。

  • 当 $i<m$,则派给了龙。
    • 若 $|\text{dragon}+s_2\times (i-m)-\text{tiger}|<ans$。
    • 则更新 $ans$,同时记录 $pos=i$。
  • 当 $i>m$ 处理方式同上。

最终只需要在 $O(n)$ 的时间内即可求出答案,注意 long long