1. 数位倍数:从暴力模拟到数学优化的思考

蓝桥杯研究生组省赛的第一题,往往是一道“开胃菜”,用来稳定心态。今年的A题“数位倍数”就是这样一个典型。题目要求统计1到202504之间,各位数字之和是5的整数倍的数的个数。比如5(和为5)、19(1+9=10)、8025(8+0+2+5=15)都符合条件。

很多同学的第一反应是:这还不简单?直接写个循环,从1遍历到202504,对每个数计算数位和,然后判断是否能被5整除不就行了?没错,这确实是标准解法,也是考场上的“保分”策略。我写出来的代码和网上流传的也差不多:

public class Main {
    static int dsum(int x) {
        int sum = 0;
        while (x > 0) {
            sum += x % 10;
            x /= 10;
        }
        return sum;
    }

    public static void main(String[] args) {
        int ans = 0;
        for (int i = 1; i <= 202504; i++) {
            if (dsum(i) % 5 == 0) ans++;
        }
        System.out.println(ans); // 输出 40500
    }
}

跑一下,答案是40500,填空一交,5分到手。但作为有追求的选手,我们不能只满足于“做出”。这道题数据范围是20万级别,暴力完全没问题。但如果范围扩大到10^9甚至10^18呢?你还能跑得动吗?这里就引出了一个更深层的问题:如何优化数位统计问题?

其实,这类问题有一个经典的优化思路——数位DP。虽然这道小题用不上,但它是蓝桥杯乃至所有算法竞赛的高频考点。数位DP的核心思想是,不逐个枚举数字,而是按位(个位、十位、百位…)进行动态规划,统计满足条件的数字个数。对于“数位和模5为0”这个条件,我们可以设计一个状态dp[pos][sum_mod],表示处理到第pos位时,当前数位和对5取模为sum_mod的方案数。这样,时间复杂度就从O(N)降到了O(位数 * 10 * 5),对于超大范围的数据也能瞬间出结果。

我之所以提这个,是因为在后续的“01串”等题目中,这种按位分块、避免暴力的思想会再次出现,而且是解决大数据问题的关键。先把这种“优化意识”种在脑子里,后面遇到难题时,你才能更快地找到方向。

2. 变换数组:理解题意与避免整数溢出

C题“变换数组”是一道标准的模拟题,题意清晰:给你一个数组,进行m次变换,每次变换让每个元素a[i]乘以它二进制表示中1的个数(bitCount)。比如数字5,二进制是101,有2个1,所以变成5*2=10。

这道题真正的难点不在于算法,而在于对数据范围的敏感度和细节处理。题目给的ai上限是1000,m上限是5。我们心算一下最坏情况:假设一个数二进制有10个1(实际上1000以内最多10个1,因为2^10=1024),每次乘以10,连乘5次,那就是1000 * 10^5 = 10^8。这个数字还在int的范围内(约21亿)吗?10^8确实在int范围内,看起来没问题。

但这里有个陷阱:中间过程可能溢出吗? 比如某个数第一次变换后变成了几万,第二次变换再乘一个10,就可能超过21亿吗?我们来估算一下:1000 * 10 = 10000(第一次),10000 * 10 = 100000(第二次)… 到第五次是10^8,确实安全。所以用int是可行的。但我在写代码时,习惯性地会问自己:如果数据范围悄悄变大了怎么办?比如ai变成10^5,m变成10呢?那中间结果就非常容易溢出。所以,一个良好的习惯是,在比赛时如果对数据范围没有绝对把握,尤其是乘法运算,直接使用long类型会更保险。虽然这道题用int够了,但养成这个习惯能帮你避免很多莫名其妙的失分。

// 一个更稳妥的写法,使用long避免任何溢出疑虑
static int bitCount(int x) {
    int cnt = 0;
    while (x > 0) {
        cnt += (x & 1);
        x >>= 1;
    }
    return cnt;
}
// 在变换时,即使a[i]是int,也先转为long计算
long temp = (long) a[j] * bitCount(a[j]);
// 然后再判断是否赋值回int数组(根据题目范围决定)

