ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从鸽巢原理到数位性质:解析“3友好整数”问题的高效算法

从鸽巢原理到数位性质:解析“3友好整数”问题的高效算法 1. 项目概述从一道竞赛题看“3友好整数”的数学魅力与编程实践最近在牛客网的暑期多校训练营里刷题遇到了一个挺有意思的题目叫“Find 3-friendly Integers”。乍一看标题可能会有点懵什么是“3友好整数”这可不是社交网络里的“好友”而是一个与数字整除性和数字组合特性相关的数学概念。简单来说一个正整数如果它本身、或者它的任意连续子串也就是从原数中截取一段连续数字组成的数能被3整除那它就是“3友好”的。题目要求我们找出在给定区间[L, R]内这样的整数有多少个。这题出自牛客竞赛是典型的算法题考察的不仅仅是编程实现更是对数学规律、数位性质和算法效率的深刻理解。无论是用Python、Java还是C解题的核心思路都是相通的。很多同学第一次碰到可能会尝试暴力枚举但区间范围可能很大比如L1, R10^18直接遍历显然会超时。这就需要我们跳出“模拟”的思维去寻找更本质的规律。今天我就结合自己的解题经历把这道题从问题本质、数学推导到代码实现的完整过程拆解一遍并分享一些在牛客网、LeetCode等平台打比赛时处理这类数位相关问题的通用技巧。2. 核心思路解析为什么暴力不可行以及破局的关键2.1 问题重述与初步分析题目定义非常清晰对于一个正整数如果它本身或者它的任意一个连续子串子串必须至少包含一位数字能被3整除则称这个数为“3-friendly”。给定两个整数L和R我们需要计算区间[L, R]内“3-friendly”整数的个数。最直观的想法就是写一个函数is_friendly(n)检查数字n的每一个连续子串。对于一个k位数它有多少个连续子串呢答案是k*(k1)/2个。对于一个长达18位的数字R最大可达10^18这个检查的复杂度是O(k^2)虽然对于单个数字尚可接受但如果要对区间内海量的数字逐一检查总时间复杂度将是O(N * k^2)其中N是区间长度。当区间跨度很大时例如R-L1接近10^18这是完全不可行的计算量。所以我们必须寻找一个不需要逐个数字检查就能快速统计的数学规律。这引导我们思考“3友好”这个性质是否与数字的某些全局特性有关2.2 关键数学洞察连续子串与模3余数的关系这里就需要用到数论中关于被3整除的经典性质一个整数能被3整除当且仅当它的各位数字之和能被3整除。这个性质是我们解题的基石。现在考虑一个数字的十进制表示d1 d2 d3 ... dk。它的一个连续子串对应原数字的一段连续序列。我们关心的是是否存在某个子串其数字之和能被3整除。我们可以计算原数字每一位的前缀和模3的余数。令prefix[i] (d1 d2 ... di) % 3其中i从1到k。一个重要的观察是对于任意子串di di1 ... dj其数字之和等于prefix[j] - prefix[i-1]在模3意义下。根据模运算的性质这个子串的数字之和模3为0当且仅当prefix[j] ≡ prefix[i-1] (mod 3)。这意味着什么这意味着只要在原数字的前缀和数组模3后中存在两个位置包括起始的0位置即i-10时prefix[0]0的余数相同那么这两个位置之间的子串数字之和就一定能被3整除。因为余数只有0, 1, 2三种可能。根据鸽巢原理抽屉原理对于一个长度大于等于4的数字即k4我们有k1个前缀和位置从0到k但只有3种可能的余数。因此当数字的位数大于等于4时必然存在至少两个前缀位置的余数相同。从而该数字必然包含一个数字之和能被3整除的连续子串也就是说所有位数大于等于4的正整数都是“3友好的”这是一个非常强的结论它极大地简化了我们的问题。它告诉我们我们只需要特别关注位数小于4的数字即1位数、2位数和3位数。对于所有位数4的数字我们可以直接认为它们都是友好的。2.3 思路总结与问题转化基于以上分析我们的解题策略变得清晰处理大数区间对于区间[L, R]其中位数4的部分我们可以直接计算其数字个数。例如所有4位数及以上的数字都是友好的。处理边界小数对于区间内位数小于4的部分即1~999我们不能直接套用上述结论需要单独处理。因为区间可能从很小的数开始比如L1也可能结束在很小的数比如R50。我们需要精确计算出[1, 999]这个范围内有多少友好数。合并计算最终答案 (区间内所有位数4的数字个数) (区间内位数3的数字中的友好数个数)。通常我们会计算[1, R]内的友好数个数F(R)和[1, L-1]内的友好数个数F(L-1)然后答案就是F(R) - F(L-1)。这样我们只需要实现一个函数F(x)它能计算从1到x的友好数个数。现在问题转化为如何高效实现F(x)3. 核心函数F(x)的设计与实现函数F(x)的目标是计算从1到xx 1中“3-friendly”整数的个数。根据我们的数学洞察实现分为两种情况。3.1 当x 1000时因为1000是第一个4位数根据结论所有4位数及以上的数都是友好的。所以所有小于1000的数1~999我们需要单独计算其中的友好数个数记这个数为cnt_under_1000。这个数可以预处理出来是一个常数。从1000到x的数字个数为(x - 999)并且它们全都是友好的。因此F(x) cnt_under_1000 (x - 999)。3.2 当x 1000时此时x最多是三位数我们不能使用上面的结论必须精确计算。方法有两种暴力枚举因为x1000最多只有999个数对每个数检查其所有连续子串是否满足条件复杂度完全可以接受。我们可以预处理出1~999每个数是否友好存储在一个数组里或者直接写一个判断函数在需要时计算。数位DP动态规划这是一种更通用的方法尤其适用于位数上限很高但需要精确计算的情况。不过对于x1000杀鸡用牛刀暴力法更简单直接。在实际编码中为了代码清晰和通用性我推荐预处理1~999的结果。我们可以在程序开始时用一个循环计算出friendly[1]到friendly[999]的布尔值并同时计算出前缀和数组pre[i]表示从1到i的友好数个数。这样当x1000时F(x)直接就是pre[x]。3.3 预处理1~999的友好数如何判断一个小于1000的数n是否友好如果n本身能被3整除那么它肯定是友好的。否则检查它的所有连续子串。对于一个最多3位的数我们可以手动枚举所有子串1位数就是各个数位本身。2位数取前两位和后两位如果是3位数。3位数取整个数以及前两位、后两位。 只要其中有一个子串能被3整除它就是友好的。这里有一个编码技巧对于数字n我们可以把它转换成字符串s然后使用两层循环枚举所有可能的起始位置i和结束位置j取出子串s[i:j1]转换成整数判断是否能被3整除。这种方法逻辑清晰不易出错。注意在判断子串是否被3整除时可以利用“各位数字之和能被3整除”的性质来优化避免大数转换。但对于3位数直接转换整数判断也更简单。3.4 代码实现框架以Python为例下面给出一个清晰、高效的Python实现框架包含了预处理和F(x)函数。# 预处理部分 MAX_PRE 1000 is_friendly [False] * MAX_PRE # is_friendly[i] 表示数字i是否友好 pre_sum [0] * MAX_PRE # pre_sum[i] 表示从1到i的友好数个数 def check_friendly(num): 检查一个小于1000的数是否友好 if num % 3 0: return True s str(num) length len(s) # 枚举所有连续子串 for i in range(length): for j in range(i, length): sub_str s[i:j1] # 避免检查单个数字为0的情况题目通常认为正整数且单个0可能不构成子串需确认 # 根据题意子串是连续数字且至少一位。如果子串表示的数字为0通常不算除非原数包含0。 # 但更重要的是如果子串能被3整除则友好。 sub_num int(sub_str) if sub_num ! 0 and sub_num % 3 0: # 通常0能被任何非零数整除但这里我们关注的是子串值 return True return False # 填充is_friendly和pre_sum数组 for i in range(1, MAX_PRE): is_friendly[i] check_friendly(i) pre_sum[i] pre_sum[i-1] (1 if is_friendly[i] else 0) # 核心函数 F(x) def count_friendly_up_to(x): 返回1到x含之间的友好数个数 if x 0: return 0 if x MAX_PRE: return pre_sum[x] else: # x 1000 # 友好数 (1~999的友好数) (1000~x的所有数个数) return pre_sum[999] (x - 999) # 主函数处理查询 [L, R] def solve(): L, R map(int, input().split()) ans count_friendly_up_to(R) - count_friendly_up_to(L-1) print(ans) if __name__ __main__: solve()3.5 关于0和负数的处理题目通常给定的是正整数区间L, R且1 L R。所以我们的处理从1开始。如果题目边界包含0需要特别注意0本身能被3整除按照定义应该是友好的。但我们的预处理从1开始count_friendly_up_to(0)会返回0。如果区间可能包含0需要在函数开头或调用处特殊处理。根据牛客原题描述输入是正整数所以我们可以忽略0的情况。4. 算法正确性验证与边界测试理论推导需要代码验证。我们可以设计一些测试用例来确保我们的算法在各种边界情况下都是正确的。4.1 测试用例设计小范围暴力验证对于L1, R100我们可以用最朴素的暴力算法对每个数检查所有子串计算结果与我们的优化算法结果对比。必须完全一致。跨越1000的边界这是关键测试点。L999, R1000答案应该是F(1000)-F(998)。999需要判断1000是4位数自动友好。L990, R1010测试包含多位数和少位数混合的情况。大数测试L1, R10**18测试算法效率应该瞬间出结果。答案应为F(10**18) pre_sum[999] (10**18 - 999)。L10**18-100, R10**18测试右边界。单个数字LR3友好。LR7不友好7 子串只有7不能被3整除。LR37友好因为子串37%31 但子串7%31 3%30等等37的子串有3友好7不友好37不友好。因为存在子串3能被3整除所以37是友好的。这个例子很好地说明了为什么需要检查所有子串。三位数复杂案例124子串有1,2,4,12,24,124。12能被3整除吗123可以。所以124友好。157子串1,5,7,15,57,157。检查1%31,5%32,7%31,15(156)%30。所以157友好。111子串1,11,111。数字和分别为1,2,3。111的数字和是3能被3整除所以友好。4.2 预处理数组的验证我们可以手动计算或写一个小脚本输出1-30以内哪些数是友好的来验证预处理函数的正确性。 例如 友好数1-303, 6, 9, 12, 13, 15, 18, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30... 注意13是友好的因为子串3能被3整除。22是友好的因为子串2和2单独都不能但22本身22%31不行等等22的子串2不友好2不友好2222%31不友好。所以22实际上不是友好数这里是一个极易出错的地方让我们重新审视定义一个数是友好的如果它本身或它的任意连续子串能被3整除。 对于22它本身22 % 3 1不能。它的连续子串有”2“ (2%32), “2“ (同上), “22“ (22%31)。 没有一个能被3整除。所以22不是友好数。我之前的举例有误。这说明手动枚举容易出错必须严格依赖代码逻辑。我们的check_friendly函数会正确判断22为False。那么1-30真正的友好数有哪些让我们用逻辑推导首先所有能被3整除的数3,6,9,12,15,18,21,24,27,30。这些都是友好的。其次不能被3整除但包含一个能被3整除的子串的数。子串可能是一位数即包含数字3,6,9,0也可能是多位数其和能被3整除。包含数字3,6,9,0的13,16,19,23,26,29, 30已计31... 但注意像13包含’3’所以友好。23包含’3’吗不是2和3数字3本身是子串吗是的”3“是23的一个连续子串取第二位。所以23友好。同理26包含629包含92和9子串”9“友好。不包含3,6,9,0但有多位子串和能被3整除例如15已被计本身能被3整除。14子串1,4,14都不行。171,7,17不行。25子串2,5,25。257%31不行。282,8,28不行。所以1-30的友好数列表应为3,6,9,12,13,15,18,21,23,24,26,27,29,30。22不在其中通过这个纠错过程我想强调一个非常重要的实操心得在解决数位或子串相关问题时对边界案例和定义的理解必须百分之百精确。像“22”这样的数乍一看好像有重复数字容易想当然。最可靠的方法是信任你编写的通用判断函数并用它来生成小范围的正确结果作为后续推理的基准。在比赛或开发中花几分钟写一个暴力验证程序来检查优化算法的正确性是非常值得的投资。5. 性能分析与扩展思考5.1 时间复杂度分析我们的算法时间复杂度是常数级的O(1)与区间[L, R]的大小无关。预处理需要检查1到999共999个数。对于每个数最多3位检查子串的复杂度是O(3^2)O(9)。所以预处理总代价约为999 * 9 ≈ 9000次操作可以忽略不计。查询函数F(x)只是几次整数比较、加减法和数组访问是O(1)的。因此处理多次查询如果题目有也毫无压力。5.2 空间复杂度分析我们使用了两个长度约为1000的数组来存储预处理结果空间复杂度为O(1000)是常数空间。5.3 从“3友好”到“K友好”的扩展这是一个很自然的延伸思考。如果题目不是找能被3整除的子串而是能被另一个数K整除的子串例如Find 7-friendly Integers我们的方法还适用吗核心的鸽巢原理推理仍然有效但条件发生了变化。一个数能被K整除的判定不再简单地等于各位数字之和能被K整除这只对3和9成立。对于一般的K没有这样简单的数字和性质。但是我们仍然可以计算前缀和模K的余数。如果两个前缀位置的余数相同它们对应的子串和模K为0。鸽巢原理告诉我们如果数字的位数n K那么n1个前缀放入K个余数抽屉必然有重复所以该数一定是“K友好”的。因此对于一般的K结论是所有位数大于K的正整数都是“K友好”的。那么我们的算法框架依然可用但需要调整预处理的范围不再是1~999而是1到10^K - 1即所有位数小于等于K的数。当K很大时比如K100这个预处理范围会变得极大暴力枚举可能不可行。判断一个数是否“K友好”的方法也需要改变不能再用数字和而是需要计算每个子串的值模K。对于大K和大数这可能需要高精度计算或更巧妙的数位DP。所以原题中K3是一个非常特殊的友好值它使得预处理范围很小999且判断规则简单用数字和让题目难度保持在适中水平。如果K变大题目可能会演变成一个更复杂的数位DP问题。5.4 在牛客、LeetCode等平台的实战技巧先暴力再优化拿到题目如果数据范围小先写一个暴力程序确保理解题意并用于验证后续优化算法的正确性。就像我们刚才验证1~30的结果一样。寻找数学规律对于计数类问题尤其是区间计数直接枚举往往不行。要尝试寻找问题的数学本质或组合规律。这道题的关键就是“位数4必然友好”的鸽巢原理推论。化区间为前缀计算[L, R]的问题转化为计算F(R) - F(L-1)这是一个非常常见的技巧前缀和思想。注意数据范围和溢出本题中R最大可达10^18在Python中整数不会溢出但在C/Java中计算(x - 999)时x需要用long long(C) 或long(Java) 类型来存储。测试边界L1, R1000, R999, LR 等情况一定要测试。6. 常见问题与排查实录在实际实现和调试过程中可能会遇到以下几个典型问题6.1 问题一结果比暴力枚举的结果大症状对于小范围数据如1-200优化算法的结果比暴力枚举的结果多。可能原因对“连续子串”的定义理解有误可能包含了空串或非连续的子序列。题目明确要求是“连续子串”。对“整除”和“子串为0”的处理有误例如数字105子串“0”是合法的连续子串吗数字0能被3整除吗是的0能被任何非零整数整除。所以105包含子串“0”因此105是友好的。如果你的判断函数忽略了长度为1且字符为’0’的子串就会漏掉这种情况。预处理范围或边界错误F(x)函数中当x1000时公式pre_sum[999] (x - 999)是否正确x-999代表的是从1000到x的数字个数包含1000和x。确保你的pre_sum[999]确实包含了1-999的所有友好数。排查步骤写一个绝对正确的暴力函数brute_force(l, r)用于验证。对于第一个出错的区间例如算出[1, N]结果不同可以逐个数字打印看是哪个数字的判断出了问题。重点关注包含数字0的数如10, 20, 30, 101, 102等。6.2 问题二结果比暴力枚举的结果小症状优化算法结果比暴力结果少。可能原因“位数4必然友好”的结论应用错误这个结论的前提是十进制。你是否错误地应用于其他进制或者忽略了某些特殊情况对于所有正整数只要位数4结论就成立无需例外。预处理数组pre_sum计算错误pre_sum[i]应该是pre_sum[i-1] (1 if friendly else 0)。检查递推公式和初始值pre_sum[0]通常为0。F(x)函数中x1000的分支错误直接返回了pre_sum[x]但pre_sum下标是否从0开始pre_sum[1]是否对应数字1的友好性累计确保下标对齐。排查步骤单独测试F(999)是否正确。它应该等于pre_sum[999]。测试F(1000)。它应该等于pre_sum[999] 1。因为1000是第一个4位数它是友好的所以从1到1000的友好数比1到999多一个。检查你的check_friendly函数对于像100、200这样的数判断是否正确。它们包含子串“0”应该是友好的。6.3 问题三处理输入时遇到麻烦症状程序在读取输入时崩溃或得到错误结果。可能原因输入格式不匹配题目通常是单行输入两个整数L和R用空格隔开。使用input().split()读取。未考虑多组测试数据有些题目可能包含T组测试用例。需要先读取T然后循环T次。数据范围导致溢出在C/Java中即使使用long long计算(x - 999)时如果x是10^18x-999也在long long范围内没问题。但要确保所有中间变量都用long long。通用输入处理模板Pythonimport sys def solve(): data sys.stdin.read().strip().split() if not data: return # 假设单组测试L, R map(int, data) # 假设多组测试第一项是T it iter(data) T int(next(it)) for _ in range(T): L int(next(it)) R int(next(it)) ans count_friendly_up_to(R) - count_friendly_up_to(L-1) print(ans)6.4 一个必须处理的细节数字0题目要求的是“正整数”。所以我们的区间从1开始。但我们的判断函数check_friendly在预处理时如果传入0会发生什么str(0)是’0’循环会检查子串’0’int(‘0’)是00 % 3 0为True所以函数会返回True。这符合数学定义0能被3整除但不符合题目“正整数”的要求。因此在我们的预处理循环中是从1到999避开了0。在F(x)函数中如果x为0我们直接返回0。这样就保证了逻辑一致性。最后分享一个我在多次竞赛中验证过的习惯写完代码后用几个极端和典型的测试用例瞬间验证一下思路。比如这道题我会立刻测试(1, 10**18)、(999, 1000)、(1, 3)、(7, 7)这几个案例并与心算或暴力小程序的结果对比。如果都能对上心里就踏实了九成。剩下的就是提交后根据在线判题系统的反馈进行微调了。这道“Find 3-friendly Integers”题目的核心就在于那一下“位数大于等于4必然友好”的洞察一旦抓住海阔天空。
返回列表