ARTICLE DETAIL

资讯详情

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

幸运数字II:从暴力到高效的区间处理算法详解

幸运数字II:从暴力到高效的区间处理算法详解

1. 问题引入:当“幸运数字”遇上“区间和”

最近在牛客网上刷题,又碰到了那道经典的“幸运数字II”。说实话,第一次看到这个题目名字,我还以为是什么玄学或者数学找规律题。但仔细一读题,发现它其实是一个披着“幸运”外衣的、非常考验思维转换和算法效率的区间处理问题。题目大意是,我们定义一种“幸运数字”:只由数字4和7组成。然后给定一个区间[L, R],我们需要找出这个区间内所有“下一个幸运数字”的和。

这里的“下一个幸运数字”定义是关键:对于区间内的任意一个数x,它的“下一个幸运数字”是大于等于x的最小幸运数字。所以,我们的任务不是简单地统计区间里有多少个幸运数字,而是要把区间[L, R]中每一个数对应的“下一个幸运数字”找出来,然后求和。

举个例子,如果区间是[1, 7]

  • 数字1、2、3的下一个幸运数字是4。
  • 数字4的下一个幸运数字是4。
  • 数字5、6的下一个幸运数字是7。
  • 数字7的下一个幸运数字是7。 那么总和就是4*3 + 4 + 7*2 + 7 = 12 + 4 + 14 + 7 = 37

暴力法的思路最直接:从L到R遍历每个数i,然后写一个循环去找大于等于i的最小幸运数字,累加。这个思路清晰易懂,但一旦L和R的范围给到像110^9这样的量级,时间复杂度就是O((R-L+1) * 寻找幸运数字的代价),显然是无法接受的,必然超时。

所以,这道题的核心挑战在于:如何避免对区间内每一个数进行独立的、耗时的查询,而是找到一种能够批量、高效处理整个区间的方法。这需要我们跳出“逐个数字处理”的惯性思维,从“区间贡献”的角度来重新审视问题。

2. 核心思路转换:从“点查询”到“区间覆盖”

暴力法之所以慢,是因为它把问题看作无数个独立的“点查询”。我们需要一个思维跃迁:把所有的幸运数字看成一系列“挡板”或者“服务站”,而区间[L, R]内的普通数字,则是需要被这些“服务站”服务的对象。

具体来说,我们首先生成所有在可能范围内(至少要覆盖到R)的幸运数字。因为幸运数字只由4和7组成,我们可以用递归或队列的方式生成,比如:4, 7, 44, 47, 74, 77, 444, 447... 我们把这些生成好的幸运数字放在一个数组里,并且为了后续处理方便,通常会在数组的开头加一个很小的数(比如0),在结尾加一个很大的数(比如一个远超R的幸运数字,或者Long.MAX_VALUE),形成一个“幸运数字序列”。我们假设这个序列是luckys = [a0, a1, a2, ..., ak],其中a0 < a1 < a2 < ... < ak,并且a0可能小于L,ak肯定大于R。

现在,关键的一步来了。考虑任意两个相邻的幸运数字luckys[i]luckys[i+1]。对于开区间(luckys[i], luckys[i+1])内的所有整数(注意,不包括左端点luckys[i]本身),它们的“下一个幸运数字”是谁?答案是统一的:都是luckys[i+1]。因为luckys[i+1]是第一个大于luckys[i]的幸运数字,那么在(luckys[i], luckys[i+1])这个区间内的数,大于等于它们的最小幸运数字,自然就是luckys[i+1]

举个例子,幸运数字序列里有..., 4, 7, 44, ...。那么对于区间(4, 7),也就是数字5和6,它们的下一个幸运数字都是7。对于区间(7, 44),也就是数字8到43,它们的下一个幸运数字都是44。

这样一来,我们就把整个数轴,用幸运数字切割成了一段一段的区间。对于每一段区间(luckys[i], luckys[i+1]),区间内所有数对总和的贡献是固定的:区间内的整数个数 * luckys[i+1]