另外,关于bitCount的计算,Java的Integer.bitCount()方法是最优的,它使用CPU底层指令,速度极快。但在蓝桥杯环境中,我们通常自己实现以加深理解。这里推荐用x & (x-1)的技巧来消除最低位的1,比逐位判断更快:

static int bitCountFast(int x) {
    int cnt = 0;
    while (x != 0) {
        x &= (x - 1); // 消除最低位的1
        cnt++;
    }
    return cnt;
}

3. 最大数字:自定义排序与大数据处理

D题“最大数字”很有意思,它把数字拼接和二进制表示结合了起来。题目说:把1到n的数字重新排列,将它们转换成二进制后按顺序拼接,要使得最终得到的那个超长二进制数最大,并输出其十进制值。

我第一眼看到这题,感觉是个排序问题。关键是要定义一种比较规则,对于任意两个数字a和b,决定谁应该排在前面,才能使拼接后的二进制数更大。这里需要一点数学推导。假设a的二进制长度是len(a),b的二进制长度是len(b)。如果把a放前面、b放后面,拼接起来的二进制数相当于把a左移len(b)位,然后加上b,即a * 2^len(b) + b。同理,如果把b放前面,就是b * 2^len(a) + a。我们只需要比较这两个值的大小,就能决定a和b的顺序。

所以,自定义比较器的核心代码就出来了:

static int compare(Integer x, Integer y) {
    int lenX = 32 - Integer.numberOfLeadingZeros(x); // 计算二进制长度
    int lenY = 32 - Integer.numberOfLeadingZeros(y);
    long concatXY = (long) x * (1L << lenY) + y; // 注意用long,防止移位溢出
    long concatYX = (long) y * (1L << lenX) + x;
    if (concatXY > concatYX) return -1; // x应该排在y前面
    else if (concatXY < concatYX) return 1;
    else return 0;
}

排好序之后,我们需要把排序后的数字依次“拼接”起来。这里又有一个坑:n最大是10000,每个数字二进制长度最多14位(因为2^14=16384>10000),全部拼接起来最大长度是14*10000=140000位。这是一个巨大的二进制数,远远超过了long的范围(64位)。所以,我们必须使用BigInteger来处理。

使用BigInteger的拼接过程很直观:初始化一个为0的BigInteger,然后遍历排序后的数组,对每个数字,先将当前结果左移该数字的二进制长度位(相当于在尾部腾出空位),然后加上这个数字。

BigInteger ans = BigInteger.ZERO;
for (int num : sortedArray) {
    int len = 32 - Integer.numberOfLeadingZeros(num);
    ans = ans.shiftLeft(len); // 左移len位
    ans = ans.add(BigInteger.valueOf(num));
}
System.out.println(ans);

这道题综合考察了自定义排序、二进制操作和大整数处理,是很好的编程基础题。我建议大家在理解后,自己尝试用不同的方法计算二进制长度(比如用while循环除以2),并思考如果n大到10^5,我们的排序算法是否依然高效?(答案是肯定的,O(n log n)的排序足够)。

4. 小说情节:抽象建模与公式推导

E题“小说”是一道典型的思维题,也是我认为这次比赛中最有趣的一道。题目描述了一个小说家写推理小说的规则,有n个人物,三种情节类型,还有一堆时间顺序上的限制。问最多能写多少章不重复的情节。

刚读完题,可能有点绕。但别慌,咱们一步步拆解。首先,把三种情节具体化:

  1. A发现B不知道真相:要求A和B是不同的人。那么对于n个人,有多少种不同的这种情节?A有n种选法,B有n-1种选法(不能和A相同),所以是n*(n-1)个。
  2. A发现B知道真相:同样A≠B,也是n*(n-1)个。
  3. A知道了真相:每个角色只能“知道真相”一次,所以有n个。

如果不考虑任何顺序限制,那么总的不同情节数就是 n*(n-1) + n*(n-1) + n = 2n^2 - n。

现在问题来了,题目要求相邻两章情节类型不能相同,而且还有三条关于事件先后顺序的硬性规定。这相当于要求我们把所有可用的情节排成一个长序列,序列中不能有相邻的类型相同,还要满足那三条时序规则。我们的目标是让这个序列尽可能长。

