惊!你还不会算时间复杂度?

惊!你还不会算时间复杂度?

基本知识

当我们学数据结构与算法的时候,最基础的就是得会估计算法的时间复杂度大O,如果不会这个我们就没法对比算法的优劣了。对算法的时间复杂度是有数学定义的,其数学定义如下:

如果存在正常数c和 n 0 n_0 n0 使得当 N > n 0 N>n_0 N>n0 T ( N ) ⩽ c f ( N ) T(N)\leqslant cf(N) T(N)cf(N),则记为 T ( N ) = O ( f ( N ) ) T(N)=O(f(N)) T(N)=O(f(N))

例如我们假定 T ( N ) = 1000 N T(N)=1000N T(N)=1000N f ( N ) = N 2 f(N)=N^2 f(N)=N2 ,显然如果当N较小时,前者比后者更大,但是后者会以更快的速度增长,因此若N较大时,后者将会比前者更大。这个转折点就是 n 0 = 1000 n_0=1000 n0=1000。对比定义我们可以发现如果 c = 1 且 n 0 = 1000 c=1且n_0=1000 c=1n0=1000 时, 1000 N ⩽ 1 ∗ N 2 ) 1000N\leqslant1*N^2) 1000N1N2) ,当然取值还有很多,比如可以 c = 10 且 n 0 = 1000 c=10且n_0=1000 c=10n0=1000 ,都可以满足前者小于小于等于后者的要求,因此我们可以说 1000 N = O ( N 2 ) 1000N=O(N^2) 1000N=O(N2)。你可能会觉得稍微有点问题,1000N不是等于O(N)吗?是的,不过说成 O ( N 2 ) O(N^2) O(N2) 没有错,这里只是举个例子,只是不那么严密而已,不过要注意的是,我们在实际使用中应保证尽可能的严密,那才是最好的答案,不然说了跟没说一样。

当我们在说 T ( N ) = O ( f ( N ) ) T(N)=O(f(N)) T(N)=O(f(N)) 时,是说保证函数T(N)在以不快于f(N)的速度增长;因此f(N)是T(N)的上界,T(N)是f(N)的下界。我们在对比函数时,往往对比的是增长速率。

对时间复杂度定义,我们可以推导出两条结论:

  • 如果 T 1 ( N ) = O ( f ( N ) ) 且 T 2 ( N ) = O ( g ( N ) ) T_1(N)=O(f(N))且T_2(N)=O(g(N)) T1(N)=O(f(N))T2(N)=O(g(N)) ,那么:
    • T 1 ( N ) + T ( N ) = m a x ( O ( f ( N ) ) , O ( g ( N ) ) ) T_1(N)+T_(N)=max(O(f(N)),O(g(N))) T1(N)+T(N)=max(O(f(N)),O(g(N)))
    • T 1 ( N ) ∗ T 2 ( N ) = O ( f ( N ) ∗ g ( N ) ) T_1(N)*T_2(N)=O(f(N)*g(N)) T1(N)T2(N)=O(f(N)g(N))
  • 对任意常数k, l o g k N = O ( N ) log^kN=O(N) logkN=O(N)。它说明对数增长得非常缓慢。

这些信息已经足够按照增长率对大部分常见得函数进行分类,我们可以简单得到一个函数增长率的关系:

c ( 常 数 ) ≤ l o g N ≤ l o g 2 N ≤ N ≤ N l o g N ≤ N 2 ≤ N 3 ≤ 2 N c(常数)\le logN\leq log^2N\leq N\leq NlogN\leq N^2\leq N^3\leq 2^N c()logNlog2NNNlogNN2N32N

通常,将常数或者低阶项放进大O是非常不好的习惯。不要说 T ( N ) = O ( 2 N 2 ) 或 T ( N ) = O ( N 2 + N ) T(N)=O(2N^2)或T(N)=O(N^2+N) T(N)=O(2N2)T(N)=O(N2+N)。在这两种情况下,正确的形式应该是 T ( N ) = O ( N 2 ) T(N)=O(N^2) T(N)=O(N2)。简单来说就是在需要大O表示时,各种简化都是可能发生的。低阶项一般可以被忽略,常数也可以被忽略。要求的精度是比较低的。

我们可以通过 lim ⁡ N → ∞ f ( N ) / g ( N ) \lim_{N\to \infty}f(N)/g(N) limNf(N)/g(N) 来确定两个函数的相对增长率,必要的时候还可使用使用洛必达法则。使用这种方法几乎总能够计算出两个函数的相对增长率,不过有时会有些复杂化。有时候不需要求极限便可以通过简单的代数方法求得结果。

比如对比 f ( N ) = N l o g ( N ) 和 g ( N ) = N 1.5 f(N)=Nlog(N)和g(N)=N^{1.5} f(N)=Nlog(N)g(N)=N1.5 谁的增长率更快,显然和对比 f ( N ) = l o g 2 N 和 g ( N ) = N f(N)=log^{2}N和g(N)=N f(N)=log2Ng(N)=N 是一样的,显然后者增长的更快。