我们的目标区间[L, R]可能会横跨多个这样的“幸运区间段”。因此,解题思路就清晰了:

  1. 生成足够多的幸运数字,形成一个序列。
  2. 定位:找到LR在这个幸运数字序列中“所处”的区间段。
  3. 分段计算:将[L, R]这个区间,按照幸运数字的切分点,分解成若干个连续的子区间,其中每个子区间都完整地落在某个(luckys[i], luckys[i+1])内(或者端点恰好是某个幸运数字)。
  4. 求和:对于每一个分解出来的子区间,计算其整数个数,乘以它对应的“下一个幸运数字”(即该子区间右侧的幸运数字luckys[i+1]),最后将所有子区间的贡献相加。

这个思路成功地将一个需要对10^9量级个点进行查询的问题,转化为了一个只需要处理O(K)个区间段的问题,其中K是幸运数字的个数。在R <= 10^9的限制下,位数不超过10位的、只由4和7组成的数字数量是有限的(2^1 + 2^2 + ... + 2^10 = 2046个),完全在可接受范围内。效率得到了质的提升。

3. 关键实现细节与算法步骤拆解

理解了核心思想,我们来看看如何用代码一步步实现。我会以Java为例进行说明,其他语言逻辑相通。

3.1 生成所有幸运数字

首先,我们需要生成所有可能用到的幸运数字。一个常见的方法是使用BFS(广度优先搜索)或DFS(深度优先搜索)。这里用DFS递归生成更直观。

