ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

最长上升子序列(LIS)算法详解:从动态规划到路径还原与字典序优化

最长上升子序列(LIS)算法详解:从动态规划到路径还原与字典序优化 1. 项目概述从“游园安排”到经典算法模型最近在整理蓝桥杯的历年真题时又看到了“游园安排”这道题。说实话每次看到它都感觉像见到了一位老朋友——外表看起来是道有趣的逻辑题内核却直指算法竞赛中一个极其经典且重要的模型最长上升子序列。这道题之所以能成为经典就是因为它完美地将一个看似复杂的实际问题抽象成了一个可以用标准算法优雅解决的模型。对于正在备赛蓝桥杯尤其是希望在算法组有所突破的同学来说吃透这道题不仅仅是解决一个具体问题更是掌握一种“问题转化”和“模型识别”的核心能力。简单来说“游园安排”题目描述了一个场景有一系列活动或游客需要按某种顺序安排每个活动有一个唯一的标识比如名字并且它们之间存在一种偏序关系可以理解为某种“优先级”或“吸引力”规则。我们的任务是从中选出一个尽可能长的序列使得这个序列在满足给定规则的前提下保持顺序。这听起来是不是很像在排一个最优的游览路线而这个问题经过抽象后其数学本质就是寻找一个序列中满足特定单调性的最长子序列。如果你对“最长上升子序列”还停留在求数值序列长度的层面那么这道题会带你看到它的一个变种基于字符串比较的最长上升子序列并且要求输出具体的序列内容而不仅仅是长度。这无疑增加了难度也更能考察选手对算法本质的理解和灵活应用能力。2. 核心需求与问题抽象2.1 题目场景还原与核心约束我们先来具象化一下题目场景。假设你是一个游园会的策划有一份游客名单名单上的每个游客都有一个独特的名字。现在你需要从这份长长的名单中邀请一部分游客来参加一个特别活动。邀请的规则不是随机的而是基于一条“吸引力规则”被邀请的游客他们的名字必须按照某种顺序排列使得后一位游客的名字“大于”前一位游客的名字。这里的“大于”需要仔细定义。在常见的编程语境下对于字符串我们通常指的是字典序。也就是说对于字符串 A 和 B如果 A 的字典序小于 B那么我们认为 A “小于” B。例如“abc” “abd”“ab” “abc”。因此我们的目标就是从原始游客名单序列中找出一个子序列保持原顺序但不一定连续使得这个子序列中每个字符串的字典序都是严格递增的并且这个子序列要尽可能长。核心约束可以总结为三点输入一个由多个字符串构成的序列。规则寻找一个子序列其元素字符串满足字典序严格递增。目标在所有满足规则的子序列中找到长度最长的那个并输出这个子序列本身按顺序拼接题目通常要求输出一个字符串。2.2 从具体问题到算法模型为什么说它是最长上升子序列问题呢让我们做一个关键的映射。 在经典的最长上升子序列问题中我们有一个数字序列[a1, a2, ..., an]要找一个下标递增的子序列同时其对应的数值也严格递增。 在“游园安排”中我们的序列元素从数字变成了字符串但核心结构完全一致序列游客名单顺序下标i从 1 到 n。比较规则从数值的“小于”()变为字符串的“字典序小于”。目标从“求最大长度”变为“求最大长度并还原序列”。所以解题的骨架就是 LIS 算法。但这里有一个至关重要的细节字典序比较。实现时我们不能直接用数字的比较运算符而必须使用字符串比较函数例如在 C 中是str1 str2在 Java 中是str1.compareTo(str2) 0在 Python 中是str1 str2。这个细节是代码能否正确的关键。注意务必确认题目要求的“上升”是严格递增还是非严格递增。对于字符串字典序严格递增更常见意味着不能有完全相同的字符串。如果名单中有重名需要根据题意特别处理但通常题目会保证名字唯一。2.3 输出序列带来的挑战经典 LIS 动态规划解法可以在 O(n²) 时间复杂度内求出最大长度。如果只求长度我们甚至可以用贪心二分的优化算法达到 O(n log n)。然而“游园安排”要求输出具体的序列内容这带来了两个额外挑战信息记录在计算过程中我们不仅需要知道以每个位置结尾的 LIS 长度还需要记录这个 LIS 是从之前哪个位置“转移”过来的这样才能在最后像链条一样回溯还原出整个序列。字典序最小当存在多个长度相同的最长上升子序列时题目往往会要求输出字典序最小的那个。这是一个更细致的要求。例如对于序列[“a”, “b”, “c”]其本身就是一个 LIS。但如果序列是[“b”, “a”, “c”]最长上升子序列可以是[“b”, “c”]或[“a”, “c”]它们的长度都是2。此时“a”的字典序小于“b”因此[“a”, “c”]的字典序更小。这就要求我们在设计状态转移或回溯策略时要有意识地选择字典序更小的路径。3. 解决方案设计与算法选型面对既要长度又要序列还要考虑字典序最优的需求我们需要一个更强大的武器。单纯的 O(n²) DP 可以记录路径但可能无法方便地处理字典序最小要求。而 O(n log n) 的贪心二分算法虽然高效但其dp数组通常记作d存储的并不是实际的子序列而是“长度为 i 的上升子序列的最小末尾元素”直接用于还原路径比较困难。因此一个综合性强、逻辑清晰的方案是采用动态规划记录所有信息并辅以精心设计的回溯策略。下面我详细拆解这个方案。3.1 动态规划状态定义这是整个解法的基础。我们定义两个核心数组dp[i]表示以第i个字符串下标从0或1开始按习惯来结尾的所有上升子序列中能达到的最大长度。pre[i]这是一个前驱指针数组。pre[i] j表示在形成以i结尾的最长上升子序列时i的前一个元素是j。如果i是序列的第一个元素即dp[i] 1我们可以设pre[i] -1或i自身作为标记。有了dp和pre我们就能完整刻画每一个可能的 LIS 链条。3.2 状态转移方程状态转移的思想是对于当前的第i个元素arr[i]我去看它前面所有位置j (0 j i)的元素arr[j]。 如果arr[j]的字典序小于arr[i]那么arr[i]就可以接在arr[j]结尾的子序列后面形成一个新的、更长的子序列。 此时以i结尾的 LIS 长度至少是dp[j] 1。 我们需要遍历所有满足条件的j找到那个能使dp[i]最大的j并记录下它即pre[i] j。用伪代码表示核心逻辑# 假设 arr 是字符串列表长度为 n dp [1] * n # 每个元素自身至少是一个长度为1的子序列 pre [-1] * n # 初始化前驱为-1表示无前驱 max_len 0 max_len_end_index -1 # 记录最长LIS结尾的下标 for i in range(n): for j in range(i): if arr[j] arr[i] and dp[j] 1 dp[i]: dp[i] dp[j] 1 pre[i] j # 更新全局最大值 if dp[i] max_len: max_len dp[i] max_len_end_index i这段代码的时间复杂度是 O(n²)对于蓝桥杯的题目规模n 通常在 1000 以内通常是可接受的。3.3 处理“字典序最小”的进阶要求如果题目要求多个等长 LIS 时输出字典序最小的上面的代码还需要调整。因为当dp[j] 1 dp[i]时意味着我们找到了另一个同样长度的方案。此时我们需要比较两条路径哪个最终形成的序列字典序更小。比较字典序不能只比较当前元素arr[i]和arr[j]因为我们要的是整个序列的字典序。一个可行的方法是在转移时不仅考虑长度当长度相同时选择能使最终序列字典序更小的前驱。但这需要在回溯时才能确定。一个更实用的技巧是在找到所有结尾位置后不直接取第一个找到的最大长度结尾而是从后往前扫描选择字典序最小的那个结尾作为起点。因为对于长度相同的 LIS其最后一个元素越小由它回溯得到的序列整体字典序可能更小这是一个充分不必要条件但在很多情况下有效且易于实现。更严谨的做法是在回溯过程中如果发现多个前驱选项都能得到相同长度的序列则选择对应元素字典序更小的那条路径。这需要更复杂的数据记录。对于竞赛通常采用“末尾元素最小”的启发式方法就能通过。4. 完整实现与代码解析下面我将给出一个完整的 Python 实现它包含了动态规划、路径记录以及处理字典序最小要求的策略。代码会附上详细注释。def garden_arrangement(names): 解决蓝桥杯“游园安排”问题。 :param names: list[str]游客名字列表。 :return: str字典序最小的最长上升子序列字符串拼接。 n len(names) if n 0: return # dp[i] 表示以 names[i] 结尾的 LIS 长度 dp [1] * n # pre[i] 表示在 LIS 中names[i] 的前一个元素下标。-1 表示它是第一个。 pre [-1] * n # 动态规划填表 for i in range(n): for j in range(i): # 核心判断字典序严格递增 if names[j] names[i]: # 如果找到更长的序列或者长度相同但当前转移能带来字典序更优的潜力通过比较当前元素则更新 # 注意这里简化处理优先更新长度长度相同时暂不处理后续回溯时再处理字典序。 if dp[j] 1 dp[i]: dp[i] dp[j] 1 pre[i] j # 如果需要更精细地处理长度相同的情况可以在这里添加逻辑但会使代码复杂。 # 一个替代方案是先找出所有最大长度的终点再从中选字典序最小的进行回溯。 # 找出最大长度和对应的所有终点下标 max_len max(dp) # 收集所有达到最大长度的位置 end_indices [i for i in range(n) if dp[i] max_len] # 关键步骤当有多个终点时选择哪个回溯 # 策略选择 names[end_index] 字典序最小的那个终点开始回溯。 # 因为最终序列是正序输出末尾元素越小整体字典序倾向于更小。 best_end_index end_indices[0] for idx in end_indices[1:]: if names[idx] names[best_end_index]: best_end_index idx # 回溯构建序列 lis_seq [] current best_end_index while current ! -1: lis_seq.append(names[current]) current pre[current] # 回溯得到的是逆序需要反转 lis_seq.reverse() # 题目要求输出一个字符串通常是直接拼接也可能需要特定分隔符按题意调整 return .join(lis_seq) # 或者用空格、换行连接根据题目要求 # 示例测试 if __name__ __main__: # 示例输入假设 names 是从题目输入中读取的 # test_names [Wo, Ai, Lan, Qiao, Bei] test_names [Lan, Qiao, Bei, Ai, Wo] # 换个顺序测试 result garden_arrangement(test_names) print(f最长邀请序列: {result}) # 对于 [Lan, Qiao, Bei, Ai, Wo]一个最长上升子序列是 [Ai, Wo]长度2 # 但字典序最小的可能是 [Ai, Wo] 还是 [Bei, Wo]需要根据完整规则判断。 # 此代码会从所有长度为2的LIS终点“Wo”开始回溯找到唯一路径。4.1 代码关键点解读双循环结构这是标准 O(n²) DP 的模板。外层循环遍历每个元素作为子序列的结尾内层循环寻找它的“前驱”。前驱数组pre这是还原路径的灵魂。pre[i]j这个简单的记录在回溯时起到了指针的作用让我们能从一个终点“顺藤摸瓜”找到整个序列。终点选择策略end_indices收集了所有 LIS 的终点。选择names[best_end_index]最小的终点进行回溯是我们处理“字典序最小”要求的核心启发式方法。在大多数情况下尤其是当 LIS 长度唯一时这个策略是有效的。如果存在多个同长且复杂的序列可能需要更复杂的比较例如比较整个回溯路径但竞赛题的数据通常不会卡得这么极端。回溯与反转回溯是从终点向起点走得到的是逆序序列所以最后需要reverse()。while current ! -1这个循环条件巧妙地利用了pre数组初始化为-1的设定。5. 算法优化探讨与性能分析虽然 O(n²) 的 DP 对于蓝桥杯的常规数据规模n 1000已经足够但了解更优的算法总是有益的。这里简要提一下 O(n log n) 的贪心二分算法并讨论它为何不直接适用于本题。5.1 O(n log n) 算法思想该算法的核心是维护一个数组dd[len]表示长度为 len 的上升子序列中末尾元素的最小值。这个数组本身是单调递增的因为长度更长的序列其末尾元素不可能比长度短的小。 遍历原序列每个元素x如果x大于d的最后一个元素说明可以接在后面形成更长的序列直接追加。否则在d数组中二分查找第一个大于等于x的位置用x替换掉那个位置的元素。这个操作的含义是找到了一个结尾元素更小的、相同长度的子序列。最终d数组的长度就是 LIS 的长度。这个算法非常巧妙将问题复杂度降到了 O(n log n)。5.2 为何难以直接用于还原序列因为d数组存储的只是“最小末尾元素”它丢失了原始的顺序信息。d[i]的值可能来自原序列中很靠后的一个元素我们无法从d数组直接得知这个长度为 i 的子序列前面 i-1 个元素是什么。虽然有一些方法可以结合额外的数组来记录路径例如记录每个元素在d数组中被放入的位置pos然后从后往前找但其逻辑比 DP 的pre数组要绕一些尤其是在处理字典序最小要求时会更复杂。结论对于“游园安排”这类要求输出具体序列的题目O(n²) DP 前驱数组的方案在思维清晰度和代码可实现性上往往是更优的选择。除非题目数据规模非常大例如 n 5000否则不必追求 O(n log n) 的优化。在竞赛中清晰的思路和正确的实现比微小的常数优化更重要。6. 常见错误与调试技巧在实际编写和调试这道题时我踩过不少坑也见很多同学犯过类似的错误。这里总结一下比较规则错误这是最常见的错误。误用了数值比较或者忽略了字典序的严格递增要求。务必使用字符串比较运算符。错误示例if names[j] names[i]:使用了非严格递增错误示例试图将字符串转换为数字再比较除非题目明确说明名字是数字字符串且按数值比否则一定是字典序。初始化错误dp数组每个位置至少为1自身pre数组初始化为无效值如 -1。忘记初始化会导致结果错误。回溯逻辑错误终点找错只记录了最大长度max_len但忘记记录或找错对应的下标max_len_end_index。当有多个相同最大长度时需要妥善选择。反转忘记回溯得到逆序后忘记reverse()导致输出顺序颠倒。拼接错误题目要求输出一个字符串如果序列是列表需要用‘’.join()或循环输出。注意输出格式是否有空格、换行。字典序最小处理不当简单地取第一个找到的最大长度终点可能在多解时得不到字典序最小的序列。采用前面提到的“选择最小末尾元素终点”策略可以规避大部分问题。调试技巧小数据测试用极简单的例子手动验证例如[“a”, “b”, “c”]应输出“abc”[“c”, “b”, “a”]应输出“a”或“b”或“c”单个字符。打印中间变量在循环中打印dp和pre数组观察它们的变化是否符合预期。这是理解 DP 过程最直观的方法。构造多解案例例如[“b”, “a”, “c”]最长上升子序列有[“b”, “c”]和[“a”, “c”]。检查你的程序是否输出了字典序更小的“ac”。边界测试输入空列表、单元素列表检查程序是否健壮。7. 总结与举一反三“游园安排”这道题的价值远远超过其本身。它提供了一个绝佳的范本展示了如何将“最长上升子序列”这一基础算法模型应用于解决实际问题。通过这道题你应该掌握问题抽象能力识别出问题背后的 LIS 模型是解题的第一步。动态规划实现熟练编写 O(n²) 的 LIS DP 代码包括状态定义、转移方程和初始化。路径还原技巧通过pre前驱数组回溯构建解这是很多 DP 要求输出具体方案时的通用方法。处理多解最优当存在多个最优解时如多个等长 LIS如何根据附加条件如字典序最小选择其一。这里的启发式策略选最小末尾是一种值得学习的思路。举一反三变体1数值序列输出路径如果题目给的是数字序列要求输出字典序最小数值和最小的 LIS方法完全一样只需将字符串比较改为数字比较。变体2二维偏序问题有时题目元素是(x, y)对要求 x 和 y 都递增或一个递增一个递减这就变成了二维偏序可能需要先排序一维再在另一维上做 LIS。变体3最大不上升/下降子序列比较规则从‘‘改为‘‘或‘‘算法框架不变。最后我个人在刷这类题时的一个习惯是永远先想清楚dp[i]数组的确切含义和前驱数组pre如何更新这比直接套模板更能加深理解。当你能把“游园安排”的代码闭着眼睛写出来并且能清晰解释每一行为什么这么写时你对 LIS 的理解就真正到位了。这道题就像一块试金石检验着你是否掌握了动态规划中“状态”与“转移”的精髓。
返回列表