这里就需要抽象建模的能力了。那三条时序规则,本质上是在说:

  • “B发现A不知道真相”必须在“A知道了真相”之前发生。
  • “B发现A知道真相”必须在“A知道了真相”之后发生。
  • “B发现A不知道真相”必须在“B发现A知道真相”之前发生。

这其实规定了对于任意一对人物(A, B),他们的三个相关事件(A不知道、A知道真相、A知道)必须按照“不知道 -> 知道真相 -> 知道”的顺序出现。这是一个很强的约束。

那么,如何计算最大章节数呢?我们需要最大化利用所有情节,同时用“知道真相”事件(类型3)来隔开那些不能相邻的同类事件(类型1和类型2)。经过推导(这里涉及一些组合数学的思考,考虑每个“知道真相”事件能帮助解决多少相邻冲突),可以得到一个公式:

当n=1时,只有一种情节(自己知道真相),答案是1。 当n>=2时,最大章节数为 2*n*n - 3*n + 4。

if (n == 1) {
    ans = 1;
} else {
    ans = 2L * n * n - 3L * n + 4L; // 注意使用long防止溢出
}

所以,对于样例n=2,2*4 - 3*2 + 4 = 6,符合输出。对于n=3,2*9 - 3*3 + 4 = 13,也符合。

这道题在考场上,如果推导不出公式,可能就会卡住。它考察的不是编码,而是问题转化、逻辑推理和数学归纳能力。我的经验是,遇到这种“规则描述复杂”的题,先别急着想代码,拿张小纸,画个小例子(比如n=2),把所有可能情节列出来,然后尝试手动排列,看看最长能排多少。往往在手动模拟的过程中,规律自己就浮现出来了。

5. 01串:二进制分块与高效计数

F题“01串”是这次比赛的一个小高峰,题目看似简单——有一个无限长的01串,它是0,1,2,3...的二进制表示直接拼接起来的。问这个串的前x位中有多少个1。但x的范围高达10^18,直接暴力拼接计数是绝对不可能的。

我们必须找到这个01串的规律。让我们先写出这个串的开头部分: 数字: 0, 1, 2, 3, 4, 5, ... 二进制: 0, 1, 10, 11, 100, 101, ... 拼接: 0, 1, 1, 0, 1, 1, 1, 0, 0, 1, 0, 1, ... (注意,这里通常题目描述中0的二进制是“0”,占一位) 实际上,题目样例的前7位是“0110111”,对应数字1,2,3的二进制“1”,“10”,“11”的拼接。

观察规律,我们可以按二进制位数对数字进行分块:

  • 第1块:长度为1位的数字,即数字0(如果从1开始则无)或1(二进制“1”)。但通常我们从1开始考虑,所以第1块是数字1(二进制“1”),长度1位,包含1个‘1’。
  • 第2块:长度为2位的数字,即二进制形式为“10”到“11”的数字,也就是2和3。这一块有2个数字,总长度是2*2=4位。每个数字的最高位都是1,所以这块至少有2个‘1’。再加上低1位中‘1’的个数(数字2的低位是‘0’,数字3的低位是‘1’,共1个),所以这块总共有2+1=3个‘1’。
  • 第3块:长度为3位的数字,即“100”到“111”(4到7)。有4个数字,总长度34=12位。每个数字最高位贡献4个‘1’。低2位中,所有可能的2位二进制组合(00,01,10,11)各出现一次,其中‘1’的个数是2个(01和10各1个,11有2个,平均每个数字低2位有1个‘1’),所以低2位总共贡献41=4个‘1’。这块总共‘1’的个数是4+4=8。

发现了吗?对于长度为k位的块(数字从2^(k-1)到2^k - 1):

  • 数字个数:2^(k-1)个。
  • 块的总长度:L(k) = k * 2^(k-1)位。
  • 块中‘1’的总数:F(k) = 2^(k-1) + (k-1) * 2^(k-2)。
    • 2^(k-1)来自每个数字最高位的1。
    • (k-1) * 2^(k-2)来自低(k-1)位。因为低(k-1)位所有可能的组合均匀出现,每个位置上是1的概率是1/2,所以总共有(k-1) * 2^(k-1) * (1/2) = (k-1)*2^(k-2)个1。

