【算法导论】XM 0823 笔试题解

题目版权归考试主办方所有!!!本文仅做非盈利性质的交流分享!!!

简易版俄罗斯方块

欢迎来到这个简易版俄罗斯方块的游戏。

本游戏为玩家提供一个高度无限,宽度为n的界面。并且在游戏开始时,在界面底部已经存在n列不可破坏的矩形。每一列矩形的宽度为1,高度为a[i]。 游戏开始后,会有m个矩形依次贴着界面最左侧从无限高处下落。对于任意一个矩形i,它的宽度w[i]和高h[i]是已知的。 和传统的俄罗斯方块游戏一样,一旦下落的矩形方块碰到任意已经停在底部的矩形,就会立即停止下落。 但是,任何新下落的矩形都不会导致已有的停在底部的矩形消失或发生任何变化,也就是说它们只会不停地堆叠起来。

请你打印游戏过程中每个下落的矩形在停止下落时,它的底部所处的高度是多少?

输入输出

输入说明

第一行输入一个正整数n,表示游戏界面的宽度。

接下来n个非负整数,表示游戏界面中每列底部已有矩形的高度。

接下来输入一个整数m,表示游戏过程中下落的矩形数目。

接下来m行,每一行都有两个整数w和h,表示游戏过程中下落矩形的宽度和高度。

输出说明

输出共m行。第i行输出表示第i个矩形在下落停止后,其底部所处的高度

测试用例

输入:

复制代码
5
1 2 3 6 6
4
1 1
3 1
1 1
4 3

输出:

复制代码
1
3
4
6

暴力解法

能通过70%以上的数据,最终会超时

c++ 复制代码
#include <bits/stdc++.h>

int main() {
    std::ios_base::sync_with_stdio(false);
    std::cin::tie(NULL);

    int64_t n;  // 游戏界面宽度
    std::cin >> n;
    
    std::vector<int64_t> a(n);
    for (int64_t i = 0; i < n; ++i) {
        std::cin >> a[i];
    }
    
    int64_t m;  // 下落方块数
    std::cin >> m;
    while (m--) {
        int64_t width, height;
        std::cin >> width >> height;
        // 从左向右遍历,找到最高的柱子
        int64_t max_height = 0;
        for (int64_t pos = 0; pos < width; ++pos) {
            max_height = std::max(max_height, a[pos]);
        }
        std::cout << max_height << std::endl;
        // 更新柱子高度
        int64_t new_height = max_height + height;
        for (int64_t pos = 0; pos < width; ++pos) {
            a[pos] = new_height;
        }
    }
}

最优解法

c++ 复制代码
#include <iostream>
#include <vector>
#include <algorithm>

// 为了方便,我们定义一个足够大的数组来存储线段树
// N 可以根据题目的n的最大值来设定,一般是4倍大小
const int MAXN = 100005; 
long long max_val[MAXN * 4]; // 存储每个区间的最大值
long long lazy_tag[MAXN * 4]; // 懒惰标记

// 向下传递懒惰标记
void push_down(int node, int l, int r) {
    if (lazy_tag[node] != 0 && l != r) {
        // 将父节点的懒惰标记传递给子节点
        // 子节点的值和懒惰标记都被更新为父节点的标记值
        int left_child = node * 2;
        int right_child = node * 2 + 1;
        
        lazy_tag[left_child] = lazy_tag[node];
        max_val[left_child] = lazy_tag[node];
        
        lazy_tag[right_child] = lazy_tag[node];
        max_val[right_child] = lazy_tag[node];
        
        // 清除父节点的懒惰标记
        lazy_tag[node] = 0;
    }
}

// 向上更新节点信息
void push_up(int node) {
    max_val[node] = std::max(max_val[node * 2], max_val[node * 2 + 1]);
}

// 构建线段树
void build(int node, int l, int r, const std::vector<long long>& a) {
    if (l == r) {
        max_val[node] = a[l - 1]; // 注意数组a是0-indexed, 线段树是1-indexed
        return;
    }
    int mid = (l + r) / 2;
    build(node * 2, l, mid, a);
    build(node * 2 + 1, mid + 1, r, a);
    push_up(node);
}

