Cancel

LeetCode Jump Games

Other

·

August 02, 2023

源代码

44 Jump Games II

虽然名字上第二个,但题目编号反而要比1要早

问题描述

求跳到尾部的最小步数1。

解①DP

这个题目本身带有 DP 的标签,首先也就想到了简单的 DP 思路:记录跳到每个位置所需要的最小步数,从左到右扫描,每到一个位置,就用当前步数加一来更新该位置下一步所能到达的那一些位置。

def solve(nums: List[int]) -> int:
    dp = [len(nums)] * len(nums)

    dp[0] = 0

    for i in range(0, len(nums)-1):
        for j in range(i+1, min(i+nums[i]+1, len(nums))):
            dp[j] = min(dp[j], dp[i]+1)

    return dp[-1]

这个算法的时间复杂度在官解和很多题解里直接标了 $O(n^2)$ ,但这是不准确的,因为根据题目标明的数据范围 nums[i] 的最大值与 len(nums) 的最大值不同,前者要小一个数量级,实际上是 1000 ,很多情况这可能只是一个稍微大一点的常数。

实际运行 3862 ms (beats 25.91%)

说差也差,说不差,还有 10000+ ms 通过的朋友。

解②Greedy:

做多了 DP 的题目,再想 Greedy 思路反而变得不太直观,所谓看山不是山。

算法推论:

探测每个位置下一步能够跳到的所有位置,比较它们下一步能够跳到的最远的位置,最远的那一个就是下一步要跳到的地方,这样直到可以等于或者超过终点。

贪心的归纳法证明:

定义:把用最小步数跳到终点的一个跳法儿称作一个最短序列。

只需要证明,这个算法的跳的每一个位置都在同一个最短序列里:

  1. 初始时刻,起始位置显然一定在最短序列里

  2. 假如上一个位置 $i_0$ 在最短序列里,那么从该位置出发,比较所有下一步可以跳到的位置,假如下一步跳最远的位置是 $j$ ,而存在另一个位置 $j_1\neq j$ 是这个最短序列里的下一步:

    定义通过 $j_1$ 的最短序列上的下一个位置为 $j_1’$ ,则:

    2.1. 如果 $j_1’ \geqslant j$ ,那是不是说明通过 $j_1$ 能够跳到的位置,通过 $j$ 也能跳到

    2.2. 如果 $j_1’ \lt j$ ,则有 $i \lt j_1 \lt j_1’$ ,那为什么不直接一步从 $i_0$ 跳到 $j_1’$ ,而是要花两步跳? 这又和最短序列矛盾了

证毕。

贪心成立关键在于:

  1. 跳的步数是一个连续地范围
  2. 跳的步数是非负值
def solve(nums: List[int]) -> int:
    if len(nums) == 1:
        return 0

    i0 = 0  # prev i
    i = nums[i0]
    step = 1

    while i < len(nums) - 1:
        i0, i = max(
            [(j, j+nums[j]) for j in range(i0+1, i0+1+nums[i0])],
            key=lambda x: x[1]
        )

        step += 1

    return step

扫描了多长的候选位置,就至少跳过多少候选位置,时间复杂度为 $O(n)$ 。

运行时间:120 ms (beats 96.35%),绝对地最优解。

55 Jump Games I

虽然名字上第一个,但题目编号反而要比2要晚

问题描述

解①Greedy-1:

这个问题和上面 Games II 的题干看起来非常像,可以用一模一样的方法

def solve(nums: List[int]) -> bool:
    if len(nums) == 1:
        return True

    i0 = 0
    i = nums[i0]

    while i < len(nums) - 1:
        if i0 == i:
            return False

        if i0+nums[i0] >= len(nums) - 1:
            return True

        i0, i = max(
            map(lambda j: (j, j+nums[j]), range(i0+1, i0+1+nums[i0])),
            key=lambda x: x[1]
        )

    return True

但是数据的规模发生了变化,nums[i] 的最大值反而要比 len(nums) 的最大值大一个数量级,时间复杂度应该类似于上面 Games II 的 DP 解法。

实际运行时间:6041 ms (beats 8.5%)

解② Greedy-2:

由于本题 len(nums) 比 nums[i] 有较小的数据规模,何况追求得最小步数而是最大可达位置,最好是可以只扫描 nums 数组。

可以这样考虑:如果终点是不可达的,那么在 nums 上一定存在一个真正的终点位置,如果能够找到这个终点位置,就可以进行判断了。

可是终点是什么样的呢?

首先终点的步长应该是零,否则从它就可以跳到更右边的位置,而它也就不是终点了。

其次,任何终点左边的位置一步之内都最多跳到终点。

