排序是数据结构中比较重要的一个算法,也是笔试面试中常考的算法题,本篇文章介绍内部排序,也就是数据全在内存中处理的排序。
主要有插入排序(直接插入排序/希尔排序),选择排序(直接选择排序/堆排序),交换排序(冒泡排序/快速排序),归并排序等。
1、插入排序
1.1 直接插入排序
1.1.1 基本思想
直接插入排序是一种简单的插入排序算法,就像我们平时玩的扑克牌,拿第一张牌的时候,已经是有序的,后面拿到的每一张牌,要和前面已经有序的牌进行比较,在有序部分找一个合适的位置,把拿到的牌放进去。
那么什么就是合适的位置呢?我们可以进行遍历查找,从后往前用拿到的牌和有序部分进行比较,比它大就放右边,比它小就放左边。
上图中蓝色部分表示已经有序,橙色部分代表要即将要进行比较的数据。
第一次拿9去比较,此时数组中还没有数据,所以直接将9放入蓝色部分,代表已经有序。
第二次用3去比较,因为a[i]<a[j],所以 j - -,j+1的位置就是要插入的位置。
第三次用1比较,同样的道理,有序部分一直增加:
第四次用5去比较,如果a[i]>a[j],就代表找到了合适的位置,j+1的位置依然是要插入的位置,后面的数据以此类推。
如果遇到a[i] == a[j],为了保证稳定性,我们也认为找到了合适的位置,j不用再往前移。
那么如何把橙色的数插入到合适的下标处呢,其实这就相当于一个顺序表,在给定位置pos处做插入。
1.1.2 算法实现
public static void insertSort(int[] array) {
for (int i = 0; i < array.length; i++) {
//有序区间[0,i)
//无序区间[i,array.length)
//1、在有序区间遍历查找,从后往前
int j;
for (j = i - 1; j >= 0 && array[i] < array[j]; j--) {
}
//j+1 就是要插入数据的下标
//2、插入数据,从后往前搬移数据
int pos = j + 1;
int key = array[i];
for (int k = i; k > pos; k--) {
array[k] = array[k - 1];
}
array[pos] = key;
}
}
可以采用边查找边搬移数据对代码进行简化。
public static void insertSort2(int[] array){
for (int i = 0;i<array.length;i++){
int key = array[i];
int j = i-1;
//边查找边搬移数据
for (;j>=0 && key < array[j+1];j--){
array[j+1] = array[j];
}
array[j+1] = key;
}
}
上面的是遍历查找的方法,下面这段代码是采用二分查找的方法,搬移数据的过程都是一样的。
public static void insertSort3(int[] array) {
for (int i = 0; i < array.length; i++) {
int key = array[i];
//有序[0,i)
int left = 0;
int right = i;
//二分查找
while (left < right) {
int mid = left + (right - left) / 2;
if (key == array[mid]) {
left = mid + 1;
} else if (key < array[mid]) {
right = mid;
} else {
left = mid + 1;
}
}
//搬数据
int pos = left;
for (int k=i;k>pos;k--){
array[k] = array[k-1];
}
array[pos] = key;
}
}
1.1.3 算法分析
- 时间复杂度:
最好:O(n)
最坏:O(n^2)
平均:O(n^2) - 空间复杂度:O(1)
- 稳定性:稳定
1.2 希尔排序
1.2.1 基本思想
希尔排序实际上是对直接插入排序的优化。首先我们对一组数据进行预排序,目的是很快的让数据基本有序,然后再分组进行插排,这样的话速度很快。
那么会有一个问题,分组到底多大才合适呢?分组分的越多,数据往后走的步伐越大,而分组分的越少,排完后越接近有序,这样看来各有优点,所以我们可以做多次分组插排,分组的个数从大到小,整个过程都是不断进行预排和插排。
1.2.2 算法实现
private static void insertSortWithGap(int[] array, int gap) {
for (int i = 0; i < array.length; i++) {
int key = array[i];
int j = i - gap;
for (; j >= 0 && key < array[j + 1]; j = j - gap) {
array[j + gap] = array[j];
}
array[j + gap] = key;
}
}
public static void shellSort(int[] array) {
int gap = array.length;
while (true) {
//gap = gap/2
gap = (gap / 3) + 1;
insertSortWithGap(array,gap);
if (gap==1){
break;
}
}
}
1.2.3 算法分析
- 时间复杂度
最好:O(n)
最坏:O(n^2) 概率变小
平均:O(n^1.2-1.3) - 空间复杂度:O(1)
- 稳定性:不稳定(很难保证相同的数据分到一个组里)
2、选择排序
2.1 直接选择排序
2.1.1 基本思想
遍历数组,每次找出最小的一个数放在放在无序部分的最开始位置,然后对剩下的数再进行遍历。需要注意的是,遍历的过程中不需要交换元素,一次遍历结束找到最小值,将最小值进行交换。有序部分的下标是[0,i),无序部分的下标是[i,length)。
2.1.2 算法实现
public static void selectSort(int[] array) {
//每次选择一个最小的放到无序部分的最开始位置
for (int i = 0; i < array.length; i++) {
//有序[0,i)
//无序[i,array.length)
int min = i; //记录最终最小数所在的下标
for (int j = i + 1; j < array.length; j++) {
if (array[j] < array[min]) {
min = j;
}
}
//最终交换一次
swap(array, min, i);
}
}
2.1.3 算法分析
- 时间复杂度
最好:O(n^2)
最坏:O(n^2)
平均:O(n^2) - 空间复杂度:O(1)
- 稳定性:不稳定
2.2 堆排序
2.2.1 基本思想
堆排序首先需要将无序数组里的元素构建成一个大堆,然后将堆顶元素与末尾元素进行交换,这样一来最大的元素就到了数组末端。重新调整结构,使它满足堆的定义,继续交换堆顶元素和当前末尾元素(当前末尾元素是除刚刚已经交换过的最大元素之外的最后一个元素),反复执行调整和交换的步骤,直到整个数组有序。具体来说就是:
建大堆:
从最后一个非叶子节点开始,从左至右,从下至上进行调整。
找到第二个非叶子节点4,再进行调整,使其满足大堆的性质。
这时无序序列已经构成了一个大堆,将堆顶元素与末尾元素进行交换,使末尾元素最大。
然后继续调整堆,再将堆顶元素与新的末尾元素交换,得到第二大元素,如此反复交换,调整。
最终得到的有序序列为:
2.2.2 算法实现
private static void heapify(int[] array, int size, int index) {
//1、判断index是不是叶子
while (2 * index + 1 < size) {
//2、找到最大的孩子的下标
int max = 2 * index + 1;
if (max + 1 < size && array[max + 1] > array[max]) {
max = 2 * index + 2;
}
//3、判断最大的孩子和根的值
if (array[index] < array[max]) {
swap(array, index, max);
index = max;
} else {
//4、根的值比较大,可以直接结束了
//不交换,也不继续往下走了
break;
}
}
}
private static void createHeap(int[] array) {
//[最后一个非叶子节点的下标,根] 向下调整
//[(array.length-2)/2,0]
for (int i = (array.length - 2) / 2; i >= 0; i--) {
heapify(array, array.length, i);
}
}
public static void heapSort(int[] array) {
//建堆 建大堆
createHeap(array);
//减治处理
for (int i = 0; i < array.length; i++) {
//无序[0,length-i-1)
//有序[length-i,length)
//最大的数在[0],最大的数应该放到的下标是[length-1-i]
//交换最大的数到无序部分最后的位置
swap(array, 0, array.length - 1 - i);
//处理[0],无序剩余部分满足堆的性质
//无序[0,lenght-i-2)
//有序[lenght-i-1,length)
//size 剩余无序部分的长度
//无序部分,除了[0],都满足堆的性质
heapify(array, array.length - 1 - i, 0);
}
}
2.2.3 算法分析
- 时间复杂度
最好:O(nlog2n)
最坏: O(nlog2n)
平均:O(nlog2n) - 空间复杂度:O(1)
- 稳定性:不稳定
3、交换排序
3.1 冒泡排序
3.1.1 基本思想
冒泡排序是最基础的排序,每次对相邻的两个元素进行操作,升序如果后一个数比前一个数小,就要进行交换。对代码进行优化可以加一个标志位isSorted=true
,如果一趟冒泡结束,isSorted仍为true,说明数组已经有序,就可以不用再进行比较了。
3.1.2 算法实现
public static void bubbleSort(int[] array){
for (int i=0;i<array.length;i++){
boolean isSorted = true;
for (int j=0;j<array.length-1-i;j++){
if (array[j]>array[j+1]){
swap(array,j,j+1);
isSorted = false;
}
}
if (isSorted){
break;
}
}
}
3.1.3 算法分析
- 时间复杂度
最好:O(n)
最坏:O(n^2)
平均:O(n^2) - 空间复杂度:O(1)
- 稳定性:稳定
3.2 快速排序
3.2.1 基本思想
快速排序分三步进行。
1、首先选择一个基准值,这里我们选择区间最右边的数作为基准值。
2、遍历整个区间,每个数都和基准值作比较,并且发生一定交换,遍历结束后,使得比基准值小的数(包括等于)都在基准值的左边,比基准值大的数(包括等于)都在基准值的右边。
3、采用分治算法,分别对左右两个小区间进行同样方式的处理,直到小区间的size == 0或者size == 1就代表已经有序了。
那么如何让小的都在基准值的左边,大的都在基准值右边呢?有三种方法。
1)Hover法
2)挖坑法
3)前后下标法
Hover法就是定义下图中这样的四个引用,begin引用一直往右走,end引用一直往左走,过程中保证begin左边的值始终比基准值小,end右边的的值始终比基准值大,如果不符合就交换begin和end的值后继续,直到begin引用和end引用相遇,这样就把区间分成了三段,最后再将基准值和begin的位置对应的值进行交换,就把基准值放到了中间:
挖坑法和Hover法的思路基本一样,不过挖坑法不用交换begin和end,它是提前将pivot位置对应的值保存下来,然后将pivot位置变成一个坑,begin开始往右走,遇到比pivot值大的停下来,放到坑的那个位置,然后end开始开始往左走,遇到比pivot值小的停下来,放到新的坑的位置,接着begin再往右走,一直循环,直到begin和end相等,把pivot放到坑的位置。
以下图为例:
先把5的位置变为坑,begin往右走,9比5大,把9放到坑的位置,原来9的位置变成新的坑:
然后end往左走,3比5小,把3放到坑的位置,原来3的位置变成新的坑:
接着begin继续往右走,走到8的位置比pivot值大,把8放到坑的位置,原来8的位置变成新的坑:
一直重复,直到begin和end相遇,把pivot的值放入坑的位置:
前后下标法是定义两个引用 i 和 j ,遍历整个数组,如果 i 对应的值大于等于pivot的值, i 往右走 j 不走,如果 i 对应的值小于 pivot的值,交换 i 和 j 对应的值,然后 i 和 j 都往右走一步,保证 j 左边的值都比pivot值小, j 和 i 之间的值都比pivot值大,一直重复上述操作,直到遍历结束。
以下图为例:
6大于基准值5,i 往右走 d 不走:
3小于基准值5,交换3和6后 i 和 j 都往右走一步:
以此类推,直到遍历结束:
3.2.2 算法实现
Hover法:
private static int parition1(int[] array,int left,int right){
int begin = left;
int end = right;
int pivot = array[right];
while (begin<end){
while (begin<end && array[begin]<=pivot){
begin++;
}
//array[begin]>pivot
while (begin<end && array[end]>=pivot){
end--;
}
//array[end]<pivot
swap(array,begin,end);
}
//注意不要交换pivot的位置,pivot只是一个局部变量
swap(array,begin,right);
//返回基准值的位置
return begin;
}
private static void quickSortInner(int[] array,int left,int right){
//直到size==1 || size==0
if (left==right){
//size==1
return;
}
if (left>right){
//size==0
return;
}
//要排序的区间是array[left,right]
//1、找基准值,array[right]
//2、遍历整个区间,把区间分成三部分
int pivotIndex = parition1(array,left,right);
//比基准值小的[left,pivotIndex-1]
//比基准值大的[pivotIndex+1,right]
//3、分治算法
//处理比基准值小的区间
quickSortInner(array,left,pivotIndex-1);
//处理比基准值大的区间
quickSortInner(array,pivotIndex+1,right);
}
public static void quickSort(int[] array){
quickSortInner(array,0,array.length-1);
}
挖坑法:
private static int parition2(int[] array,int left,int right){
int begin = left;
int end = right;
int pivot = array[right];
while (begin<end){
while (begin<end && array[begin]<=pivot){
begin++;
}
array[end] = array[begin];
while (begin<end && array[end]>=pivot){
end--;
}
array[begin] = array[end];
}
array[begin] = pivot;
//返回基准值的位置
return begin;
}
private static void quickSortInner(int[] array,int left,int right){
//直到size==1 || size==0
if (left==right){
//size==1
return;
}
if (left>right){
//size==0
return;
}
//要排序的区间是array[left,right]
//1、找基准值,array[right]
//2、遍历整个区间,把区间分成三部分
int pivotIndex = parition1(array,left,right);
//比基准值小的[left,pivotIndex-1]
//比基准值大的[pivotIndex+1,right]
//3、分治算法
//处理比基准值小的区间
quickSortInner(array,left,pivotIndex-1);
//处理比基准值大的区间
quickSortInner(array,pivotIndex+1,right);
}
public static void quickSort(int[] array){
quickSortInner(array,0,array.length-1);
}
前后下标法:
private static int parition3(int[] array,int left,int right){
int j = left;
for (int i=left;i<right;i++){
if (array[i] < array[right]){
swap(array,j,i);
j++;
}
}
swap(array,j,right);
return j;
}
private static void quickSortInner(int[] array,int left,int right){
//直到size==1 || size==0
if (left==right){
//size==1
return;
}
if (left>right){
//size==0
return;
}
//要排序的区间是array[left,right]
//1、找基准值,array[right]
//2、遍历整个区间,把区间分成三部分
int pivotIndex = parition1(array,left,right);
//比基准值小的[left,pivotIndex-1]
//比基准值大的[pivotIndex+1,right]
//3、分治算法
//处理比基准值小的区间
quickSortInner(array,left,pivotIndex-1);
//处理比基准值大的区间
quickSortInner(array,pivotIndex+1,right);
}
public static void quickSort(int[] array){
quickSortInner(array,0,array.length-1);
}
3.2.3 算法分析
- 时间复杂度
最好:O(nlog(n))
最坏:O(n^2) 选最边上作为基准值时,当数组已经有序或者数组逆序,都是最坏的情况
平均:O(nlog(n)) - 空间复杂度:
最好:O(log n)
最坏:O(n)
平均:O(log n) - 稳定性:不稳定
补充:
如何避免最坏的情况,也就是单支树的情况呢,有两种方法可以解决:
1)随机法
顾名思义就是在数组中随机选取一个数作为基准值(random.nextInt())
2)三数取中法
在数组中选取最左边,最中间和最右边的三个数,比较大小,找到三个数中中间的数,比如说9 8 7 6 5 ,选取9 7 5,发现7是中间的数,那么7就是基准值。
利用上面两种方法选出来的基准值不在最边上,我们可以先把基准值交换到最边上再按之前的方法进行排序。
4、归并排序
4.1.1 基本思想
归并排序和快速排序唯一的相同点是都采用了分治算法的思想,其他的完全不一样。归并排序首先把要排序的区间平均分割成两部分,然后采用分治算法,对左右两个区间进行同样方式的排序,直到size == 1,表示区间已经有序,size == 0,表示区间没有数了(实际不会走到),最后再合并左右两个区间到一个有序区间,这一步需要开辟额外的空间。
以下图为例:
递归的过程如下:
橙色部分表示已经有序的区间,剩下的递归过程以此类推,递归结束就可以得到一个有序数组。
4.1.2 算法实现
private static void merge(int[] array, int low, int mid, int high) {
int[] extra = new int[high - low];
int i = low; //遍历array[low,mid)
int j = mid; //遍历array[mid,high)
int x = 0; //遍历extra
while (i<mid && j<high){
if (array[i] <= array[j]){
extra[x++] = array[i++];
}else {
extra[x++] = array[j++];
}
}
while (i<mid){
extra[x++] = array[i++];
}
while (j<high){
extra[x++] = array[j++];
}
//把数据从额外的空间搬回原来的空间,k-low是因为额外空间下标是从0开始,原空间下标从low开始
for (int k=low;k<high;k++){
array[k] = extra[k-low];
}
}
private static void mergeSortInner(int[] array, int low, int high) {
//array[low,high)
//[3,4)
if (low == high - 1) {
return;
}
if (low >= high) {
return;
}
//1、平均切分
int mid = low + (high - low) / 2;
//[low,mid)+[mid,high)
//2、分治算法处理所有两个小区间
mergeSortInner(array, low, mid);
mergeSortInner(array, mid, high);
//左右两个小区间已经有序了
merge(array, low, mid, high);
}
public static void mergeSort(int[] array) {
mergeSortInner(array, 0, array.length);
}
对代码进行优化:
由于归并的时候每次都要开辟额外的空间,所以我们可以提前申请一个最大的额外空间,每次归并的时候带着这个额外空间,这样可以避免造成大量的内存碎片。
public static void mergeSort(int[] array) {
int[] extra = new int[array.length];
mergeSortInner(array, 0, array.length, extra);
}
补充:归并排序非递归版
归并排序递归版一开始的目的是拆成一个数一个数进行归并,那么既然本来就要拆成一个数一个数归并,那么我们可以不进行递归,直接一个数一个数进行归并,以下图为例:
接下来3,9和2,5一组进行归并,4,1和7一组进行归并:
然后2,3,5,9和1,4,7一组进行归并:
具体实现代码如下:
//归并排序(非递归)
public static void mergeSortNoR(int[] array){
int[] extra = new int[array.length];
//外层循环控制循环的次数,内层循环控制分组
for (int i=1;i<array.length;i*=2){
for (int j=0;j<array.length;j+=2*i){
int low = j;
int mid = low +i;
if (mid>=array.length){
continue;
}
int high = mid + i;
if (high>array.length){
high=array.length;
}
merge(array,low,mid,high,extra);
}
}
}
4.1.3 算法分析
- 时间复杂度:O(n*log(n))
- 空间复杂度:O(1)
- 稳定性:稳定