【小白笔记】虚拟货币挖矿算力匹配
一个虚拟货币挖矿系统中,每个矿工拥有一定的算力值n(范围在1到 1018 之间)。系统需要为每个矿工分配一个算力档位,这个档位必须是小于等于矿工当前算力n的最大"稳定算力档",并且这个档位的算力值各个数位之和必须是一个质数(质数又称素数。一个大于1的自然数,除了1和它自身外,不能被其他自然数整除的数叫做质数)。"稳定算力档"定义为从左到右每一位数字都不小于前一位数字,例如 123、111、399 都是符合要求的稳定算力档,像 121、897这种则不符合要求。合理分配算力档位有助于提高挖矿效率和稳定性。
这类“小于等于 NNN 的最大/最小满足约束 XXX”的题目,都有一个核心模板,只要记住这个模板,就能快速应对:
- 分治策略:位数 vs. 各位数字。
- 先考虑位数:
- 第一步:检查 NNN 本身。
- 第二步:尝试构造与 NNN 位数相同且 <N\mathbf{< N}<N 的最大数 XXX。
- 第三步(备选):如果找不到,考虑位数比 NNN 少一位的最大数 X′X'X′。
- 先考虑位数:
- 构造策略:高位匹配 + 低位决定 (贪心)。
- 从最高位 i=1i=1i=1 开始,依次尝试保持前缀 X1…i−1=N1…i−1X_{1\dots i-1} = N_{1\dots i-1}X1…i−1=N1…i−1。
- 枚举第一个不匹配位 iii:从 Xi=Ni−1X_i = N_i-1Xi=Ni−1 从大到小尝试。
- 一旦 XiX_iXi 确定后,为了使 XXX 最大,剩余的低位 Xi+1…LX_{i+1 \dots L}Xi+1…L 必须填入能满足约束的(且尽可能大的)数字。
- 本题约束是“非递减”:所以直接将所有低位都填入 XiX_iXi(即 Xi+1=⋯=XL=XiX_{i+1}=\dots=X_L=X_iXi+1=⋯=XL=Xi),这样才能保证 XXX 尽可能大且满足稳定算力档要求。
口诀:
先验 NNN (Check NNN),再寻 XXX。
高位尽跟 NNN (Prefix Match),低位向下找 XiX_iXi (Try Ni−1→N_i-1 \toNi−1→ lower)。
一旦 XiX_iXi 降,后缀全铺满 (Fill Suffix with XiX_iXi),最大即刻返 (Return XXX)。
掌握了这个高位贪心降位的套路,就能快速构思出这道题的高效解法。
1. 明确目标与约束
- 输入 NNN (矿工算力 nnn): 1≤N≤10181 \le N \le 10^{18}1≤N≤1018(这是一个非常大的数,意味着我们需要处理最多 181818 位的整数,需要使用 646464 位整数类型)。
- 约束 A (稳定算力档):从左到右,每一位数字不小于前一位数字(非递减数列)。例如 123123123, 447447447, 135791357913579。
- 约束 B (数字和为质数):稳定算力档的各位数字之和(SSS)必须是一个质数(Prime\text{Prime}Prime)。
- 目标 (Maximum Stable Power Level):找到小于或等于 NNN 的最大整数 XXX,使 XXX 同时满足约束 A 和约束 B。
2. 核心难点分析
A. 质数 (Prime Number)
质数(Prime Number, 又称素数):一个大于 1 的自然数,除了 1 和它自身外,不能被其他自然数整除。这是一个基础数学概念。
由于 NNN 最大是 101810^{18}1018,其各位数字之和最大是 9×18=1629 \times 18 = 1629×18=162。我们需要判断的质数范围非常小(1 到 162),可以预先生成或硬编码这个范围内的质数表,以提高判断效率。
KaTeX parse error: Expected 'EOF', got '}' at position 181: …, 149, 151, 157}̲
Prime(≤162)={2,3,5,…,157}\text{Prime}(\le 162) = \{2, 3, 5, \dots, 157\}Prime(≤162)={2,3,5,…,157}。
B. 稳定算力档 (Non-decreasing Number)
这是一个典型的数字型动态规划 (Digit DP) 或 深度优先搜索 (DFS) 结合记忆化 的范畴,因为需要生成满足特定位值约束的数字。但在这里,我们是要找最大且小于等于 NNN 的数 XXX,这提示我们使用贪心 (Greedy) 或从高位到低位的尝试 (Trial from MSB) 的思路。
3. 算法设计与思路 (找最大值 X≤NX \le NX≤N)
由于要求找到最大的 X≤NX \le NX≤N,并且 NNN 的位数是固定的(最多 18 位),我们应该从 NNN 的最高位开始,尽量构造一个与 NNN 相似的数字 XXX,并尝试让 XXX 的位数与 NNN 的位数相同。
思路:从 NNN 的高位开始构造
- 位数相同(优先):尝试构造一个 LLL 位(LLL 是 NNN 的位数)的稳定算力档 X≤NX \le NX≤N。
- 位数减少(备选):如果所有 LLL 位的稳定算力档都不满足,或者构造 LLL 位的数过于复杂,我们可以直接考虑最大的 L−1L-1L−1 位的稳定算力档。最大的 L−1L-1L−1 位的稳定算力档肯定是 99…999\dots999…9 ( L−1L-1L−1 个 999),但它不一定是稳定算力档,且数字和不一定是质数。因此,我们需要找最大的 L−1L-1L−1 位的稳定算力档 X′≤10L−1−1X' \le 10^{L-1}-1X′≤10L−1−1,且其数字和是质数。最大的 L−1L-1L−1 位的稳定算力档是 11…1⏟L−1\underbrace{11\dots1}_{L-1}L−111…1 (数字和 L−1L-1L−1) 或 99…9⏟L−1\underbrace{99\dots9}_{L-1}L−199…9 (如果 L−1L-1L−1 个 999 的和是质数),但最好的 L−1L-1L−1 位的稳定算力档应该是 99…9⏟L−1\underbrace{99\dots9}_{L-1}L−199…9。我们应该找小于 NNN 的、且位数与 NNN 相同的最大数 XXX。
主要策略:位构造 + 回溯/迭代 (Digit Construction + Backtracking/Iteration)
我们构造 XXX 的策略是:尽量让 XXX 的前缀与 NNN 的前缀相同,直到某一位 iii,让 X[i]<N[i]X[i] < N[i]X[i]<N[i],然后从 i+1i+1i+1 位到末位,都填入 X[i]X[i]X[i],使得 XXX 尽可能大且满足稳定算力档的要求。
假设 NNN 有 LLL 位,我们尝试构造 X=X1X2…XLX = X_1 X_2 \dots X_LX=X1X2…XL。
- 枚举不匹配位 iii (Mismatch Position iii):从最高位 i=1i=1i=1 到 LLL。
- 前缀匹配:X1X2…Xi−1=N1N2…Ni−1X_1 X_2 \dots X_{i-1} = N_1 N_2 \dots N_{i-1}X1X2…Xi−1=N1N2…Ni−1。
- iii 位向下尝试:XiX_iXi 从 Ni−1N_i-1Ni−1 向下遍历到 111(或 Xi−1X_{i-1}Xi−1,如果是 i>1i>1i>1)。
- 后缀填充:一旦确定了 XiX_iXi,那么为了让 XXX 最大,剩下的位 Xi+1…XLX_{i+1} \dots X_LXi+1…XL 必须全部填入 XiX_iXi(因为稳定算力档要求非递减)。即 Xi+1=Xi+2=⋯=XL=XiX_{i+1} = X_{i+2} = \dots = X_L = X_iXi+1=Xi+2=⋯=XL=Xi。
X=N1N2…Ni−1XiXiXi…Xi⏟L−i 个X = N_1 N_2 \dots N_{i-1} X_i \underbrace{X_i X_i \dots X_i}_{L-i \text{ 个}}X=N1N2…Ni−1XiL−i 个XiXi…Xi - 校验:
- 稳定算力档:检查 Xi−1≤XiX_{i-1} \le X_iXi−1≤Xi 是否成立(对于 i=1i=1i=1 恒成立)。如果 XiX_iXi 不小于前一位 Xi−1X_{i-1}Xi−1,则满足稳定算力档要求。
- 数字和质数:计算 XXX 的各位数字之和 SSS,检查 SSS 是否为质数。
- 结果:一旦找到第一个满足条件的 XXX,它就是小于 NNN 的最大稳定算力档。因为我们是从高位到低位,从大到小地尝试 XXX 的值。
例:N=1245N=1245N=1245
| 不匹配位 iii | NiN_iNi | XiX_iXi (从 Ni−1N_i-1Ni−1 开始向下) | XXX (后缀填充 XiX_iXi) | 稳定算力档? | 数字和 SSS | SSS 是质数? | 结果 XXX |
|---|---|---|---|---|---|---|---|
| i=4i=4i=4 | 555 | 444 | 124412441244 | 是 | 111111 | 是 | 124412441244 |
| i=3i=3i=3 | 444 | 333 | 123312331233 | 是 | 999 | 否 | |
| 222 | 122212221222 | 是 | 777 | 是 | 122212221222 | ||
| i=2i=2i=2 | 222 | 111 | 111111111111 | 是 | 444 | 否 | |
| 000 | 100010001000 | 是 | 111 | 否 | |||
| i=1i=1i=1 | 111 | (无,因为 X1X_1X1 最小为 111 ) |
在这个例子中,124412441244 是满足要求的最大值。
边界情况:X=NX=NX=N 满足条件
在上述查找 X<NX < NX<N 的过程之前,我们应该先检查 NNN 本身是否满足约束:
- NNN 是否是稳定算力档?
- NNN 的数字和 SNS_NSN 是否是质数?
如果 NNN 满足,那么答案就是 NNN。
4. Python实现细节与代码
在 Python 中,由于 101810^{18}1018 仍然在标准 int 的处理范围内,所以不用担心大数问题。
在很多主流编程语言中,处理 101810^{18}1018 这样的数值确实需要使用 long long 或类似的 64 位整数类型。
但是,对于 Python 来说,这句话是正确的:
-
Python 的
int类型:
Python 标准的int类型没有固定的大小限制(unlimited precision)。它会根据需要,自动分配内存来存储任意大的整数。只要计算机的内存允许,Python 的int就可以处理。 -
其他语言 (
C/C++/Java):
在 C/C++ 或 Java 中:int通常是 32 位,最大值约为 2×1092 \times 10^92×109。long long(C/C++) 或long(Java) 是 64 位,最大值约为 9×10189 \times 10^{18}9×1018。- 因此,在这些语言中,要存储 101810^{18}1018,必须使用
long long或long。
结论:
- 在 Python 中:
10**18可以直接用标准int存储和计算,不需要担心溢出问题,所以不用担心大数问题的说法是准确的。 - 在底层原理上:Python 在内部处理 101810^{18}1018 时,实际上是使用了类似于其他语言的 64 位甚至更高位的机制,但这个细节对程序员是透明的,无需手动声明为
long long。
总结一下:
| 语言 | 如何处理 101810^{18}1018 | 程序员需要做什么 |
|---|---|---|
| Python | 标准 int 自动支持(无上限) | 像处理普通数字一样使用 int |
| C/C++ | 必须使用 long long | 必须声明为 long long |
| Java | 必须使用 long | 必须声明为 long |
步骤一:质数预处理
def is_prime(n):"""质数判断函数,用于数字和S <= 162"""if n <= 1:return False# 只需要检查到 sqrt(162) 约 12.7for i in range(2, int(n**0.5) + 1):if n % i == 0:return Falsereturn True# 预先计算出所有可能的数字和(1到162)中的质数集合
MAX_DIGIT_SUM = 9 * 18 # 10^18 - 1 的数字和最大是 9 * 18 = 162
PRIME_SUMS = {i for i in range(2, MAX_DIGIT_SUM + 1) if is_prime(i)}def check_sum(num_str):"""检查字符串表示的数字的各位和是否为质数"""s = sum(int(d) for d in num_str)return s in PRIME_SUMS
这段代码逻辑非常清晰且高效,它负责解决原问题中的第二个核心约束:检查一个数 XXX 的各位数字之和是否为一个质数。
这段代码的核心思想是:预计算(Pre-computation)。由于数字和 SSS 的最大值非常小(最大 162),可以先算出所有可能的质数和,然后通过查表(PRIME_SUMS\text{PRIME\_SUMS}PRIME_SUMS 集合)来快速判断,避免重复计算。
下面我将详细解释这段代码的三个部分:
1. is_prime(n) 函数:质数判断
作用:判断输入的整数 nnn 是否为质数(Prime Number)。
def is_prime(n):"""质数判断函数,用于数字和S <= 162"""if n <= 1:return False# 只需要检查到 sqrt(162) 约 12.7for i in range(2, int(n**0.5) + 1):if n % i == 0:return Falsereturn True
-
第一步:边界条件
if n <= 1:return False根据质数(Prime\text{Prime}Prime)的定义:一个大于 111 的自然数,除了 111 和它自身外,不能被其他自然数整除。因此 111 和所有小于等于 000 的数都不是质数。
-
第二步:试除法(Trial Division)
for i in range(2, int(n**0.5) + 1):if n % i == 0:return False return True这是判断质数最常用的方法之一:试除法。
- 原理:如果一个数 nnn 不是质数,那么它一定可以分解为两个因子 a×b=na \times b = na×b=n。这两个因子中,至少有一个因子 ≤n\le \sqrt{n}≤n。
- 效率优化:因此,我们只需要从 222 开始,检查到 n\sqrt{n}n(即
n**0.5)为止的整数,如果 nnn 能被其中任何一个数整除(n % i == 0),则 nnn 不是质数,立即返回False。 - 如果循环结束,都没有找到因子,则 nnn 是质数,返回
True。
在面试中,如果因为紧张或遗忘而想不起“只需要检查到 n\sqrt{n}n”这个优化定理,我们应该如何应对,才能既保证代码的正确性,又展示出良好的解决问题思路呢?
以下是你可以采取的策略和回答:
策略一:保证正确性,牺牲效率 (完整试除)
如果你忘记了 n\sqrt{n}n 这个优化,首先要做的是保证代码逻辑的绝对正确。
面试回答示例:
“如果我暂时想不起质数判断的最佳优化方法,为了保证程序逻辑的正确性,我会先采用最直观的试除法,即从 2 一直检查到 n−1n-1n−1。
def is_prime_simple(n):if n <= 1:return False# 从 2 检查到 n-1for i in range(2, n): if n % i == 0:return Falsereturn True
但是,我会立刻向面试官指出:我知道这种方法效率低下,需要优化。
策略二:分析并提出优化思路 (现场推导 n\sqrt{n}n)
接着,你应该尝试在不依赖记忆的情况下,现场推导出优化为什么只需要到 n\sqrt{n}n。这比直接背出公式更有价值。
面试回答示例:
“这种 O(n) 的方法对于大数来说效率太低了。我们知道,如果一个数 nnn 不是质数,它一定可以分解为两个因子 aaa 和 bbb,使得 a×b=na \times b = na×b=n。
现在我们考虑这两个因子的大小关系:
- 如果 a>na > \sqrt{n}a>n 且 b>nb > \sqrt{n}b>n:那么 a×ba \times ba×b 必然会大于 n×n=n\sqrt{n} \times \sqrt{n} = nn×n=n。
a×b>na \times b > na×b>n
但这与 a×b=na \times b = na×b=n 的前提相矛盾。 - 因此,两个因子不可能都大于 n\sqrt{n}n。
结论就是:如果 nnn 有因子,它必然至少有一个因子 aaa 满足 a≤na \le \sqrt{n}a≤n。我们只需要找到这个较小的因子 aaa 即可证明 nnn 不是质数。
这样,我就能现场推导出优化后的代码,并将循环范围从 nnn 缩小到 n\sqrt{n}n,使得复杂度降为 O(n)O(\sqrt{n})O(n)。”
策略三:结合本题的特殊性 (数字和范围极小)
在这个特定的题目中,数字和 SSS 的最大值只有 162。
面试回答示例:
“在这个具体问题中,我们判断质数的数 SSS 最大只有 162162162。
- 162≈12.7\sqrt{162} \approx 12.7162≈12.7。这意味着,即使采用 n\sqrt{n}n 的优化,循环最多也只执行 12 次。
- 即使我们忘记了 n\sqrt{n}n,采用 O(n)O(n)O(n) 的朴素方法,循环最多也只执行 162 次。
由于 NNN 的各位数字和是一个非常小的数,两种方法在时间上的差异微乎其微。在实际工程中,我会选择 O(n)O(\sqrt{n})O(n) 以养成良好的编码习惯;但在面试中,如果时间紧张,使用 O(n)O(n)O(n) 方法来处理 S≤162S \le 162S≤162 这样的小范围数字,是完全可以接受的,因为**预计算(Pre-computation)**的总耗时仍然非常低。”
编程规范和函数设计的优秀实践。
1. 为什么返回 True 和 False 而不是 000 和 111?
在现代编程实践中,尤其是在像 Python 这样的高级语言中,布尔值(Boolean values) 是判断函数(is_xxx 函数)的标准返回值,而不是整数 000 和 111。
| 返回值类型 | Python 中的表示 | 含义 |
|---|---|---|
| 布尔值 | True 和 False | 最直接、最清晰地表示“是/否”或“真/假”的状态。 |
| 整数 | 111 和 000 | 在底层或早期的 C 语言中常用,111 代表真,000 代表假。 |
选择 True/False 的理由(可读性与规范):
- 极高的可读性 (Readability):当代码写成
if is_prime(s):时,任何人都能立刻理解“如果 sss 是质数”的意思。如果写成if is_prime(s) == 1:,则多了一层转换,可读性下降。 - 符合 Python 语言习惯 (Idiomatic Python):Python 强烈推荐使用布尔值进行逻辑判断。
- 函数名称决定 (Naming Convention):在编程中,以
is_、has_或can_开头的函数,其约定俗成的规范就是返回布尔值(True/False),表示对某个状态的判断结果。
2. 返回 000 和 111 是否可行?
可行,但强烈不推荐。
在 Python 中,000 和 111 可以被解释为布尔值(称为布尔上下文):
- 000 会被解释为
False。 - 任何非零数字(包括 111)都会被解释为
True。
因此,如果函数返回 000 和 111,代码 if is_prime(s): 仍然可以正常工作。
# 假设函数返回 1 和 0
def is_prime_int(n):if n <= 1:return 0 # 0 代表 False# ... 循环 ...if n % i == 0:return 0return 1 # 1 代表 True# 这样使用是完全有效的
if is_prime_int(13):print("是质数")
但是,您不应该这样做。 如果在面试中写了返回 000 和 111 的代码,面试官可能会认为您对 Python 的最佳实践或编程规范不够熟悉。
3. 返回值和报错(Error)的关系
返回 True/False 和程序报错(Error/Exception) 是两码事。
| 概念 | 目的/用途 | 示例 |
|---|---|---|
返回值 (True/False) | 表示正常的逻辑结果。函数完成了它的判断工作并返回了判断结论。 | is_prime(5) 返回 True。 |
报错/异常 (Exception) | 表示程序在执行过程中遇到了非预期的、不正常的情况,导致函数无法完成其设计目标。 | 如果你调用 is_prime("abc"),程序会因为无法将字符串转为整数而报错(抛出 TypeError)。 |
在这个 is_prime(n) 函数中,我们期望 nnn 是一个整数,所以它只会返回 True 或 False,不会在正常情况下返回错误代码(如 −1-1−1)来表示“不是质数”。
总结:
因为函数名是 is_prime,且用于表示一个状态判断,所以最规范、最清晰、最符合 Python 习惯的返回值是布尔值 True 和 False。
1. ** 运算符的含义
- 代码中的符号:
n**0.5 - 含义:在 Python 中,
**是幂运算符(Exponentiation Operator),用于计算乘方。
| 运算符 | 含义 | 数学表示 | 举例 | 结果 |
|---|---|---|---|---|
** | 乘方(幂) | xyx^yxy | 2 ** 3 | 23=82^3 = 823=8 |
因此,n**0.5 的含义是计算 nnn 的 0.50.50.5 次方,即计算 nnn 的平方根 n\sqrt{n}n。
注意: 很多其他编程语言(如 C/C++/Java)使用
^符号来表示按位异或(Bitwise XOR),而不是乘方。在 Python 中,^也代表按位异或。
2. 为什么不用 math.sqrt() 函数?
在 Python 中,有两种主要的方法计算平方根:
- 使用
math.sqrt(n)函数:这是标准库math中的函数,专门用于计算平方根。 - 使用幂运算符
n**0.5:这是 Python 语言内置的运算符,更为简洁。
选择 n**0.5 的原因:
- 简洁性:它不需要在文件开头导入
math模块,代码更精简。 - 性能(在本例中):对于内置类型,
**运算符通常经过高度优化,性能接近甚至有时优于库函数。
在实际编码中,两种方法都是正确的。选择
n**0.5只是追求简洁和 Pythonic(符合 Python 风格)的一种方式。
3. % 运算符的含义
- 代码中的符号:
n % i == 0 - 含义:在 Python 和大多数编程语言中,
%是取模运算符(Modulo Operator)或取余运算符。
| 运算符 | 含义 | 举例 | 结果 |
|---|---|---|---|
% | 取余数 | 10 % 3 | 10÷3=310 \div 3 = 310÷3=3 余 111 |
在判断整除中的作用:
n % i == 0意为:“nnn 除以 iii 的余数等于 000”。- 这正是判断整除关系的数学表达:如果余数为 000,则 nnn 能被 iii 整除。
对比 / 运算符:
| 运算符 | 含义 | 举例 | 结果 | 目的 |
|---|---|---|---|---|
/ | 浮点除法 (True Division) | 10 / 3 | 3.333…3.333\dots3.333… | 计算商(精确值) |
// | 整数除法 (Floor Division) | 10 // 3 | 333 | 计算商(向下取整) |
% | 取模/取余 (Modulo) | 10 % 3 | 111 | 计算余数 |
因此,在判断质数的过程中,我们关心的是能否整除,所以必须使用 % 来获取余数,而不是使用 / 或 // 来获取商。
2. 预计算质数集合:PRIME_SUMS
作用:根据题目中各位数字和的范围,计算出一个包含所有可能质数和的集合,用于快速查表。
MAX_DIGIT_SUM = 9 * 18 # 10^18 - 1 的数字和最大是 9 * 18 = 162
PRIME_SUMS = {i for i in range(2, MAX_DIGIT_SUM + 1) if is_prime(i)}
这行代码的核心作用是:“从 2 遍历到 162,把其中所有的质数都挑出来,放到 PRIME_SUMS 这个集合里。”
这样,PRIME_SUMS 集合就存储了所有可能的质数和,后续的判断就非常快了。
MAX_DIGIT_SUM = 9 * 18:只需要考虑数字和在 [1,162][1, 162][1,162] 范围内的质数。PRIME_SUMS = {...}:
这行代码是一个典型的 集合推导式 (Set Comprehension)。它本质上是三步操作的浓缩:
| 部分 | 含义 | 对应英文 | 作用 |
|---|---|---|---|
i | 输出表达式 | EEE (Expression) | 每次循环要放入集合的元素。 |
for i in range(2, MAX_DIGIT_SUM + 1) | 迭代循环 | FFF (For) | 告诉程序要从哪里遍历元素。 |
if is_prime(i) | 过滤条件 | CCC (Condition) | 告诉程序哪些元素可以被放入集合。 |
对应到传统的 for 循环:
这行推导式等价于以下多行代码:
PRIME_SUMS = set() # 1. 初始化一个空集合
MAX_DIGIT_SUM = 162 # 假设值为 162# 2. 遍历所有可能的数字和
for i in range(2, MAX_DIGIT_SUM + 1): # 3. 检查是否满足条件(是否为质数)if is_prime(i): # 4. 如果满足条件,将元素加入集合PRIME_SUMS.add(i)
对比:可以看到,推导式将 4 步操作浓缩为 1 行,大大提升了代码简洁度。
2. 记忆口诀(推导式模板)
记住推导式的顺序是:[表达式] + [循环] + [条件]
用中文口诀来记忆:
“想放什么(EEE),从哪开始(FFF),满足什么(CCC)。”
| 推导式 | 口诀 | 对应代码 |
|---|---|---|
{ EEE | 想放什么?(放入遍历出的元素 iii) | i\mathbf{i}i |
| FFF | 从哪开始?(从 2 到 162 遍历 iii) | for i in range(… )\mathbf{for\ i\ in\ range(\dots)}for i in range(…) |
| CCC | 满足什么?(只有 iii 是质数时才放入) | if is_prime(i)\mathbf{if\ is\_prime(i)}if is_prime(i) |
} |
所以这行代码就是:
“构建一个集合,我要放进去(iii),是从这个范围(
for i in range(...))里找出来的,并且要满足(if is_prime(i))是质数这个条件。”
3. check_sum(num_str) 函数:快速查表判断
作用:接受一个表示数字的字符串,计算其各位数字之和 SSS,然后检查 SSS 是否在预计算的质数集合中。
def check_sum(num_str):"""检查字符串表示的数字的各位和是否为质数"""s = sum(int(d) for d in num_str)return s in PRIME_SUMS
-
计算数字和 SSS:
s = sum(int(d) for d in num_str)- 这里使用了 Python 的生成器表达式(Generator Expression):
int(d) for d in num_str。 - 它遍历字符串
num_str中的每一个字符 ddd(即每一位数字)。 - 将字符 ddd 转换为整数(
int(d))。 - 最后,
sum()函数将所有转换后的整数加起来,得到各位数字之和 SSS。
- 这里使用了 Python 的生成器表达式(Generator Expression):
-
查表判断:
return s in PRIME_SUMS- 检查计算出的数字和 sss 是否存在于预先计算好的
PRIME_SUMS集合中。 - 如果存在,则各位数字之和是质数,返回
True;否则返回False。
- 检查计算出的数字和 sss 是否存在于预先计算好的
整个流程的优势:这种预计算和查表的方法,比在每次检查时都去计算 sss 的质数性要快得多,极大地提高了算法的整体效率。
Python 代码:check_sum(num_str)
这个函数的目的是计算一个数字字符串(例如 "1244")的各位数字之和,然后快速判断这个和是否为质数。
1. 函数定义与文档字符串
| 代码 | 解释 |
|---|---|
def check_sum(num_str): | 定义函数:定义了一个名为 check_sum 的函数,它接受一个字符串参数 num_str。 |
"""检查字符串表示的数字的各位和是否为质数""" | 文档字符串 (Docstring):说明函数的功能是检查各位数字之和的质数性。 |
2. 计算各位数字之和
| 代码 | 解释 |
|---|---|
s = sum(int(d) for d in num_str) | 核心计算:这一行代码高效地计算了字符串 num_str 中所有数字字符的整数和,并将结果赋值给变量 sss。 |
| 细节拆解(生成器表达式): | |
for d in num_str:遍历字符串 num_str 中的每一个字符 ddd。例如,如果 num_str 是 "1244", ddd 会依次是 '1', '2', '4', '4'。 | |
int(d):将字符 ddd 转换为它对应的整数值。例如,将 '1' 转换为 111。 | |
sum(...):将生成器表达式产生的所有整数值(即各位数字)累加起来,得到最终的和 sss。 | |
举例:对于 "1244", s=1+2+4+4=11s = 1 + 2 + 4 + 4 = 11s=1+2+4+4=11。 |
3. 查表与返回结果
| 代码 | 解释 |
|---|---|
return s in PRIME_SUMS | 最终返回:将计算得到的数字和 sss,与预先计算好的质数集合 PRIME_SUMS 进行比较。 |
s in PRIME_SUMS:这是一个成员测试运算符。它检查 sss 是否是 PRIME_SUMS 集合中的一个元素。 | |
| 返回值: | |
* 如果 sss 在集合中,说明这个和是一个质数,表达式返回布尔值 True。 | |
* 如果 sss 不在集合中,说明这个和不是质数,表达式返回布尔值 False。 |
总结
输入(num_str) | 各位数字之和(sss) | s 在 PRIME_SUMS 中吗? | 返回值 | 结论 |
|---|---|---|---|---|
"1244" | 111111 | 是(111111 是质数) | True | 各位和是质数 |
"1233" | 999 | 否(999 不是质数) | False | 各位和不是质数 |
这个函数体现了高效查表的优势:它避免了每次都重新进行质数判断,而是通过一次简单的集合查找(O(1)O(1)O(1) 复杂度)完成了判断。
步骤二:稳定算力档判断
def is_stable(num_str):"""检查字符串表示的数字是否为稳定算力档(非递减)"""for i in range(1, len(num_str)):if num_str[i] < num_str[i-1]:return Falsereturn True
它通过遍历数字字符串的每一位,确保从左到右的数字是非递减的。
以下是对 is_stable(num_str) 函数的详细解释:
Python 代码:is_stable(num_str)
1. 函数定义与文档字符串
| 代码 | 解释 |
|---|---|
def is_stable(num_str): | 定义函数:定义了一个名为 is_stable 的函数,它接受一个字符串参数 num_str(代表矿工算力 NNN 或候选算力 XXX)。 |
"""检查字符串表示的数字是否为稳定算力档(非递减)""" | 文档字符串 (Docstring):说明函数的功能是判断数字是否满足“稳定算力档”的要求。稳定算力档的定义就是数字串是非递减的(Non-decreasing)。 |
2. 遍历检查核心逻辑
| 代码 | 解释 |
|---|---|
for i in range(1, len(num_str)): | 循环开始:遍历数字字符串 num_str。 |
len(num_str):获取字符串的长度(即数字的位数)。 | |
range(1, ...):循环索引 iii 从 111 开始,直到字符串的末尾。 | |
为什么从 i=1i=1i=1 开始? 因为我们要比较当前位 num_str[i] 与它的前一位 num_str[i-1]。从 i=1i=1i=1 开始,可以确保 i−1i-1i−1(即 000)是字符串的有效索引(第一位)。 | |
if num_str[i] < num_str[i-1]: | 条件判断:检查“非递减”的约束是否被破坏。 |
num_str[i]:当前位数字(例如,"121" 中的第二个 '2' 或第三个 '1')。 | |
num_str[i-1]:当前位的前一位数字。 | |
num_str[i] < num_str[i-1]:如果当前位小于前一位(即出现了递减),则违反了“非递减”的约束。 | |
return False | 返回值:如果找到任何一位数字小于它的前一位,函数立即返回 False。含义:这个数不是稳定算力档。 |
3. 最终判断
| 代码 | 解释 |
|---|---|
return True | 返回值:如果循环执行完毕,没有触发 return False(即在整个数字串中,没有发现递减的情况),则函数返回 True。含义:这个数是稳定算力档。 |
举例说明
输入(num_str) | 循环过程 | 结果 | 结论 |
|---|---|---|---|
"123" | i=1i=1i=1: 2 ≥\ge≥ 1 (继续);i=2i=2i=2: 3 ≥\ge≥ 2 (继续) | 循环结束,返回 True | 是稳定算力档 |
"111" | i=1i=1i=1: 1 ≥\ge≥ 1 (继续);i=2i=2i=2: 1 ≥\ge≥ 1 (继续) | 循环结束,返回 True | 是稳定算力档 |
"121" | i=1i=1i=1: 2 ≥\ge≥ 1 (继续);i=2i=2i=2: 1 $< 2` (条件满足) | 立即返回 False | 不是稳定算力档 |
"897" | i=1i=1i=1: 9 ≥\ge≥ 8 (继续);i=2i=2i=2: 7 $< 9` (条件满足) | 立即返回 False | 不是稳定算力档 |
步骤三:主算法实现
核心部分:数位 DP + 记忆化搜索 + 主函数调用
✅ 完整版(在你提供的前置代码后面直接接上)
from functools import lru_cache
# =======================
# 数位 DP 主体部分
# =======================def solve(n: int) -> int:digits = list(map(int, str(n)))length = len(digits)@lru_cache(None)def dfs(pos: int, prev: int, digit_sum: int, tight: bool) -> str | None:if pos == length:return "" if digit_sum in PRIME_SUMS else Noneupper = digits[pos] if tight else 9# 从大到小枚举for d in range(upper, prev - 1, -1):next_tight = tight and (d == upper)sub = dfs(pos + 1, d, digit_sum + d, next_tight)if sub is not None:return str(d) + subreturn Noneresult = dfs(0, 0, 0, True)return int(result) if result is not None else -1
这段代码是经典的**数位 DP(Digit Dynamic Programming)算法,它通过记忆化搜索(Memoized Search)**来高效地解决“在小于等于 NNN 的数中找最大值”这类问题。
要理解和记忆这段代码,关键在于掌握 dfs 函数的四个参数和它如何实现三大约束。
一:四位“守卫者”与三大约束
这段代码的核心是 dfs 函数,它就像一个复杂的搜索机器人,由四个“守卫者”参数控制,同时检查三项任务(约束)。
1. 四位“守卫者”(参数)
| 参数 | 含义 | 角色(记忆点) |
|---|---|---|
pos | 位置 (Position) | 进度尺:代表当前在构造数字的第几位,用于控制搜索的长度。 |
prev | 前一位 (Previous Digit) | 稳定尺:记录前一个数字,用于确保当前位 d≥prevd \ge \text{prev}d≥prev,保证非递减。 |
digit_sum | 数字和 (Sum) | 质数尺:记录各位数字之和,用于在终点检查质数和约束。 |
tight | 紧边界 (Tightness) | 上限尺:布尔值,代表当前构造的前缀是否与 NNN 的前缀完全相同,用于确保 X≤NX \le NX≤N。 |
2. 三大约束的实现
dfs 函数体内的逻辑是围绕这三大约束展开的:
A. 约束一:X≤N\mathbf{X \le N}X≤N (由 tight 决定上限)
这是数位 DP 的核心。
| 代码片段 | 作用 | 记忆点 |
|---|---|---|
upper = digits[pos] if tight else 9 | 确定当前位的遍历上限。如果 tight 为真(前缀紧贴 NNN),上限就是 NNN 的当前位;否则(前缀已经小于 NNN),上限是 999。 | 紧边界决定天花板。 |
next_tight = tight and (d == upper) | 紧边界的传递。只有当 ddd 达到了上限 upper,且前一位也是紧边界时,next_tight 才是真。 | 只有贴着走才继续紧。 |
B. 约束二:稳定算力档(非递减)
| 代码片段 | 作用 | 记忆点 |
|---|---|---|
for d in range(upper, prev - 1, -1): | 循环从 upper 开始,到 prev - 1 结束。这保证了 d≥prev\mathbf{d \ge prev}d≥prev。 | 从 prev\mathbf{prev}prev 开始爬坡。 |
C. 约束三:数字和为质数
| 代码片段 | 作用 | 记忆点 |
|---|---|---|
if pos == length: | 终点检查。 | 跑到终点再查和。 |
return "" if digit_sum in PRIME_SUMS else None | 质数和检查。如果和是质数,返回有效解("");否则返回 None。 | 是质数才放行。 |
3. 整体流程与最大化原则
要理解 为什么这个函数能找到最大值,关键在于循环方向和返回机制:
- 从大到小枚举:
for d in range(upper, prev - 1, -1)→\rightarrow→ 这确保了我们总是优先尝试最大的数字 ddd。 - 找到即返回:
if sub is not None: return str(d) + sub→\rightarrow→ 由于是从高位到低位,且每一位都是从大到小尝试的,所以找到的第一个完整解 XXX 必然是最大的。
记忆口诀(自顶向下搜索):
“从高位(
pos=0)开始,用 NNN 的上限(tight=True)框住。
每一位(d)都要从大到小(upper到 prev\mathbf{prev}prev)试。
一旦找到一个有效数字 ddd,立即递归(dfs),拼接(str(d) + sub),第一个返回的就是最大值。”
通过分解参数的角色和对应约束,这段复杂的数位 DP 代码的逻辑结构就会变得清晰而容易记忆。
1️⃣ 输入和准备
digits = list(map(int, str(n)))
length = len(digits)
- 将数字
n转成 每位数字的列表,方便按位处理。 length是数字的位数。
2️⃣ 内部递归函数 dfs
@lru_cache(None)
def dfs(pos: int, prev: int, digit_sum: int, tight: bool) -> str | None:
参数解释:
-
pos:当前处理的数字位索引(从左到右)。 -
prev:前一位数字,保证生成的数字 非递减。 -
digit_sum:当前已经生成数字的 数字和。 -
tight:是否受n的上界限制。tight=True表示当前位置的数字不能超过digits[pos]。tight=False表示当前位置可以自由选数字0~9。
返回值:
- 返回 从当前位置生成的最大数字字符串,或者
None表示无解。
3️⃣ 递归终止条件
if pos == length:return "" if digit_sum in PRIME_SUMS else None
pos == length表示 递归已经处理到数字的最后一位之后,也就是整个数字已经生成完成。
return "" if digit_sum in PRIME_SUMS else None
digit_sum是当前生成数字的 数字和。PRIME_SUMS是预先计算好的 质数集合(1到162以内的质数)。
return "" if digit_sum in PRIME_SUMS else None 可以分成两种情况:
-
数字和是质数:
digit_sum in PRIME_SUMS为 True- 返回空字符串
""
✅ 这里返回空字符串,是为了递归向上一层构建完整的数字。例如上一位加上当前位形成数字字符串。
-
数字和不是质数:
- 返回
None - 表示这个生成的数字 不合法,需要回溯到上一层尝试其他可能。
- 返回
3️⃣ 为什么返回 "" 而不是数字
-
递归构造数字是从高位到低位:
str(d) + sub -
当递归到末位:
- 如果合法,用空字符串作为“基础”,上一层再加上当前位即可。
- 如果不合法,用
None表示没有解,这样上一层就知道要尝试下一个数字。
4️⃣ 举例
假设目标数字是 7:
-
dfs(pos=1, prev=7, digit_sum=7, tight=True):- 已经到末位
pos == length digit_sum = 7,7 是质数- 返回
""
- 已经到末位
-
上一层:
return str(7) + "" # 返回 "7"
如果数字是 4:
-
dfs(pos=1, prev=4, digit_sum=4, tight=True):digit_sum = 4,不是质数- 返回
None
-
上一层知道这个数字不合法,会尝试别的数字。
🔹 总结
这句代码的核心作用:
-
判断生成的数字是否合法(数字和是质数)。
-
返回递归构造的基础:
- 合法 →
""作为字符串的基础 - 不合法 →
None,触发回溯
- 合法 →
4️⃣ 当前位的上界
upper = digits[pos] if tight else 9
- 如果
tight=True,当前位不能超过n的对应位digits[pos]。 - 如果
tight=False,当前位可以自由取最大 9。
5️⃣ 核心逻辑(循环尝试当前位)
for d in range(prev, upper + 1):next_tight = tight and (d == upper)sub = dfs(pos + 1, d, digit_sum + d, next_tight)if sub is not None:return str(d) + sub
-
range(prev, upper + 1):- 保证当前位 >= 前一位,满足 非递减。
-
next_tight = tight and (d == upper):- 如果当前位选的数字等于上界,下一位仍受限制。
- 否则下一位可以自由选。
-
dfs(pos + 1, d, digit_sum + d, next_tight):- 递归处理下一位,更新
digit_sum和prev。
- 递归处理下一位,更新
-
if sub is not None: return str(d) + sub:- 一旦找到合法解,返回这个组合,因为我们是从大到小枚举,所以第一个合法解就是最大解。
🔹 总结
这个 solve 函数做的事情就是:
- 按位构造不超过
n的数字。 - 保证数字非递减(每位 ≥ 前一位)。
- 保证数字和是质数。
- 利用 记忆化 避免重复计算相同状态,加速递归搜索。
- 使用
tight控制是否受n限制,保证生成数字 ≤ n。 - 枚举顺序从大到小 → 第一个合法数字就是 最大数字。
完全可以说这是 通用的方法,尤其在 数位 DP(Digit DP)题里非常常见
1️⃣ 方法本质
这个 solve 函数实现的是 数位 DP + 记忆化搜索(带剪枝):
-
数位 DP 的思想是:
- 按 数字的每一位 从高位到低位递归处理。
- 每一位可以选择的数字范围,受前一位数字限制(如非递减)以及目标数字
n的前缀约束。 - 用 状态记录 避免重复计算,常用
lru_cache或显式 DP 表。
-
状态设计:
pos -> 当前处理到第几位 prev -> 上一位数字,保证非递减 digit_sum -> 当前数字和 tight -> 是否还受到 n 前缀限制 -
递归逻辑:
- 遍历当前位可能取的数字
- 累加数字和
- 根据
tight决定下一位是否还受约束 - 递归结束时判断数字和是否满足要求(这里是质数)
-
从大到小枚举保证第一个合法解就是 最大值。
2️⃣ 为什么常见
这种模式在很多面试题和竞赛题里出现,例如:
-
求最大/最小满足条件的数字 ≤ n
-
题目条件可以是:
- 数字和满足某种约束(质数、能被 k 整除、能组成回文等)
- 数字按某种规律排列(非递减、交替大小)
-
-
计数问题
- 不求具体数字,而是求 满足条件的数字有多少个
- 状态类似:
dfs(pos, prev_digit, digit_sum, tight)→ 返回数量
-
组合优化问题
- 题目要求在约束下选出最大/最小值
- DP 记录状态,递归从大到小/小到大枚举即可得到最优解
3️⃣ 这个方法的通用特点
- 高位到低位递归:保证顺序、方便处理前缀约束
- tight 标记:处理数字不能超过 n
- prev 或其他状态变量:处理数字间关系(非递减/非递增)
- digit_sum 或其他累加量:处理全局条件(和、模、特定模式等)
- 记忆化/DP:防止重复计算,保证 O(位数 * 状态数) 的复杂度
4️⃣ 总结
-
这是 数位 DP 的标准模板,很多代码题都可以套用。
-
变形非常灵活:
- 可以统计数量或求最大值/最小值
- 可以改成 数组 DP 而不是递归 +
lru_cache - 可以添加更多状态,如奇偶、模数、前缀标记等
from functools import lru_cachedef is_prime(n):"""判断是否为质数"""if n <= 1:return Falsefor i in range(2, int(n**0.5) + 1):if n % i == 0:return Falsereturn True# 预先计算数字和可能的质数集合
MAX_DIGIT_SUM = 9 * 18
PRIME_SUMS = {i for i in range(2, MAX_DIGIT_SUM + 1) if is_prime(i)}def is_stable(num_str):"""判断是否为非递减数字"""for i in range(1, len(num_str)):if num_str[i] < num_str[i-1]:return Falsereturn True# =======================
# 数位 DP 主体部分
# =======================def solve(n: int) -> int:digits = list(map(int, str(n)))length = len(digits)@lru_cache(None)def dfs(pos: int, prev: int, digit_sum: int, tight: bool) -> str | None:if pos == length:return "" if digit_sum in PRIME_SUMS else Noneupper = digits[pos] if tight else 9# 从大到小枚举for d in range(upper, prev - 1, -1):next_tight = tight and (d == upper)sub = dfs(pos + 1, d, digit_sum + d, next_tight)if sub is not None:return str(d) + subreturn Noneresult = dfs(0, 0, 0, True)return int(result) if result is not None else -1# 测试
for n in [7, 123, 1,100,20]:print(n, "->", solve(n))
