Skip to content

可莉学数学 - 题解

标签与难度

标签: 字符串, 枚举, 模拟, 数学, 大整数处理 难度: 2100

题目大意喵~

呜喵~ 旅行者,你好呀!可莉又被关禁闭了,这次是因为数学题... 我们要帮帮她,这样她才能早点出来玩!

题目是这样的:有一个神奇的无限小数,它的样子是 0.012345678910111213...,小数部分是把所有自然数(从0开始哦)一个接一个拼起来组成的。

丽莎阿姨会问 q 次,每次给出一个数字字符串 x,我们需要找到这个字符串 x 第一次 在这个无限小数的小数部分出现时,它的起始位置是小数点后的第几位。

比如说,对于 0.0123... 这个小数:

  • 0 出现在第 1 位。
  • 1 出现在第 2 位。
  • 8910 这个串,是由数字 8910 拼接起来的一部分,它第一次出现的位置就是 8 的起始位置,也就是第 9 位。

我们要做的就是写一个程序,来回答丽莎阿姨的所有问题,帮可莉解决这个大麻烦,喵~

解题思路分析

这道题看起来是在一个无限长的字符串里找东西,感觉有点吓人,但别怕,我带你一步步把它分解开,呐!

这个无限长的小数串 S 是由 0, 1, 2, 3, ... 这样有序的数字拼接起来的。我们要找的查询字符串 x,它在 S 中的出现方式,仔细想想,无非就下面几种情况:

  1. x 恰好是某个数字 N 的一部分。比如 x="23" 是数字 123 的一部分。
  2. x 恰好是某个完整的数字 N。比如 x="123" 就是数字 123
  3. x 跨越了两个或多个数字。比如 x="910" 是数字 9 的末尾和数字 10 的开头拼成的。

这么多可能性,要怎么找才能找到第一次出现的那个位置呢?第一次出现,意味着它一定是由尽可能小的数字构成的。所以,我们可以从这个角度入手!

任何一个 x 的出现,我们都可以看作是 ... str(N-1) + str(N) + str(N+1) ... 这个序列的一部分。x 在这个序列里,要么完全包含在某个 str(N) 中,要么跨越了 N-1N 的边界,要么是 str(N)str(N+1) 的拼接,等等。

一个非常直接且有效的思路是:枚举查询字符串 x 中,哪一部分是它遇到的第一个完整的数字

听起来有点绕?让我举个例子!假设查询 x = "99100"

  • 它可能是由 (某个数字的后缀) + 100 + (后续数字的前缀) 组成的。这里的 100 就是它遇到的第一个完整数字。
  • 它也可能是由 99 + 100 组成的。这里的 99 是它遇到的第一个完整数字。

所以,我们可以遍历 x 的所有子串,并假设这个子串就是 x 在无限小数串中遇到的第一个完整的数字,我们称之为 N

设查询字符串 x 的长度为 len。我们用 ij 来表示 x 的一个子串 x.substr(i, j),其中 i 是起始位置,j 是长度。

对于每一个子串 sub = x.substr(i, j)

  1. 我们把它当做第一个完整数字 N。所以,N = stoll(sub)。(如果 sub 以 '0' 开头且长度大于1,那它就不是一个合法的数字表示,可以直接跳过,喵~)

  2. 现在我们有了假设的数字 N,那么 x 中在 N 前面的部分 x.substr(0, i),就必须是数字 N-1 的后缀。

    • 我们可以生成 N-1 的字符串 str(N-1)
    • 检查 x.substr(0, i) 是不是 str(N-1) 的后缀。如果不是,或者长度对不上,说明我们的假设是错的,就继续尝试下一个子串。
    • 特别注意:如果 N=0,它前面没有数字了,所以 x 的前面必须是空的,即 i 必须等于 0
  3. 同理,x 中在 N 后面的部分 x.substr(i+j),就必须是 str(N+1) + str(N+2) + ... 这个序列的前缀。

    • 我们可以从 N+1 开始,一个一个生成数字的字符串,然后和 x.substr(i+j) 进行匹配。
    • 如果中途有任何不匹配,说明这个假设也是错的。
  4. 如果以上两步检查都通过了,恭喜!我们找到了一个 x 的合法出现方式!它的起始位置是什么呢?

    • x 的出现,是从 N-1 的某个后缀开始的。
    • 我们需要一个辅助函数 getPosition(k),用来计算数字 k 在无限小数串中的起始位置(1-based)。
    • 那么 N 的起始位置是 getPosition(N)
    • 我们的 xNi 个字符开始,所以 x 的起始位置就是 getPosition(N) - i
    • 我们把这个位置记录下来,作为候选答案。

我们遍历完所有可能的子串 x.substr(i, j) 作为 N 的情况,取所有候选答案中最小的那个,就是最终的答案啦!

如何计算 getPosition(k) 呢? 这个也简单!我们可以分段计算:

  • 1位数字(0-9)有10个,总长度是 10 * 1 = 10
  • 2位数字(10-99)有90个,总长度是 90 * 2 = 180
  • 3位数字(100-999)有900个,总长度是 900 * 3 = 2700
  • ...
  • d位数字有 9 * 10^(d-1) 个,总长度是 ... * d

要找 k 的位置,我们先把所有位数比 k 小的数字的总长度加起来,再加上从该位数最小的数字(如10, 100)到 k-1 的这些数字的总长度。最后别忘了最开始的 0 占了1位,所以要基于这个计算。

这个方法虽然要枚举所有子串,看起来有点暴力,但因为查询字符串 x 的长度不会特别长(一般在20以内),所以 O(len^3) 的复杂度是可以通过的,喵~

代码实现

