1. 题目背景与核心挑战
这道来自POI 2014的题目"Little Bird"属于动态规划与单调队列优化的经典题型。题目描述一只小鸟要跳过n棵树,每棵树i有高度h_i。小鸟每次可以跳到最多k棵树之外(即从i跳到i+1到i+k),且当目标树不比当前树高时消耗1单位体力,否则消耗0体力。我们需要计算小鸟从第1棵树到第n棵树的最小体力消耗。
这个问题的难点在于:
- 直接暴力DP的时间复杂度是O(nk),当n=1e6时会超时
- 需要维护一个滑动窗口内的最优解
- 决策需要考虑两个维度:体力消耗和树高
2. 动态规划基础解法
2.1 状态定义与转移方程
最直观的DP解法是定义dp[i]表示跳到第i棵树的最小体力消耗。转移方程为:
code复制dp[i] = min(dp[j] + (h[j] <= h[i] ? 1 : 0)) for j in [i-k, i-1]
这个转移需要遍历前k个状态,时间复杂度O(nk)。对于n=1e6,k=1e6的情况,这样的复杂度显然无法通过。
2.2 暴力实现代码示例
cpp复制int dp[MAXN];
void solve_naive() {
dp[1] = 0;
for(int i=2; i<=n; i++) {
dp[i] = INF;
for(int j=max(1,i-k); j<i; j++) {
dp[i] = min(dp[i], dp[j] + (h[j]<=h[i]));
}
}
cout << dp[n] << endl;
}
3. 单调队列优化
3.1 优化思路分析
观察转移方程,我们需要在滑动窗口[i-k, i-1]中寻找满足以下条件的最优j:
- dp[j]最小
- 如果有多个dp[j]相同,则选择h[j]最大的(这样h[j]<=h[i]的概率更小)
这提示我们可以维护一个单调队列,其中存储可能成为最优解的候选j,且满足:
- dp值单调递增
- 对于相同dp值,h值单调递减
3.2 单调队列实现细节
具体实现时,队列中存储的是树的编号。我们需要保证:
- 队首元素在有效窗口内(j >= i-k)
- 队列中的dp值单调递增
- 对于相同dp值,h值单调递减
每次处理新元素i时:
- 移除队首超出窗口的元素
- 计算dp[i] = dp[q.front()] + (h[q.front()] <= h[i])
- 从队尾移除所有dp值大于dp[i]的元素
- 从队尾移除所有dp值等于dp[i]但h值小于等于h[i]的元素
- 将i加入队尾
3.3 优化后代码实现
cpp复制int dp[MAXN];
void solve_optimized() {
deque<int> q;
dp[1] = 0;
q.push_back(1);
for(int i=2; i<=n; i++) {
// 移除超出窗口的元素
while(!q.empty() && q.front() < i-k) q.pop_front();
// 计算dp[i]
dp[i] = dp[q.front()] + (h[q.front()] <= h[i]);
// 维护队列单调性
while(!q.empty()) {
int j = q.back();
if(dp[j] > dp[i]) {
q.pop_back();
} else if(dp[j] == dp[i]) {
if(h[j] <= h[i]) {
q.pop_back();
} else {
break;
}
} else {
break;
}
}
q.push_back(i);
}
cout << dp[n] << endl;
}
4. 复杂度分析与正确性证明
4.1 时间复杂度
每个元素最多入队一次、出队一次,因此总时间复杂度为O(n)。相比暴力解法的O(nk),这是一个巨大的优化。
4.2 正确性证明
我们需要证明队列维护的性质保证了总能找到最优解:
- 窗口有效性:通过定期移除队首元素保证
- 最优性:队列中的元素按dp值排序,且相同dp值时h值更大者优先
- 单调性维护:通过步骤3-5保证队列始终满足所需性质
5. 实现细节与注意事项
5.1 边界条件处理
- 初始条件:dp[1] = 0
- 队列初始化:先将第一个元素加入队列
- 窗口范围:注意i-k可能小于1的情况
5.2 常见错误与调试技巧
- 队列比较条件错误:容易混淆dp和h的比较顺序
- 窗口范围错误:忘记检查q.front() >= i-k
- 初始条件遗漏:忘记设置dp[1] = 0
调试时可以:
- 打印队列内容检查单调性
- 对小样例手动模拟队列操作
- 比较暴力解和优化解的结果
5.3 输入输出优化
对于n=1e6的情况,需要优化IO:
cpp复制ios::sync_with_stdio(false);
cin.tie(0);
6. 同类题型与扩展思考
6.1 类似题目推荐
- 滑动窗口最大值(单调队列基础)
- POI2014 Tourism(类似的双单调队列优化)
- LeetCode 1696. Jump Game VI(类似思想)
6.2 扩展思考
- 如果每次跳跃的体力消耗是|h[i]-h[j]|,如何修改解法?
- 如果小鸟可以向左或向右跳,如何调整算法?
- 如果k不是固定值而是每个位置不同,如何解决?
7. 完整AC代码参考
cpp复制#include <bits/stdc++.h>
using namespace std;
const int MAXN = 1e6+5;
int h[MAXN], dp[MAXN];
int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int n;
cin >> n;
for(int i=1; i<=n; i++) cin >> h[i];
int q, k;
cin >> q;
while(q--) {
cin >> k;
deque<int> dq;
dp[1] = 0;
dq.push_back(1);
for(int i=2; i<=n; i++) {
while(!dq.empty() && dq.front() < i-k) dq.pop_front();
dp[i] = dp[dq.front()] + (h[dq.front()] <= h[i]);
while(!dq.empty()) {
int j = dq.back();
if(dp[j] > dp[i]) {
dq.pop_back();
} else if(dp[j] == dp[i]) {
if(h[j] <= h[i]) {
dq.pop_back();
} else {
break;
}
} else {
break;
}
}
dq.push_back(i);
}
cout << dp[n] << '\n';
}
return 0;
}
8. 算法竞赛中的应用技巧
- 识别单调队列适用的场景:滑动窗口最值问题
- 双关键字比较的处理技巧:先比较主要关键字,再比较次要关键字
- 调试单调队列的有效方法:打印队列状态,手动模拟小样例
- 时间复杂度估算:每个元素最多入队出队一次,O(n)复杂度
在实际比赛中遇到类似问题时,可以按照以下步骤思考:
- 先写出暴力DP解法
- 观察转移方程的特点
- 分析是否可以维护某种单调性
- 设计合适的数据结构来优化转移