数据结构与算法(二)复杂度

什么是算法

算法是用于解决特定问题的一系列的执行步骤

看下面两段代码,都属于算法

// 计算a+b的和
public static init 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;
} 

使用不同的算法,解决同一个问题,效率可能相差很大

比如:求第n个斐波那契数,见下面代码

// 斐波那契数列,前两位数相加等于后一个数,依次类推
// 0, 1, 1, 2, 3, 5, 8, 13, ....
// 0 + 1 = 1, 
// 1 + 1 = 2, 
// 2 + 3 = 5, 
// 3 + 5 = 8
// .....

对比下面两种算法,哪一种效率更高

// 第一种:递归方式
public static int fib1(int n) {
  if (n <= 1) return n;
  return fib1(n - 1) + fib1(n - 2);
}

// 第二种:循环计算
public static int fib2(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;
}

// 在main函数中分别打印时间差
public static void main(String[] args) {

  Times.test("fib1", new Times.Task() {
    public void execute() {
      System.out.print(fib1(30));
    }
  });

  Times.test("fib2", new Times.Task() {
    public void execute() {
      System.out.print(fib2(30));
    }
  });
}

运行程序查看打印结果可见,第一种要比第二种耗时时间长;而且当我们将传入的值变得更大时,第一种有很明显的延迟计算,而第二种还是几乎为0;所以第二种要比第一种效率高很多

// 打印结果:
【fib1】
开始:17:18:03.032
832040结束:17:18:03.039
耗时:0.006秒
-------------------------------------
【fib2】
开始:17:18:03.046
832040结束:17:18:03.047
耗时:0.0秒

如何评判一个算法的好坏

看下面示例代码,以求和的算法来说明,很明显是第二种更好

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

// 第一种
public static int sum1(int n) {
  int result = 0;
	
  for (int i = 1; i <= n, i++) {
	  result += i;
  }
	
  return result;
} 

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

判断算法好坏的不同分析

所以如果我们单从执行效率上进行评估,可能会想到这么一种方案:

比较不同算法对同一组输入的执行时间,这种方案也叫做事后统计法。

这种方法缺点明显:

  • 1.执行时间严重依赖硬件以及运行时各种不确定的因素

比如运行不同算法时程序打开过多或者有偏差,都会对结果造成影响

  • 2.必须编写相应的测试代码

为了测试要写或多或少的代码来测试,相对就会麻烦一些

  • 3. 测试数据的选择比较难保证公正性

比如测试斐波那契数列时,一开始传的值都比较小,那么可能第一种算法会更快一些;而当传的值变得更大了,第一种算法的速度可能又会慢过第二种,所以有不确定性

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

  • 1.正确性、可读性、健壮性

正确性即代码写的要正确;
可读性即代码要易于阅读;
健壮性即为对不合理输入的反应能力和处理能力

  • 2.时间复杂度

时间复杂度即估算程序指令的执行次数,也就是对一共有多少条执行指令的时间做一个统计计算

  • 3.空间复杂度

空间复杂度即估算要开辟多少存储空间来解决

大O表示法

什么是大O表示法

一般用大O表示法来描述复杂度,它表示的是数据规模n对应的复杂度

需要忽略常数、系数、低阶

大O表示法仅仅是一种粗略的分析模型,是一种估算,能帮助我们短时间内了解一个算法的执行效率

常见复杂度有以下几种

-w650

O( 1 ) < O( logn ) < O( n ) < O( nlogn ) < O( n^2 ) < O( n^3 ) < O( 2^n ) < O( n! ) < O( n^n )

通过函数生成工具来对比

我们可以借助函数生成工具对比复杂度的大小

相关在线测试链接:https://zh.numberempire.com/graphingcalculator.php

数据规模较小时

-w678

数据规模较大时

-w852

通过示例分析复杂度是多少

分析下面几个函数的算法,用大O表示法的复杂度是多少

public static void test1(int n) {
  if (n > 10) { 
    System.out.println("n > 10");
  } else if (n > 5) { // 2
    System.out.println("n > 5");
  } else {
    System.out.println("n <= 5"); 
  }
	
  for (int i = 0; i < 4; i++) {
    System.out.println("test");
  }
	
  /* 
  时间复杂度:
  if else的判断只会执行其中一次 (1)
	
  int i = 0(1)
  i < 4(4)
  i++(4)
  System.out.println("test");(4)

  所以加一起总共执行14次
  常数即为O(1)
	
  空间复杂度:
  由于只有int i = 0占用存储空间了,所以为O(1)
  */
}

