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

资讯详情

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

蓝桥杯国赛题解:最长上升子序列在“游园安排”中的优化与应用

蓝桥杯国赛题解:最长上升子序列在“游园安排”中的优化与应用 1. 问题引入从“游园安排”到最长上升子序列最近在整理蓝桥杯的历年真题翻到了第十一届C B组的国赛题目“游园安排”。这道题挺有意思的它披着一层“活动安排”或“路径规划”的皮但内核其实是一个经典的算法问题——最长上升子序列。很多同学第一次看到题目描述可能会下意识地去想贪心或者动态规划去安排活动结果一上手就发现不对劲。这正是这道题设计的巧妙之处也是国赛题目常见的风格用一个生活化的场景来考察你对基础算法模型深刻理解和灵活应用的能力。简单来说题目给了一串代表游客姓名的字符串序列要求你从中找出一个最长的、按字典序严格递增的子序列。这听起来是不是很像我们熟悉的“最长上升子序列”只不过把数字换成了字符串比较规则从数值大小变成了字典序。但正是这个“换汤不换药”的转变加上国赛对时间、空间复杂度的严苛要求让这道题从一道简单的模板题变成了需要仔细斟酌优化策略的挑战。今天我们就来彻底拆解这道“游园安排”不仅讲清楚怎么做更要讲明白为什么这么做以及如何在考场上快速识别这类“变种”问题并给出最优解。2. 题目核心字典序最长上升子序列的模型抽象首先我们必须抛开“游园”、“游客”这些故事背景直接看到问题的本质。题目输入是一个字符串序列例如WoAiLanQiaoBei。注意这里的每个字符区分大小写代表一位游客我们需要从中选出一个子序列。这个子序列需要满足两个核心条件子序列顺序必须与原序列保持一致但不要求连续。比如从“ABCD”中“ACD”是一个合法的子序列。字典序严格递增对于子序列中相邻的两个字符串在这里是单个字符后一个必须严格大于前一个。在C中字符比较基于ASCII码B Aa Z因为小写字母ASCII码大于大写字母。那么问题就转化为给定一个由字符组成的序列求其最长严格递增子序列的长度并输出该子序列。如果最长序列不唯一则输出字典序最小的那个。这几乎就是LeetCode上“最长递增子序列”问题的字符串版本。但国赛题往往要求输出具体的序列而不仅仅是长度这就增加了难度。最直接的思路是动态规划。2.1 基础动态规划思路与瓶颈定义dp[i]为以第i个字符结尾的最长上升子序列的长度。状态转移方程为dp[i] max(dp[j]) 1其中0 j i且s[j] s[i]。 同时我们需要一个pre[i]数组来记录状态转移的路径即dp[i]是由哪个j转移过来的以便最后回溯构造出序列。这是一个 O(n²) 的算法。对于长度 n 的字符串在极端情况下比如完全递增或完全递减我们需要进行大约 n²/2 次比较。在蓝桥杯的评测环境下如果 n 达到 10^5 甚至更高O(n²) 是绝对会超时的。国赛的数据规模一定会卡这个朴素解法这就要求我们必须找到 O(n log n) 的优化方法。2.2 优化关键贪心二分查找O(n log n) 求解最长上升子序列的标准优化算法其核心在于维护一个“有序数组”low。low[i]的含义是所有长度为 i1 的上升子序列中末尾元素的最小可能值。为什么维护这个数组有效因为对于一个固定的长度末尾元素越小未来接上更大元素的可能性就越大这个子序列“潜力”就越大。我们遍历原序列每个字符s[i]时如果s[i]大于low数组中的所有元素即大于最后一个元素说明它可以接在当前最长的子序列后面形成更长的序列。我们将其追加到low末尾。否则我们在low数组中找到第一个大于等于s[i]的元素并用s[i]替换它。这个查找过程可以用二分查找在 O(log n) 时间内完成。这个算法可以高效地求出最长上升子序列的长度。但是它最初并不能直接给出具体的序列是什么因为low数组在更新过程中被替换的元素可能并不是最终构成最长序列的元素。例如对于序列[2, 5, 3, 4]处理2:low [2]处理5:5 2追加low [2, 5]处理3: 找到low中第一个3的是5替换low [2, 3]处理4:4 3追加low [2, 3, 4]最终长度是3low数组是[2, 3, 4]恰好就是最长序列。但这不是必然的low数组的最终状态不一定是最长上升子序列本身它只保证最后一个元素是正确的。注意这里有一个常见的误解认为low数组就是最终的最长上升子序列。实际上low数组维护的是“每种长度下的最小末尾”它是一个“潜力”数组。在求解具体序列时我们需要额外的记录。为了输出具体序列我们需要在二分查找更新low数组的同时记录更多信息。这是本题实现上的一个关键细节。3. 算法实现记录路径与字典序处理我们需要在 O(n log n) 的算法框架下不仅求出长度还要构造出字典序最小的最长序列。这需要巧妙地记录路径信息。3.1 数据结构设计我们维护以下几个数组low: 向量存储当前维护的“每种长度下的最小末尾字符”。pos: 向量与low一一对应。pos[i]记录low[i]这个字符在原字符串s中的索引位置。pre: 数组长度等于原字符串长度n。pre[i]表示在以原串第i个字符结尾的当前最优子序列中i的前一个字符在原串中的索引。初始化为 -1。这样low和pos是同步更新的它们共同描述了当前找到的“最优潜力子序列链”。3.2 算法步骤详解我们以输入s “WoAiLanQiaoBei”为例手动模拟核心过程。为清晰起见我们暂时忽略大小写先将其视为字符序列[W, o, A, i, L, a, n, Q, i, a, o, B, e, i]。初始化low为空pos为空。遍历第一个字符s[0]‘W’low为空直接插入。low [‘W’],pos [0]。此时以s[0]结尾的子序列就是它自己pre[0] -1。遍历第二个字符 s[1]‘o’ (ASCII 111)比较‘o’和low最后一个元素‘W’ (ASCII 87)。111 87可以接在后面。执行追加low [‘W’, ‘o’],pos [0, 1]。记录路径新增长度2的子序列末尾是s[1]它的前驱是pos[0]即索引0。所以pre[1] 0。遍历第三个字符 s[2]‘A’ (ASCII 65)在low中二分查找第一个 ‘A’的元素。low[0]‘W’ (87) 65所以找到的就是low[0]。执行替换low[0] ‘A’,pos[0] 2。记录路径替换操作意味着我们找到了一个以‘A’结尾的长度为1的子序列它比之前以‘W’结尾的长度为1的子序列“潜力”更大末尾更小。这个子序列就是[‘A’]自己所以pre[2] -1。遍历第四个字符 s[3]‘i’ (ASCII 105)比较‘i’和low最后一个元素‘o’ (111)。105 111不能直接追加。二分查找low中第一个 ‘i’的元素。low [‘A’(65), ‘o’(111)]‘i’(105)比‘o’小比‘A’大所以找到low[1]‘o’。执行替换low[1] ‘i’,pos[1] 3。记录路径这个替换意味着我们找到了一个以‘i’结尾的长度为2的子序列。这个子序列的前一个字符应该是当前长度为1的子序列的末尾即low[0]对应的字符在原串中的位置pos[0]2。所以pre[3] 2。继续此过程...关键点在于每次更新low数组时如何正确设置pre追加操作当s[i]大于low最后一个元素时我们扩展了最长长度。新子序列的末尾是s[i]它的前驱就是前一个长度的子序列的末尾索引即pos[当前low长度-2]。替换操作当我们在low的idx位置替换时我们更新了长度为idx1的子序列的最小末尾。新子序列的末尾是s[i]。如果idx 0它的前驱就是长度为idx的子序列的末尾索引即pos[idx-1]如果idx 0则pre[i] -1。通过这种方式我们为原序列中的每个字符s[i]都记录了它在“当前找到的、以它结尾的、某长度下的最优子序列”中的前驱是谁。3.3 构造最终答案当遍历完所有字符后最长上升子序列的长度len就是low数组的大小。但是low数组的最后一个元素low[len-1]对应的pos[len-1]就是最长上升子序列最后一个字符在原串中的索引。我们称这个索引为cur。那么整个序列就可以通过pre数组向前回溯得到ans_seq [s[cur], s[pre[cur]], s[pre[pre[cur]]], ...]直到前驱为 -1。 注意这样得到的是逆序需要反转一下。但是题目还有一个要求如果存在多个最长序列输出字典序最小的。我们上述方法得到的是哪一个由于我们在维护low数组时总是用更小的字符去替换这本身就倾向于让序列的末尾部分字典序更小。但是这不能保证整个序列的字典序最小。为了保证字典序最小我们需要在回溯时做一个贪心选择。不是简单地从pos[len-1]开始回溯而是找到所有可能作为最长子序列最后一个字符的位置。这些位置i满足以s[i]结尾的最长上升子序列长度等于len。我们需要在遍历时额外记录一个maxLen[i]表示以i结尾的最长上升子序列长度。从后往前扫描原字符串找到第一个满足maxLen[i] len的字符s[i]将其作为回溯的起点cur。因为从后往前找我们找到的是在原串中靠后的、且能构成最长序列的字符。在长度固定的情况下我们希望最后一个字符尽可能小且在原串中位置尽可能靠后这样在选择前驱时空间更大。从后往前扫描可以天然满足“位置靠后”的条件再结合我们维护low数组时“用小的替换大的”策略就能有效地得到字典序最小的序列。确定了终点cur后我们还需要在回溯选择前驱时也采用贪心策略。对于当前字符s[cur]它的前驱pre[cur]可能是在算法过程中某次替换时记录的。为了得到字典序最小的序列在每一步回溯时我们应该选择所有可能的前驱中字符最小且在原串中索引最大的。这通常需要在记录pre时如果发现一个新的、能构成相同长度子序列且末尾字符更小的路径就更新pre。在我们的算法中由于low的替换机制pre[i]记录的就是“以s[i]结尾的、当前找到的长度为maxLen[i]的子序列中字典序最小的那个序列”的前驱。因此直接使用pre数组回溯即可。4. 代码实现与逐行解析理解了算法和路径记录的精髓后我们来看完整的C实现。代码包含了详细的注释解释了每一步的意图。#include iostream #include string #include vector #include algorithm using namespace std; int main() { string s; cin s; int n s.length(); vectorchar low; // low[i]: 长度为i1的LIS的最小末尾字符 vectorint pos; // pos[i]: low[i]这个字符在原串中的索引 vectorint pre(n, -1); // pre[i]: 以s[i]结尾的LIS中i的前一个字符索引 vectorint maxLen(n, 1); // maxLen[i]: 以s[i]结尾的LIS长度 for (int i 0; i n; i) { char c s[i]; // 二分查找 low 中第一个 c 的元素的位置 auto it lower_bound(low.begin(), low.end(), c); int idx it - low.begin(); // 这个位置就是c应该放入low中的位置 if (it low.end()) { // c 比 low 中所有字符都大可以延长LIS low.push_back(c); pos.push_back(i); if (!low.empty() low.size() 1) { // 新增长度前驱是上一个长度的末尾字符索引 pre[i] pos[low.size() - 2]; } else { pre[i] -1; // 第一个元素没有前驱 } } else { // 用 c 替换掉 low[idx] *it c; pos[idx] i; if (idx 0) { // 替换操作前驱是 idx-1 长度的末尾字符索引 pre[i] pos[idx - 1]; } else { pre[i] -1; // 替换的是第一个位置没有前驱 } } // 记录以s[i]结尾的LIS长度 maxLen[i] idx 1; // idx是0-based长度需要1 } int LIS_len low.size(); // 最长上升子序列的长度 // 构造字典序最小的LIS从后往前找第一个能构成最长序列的字符 int cur -1; char minChar 127; // 初始化为一个较大的ASCII值 for (int i n - 1; i 0; --i) { if (maxLen[i] LIS_len) { // 如果s[i]比当前找到的末尾字符更小则更新 // 因为从后往前扫描i更大的位置会被优先考虑这有助于字典序最小 if (cur -1 || s[i] s[cur]) { // 注意这里用 是因为从后往前索引大的优先如果字符相同选后面的 cur i; } } } // 回溯构造序列 string ans; while (cur ! -1) { ans.push_back(s[cur]); cur pre[cur]; } reverse(ans.begin(), ans.end()); // 回溯得到的是逆序需要反转 cout ans endl; return 0; }代码关键点解析lower_bound的使用这是STL提供的二分查找函数在有序区间[low.begin(), low.end())中找到第一个 c的位置。它直接实现了我们算法中的关键步骤且时间复杂度为 O(log n)。pos数组的同步更新low和pos总是同步插入和替换确保pos[idx]始终指向当前low[idx]所代表的字符在原串中的最新也是最优位置。pre数组的赋值逻辑这是全篇最需要理解的地方。追加时 (it low.end())如果low非空且长度大于1pre[i]应指向构成前一个长度序列的末尾索引即pos[low.size()-2]。替换时 (it ! low.end())如果替换的不是第一个位置 (idx 0)pre[i]应指向构成前一个长度 (idx) 序列的末尾索引即pos[idx-1]。maxLen数组的记录maxLen[i] idx 1。idx是c在low数组中的位置索引从0开始这个值恰好就是以s[i]结尾的最长上升子序列的长度。字典序最小化处理在求出LIS_len后我们不是直接用pos.back()作为终点而是从后往前扫描maxLen数组找到第一个即原串中位置最靠后的满足maxLen[i] LIS_len的索引i作为终点cur。这里有一个细节判断条件s[i] s[cur]中的确保了当字符相同时我们选择索引更大的更靠后的那一个这符合字典序最小的要求因为前缀相同位置靠后的字符其后续选择空间可能更大但更重要的是从后往前找本身就是为了固定终点而终点的字符大小是优先比较因素。5. 测试、边界与性能分析任何算法代码都需要经过充分测试尤其是竞赛代码。5.1 测试用例设计我们可以设计以下几类测试用例来验证代码的正确性和鲁棒性基础功能测试输入“abcde”输出“abcde”。测试完全递增序列。输入“edcba”输出“e”或第一个字符。测试完全递减序列。输入“aAbBcC”输出“aBc”。测试大小写混合‘A’(65) ‘a’(97) ‘B’(66) … 注意ASCII顺序。字典序最小测试输入“bacd”。最长上升子序列可以是“acd”或“bcd”长度均为3。字典序最小的是“acd”。我们的算法需要输出“acd”。输入“WoAiLanQiaoBei”。这是题目可能给的样例。我们可以手动推导或编写暴力程序验证。边界与特殊字符测试输入空字符串。题目应保证非空但代码中n s.length()为0时后续循环不会执行low为空LIS_len0回溯部分不会执行ans为空输出空行。这符合预期。输入单个字符如“Z”输出“Z”。输入包含数字、标点如“a1B2c3”。根据ASCII数字‘0’-‘9’在大写字母之前小写字母之后。需要确认算法对任意ASCII字符都有效。性能压力测试构造一个长字符串例如10万个随机字符。使用O(n log n)算法应能在毫秒级完成。而O(n²)算法会超时。5.2 时间复杂度与空间复杂度分析时间复杂度O(n log n)。遍历字符串n次每次遍历中进行一次lower_bound二分查找O(log n)和可能的向量尾部插入O(1) 摊销或替换O(1)。因此总复杂度为 O(n log n)。空间复杂度O(n)。使用了low,pos,pre,maxLen四个向量/数组其大小均与输入字符串长度n线性相关。这个复杂度足以应对蓝桥杯国赛级别的数据规模通常n在 10^5 到 10^6 量级。5.3 常见错误与调试技巧在实现这道题时容易踩的坑有几个混淆“字符”与“字符串”题目中每个“游客”是一个字符序列是字符串。一定要按字符处理不要误以为是单词序列。字典序比较规则C中char的直接比较就是基于ASCII码。要清楚大小写字母的ASCII关系‘A’-‘Z’是65-90‘a’-‘z’是97-122。所以‘a’ ‘Z’是成立的。lower_bound与upper_bound的选择我们需要找到第一个大于等于当前字符c的位置以便进行替换。如果使用upper_bound找第一个大于c的位置对于连续相同的字符行为会不同可能导致错误。例如序列中有多个相同的字符在严格递增子序列中它们不能同时出现。lower_bound能确保我们用当前字符替换掉第一个不小于它的字符从而维持序列的严格递增性。路径回溯的终点选择直接使用pos.back()作为回溯起点在某些情况下得到的可能不是字典序最小的序列。必须进行“从后往前扫描选择终点”的步骤。pre数组初始化务必初始化为-1表示没有前驱。调试技巧对于复杂路径记录的算法可以编写一个小规模的测试用例在关键步骤如每次更新low,pos,pre后打印出这些数组的状态与手动模拟的过程进行比对这是定位逻辑错误最有效的方法。6. 举一反三LIS模型的应用与变种“游园安排”这道题完美地展示了如何将一个实际问题抽象为最长上升子序列模型。掌握这个模型能解决一大类问题。我们来看看几种常见的变种输出所有最长序列如果题目要求输出所有最长上升子序列而不仅仅是字典序最小的一个。我们的算法就不够了。通常需要结合DFS回溯记录所有可能的前驱关系而不仅仅是一个在得到最大长度后从所有可能的终点进行深度优先搜索收集所有路径。这会大大增加时间复杂度但在数据规模较小时可行。求最长不下降子序列将条件从“严格递增”改为“非严格递增”即s[i] s[i1]。此时在二分查找时应将lower_bound改为upper_bound。因为upper_bound找的是第一个大于x的位置替换后low数组中存储的就是“每种长度下末尾元素的最小值”并且允许相等。二维偏序问题例如“信封嵌套问题”LeetCode 354。给定一些信封的宽高如果一个信封的宽和高都大于另一个则可以嵌套。求最多能嵌套多少层。解法是先对宽度排序宽度相同则按高度降序排然后在高度序列上求最长上升子序列。这里的排序技巧是为了将二维问题降为一维。带权值的LIS每个元素有一个权值求权值和最大的上升子序列。此时动态规划dp[i]表示以i结尾的最大权值和转移方程仍是dp[i] max(dp[j]) weight[i]但无法用贪心二分优化到 O(n log n)通常需要数据结构如树状数组来优化。在树上求LIS结合树形DP在树的路径上求最长上升子序列。这需要更复杂的状态设计和转移。对于竞赛选手来说看到“选出一个序列保持原顺序且满足某种单调性递增、递减、特定规则”这类描述要立刻联想到LIS模型。然后分析比较规则是什么数字大小、字符串字典序、结构体特定字段是否需要输出具体方案是否需要字典序最小数据规模是否允许 O(n²)。想清楚这些就能快速套用或修改模板。回到“游园安排”它考察的正是对标准LIS O(n log n) 算法的掌握以及在此基础上如何通过额外的pos和pre数组来记录路径并处理字典序最小的输出要求。这是一道非常经典的、综合性较强的动态规划题目。理解并熟练实现它对于备战蓝桥杯国赛乃至其他算法竞赛都大有裨益。
返回列表