Hi 你们好,我是张小猪。欢迎来到『宝宝也能看懂』系列之 leetcode 周赛题解。node
这里是第 174 期的第 4 题,也是题目列表中的第 1340 题 -- 『跳跃游戏 V』git
给你一个整数数组 arr
和一个整数 d
。每一步你能够从下标 i
跳到:github
i + x
,其中 i + x < arr.length
且 0 < x <= d
。i - x
,其中 i - x >= 0
且 0 < x <= d
。除此之外,你从下标 i
跳到下标 j
须要知足:arr[i] > arr[j]
且 arr[i] > arr[k]
,其中下标 k
是全部 i
到 j
之间的数字(更正式的,min(i, j) < k < max(i, j)
)。shell
你能够选择数组的任意下标开始跳跃。请你返回你 最多 能够访问多少个下标。segmentfault
请注意,任什么时候刻你都不能跳到数组的外面。数组
示例 1:缓存
输入:arr = [6,4,14,6,8,13,9,7,10,6,12], d = 2 输出:4 解释:你能够从下标 10 出发,而后如上图依次通过 10 --> 8 --> 6 --> 7 。 注意,若是你从下标 6 开始,你只能跳到下标 7 处。你不能跳到下标 5 处由于 13 > 9 。你也不能跳到下标 4 处,由于下标 5 在下标 4 和 6 之间且 13 > 9 。 相似的,你不能从下标 3 处跳到下标 2 或者下标 1 处。
示例 2:数据结构
输入:arr = [3,3,3,3,3], d = 3 输出:1 解释:你能够从任意下标处开始且你永远没法跳到任何其余坐标。
示例 3:性能
输入:arr = [7,6,5,4,3,2,1], d = 1 输出:7 解释:从下标 0 处开始,你能够按照数值从大到小,访问全部的下标。
示例 4:优化
输入:arr = [7,1,7,1,7,1], d = 2 输出:2
示例 5:
输入:arr = [66], d = 1 输出:1
提示:
1 <= arr.length <= 1000
1 <= arr[i] <= 10^5
1 <= d <= arr.length
HARD
题目的意思仍是稍微解释一下吧。首先是给定了一个数组 arr
,其中每个值表示当前下标的高度。而后给定了一个每次移动的最远距离 d
。能够以任何一个点为出发点,需求是想找到能移动的最大的步数。
每一次移动须要从当前位置开始,向左和右最大为 d
的范围内,选择下一步的位置。其中在移动的时候,咱们的途径点必定都须要是比当前位置低才行,即咱们没法到达与当前位置高度想等或者比当前位置高的地方,哪怕还处于最大范围 d
之中。而且咱们不能移动到数据外面去,即目标地点不能超过下标范围 [0, arr.length)
。
若是仍是比较难明白的话,咱们能够先看一下下面这个图:
假设最大的移动范围为 2,那么结合上图以及题目的要求,能够总结几个栗子:
理完题目以后,接下来看看如何处理这个问题。按照惯例,咱们对于上图中的状况,能够先尝试写可能的路线看看。
上面是以从 C 点出发开始,展开的一个栗子。其实咱们也能够尝试一下,从其余的点出发,会发现均可以展开成相似的栗子。即基于当前的位置咱们能够尝试判断条件后找出下一步可能的位置,而且不断轮回。直到咱们没有下一步了,就能获得一条完整的路线了,从而也就知道了这条路线的长度。
若是咱们知道了从一个点出发的全部路线的长度,那么其实也就知道了以这个点为出发点的最大步数。若是咱们要写成相似公式的话其实就是:dp[i] = 1 + Math.max(dp[j]: j in range[i])
。
而若是咱们知道了全部点的最大步数,也就能获得最终题目的需求了。
下面小猪爆肝了 5 种方案,但愿喜欢的小伙伴们多多三连支持!(等等,好像这不是 B 站啊喂 >.<
回看上面的分析过程,其实会发现,若是放在程序里的话,这就是一个不断递归的过程。而整个寻找的方案,其实就是咱们熟悉的深度优先遍历。那么咱们来尝试整理一下流程吧,千万别忘了用缓存,不然性能会差不少。
基于这个流程,咱们能够实现相似下面的代码:
const maxJumps = (arr, d) => { const cache = new Uint16Array(arr.length); return Math.max(...arr.map((v, i) => helper(i))); function helper(cur) { if (cache[cur] === 0) { let max = 0; for (let i = cur + 1; i <= cur + d && i < arr.length && arr[i] < arr[cur]; ++i) { max = Math.max(helper(i), max); } for (let i = cur - 1; i >= cur - d && i >= 0 && arr[i] < arr[cur]; --i) { max = Math.max(helper(i), max); } cache[cur] = 1 + max; } return cache[cur]; } };
上面那个思路咱们能够认为是一种自顶向下的分解方式,即咱们把一个大的任务拆分红许多小的任务,而后再根据这些小任务的结果来获得大任务的结果。那么咱们是否能够换一个方向,自底向上的来解决这个问题呢?
这样处理的话,意味着咱们每次都须要知道当前能够完成的小任务,完成以后再去寻找下一批能够由当前情况组合推断的大任务。直到咱们的大任务覆盖了全部内容,也就完成了最终的目标。那么问题来了,挖掘机 咱们如何知道目前的小任务呢?
这里咱们能够回头看一下题目的条件,即只能从比较高的地方走到比较低的地方。那么对于全部位置中,高度最低的地方,其实它的值已经肯定了,由于到这里以后便无路可走。
那么顺着这个思路,对高度第二低的地方呢?这时候它的最大值其实仍是不肯定的,由于它有可能可以去到最低的地方,那么最大值即是 2;也有可能没法去到最低的地方,那么最大值即是 1。因此咱们须要作相关的判断才能获得结果。
那么再继续,以这样从低到高的顺序,咱们如何求得任意一个位置的最大值呢?因为全部的比它低的位置已经有最大值了,因此其实很简单,就是找到那些可能走到而且有值的位置里面的最大值,而后加 1 便可。
基于这个思路,咱们能够整理出以下流程:
从低到高开始,更新以当前点为出发点时的最大步数:
基于这个流程,咱们能够实现相似下面的代码:
const maxJumps = (arr, d) => { const LEN = arr.length; const sortedHeights = arr.map((val, idx) => [val, idx]).sort((a, b) => a[0] === b[0] ? a[1] - b[1] : a[0] - b[0]); const steps = new Uint16Array(LEN); let ret = 0; for (const [height, cur] of sortedHeights) { let max = 0; for (let i = cur + 1; i <= cur + d && i < LEN && arr[i] < height; ++i) { steps[i] > max && (max = steps[i]); } for (let i = cur - 1; i >= cur - d && i >= 0 && arr[i] < height; --i) { steps[i] > max && (max = steps[i]); } steps[cur] = max + 1; steps[cur] > ret && (ret = steps[cur]); } return ret; };
这个思路的前戏会比较多一些,由于咱们须要借助一个数据结构 -- 线段树,例以下图就是一个线段树。这里作一个简单的介绍吧。
首先,线段树是一种二叉搜索树,至于什么是二叉搜索树这里就不作过多展开了,不太清楚的小伙伴们能够想象一下对于有序数组进行二分查找的过程。不过线段树的节点并不仅是记录了值,并且还标识了以当前节点为顶点的子二叉树的范围,例如上图中的根节点的范围就是 [1, 10]
。而全部的叶节点必定是一个长度为 1 的范围。
那么咱们这里要用它来作什么呢?咱们能够现象一下,在上图中的树里,咱们若是知道了 1 的值,知道了 2 的值,是否是就能知道 1 和 2 的最大值,也就是节点 1~2 的值。若是咱们再知道 3 的值,那么范围 [1, 3]
的最大值,即节点 1~3 的值,也就知道了。以此类推,咱们能够很容易的获得整个范围 [1, 10]
的最大值。
而后咱们再来看,假如我如今想获得范围 [2, 7]
的最大值,其实也是能够轻松的获取的。即咱们根据目标范围和当前范围的拆分点,从根节点开始向下搜索这个二叉树。最终咱们所须要的最大值会被以这样的方式计算 Math.max(val(2), val(3), val(4~5), val(6~7))
。
这时候可能会有小伙伴有疑问,若是经过一次遍历,也能获得一个范围的最大值呀。为何须要使用线段树呢?其实确实是的,只不过一次遍历的时间复杂度是 O(n),而线段树的查找会是 O(logn)。而且,若是某个叶节点的值产生了变化,咱们也能够方便的在 O(logn) 的时间里更新这棵树。
不过因为 JS 没有直接的内置相似的数据结构,因此咱们须要手动实现一下,而且这个 SegmentTree
类也不是一个通用类,它为了这道题作了一点调整。
另外,这个思路还用到一种基于栈的处理数据的方式,而这里咱们用到的栈,即称为单调栈。这种处理数据的方式核心思路就是,保持栈内的数据是单调递增或者单调递减的,从而方便结合后续新来的数进行逻辑处理。这里咱们举个栗子,以输入数据 [6, 4, 14, 6, 8, 13, 9]
来看,咱们保持栈的单调递减,整个过程以下图:
相信看完这个过程,小伙伴们应该能明白其中的逻辑了吧。不过咱们这里为何须要使用单调栈呢?其实咱们能够看看它的性质保留的都是距离当前位置最近的一个更大的值。因此基于此,咱们能够经过一次遍历就获得全部的点在一侧的下一个更大的值。那么左右各来一次,咱们就能获得每个点左右两个方向的下一个更大的值。
而这个更大的值有什么用呢?能够回想一下题目的条件 -- 咱们只能去到比当前位置更低的位置,那么这个更大的值的做用也就浮出水面啦。
这里关于线段树和单调栈的更多内容,就不作过多的展开和说明了,可能会在后续数据结构的专题里详细的说。下面咱们来整理一下流程:
基于这个流程,咱们能够实现相似下面的代码:
class SegmentTree { constructor(len) { this.data = new Array(len * 4); this.build(0, 0, len - 1); } build(cur, left, right) { if (left === right) { this.data[cur] = [left, right, 0]; return; } const mid = Math.floor((left + right) / 2); this.data[cur] = [left, right, 0]; this.build(cur * 2 + 1, left, mid), this.build(cur * 2 + 2, mid + 1, right) } query(left, right, cur = 0) { const node = this.data[cur]; if (node[0] === left && node[1] === right) return node[2]; const mid = Math.floor((node[0] + node[1]) / 2); if (left > mid) return this.query(left, right, cur * 2 + 2); if (right <= mid) return this.query(left, right, cur * 2 + 1); return Math.max( this.query(left, mid, cur * 2 + 1), this.query(mid + 1, right, cur * 2 + 2), ); } update(idx, value, cur = 0) { const node = this.data[cur]; if (node[0] === node[1] && node[0] === idx) { node[2] = value; return; } const mid = Math.floor((node[0] + node[1]) / 2); this.update(idx, value, idx > mid ? cur * 2 + 2 : cur * 2 + 1); value > node[2] && (node[2] = value); } } const maxJumps = (arr, d) => { const LEN = arr.length; const tree = new SegmentTree(LEN); const sortedHeights = arr.map((val, idx) => [val, idx]).sort((a, b) => a[0] === b[0] ? a[1] - b[1] : a[0] - b[0]); const leftTops = new Int16Array(LEN); const rightTops = new Int16Array(LEN); for (let i = 0, j = LEN - 1, lstack = [], ltop = -1, rstack = [], rtop = -1; i < LEN; ++i, --j) { while (ltop >= 0 && arr[lstack[ltop]] < arr[i]) { lstack.pop(); --ltop; } leftTops[i] = ltop === -1 ? -1 : lstack[ltop]; lstack[++ltop] = i; while (rtop >= 0 && arr[rstack[rtop]] < arr[j]) { rstack.pop(); --rtop; } rightTops[j] = rtop === -1 ? LEN: rstack[rtop]; rstack[++rtop] = j; } for (const item of sortedHeights) { const idx = item[1]; tree.update(idx, 1 + tree.query( Math.max(leftTops[idx] + 1, idx - d), Math.min(rightTops[idx] - 1, idx + d) )); } return tree.query(0, LEN - 1); };
不知道小伙们们看到这里的话会不会已经累了,哈哈哈哈。咱们换个姿式,再来一次!
看完前面的内容以后,相信对于这种思路会很是容易理解,由于它其实就是以前一些方案的融合。
咱们这里一样是用到了单调栈来获取每一个节点两侧的最近的大值,只不事后续处理的方式替换为基于缓存 + 深度优先遍从来实现,能够认为是前面深度优先遍历方案的分支优化版本。顺便也把上面代码里的单调栈的部分写的更好看了一些。
那么流程这里就不写啦,直接给出代码:
const maxJumps = (arr, d) => { const LEN = arr.length; const cache = new Uint16Array(LEN); const map = Array.from({ length: LEN }, () => []); for (let left = 0, right = LEN - 1, ltop = -1, rtop = -1, lstack = new Uint16Array(LEN), rstack = new Uint16Array(LEN); left < LEN; ++left, --right) { ltop = upStack(lstack, ltop, left); rtop = upStack(rstack, rtop, right); } return Math.max(...arr.map((v, i) => helper(i))); function upStack(stack, top, i) { while (top >= 0 && arr[stack[top]] < arr[i]) { const idx = stack[top--]; Math.abs(idx - i) <= d && map[i].push(idx); } stack[++top] = i; return top; } function helper(cur) { cache[cur] === 0 && ( cache[cur] = 1 + (map[cur].length && Math.max(...map[cur].map(helper))) ); return cache[cur]; } };
小猪答应你,这真的是最后一个了 >.<
这里咱们抛弃了前面全部思路中,对于每个值去尝试它两侧可能的下一步这个核心思路。转而基于单调栈的方式直接推出结果。可能初看起来会比较绕,不过放心,有小猪在,神马都是纸脑抚 >.<
如上图,咱们首先看回最初的这个栗子,按照以前单调栈的思路,在执行过程当中,咱们会遇到如下几种状况:
首先,按照题目要求,咱们没法从 B 移动到 C,也没法从 C 移动到 B。这也就意味着,对于存在相等值的递减状况,咱们不能一味的向上更新。例如这里从 D 到 C 到 A 这样更新 A 的值,若是范围只有 1,那么实际上是行不通的。再换一种状况,那么对于 C 以后的值,是否就能够无视 B 了呢?也不必定。例如若是范围够大,F 是能够跳过 C 直接走到 B 的。
那么这里应该怎么处理这些状况呢?其实咱们能够在出栈遇到相同值的时候,把它们全都取出来,并对触发该次出栈的位置和上一个非相同值的位置进行判断和更新便可。
那么好啦,到这里咱们就已经有了处理思路了,接下来整理一下流程吧:
[1, 10^5]
)。基于这个流程,咱们能够实现相似下面的代码:
const maxJumps = (arr, d) => { arr.push(10 ** 5 + 1); const LEN = arr.length; const dp = new Uint16Array(LEN).fill(1); for (let i = 1, top = 0, stack = new Uint16Array(LEN); i < LEN; ++i) { while (top >= 0 && arr[stack[top]] < arr[i]) { let prevNoneSame = top; const height = arr[stack[top]]; while (arr[stack[prevNoneSame]] === height) --prevNoneSame; while (arr[stack[top]] === height) { const idx = stack[top--]; i - idx <= d && dp[idx] + 1 > dp[i] && (dp[i] = dp[idx] + 1); prevNoneSame >= 0 && idx - stack[prevNoneSame] <= d && dp[idx] + 1 > dp[stack[prevNoneSame]] && (dp[stack[prevNoneSame]] = dp[idx] + 1); } } stack[++top] = i; } dp[LEN - 1] = 0; return Math.max(...dp); };
这段代码跑了 52ms,暂时 beats 100%。
终于结束啦,小猪长舒了一口气。对于这个问题,小猪一下爆肝了 5 种方案,忽然以为,这仍是那个懒懒小猪么,必定是找人代写了,哼 >.<
这道题尝试给出这些不一样的解决方案,其实主要就是想给小伙伴们提供不一样的思考方向和可能性,也算是对小猪本身的一个小小的练习吧。
要是小伙伴们喜欢的话,不要忘了三连哦~ 小猪爱大家鸭,么么嗒 >.<