目录
二分查找
冒泡排序
选择排序
插入排序
归并排序
希尔排序
快速排序
堆排序
基数排序
常用排序算法稳定性分析
注意:二分查找的数组一定是有序的!!!
在有序数组array[]中,不断将数组的中间值(mid)和被查找的值比较,如果被查找的值等于array[mid],就返回下标mid; 否则,就将查找范围缩小一半。如果被查找的值小于array[mid], 就继续在左半边查找;如果被查找的值大于array[mid], 就继续在右半边查找。 直到查找到该值或者查找范围为空时, 查找结束。
不用递归的二分查找如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
|
二分查找用递归来改写,相信也很简单。边界条件是找到当前值,或者查找范围为空。否则每一次查找都将范围缩小一半。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
|
递归的二分查找和非递归的二分查找效率都为O(logN),递归的二分查找更加简洁,便于理解,但是速度会比非递归的慢。
这个名词的由来很好理解,一般河水中的冒泡,水底刚冒出来的时候是比较小的,随着慢慢向水面浮起会逐渐增大,这物理规律我不作过多解释,大家只需要了解即可。
冒泡算法的运作规律如下:
①、比较相邻的元素。如果第一个比第二个大,就交换他们两个。
②、对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数(也就是第一波冒泡完成)。
③、针对所有的元素重复以上的步骤,除了最后一个。
④、持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 |
|
结果如下:
本来应该是 8 轮排序的,这里我们只进行了 7 轮排序,因为第 7 轮排序之后已经是有序数组了。
冒泡排序解释:
冒泡排序是由两个for循环构成,第一个for循环的变量 i 表示总共需要多少轮比较,第二个for循环的变量 j 表示每轮参与比较的元素下标【0,1,......,length-i】,因为每轮比较都会出现一个最大值放在最右边,所以每轮比较后的元素个数都会少一个,这也是为什么 j 的范围是逐渐减小的。相信大家理解之后快速写出一个冒泡排序并不难。
冒泡排序性能分析:
假设参与比较的数组元素个数为 N,则第一轮排序有 N-1 次比较,第二轮有 N-2 次,如此类推,这种序列的求和公式为:
(N-1)+(N-2)+...+1 = N*(N-1)/2
当 N 的值很大时,算法比较次数约为 N的平方/2次比较,忽略减1。
假设数据是随机的,那么每次比较可能要交换位置,可能不会交换,假设概率为50%,那么交换次数为 N的平方/4。不过如果是最坏的情况,初始数据是逆序的,那么每次比较都要交换位置。
交换和比较次数都和N的平方 成正比。由于常数不算大 O 表示法中,忽略 2 和 4,那么冒泡排序运行都需要 O(N的平方) 时间级别。
其实无论何时,只要看见一个循环嵌套在另一个循环中,我们都可以怀疑这个算法的运行时间为 O(N的平方)级,外层循环执行 N 次,内层循环对每一次外层循环都执行N次(或者几分之N次)。这就意味着大约需要执行N的平方次某个基本操作。
选择排序是每一次从待排序的数据元素中选出最小的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
分为三步:
①、从待排序序列中,找到关键字最小的元素
②、如果最小元素不是待排序序列的第一个元素,将其和第一个元素互换
③、从余下的 N - 1 个元素中,找出关键字最小的元素,重复(1)、(2)步,直到排序结束
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
|
运行结果:
选择排序性能分析:
选择排序和冒泡排序执行了相同次数的比较:N*(N-1)/2,但是至多只进行了N次交换。
当 N 值很大时,比较次数是主要的,所以和冒泡排序一样,用大O表示是O(N的平方) 时间级别。但是由于选择排序交换的次数少,所以选择排序无疑是比冒泡排序快的。当 N 值较小时,如果交换时间比选择时间大的多,那么选择排序是相当快的。
直接插入排序基本思想是每一步将一个待排序的记录,插入到前面已经排好序的有序序列中去,直到插完所有元素为止。
插入排序还分为直接插入排序、二分插入排序、链表插入排序、希尔排序等等,这里我们只是以直接插入排序讲解,后面讲高级排序的时候会将其他的。
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
|
运行结果:
插入排序性能分析:
在第一轮排序中,它最多比较一次,第二轮最多比较两次,一次类推,第N轮,最多比较N-1次。因此有 1+2+3+...+N-1 = N*(N-1)/2。
假设在每一轮排序发现插入点时,平均只有全体数据项的一半真的进行了移动,我们除以2得到:N*(N-1)/4。用大O表示法大致需要需要 O(N的平方) 时间级别。
移动的次数大致等于比较的次数,但是一次移动与一次交换的时间耗时不同,所以相对于随机数据,插入排序比冒泡快一倍,比选择排序略快。
这里需要注意的是,如果要进行逆序排列,那么每次比较和移动都会进行,这时候并不会比冒泡排序快。
总结
面讲的三种排序,冒泡、选择、插入用大 O 表示法都需要 O(N的平方) 时间级别。一般不会选择冒泡排序,虽然冒泡排序书写是最简单的,但是平均性能是没有选择排序和插入排序好的。
选择排序把交换次数降低到最低,但是比较次数还是挺大的。当数据量小,并且交换数据相对于比较数据更加耗时的情况下,可以应用选择排序。
在大多数情况下,假设数据量比较小或基本有序时,插入排序是三种算法中最好的选择。
归并算法的中心是归并两个已经有序的数组。归并两个有序数组A和B,就生成了第三个有序数组C。数组C包含数组A和B的所有数据项。
非递归算法为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
|
该方法有三个while循环,第一个while比较数组a和数组b的元素,并将较小的赋值到数组c;第二个while循环当a数组所有元素都已经赋值到c数组之后,而b数组还有元素,那么直接把b数组剩余的元素赋值到c数组;第三个while循环则是b数组所有元素都已经赋值到c数组了,而a数组还有剩余元素,那么直接把a数组剩余的元素全部赋值到c数组。
归并排序的思想是把一个数组分成两半,排序每一半,然后用上面的sort()方法将数组的两半归并成为一个有序的数组。如何来为每一部分排序呢?这里我们利用递归的思想:
把每一半都分为四分之一,对每个四分之一进行排序,然后把它们归并成一个有序的一半。类似的,如何给每个四分之一数组排序呢?把每个四分之一分成八分之一,对每个八分之一进行排序,以此类推,反复的分割数组,直到得到的子数组是一个数据项,那这就是这个递归算法的边界值,也就是假定一个数据项的元素是有序的。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 |
|
测试:
1 2 3 |
|
结果为:
消除递归
一个算法作为一个递归的方法通常通概念上很容易理解,但是递归的使用在方法的调用和返回都会有额外的开销,通常情况下,用递归能实现的,用循环都可以实现,而且循环的效率会更高,所以在实际应用中,把递归的算法转换为非递归的算法是非常有用的。这种转换通常会使用到栈。
递归和栈
递归和栈有这紧密的联系,而且大多数编译器都是用栈来实现递归的,当调用一个方法时,编译器会把这个方法的所有参数和返回地址都压入栈中,然后把控制转移给这个方法。当这个方法返回时,这些值退栈。参数消失了,并且控制权重新回到返回地址处。
调用一个方法时所发生的事:
一、当一个方法被调用时,它的参数和返回地址被压入一个栈中;
二、这个方法可以通过获取栈顶元素的值来访问它的参数;
三、当这个方法要返回时,它查看栈以获得返回地址,然后这个地址以及方法的所有参数退栈,并且销毁。
希尔排序是基于直接插入排序的,它在直接插入排序中增加了一个新特性,大大的提高了插入排序的执行效率。所以在讲解希尔排序之前,我们先回顾一下直接插入排序。
①、直接插入排序
直接插入排序基本思想是每一步将一个待排序的记录,插入到前面已经排好序的有序序列中去,直到插完所有元素为止。
我们可以分析一下这个直接插入排序,首先我们将需要插入的数放在一个临时变量中,这也是一个标记符,标记符左边的数是已经排好序的,标记符右边的数是需要排序的。接着将标记的数和左边排好序的数进行比较,假如比目标数大则将左边排好序的数向右边移动一位,直到找到比其小的位置进行插入。
这里就存在一个效率问题了,如果一个很小的数在很靠近右边的位置,比如上图右边待排序的数据 1 ,那么想让这个很小的数 1 插入到左边排好序的位置,那么左边排好序的数据项都必须向右移动一位,这个步骤就是将近执行了N次复制,虽然不是每个数据项都必须移动N个位置,但是每个数据项平均移动了N/2次,总共就是N2/2,因此插入排序的效率是O(N2)。
那么如果以某种方式不必一个一个移动中间所有的数据项,就能把较小的数据项移动到左边,那么这个算法的执行效率会有很大的改进。
②、希尔排序图解
希尔排序应运而生了,希尔排序通过加大插入排序中元素的间隔,并在这些有间隔的元素中进行插入排序,从而使数据项能够大跨度的移动。当这些数据项排过一趟序后,希尔排序算法减小数据项的间隔再进行排序,依次进行下去,最后间隔为1时,就是我们上面说的简单的直接插入排序。
下图显示了增量为4时对包含10个数组元素进行排序的第一个步骤,首先对下标为 0,4,8 的元素进行排序,完成排序之后,算法右移一步,对 1,5,9 号元素进行排序,依次类推,直到所有的元素完成一趟排序,也就是说间隔为4的元素都已经排列有序。
当我们完成4-增量排序之后,在进行普通的插入排序,即1-增量排序,会比前面直接执行简单插入排序要快很多。
③、排序间隔选取
对于10个元素,我们选取4的间隔,那么100个数据,1000个数据,甚至更多的数据,我们应该怎么选取间隔呢?
希尔的原稿中,他建议间隔选为N/2,也就是每一趟都将排序分为两半,因此对于N=100的数组,逐渐减小的间隔序列为:50,25,12,6,3,1。这个方法的好处是不需要在开始排序前为找到初始序列的间隔而计算序列,只需要用2整除N。但是这已经被证明并不是最好的序列。
间隔序列中的数字互质是很重要的指标,也就是说,除了1,他们没有公约数。这个约束条件使得每一趟排序更有可能保持前一趟排序已经排好的结果,而希尔最初以N/2的间隔的低效性就是没有遵守这个准则。
所以一种希尔的变形方法是用2.2来整除每一个间隔,对于n=100的数组,会产生序列45,20,9,4,1。这比用2会整除会显著的改善排序效果。
还有一种很常用的间隔序列:knuth 间隔序列 3h+1
但是无论是什么间隔序列,最后必须满足一个条件,就是逐渐减小的间隔最后一定要等于1,因此最后一趟排序一定是简单的插入排序。
下面我们通过knuth间隔序列来实现希尔排序:
④、knuth间隔序列的希尔排序算法实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
测试结果:
1 2 3 4 |
|
快速排序是对冒泡排序的一种改进,由C. A. R. Hoare在1962年提出的一种划分交换排序,采用的是分治策略(一般与递归结合使用),以减少排序过程中的比较次数。
①、快速排序的基本思路
一、先通过第一趟排序,将数组原地划分为两部分,其中一部分的所有数据都小于另一部分的所有数据。原数组被划分为2份
二、通过递归的处理, 再对原数组分割的两部分分别划分为两部分,同样是使得其中一部分的所有数据都小于另一部分的所有数据。 这个时候原数组被划分为了4份
三、就1,2被划分后的最小单元子数组来看,它们仍然是无序的,但是! 它们所组成的原数组却逐渐向有序的方向前进。
四、这样不断划分到最后,数组就被划分为多个由一个元素或多个相同元素组成的单元,这样数组就有序了。
具体实例:
对于上图的数组[3,1,4,1,5,9,2,6,5,3],通过第一趟排序将数组分成了[2,1,1]或[4,5,9,3,6,5,3]两个子数组,且对于任意元素,左边子数组总是小于右边子数组。通过不断的递归处理,最终得到有序数组[1 1 2 3 3 4 5 5 6]
②、快速排序的算法实现
假设被排序的无序区间为[A[i],......,A[j]]
一、基准元素选取:选择其中的一个记录的关键字 v 作为基准元素(控制关键字);怎么选取关键字?
二、划分:通过基准元素 v 把无序区间 A[I]......A[j] 划分为左右两部分,使得左边的各记录的关键字都小于 v;右边的各记录的关键字都大于等于 v;(如何划分?)
三、递归求解:重复上面的一、二步骤,分别对左边和右边两部分递归进行快速排序。
四、组合:左、右两部分均有序,那么整个序列都有序。
上面的第 三、四步不用多说,主要是第一步怎么选取关键字,从而实现第二步的划分?
划分的过程涉及到三个关键字:“基准元素”、“左游标”、“右游标”
基准元素:它是将数组划分为两个子数组的过程中,用于界定大小的值,以它为判断标准,将小于它的数组元素“划分”到一个“小数值的数组”中,而将大于它的数组元素“划分”到一个“大数值的数组”中,这样,我们就将数组分割为两个子数组,而其中一个子数组的元素恒小于另一个子数组里的元素。
左游标:它一开始指向待分割数组最左侧的数组元素,在排序的过程中,它将向右移动。
右游标:它一开始指向待分割数组最右侧的数组元素,在排序的过程中,它将向左移动。
注意:上面描述的基准元素/右游标/左游标都是针对单趟排序过程的, 也就是说,在整体排序过程的多趟排序中,各趟排序取得的基准元素/右游标/左游标一般都是不同的。
对于基准元素的选取,原则上是任意的。但是一般我们选取数组中第一个元素为基准元素(假设数组是随机分布的)
③、快速排序图示
上面表示的是一个无序数组,选取第一个元素 6 作为基准元素。左游标是 i 哨兵,右游标是 j 哨兵。然后左游标向左移动,右游标向右移动,它们遵循的规则如下:
一、左游标向右扫描, 跨过所有小于基准元素的数组元素, 直到遇到一个大于或等于基准元素的数组元素, 在那个位置停下。
二、右游标向左扫描, 跨过所有大于基准元素的数组元素, 直到遇到一个小于或等于基准元素的数组元素,在那个位置停下。
第一步:哨兵 j 先开始出动。因为此处设置的基准数是最左边的数,所以需要让哨兵 j 先开始出动,哨兵 j 一步一步的向左挪动,直到找到一个小于 6 的元素停下来。接下来,哨兵 i 再一步一步的向右挪动,直到找到一个大于 6 的元素停下来。最后哨兵 i 停在了数字 7 面前,哨兵 j 停在了数字 5 面前。
到此,第一次交换结束,接着哨兵 j 继续向左移动,它发现 4 比基准数 6 要小,那么在数字4面前停下来。哨兵 i 也接着向右移动,然后在数字 9 面前停下来,然后哨兵 i 和 哨兵 j 再次进行交换。
第二次交换结束,哨兵 j 继续向左移动,然后在数字 3 面前停下来;哨兵 i 继续向右移动,但是它发现和哨兵 j 相遇了。那么此时说明探测结束,将数字 3 和基准数字 6 进行交换,如下:
到此,第一次探测真正结束,此时已基准点 6 为分界线,6 左边的数组元素都小于等于6,6右边的数组元素都大于等于6。
左边序列为【3,1,2,5,4】,右边序列为【9,7,10,8】。接着对于左边序列而言,以数字 3 为基准元素,重复上面的探测操作,探测完毕之后的序列为【2,1,3,5,4】;对于右边序列而言,以数字 9 位基准元素,也重复上面的探测操作。然后一步一步的划分,最后排序完全结束。
通过这一步一步的分解,我们发现快速排序的每一轮操作就是将基准数字归位,知道所有的数都归位完成,排序就结束了。
④、快速排序完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 |
|
⑤、优化分析
假设我们是对一个逆序数组进行排序,选取第一个元素作为基准点,即最大的元素是基准点,那么第一次循环,左游标要执行到最右边,而右游标执行一次,然后两者进行交换。这也会划分成很多的子数组。
那么怎么解决呢?理想状态下,应该选择被排序数组的中值数据作为基准,也就是说一半的数大于基准数,一般的数小于基准数,这样会使得数组被划分为两个大小相等的子数组,对快速排序来说,拥有两个大小相等的子数组是最优的情况。
三项取中划分
为了找到一个数组中的中值数据,一般是取数组中第一个、中间的、最后一个,选择这三个数中位于中间的数。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
|
处理小划分
如果使用三数据取中划分方法,则必须遵循快速排序算法不能执行三个或者少于三个的数据,如果大量的子数组都小于3个,那么使用快速排序是比较耗时的。联想到前面我们讲过简单的排序(冒泡、选择、插入)。
当数组长度小于M的时候(high-low <= M), 不进行快排,而进行插入排序。转换参数M的最佳值和系统是相关的,一般来说, 5到15间的任意值在多数情况下都能令人满意。
1 2 3 4 5 6 7 8 9 10 11 12 |
|
在最优的情况下,数组元素大小分配均匀,遍历过程像一颗平衡二叉树,故快速排序算法的时间复杂度为O(nlogn)。
在最坏的情况下,待排序的序列为正序或者逆序,每次划分只得到一个比上一次划分少一个记录的子序列,注意另一个为空。如果递归树画出来,它就是一棵斜树。此时需要执行n‐1次递归调用,且第i次划分需要经过n‐i次关键字的比较才能找到第i个记录,也就是枢轴的位置,因此比较次数为
,最终其时间复杂度为O(n的平方)。
堆排序是利用堆这种数据结构而设计的一种排序算法,堆排序是一种选择排序,它的最坏,最好,平均时间复杂度均为O(nlogn),它也是不稳定排序。首先简单了解下堆结构。
堆
堆是具有以下性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。如下图:
同时,我们对堆中的结点按层进行编号,将这种逻辑结构映射到数组中就是下面这个样子
该数组从逻辑上讲就是一个堆结构,我们用简单的公式来描述一下堆的定义就是:
大顶堆:arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
小顶堆:arr[i] <= arr[2i+1] && arr[i] <= arr[2i+2]
ok,了解了这些定义。接下来,我们来看看堆排序的基本思想及基本步骤:
堆排序的基本思想是:将待排序序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点。将其与末尾元素进行交换,此时末尾就为最大值。然后将剩余n-1个元素重新构造成一个堆,这样会得到n个元素的次小值。如此反复执行,便能得到一个有序序列了
步骤一 构造初始堆。将给定无序序列构造成一个大顶堆(一般升序采用大顶堆,降序采用小顶堆)。
a.假设给定无序序列结构如下
2.此时我们从最后一个非叶子结点开始(叶结点自然不用调整,第一个非叶子结点 arr.length/2-1=5/2-1=1,也就是下面的6结点),从左至右,从下至上进行调整。
4.找到第二个非叶节点4,由于[4,9,8]中9元素最大,4和9交换。
这时,交换导致了子根[4,5,6]结构混乱,继续调整,[4,5,6]中6最大,交换4和6。
此时,我们就将一个无需序列构造成了一个大顶堆。
步骤二 将堆顶元素与末尾元素进行交换,使末尾元素最大。然后继续调整堆,再将堆顶元素与末尾元素交换,得到第二大元素。如此反复进行交换、重建、交换。
a.将堆顶元素9和末尾元素4进行交换
b.重新调整结构,使其继续满足堆定义
c.再将堆顶元素8与末尾元素5进行交换,得到第二大元素8.
后续过程,继续进行调整,交换,如此反复进行,最终使得整个序列有序
再简单总结下堆排序的基本思路:
a.将无需序列构建成一个堆,根据升序降序需求选择大顶堆或小顶堆;
b.将堆顶元素与末尾元素交换,将最大元素"沉"到数组末端;
c.重新调整结构,使其满足堆定义,然后继续交换堆顶元素与当前末尾元素,反复执行调整+交换步骤,直到整个序列有序。
package sortdemo;
import java.util.Arrays;
/**
* Created by chengxiao on 2016/12/17.
* 堆排序demo
*/
public class HeapSort {
public static void main(String []args){
int []arr = {9,8,7,6,5,4,3,2,1};
sort(arr);
System.out.println(Arrays.toString(arr));
}
public static void sort(int []arr){
//1.构建大顶堆
for(int i=arr.length/2-1;i>=0;i--){
//从第一个非叶子结点从下至上,从右至左调整结构
adjustHeap(arr,i,arr.length);
}
//2.调整堆结构+交换堆顶元素与末尾元素
for(int j=arr.length-1;j>0;j--){
swap(arr,0,j);//将堆顶元素与末尾元素进行交换
adjustHeap(arr,0,j);//重新对堆进行调整
}
}
/**
* 调整大顶堆(仅是调整过程,建立在大顶堆已构建的基础上)
* @param arr
* @param i
* @param length
*/
public static void adjustHeap(int []arr,int i,int length){
int temp = arr[i];//先取出当前元素i
for(int k=i*2+1;k
}
if(arr[k] >temp){//如果子节点大于父节点,将子节点值赋给父节点(不用进行交换)
arr[i] = arr[k];
i = k;
}else{
break;
}
}
arr[i] = temp;//将temp值放到最终的位置
}
/**
* 交换元素
* @param arr
* @param a
* @param b
*/
public static void swap(int []arr,int a ,int b){
int temp=arr[a];
arr[a] = arr[b];
arr[b] = temp;
}
}
堆排序是一种选择排序,整体主要由构建初始堆+交换堆顶元素和末尾元素并重建堆两部分组成。其中构建初始堆经推导复杂度为O(n),在交换并重建堆的过程中,需交换n-1次,而重建堆的过程中,根据完全二叉树的性质,[log2(n-1),log2(n-2)...1]逐步递减,近似为nlogn。所以堆排序时间复杂度一般认为就是O(nlogn)级。
将数组中的所有数按位进行分类,由于每一位数的大小都在0~9之间,因此创建下标为0~9的十个数组,根据需要对数进行存储
图文过程:
对于一组数字:8 9 6 11 23 1 9 18 10 213 33 7 87 91 180 35 52 716 106
选择个位数相同的元素,并成为一个数组
此时个位为i的数,分别别存储在下标为i的数组中
对数据开始进行收集,将数组重新整理,数组变为:
10 180 11 1 91 52 23 213 33 35 6 716 106 7 87 8 18 9 9
选择十位数为i的数的元素,并成为一个数组
此时十位为i的数,分别别存储在下标为i的数组中
对数据开始收集,将数组重新整理,数组变为:
1 6 106 7 8 9 9 10 11 213 716 18 23 33 35 52 180 87 91
选择百位数为i的数的元素,并成为一个数组
此时百位为i的数,分别别存储在下标为i的数组中
对数据开始收集,将数组重新整理,数组变为:
1 6 7 8 9 9 10 11 18 23 33 35 52 87 91 106 180 213 716
核心代码实现:
1.上面将数字填表的过程,实际上就是两个数组的叠加dyadic
List
该数组的横坐标,即为按照位数分配的下标,每个横坐标(下标)包含了一个数组,这个数组就是按照下标与自己的关系组成的
一共有0~9的取值,所以dyadic.add(new ArrayList())一共实现10次,即由这10个数组组成大数组dyadic
2.接下来是将原数组Array的元素分配给这是个小数组中,由于我们是按照最大数的位数决定循环次数的,因为需要找到整个数组Array的最大元素值,并决定循环次数,以上述数组为例,最大数是716,最高位为3,则循环三次
第一次寻找个位,第二次寻找十位,第三次寻找百位(其他更大数据以此类推)
寻找方法:以数字5432为例
通过上述图解,我们可以推出:位数值 x = num % (int)Math.pow(10, index + 1) / (int)Math.pow(10, index); {注:pow方法是Math类中的,所以需要类名,且该方法是double类型的,所以需要强转成int类型的}
3.找到位数之后,就要将数组array中的元素按照位数和要求进行分类,原理即为图文原理中的图解,共进行times次
for(int j = 0; j < array.length; j++) {
//找出每个数对应的位的数值
int x = array[j] % (int)Math.pow(10, i + 1) / (int)Math.pow(10, i);
//将该数组作为下标,找到对应的子数组
ArrayList arr = dyadic.get(x);
//将该元素添加到子数组中
arr.add(array[j]);
//因为子数组改变,因此更新大数组
dyadic.set(x, arr);
}
4.将分配好的子数组中的值依次赋值给原数组array
//将重新排好的子数组的值依次将需要被排序的数组的值覆盖
int index = 0; //用index作为数组array的下标
//将子数组依次遍历,将每个子数组中的元素添加到array中
for(int k = 0; k < 10; k++) {
//当下标为k的子数组中有元素时
while(dyadic.get(k).size() > 0) {
//得到该数组
ArrayList arr = dyadic.get(k);
///将该数组的第一个元素添加到array中
array[index] = (int)arr.get(0);
//移除子数组中的第一个元素,这样就能在第一个元素被使用之后,后面元素替换
arr.remove(0);
//将array数组中下标也后移一位
index++;
}
}
这样,整个原理过程就完成啦
不过要注意,上述过程只适合正整数的排序,如果是负数的话,就会出现下标越界的异常,但不用担心,因为这种方法同样适用于负整数,只不过需要一些小改动,即要对元素的正负进行判断,如果是负数,则需要绝对值化,并将结果反转即可
完整代码(不含负数)
package sort;
import java.util.ArrayList;
import java.util.List;
public class BasicSort {
public static void basicSort(int[] array) {
//创建叠加数组
List
//给大数组dyadic添加子数组
for(int i = 0; i < 10; i++) {
ArrayList
dyadic.add(arr);
}
//找出数组中的最大值
int max = 0;
for(int i = 0; i
max = array[i];
}
}
//判断最大值为几位数,其位数就是应该循环的次数
int times = 0;
while(max > 0) {
max /= 10;
times++;
}
//循环times次,每次将对应位的数分配到相应的自数组中
for(int i = 0; i < times; i++) {
for(int j = 0; j < array.length; j++) {
//找出每个数对应的位的数值
int x = array[j] % (int)Math.pow(10, i + 1) / (int)Math.pow(10, i);
//将该数组作为下标,找到对应的子数组
ArrayList arr = dyadic.get(x);
//将该元素添加到子数组中
arr.add(array[j]);
//因为子数组改变,因此更新大数组
dyadic.set(x, arr);
}
//将重新排好的子数组的值依次将需要被排序的数组的值覆盖
int index = 0; //用index作为数组array的下标
//将子数组依次遍历,将每个子数组中的元素添加到array中
for(int k = 0; k < 10; k++) {
//当下标为k的子数组中有元素时
while(dyadic.get(k).size() > 0) {
//得到该数组
ArrayList arr = dyadic.get(k);
///将该数组的第一个元素添加到array中
array[index] = (int)arr.get(0);
//移除子数组中的第一个元素,这样就能在第一个元素被使用之后,后面元素替换
arr.remove(0);
//将array数组中下标也后移一位
index++;
}
}
}
}
}
测试代码
package sort;
import static sort.BasicSort.basicSort;
public class Test {
public static void main(String[] args) {
int[] array = new int[]{2, 9, 1, 32, 31, 6, 22, 0};
basicSort(array);
for(int i : array) {
System.out.println(i);
}
}
}
其中:希尔排序的平均时间复杂度为O(NlogN)~O(n的平方)的平均值
【1】选择排序、快速排序、希尔排序、堆排序不是稳定的排序算法
冒泡排序、插入排序、归并排序和基数排序都是稳定的排序算法。
【2】研究排序算法的稳定性有何意义?
首先,排序算法的稳定性大家应该都知道,通俗地讲就是能保证排序前两个相等的数据其在序列中的先后位置顺序与排序后它们两个先后位置顺序相同。
再简单具体一点,如果A i == A j,Ai 原来在 Aj 位置前,排序后 Ai 仍然是在 Aj 位置前。
下面我们分析一下稳定性的好处:
(1)如果排序算法是稳定的,那么从一个键上排序,然后再从另一个键上排序,第一个键排序的结果可以为第二个键排序所利用。
基数排序就是这样,先按低位排序,逐次按高位排序,那么,低位相同的数据元素其先后位置顺序即使在高位也相同时是不会改变的。详细请参见随笔《基数排序》。
(2)学习排序原理时,可能编的程序里面要排序的元素都是简单类型,实际上真正应用时,可能是对一个复杂类型(自定义类型)的数组排序,
而排序的键值仅仅只是这个元素中的一个属性,对于一个简单类型,数字值就是其全部意义,即使交换了也看不出什么不同。
但是,对于复杂类型,交换的话可能就会使原本不应该交换的元素交换了。比如:一个“学生”数组,欲按照年龄排序,“学生”这个对象不仅含有“年龄”,还有其它很多属性。
假使原数组是把学号作为主键由小到大进行的数据整理。而稳定的排序会保证比较时,如果两个学生年龄相同,一定不会交换。
那也就意味着尽管是对“年龄”进行了排序,但是学号顺序仍然是由小到大的要求。
(3)如果排序算法稳定,对基于比较的排序算法而言,元素交换的次数可能相对会少一些(个人感觉,没有证实)。
【3】各种排序算法稳定性分析
现在分析一下常见的排序算法的稳定性,每个都给出简单的理由。
(1)冒泡排序
冒泡排序就是把小的元素往前调(或者把大的元素往后调)。注意是相邻的两个元素进行比较,而且是否需要交换也发生在这两个元素之间。
所以,如果两个元素相等,我想你是不会再无聊地把它们俩再交换一下。
如果两个相等的元素没有相邻,那么即使通过前面的两两交换把两个元素相邻起来,最终也不会交换它俩的位置,所以相同元素经过排序后顺序并没有改变。
所以冒泡排序是一种稳定排序算法。
(2)选择排序
选择排序即是给每个位置选择待排序元素中当前最小的元素。比如给第一个位置选择最小的,在剩余元素里面给第二个位置选择次小的,
依次类推,直到第n-1个元素,第n个元素不用选择了,因为只剩下它一个最大的元素了。
那么,在一趟选择时,如果当前锁定元素比后面一个元素大,而后面较小的那个元素又出现在一个与当前锁定元素相等的元素后面,那么交换后位置顺序显然改变了。
呵呵!比较拗口,举个例子:序列5 8 5 2 9, 我们知道第一趟选择第1个元素5会与2进行交换,那么原序列中两个5的相对先后顺序也就被破坏了。
所以选择排序不是一个稳定的排序算法。
(3)插入排序
插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。当然,刚开始这个有序的小序列只有1个元素,也就是第一个元素(默认它有序)。
比较是从有序序列的末尾开始,也就是把待插入的元素和已经有序的最大者开始比起,如果比它大则直接插入在其后面。
否则一直往前找直到找到它该插入的位置。如果遇见一个与插入元素相等的,那么把待插入的元素放在相等元素的后面。
所以,相等元素的前后顺序没有改变,从原无序序列出去的顺序仍是排好序后的顺序,所以插入排序是稳定的。
(4)快速排序
快速排序有两个方向,左边的i下标一直往右走(当条件a[i] <= a[center_index]时),其中center_index是中枢元素的数组下标,一般取为数组第0个元素。
而右边的j下标一直往左走(当a[j] > a[center_index]时)。
如果i和j都走不动了,i <= j, 交换a[i]和a[j],重复上面的过程,直到i>j。交换a[j]和a[center_index],完成一趟快速排序。
在中枢元素和a[j]交换的时候,很有可能把前面的元素的稳定性打乱,比如序列为 5 3 3 4 3 8 9 10 11
现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱。
所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j]交换的时刻。
(5)归并排序
归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),
然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。
可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。
那么,在短的有序序列合并的过程中,稳定是是否受到破坏?
没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面,这样就保证了稳定性。
所以,归并排序也是稳定的排序算法。
(6)基数排序
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。
有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序,最后的次序结果就是高优先级高的在前,高优先级相同的情况下低优先级高的在前。
基数排序基于分别排序,分别收集,所以其是稳定的排序算法。
(7)希尔排序
希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;
当元素基本有序时,步长很小,插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比O(N^2)好一些。
由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,
但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱。
所以shell排序是不稳定的排序算法。
(8)堆排序
我们知道堆的结构是节点i的孩子为2*i和2*i+1节点,大顶堆要求父节点大于等于其2个子节点,小顶堆要求父节点小于等于其2个子节点。
在一个长为n的序列,堆排序的过程是从第n/2开始和其子节点共3个值选择最大(大顶堆)或者最小(小顶堆),这3个元素之间的选择当然不会破坏稳定性。
但当为n/2-1, n/2-2, ...1这些个父节点选择元素时,就会破坏稳定性。
有可能第n/2个父节点交换把后面一个元素交换过去了,而第n/2-1个父节点把后面一个相同的元素没有交换,那么这2个相同的元素之间的稳定性就被破坏了。
所以,堆排序不是稳定的排序算法。
先拿10000个数建堆,然后一次添加剩余元素,如果大于堆顶的数(10000中最小的),将这个数替换堆顶,并调整结构使之仍然是一个最小堆,这样,遍历完后,堆中的10000个数就是所需的最大的10000个。建堆时间复杂度是O(mlogm),算法的时间复杂度为O(nmlogm)(n为10亿,m为10000)。
优化的方法:可以把所有10亿个数据分组存放,比如分别放在1000个文件中。这样处理就可以分别在每个文件的10^6个数据中找出最大的10000个数,合并到一起在再找出最终的结果
Hash+堆排序
采用Hash+小顶堆
Hash就是为了统计每个数出现的次数,然后发生冲突的地方用个链表把它链接起来,在每个节点中存储一个含有data和count成员的结构体,data记录相应的数字,而count记录对应的数字出现的次数,这一步的时间复杂度是o(n).(注意这里虽然数字很多,但是因为会存在大量的重复数据,不用担心最后的空间会有10亿),然后创建一个大小为100的小顶堆,然后将Hash表中前面100个非空的成员放入小顶堆中,然后将hash表中的其他数据和堆顶出现的次数比较,如果比堆顶出现的次数少,则丢弃当前数,如果大于堆顶元素的出现次数,则替换堆顶,然后进行堆调整,这一步时间复杂度是o(nlog100).
总的时间复杂度是o(n)+o(nlog100)
Hash+桶排序
1、首先预设1024个文件作为“桶”,依次读取原始数据的记录,每读到一条记录就进行哈希计算
2、由于相同的记录哈希值一定相同,所以重复数据一定落入同一个桶内,对于落入同一个桶内的数据,直接为该数据的数量加一,即桶内的条目都是唯一的,各自记录自己的总重复数量。
3、当一个桶的体积达到64M的时候(应该非常罕见),为该桶增加一个子桶,新的数据进来的时候先在父桶内找相同记录,没有的话在放入子桶,重复数设置为1。
4、当全部数据读取完之后,依次对1024个桶(及其子桶)进行内部排序,可以一次性把64M的数据读入内存快速排序即可,然后再归并父桶及其子桶,最终得到1024个已经内部排序的桶。
5、最后,构造一个容量为100的大堆,遍历1024个桶,每次从桶内取出一个数放进堆中,如果堆中没有数字被替换出来,则换到下一个桶继续取数字放进堆中,如果堆中的数字被换出来一个,则继续从该桶取数据。直到连续1024次替换没有新的数子桶堆中被换出来位置。
6、最后得到的100容量的大堆即为所求。
给你一个包含20亿个int类型整数的文件,计算机的内存只有2GB,怎么给它们排序?一个int数占4个字节,20个亿需要80亿字节,大概占用8GB的内存,而计算机只有2GB的内存,数据都装不下!可以把8GB分割成4个2GB的数据来排,然后在把他们拼凑回去。如下图:
排序的时候可以选择快速排序或归并排序等算法。为了方便,我们把排序好的2G有序数据称为有序子串。接着把两个小的有序子串合并成一个大的有序子串。
注意:读取的时候是每次读取一个int数,通过比较之后再输出。
按照这个方法来回合并,总共经过三次合并之后就可以得到8G的有序子串。
我们假设需要排序的int数有12个,内存一次只能装下3个int数。
接下来把12个数据分成4份,然后排序成有序子串:
然后把子串进行两两合并:
输出哪个元素就在那个元素所在的有序子串再次读入一个元素:
继续
重复直到合并成一个包含6个int有序子串:
再把两个包含6个int的有序子串合并成一个包含12个int数据的最终有序子串:
优化策略
因为硬盘的读写速度比内存要慢的多,按照以上这种方法,每个数据都从硬盘读了三次,写了三次,要花很多时间。
解释下:例如对于数据2,我们把无序的12个数据分成有序的4个子串需要读写各一次,把2份3个有序子串合并成6个有序子串读写各一次;把2份6个有序子串合并从12个有序子串读写各一次,一共需要读写各3次。
在进行有序子串合并的时候,不采取两两合并的方法,而是可以3个子串,或4个子串一起来合并。