如果从 nums 的尾部开始向左扫描,如果一个位置可以一步到达尾部,那么就可以把尾部设置到该位置,如果在结束的时候尾部没有归为零,终点就是尾部前一个位置。

def solve(nums: List[int]) -> bool:
    lf = len(nums) - 1

    for i in range(len(nums)-2, -1, -1):
        if i + nums[i] >= lf:
            lf = i

    return lf == 0

运行时间:394 ms (beats 98.95%)

也可以从正面开始扫描,寻找终点,但除了让代码更复杂一点没有任何好处。

def solve(nums: List[int]) -> bool:
    to = 0

    for i in range(0, len(nums)-1):
        if i > to:
            return False

        if i+nums[i] >= len(nums)-1:
            return True

        to = max(to, i+nums[i])

    return to >= len(nums) - 1

运行时间:428 ms (beats 96.53%)

1306 Jump Games III

问题描述

从给定位置开始跳,每次有左右两个方向,不是跳一个范围,而是一个固定地步长。

谈不到什么算法的简单题目。

from typing import List

def solve(arr: List[int], start: int) -> bool:
    """ false: unvisited, true: visited """

    n = len(arr)
    stack = [start]
    cache = [False] * n

    while stack:
        i = stack.pop()

        if arr[i] == 0:
            return True

        if not cache[i]:
            cache[i] = True

            if i+arr[i] < n:
                stack.append(i+arr[i])
            if i-arr[i] >= 0:
                stack.append(i-arr[i])

    return False

运行时间:249 ms (beats 98.79%),内存占用: 23.6 MB (beats 47.8%, ~ 100%)

题目很简单,但是有两点细节很值得一提:

  1. 这个题目内存占用最低地情况就是 23 MB ,如果不创建新的结构,而是直接在 arr 上就地修改2,几乎不会节约任何内存占用,只少了大概 0.3 MB ,不知道造成这个原因的技术细节是什么,可能只是 Python 的内存分配器一次性地申请了很大的内存3;
  2. 而更令人意想不到地是这个题目的解的内存占用可以达到 80 MB!粗略地看了一下,它们全都是使用了基于哈希表的数据结构(set,map,default_dict,etc.)而不是数组来标记访问,一方面它们的访问速度相较于列表并没有明显地更慢,另一方面它们占用了有点儿意料之外地更多的内存。

对于第一点,CPython 显然不如 JVM 那样有很详细或者冗繁地参数控制,更不如编译型语言对内存使用那样地节约,这没什么好说的。

对于第二点,必须申明地是,必须实际地考虑哈希表的实现,由于负载因子(fill factor)的存在4,至少内部的空间容量就是原数组的倍数,再考虑存储碰撞元素的列表甚至树形结构指针的开销,实际地哈希表占据的空间如果有个数组的两倍也毫不奇怪(但是像 Python 这样确实有些夸张,内存开销大概是普通列表的 1000 倍的级别,只能说完全地对象语言是这样的)。

因此一个很重要地原则是,如果是追踪一个已知列表的每个元素状态,直接使用列表无论时间还是空间性能都要优于使用哈希表。

1345. Jump Game IV

问题描述

固定跳点,每个点可以跳到三类地方,+1/-1/同值点,求从头跳到尾的最小步数。

非常类似于 Games III ,前者被标定为 Medium 难度,现在只不过又缝了一个哈希表的使用,难度就被定位到 Hard 级了。56

运用哈希表统计同值点,用 BFS 求取最小步数,需要注意得是哈希表上的元素在被访问过后必须弹出,否则时间复杂度会劣化到 $O(n^2)$ 。

def solve(arr: List[int]) -> int:
    n = len(arr)
    val = defaultdict(list)

    for i, v in enumerate(arr):
        val[v].append(i)

    step = 0
    queue = [0] if arr[1:] else []
    visited = [False] * n

    while queue:
        nxt_queue = []
        step += 1

        for i in queue:
            if i+1 < n and not visited[i+1]:
                visited[i+1] = True
                nxt_queue.append(i+1)

                if i+1 == n-1:
                    return step

            if i-1 >= 0 and not visited[i-1]:
                visited[i-1] = True
                nxt_queue.append(i-1)

            while val[arr[i]]:
                j = val[arr[i]].pop()

                if not visited[j]:
                    visited[j] = True
                    nxt_queue.append(j)

                    if j == n-1:
                        return step

        queue = nxt_queue

    return step

运行时间:549 ms (beats 98.59%),内存占用:29.6 MB (beats 94.3%)

关于使用什么样的算法和数据结构来对同值位置进行统计,可以继续 Games III 的讨论,当一般地值域比元素数大一个数量级,就是一定要使用哈希表的情况。

1340. Jump Game V

有单独一篇博文来讨论: LeetCode1340

