排序算法的奇妙冒险
- 一.排序的概念
- 1.1 排序的定义
- 1.2 排序的稳定性
- 1.3 排序的内排序和外排序
- 二.插入排序
- 2.1 直接插入排序
- 2.2 希尔排序
- 三.选择排序
- 3.1直接选择排序
- 3.2 堆排序
- 四.交换排序
- 4.1 冒泡排序
- 4.2 快速排序
- **选取基准值的方法**
- 快速排序的优化
- 非递归实现快速排序
- 五.归并排序
- 递归实现递归排序
- 非递归实现归并排序
- 六.总结
一.排序的概念
1.1 排序的定义
排序是我们生活中经常会面对的问题。同学们做操时会按照从矮到高排列;老师查看上课出勤情况时,会按学生学号顺序点名;高考录取时,会按成绩总分降序依次进行录取.
所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作.
1.2 排序的稳定性
什么是排序的稳定性呢?
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
看下面的图示:
1.3 排序的内排序和外排序
内部排序:数据全部放在内部的排序
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
因此,根据排序过程中借助的主要操作,我们把内排序分为:插入排序、交换排序、选
择排序和归并排序。
二.插入排序
2.1 直接插入排序
基本思想
在介绍插入排序之前,我们来说一个生活场景,大家都玩过扑克牌吧?最基本的扑克玩法都是一边摸牌,一边理牌。假如我们拿到了这样一手牌,如下面的图片所示,似乎是同花顺呀,别急,我们得理一理顺序才知道是否是真的同花顺。请问,如果是你,应该如何理牌呢?
我们使用下面的方法,这种方法也不太复杂,应该说,哪怕你是第一次玩扑克牌,只要认识这些数字,理牌的方法都是不用教的。将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;
}
}
具体的一个分析过程如下:
代码思路如下:
- 将数组的第一个元素认为是有序序列,其余元素均为无序序列。
- 取出无序序列中的第一个元素,在有序序列中找到适当的位置并插入。
- 重复步骤 2,直到无序序列中没有元素为止。
- 该算法实现是通过不断将无序序列中的元素插入到有序序列中的正确位置,最终实现整个序列有序
时间复杂度分析:
最好:O(n)
最坏:O(n^2)
平均:O(n^2)
在插入排序的代码实现中,有两个for循环:
外层for循环控制待插入元素的个数,循环n次。
内层for循环控制查找插入位置,循环次数的上界为n-1,因此内层for循环的复杂度最坏可达O(n)。
2.2 希尔排序
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数,把待排序文件中所有记录分成多个组,所有距离为的记录分在同一组内,并对每一组内的记录进行排序。然后,取,重复上述分组和排序的工作。当到达=1时,所有记录在统一组内排好序。
实际上我们先来看一下希尔排序分组究竟是怎么一回事
具体思路:
- 选择一个增量序列gap,gap初始值为数组长度的一半, gap /= 2不断减小增量。
- 每一轮按gap的值对数组进行分组,然后每组进行插入排序。
- gap不断减小,最后gap = 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;
}
}
时间复杂度的分析
三.选择排序
3.1直接选择排序
先来看一遍动画,来理解选择排序的具体过程是什么样子的.
基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
代码思路:
- 首先找到数组中的最小值,其索引为minIndex。
- 然后将最小值与数组的第一个元素交换位置。
- 重复步骤1和2,对剩余的元素进行选择排序,直到数组有序。
具体代码
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)。
3.2 堆排序
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
先来看一下,堆排序的动画
具体思路:
- 将待排序序列构建成一个大根堆或小根堆。
- 此时,整个序列的最大值或最小值就是堆顶元素。
- 将堆顶元素与末尾元素交换,此时末尾元素为最大值或最小值。
- 然后将剩余n-1个元素重新构建成一个大根堆或小根堆。
- 重复步骤3和4,直到堆变为空,排序完成。
具体代码
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)
在堆排序的代码实现中,主要有两个部分:
- 构建初始堆:需要进行n/2次下滤操作,复杂度为O(n)。
- 堆排序:需要进行n-1次删除最大元素及下滤操作,每个下滤操作的时间复杂度为O(logn),所以总的时间复杂度为O((n-1)logn)=O(nlogn)。
将构建初始堆和堆排序两个部分的复杂度相加,得出堆排序的总时间复杂度为O(nlogn)。
四.交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特
点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
4.1 冒泡排序
具体思路
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个。
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对。这步做完后,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了最后一个。
- 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较。
具体代码:
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)。
4.2 快速排序
基本概念
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有无素都排列在相应位置上为止。
算法步骤
- 选取基准值:从数列中选出一个元素作为基准值。常用的方法是选取第一个元素或者最后一个元素作为基准值。
- 分区:将比基准值小的元素放在基准值的左边,比基准值大的元素放在基准值的右边。分区结束后,基准值所在位置就是其最终位置。
- 递归调用:对基准值左边和右边的数列分别进行递归调用,实现排序。
- 终止条件:当数列的大小为1时,递归结束。
我这里先列出快速排序的基本代码框架
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);
}
看了上面的代码之后,我们现在来分析分基准是怎么样的,下面提供了三种分基准的方法.
选取基准值的方法
方法一:挖坑法
具体步骤:
- 选取第一个元素作为基准值pivot。
- 从下标为1的元素开始遍历,将比pivot小的元素移到左边,比pivot大的元素移到右边,相等的元素暂且不动。
- 遍历完成后,pivot的最终位置就在左边的空隙,此空隙即为“坑”。
- 将pivot放入“坑”中,分区完成。
具体图示
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
具体步骤
- 选取数组的第一个元素和最后一个元素作为左右指针,初始时指针i指向第一个元素,指针j指向最后一个元素。
- i和j指针向中间遍历,将比基准值小的元素换到左边,将比基准值大的元素换到右边。
- 当i和j指针重合时,基准值的正确位置找到,进行交换。
- 递归调用基准值左边和右边的两部分
具体图示
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;
}
方法三:前后指针
具体步骤
- 选取第一个元素作为基准值pivot。
- 定义两个指针prev和cur,prev初始指向第一个元素,cur指向第二个元素。
- cur从左向右遍历,如果遇到比pivot小的元素,prev同时右移,并将该元素交换到prev的位置。
- 重复步骤3,当cur遍历完成后,prev的位置就是pivot的最终位置。
- 交换pivot和prev指向的元素,完成一次分割。
- 递归调用pivot左边和右边的两部分。
具体图示:
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;
}
}
非递归实现快速排序
具体思路:
- 选取第一个元素和最后一个元素作为左右边界,进行一次分割操作。
- 根据分割结果,将左右两边的子区间入栈。
- 从栈中弹出子区间,重复步骤1和2,直到栈为空。
该方法使用栈来代替递归来实现快速排序。它通过不断从栈中弹出子区间,并进行分割和入栈的操作,最终实现整个数组的排序。
具体代码:
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)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。归并排序核心步骤:
递归实现递归排序
具体思路:
- 递归将数组分成左右两半,直到每个子数组只有一个元素。
- 合并左右两半,使之有序。
- 重复步骤1和2,直至整个数组有序。
具体代码:
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)。
非递归实现归并排序
具体思路:
- 定义gap初始为1,表示当前有1组有序数据。
- 遍历数组,每gap个元素进行一次合并,将gap组数据合并成gap/2组有序数据。
- gap乘2,表示现在有gap/2组有序数据。
- 重复步骤2和3,直到gap大于数组长度,说明整个数组有序。
具体代码:
/*
几个坐标之间的关系
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)。
理由如下:
- 每次合并操作的时间复杂度为O(n),因为需要对n/2个元素进行合并。
- 第1次合并操作后,有2组有序数据;第2次有4组;第3次有8组;以此类推。
- 所以,总的合并次数为log2n次。
- 每次合并时间复杂度为O(n),所以总时间复杂度为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)。
六.总结
最后来一个简单的总结,列出一个表格,讲七种排序算法,进行一个对比.