尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

幸运数字II题解:基于数字生成与区间跳跃的算法优化

幸运数字II题解:基于数字生成与区间跳跃的算法优化 1. 项目概述从一道题看“幸运数字”的解题艺术最近在牛客网的算法题库里又看到了“幸运数字II”这道题。它属于那种初看有点绕但一旦理清思路实现起来又很清爽的题目非常适合用来锻炼对数字处理、区间操作和思维严谨性的把握。很多朋友卡住往往不是因为算法有多高深而是被题目描述中的“下一个幸运数字”和区间累加给绕晕了。今天我就结合自己多次ACAccepted的经验把这道题的来龙去脉、核心思路、代码实现以及那些容易踩的坑掰开揉碎了讲清楚。无论你是正在备战笔试面试还是单纯想提升一下解决此类模拟/枚举问题的能力这篇题解都会让你有收获。简单来说题目定义了一种“幸运数字”只由数字4和7组成。比如4, 7, 44, 47, 74, 77... 都是幸运数字。给定一个区间 [L, R]题目要求我们计算这个区间内所有整数的“幸运值”之和。而一个数n的“幸运值”被定义为大于等于n的第一个幸运数字。举个例子数字5的幸运值是7因为5,6都不是幸运数字7是数字7的幸运值就是7本身数字50的幸运值是74。所以我们的任务就是高效地算出从L到R的每一个数其对应的幸运值然后求和。2. 核心思路拆解化连续为离散的跳跃计算直接遍历L到R的每一个数然后为每个数寻找下一个幸运数字再累加在R很大比如10^9时会超时这是最朴素也最不可行的想法。这道题的精髓在于我们需要发现“幸运值”在连续整数上的变化规律。2.1 关键观察幸运值是分段常数让我们列一小段数字来看看数字 1, 2, 3, 4 - 幸运值都是 4 (因为4是它们的第一个幸运数字)数字 5, 6, 7 - 幸运值都是 7数字 8, 9, 10, ..., 43 - 幸运值都是 44数字 44, 45, 46, 47 - 幸运值分别是 44, 47, 47, 47...发现了吗幸运数字将整个数轴划分成了一段一段的区间。在每个区间内所有整数的幸运值都相同且等于该区间右端点的那个幸运数字或者说是定义这个区间的“下一个幸运数字”。更准确地说对于相邻的两个幸运数字luck[i]和luck[i1]所有满足luck[i] n luck[i1]的整数n它们的幸运值都是luck[i1]。注意当n自己就是幸运数字时其幸运值等于它自身即luck[i]。因此整个解题框架就清晰了生成所有在范围内的幸运数字我们需要一个有序列表包含所有可能涉及到的幸运数字。由于L和R最大可以到10^9我们需要生成所有不超过某个上限比如R1的幸运数字。定位区间并分段计算找到L和R分别落在哪个幸运数字区间里。然后将[L, R]这个区间根据幸运数字列表切割成若干个“幸运值恒定”的子区间。快速求和对于每一个子区间[start, end]其幸运值为luck_val那么它对总和的贡献就是(end - start 1) * luck_val。将所有子区间的贡献累加即可。2.2 为什么不能暴力枚举数据范围的考量题目中L和R的范围通常是1到10^9。如果暴力枚举每个数复杂度是O(N)在10^9的量级下必然超时。而幸运数字的数量是多少呢由4和7组成的、长度不超过k位的数字总数是2^1 2^2 ... 2^k。因为10^9是10位数我们只需要生成到10位数实际上比R大的第一个幸运数字可能位数更多一点但非常有限。计算一下2^1到2^10的和是2046也就是说我们最多只需要生成约2000个幸运数字。这个数量级非常小无论是生成还是后续遍历代价都极低。这就是“化连续为离散”思想的威力将复杂度从O(R-L)降到了O(M)其中M是幸运数字的数量。3. 实操步骤详解手把手实现AC代码理解了思路我们来看具体怎么实现。我会以C为例进行讲解其他语言的逻辑是完全一致的。3.1 第一步生成幸运数字列表我们需要生成一个有序的、包含所有可能相关的幸运数字的列表。一个经典的生成方法是使用BFS广度优先搜索或DFS深度优先搜索这里用DFS更直观。// 生成所有不超过上限 limit 的幸运数字 vectorlong long generateLuckyNumbers(long long limit) { vectorlong long lucky; // DFS函数cur表示当前生成的数字 functionvoid(long long) dfs [](long long cur) { if (cur limit) return; // 超过上限停止递归 if (cur 0) lucky.push_back(cur); // 大于0的数字加入列表避免把0加进去 dfs(cur * 10 4); // 末尾加4 dfs(cur * 10 7); // 末尾加7 }; dfs(0); // 从0开始生成 sort(lucky.begin(), lucky.end()); // DFS生成顺序并非严格有序需要排序 return lucky; }注意这里上限limit应该设多少因为我们要找的是“大于等于n的第一个幸运数字”所以对于区间右端点R我们可能需要一个比R大的幸运数字。一个安全的做法是将上限设置为R1或者一个足够大的数比如10^10。但更高效的做法是在生成时当数字超过R1且已经比当前列表中最大数大时就可以停止但为了代码简洁通常直接生成到比如1e10100亿这个数量级对于2000多个数字来说生成很快。实操心得在实际编码中我更喜欢用BFS队列来生成感觉更清晰且自然有序按数字大小层级增长。但DFS代码更短。两种方式都需要最后排序因为DFS先深挖“4”分支会先生成4, 44, 444,...然后才是47等不是严格按数值大小。BFS版本参考vectorlong long generateLuckyNumbersBFS(long long limit) { vectorlong long lucky; queuelong long q; q.push(0); while (!q.empty()) { long long cur q.front(); q.pop(); long long nxt4 cur * 10 4; long long nxt7 cur * 10 7; if (nxt4 limit) { lucky.push_back(nxt4); q.push(nxt4); } if (nxt7 limit) { lucky.push_back(nxt7); q.push(nxt7); } } // BFS生成的结果已经是按层递增且同层内先4后7但为了绝对有序依然建议排序 sort(lucky.begin(), lucky.end()); return lucky; }3.2 第二步分段计算逻辑与指针遍历生成了幸运数字列表luck后假设luck [4, 7, 44, 47, 74, 77, ...]。 我们需要计算区间[L, R]的和。定义两个指针i和j或者用一个循环遍历幸运数字。核心是找到覆盖[L, R]的那些“幸运值恒定区间”。算法流程在幸运数字列表末尾添加一个很大的数如1e18作为哨兵方便处理边界。找到第一个大于等于L的幸运数字的索引pos。那么luck[pos]就是L的幸运值吗不一定。仔细分析如果L本身就是一个幸运数字比如L44那么从L开始直到下一个幸运数字luck[pos1]之前幸运值都是44吗不对44自己的幸运值是44但45的幸运值是47。所以L如果是幸运数字它自己独占一个区间长度为1幸运值为L。如果L不是幸运数字那么从L开始直到第一个大于L的幸运数字luck[pos]之前这些数的幸运值都是luck[pos]。 因此更通用的方法是我们关注的是“幸运值”而幸运值就是某个幸运数字luck[k]。对于区间[luck[k-1], luck[k]-1]内的所有数注意左闭右开它们的幸运值都是luck[k]。特别地当数等于luck[k]时其幸运值就是luck[k]。所以我们可以遍历幸运数字列表对于相邻的两个幸运数字a luck[i],b luck[i1]区间[a, b-1]的幸运值都是b。数a本身的幸运值是a但它被上面的区间规则覆盖了吗没有因为[a, b-1]包含了a而a的幸运值应该是a不是b。这里出现了矛盾这揭示了我们的区间定义需要调整。正确的区间划分 让我们重新审视对于任意一个幸运数字x它自身的幸运值就是x。对于一个非幸运数字y假设比y大的第一个幸运数字是next_luck那么y的幸运值就是next_luck。那么如何划分区间使得区间内幸运值相同呢 假设我们有幸运数字序列..., L_i, L_{i1}, ...。对于所有满足L_i n L_{i1}的整数n它们的幸运值都是L_{i1}。对于n L_i幸运值就是L_i。所以每个幸运数字L_i自己单独构成一个长度为1的区间幸运值为L_i。而两个幸运数字之间的“缝隙”(L_i, L_{i1})构成一个区间区间内所有数的幸运值都是L_{i1}。因此我们可以这样计算遍历所有幸运数字区间包括幸运数字点和缝隙区间。对于每个区间[left, right]其幸运值为val。计算原始区间[L, R]与当前区间[left, right]的交集。如果交集不为空设交集为[max(L, left), min(R, right)]则其对总和的贡献为(交集长度) * val。具体实现时更巧妙的做法 我们可以不显式地划分出“缝隙区间”而是用一个指针cur表示当前“幸运值”。初始时cur设为第一个大于等于L的幸运数字。然后我们用n从L遍历到R但这不是暴力遍历每个数而是“跳跃”遍历。跳跃遍历算法初始化ans 0,n L。找到第一个大于等于n的幸运数字cur_luck。这可以用二分查找在幸运数字列表中快速完成。那么从n开始直到min(R, cur_luck)这些数字的幸运值都是cur_luck。注意上界是min(R, cur_luck)因为当n增长到cur_luck时幸运值就变了。如果cur_luck R则区间[n, cur_luck]的幸运值都是cur_luck。但注意cur_luck本身的幸运值是cur_luck而[n, cur_luck-1]的幸运值也是cur_luck。所以我们可以把cur_luck这个点合并进来。实际上区间[n, cur_luck]的长度为cur_luck - n 1贡献为(cur_luck - n 1) * cur_luck。然后将n更新为cur_luck 1。如果cur_luck R说明从n到R的所有数幸运值都是cur_luck。贡献为(R - n 1) * cur_luck。计算结束。更新n后重复步骤2直到n R。这个算法中n的每次迭代都会跳跃到一个新的幸运数字或越过R而幸运数字只有O(M)个所以循环次数是O(M)效率很高。3.3 第三步代码实现与注释结合以上分析下面是完整的C题解代码#include iostream #include vector #include algorithm using namespace std; // 生成所有不超过上限的幸运数字 vectorlong long getLuckyNumbers(long long limit) { vectorlong long res; // 使用DFS生成从0开始 functionvoid(long long) dfs [](long long cur) { if (cur limit) return; if (cur 0) res.push_back(cur); // 避免加入0 dfs(cur * 10 4); dfs(cur * 10 7); }; dfs(0); sort(res.begin(), res.end()); // 排序 return res; } int main() { long long L, R; cin L R; // 生成幸运数字列表上限需要略大于R这里取R1e5足够 long long limit R 100000; // 加一个足够大的缓冲确保包含大于R的第一个幸运数字 vectorlong long lucky getLuckyNumbers(limit); // 添加一个巨大的哨兵防止后续二分查找越界 lucky.push_back(1e18); long long ans 0; long long n L; while (n R) { // 在lucky中找到第一个大于等于n的数 // 使用lower_bound进行二分查找 auto it lower_bound(lucky.begin(), lucky.end(), n); long long cur_luck *it; // 当前n对应的幸运值 // 计算当前幸运值能覆盖到的范围 // 覆盖的右边界是 min(R, cur_luck) long long cover_end min(R, cur_luck); // 覆盖的区间长度 long long length cover_end - n 1; // 累加贡献 ans length * cur_luck; // 移动到下一个未计算的数 n cover_end 1; } cout ans endl; return 0; }代码逐段解析生成列表getLuckyNumbers函数生成所有不超过limit的幸运数字。limit设置为R 100000是一个经验值确保能包含大于R的第一个幸运数字。你也可以设置为R*10或1e10只要足够大即可。添加哨兵在列表末尾添加一个极大的数1e18这是为了确保当n很大时lower_bound总能返回一个有效的迭代器避免程序崩溃。核心循环n初始化为L表示当前要计算幸运值的起始点。在循环中用lower_bound快速找到n的第一个幸运数字cur_luck。这就是从n开始的数字的幸运值。cover_end min(R, cur_luck)确定了当前幸运值cur_luck能连续覆盖到的最远位置。如果cur_luck超过了R那么只能覆盖到R否则可以覆盖到cur_luck本身。覆盖区间[n, cover_end]的长度是cover_end - n 1这些数的幸运值都是cur_luck所以贡献为长度 * cur_luck。更新n cover_end 1跳过已计算区间进入下一轮循环。循环结束当n超过R时计算完成。4. 常见问题与调试技巧实录即使思路清晰实现时也可能遇到各种问题。下面是我在多次解答和帮助他人调试时总结的常见坑点。4.1 数据范围与溢出问题这是最容易出错的地方。题目中L和R是10^9级别幸运数字也可能达到10^10级别。区间长度(R-L1)最大可达10^9幸运值最大可达10^10量级。两者相乘最大可能达到10^19这远远超过了32位整数int约21亿的表示范围甚至超过了64位有符号整数long long约9e18的一半。但在本题中最坏情况计算一下假设区间是[1, 1e9]幸运值最大可能是比1e9大的第一个幸运数字比如是444444444410位约4.4e9。那么单次贡献1e9 * 4.4e9 4.4e18这仍然在long long(约9.22e18) 的范围内。总和可能由多段组成但总和的最大值不会超过这个量级太多。因此使用long long是安全且必要的。注意事项在C中务必使用long long类型来定义L, R, ans, cur_luck, length等所有相关变量。int一定会溢出导致错误答案。在代码开头可以用typedef long long ll;来简化。4.2 幸运数字列表生成不完整如果生成的幸运数字列表的最大值小于R那么当n接近R时lower_bound找到的cur_luck可能不是真正的“下一个幸运数字”因为列表里最大的数可能小于n。这会导致计算错误。我们的解决方案是确保生成列表的上限limit足够大。一个简单粗暴的方法是生成到1e10100亿这对于DFS/BFS来说只是多了几个递归层级时间可以忽略不计。或者在生成函数中不设上限一直生成到数字长度超过10位因为10^9是10位数下一个幸运数字最多11位。但更推荐设置一个足够大的固定上限。检查方法可以输出生成的幸运数字列表查看最大值是否明显大于输入的R。4.3 二分查找的使用与哨兵我们使用lower_bound来查找第一个大于等于n的幸运数字。这要求lucky数组是有序的。我们的DFS生成后必须排序。 另外为了防止n大于列表中所有数时lower_bound返回lucky.end()一个无效迭代器我们在列表末尾添加了一个非常大的哨兵值如1e18。这样lower_bound永远返回有效迭代器指向某个幸运数字或哨兵。4.4 边界条件L或R就是幸运数字我们的算法已经正确处理了这种情况。例如L44第一轮循环n44lower_bound找到cur_luck44。cover_end min(R, 44)。假设R44则cover_end44。长度length 44-4411贡献1*44。n更新为45。 算法正确地将幸运数字自身作为一个长度为1的区间处理了。4.5 算法复杂度分析生成幸运数字O(M)M是幸运数字数量约2000。排序O(M log M)M很小可忽略。主循环每次循环至少将n推进到下一个幸运数字循环次数不超过M次。每次循环中的lower_bound是 O(log M)。总复杂度O(M log M)完全可以在任何限制下通过。4.6 调试与测试用例自己构造一些测试用例来验证程序非常重要最小用例L1, R1。幸运数字列表[4,7,44...]。n1cur_luck4cover_endmin(1,4)1length1ans4。正确因为1的幸运值是4。包含幸运数字L4, R7。n4, cur_luck4, cover_end4, length1, ans4, n5n5, cur_luck7, cover_end7, length3 (5,6,7), ans43*725, n8R结束。 手动计算4(4) 5(7) 6(7) 7(7) 477725。正确。大区间L1, R10。预期1-3-4, 4-4, 5-7-7, 8-10-44。计算(34) 4 (37) (3*44) 12421132169。用程序验证。极端情况L777777777, R1000000000。可以手动计算或与暴力程序小范围对拍验证。实操心得在编写完代码后不要急于提交。先在本地用几个小样例跑通再用一个中等规模的样例比如L1, R10000写一个暴力双重循环的程序进行对拍确保核心逻辑正确。这是避免罚时在竞赛中和反复调试的关键。5. 思路延伸与变种思考解决这道题的核心思想——“将连续区间根据某个特性离散化然后分段处理”——在算法问题中非常常见。类似的题目有区间覆盖问题给定一些区间和权值问某个大区间被覆盖的权值和。基于值的跳跃查询例如有些题目中下一个“特定值”的位置需要预处理。对于“幸运数字II”本身我们也可以思考一些变种如果幸运数字的定义变化比如只由3和8组成或者由更多数字组成。我们的算法框架完全不变只需要修改生成幸运数字的那部分代码即可。如果“幸运值”的定义变化比如定义为小于等于n的最大幸运数字。那么我们的区间划分和跳跃逻辑就需要反向处理。核心依然是“分段常数”只是区间的归属变了。如果询问非常多Q次查询每次查询[L, R]我们还能不能更快可以的。我们可以预处理出幸运数字序列并预处理前缀和。对于每次查询依然可以用二分找到L和R所在的“段”然后利用前缀和公式O(log M)计算。这需要更精细地处理区间边界但思想一脉相承。6. 从这道题中学到的编程思维回顾整个解题过程我们可以提炼出几点宝贵的思维模式观察规律化连续为离散这是优化算法的经典手段。当面对连续整数区间上的某个函数本题是幸运值函数如果发现函数值是分段常数或分段线性等就可以通过找到分段点来避免逐个计算。善用二分查找lower_bound/upper_bound在有序序列中快速定位是算法竞赛和实际编程中的基本功。本题中用它来快速找到“下一个幸运数字”将线性查找的O(M)降到了O(log M)。注意数据范围和溢出这几乎是所有涉及数值计算题目的必考点。养成习惯根据题目给出的数据范围第一时间确定合适的变量类型int,long long,unsigned long long, 高精度等。哨兵技巧在数组末尾添加一个极大或极小的值可以简化边界条件的判断让代码更简洁、更健壮。这是一个非常实用的编程技巧。测试驱动编写代码的同时脑子里就要构造简单的测试用例。写完先跑通这些用例再尝试更复杂、更边界的用例。对拍与暴力程序比较是验证正确性的利器。这道“幸运数字II”题很好地融合了数位生成、二分查找、区间处理和细节把控。把它吃透不仅能帮你通过这道题更能提升你解决一大类模拟、枚举和优化问题的能力。下次再遇到类似“根据某种规则找下一个/上一个XX”的题目时不妨先想想能不能先预处理出所有“XX”然后利用有序性进行跳跃计算。
返回列表