/** * 生成所有不超过上限的、只由4和7组成的数字(幸运数字) * @param limit 上限,通常取R的最大可能值,这里我们可以直接生成到10^10量级以确保覆盖 * @param current 当前生成的数字 * @param luckys 用于存储结果的列表 */ private void generateLuckyNumbers(long current, long limit, List<Long> luckys) { if (current > limit) { return; } if (current != 0) { // 避免把0加进去,不过我们后面会排序,0不影响 luckys.add(current); } // 在当前数字末尾分别加上4和7,生成新的数字 generateLuckyNumbers(current * 10 + 4, limit, luckys); generateLuckyNumbers(current * 10 + 7, limit, luckys); }

在调用时,我们可以设定一个足够大的limit,比如10_000_000_000L(100亿),这远大于题目可能的R (10^9),确保能覆盖所有需要的幸运数字。生成后,务必对列表进行排序,因为递归生成的顺序不一定是严格递增的。同时,为了方便后续的区间处理,我们通常在排序后的列表头部插入一个很小的数(如0),在尾部插入一个很大的数(如 Long.MAX_VALUE 或一个极大的幸运数字)。这样,luckys.get(i)luckys.get(i+1)就构成了一个完整的区间覆盖。

List<Long> list = new ArrayList<>(); generateLuckyNumbers(0, 10_000_000_000L, list); Collections.sort(list); list.add(0, 0L); // 在索引0处插入0 list.add(Long.MAX_VALUE); // 在末尾插入一个极大值 // 现在list看起来像 [0, 4, 7, 44, 47, ..., Long.MAX_VALUE]

3.2 定位与区间分解

这是整个算法最精妙也最容易出错的部分。我们需要把[L, R]分解成若干个落在(luckys[i], luckys[i+1])内的子区间。

我们可以用两个指针ij在幸运数字序列上滑动,同时维护一个current变量表示当前待处理的起点。

  1. 初始化:找到第一个大于等于L的幸运数字的索引。更准确地说,是找到这样一个位置idx,使得luckys[idx] < L <= luckys[idx+1]。那么,区间[L, R]的起点L就落在(luckys[idx], luckys[idx+1]]这个半开半闭区间内。我们设current = L
  2. 循环分解:只要current <= R,就继续循环。
    • 确定current所在的区间段。假设current(luckys[i], luckys[i+1]]内。那么从current开始,直到min(R, luckys[i+1])为止,这个子区间[current, min(R, luckys[i+1])]内的所有数,它们的“下一个幸运数字”都是luckys[i+1]
    • 计算这个子区间的贡献:(min(R, luckys[i+1]) - current + 1) * luckys[i+1],并累加到总和中。
    • 更新currentmin(R, luckys[i+1]) + 1,即移动到下一个待处理的起点。如果current恰好等于luckys[i+1] + 1,那么它就进入了下一个区间段(luckys[i+1], luckys[i+2])

这个逻辑确保了[L, R]被完整地、不重不漏地覆盖。

3.3 代码实现与注释

将上述步骤整合,以下是完整的Java题解代码:

import java.util.*; public class Main { // 存储所有幸运数字的列表 private static List<Long> luckyList = new ArrayList<>(); public static void main(String[] args) { Scanner sc = new Scanner(System.in); long L = sc.nextLong(); long R = sc.nextLong(); // 1. 生成幸运数字 generateLuckys(0L, 10_000_000_000L); // 生成到100亿,足够覆盖10^9 Collections.sort(luckyList); // 插入边界值,方便处理 luckyList.add(0, 0L); luckyList.add(Long.MAX_VALUE); long sum = 0L; long current = L; // 当前要处理的起点 // 2. 遍历幸运数字列表,进行区间分解和计算 // 从第一个大于0的幸运数字开始找L所在的区间 for (int i = 0; i < luckyList.size() - 1; i++) { long left = luckyList.get(i); long right = luckyList.get(i + 1); // 如果当前区间 [left, right) 与 [current, R] 有交集 // 即 current < right 且 current <= R // 并且交集部分是从 current 开始 if (current < right && current <= R) { // 计算交集区间的右端点 long segmentEnd = Math.min(R, right - 1); // 注意:区间是(left, right),所以右端点是right-1 // 计算这个子区间的长度 long length = segmentEnd - current + 1; // 这个子区间内所有数的下一个幸运数字都是 right sum += length * right; // 更新current到下一个区间的起点 current = segmentEnd + 1; } // 如果current已经超过R,提前结束 if (current > R) { break; } } System.out.println(sum); sc.close(); } /** * DFS生成幸运数字 * @param num 当前数字 * @param limit 上限 */ private static void generateLuckys(long num, long limit) { if (num > limit) return; if (num != 0) { luckyList.add(num); } generateLuckys(num * 10 + 4, limit); generateLuckys(num * 10 + 7, limit); } }

几点关键解释:

  • 区间表示:在代码中,我将幸运数字luckys[i]luckys[i+1]构成的区间理解为(luckys[i], luckys[i+1]),即左开右开。这意味着数字luckys[i]本身不属于这个区间,它的“下一个幸运数字”是它自己。而在计算时,luckys[i]需要被单独处理。在上面的循环中,right-1就是为了获取这个开区间的最大整数。
  • 单独处理端点:上述循环逻辑已经隐含处理了端点情况。当current正好等于某个幸运数字luckys[i]时,因为我们的区间是(left, right)其中left = luckys[i-1],right = luckys[i],此时current(luckys[i]) 并不小于right,所以不会进入if (current < right)分支。那么luckys[i]什么时候被处理呢?它会在i增加后,作为下一个区间的左端点left被考虑吗?不,luckys[i]应该被算作它自己。更清晰的写法是:在循环中,如果current正好等于某个luckys[i],那么它单独贡献luckys[i],然后current++。为了逻辑统一,另一种更清晰的实现方式是直接使用闭区间[luckys[i]+1, luckys[i+1]]来表示“下一个幸运数字是luckys[i+1]”的区间,而luckys[i]自身单独处理。这取决于个人的区间定义习惯,但核心思想不变。
  • 效率:生成幸运数字是O(2^10)量级,分解区间是O(K)量级(K为幸运数字个数,约2000),因此总时间复杂度对L, R高达10^9是常数级,非常高效。

4. 易错点分析与调试技巧

这道题思路清晰后,实现起来并不复杂,但仍有几个“坑”容易让初学者栽跟头。

1. 数据类型与溢出题目中LR的范围是1 <= L <= R <= 10^9。单个幸运数字最大可能接近10^10(比如7777777777)。在计算区间贡献长度 * 幸运数字时,长度最大可以是10^9幸运数字最大可以是10^10,乘积就是10^19,这远远超过了int甚至long的表示范围(long最大值约9.22e18)。因此,必须使用long类型来存储所有变量,包括循环中的索引i,如果用它来计算区间长度,也可能需要转为long进行乘法运算,否则可能发生溢出导致结果错误。

2. 区间边界处理这是最大的难点。如前所述,如何定义“幸运数字区间”直接影响代码逻辑。

  • 方案A(推荐):将数轴划分为这样的区间:(-∞, 4],(4, 7],(7, 44],(44, 47], ...。这意味着对于区间(prevLucky, currentLucky],其中的数x满足prevLucky < x <= currentLucky,它们的下一个幸运数字是currentLucky。这样,幸运数字本身落在了区间的右端点,处理起来比较方便。初始化时,prevLucky可以设为0。
  • 方案B:划分为[4, 4],(4, 7],(7, 44], ...。这样每个幸运数字单独成一个区间。逻辑上更清晰,但循环判断条件会稍多。

无论哪种方案,务必在纸上用一个小例子(如L=1, R=10)走一遍流程,验证你的区间划分和累加逻辑是否正确。特别是当L或R本身就是幸运数字时。

3. 幸运数字的生成范围虽然R <= 10^9,但“下一个幸运数字”可能大于R。例如,R=999,999,999,它的下一个幸运数字可能是1,000,000,000量级的。所以生成幸运数字时,上限要设得足够大。通常生成到10^10(100亿)是安全且足够的。生成太少,会导致程序在寻找大于R的幸运数字时找不到,从而出错。

4. 二分查找的运用在上面的实现中,我们使用了一个for循环来遍历幸运数字列表。实际上,我们可以用二分查找(Collections.binarySearch)快速定位LR在幸运数字列表中的位置,然后只处理中间涉及的那些区间,这样效率更高。但对于K=2000这个数量级,线性遍历也完全无压力,代码更易读。

调试技巧

  • 首先测试小范围数据,比如[1, 20],手动计算结果,与程序输出对比。
  • 重点测试边界情况:
    • LR相等,且是幸运数字(如[7,7])。
    • LR相等,不是幸运数字(如[8,8])。
    • L是幸运数字,R不是(如[4, 6])。
    • L不是幸运数字,R是(如[5, 7])。
    • LR跨越多個幸运数字区间(如[1, 100])。
  • 打印中间变量。在循环中打印出current,left,right,segmentEnd,length和每次累加的sum,可以非常直观地看到区间是如何被分解和计算的,便于定位逻辑错误。

5. 算法扩展与同类问题思考

解决了“幸运数字II”,我们掌握了“区间批量处理”和“用关键点分割区间”的核心思想。这个思想可以推广到许多类似问题。

变体1:上一个幸运数字如果问题改为求区间[L, R]内每个数的“上一个幸运数字”(小于等于该数的最大幸运数字)的和,思路完全一样。只需要将区间定义为[luckys[i], luckys[i+1]),那么这个区间内所有数的“上一个幸运数字”就是luckys[i]

变体2:幸运数字的个数如果问题改为统计区间内幸运数字的个数,那就更简单了。生成幸运数字列表后,使用两次二分查找,找到第一个大于等于L的幸运数字索引idxL,和第一个大于R的幸运数字索引idxR,那么idxR - idxL就是结果。

变体3:多维或多属性关键点有时“关键点”不是单一的数字,而是具有某种属性的对象。例如,给定一系列“服务站点”(每个站点有位置和权重),求区间内每个点到其最近服务站点的权重和。这时,我们可以先对所有服务站点排序,然后同样用它们将数轴分段,在每一段内,所有点都对应同一个“最近服务站点”。问题就化归为计算每个区间段的贡献。

思想总结: 这类问题的通用解决模式是:

  1. 识别关键点:找出那些能改变“目标函数”取值的点(如本题的幸运数字)。
  2. 排序与分段:将这些关键点排序,它们将整个定义域(如数轴)分割成若干个连续区间。
  3. 区间内统一处理:在每个区间内部,“目标函数”的行为是统一的(如本题的“下一个幸运数字”是常数),因此可以批量计算。
  4. 处理查询区间:将查询区间[L, R]与这些固定区间求交,分解为若干个子区间,分别计算后求和。

这种“化点为段”、“批量处理”的思想,是优化许多区间查询问题的利器,其核心在于发现问题的“分段常数”性质。下次再遇到类似“区间内每个数对应某个函数值求和”的问题时,不妨先思考一下:这个函数值的变化是不是由一些离散的关键点决定的?如果是,恭喜你,你已经找到了高效解题的钥匙。

返回列表