有了这个公式,我们就可以高效计算了。算法步骤如下:

  1. 预处理出所有完整的块。从k=1开始,累加L(k),直到加上下一块会超过总位数x。假设前m-1块是完整的,我们已经累计了totalLen位和totalOnes个1。
  2. 现在处理最后一个不完整的块(第m块)。我们还有remain = x - totalLen位需要处理。这个块里的数字是2^(m-1)到2^m - 1,每个数字占m位。
  3. 计算这个不完整块中有多少个完整的数字:fullNum = remain / m。对于前fullNum个数字,我们可以用公式计算它们贡献的1的个数:每个数字最高位贡献1个1,低(m-1)位的1的个数,等于数字0到fullNum-1的二进制中1的个数之和。这个和可以用一个函数countOnesUpTo(fullNum-1)快速计算。
  4. 最后,可能还有一个数字只取了前part = remain % m位。我们需要计算这个数字(即2^(m-1) + fullNum)的前part位(从最高位开始)中有多少个1,用位运算提取即可。
// 计算0到n(含)的所有数字的二进制中1的个数之和
static long countOnesUpTo(long n) {
    if (n <= 0) return 0;
    int highestBit = 63 - Long.numberOfLeadingZeros(n);
    long count = (1L << (highestBit - 1)) * highestBit; // 最高位以下所有位贡献的1的规律
    long remainder = n - (1L << highestBit);
    return count + remainder + 1 + countOnesUpTo(remainder);
}

这道题是数学推导和细节实现的完美结合。在考场上,能推导出分块公式已经成功了一大半,剩下的就是小心处理边界条件(比如x=1的情况)和大整数运算(使用long)。我建议大家都亲手实现一遍这个countOnesUpTo函数,它本身就是一个经典的数位DP问题,在很多场景下都有用。

6. 甘蔗:动态规划的状态设计与转移

G题“甘蔗”是一道质量很高的动态规划题。题目描述是:有一排n根甘蔗,每根有原始高度a[i]。你可以砍甘蔗,砍完后高度可以是0到a[i]-1之间的任意整数(不砍就是a[i])。砍一根甘蔗算一次操作。要求砍完后,任意相邻两根甘蔗的高度差,其绝对值必须在一个给定的集合B中。问最少砍多少根,如果无法满足输出-1。

看到“相邻约束”和“最小操作次数”,DP的直觉就应该有了。定义状态是这类题的关键。一个很自然的想法是:dp[i][h]表示考虑前i根甘蔗,并且第i根甘蔗的最终高度是h时,所需的最少砍伐次数。这里h的取值范围是0到a[i]。

那么状态如何转移呢?对于第i根甘蔗的高度h,它必须和前一根甘蔗的某个高度h_prev满足:|h - h_prev| 的值在集合B中。也就是说,h_prev必须是h + d 或 h - d,其中d是集合B中的某个数。所以,状态转移方程是: dp[i][h] = min(dp[i-1][h_prev]) + cost,其中h_prev满足|h - h_prev| ∈ B,cost是砍第i根甘蔗的代价(如果h == a[i],cost=0,否则cost=1)。

这里有一个优化点:集合B的大小m最大为500,如果对于每个h我们都遍历B中的所有d来寻找h_prev,再检查h_prev是否合法(在0到a[i-1]之间),那么复杂度是O(n * max(a) * m)。max(a)是1000,n和m都是500,那么5001000500=2.5亿,有点危险,但或许在Java的时限内勉强能过。

更优雅的写法是,对于每个h_prev,我们知道它能转移到哪些h(即h_prev ± d)。我们可以先初始化dp[i]为一个很大的数,然后遍历所有可能的h_prev,对于每个h_prev,遍历B中的d,去更新dp[i][h_prev+d]和dp[i][h_prev-d](如果目标h在0到a[i]范围内)。这样复杂度是一样的,但写起来更清晰。

