详解降低确定「记忆化容器大小」的思维难度 & 利用「对偶性质」构造有效状态值|Java 刷题打卡

本文涉及的产品
容器服务 Serverless 版 ACK Serverless,317元额度 多规格
容器服务 Serverless 版 ACK Serverless,952元额度 多规格
容器镜像服务 ACR,镜像仓库100个 不限时长
简介: 详解降低确定「记忆化容器大小」的思维难度 & 利用「对偶性质」构造有效状态值|Java 刷题打卡

题目描述



这是 LeetCode 上的 403. 青蛙过河 ,难度为 困难


Tag : 「DFS」、「BFS」、「记忆化搜索」、「线性 DP」


一只青蛙想要过河。 假定河流被等分为若干个单元格,并且在每一个单元格内都有可能放有一块石子(也有可能没有)。 青蛙可以跳上石子,但是不可以跳入水中。


给你石子的位置列表 stones(用单元格序号 升序 表示), 请判定青蛙能否成功过河(即能否在最后一步跳至最后一块石子上)。


开始时, 青蛙默认已站在第一块石子上,并可以假定它第一步只能跳跃一个单位(即只能从单元格 1 跳至单元格 2 )。


如果青蛙上一步跳跃了 k 个单位,那么它接下来的跳跃距离只能选择为 k - 1、k 或 k + 1 个单位。 另请注意,青蛙只能向前方(终点的方向)跳跃。


示例 1:


输入:stones = [0,1,3,5,6,8,12,17]
输出:true
解释:青蛙可以成功过河,按照如下方案跳跃:跳 1 个单位到第 2 块石子, 然后跳 2 个单位到第 3 块石子, 接着 跳 2 个单位到第 4 块石子, 然后跳 3 个单位到第 6 块石子, 跳 4 个单位到第 7 块石子, 最后,跳 5 个单位到第 8 个石子(即最后一块石子)。
复制代码


示例 2:


输入:stones = [0,1,2,3,4,8,9,11]
输出:false
解释:这是因为第 5 和第 6 个石子之间的间距太大,没有可选的方案供青蛙跳跃过去。
复制代码


提示:


  • 2 <= stones.length <= 2000
  • 0 <= stones[i] <= 2312^{31}231 - 1
  • stones[0] == 0


DFS(TLE)



根据题意,我们可以使用 DFS 来模拟/爆搜一遍,检查所有的可能性中是否有能到达最后一块石子的。


通常设计 DFS 函数时,我们只需要不失一般性的考虑完成第 iii 块石子的跳跃需要些什么信息即可:


  • 需要知道当前所在位置在哪,也就是需要知道当前石子所在列表中的下标 uuu
  • 需要知道当前所在位置是经过多少步而来的,也就是需要知道上一步的跳跃步长 kkk


代码:


class Solution {
    Map<Integer, Integer> map = new HashMap<>();
    public boolean canCross(int[] ss) {
        int n = ss.length;
        // 将石子信息存入哈希表
        // 为了快速判断是否存在某块石子,以及快速查找某块石子所在下标
        for (int i = 0; i < n; i++) {
            map.put(ss[i], i);
        }
        // check first step
        // 根据题意,第一步是固定经过步长 1 到达第一块石子(下标为 1)
        if (!map.containsKey(1)) return false;
        return dfs(ss, ss.length, 1, 1);
    }
    /**
     * 判定是否能够跳到最后一块石子
     * @param ss 石子列表【不变】
     * @param n  石子列表长度【不变】
     * @param u  当前所在的石子的下标
     * @param k  上一次是经过多少步跳到当前位置的
     * @return 是否能跳到最后一块石子
     */
    boolean dfs(int[] ss, int n, int u, int k) {
        if (u == n - 1) return true;
        for (int i = -1; i <= 1; i++) {
            // 如果是原地踏步的话,直接跳过
            if (k + i == 0) continue;
            // 下一步的石子理论编号
            int next = ss[u] + k + i;
            // 如果存在下一步的石子,则跳转到下一步石子,并 DFS 下去
            if (map.containsKey(next)) {
                boolean cur = dfs(ss, n, map.get(next), k + i);
                if (cur) return true;
            }
        }
        return false;
    }
}
复制代码


  • 时间复杂度:O(3n)O(3^n)O(3n)
  • 空间复杂度:O(3n)O(3^n)O(3n)


但数据范围为 10310^3103,直接使用 DFS 肯定会超时。


我们需要考虑加入「记忆化」功能,或者改为使用带标记的 BFS


记忆化搜索



在考虑加入「记忆化」时,我们只需要将 DFS 方法签名中的【可变】参数作为维度,DFS 方法中的返回值作为存储值即可。


