Skip to content

算法的复杂度分析 #10

@luo-FrontEnd

Description

@luo-FrontEnd

算法的复杂度分析

参考来源:
    1、《大话数据结构》
    2、极客时间-数据结构与算法之美;

1、开场白

算法的重要性不言而喻。

我们先来看一下算法(Algorithm)的定义:
    算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。

这里我将《大话数据结构》里面的定义拿了过来,其实去维基百科上看一下,基本上一次差不多,但是维基上比较复杂。

算法具有五个特点,同时好的算法有四个设计要求:(可能有些课本不同,求同存异。)
    五个特点是:
        1)输入
        2)输出
        3)有穷性
        4)确定性
        5)可行性
        
    四个要求:
        1)正确性
        2)可读性
        3)健壮性
        4)时间效率高和存储量低
        
具体的可以自行查找,接下来我们进入主题,一个好的算法怎么去度量算法的效率?

2、算法效率的度量方法

算法的度量方法可以分为事后统计方法、事前分析估算法。

首先说说这个事后统计方法。

    这种方法主要是通过设计号的测试程序和数据,利用计算机计时器对不同算法的程序运行时间进行比较。
    其实很容易想到,一个算法都设计好了,怎么度量?当然是前面加个时间,后面加个时间,一减,不就出来了程序跑的时间了。但是,这种方式呢,有一些弊端:
        第一,要事后统计,算法得先写出来吧?如果写出来的算法耗时很高,不就白写了?
        
        第二,这个呢,玩游戏的同学肯定很清楚,一个游戏的流畅度受硬件的影响,同一个游戏,跑在最新的硬件上,基本上可以说比几年前机器要流畅。算法也可以这样想,因为处理算法的运算速度受操作系统、编译器、运行框架等等环境的影响。
        
        第三,算法的测试数据设计困难。这个好像不需要过多解释,因为算法的输入不是固定的,所以谁知道会输入什么样的测试数据呢。
        
    根据上面我们可以暂时不选择事后统计方法,要选的话也只是用来测试,毕竟算法编好了,也是需要测试的。千万不要认为事后统计方法没用了,像leetcode上面,提交能够计算出用时等数据,不就是事后统计方法嘛。

接下来我们来重点看一下事前分析估算法。

    很好解释,事前就是程序编写之前,依据统计方法对算法进行估算。
    经过分析,可以发现一个程序在计算机上运行时所消耗的时间取决于下列因素:
        1)算法采用的策略、方法。
        2)编译产生的代码质量。
        3)问题的输入规模。
        4)机器执行指令的速度。
    第一条是算法好坏的根本,第二条需要由编译器来支持,第四条要看硬件性能。所以抛开固定环境有关的,我们可以这样看:
        一个程序的运行时间,依赖于算法的好坏和问题的输入规模。
        
我们已经选好了算法效率度量的方法了,接下来我们来看看算法到底是怎么度量效率的?

3、算法的时间复杂度

同样的,先看一下算法时间复杂度的定义
    在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记作:T(n) = O(f(n));。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度,简称时间复杂度。

绕的头都晕了,简而言之:一个算法运行时间(抛开环境的影响),是关于这个算法解决问题规模的函数。规模越大,运行时间或者称时间复杂度越高。
现在基本上都在使用大O记法来表示时间复杂度。

