1. 题目背景与问题解析
这道来自COI 2007的编程竞赛题目"Patrik音乐会的等待"(洛谷编号P1823),描述了一个经典的单调栈应用场景。题目大意是:N个人排成一列等待音乐会入场,每个人有特定的身高。我们需要计算在这些排队者中,能够互相看见的两人组合数量。两个人能互相看见的条件是:他们之间没有比两人中任意一个更高的人阻挡视线。
1.1 问题建模
假设队伍中每个人的身高用数组h表示,h[i]表示第i个人的身高。对于任意两个人i和j(i < j),他们能互相看见的条件是:在i和j之间的所有人k(i < k < j)都满足h[k] ≤ min(h[i], h[j])。换句话说,两人之间不能有比他们中较矮者更高的人存在。
这个问题可以转化为:对于数组中的每个元素h[i],我们需要找到它左边和右边第一个比它大的元素的位置,然后统计满足特定条件的对数。这正是单调栈数据结构的典型应用场景。
1.2 输入输出分析
根据洛谷题目描述,输入格式为:
- 第一行:整数N,表示排队人数
- 接下来N行:每行一个整数,表示每个人的身高
输出格式为一个整数,表示能互相看见的两人组合数。
示例输入:
code复制7
2
4
1
2
2
5
1
示例输出:
code复制10
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 单调栈算法原理
2.1 单调栈基本概念
单调栈是一种特殊的栈结构,它保持栈内元素按照某种单调性(递增或递减)排列。在这个问题中,我们需要维护一个单调递减栈,即栈顶元素最小,栈底元素最大。
单调栈的核心思想是:当新元素要入栈时,先弹出所有比它小的元素,这些被弹出的元素就找到了它们右边第一个比它们大的元素(即当前要入栈的元素)。同时,栈内剩下的第一个元素就是当前元素的左边第一个比它大的元素。
2.2 算法流程设计
- 初始化一个空栈和一个结果变量res=0
- 遍历数组中的每个元素h[i]:
a. 当栈不为空且栈顶元素<h[i]时:- 弹出栈顶元素
- res += 1(因为弹出的元素和h[i]可以互相看见)
b. 如果栈不为空: - 如果栈顶元素 == h[i]:
- 我们需要计算栈中有多少个连续相同的h[i]
- res += 相同元素的数量
- 如果栈中还有比h[i]大的元素:
- res += 1(因为最左边的h[i]和当前h[i]也能互相看见)
- 否则:
- res += 1(栈顶元素 > h[i],可以互相看见)
c. 将h[i]压入栈中
- res += 1(栈顶元素 > h[i],可以互相看见)
- 返回res作为最终结果
2.3 时间复杂度分析
每个元素最多入栈一次、出栈一次,因此算法的时间复杂度是O(N),空间复杂度也是O(N)(最坏情况下所有元素都在栈中)。这比暴力解法O(N^2)要高效得多。
3. 算法实现细节
3.1 数据结构选择
我们可以使用C++的stack容器来实现单调栈:
cpp复制#include <stack>
using namespace std;
stack<pair<int, int>> st; // 存储<身高, 连续相同身高的个数>
使用pair来同时存储身高值和连续相同身高的计数,这样可以高效处理重复元素的情况。
3.2 关键代码实现
cpp复制long long solve(vector<int>& h) {
long long res = 0;
stack<pair<int, int>> st;
for (int i = 0; i < h.size(); ++i) {
while (!st.empty() && st.top().first < h[i]) {
res += st.top().second;
st.pop();
}
if (!st.empty()) {
if (st.top().first == h[i]) {
pair<int, int> cur = st.top();
st.pop();
res += cur.second;
if (!st.empty()) {
res += 1;
}
st.push({cur.first, cur.second + 1});
} else {
res += 1;
st.push({h[i], 1});
}
} else {
st.push({h[i], 1});
}
}
return res;
}
3.3 处理重复元素的技巧
当遇到连续相同身高的人时,我们需要特殊处理:
- 合并相同身高的计数,避免重复计算
- 相同身高的人之间可以互相看见
- 相同身高块的两端如果还有更高的人,也需要计入结果
这就是为什么我们使用pair<int, int>来存储身高和计数的原因。每当遇到相同身高时,我们增加计数而不是创建新的栈元素。
4. 边界条件与特殊测试用例
4.1 常见边界情况
-
所有人身高相同:
- 输入:[3,3,3,3]
- 输出:6(即C(4,2)=6)
-
严格递增序列:
- 输入:[1,2,3,4,5]
- 输出:4(每对相邻的人)
-
严格递减序列:
- 输入:[5,4,3,2,1]
- 输出:4(每对相邻的人)
-
单峰序列:
- 输入:[1,2,3,2,1]
- 输出:7
4.2 大数据量测试
当N=5×10^5时,暴力解法显然会超时,而单调栈解法可以在几十毫秒内完成计算。这也是为什么选择正确算法如此重要。
5. 算法优化与变种
5.1 空间优化
我们可以使用数组模拟栈来减少STL容器的开销:
cpp复制pair<int, int> st[MAXN];
int top = 0;
// 入栈操作
st[top++] = {h[i], cnt};
// 出栈操作
--top;
5.2 类似问题扩展
- 下一个更大元素问题(LeetCode 496)
- 柱状图中的最大矩形(LeetCode 84)
- 接雨水问题(LeetCode 42)
这些题目都可以使用单调栈的思想来解决,只是具体应用方式略有不同。
6. 常见错误与调试技巧
6.1 典型错误
- 未处理重复元素导致结果偏小
- 在弹出元素时未正确累加结果
- 数据类型使用int导致溢出(当N很大时,结果可能超过2^31-1)
- 边界条件处理不当(如空栈时的访问)
6.2 调试建议
- 使用小规模测试用例手动模拟算法过程
- 打印栈的状态和结果变量的变化过程
- 特别注意相同身高连续出现的情况
- 检查最终结果是否可能溢出
提示:在竞赛中,遇到这类问题时,先考虑小规模例子手动计算预期结果,再与程序输出对比,可以快速定位问题。
7. 实际应用与总结
虽然这个问题看起来是理论性的,但单调栈的思想在实际开发中有广泛应用:
- 计算广告中的曝光统计
- 用户行为分析中的连续事件检测
- 股票分析中的价格波动区间计算
通过这道题目,我们学习到:
- 单调栈是处理"下一个更大/更小元素"类问题的高效工具
- 合理设计数据结构(如使用pair存储值和计数)可以简化复杂逻辑
- 边界条件和特殊情况的考虑是算法正确性的关键
最后分享一个优化技巧:在竞赛中,如果确定数据范围,使用数组模拟栈通常比STL stack更快,这在处理大数据量时可能带来明显的性能提升。