通常我们会使用「数组」来作为我们缓存中间结果的容器,


对应到本题,就是需要一个 boolean[石子列表下标][跳跃步数] 这样的数组,但使用布尔数组作为记忆化容器往往无法区分「状态尚未计算」和「状态已经计算,并且结果为 false」两种情况。


因此我们需要转为使用 int[石子列表下标][跳跃步数],默认值 000 代表状态尚未计算,−1-11 代表计算状态为 false111 代表计算状态为 true


接下来需要估算数组的容量,可以从「数据范围」入手分析。


根据 2 <= stones.length <= 2000,我们可以确定第一维(数组下标)的长度为 200920092009,而另外一维(跳跃步数)是与跳转过程相关的,无法直接确定一个精确边界,但是一个显而易见的事实是,跳到最后一块石子之后的位置是没有意义的,因此我们不会有「跳跃步长」大于「石子列表长度」的情况,因此也可以定为 200920092009(这里是利用了由下标为 iii 的位置发起的跳跃不会超过 i+1i + 1i+1 的性质)。


至此,我们定下来了记忆化容器为 int[][] cache = new int[2009][2009]


但是可以看出,上述确定容器大小的过程还是需要一点点分析 & 经验的。


那么是否有思维难度再低点的方法呢?


答案是有的,直接使用「哈希表」作为记忆化容器。「哈希表」本身属于非定长容器集合,我们不需要分析两个维度的上限到底是多少。


另外,当容器维度较多且上界较大时(例如上述的 int[2009][2009]),直接使用「哈希表」可以有效降低「爆空间/时间」的风险(不需要每跑一个样例都创建一个百万级的数组)。


代码:


class Solution {
    Map<Integer, Integer> map = new HashMap<>();
    // int[][] cache = new int[2009][2009];
    Map<String, Boolean> cache = new HashMap<>();
    public boolean canCross(int[] ss) {
        int n = ss.length;
        for (int i = 0; i < n; i++) {
            map.put(ss[i], i);
        }
        // check first step
        if (!map.containsKey(1)) return false;
        return dfs(ss, ss.length, 1, 1);
    }
    boolean dfs(int[] ss, int n, int u, int k) {
        String key = u + "_" + k;
        // if (cache[u][k] != 0) return cache[u][k] == 1;
        if (cache.containsKey(key)) return cache.get(key);
        if (u == n - 1) return true;
        for (int i = -1; i <= 1; i++) {
            if (k + i == 0) continue;
            int next = ss[u] + k + i;
            if (map.containsKey(next)) {
                boolean cur = dfs(ss, n, map.get(next), k + i);
                // cache[u][k] = cur ? 1 : -1;
                cache.put(key, cur);
                if (cur) return true;
            }
        }
        // cache[u][k] = -1;
        cache.put(key, false);
        return false;
    }
}
复制代码


  • 时间复杂度:O(n2)O(n^2)O(n2)
  • 空间复杂度:O(n2)O(n^2)O(n2)


动态规划



有了「记忆化搜索」的基础,要写写出来动态规划就变得相对简单了。


我们可以从 DFS 函数出发,写出「动态规划」解法。


我们的 DFS 函数签名为:


boolean dfs(int[] ss, int n, int u, int k);
复制代码


其中前两个参数为不变参数,后两个为可变参数,返回值是我们的答案。


因此可以设定为 f[][]f[][]f[][] 作为动规数组:


  1. 第一维为可变参数 uuu,代表石子列表的下标,范围为数组 stones 长度;
  2. 第二维为可变参数 kkk,代表上一步的的跳跃步长,前面也分析过了,最多不超过数组 stones 长度。


这样的「状态定义」所代表的含义:当前在第 iii 个位置,并且是以步长 kkk 跳到位置 iii 时,是否到达最后一块石子。


那么对于 f[i][k]f[i][k]f[i][k] 是否为真,则取决于上一位置 jjj 的状态值,结合每次步长的变化为 [-1,0,1] 可知:


  • 可从 f[j][k−1]f[j][k - 1]f[j][k1] 状态而来:先是经过 k−1k - 1k1 的跳跃到达位置 jjj,再在原步长的基础上 +1,跳到了位置 iii
  • 可从 f[j][k]f[j][k]f[j][k] 状态而来:先是经过 kkk 的跳跃到达位置 jjj,维持原步长不变,跳到了位置 iii
  • 可从 f[j][k+1]f[j][k + 1]f[j][k+1] 状态而来:先是经过 k+1k + 1k+1 的跳跃到达位置 jjj,再在原步长的基础上 -1,跳到了位置 iii


