一.算法的优劣判断
算法 (Algorithm),是对特定问题求解步骤的一种描述。解决一个问题往往有不止一种方法,算法也是如此
。那么解决特定问题的多个算法之间如何衡量它们的优劣呢?好的程序设计无外乎两点,“快"和"省”。"快"指程序执行速度快,高效,"省"指占用更小的内存空间。这两点其实就对应**“时间复杂度"和"空间复杂度”**。通过这两点就能衡量多个算法之间的优劣。
1.时间复杂度
一个算法执行所花费的时间,需上机运行测试才能知道。但我们不可能对每个算法都上机测试,所以只需知道哪个算法花费的时间多,哪个算法花费的时间少就可以了。因为一个算法花费的时间与算法中的基本操作语句的执行次数成正比例
,所以哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度,记为T(n)
。
n称为问题的规模,当n不断变化时,时间频度T(n)也会不断变化。为了知道变化时呈现什么规律,所以引入时间复杂度。一般情况下,算法中的基本操作语句的重复执行次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n) / f(n) 的极限值为不等于零的常数, 则称f(n)是T(n)的同数量级函数。记作 T(n)=O( f(n) ),称O( f(n) ) 为算法的渐进时间复杂度,简称时间复杂度。记为O(…),也称为大O表示法;
2.空间复杂度
算法的空间复杂度是指算法需要消耗的空间资源。 其计算和表示方法与时间复杂度类似,一般都用复杂度的渐近性来表示。
注:同一个问题可以用不同的算法解决,而一个算法的优劣将影响到算法乃至程序的效率。算法分析的目的在于为特定的问题选择合适算法。一个算法的评价主要从
时间复杂度
和空间复杂度
来考虑。但是算法在时间的高效性和空间的高效性之间通常是矛盾的。所以一般只会取一个平衡点。通常我们假设程序运行在足够大的内存空间中,所以研究更多的是算法的时间复杂度。
二.大O表示法对比算法的优劣
1.BigO表示法的概念
上文说到在实际操作上,精确计算公式是不可能被统计计算出来的,因为不同的语句,不同的操作,耗费的时间也是不一样。所以,在实际应用中,不需要精确计算,只需要一个公式,来表达 时间花费和数据集大小的渐近关系。所以,在描述算法的时候,一般采用 大O(Big-O notation)表示,又称为渐进符号,时间复杂度,又称"渐进式时间复杂度",表示代码执行时间与数据规模之间的增长关系。
"大O表示法"表示程序的执行时间或占用空间
随数据规模的增长趋势。被用来描述一个算法的性能或复杂度。大O表示法可以用来描述一个算法的最差情况
,或者一个算法执行的耗时或占用空间(例如内存或磁盘占用)。
大O表示法表示时间复杂度,注意它是某一个算法的时间复杂度。大O表示只是说有上界,由定义如果 f(n)=O(n),那显然成立 f(n)=O(n^2),它给你一个上界,但并不是上确界。一个问题本身也有它的复杂度,如果某个算法的复杂度到达了这个问题复杂度的下界,那就称这样的算法是最佳算法。
2.BigO表示法的特点
大O符号是关于一个算法的最坏情况的
。例如:你要从一个大小为 100 的数字数组中找出一个数值等于 10 的元素,我们可以从写一个循环,从头到尾对数据进行扫描,这个复杂度就是 O(n),这里 n 等于 100, 实际上有可能第 1 次就找到了,也有可能是第 100 次才找到,但是大 O 表示法考虑的是最坏的情况,也就是一个算法理论上要执行多久才能覆盖所有的情况。
3.BigO表示法用法
假设一个算法的时间复杂度是 O(n),n 在这里代表的意思就是数据的个数。举个例子,如果你的代码用一个循环遍历 100 个元素,那么这个算法就是 O(n),n 为 100,所以这里的算法在执行时就要做 100 次工作。
随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越低。
三.常见的大O表示法
1.常见的大O表示法介绍
- 常数阶O(1)
- 对数阶O(log2n)
- 线性阶O(n)
- 线性对数阶O(nlog2n)
- 平方阶O(n2)
- 立方阶O(n3)
- k次方阶O(nk)
- 指数阶O(2n)
不论输入数据量有多大,这个算法的运行时间总是一样的。该算法的执行时间(或执行时占用空间)总是为一个常量,不论输入的数据集是大是小。也就是说代码片段执行时间不随数据规模n的增加而增加,即使执行次数非常大,只要与数据规模无关,都算作常量阶。
例如判断集合中第一个元素是不是为空:
boolean isFirstElementEmpty(List<String> elements){
return elements.get(0).isEmpty();
}
应用:
- 基于索引取出数组中对应的元素。
- 哈希算法就是典型的 O ( 1 )时间复杂度,无论数据规模多大,都可以在一次计算后找到目标(不考虑冲突的话)。
这种算法每次循环时会把需要处理的数据量减半。如果你有 100 个元素,则只需要七步就可以找到答案。1000 个元素只要十步。100,0000 元素只要二十步。即便数据量很大这种算法也非常快。
例如:
public int binarySearch(int[] arr, int value) {
int start = 0, end = arr.length - 1;
while (start <= end) {
int middle = (start + end) / 2;
if (value == arr[middle]) {
return middle;
}
if (value > arr[middle]) {
start = middle + 1;
}
if (value < arr[middle]) {
end = middle - 1;
}
}
return -1;
}
应用:二分查找就是 O ( logn )的算法,每找一次排除一半的可能, 256 个数据中查找只要找 8 次就可以找到目标。
O(n),算法的执行时间呈线性关系。如果你有 100 个元素,这种算法就要做 100 次工作。数据量翻倍那么运行时间也翻倍。
例如在集合中找到某一个元素:
public boolean ContainsValue(List<String> elements, String value) {
int n = elements.size();
for (int i = 0; i < n; i++) {
if (elements.get(i).equals(value)) {
return true;
}
}
return false;
}
花费的时间和输入的集合大小有关(集合越大理论上花费的时间就越长),呈线性关系。寻找item要把array遍历一次。
应用:遍历查找
O(n^2 ),如果你有 100 个元素,这种算法需要做 100^2 = 10000 次工作。数据量 x 2 会导致运行时间 x 4 (因为 2 的 2 次方等于 4)。例子:循环套循环的算法,比如插入排序。O(n2)表示算法的复杂度与数据集大小的平方成正比,一般的循环嵌套就是这种,随着嵌套的层级增加可能是O(n3)、O(n4)等。
boolean ContainsDuplicates(List<String> elements) {
for (int i = 0; i < elements.size(); i++) {
for (int j = 0; j < elements.size(); j++) {
if (i == j) continue;
if (elements.get(i).equals(elements.get(j))) return true;
}
}
return false;
}
应用:选择排序
表示算法的复杂度与数据集大小成指数增长。
应用:递归
1.常见的大O表示法总结
Big-O | 名字 | 描述 |
---|---|---|
O(1) | 常数级 | 速度最快,不论输入数据量有多大,这个算法的运行时间总是一样的。例如基于索引取出数组中对应的元素。 |
O(log n) | 对数级 | 速度快,这种算法每次循环时会把需要处理的数据量减半。如果你有 100 个元素,则只需要七步就可以找到答案。1000 个元素只要十步。100,0000 元素只要二十步。即便数据量很大这种算法也非常快。例如二分查找。 |
O(n) | 线性级 | 速度比较快,如果你有 100 个元素,这种算法就要做 100 次工作。数据量翻倍那么运行时间也翻倍。例如线性查找。 |
O(n log n) | 线性对数级 | 速度一般,比线性级差了一些。例如最快的通用排序算法。 |
O(n^2) | 二次方级 | 速度有点慢,如果你有 100 个元素,这种算法需要做 100^2 = 10000 次工作。数据量 x 2 会导致运行时间 x 4 (因为 2 的 2 次方等于 4)。例如循环套循环的算法,比如插入排序。 |
O(n^3) | 三次方级 | 速度有很慢,如果你有 100 个元素,那么这种算法就要做 100^3 = 100,0000 次工作。数据量 x 2 会导致运行时间 x 8。例如矩阵乘法。 |
O(2^n) | 指数级 | 速度有特别慢,这种算法你要想方设法避免,但有时候你就是没得选。加一点点数据就会把运行时间成倍的加长。例如旅行商问题。 |
O(n!) | 阶乘级 | 速度有最慢! |
- 大部分情况下用直觉就可以知道一个算法的大 O 表示法。比如说,如果你的代码用一个循环遍历你输入的每个元素,那么这个算法就是 O(n)。如果是循环套循环,那就是 O(n^2)。如果 3 个循环套在一起就是 O(n^3),以此类推。
- 大O表示法只是一种估算,仅仅在比较两种算法哪种更好的时候才有点用。当数据量大的时候才有用。归根结底,还是要实际测试之后才能得出结论。而且如果数据量相对较小,哪怕算法比较慢,在实际使用也不会造成太大的问题。举个例子,插入排序(Insertion Sort)的最糟情况运行时间是 O(n^2)。 理论上来说它的运行时间比归并排序(Merge Sort)要慢一些。归并排序是 O(n log n)。但对于小数据量,插入排序实际上更快一些,特别是那些已经有一部分数据是排序好的数组。
四.常见的排序算法
常见的排序方式有:冒泡排序、选择排序、插入排序、快速排序
。
1.冒泡排序算法
特点:效率低,实现简单
冒泡排序是最简单的排序之一了,其大体思想就是通过与相邻元素的比较和交换来把小的数交换到最前面。这个过程类似于水泡向上升一样,因此而得名。举个栗子,对8, 5, 3, 2, 4这个无序序列进行冒泡排序。首先从后向前冒泡,8和5比较,把5交换到前面,序列变成5, 8, 3, 2, 4。再使用8和3比较再交换,同理变成5, 3, 8, 2, 4。按照这样的规则,比较完成以后序列变成了5, 3, 2, 4, 8。总共冒泡次数为4次(无需与集合中自身进行比较,所以要少比较一次)。然后用目前第一个元素(5)进行冒泡操作,同理第二次比较完成以后的序列变成了3, 2, 4, 5, 8。总共冒泡次数为3次(除去自身,以及上次比较过的元素8),按照规则对剩下的序列依次冒泡就会得到一个有序序列。冒泡排序的时间复杂度为O(n^2)。
/*
* 冒泡排序
* 升序(比较相邻的元素。如果前一个值比后一个值大,则交换两个元素的位置)
* 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。通过这样操作,最后的元素应该会是最大的值。
* 针对所有的元素重复以上的步骤,除了上一次操作的最后一个(因为上一次排序的已经判断过比本次元素的值大)。
* 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
* @param array 需要排序的整型数组
*/
private static int[] bubbleSort(int[] array) {
if (array.length == 0) {
return array;
}
for (int i = 0; i < array.length; i++) {
//每一个元素都不用与自身进行比较所以会减少一次比较次数,要进行-1的操作。
//每排一次序,数组最后的元素都会由大到小排列。所以随着排序的次数增加,比较的次数会越来越少。
// 直到没有任何一对数字需要比较。此时排序完成
for (int j = 0; j < array.length - 1 - i; j++) {
if (array[j + 1] < array[j]) {
// 升序(比较相邻的元素。如果前一个值比后一个值大,则交换两个元素的位置)
int temp = array[j + 1];
array[j + 1] = array[j];
array[j] = temp;
}
}
}
return array;
}
2.选择排序算法
特点:效率低,容易实现。
选择排序的思想其实和冒泡排序有点类似,都是在一次排序后把最小的元素放到最前面。但是过程不同,冒泡排序是通过相邻的比较和交换。而选择排序是通过对整体的选择。举个栗子,还是对8, 5, 3, 2, 4这个无序序列进行简单选择排序,第一次选择8以外的最小数来和8进行交换,第一次排序后就变成了2, 5, 3, 8, 4。然后在选择5以外的集合中的最小数来和5进行交换,第二次排序以后就变成了2, 3, 5, 8, 4。同理对剩下的序列多次进行选择和交换,最终就会得到一个有序序列。其实选择排序可以看成冒泡排序的优化,因为其目的相同,只是选择排序只有在确定了最小数的前提下才进行交换,大大减少了交换的次数。选择排序的时间复杂度为O(n^2)。
/**
* 选择排序算法
* 在未排序序列中找到最小元素,存放到排序序列的起始位置
* 再从剩余未排序元素中继续寻找最小元素,然后放到排序序列末尾。
* 以此类推,直到所有元素均排序完毕。
*/
public static int[] selectionSort(int[] arr) {
//选择
for (int i = 0; i < arr.length; i++) {
int min = arr[i]; //默认第一个是最小的。
int index = i; //记录最小的下标
//不用与自身比较所有要做+1操作
for (int j = i + 1; j < arr.length; j++) {
//通过与数组中的的数据进行比较得出,最小值和下标
if (min > arr[j]) {
min = arr[j];
index = j;
}
}
//然后将最小值与本次循环的,开始值交换
int temp = arr[i];
arr[i] = min;
arr[index] = temp;
}
return arr;
}
3.插入排序算法
特点:效率低,容易实现。
插入排序不是通过交换位置而是通过比较找到合适的位置插入元素来达到排序的目的的。举个栗子,对8, 5, 3, 2, 4这个无序序列进行简单插入排序。首先假设第一个数的位置时正确的,然后5要插到8前面,把8后移一位,变成5, 8, 3, 2, 4。注意在插入一个数的时候要保证这个数前面的数已经有序。简单插入排序的时间复杂度也是O(n^2)。
// 插入排序
public static int[] insertSort(int[] arr) {
for (int i = 1; i < arr.length; i++) {
// j表示当前元素的位置,将其和左边的元素比较,若当前元素小与左边的元素,就进行位置交换,也就相当于插入左边.
// 这样当前元素位于j-1处,j--来更新当前元素,j一直左移不能越界,因此应该大于0
for (int j = i; j > 0 && arr[j] < arr[j - 1]; j--) {
int temp = arr[j]; // 元素交换
arr[j] = arr[j - 1];
arr[j - 1] = temp;
}
}
return arr;
}
4.快速排序算法
特点:高效
从数列中挑出一个元素,称为 “基准”(pivot),重新排序数列,比基准值小的所有元素摆放在基准左面,比基准值大的所有元素摆在基准的右面。再在左面和右面的数列中挑出一个基准元素,再按照上面的步骤分成两个数列。如此不断递归地把小于基准值元素的子数列和大于基准值元素的子数列排序,直到子序列为一个元素为止。 快速排序是不稳定的,其时间平均时间复杂度是O(nlgn)。
五.常见的查找算法
查找(searching)是这样一个过程,即在某个项目组中寻找某一指定目标元素,或者确定该组中并不存在该目标元素。
常见的查找方式有:顺序查找、二分法查找、插值查找
。
1.顺序查找算法
基本思想:顺序查找也称为线形查找,属于无序查找算法。从数据结构线形表的一端开始,顺序扫描,依次将扫描到的结点关键字与给定值k相比较,若相等则表示查找成功;若扫描结束仍没有找到关键字等于k的结点,表示查找失败。
顺序查找的时间复杂度为O(n)。
/**顺序查找平均时间复杂度 O(n)
* @param searchKey 要查找的值
* @param array 数组(从这个数组中查找)
* @return 查找结果(数组的下标位置)
*/
public static int orderSearch(int searchKey,int[] array){
if(array==null||array.length<1)
return -1;
for(int i=0;i<array.length;i++){
if(array[i]==searchKey){
return i;
}
}
return -1;
}
2.二分法查找算法
算法思想是将数列按有序化(递增或递减)排列,查找过程中采用跳跃式方式查找,即先以有序数列的中点位置为比较对象,如果要找的元素值小 于该中点元素,则将待查序列缩小为左半部分,否则为右半部分。通过一次比较,将查找区间缩小一半。 折半查找是一种高效的查找方法。它可以明显减少比较次数,提高查找效率。但是,折半查找的先决条件是查找表中的数据元素必须有序。
时间复杂度为 O(logN)
说明:元素必须是有序的,如果是无序的则要先进行排序操作。
/**
* 二分查找又称折半查找,它是一种效率较高的查找方法。 【二分查找要求】:1.必须采用顺序存储结构 2.必须按关键字大小有序排列。
*
* @param array
* 有序数组 *
* @param searchKey
* 查找元素 *
* @return searchKey的数组下标,没找到返回-1
*/
public static int binarySearch(int[] array, int searchKey) {
int low = 0;
int high = array.length - 1;
while (low <= high) {
int middle = (low + high) / 2;
if (searchKey == array[middle]) {
return middle;
} else if (searchKey < array[middle]) {
high = middle - 1;
} else {
low = middle + 1;
}
}
return -1;
}
由此看来,在处理已排序数据的查找工作时,二分查找法显然效率高于线性查找法。这种优势在数据量越大的时候越明显。比如说,现在有序数组中含有100万个数据,我们要求查找特定元素。如果使用线性查找法,我们必须对这一100万个数据依次考察以确定出目标元素是不是存在,最好的情况是目标元素在数组的第一个位置a[0],这样只要一次就查找到目标元素了,最坏情况是目标元素在数组的最后a[999999],这样我们就得比较100万次才能知道目标元素到底在不在,平均下来看的话也需要50万次比较。而如果使用二分查找法,我们大约做20次比较就可以知道目标元素是不是存在于数组中了。
既然二分查找法效率这么高,比线性查找法好很多,那为什么还要线性查找法呢?其实,线性查找法也不是一无是处,它最大的优点就是简单,特别简单。还有一点就是二分法本身也有局限性,那就是二分法必须要求待查数组是已排序的,比如我给你一个很大的数组,但是这个数组并没有排序,那你如果想用二分查找法的话还必须先给数组排序,然后再查找。这样就会造成除查找之外的额外成本(排序)。
六.总结
参考资料:
查找算法的Java实现
算法的时间复杂度(大O表示法)
必须知道的八大种排序算法【java实现】
数据结构Java实现