1696. Jump Game VI

问题描述

乍看起来很简单,但是做起来令人有些不快,因为它作为中等难度的题,但寻找 $O(n)$ 的解决方案并不如我预计得那样容易。

题目求解最大的跳的位置和,也是跳一个连续范围,看起来就是利用贪心地思想寻找一个简单方法就 OK 了,但是没有这样的方法,想了很长时间,就是发现在所有位置的值中,如果是正的,一定在序列里,但如果是负的,就存在一个更少的步数但更大(绝对值)的负值与更多的步数而更小的负值,这就不适合贪心算法了。

解①分治+DP:

只能一步一步考虑,从索引 $0$ 的位置,第一步跳到的位置,有 $k$ 个,可以线性地计算出它们的最大值,包括这个长度为 $k$ 数组的后缀最大值,那么对于下一个 $k$ 长度的区间,它们每个位置的最大访问和就可以通过前 $k$ 个的后缀最大值和当前 $k$ 个的前缀最大值计算得出,这样一直到计算出了到最后一个位置的最大访问和,整个时间复杂度都是线性的。

from typing import List

def solve(nums: List[int], k: int) -> int:
    init = nums[0]

    nums = nums[1:]
    n = len(nums)

    cache = [0] * k
    prev_postfix_max = [0] * k
    cur_prefix_max = [0] * k

    blk_rem = n % k
    max_blks = n // k + (1 if blk_rem else 0)

    acc = 0

    for i, v in enumerate(nums[:k]):
        cache[i] = v + acc

        if v > 0:
            acc += v

    for blk in range(1, max_blks):
        prev_postfix_max[k-1] = cache[k-1]

        for j in range(k-2, -1, -1):
            prev_postfix_max[j] = max(prev_postfix_max[j+1], cache[j])

        cur_prefix_max[0] = cache[0] = prev_postfix_max[0] + nums[blk*k]

        if blk == max_blks-1 and blk_rem:
            tail = blk_rem
        else:
            tail = k

        for j in range(1, tail):
            cache[j] = max(prev_postfix_max[j], cur_prefix_max[j-1]) + nums[blk*k+j]
            cur_prefix_max[j] = max(cur_prefix_max[j-1], cache[j])

    return init + cache[(k+blk_rem-1) % k]

时间复杂度 $O(n)$ 。

运行时间:826 ms (beats 80.95%),内存占用:30.50 MB (beats 52.38%)

解②双头单调栈+DP:

本方法是上面方法的规整解,可以使用优先级队列维护之前最大的统计和的位置,当堆顶的元素过期(即与当前位置差距超过 $k$ )就弹出,不过这样每次弹出栈顶元素需要的时间复杂度就是 $O(\text{log}n)$ ,总共的时间复杂度 $O(n\text{log}n)$ 。

可以用双头的单调栈,从头到尾严格减的顺序,每次检查栈头最大值,如果“过期”就弹出($i$ 最多增加一次,因此只需要检测一次),用栈顶的访问最大值和当前值加和计算出当前位置的访问最大值,把栈尾所有不超过当前访问最大值的索引位置弹出,然后把当前索引位置插入尾部。

from typing import List
from collections import deque

def solve(nums: List[int], k: int) -> int:
    n = len(nums)

    q = deque([0])
    dp = [0] * n

    dp[0] = nums[0]

    for i in range(1, n):
        if i-q[0] > k:
            q.popleft()

        dp[i] = nums[i] + dp[q[0]]

        while q and dp[q[-1]] <= dp[i]:
            q.pop()

        q.append(i)

    return dp[-1]

时间复杂度 $O(n)$ 。

运行时间:773 ms (beats 97.14%),内存占用:30.53 MB (beats 52.38%)

1871 Jump Game VII

问题描述

首先吐槽下这个题的测试用例很不健全,导致有些 $O(n^2)$ 复杂度的算法甚至比 $O(n)$ 的算法都快 。

这个题目是单向跳,跳的步长是一个固定的范围: [minJump, maxJump] ,而且只能跳到值为 ‘0’ 的位置,从头开始跳,看能否跳到最后一个。

又是一个跳的这个范围不是从 $1$ 开始,但仍然是连续的,这就启发我们仍然可能找到一个基于贪心地思想的 $O(n)$ 的解决方案。

把值为 ‘0’ 的位置称为可跳位置,事实上,这个题目破题的关键是向前查找可跳位置是否可以跳到。

解🄋贪心:序

可以用一个列表保存所有可跳到的位置,然后使用最多两次二分搜索就可以判断出 [minJump, maxJump] 范围内是否有可跳到的位置跳到当前位置:

from bisect import bisect_left

