目录
一、算法分析
研究算法的最终目的就是如何花更少的时间,如何占用更少的内存去完成相同的需求。
有关算法时间耗费分析,我们称之为算法的时间复杂度分析,有关算法的空间耗费分析,我们称之为算法的空间复杂度分析。
1、算法的时间复杂度分析
事后分析估算方法:
public static void main(String[] args) {
long start = System.currentTimeMillis();
int sum = 0;
int n = 100;
for (int i = 1; i <= n; i++) {
sum += i;
}
System.out.println("sum=" + sum);
long end = System.currentTimeMillis();
System.out.println(end - start);
}
事前分析估算方法:
在计算机程序编写前,依据统计方法对算法进行估算,经过总结,程序在计算机上运行所消耗的时间取决于下列因素:
1、算法采用的策略和方案;
2、编译产生的代码质量;
3、问题的输入规模(所谓的问题输入规模就是输入量的多少);
4、机器执行指令的速度;
由此可见,抛开这些与计算机硬件、软件有关的因素,一个程序的运行时间依赖于算法的好坏和问题的输入规模。如果算法固定,那么该算法的执行时间就只和问题的输入规模有关系了。
需求:计算1到100的和。
第一种解法:
//如果输入量n为1次,则需要计算1次
//如果输入量n为1亿次,则需要计算1亿次
public static void main(String[] args) {
int sum = 0;//执行1次
int n = 100;//执行1次
for (int i = 1; i <= n; i++) {//执行n+1次
sum += i;//执行n次
}
System.out.println("sum=" + sum);
}
第二种解法:
//如果输入量n为1次,则需要计算1次
//如果输入量n为1亿次,则需要计算1次
public static void main(String[] args) {
int sum = 0;//执行1次
int n = 100;//执行1次
sum = (n + 1) * n / 2;//执行1次
System.out.println("sum=" + sum);
}
因此,当输入规模为n时,第一种算法执行了1+1+(n+1)+n=2n+3次;第二种算法执行1+1+1=3次。如果我们把第一种算法的循环体看做是一个整体,忽略结束条件的判断,那么其实这两个算法运行时间的差距就是n和1的差距。
需求:计算100个1+100个2+100个3+....100个100的结果
public static void main(String[] args) {
int sum = 0;
int n = 100;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
sum += i;
}
}
System.out.println("sum=" + sum);
}
最重要的就是把核心操作的次数和输入规模关联起来。
1.1、函数渐近增长
概念:给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么我们说f(n)的增长渐近快于g(n)。
测试一:
规模 | 算法A1(2n+3)执行次数 | 算法A2(2n)执行次数 | 算法B1(3n+1)执行次数 | 算法B2(3n)执行次数 |
n=1 | 5 | 2 | 4 | 3 |
n=2 | 7 | 4 | 7 | 6 |
n=3 | 9 | 6 | 10 | 9 |
n=10 | 23 | 20 | 31 | 30 |
n=100 | 203 | 200 | 301 | 300 |
结论:
当输入规模n>2时,算法A1的渐近增长小于算法B1的渐近增长。
随着输入规模的增大,算法的常数操作可以忽略不计。
测试二:
规模 | 算法C1(4n+8)执行次数 | 算法C2(n)执行次数 | 算法D1(2n^2+1)执行次数 | 算法D2(n^2).... |
n=1 | 12 | 1 | 3 | 1 |
n=2 | 16 | 2 | 9 | 4 |
n=3 | 20 | 3 | 19 | 9 |
n=10 | 48 | 10 | 201 | 100 |
n=100 | 408 | 100 | 20001 | 10000 |
n=1000 | 4008 | 1000 | 2000001 | 1000000 |
结论:
随着输入规模的增大,与最高次项相乘的常数可以忽略。
测试三:
规模 | 算法E1(2n^2+3n+1)执行次数 | 算法E2(n^2)执行次数 | 算法F1(2n^3+3n+1).. | 算法F2(n^3).. |
n=1 | 6 | 1 | 6 | 1 |
n=2 | 15 | 4 | 23 | 8 |
n=3 | 28 | 9 | 64 | 27 |
n=10 | 231 | 100 | 2031 | 1000 |
n=100 | 20301 | 10000 | 2000301 | 1000000 |
结论:
最高次项指数大的,随着n的增长,结果也会变得增长特别快。
测试四:
规模 | 算法G(n^3)执行次数 | 算法H(n^2)执行次数 | 算法I(n).. | 算法J(logn).. | 算法K(1).. |
n=2 | 8 | 4 | 2 | 1 | 1 |
n=4 | 64 | 16 | 4 | 2 | 1 |
结论:
算法函数中n最高次幂越小,算法效率越高。
1、算法函数中的常数可以忽略;
2、算法函数中最高次幂的常数因子可以忽略;
3、算法函数中最高次幂越小,算法效率越高;
1.2、大O记法
定义:在进行算法分析时,语句总的执行次数T(n)是关于问题规律n的函数,进而分析T(n)随着n的变化情况并确定T(n)的量级。算法的时间复杂度,就是算法的时间量度,记作:T(n)=O(f(n))。它表示随着问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称时间复杂度,其中f(n)是问题规模n的某个函数。
在这里,我们需要明确一个事情:执行次数=执行时间
用大写O()来体现算法时间复杂度的记法,我们称之为大O记法。一般情况下,随着输入规模n的增大,T(n)增长最慢的算法为最优算法。
算法一:
public static void main(String[] args) {
int sum = 0;//执行1次
int n = 100;//执行1次
sum = (n + 1) * n / 2;//执行1次
System.out.println("sum=" + sum);
}
算法二:
public static void main(String[] args) {
int sum = 0;//执行1次
int n = 100;//执行1次
for (int i = 1; i <= n; i++) {
sum += i;//执行n次
}
System.out.println("sum=" + sum);
}
算法三:
public static void main(String[] args) {
int sum = 0;//执行1次
int n = 100;//执行1次
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
sum += i;//执行n^2次
}
}
System.out.println("sum=" + sum);
}
以上算法执行次数:
算法一:3次
算法二:n+2次
算法三:n^2+2次
如果用大O记法表示上述每个算法的时间复杂度,应该如何使用呢?基于我们对函数渐近增长分析,推导大O阶的表示法有以下几个规则可以使用:
1、用常数1取代运行时间中的所有加法常数;
2、在修改后的运行次数中,只保留最高阶项;
3、如果最高阶项存在,且常数因子不为1,则去除与这个项相乘的常数;
所以上述算法的大O记法分别为:
算法一:O(1)--常数阶
算法二:O(n)----线性阶
算法三:O(n^2)----平方阶
1.3、常见的大O阶
1、线性阶--O(n)
2、平方阶--O(n^2)2次for循环
3、立方阶--O(n^3)--3次for循环
4、对数阶--O(logn)
int i=1,n=100;
while(i<n){
i=i*2;
}
由于每次i*2之后,就距离n更近一步,假设有x个2相乘后大于n,则会退出循环。由于是2^x=n,得到x=log(2)n,所以这个循环的时间复杂度为O(logn);
对于对数阶,由于随着输入规模n的增大,不管底数为多少,它们的增长趋势是一样的,所以我们会忽略底数。
规模规模 | log(2)n | log(4)n | log(8)n |
8 | 3 | 1.5 | 1 |
64 | 6 | 3 | 2 |
512 | 9 | 4.5 | 3 |
4096 | 12 | 6 | 4 |
16777216 | 24 | 12 | 8 |
134217728 | 27 | 13.5 | 9 |
2.815E+14 | 48 | 24 | 16 |
7.923E+28 | 96 | 48 | 32 |
6.28E+57 | 192 | 96 | 64 |
3.94E+115 | 384 | 192 | 128 |
1.55E+231 | 768 | 384 | 256 |
5、常数阶--O(1)
描述 | 增长的数量级 | 说明 | 举例 |
常数级别 | 1 | 普通语句 | 两个数相加 |
对数级别 | logn | 二分策略 | 二分查找 |
线性级别 | n | 循环 | 找出最大元素 |
线性对数级别 | nlogn | 分治思想 | 归并排序 |
平方级别 | n^2 | 双层循环 | 检查所有元素对 |
立方级别 | n^3 | 三层循环 | 检查所有三元组 |
指数级别 | 2^n | 穷举查找 | 检查所有子集 |
它们的时间复杂度从低到高依次为:O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<O(n^3)
从平方阶开始,随着输入规模的增大,时间成本会急剧增大;所以,我们的算法,尽可能的追求是
O(1)<O(logn)<O(n)<O(nlogn)这几种时间复杂度,而如果发现算法的时间复杂度为平方阶,立方阶或者更复杂的,那我们可以分为这种算法是不可取的,需要优化。
1.4、函数调用的时间复杂度
案例一:
public static void main(String[] args) {
int n = 100;
for (int i = 0; i < n; i++) {
show(i);
}
}
private static void show(int i) {
System.out.println(i);
}
时间复杂度:O(n)
案例二:
public static void main(String[] args) {
int n = 100;
for (int i = 0; i < n; i++) {
show(i);
}
}
private static void show(int i) {
for (int j = 0; j < i; j++) {
System.out.println(i);
}
}
时间复杂度:O(n^2)
案例三:
public static void main(String[] args) {
int n = 100;
show(n);
for (int i = 0; i < n; i++) {
show(i);
}
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
System.out.println(i);
}
}
}
private static void show(int i) {
for (int j = 0; j < i; j++) {
System.out.println(i);
}
}
时间复杂度:O(n^2)-----n+n^2+n^2=2n^2+n根据大O推导规则,最终时间复杂度:O(n^2)
1.5、最坏情况
//有一个存储n个随机数字的数组,请从中查找出指定的数字的索引下标
public int search(int num) {
int[] arr = { 11, 10, 8, 9, 7, 22, 33, 23, 0 };
for (int i = 0; i < arr.length; i++) {
if (num == arr[i]) {
return i;
}
}
return -1;
}
最好情况:O(1)
最坏情况:O(n)
平均情况:O(n/2)
2、算法的空间复杂度分析
早期计算机软硬件的发展漫长过程中,512K-->1M-->2M-->4M...等.发展到现在8G,16G,32G。早期,算法在运行过程中对内存的占用情况是需要考虑的问题,我们可以用算法的空间复杂度来描述算法对内存的占用。
2.1、Java中常见内存占用
1、基本数据类型内存占用情况
数据类型 | 内存占用字节数 |
byte | 1 |
short | 2 |
int | 4 |
long | 8 |
float | 4 |
double | 8 |
boolean | 1 |
char | 2 |
2、计算机访问内存的方式都是一次一个字节
3、一个引用(机器地址)需要8个字节表示
4、创建一个对象,比如new Date();除了Date对象内存的数据(年月日等信息占用的内存),该对象本身也有内存开销,每个对象自身开销是16个字节,用来保存对象的头信息。
5、一般内存的使用,如果不够8个字节,都会被自动填充为8字节
public class A {
public int a=1;
}
通过new A();创建一个对象的内存占用如下:
1、整型成员变量a占用4个字节
2、对象本身占用16个字节
那么创建该对象总共需要20个字节,但由于不是以8为单位,会自动填充为24个字节。
6、Java中数组被限定为对象,它们一般都会因为记录长度而需要额外的内存,一个原始数据类型的数组,一般需要24字节的头信息(16个自己的对象开销,4字节用于保存长度以及4个填充字节)再加上保存值所需的内存。
2.2、算法的空间复杂度
算法的空间复杂度计算公式记作:S(n)=O(f(n)),其中n为输入规模,f(n)为语句关于n所占存储空间的函数。
案例:对指定的数组元素进行反转,并返回反转的内容。
解法一:
public static int[] reverse(int[] arr) {
int n = arr.length;// 申请4个字节
int tmp;// 申请4个字节
for (int start = 0, end = n - 1; start <= end; start++, end--) {
tmp = arr[start];
arr[start] = arr[end];
arr[end] = tmp;
}
return arr;
}
解法二:
public static int[] reverse(int[] arr) {
int n = arr.length;// 申请4个字节
int[] tmp = new int[n];// 申请n*4个字节+数组自身开销24字节
for (int i = n - 1; i >= 0; i--) {
tmp[n - 1 - i] = arr[i];
}
return tmp;
}
解法一:O(1)----4+4=8
解法二:O(n)----4+4n+24=4n+28
天下事有难易乎?为之,则难者亦易矣;不为,则易者亦难矣。