ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

蓝桥杯经典题Log大侠:位运算与并查集优化算法详解

蓝桥杯经典题Log大侠:位运算与并查集优化算法详解 1. 项目概述当“Log大侠”遇上蓝桥杯国赛看到“Log大侠”这个标题很多参加过蓝桥杯的老选手可能会心一笑。这可不是什么武侠小说里的角色而是第五届蓝桥杯软件类国赛C/C本科A/B组中一道非常经典的编程题。它之所以让人印象深刻是因为题目巧妙地将“对数Log”的概念与计算机底层的“位运算”结合在了一起考察选手对数据本质的理解和算法优化能力。简单来说题目给你一个整数数组然后让你反复执行一种特殊的“取对数”操作并最终回答一系列查询。这个“取对数”并非数学库里的log()函数而是一种经过位运算定义的、结果恒为整数的特殊运算。对于当时以及现在准备蓝桥杯的选手而言这道题是一个绝佳的思维训练场它看起来是数学题骨子里却是算法题核心是考察如何利用运算的性质将看似复杂的循环操作优化到极致。这道题适合所有正在备战蓝桥杯、ACM-ICPC等算法竞赛的同学尤其是那些已经掌握了基础数据结构如数组、前缀和但对时间复杂度和空间复杂度优化、位运算技巧以及数学思维转化还感到棘手的同学。通过深入剖析“Log大侠”你不仅能学会解这一道题更能掌握一类问题的思考方式——即如何发现并利用题目中操作的“不动点”、“周期性”或“收敛性”来避免无效计算。下面我们就化身“Log大侠”一起拆解这道题的筋骨看看如何从暴力模拟走向高效优化。2. 题目核心需求与操作定义解析2.1 问题场景还原首先我们需要把题目场景具象化。题目通常会给出以下信息初始数组一个包含N个正整数的数组A。特殊操作定义一种针对单个正整数x的“ATM操作”题目原名这里我们延续“Log大侠”的称呼。这个操作是x x的二进制表示中‘1’的个数。举个例子x13二进制1101有3个‘1’操作后x3x3二进制11有2个‘1’操作后x2x2二进制10有1个‘1’操作后x1x1二进制1有1个‘1’操作后x1。批量操作与查询接下来会有M个操作/查询。每个操作指定一个区间[L, R]你需要将数组A中下标从L到R通常题目下标从1开始的每一个数A[i]都执行一次上述的“Log操作”。在每次区间操作之后可能会紧接着一个查询询问当前整个数组所有元素的总和。或者M个指令全部是操作最后一次性查询总和。这是题目常见的两种变体。核心矛盾立刻浮现如果N和M都很大比如N, M 100,000对于每一个区间操作我们都老老实实地遍历区间内每个数并对每个数反复执行操作直到其稳定变成1那么时间复杂度将是灾难性的。一个数可能很大比如10^9但经过几次“数1”操作后会迅速减小最坏情况下比如从10^9开始需要多少次操作呢这引出了我们对操作本身性质的深度分析。2.2 “Log操作”的数学与计算机本质这个操作f(x) popcount(x)其中popcount是计算整数二进制表示中1的个数的函数也被称为汉明重量。为什么把它类比为“Log”对数函数的直观在数学中log函数能将大数映射为小数增长非常缓慢。这里的popcount操作也有类似效果。一个很大的数其二进制位数bit长度约为log2(x)而其中‘1’的个数一定不大于位数。所以一次操作至少将数值从x降到了O(log x)的量级。收敛速度极快更重要的是这个操作具有极强的“收敛性”。对于任何正整数x反复应用f序列x, f(x), f(f(x)), ...会迅速收敛到1。并且一旦x变成1、2、3中的某个值后其行为就固定了1 (二进制1) - popcount(1)1。1是不动点。2 (二进制10) - popcount(2)1 - 1。3 (二进制11) - popcount(3)2 - 1。4 (二进制100) - popcount(4)1 - 1。5 (二进制101) - popcount(5)2 - 1。6 (二进制110) - popcount(6)2 - 1。7 (二进制111) - popcount(7)3 - 2 - 1。实操心得你可以写个简单的程序枚举一下会发现几乎所有数在极少步骤通常不超过5步内都会收敛到1。一个经典结论是对于x 10^9最多只需要执行3次popcount操作它一定会变成1。因为10^9 2^30第一次popcount后结果最大为3030的二进制是11110popcount(30)4popcount(4)1。所以最多3步。这是本题能够优化的根本前提。3. 从暴力模拟到高效算法的设计思路3.1 最直接的暴力法及其缺陷最朴素的想法是模拟对于每个区间[L, R]的更新操作遍历i从L到R对每个A[i]执行while(A[i] 1) A[i] popcount(A[i])。然后如果需要查询总和就再遍历整个数组求和。缺陷分析时间浪费在重复计算如果一个数A[i]已经变成了1那么后续任何包含i的区间操作对它都是无效的因为popcount(1)1值不变。暴力法不会区分每次都会再次尝试“操作”它。单点操作成本可能高虽然每个数收敛很快但如果M很大且区间经常重叠一个数可能被多次、无意义地访问。最坏时间复杂度可达O(M * N * C)其中C是收敛步数约3-5这显然是无法接受的。3.2 核心优化思路懒惰标记与状态管理既然一个数变成1后就“死”了不再变化那么我们优化的核心就是避免对已经变成1的元素进行任何不必要的操作和遍历。如何实现这里需要结合两种经典思想并查集Union-Find的“跳跃”思想我们可以维护一个next数组next[i]表示从下标i开始下一个值大于1的元素的下标。初始化时next[i] i1。当我们处理A[i]并发现它变成1后就将next[i]指向next[i1]。这样当我们遍历区间时就可以“跳过”那些已经变成1的位置。树状数组Fenwick Tree或线段树Segment Tree为了高效地维护区间和查询总和以及支持单点更新某个A[i]变化了我们需要一个能在O(log N)时间内完成“单点更新”和“区间查询”的数据结构。树状数组代码更简洁是首选。整体算法流程设计初始化读入数组A。初始化树状数组BIT存储A的当前值用于快速求区间和。初始化next数组next[i] i1next[n]可以设为n1作为哨兵。处理每个操作[L, R]令pos L。当pos R时循环 a.定位实际需要操作的元素pos find(pos)。这里find函数利用next数组进行路径压缩找到pos之后第一个值未收敛到1的下标。如果pos R跳出循环。 b.执行一次popcount操作old_val A[pos]new_val popcount(old_val)。 c.更新树状数组在树状数组中将pos位置的值增加(new_val - old_val)。 d.更新原数组A[pos] new_val。 e.检查是否收敛如果new_val 1说明该位置已“死亡”修改next[pos] find(next[pos])将其指向下一个活元素。 f.移动到下一个待检查位置pos next[pos]。处理查询如果操作后需要查询总和直接使用树状数组查询全局和query(1, N)即可时间复杂度O(log N)。这个算法的精妙之处在于每个数组元素最多被“有效操作”即值发生变化的操作的次数就是它收敛到1所需的步数最多3-5次。一旦变成1就会被next数组跳过。因此总的有效操作次数是O(N * C)这是一个与M无关的量遍历区间的开销则通过next数组的跳跃式前进大大降低均摊复杂度接近O(M N * C * log N)完全可以处理大数据量。4. 关键代码实现与细节剖析4.1 快速计算popcount计算二进制中1的个数有高效的位运算方法。虽然编译器内置函数__builtin_popcount对于GCC/Clang非常高效但了解其原理有益无害。这里介绍经典的“平行算法”int popcount(int x) { x (x 0x55555555) ((x 1) 0x55555555); x (x 0x33333333) ((x 2) 0x33333333); x (x 0x0F0F0F0F) ((x 4) 0x0F0F0F0F); x (x 0x00FF00FF) ((x 8) 0x00FF00FF); x (x 0x0000FFFF) ((x 16) 0x0000FFFF); return x; }在竞赛中直接使用__builtin_popcount是最佳选择代码简洁且效率极高。4.2 树状数组实现树状数组用于维护前缀和支持单点增加和区间求和。class FenwickTree { private: vectorlong long tree; // 注意用long long总和可能很大 int n; public: FenwickTree(int size) : n(size), tree(size 1, 0) {} void add(int idx, int delta) { while (idx n) { tree[idx] delta; idx idx -idx; // lowbit操作 } } long long prefixSum(int idx) { long long sum 0; while (idx 0) { sum tree[idx]; idx - idx -idx; } return sum; } long long rangeSum(int l, int r) { return prefixSum(r) - prefixSum(l - 1); } };4.3 并查集式“跳跃”数组的实现这是本算法的核心优化点。我们并不需要完整的并查集结构一个数组配合路径压缩即可。vectorint nxt; // nxt[i] 表示从i开始下一个需要检查的位置 // 初始化 nxt.resize(n 2); for (int i 1; i n 1; i) { nxt[i] i; // 初始时每个位置都指向自己但通常我们初始化为i1来跳过自己这里需要根据逻辑调整。 } // 更常见的初始化是nxt[i] i 1 并设置 nxt[n1] n1 作为哨兵。 // 带路径压缩的find函数 int find(int x) { if (x n || nxt[x] x) return x; // 到达边界或指向自己未初始化情况 // 路径压缩直接让nxt[x]指向最终找到的活位置 return nxt[x] (A[x] 1 ? x : find(nxt[x])); }在实际代码中我们通常不单独写find而是将路径压缩逻辑直接嵌入到主循环的跳跃过程中。主循环核心代码片段int l, r; // 输入操作区间 l, r for (int pos l; pos r; ) { // 如果当前pos已经“死”了值为1就跳到nxt[pos] if (A[pos] 1) { pos nxt[pos]; continue; } // 执行操作 int old_val A[pos]; int new_val popcount(old_val); if (new_val ! old_val) { bit.add(pos, new_val - old_val); // 更新树状数组 A[pos] new_val; } // 如果操作后变成1则更新nxt指针使其跳过自己 if (A[pos] 1) { nxt[pos] (pos 1 n) ? nxt[pos 1] : (n 1); // 尝试进行路径压缩让前面指向pos的指针直接指向nxt[pos] // 这一步可以在查找时动态完成为了清晰这里展示一个简化版本 } // 移动到下一个位置 pos; }但上述代码在pos时可能会回溯检查已死的元素。更高效的是“跳跃式”前进int pos l; while (pos r) { // 使用while循环跳过所有已经为1的位置 while (pos r A[pos] 1) { pos nxt[pos]; } if (pos r) break; // 对A[pos]进行操作... int old_val A[pos]; int new_val popcount(old_val); // ... 更新树状数组和A[pos] if (new_val 1) { // 当前pos死亡将其nxt指向下一个位置 nxt[pos] (pos 1 n) ? (nxt[pos 1] ? nxt[pos 1] : pos 1) : (n 1); // 注意我们需要维护nxt链使得find操作能快速跳过连续死亡区间 } // 关键无论是否死亡下一个要检查的位置应该是 nxt[pos] // 但如果没死我们还需要继续处理它直到它死所以这里不能直接跳。 // 因此更准确的做法是每次循环只处理一次“操作”然后pos不变直到它变成1。 // 但这样会陷入死循环。所以我们需要改变策略。 }正确的“跳跃”逻辑需要结合“并查集”的find函数确保我们总是定位到下一个值大于1的位置。这是实现中最容易出错的地方。4.4 一个经过验证的正确实现框架#include bits/stdc.h using namespace std; const int MAXN 100010; int A[MAXN]; long long BIT[MAXN]; int nxt[MAXN]; int n, m; inline int lowbit(int x) { return x -x; } void add(int idx, int delta) { while (idx n) { BIT[idx] delta; idx lowbit(idx); } } long long sum(int idx) { long long res 0; while (idx 0) { res BIT[idx]; idx - lowbit(idx); } return res; } // 使用GCC内置函数效率极高 #define popcnt __builtin_popcount // 并查集find函数寻找下一个未收敛的点 int find(int x) { if (x n || x 0) return n 1; // 哨兵 if (A[x] 1) return x; // 当前点还“活着” if (nxt[x] ! x) nxt[x] find(nxt[x]); // 路径压缩 return nxt[x]; } int main() { scanf(%d %d, n, m); for (int i 1; i n; i) { scanf(%d, A[i]); add(i, A[i]); nxt[i] i; // 初始指向自己 } nxt[n 1] n 1; // 哨兵 while (m--) { int op, l, r; scanf(%d %d %d, op, l, r); if (op 1) { // 更新操作 for (int pos find(l); pos r; pos find(pos 1)) { int old_val A[pos]; int new_val popcnt(old_val); if (new_val ! old_val) { add(pos, new_val - old_val); A[pos] new_val; } if (A[pos] 1) { // 当前点死亡将其连接到下一个点 nxt[pos] find(pos 1); } } } else { // 查询操作 printf(%lld\n, sum(r) - sum(l - 1)); } } return 0; }注意事项这个实现中find函数是递归的并且进行了路径压缩。在更新时我们通过for (int pos find(l); pos r; pos find(pos 1))来确保pos始终是下一个活着的元素。当A[pos]变成1后我们执行nxt[pos] find(pos 1)这样下次find(pos)就会直接跳过它。这个写法非常清晰且高效。5. 算法复杂度分析与边界情况5.1 时间复杂度树状数组操作每次单点更新和区间查询都是O(log N)。popcount操作每个元素最多执行C次C5。find操作与遍历利用并查集路径压缩的均摊复杂度接近常数。每个元素在“死亡”变成1时会被find访问一次在作为“下一个活元素”被定位时也可能被访问。但每个元素最多从“活”变“死”一次因此所有find操作的总次数是O(N * α(N))其中α是反阿克曼函数可视为常数。总复杂度约为O((N * C M) * log N)。对于N, M 10^5这个复杂度完全可行。5.2 空间复杂度主要是数组A、树状数组BIT、nxt数组都是O(N)。5.3 边界情况与调试要点下标从1开始树状数组和并查集通常使用1-based索引输入数据需要注意转换。整数溢出数组元素初始值和总和可能超过32位int范围树状数组和求和变量应使用long long。初始状态所有nxt[i]初始化为i表示每个位置自身就是“活”的。哨兵nxt[n1] n1很重要用于终止查找。操作区间可能LR根据题目描述通常保证L R但严谨的代码可以不加判断。popcount的参数确保传入的是无符号整数或正整数对于负数__builtin_popcount的行为是未定义的视作补码形式。本题保证是正整数。6. 常见问题与实战调试技巧6.1 为什么我的程序超时了没有使用优化最可能的原因是使用了纯粹的暴力模拟对每个区间都逐个元素循环并执行while操作。必须实现“跳过已收敛元素”的优化。find函数效率低如果没有进行路径压缩find函数可能会退化成O(N)的链式查找。确保在find函数中更新nxt[x]。popcount实现效率低如果自己写循环数1的个数对于大数虽然本题很快收敛可能稍慢。使用__builtin_popcount或查表法。输入输出效率在C中对于大量数据使用scanf/printf或关闭同步的cin/cout。6.2 为什么我的答案错了树状数组更新错误更新时delta new_val - old_val要确保这个差值计算正确。nxt数组更新逻辑错误这是最容易出错的地方。核心原则是只有当A[pos]从大于1变成1的那一刻才需要更新nxt[pos]将其指向下一个活元素。并且这个“指向”应该是find(pos1)而不是简单的pos1。忽略了多次操作题目要求是对区间内每个数执行一次“ATM操作”。我们的算法在每次更新指令中对区间内每个活元素只执行了一次popcount。这是正确的因为如果某个元素在这次操作后没有变成1它会在后续的更新指令中如果区间再次包含它被再次处理。我们的find机制保证了它能被再次找到。查询与操作顺序仔细阅读题目是每次操作后立即查询还是所有操作后查询这会影响输出格式。6.3 调试技巧小数据模拟构造一个N5, M10的小数据用手算或打印出每一步的数组A、nxt和树状数组的和与你的程序输出对比。打印关键变量在更新循环中打印pos,old_val,new_val,nxt[pos]的变化观察跳跃逻辑是否正确。测试极端数据所有数初始为1程序应该几乎不进入更新循环。一个大数反复被操作观察它是否在几步后变成1并被正确跳过。连续大区间更新观察时间复杂度是否可接受。对拍写一个绝对正确但低效的暴力程序用于小数据范围用随机生成的数据与你的优化程序对比结果。6.4 算法扩展思考“Log大侠”的核心优化思想——利用操作的不动点和收敛性通过并查集跳过无效元素——可以推广到一类问题。例如如果操作是x x / 2向下取整或者x sqrt(x)向下取整这些操作也具有快速收敛到1或0的特性。面对这类“区间操作单点快速收敛”的问题都可以尝试采用类似的“跳跃”“区间查询”数据结构线段树有时也可直接维护区间最大值若最大值阈值则跳过的解决方案。我个人在最初解这道题时也曾陷入暴力模拟的思维定式。直到画出popcount操作的收敛树才恍然大悟其收敛速度之快。这提醒我们在算法竞赛中对题目给定操作进行数学性质分析往往比直接上手写代码更重要。花几分钟时间推导一下最坏情况步骤、寻找不动点可能就能发现通往AC的捷径。对于“Log大侠”来说认识到“任何数最多变3次”和“变成1后永不变”这两个性质就是打开优化之门的钥匙。最后记得在竞赛中如果遇到10^5量级的数据和区间操作先想想有没有办法让每个元素只被“有效访问”有限次这通常是正解的信号。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表