尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

算法竞赛中的递推序列与Floyd判圈算法应用详解

算法竞赛中的递推序列与Floyd判圈算法应用详解 1. 从一道“倍减序列”题聊聊算法竞赛中的递推与边界处理最近在整理蓝桥杯的历年训练题翻到了ALGO-570这道“倍减序列”。题目本身描述很简洁但评论区里不少朋友都卡在了各种边界条件和递推关系的细节上。这其实挺典型的算法竞赛里很多题目核心思想可能就一两行但想把代码写对、写稳尤其是处理那些“坑点”需要的功夫远不止理解题意那么简单。这道题就是一个很好的例子它考察的不仅仅是递推公式的推导更是对问题边界、整数运算特性以及代码鲁棒性的综合把握。今天我就结合这道题拆解一下这类“序列生成”问题的通用解题思路以及那些容易让人栽跟头的细节。所谓“倍减序列”题目给定了一个初始项x和一个模数M。序列的生成规则是下一项是当前项的两倍然后对M取模。更形式化地说如果序列是a[0], a[1], a[2], ...且a[0] x那么对于i 0有a[i1] (2 * a[i]) % M。题目通常会要求我们找出序列首次出现重复值的位置或者序列的循环节长度等。理解了这个定义我们就能看到它的核心这是一个在有限集合{0, 1, ..., M-1}上由线性同余公式f(n) (2*n) % M迭代生成的确定性序列。序列的行为完全由初始值x和模数M决定。2. 问题本质分析与建模它到底是什么拿到题目第一步永远是抛开那些“倍减”、“序列”的名字看清它的数学本质。规则a[i1] (2 * a[i]) % M描述了一个离散动力系统。由于模运算的存在序列的值域被限制在0到M-1这有限的M个整数中。根据鸽巢原理最多在生成M1项后序列中必然会出现重复的值。一旦出现重复由于递推规则是确定性的序列将进入一个循环。因此这道题通常不会让我们无限生成序列而是会问一些关于这个序列结构的问题比如循环检测序列从第几项开始进入循环循环节的长度是多少特定值查找序列中第K项的值是多少K可能很大序列性质判断序列是否最终会归零或者是否包含某个特定值对于ALGO-570从常见的变体来看很可能是要求我们模拟序列的生成直到某个条件被触发比如值重复或者达到某个指定的项数并输出相应的结果。我们的解题核心就在于高效、准确地模拟这个生成过程并处理好各种边界情况。这里的关键是意识到直接使用数组按顺序存储所有生成项来检测重复在M很大时比如10^9量级是行不通的内存会爆炸。我们必须使用更高效的方法来检测循环。最常用的就是Floyd判圈算法也叫龟兔赛跑算法。这个算法用两个指针或索引以不同速度遍历序列在O(λ μ)的时间复杂度内找到循环的起点和长度其中μ是进入循环前的步数λ是循环长度并且它只需要O(1)的额外空间。这对于本题场景再合适不过。3. 算法核心Floyd判圈算法的推导与实现为什么Floyd算法适用于这道题因为我们的序列生成函数f(n) (2*n) % M是确定性的。这意味着从任意起点n出发它的“下一个”值是唯一确定的。这种结构就像一个有向图每个节点出度为1。在这样的图上从任意点出发的路径最终必然进入一个循环。Floyd算法正是为这种场景设计的。算法的思想非常巧妙。我们维护两个“指针”一个叫slow乌龟每次前进一步即应用一次f函数另一个叫fast兔子每次前进两步即连续应用两次f函数。它们从同一个起始点x开始移动。第一阶段检测循环是否存在。由于兔子跑得快如果序列中存在循环兔子一定会追上乌龟即两者指向相同的值。我们不断迭代直到slow fast。注意在初始状态下slow fast x所以我们要么先让兔子先走一步要么在循环条件里处理第一次相等的情况。更常见的写法是使用一个do...while循环。第二阶段寻找循环的起点。当兔子追上乌龟后我们将兔子重新放到起点x然后让兔子和乌龟都以每次一步的速度前进。当它们再次相遇时相遇点就是循环的入口点。这个结论是Floyd算法的一个经典定理可以通过数学证明理解起来就是设从起点到环入口的距离为μ环的周长为λ。在第一次相遇时乌龟走了μ i步兔子走了μ j步且j - i n * λ。重置后当兔子从起点走μ步到达环入口时乌龟从相遇点也走了μ步由于环的特性它也刚好到达环入口。第三阶段测量循环的长度。找到环入口后让其中一个指针比如乌龟从入口点开始一次走一步并计数直到它回到入口点所计的步数就是环的长度λ。对于“倍减序列”问题我们通常关心的是整个序列的形态。假设题目要求我们输出直到首次出现重复值之前的序列长度即μ λ或者循环节的长度λ。下面我们用C语言来勾勒这个算法的框架#include stdio.h // 序列生成函数 long long next(long long current, long long M) { return (2 * current) % M; } void findCycle(long long x, long long M) { if (M 0) { // 处理非法输入通常M应为正整数 printf(Invalid Modulus M.\n); return; } // 第一阶段检测环 long long slow x, fast x; do { if (fast -1 || next(fast, M) -1) { // 这里用-1表示可能出现的“无循环”或异常情况根据题目实际调整 printf(No cycle detected (possibly reached a fixed point like 0).\n); return; } slow next(slow, M); // 乌龟走一步 fast next(next(fast, M), M); // 兔子走两步 } while (slow ! fast); // 第二阶段找到环的入口 (mu) long long mu 0; fast x; // 兔子回到起点 while (slow ! fast) { slow next(slow, M); fast next(fast, M); mu; } long long cycle_start slow; // 环的入口值 // 第三阶段计算环的长度 (lambda) long long lambda 1; fast next(slow, M); while (slow ! fast) { fast next(fast, M); lambda; } printf(Cycle starts at position %lld (0-based), value %lld\n, mu, cycle_start); printf(Cycle length is %lld\n, lambda); printf(Total distinct elements before cycling: %lld\n, mu lambda); }注意上面的代码是一个演示框架。在实际解题中我们需要根据题目的具体输出要求来调整。例如如果题目要求输出序列直到重复我们可能需要在第一阶段用数组或哈希表记录已访问的值和它们的索引这样在检测到重复时就能直接知道位置。Floyd算法帮我们高效找到了μ和λ但如果我们想输出前μλ个不重复的值可能还是需要配合一个缓存结构。4. 边界条件与易错点深度剖析这是把题目做对的关键。很多同学算法思路懂了一提交就WA问题往往出在这里。4.1 模数 M 为 1 的情况这是最经典的坑点。当M 1时任何数对1取模都是0。因此无论初始值x是什么通常题目规定x在[0, M-1]范围内next(x, 1)永远等于0。序列从第一项开始就恒为0。此时序列是x, 0, 0, 0, ...。循环从第1项如果从0开始计数或第2项如果从1开始计数开始循环长度是1。如果我们用Floyd算法在do...while循环的第一步slow和fast在计算后可能都变成了0从而检测到循环。但入口点mu的计算需要小心它可能是0也可能是1取决于x是否为0。必须单独处理M1的情况。4.2 初始值 x 为 0 的情况当x 0时序列为0, 0, 0, ...。这同样是一个从开始就进入的循环。我们的算法需要能正确处理这种情况。在Floyd算法的第一阶段slow和fast一开始就相等都为0所以do...while循环至少会执行一次。在计算next(0, M)时结果是(2*0)%M 0。所以兔子fast在走两步后还是0。循环会被立即检测到。在寻找入口点时兔子重置为0乌龟也是0所以mu 0环长度lambda 1。这符合预期。4.3 整数溢出问题题目中的x和M可能是很大的整数比如10^9级别。注意递推式a[i1] (2 * a[i]) % M。在计算2 * a[i]时如果a[i]接近10^9那么2 * a[i]就会接近2e9这在32位有符号整数int范围约-2.1e9 ~ 2.1e9的边界上。虽然结果会对M取模但乘法运算本身可能已经溢出。这是极其常见的错误。安全的做法是使用64位整数long long来进行中间计算。即next (2LL * current) % M。确保乘法运算在64位空间进行避免未定义行为。4.4 关于“倍减”与取模的思考为什么叫“倍减”序列我个人的理解是“倍”指的是乘以2“减”指的是取模运算在某种意义上可以看作减法减去M的整数倍。但更准确地说取模运算是为了将值域限制在有限范围内从而必然产生循环。理解这一点有助于我们跳出具体数字从状态机转移的角度来看问题。序列的每一个值都是状态机的一个状态f(n)是状态转移函数。问题就转化为在这个有限状态机上寻找路径和循环。4.5 输入格式与输出格式蓝桥杯的题目对输入输出格式要求非常严格。务必仔细阅读题目说明。是单组数据还是多组数据x和M的输入顺序是什么输出是要求输出序列、长度还是其他每个数字后是否有空格或换行这些细节的疏忽会导致不必要的“格式错误”。建议使用标准的scanf/printf进行输入输出并注意long long的格式符是%lld。5. 从解题到举一反三这类问题的通用策略解决ALGO-570“倍减序列”的过程提炼出了一套处理类似“迭代序列找循环”问题的通用方法论定义状态与转移函数首先明确系统的“状态”是什么。在这里状态就是序列的当前项值a[i]。转移函数就是f(n) (2*n) % M。任何能用确定性的next(state)函数描述的问题都可以套用这个框架。判断问题类型是找循环节找第K项还是判断是否可达某个状态不同问题目标决定了算法的细微调整。找循环节首选Floyd算法找第K项巨大时可能要用快速幂思想结合状态转移矩阵。选择检测算法哈希表法最直观。用一个unordered_map或数组记录每个状态首次出现的步数。当遇到一个已记录的状态时循环就找到了。优点是可以直接得到循环入口和长度且易于理解。缺点是空间复杂度O(M)在状态空间巨大时不适用。Floyd判圈算法空间复杂度O(1)时间复杂度O(μλ)。是处理此类问题的标准答案。尤其适合状态空间大、但转移函数计算简单的情况。Brent算法另一种O(1)空间、O(μλ)时间的算法据说平均比Floyd快一些。但Floyd算法足够经典且易于实现。小心边界条件永远单独考虑那些“退化”的情况。比如转移函数导致值恒定如M1或x0导致序列全0、状态空间为0、初始状态就在循环上等。这些情况往往会让通用算法出现除零、死循环或错误输出。注意数据范围与溢出这是算法竞赛的永恒主题。仔细看题目给出的数据范围选择合适的数据类型int,long long,unsigned long long。在可能发生乘法、加法溢出的地方主动使用更大类型或进行溢出检查。6. 结合具体场景的代码实现与测试让我们假设ALGO-570的一个具体任务描述“给定初始值x和模数M生成倍减序列输出序列中第一次出现重复数字时已经生成了多少个不同的数字包括重复的那个如果序列永不重复在有限步内输出-1。”基于这个假设我们设计一个更贴合题目要求的解决方案。既然要输出“不同数字的个数直到重复”我们其实需要知道从开始到第一次遇到重复值时的总步数μ λ。但是注意Floyd算法找到的第一次相遇点不一定是第一次重复出现的点。龟兔相遇在环内这个相遇点可能是环内的任意位置。要找到第一次重复的值即环的入口我们需要第二阶段。那么总的不同数字个数就是μ λ。然而题目要求“包括重复的那个”所以如果从0开始计数步数当走到第μ步时值是环入口这是第一次出现重复因为它之前在第μ步已经出现过一次了不这里要仔细。实际上序列a[0], a[1], ..., a[μ], a[μ1], ...。值a[μ]是环的入口它在a[μ]是第一次出现吗不一定。环入口的值可能在前面a[0]到a[μ-1]中就出现过吗根据定义μ是从起点到环入口的距离所以a[0]...a[μ-1]都是不重复的a[μ]是第一个重复出现的值因为它等于之前的某个a[k], 0kμ。所以从a[0]到a[μ]一共是μ1个项但其中a[μ]是重复值。题目如果问“第一次出现重复数字时已经生成了多少个数字”那答案就是μ1因为生成了a[0]到a[μ]共μ1个数字。如果问“已经生成了多少个不同的数字”那答案就是μ因为前μ个数字a[0]到a[μ-1]是互异的。为了保险我们需要明确题目的确切表述。这里我假设题目是前者“输出序列中第一次出现重复数字时已经生成了多少个数字”。那么我们的算法需要找到μ然后输出μ1。考虑到可能的状态空间很大我们采用Floyd算法找μ并结合哈希表来记录第一次出现的位置以精确找到第一次重复的时刻。但Floyd算法本身在第二阶段找到的就是环入口即第一次重复发生的索引μ。所以我们可以用Floyd算法求出μ然后输出μ1。但这里还有一个问题当M1或x0时序列从一开始就“重复”了第二项就重复第一项。此时μ 0第一次重复发生在生成第二个数字时所以答案应该是2。我们的算法需要能正确处理。下面给出一个考虑了多种边界、使用哈希表思想但用数组模拟适用于M不是特别大的情况和Floyd算法思想的综合实现示例。为了应对M可能很大的情况我们这里展示一个使用unordered_map的通用解法它更直观且能直接得到我们想要的索引。#include stdio.h #include stdlib.h #include string.h // 假设M的最大值不是特别大我们可以用一个数组来模拟哈希表记录每个值第一次出现的索引。 // 如果M很大比如1e9数组就不行了需要真正的哈希表如C的unordered_map。 // 这里为了演示通用性我们使用一个简单的开放寻址哈希表线性探测。 #define HASH_SIZE 1000003 // 一个较大的质数作为哈希表大小 #define NOT_FOUND -1LL typedef long long ll; ll hash_key[HASH_SIZE]; ll hash_val[HASH_SIZE]; // 存储该值第一次出现的索引 void hash_init() { memset(hash_val, NOT_FOUND, sizeof(hash_val)); } // 一个简单的哈希函数 int get_hash(ll key) { return (int)((key 0x7fffffff) % HASH_SIZE); // 确保非负 } // 在哈希表中查找key如果找到返回其索引否则返回NOT_FOUND ll hash_find(ll key) { int idx get_hash(key); while (hash_val[idx] ! NOT_FOUND) { if (hash_key[idx] key) { return hash_val[idx]; } idx (idx 1) % HASH_SIZE; // 线性探测 } return NOT_FOUND; } // 在哈希表中插入(key, value)如果key已存在不更新 void hash_insert(ll key, ll value) { int idx get_hash(key); while (hash_val[idx] ! NOT_FOUND) { if (hash_key[idx] key) { return; // 已存在不插入 } idx (idx 1) % HASH_SIZE; } hash_key[idx] key; hash_val[idx] value; } ll solve(ll x, ll M) { if (M 0) return -1; // 非法输入 hash_init(); ll current x; ll step 0; // 特殊情况M1任何数模1都是0序列为 x, 0, 0, ... // 第一次重复发生在第二步生成0时如果x!0或第一步如果x0。 // 但根据通用算法我们也可以处理。 // 更简单的方式是直接处理 if (M 1) { // 序列: x, 0, 0, ... // 如果 x 0那么第一步 a[0]0第二步 a[1]0 就重复了。所以答案是2。 // 如果 x ! 0那么 a[0]x, a[1]0, a[2]0第一次重复是a[2]重复a[1]但a[1]是0第一次出现0是a[1]。 // 所以第一次重复是a[2]值0重复了a[1]值0。生成数字个数是3。 // 这有点反直觉。实际上当M1时序列在第二步之后必然全0。 // 题目可能期望的“第一次出现重复”是指值重复出现。值0在a[1]第一次出现在a[2]第二次出现。 // 所以当生成a[2]时发现了重复。此时生成了3个数字。 // 我们用通用算法来跑一下看看。 // 为了简化我们直接返回2如果x0或3如果x!0。但需要看题目定义。 // 这里我们按照通用逻辑实现不特殊处理让哈希表算法来判定。 } while (1) { // 检查当前值是否出现过 ll prev_step hash_find(current); if (prev_step ! NOT_FOUND) { // 当前值在 prev_step 出现过现在又出现在 step // 第一次发现重复此时已经生成了 step 1 个数字因为step从0开始 return step 1; } // 记录当前值第一次出现的位置 hash_insert(current, step); // 生成下一个值 current (2LL * current) % M; step; // 安全限制防止意外无限循环理论上不会因为M有限 if (step M 5) { // 理论上最多M步内必重复这里加个保护 break; } } // 理论上不会走到这里 return -1; } int main() { ll x, M; // 假设输入格式每行两个整数 x M以文件结束或特定终止符为结束 while (scanf(%lld %lld, x, M) 2) { ll ans solve(x, M); printf(%lld\n, ans); } return 0; }这个实现使用了哈希表来记录每个值第一次出现的步数一旦遇到重复就立即返回当前已生成的数字个数。它直观地解决了问题并且能正确处理各种边界情况。缺点是当M非常大时哈希表可能面临冲突和扩容问题。在算法竞赛中如果M大到无法用哈希表比如10^9以上那么题目很可能期望我们使用O(1)空间的Floyd算法并且问题可能转化为求循环节长度等而不是精确记录每个位置。因此在真正解题时我们必须根据题目的数据范围来选择合适的算法。如果M在10^6量级用哈希表是可行的。如果M在10^9量级就必须用Floyd算法并且问题可能只需要我们输出循环节长度之类的信息。7. 调试技巧与常见“坑”点复盘即便思路正确实现时也可能出错。以下是一些调试建议从小数据开始用手算模拟M1,2,3,4,5x取各种值的情况。写出序列验证你的程序输出。这是发现边界条件错误最有效的方法。测试极端值x0,xM-1,M1,M2。特别是M1时你的程序是否能正确输出x0且M1时呢检查整数溢出确保所有中间计算尤其是2 * current都在long long范围内进行。如果题目中M可能很大比如10^18那么2*current可能超过long long范围吗current最大是M-1所以2*(M-1)可能接近2e18这在64位有符号整数范围内最大值约9.22e18所以用long long是安全的。但如果M更大就需要使用unsigned long long或高精度计算了。理解“第一次重复”务必明确题目对“重复”和“计数”的定义。是从第0项开始计数还是第1项重复是指值重复还是索引重复输出的数字个数是包括重复项的那一项还是之前的项数这些细微差别会导致答案差1。最好的办法是仔细阅读题目样例并自己构造几个小样例验证。多组输入的处理蓝桥杯题目经常是多组测试数据直到文件结束。确保你的程序在每组数据前正确初始化了全局变量如哈希表、标记数组等。一个常见的错误是上一组数据污染了下一组。最后这道“倍减序列”题其价值远不止于解出它本身。它训练了我们几种关键能力将自然语言描述转化为严谨的数学模型在有限状态机上应用经典的循环检测算法以及对边界条件和特殊情况进行周密思考。把这些点都把握住再遇到类似的“迭代序列”、“状态转移找循环”问题你就能游刃有余了。在算法竞赛和实际开发中这种严谨的逻辑思维和对细节的掌控力才是从“知道”到“做对”的关键跨越。
返回列表