恋上数据结构笔记:复杂度

杂记1588 字

什么是算法?

算法理解起来其实非常简单,就是是用于解决特定问题的一系列的执行步骤。

// 计算 a 跟 b 的和
public  static int plus(int a, int b ) {
        return a + b;
}

上面的代码很简单,但是它其实也是一个算法,因为它解决了加法运算。

// 计算 1 + 2 +3 ... +n 的和

public static int sum(int n ) {
        int result =0;
    for (int i = 1; i <= n; i++) {
                result += i
        }
        return result;
}

sum 也是一个算法,它解决了从 1 加到 n 的问题,这两个我们都可以称之为算法,算法其实就是你能解决问题就可以了,小到一行代码,大到上万行都可以称之为算法。

但是当我们使用不同额算法,解决同一个问题,有时效率可能相差非常大。因为我们都知道当我们解决一个问题的时候,解决方案可能会有很多,写代码也是如此,不同算法之间执行效率可能会相差很多。

public static int sum2(int n) {
        return (1 + n) * n / 2;
}

就拿上面从 1 到 n 计算累加的算法,其实让我们如果使用数学公式解决的话,一行代码就搞定了,根本不需要使用循环。

再举个例子:求第 n 个斐波拉切(fibonacci number)

求斐波拉切的方式有很多,在这里我们用两种方案来实现:

  • 递归
public static int fib(int n) {
        if (n <= 1) {
                return n;
        }
        return fib(n - 1) + fib(n - 2);
}

这是一种解决方案,肯定能够解决我们的问题,但是这个算法有性能问题,当我们把 n 写成 64 的时候,这个算法的执行效率就开始降低了,需要执行很久才能够返回。原因我们在后面说,我们先来换一个算法。

  • 动态规划

我们将斐波拉切数列展开:

0 1 1 2 3 5 8 13 ....

我们发现,其实最终值就是前两个值加在一起,那么我们完全可以将前面的值保存起来。


public staitc int fib (int n) {
      if (n <= 1) {
              return n;
      }
   
    int first = 0;
    int second = 1;

    for (int i =0; i < n - 1; i++ ) {
                 int sum = first + second;
        first = second;
            second = sum;
    }
        return second;
}

当我们使用 fib(64) 的时候几乎是秒出结果。

这也就证明了,当我们以后解决问题的时候会有很多种算法,但是算法之间效率相差非常大。这两个算法的差距我们该如何测量呢?

我们可以计算它们给出结果花费时间。

package com.mj;

import java.text.SimpleDateFormat;
import java.util.Date;

public class Times {
    private static final SimpleDateFormat fmt = new SimpleDateFormat("HH:mm:ss.SSS");
    
    public interface Task {
        void execute();
    }
    
    public static void test(String title, Task task) {
        if (task == null) return;
        title = (title == null) ? "" : ("【" + title + "】");
        System.out.println(title);
        System.out.println("开始:" + fmt.format(new Date()));
        long begin = System.currentTimeMillis();
        task.execute();
        long end = System.currentTimeMillis();
        System.out.println("结束:" + fmt.format(new Date()));
        double delta = (end - begin) / 1000.0;
        System.out.println("耗时:" + delta + "秒");
        System.out.println("-------------------------------------");
    }
}

Times 其实就是一个代理,它可以执行满足 Task 的接口的类,然后在执行前后加入时间计数。这样我们就可以统计算法的执行时长了

int = 45
TimeTool.check("fib1", new Task() {
        public void execute() {
        System.out.println(fib1(n));
        }
});

exec

我们对比两个算法之间的差距,可以发现采用动态规划的的方案,随着数字的增大执行时间并不是很大,但是采用递归的方案,时间就慢慢的增加了。

exec

当我们执行 n = 46 的时候,采用递归的方案时间已经达到了 9.44 秒。

如何评判算法的好坏?

还是拿上面的两个案例来举例,我们可以拿代码越短就证明代码越好吗?

肯定不行,在累加中的确是利用公式更好,因为它代码足够简洁,但是在斐波拉切中,反倒是代码更多的执行效率更好。

如果单从执行效率上进行评估,你可能会得到这么一个方案,比较不同算法对同一组输入的执行处理时间这种方案也叫做:事后统计法。

但是这种方案是有以下缺点:

  • 执行时间严重依赖硬件以及运行时各种不确定的因素环境,例如 CPU 更好,当前进程数量比较少等等。
  • 必须编写相应的测试代码
  • 测试数据的选择比较难保证公正性

一般从以下维度来评估算法的优劣

  • 正确性、可读性、健壮性(对不合理输入的反应能力和处理能力)
  • 时间复杂性(time complexity):估算程序指令的执行次数(执行时间)
  • 空间复杂度(space complexity):估算所需占用的存储空间

算法的评估

我们来算一下面这个函数需要执行多少条指令

