本文将介绍复杂度分析和三大类算法:递归,排序,二分查找(下篇博客写)。
不吹不黑,绝对是史上最详细的教程!!!要是能举出反例,欢迎来扇我的猪脸
算法的复杂度分析主要包含两个方面:
• 时间复杂度分析
• 空间复杂度分析
为什么要进行复杂度分析?
1:和性能测试相比,复杂度分析有不依赖执行环境、成本低、效率高、易操作、指导性强的特点。
2:掌握复杂度分析,将能编写出性能更优的代码,有利于降低系统开发和维护成本
代码的执行时间 T(n)与每行代码的执行次数 n 成正比 。 我们可以把这个规律总结成一个公式。
T(n) = O(f(n))
其中T(n)表示代码的执行时间, n 表示数据规模的大小, f(n)表示了代码执行次数的总和,它是一个公式因此用 f(n)表示, O 表示了代码执行时间与 f(n)成正比 , 这就是大 O 时间复杂度表示法
大 O 时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势,所以,也叫作渐进时间复杂度, 简称时间复杂度。
1:代码循环次数最多原则
分析一个算法或者一个代码的时间复杂度时,只需关注循环执行次数最多的那一段代码即可。
2:加法原则
如果两段最大量级的复杂度分别为 O(n)和 O(n*n),其结果本应该是:T(n)=O(n)+O(n * n),我们取其中最大的量级,因此整段代码的复杂度为: O(n * n)
总的时间复杂度就等于量级最大的那段代码的时间复杂度
3:乘法原则
int sum(int n) {
int ret = 0;
int i = 1;
//单独看是:O(n),由于 func(i)是 O(n)因此整体是:O(n) * O(n) = O(n*n) = O
(n*n)
for (; i < n; ++i) {
ret = ret + func(i);//f(i)是 O(n)
}
}
// O(n)
int func(int n) {
int sum = 0;
int i = 1;
for (; i < n; ++i) {
sum = sum + i;
}
return sum;
}
因此可以看出: 嵌套代码的复杂度等于嵌套内外代码复杂度的乘积
O(1)
只要代码的执行时间不随着 n 的增大而增大,这样的代码复杂度都是 O(1),或者说:只要在算法中不存在递归语句,随 n 变化的循环语句等,即使有千万行代码,复杂度也是 O(1)
O(n)
很常见,基本一眼看出来
O(logn) 对数阶的复杂度非常的常见,但同时也是很难分析的一种复杂度
public void test04(int n){ int i=1; while(i<=n){ i = i * 2; } }
使用大 O 标记时间复杂度时不考虑低阶,系数,常量,所以在对数阶时间复杂度中我们忽略对数的底统一表示为 O(logn)
O(nlogn) 对数阶的复杂度非常的常见,但同时也是很难分析的一种复杂度
public void test06(int n){ int i=0; for(;i<=n;i++){ test04(n); } } public void test07(int n){ int i=1; while(i<=n){ i = i * 2; } }
差不多就是 O(logn)嵌套一个O(n)循环,合起来就是O(nlogn)
最好情况复杂度:在最理想的情况下代码的时间复杂度
最坏情况复杂度:在最糟糕的情况下代码的时间复杂度
平均时间复杂度:加权平均值,也叫做期望值,所以平均时间复杂度也叫期望时间复杂度
实际上:大多数情况下,我们不需要去区分最好/最坏/平均 时间复杂度,只有在同一段代码块在不同情况下的复杂度有量级的差距时才需要用这三种复杂度来加以区分
我们一般说的时间复杂度都是最坏情况复杂度
时间复杂度的全称是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系,类比一下, 空间复杂度全称是渐进空间复杂度,表示算法占用的存储空间与数据规模之间的增长关系
我们常见的空间复杂度就是 O(1),O(n),O(n * n),其他像对数阶的复杂度几乎用不到,因此空间复杂度比时间复杂度分析要简单的多。因此掌握这些足够。
概念:
递归在维基百科的官方解释为: 递归(Recursion),又名递回, 在数学与计算机科学中,是指在函数的定义
中使用函数自身的方法。
在编程语言中对递归可以简单理解为:方法自己调用自己,只不过每次调用时参数不同而已。
条件:
条件 1:递归表达式(规律)
条件 2:终止递归的条件(递归出口)
写递归代码的关键就是找到如何将一个问题拆分成多个小问题的规律,并且基于此写出递推公式,然后再找到递归终止条件,最后将递推公式和终止条件翻译成代码即可
循环都可以改写成递归,递归未必能改写成循环。
自己总结的纯白话:
在调用递归函数时,由于每次内存都会提供函数调用栈,用来存储函数调用时的临时变量 ,但是当递归层次很深时,这些就会一直在压入栈,我们知道系统栈或者虚拟机栈的空间一般都不大,所以如果一直入栈就会出现堆栈溢出的风险
如何解决:
我们可以在代码中限制递归调用的最大深度,比如递归调用超过 10000 次后就不在继续递归调用了,直接返回或者抛出异常。但是其实这样去做并不能完全的解决问题,因为当前线程能允许的最大递归深度其实跟当前剩余的栈空间大小有关系,事先是不知道有多大的,如果想知道就得实时的去计算剩余的栈空间大小,这样也会影响我们的性能,所以说如果递归的深度不是特别大的话是可以使用这种方式来防止堆栈溢出的,否则的话这种办法也不合适。
案例:
比如在斐波那契数列中,我们如果算f(5)=f(4)+f(3),但是f(4)又会计算一下f(3)造成f(3)被重复计算了多次
如何解决:
为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免重复计算的问题了
public static int factorial(int n) {
if(n==0)return 1;
return n*factorial(n-1);
}
这容易理解,但是,当这个n足够大,返回的值超出了int的范围,我们需要重新设计返回值
java 中提供了一些数学类可以帮助我们实现大数据运算,特别是一些商业计算,他们分别是: BigInteger 或者 BigDecimal
BigInteger 实现了任意精度的整数运算, BigDecimal 实现了任意精度的浮点数运算,接下来我们使用 BigInteger 来对我们的阶乘算法进行改造。
//超过int范围用这个
public static BigInteger factorial2(int n) {
if(n==0)return BigInteger.ONE;
return BigInteger.valueOf(n).multiply(factorial2(n-1));
}
有些时候,计算的比这个还要大,我们可以使用数组,如123*255,我们分别用两个int[3]存储,然后将各位运算 ,并将最后结果再次变成一个数组返回
/**
* 高效拷贝目录
*/
public class CopyDir {
/**
* 实现目录拷贝
* @param source
* @param targer
*/
public void copyDir(File source,File targer){
if(source.isFile()|| !source.exists()){
return;
}
//在目标目录下创建原目录
File newDir = new File(targer,source.getName());
newDir.mkdir();
//获取原目录下的所有文件和目录
File[] listFiles = source.listFiles();
//遍历判断是文件还是目录,是文件则直接进行拷贝工作,是目录则递归调用自
己实现拷贝
for (File listFile : listFiles) {
if(listFile.isFile()){
//如果是文件则直接拷贝
try {
BufferedInputStream bis = new BufferedInputStream(n
ew FileInputStream(listFile));
BufferedOutputStream bos = new BufferedOutputStream
(new FileOutputStream(new File(newDir,listFile.getName())));
int len = 0;
byte[] bytes = new byte[1024*8];
while ((len= bis.read(bytes))!=-1){
bos.write(bytes,0,len);
}
bos.close();
bis.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
}else {
//如果是目录就递归拷贝
copyDir(listFile,newDir);
}
}
}
分析排序算法的时间复杂度时要分别给出最好情况、最坏情况、平均情况下的时间复杂度。
在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。
在分析基于比较的排序算法时要将元素比较/交换/移动的次数也考虑进来
in-place 和 out-place
in-place 可以称为原地排序就是特指空间复杂度为 O(1)的排序算法,算法只占用常数内存,不占用额外内存,而 out-place 的算法需要占用额外内存
所谓排序算法的稳定性指的是:如果待排序的序列中存在值相等的元素,经过排序之后, 相等元素之间原有的先后顺序不变。
举个例子,有一组数据: 3 7 2 7 5 8 9,我们按照大小排序之后的数据为: 2 3 5 7 78 9,在这组数据中有两个 7,如果经过某种排序算法后两个 7 的前后顺序没有发生改变则称该算法是稳定的排序算法,否则称为该算法是不稳定的排序算法。
冒泡排序通过依次比较两个相邻的的元素,看两个元素是否满足大小关系要求,如果不满足则交换两个元素。每一次冒泡会让至少一个元素移动到它应该在的位置上,这样 n 次冒泡就完成了 n 个数据的排序工作。
/**
* 冒泡排序
* @param array
*/
public static void Bubble(int[] array) {
if(array.length<=1)return;
for(int i=0;i<array.length;i++) {
boolean flag = true;//是否需要提前结束冒泡排序的标识
for(int j=0;j<array.length-i-1;j++) {
if(array[j]>array[j+1]) {
//使用中间变量
//int temp = array[j];
//array[j]=array[j+1];
//array[j+1] = temp;
flag = false;
//不使用中间变量
array[j]=array[j]+array[j+1];
array[j+1]=array[j]-array[j+1];
array[j]=array[j]-array[j+1];
}
}
if (flag) {
break;//在当前这次冒泡中如果所有元素都不需要进行交换则证明所有元素都已有序,则无需进行后续的冒泡操作了
}
}
}
这里对普通冒泡做了一次优化,因为当某次冒泡时发现已经没有数据需要进行交换时,说明所有元素都已经达到有序状态了,此时就不用再执行后续的冒泡操作了
时间复杂度:O(n^2)
空间复杂度:冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一种 in-place 排序算法。
为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。
数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。
/**
* 插入排序
* @param array
*/
public static void Insertion(int[] array) {
if(array.length<=1)return;
for(int i=1;i<array.length;i++) {
//取出未排序的下一元素
int cur = array[i];
//在已经排序的元素序列中从后向前扫描,定义前置索引
int pre = i-1;
while(pre>=0&&array[pre]>cur) {
//比较过程中如果前置元素大于cur元素则将该元素后移一位
array[pre+1] = array[pre];
pre--;
}
//比较过程中如果该元素小于等于当前元素,则将cur元素放在该元素后面
array[pre+1]=cur;
}
}
时间复杂度:O(n^2)
插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1)
对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。
先遍历所有,然后找出最小的索引交换。也分已排序区间和未排序区间。但是选择排序每次会从排序区间中找到最小的元素,将其放到已排序区间的末尾。
/**
* 选择排序
* @param array
*/
public static void selection(int[] array) {
if(array.length<=1)return;
for(int i=0;i<array.length-1;i++) {
int min = i;
for(int j=i+1;j<array.length;j++) {
if(array[min]>array[j])min=j;
}
if(min!=i) {
int temp = array[i];
array[i] = array[min];
array[min] = temp;
}
}
}
时间复杂度:O(n^2)
空间复杂度:O(1)
选择排序不是一个稳定的排序算法 :
选择排序每次都要找剩余未排序元素中的最小值,并和未排序区间的第一个元素进行交换位置,这样破坏了稳定性,比如 5, 8, 5, 2, 9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。
归并排序使用的是分治思想 分治是一种解决问题的处理思想,递归是一种编程技巧,这两者并不冲突。而对于递归就是要找到递推公式及终止条件,所以我们可以先写出归并排序的递推公式
mergeSort(m->n) = merge(mergeSort(m->k),mergeSort(k+1->n));当 m=n 时终止
我们来解释一下这个公式:我们要对 m->n 之间的数列进行排序,其实可以拆分成对 m->k 之间的数列进行排序,以及对 k+1->n 之间的数列排序,然后将连个拍好序的数列进行合并就称为了最终的数列,同样的道理,每一段数列的排序又可以继续往下拆分,形成递归
这里引用网上找来的图片方便理解过程
/**
* 归并排序
* @param array
* @return
*/
public static int[] mergeSort(int[] array) {
if(array.length<2)return array;
//拆分两半
int mid = array.length/2;
int[] left = Arrays.copyOfRange(array, 0, mid);
int[] right = Arrays.copyOfRange(array, mid, array.length);
//这里递归,先拆分后合并
return merge(mergeSort(left), mergeSort(right));
}
/**
* 合并两个有序数组变成一个新的数组
* @param left
* @param right
* @return
*/
public static int[] merge(int[] left,int[] right) {
int[] newArray = new int[left.length+right.length];
//定义两个指针,分别指向两边数组比较元素的下标
int lindex = 0;
int rindex = 0;
for(int i=0;i<newArray.length;i++) {
if(lindex>=left.length) { //检查左下标是否越界
newArray[i]=right[rindex++];
}else if(rindex>=right.length) { //检查右下标是否越界
newArray[i]=left[lindex++];
}else if(left[lindex]<=right[rindex]) {
newArray[i]=left[lindex++];
}else {
newArray[i]=right[rindex++];
}
}
return newArray;
}
时间复杂度:O(nlogn)
我们假设对 n 个元素进行归并排序需要的时间是 O(n),那分解成两个子数组排序的时间都是 O(n/2)。我们知道, merge() 函数合并两个有序子数组的时间复杂度是O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:
O(1) = C; n=1 时,只需要常量级的执行时间,所以表示为 C。
O(n) = 2*O(n/2) + n; n>1
当数据量很大的时候 nlogn的优势将会比n2越来越大,当n=105的时候,nlogn的算法要比n2的算法快6000倍,那么6000倍是什么概念呢,就是如果我们要处理一个数据集,用nlogn的算法要处理一天的话,用n2的算法将要处理6020天。这就基本相当于是15年。一个优化改进的算法可能比一个比一个笨的算法速度快了许多,这就是为什么我们要学习算法。
空间复杂度:O(n)
尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻, CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n),因此归并排序并不是一种 in-place 排序算法而是一种 out-place 排序算法。
归并排序也是一个稳定的排序算法。
快速排序(Quick Sort)算法,简称快排,利用的也是分治的思想,初步看起来有点像归并排序,但是其实思路完全不一样,快排的思路是:如果要对 m->n 之间的数列进行排序,我们选择 m->n 之间的任意一个元素数据作为分区点(Pivot),然后我们遍历 m->n 之间的所有元素,将小于 pivot 的元素放到左边,大于 pivot 的元素放到右边, pivot 放到中间,这样整个数列就被分成三部分了, m->k-1 之间的元素是小于 pivot 的,中间是 pivot, k+1->n 之间的元素是大于 pivot 的。然后再根据分治递归的思想处理两边区间的的元素数列,直到区间缩小为 1,就说明整个数列都已有序了。
递推公式:quickSort(p…r) = quickSort(p…q-1) + quickSort(q+1, r)
终止条件:p >= r
partition() 分区函数: partition() 分区函数实际上我们前面已经讲过了,就是随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A[p…r] 分区,函数返回 pivot 的下标。如果我们不考虑空间消耗的话, partition() 分区函数可以写得非常简单。我们申请两个临时数组 X 和 Y,遍历 A[p…r],将小于 pivot 的元素都拷贝到临时数组 X,将大于 pivot 的元素都拷贝到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷贝到 A[p…r], 但是,如果按照这种思路实现的话, partition() 函数就需要很多额外的内存空间,快排就不是一种 in-place 排序算法了。如果我们希望快排的空间复杂度得是O(1),那 partition()分区函数就不能占用太多额外的内存空间,我们就需要在A[p…r] 的原地完成分区操作。具体解决看一下代码
/**
* 第一种快排
* @param arr
* @param begin
* @param end
*/
public static void quickSort(int[] arr,int begin,int end) {
//递归截止条件
if(arr.length<2||begin>=end) {
return;
}
//进行分区 得到分区下标
int privotIndex = partition(arr, begin, end);
//对左分区进行快排
quickSort(arr, begin, privotIndex-1);
//对右分区进行快排
quickSort(arr, privotIndex+1, end);
}
/**
* 分区函数
* @param arr
* @param begin
* @param end
* @return
*/
public static int partition(int[] arr,int begin,int end) {
//默认数组中待分区区间的最后一个是 pivot 元素
int privot = arr[end];
//定义分区后 pivot 元素的下标
int privotIndex = begin;
for(int i=begin;i<end;i++) {
//判断如果该区间内如果有元素小于 pivot 则将该元素从区间头开始一直向后填充,并且privot元素下标后移(为了方便下次找到小于的元素然后交换实现该元素从区间头开始一直向后填充)
if(arr[i]<privot) {
if(i>privotIndex)swap(arr, privotIndex, i);
privotIndex++;
}
}
//最后将基准为与privotIndex位置上的元素交换(这个元素肯定大于基准),而这个索引之前的元素都小于基准
swap(arr, end, privotIndex);
return privotIndex;
}
public static void swap(int[] arr,int i,int j){
int temp = arr[j];
arr[j] = arr[i];
arr[i] = temp;
}
同时,我在网上还找到了第二种类似写法,原作通过图文并茂解释了原理
原作地址
/**
* 第二种快排
* @param arr
* @param begin
* @param end
*/
public static void quickSort2(int[] arr,int begin,int end) {
if(arr.length<2||begin>=end) {
return;
}
int i=begin,j=end;//定义左右哨兵索引
int privot = arr[begin];//以最左边为基准
while(i<j) {
//先从右往左找比基准小的
while(privot<=arr[j]&&i<j) {
j--;
}
//在从左往右找比基准大的{
while(privot>=arr[i]&&i<j) {
i++;
}
if(i<j) {
swap(arr, i, j);
}
}
//跳出循环说明i==j,将基准和ij相等的位置互换
swap(arr, i, begin);
//对左分区进行快排
quickSort(arr, begin, i-1);
//对右分区进行快排
quickSort(arr, i+1, end);
}
这里我要特地解释一下为什么设置的基准数是最左边的数,需要让哨兵j先出动,我的理解如下:
虽然我们让i先出动前面得到的结果是一样的,但是当我们到了这张图的地步时
我们要是还是让i先走,这时候他会直接跳过3到9,也就是说最后会在9回合,基准点变成9,结果可想而知
时间复杂度:快排的时间复杂度最好以及平均情况下的复杂度都是 O(nlogn),只有在极端情况下会变成 O(n^2)
快速排序之所比较快,因为相比冒泡排序,每次交换是跳跃式的。每次排序的时候设置一个基准点,将小于等于基准点的数全部放到基准点的左边,将大于等于基准点的数全部放到基准点的右边。这样在每次交换的时候就不会像冒泡排序一样每次只能在相邻的数之间进行交换,交换的距离就大的多了。因此总的比较和交换次数就少了,速度自然就提高了。当然在最坏的情况下,仍可能是相邻的两个数进行了交换。因此快速排序的最差时间复杂度和冒泡排序是一样的都是O(N2),它的平均时间复杂度为O(NlogN)。其实快速排序是基于一种叫做“二分”的思想。
空间复杂度: O(1)
因为分区的过程涉及跳跃式交换操作,所以快速排序并不是一个稳定的排序算法。
快排和归并的异同
首先快排和归并都用到了分治递归的思想,在快排中对应的叫分区操作,递推公式和递归代码也非常相似,但是归并排序的处理过程是由下到上的由局部到整体,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下由整体到局部,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是一种 out-place 排序算法。主要原因是合并函数无法在原地(数组内)执行。快速排序通过设计巧妙的原地(数组内)分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。
之所以能做到线性的时间复杂度O(n) ,主要原因是这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。
桶排序是将待排序集合中处于同一个值域的元素存入同一个桶中,也就是根据元素值特性将集合拆分为多个区域,则拆分后形成的多个桶,从值域上看是处于有序状态的。对每个桶中元素进行排序,则所有桶中元素构成的集合是已排序的。
桶排序的思想近乎彻底的分治思想。
动图演示:
/**
* 桶排序
* @param array 待排序集合
* @param bucketSize 桶中元素类型的个数即每个桶所能放置多少个不同数值
* @return
*/
public static List<Integer> bucketSort(List<Integer> array,int bucketSize){
//合法性校验(为桶中元素排序用递归的话这就是递归截止条件)
if(array==null||array.size()<2||bucketSize<1) {
return array;
}
//找到集合中元素的最大值最小值
int max = array.get(0);
int min = array.get(0);
for(int i=1;i<array.size();i++) {
if(max<array.get(i))max=array.get(i);
if(min>array.get(i))min=array.get(i);
}
//计算桶的个数
int bucketCount = (max-min)/bucketSize+1;
//按序创建桶,创建一个 List,List 带下标是有序的,List 中的每一个元素是一个桶,也用 List 表示
List<List<Integer>> bucketList = new ArrayList<List<Integer>>();
for(int i=0;i<bucketCount;i++) {
bucketList.add(new ArrayList<Integer>());
}
//将待排序的集合依次添加到对应的桶中
for(int j=0;j<array.size();j++) {
int bucketIndex = (array.get(j)-min)/bucketSize;
bucketList.get(bucketIndex).add(array.get(j));
}
//对每一个桶中的数据进行排序(可以使用别的排序方式),然后再将桶中的数据依次取出存放到一个最终的集合中
List<Integer> resultList = new ArrayList<>();
for(int j=0;j<bucketList.size();j++) {
List<Integer> everyList = bucketList.get(j);
//如果桶内有元素
if(everyList.size()>0) {
//递归的使用桶排序为每一个桶进行排序
//当某次桶排序待排序集合都分配到一个桶中时,缩小桶的范围以获得更多的桶
if(bucketCount==1)bucketSize--;
List<Integer> temp = bucketSort(everyList, bucketSize);
for(int i=0;i<temp.size();i++) {
resultList.add(temp.get(i));
}
}
}
return resultList;
}
注意,如果递归使用桶排序为各个桶排序,则当桶数量为 1 时要手动减小BucketSize 增加下一循环桶的数量,否则会陷入死循环,导致内存溢出。
时间复杂度:
分析:
如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有k=n/m 个元素。假设每个桶内部使用快速排序,时间复杂度为 O(k * logk)。 m 个桶排序的时间复杂度就是 O(m * k * logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n*log(n/m))。当桶的个数 m 接近数据个数 n 时, log(n/m) 就是一个非
常小的常量,这个时候桶排序的时间复杂度接近 O(n)。
桶排序看起来是如此的优秀,那是不是可以替代我们之前讲到的排序算法呢?答案是否定的。首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
桶排序比较适合用在非内存排序中。所谓的非内存排序就是说数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。此外由桶排序的过程可知,当待排序集合中存在元素值相差较大时,对映射规则的选择是一个挑战,有时可能导致元素集中分布在某一个桶中或者绝大多数桶是空桶的现象,对算法的时间复杂度或空间复杂度有较大影响,所以桶排序适用于元素值分布较为集中的序列,或者说待排序的元素能够均匀分布在某一个范围[MIN, MAX]之间。
桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,如果我们将待排序元素映射到某一个桶的映射规则做的很好的话,很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。我们一般对每个桶内的元素进行排序时采用快排也可以采用递归桶排序,通过我们刚开始的分析,当我们对每个桶采用快排时如果桶的个数接近数据规模 n 时, 复杂度为 O(n),如果在极端情况下复杂度退化为 O(n* log n)。
空间复杂度:
由于需要申请额外的空间来保存元素,并申请额外的空间来存储每个桶,所以空间复杂度为 O(N+M),其中 M 代表桶的个数。所以桶排序虽然快,但是它是采用了用空间换时间的做法 。
桶排序是否稳定取决于对每一个桶内元素排序的算法的稳定性,如果我们对桶内元
素使用快排时桶排序就是一个不稳定的排序算法。
描述:比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?
解决方案:
我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00, 01, 02…99)
理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。
不过,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间, 1 元到 100 元, 101 元到 200 元, 201 元到 300 元…901 元到 1000 元。如果划分之后, 101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。
计数排序(Counting Sort) 使用了一个额外的数组 C,其中第 i 个元素是待排序数组A 中值等于 i 的元素的个数。其实计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 m,我们就可以把数据划分成 m 个桶(其实是个数组)。
我们都经历过高考,我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 100 万考生,如何通过成绩快速排序得出名次呢?我们都知道高考的满分是 750 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 751 个桶,对应分数从 0 分到 750 分。根据考生的成绩,我们将这 100 万考生划分到这 751 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序 。
计数排序需要占用大量空间,它仅适用于数据比较集中的情况。
/**
* 计数排序
* @param array
*/
public static void countingSort(int[] array) {
//求出最大值最小值
int max = array[0];
int min = array[0];
for(int i=1;i<array.length;i++){
if( array[i] > max) max = array[i];
if( array[i] < min) min = array[i];
}
//定义一个额外的数组
int bucketSize = max-min+1;
int[] bucket = new int[bucketSize];
//统计对应元素的个数
for(int i=0;i<array.length;i++) {
int bucketIndex = array[i]-min;
bucket[bucketIndex]++;
}
//对额外的数组元素进行累加,目的是为了知道在最终排序后数组中的位置
for(int i=1;i<bucket.length;i++) {
bucket[i] = bucket[i]+bucket[i-1];
}
//创建一个临时数组用来存储最终有序的数据列表
int[] temp = new int[array.length];
//逆序扫描待排序数组----保证元素的稳定性
/**
* 这里解释一下为什么逆序可以保持稳定性
* 正序的话刚开始可能没问题,但是bucket[bucketIndex]一开始特别大,而后慢慢变小,反而arr[i]有小变大,破坏了稳定性
*/
for(int i=array.length-1;i>=0;i--) {
int bucketIndex = array[i]-min;
temp[bucket[bucketIndex]-1]=array[i];
bucket[bucketIndex]--;
}
//将临时数据列表依次放入原始数组
for(int i=0;i<temp.length;i++){
array[i] = temp[i];
}
}
这里可能有些小伙伴还是不懂为什么逆序才能保证稳定性,我通过图举个例子方便你们理解
时间复杂度:
通过代码的实现过程我们发现计数排序不涉及元素的比较,不涉及桶内元素(数组C)的排序,只有对待排序数组和用于计数数组的遍历操作,因此计数排序的时间复杂度是 O(n+k),其中 k 是桶的个数即待排序的数据范围,是一种线性排序算法。
空间复杂度:
计数排序的口空间复杂度为:** O(n+K),其中 n 是数据规模大小, K 是计数排序中需要的桶的个数
计数排序是一个稳定的排序算法。
计数排序的使用场景:
计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
基数排序也是排序算法的一种,老规矩,先来看看百度百科的定义:基数排序(radix sort)属于“分配式排序”(distribution sort),又称“桶子法”(bucket sort)或bin sort,顾名思义,它是透过键值的部份资讯,将要排序的元素分配至某些“桶”中,藉以达到排序的作用,基数排序法是属于稳定性的排序,其时间复杂度为O (nlog®m),其中r为所采取的基数,而m为堆数,在某些时候,基数排序法的效率高于其它的稳定性排序法。
实现:将所有待比较数值(自然数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后, 数列就变成一个有序序列。
基数排序的两种方式:
图解:
个人理解就是先将个位排序,方便个位数和十位数(十位相等)的进行比较,然后逐渐类推,形成有序数列
这里太累了,没咋看得懂,日后再更
各位如果真的想看的话,我直接先复制一段其他博主的代码:
package sort;
public class RadixSort {
private static void radixSort(int[] array,int d)
{
int n=1;//代表位数对应的数:1,10,100...
int k=0;//保存每一位排序后的结果用于下一位的排序输入
int length=array.length;
int[][] bucket=new int[10][length];//排序桶用于保存每次排序后的结果,这一位上排序结果相同的数字放在同一个桶里
int[] order=new int[length];//用于保存每个桶里有多少个数字
while(n<d)
{
for(int num:array) //将数组array里的每个数字放在相应的桶里
{
int digit=(num/n)%10;
bucket[digit][order[digit]]=num;
order[digit]++;
}
for(int i=0;i<length;i++)//将前一个循环生成的桶里的数据覆盖到原数组中用于保存这一位的排序结果
{
if(order[i]!=0)//这个桶里有数据,从上到下遍历这个桶并将数据保存到原数组中
{
for(int j=0;j<order[i];j++)
{
array[k]=bucket[i][j];
k++;
}
}
order[i]=0;//将桶里计数器置0,用于下一次位排序
}
n*=10;
k=0;//将k置0,用于下一轮保存位排序结果
}
}
public static void main(String[] args)
{
int[] A=new int[]{73,22, 93, 43, 55, 14, 28, 65, 39, 81};
radixSort(A, 100);
for(int num:A)
{
System.out.println(num);
}
}
}
作者:Leo-Yang
原文都先发布在作者个人博客:http://www.leoyang.net/
本文版权归作者和博客园共有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利.
排序算法 | 平均情况复杂度 | 最好情况复杂度 | 最坏情况复杂度 | 空间复杂度 | 排序方式 | 稳定性 | 备注 |
---|---|---|---|---|---|---|---|
冒泡排序 | O(n2) | O(n) | O(n2) | O(1) | in-place | 稳定 | |
选择排序 | O(n2) | O(n2) | O(n2) | O(1) | in-place | 不稳定 | |
插入排序 | O(n2) | O(n) | O(n2) | O(1) | in-place | 稳定 | |
归并排序 | O(n*log n) | O(n*log n) | O(n*log n) | O(n) | out-place | 稳定 | |
快速排序 | O(n*log n) | O(n*log n) | O(n2) | O(1) | in-place | 不稳定 | |
桶排序 | O(n) | O(n) | O(n*log n) | O(n+k) | out-place | 不稳定 | 桶排序的复杂度和稳定性取决于用何种排序算法为每一个桶进行排序,在此以快排为例为每个桶进行排序,k为桶的个数 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(n+k) | out-place | 稳定 | 其中n为数据规模,k为计数排序中需要的桶的个数,其实也就是用来计数的数组C的长度,它取决于待排序数组中数据的范围 |
本来还想再接二分查找的,妈的一看已经一万一千多字了,写不动了,这是四月末最后的结晶了。
是什么?爷的意思,一般用来尊称长辈。一群年轻人,用爷自称,这本身就体现了一种不尊重。
其次,为什么用自称,因为他不自信。把自己叫的老一点,年纪大一点,给自己的胡作非为套上一层倚老卖老的面纱。无怪乎现在的人感叹:不是老人变坏了,是坏人变老了。
第三,把爷说成,用图片表述文字,这说明了什么。试想一下,在你幼年,在人类的早期,尚未识字的阶段,是否都会用图片表述自己要表达的意思?他们作为21世纪的新人,还在用图片表意,这说明什么,说明他们文化程度低,文化又低又不自信,用来自称给自己壮胆。
综上所述,用的人一般道德修养品味都很低。
除外。
五月见,各位