排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序。
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
常见的排序算法
代码实现排序算法
插入排序
直接插入排序
原理:
直接插入排序是一种简单直观的排序算法,其核心思想是将待排序的数组分为已排序和未排序两个部分,然后通过逐步将未排序部分的元素插入到已排序部分的合适位置,从而实现排序。
直接插入排序的基本步骤如下:
-
初始化:将数组的第一个元素视为已排序部分,其余元素为未排序部分。
-
遍历未排序部分:
- 从未排序部分中取出一个元素(通常称为“关键值”)。
- 将这个关键值与已排序部分的元素进行比较,找到合适的位置。
-
插入关键值:
- 将已排序部分中大于关键值的元素向后移动一位,为关键值腾出位置。
- 将关键值插入到找到的合适位置。
-
重复:重复步骤2和3,直到未排序部分的所有元素都被插入到已排序部分中。
时间复杂度:
- 最坏情况:O(n^2),当输入数组是反序的时候,比较和移动次数最多。
- 最好情况:O(n),当输入数组已经是有序时,仅需进行n-1次比较而不需要移动元素。
- 平均情况:O(n^2)。
空间复杂度:O(1),因为只需常量级的额外空间。
总结: 直接插入排序适合小规模数据的排序,且具有稳定性,简单易懂,尤其在部分有序的情况下性能优越。通过不断将未排序的元素插入到已排序的部分,逐步构建一个完整的有序序列。
代码实现:
public class InsertSort {
public static void sort(int[] array){
for (int i = 1; i < array.length; i++) {
int tmp = array[i];
int j = i - 1;
for (; j >= 0; j--) {
if(tmp < array[j]){
array[j+1] = array[j];
}else{
break;
}
}
array[j+1] = tmp;
}
}
}
希尔排序( 缩小增量排序 )
原理:
希尔排序(Shell Sort)是一种基于插入排序的更高效的排序算法,它通过将待排序数组分成若干个子序列来实现。希尔排序通过对这些子序列进行插入排序,使得整体上比直接插入排序更快,特别是在数组较大时。
希尔排序的基本原理如下:
-
选择增量:首先,选择一个增量(也称“步长”),通常选取某个初始值,然后缩小这个值,直至增量为1。增量的选择方式影响排序的效率,常见的选择方式包括逐步减半、使用特定的增量序列等。
-
分组排序:根据增量将待排序数组分成若干个子序列,例如:
- 对于增量d,将数组中相隔d个元素分为一个组。
- 每组内的元素使用插入排序进行排序。
-
重复过程:在对所有组进行排序后,缩小增量(通常为原来的某个比例),重复步骤2,直到增量为1,此时整个数组就只有一个组,再进行一次插入排序,完成排序。
-
完成排序:经过多次分组和排序后,整个数组将变得有序。
时间复杂度:
- 最坏情况:O(n^(3/2))到O(n^2),具体取决于增量序列的选择。
- 平均情况:O(n^(5/4))到O(n log^2 n)。
- 最好情况:O(n)。
空间复杂度:O(1),因为只需要常量级的额外空间。
总结: 希尔排序通过分组的方式对元素进行排序,逐步减少增量,使数组有序。其优点是适用于较大数据集,并且比简单的插入排序快得多,特别是在数组部分有序的情况下,能够更有效地提升排序效率。
代码实现:
public class ShellSort {
public static void sort(int[] array){
int gap = array.length;
while(gap != 1){
gap /= 2;
shell(array,gap);
}
shell(array,gap);
}
private static void shell(int[] array,int gap){
for (int i = gap; i < array.length; i++) {
int tmp = array[i];
int j = i - gap;
for (; j >= 0; j -= gap) {
if(tmp < array[j]){
array[j+gap] = array[j];
}else{
break;
}
}
array[j+gap] = tmp;
}
}
}
选择排序:
直接选择排序
原理:
直接选择排序(Selection Sort)是一种简单直观的排序算法,其基本思想是每一次从待排序的数列中选出最小(或最大)的元素,将其放到已排序序列的末尾。具体原理如下:
直接选择排序的基本步骤:
-
初始化:将整个数组视为未排序部分。
-
遍历未排序部分:
- 在未排序部分中找到最小(或最大)元素的下标。
-
交换元素:
- 将找到的最小元素与未排序部分的第一个元素交换位置,从而将该最小元素移至已排序部分的末尾。
-
重复:
- 缩小未排序部分的范围,继续从未排序部分中选择最小元素,并进行交换,直到整个数组都被排序完成。
示例:
假设待排序数组为 [64, 25, 12, 22, 11]
,过程如下:
- 第一轮:找到最小元素11,并与第一个元素64交换,得到
[11, 25, 12, 22, 64]
。 - 第二轮:在剩下的未排序部分
[25, 12, 22, 64]
中找到最小元素12,并将其与25交换,得到[11, 12, 25, 22, 64]
。 - 第三轮:找到最小元素22,将其与25交换,得到
[11, 12, 22, 25, 64]
。 - 第四轮:在最后的未排序部分中,25和64已经是有序的,因此最终结果为
[11, 12, 22, 25, 64]
。
时间复杂度:
- 最坏情况:O(n^2)
- 最好情况:O(n^2)
- 平均情况:O(n^2)
空间复杂度:
- O(1),因为直接选择排序是原地排序算法,只需常量级的额外空间。
总结:
直接选择排序是一种简单且易于理解的排序算法,但它的效率较低,特别是在大数据集时,通常不适用于实际应用中。它适合小规模数据的排序,且在元素比较时的稳定性较好。
代码实现:
public class SelectSort {
public static void sort(int[] array){
for (int i = 0; i < array.length - 1; i++) {
int minIndex = i;
for (int j = i+1; j < array.length; j++) {
if(array[j] < array[minIndex]){
minIndex = j;
}
}
int tmp = array[i];
array[i] = array[minIndex];
array[minIndex] = tmp;
}
}
}
对于该代码我们可以进行优化,我们使用 left 指针指向最左边,用 right 指针指向最右边,如何在数组中找最小值和最大值分别与 left 和 right 交换,这样可以提高效率。
代码实现:
public class SelectSort {
private static void swap(int[] array,int i,int j){
int tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
public static void sort(int[] array){
int left = 0;
int right = array.length - 1;
while(left < right){
int minValue = left;
int maxValue = left;
for (int i = left+1; i <= right; i++) {
if(array[i] < array[minValue]){
minValue = i;
}
if(array[i] > array[maxValue]){
maxValue = i;
}
}
swap(array,left,minValue);
if(maxValue == left){
maxValue = minValue;
}
swap(array,right,maxValue);
left++;
right--;
}
}
}
堆排序
原理:
堆排序(Heap Sort)是一种基于堆数据结构的排序算法,利用堆的性质来实现高效排序。堆是一种完全二叉树,具有以下特点:对于每个节点,值总是大于或等于(最大堆)或小于或等于(最小堆)其子节点的值。
堆排序的基本原理:
-
构建最大堆:首先,将待排序的数组构建成一个最大堆(或最小堆,具体取决于排序顺序)。在最大堆中,父节点的值大于等于子节点的值。
-
交换与调整:
- 将堆顶元素(最大元素)与堆的最后一个元素交换,这样最大元素就移到了数组的末尾。
- 然后减少堆的大小(即排除已排序的最大元素),并对堆顶元素进行“下沉”操作,调整堆的结构,恢复最大堆的性质。
-
重复步骤:重复步骤2,直到堆的大小减小到1,此时所有元素都已排序。
示例:
假设待排序数组为 [3, 5, 1, 10, 2]
,过程如下:
-
构建最大堆:
- 首先将数组调整为最大堆:
[10, 5, 1, 3, 2]
。
- 首先将数组调整为最大堆:
-
堆排序过程:
- 交换堆顶和最后一个元素,得到
[2, 5, 1, 3, 10]
。减小堆的大小,调整堆,变为[5, 3, 1, 2, 10]
。 - 继续交换堆顶和最后未排序元素,得到
[2, 3, 1, 5, 10]
,再调整,变为[3, 2, 1, 5, 10]
。 - 重复这个过程,最终得到有序数组
[1, 2, 3, 5, 10]
。
- 交换堆顶和最后一个元素,得到
时间复杂度:
- 最坏情况:O(n log n)
- 最好情况:O(n log n)
- 平均情况:O(n log n)
空间复杂度:
- O(1),堆排序是原地排序算法,仅需常量级的额外空间。
总结:
堆排序是一种高效的排序算法,适用于大规模数据的排序。它的时间复杂度稳定且较优,且是原地排序,不需要额外的存储空间。但由于堆排序不是稳定排序,因此在需要保持相同元素相对顺序的情况下可能不适用。
代码实现:
public class HeapSort {
private static void createHeap(int[] array){
for (int parent = (array.length-1-1)/2; parent >= 0; parent--) {
shiftDown(array,parent,array.length);
}
}
private static void shiftDown(int[] array,int parent, int usedSize) {
int child = parent*2+1;
while(child < usedSize) {
if (child+1 < usedSize && array[child + 1] > array[child]) {
child = child + 1;
}
if (array[parent] < array[child]) {
int tmp = array[child];
array[child] = array[parent];
array[parent] = tmp;
parent = child;
child = parent*2+1;
}else{
break;
}
}
}
public static void sort(int[] array) {
createHeap(array);
int end = array.length - 1;
while(end > 0){
int tmp = array[0];
array[0] = array[end];
array[end] = tmp;
shiftDown(array,0,end);
end--;
}
}
}
交换排序
冒泡排序
原理:
冒泡排序(Bubble Sort)是一种简单的基于比较的排序算法,其核心思想是通过重复遍历数组,比较相邻元素并交换它们的位置,以将最大的元素“冒泡”到数组的末尾。具体原理如下:
冒泡排序的基本步骤:
-
初始化:将待排序数组视为未排序部分。
-
遍历数组:
- 从数组的开始元素开始,依次比较相邻的两个元素。
- 如果前一个元素大于后一个元素,则交换它们的位置。
- 每一轮遍历将确定一个最大元素(或最小元素)的位置,把它放到已排序部分的末尾。
-
重复过程:对整个数组重复上述步骤,每次遍历后,最大的元素会“冒泡”到未排序部分的末尾。随着遍历次数的增加,未排序部分的元素会逐渐减少。
-
终止条件:当一次遍历中没有发生交换时,表示数组已经有序,可以提前终止排序过程。
示例:
假设待排序数组为 [5, 3, 8, 4, 2]
,过程如下:
-
第一轮遍历:
- 比较5和3,交换得到
[3, 5, 8, 4, 2]
- 比较5和8,不交换
- 比较8和4,交换得到
[3, 5, 4, 8, 2]
- 比较8和2,交换得到
[3, 5, 4, 2, 8]
- 第一轮结束,最大元素8已在正确位置。
- 比较5和3,交换得到
-
第二轮遍历:
- 比较3和5,不交换
- 比较5和4,交换得到
[3, 4, 5, 2, 8]
- 比较5和2,交换得到
[3, 4, 2, 5, 8]
- 第二轮结束,5已在正确位置。
-
第三轮遍历:
- 比较3和4,不交换
- 比较4和2,交换得到
[3, 2, 4, 5, 8]
- 第三轮结束,4已在正确位置。
-
第四轮遍历:
- 比较3和2,交换得到
[2, 3, 4, 5, 8]
- 第四轮结束,2已在正确位置。
- 比较3和2,交换得到
最终得到的有序数组为 [2, 3, 4, 5, 8]
。
时间复杂度:
- 最坏情况:O(n^2),当数组是反序时,比较和交换次数最多。
- 最好情况:O(n),当数组已经有序时,仅需进行一次遍历。
- 平均情况:O(n^2)。
空间复杂度:
- O(1),因为冒泡排序是原地排序算法,仅需常量级的额外空间。
总结:
冒泡排序是一种基本的排序算法,简单易懂,适合于小规模数据的排序。由于其较低的效率,在实际应用中通常不推荐使用,特别是对于大规模数据。尽管如此,冒泡排序有助于理解排序算法的基本概念和实现原理。
代码实现:
public class BubbleSort {
public static void Sort(int[] array){
for (int i = 0; i < array.length-1; i++) {
boolean flag = false;
for (int j = 0; j < array.length-1-i; j++) {
if(array[j+1] < array[j]){
int tmp = array[j];
array[j] = array[j+1];
array[j+1] = tmp;
flag = true;
}
}
if(flag == false){
return;
}
}
}
}
快速排序
原理:
快速排序(Quick Sort)是一种高效的排序算法,它采用分治法(Divide and Conquer)策略来排序。快速排序的核心思想是通过一个称为“基准”(Pivot)的元素,将数组分成左右两部分,将小于基准的元素放在左侧,大于基准的元素放在右侧,随后递归地对这两个部分进行排序。以下是快速排序的基本原理:
快速排序的基本步骤:
-
选择基准元素:从数组中选择一个元素作为基准(常见的方法包括选择第一个元素、最后一个元素、随机选择或者三数取中等)。
-
分区操作:
- 重新排列数组,使得所有小于基准的元素放在基准的左侧,所有大于基准的元素放在基准的右侧。
- 经过这一轮分区,基准元素就处于其最终位置。
-
递归排序:
- 对基准左侧和右侧的两个子数组递归执行快速排序,直到子数组的长度为1或0,此时数组已经有序。
示例:
假设待排序数组为 [10, 7, 8, 9, 1, 5]
,过程如下:
-
选择基准:
- 选取最后一个元素5作为基准。
-
分区操作:
- 通过遍历数组,将小于5的元素分到左边,得到
[1, 5, 8, 9, 7, 10]
,此时基准元素5在正确的位置。
- 通过遍历数组,将小于5的元素分到左边,得到
-
递归排序:
- 对子数组
[1]
(左侧)和[8, 9, 7, 10]
(右侧)进行递归。 - 子数组
[1]
已经有序,递归结束。 - 对子数组
[8, 9, 7, 10]
继续进行快速排序,选择基准元素10进行分区,得到[8, 7, 9, 10]
,然后为子数组[8, 7, 9]
递归排序。
- 对子数组
-
继续递归:
- 对
[8, 7, 9]
选择基准元素9进行分区,得到[7, 8, 9]
。此时这个子数组也是有序。
- 对
-
汇总结果:
- 最终组合得到:
[1, 5, 7, 8, 9, 10]
。
- 最终组合得到:
时间复杂度:
- 最坏情况:O(n^2),发生在每次选择的基准元素都是当前分区中的最大或最小元素(如已经有序或逆序时)。
- 最好情况:O(n log n),每次分割都能将数组平均分成两部分。
- 平均情况:O(n log n)。
空间复杂度:
- O(log n)至O(n),具体取决于递归的深度。通常情况下,由于递归是使用栈来实现的,因此需要额外的空间。
总结:
快速排序是一种高效且广泛使用的排序算法,适合大规模数据的排序。在实际应用中,快速排序通常比其他O(n log n)复杂度的排序算法(如归并排序)更快,因为它可以充分利用缓存和数据局部性原则。尽管快速排序不稳定,但其高效性使其在多种排序任务中依然被广泛应用。
代码实现:
public class QuickSort {
//在递归比较底的时候,改为直接插入排序来降低递归次数
public static void insertSortRange(int[] array,int start,int end){
for (int i = start+1; i <= end; i++) {
int tmp = array[i];
int j = i - 1;
for (; j >= start; j--) {
if(tmp < array[j]){
array[j+1] = array[j];
}else{
break;
}
}
array[j+1] = tmp;
}
}
private static void swap(int[] array,int j,int k){
int tmp = array[j];
array[j] = array[k];
array[k] = tmp;
}
public static void sort(int[] array){
quick(array,0,array.length-1);
}
//递归法
private static void quick(int[] array,int start,int end){
if(start >= end) return;
if(end-start+1 <= 15) {
insertSortRange(array,start,end);
return;
}
int mid = midOfThree(array,start,end);
swap(array,mid,start);
int pivot = partition(array,start,end);
quick(array,start,pivot-1);
quick(array,pivot+1,end);
}
//三数取中,优化方案,降低递归次数
private static int midOfThree(int[] array, int start, int end) {
int mid = (start+end)/2;
if(array[start] < array[end]) {
if(array[mid] < array[start]){
return start;
}else if(array[mid] > array[end]) {
return end;
}else {
return mid;
}
}else {
if(array[mid] > array[start]) {
return start;
}else if(array[mid] < array[end]) {
return end;
}else {
return mid;
}
}
}
//填坑法 找标记
private static int partition(int[] array,int left,int right){
int key = array[left];
while(left < right){
while(left < right && array[right] >= key){
right--;
}
array[left] = array[right];
while(left < right && array[left] <= key){
left++;
}
array[right] = array[left];
}
array[left] = key;
return left;
}
}
归并排序
原理:
归并排序(Merge Sort)是一种基于分治法(Divide and Conquer)的高效排序算法,其基本思想是将待排序的数组分为两个子数组,分别对这两个子数组进行排序,最后将排序后的子数组合并成一个有序的数组。归并排序具有稳定性和良好的时间复杂度表现,适合处理大数据集。以下是归并排序的基本原理:
归并排序的基本步骤:
-
分割过程:
- 将待排序的数组从中间分成两个子数组,递归地对这两个子数组进行归并排序,直到每个子数组的长度为1或0,此时这些子数组自然是有序的。
-
合并过程:
- 将两个已排序的子数组合并成一个新的有序数组。合并时,通过比较两个子数组中的元素,将较小的元素依次添加到合并后的数组中。
-
递归调用:
- 重复上述过程,直到整个数组合并成一个有序的数组。
示例:
假设待排序数组为 [38, 27, 43, 3, 9, 82, 10]
,过程如下:
-
分割过程:
- 将数组分为
[38, 27, 43]
和[3, 9, 82, 10]
。 - 继续分割,第一个子数组分为
[38]
和[27, 43]
,第二个子数组分为[3, 9]
和[82, 10]
。 - 然后继续对
[27, 43]
和[82, 10]
分割,直到所有子数组长度为1。
- 将数组分为
-
合并过程:
- 开始合并:将单个元素的子数组合并成有序数组。
- 合并
[27]
和[43]
得到[27, 43]
。 - 合并
[3]
和[9]
得到[3, 9]
。 - 合并
[82]
和[10]
得到[10, 82]
。 - 接着合并
[38]
和[27, 43]
得到[27, 38, 43]
。 - 最后合并
[3, 9]
和[10, 82]
得到[3, 9, 10, 82]
。 - 最终合并
[27, 38, 43]
和[3, 9, 10, 82]
得到完整的有序数组[3, 9, 10, 27, 38, 43, 82]
。
时间复杂度:
- 最坏情况:O(n log n)
- 最好情况:O(n log n)
- 平均情况:O(n log n)
空间复杂度:
- O(n),由于合并过程需要一个额外的数组来存放合并的结果。
总结:
归并排序是一种稳定的排序算法,时间复杂度较好,适用于大规模数据的排序。尽管归并排序的空间复杂度较高,但它的稳定性和效率,使其在许多应用中仍然非常流行。在实际应用中,归并排序常常用于外部排序和链表排序等场景。
代码实现:
public class MergeSort {
//递归思路
public static void sort(int[] array) {
int left = 0;
int right = array.length-1;
recursion(array,left,right);
}
private static void recursion(int[] array, int left, int right) {
int mid = (left+right) / 2;
if(left >= right) return;
recursion(array,left,mid);
recursion(array,mid+1,right);
merge(array,left,right,mid);
}
//归并操作
private static void merge(int[] array, int left, int right, int mid) {
int tmp = left;
int s1 = left;
int s2 = mid+1;
int[] tmpArr = new int[right-left+1];
int k = 0;
//证明两个区间都有元素
while (s1 <= mid && s2 <= right) {
if(array[s2] <= array[s1]) {
tmpArr[k++] = array[s2++];
}else {
tmpArr[k++] = array[s1++];
}
}
while (s1 <= mid) {
tmpArr[k++] = array[s1++];
}
while (s2 <= right) {
tmpArr[k++] = array[s2++];
}
//走到这里这个区间的数组已经有序
for (int i = 0; i < tmpArr.length; i++) {
array[tmp++] = tmpArr[i];
}
}
}
排序算法复杂度及稳定性总结
海量数据的排序问题
外部排序:排序过程需要在磁盘等外部存储进行的排序。
前提:内存只有 1G,需要排序的数据有 100G
因为内存中因为无法把所有数据全部放下,所以需要外部排序,而归并排序是最常用的外部排序
1. 先把文件切分成 200 份,每个 512 M
2. 分别对 512 M 排序,因为内存已经可以放的下,所以任意排序方式都可以
3. 进行 2路归并,同时对 200 份有序文件做归并过程,最终结果就有序了