只要上述三种情况其中一种为真,则 f[i][j]f[i][j]f[i][j] 为真。


至此,我们解决了动态规划的「状态定义」&「状态转移方程」部分。


但这就结束了吗?还没有。


我们还缺少可让状态递推下去的「有效值」,或者说缺少初始化环节。


因为我们的 f[i][k]f[i][k]f[i][k] 依赖于之前的状态进行“或运算”而来,转移方程本身不会产生 truetruetrue 值。因此为了让整个「递推」过程可滚动,我们需要先有一个为 truetruetrue 的状态值。


这时候再回看我们的状态定义:当前在第 iii 个位置,并且是以步长 kkk 跳到位置 iii 时,是否到达最后一块石子。


显然,我们事先是不可能知道经过「多大的步长」跳到「哪些位置」,最终可以到达最后一块石子。


这时候需要利用「对偶性」将跳跃过程「翻转」过来分析:


我们知道起始状态是「经过步长为 1」的跳跃到达「位置 1」,如果从起始状态出发,存在一种方案到达最后一块石子的话,那么必然存在一条反向路径,它是以从「最后一块石子」开始,并以「某个步长 kkk」开始跳跃,最终以回到位置 1。


因此我们可以设 f[1][1]=truef[1][1] = truef[1][1]=true,作为我们的起始值。


这里本质是利用「路径可逆」的性质,将问题进行了「等效对偶」。表面上我们是进行「正向递推」,但事实上我们是在验证是否存在某条「反向路径」到达位置 111


建议大家加强理解 ~


代码:


class Solution {
    public boolean canCross(int[] ss) {
        int n = ss.length;
        // check first step
        if (ss[1] != 1) return false;
        boolean[][] f = new boolean[n + 1][n + 1];
        f[1][1] = true;
        for (int i = 2; i < n; i++) {
            for (int j = 1; j < i; j++) {
                int k = ss[i] - ss[j];
                // 我们知道从位置 j 到位置 i 是需要步长为 k 的跳跃
                // 而从位置 j 发起的跳跃最多不超过 j + 1
                // 因为每次跳跃,下标至少增加 1,而步长最多增加 1 
                if (k <= j + 1) {
                    f[i][k] = f[j][k - 1] || f[j][k] || f[j][k + 1];
                }
            }
        }
        for (int i = 1; i < n; i++) {
            if (f[n - 1][i]) return true;
        }
        return false;
    }
}
复制代码


  • 时间复杂度:O(n2)O(n^2)O(n2)
  • 空间复杂度:O(n2)O(n^2)O(n2)


BFS



事实上,前面我们也说到,解决超时 DFS 问题,除了增加「记忆化」功能以外,还能使用带标记的 BFS


因为两者都能解决 DFS 的超时原因:大量的重复计算。


但为了「记忆化搜索」&「动态规划」能够更好的衔接,所以我把 BFS 放到最后。


如果你能够看到这里,那么这里的 BFS 应该看起来会相对轻松。


它更多是作为「记忆化搜索」的另外一种实现形式。


代码:


class Solution {
    Map<Integer, Integer> map = new HashMap<>();
    public boolean canCross(int[] ss) {
        int n = ss.length;
        for (int i = 0; i < n; i++) {
            map.put(ss[i], i);
        }
        // check first step
        if (!map.containsKey(1)) return false;
        boolean[][] vis = new boolean[n][n];
        Deque<int[]> d = new ArrayDeque<>();
        vis[1][1] = true;
        d.addLast(new int[]{1, 1});
        while (!d.isEmpty()) {
            int[] poll = d.pollFirst();
            int idx = poll[0], k = poll[1];
            if (idx == n - 1) return true;
            for (int i = -1; i <= 1; i++) {
                if (k + i == 0) continue;
                int next = ss[idx] + k + i;
                if (map.containsKey(next)) {
                    int nIdx = map.get(next), nK = k + i;
                    if (nIdx == n - 1) return true;
                    if (!vis[nIdx][nK]) {
                        vis[nIdx][nK] = true;
                        d.addLast(new int[]{nIdx, nK});
                    }
                }
            }
        }
        return false;
    }
}
复制代码


  • 时间复杂度:O(n2)O(n^2)O(n2)
  • 空间复杂度:O(n2)O(n^2)O(n2)


最后



这是我们「刷穿 LeetCode」系列文章的第 No.403 篇,系列开始于 2021/01/01,截止于起始日 LeetCode 上共有 1916 道题目,部分是有锁题,我们将先将所有不带锁的题目刷完。


在这个系列文章里面,除了讲解解题思路以外,还会尽可能给出最为简洁的代码。如果涉及通解还会相应的代码模板。


