拓冰建站拓冰建站
首页 / 资讯中心 / 正文

四毛子算法与±1 RMQ:O(1)查询的静态区间最值优化方案

1. 算法背景与问题定义四毛子算法和±1 RMQ这两个名字听起来有点怪但它们在算法竞赛和某些特定场景的底层优化里可是实打实的“性能怪兽”。我第一次接触这个概念是在准备一场重要的线上编程比赛当时卡在了一道看似简单的区间最值查询题上数据规模大到O(n log n)的预处理都吃不消。就在一筹莫展时一位前辈扔给我一篇论文里面提到的“Four Russians”和“±1 RMQ”让我豁然开朗。这本质上是一种“用空间换时间”和“用预处理换查询效率”的极致思想通过精巧的数学构造和预处理将RMQRange Minimum Query问题的查询时间复杂度压到了惊人的O(1)而预处理时间依然是线性的O(n)。那么到底什么是±1 RMQ简单说它是一种特殊的RMQ问题给定一个长度为n的数组A且满足相邻元素的差值绝对值为1即 |A[i] - A[i1]| 1。我们需要快速回答任意区间 [l, r] 内的最小值及其位置。你可能会觉得这个限制太强没什么用。但恰恰相反很多更一般的RMQ问题比如最常见的基于稀疏表的RMQ都可以通过一种叫做“欧拉序”的技巧转化为±1 RMQ来解决。而四毛子算法就是解决±1 RMQ乃至推广到一般RMQ问题的核心方法论。为什么我们需要这么复杂的算法因为对于海量数据的实时查询比如基因序列比对中的片段匹配、大型网络日志的实时监控统计甚至是游戏引擎中场景管理的空间查询每次查询都必须是常数时间否则累积的延迟是无法接受的。标准的线段树查询是O(log n)稀疏表虽然查询是O(1)但预处理的空间复杂度是O(n log n)当n上亿时内存可能就扛不住了。四毛子算法配合±1 RMQ能在O(n)预处理空间和时间内实现O(1)查询这是一个理论上的最优解之一。2. 核心思想分而治之与查表法四毛子算法的核心思想非常直观就是“分块”和“预处理所有小块的可能情况”。这个“四毛子”的名字源于四位前苏联的计算机科学家。它的精髓在于将一个大问题分解成若干个足够小的子问题由于子问题的规模小其所有可能的情况是有限的。我们可以预先计算出所有可能情况下的答案并存储在一张表里。当需要解决原问题时我们只需将问题映射到对应的子问题然后通过查表瞬间得到答案。把这个思想应用到±1 RMQ上具体步骤如下宏观分块将长度为n的原数组划分为若干个连续的大块。设块大小为b (log2 n) / 2。这样划分后大块的数量大约是n / b也就是 O(n / log n) 个。处理块间最值对每个大块我们只关心这个块内的最小值是多少。这样我们就得到了一个由“块最小值”组成的、长度为 O(n / log n) 的新数组。对这个新数组我们可以使用一个空间复杂度稍高但查询为O(1)的方法来预处理比如稀疏表Sparse Table。因为新数组长度是 O(n / log n)所以为其构建稀疏表的总空间开销是 O((n / log n) * log(n / log n)) O(n)依然是线性的。处理块内最值关键这才是四毛子算法的精髓所在也是±1 RMQ性质发挥作用的地方。对于任何一个大小为b的块由于数组满足±1性质这个块内所有元素的值本质上是由起始元素的值和一段长度为(b-1)的“差分序列”记录每一步是1还是-1唯一确定的。这个差分序列一共有2^(b-1)种可能。由于我们之前设定了b (log2 n) / 2那么2^(b-1) 2^((log2 n)/2 - 1) O(sqrt(n))。这是一个关于n的子线性规模暴力枚举与建表对于这 O(sqrt(n)) 种可能的差分序列即块类型我们预先在算法开始时暴力计算出每一种序列下该块内所有可能区间[l, r] (0 l r b) 的最小值位置。注意一个块内这样的区间总共有 O(b^2) O((log n)^2) 个。所以为所有块类型预计算答案表的总时间复杂度是 O(sqrt(n) * (log n)^2)这个复杂度相对于O(n)的预处理是可以接受的通常被视为常数或很小的开销总空间需求也是 O(sqrt(n) * (log n)^2)。查询时的O(1)操作当我们需要查询原数组区间 [L, R] 时找到L和R所在的大块编号bl和br。如果bl等于br说明区间在一个块内。我们根据该块的“类型”即其差分序列的编码和块内偏移l和r直接查步骤4中预处理的表得到块内最小值位置再映射回原数组索引。如果bl小于br则区间跨越多个块。此时答案可能来自三部分左残块bl块中从L到该块末尾的部分。中间完整块从bl1到br-1这些完整块的最小值。右残块br块中从该块开头到R的部分。对于左右残块用块内查表法解决。对于中间完整块用步骤2为块最小值数组构建的稀疏表来查询。最后比较这三个候选值取最小者即可。每一步都是O(1)。注意这里有一个非常关键的实现细节就是如何高效地表示一个块的“类型”。由于是±1数组我们可以用长度为(b-1)的二进制位来表示差分序列比如用0表示-1下降1表示1上升。这个二进制数就是块的“指纹”或“类型ID”直接用作预计算表的索引。计算这个ID可以在遍历原数组构建块时在线性时间内完成。3. 从±1 RMQ到一般RMQ的桥梁欧拉序与LCA前面提到±1 RMQ看似限制很强但通过“欧拉序”这个技巧我们可以将树上的LCA最近公共祖先问题进而将一般的RMQ问题转化为±1 RMQ。首先任何一个RMQ问题在静态数组上都可以通过构建一颗笛卡尔树Cartesian Tree转化为LCA问题。笛卡尔树满足中序遍历是原序列且具有堆性质这里我们构建小根堆。那么原数组区间[l, r]的最小值就等于笛卡尔树上节点l和节点r的LCA所对应的值。接着如何快速求解LCA这里就用到欧拉序。对笛卡尔树进行深度优先遍历DFS在每次“进入”一个节点和“离开”一个节点时都将该节点记录到序列中同时记录其深度。这样得到的序列就是欧拉序长度是2n-1。关键的性质来了树上任意两个节点u和v的LCA在欧拉序中一定出现在u和v第一次出现的位置之间并且是这个区间内深度最小的那个节点于是LCA问题转化为了在欧拉序的深度数组上做RMQ。最后也是最妙的一点欧拉序对应的深度数组满足±1 性质因为DFS遍历时每次步进要么走到子节点深度1要么回退到父节点深度-1。因此深度数组相邻元素的差值绝对是1。这样我们就把一个一般的RMQ问题通过笛卡尔树和欧拉序转化为了一个在长度2n-1的±1数组上的RMQ问题然后就可以用上面介绍的四毛子算法来以O(n)时间/空间预处理O(1)查询了。整个转换流程可以概括为一般数组RMQ-构建笛卡尔树-转化为LCA问题-生成欧拉序及深度数组±1序列-应用四毛子算法解决±1 RMQ-得到原RMQ答案。实操心得在实际编码中构建笛卡尔树有O(n)的单调栈算法一定要掌握。欧拉序的生成就是一次DFS。最难也最需要细心实现的部分就是四毛子算法本身特别是块类型的编码和解码以及三部分查询结果的合并。建议先用小数据测试块内查表的正确性。4. 算法实现细节与参数选择理论很优美但实现起来魔鬼都在细节里。下面我们拆解关键步骤的实现。4.1 块大小b的选择与类型编码块大小b的选择不是随意的。我们推导过为了确保预计算所有块类型表的空间开销在O(n)以内需要2^(b-1) * b^2 O(n)。通常选择b max(1, (log2 n) / 2)的向下取整。在实际代码中log2 n可以通过计算__lg(n)GCC内置函数或不断右移得到。例如当n在1e6量级时log2 n ≈ 20 b ≈ 10。此时块类型有2^9 512种每块内预处理55个区间(b*(b1))/2总表大小很小。编码时我们顺序遍历一个块内的元素从第二个开始因为第一个元素作为基准。用uint64_t或bitset存储一个b-1位的整数。遇到A[i] A[i-1] 1则当前位设为1遇到A[i] A[i-1] - 1则设为0。这个整数就是块的type_id。// 假设数组 vec 满足 ±1 性质 block_start 是块起始下标 int type_id 0; for (int i 1; i block_size; i) { if (vec[block_start i] vec[block_start i - 1] 1) { type_id | (1 (i - 1)); // 第 i-1 位设为 1 } // 否则就是 -1对应位为 0无需操作 }4.2 块内预计算表的结构与填充我们需要一个表precalc[type_id][l][r]存储对于类型为type_id的块区间[l, r]内的最小值相对于块起点的偏移量。直接开三维数组可能不现实。因为type_id范围是[0, 2^(b-1))l和r范围是[0, b)。一个优化的方法是扁平化。对于每个type_id我们只存储一个长度为b的数组min_in_block它表示以每个位置为结尾的前缀的最小值位置不这样查询时还需要扫描。更好的方法是存储所有区间的结果。由于b很小通常20我们可以为每个type_id分配一个b * b的二维数组或者用一维数组手动索引。填充这个表需要模拟vectorvectorvectorint table(1 (b-1), vectorvectorint(b, vectorint(b, 0))); for (int type 0; type (1 (b-1)); type) { // 1. 根据 type 重建这个块的所有元素值相对值即可 vectorint block(b); block[0] 0; // 设第一个元素为基准0 for (int i 1; i b; i) { if (type (1 (i-1))) { block[i] block[i-1] 1; } else { block[i] block[i-1] - 1; } } // 2. 暴力计算所有区间 [l, r] 的最小值索引 for (int l 0; l b; l) { int min_idx l; int min_val block[l]; for (int r l; r b; r) { if (block[r] min_val) { min_val block[r]; min_idx r; } table[type][l][r] min_idx; // 记录块内偏移 } } }注意事项这个预计算是在算法初始化时做的只做一次。虽然看起来是O(2^b * b^2)但因为b ≈ log n / 2所以是O(sqrt(n) * (log n)^2)在n很大如1e6时这个预处理时间通常远小于主预处理O(n)可以接受。如果非常苛刻可以考虑进一步优化比如利用±1序列的性质用DP递推填充表。4.3 块间稀疏表的构建与查询对于由每个大块最小值组成的数组block_min长度为num_blocks我们为其构建稀疏表。稀疏表st[k][i]表示从i开始长度为2^k的区间的最小值在原数组中的索引注意这里存储索引比存储值更重要因为最终要定位到原数组。构建是标准的O(num_blocks log num_blocks)int k __lg(num_blocks) 1; vectorvectorint st(k, vectorint(num_blocks)); // st[0][i] 初始化为 block_min 对应的原数组索引 for (int i 0; i num_blocks; i) st[0][i] block_min_index[i]; for (int j 1; j k; j) { for (int i 0; i (1 j) num_blocks; i) { int left st[j-1][i]; int right st[j-1][i (1 (j-1))]; st[j][i] (arr[left] arr[right]) ? left : right; } }查询区间[bl, br]块索引时int len br - bl 1; int k __lg(len); int i1 st[k][bl]; int i2 st[k][br - (1 k) 1]; return (arr[i1] arr[i2]) ? i1 : i2;4.4 完整查询流程给定原数组下标L和R计算块索引bl L / block_size,br R / block_size以及块内偏移l L % block_size,r R % block_size。如果bl br属于块内查询获取该块的type_id block_type[bl]。查表int offset precalc_table[type_id][l][r]。原数组答案索引idx bl * block_size offset。如果bl br属于跨块查询候选1左残块查询块bl内区间[l, block_size-1]方法同2得到索引idx1。候选2右残块查询块br内区间[0, r]方法同2得到索引idx2。候选3中间块如果bl 1 br - 1使用块间稀疏表查询区间[bl1, br-1]的最小值索引idx3。比较arr[idx1],arr[idx2],arr[idx3]如果存在取最小值对应的索引作为最终答案。所有步骤都是常数时间操作。5. 复杂度分析与适用场景时间复杂度预处理构建笛卡尔树O(n)生成欧拉序和深度数组O(n)四毛子算法预处理分块、计算块类型、构建块内表、构建块间稀疏表O(n) O(√n * log² n) 后者通常被视为常数或较低阶。综合为 O(n)。单次查询O(1)。这是最核心的优势。空间复杂度主要是存储欧拉序数组O(n)、深度数组O(n)、块类型数组O(n/b)、块内预计算表O(2^b * b²) O(√n * log² n)、块间稀疏表O((n/b) * log(n/b)) O(n)。总体是 O(n)。适用场景静态序列的频繁RMQ数据一旦给定就不再改变但需要执行海量数百万甚至更多次的区间最值查询。例如历史股票价格分析、静态基因组数据比对、预计算好的网络拓扑上的路径查询。作为子模块解决LCA问题在需要超快速回答树上任意两点LCA的场景如大型软件工程中继承树的解析、XML文档的查询处理。算法竞赛中的压轴题数据规模极大n up to 10^6, q up to 10^7必须使用O(1)查询的RMQ才能通过。不适用场景动态数据如果数组元素会更新此算法完全失效需要转向线段树等数据结构。一次性或少量查询杀鸡用牛刀预处理开销得不偿失直接扫描或者用简单的稀疏表即可。内存极度受限虽然空间是O(n)但常数因子比稀疏表大因为存储了多份辅助数据欧拉序、深度、块类型、预计算表等。踩坑实录我第一次实现时犯了一个错误在块内预计算表中存储的是最小值相对于块起点的偏移量但在查询合并时忘记将其加上bl * block_size转换回原数组索引导致答案错位。另一个易错点是计算__lg(len)求2的对数时要确保len 0否则会出现未定义行为。对于中间块查询[bl1, br-1]一定要先判断这个区间是否有效即bl1 br-1。6. 代码实现框架与测试要点这里给出一个高度概括的C类框架用于解决一般RMQ问题通过转化为±1 RMQclass RMQ { private: vectorint arr; // 原数组副本可选 vectorint euler, depth, first; // 欧拉序深度节点首次出现位置 vectorint block_type; // 每个块的类型ID vectorvectorvectorint block_table; // 块内预计算表 [type][l][r]-offset vectorvectorint sparse_table; // 块间稀疏表 int block_size, num_blocks; // ... 其他辅助数组 // 1. 构建笛卡尔树 (返回根节点索引) int build_cartesian_tree(const vectorint input); // 2. DFS生成欧拉序和深度数组 void dfs(int u, int parent, int dep); // 3. 四毛子算法预处理深度数组 void build_plus_minus_one_rmq(const vectorint depth_arr); // 4. 查询深度数组区间 [l, r] 最小深度值的索引 int query_plus_minus_one(int l, int r); public: RMQ(const vectorint input_arr); // 查询原数组区间 [ql, qr] 的最小值索引 int query(int ql, int qr); };测试要点基础功能测试用小规模随机数组n10~100与暴力算法结果逐一对比较。±1性质测试专门测试一个满足±1条件的数组验证查询正确性。性能压力测试构造 n1e6 的随机数组进行 q1e7 次随机区间查询统计总耗时。与稀疏表O(1)查询但O(n log n)空间对比内存和时间。观察预处理时间是否线性增长。边界测试查询区间[0,0],[0, n-1]。数组元素全部相等此时仍满足±1吗注意|A[i]-A[i1]|0不满足绝对值为1。这是一个边界情况需要确认算法是否处理。通常的LCA转化生成的深度数组是严格±1的。块大小b1的特殊情况处理。内存占用检查使用sizeof或工具估算各数据结构内存确保符合O(n)预期。实现这个算法是一次对综合数据结构能力的很好锻炼。它不像AC自动机或Splay树那样有固定的模板需要你真正理解分块、编码、查表、稀疏表以及问题转化的每一个环节。当你成功实现并AC一道卡RMQ的难题时那种成就感是无与伦比的。它让我深刻体会到在计算机科学中通过巧妙的预处理和数学构造我们确实能在时空复杂度上实现看似不可能的优化。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门