// 区间更新: 将[update_l, update_r]区间的值都设置为val
void update(int node, int l, int r, int update_l, int update_r, long long val) {
    if (update_l <= l && r <= update_r) {
        // 当前节点代表的区间完全被更新区间覆盖
        max_val[node] = val;
        lazy_tag[node] = val; // 打上懒惰标记
        return;
    }
    
    push_down(node, l, r); // 向下传递懒惰标记
    
    int mid = (l + r) / 2;
    if (update_l <= mid) {
        update(node * 2, l, mid, update_l, update_r, val);
    }
    if (update_r > mid) {
        update(node * 2 + 1, mid + 1, r, update_l, update_r, val);
    }
    
    push_up(node); // 根据子节点信息更新当前节点
}

// 区间查询: 查询[query_l, query_r]区间的最大值
long long query(int node, int l, int r, int query_l, int query_r) {
    if (query_l <= l && r <= query_r) {
        // 当前节点代表的区间完全被查询区间覆盖
        return max_val[node];
    }
    
    push_down(node, l, r); // 查询前先确保信息已从父节点传递下来
    
    int mid = (l + r) / 2;
    long long max_res = 0;
    if (query_l <= mid) {
        max_res = std::max(max_res, query(node * 2, l, mid, query_l, query_r));
    }
    if (query_r > mid) {
        max_res = std::max(max_res, query(node * 2 + 1, mid + 1, r, query_l, query_r));
    }
    
    return max_res;
}

int main() {
    // 提高cin/cout效率
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int n; // 游戏界面宽度
    std::cin >> n;
    
    std::vector<long long> a(n);
    for (int i = 0; i < n; ++i) {
        std::cin >> a[i];
    }
    
    // 构建线段树,区间范围为 [1, n]
    build(1, 1, n, a);
    
    int m; // 下落方块数
    std::cin >> m;
    while (m--) {
        long long width, height;
        std::cin >> width >> height;
        
        // 1. O(log n) 查询 [1, width] 区间的最大高度
        long long max_height = query(1, 1, n, 1, (int)width);
        std::cout << max_height << "\n";
        
        // 2. O(log n) 更新 [1, width] 区间的高度
        long long new_height = max_height + height;
        update(1, 1, n, 1, (int)width, new_height);
    }
    
    return 0;
}

登山鞋

小明在参加一个登山游戏。从左往右依次经历n座山,其中第i座山的高度为h[i]。在游戏开始前,他需要准备一双耐久度足够高的登山鞋才行。

假设小明准备的登山鞋的耐久度为x。如果存在h[i]和h[i+1],满足|h[i+1]-h[i]|>x,那么小明就无法从第i座山攀爬到第i+1座山。

也就是说,如果小明想要完成这个游戏,任意相邻两座山的高度之差不能超过x。

另外,小明还有k次魔法操作的机会。每使用一次魔法操作,小明都可以将任意一座山的高度修改成任何一个非负数值。

现在已知n、k以及n座山各自的原始高度。请你求解x的最小值是多少?

输入说明

第一行输入一个t,表示共有t组测试数据。

接下来每组测试数据中,第一行为n和k。接下来有n个数值,表示从左往右n座山各自的原始高度。

测试数据

输入:

复制代码
3
1 1
2
5 1
1 2 4 7 8
5 3
6 4 7 10 5

输出:

复制代码
0
2
1

错误解法

本题从表面上看是一道普通的dp题,但事实上它并不符合使用dp解题的"最优子结构"要求。比如,修改一座山 h[m] 会同时影响 |h[m] - h[m-1]| 和 |h[m+1] - h[m]|,因此我们对首先遍历到的山调整高度这个操作,并不一定是全局最优的。

以下是错误解法的示例代码:

c++ 复制代码
#include <bits/stdc++.h>