public static void test1(int n) {
        // 汇编指令
        
        // 1
        if (n > 10) { 
            System.out.println("n > 10");
        } else if (n > 5) { // 2
            System.out.println("n > 5");
        } else {
            System.out.println("n <= 5"); 
        }
        
        // 1 + 4 + 4 + 4
        for (int i = 0; i < 4; i++) {
            System.out.println("test");
        }
        
        // 140000
        // O(1) 时间复杂度
        // O(1) 空间复杂度
    }

判断的执行指令次数我们假定是 1,这样算其实不太严谨,但是没有关系,我们先按照这个规则计算。

我们继续向下,到了 for 循环的时候 int i = 0,我们算一个指令,i++ 会执行 4 次,i < 4 会被执行 4 次,for 循环中的打印输出也会执行四次,这样就是 1 + 4 + 4+ 4。总的算下来 就是14。

public static void test2(int n) {
        // O(n)
        // 1 + 3n
        for (int i = 0; i < n; i++) {
            System.out.println("test");
        }
}

int = 0 是 1 次, i < n 执行 n 次, i++ 执行 n 次,输出执行 n 次,那么执行指令的次数就是 1 + 3 * n 次。

public static void test3(int n) {
        // 1 + 2n + n * (1 + 3n)
        // 1 + 2n + n + 3n^2
        // 3n^2 + 3n + 1
        // O(n^2)
        
        // O(n)
        for (int i = 0; i < n; i++) {
            for (int j = 0; j < n; j++) {
                System.out.println("test");
            }
        }
    }
public static void test5(int n) {
        // 8 = 2^3
        // 16 = 2^4
        
        // 3 = log2(8)
        // 4 = log2(16)
        
        // 执行次数 = log2(n)
        // O(logn)
        while ((n = n / 2) > 0) {
            System.out.println("test");
        }
    }

    public static void test6(int n) {
        // log5(n)
        // O(logn)
        while ((n = n / 5) > 0) {
            System.out.println("test");
        }
    }

    public static void test7(int n) {
        // 1 + 2*log2(n) + log2(n) * (1 + 3n)
        
        // 1 + 3*log2(n) + 2 * nlog2(n)
        // O(nlogn)
        for (int i = 1; i < n; i = i * 2) {
            // 1 + 3n
            for (int j = 0; j < n; j++) {
                System.out.println("test");
            }
        }
    }

    public static void test10(int n) {
        // O(n)
        int a = 10;
        int b = 20;
        int c = a + b;
        int[] array = new int[n];
        for (int i = 0; i < array.length; i++) {
            System.out.println(array[i] + c);
        }
    }

上面的这些函数计算方式都差不多,我就直接列在这里了。

大 O 表示法

我们已经算出了执行指令的大概次数,但是看上去还是很复杂。这个时候我们就要用大 O 表示法来描述复杂度,它表示的是数据规模 N 的一应的复杂度。

规则如下:

  • 忽略常数、系数、低阶

    • 9 >> O(1)
    • 2n + 3 >> O(n)
    • n^2 + 2n + 6 >> o(n^2)
    • 4n^3 + 3n^2 + 22n + 100 >> o(n^3)

上一节中的函数对应的大 O 表示法,在注释中,可以翻回去看一下。大 O 表示法仅仅是一种粗略的分析模型,是一种估算帮助我们短时间内了解一个算法的执行效率。

对数阶的细节

  • 对数阶一般省略底数

常见的复杂度

table

数据规模对比

数据规模较小时候

数据规模较小

数据规模较大时

数据规模较大

这个对比就已经很明显了。

斐波拉切的时间复杂度

斐波拉切的时间复杂度

斐波拉切的时间复杂度

他们的差距有多大?

  • 如果是一台 1GHz 的普通计算机,运算速度 10^9 次每秒(n 为 64)
  • O(n)大约耗时 6.4 * 18^-18 秒
  • O(2^n)大约耗时 584.94 年
  • 有时候算法之间的差距,往往比硬件方面的差距还大

算法的优化方向

  • 用尽量少的存储空间
  • 用尽量少的执行步骤(执行时间)
  • 根据情况:

    • 空间换时间
    • 时间换空间

多个数据规模的情况

public function void test (int n, int k) {
        for (int i = 0; i < n; i++){
                System.out.println("test")
        }

        for (int i = 0; i < k; i++){
                System.out.println("test")
        }
}

这个函数的时间复杂度是由 n 和 k 决定的,O( n + k) 这两个都不能被忽略,因为是两个数据规模

更多的知识

更多的复杂度相关知识,会在后续讲解数据额机构、算法的过程中穿插

  • 更好、更坏复杂度
  • 均摊复杂度
  • 复杂度震荡
  • 平均复杂度
maksim
Maksim(一笑,吡罗),PHPer,Goper
OωO
开启隐私评论,您的评论仅作者和评论双方可见