蓝桥杯国赛真题解析:数组与字符串解法在单调栈问题中的性能博弈
2026/8/27 5:00:29 网站建设 项目流程

1. 项目概述:从一道国赛真题看算法思维的深度

去年蓝桥杯国赛结束后,这道关于“最大的数”的题目在算法圈子里引起了不小的讨论。乍一看,题目描述似乎并不复杂,但真正动手实现时,很多朋友(包括当时的我)都踩进了各种“坑”里。这道题的核心,远不止是找出一个最大数字那么简单,它更像是一个精巧的思维实验,逼迫你在数组和字符串这两种最基础的数据结构之间做出选择,并深刻理解其背后的性能逻辑与边界条件。今天,我就结合自己当时参赛和后续复盘的经验,把数组解法和字符串解法掰开揉碎了讲清楚,特别是那些官方题解里可能不会提,但实际编码时能让你调试到头疼的细节。

这道题适合所有正在备战蓝桥杯、AcWing、LeetCode等算法竞赛的同学,尤其是对贪心、排序、字符串处理这些基础算法感觉“会了但又不完全会”的朋友。通过这道题,你能彻底明白为什么有时候用数组快如闪电,有时候却非得用字符串才能解决问题,以及如何根据数据规模和操作类型,在两者间做出最明智的选择。我们不止步于AC(通过),更要追求优雅和高效。

2. 题目核心需求与场景深度解析

2.1 问题重述与抽象建模

首先,我们得把题目从自然语言翻译成程序员能理解的语言。原题大意是:给定一个由数字字符组成的字符串num(例如 “12345264”),和一个整数k。要求你从这个字符串中删除k位数字,使得剩下的数字按原始顺序拼接起来后,形成的数字最大。

这里有几个至关重要的约束和隐含条件,直接决定了我们的解法方向:

  1. 顺序不变:你不能打乱剩余数字的原始顺序。这是最关键的一点,它排除了“先排序再拼接”这种偷懒的想法。你必须在一个线性序列上做决策。
  2. 删除操作:移除k个字符。这等价于从n位数字中保留n-k位。
  3. 目标最大化:在所有的保留方案中,找到数值最大的那个。对于数字,高位(左边)的数字权重大得多,所以我们的核心策略必然是:尽可能让高位的数字大

这立刻让我们联想到一个经典的算法范式:单调栈。但具体是用数组模拟栈,还是直接用字符串API?这就是分歧的开始,也是性能差异的根源。

2.2 两种解法的根本分歧点

为什么会有数组和字符串两种主流解法?根源在于对“删除”这一操作的成本考量。

  • 字符串解法:直观,易于理解。我们可以将字符串视为一个字符列表,在遍历过程中,如果发现当前字符比已保留的最后一个字符大,且还有删除次数,就弹出(删除)最后一个字符。在C++中,std::string提供了pop_back()push_back()操作,模拟栈的行为非常方便。它的优势是代码简洁,贴近人的思维。但劣势是,pop_back()push_back()虽然通常是O(1),但在某些底层实现或频繁操作下,可能涉及内存的局部调整。更重要的是,字符串的拼接、截取(如substr)操作是O(n)的,如果在算法中不慎使用了这些操作,复杂度会急剧上升。

  • 数组解法:更底层,性能通常更稳定。我们使用一个定长数组(如char stack[100005])和一个栈顶指针top来手动模拟栈的所有行为。stack[++top] = num[i]表示入栈,top--表示出栈。所有操作都是对固定内存的赋值和指针移动,是纯粹的O(1)操作,没有任何隐藏开销。这种解法的优势是极致的速度和可控的内存,特别适合对性能要求苛刻、输入规模巨大的竞赛场景。劣势是代码需要手动管理“栈”,对于初学者来说抽象层次稍高,且要小心数组越界。

选择哪种解法,取决于你对性能的追求和对代码可控性的要求。在蓝桥杯等竞赛中,数据规模往往是设计好的,两种方法通常都能AC。但理解数组解法的优越性,是向高手进阶的必经之路。

