小E的怪物挑战 | 豆包MarsCode AI刷题

238 阅读5分钟

今天来做一道中等难度的算法题:小E的怪物挑战

题目链接:小E的怪物挑战

问题描述

这道题我第一次看到的时候,题目是这样的(后续题面有变化,见下文):

小E在一个游戏中遇到了 n 个怪物。每个怪物都有其特定的血量 h 和攻击力 a 。小E的初始血量为 H ,攻击力为 A 。她可以击败那些血量和攻击力都小于她自身的怪物。每击败一个怪物后,小E的血量和攻击力会变为该怪物的血量和攻击力。小E想知道,她最多能击败多少怪物。

思路分析

这个题目是有问题的,题面没有给出打怪的顺序,误导了很多人(包括我)。我当时以为就是对怪物按照某种规则排序,然后按顺序计数即可,请教了豆包Marscode以后也得到了同样的思路。但是无论怎么写代码都是不对的。

后来我根据测试用例和最终提交情况来倒推,打怪似乎是有顺序要求的:小E必须是从右向左的顺序打怪,可以跳过,但不可以回头。理解题意后我终于写出了答案。

由于小E每次打完怪,自身的状态会改变,那么相当于能打的怪越来越弱,自然想到就单调性了。也就是说,我们需要找到一个从右往左状态依次递减的序列,且长度要尽可能地长(消灭的怪物尽可能多)。那么此题的本质便拨云见日,呼之欲出:二维最长递增子序列问题

定义数组 dp[i],维护以第 i 个怪物为结尾的最长递增子序列长度,可以从前 i-1 个状态中,找到符合递增条件的最大值,加上 1 即可完成状态转移。

代码

public static int solution(int n, int H, int A, int[] h, int[] a) {
    // write code here
    // 动态规划,最长递增子序列
    // dp[i] 表示考虑前 i 个怪物,且需要击败第 i 个怪物,此时的最大击败数量
    int ans = 0;
    int[] dp = new int[n];
    for (int i = 0; i < n; i++) {
        // 打不过当前怪物,选择跳过
        if (h[i] >= H || a[i] >= A) {
            continue;
        }
        dp[i] = 1;
        for (int j = 0; j < i; j++) {
            if (h[i] > h[j] && a[i] > a[j]) {
                dp[i] = Math.max(dp[i], dp[j] + 1);
            }
        }
        ans = Math.max(ans, dp[i]);
    }
    return ans;
}

题目吐槽

过了几天再看时,题目改成了这样:

小E在一个游戏中遇到了 n 个按顺序出现的怪物,每个怪物都有其特定的血量 hi 和攻击力 ai。小E的初始血量为 H,攻击力为 A。 游戏规则如下:

  1. 小E可以击败一个血量和攻击力都小于她当前属性的怪物。
  2. 对于第一个击败的怪物,需要满足其血量小于 H 且攻击力小于 A。
  3. 击败怪物后,小E会获得该怪物的属性值。
  4. 为了保持战斗节奏,要求击败的怪物序列中,后一个怪物的血量和攻击力都必须严格大于前一个怪物。

小E想知道,她最多能击败多少怪物。

改过之后的题目……我感觉……问题更大了好吧?这直接前后矛盾了吧,又要“按顺序出现”,又要“后一个严格大于前一个”,又要“击败小于她属性的怪物并继承该怪物属性”,我实在没能get到出题人的point。

相似题目联想

题目

既然本题涉及到了最长递增子序列,我们不妨来回顾一下leetcode第300题:最长递增子序列

题目如下:

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。 子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

动态规划

这道题通用的做法便是动态规划,分析过程与本题一致,不过数据只有一维:

class Solution {
    public int lengthOfLIS(int[] nums) {
        int n = nums.length;
        int ans = 0;
        // dp[i] 表示前 i 个元素中,以第 i 个数字结尾的最长上升子序列长度
        int[] dp = new int[n];
        for (int i = 0; i < n; i++) {
            dp[i] = 1; // 当前元素本身
            for (int j = 0; j < i; j++) {
                if (nums[j] < nums[i]) {
                    dp[i] = Math.max(dp[i], dp[j] + 1);
                }
            }
            ans = Math.max(ans, dp[i]);
        }
        return ans;
    }
}

上述代码的时间复杂度显而易见是 O(n^2) ,空间复杂度 O(n)

贪心 + 二分

然而,由于这道题需要考虑的只有一维数字,我们有一种更好更妙的做法。

在动态规划中,我们维护了以某元素为结尾的递增子序列的最大长度,换个角度想,我们也可以维护某个长度的递增子序列的最小末尾元素,末尾元素越小,才更有机会扩展子序列的长度。详细请参考本题的官方题解

在这种思路之下,我们可以使用二分查找来决定元素更新的位置,从而优化时间复杂度为 O(nlogn) ,即贪心 + 二分

class Solution {
    public int lengthOfLIS(int[] nums) {
        List<Integer> g = new ArrayList<>();
        for (int num : nums) {
            // 二分查找到应插入的位置
            int j = lowerBound(g, num);
            if (j == g.size()) {
                g.add(num);
            } else {
                // 直接覆盖
                g.set(j, num);
            }
        }
        return g.size();
    }

    private int lowerBound(List<Integer> g, int target) {
        int left = 0, right = g.size() - 1;
        while (left <= right) {
            int mid = left + ((right - left) >> 1);
            if (g.get(mid) < target) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return left;
    }
}

是不是拍案叫绝呢?可惜在小E的怪物挑战中,这种做法是行不通的。因为本题需要考虑二维的数据,我们无法准确知道当这两维元素各有大小的时候,如何决定替换与否的问题(这里说的太别扭了,大家可以参照上述代码写一遍,就知道为什么无法决定替换与否了)。

好在动态规划是通用做法,我们依然可以应用于本题,如果大家有更好的做法,欢迎交流。

好了,今天的题目就分享到这里,感谢观看。