为了方便各位同学能够电脑上进行调试和提交代码,我建立了相关的仓库:github.com/SharingSour…


在仓库地址里,你可以看到系列文章的题解链接、系列文章的相应代码、LeetCode 原题链接和其他优选题解。

相关文章
|
18天前
|
Kubernetes Cloud Native Java
云原生之旅:从容器到微服务的演进之路Java 内存管理:垃圾收集器与性能调优
【8月更文挑战第30天】在数字化时代的浪潮中,企业如何乘风破浪?云原生技术提供了一个强有力的桨。本文将带你从容器技术的基石出发,探索微服务架构的奥秘,最终实现在云端自由翱翔的梦想。我们将一起见证代码如何转化为业务的翅膀,让你的应用在云海中高飞。
|
19天前
|
Java
【思维导图】JAVA网络编程思维升级:URL与URLConnection的逻辑梳理,助你一臂之力!
【思维导图】JAVA网络编程思维升级:URL与URLConnection的逻辑梳理,助你一臂之力!
32 1
|
25天前
|
Java Linux Maven
java依赖冲突解决问题之容器加载依赖jar包如何解决
java依赖冲突解决问题之容器加载依赖jar包如何解决
|
11天前
|
存储 Java 开发者
【Java新纪元启航】JDK 22:解锁未命名变量与模式,让代码更简洁,思维更自由!
【9月更文挑战第7天】JDK 22带来的未命名变量与模式匹配的结合,是Java编程语言发展历程中的一个重要里程碑。它不仅简化了代码,提高了开发效率,更重要的是,它激发了我们对Java编程的新思考,让我们有机会以更加自由、更加创造性的方式解决问题。随着Java生态系统的不断演进,我们有理由相信,未来的Java将更加灵活、更加强大,为开发者们提供更加广阔的舞台。让我们携手并进,共同迎接Java新纪元的到来!
36 11
|
28天前
|
安全 算法 Java
【Java集合类面试二】、 Java中的容器,线程安全和线程不安全的分别有哪些?
这篇文章讨论了Java集合类的线程安全性,列举了线程不安全的集合类(如HashSet、ArrayList、HashMap)和线程安全的集合类(如Vector、Hashtable),同时介绍了Java 5之后提供的java.util.concurrent包中的高效并发集合类,如ConcurrentHashMap和CopyOnWriteArrayList。
【Java集合类面试二】、 Java中的容器,线程安全和线程不安全的分别有哪些?
|
28天前
|
Java 容器
【Java集合类面试一】、 Java中有哪些容器(集合类)?
这篇文章列出了Java中的四大类集合接口:Set、List、Queue和Map,以及它们的常用实现类,如HashSet、TreeSet、ArrayList、LinkedList、ArrayDeque、HashMap和TreeMap。
【Java集合类面试一】、 Java中有哪些容器(集合类)?
|
27天前
|
Java 测试技术 数据库
容器镜像解析问题之解析 Java 应用依赖时识别 jar 包如何解决
容器镜像解析问题之解析 Java 应用依赖时识别 jar 包如何解决
15 0
|
28天前
|
存储 安全 Java
【Java 第四篇章】流程控制、容器
本文档详细介绍了Java中的流程控制、集合类型、数组声明及容器的声明与遍历等内容。在流程控制部分,包括了if、if...else、if...else if...else、switch等语句的使用方法,并提供了具体示例。接着,文档对比分析了Java中单列集合(如HashSet、LinkedHashSet、TreeSet等)与双列集合(如HashMap、LinkedHashMap、Hashtable等)的特点及底层实现原理。此外,还介绍了如何声明与初始化数组,并提供了多种循环结构的使用示例。最后,通过具体的代码示例展示了不同集合类型的声明、基本操作(如添加、删除、更新、查找)以及遍历方法。
12 0
|
2月前
|
Java Scala 流计算
实时计算 Flink版产品使用问题之Docker镜像中的Java路径和容器内的Java路径不一致,是什么导致的
实时计算Flink版作为一种强大的流处理和批处理统一的计算框架,广泛应用于各种需要实时数据处理和分析的场景。实时计算Flink版通常结合SQL接口、DataStream API、以及与上下游数据源和存储系统的丰富连接器,提供了一套全面的解决方案,以应对各种实时计算需求。其低延迟、高吞吐、容错性强的特点,使其成为众多企业和组织实时数据处理首选的技术平台。以下是实时计算Flink版的一些典型使用合集。
|
2月前
|
缓存 安全 Java
Java中的并发容器:ConcurrentHashMap详解
Java中的并发容器:ConcurrentHashMap详解