3. 贪心策略与单调栈的深度结合

3.1 贪心决策的局部最优性证明

我们的算法核心是贪心:遍历数字字符串,维护一个单调不增的序列(从栈底到栈顶)。为什么是“单调不增”而不是“单调递增”?因为我们要找最大的数。

假设我们当前已经维护了一个序列(栈),其最后一个数字是last,当前遍历到的数字是curr

  • 如果curr > last,并且我们还有删除次数(k > 0),那么删除lastcurr提前,必然能使最终数字变大。因为curr占据了last原本的高位,而curr更大。
  • 我们不断地进行这个操作,直到栈为空、栈顶元素不小于curr、或者删除次数用尽。

这个过程保证了在每一步,我们都为了当前能看到的高位数字尽可能大而行动。这是一个经典的“移除K位数字使剩余数字最大/最小”问题的贪心策略,其正确性可以通过反证法证明:如果某一步不这样做,那么得到的结果一定不会比这样做更优。

3.2 算法流程的精细化步骤拆解

让我们抛开代码,用大脑模拟一遍算法流程,以num = “1432219“,k = 3为例,目标是保留7-3=4位数字。

  1. 初始化:一个空栈(数组或字符串),剩余删除次数k = 3
  2. 遍历num:
    • i=0, ‘1‘: 栈空,直接入栈。栈:[‘1‘],k=3
    • i=1, ‘4‘:‘4‘ > ‘1‘(栈顶),且k>0,弹出‘1‘k减为2。栈空,‘4‘入栈。栈:[‘4‘],k=2(这一步是关键,为了高位的‘4‘舍弃了‘1‘)
    • i=2, ‘3‘:‘3‘ < ‘4‘(栈顶),直接入栈。栈:[‘4‘, ‘3‘],k=2
    • i=3, ‘2‘:‘2‘ < ‘3‘(栈顶),直接入栈。栈:[‘4‘, ‘3‘, ‘2‘],k=2
    • i=4, ‘2‘:‘2‘ == ‘2‘(栈顶),根据单调不增规则,可以入栈。栈:[‘4‘, ‘3‘, ‘2‘, ‘2‘],k=2
    • i=5, ‘1‘:‘1‘ < ‘2‘(栈顶),直接入栈。栈:[‘4‘, ‘3‘, ‘2‘, ‘2‘, ‘1‘],k=2(此时栈长度5,已超过目标4)
    • i=6, ‘9‘:‘9‘ > ‘1‘(栈顶),且k>0,弹出‘1‘k减为1。继续比较,‘9‘ > ‘2‘,且k>0,弹出‘2‘k减为0。此时k=0或栈顶‘2‘(现在是第二个‘2‘) 不大于‘9‘?不,‘9‘ > ‘2‘,但k已为0,停止弹出。‘9‘入栈。栈:[‘4‘, ‘3‘, ‘2‘, ‘9‘],k=0
  3. 遍历结束:此时栈内元素为“4329“,长度恰好为4,k=0。结果就是“4329“

如果遍历结束后k > 0(例如原数字是单调递增的“12345“,k=2),那么我们需要从栈顶(数字尾部)再删除k位,因为尾部数字最小。这就是贪心算法的后续处理。

注意:这个例子清晰地展示了贪心过程。但这里有一个非常重要的边界情况:前导零。如果输入是“10200“,k=1,贪心结果是“200“。但如果结果是“0200“,我们需要去掉前导零,返回“200“。如果结果全是零,应返回“0“。这是很多解法容易遗漏的。

4. 数组解法实现与极致优化

4.1 手写栈的数据结构与操作

数组解法的精髓在于完全掌控数据流动。我们首先定义数据结构:

char stack[100010]; // 假设输入字符串长度不超过100000 int top = -1; // 栈顶指针,-1表示空栈

操作对应关系:

  • push(x):stack[++top] = x;
  • pop():top--;(逻辑删除)
  • peek():stack[top](需判断top >= 0)
  • is_empty():top == -1

