数据结构与算法(二)复杂度
什么是算法
算法是用于解决特定问题的一系列的执行步骤
看下面两段代码,都属于算法
// 计算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表示法仅仅是一种粗略的分析模型,是一种估算,能帮助我们短时间内了解一个算法的执行效率
常见复杂度有以下几种
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
数据规模较小时
数据规模较大时
通过示例分析复杂度是多少
分析下面几个函数的算法,用大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),而第一种相对就很复杂,通过下图我们来分析
由此可见其成指数级的方式进行增长,通过计算可得复杂度为O( 2^n ),远比第二种要复杂的多
算法的优化
我们平时写算法可以从以下几个方向来优化算法
- 用尽量少的存储空间
- 用尽量少的执行步骤,也就是执行时间要更短
- 适时地采取空间换时间的方式
- 例如Windows系统上由于较大的内存空间,就可以尽多的来提高运行的速度
- 适时地采取时间换空间的方式
- 例如内存较小的系统就尽量以腾出更多空间为主要目标,适当的增加运算的时间成本