1. 从一道题说起:纯位数到底在考什么
纯位数这个概念,第一次接触的人可能会愣一下——什么叫纯位数?其实定义非常朴素:一个十进制正整数,如果它的每一位数字都相同,那它就是纯位数。比如 1、22、333、4444、99999 都是纯位数,而 12、101、1234 就不是。这个概念在编程竞赛和算法练习里出现的频率不算低,因为它天然适合用来考察几个核心能力:数字拆解、循环枚举、边界处理,以及最重要的——如何把一个看似需要暴力穷举的问题,转化成数学上可推导的规律。
我第一次遇到纯位数相关的题目,是在一个在线评测平台上。题目大意是:给定一个整数 n,要求找到不小于 n 的最小纯位数。看起来很简单对吧?但如果 n 的范围到了 10 的 9 次方甚至更大,暴力一个个往上加再判断是不是纯位数,时间复杂度就完全不可接受了。这时候就需要换个思路——纯位数的数量其实非常稀少,与其去“判断”,不如直接“构造”。
这篇文章我会围绕纯位数这个主题,把它的数学性质、常见题型的解法、代码实现细节、以及我在实际做题过程中踩过的坑,全部梳理一遍。不管你是刚学编程的新手,还是已经刷了不少题想回头巩固基础的老手,应该都能从中找到有用的东西。尤其是那些“看起来会做但总是差一点 AC”的朋友,很多问题其实出在边界条件的处理上,我会重点讲。
2. 纯位数的数学本质与规律拆解
2.1 纯位数的生成规律
纯位数有一个非常优雅的生成方式。对于长度为 L 的纯位数,它一定可以写成:
d × (10^(L-1) + 10^(L-2) + ... + 10^1 + 10^0)
其中 d 是 1 到 9 之间的数字(首位不能是 0,所以 d 不能为 0)。后面那一串 10 的幂次和,其实就是 (10^L - 1) / 9,也就是 L 个 1 组成的数。所以长度为 L 的纯位数一共有 9 个:1×repunit(L)、2×repunit(L)、...、9×repunit(L)。
这个规律意味着什么呢?意味着如果你需要枚举所有小于某个上限的纯位数,你根本不需要逐个数字去判断,只需要两层循环:外层枚举位数 L,内层枚举数字 d,直接构造出来就行了。总数量是 9×L,对于 L=10 来说也才 90 个,枚举成本几乎为零。
我在做题时经常看到有人写一个isPure(n)函数然后从 n 开始逐个往上试,对于小范围数据没问题,但一旦 n 到了 10^9 以上,最坏情况下要试将近 10^9 次才能找到下一个纯位数,这在任何评测系统上都会超时。而用构造法,你只需要生成所有候选纯位数,然后从中挑出大于等于 n 的最小那个,时间复杂度直接降到 O(L)。
2.2 为什么纯位数在进制表示下很有意思
纯位数不只在十进制下有定义,在任意进制下都可以定义。比如二进制下的 11(十进制的 3)、111(十进制的 7)、1111(十进制的 15)都是纯位数。事实上,二进制下的纯位数就是 2^k - 1 形式的数,也就是梅森数。这就把纯位数和数论里的一些经典概念联系起来了。
在十进制下,纯位数也有一些有趣的数论性质。比如长度为 L 的纯位数 d×repunit(L),当 d=9 时就是 10^L - 1,这个数能被 9 整除。更一般地,repunit(L) 的因子结构决定了纯位数的整除性质。这些性质在一些进阶题目里会用到,比如判断一个纯位数能否被某个数整除,或者找纯位数中的质数(这类质数叫做 repunit 质数,目前已知的非常少)。
不过对于大多数编程题目来说,你不需要深入到数论层面,只需要掌握构造法和比较逻辑就够了。但了解这些背景知识有助于你理解为什么纯位数这个主题会反复出现在算法练习中——它是一个很好的“麻雀虽小五脏俱全”的题材。
2.3 纯位数问题的几种常见变体
在实际的题目中,纯位数相关的问法有好几种,我总结了一下最常见的几类:
| 题型 | 典型描述 | 核心思路 |
|---|---|---|
| 查找下一个 | 给定 n,找 ≥n 的最小纯位数 | 构造所有候选,取最小值 |
| 计数 | 统计 ≤n 的纯位数个数 | 按位数分类计数 |
| 判断 | 判断 n 是否为纯位数 | 拆位比较 |
| 求和 | 求所有 ≤n 的纯位数之和 | 构造后累加 |
| 第 k 个 | 找第 k 小的纯位数 | 按位数定位后取模 |
每种题型的难度和坑点都不一样。查找下一个和判断是最基础的,计数和求和需要注意边界,第 k 个则需要在位数定位上想清楚。下面我会逐一展开。
3. 核心解法与代码实现细节
3.1 判断一个数是否为纯位数
这是最基础的操作,也是很多其他解法的基础组件。思路很简单:把数字的每一位拆出来,看是否全部相同。
def is_pure(n): s = str(n) return all(ch == s[0] for ch in s)用字符串处理是最直观的写法。如果你追求极致的效率,可以用数学方法:
def is_pure_math(n): if n < 10: return True last = n % 10 n //= 10 while n > 0: if n % 10 != last: return False n //= 10 return True两种写法各有优劣。字符串写法简洁,适合快速实现;数学写法不依赖字符串转换,在某些语言里性能更好。但说实话,对于纯位数这类问题,判断函数的性能通常不是瓶颈,因为更高效的解法根本不会用到逐个判断。
注意:n=0 的情况需要特别考虑。0 是不是纯位数?在大多数题目定义中,纯位数是正整数,所以 0 不算。但如果题目没有明确说明,你需要根据题意判断。我遇到过一道题把 0 也算作纯位数,导致很多人在边界上 WA 了。
3.2 构造法:找不小于 n 的最小纯位数
这是最经典的纯位数题目。解法分三步:
- 把 n 转成字符串,得到它的位数 L
- 生成所有位数为 L 的纯位数(9 个),以及位数为 L+1 的最小纯位数(即 10^L 对应的纯位数,也就是 (L+1) 个 1)
- 从这些候选里选出 ≥n 的最小值
为什么需要考虑 L+1 位?因为如果 n 是 999 这种,位数为 3 的纯位数最大是 999,刚好等于 n,那答案就是 999。但如果 n 是 1000,位数为 4 的纯位数最小是 1111,而位数为 3 的纯位数最大只有 999,不够用,所以必须考虑 L+1 位的情况。
def next_pure(n): s = str(n) L = len(s) candidates = [] # 生成 L 位的纯位数 for d in range(1, 10): candidates.append(int(str(d) * L)) # 生成 L+1 位的最小纯位数 candidates.append(int('1' * (L + 1))) # 找 ≥n 的最小值 ans = float('inf') for c in candidates: if c >= n: ans = min(ans, c) return ans这段代码逻辑清晰,但有一个细节值得注意:当 n 本身就是纯位数时,答案应该是 n 本身(因为题目要求“不小于”)。上面的代码用>=比较,所以会正确返回 n。
3.3 计数法:统计不超过 n 的纯位数个数
这个问题的思路是按位数分类。假设 n 有 L 位,那么:
- 位数小于 L 的纯位数:1 位有 9 个,2 位有 9 个,...,L-1 位有 9 个,总共 9×(L-1) 个
- 位数等于 L 的纯位数:需要逐个比较
对于位数等于 L 的情况,设 n 的首位数字为 first,则:
- 首位数字小于 first 的纯位数:有 (first - 1) 个(从 1 到 first-1)
- 首位数字等于 first 的纯位数:需要判断由 first 重复 L 次组成的数是否 ≤n
def count_pure(n): if n < 1: return 0 s = str(n) L = len(s) first = int(s[0]) # 位数小于 L 的 count = 9 * (L - 1) # 位数等于 L,首位小于 first 的 count += first - 1 # 位数等于 L,首位等于 first 的 if int(str(first) * L) <= n: count += 1 return count这个解法的时间复杂度是 O(L),非常高效。但要注意 n<1 的边界情况,以及 n 恰好等于某个纯位数时的处理。
3.4 第 k 个纯位数
找第 k 小的纯位数,思路是先确定它有多少位,再确定它的数字是什么。
纯位数的排列顺序是:1,2,...,9,11,22,...,99,111,222,...,999,...
所以前 9 个是 1 位数,接下来 9 个是 2 位数,再接下来 9 个是 3 位数,以此类推。
def kth_pure(k): # 确定位数 L = (k - 1) // 9 + 1 # 确定是该位数中的第几个 idx = (k - 1) % 9 + 1 return int(str(idx) * L)这段代码非常简洁,但理解起来需要想清楚:k=1 对应 1 位数第 1 个(即 1),k=9 对应 1 位数第 9 个(即 9),k=10 对应 2 位数第 1 个(即 11),k=18 对应 2 位数第 9 个(即 99),k=19 对应 3 位数第 1 个(即 111)。用 (k-1)//9 来确定位数偏移,用 (k-1)%9+1 来确定数字,逻辑是自洽的。
实操心得:这类“第 k 个”的题目,关键在于找到排列的周期性规律。纯位数的周期非常规整,每 9 个一组,所以直接用整除和取模就能定位。如果题目变形成“第 k 个不含某数字的纯位数”或者“第 k 个纯位数且能被某数整除”,就需要在这个基础上做调整了。
4. 实操过程中的坑点与排查记录
4.1 边界条件:最容易翻车的地方
我在做纯位数题目时,WA 的原因十有八九出在边界上。以下是我整理的高频边界情况:
| 输入 | 期望输出 | 容易犯的错 |
|---|---|---|
| n=1 | 1 | 忘记 n 本身是纯位数的情况 |
| n=9 | 9 | 同上 |
| n=10 | 11 | 忘记考虑位数增加 |
| n=99 | 99 | 同上 |
| n=100 | 111 | 忘记 L+1 位的候选 |
| n=999999999 | 999999999 | 大数处理 |
| n=1000000000 | 1111111111 | 超出 int 范围 |
特别是最后一行的情况,如果题目给定的 n 到了 10^9,那么答案可能是 10 位的纯位数 1111111111,这在某些语言里会超出 32 位整数的范围。用 Python 不用担心这个问题,但用 C++ 或 Java 的话,需要用long long或long。
4.2 字符串构造 vs 数学构造的性能对比
构造纯位数时,有两种常见方式:
# 方式一:字符串重复 candidate = int(str(d) * L) # 方式二:数学计算 repunit = (10**L - 1) // 9 candidate = d * repunit两种方式的结果完全一样,但在性能上有细微差别。字符串方式在 L 很大时(比如 L=1000)会创建很长的字符串,而数学方式只涉及整数运算。不过在编程竞赛的常见数据范围内(L ≤ 10),两者差异可以忽略。我个人的习惯是用字符串方式,因为代码更直观,不容易写错。
4.3 多个纯位数候选时的比较逻辑
在“找下一个纯位数”的题目中,候选集合里可能有多个满足条件的值。比如 n=5 时,候选有 5,6,7,8,9,11,22,...,99,111,满足 ≥5 的有 5,6,7,8,9,11,...,最小值是 5。如果你不小心用了>而不是>=,就会返回 6,直接 WA。
另一个容易出错的地方是:生成候选时忘记去重。比如 L 位的纯位数和 L+1 位的纯位数不会有重叠,所以其实不会重复。但如果你同时生成了“L 位的纯位数”和“所有不超过某个上限的纯位数”,就可能出现重复。用集合去重是一个保险的做法,但会增加一点开销。
4.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| WA on n=1 | 没处理 n 本身是纯位数 | 检查比较符号是否用了 >= |
| WA on n=10 | 没考虑位数增加 | 检查是否生成了 L+1 位候选 |
| WA on n=99 | 同上 | 同上 |
| TLE | 用了暴力枚举 | 改用构造法 |
| 输出负数 | 整数溢出 | 检查数据类型范围 |
| 结果差 1 | 计数时边界算错 | 手动验证小数据 |
避坑技巧:每次写完纯位数相关代码,我都会用一组小数据手动跑一遍:n=1,2,...,20,看看输出是否和手算一致。这组数据能覆盖位数不变、位数增加、n 本身是纯位数、n 本身不是纯位数等多种情况,是最小完备的测试集。
5. 从纯位数延伸出去的思考
5.1 纯位数与回文数的关系
纯位数一定是回文数,但回文数不一定是纯位数。比如 121 是回文数但不是纯位数。这个关系在解题时有时能用到——如果你需要判断一个数是否既是回文又是纯位数,只需要判断它是否纯位数就够了,因为纯位数天然满足回文性质。
反过来,有些题目会要求找“既是回文数又是纯位数”的数,这时候你直接用纯位数的构造法就行,不需要额外做回文判断。我在一次练习中看到有人写了两套判断逻辑,其实完全没必要。
5.2 纯位数在进制转换中的表现
一个数在十进制下不是纯位数,换一个进制可能就是纯位数了。比如十进制的 7,在二进制下是 111,是纯位数。十进制的 10,在二进制下是 1010,不是纯位数;在三进制下是 101,也不是;在四进制下是 22,是纯位数。
这类“在某个进制下是纯位数”的题目,解法通常是枚举进制,然后判断。判断的方法和十进制下的判断逻辑一样,只是把取模和整除的基数从 10 换成对应的进制数。
def is_pure_in_base(n, base): if n < base: return True last = n % base n //= base while n > 0: if n % base != last: return False n //= base return True这段代码可以直接复用到任意进制。注意 base 的范围通常是 2 到 36 或者更大,取决于题目要求。
5.3 纯位数的实际应用场景
你可能会想,纯位数这种东西除了做题还有什么用?说实话,在实际工程中直接用到纯位数的场景确实不多。但它背后的思维方式——把枚举问题转化为构造问题——是非常通用的。
比如在测试数据生成时,你需要生成一批“有规律的数字”来测试某个函数的边界行为,纯位数就是一个很好的选择。又比如在某些编码场景中,需要生成易于人工识别和校验的编号,纯位数因为其规律性,不容易被看错或输错。
更重要的是,纯位数问题训练的是你对“数字的位数结构”的敏感度。这种敏感度在处理大数运算、格式化输出、数字拆解等问题时都会用到。我自己在后来做一些数据清洗的工作时,就经常需要按位数对数字进行分类处理,那时候纯位数问题里学到的技巧就派上了用场。
5.4 如果题目数据范围继续扩大
假设题目给定的 n 到了 10^18 甚至更大,构造法依然适用,因为纯位数的数量只和位数有关,和具体数值无关。10^18 对应的位数是 19,候选纯位数最多也就 9×19+9 个,枚举成本依然极低。
但如果题目要求的是“找第 k 个纯位数”且 k 到了 10^18,那就不能逐个枚举了。不过用前面讲的整除取模方法,依然可以在 O(1) 时间内算出答案。这就是数学规律带来的威力——不管数据范围怎么扩大,只要规律在,解法就不会过时。
真正麻烦的是那些“纯位数 + 额外约束”的题目,比如“找第 k 个能被 7 整除的纯位数”。这时候整除取模的简单方法就不够用了,需要结合数论知识来分析。不过这类题目已经超出了基础练习的范畴,属于进阶内容了。
6. 我在刷纯位数题目时总结的几条经验
第一,先想清楚再写代码。纯位数题目的代码量通常很短,但逻辑分支不少。如果你上来就写,很容易在边界上翻车。我的习惯是先在纸上列出所有候选的生成方式,然后手动模拟几个小数据,确认逻辑无误后再敲代码。
第二,测试用例要覆盖位数变化。纯位数问题最容易出错的地方就是位数从 L 变到 L+1 的那个临界点。所以测试数据一定要包含 9、10、99、100、999、1000 这些数。如果这些都能过,基本就不会有大问题了。
第三,不要迷信暴力。我知道很多人做算法题的习惯是“先暴力一发看看能不能过”。对于纯位数问题,暴力在小数据上确实能过,但一旦数据范围扩大就必死。而且暴力解法容易掩盖你对问题本质的理解不足。与其花时间调暴力,不如直接想构造法。
第四,注意数据类型的范围。纯位数在位数增加时增长很快,10 位的纯位数就已经到了 10^9 量级,11 位到了 10^10,超出了 32 位整数的范围。用 C++ 的话记得开long long,用 Java 的话用long,Python 虽然不用担心溢出,但在和其他语言交互时要注意。
第五,多回头看自己的代码。纯位数问题的解法通常很短,短到你觉得“这不可能有错”。但恰恰是这种自信最容易导致低级错误。我自己的习惯是写完代码后,把关键变量在纸上走一遍,确认每一步的值都符合预期。这个习惯帮我省下了大量调试时间。
纯位数这个主题看起来简单,但真正做透之后,你会发现它涉及了枚举、构造、边界处理、数据类型、数学规律等多个方面的知识。把它当作一个练习基本功的题材,是非常合适的选择。