蓝桥杯国赛题解:最长上升子序列在“游园安排”中的优化与应用
2026/9/23 14:20:49 网站建设 项目流程

1. 问题引入:从“游园安排”到最长上升子序列

最近在整理蓝桥杯的历年真题,翻到了第十一届C++ B组的国赛题目“游园安排”。这道题挺有意思的,它披着一层“活动安排”或“路径规划”的皮,但内核其实是一个经典的算法问题——最长上升子序列。很多同学第一次看到题目描述,可能会下意识地去想贪心或者动态规划去安排活动,结果一上手就发现不对劲。这正是这道题设计的巧妙之处,也是国赛题目常见的风格:用一个生活化的场景,来考察你对基础算法模型深刻理解和灵活应用的能力。

简单来说,题目给了一串代表游客姓名的字符串序列,要求你从中找出一个最长的、按字典序严格递增的子序列。这听起来是不是很像我们熟悉的“最长上升子序列”?只不过把数字换成了字符串,比较规则从数值大小变成了字典序。但正是这个“换汤不换药”的转变,加上国赛对时间、空间复杂度的严苛要求,让这道题从一道简单的模板题,变成了需要仔细斟酌优化策略的挑战。今天,我们就来彻底拆解这道“游园安排”,不仅讲清楚怎么做,更要讲明白为什么这么做,以及如何在考场上快速识别这类“变种”问题并给出最优解。

2. 题目核心:字典序最长上升子序列的模型抽象

首先,我们必须抛开“游园”、“游客”这些故事背景,直接看到问题的本质。题目输入是一个字符串序列,例如:WoAiLanQiaoBei。注意,这里的每个字符(区分大小写)代表一位游客,我们需要从中选出一个子序列。这个子序列需要满足两个核心条件:

  1. 子序列:顺序必须与原序列保持一致,但不要求连续。比如从“ABCD”中,“ACD”是一个合法的子序列。
  2. 字典序严格递增:对于子序列中相邻的两个字符串(在这里是单个字符),后一个必须严格大于前一个。在C++中,字符比较基于ASCII码,'B' > 'A','a' > '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) 求解最长上升子序列的标准优化算法,其核心在于维护一个“有序数组”lowlow[i]的含义是:所有长度为 i+1 的上升子序列中,末尾元素的最小可能值

为什么维护这个数组有效?因为对于一个固定的长度,末尾元素越小,未来接上更大元素的可能性就越大,这个子序列“潜力”就越大。我们遍历原序列每个字符s[i]时:

  1. 如果s[i]大于low数组中的所有元素(即大于最后一个元素),说明它可以接在当前最长的子序列后面,形成更长的序列。我们将其追加到low末尾。
  2. 否则,我们在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]最终长度是3,low数组是[2, 3, 4],恰好就是最长序列。但这不是必然的,low数组的最终状态不一定是最长上升子序列本身,它只保证最后一个元素是正确的。

注意:这里有一个常见的误解,认为low数组就是最终的最长上升子序列。实际上,low数组维护的是“每种长度下的最小末尾”,它是一个“潜力”数组。在求解具体序列时,我们需要额外的记录。

为了输出具体序列,我们需要在二分查找更新low数组的同时,记录更多信息。这是本题实现上的一个关键细节。

3. 算法实现:记录路径与字典序处理

我们需要在 O(n log n) 的算法框架下,不仅求出长度,还要构造出字典序最小的最长序列。这需要巧妙地记录路径信息。

3.1 数据结构设计

我们维护以下几个数组:

  • low: 向量,存储当前维护的“每种长度下的最小末尾字符”。
  • pos: 向量,与low一一对应。pos[i]记录low[i]这个字符在原字符串s中的索引位置
  • pre: 数组,长度等于原字符串长度npre[i]表示:在以原串第i个字符结尾的当前最优子序列中,i的前一个字符在原串中的索引。初始化为 -1。

这样,lowpos是同步更新的,它们共同描述了当前找到的“最优潜力子序列链”。

3.2 算法步骤详解

