今天来做一道中等难度的算法题:小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。 游戏规则如下:
- 小E可以击败一个血量和攻击力都小于她当前属性的怪物。
- 对于第一个击败的怪物,需要满足其血量小于 H 且攻击力小于 A。
- 击败怪物后,小E会获得该怪物的属性值。
- 为了保持战斗节奏,要求击败的怪物序列中,后一个怪物的血量和攻击力都必须严格大于前一个怪物。
小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的怪物挑战中,这种做法是行不通的。因为本题需要考虑二维的数据,我们无法准确知道当这两维元素各有大小的时候,如何决定替换与否的问题(这里说的太别扭了,大家可以参照上述代码写一遍,就知道为什么无法决定替换与否了)。
好在动态规划是通用做法,我们依然可以应用于本题,如果大家有更好的做法,欢迎交流。
好了,今天的题目就分享到这里,感谢观看。