计算大O

首先在计算大O的时候我们需要约定一些东西用以简化计算,例如机器在执行代码的时候执行加法、减法、乘法、除法、赋值等等指令的时候,我们不要去关心它们谁快谁慢,应该把它们都看作一个时间单位,同时假设机器应该有足够的内存,不会发生问题等等。虽然现实生活中我们的假设很可能不存在,不过大O从来不是一个精确的值,能较好的估算就行。

首先看一个简单的例子:

1	public static int sum(int N) {
2	    int partialSum = 0;
3	    for (int i=1; i<=N; i++) {
4	        partialSum += i*i*i;
5	    }
6	    return partialSum;
7	}

我们先来详细分析一下这个程序,看它占执行需要花费多少时间。

  • 第二行变量声明不计时间,而初始化占1个时间单元。
  • 第6行返回值占用1个时间单元。
  • 第三行i的初始化占1个时间单元,自增运算增加N次占N个时间单元,i<=N逻辑运算,运算N+1次占用N+1个时间单元。
  • 第四行一个+和三个*都运算N次,总共占4N个时间单元。

那么程序总花销应该是1+1+1+N+N+1+4N=6N+4个时间单元。那么我们应该说这个程序是O(N)。N的系数以及后面跟的常数我们都给它简化了,因为它们不是一个量级。明白了吗?

为什么要简化?想一想你每看到一个程序时都像刚才那样一行一行分析时间,你迟早会崩溃的,因此在计算大O的时候各种简化都有可能发生,比如我们还看这个示例程序,其实分析它的大O很简单,从上到下语句执行是O(1),而里面有个for循环,共循环N次,那么程序的大O就是O(N)了,不需要我们再复杂的去一条条计算。

如上所讲一样,有时候分析程序时间复杂度往往看一部分就够了,在计算大O的时候可以使用这些一般法则:

  • for循环:一个for循环的运行时间应该至多是循环内的语句的运行时间乘以迭代的次数。
  • 嵌套for循环:应该从里向外分析这些循环,在一组嵌套循环内部的一条语句的总运行时间为该语句的运行时间乘以该组所有的for循环的大小的乘积。
  • 顺序语句:将各个语句的运行时间求和即可,最终大O就是最大的那个量级。
  • if/else语句:一个if/else语句的运行时间应该是判断花费的时间与各部分时间最长者之和。(注意如果这么算可能有时候会估计的过高,但是绝不会估计低。比如if中的运行时间是 O ( N 2 ) , 但 是 e l s e 中 是 O ( N ) O(N^2),但是else中是 O(N) O(N2)elseO(N) ,而在实际判断中极少用到if中的语句块,那么就估计过高了,不过没关系,我们就是需要算最坏情况。)

知道了上边这写,那么对于简单的for循环、if判断语句等时间复杂度应该就会计算了,因为往往只需要看循环次数等就行了,不过可能对于log(N)之类的还不大会,那我们先看两个简单的例子:

public static long factorial( in N ) {
    if (N <= 1)
        return 1;
    else
        return N*factorial(N - 1);
}

这是一个非常基础的递归程序,那么各位看它的时间复杂度应该为多少?很简单,总共递归了N-1次,并且没有额外花销,那么时间复杂度当然是O(N)

那么再看下面这个程序:

public static long fib( int N ) {
    if (N <= 1)
        return 1;
    else
        return fib(N-1) + fib(N-2);
}

那这个程序时间复杂度是多少呢?乍一看好像每次递归都调用两次函数,那么是 O ( 2 N ) O(2^N) O(2N) ? 其实差不多,如果你去详细计算的话会发现它的时间复杂度应该为 ( 3 2 ) N 与 ( 5 3 ) N (\frac{3}{2})^N与(\frac{5}{3})^N (23)N(35)N 之间,那么我们也可以看作是 O ( 2 N ) O(2^N) O(2N)

OK,上边两个应该没有什么问题,咱们看看诸如 O ( N l o g N ) O(NlogN) O(NlogN) 之类的时间复杂度是怎么来的。

还记得我们上次发过的最大子序列和的算法问题吗?其中有一个分治+递归的解法,它就是 $ O(2^N)$ 的,如果忘了或者没看过的读者可以点击下方链接看一下。

留出链接

那个算法的程序如下:

public class maxVlaueSum2 {
    public static void main(String[] args) {
        // 初始化数组
        int[] array = {-2,11,-4,13,-5,-2};
        System.out.println(maxSum(array,0,array.length-1));
    }