上面的定义了解一下就好了,我们来看看时间复杂度怎么分析:

    其实分析时间复杂度,只需要关注循环次数最多的代码块就好了。
    时间复杂度有两个法则:加法法则、乘法法则
    
        时间复杂度的加法法则:T(n) = T1(n) + T2(n) = O(f(n1)) + O(f(n2)) = O(MAX(f(n1),f(n2)))。
            就是说,当循环代码块是独立的,只需要看时间复杂度高的就行了。
            比如:
                let O = function(n) {
                    for(let i = 0; i < n; i++) {
                        ...
                    }
                    let j = 1
                    while (j <= n) {
                        j *= 2;
                    }
                }
            函数O里面包含两个循环,但是两个循环都是独立的,所以时间复杂度是两个循环中最大的一段循环的时间复杂度,这里是O(n),同学们可以想一下,两段循环的事件负责度都是多少。后面会有讲解。
            
        时间复杂度的乘法法则:T(n) = T1(n) * T2(n) = O(f(n1)) * O(f(n2)) = O(f(n1) * f(n2))。
            
            就是说,当循环代码块是嵌套的时候,需要将每个循环的时间复杂度相乘得到总的时间复杂度。
            比如:
                let O2 = function(n) {
                    for(let i = 0; i < n; i++) {
                        let j = 1
                        while (j <= n) {
                            j *= 2;
                        }
                    }
                }
            这里的函数O2里面也是包含两个循环,但是两个循环是嵌套在一起的,所以总的时间复杂度就等于,外层循环时间复杂*内存循环时间复杂度。这里的时间复杂度是O(nlogn)。具体每个循环怎么分析等会再说,只需要直到,外层时间复杂度O(n),内层的是O(logn)。
            
    上面是时间复杂度的两个法则,现在我们来看一下几种常见的时间复杂度具体分析。
    
    1)常量阶O(1)
    2)对数阶O(logn)
    3)线性阶O(n)
    4)线性对数阶O(nlogn)
    5)平方阶O(n^2)
    6)指数阶O(2^n)
    7)阶乘阶O(n!)
    
    是不是觉得括号里面的表达式很熟悉?没错,这就是基本初等函数表达式,如果不记得的话可以查看高数上的第一章。
    
    我们都知道,时间复杂度是算法执行时间的增长率。结合上面常见的时间复杂度表示,我们不用看下面也能推出:
        O(1) < O(logn) < O(nlogn) < O(n) < O(n^2) < O(2^n) < O(n!)
    这是根据函数的图形得出来的,可以尝试画出上述函数的图形,看一下在坐标系中的图形就能直观的感受这个含义。
    
    了解了函数的渐进增长,接下来就一个一个分析下。
    
        1)常数阶:
            就是只执行一次的语句。
            比如:
            function ab() {
                let a = 1,b = 2;     //执行一次
                a += 1;              //执行一次
                b -= 1;              //执行一次
                a = a + b;           //执行一次
            }
            上面这个函数,里面有四个执行一次的语句,实际上不管有多少这样语句,它的时间复杂度都是O(1),而不是O(4)什么的。
            因为这些语句与问题的大小无关。这句话后面多看两个例子就懂了。
        
        2)对数阶O(logn)、线性对数阶O(nlogn):
            还记得上面那个函数嘛?
            let O2 = function(n) {
                for(let i = 0; i < n; i++) {
                    let j = 1
                    while (j <= n) {
                        j *= 2;
                    }
                }
            }
            我们分析一下。外层的先放一下,内层的函数我们拿出来就是:
                let j = 1
                while (j <= n) {
                    j *= 2;
                }
            从代码中可以看出,变量j的值从1开始取,每循环一次就乘以2。当大于n时,循环结束。还记得我们高中学过的等比数列吗?实际上,变量j的取值就是一个等比数列。如果我把它一个一个列出来,就应该是这个样子的:
                2^1 2^2 ... 2^x =n
            => x = log2^n;
            如果是 3^1 3^2 3^3 .. 3^x = n
            => x = log3^n
            但是我们知道 log3^n = log3^2 * log2^n = O(C * log2^n);
            在使用大O表示法的时候,我们可以忽略系数即O(C * f(n)) = O(f(n));
            所以 log3^n => O(log2^n)因此,在对数阶时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O(logn)。
            
            同时我们来说一下O2这个函数,内层是O(logn);外层是下一步说到的线性阶O(n);那根据乘法法则,总的时间复杂度是多少?就不需要说了吧。
        
        3)线性阶O(n):
            这个很好理解,比如
            for(let i = 0; i < n; i++) {
                ...
            }
            这个循环就是循环了n次,直到i = n的时候退出循环,时间复杂度就是O(n)。
            
        4)平方阶O(n^2):
            一个线性阶是O(n),根据乘法法则,两个线性阶嵌套在一起不就是O(n * n)嘛。
            for (let i = 0; i < n; i++) {
                for (let j = 0; j < n; j++) {
                    ...
                }
            }
            这个就是平方阶O(n^2);
            
        5)O(m + n),O(m * n):
            这是拓展的,可以自己写一下时间复杂度是O(m + n),O(m * n)的代码块。

4、算法的空间复杂度

其实空间复杂度比时间复杂度好理解,时间复杂度是算法执行时间与数据规模的增长关系。空间复杂度就是算法的存储空间与数据规模的增长关系。

空间复杂度使用的没有时间复杂度多,因为现在计算机硬件的发展,人们可以更多的拿空间来换取时间。

空间复杂度为O(1):若算法执行时所需的辅助空间相对于输入数据量而言是个常数,则称次算法为原地工作,空间复杂度记为O(1)

5、最好、最坏、平均时间复杂度

最好时间复杂度:在最理想的情况下,执行这段代码的时间复杂度

最坏时间复杂度:在最糟糕的情况下,执行这段代码的时间复杂度

平均时间复杂度:所有情况下,求一个平均值,可以省略掉系数、低阶、常量

平均时间复杂度是所有情况最有意义的,因为它是期望的运行时。

平均时间复杂度计算就是将所有的情况相加 / 运行了多少次情况。这个后面遇到具体的算法会具体分析。

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions