排序是我们生活中经常会面对的问题。同学们做操时会按照从矮到高排列;老师查看上课出勤情况时,会按学生学号顺序点名;高考录取时,会按成绩总分降序依次进行录取.
所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作.
什么是排序的稳定性呢?
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
看下面的图示:
内部排序:数据全部放在内部的排序
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
因此,根据排序过程中借助的主要操作,我们把内排序分为:插入排序、交换排序、选
择排序和归并排序。
基本思想
在介绍插入排序之前,我们来说一个生活场景,大家都玩过扑克牌吧?最基本的扑克玩法都是一边摸牌,一边理牌。假如我们拿到了这样一手牌,如下面的图片所示,似乎是同花顺呀,别急,我们得理一理顺序才知道是否是真的同花顺。请问,如果是你,应该如何理牌呢?
我们使用下面的方法,这种方法也不太复杂,应该说,哪怕你是第一次玩扑克牌,只要认识这些数字,理牌的方法都是不用教的。将3和4移动到5的左侧,再将2移动到最左侧,顺序就算是理好了。这里,我们的理牌方法,就是直接插入排序法。
具体做法
直接插入排序(Straight Insertion Sort)的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。
先看代码思路
public static void insertSort(int[] array) {
for (int i = 1; i < array.length; i++) {
int tmp = array[i];
int j = i-1;
for (; j >= 0 ; j--) {
if(array[j] > tmp) {
array[j+1] = array[j];
}else {
//array[j+1] = tmp;
break;
}
}
array[j+1] = tmp;
}
}
时间复杂度分析:
最好:O(n)
最坏:O(n^2)
平均:O(n^2)
在插入排序的代码实现中,有两个for循环:
外层for循环控制待插入元素的个数,循环n次。
内层for循环控制查找插入位置,循环次数的上界为n-1,因此内层for循环的复杂度最坏可达O(n)。
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成多个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
具体思路:
具体代码
/*
gap 控制着 i 的步长
i 的值决定了 j 的初始值
j 会根据 tmp 和有序序列的比较,向左移动
*/
public static void shellSort(int[] array) {
int gap = array.length;
while (gap > 1) {
shell(array,gap);
gap /= 2;
}
//整体进行插入排序
shell(array,1);
}
//插入排序 -》GAP
public 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(array[j] > tmp) {
array[j+gap] = array[j];
}else {
break;
}
}
array[j+gap] = tmp;
}
}
先来看一遍动画,来理解选择排序的具体过程是什么样子的.
基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
代码思路:
public static void selectSort(int[] array) {
for (int i = 0; i < array.length; i++) {
int minIndex = i;
int j = i+1;
for (; j < array.length; j++) {
if(array[j] < array[minIndex]) {
minIndex = j;
}
}
swap(array,i,minIndex);
}
}
private static void swap(int[] array,int i,int j) {
int tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
时间复杂度分析:
最好:O(n^2)
最坏:O(n^2)
平均:O(n^2)
外层for循环控制排序轮数,循环n次。
内层for循环找出剩余元素中的最小值,循环n-1次。
将内外层for循环的复杂度相乘,得出选择排序总的时间复杂度为O(n^2)。
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
先来看一下,堆排序的动画
具体思路:
具体代码
public static void heapSort(int[] array) {
createBigHeap(array);
int end = array.length-1;
while (end > 0) {
swap(array,0,end);
shiftDown(array,0,end);
end--;
}
}
private static void createBigHeap(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 len) {
int child = 2*parent+1;
while (child < len) {
if(child+1 < len && array[child] < array[child+1]) {
child++;
}
if(array[child] > array[parent]) {
swap(array,child,parent);
parent = child;
child = 2*parent+1;
}else {
break;
}
}
}
时间复杂度分析:
最好:O(nlogn)
最坏:O(nlogn)
平均:O(nlogn)
在堆排序的代码实现中,主要有两个部分:
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特
点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
具体思路
public static void bubbleSort(int[] array) {
for (int i = 0; i < array.length-1; i++) {
boolean flg = false;
for (int j = 0; j < array.length-1-i; j++) {
if(array[j] > array[j+1]) {
swap(array,j,j+1);
flg = true;
}
}
if(flg == false) {
return;
}
}
}
时间复杂度分析
最好:O(n)
最坏:O(n^2)
平均:O(n^2)
在冒泡排序的代码实现中,有两个for循环:
外层循环控制排序的轮数,内层循环控制每轮比较的次数。
当数组已经有序时,外层循环只需要运行1次,内层循环不运行,时间复杂度为O(n)。
当数组完全反序时,外层循环需要运行n次,内层循环每次运行n-1次,时间复杂度为O(n^2)。
在一般情况下,冒泡排序的时间复杂度为O(n^2)。
基本概念
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有无素都排列在相应位置上为止。
算法步骤
我这里先列出快速排序的基本代码框架
public static void quickSort1(int[] array) {
quick(array,0,array.length-1);
}
private static void quick(int[] array,int start,int end) {
//为什么取大于号 : 1 2 3 4 5 6
if(start >= end) {
return;
}
//找基准
int pivot = partition(array,start,end);//划分
quick(array,start,pivot-1);
quick(array,pivot+1,end);
}
看了上面的代码之后,我们现在来分析分基准是怎么样的,下面提供了三种分基准的方法.
方法一:挖坑法
具体步骤:
private static int partition(int[] array,int left,int right) {
int tmp = array[left];
while (left < right) {
while (left< right && array[right] >= tmp) {
right--;
}
array[left] = array[right];
while (left< right && array[left] <= tmp) {
left++;
}
array[right] = array[left];
}
array[left] = tmp;
return left;
}
方法二: Hoare
具体步骤
private static int partition2(int[] array,int left,int right) {
int tmp = array[left];
int i = left;
while (left < right) {
while (left< right && array[right] >= tmp) {
right--;
}
while (left< right && array[left] <= tmp) {
left++;
}
swap(array,left,right);
}
swap(array,left,i);
return left;
}
方法三:前后指针
具体步骤
具体图示:
private static int partition3(int[] array,int left,int right) {
int prev = left ;
int cur = left+1;
while (cur <= right) {
if(array[cur] < array[left] && array[++prev] != array[cur]) {
swap(array,cur,prev);
}
cur++;
}
swap(array,prev,left);
return prev;
}
其实针对上面的快速排序,我们还可以做一些优化,我提供了,俩种优化方案.具体为什么这样做,我会给出原因和解释方法,大家不用担心.
1. 三数取中法选key
三数取中法是快速排序中选取基准值的一种常用方法。它的做法是:
选取数组左端、右端和中间三个元素,取其中间大小的元素作为基准值。
相比直接选取第一个或最后一个元素,三数取中法可以避免出现基准值过大或过小的情况,实现更为平衡的分割。这可以在一定程度上提高快速排序的性能,尤其是在最差情况下。
所以,三数取中法是一个较好的选key方法,能够一定程度优化快速排序。
至于为什么使用三数取中,只是为了能够在找基准的时候,能够实现平衡的分割.
大家看一下我对三数取中情况的判定
具体代码:
private static int midThree(int[] array,int left,int right) {
int mid = (left+right) / 2;
//6 8
if(array[left] < array[right]) {
if(array[mid] < array[left]) {
return left;
}else if(array[mid] > array[right]) {
return right;
}else {
return mid;
}
}else {
//array[left] > array[right]
if(array[mid] < array[right]) {
return right;
}else if(array[mid] > array[left]) {
return left;
}else {
return mid;
}
}
}
添加优化后的代码
public static void quickSort1(int[] array) {
quick(array,0,array.length-1);
}
private static void quick(int[] array,int start,int end) {
//为什么取大于号 : 1 2 3 4 5 6
if(start >= end) {
return;
}
//三数取中法
int index = midThree(array, start,end);
swap(array,index,start);
//找基准
int pivot = partition(array,start,end);//划分
quick(array,start,pivot-1);
quick(array,pivot+1,end);
}
private static int midThree(int[] array,int left,int right) {
int mid = (left+right) / 2;
//6 8
if(array[left] < array[right]) {
if(array[mid] < array[left]) {
return left;
}else if(array[mid] > array[right]) {
return right;
}else {
return mid;
}
}else {
//array[left] > array[right]
if(array[mid] < array[right]) {
return right;
}else if(array[mid] > array[left]) {
return left;
}else {
return mid;
}
}
}
2. 递归到小的子区间时,可以考虑使用插入排序
当快速排序递归层数较深,子区间长度较小时,继续递归的性能开销会变大。此时,可以考虑改用其他更为高效的排序方法,以提高效率。
插入排序对于较小的子区间更加高效,它的时间复杂度为O(n^2)。而快速排序的时间复杂度为O(nlogn),但它的性能并不依赖于区间大小。
所以,在快速排序递归到一定层数,子区间足够小时,可以改用插入排序来排序此子区间。这可以减少继续递归带来的性能损失,优化快速排序的性能。
至于我们为什么要使用这种去优化快速排序,让我们再来看一下快速排序的过程.
大家可以看到上述动画过程中,实际上我们在递归到最后俩层的时候,区间就趋于有序了,这个时候如果还继续使用快速排序找基准去排序的话,就有些慢了.所以我们在倒数俩层的时候使用插入排序.
代码展示:
public static void quickSort1(int[] array) {
quick(array,0,array.length-1);
}
private static void quick(int[] array,int start,int end) {
//为什么取大于号 : 1 2 3 4 5 6
if(start >= end) {
return;
}
//使用这个优化 主要解决 减少递归的次数,使用插入排序优化
if(end - start + 1 <= 14) {
//插入排序
insertSort2(array,start,end);
return;
}
System.out.println("start:"+start+" end: "+end);
//三数取中法
int index = midThree(array, start,end);
swap(array,index,start);
int pivot = partition(array,start,end);//划分
quick(array,start,pivot-1);
quick(array,pivot+1,end);
}
private static void insertSort2(int[] array,int left,int right) {
for (int i = left+1; i <= right; i++) {
int tmp = array[i];
int j = i-1;
for (; j >= left ; j--) {
if(array[j] > tmp) {
array[j+1] = array[j];
}else {
break;
}
}
array[j+1] = tmp;
}
}
具体思路:
具体代码:
public static void quickSort(int[] array) {
Deque<Integer> stack = new LinkedList<>();
int left = 0;
int right = array.length-1;
int pivot = partition(array,left,right);
if(pivot > left+1) {
stack.push(left);
stack.push(pivot-1);
}
if(pivot < right-1) {
stack.push(pivot+1);
stack.push(right);
}
while (!stack.isEmpty()) {
right= stack.pop();
left = stack.pop();
pivot = partition(array,left,right);
if(pivot > left+1) {
stack.push(left);
stack.push(pivot-1);
}
if(pivot < right-1) {
stack.push(pivot+1);
stack.push(right);
}
}
}
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and
Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。归并排序核心步骤:
具体思路:
public static void mergeSort(int[] array) {
mergeSortFunc(array,0,array.length-1);
}
private static void mergeSortFunc(int[] array,int left,int right) {
if(left >= right) {
return;
}
//分解过程
int mid = (left+right) / 2;
mergeSortFunc(array,left,mid);
mergeSortFunc(array,mid+1,right);
//合并过程
merge(array,left,right,mid);
}
/*
1. 定义s1和s2指针,初始指向两个子数组起点。
2. 比较s1和s2指向元素,小的存入tmp并移动对应指针。
3. 重复步骤2,直到s1或s2遍历完对应子数组。
4. 将未遍历完的子数组元素存入tmp。
5. 将tmp中的元素存回原数组。
*/
private static void merge(int[] array,int start,int end,int mid) {
int s1 = start;
//int e1 = mid;
int s2 = mid+1;
//int e2 = end;
int[] tmp = new int[end-start+1];
int k = 0;//tmp数组的下标
while (s1 <= mid && s2 <= end) {
if(array[s1] <= array[s2]) {
tmp[k++] = array[s1++];
}else {
tmp[k++] = array[s2++];
}
}
while (s1 <= mid) {
tmp[k++] = array[s1++];
}
while (s2 <= end) {
tmp[k++] = array[s2++];
}
for (int i = 0; i < tmp.length; i++) {
array[i+start] = tmp[i];
}
}
时间复杂度分析:
归并排序是一种典型的分治算法。它将原问题划分为两个子问题,并递归求解。然后将两个子问题的解进行合并,得到原问题的解。
假设有n个元素的数组,每次都能等分为两个n/2元素的子数组。那么:
第1层递归有2个子问题,每个子问题的规模为n/2;
第2层递归有4个子问题,每个子问题的规模为n/4;
第k层递归有2k个子问题,每个子问题的规模为n/2k;
直到数组中每个子数组都只有1个元素。
此时,一共递归了log2n层。每层的时间复杂度都是O(n),因为需要对n/2个元素进行合并。
所以,总的时间复杂度为O(nlogn)。
具体的计算过程如下:
第1层:2 * (n/2) = n //2个子问题,每个n/2个元素,合并需n时间
第2层:4 * (n/4) = n //4个子问题,每个n/4个元素,合并需n时间
第3层:8 * (n/8) = n //8个子问题,每个n/8个元素,合并需n时间
…
第k层:2^k * (n/2^k) = n //2k个子问题,每个n/2k个元素,合并需n时间
一共log2n层,所以时间复杂度为O(nlogn)。
具体思路:
/*
几个坐标之间的关系
left = i;
mid = left+gap-1;
right = mid+gap;
*/
public static void mergeSort(int[] array) {
int gap = 1;
while (gap < array.length) {
// i += gap * 2 当前gap组的时候,去排序下一组
for (int i = 0; i < array.length; i += gap * 2) {
int left = i;
int mid = left+gap-1;//有可能会越界
if(mid >= array.length) {
mid = array.length-1;
}
int right = mid+gap;//有可能会越界
if(right>= array.length) {
right = array.length-1;
}
merge(array,left,right,mid);
}
//当前为2组有序 下次变成4组有序
gap *= 2;
}
}
private static void merge(int[] array,int start,int end,int mid) {
int s1 = start;
//int e1 = mid;
int s2 = mid+1;
//int e2 = end;
int[] tmp = new int[end-start+1];
int k = 0;//tmp数组的下标
while (s1 <= mid && s2 <= end) {
if(array[s1] <= array[s2]) {
tmp[k++] = array[s1++];
}else {
tmp[k++] = array[s2++];
}
}
while (s1 <= mid) {
tmp[k++] = array[s1++];
}
while (s2 <= end) {
tmp[k++] = array[s2++];
}
for (int i = 0; i < tmp.length; i++) {
array[i+start] = tmp[i];
}
}
时间复杂度分析
该非递归归并排序的时间复杂度仍然为O(nlogn)。
理由如下: