今天开始看排序算法,渐渐 发现一个事实,脑子有点不够用……
排序算法主要的性能指标
有三个:
1.时间性能;
2.辅助空间;
3.算法复杂度
(不是时间空间复杂度,就是纯粹的代码复杂程度)。
详细概念就不抄了……反正总的来说,时间性能一般是大家最看重的,这篇文章里一共会有7种排序算法。按算法复杂度分为两类:
简单算法:冒泡排序、简单选择排序、直接插入排序;
改进算法:希尔算法、堆排序、归并排序和快速排序。
一个一个来吧~
具体排序算法
1.冒泡排序(Bubble Sort)
冒泡排序,一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
直接上代码:
public class BubbleSort {
public static void main(String[] args){
int[] a = {11,25,32,1,3,4,37,12,33,13,32,10,38,58,7,4,63,33,6,43,4,21,14,24,62,4,42,1};
Tool.print(a);
int[] b = bubbleSort(a);
Tool.print(b);
}
public static int[] bubbleSort(int[] a){
int length = a.length;
for(int i=0; i<length; i++){
for(int j=0;j<length-1-i;j++){
if(a[j] > a[j+1])
Tool.swap(a, j, j+1);
}
}
return a;
}
}
其中
Tool.print(a);
…
Tool.swap(a, j, j+1);
是我自己在同一个包下编写的一个小工具类,这是
链接。
代码非常简单,for双循环,内循环中每两个相邻的数都进行比较,如果前边的数比后边的数大,两个数就进行交换。外循环是控制保证每次数组中最大的数都能放到此次循环的最后面。
运行结果:
11 25 32 1 3 4 37 12 33 13 32 10 38 58 7 4 63 33 6 43 4 21 14 24 62 4 42 1
1 1 3 4 4 4 4 6 7 10 11 12 13 14 21 24 25 32 32 33 33 37 38 42 43 58 62 63
成功对数组进行了排序,其时间复杂度为O(n^2)。
2.简单选择排序(Simple Selection Sort)
简单选择排序就是通过n-i次数组值的比较,从n-i+1个记录中选出数组值的最小值,并和第i(1<=i<=n)个值交换值。直接上代码:
public class SimpleSelSort {
public static void main(String[] args){
int[] a = {11,25,32,1,3,4,37,12,33,13,32,10,38,58,7,4,63,33,6,43,4,21,14,24,62,4,42,1};
Tool.print(a);
int[] b = simpleSelSort(a);
Tool.print(b);
}
public static int[] simpleSelSort(int[] a){
int length = a.length;
int min;
for(int i=0; i<length; i++){
min = i;
for(int j=i+1; j<length; j++){
if(a[min]>a[j]){
min = j;
}
}
if(min!=i)
Tool.swap(a, i, min);
}
return a;
}
}
代码跟冒泡排序类似,先拿出一个值a[i](一般都是数组第一个值a[0]),将它的数组下标保存到一个临时变量min,此时a[min]指向a[0]。之后开始遍历数组,如果有数比a[0]小,(例如说a[3])那就把找到的这个数a[3]的下标3赋给临时变量min,此时a[min]指向a[3],每次遇到比a[min]小的数,都将它的下标赋给min。一遍遍历结束后,我们得到了数组中最小值的下标(例如是10),之后我们将a[0]和a[10]交换,这时数组的最小值已经存到数组的第一位了。之后再拿出最小值的后面一位(a[1]),然后重复上述步骤,会得到数组中第二小的值并将其存入数组的第二位中。……最后我们会得到一个其中的数从小到大排列的数组。
运行结果:
11 25 32 1 3 4 37 12 33 13 32 10 38 58 7 4 63 33 6 43 4 21 14 24 62 4 42 1
1 1 3 4 4 4 4 6 7 10 11 12 13 14 21 24 25 32 32 33 33 37 38 42 43 58 62 63
排序完成,其时间复杂度和冒泡排序一样,也是O(n^2),但实际上,效率比冒泡算法稍高。
3.直接插入排序(Straight Insertion Sort)
直接插入排序的基本操作是将一个数字插入已经排好序的有序表中,从而得到一个新的、长度加1的有序表。
代码如下:
public class StraitInsertSort {
public static void main(String[] args){
int[] a = {11,25,32,1,3,4,37,12,33,13,32,10,38,58,7,4,63,33,6,43,4,21,14,24,62,4,42,1};
Tool.print(a);
int[] b = straitInsertSort(a);
Tool.print(b);
}
public static int[] straitInsertSort(int[] unSorted){
int length = unSorted.length;
for(int i=1; i<length; i++){
if(unSorted[i-1]>unSorted[i]){
int temp;
int j = i;
temp = unSorted[i];
for(; j > 0 && unSorted[j-1] > temp;j --){
unSorted[j] = unSorted[j-1]; //复制一份给后一位
}
unSorted[j] = temp; //覆盖后面数值相同两位中的第一位
}
}
return unSorted;
}
}
其代码逻辑跟上面两个排序算法稍有不同,但也差距不大。本质上都是双for循环,这也是它们时间复杂度都是O(n^2)的原因。直接插入算法是在执行内循环之前先进行一个大小判断,例如如果两个相邻的数中前一个数unSorted[3]比后一个数unSorted[4]大(我们想得到的是从小到大的排序)那就将后一个数unSorted[4]存入一个临时变量temp,之后开始进行循环判断,如果unSorted[4]前边的数有大于unSorted[4]的,那就把大的数往后移一位(此时由于unSorted[4]的值已经存入temp中,因为unSorted[3]比unSorted[4]大,那么将unSorted[3]的值直接赋给unSorted[4]的位置上,unSorted[4]的值我们仍然可以用。这样之后unSorted[3]与被覆盖后的unSorted[4](此时unSorted[4]的值是unSorted[3])都存放了unSorted[3]的值,那么对unSorted[3]又可以进行判断,修改的操作了)。直到遇见一个值,它比unSorted[4]小,那么将unSorted[4]的值存到这个数的后一位(后一位和后后一位存放的是相同的值,不怕被覆盖)。内循环完成,之后继续外循环,完成排序。
其结果跟前面一致:
11 25 32 1 3 4 37 12 33 13 32 10 38 58 7 4 63 33 6 43 4 21 14 24 62 4 42 1
1 1 3 4 4 4 4 6 7 10 11 12 13 14 21 24 25 32 32 33 33 37 38 42 43 58 62 63
其时间复杂度为O(n^2),实际中它的性能稍强于冒泡排序和简单选择排序。
----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------简单排序完成-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
4.希尔排序(Shell Sort)
希尔排序,就是将相聚某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。基本有序就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间。概念这种东西,能看懂就看懂了,看不懂也不要紧,重要的是掌握思想。
说白了,希尔算法是插入算法的一种优化,它不是整个数组都去比较,而是将数组先分成相同的好几块,每个块相处于同样位置的数进行插入排序。
举个例子,一个年级有300人,要将考试成绩进行排序,希尔排序的做法就是先将这300人分成A, B, C 3个班,每班100人,每个班每个人都有1个学号,分别是1~100。先对3个班的1号成绩进行比较:按照最高分进C班,其次的进B班,最差的进A班(C>B>A)的顺序将3个1号进行排序,之后3个2号,3个3号……直到3个100号进行排序。这样我们得到的A,B,C3个班,我们可以说分数最低的学生基本在A班(不能直接说都在A班,因为之前排序的时候可能存在3个人分数都很高,但是必须有人去A班的情况),中等的成绩基本在B班,成绩较好的基本在C班,这也是之前概念中的意思。
第一次的排序之后,我们将每个班分为两个班,A分为A1和A2,B分为B1和B2,C分为C1和C2。每个班都有50人,学号为1-50。对6个班中学号相同的人继续进行插入排序,规则也是成绩最高的去C2班,之后由高到低去C1,B2,B1,A2,A1。分完之后再将每个班分为两个班,然后重复排序动作……直到分的每个班都只有1个人则完成操作(如果是奇数也没事,就取一多一少即可,最后分到1个人,这些多出来的人还会被排序,并无影响)。好了这就是希尔排序的基本思想,可能会有点抽象。多来几遍就能理解了,直接上代码:
public class Test1 {
public static void main(String[] args){
int[] a = getRandomArray(9546400); //生成一个长度为9546400的数组
Tool.print("--------------------------------------");
int[] b = shellSort(a);
Tool.print(b);
}
public static int[] getRandomArray(int log){
int[] result = new int[log];
for (int i = 0; i < log; i++) {
result[i] = i;
}
for (int i = 0; i < log; i++) {
int random = (int) (log * Math.random());
int temp = result[i];
result[i] = result[random];
result[random] = temp;
}
return result;
}
public static int[] shellSort(int[] a) {
int d = a.length / 2;
while (true) {
// 把距离为d的元素编为一个组,扫描所有组
for (int i = d; i < a.length; i++) {
int j = 0;
int temp = a[i];
// 对距离为d的元素组进行排序
for (j = i - d; j >= 0 && temp < a[j]; j = j - d) {
a[j + d] = a[j];
}
a[j + d] = temp;
}
if(d==1) return a;
d = d / 2; // 减小增量
}
}
}
代码的逻辑是在一个while循环里套两个for循环,虽然看着套了2个循环,但是由于每次循环都会将工作完成一部分,所以代码到最后反而效率很高。当d没有到1的时候会一直执行for循环里面的for循环,for循环中每个被分好的小组的相同位置的数会进行比较,如果前面的数比后面的大,那么进行一次交换,之后反复,直到 i超出数组的范围,跳出循环,d变成自己之前的一半,继续进行循环。最后得出正确数组。
题外话:得到的教训就是要午睡,不然晚上根本没精神看书,本来准备一天学完另外4个改进排序的,可是只完成了1个,其余的只能放到第二天了。。
参考网址:
http://www.cnblogs.com/jingmoxukong/p/4303279.html(参考了代码和思路,这个讲的很清楚)
http://www.cnblogs.com/archimedes/p/shell-sort-algorithm.html(帮助理解不错)
http://www.cnblogs.com/maxinliang/archive/2012/09/11/2680553.html(随机数组生成)
http://baike.baidu.com/link?url=I_92ChybM61WzL_1DIYvhepbLInGUiErStKCFG3rEDqe4oDsmjNBsFWYXY1PzZJZCvTnEgiDDYCI2G3SFnmtVJZnTfPay7sAxXoHFCtfz36tjSg5OTfaQ-jfO5XvIRrX(百度百科其中Java版中的简易版是错的)
5.堆排序(Heap Sort)
首先我们得知道什么叫堆,对是具有下列性质的完全二叉树:每个节点的值都大于或等于其左右孩子节点的值,称为大顶堆;或者每个节点的值都小于或者等于其左右孩 子节点的值,称为小顶堆。我们排序中用到的是大顶堆,堆从根节点开始,根节点的编号是0,那么假设i为某个节点的值,其中i所在节点的孩子编号为2i+1和2i+2,其父节点编号为(i-2)/2。大顶堆的性质记为a[parent(i)] >= a[i]。
堆排序就是利用堆进行排序的方法。它的基本思想是:
(1)将带排序的序列构成一个大顶堆。此时,整个序列的最大值就是堆顶的根节点;
(2)将它移走(也就是将其与堆数组的末尾 元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次小值;
(3)如此反复执行,便能得到一个有序序列了(此处有些 难理解,建议参考一下之后列出来的参考网址,里面有图利于理解)。
上代码:
public class HeapSort {
public static void main(String[] args){
int[] a = Tool.getRandomArray(9546400); //生成一个长度为9546400的数组
Tool.print("--------------------------------------");
int[] b = heapSort(a);
Tool.printL(b);
}
public static void heapAdjust(int[] array, int parent, int length) {
int temp = array[parent]; // temp保存当前父节点
int child = 2 * parent + 1; // 先获得左孩子
while (child < length) {
if (child + 1 < length && array[child] < array[child + 1]) // 如果有右孩子结点,并且右孩子结点的值大于左孩子结点,则选取右孩子结点
child++;
if (temp >= array[child]) // 如果父结点的值已经大于孩子结点的值,则直接结束
break;
array[parent] = array[child]; // 把孩子结点的值赋给父结点
parent = child; // 选取孩子结点的左孩子结点,继续向下筛选
child = 2 * child + 1;
}
array[parent] = temp;
}
public static int[] heapSort(int[] list) {
// 循环建立初始堆
int length = list.length;
for (int i = length / 2; i >= 0; i--) {
heapAdjust(list, i, length - 1);
}
// 进行n-1次循环,完成排序
for (int i = length - 1; i > 0; i--) {
// 最后一个元素和第一元素进行交换
Tool.swap(list, 0, i);
// 筛选 R[0] 结点,得到i-1个结点的堆
heapAdjust(list, 0, i);
}
return list;
}
}
看代码可以看到,堆排序分为两个函数,其中heapSort()是最后的排序函数,而heapAjust()就是我们之前第一步里所说的先对堆进行排序,而仅靠它并不能完成使得堆中最大值处于根节点,所以我们可以再heapSort()方法中看到有一个for()循环,它从堆长度的一半开始,逐步往堆顶进行heapAjust()操作,自己稍微画草图验证一下就能知道,这样做能保证总能从堆的最底层最后一个最后一个节点所在的二叉树开始进行调整获取最大值(有点绕),然后逐步往上进行调整,这样能保证整个二叉树中的数都能被遍历,由下往上 而不会被遗漏。最终调整完后,整个数组的最大值就会在在二叉堆的根节点上(此时其它的节点都是无序而需要重新进行排序的,这点很重要)。在heapSort()的第二个for循环中交换根节点和最后一个节点(数组的最后一个值),然后继续进行调整,数组中的最大值已经被放到数组最后一位,不参加排序,其余继续上述过程,最终得到一个从小到大排好序的有序数组。
ps:另外的两个排序花了两天……还要想很久,重在理解吧。
参考网址:
http://www.cnblogs.com/jingmoxukong/p/4303826.html(参考了代码和思路)
http://www.cnblogs.com/zabery/archive/2011/07/26/2117103.html(帮助理解)
6.归并排序(Merging Sort)
“归并”一词的中文含义就是合并、并入的意思,而在数据结构中的定义就是将两个或者两个以上的有序表组合成一个新的有序表。
归并排序就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,泽勒已堪称是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2]+1(不小于n/2的最小整数)个长度为2或者1的有序子序列,再两两归并,……,如此重复,直到得到一个长度为n的有序序列为止,这种排序放大称为2路归并排序。
直接上代码:
public class MergeSort {
public static void main(String[] args){
// int[] a = Tool.getRandomArray(9546400); //生成一个长度为9546400的数组
int[] a = {11,25,32,1,3,4,37,12,33,13,32,10,38,58,7,4,63,33,6,43,4,21,14,24,62,4,42,1};
// Tool.print(a);
Tool.print("--------------------------------------");
// int[] b = bubbleSort(a);
int[] b = mergeSort(a);
Tool.printL(b);
}
public static void merge(int[] a, int low, int mid, int high) {
int i = low; // i是第一段序列的下标
int j = mid + 1; // j是第二段序列的下标
int k = 0; // k是临时存放合并序列的下标
int[] b = new int[high - low + 1]; // array2是临时合并序列
// 扫描第一段和第二段序列,直到有一个扫描结束
while (i <= mid && j <= high) {
// 判断第一段和第二段取出的数哪个更小,将其存入合并序列,并继续向下扫描
if (a[i] <= a[j]) {
b[k++] = a[i++];
} else {
b[k++] = a[j++];
}
}
// 若第一段序列还没扫描完,将其全部复制到合并序列
while (i <= mid) {
b[k++] = a[i++];
}
// 若第二段序列还没扫描完,将其全部复制到合并序列
while (j <= high) {
b[k++] = a[j++];
}
// 将合并序列复制到原始序列中
for (k = 0, i = low; i <= high; i++, k++) {
a[i] = b[k];
}
}
public static void mergePass(int[] a, int gap, int length) {
int i = 0;
// 归并gap长度的两个相邻子表.如果i之后还有两个gap的长度,就继续循环否则跳出循环。
// 每次进行两个gap之间的归并,刚开始gap==1, 每两个数进行归并,确定大小后排好序
for (i = 0; i + 2 *gap-1 < length; i = i + 2 * gap) {
merge(a, i, i + gap - 1, i + 2 * gap - 1);
}
if (i + gap - 1 < length) {// 余下两个子表,后者长度小于gap
merge(a, i, i + gap - 1, length - 1);
}
}
public static int[] mergeSort(int[] a) {
for (int gap = 1; gap < a.length; gap = 2 * gap) {
mergePass(a, gap, a.length);
}
return a;
}
}
观察代码,可以发现代码分为三个方法merge(),mergePass(),mergeSort(),这三个方法逐渐上升层次,分别对应了不同的三个功能:
merge():传入数组a,low,mid和high三个下标。它只对low 和high 之间的数进行基本排序处理(排完序并不能保证绝对有序),mid将这个区间分成两部分,其中左边和右边都有一个下标,分别为i和j。然后对a[i]和a[j]进行比较,大的一方会存储到一个新的数组b,并且相对应的下标+1。之后一直循环,直到某一段已经扫描完成,将另一段剩余的部分直接放入数组b的后面,扫描结束。
mergePass():merge()只是扫描了数组中分好的两小段,当数组分成多段时,每段长度为gap,就可以用mergePass()进行整个数组的遍历扫描,它将merge()运用到了整个数组。其中最后的if()语句是说如果扫描完成,最后剩下的不足两个小段,那么直接把最后一段进行当成一个整体进行扫描。最终得出一个有序数组。
mergeSort():让gap从1开始,逐渐上升到2,4,8,16…,所以数组的有序是从小段到大段的,这样就保证了数组的整体有序性,最终得到了一个完整的严格有序数组。
理解了很久,因为层层调用,代码并不是很好理解,但是最终还是理清逻辑了。多思考才能得出最终的结论。
参考网址:
http://www.cnblogs.com/kkun/archive/2011/11/23/merge_sort.html
7.快速排序(Quick Sort)
快速排序的基本思想是:通过一趟排序将待排序记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。直接上代码比较直观:
public class QuickSort {
public static void main(String[] args){
int[] a = Tool.getRandomArray(9546400); //生成一个长度为9546400的数组
// int[] a = {11,25,32,1,3,4,37,12,33,13,32,10,
// 38,58,7,4,63,33,6,43,4,21,14,24,62,4,42,1};
// Tool.printL(a);
Tool.print("-------------------------------------" +
"-------------------------------------");
int[] b = quickSort(a, 0, a.length-1);
Tool.printL(b);
}
public static int[] quickSort(int[] a, int left, int right){
if(left<right){
int pivot = quickAdjust(a, left, right);
quickSort(a, left, pivot);
quickSort(a, pivot+1, right);
}
return a;
}
public static int quickAdjust(int[] a, int left, int right){
int pivot = a[left];
while(left < right){
while(pivot<=a[right] && left < right)
right--;
a[left] = a[right];
while(a[left]<=pivot && left < right)
left++;
a[right] = a[left];
}
a[left] = pivot;
return left;
}
}
代码总体是比较明了的,简单说一下吧,这个快速排序中用到了递归。先找一个分界点pivot(一般直接选取数组第一个值),分界点的作用是:先将数组通过分界点分为两个较小的数组,分界点前所有的数比分界点的数都小,分界点后的数比分界点都大( 其中quickAdjust()方法实现了这一功能),再将每个较小的数组继续以相同的方法进行分离,排序……最终能得到一个排好序的数组。这样算初步完成快速排序了,但是有一点,分界点pivot选的太过随意,万一它离最值点比较近,那么快速排序的效率就会变小,所以有改进之处。一般做法是三数取中(median-of-three)法。即去三个挂念自先进行排序,再将中间数作为分界点,一般是去左端、右端和中间三个数。代码实现如下:
public static int quickAdjust(int[] a, int left, int right){
// int pivot = a[left];
// 对pivot的取法进行优化
int pivot;
int mid = (left + right) /2;
if(a[left]>a[right]) //保持右端比左端大
Tool.swap(a, left, right);
if(a[mid]>a[right]) //保持右端比中间大
Tool.swap(a, mid, right);
//这时右端最大。只需要比较左端和中间,取较大值即可
if(a[mid]>a[left])
//保持左端处于中间值
Tool.swap(a, left, right);
pivot = a[left];
while(left < right){
while(pivot<=a[right] && left < right)
right--;
a[left] = a[right];
while(a[left]<=pivot && left < right)
left++;
a[right] = a[left];
}
a[left] = pivot;
return left;
}
这个目前会报错……等待处理
参考网址:
http://www.cnblogs.com/jingmoxukong/p/4302891.html
总结
终于写完了……有点累,一口气学了这么多算法,短时间内肯定消化不了,但是随着时间的推移,会慢慢在学习工作中熟练掌握它们。很重要的一点是掌握排序的思想,改进算法都无一例外地应用了算法中很重要的一个思想——分治法(Divide and Conquer),简单的说就是将一个大问题划分成许多小问题,然后分别解决这些小问题,最终达到解决整个大问题。(图片来源:大话数据结构)
由图可以看出,其中:
插入排序分为直接插入排序和希尔排序;
选择排序分为简单选择排序和堆排序;
交换排序分为冒泡排序和快速排序;
归并排序是一个单独分出来的类。
没有完美的算法,每个算法都有自身的局限性,下图是各个算法的对比:
从算法的简单性上来看,我们将算法分为两类:
简单算法:冒泡、简单选择、直接插入;
改进算法:希尔、堆、归并、快速。
可以得出的结论是:
(1)平均情况下,改进算法时间性能上明显优于简单算法,而希尔算法不如其它三种改进算法;
(2)最坏情况下,堆排序和归并排序强过快速排序以及其余简单排序;
(3)最好情况下,冒泡和直接插入更胜一筹,所以如果带排序序列基本有序,那就反而应该考虑这两种算法,而不是四种改进算法;
(4)空间复杂度来说,归并排序所需要的空间最大,快速排序也有相应空间需求,而堆排序等等对空间要求很低;
(5)稳定性来说,归并算法一枝独秀,如果系统非常看重稳定性,那么就选择归并排序;
(6)待排序数n对于算法的选择也有较大影响,如果n不大,那么选择简单算法是要比改进算法明智的;
(7)综合各项指标,经过优化的快速排序算法是最好的排序算法,不过在不同的场合也应该考虑使用不同的算法。
参考资料:
大话数据结构
http://www.cnblogs.com/kkun/archive/2011/11/23/2260265.html
http://www.cnblogs.com/jingmoxukong/tag/%E6%8E%92%E5%BA%8F/
算法可视化网站:
这几个网站能较为直观地显示各个算法的排序过程,能分步观察,我觉得挺适合初学者的。
https://visualgo-translation.club/zh(可能得用梯子)
http://sorting.at/