    /**
     * 递归求解函数
     * @param array :传入要解决的数组
     * @param left  :要解决子问题的左界
     * @param right :要解决子问题的右界
     * @return  :返回三个部分的最大值
     */
    public static int maxSum(int[] array,int left,int right) {
        //   BASE情况,如果递归到了最后一层,那么返回合适的值,小于0返回0,大于0返回该数。
1	        if(left == right) {
2	            if (array[left] > 0)
3	                return array[left];
4	            else return 0;
5	        }
6	        int center = (left+right)/2;    //  取得数组中点
7	        int leftSubSUM = maxSum(array,left,center);     //  子问题左半部分最优解
8	        int rightSubSUM = maxSum(array,center+1,right);     //  子问题右半部分最优解

9	        int leftNowSum = 0, maxleftSum = 0;     //  当前问题左半部分等待求和的当前值以及最大值

        //  遍历求包含左半部分最后一个值的最大值
10	        for (int i=center;i>=left;i--) {
11	            leftNowSum += array[i];
12	            if (leftNowSum > maxleftSum) {
13	                maxleftSum = leftNowSum;
14	            }
15	        }
16	        int rightNowSum = 0,maxrightSum = 0;    //  当前问题右半部分等待求和的当前值以及最大值

        //  遍历求包含右半部分的第一个值的最大值
17	        for (int j=center+1;j<=right;j++) {
18	            rightNowSum += array[j];
19	            if (rightNowSum>maxrightSum) {
20	                maxrightSum = rightNowSum;
21	            }
22	        }
        //  返回当前问题的左半部分最大值、右半部分的最大值和横跨两部分最大值的最大值。即为当前问题的解。
23	        return Math.max(Math.max(leftSubSUM,rightSubSUM),(maxleftSum+maxrightSum));
24	    }
25	}

我们看这个程序,先来简单分析一下。

首先设 T ( N ) T(N) T(N) 为求解大小为N的最大子序列和问题所花费的时间。

假如数组只有一个元素,那么就是基准情况,这样的话程序从第4行就返回,时间复杂度为 O ( 1 ) O(1) O(1) ,这个时候 T ( 1 ) = 1 T(1)=1 T(1)=1 ,如果数组不止一个元素,那么程序会至少多执行两个递归调用两个for循环

对于两个for循环来讲,它们加起来的时间复杂度也才 O ( 2 N ) O(2N) O(2N) ,也就是 O ( N ) O(N) O(N) ,除了两个递归调用外,其余的时间复杂的都为 O ( 1 ) O(1) O(1),而对于两个递归调用,首先我们需要明白它们做了什么,它们是把问题分成了两个相等大小的子问题,对于任一个递归调用来讲,它所花费的时间应该为 T ( N 2 ) T(\frac{N}{2}) T(2N) ,因此两个递归调用花费的时间之和应该为 2 T ( N 2 ) 2T(\frac{N}{2}) 2T(2N) ,那么程序的花费时间我们就算出来了,低量级的直接忽略,得到方程组:

T ( 1 ) = 1 ( N = 1 ) T(1)=1 (N=1) T(1)=1(N=1)

T ( N ) = 2 T ( N 2 ) + O ( N ) T(N)=2T(\frac{N}{2})+O(N) T(N)=2T(2N)+O(N)

为了简化计算,现用N代替上式中的 O ( N ) O(N) O(N) ,因为最终 T ( N ) T(N) T(N) 还是要转化为大O来表示,因此这么做不会影响答案。由这个方程组可以解出来 T ( N ) T(N) T(N) 的时间复杂度。

来看第二个方程,

首先等式两边同除以N,得到:

T ( N ) N = T ( N 2 ) N 2 + 1 \frac{T(N)}{N}=\frac{T(\frac{N}{2})}{\frac{N}{2}}+1 NT(N)=2NT(2N)+1

因为将式子中的N同时换为另一个式子时等式不变,那么式子可以做如下变化:

T ( N 2 ) N 2 = T ( N 4 ) N 4 + 1 \frac{T(\frac{N}{2})}{\frac{N}{2}}=\frac{T(\frac{N}{4})}{\frac{N}{4}}+1 2NT(2N)=4NT(4N)+1

T ( N 4 ) N 4 = T ( N 8 ) N 8 + 1 \frac{T(\frac{N}{4})}{\frac{N}{4}}=\frac{T(\frac{N}{8})}{\frac{N}{8}}+1 4NT(4N)=8NT(8N)+1

T ( 2 ) 2 = T ( 1 ) 1 + 1 \frac{T(2)}{2}=\frac{T(1)}{1}+1 2T(2)=1T(1)+1

注意把N换成了 N 2 x \frac{N}{2^x} 2xN ,其中 2 x 2^x 2x 从2到N,那么就有了上面那些式子。

然后把所有式子等式两边分别求和,可以发现大部分都被消去,得到如下式子:

T ( N ) N = T ( 1 ) 1 + l o g N \frac{T(N)}{N}=\frac{T(1)}{1}+logN NT(N)=1T(1)+logN

又因为 T ( 1 ) = 1 T(1)=1 T(1)=1 ,同时式子两边同乘以N,得到:

T ( N ) = N + N l o g N T(N)=N+NlogN T(N)=N+NlogN

那么 T ( N ) T(N) T(N) 的时间复杂度不就变成了 O ( N l o g N ) O(NlogN) O(NlogN) 吗?明白了吗?

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

阳寜

“请作者吃颗糖!”

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

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

打赏作者

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

抵扣说明:

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

余额充值