public static void test2(int n) {
  for (int i = 0; i < n; i++) {
    System.out.println("test");
  }
	
  /* 
  时间复杂度:
  int i = 0;(1)
  i < n;(n)
  i++(n)
  System.out.println("test");(n)
	
  所以加一起总共为1+3n次
  即为O(n)
  */
}

public static void test3(int n) {
  for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
	    System.out.println("test");
	  }
  }
	
  /*
  时间复杂度:
  (int i = 0; i < n; i++)这三句即为1 + 2n
  然后里面的for循环为1 + 3n
  要循环n倍的1 + 3n即为n * (1 + 3n)
  所以加一起为1 + 2n + n * (1 + 3n)
  简化过程:
  1 + 2n + n + 3n^2
  最后就是:3n^2 + 3n + 1
  即为O(n^2)
  */
}

public static void test4(int n) {
  for (int i = 0; i < n; i++) {
    for (int j = 0; j < 15; j++) {
      System.out.println("test");
	  }
  }
	
  /*
  时间复杂度:
  外面的for循环:1 + 2n
  里面的for循环:1 + 15 * 3
  然后循环n倍就是:n * (1 + 15 * 3)
  最后就是:1 + 2n + n * (1 + 45)
  简化过程:
  1 + 2n + 46n
  最后就是:48n + 1
  即为O(n)
  */
}

public static void test5(int n) {
  while ((n = n / 2) > 0) {
    System.out.println("test");
  }
	
  /*
  时间复杂度:
  如果n = 8,那么就是除以2分别就是4、2、1,循环三次;
  如果n = 16,那么就分别是8、4、2、1,循环4次
  所以也就是8 = 2^3,16 = 2^4
  执行多少次取决于2的几次方,反过来也就是3 = log2(8),4 = log2(16)
  所以结果就是log2(n)
  即为O(logn)
  */
}

public static void test6(int n) {
  while ((n = n / 5) > 0) {
    System.out.println("test");
  }
	
  /*
  时间复杂度:
  同上一题:log5(n)
  即为O(logn)
  */
}

public static void test7(int n) {
  for (int i = 1; i < n; i += i) {
    for (int j = 0; j < n; j++) {
      System.out.println("test");
    }
  }
	
  /*
  时间复杂度:
  i += i也就是i = i + i,也就是i = i * 2
  当n = 8时,i = 1、2、4
  当n = 16时,i = 1、2、4、8
  所以也是8 = 2^3,16 = 2^4
  要循环log2(n)次
  外面循环等于:1 + 2*log2(n)
  里面的循环等于:1 + 3n
  也就是1 + 2*log2(n) + log2(n) * (1 + 3n)
  简化过程:
  1 + 2*log2(n) + log2(n) + 3nlog2(n)
  所以结果就是1 + 3 * log2(n) + 3nlog2(n)
  即为O(nlogn)
  */
}

public static void test10(int 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);
  }
	
  /*
  空间复杂度:
  申请多少个存储空间取决于n,所以为O(n)
  */
}

public static 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");
  }
	
  /*
  时间复杂度:O(n + k)
  */
}

我们再回过头来分析文章一开始的斐波那契数列的示例

// 第一种:递归方式
public static int fib1(int n) {
  if (n <= 1) return n;
  return fib1(n - 1) + fib1(n - 2);
}

// 第二种:循环计算
public static int fib2(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;
}

第二种方式的时间复杂度为O(n),而第一种相对就很复杂,通过下图我们来分析

-w939

由此可见其成指数级的方式进行增长,通过计算可得复杂度为O( 2^n ),远比第二种要复杂的多

算法的优化

我们平时写算法可以从以下几个方向来优化算法

  • 用尽量少的存储空间
  • 用尽量少的执行步骤,也就是执行时间要更短
  • 适时地采取空间换时间的方式
    • 例如Windows系统上由于较大的内存空间,就可以尽多的来提高运行的速度
  • 适时地采取时间换空间的方式
    • 例如内存较小的系统就尽量以腾出更多空间为主要目标,适当的增加运算的时间成本
posted on 2021-04-01 15:02  FunkyRay  阅读(165)  评论(0编辑  收藏  举报