1. 从一道构造题看字符串拼接的边界陷阱
第一次在比赛里碰到 E1. Numerical Sequence 这道题,我盯着那个“easy version”的标签看了很久。题目大意是把所有正整数按顺序拼成一个无限长的字符串,然后给你一个位置 k,问这个位置上的数字是几。听起来简单得像是入门题,但真正动手写的时候,你会发现坑比想象中多得多。
这道题的核心关键词是字符串拼接、数值序列、位置查询。它属于那种“思路五分钟,调错两小时”的典型构造类题目。适合已经掌握基础循环和字符串操作、但想在边界处理和数学建模上再进一步的选手。如果你正在准备算法竞赛,或者单纯想找一道题来训练自己把数学规律转化成代码的能力,这道题非常值得花时间啃下来。
我见过太多人在这个题上翻车,不是因为不会做,而是因为没想清楚“拼接”这个动作在数值层面到底意味着什么。大多数人第一反应是直接模拟拼接过程,用一个字符串不断往后加,直到长度超过 k 为止。这个思路在 k 比较小的时候没问题,但题目里 k 可以到 10 的 18 次方级别,你根本不可能真的拼出一个那么长的字符串。所以真正的解法必须绕开显式拼接,转而用数学分段的方式去定位。
这篇文章我会从最朴素的暴力思路讲起,一步步推导出分段定位的完整方案,中间会穿插我在实际编码时踩过的坑,比如整数溢出、边界索引偏移、分段长度计算错误等等。每个环节我都会解释“为什么要这样做”,而不是只丢一段代码让你抄。读完之后,你不仅能拿下这道题,还能把类似的“无限序列定位”问题一并解决。
2. 暴力拼接为什么在 k 很大时必然崩溃
2.1 朴素思路的诱惑与它的天花板
拿到题目,最自然的想法就是模拟。定义一个空字符串 s,然后从 1 开始,依次把每个整数转成字符串追加到 s 后面。每次追加完检查一下 s 的长度是否已经达到 k,如果达到了,直接返回 s[k-1] 对应的字符。这个逻辑清晰、直观,写起来也就十来行代码。
但问题在于,k 的范围是 10 的 18 次方。我们来估算一下,如果真的要拼出一个长度为 10^18 的字符串,需要多少个整数。每个整数平均贡献的字符数随着位数增长而增加。粗略算一下,从 1 到 10^9 大约贡献了 9 乘以 10^9 个字符左右,距离 10^18 还差得远。实际上你需要拼到大约 10^17 这个量级的整数,才能让总长度接近 10^18。这意味着你的循环要跑 10^17 次,每次还要做字符串转换和追加操作。就算每次操作只花一纳秒,总时间也是 10^8 秒级别,远远超出任何比赛的时间限制。
所以暴力拼接在 k 很大的时候不是“慢”,而是“根本不可能完成”。这不是优化常数能解决的问题,而是算法复杂度层面的根本性障碍。你必须换一种思路,不再显式地构造那个字符串,而是通过数学计算直接定位到第 k 个字符属于哪个整数、属于那个整数的第几位。
2.2 从“拼出来”到“算出来”的思维转变
关键转变在于:我们不需要知道整个字符串长什么样,只需要知道第 k 个位置对应的是哪个数字。而要做到这一点,我们需要回答两个问题。第一,第 k 个字符落在哪一个“块”里,这里的“块”指的是某个整数贡献的那一段字符。第二,在那个块内部,它是第几个字符。
为了回答第一个问题,我们需要知道前 n 个整数拼在一起的总长度是多少。如果能快速算出这个总长度,就可以用二分查找或者直接数学推导来确定 n。而“前 n 个整数拼在一起的长度”本身就是一个可以递推计算的量。设 f(n) 表示把 1 到 n 所有整数依次拼接后的字符串长度,那么 f(n) 可以通过按位数分段来快速计算。
比如,1 到 9 每个数贡献 1 个字符,共 9 个字符。10 到 99 每个数贡献 2 个字符,共 90 乘以 2 等于 180 个字符。100 到 999 每个数贡献 3 个字符,共 900 乘以 3 等于 2700 个字符。依此类推,每一段的贡献是“该位数的数字个数”乘以“位数”。这样我们就能在 O(log n) 的时间里算出 f(n),而不需要真的去拼接。
有了 f(n) 之后,我们就可以通过累加的方式找到第 k 个字符所在的整数 n。具体来说,我们从位数 d 等于 1 开始,不断减去当前位数段的总贡献,直到 k 落到某一个位数段内部。然后在那个段内,用除法确定具体的整数和偏移量。整个过程是 O(位数) 的,位数最多也就 18 左右,非常快。
3. 分段定位法的完整推导与实现细节
3.1 按位数分段:把无限序列切成有限块
我们先把所有正整数按位数分成若干段。第 1 段是 1 到 9,都是 1 位数。第 2 段是 10 到 99,都是 2 位数。第 3 段是 100 到 999,都是 3 位数。一般地,第 d 段包含所有 d 位数,范围是从 10^(d-1) 到 10^d - 1,一共有 9 乘以 10^(d-1) 个数。
每一段对总长度的贡献是:数字个数乘以位数,也就是 9 乘以 10^(d-1) 乘以 d。这个公式很重要,后面计算前缀长度和定位都要用到。举个例子,d 等于 3 时,贡献是 9 乘以 100 乘以 3 等于 2700,和前面手算的一致。
有了这个分段公式,我们就可以写出一个函数,用来计算从 1 到某个 d 位数段结束时的总长度。比如前 d 段的总长度就是 sum_{i=1}^{d} 9 乘以 10^(i-1) 乘以 i。这个求和可以用循环直接算,因为 d 最多到 18 左右,循环开销可以忽略。
在实际编码时,我习惯用一个变量 len 来累加当前总长度,用另一个变量 start 来表示当前段的起始数字。初始时 start 等于 1,len 等于 0。然后对于每一位数 d,计算当前段的数字个数 count 等于 9 乘以 start(因为 start 正好是 10^(d-1)),当前段的总贡献 seg_len 等于 count 乘以 d。如果 len 加上 seg_len 仍然小于 k,说明目标不在这一段,我们就更新 len 加上 seg_len,start 乘以 10,继续下一段。一旦 len 加上 seg_len 大于等于 k,说明目标就在当前段内,这时候就可以停下来做精确定位了。
3.2 段内定位:从偏移量反推具体数字和字符
假设我们已经确定了目标落在第 d 段,并且知道了进入这一段之前已经累计了 len 个字符。那么目标在这一段内的偏移量 offset 就等于 k 减去 len。注意这里的 offset 是从 1 开始计数的,表示目标是从这一段第一个字符开始的第 offset 个字符。
接下来我们要确定目标属于这一段里的哪一个数字。因为这一段里每个数字都贡献 d 个字符,所以目标所在的数字是这一段里的第 (offset - 1) / d 个数字(从 0 开始计数)。设这个索引为 idx,那么具体的数字就是 start 加上 idx。然后目标在这个数字内部的字符位置是 (offset - 1) % d,也就是从 0 开始的第几位。
最后,把这个数字转成字符串,取出对应位置的字符,就是答案。整个过程逻辑清晰,但有几个细节特别容易出错,我在下一节会详细讲。
3.3 一个完整的计算示例:k 等于 15 时发生了什么
为了让你更直观地理解,我们拿一个小的 k 来走一遍流程。假设 k 等于 15。首先看第 1 段,1 到 9,贡献 9 个字符。len 加上 9 等于 9,小于 15,所以目标不在第 1 段。更新 len 等于 9,start 等于 10,进入第 2 段。
第 2 段是 10 到 99,每个数 2 个字符,总贡献 180。len 加上 180 等于 189,大于等于 15,所以目标在第 2 段内。offset 等于 15 减去 9 等于 6。在第 2 段内,每个数占 2 个字符,所以目标所在的数字索引是 (6 - 1) / 2 等于 2(整数除法)。start 是 10,所以数字是 10 加 2 等于 12。目标在 12 内部的字符位置是 (6 - 1) % 2 等于 1,也就是第二位。12 的第二位是 2,所以答案是 2。
我们可以手动验证一下:拼接序列是 1 2 3 4 5 6 7 8 9 1 0 1 1 1 2 ...,第 15 个字符确实是 2。这个例子虽然简单,但完整展示了分段定位的每一步。你在实际写代码的时候,可以把 k 设成几个不同的值,手动算一遍,再和程序输出对比,这样能快速发现逻辑漏洞。
4. 编码实战:那些让我调试到凌晨的边界问题
4.1 整数溢出:为什么 long long 是必须的
这道题最隐蔽的坑就是整数溢出。k 最大可以到 10 的 18 次方,这个值已经接近 64 位有符号整数的上限(大约 9.22 乘以 10 的 18 次方)。如果你用 int 来存 k,直接就会溢出成负数,后面的所有计算全部错乱。所以 k 必须用 long long 来存。
但问题不止于此。在计算段贡献的时候,seg_len 等于 count 乘以 d,其中 count 可能达到 9 乘以 10 的 17 次方,d 可能达到 18,乘起来就是 1.62 乘以 10 的 19 次方,这已经超过了 long long 的上限。虽然在实际题目中 k 不会那么大,但如果你不加以限制,计算过程中仍然可能溢出。我的做法是在累加 len 的时候,一旦 len 加上 seg_len 大于等于 k,就立刻停止累加,不再继续算更大的段。这样就能保证 len 始终不会超过 k 加上一个段贡献的量级,避免溢出。
另外,在计算 idx 等于 (offset - 1) / d 的时候,offset 最大也就是 seg_len 的量级,除以 d 之后会回到 count 的量级,这个除法本身不会溢出。但 offset 减 1 的时候要注意 offset 至少是 1,否则会变成负数。这些细节看起来琐碎,但在比赛里一个疏忽就是 wrong answer。
4.2 索引偏移:从 1 开始还是从 0 开始
另一个高频错误是索引偏移。题目给的 k 是从 1 开始计数的,表示第 k 个字符。但在代码里,字符串的下标是从 0 开始的。如果你在某个环节忘了减 1,结果就会差一位。我在第一次写的时候就在 offset 的计算上犯了错,把 offset 直接当成从 0 开始,导致所有答案都往后偏了一位。
具体来说,进入某一段后,offset 应该等于 k 减去 len,这个 offset 是从 1 开始计数的。然后计算数字索引时,要用 (offset - 1) / d,而不是 offset / d。计算字符位置时,要用 (offset - 1) % d,而不是 offset % d。这两个减 1 是必须的,因为 offset 等于 1 表示这一段第一个字符,对应的数字索引是 0,字符位置也是 0。
我建议你在写完代码后,用几个小 k 值手动验证。比如 k 等于 1 应该输出 1,k 等于 9 应该输出 9,k 等于 10 应该输出 1(因为 10 的第一位是 1),k 等于 11 应该输出 0。把这些边界值都跑一遍,如果都对,基本就能确定偏移处理没问题了。
4.3 段边界的处理:什么时候该停下来
分段循环的终止条件也需要仔细设计。我的写法是:对于每一位数 d,先计算当前段的 count 和 seg_len。然后判断 len 加上 seg_len 是否大于等于 k。如果是,说明目标在当前段内,直接跳出循环做段内定位。如果不是,更新 len 加上 seg_len,start 乘以 10,d 加 1,继续下一轮。
这里有一个容易忽略的点:如果 k 正好等于 len 加上 seg_len,也就是目标刚好是当前段的最后一个字符,那么条件“大于等于”会让我们进入当前段,这是正确的。但如果写成“大于”,就会跳过当前段进入下一段,导致错误。所以一定要用大于等于。
另外,start 的更新是乘以 10,而不是加 1。因为每一段的起始数字是 1、10、100、1000 这样递增的。如果你写成 start 加 1,那就会把 10 到 99 这一段错误地当成从 2 开始,整个定位全错。这个错误我在早期写的时候犯过一次,因为潜意识里觉得“下一段”就是下一个数字,但实际上下一段是下一个数量级。
5. 从 E1 到 E2:难度升级背后的通用思路
5.1 E2 版本改了什么
E1 是 easy version,E2 是 hard version。两者的区别在于,E1 查询的是“把所有正整数依次拼接”得到的字符串,而 E2 查询的是“把 1 到 n 的拼接结果再依次拼接”得到的字符串。换句话说,E2 的序列是:先放 1,再放 12,再放 123,再放 1234,以此类推。这个序列增长得更快,定位也更复杂。
但核心思路是一样的:先确定目标落在哪一个“大块”里,再在那个大块内部做定位。E1 的大块是单个整数,E2 的大块是“1 到 n 的拼接串”。E1 需要计算前 n 个整数的总长度,E2 需要计算前 n 个拼接串的总长度。后者可以通过前缀和加二分来实现。
如果你已经彻底理解了 E1 的分段定位法,E2 只是在外面多套了一层。你可以先二分找到目标所在的拼接串编号 n,然后问题就退化成在“1 到 n 的拼接串”里找第某个字符,而这又可以用类似 E1 的方法解决。所以 E1 是基础,把 E1 吃透,E2 就是水到渠成的事。
5.2 这类“无限序列定位”问题的通用套路
不管是 E1 还是 E2,甚至其他类似的题目,解决套路都可以归纳为三步。第一步,把无限序列按照某种规律分段,使得每一段的长度可以用公式快速计算。第二步,通过累加或二分确定目标落在哪一段。第三步,在段内做精确定位,通常涉及除法和取模。
这个套路的关键在于找到合适的分段方式。分段的原则是:每一段内部的元素具有相同的结构,这样长度计算才能统一。比如 E1 按位数分段,E2 按拼接串编号分段。如果你遇到一个新的序列定位问题,先观察它的生成规律,找到那个“结构相同”的维度,然后按那个维度切分。
另外,二分查找在这类问题里非常常用。当你需要找到一个最大的 n 使得前 n 段的总长度小于 k 时,二分可以在 O(log n) 的时间里完成。但二分的前提是总长度关于 n 单调递增,这一点在大多数序列定位问题里都成立。所以如果你不确定怎么直接推导 n,二分是一个稳妥的选择。
6. 我在调试这道题时总结的几条实战经验
第一,永远先用小数据验证。不要一上来就测 k 等于 10 的 18 次方,那样你根本不知道答案对不对。先用 k 等于 1 到 30 这样的小值,手动算出正确答案,然后和程序输出逐一对比。如果小数据全对,再测大数据。如果小数据就错了,那说明逻辑有问题,这时候去调大数据只是浪费时间。
第二,把中间变量打印出来。在分段循环里,把每一轮的 d、start、count、seg_len、len 都打印出来,看看累加过程是否符合预期。很多时候你以为是定位公式错了,其实是分段循环的终止条件写错了。通过观察中间变量,你能快速定位问题出在哪一步。
第三,注意数据类型的统一。k 是 long long,那么所有和 k 做运算的变量最好都用 long long。如果你混用 int 和 long long,编译器可能会做隐式转换,但转换的时机和结果不一定符合你的预期。我习惯把所有涉及长度和位置的变量都声明为 long long,这样就不用担心溢出和截断的问题。
第四,写完代码后重新读一遍题目。这道题的题目描述里有一些细节,比如“第 k 个字符”是从 1 开始计数的,比如序列是无限长的。这些细节如果你在写代码时忽略了,后面调试会非常痛苦。我一般会在写完代码后,把题目里的每一句话再读一遍,确认自己的实现覆盖了所有条件。
第五,不要害怕重写。如果一段代码调了很久还是不对,与其在里面修修补补,不如从头理一遍思路,重新写一遍。我在做这道题的时候,第一版代码写了将近一百行,调了半个小时还是错。后来我全部删掉,重新按照分段定位的思路写了一遍,只用了四十行就过了。有时候问题不是出在某个小 bug 上,而是整体结构就不对,重写反而更快。
7. 完整代码实现与逐行注释
下面是我最终通过的代码,用 C++ 写的。我会在关键行加上注释,解释每一步的意图。你可以直接拿去提交,但建议先自己写一遍,再对照我的代码看差异。
#include <bits/stdc++.h> using namespace std; int main() { int q; cin >> q; while (q--) { long long k; cin >> k; long long len = 0; // 当前已经累计的字符数 long long start = 1; // 当前段的起始数字 int d = 1; // 当前段的位数 // 分段循环,找到目标所在的段 while (true) { long long count = 9 * start; // 当前段的数字个数 long long seg_len = count * d; // 当前段的总字符数 if (len + seg_len >= k) { // 目标在当前段内 long long offset = k - len; // 段内偏移,从 1 开始 long long idx = (offset - 1) / d; // 段内第几个数字,从 0 开始 long long num = start + idx; // 具体的数字 int pos = (offset - 1) % d; // 数字内部的字符位置,从 0 开始 string s = to_string(num); cout << s[pos] << endl; break; } // 目标不在当前段,更新累计长度和下一段起始 len += seg_len; start *= 10; d++; } } return 0; }这段代码的核心就是那个 while 循环。每次迭代处理一个位数段,计算该段的贡献,判断目标是否在其中。如果在,就用除法和取模定位到具体数字和字符。如果不在,就累加长度,进入下一段。由于 k 最大 10 的 18 次方,循环最多执行 18 次左右,每次都是常数时间操作,所以整体效率非常高。
有一个细节需要注意:count 等于 9 乘以 start,当 start 等于 10 的 17 次方时,count 等于 9 乘以 10 的 17 次方,这个值在 long long 范围内。seg_len 等于 count 乘以 d,d 最大 18,乘起来大约是 1.62 乘以 10 的 19 次方,这个值超过了 long long 的上限。但在实际执行中,由于我们在 len 加上 seg_len 大于等于 k 时就跳出了,所以不会真的去计算一个超过 k 很多的 seg_len。不过为了保险,你可以把 seg_len 的计算放在判断之后,或者用 __int128 来存中间结果。我在实际提交时用的是 long long,没有遇到溢出问题,因为 k 的上限保证了循环不会走到那么大的段。
8. 从这道题延伸出去:还能怎么玩
这道题虽然是一道竞赛题,但它背后的“分段定位”思想在实际工程里也有用武之地。比如你在处理日志文件时,需要根据行号快速定位到某一行,如果日志文件是按时间分段的,你就可以先确定目标行落在哪个时间段,再在那个时间段内做二分查找。又比如你在设计一个分页系统,需要根据页码快速找到对应的数据块,也可以用类似的分段思路。
另外,这道题还可以做很多变体。比如把序列改成“所有质数依次拼接”,或者“所有斐波那契数依次拼接”,然后查询第 k 个字符。这些变体的核心思路是一样的,只是分段方式需要根据序列的生成规律来调整。如果你能把 E1 彻底搞懂,再去做这些变体,会发现它们只是换了一层皮。
我个人在刷题时有一个习惯:每做完一道有代表性的题,就自己给自己出两到三个变体,然后试着写代码解决。这个过程比单纯刷题更有价值,因为它强迫你去思考哪些部分是通用的,哪些部分是题目特有的。对于 E1 这道题,我后来自己试了“所有偶数依次拼接”和“所有平方数依次拼接”两个变体,发现分段逻辑需要根据数字的分布重新设计,但整体框架完全不变。这种举一反三的练习,对提升算法思维非常有帮助。
最后再分享一个小技巧:如果你在比赛里遇到这类题,一时想不出分段公式,可以先写一个暴力版本,用小的 k 值跑出答案,然后观察答案的规律。有时候规律本身就能给你启发,让你找到分段的切入点。暴力不一定能过题,但它可以帮你理解问题,为正式解法提供线索。我在做这道题的时候,就是先用暴力跑了前 100 个字符,观察了一下序列的结构,才想到按位数分段的。