在算法中,我们不会真的写这些函数,而是直接内联这些操作,以获得最高效率。

4.2 完整C/C++代码实现与逐行分析

下面给出一个充分考虑边界情况的C++数组解法:

#include <iostream> #include <string> using namespace std; string removeKdigits(string num, int k) { int n = num.size(); // 特判:如果要删除所有数字,直接返回"0" if (k >= n) return "0"; char stack[100010]; int top = -1; for (int i = 0; i < n; ++i) { char curr = num[i]; // 当栈非空、还有删除次数、且当前数字大于栈顶数字时,弹出栈顶 while (top >= 0 && k > 0 && curr > stack[top]) { top--; // 模拟pop k--; } // 当前数字入栈 stack[++top] = curr; // 模拟push } // 处理遍历完成后剩余的删除次数(例如原数字是单调递增的) // 此时栈内是单调不增的,直接从末尾删除k位即可 top -= k; // 逻辑上删除栈顶的k个元素 int newLen = top + 1; // 处理前导零:找到第一个非零字符的位置 int startIdx = 0; while (startIdx < newLen && stack[startIdx] == '0') { startIdx++; } // 构造结果字符串 if (startIdx == newLen) { // 如果全是零 return "0"; } // 使用string的构造函数,从stack数组的指定位置复制指定长度的字符 return string(stack + startIdx, stack + newLen); } int main() { string num; int k; // 示例输入 // num = "1432219"; // k = 3; cin >> num >> k; cout << removeKdigits(num, k) << endl; return 0; }

关键点分析:

  1. while循环条件top >= 0 && k > 0 && curr > stack[top]。这三个条件缺一不可。必须先判断栈非空才能访问stack[top],这是防止数组越界的生命线。
  2. top -= k:这是处理剩余删除次数的优雅方式。因为此时栈内是单调不增的,末尾的数字最小,直接移动栈顶指针“丢弃”它们即可,无需实际删除。
  3. 前导零处理while (startIdx < newLen && stack[startIdx] == '0')。这个循环必须放在截取子串之前。如果放在构造字符串之后再用erase,会多一次O(n)遍历。
  4. 结果构造return string(stack + startIdx, stack + newLen);。这是C++std::string构造函数的一种形式,它接受两个指针(迭代器),直接复制这个字符区间到新的字符串中。这比用循环+=拼接要高效得多。

4.3 数组解法的性能优势与内存布局

为什么数组解法快?我们对比一下CPU和内存的工作:

  • 字符串解法push_backpop_back可能触发字符串内部缓冲区的重新分配(虽然pop_back通常不会,但push_back在容量不足时会)。此外,字符串对象本身有大小、容量、指针等成员变量,操作时有少许开销。
  • 数组解法:所有操作都是对预先分配好的连续内存进行赋值和整数(指针)的加减。CPU的缓存预取机制对这种连续访问模式非常友好,几乎没有任何额外开销。在算法竞赛中,当输入规模达到10^5甚至10^6级别时,这种差异可能会从毫秒级放大到几十甚至上百毫秒,成为能否AC的关键。

5. 字符串解法实现与细节陷阱

5.1 利用std::string模拟栈

字符串解法更符合直觉,我们直接把结果字符串res当作栈来用。