final int INF = Integer.MAX_VALUE / 2;
int[] dpPrev = new int[MAX_H + 1]; // MAX_H 是a[i]的最大值,题目为1000
Arrays.fill(dpPrev, INF);
// 初始化第一根甘蔗
for (int h = 0; h <= a[0]; h++) {
    dpPrev[h] = (h == a[0]) ? 0 : 1;
}

for (int i = 1; i < n; i++) {
    int[] dpCur = new int[MAX_H + 1];
    Arrays.fill(dpCur, INF);
    for (int hPrev = 0; hPrev <= MAX_H; hPrev++) {
        if (dpPrev[hPrev] == INF) continue; // 无效状态跳过
        for (int d : B) {
            int h1 = hPrev + d;
            if (h1 <= a[i]) {
                int cost = (h1 == a[i]) ? 0 : 1;
                dpCur[h1] = Math.min(dpCur[h1], dpPrev[hPrev] + cost);
            }
            int h2 = hPrev - d;
            if (h2 >= 0) {
                int cost = (h2 == a[i]) ? 0 : 1;
                dpCur[h2] = Math.min(dpCur[h2], dpPrev[hPrev] + cost);
            }
        }
    }
    dpPrev = dpCur;
}
// 最后在dpPrev中找最小值

最后,答案就是dpPrev[h]中的最小值(h从0到a[n-1])。如果最小值还是INF,说明无解,输出-1。

这道题体现了DP的经典应用场景:序列上的带约束优化问题。关键在于设计出“以第i个元素结尾且状态为x”这样的状态,并处理好状态之间的转移条件。我在实际写的时候,还考虑过是否可以用BFS来解,因为每次砍伐可以看作一次状态转移,但DP显然是更直观和高效的做法。

7. 原料采购:贪心策略与数据结构优化

H题“原料采购”是本次的压轴编程题,考察的是贪心算法和数据结构(TreeMap) 的运用。题目背景是开车去一条路上的多个采购点买原料,卡车容量为m,每个点有价格、库存和距离。行驶每单位距离有额外花费o。我们要在装满卡车的前提下,最小化总花费(采购费+行驶费)。

理解题意后,一个核心观察是:如果我们决定最远去到距离为L的采购点,那么所有距离超过L的点都不能去。总行驶花费就是L * o。 那么,为了最小化总花费,我们希望在满足采购量的前提下,尽可能让L小,同时采购价格低的原料。

这自然引出了一个贪心策略:

  1. 将所有采购点按距离c从小到大排序。
  2. 我们依次考虑将每个点作为“最远点”L。假设当前遍历到第i个点,距离为c[i],那么所有j <= i的点都是可选的。
  3. 我们需要从这些可选点中,选出足够装满卡车(m单位)的原料,并且采购总价最低。显然,应该优先买价格低的原料。
  4. 因此,我们需要一个数据结构,能动态维护当前所有可选点的原料信息,并支持按价格从低到高取出原料。Java中的TreeMap<Long, Long>(价格 -> 库存量)非常适合。每当我们引入一个新的采购点(距离更远),就把它的价格和库存加入TreeMap。当累计库存 >= m时,我们就从TreeMap中按价格从低到高取出m单位的原料,计算采购费,再加上当前距离c[i] * o,就得到一种候选方案的总花费。
  5. 遍历所有点作为最远点,取所有候选方案中的最小花费,即为答案。

这里有几个细节需要注意:

  • 累计库存的更新:不是每次重新计算,而是随着新点加入而增加。
  • 从TreeMap中取货:需要遍历EntrySet,按价格从低到高(TreeMap默认按键升序)取出,直到取够m单位。如果取完了所有库存还不够m,说明当前最远点下无法满足需求,跳过。
  • 大整数处理:价格、库存、距离、o都可能很大(10^9),m也是10^9,采购费可能达到10^18,所以必须使用long。