这是我根据上面的思路,为你精心准备的一份代码,注释超详细的哦!

cpp
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
#include <limits>

// 使用 long long 来处理可能很大的数字和位置
using namespace std;
using ll = long long;

// 计算数字 k 在 0123... 序列中的起始位置 (1-based)
// 比如 get_pos(0) -> 1, get_pos(1) -> 2, get_pos(10) -> 11
ll get_pos(ll k) {
    if (k < 0) return -1; // 不应该发生
    if (k == 0) return 1;

    ll length_before = 1; // 数字 '0' 的长度
    ll power_of_10 = 1;
    ll num_digits = 1;

    while (true) {
        // 当前位数下,数字的数量 (例如 1-9, 10-99, 100-999)
        // 注意,我们从0开始,所以1位数的特殊处理
        ll count_in_this_tier = (num_digits == 1) ? 9 : power_of_10 * 9;
        
        // 如果 k 大于等于当前位数区间的上界
        if (k >= power_of_10 + count_in_this_tier) {
            length_before += count_in_this_tier * num_digits;
            power_of_10 *= 10;
            num_digits++;
        } else {
            // k 就在当前位数区间内
            length_before += (k - power_of_10) * num_digits;
            break;
        }
    }
    // 返回 k 的起始位置,等于 0 到 k-1 所有数字的总长度 + 1
    return length_before + 1;
}

void solve() {
    string query_str;
    cin >> query_str;
    int n = query_str.length();
    ll min_pos = -1;

    // 枚举 query_str 的所有子串,假设这个子串是第一个遇到的完整数字 N
    for (int i = 0; i < n; ++i) {         // i: 第一个完整数字在 query_str 中的起始索引
        for (int j = 1; i + j <= n; ++j) { // j: 第一个完整数字的长度
            
            string num_part_str = query_str.substr(i, j);

            // 如果数字长度大于1且以'0'开头,则不是一个合法的数字表示
            if (j > 1 && num_part_str[0] == '0') {
                continue;
            }

            ll N = stoll(num_part_str);

            // --- 1. 检查 N 前面的部分 ---
            // N 前面的部分是 query_str.substr(0, i)
            // 它必须是 N-1 的后缀
            string prefix_part_str = query_str.substr(0, i);
            if (i > 0) {
                if (N == 0) { // N=0 前面没有数字,所以 i 必须是 0
                    continue;
                }
                string prev_num_str = to_string(N - 1);
                if (prev_num_str.length() < i || prev_num_str.substr(prev_num_str.length() - i) != prefix_part_str) {
                    continue; // 不是 N-1 的后缀,假设失败
                }
            }
            
            // --- 2. 检查 N 后面的部分 ---
            // N 后面的部分是 query_str.substr(i + j)
            // 它必须是 N+1, N+2, ... 序列的前缀
            string suffix_part_str = query_str.substr(i + j);
            string generated_suffix = "";
            ll current_num = N + 1;
            while (generated_suffix.length() < suffix_part_str.length()) {
                generated_suffix += to_string(current_num);
                current_num++;
            }
            if (generated_suffix.rfind(suffix_part_str, 0) != 0) { // 检查前缀是否匹配
                continue; // 不匹配,假设失败
            }

            // --- 3. 假设成功,计算位置并更新答案 ---
            ll current_pos = get_pos(N) - i;
            if (min_pos == -1 || current_pos < min_pos) {
                min_pos = current_pos;
            }
        }
    }
    cout << min_pos << endl;
}

int main() {
    // 加速输入输出,喵~
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    int q;
    cin >> q;
    while (q--) {
        solve();
    }

    return 0;
}

复杂度分析

  • 时间复杂度: O(QN3)O(Q \cdot N^3)

    • Q 是询问的次数。
    • 对于每次询问,我们有两层循环来枚举子串。外层循环 i0N-1,内层循环 j1N-i,这里有 O(N2)O(N^2) 种组合。
    • 在循环内部,stollto_string 和字符串比较操作的耗时与字符串长度成正比,最坏情况下是 O(N)O(N)。特别是检查后缀序列的部分,虽然看起来会生成很长的串,但总长度不会超过 N,所以也是 O(N)O(N)
    • 因此,总的时间复杂度是 O(QN2N)=O(QN3)O(Q \cdot N^2 \cdot N) = O(Q \cdot N^3)
  • 空间复杂度: O(N)O(N)

    • 对于每次询问,我们主要消耗的空间是存储查询字符串 query_str 和一些在计算过程中生成的临时字符串,它们的长度都与 N 相关。所以空间复杂度是 O(N)O(N)

知识点总结

这道题真是一次有趣的冒险,我们来总结一下学到了什么吧,喵~

  1. 问题分解: 面对一个复杂的问题,把它分解成更小、更易于处理的子问题是王道!我们把 "在无限串中找子串" 分解为 "枚举子串作为第一个完整数字" 的情况。
  2. 系统性枚举: "暴力" 有时候并不可怕,只要它是有组织的、能覆盖所有情况的。这里的 O(N^3) 枚举就是一个例子,通过锚定一个关键部分(第一个完整数字),让搜索变得有序。
  3. 字符串处理: 熟练运用 substr, to_string, stoll 等函数是解决这类问题的基本功,呐。
  4. 数学计算与模式发现: get_pos 函数的实现,需要我们发现数字串长度的规律,并用数学方式表达出来。这在很多数论和组合问题中都很常见。
  5. 边界情况处理: 一定要小心像 N=0 这样的特殊情况。在算法竞赛中,细节决定成败!

希望能帮到可莉,也希望能帮到正在看题解的你!加油哦,你超棒的,喵~