string removeKdigits(string num, int k) { string res; // 作为栈使用 for (char digit : num) { while (!res.empty() && k > 0 && digit > res.back()) { res.pop_back(); k--; } res.push_back(digit); } // 处理剩余的k while (k-- > 0 && !res.empty()) { res.pop_back(); } // 处理前导零 int startIdx = 0; while (startIdx < res.size() && res[startIdx] == '0') { startIdx++; } // 获取最终结果,如果全零则返回"0" string ans = (startIdx == res.size()) ? "0" : res.substr(startIdx); return ans; }

这段代码非常简洁,但其中隐藏着一个性能陷阱res.substr(startIdx)。 在C++中,std::string::substr通常返回一个新字符串,这个操作的时间复杂度是O(n),其中n是子串的长度。在我们这个场景下,它意味着我们需要额外复制一遍结果字符串。虽然对于竞赛数据规模通常可以接受,但这是一种不必要的开销。一个优化方法是直接修改原字符串并返回:

// ... 前导零处理 ... if (startIdx == res.size()) return "0"; res.erase(0, startIdx); // 删除前导零部分 return res;

erase从头部删除也可能导致元素移动(O(n)复杂度)。更高效的做法是像数组解法一样,使用迭代器构造新字符串,或者直接返回res.substr(startIdx)并接受其开销,因为代码清晰度更重要,且竞赛中通常不会因此超时。

5.2 两种解法的对比与选型建议

特性数组解法字符串解法
性能极高。纯数组操作,内存连续,CPU缓存友好。。依赖std::string实现,pop_back/push_back摊销O(1),但可能有微小开销。
代码复杂度中等。需要手动管理栈顶指针和边界。。代码直观简洁,更易读写。
内存控制精确。栈大小固定,无动态分配开销(已知最大长度)。自动。由std::string管理,可能有预留空间。
防错性较低。需程序员自己保证不越界。较高。std::stringback()pop_back()在空时调用是未定义行为,但empty()检查简单。
适用场景极致性能追求,超大输入规模,嵌入式等受限环境。快速原型开发,代码可读性优先,一般竞赛和面试。

个人建议

  • 对于蓝桥杯等竞赛:如果你对数组操作熟练,追求极致的运行速度和心理上的“稳”,强烈推荐数组解法。它让你对程序的每个细节都了然于胸。
  • 对于日常学习或面试:字符串解法足矣。它清晰地表达了算法逻辑,面试官更容易理解。你可以主动提及“这里用字符串模拟栈,当然也可以用数组手动模拟栈来避免动态容器的微小开销”,这能展示你的深度。

6. 常见错误与调试心得实录

这道题我见过太多人栽在奇怪的错误上,下面是我总结的“避坑指南”。

6.1 边界条件处理不全

  1. 删除次数k用不完或不够用

    • 问题:循环结束后k > 0。例如num=”12345″, k=2,遍历时因为数字递增,不会进入while循环弹出,最后栈是”12345″k还是2。
    • 解决:必须在遍历结束后显式处理剩余的k。对于数组/字符串解法,都是从末尾删除(因为此时序列是单调不增的,尾部最小)。
    • 错误示例while(k--) res.pop_back();如果k很大,可能pop空栈导致错误。必须加条件:while(k>0 && !res.empty()) { res.pop_back(); k--; }
  2. 前导零的幽灵

    • 问题:结果可能是”0200″”000″。直接返回就不符合数字表示习惯。
    • 解决:在返回结果前,必须循环移除开头的所有 ‘0’。如果移除后字符串为空,则返回”0″
    • 易错点:这个处理必须放在删除完k位数字之后。不能先处理前导零再删除,逻辑就乱了。
  3. 全部删除的情况

    • 问题:如果k >= num.length(),按照题意应该返回”0″
    • 解决:在函数开头进行特判。这是一个很好的习惯,能避免后续复杂逻辑中的潜在错误。

6.2 贪心策略理解偏差

  1. 误用单调递增栈:题目是求最大数,所以应该维护一个单调不增栈(允许相等),在遇到更大的数字时弹出栈顶。如果要求最小数(如LeetCode 402题),则维护单调不减栈,在遇到更小的数字时弹出。方向千万别搞反。

  2. 比较条件写错while循环里的条件是curr > stack[top](求最大)。我曾见过有人写成curr >= stack[top],这在有重复数字时会导致过度删除,可能得不到最优解。例如”332″, k=1,用>=可能会得到”32″,而最优解是”33″。所以对于求最大数,只有严格大于时才弹出。

6.3 数据结构操作失误

  1. 数组越界:在数组解法中,访问stack[top]前务必检查top >= 0while (k>0 && curr > stack[top] && top>=0)这个顺序是错误的,因为如果top=-1,会先访问stack[-1]导致未定义行为。正确的顺序是while (top>=0 && k>0 && curr > stack[top]),利用逻辑运算符的短路特性。

  2. 字符串空栈判断:在字符串解法中,调用res.back()res.pop_back()之前,必须用!res.empty()判断。虽然竞赛时输入可能不会触发,但这是良好的编程习惯,能避免运行时崩溃。

6.4 调试技巧与测试用例设计

自己调试时,不要只用题目给的例子。构造以下几类极端测试用例,能帮你快速找到bug:

// 1. 常规测试 assert(removeKdigits("1432219", 3) == "4329"); // 2. 删除所有数字 assert(removeKdigits("123", 3) == "0"); assert(removeKdigits("123", 5) == "0"); // k>n // 3. 前导零处理 assert(removeKdigits("10200", 1) == "200"); assert(removeKdigits("10000", 2) == "0"); // 注意,删除后是"000",应返回"0" // 4. 单调递增/递减序列 assert(removeKdigits("12345", 2) == "345"); // 递增,从末尾删 assert(removeKdigits("54321", 2) == "543"); // 递减,开头的就是最大的 // 5. 相等数字序列 assert(removeKdigits("11111", 2) == "111"); // 不会触发弹出,从末尾删 // 6. 大数测试 (心理测试,确保不超时) // string bigNum(100000, '9'); bigNum[50000] = '1'; // assert(removeKdigits(bigNum, 50000).size() == 50000);

在调试时,可以在循环中打印出每一步操作后的栈状态和k值,这是理解贪心过程最直观的方式。对于数组解法,打印stack[0..top];对于字符串解法,直接打印res

7. 从本题延伸的算法思维与优化

这道题虽然解完了,但它的价值远不止于此。它为我们打开了几个重要的算法思维窗口:

7.1 单调栈问题的识别模式

当你遇到一个问题,需要在线性序列中,通过移除选择部分元素,使得剩下的序列满足某种极值条件(最大、最小、字典序等),并且元素的相对顺序不能改变时,就应该立刻想到单调栈。这是单调栈的经典应用场景。除了本题,还有:

  • 下一个更大元素(LeetCode 496, 503)
  • 柱状图中最大的矩形(LeetCode 84)
  • 接雨水(LeetCode 42)
  • 去除重复字母使字典序最小(LeetCode 316)

识别出模式,就能快速套用解题框架。

7.2 空间换时间的权衡艺术

数组解法本质上是一种“空间换时间”和“控制换效率”的权衡。我们预先分配了足够大的静态数组,避免了动态内存分配的开销。在算法竞赛中,这是一种非常实用的技巧。当你知道输入数据的最大规模时(比如题目说明n ≤ 10^5),直接开一个int arr[100005]往往比vector<int> arr更快更安全(避免push_back的扩容)。但这要求你对问题规模有清晰的把握,防止开得过大浪费内存,或者开小了导致越界。

7.3 对“最大数”定义的再思考

我们通常认为数字比较大小时,就是简单的逐位比较。但在这个问题里,“最大数”是在删除固定位数后形成的。这引出了一个更深层的问题:如果操作不是删除,而是交换相邻数字(如 LeetCode 670. 最大交换),或者可以任意重排,策略就完全不同了。这提醒我们,在解题时,必须严格绑定题目给出的操作约束,任何脱离约束的“想当然”优化都可能导致错误。

最后,关于代码风格,我个人在竞赛中更偏爱数组解法,因为它给我一种“一切尽在掌握”的感觉。但在给团队写工程代码或者做算法讲解时,我会优先使用字符串解法,因为它的意图更清晰。两种解法都熟练掌握,才能在不同的场景下游刃有余。这道题的价值,就在于它用一个小切口,引出了数据结构选择、算法策略、边界处理和性能优化等多个维度的思考,值得反复品味。

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

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

立即咨询