static long solve() {
    long totalStock = 0; // 当前可选点的总库存
    long minTotalCost = Long.MAX_VALUE;
    TreeMap<Long, Long> priceToStock = new TreeMap<>();
    
    for (int i = 0; i < n; i++) {
        long price = points[i][0];
        long stock = points[i][1];
        long dist = points[i][2];
        
        // 加入当前点
        priceToStock.put(price, priceToStock.getOrDefault(price, 0L) + stock);
        totalStock += stock;
        
        // 如果总库存已经足够
        if (totalStock >= m) {
            long purchaseCost = calculateMinPurchaseCost(priceToStock, m);
            if (purchaseCost >= 0) { // 能够取够m单位
                long travelCost = dist * o;
                minTotalCost = Math.min(minTotalCost, purchaseCost + travelCost);
            }
        }
    }
    return minTotalCost == Long.MAX_VALUE ? -1 : minTotalCost;
}

// 从TreeMap中贪心取出need单位的原料,返回最小采购费,不够则返回-1
static long calculateMinPurchaseCost(TreeMap<Long, Long> map, long need) {
    long cost = 0;
    for (Map.Entry<Long, Long> entry : map.entrySet()) {
        long price = entry.getKey();
        long stock = entry.getValue();
        if (need <= stock) {
            cost += need * price;
            need = 0;
            break;
        } else {
            cost += stock * price;
            need -= stock;
        }
    }
    return need == 0 ? cost : -1;
}

这道题的贪心策略需要证明:为什么固定最远距离后,按价格从低到高买是最优的?这个证明比较直观,因为采购费只和价格有关,与距离无关(距离已固定),所以当然先买便宜的。整个算法的时间复杂度是O(n log n),主要开销在TreeMap的操作上,对于n=10^5完全可行。

8. 总结与备赛建议

一口气分析了这八道题,不知道你有没有发现一些共通的考点和技巧?我简单总结一下:

  1. 基础为王:A题数位和、C题模拟变换,考察的是最基本的循环、函数和位运算。这些分必须稳拿。
  2. 数学思维:E题小说和F题01串,都需要从题目描述中抽象出数学模型,推导出公式或规律。这要求我们不仅有编程能力,还要有扎实的数学基础和逻辑推理能力。
  3. 算法核心:动态规划(G题甘蔗)和贪心(H题原料采购)是算法竞赛的两大支柱。DP的关键在于状态设计和转移方程;贪心的关键在于正确性证明(或至少是直觉)和高效实现。
  4. 工具熟练度:D题最大数字要求使用BigInteger,H题要求使用TreeMap。对Java标准库中常用数据结构和工具的熟悉,能让你在比赛中节省大量时间。
  5. 细节决定成败:几乎每道题都有需要注意的细节——数据范围与溢出(C、D、H)、边界条件(F)、特殊情况的处理(E中n=1)。

对于准备蓝桥杯研究生组比赛的同学,我的建议是:

  • 刷真题:这是最有效的途径。历年真题(尤其是近三年的)覆盖了大部分考点。像“砝码称重”、“修改数组”、“杨辉三角形”等经典题目,其解题思想会反复出现。
  • 分专题训练:针对自己的薄弱环节,比如动态规划、图论、数论,进行集中突破。可以在洛谷、AcWing等OJ上找相关专题练习。
  • 模拟实战:定期进行4小时的限时模拟赛,训练时间分配和心态调整。比赛时通常前几题要快速通过,为后面的难题留出思考时间。
  • 注重思维:研究生组的题目往往在思维难度上有所提升,不能只满足于套模板。平时多做一些思维题,锻炼自己分析问题、转化问题的能力。

最后,我想说,蓝桥杯不仅仅是一场比赛,更是一个检验和提升自己编程与算法能力的平台。无论比赛结果如何,在备赛和参赛过程中学到的知识、锻炼的思维,才是真正宝贵的财富。我在刚开始参加这类比赛时,也常常被难题卡住,但每次赛后复盘,把不懂的弄懂,就是一种成长。希望这篇详细的解析能帮助你更好地理解这些题目,并在未来的比赛中取得好成绩。如果在练习中遇到问题,多和同学讨论,多在社区看看别人的解题思路,进步会更快。

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