def solve(s: str, minJump: int, maxJump: int) -> bool:
    n = len(s)
    stack = [0]

    for i in range(1, n):
        if s[i] == '0':
            if i-maxJump <= stack[-1] and i-minJump >= 0:
                l = bisect_left(stack, i-maxJump)

                if stack[l] == i-maxJump:
                    stack.append(i)
                    continue

                r = bisect_left(stack, i-minJump)

                if l < r or stack[r] == i-minJump:
                    stack.append(i)
                    continue

    return stack[-1] == n-1

时间复杂度是 $O(n\text{log}n)$

运行时间:439 ms (beats 36.99%),内存占用:21.1 MB (beats 25.62%)

解①贪心:双头栈

如果能悟出了贪心算法,那么马上就能想到前面 Jump Game VI 里面使用过的双头单调栈,只不过这里更简单,序号本来就是自然增长的,不需要特别维护单调性,弹出头部所有小于 i-maxJump 的索引。

因为之后永远都不会检查到这些位置了,然后检查新的栈头是否大于等于 i-minJump ,成立的话就说明存在前面一个可跳到的位置能够跳到当前位置,这就把当前位置索引插入到尾部,当然如果所有栈已经空了,就提前返回失败,因为已经没有任何可跳到的位置能继续向前跳了。

最后检查栈的尾是否有最后一个索引编号。

from collections import deque
from itertools import islice

def solve(s: str, minJump: int, maxJump: int) -> bool:
    n = len(s)
    stack = deque([0])

    for i, v in islice(enumerate(s), 1, None):
        if v == '0':
            while stack and stack[0] < i-maxJump:
                stack.popleft()

            if not stack:
                return False

            if stack[0] <= i-minJump:
                stack.append(i)

    return bool(stack) and stack[-1] == n-1

时间复杂度 $O(n)$ 。

运行时间:201 ms (beats 96.49%),内存占用:20.50 MB (beats 48.76%)

解②贪心:其他

这里介绍本质相同地另外几个 $O(n)$ 的算法:

DP + 滑动窗口

利用一个数组 dp 记录扫描过的位置是否可跳达,使用一个变量 cnt 来追踪当前位置$i$ 的 [i-maxJump, i-minJump] 范围里可跳达位置的数量,到时候检查这个变量是否为零,就可以判断当前可跳位置是否可以跳到。

维护 cnt:

  1. 当 dp[i-minJump] 是 true 时,说明随着窗口滑动新进来了一个可跳到的位置,cnt 就加一;
  2. 当 dp[i-maxJump-1] 是 false 时,说明随着窗口滑动,原本尾部的可跳到的位置出去了,cnt 就减一

基本代码和下面方法一样,这里就不赘写了。

前缀和

这个是官解,只不过用了一个记录可跳到位置的数量和的前缀数组 prefix 代替 dp,用前缀和之差代替对 cnt 的追踪。

这个有点儿意思得地方是,它用了 prefix[i-minJump] - prefix[i-maxJump-1] 来计算 [i-maxJump, i-minJump] 范围内可跳到位置的数量,让人想起了树状数组也采用同样的方法来计算区间值。

有一点让人不爽地是,必须单独处理 [0, maxJump+1] 部分的内容来初始化前缀树组。

from itertools import islice

def solve(s: str, minJump: int, maxJump: int) -> bool:
    n = len(s)

    if s[-1] != '0':
        return False

    if n <= maxJump+1:
        return True

    prefix = [1] * n

    it = enumerate(s)

    for i, v in islice(it, minJump, maxJump+1):
        prefix[i] = prefix[i-1]

        if v == '0':
            prefix[i] += 1

    for i, v in it:
        prefix[i] = prefix[i-1]

        if v == '0' and prefix[i-minJump] - prefix[i-maxJump-1]:
            prefix[i] += 1

    return prefix[-1] - prefix[-2] == 1

算法复杂度 $O(n)$ 。

运行时间:173 ms (beats 97.52%),内存占用:21.07 MB (beats 25.62)

注解

  1. 题目确保一定会跳到结尾 ↩

  2. 比如取负值表示访问过的状态 ↩

  3. 黑箱测试结果比较支持这个看法 ↩

  4. 为了避免在内部数组接近填满时哈希性能地急速下降 ↩

  5. 可能是知识体系的不同,这些前面的 LeetCode 的题目难度普遍不高,这和我最初做图上的问题以及扫描线的问题的难度感完全不是一个等级的 ↩

  6. 在做了一些题后,我对 LeetCode 出题思路也有了一些把握,Medium 难度题目只涉及单一知识点,而 Hard 难度题目就是两个独立的知识点缝在一起,一般还是一大一小 ↩

© minghu6

·

theme

Simplex theme logo

by golas