我们以输入s = “WoAiLanQiaoBei”为例,手动模拟核心过程。为清晰起见,我们暂时忽略大小写,先将其视为字符序列[W, o, A, i, L, a, n, Q, i, a, o, B, e, i]

  1. 初始化low为空,pos为空。
  2. 遍历第一个字符s[0]=‘W’
    • low为空,直接插入。low = [‘W’],pos = [0]
    • 此时,以s[0]结尾的子序列就是它自己,pre[0] = -1
  3. 遍历第二个字符 `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
  4. 遍历第三个字符 `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
  5. 遍历第四个字符 `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
  6. 继续此过程...

关键点在于每次更新low数组时,如何正确设置pre

  • 追加操作:当s[i]大于low最后一个元素时,我们扩展了最长长度。新子序列的末尾是s[i],它的前驱就是前一个长度的子序列的末尾索引,即pos[当前low长度-2]
  • 替换操作:当我们在lowidx位置替换时,我们更新了长度为idx+1的子序列的最小末尾。新子序列的末尾是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]开始回溯,而是:

  1. 找到所有可能作为最长子序列最后一个字符的位置。这些位置i满足:s[i]结尾的最长上升子序列长度等于len。我们需要在遍历时额外记录一个maxLen[i],表示以i结尾的最长上升子序列长度。
  2. 从后往前扫描原字符串,找到第一个满足maxLen[i] == len的字符s[i],将其作为回溯的起点cur。因为从后往前找,我们找到的是在原串中靠后的、且能构成最长序列的字符。在长度固定的情况下,我们希望最后一个字符尽可能小,且在原串中位置尽可能靠后(这样在选择前驱时空间更大)。从后往前扫描可以天然满足“位置靠后”的条件,再结合我们维护low数组时“用小的替换大的”策略,就能有效地得到字典序最小的序列。
  3. 确定了终点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(); vector<char> low; // low[i]: 长度为i+1的LIS的最小末尾字符 vector<int> pos; // pos[i]: low[i]这个字符在原串中的索引 vector<int> pre(n, -1); // pre[i]: 以s[i]结尾的LIS中,i的前一个字符索引 vector<int> 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; }

代码关键点解析:

  1. lower_bound的使用:这是STL提供的二分查找函数,在有序区间[low.begin(), low.end())中找到第一个>= c的位置。它直接实现了我们算法中的关键步骤,且时间复杂度为 O(log n)。
  2. pos数组的同步更新:lowpos总是同步插入和替换,确保pos[idx]始终指向当前low[idx]所代表的字符在原串中的最新(也是最优)位置。
  3. pre数组的赋值逻辑:这是全篇最需要理解的地方。
    • 追加时 (it == low.end()):如果low非空且长度大于1,pre[i]应指向构成前一个长度序列的末尾索引,即pos[low.size()-2]
    • 替换时 (it != low.end()):如果替换的不是第一个位置 (idx > 0),pre[i]应指向构成前一个长度 (idx) 序列的末尾索引,即pos[idx-1]
  4. maxLen数组的记录:maxLen[i] = idx + 1idxclow数组中的位置索引(从0开始),这个值恰好就是以s[i]结尾的最长上升子序列的长度。
  5. 字典序最小化处理:在求出LIS_len后,我们不是直接用pos.back()作为终点,而是从后往前扫描maxLen数组,找到第一个(即原串中位置最靠后的)满足maxLen[i] == LIS_len的索引i作为终点cur。这里有一个细节,判断条件s[i] <= s[cur]中的<=确保了当字符相同时,我们选择索引更大的(更靠后的)那一个,这符合字典序最小的要求(因为前缀相同,位置靠后的字符其后续选择空间可能更大,但更重要的是,从后往前找本身就是为了固定终点,而终点的字符大小是优先比较因素)。

5. 测试、边界与性能分析

任何算法代码都需要经过充分测试,尤其是竞赛代码。

5.1 测试用例设计

我们可以设计以下几类测试用例来验证代码的正确性和鲁棒性:

  1. 基础功能测试
    • 输入:“abcde”,输出:“abcde”。测试完全递增序列。
    • 输入:“edcba”,输出:“e”(或第一个字符)。测试完全递减序列。
    • 输入:“aAbBcC”,输出:“aBc”。测试大小写混合(‘A’(65) < ‘a’(97) < ‘B’(66) … 注意ASCII顺序)。
  2. 字典序最小测试
    • 输入:“bacd”。最长上升子序列可以是“acd”“bcd”,长度均为3。字典序最小的是“acd”。我们的算法需要输出“acd”
    • 输入:“WoAiLanQiaoBei”。这是题目可能给的样例。我们可以手动推导或编写暴力程序验证。
  3. 边界与特殊字符测试
    • 输入:空字符串。题目应保证非空,但代码中n = s.length()为0时,后续循环不会执行,low为空,LIS_len=0,回溯部分不会执行,ans为空,输出空行。这符合预期。
    • 输入:单个字符,如“Z”,输出“Z”
    • 输入包含数字、标点,如“a1B2c3”。根据ASCII,数字(‘0’-‘9’)在大写字母之前,小写字母之后。需要确认算法对任意ASCII字符都有效。
  4. 性能压力测试
    • 构造一个长字符串,例如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 常见错误与调试技巧

在实现这道题时,容易踩的坑有几个:

  1. 混淆“字符”与“字符串”:题目中每个“游客”是一个字符,序列是字符串。一定要按字符处理,不要误以为是单词序列。
  2. 字典序比较规则:C++中char的直接比较就是基于ASCII码。要清楚大小写字母的ASCII关系(‘A’-‘Z’是65-90,‘a’-‘z’是97-122)。所以‘a’ > ‘Z’是成立的。
  3. lower_boundupper_bound的选择:我们需要找到第一个大于等于当前字符c的位置,以便进行替换。如果使用upper_bound(找第一个大于c的位置),对于连续相同的字符,行为会不同,可能导致错误。例如序列中有多个相同的字符,在严格递增子序列中它们不能同时出现。lower_bound能确保我们用当前字符替换掉第一个不小于它的字符,从而维持序列的严格递增性。
  4. 路径回溯的终点选择:直接使用pos.back()作为回溯起点,在某些情况下得到的可能不是字典序最小的序列。必须进行“从后往前扫描选择终点”的步骤。
  5. pre数组初始化:务必初始化为-1,表示没有前驱。

调试技巧:对于复杂路径记录的算法,可以编写一个小规模的测试用例,在关键步骤(如每次更新low,pos,pre后)打印出这些数组的状态,与手动模拟的过程进行比对,这是定位逻辑错误最有效的方法。

6. 举一反三:LIS模型的应用与变种

“游园安排”这道题完美地展示了如何将一个实际问题抽象为最长上升子序列模型。掌握这个模型,能解决一大类问题。我们来看看几种常见的变种:

  1. 输出所有最长序列:如果题目要求输出所有最长上升子序列,而不仅仅是字典序最小的一个。我们的算法就不够了。通常需要结合DFS回溯,记录所有可能的前驱关系(而不仅仅是一个),在得到最大长度后,从所有可能的终点进行深度优先搜索,收集所有路径。这会大大增加时间复杂度,但在数据规模较小时可行。
  2. 求最长不下降子序列:将条件从“严格递增”改为“非严格递增”(即s[i] <= s[i+1])。此时,在二分查找时,应将lower_bound改为upper_bound。因为upper_bound找的是第一个大于x的位置,替换后,low数组中存储的就是“每种长度下末尾元素的最小值”,并且允许相等。
  3. 二维偏序问题:例如“信封嵌套问题”(LeetCode 354)。给定一些信封的宽高,如果一个信封的宽和高都大于另一个,则可以嵌套。求最多能嵌套多少层。解法是先对宽度排序(宽度相同则按高度降序排),然后在高度序列上求最长上升子序列。这里的排序技巧是为了将二维问题降为一维。
  4. 带权值的LIS:每个元素有一个权值,求权值和最大的上升子序列。此时动态规划dp[i]表示以i结尾的最大权值和,转移方程仍是dp[i] = max(dp[j]) + weight[i],但无法用贪心+二分优化到 O(n log n),通常需要数据结构(如树状数组)来优化。
  5. 在树上求LIS:结合树形DP,在树的路径上求最长上升子序列。这需要更复杂的状态设计和转移。

对于竞赛选手来说,看到“选出一个序列,保持原顺序,且满足某种单调性(递增、递减、特定规则)”这类描述,要立刻联想到LIS模型。然后分析比较规则是什么(数字大小、字符串字典序、结构体特定字段),是否需要输出具体方案,是否需要字典序最小,数据规模是否允许 O(n²)。想清楚这些,就能快速套用或修改模板。

回到“游园安排”,它考察的正是对标准LIS O(n log n) 算法的掌握,以及在此基础上,如何通过额外的pospre数组来记录路径,并处理字典序最小的输出要求。这是一道非常经典的、综合性较强的动态规划题目。理解并熟练实现它,对于备战蓝桥杯国赛乃至其他算法竞赛,都大有裨益。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询