尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

算法竞赛中单词接龙问题的DFS解法与剪枝优化

算法竞赛中单词接龙问题的DFS解法与剪枝优化 1. 从一道“单词接龙”题聊聊算法竞赛中的字符串处理与搜索策略最近在整理蓝桥杯的历年真题和训练题翻到了ALGO-678这道“单词接龙”。题目本身不算新颖但作为算法训练它完美地融合了字符串处理、图论建模和深度优先搜索DFS这几个核心知识点。很多刚接触算法竞赛的朋友一看到“接龙”两个字可能下意识觉得就是个简单的字符串匹配但实际动手写起来往往会卡在“如何高效判断两个单词能否相接”以及“如何避免在搜索中陷入死循环”这两个坎上。今天我就结合这道题把里面涉及到的技术细节、常见的思维误区以及我个人在实现时的一些优化心得系统地梳理一遍。无论你是正在备赛蓝桥杯还是单纯想提升自己的算法实现能力相信这篇内容都能给你带来一些直接的帮助。简单来说“单词接龙”的规则和我们小时候玩的成语接龙类似给定一个初始单词和一组备用单词要求用备用单词拼接出一条最长的“龙”每个单词最多使用两次具体次数看题目要求常见是两次且相邻两个单词重叠的部分必须相同。比如“ababa”和“babab”就可以重叠“bab”或“aba”部分进行连接。我们的目标就是找出最长的那条龙。这听起来像是一个搜索问题但如何把它抽象成计算机能高效处理的形式才是关键。2. 问题核心拆解规则、目标与抽象建模在动手写代码之前我们必须把题目要求吃透并转化为清晰的数学模型。这是解决任何算法问题的第一步也是最容易出错的一步。2.1 规则的形式化定义首先我们需要明确几个核心规则这直接决定了后续的数据结构和算法设计重叠规则两个单词A和B只有当A的某个后缀与B的某个前缀完全相同时才能连接。这个重叠部分的长度通常有最小限制比如至少为1并且不能超过任一单词的长度即不能完全覆盖另一个单词。例如A“abcde” B“cdefg”它们可以重叠“cde”部分连接成“abcdefg”。单词使用限制每个给定的备用单词通常有使用次数上限。在经典的“单词接龙”问题中为了增加龙的长度和搜索的复杂性经常会允许每个单词最多使用两次。这意味着在搜索路径中同一个单词可以出现两次但不能超过两次。这需要在搜索状态中记录每个单词的已使用次数。龙的起始龙由一个给定的初始单词开始。这个初始单词可能不计入长度也可能作为第一部分计入。需要仔细阅读题目描述。目标函数我们需要找到的是最长的龙。这里的“长度”通常指拼接后整个字符串的字符总数。因此我们的搜索目标是最大化这个总字符数。2.2 从规则到图论模型的抽象为什么要把这个问题和图论联系起来因为“接龙”的本质就是在单词之间寻找可行的连接路径。我们可以这样建模顶点Vertex每个单词包括初始单词都可以看作图中的一个顶点。但更精细的建模方式有时会考虑单词的“状态”如使用次数但这会使状态空间爆炸。更常见的简化是将每个单词的实体作为顶点。边Edge如果单词A可以接到单词B后面即满足重叠规则那么就从A向B连一条有向边。边的权重Weight这条边带来的“收益”是多少连接后龙的长度增加了len(B) - overlap_len其中overlap_len是重叠部分的长度。因为重叠部分的字符是共用的只计算一次。于是我们的问题就转化为在一个有向有权图中从一个指定的起始顶点出发寻找一条路径使得路径上所有边的权重之和即总增加长度最大同时满足每个顶点单词的访问次数不超过其限制如最多两次。这是一个典型的带权最长路径搜索问题。由于图可能包含环单词可以重复使用且我们要求的是全局最长路径这本质上是一个NP-Hard问题在多项式时间内没有已知的通用最优解法。但对于竞赛题目数据规模N通常在20以内较小我们可以通过深度优先搜索DFS配合剪枝来暴力求解。2.3 搜索状态的定义在DFS中我们需要定义一个“状态”来记录当前的搜索进度。一个完整的状态通常包括current_word: 当前龙的最后一个单词是什么或其索引。used_count[]: 一个数组记录每个备用单词已经被使用了几次。current_length: 当前龙的总长度。DFS的过程就是从当前状态出发遍历所有可以连接的下一个单词需满足使用次数限制和重叠规则更新状态后递归进入下一层搜索回溯时恢复状态。3. 实现基石高效计算单词重叠长度这是整个算法中最基础、最频繁的操作其效率直接影响程序性能。我们需要一个函数get_overlap_len(wordA, wordB)返回单词A后缀与单词B前缀的最大匹配长度需满足题目要求的最小重叠长度限制。3.1 朴素匹配法及其复杂度最直观的方法是枚举所有可能的重叠长度k从min_overlap到min(lenA, lenB)-1检查A的最后k个字符是否等于B的前k个字符。int get_overlap_naive(const char* A, const char* B, int min_len) { int lenA strlen(A); int lenB strlen(B); // 重叠长度不能超过单词长度-1也不能超过最小长度限制到最大可能长度 int max_possible (lenA lenB) ? lenA : lenB; for (int k max_possible; k min_len; k--) { // 从大到小找最大重叠 // 比较A的后k位和B的前k位 int i lenA - k; int j 0; while (j k A[i] B[j]) { i; j; } if (j k) { return k; // 找到最大重叠 } } return 0; // 没有满足最小长度的重叠 }这种方法每次比较的时间复杂度是O(k)而k最大为单词长度L。在DFS中这个函数会被调用成千上万次最坏情况O(N^2)次如果单词较长比如L100朴素方法可能成为瓶颈。但对于蓝桥杯这类竞赛单词长度通常较短20这种方法完全可行且易于实现。3.2 预处理与优化思路如果追求极致性能或者单词长度真的很大可以考虑预处理。一个常见的优化是“字符串哈希”。我们可以预先计算每个单词所有前缀和后缀的哈希值。判断A的后缀与B的前缀是否相等就变成了比较两个哈希值是否相等时间复杂度可以降到O(1)。不过这增加了代码复杂度和内存开销在竞赛中需要权衡。对于本题规模我个人的建议是先用朴素法实现确保逻辑正确如果超时再考虑引入哈希优化。清晰的逻辑远比微小的性能提升更重要尤其是在比赛时间紧张的情况下。注意在实现重叠判断时一定要仔细处理边界条件。比如题目是否允许一个单词完全包含另一个单词通常规则是重叠部分必须小于两个单词各自的长度即连接后长度要严格增加。你的get_overlap_len函数必须准确体现这一规则。4. 深度优先搜索(DFS)框架与关键实现细节有了重叠判断我们就可以搭建DFS的主框架了。这是整个程序的核心也是最容易写出Bug的地方。4.1 DFS函数的设计一个典型的DFS函数签名可能如下void dfs(int last_word_idx, int current_total_len) { // last_word_idx: 当前路径最后一个单词在单词数组中的索引 // current_total_len: 当前路径形成的龙的总长度 // 使用全局或引用传递的 used_count 数组来记录使用次数 ... }在函数内部我们需要尝试更新答案每次进入一个新的状态都比较一下current_total_len是否超过了历史记录的最大值max_length。枚举下一个单词遍历所有单词i。剪枝条件判断单词i的使用次数是否已达上限如2次单词last_word_idx和单词i是否存在有效重叠overlap get_overlap_len(words[last_word_idx], words[i]) 0状态更新与递归如果条件满足则used_count[i]新的龙长度 current_total_len strlen(words[i]) - overlap递归调用dfs(i, new_length)状态回溯递归返回后务必执行used_count[i]--。这是DFS回溯法的关键忘记回溯会导致状态混乱结果错误。4.2 初始化与启动初始状态是什么题目给定一个起始单词start_word。我们需要把它也纳入考虑。一种常见的处理方式是将起始单词作为一个特殊的“单词”加入我们的单词列表并将其使用次数上限设为1因为龙必须从它开始且它通常只使用一次。或者在第一次DFS调用时特殊处理遍历所有备用单词找出那些可以和起始单词连接的单词作为搜索的第一层。我更喜欢第一种方式它让代码逻辑更统一。我们将start_word放在单词数组的第0位并设置used_count[0]的初始值为1表示已使用然后以last_word_idx0和current_total_lenstrlen(start_word)开始DFS。4.3 一个完整的DFS流程示例假设单词列表为[“at”, “touch”, “cheap”, “choose”, “tact”]起始词为“at”每个词最多用2次。初始调用dfs(0, 2)。last_word_idx0对应“at”。遍历所有单词i1到4i1 (“touch”): 检查“at”和“touch”能否连接“at”的后缀“t”与“touch”的前缀“t”相同重叠长度为1。used_count[1]02满足条件。更新状态used_count[1]1, 新长度2(5-1)6。递归调用dfs(1,6)。在dfs(1,6)中last_word_idx1对应“touch”。继续遍历...可能找到“touch”-“cheap”(重叠‘ch‘? 不’touch‘结尾是’ch‘吗是’ch‘不对’touch‘结尾是’ch‘’touch‘t,o,u,c,h。结尾’ch‘是’ch‘两个字符。‘cheap‘开头是’che‘。重叠’ch‘长度为2)。新长度6(5-2)9。递归...这是一个搜索分支。i2,3,4... 同理进行尝试。DFS会像一棵树一样展开探索所有可能的连接序列并始终用max_length记录遇到的最大总长度。5. 至关重要的优化与剪枝技巧纯暴力的DFS在单词数N稍大时比如N15就会非常慢因为搜索空间是阶乘级别的。我们必须加入有效的剪枝提前砍掉不可能产生更优解的分支。5.1 可行性剪枝这是最直接的剪枝。在决定是否选择单词i作为下一个连接时除了检查使用次数和重叠关系还可以思考即使我后面能用尽所有单词得到的总长度也不可能超过当前历史最优解那我还有必要继续搜下去吗我们可以维护一个“最大潜力值”。例如预处理出所有单词的长度总和total_chars。在DFS过程中当前已使用的单词总字符数是可以计算的。那么剩余未使用或未达使用上限的单词其最大可能贡献的字符数就是剩余单词的长度和。注意连接时重叠部分会损失字符所以这是一个非常宽松的上界。一个更紧的上界很难计算。在竞赛中有时一个简单的if (current_total_len remaining_total_chars max_length) return;就能剪掉大量分支。当然remaining_total_chars需要动态维护随着used_count的变化而更新这增加了状态管理的复杂度。5.2 搜索顺序优化搜索的顺序也能极大影响效率。一个常用的启发式策略是优先尝试更长的单词。因为我们的目标是最大化总长度先加入长单词更有可能快速得到一个较高的max_length从而在后续搜索中形成更强的剪枝条件。 在DFS开始前我们可以对单词列表按照长度进行降序排序。注意排序后单词的索引会变需要同步处理好起始单词的位置和使用次数数组。5.3 避免重复状态与记忆化这是一个高级优化。考虑这种情况当前路径的最后一个单词是W且各个单词的使用次数状态为S。从(W, S)这个状态出发能搜索到的最长龙长度是确定的。如果我们用记忆化数组dp[W][S]把这个结果存下来下次再遇到相同的状态(W, S)时就可以直接返回结果避免重复搜索。 然而状态S是used_count数组它很难直接作为数组的下标是一个高维向量。一种方法是状态压缩如果每个单词最多用2次我们可以用三进制数来表示S每位0,1,2代表使用次数。但这样状态空间是3^N对于N20是34亿显然不可行。因此记忆化在这类问题中通常不适用除非N非常小比如12。所以对于本题更实用的优化组合是排序 可行性剪枝。这已经能应对绝大多数竞赛数据规模。6. 代码实现中的常见“坑”与调试心得理论清晰了动手写代码时还是会遇到各种问题。下面是我在实现和调试这类题目时总结的几个关键点。6.1 重叠长度计算的方向性这是最容易出错的地方。单词接龙是有向的。get_overlap_len(A, B)计算的是A接B的条件即A的后缀匹配B的前缀。在DFS中我们当前单词是last_word要找的是能接在它后面的单词next_word。所以调用必须是get_overlap_len(words[last_idx], words[next_idx])。千万不要搞反方向否则会得到完全错误的可连接关系。6.2 使用次数的记录与回溯这是DFS的经典陷阱。used_count数组必须在递归调用前增加在递归调用后减少。任何在递归分支中修改的全局状态或引用状态都必须回溯。我习惯在递归调用后立刻写回溯代码形成条件反射。used_count[i]; dfs(i, new_len); used_count[i]--; // 回溯另外初始单词的使用次数管理也要小心。如果把它加入单词列表并设used_count[0]1那么在DFS主循环中要记得跳过它或者通过判断used_count[i] limit[i]来自然跳过其中limit[0]1。6.3 最大长度的初始化max_length应该初始化多少很多人会初始化为0。但龙至少包含起始单词所以max_length的初始值应该是起始单词的长度strlen(start_word)。否则如果所有备用单词都无法与起始词连接你的程序会输出0而正确答案应该是起始词本身的长度。6.4 处理“无连接”的情况在DFS中可能存在这样的情况当前单词last_word找不到任何一个满足条件未超限且可连接的后续单词。这时DFS函数会结束所有循环并返回。这代表一条搜索路径的终点。我们的程序必须能够正确处理这种情况它本身就是一种合法的龙无法再延长。所以更新最大长度的操作不能只在找到后续单词时才进行而应该在DFS函数的开头进行。正如前面提到的每次进入DFS都代表我们到达了一个新的状态一条新的龙都需要尝试更新答案。6.5 调试建议从小数据开始当你写完代码结果不对时不要急于用复杂的数据测试。构造最小的测试用例只有一个起始词没有备用词。答案应为起始词长度。起始词 1个备用词且可以连接。手动计算长度与程序输出对比。起始词 2个备用词形成链。检查顺序和长度。包含重复使用单词的情况如果规则允许。 通过这些小数据配合打印详细的DFS路径如打印每次递归进入和离开时的last_word和current_total_len可以快速定位逻辑错误是在状态转移、回溯还是答案更新环节。7. 性能分析与扩展思考对于N个单词每个最多用K次通常K2的“单词接龙”问题最坏情况下搜索树的深度可能达到NK每个节点有大约N个分支。这是一个O((NK)^N)级别的复杂度理论上是不可接受的。但通过前面提到的剪枝尤其是排序和可行性剪枝实际运行中大部分分支会被提前剪掉使得程序能在规定时间如1秒内处理N20左右的数据。这道题的价值在于它是一个非常典型的指数级搜索空间剪枝优化的案例。它训练了我们以下几个能力问题抽象能力将现实游戏规则转化为图论模型和搜索状态。DFS/回溯法的熟练运用这是算法竞赛中最基础的“武器”之一。剪枝设计能力如何利用问题特性求最大值、长度限制来减少不必要的搜索。严谨的代码实现边界条件、状态管理、回溯每一处细节都可能导致错误。如果学有余力可以思考一些变种如果要求输出最长龙本身的内容而不仅仅是长度你需要在状态中额外记录路径或者使用一个全局路径数组在更新答案时同步保存路径。如果重叠规则变化比如要求重叠部分必须是指定长度或者有最大长度限制。如果使用次数无限制这会导致搜索深度无限必须引入其他机制如检测环并结合动态规划。最后关于蓝桥杯的备赛我的个人体会是像ALGO-678这类题目属于“搜索与回溯”专题里的经典题。掌握它不仅仅是掌握一道题而是掌握了一类题的解法框架。在练习时务必亲手编码、调试直到完全通过。理解每一步为什么这样做比单纯地ACAccept一道题更重要。遇到卡壳时回到问题定义和状态设计这两个根本点上往往就能找到突破口。
返回列表