常见排序算法如下:
(1)直接插入排序
(2)希尔排序
(3)简单选择排序
(4)堆排序
(5)冒泡排序
(6)快速排序
(7)归并排序
(8)基数排序
它们都属于内部排序
,也就是只考虑数据量较小仅需要使用内存的排序算法,他们之间关系如下:
一、直接插入排序
1.1 基本思想
通常人们整理桥牌的方法是一张一张的来,将每一张牌插入到其他已经有序的牌中的适当位置。在计算机的实现中,为了要给插入的元素腾出空间,我们需要将其余所有元素在插入之前都向右移动一位。
1.2 算法描述
一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:
(1)从第一个元素开始,该元素可以认为已经被排序
(2)取出下一个元素,在已经排序的元素序列中从后向前扫描
(3)如果该元素(已排序)大于新元素,将该元素移到下一位置
(4)重复步骤3,直到找到已排序的元素小于或者等于新元素的位置
(5)将新元素插入到该位置后
(6)重复步骤2~5
动态效果如下:
注意:
如果 比较操作 的代价比 交换操作 大的话,可以采用二分查找法来减少 比较操作 的数目。该算法可以认为是 插入排序 的一个变种,称为二分查找插入排序。
1.3 代码实现
/**
* 通过交换进行插入排序,借鉴冒泡排序
*
*/
public static void sort(int[] a) {
for (int i = 0; i < a.length - 1; i++) {
for (int j = i + 1; j > 0; j--) {
if (a[j] < a[j - 1]) {
int temp = a[j];
a[j] = a[j - 1];
a[j - 1] = temp;
}
}
}
}
/**
* 通过将较大的元素都向右移动而不总是交换两个元素
*
*/
public static void sort2(int[] a) {
for (int i = 1; i < a.length; i++) {
int num = a[i];
int j;
for (j = i; j > 0 && num < a[j - 1]; j--) {
a[j] = a[j - 1];
}
a[j] = num;
}
}
1.4 复杂度分析
1.5 比较与总结
插入排序所需的时间取决于输入元素的初始顺序
。例如,对一个很大且其中的元素已经有序(或接近有序)的数组进行排序将会比随机顺序的数组或是逆序数组进行排序要快得多。
二、希尔排序
希尔排序,也称 递减增量排序算法
,是插入排序的一种更高效的改进版本。希尔排序是 非稳定排序算法
。
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率
但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一次
希尔排序是先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序”时,再对全体记录进行依次直接插入排序。
2.1 基本思想
将待排序数组按照步长gap进行分组,然后将每组的元素利用直接插入排序的方法进行排序;每次再将gap折半减小,循环上述操作;当gap=1时,利用直接插入,完成排序。
可以看到步长的选择是希尔排序的重要部分。只要最终步长为1任何步长序列都可以工作。一般来说最简单的步长取值是初次取数组长度的一半
为增量,之后每次再减半,直到增量为1。
2.2 算法描述
(1)选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1;
(2)按增量序列个数 k,对序列进行 k 趟排序;
(3)每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
效果如下:
2.3 代码实现
下面代码中递增序列的计算和使用都很简单,和复杂递增序列的性能接近。当可以证明复杂的序列在最坏情况下的性能要好于我们所使用的递增序列。
public static void sort(int[] a) {
int length = a.length;
int h = 1;
while (h < length / 3) h = 3 * h + 1;
for (; h >= 1; h /= 3) {
for (int i = 0; i < a.length - h; i += h) {
for (int j = i + h; j > 0; j -= h) {
if (a[j] < a[j - h]) {
int temp = a[j];
a[j] = a[j - h];
a[j - h] = temp;
}
}
}
}
}
2.4 复杂度分析
2.5 总结与思考
希尔排序更高效的原因是它权衡了子数组的规模和有序性。排序之初,各个子数组都很短,排序之后子数组都是部分有序的,这两种情况都很适合插入排序。
三、简单选择排序
3.1 基本思想
选择排序(Selection sort)是一种简单直观
的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
选择排序的主要优点与数据移动有关
。如果某个元素位于正确的最终位置上,则它不会被移动。选择排序每次交换一对元素,它们当中至少有一个将被移到其最终位置上,因此对 n个元素的表进行排序总共进行至多 n-1 次交换。在所有的完全依靠交换去移动元素的排序方法中,选择排序属于非常好的一种。
3.2 算法描述
(1)从未排序序列中,找到关键字最小的元素
(2)如果最小元素不是未排序序列的第一个元素,将其和未排序序列第一个元素互换
(3)重复1、2步,直到排序结束。
动态图如下:
3.3 代码实现
public static void sort(int[] a) {
for (int i = 0; i < a.length; i++) {
int min = i;
//选出之后待排序中值最小的位置
for (int j = i + 1; j < a.length; j++) {
if (a[j] < a[min]) {
min = j;
}
}
//最小值不等于当前值时进行交换
if (min != i) {
int temp = a[i];
a[i] = a[min];
a[min] = temp;
}
}
}
3.4 复杂度分析
3.5 总结与思考
选择排序的简单和直观名副其实,这也造就了它”出了名的慢性子”,无论是哪种情况,哪怕原数组已排序完成,它也将花费将近n²/2次遍历来确认一遍。即便是这样,它的排序结果也还是不稳定的。 唯一值得高兴的是,它并不耗费额外的内存空间
。
四、堆排序
1991年的计算机先驱奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德(Robert W.Floyd) 和威廉姆斯(J.Williams) 在1964年共同发明了著名的堆排序算法(Heap Sort).
堆的定义如下:
nn个元素的序列{k1,k2,..,kn}
当且仅当满足下关系时,称之为堆。
把此序列对应的二维数组看成一个完全二叉树。那么堆的含义就是:完全二叉树中任何一个非叶子节点的值均不大于(或不小于)其左,右孩子节点的值
。 由上述性质可知大顶堆的堆顶的关键字肯定是所有关键字中最大的,小顶堆的堆顶的关键字是所有关键字中最小的。因此我们可使用大顶堆进行升序排序, 使用小顶堆进行降序排序。
4.1 基本思想
此处以大顶堆为例,堆排序的过程就是将待排序的序列构造成一个堆,选出堆中最大的移走,再把剩余的元素调整成堆,找出最大的再移走,重复直至有序。
4.2 算法描述
(1)先将初始序列K[1…n]K[1…n]建成一个大顶堆, 那么此时第一个元素K1K1最大, 此堆为初始的无序区.
(2)再将关键字最大的记录K1K1 (即堆顶, 第一个元素)和无序区的最后一个记录 KnKn 交换, 由此得到新的无序区K[1…n−1]K[1…n−1]和有序区K[n]K[n], 且满足K[1…n−1].keys⩽K[n].keyK[1…n−1].keys⩽K[n].key
(3)交换K1K1 和 KnKn 后, 堆顶可能违反堆性质, 因此需将K[1…n−1]K[1…n−1]调整为堆. 然后重复步骤2, 直到无序区只有一个元素时停止。
动态效果图如下:
4.3 代码实现
从算法描述来看,堆排序需要两个过程,一是建立堆,二是堆顶与堆的最后一个元素交换位置。所以堆排序有两个函数组成。一是建堆函数,二是反复调用建堆函数以选择出剩余未排元素中最大的数来实现排序的函数。
总结起来就是定义了以下几种操作:
(1)最大堆调整(Max_Heapify):将堆的末端子节点作调整,使得子节点永远小于父节点
(2)创建最大堆(Build_Max_Heap):将堆所有数据重新排序
(3)堆排序(HeapSort):移除位在第一个数据的根节点,并做最大堆调整的递归运算
对于堆节点的访问:
(1)父节点i的左子节点在位置:(2i+1);
(2)父节点i的右子节点在位置:(2i+2);
(3)子节点i的父节点在位置:floor((i-1)/2)
public static void sort(int[] a) {
for (int i = a.length - 1; i > 0; i--) {
max_heapify(a, i);
//堆顶元素(第一个元素)与Kn交换
int temp = a[0];
a[0] = a[i];
a[i] = temp;
}
}
/***
*
* 将数组堆化
* i = 第一个非叶子节点。
* 从第一个非叶子节点开始即可。无需从最后一个叶子节点开始。
* 叶子节点可以看作已符合堆要求的节点,根节点就是它自己且自己以下值为最大。
* */
public static void max_heapify(int[] a, int n) {
int child;
for (int i = (n - 1) / 2; i >= 0; i--) {
//左子节点位置
child = 2 * i + 1;
//右子节点存在且大于左子节点,child变成右子节点
if (child != n && a[child] < a[child + 1]) {
child++;
}
//交换父节点与左右子节点中的最大值
if (a[i] < a[child]) {
int temp = a[i];
a[i] = a[child];
a[child] = temp;
}
}
}
4.4 复杂度分析
(1)建立堆的过程, 从length/2 一直处理到0, 时间复杂度为O(n);
(2)调整堆的过程是沿着堆的父子节点进行调整, 执行次数为堆的深度, 时间复杂度为O(lgn);
(3)堆排序的过程由n次第2步完成, 时间复杂度为O(nlgn).
4.5 总结与思考
由于堆排序中初始化堆的过程比较次数较多, 因此它不太适用于小序列
。 同时由于多次任意下标相互交换位置, 相同元素之间原本相对的顺序被破坏了, 因此, 它是不稳定的排序。
五、冒泡排序
5.1 基本思想
冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
5.2 算法描述
冒泡排序算法的运作如下:
(1)比较相邻的元素。如果第一个比第二个大,就交换他们两个。
(2)对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
(3)针对所有的元素重复以上的步骤,除了最后一个。
(4)持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
动图如下:
5.3 代码实现
public static void sort(int[] a) {
//外层循环控制比较的次数
for (int i = 0; i < a.length - 1; i++) {
//内层循环控制到达位置
for (int j = 0; j < a.length - i - 1; j++) {
//前面的元素比后面大就交换
if (a[j] > a[j + 1]) {
int temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
}
}
}
}
5.4 复杂度分析
冒泡排序是最容易实现的排序, 最坏的情况是每次都需要交换, 共需遍历并交换将近n²/2次, 时间复杂度为O(n²). 最佳的情况是内循环遍历一次后发现排序是对的, 因此退出循环, 时间复杂度为O(n). 平均来讲, 时间复杂度为O(n²). 由于冒泡排序中只有缓存的temp变量需要内存空间, 因此空间复杂度为常量O(1).
5.5 总结与思考
由于冒泡排序只在相邻元素大小不符合要求时才调换他们的位置, 它并不改变相同元素之间的相对顺序, 因此它是稳定的排序算法
。
六、快速排序
快速排序是由东尼·霍尔所发展的一种排序算法。在平均状况下,排序 n 个项目要 Ο(nlogn) 次比较。在最坏状况下则需要 Ο(n2) 次比较,但这种状况并不常见。事实上,快速排序通常明显比其他 Ο(nlogn) 算法更快,因为它的内部循环(inner loop)可以在大部分的架构上很有效率地被实现出来。
6.1 基本思想
快速排序的基本思想:挖坑填数+分治法
。
快速排序使用分治法(Divide and conquer)策略来把一个串行(list)分为两个子串行(sub-lists)。
快速排序又是一种分而治之思想在排序算法上的典型应用。本质上来看,快速排序应该算是在冒泡排序基础上的递归分治法
。
快速排序的名字起的是简单粗暴,因为一听到这个名字你就知道它存在的意义,就是快,而且效率高!它是处理大数据最快的排序算法之一了。虽然 Worst Case 的时间复杂度达到了 O(n²),但是人家就是优秀,在大多数情况下都比平均时间复杂度为 O(n logn) 的排序算法表现要更好。
6.2 算法描述
快速排序使用分治策略来把一个序列(list)分为两个子序列(sub-lists)。步骤为:
(1)从数列中挑出一个元素,称为"基准"(pivot)。
(2)重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
(3)递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
递归到最底部时,数列的大小是零或一,也就是已经排序好了。这个算法一定会结束,因为在每次的迭代(iteration)中,它至少会把一个元素摆到它最后的位置去。
动图如下:
6.3 代码实现
用伪代码描述如下:
(1)i = L; j = R; 将基准数挖出形成第一个坑a[i]。
(2)j–,由后向前找比它小的数,找到后挖出此数填前一个坑a[i]中。
(3)i++,由前向后找比它大的数,找到后也挖出此数填到前一个坑a[j]中。
(4)再重复执行2,3二步,直到i==j,将基准数填入a[i]中
public static void sort(int[] a, int low, int high) {
//已经排完
if (low >= high) {
return;
}
int left = low;
int right = high;
//保存基准值
int pivot = a[left];
while (left < right) {
//从后向前找到比基准小的元素
while (left < right && a[right] >= pivot)
right--;
a[left] = a[right];
//从前往后找到比基准大的元素
while (left < right && a[left] <= pivot)
left++;
a[right] = a[left];
}
// 放置基准值,准备分治递归快排
a[left] = pivot;
sort(a, low, left - 1);
sort(a, left + 1, high);
}
上面是递归版的快速排序:通过把基准插入到合适的位置来实现分治,并递归地对分治后的两个划分继续快排。那么非递归版的快排如何实现呢?
因为 递归的本质是栈
,所以我们非递归实现的过程中,可以借助栈来保存中间变量就可以实现非递归了。在这里中间变量也就是通过Pritation函数划分区间之后分成左右两部分的首尾指针,只需要保存这两部分的首尾指针即可。
public static void sortByStack(int[] a) {
Stack<Integer> stack = new Stack<Integer>();
//初始状态的左右指针入栈
stack.push(0);
stack.push(a.length - 1);
while (!stack.isEmpty()) {
//出栈进行划分
int high = stack.pop();
int low = stack.pop();
int pivotIndex = partition(a, low, high);
//保存中间变量
if (pivotIndex > low) {
stack.push(low);
stack.push(pivotIndex - 1);
}
if (pivotIndex < high && pivotIndex >= 0) {
stack.push(pivotIndex + 1);
stack.push(high);
}
}
}
private static int partition(int[] a, int low, int high) {
if (low >= high) return -1;
int left = low;
int right = high;
//保存基准的值
int pivot = a[left];
while (left < right) {
//从后向前找到比基准小的元素,插入到基准位置中
while (left < right && a[right] >= pivot) {
right--;
}
a[left] = a[right];
//从前往后找到比基准大的元素
while (left < right && a[left] <= pivot) {
left++;
}
a[right] = a[left];
}
//放置基准值,准备分治递归快排
a[left] = pivot;
return left;
}
6.4 复杂度分析
七、归并排序
归并排序是建立在归并操作上的一种有效的排序算法,1945年由约翰·冯·诺伊曼首次提出。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行。
7.1 基本思想
归并排序算法是将两个(或两个以上)有序表合并成一个新的有序表,即把待排序序列分为若干个子序列,每个子序列是有序的。然后再把有序子序列合并为整体有序序列。
7.2 算法描述
归并排序可通过两种方式实现:
(1)自上而下的递归
(2)自下而上的迭代
递归法(假设序列共有n个元素):
(1)将序列每相邻两个数字进行归并操作,形成 floor(n/2)个序列,排序后每个序列包含两个元素;
(2)将上述序列再次归并,形成
floor(n/4)个序列,每个序列包含四个元素; (3)重复步骤2,直到所有元素排序完毕。
迭代法:
(1)申请空间,使其大小为两个已经排序序列之和,该空间用来存放合并后的序列
(2)设定两个指针,最初位置分别为两个已经排序序列的起始位置
(3)比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置
(4)重复步骤3直到某一指针到达序列尾
(5)将另一序列剩下的所有元素直接复制到合并序列尾
7.3 代码实现
归并排序其实要做两件事:
(1)分解:将序列每次折半拆分
(2)合并:将划分后的序列段两两排序合并
因此,归并排序实际上就是两个操作,拆分+合并
下面是递归的方法:
public class Merge {
//归并所需的辅助数组
private static int[] aux;
public static void sort(int[] a) {
//一次性分配空间
aux = new int[a.length];
sort(a, 0, a.length - 1);
}
public static void sort(int[] a, int low, int high) {
if (low >= high) {
return;
}
int mid = (low + high) / 2;
//将左半边排序
sort(a, low, mid);
//将右半边排序
sort(a, mid + 1, high);
merge(a, low, mid, high);
}
/**
* 该方法先将所有元素复制到aux[]中,然后在归并会a[]中。方法咋归并时(第二个for循环)
* 进行了4个条件判断:
* - 左半边用尽(取右半边的元素)
* - 右半边用尽(取左半边的元素)
* - 右半边的当前元素小于左半边的当前元素(取右半边的元素)
* - 右半边的当前元素大于等于左半边的当前元素(取左半边的元素)
* @param a
* @param low
* @param mid
* @param high
*/
public static void merge(int[] a, int low, int mid, int high) {
//将a[low..mid]和a[mid+1..high]归并
int i = low, j = mid + 1;
for (int k = low; k <= high; k++) {
aux[k] = a[k];
}
for (int k = low; k <= high; k++) {
if (i > mid) {
a[k] = aux[j++];
} else if (j > high) {
a[k] = aux[i++];
} else if (aux[j] < aux[i]) {
a[k] = aux[j++];
} else {
a[k] = aux[i++];
}
}
}
}
7.4 复杂度分析
从效率上看,归并排序可算是排序算法中的”佼佼者”. 假设数组长度为n,那么拆分数组共需logn,, 又每步都是一个普通的合并子数组的过程, 时间复杂度为O(n), 故其综合时间复杂度为O(nlogn)。另一方面, 归并排序多次递归过程中拆分的子数组需要保存在内存空间, 其空间复杂度为O(n)。
7.5 总结与思考
归并排序最吸引人的性质是它能够保证将任意长度为N的数组排序所需时间和NlogN成正比,它的主要缺点则是他所需的额外空间和N成正比。
八、基数排序
基数排序的发明可以追溯到1887年赫尔曼·何乐礼在打孔卡片制表机(Tabulation Machine), 排序器每次只能看到一个列。它是基于元素值的每个位上的字符来排序的。 对于数字而言就是分别基于个位,十位, 百位或千位等等数字来排序。
基数排序(Radix sort)是一种非比较型整数排序算法,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。
8.1 基本思想
它是这样实现的:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。
基数排序按照优先从高位或低位来排序有两种实现方案:
(1)MSD(Most significant digital) 从最左侧高位开始进行排序。先按k1排序分组, 同一组中记录,
关键码k1相等, 再对各组按k2排序分成子组, 之后, 对后面的关键码继续这样的排序分组, 直到按最次位关键码kd对各子组排序后.再将各组连接起来, 便得到一个有序序列。MSD方式适用于位数多的序列。
(2)LSD (Least significant digital)从最右侧低位开始进行排序。先从kd开始排序,再对kd-1进行排序,依次重复,直到对k1排序后便得到一个有序序列。LSD方式适用于位数少的序列。
8.2 算法描述
我们以LSD为例,从最低位开始,具体算法描述如下:
(1)取得数组中的最大数,并取得位数; (2)arr为原始数组,从最低位开始取每个位组成radix数组;
(3)对radix进行计数排序(利用计数排序适用于小范围数的特点);
8.3 代码实现
基数排序:通过序列中各个元素的值,对排序的N个元素进行若干趟的“分配”与“收集”来实现排序。
(1)
分配
:我们将L[i]中的元素取出,首先确定其个位上的数字,根据该数字分配到与之序号相同的桶中
(2)收集
:当序列中所有的元素都分配到对应的桶中,再按照顺序依次将桶中的元素收集形成新的一个待排序列L[]。对新形成的序列L[]重复执行分配和收集元素中的十位、百位…直到分配完该序列中的最高位,则排序结束
public static void sort(int[] arr) {
if (arr.length <= 1) return;
//取得数组中的最大数,并取得位数
int max = 0;
for (int i = 0; i < arr.length; i++) {
if (max < arr[i]) {
max = arr[i];
}
}
int maxDigit = 1;
while (max / 10 > 0) {
maxDigit++;
max = max / 10;
}
//申请一个桶空间
int[][] buckets = new int[10][arr.length];
int base = 10;
//从低位到高位,对每一位遍历,将所有元素分配到桶中
for (int i = 0; i < maxDigit; i++) {
int[] bktLen = new int[10]; //存储各个桶中存储元素的数量
//分配:将所有元素分配到桶中
for (int j = 0; j < arr.length; j++) {
int whichBucket = (arr[j] % base) / (base / 10);
buckets[whichBucket][bktLen[whichBucket]] = arr[j];
bktLen[whichBucket]++;
}
//收集:将不同桶里数据挨个捞出来,为下一轮高位排序做准备,由于靠近桶底的元素排名靠前,因此从桶底先捞
int k = 0;
for (int b = 0; b < buckets.length; b++) {
for (int p = 0; p < bktLen[b]; p++) {
arr[k++] = buckets[b][p];
}
}
System.out.println("Sorting: " + Arrays.toString(arr));
base *= 10;
}
}
8.4 复杂度分析
其中,d 为位数,r 为基数,n 为原数组个数。在基数排序中,因为没有比较操作,所以在复杂上,最好的情况与最坏的情况在时间上是一致的,均为 O(d*(n + r))。
8.5 总结和思考
基数排序更适合用于对时间, 字符串等这些 整体权值未知的数据
进行排序。
基数排序不改变相同元素之间的相对顺序,因此它是稳定的排序算法。
基数排序 vs 计数排序 vs 桶排序
这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:
(1)基数排序:根据键值的每位数字来分配桶
(2)计数排序:每个桶只存储单一键值
(3)桶排序:每个桶存储一定范围的数值
九、八大排序算法总结
各种排序性能对比如下:
9.1 从时间复杂度来说:
(1)平方阶O(n²)排序:各类简单排序:直接插入、直接选择和冒泡排序
(2)线性对数阶O(nlog₂n)排序:快速排序、堆排序和归并排序
(3)O(n1+§))排序,§是介于0和1之间的常数:希尔排序
(4)线性阶O(n)排序:基数排序,此外还有桶、箱排序
9.2 论是否有序的影响
(1)当原表有序或基本有序时,直接插入排序和冒泡排序将大大减少比较次数和移动记录的次数,时间复杂度可降至O(n);
(2)而快速排序则相反,当原表基本有序时,将蜕化为冒泡排序,时间复杂度提高为O(n2);
(3)原表是否有序,对简单选择排序、堆排序、归并排序和基数排序的时间复杂度影响不大。