int main() {
    int t;
    std::cin >> t;
    while (t--) {
        int n;  // 山的数量
        int k;  // 操作次数
        std::cin >> n >> k;
        // 一次操作可以将任意一座山的高度修改为一个非负整数
        std::vector<int> heights(n);
        for (int i = 0; i < n; ++i) {
            std::cin >> heights[i];
        }
        // dp[i][k] 对于第0~i座山,有k个操作次数的前提下
        // 任意两座山的高度之差的最大值的最小值,即我们要求解的x
        std::vector<std::vector<int>> dp(n, std::vector<int>(k + 1, 0));
        
        // 一开始就在第一座山,不需要消耗任何耐久度
        for (int j = 0; j <= k; ++j) {
            dp[0][j] = 0;
        }
        
        // 没有任何可以修改山的高度的机会,那么耐久度就是最大高度差
        int max_delta_height = 0;
        for (int i = 1; i < n; ++i) {
            max_delta_height = std::max(max_delta_height, std::abs(heights[i] - heights[i - 1]));
            dp[i][0] = max_delta_height;
        }
        
        for (int i = 1; i < n; ++i) {
            for (int j = 1; j <= k; ++j) {
                // 如果当前山与前一座山的高度超出了已有的x,那么有两种选择
                // 1. 更新x
                // 2. 消耗一次k,修改山的高度
                if (std::abs(heights[i] - heights[i - 1]) > dp[i - 1][j]) {
                    dp[i][j] = std::abs(heights[i] - heights[i - 1]);
                    dp[i][j] = std::min(dp[i][j], dp[i - 1][j - 1]);
                }
                // 否则不需要做任何处理
                else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }
        
        std::cout << dp[n - 1][k] << std::endl;
    }
}

正确解法

本题是一个典型的"求最大值的最小值"问题,很适合使用二分答案来求解。这样我们就把原问题从一个最优化问题转换成了一个判定问题。

以下是AI帮助整理的完整思考过程:

第1步:识别问题类型,初步判断

  • "求解x的最小值是多少?" 这句话是第一个,也是最重要的信号。
  • "求最小值的最大值"或者"求最大值的最小值"类型的问题,有超过一半的可能性都可以用 二分答案 来解决。
  • 立刻开始思考:这个问题是否具有 单调性?也就是说,如果一个耐久度 x 是可行的(能通过不多于 k 次操作完成),那么一个更大的耐久度 x+1 是否也一定可行?
  • 思考验证:是的。如果 x 能满足所有高度差要求,x+1 作为一个更宽松的条件,当然也更能满足。这个单调性是存在的!
  • 初步结论:太好了,这基本就是一个二分答案的题目。我们不需要直接去"计算"那个最小值 x,我可以把它变成一个"猜"值的游戏。

第2步:问题转换,从"优化问题"到"判定问题"

  • 既然决定了要二分答案,那么原来的问题:

    • "找到最小的 x,使得我们用 <= k 次操作完成游戏。"
  • 就转换成了一个新的,更容易处理的子问题,也就是 check(x) 函数要解决的问题:

    • "给你一个固定的 x,判断我们是否能用 <= k 次操作完成游戏?"
  • 为了方便判断,我们还可以进一步转换这个问题:

    • "给你一个固定的 x,计算出要满足所有高度差 <= x,最少需要多少次魔法操作?然后将这个最少次数和 k 比较。"

第3步:设计 check(x) 函数,思考如何解决子问题

  • 现在我们的目标很明确:给定 x,求最少操作次数。

  • 这是一个典型的规划问题。我们手上有 n-1 个"潜在问题"(即 n-1 个相邻高度差),和 k 次"解决问题的机会"(魔法操作)。

  • 首先考虑贪心:是不是可以每次都找最大的高度差 |h[i+1] - h[i]|,然后用一次魔法把它消除掉?

    • 思考验证:修改 h[i] 会同时影响 |h[i] - h[i-1]| 和 |h[i+1] - h[i]|。一次操作可以"一石二鸟"。贪心地修改造成最大局部差值的山,不一定是全局最优的。例如,修改一个虽然不是最高但处在中间位置的山,可能比修改一个在边缘、造成最大差值的山,带来的全局收益更大。
    • 结论:简单的贪心看起来行不通。
  • 转向动态规划(DP) :既然贪心不行,DP 是处理这类"求最优解"(最少次数)问题的标准武器。

    • 定义状态:我们需要定义一个 dp 数组。dp[i] 应该表示什么?它应该表示处理到第 i 座山时的某种最优状态。在这里,"最优"就是指"最少操作次数"。

    • 状态设计的关键:DP 的状态必须是明确的、无后效性的。如果我定义 dp[i] 为"让前 i 座山满足条件的最少操作次数",我们会遇到一个问题:第 i 座山的高度是原始的还是被修改过的?如果被修改过,它的新高度是多少?这太模糊了,状态无法定义。

    • 关键的思维飞跃 :DP 的状态点必须是确定 的。什么东西是确定的?原始的山峰高度 是确定的。所以我们可以这样定义状态:dp[i] 表示处理到第 i 座山,并且我们决定保留第 i 座山的原始高度 h[i] 时,所需要的最少操作次数。

    • 推导转移方程:如果我们保留了 h[i],那么为了计算 dp[i],我们必须找到它之前的一座山 h[j] ( j < i ),它也是被保留的。那么 h[j] 和 h[i] 之间的所有山(j+1 到 i-1)都必须被修改掉。

      • 修改的代价是 i - j - 1 次。
      • 这个转移的前提是什么?是保留 h[j] 和 h[i] 是可行的。也就是说,它们之间的距离 i-j,乘以每一步的最大高度差 x,必须能覆盖它们本身的高度差。即 abs(h[i] - h[j]) <= (i - j) * x。
      • 所以,dp[i] 就是从所有满足条件的 j 转移过来的最小代价:dp[i] = min(dp[j] + i - j - 1)。
    • 处理最终结果:dp 算完了,但 dp[n-1] 不一定是最终答案。因为最后一座被保留的山不一定是 h[n-1]。如果最后一座保留的山是 h[i],那它后面的山都得被修改。所以,我们需要遍历所有可能的"最后一座保留山",计算总代价,取最小值。


代码:

c++ 复制代码
#include <iostream>
#include <vector>
#include <cmath>
#include <algorithm>

// 检查在给定的耐久度x下,是否可以通过不超过k次操作完成游戏
bool check(long long x, int n, int k, const std::vector<int>& heights) {
    if (n <= 1) {
        return true;
    }

    // dp[i]表示:处理前 i+1 座山(0到i),且保留第i座山不修改时,所需的最少操作次数
    std::vector<int> dp(n);

    // 基础情况:保留第0座山,需要0次操作
    dp[0] = 0;

    for (int i = 1; i < n; ++i) {
        // 初始化dp[i]为最坏情况:修改掉前面所有的i座山
        dp[i] = i; 
        for (int j = 0; j < i; ++j) {
            // 如果从保留的j跳到保留的i是可行的
            if (std::abs((long long)heights[i] - heights[j]) <= (long long)(i - j) * x) {
                // 更新dp[i]
                // dp[j]是处理0到j并保留j的成本
                // (i - j - 1)是修改j和i之间所有山的成本
                dp[i] = std::min(dp[i], dp[j] + (i - j - 1));
            }
        }
    }

    // 计算总的最小操作次数
    // 假设第i座山是最后一座被保留的山,那么i后面的山(i+1...n-1)都要修改
    // 需要的操作次数是 n - 1 - i
    int min_ops = n - 1; // 最坏情况:只保留一座山,修改n-1次
    for (int i = 0; i < n; ++i) {
        min_ops = std::min(min_ops, dp[i] + (n - 1 - i));
    }

    return min_ops <= k;
}

void solve() {
    int n, k;
    std::cin >> n >> k;
    std::vector<int> heights(n);
    for (int i = 0; i < n; ++i) {
        std::cin >> heights[i];
    }

    long long left = 0, right = 1000000000, ans = right;

    while (left <= right) {
        long long mid = left + (right - left) / 2;
        if (check(mid, n, k, heights)) {
            ans = mid;
            right = mid - 1; // 尝试更小的x
        } else {
            left = mid + 1; // x太小,不可行,需要增大
        }
    }
    std::cout << ans << std::endl;
}

int main() {
    // 优化输入输出
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);

    int t;
    std::cin >> t;
    while (t--) {
        solve();
    }
    return 0;
}
相关推荐
GIS小天2 小时前
AI+预测3D新模型百十个定位预测+胆码预测+去和尾2025年8月25日第170弹
人工智能·算法·机器学习·彩票
岁月栖迟3 小时前
leetcode 49. 字母异位词分组
windows·算法·leetcode
Asmalin3 小时前
【代码随想录day 21】 力扣 77. 组合
算法·leetcode·职场和发展
言兴5 小时前
秋招面试---性能优化(良子大胃袋)
前端·javascript·面试
2501_924878598 小时前
强光干扰下漏检率↓78%!陌讯动态决策算法在智慧交通违停检测的实战优化
大数据·深度学习·算法·目标检测·视觉检测
耳总是一颗苹果9 小时前
排序---插入排序
数据结构·算法·排序算法
胡gh9 小时前
数组开会:splice说它要动刀,map说它只想看看。
javascript·后端·面试
胡gh9 小时前
浏览器:我要用缓存!服务器:你缓存过期了!怎么把数据挽留住,这是个问题。
前端·面试·node.js
YLCHUP9 小时前
【联通分量】题解:P13823 「Diligent-OI R2 C」所谓伊人_连通分量_最短路_01bfs_图论_C++算法竞赛
c语言·数据结构·c++·算法·图论·广度优先·图搜索算法