前言
第二期来啦!本期要学习内容为算法,我们写代码的过程中,或多或少都用过或者写过一些相关的算法,那么算法也是程序员必修课之一,这一期我们来简单地了解一下算法的定义以及算法的特性等,主要学习一下分析时间复杂度相关的知识点。
一、算法的定义
算法(Algorithm) 是为了解决某个特定问题的方法,它是一个有限的指令序列,每条指令可以表示一个或多个操作,而且能够在计算机处理的范围之内。
二、算法的特性
1.输入与输出
算法可以接受输入(在有些情况下也可以不接受输入),但是无论接不接受输入,它会产生至少一个输出, 输出的形式可以是打印(例如:System.out.println(“Hello World”)),也可以是返回一个或多个值。
2.有穷性
算法一定是在有限步骤之后结束,它的每一步操作在可接受的时间内完成。算法它没有无限循环这种说法。
3.确定性
算法的每一步操作都是充分明确的,不存在歧义。算法在一定条件下,只有一条执行路径,相同的输入只能有唯一的输出结果。
4.可行性
算法的每一步必须是可行的,每一步都能在有限次数执行完成。但是计算机的计算能力是有限的,有些算法理论上可行,但实际上无法完成,所以说算法必须是计算机能够处理的。
三、衡量算法
一个好的算法,一般都是通过两个指标来衡量的,一是时间复杂度,二个是空间复杂度。所以尽量用最少的存储空间和时间来设计一个算法。
1.时间复杂度
时间复杂度T(n),即算法写成的程序在执行时所运行的时间,这个运行时间与输入数据的规模有关,如果算法的时间复杂度过高会造成该算法无法正常使用。
2.空间复杂度
空间复杂度S(n),即算法写成的程序在执行时所占用存储单元的长度,这个长度往往与输入数据的规模有关,如果算法的空间复杂度过高也可能会导致该算法无法使用。
四、时间复杂度分析
1.事后统计法
事后统计法是通过使用测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,来确定算法效率的高低。 但是这种方法也存在一些缺陷:
- 算法必须提前编写好,而且准备测试程序和数据也是需要一定时间的,如果算法编写出来后发现是很low的算法,
可能又要耗费时间和精力再编写算法,就算算法编写好了,万一测试又出了问题,这可太折磨人了。 - 算法的执行会依赖计算机的硬件和软件等原因,因为同一个算法在不同的计算机它所运行的时间也会不同。
- 算法的执行时间通常还会与测试数据的规模有关,如果你只是使用几个数字进行排序,不管使用什么排序算法,其耗时都基本相同。
但这时突然要对百万个数据,千万个数据来进行排序,不同的排序算法那差异就太大了。所以要准备多少测试数据来测试算法也是很头痛的问题。
所以我们就来看看另一种方法:事前分析法
2.事前分析法
事前分析法不需要依赖测试程序和数据,根据统计方法对算法进行估算。 下面我们来分析两种不同的累加求和算法。
public static void getSumMethod1(int n) {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum = sum + i;
}
System.out.println(sum);
}
public static void getSumMethod2(int n) {
int sum = 0;
sum = (1 + n) * n / 2;
System.out.println(sum);
}
假设我需要计算1+2+3+4…+98+99+100的和,第一个算法和第二个算法传入的参数同时为100,第一个求和算法需要不断地执行累加直到循环结束(第1次循环:1 = 0+1; 第2次循环:3 = 1+2; 第100次循环:5050 = 4950+100),而第二个求和算法只需要将传入的参数n代入求和公式5050 = (1 + 100) * 100 / 2,所以第一个求和算法整体下来执行了2n+3次,第二个求和算法整体下来执行了3次,很明显看出这两个算法的好坏。
3.大O记法
在介绍大O记法之前,我们先来看看下面这个算法:
public static void getSumMethod3(int n) {
int sum = 0, x = 0;
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
x++;
sum += x;
}
}
System.out.println(sum);
}
我们主要看for循环嵌套这一部分,没错,根据n的规模,外循环需要执行n次,内循环则需要执行n2次,我们把所有代码的执行时间记作T(n),假设每段代码每执行一次所耗费的时间相同,记作unit_time,所以这个算法执行总耗时为: T(n) = (1 + n + n2 + n2 + n2 + 1)unit_time = (3n2 + n + 2)unit_time 所以可以得出结论:一个算法的总执行时长T(n)与该算法的每一步操作的执行次数n成正比。 其中T(n)是关于问题规模n的函数,用f(n)来表示算法总的执行次数
T(n) = O(f(n))
我们用大写的O来表示算法时间复杂度的记法,这种记法称之为大O记法 大O记法:它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,表示为算法的渐近时间复杂度,简称为时间复杂度。
4.推导大O阶
上面我们推导出了getSumMethod3中的时间复杂度为T(n) = O(3n2 + n + 2),这其中就用到了加法规则和乘法规则。 加法规则就是,如果算法代码是按顺序增加的,那么就加上相应的时间复杂度。 乘法规则就是,如果算法代码增加的是循环嵌套或者函数的嵌套,那么就需要乘上相应的时间复杂度。 另外我们还有两个分析时间复杂度的结论:
- 只关注最高次项,加法常数项和其他次项可以忽略。
- 与最高次项相乘的常数也可以忽略。
只关注最高次项,加法常数项和其他次项可以忽略
次数n | 算法A (3n2 + n + 2) | 算法B (3n2) |
---|
n=1 | 6 | 3 | n=2 | 16 | 12 | n=3 | 32 | 27 | n=10 | 312 | 300 | n=100 | 30102 | 30000 | n=1000 | 3001000 | 3000000 | n=10000 | 300010002 | 300000000 |
算法B相比于算法A,去掉了常数项2 和 次项n,但是随着n的增长,算法A同样也趋近于算法B,所以判断一个算法效率,可以忽略掉常数项和其他次项,主要关注最高阶项。
与最高次项相乘的常数也可以忽略
次数n | 算法A (4n+8) | 算法A1 (n) | 算法B (2n2+1) | 算法B1 (n2) |
---|
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 | n=10000 | 40008 | 10000 | 200000001 | 100000000 |
可以看到n<=3时,算法A要差于算法B,但是当n>3后,算法A的优势越来越优于算法B,算法A1随着n的增长也是要优于算法B1,所以与最高次项相乘的常数也可以忽略掉。 所以getSumMethod3算法的时间复杂度为O(n2)。
五、常见的时间复杂度
1.常数阶 O(1)
如下代码是我们上面第二个求和算法的代码,每行代码都执行了1次,所以运行次数函数是f(n) = 3, 但是大O记法中有这样一个规则:用常数1取代运行时间中的所有加法常数。所以这个算法的时间复杂度是T(n) = O(1)
public static void getSumMethod2(int n) {
int sum = 0;
sum = (1 + n) * n / 2;
System.out.println(sum);
}
2.线性阶 O(n)
如下代码是第一个求和算法的代码,线性阶O(n)表示代码需要执行n次,循环部分执行了2n+1次,但是我们在分析时间复杂度时 用到的结论:加法常数项忽略,与最高次项相乘的常数也可以忽略,所以这个算法的时间复杂度为T(n) = O(n)
public static void getSumMethod1(int n) {
int sum = 0;
for (int i = 1; i <= n; i++) {
sum = sum + i;
}
System.out.println(sum);
}
3.对数阶 O(logn)
由于count每次乘以2之后,就会离n越来越近,当count>n时,这段循环就会停止,也就说有多少个2相乘 > n,即2x=n 得到x = log?n,所以这个循环的时间复杂度为T(n) = O(logn)
public static void logarithmic(int n) {
int count = 1;
while (count < n) {
count = count * 2;
}
}
4.线性对数阶 O(nlogn)
线性对数阶即对一段时间复杂度为O(logn)的代码执行n次
public static void nLogarithmic(int n) {
int count = 1;
for (int i = 0; i < n; i++) {
while (count < n) {
count = count * 2;
}
}
}
5.平方阶 O(n2)
下面代码为for循环嵌套,内循环的复杂度为O(n),对于外循环而言,又将内循环执行了n次, 所以这段代码的时间复杂度为T(n) = O(n2)
public static void squareMethod(int n) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
System.out.println("Hello");
}
}
}
六、最好情况、最坏情况、平均情况时间复杂度
public static boolean findTargetNumber(int num, int[] arr) {
int length = arr.length;
for (int i = 0; i < length; i++) {
if (arr[i] == num) {
return true;
}
}
return false;
}
我们能够直接得到这段代码的时间复杂度吗?答案是不确定的,假设我们需要找的数字就在数组中的第一个位置,那么剩下的数字就不用找了,直接返回true,时间复杂度为O(1),这种最理想的情况就是最好情况时间复杂度。
如果我们要找的数字在这个数组的最后一个位置或者不在这个数组里面,那么就需要遍历这整个数组最终来确定找没找到,所以时间复杂度为O(n),这种情况就是最坏情况时间复杂度。
可以看到,由于寻找的数字在此数组中不同的位置,所以这段代码在不同情况下时间复杂度出现量级差别,所以我们就需要考虑平均情况时间复杂度了。那么我们如何去计算这个平均情况时间复杂度?可以看到我们需要找的数字在这个数组中有两种最基本的情况,一是这个数一定在数组中,二是不在数组中。
如果需要找的这个数一定在数组中,这个数会出现在此数组的任意位置(即0~n-1),所以就会有n种情况,遍历的次数为(1+2+3+···+n)次,如果这个数不在数组中,也需要遍历n次,也是一种情况,总共就是n+1种情况,总共遍历的次数为1+2+3···+n+n次。我们把每种情况下的需要查找的元素个数累加起来,然后再除以n+1,最后得到一个平均情况时间复杂度为O(n)。
七、了解空间复杂度
算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式为S(n) = O(f(n)),n为问题的规模,f(n)为语句关于n所占存储空间的函数。 一般情况下,一个程序在机器上执行时,除了需要存储程序本身的指令、常数、变量、输入数据外,还需存储对数据操作的存储单元。若输入数据所占空间只取决于问题本身,和算法无关,那么只需要分析该算法在实现时所需的辅助单元即可。如果算法执行时所需的辅助空间对于输入数据量而言是个常数,则称此算法为原地工作,空间复杂度为O(1)。到此只对空间复杂度做一个大概的了解。
八、二分查找法分析
public static int binarySearch(int[] arr, int num) {
int left = 0;
int right = arr.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (arr[mid] == num) {
return mid;
} else if (arr[mid] > num) {
right = mid - 1;
} else {
left = mid + 1;
}
}
return -1;
}
假设数组长度为n,第一次循环在n的范围内查找,第二次循环在n/2的范围内查找,第三次循环在n/4的范围内查找,在每次查找完成后,查找的区间都会缩小为原来的一半,最坏的情况是:直到查找区间为被缩小为空后,才停止。所以经过x次缩小区间的操作时间复杂度为O(x),根据n / 2x = 1得到 x = log?n,所以时间复杂度为O(logn)。
九、参考
参考书籍 《大话数据结构》-程杰
极客时间 《数据结构与算法之美》-王争
结语
第二期#数据结构与算法在这里也就结束了,这期了解到算法的一些基本概念和特性,还学习到了时间复杂度的表示法大O记法和相关的推导结论,在此,感谢你看到了最后,这里是小空,一个正在慢慢努力成长的新人程序员,那么我们下期再见!
举头望明月 低头思故乡
|