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回答

liuyubobobo

2022-07-01

非常赞的问题。


首先,时间复杂度的分析完全正确。


其次,我测试了一下,这两个代码在我的测试中,时间相差是 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 的分析,是非常不紧的。这导致了单看这两个式子,前者比后者小,但实际性能,前者比后者慢。


继续加油!:)

0
1
讲武德的年轻人
多谢老师,醍醐灌顶!
2022-07-01
共1条回复

玩转算法系列--图论精讲(Java版)

30+小时系统学习,bobo带你克服被图论支配的恐惧

1599 学习 · 330 问题

查看课程