LC 1102时间复杂度分析
来源:12-6 Dijkstra 和 BFS 的联系

讲武德的年轻人
2022-06-30
bobo老师,我用两种方法做了LC 1102 Path with maximum minimum value这个题目: Dijkstra和二分+DFS。
我的时间复杂度分析是Dijkstra应该比DFS+二分要快,但是结果是二分+DFS要比Dijkstra快一倍,所以想请教您,是否分析有误?
首先,Dijkstra算法的时间复杂度是 M*N*log(M*N),其中M和N是grid矩阵的行数和列数,范围是[0, 100]。
而DFS + 二分的时间复杂度是 log(C)*M*N,其中C是二分搜索的范围,即矩阵的值的范围[0, 10^9]。
在Dijkstra算法中,M*N最大是10^4。所以比较log的order,Dijkstra算法应该更优秀。但是我的代码显示 DFS+二分更好,甚至速度上beat了98%。
所以想请教您,时间复杂度分析是否正确?如果正确的话,如何解释这种差异呢?谢谢老师!
代码如下
class Solution { private int[] dx = {1, 0, -1, 0}; private int[] dy = {0, 1, 0, -1}; public int maximumMinimumPath(int[][] grid) { int m = grid.length; int n = grid[0].length; boolean[][] visited = new boolean[m][n]; Queue<int[]> pq = new PriorityQueue<>((a, b) -> b[1] - a[1]); pq.offer(new int[]{0, grid[0][0]}); while (!pq.isEmpty()) { int x = pq.peek()[0] / n; int y = pq.peek()[0] % n; int val = pq.peek()[1]; pq.poll(); if (x == m - 1 && y == n - 1) return val; if (visited[x][y]) continue; visited[x][y] = true; for (int k = 0; k < 4; k++) { int nx = x + dx[k]; int ny = y + dy[k]; if (!inArea(nx, ny, grid) || visited[nx][ny]) continue; pq.offer(new int[]{nx * n + ny, Math.min(val, grid[nx][ny])}); } } return -1; } private boolean inArea(int x, int y, int[][] grid) { return x >= 0 && x < grid.length && y >= 0 && y < grid[0].length; } }
DFS + Binary Search
class Solution { private int[] dx = {1, 0, -1, 0}; private int[] dy = {0, 1, 0, -1}; public int maximumMinimumPath(int[][] grid) { int l = 0, r = 1000000000; int m = grid.length; int n = grid[0].length; boolean[][] visited; while (l < r) { int mid = l + (r - l + 1) / 2; visited = new boolean[m][n]; if (hasPath(0, 0, mid, grid, visited)) l = mid; else r = mid - 1; } return l; } private boolean hasPath(int x, int y, int score, int[][] grid, boolean[][] visited) { if (grid[x][y] < score) return false; visited[x][y] = true; int m = grid.length; int n = grid[0].length; for (int k = 0; k < 4; k++) { int nx = x + dx[k]; int ny = y + dy[k]; if (!inArea(nx, ny, grid) || visited[nx][ny] || grid[nx][ny] < score) continue; if (nx == m - 1 && ny == n - 1) return true; if (hasPath(nx, ny, score, grid, visited)) return true; } return false; } private boolean inArea(int x, int y, int[][] grid) { return x >= 0 && x < grid.length && y >= 0 && y < grid[0].length; } }
1回答
-
非常赞的问题。
首先,时间复杂度的分析完全正确。
其次,我测试了一下,这两个代码在我的测试中,时间相差是 100ms 这个级别的,用倍数算大概是 3 倍。通常这个时间差距,结合这两个复杂度分析(大致是 nlogx 级别的),在我看来不是特别构成可以“需要被仔细研究的性能差距”,但如果仔细想,在这个数据条件下,确实很有意思。毕竟两个式子的 log 项相差 log(10^5) 大概就是 60 倍左右,但实际却反超 3 倍,里外里 200 倍的差距,和 m 或者 n 的数据规模在一个级别了,interesting。
我仔细思考了一下,我的解释如下(如果真要特别严谨研究这样的性能问题,要仔细看和分析二者的汇编码或者字节码(对 Java)来说,但我没有做这样的研究,只是直观分析。)
1)
不一定是最重要的,但应该有影响的: 你的代码 1) 的 new 操作更多。而 new 操作是很耗时的。同时 new 操作多也意味着 gc 的工作更频繁,而这钟工作的工作量,在 Java 这种自动回收内存的语言中,是没有体现在代码中的。再一次,如果真要特别严谨的研究性能,使用的语言越底层越好,越上层,越有太多不可见的因素在左右实际性能(除算法之外的)。
在代码1)中,最差情况会有边那么多次的 new 操作,new 出一个大小为 2 的数组入队。这个规模就大概是 4 * m * n = 4e4 这个级别。(实际会更小,因为不是每条边都会被松弛的。)
但是在代码 2) 中,每次二分 new 一个新的 visit 数组,最多 new 操作是 logC 这个级别的,在这个数据下,也就是最多只有 30 次 new 操作。
这两个 new 操作的数量规模是不成比例的。而在一定数据量的情况下,new 操作的数量是会 影响性能的。(最典型的,数据规模达到一定程度的时候,链表的性能开始下降,一个重要的原因就是因为其中 new。)
当然,还有其他因素,比如因为 new 了过多的零碎一维数组,导致代码 1)实际的寻址数量是远超代码 2)的。比如代码1)还使用了大量的除法和求余运算,这些都是代码2)没有的,同时会影响性能的。(虽然都是常数级影响,在一定数据规模或者测试数据下,才能体现出来。)
2)
这一点应该是最重要的。这两个算法其实有”不易察觉“的算法层面的差距。
这个差距就是 dij 算法计算了比 二分 + dfs 更多的信息。
回忆一下,如果我们想要求一个数组中的第 k 小(大)元素,可以对整个数组排序,也可以使用基于快排修改的 select k 算法。后者比前者快,但相应的,前者计算了比后者更多的信息。因为对整个数组排序以后,我们不仅可以轻易知道第 k 小元素,还可以知道第 k + 1 小元素, k + 2 小元素,我们可以知道任意 rank 的元素是谁。但是基于 快排修改的 select k 算法,仅仅求出了第 k 小元素。
恰恰是这个差别,造成了这两个算法性能上的差别。select k 提速的原理就是,每次对于一半(近乎一半)的元素,我们知道 k 肯定落不在这个区间,所以不去管它了。结果就是速度提高了,但是获得的信息少了。
你的这两个代码同理。dij 可以类比成是对整个数组排序。因为运转一遍 dij 之后,我们不仅得到了从 (0, 0) 到 (m - 1, n - 1) 的结果,我们还得到了从 (0, 0) 到 grid 上任意点的结果。我们计算出了比问题要求更多的信息。而通常,获得更多信息,是需要有额外的耗损的。
而你的 dfs + 二分的算法,只计算出了从 (0, 0) 到 (m - 1, n - 1) 的结果,我们无法从这个算法获得从 (0, 0) 到其他点的结果。所以没有 dij 算法的“额外损耗”。
在第二个代码中,这个额外损耗的“节省”表现在哪里?表现在你的第二个算法的下面这个 if:
if
(grid[x][y] < score)
return
false
;
换句话说,你的第二个算法,一旦看见路径上绕不过 < score 的节点,就结束了。虽然从时间复杂度的分析上,是 m * n * logC,每一次二分会在整个 m * n 的 grid 上做事情。但实际上,会有很多轮,根本没有在整个 m * n 的 grid 上做事情,而是提前结束了。最极端的情况,如果 grid[0][0] < score,这一轮二分搜索直接就结束了。这个直接结束的代价就是,不能获得从 0,0 到任意点的结果。但在这个问题下,我们也不 care 其他结果。
但是对于 dij 算法,是实打实的,这个 m * n * log(m * n),没有这个“提前结束”的可能,每一个节点,至少会有一条相连接的边入队,谁都逃不过。也正因为如此,我们其实拿到了从 0,0 到任一点的结果(不过在你的代码中,没有把这些结果存储起来。)
这就是使用大 O 做时间复杂度分析的局限性。大 O 只是给出了一个上界。但是对于具体的代码,这个上界存在“紧”或者“不紧”的区别(哪怕无法得到更紧的数学表达。)在这个例子中,对 dij 算法的 m*n*log(m * n) 的分析,是紧的;对 dfs + 二分的 m * n * logC 的分析,是非常不紧的。这导致了单看这两个式子,前者比后者小,但实际性能,前者比后者慢。
继续加油!:)
012022-07-01
相似问题
回答 1
回答 1