LeetCode 双周赛 102,模拟 / BFS / Dijkstra / Floyd

本文已收录到 AndroidFamily,技术和职场问题,请关注公众号 [彭旭锐] 提问。

大家好,欢迎来到小彭的 LeetCode 周赛解题报告。

昨晚是 LeetCode 双周赛第 102 场,你参加了吗?这场比赛比较简单,拼的是板子手速,继上周掉大分后算是回了一口血 😁。


2618. 查询网格图中每一列的宽度(Easy)

简单模拟题,无需解释。

  • 模拟: O ( n m ) O(nm) O(nm)

2619. 一个数组所有前缀的分数(Medium)

简单动态规划题,简单到像模拟题。

  • 动态规划: O ( n ) O(n) O(n)

2620. 二叉树的堂兄弟节点 II(Medium)

思考过程:递归→DFS→BFS。由于堂兄弟节点都在同一层,发现 “递归地减少问题规模求解原问题” 和 DFS 都不好编码,而 BFS 更符合 “层” 的概念。往 BFS 方向思考后,容易找到解决方法。

  • BFS: O ( n ) O(n) O(n)

2621. 设计可以求最短路径的图类(Hard)

最近周赛的最短路问题非常多,印象中已经连续出现三次最短路问题。理解 Dijkstra 算法和 Floyd 算法的应用场景非常重要。

  • 朴素 Dijkstra: O ( m + q 1 ⋅ n 2 + q 2 ) O(m + q_1·n^2 + q_2) O(m+q1n2+q2)
  • Dijkstra + 最小堆: O ( m + q 1 ⋅ n l g m + q 2 ) O(m + q_1·nlgm+q_2) O(m+q1nlgm+q2)
  • Floyd: O ( m + n 3 + q 1 + q 2 ⋅ n 2 ) O(m + n^3 + q_1 + q_2·n^2) O(m+n3+q1+q2n2)


2618. 查询网格图中每一列的宽度(Easy)

题目地址

https://leetcode.cn/problems/find-the-width-of-columns-of-a-grid/description/

题目描述

给你一个下标从 0 开始的 m x n 整数矩阵 grid 。矩阵中某一列的宽度是这一列数字的最大 字符串长度

  • 比方说,如果 grid = [[-10], [3], [12]] ,那么唯一一列的宽度是 3 ,因为 10 的字符串长度为 3

请你返回一个大小为 n 的整数数组 ans ,其中 ans[i] 是第 i 列的宽度。

一个有 len 个数位的整数 x ,如果是非负数,那么 字符串长度len ,否则为 len + 1

题解(模拟)

