一,算法分析
算法性能的分析:时间复杂度、空间复杂度和稳定性。
1,时间复杂度
在计算机科学中,算法的时间复杂度是一个函数,它定量地描述了一个算法的运行时间。时间复杂度常用一个大O符号表示,不包括这个函数的低阶项和首项系数。
时间复杂度是渐进的,考虑的是这个值趋于无穷时的情况。比如一个算法的执行时间为3n²+2n+3,这里用O符号来表示时,不考虑低阶项,也就是只考虑最高项3n²,也不考虑首项的系数,所以直接将这个算法的时间复杂度表示为O(n²)。
一般在计算时间复杂度时,需要考虑算法是否会有多重嵌套循环(即代码中包含的循环内部还有一个循环操作),因为嵌套环势必会使时间复杂度升阶。而对于一个列表进行循环有限次数的操作,则无须考虑,因为我们会忽略首项的系数。
我们在计算一个算法的时间复杂度时,首先需要找到算法的核心部分,然后根据代码确认时间复杂度。
一般的时间复杂度按照性能从差到好有这么几种:O(n³)、O(n²)、O(nlogn)、O(n)、O(logn)、O(1)。当然,性能差的情况可能还有更高的幂数,但是当算法的时间复杂度达到O(n²)以上时,性能就会相当差,我们应当寻找更优的方案。当然,对于某些比较特殊的算法,可能最优的性能也不会很好。
另外,O(nlogn)、O(logn)内部的内容在数学里是错误的,一般应该是log₂n等,但是这里的系数并不在我们的考虑范围之内,所以我们一般在计算复杂度时直接将其表示为O(nlogn)和O(logn)。
for(int i=0;i<n;i++){
//some code here
for (int j = 0; j < n; j++) {
//some code here
for (int k = 0; k < n; k++) {
//some code here
}
}
}
上面这段代码是个三重嵌套循环代码(且每重循环都执行了完整的n遍),n一般指算法的规模,很容易推断出这段代码的时间复杂度是O(n³)。
所以如果是两重的嵌套循环,那么时间复杂度是O(n²),如果只有一重循环,那么时间复杂度是O(n)。
for(int i=0;i<n;i++){
//some code here
for (int j = i; j < n; j++) {
//some code here
}
}
在内层循环中j的起始量是i,而随着每次外层循环i的增加,j的一层循环执行的次数将会减少。对于这种情况,我们把时间复杂度称为O(nlogn)。
for(int i=0;i<n;i*=2){
//some code here
}
上面这段代码的时间复杂度称为O(logn),并将这种情况称为对数阶,性能要优于O(n)。
性能最好的算法的时间复杂度为O(1),也就是在执行有限次的操作之后达到目标。比如一些计算类型的代码或者交换值的代码等。
int a = 1;
int b = 2;
int sum = a + b;
int temp = a;
a = b;
b = temp;
当然,一个算法能不能达到O(1)的时间复杂度,要看具体情况,我们当然希望程序的性能能够达到最优,所以算法的时间复杂度能够低于O(n²)一般来说已经很不错了。算法的性能除考虑时间复杂度外还要考虑空间复杂度,在大多数情况下往往需要在时间复杂度和空间复杂度之间进行平衡。
我们在上面提到的情况都只有一个规模参数,有时候参数也可能有两个。比如两层嵌套循环的规模不一样,我们假设分别为m和n,这时我们一般会把时间复杂度写为O(m*n),但是我们自己需要明确,如果m和n非常相近,则这个时间复杂度趋于O(n²),如果m通常比较小,也就是说我们能够明白m的范围是多少,则这个时间复杂度趋于O(n)。在这两种情况下,虽然时间复杂度都是O(m*n),但是真实的时间复杂度可能相差很大。
实际上,一个算法的执行时间是不可能通过我们的计算得出的,必须到机器上真正执行才能知道,而且每次的运行时间不一样。但是我们没必要将每个算法的都到机器上运行和测试,并且对于很多算法,我们通过简单的分析就能知道其性能的好坏,而没有必要详细地写出来,所以时间复杂度的计算还是非常重要的。
时间复杂度其实还分为平均时间复杂度、最好时间复杂度和最坏时间复杂度。对于一个算法来说,往往很多特殊情况,一般而言,我们所说的时间复杂度都指最坏时间复杂度,因为在最坏的情况下,我们才能够评估一个算法的性能最差会到什么地步,这样我们才能更好地选择相应的算法去解决问题。
2,空间复杂度
其实我们在做算法分析时,往往会忽略空间复杂度,可能是因为现在计算机的空间已经越来越便宜了,成本很低,而一台计算机的CPU的性能很难得到太大的提升。但是空间复杂度作为另一个算法性能指标,也是我们需要掌握的,这能够让程序在时间和空间上都得到优化,称为一个好的算法。
空间复杂度的表示其实和时间复杂度时一样的,都用大O符号表示。空间复杂度时一个算法在运行过程中所消耗的临时空间的一个度量。
空间复杂度的计算方式和时间复杂度一样,也不包括这个函数的低阶项和首项系数。
一般我们认为对于一个算法,本身的数据会消耗一定的空间,可能还需要一些其他空间,如果需要的其他空间是有限的,那么这个空间复杂度为O(1)。相对地,也有O(n)、O(nlogn)、O(n²)。
3,稳定性
算法性能分析一般分为时间复杂度分析和空间复杂度分析。另外,在排序算法中会有另一个指标——稳定性。
在排序算法中,可能在一个列表中存在多个相等的元素,而经过排序之后,这些元素的相对次序保持不变,这时我们称这个算法是稳定的。若经过排序之后次序变了,那么就是不稳定的。
如果算法是稳定的,那么第1个元素排序的结果可以被第2个相同值的元素排序时所用,也就是说如果算法是稳定的,那么可能避免多余的比价。
而某些情况下,若是值一样的元素也要保持与原有的相对次序不变,那么这时必须用一个稳定的算法。
二,快而简单的排序——桶排序
1,桶排序
桶排序,也叫作箱排序,是一个排序算法,也是所有排序算法中最快、最简单的排序算法。其中的思想是我们首先需要知道所有待排序元素的范围,然后需要由在这个范围内的同样数量的桶,接着把元素放到对应的桶中,最后按顺序输出。
2,实现
public class test {
public static void main(String[] args) {
int[] array = {5, 9, 1, 9, 5, 3, 7, 6, 1};
BucketSort bucketSort = new BucketSort(11, array);
bucketSort.sort();
bucketSort.print();
}
public static class BucketSort {
private int[] buckets;
private int[] array;
public BucketSort(int range, int[] array) {
this.buckets = new int[range];
this.array = array;
}
/**
* 排序
*/
public void sort() {
if (array != null && array.length > 1) {
for (int i = 0; i < array.length; i++) {
buckets[array[i]] ++;
}
}
}
/**
* 从大到小排序
*/
public void print() {
// 倒序输出数据
for (int i = buckets.length - 1; i >= 0; i--) {
// 元素中值为几,说明有多少个相同值的元素,则输出几遍
for (int j = 0; j < buckets[i]; j ++) {
System.out.println(i);
}
}
}
}
}
3,桶排序的性能及特点
桶排序实际上只需要遍历一遍所有的带排序元素,然后依次放入指定的位置。如果加上输出排序的时间,那么需要遍历所有的桶,时间复杂度就是O(n+m),其中,n为待排序的元素个数,m为桶的个数。当元素的跨度范围越大时,空间的浪费就越大。
桶排序的特点就是速度快,简单,但是也有相应的弱点,就是空间利用率低,如果数据跨度过大,则空间可能无法承受,或者说这些元素并不适合使用桶排序。
4,桶排序的适用场景
桶排序的适用场景就是在数据分布相对比较均匀或者数据跨度范围并不是很大时,排序的速度还是相当快且简单的。
三,冒泡排序
冒泡排序(bubble sort)重复地走访要排序的数列,一次比较两个数据元素,如果顺序不对则进行交换,并一直重复这样的走访操作,直到没有要交换的数据元素为止。
1,实现
public class test {
public static void main(String[] args) {
int[] array = {5, 9, 1, 9, 5, 3, 7, 6, 1};
BubbleSort bubbleSort = new BubbleSort(array);
bubbleSort.sort2();
bubbleSort.print();
}
public static class BubbleSort {
private int[] array;
public BubbleSort(int[] array) {
this.array = array;
}
/**
* 从小到大
*/
public void sort() {
int length = array.length;
if (length > 0) {
for (int i = 1; i < length; i++) {
for (int j = 0; j < length - i; j++) {
if (array[j] > array[j + 1]) {
int temp = array[j];
array[j] = array[j + 1];
array[j + 1] = temp;
}
}
}
}
}
/**
* 从大到小
*/
public void sort2() {
int length = array.length;
if (length > 0) {
for (int i = length - 1; i > 0; i--) {
for (int j = length - 1; j > length - 1 - i ; j--) {
if (array[j] > array[j - 1]) {
int temp = array[j];
array[j] = array[j - 1];
array[j - 1] = temp;
}
}
}
}
}
public void print() {
for (int i = 0; i < array.length; i++) {
System.out.println(array[i]);
}
}
}
}
2,冒泡排序的特点及性能
冒泡排序算法在每轮排序中会使一个元素排到一端,也就是最终需要n-1轮这样的排序(n为待排序的数列的长度),而在每轮排序中都需要对相邻的两个元素进行比较,在最坏的情况下,每次比较之后都需要交换位置,所以这里的时间复杂度是O(n²)。其实冒泡排序在最好的情况下,时间复杂度可以达到O(n),在待排序的数列本身就是我们想要的排序结果时,时间复杂度就是O(n),因为只需要一轮排序并且不用交换。但是实际上这种情况很少,所以冒泡排序的平均时间复杂度是O(n²)。
对于空间复杂度来说,冒泡排序用到额外的存储空间只有一个,那就是用来交换位置的临时变量,其他所有操作都是在原有待排序列上处理的,所以空间复杂度为O(1)。
冒泡排序是稳定的,因为在比较过程中,只有后一个元素比前面的元素大时才会对它们交换位置并向上冒出,对于同样大小的元素,是不需要交换位置的,所以对于同样大小的元素来说,相对位置是不会改变的。
3,冒泡排序的改进方案
1,增加标志位
我们增加一个变量来记录每趟排序中最后一个交换的位置,由于这个位置之后的元素已经不用交换了,这说明后面的元素都完成排序了,所以下次开始时可以直接从尾比较这个位置,这样便能保证前面的元素如果本身有序就不用重复比较了。
2,一次冒两个元素
每一趟排序都是通过交换把最大的元素冒到上面去,那么可不可以在每趟排序中进行正向和反向的两次冒泡比较呢?答案是可以。对于每一趟比较,在倒着比较出最大的元素之后,再正着比较出最小的元素并使其沉下去,可以使排序唐数几乎减少一半。
四,最常用的快速排序
冒泡排序的时间复杂度是O(n²),如果计算机每秒运算10亿次,排序1亿个数字,那么桶排序只需要0.1秒,冒泡排序则需要1千万秒(也就是115天),那么有没有一种排序既省时又省空间呢?快速排序。
1,什么是快速排序
通过一趟排序将要排序的数据分割成独立的两部分,其中一部分的所有数据比另一部分的所有数据要小,再按这种方法对这两部分数据分布进行快速排序,整个排序过程可以递归进行,使整个数据变成有序序列。
2,实现
public class test {
public static void main(String[] args) {
int[] array = {5, 9, 1, 9, 5, 3, 7, 6, 1};
QuickSort quickSort = new QuickSort(array);
quickSort.sort();
quickSort.print();
}
public static class QuickSort {
private int[] array;
public QuickSort(int[] array) {
this.array = array;
}
public void sort() {
quickSort(array, 0, array.length - 1);
}
public void print() {
for (int i = 0; i < array.length; i++) {
System.out.println(array[i]);
}
}
/**
* 递归排序
* @param src
* @param begin
* @param end
*/
private void quickSort(int[] src, int begin, int end) {
if (begin < end) {
int key = src[begin];
int i = begin;
int j = end;
while (i < j) {
while (i < j && src[j] > key) {
j--;
}
if (i < j) {
src[i] = src[j];
i++;
}
while (i < j && src[i] < key) {
i++;
}
if (i < j) {
src[j] = src[i];
j--;
}
}
src[i] = key;
quickSort(src, begin, i - 1);
quickSort(src, i + 1, end);
}
}
}
}
3,快速排序的特点及性能
快速排序是在冒泡排序的基础上改进而来的,冒泡排序每次只能交换相邻的两个元素,而快速排序是跳跃式的交换,交换的距离很大,因此总的比较和交换次数少了很多,速度也快乐不少。
但是快速排序在最坏情况下的时间复杂度和冒泡排序一样,是O(n²),实际上每次比较都需要交换,但是这种情况并不常见,平均时间复杂度是O(nlogn)。空间复杂度为O(logn)。
快速排序是一个不稳定的算法,在经过排序之后,可能会对相同值的元素的相对位置造成改变。
快速排序基本上被认为是相同数量级的所有排序算法中,平均性能最好的。
4,快速排序的适用场景
快速排序由于相对简单而且性能不错,所以是我们比较常用的排序算法,在需要对数列排序时,我们优先选择快速排序。
5,快速排序的优化
1,三者取中法
由于每次选择基准值都选择第1个,这就会产生一个问题,那就是可能会造成每次都需要移动,这样会使算法的性能很差,趋向于O(n²),所以我们要找出中间位置的值。我们希望基准值能够更接近中间位置的值,所以这里可以每次使用带排序的数列部分的头、尾、中间数,在三个数中取中间大小的那个数作为基准值,然后进行快速排序,这样能够对一些情况记性优化。
2,根据规模大小改变算法
由于快速排序在数据量较小的情况下,排序性能并没有其他算法好,所以我们可以在待排序的数列分区小于某个值后,采用其他算法进行排序,而不是继续使用快速排序,这时也能得到一定的性能提升。
3,其他分区方案考虑
有时,我们选择的基准数在数列中可能存在多个,这时我们可以考虑改变分区的方案,那就是分三个分区,除了小于基准数的分区、大于基准数的分区,我们还可以交换出一个等于基准数的分区,这样我们在之后每次进行递归时,就只递归小于和大于两个部分的区间,对于等于基准数的区间就不用再考虑了。
4,并行处理
由于快速排序对数组中每一小段范围进行排序,对其他段并没有影响,所以可以采用多线程并行处理来提高效率。
五,简单的插入排序
1,什么是插入排序
插入排序就是往数列里面插入数据元素。一般我们认为插入排序就是往一个已经排好序的待排序的数列中插入一个数,使得插入这个数之后,数列仍然有序。
2,实现
public class test {
public static void main(String[] args) {
int[] array = {5, 9, 1, 9, 5, 3, 7, 6, 1};
InsertSort insertSort = new InsertSort(array);
insertSort.sort();
insertSort.print();
}
public static class InsertSort {
private int[] array;
public InsertSort(int[] array) {
this.array = array;
}
public void sort() {
if (array == null) {
throw new RuntimeException("array is null");
}
int length = array.length;
if (length > 0) {
for (int i = 1; i < length; i++) {
int temp = array[i];
int j = i;
for (; j > 0 && array[j - 1] > temp; j--) {
array[j] = array[j - 1];
}
array[j] = temp;
}
}
}
public void print() {
for (int i = 0; i < array.length; i++) {
System.out.println(array[i]);
}
}
}
}
3,插入排序的特点
插入排序的时间复杂度是O(n²),空间复杂度是O(1),是稳定的。
插入排序的性能并不是很好。
六,散列表
若在手机通讯录中查找一个人,那我们应该不会从第1个人一直找下去,因为这样实在是太慢了。我们其实是这样做的:首先看这个人的名字的首字母是什么,比如姓张,那么我们一定会滑倒最后,因为Z姓的名字都在最后。
其实这里就用到了散列表的思想。
散列表,又叫哈希表(hash table),是能够通过给定的关键字的值直接访问到具体对应的值的一个数据结构。也就是说,把关键字映射到一个表中的位置来直接访问记录,以加快访问速度。
通常,我们把这个关键字称为key,把对应的记录称为value,所以也可以说是通过key访问一个映射表来得到value的地址。而这个映射表,也叫散列函数或者哈希函数,存放记录的数组叫做散列表。
其中有特殊情况,就是通过不同的key,可能访问到同一地址,这种现象叫做碰撞。而通过某个key一定会得到唯一的value地址。
散列表有两种用法:一种是key的值与value的值一样,一般我们称这种情况的结构为set(集合),而如果key和value所对应的内容不一样时,我们称这种情况为map,也就是键值对集合。
散列表的使用场景:1,缓存;2,快速查找。
七,参考资料
《轻松学算法——互联网算法面试宝典》