class Solution {
    fun findColumnWidth(grid: Array<IntArray>): IntArray {
        val m = grid.size
        val n = grid[0].size
        val ret = IntArray(n)
        for (column in 0 until n) {
            for (row in 0 until m) {
                ret[column] = Math.max(ret[column], "${grid[row][column]}".length)
            }
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度: O ( n m ) O(nm) O(nm) 其中 n n n m m m 为 grid 数组的行列大小,每个节点最多访问 1 次;
  • 空间复杂度: O ( 1 ) O(1) O(1) 不考虑结果数组。

2619. 一个数组所有前缀的分数(Medium)

题目地址

https://leetcode.cn/problems/find-the-score-of-all-prefixes-of-an-array/description/

题目描述

定义一个数组 arr转换数组 conver 为:

  • conver[i] = arr[i] + max(arr[0..i]),其中 max(arr[0..i]) 是满足 0 <= j <= i 的所有 arr[j] 中的最大值。

定义一个数组 arr分数arr 转换数组中所有元素的和。

给你一个下标从 0 开始长度为 n 的整数数组 nums ,请你返回一个长度为 n 的数组 **ans ,其中 ans[i]是前缀 nums[0..i] 的分数。

题解(动态规划)

简单动态规划题,容易发现递归关系:

  • conver[i] = max{maxNum, arr[i]}
  • dp[i] = dp[i-1] + conver[i]
class Solution {
    fun findPrefixScore(nums: IntArray): LongArray {
        val n = nums.size
        val ret = LongArray(n)
        // 初始状态
        ret[0] = 2L * nums[0]
        var maxNum = nums[0]
        // DP
        for (i in 1 until n) {
            maxNum = Math.max(maxNum, nums[i])
            ret[i] = ret[i - 1] + (0L + nums[i] + maxNum)
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度: O ( n ) O(n) O(n) 其中 n n n a r r arr arr 数组的长度,每个节点最多访问 1 次;
  • 空间复杂度: O ( 1 ) O(1) O(1) 不考虑结果数组。

2620. 二叉树的堂兄弟节点 II(Medium)

题目地址

https://leetcode.cn/problems/cousins-in-binary-tree-ii/description/

题目描述

给你一棵二叉树的根 root ,请你将每个节点的值替换成该节点的所有 堂兄弟节点值的和

如果两个节点在树中有相同的深度且它们的父节点不同,那么它们互为 堂兄弟

请你返回修改值之后,树的根 **root **。

注意,一个节点的深度指的是从树根节点到这个节点经过的边数。

题解(BFS)

分析 1 - 递归:尝试分解左右子树求解问题,发现左右子树不独立,不再考虑此思路;

分析 2 - DFS / BFS:由于堂兄弟节点都在同一层,而 BFS 更符合 “层” 的概念,往 BFS 方向思考后,容易找到解决方法:在处理每一层的节点时,第一轮遍历先累计下一层节点的和,在第二轮遍历时更新下一层节点(取出自己和兄弟节点的值)。

/**
 * Example:
 * var ti = TreeNode(5)
 * var v = ti.`val`
 * Definition for a binary tree node.
 * class TreeNode(var `val`: Int) {
 *     var left: TreeNode? = null
 *     var right: TreeNode? = null
 * }
 */
class Solution {
    fun replaceValueInTree(root: TreeNode?): TreeNode? {
        if (null == root) return root
        // BFS
        val queue = LinkedList<TreeNode>()
        queue.offer(root)
        root.`val` = 0
        while (!queue.isEmpty()) {
            val size = queue.size
            // 计算下一层的和
            var nextLevelSum = 0
            for (i in 0 until size) {
                val node = queue[i]
                if (null != node.left) nextLevelSum += node.left.`val`
                if (null != node.right) nextLevelSum += node.right.`val`
            }
            for (count in 0 until size) {
                val node = queue.poll()
                // 减去非堂兄弟节点
                var nextLevelSumWithoutNode = nextLevelSum
                if (null != node.left) nextLevelSumWithoutNode -= node.left.`val`
                if (null != node.right) nextLevelSumWithoutNode -= node.right.`val`
                // 入队
                if (null != node.left) {
                    queue.offer(node.left)
                    node.left.`val` = nextLevelSumWithoutNode
                }
                if (null != node.right) {
                    queue.offer(node.right)
                    node.right.`val` = nextLevelSumWithoutNode
                }
            }
        }
        return root
    }
}

复杂度分析:

  • 时间复杂度: O ( n ) O(n) O(n) 其中 n 为二叉树的节点总数,每个节点最多访问 2 次(含入队 1 次);
  • 空间复杂度: O ( n ) O(n) O(n) BFS 队列空间。

相似题目:


2621. 设计可以求最短路径的图类(Hard)

题目地址

https://leetcode.cn/problems/design-graph-with-shortest-path-calculator/

题目描述

给你一个有 n 个节点的 有向带权 图,节点编号为 0n - 1 。图中的初始边用数组 edges 表示,其中 edges[i] = [fromi, toi, edgeCosti] 表示从 fromitoi 有一条代价为 edgeCosti 的边。

请你实现一个 Graph 类:

  • Graph(int n, int[][] edges) 初始化图有 n 个节点,并输入初始边。
  • addEdge(int[] edge) 向边集中添加一条边,其中 ****edge = [from, to, edgeCost] 。数据保证添加这条边之前对应的两个节点之间没有有向边。
  • int shortestPath(int node1, int node2) 返回从节点 node1node2 的路径 最小 代价。如果路径不存在,返回 1 。一条路径的代价是路径中所有边代价之和。

问题分析

这道题勉强能算 Floyd 算法或 Dijkstra 算法的模板题,先回顾一下最短路问题解决方案:

  • Dijkstra 算法(单源正权最短路):
    • 本质上是贪心 + BFS;
    • 负权边会破坏贪心策略的选择,无法处理含负权问题;
    • 稀疏图小顶堆的写法更优,稠密图朴素写法更优。
  • Floyd 算法(多源汇正权最短路)
  • Bellman Ford 算法(单源负权最短路)
  • SPFA 算法(单源负权最短路)

由于这道题需要支持多次查询操作,而 Floyd 算法能够缓存最短路结果,理论上 Floyd 算法是更优的选择。不过,我们观察到题目的数据量非常非常小,所以朴素 Dijkstra 算法也能通过。

题解一(朴素 Dijkstra)

这道题的查询操作是求从一个源点到目标点的最短路径,并且这条路径上没有负权值,符合 Dijkstra 算法的应用场景,在处理添加边时,只需要动态的修改图数据结构。

Dijkstra 算法的本质是贪心 + BFS,我们需要将所有节点分为 2 类,在每一轮迭代中,我们从 “候选集” 中选择距离起点最短路长度最小的节点,由于该点不存在更优解,所以可以用该点来 “松弛” 相邻节点。

  • 1、确定集:已确定(从起点开始)到当前节点最短路径的节点;
  • 2、候选集:未确定(从起点开始)到当前节点最短路径的节点。

技巧:使用较大的整数 0x3F3F3F3F 代替整数最大值 Integer.MAX_VALUE 可以减少加法越界判断。

class Graph(val n: Int, edges: Array<IntArray>) {

    private val INF = 0x3F3F3F3F

    // 带权有向图(临接矩阵)
    private val graph = Array(n) { IntArray(n) { INF } }

    init {
        // i 自旋的路径长度
        for (i in 0 until n) {
            graph[i][i] = 0
        }
        // i 直达 j 的路径长度
        for (edge in edges) {
            addEdge(edge)
        }
    }

    fun addEdge(edge: IntArray) {
        graph[edge[0]][edge[1]] = edge[2]
    }

    fun shortestPath(node1: Int, node2: Int): Int {
        // Dijkstra

        // 最短路
        val dst = IntArray(n) { INF }
        dst[node1] = 0
        // 确定标记
        val visited = BooleanArray(n)
        // 迭代 n - 1 次
        for (count in 0 until n - 1) {
            // 寻找候选集中最短路长度最短的节点
            var x = -1
            for (i in 0 until n) {
                if (!visited[i] && (-1 == x || dst[i] < dst[x])) x = i
            }
            // start 可达的节点都访问过 || 已确定 node1 -> node2 的最短路
            if (-1 == x || dst[x] == INF || x == node2) break
            visited[x] = true
            // 松弛相邻节点
            for (y in 0 until n) {
                dst[y] = Math.min(dst[y], dst[x] + graph[x][y])
            }
        }
        return if (INF == dst[node2]) -1 else dst[node2]
    }
}

复杂度分析:

  • 时间复杂度: O ( m + q 1 ⋅ n 2 + q 2 ) O(m + q_1·n^2 + q_2) O(m+q1n2+q2) 其中 n 为节点数量,m 为边数量, q 1 q_1 q1 为查询次数, q 2 q_2 q2 为添加边次数。建图时间 O(m),每个节点访问 n 次;
  • 空间复杂度: O ( n 2 + n ) O(n^2 + n) O(n2+n) 图空间 + 最短路数组

题解二(Dijkstra + 最小堆)

这道题是稠密图,朴素 Dijkstra 由于 Dijkstra + 最小堆。

朴素 Dijkstra 的每轮迭代中需要遍历 n 个节点寻找候选集中的最短路长度。事实上,这 n 个节点中有部分是 ”确定集“,有部分是远离起点的边缘节点,每一轮都遍历显得没有必要。我们使用小顶堆记录候选集中最近深度的节点。

class Graph(val n: Int, edges: Array<IntArray>) {

    private val INF = 0x3F3F3F3F

    // 带权有向图(临接矩阵)
    private val graph = Array(n) { IntArray(n) { INF } }

    init {
        // i 自旋的路径长度
        for (i in 0 until n) {
            graph[i][i] = 0
        }
        // i 直达 j 的路径长度
        for (edge in edges) {
            addEdge(edge)
        }
    }

    fun addEdge(edge: IntArray) {
        graph[edge[0]][edge[1]] = edge[2]
    }

    fun shortestPath(node1: Int, node2: Int): Int {
        // Dijkstra + 最小堆

        // 最短路
        val dst = IntArray(n) { INF }
        dst[node1] = 0
        val heap = PriorityQueue<Int>() { i1, i2 ->
            dst[i1] - dst[i2]
        }
        heap.offer(node1)
        while (!heap.isEmpty()) {
            // 使用 O(lgm) 时间找出最短路长度
            var x = heap.poll()
            // 松弛相邻节点
            for (y in 0 until n) {
                if (dst[x] + graph[x][y] < dst[y]) {
                    dst[y] = dst[x] + graph[x][y]
                    heap.offer(y)
                }
            }
        }
        return if (INF == dst[node2]) -1 else dst[node2]
    }
}

复杂度分析:

  • 时间复杂度: O ( m + q 1 ⋅ n l g m + q 2 ) O(m + q_1·nlgm+q_2) O(m+q1nlgm+q2) 其中 n 为节点数量,m 为边数量, q 1 q_1 q1 为查询次数, q 2 q_2 q2 为添加边次数。建图时间 O ( m ) O(m) O(m),每条边都会访问一次,每轮迭代取堆顶 O(lgm)。这道题边数大于点数,朴素写法更优。
  • 空间复杂度: O ( n 2 + n ) O(n^2 + n) O(n2+n) 图空间 + 堆空间。

题解三(Floyd)

Fload 算法的本质是贪心 + BFS,我们需要三层循环枚举中转点 i、枚举起点 j 和枚举终点 k,如果 dst[i][k] + dst[k][j] < dst[i][j],则可以松弛 dst[i][j]。

这道题的另一个关键点在于支持调用 addEdge() 动态添加边,所以使用 Floyd 算法时要考虑如何更新存量图。

class Graph(val n: Int, edges: Array<IntArray>) {

    val INF = 0x3F3F3F3F

    // 路径长度(带权有向图)
    val graph = Array(n) { IntArray(n) { INF } }

    init {
        // i 自旋的路径长度
        for (i in 0 until n) {
            graph[i][i] = 0
        }
        // i 直达 j 的路径长度
        for (edge in edges) {
            graph[edge[0]][edge[1]] = edge[2]
        }
        // Floyd 算法
        // 枚举中转点
        for (k in 0 until n) {
            // 枚举起点
            for (i in 0 until n) {
                // 枚举终点
                for (j in 0 until n) {
                    // 比较 <i to j> 与 <i to p> + <p to j>
                    graph[i][j] = Math.min(graph[i][j], graph[i][k] + graph[k][j])
                }
            }
        }
    }

    fun addEdge(edge: IntArray) {
        val (x, y, cost) = edge
        // 直达
        graph[x][y] = Math.min(graph[x][y], cost)
        // 枚举中转点
        for (k in intArrayOf(x, y)) {
            // 枚举起点
            for (i in 0 until n) {
                // 枚举终点
                for (j in 0 until n) {
                    // 比较 <i to j> 与 <i to k> + <k to j>
                    graph[i][j] = Math.min(graph[i][j], graph[i][k] + graph[k][j])
                }
            }
        }
    }

    fun shortestPath(node1: Int, node2: Int): Int {
        return if (graph[node1][node2] == INF) -1 else graph[node1][node2]
    }
}

复杂度分析:

  • 时间复杂度: O ( m + n 3 + q 1 + q 2 ⋅ n 2 ) O(m + n^3 + q_1 + q_2·n^2) O(m+n3+q1+q2n2) 其中 n n n 为节点数量, m m m 为边数量, q 1 q_1 q1 为查询次数, q 2 q_2 q2 为添加边次数。建图时间 O ( m + n 3 ) O(m + n^3) O(m+n3),单次查询时间 O ( 1 ) O(1) O(1),单次添加边时间 O ( n 2 ) O(n^2) O(n2)
  • 空间复杂度: O ( n 2 ) O(n^2) O(n2) 图空间。

相关题目:

近期周赛最短路问题:

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
### 回答1: 好的,我来用中文回复这个链接:https://leetcode-cn.com/tag/dynamic-programming/ 这个链接是 LeetCode 上关于动态规划的题目集合。动态规划是一种常用的算法思想,可以用来解决很多实际问题,比如最长公共子序列、背包问题、最短路径等等。在 LeetCode 上,动态规划也是一个非常重要的题型,很多题目都需要用到动态规划的思想来解决。 这个链接里包含了很多关于动态规划的题目,按照难度从简单到困难排列。每个题目都有详细的题目描述、输入输出样例、题目解析和代码实现等内容,非常适合想要学习动态规划算法的人来练习和提高自己的能力。 总之,这个链接是一个非常好的学习动态规划算法的资源,建议大家多多利用。 ### 回答2: 动态规划是一种算法思想,通常用于优化具有重叠子问题和最优子结构性质的问题。由于其成熟的数学理论和强大的实用效果,动态规划在计算机科学、数学、经济学、管理学等领域均有重要应用。 在计算机科学领域,动态规划常用于解决最优化问题,如背包问题、图像处理、语音识别、自然语言处理等。同时,在计算机网络和分布式系统中,动态规划也广泛应用于各种优化算法中,如链路优化、路由算法、网络流量控制等。 对于算法领域的程序员而言,动态规划是一种必要的技能和知识点。在LeetCode这样的程序员平台上,题目分类和标签设置十分细致和方便,方便程序员查找并深入学习不同类型的算法LeetCode的动态规划标签下的题目涵盖了各种难度级别和场景的问题。从简单的斐波那契数列、迷宫问题到可以用于实际应用的背包问题、最长公共子序列等,难度不断递进且话题丰富,有助于开发人员掌握动态规划的实际应用技能和抽象思维模式。 因此,深入LeetCode动态规划分类下的题目学习和练习,对于程序员的职业发展和技能提升有着重要的意义。 ### 回答3: 动态规划是一种常见的算法思想,它通过将问题拆分成子问题的方式进行求解。在LeetCode中,动态规划标签涵盖了众多经典和优美的算法问题,例如斐波那契数列、矩阵链乘法、背包问题等。 动态规划的核心思想是“记忆化搜索”,即将中间状态保存下来,避免重复计算。通常情况下,我们会使用一张二维表来记录状态转移过程中的中间值,例如动态规划求解斐波那契数列问题时,就可以定义一个二维数组f[i][j],代表第i项斐波那契数列中,第j个元素的值。 在LeetCode中,动态规划标签下有众多难度不同的问题。例如,经典的“爬楼梯”问题,要求我们计算到n级楼梯的方案数。这个问题的解法非常简单,只需要维护一个长度为n的数组,记录到达每一级楼梯的方案数即可。类似的问题还有“零钱兑换”、“乘积最大子数组”、“通配符匹配”等,它们都采用了类似的动态规划思想,通过拆分问题、保存中间状态来求解问题。 需要注意的是,动态规划算法并不是万能的,它虽然可以处理众多经典问题,但在某些场景下并不适用。例如,某些问题的状态转移过程比较复杂,或者状态转移方程中存在多个参数,这些情况下使用动态规划算法可能会变得比较麻烦。此外,动态规划算法也存在一些常见误区,例如错用贪心思想、未考虑边界情况等。 总之,掌握动态规划算法对于LeetCode的学习和解题都非常重要。除了刷题以外,我们还可以通过阅读经典的动态规划书籍,例如《算法进阶指南》、《算法与数据结构基础》等,来深入理解这种算法思想。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

彭旭锐

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值