版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/jt102605/article/details/83591390
一.O(n^2)的排序算法
O(n^2)的排序算法
- 基础
- 编码简单,易于实现,是一些简单情景的首选
- 在一些特殊情况下,简单的排序算法更有效
- 简单的排序算法思想衍生出复杂的排序算法
- 作为子过程,改进更复杂的排序算法
1. 选择排序 时间复杂度为O(n^2),空间复杂度O(1)
//选择排序算法,时间复杂度为O(n^2),空间复杂度为O(1).
public int[] selectionSort(int[] arr){
for (int i=0; i<arr.length; i++){
//寻找[i,n)区间里的最小值(合法值)
int currentIndex=i;
for (int j=i+1; j<arr.length; j++){
if(arr[j]<arr[currentIndex]){
currentIndex=j;
}
}
//交换数组中索引为 i , cuurentIndex 的元素
Utils.swap(arr, i, currentIndex);
}
return arr;
}
2.插入排序法, 时间复杂度为O(n^2),空间复杂度O(1)
//插入排序算法, 时间复杂度为O(n^2),空间复杂度为O(1).
public int[] insertionSort(int[] arr){
for(int i=1; i<arr.length; i++){
//寻找元素arr[i]合适的插入位置
for(int j=i; j>0; j--){
if(arr[j]<arr[j-1]){
Utils.swap(arr, j, j-1);
}else{
break;
}
}
}
return arr;
}
2.1 改进的插入排序法,时间复杂度为O(n^2),空间复杂度O(1).
由于元素的交换操作比元素的赋值操作更费时(一次交换需要3次赋值操作),所以优化思路是将比较中的元素交换操作改进为了赋值操作。
//改进的插入排序算法, 时间复杂度为O(n^2),空间复杂度为O(1).
//将频繁的交换元素操作改为了赋值操作,提高了效率
public int[] insertionSort(int[] arr){
for (int i=1; i<arr.length; i++){
//寻找元素arr[i]合适的插入位置
int e = arr[i];
int j; //j保存元素e应该插入的位置, 生存周期在for循环外
//for循环写法1
// for(j=i; j>0; j--){
// if(e<arr[j-1]){
// arr[j]=arr[j-1];
// } else{
// break; //此处的break不可省略,不然for循环退出时,j=1.
// }
// }
//上述for循环更优雅的写法 条件 e<arr[j-1] 不满足时,无法进入循环
for(j=i; j>0 && e<arr[j-1]; j--){
arr[j]=arr[j-1];
}
arr[j]=e;
}
return arr;
}
- 相比于选择排序法,改进的插入排序法的内层循环在元素找到正确位置后就可以直接跳出,所以时间上要更快一些
- 插入排序法虽然是一个O(n^2)的排序算法,但它有自己的特点,在近乎有序的数组中排序时,它几乎可以看作是O(n)级别的,比O(nlogn)的算法还要有效,所以具有实际意义的
- 希尔排序法就是插入排序法的一种延伸,希尔排序法的时间复杂度在O(n^2)与O(nlogn)之间,但相比于O(nlogn)的算法来说,实现较为简单,具有一定的实际意义
2.2 希尔排序
排序思想:
- 希尔排序是对插入排序的优化,基于以下两个认识:1. 数据量较小时插入排序速度较快,因为n和n^2差距很小;2. 数据基本有序时插入排序效率很高,因为比较和移动的数据量少。
- 希尔排序的基本思想是把需要排序的序列按下标的一定增量分组,对每组使用直接插入排序算法排序;随着增量逐渐减少,每组包含的数据越来越多,当增量减至1时,整个数组恰被分成一组,算法便终止。这样通过对较小的序列进行插入排序,然后对基本有序的数列进行插入排序,能够提高插入排序算法的效率。
- 简单插入排序很循规蹈矩,不管数组分布是怎么样的,依然一步一步的对元素进行比较,移动,插入,比如[5,4,3,2,1,0]这种倒序序列,数组末端的0要回到首位置很是费劲,比较和移动元素均需n-1次。而希尔排序在数组中采用跳跃式分组的策略,通过某个增量将数组元素划分为若干组,然后分组进行插入排序,随后逐步缩小增量,继续按组进行插入排序操作,直至增量为1。希尔排序通过这种策略使得整个数组在初始阶段达到从宏观上看基本有序,小的基本在前,大的基本在后。然后缩小增量,到增量为1时,其实多数情况下只需微调即可,不会涉及过多的数据移动。
实现方法:
- 希尔排序的基本步骤,首先,选择增量gap=length/2,缩小增量的方式为:gap = gap/2,这种增量选择我们可以用一个序列来表示,{n/2,(n/2)/2...1},称为增量序列。希尔排序的增量序列的选择与证明是个数学难题,我们选择的这个增量序列是比较常用的,也是希尔建议的增量,称为希尔增量,但其实这个增量序列不是最优的。
- 在希尔排序的理解时,我们倾向于对于每一个分组,逐组进行处理,但在代码实现中,我们可以不用这么按部就班地处理完一组再调转回来处理下一组(这样还得加个for循环去处理分组)比如[5,4,3,2,1,0] ,首次增量设gap=length/2=3,则为3组[5,2] [4,1] [3,0],实现时不用循环按组处理,我们可以从第gap个元素开始,逐个跨组处理。同时,在插入数据时,可以采用元素交换法寻找最终位置,也可以采用数组元素移动法寻觅。
- 希尔排序的时间复杂度和增量的选择策略有关,上述增量方法造成希尔排序的不稳定性。
特点:
- Shell Sort虽然慢于高级的排序方式, 但仍然是非常有竞争力的一种排序算法 ,其所花费的时间完全在可以容忍的范围内, 远不像O(n^2)的排序算法, 在数据量较大的时候无法忍受 。
- 同时, Shell Sort实现简单, 只使用循环的方式解决排序问题, 不需要实现递归, 不占用系统占空间, 也不依赖随机数 。
- 如果算法实现所使用的环境不利于实现复杂的排序算法, 或者在项目工程的测试阶段, 完全可以暂时使用Shell Sort来进行排序任务
参考: 图解排序算法(二)之希尔排序
1>. 增量序列为 {n/2, (n/2)/2,...1} 的希尔排序
//增量序列为 {n/2, (n/2)/2,...1} 的希尔排序
public int[] shellSort(int[] arr){
//第一重循环控制增量的变化
for(int gap = arr.length/2; gap>=1; gap/=2){
//对每个小组进行插入排序,排序是跨组处理的
//arr[i], arr[i-gap], arr[i-2*gap]...属于一组
for (int i=gap; i<arr.length; i++){
int j;
int e=arr[i];
for(j=i; j>=gap && e<arr[j-gap]; j-=gap){ //j>=gap,最终保证j-gap在[0,gap)内
arr[j]=arr[j-gap];
}
arr[j]=e;
}
}
return arr;
}
2>. 根据数据量大小不同自动调整增量序列为 {1, 4, 13, 40, 121, 364, 1093...} 的希尔排序
//增量序列为 {1, 4, 13, 40, 121, 364, 1093...} 的希尔排序
public int[] shellSortII(int[] arr){
//计算增量序列,根据数据量大小自动初始化
int gap = 1;
while (gap<arr.length/3){
gap=3*gap+1;
}
//while循环控制增量的变化
while(gap>=1){
//对每个小组进行插入排序,排序是跨组处理的
//arr[i], arr[i-gap], arr[i-2*gap]...属于一组
for(int i=gap; i<arr.length; i++){
int j;
int e=arr[i];
for(j=i; j>=gap && e<arr[j-gap]; j-=gap){
arr[j]=arr[j-gap];
}
arr[j]=e;
}
//while循环退出条件控制
gap/=3;
}
return arr;
}
3.冒泡排序, 时间复杂度为O(n^2),空间复杂度O(1).
//基本的冒泡排序,对n个数据冒泡,则需要经过n-1轮比较,每一轮比较都将最大的元素放到末端
public int[] bubbleSort(int[] arr){
for (int i=arr.length-1; i>0; i--){ //i从 arr.length-1 ~ 1, 一共arr.length-1次
for(int j=0; j<i; j++){
if(arr[j]>arr[j+1]){
Utils.swap(arr, j, j+1);
}
}
}
return arr;
}
3.1 优化的冒泡排序, 时间复杂度为O(n^2),空间复杂度O(1).
冒泡排序的优化思路:
- 如果在某一趟的冒泡途中没有出现数据交换,那就只能是数据已经被排好序了,这样就可以提前得知数据排好序从而中断循环,消除掉不必要的比较
- 如果在某一趟的冒泡途中最后的交换出现在pos的位置,那么表示pos位置以后都已经排好序,这样相比于基本冒泡每一次缩小遍历范围1而言有可能一次缩小的遍历范围>=1,所以这样也可以提高排序的效率
//优化冒泡排序
public int[] bubbleSortII(int[] arr){
int lastswap=0; //记录最后一次发生交换的位置,下轮交换直接跳到此位置
for(int i=arr.length-1; i>0; i=lastswap){
lastswap=0; //在每轮冒泡比较前,将lastswap置0,若冒泡比较时由元素的交换,则重新记录lastswap的值,
// 若没有元素的交换,则说明排序已完成,此时lastswap保持为0,可以直接跳出循环,提前结束排序过程
for(int j=0; j<i; j++){
if(arr[j]>arr[j+1]){
Utils.swap(arr, j, j+1);
lastswap=i;
}
}
}
return arr;
}
二. O(nlogn)的排序算法
1.归并排序,递归实现,时间复杂度为O(nlogn),空间复杂度O(n)
//归并排序, 时间复杂度为O(nlogn), 空间复杂度为O(n).
public void mergeSort(int[] arr, int l, int r){
//递归退出条件
if(l>=r){
return;
}
int middle=(r-l)/2+l;
mergeSort(arr, l, middle);
mergeSort(arr, middle+1, r);
merge(arr,l, middle, r);
}
//归并过程,即对arr[l, middle], arr[middle+1, r]两个分别有序的部分做有序归并
private void merge(int[] arr, int l,int middle, int r){
//辅助空间
int[] temp = new int[r-l+1];
for(int i=l; i<=r; i++){
temp[i-l]=arr[i]; //i-l是为了保证temp的下标起始为0
}
//将temp中的数据重新归并回arr
int k=l;
int m=l, n=middle+1;
for(k=l; k<=r; k++){
if(m>middle){
arr[k]=temp[n-l];
n++;
}else if(n>r){
arr[k]=temp[m-l];
m++;
}else{
if(temp[m-l]>temp[n-l]){
arr[k]=temp[n-l];
n++;
}else{
arr[k]=temp[m-l];
m++;
}
}
}
}
归并排序的优化思路:
- 由于merge是对两个有序的部分进行合并,所以,在进行merge之前, 可以先进行一下判断,如果arr[middle]<arr[middle+1],则不需要merge过程,这样在数据量较大时,可以提高效率。(注,if判断语句也会耗费时间)
- 在数据量很小时(~15), 可以使用插入排序来代替后续的归并排序。
1.2 自底向上的递归排序,迭代实现
//自下向上的归并排序,通过迭代实现
public void mergeSortII(int[] arr){
//size表示每次归并的长度,即对多少个元素归并
for (int size=1; size<arr.length; size+=size){
//对arr[i, i+size-1], arr[i+size, i+size+size-1]进行归并
for (int i=0; i+size<arr.length; i+=size+size){ // 注意边界限制,防止越界
merge(arr, i, i+size-1, Math.min(i+size+size-1, arr.length-1));
}
}
}
private void merge(int[] arr, int l, int middle, int r){
int[] temp = new int[r-l+1];
for (int i=l; i<=r; i++){
temp[i-l]=arr[i];
}
int k=l;
int m=l, n=middle+1;
for(k=l; k<=r; k++){
if(m>middle){
arr[k]=temp[n-l];
n++;
}else if(n>r){
arr[k]=temp[m-l];
m++;
}else{
if(temp[m-l]>temp[n-l]){
arr[k]=temp[n-l];
n++;
}else{
arr[k]=temp[m-l];
m++;
}
}
}
}
迭代实现的自底向上的归并排序特点:
不需要通过数组索引获取数组元素,可以使用O(nlogn)的时间对链表这样的数据结构进行排序,即对链表进行时间复杂度为O(nlogn)的排序,可以用迭代实现的自底向上的归并排序。
2.快速排序
//基础版快速排序
public void quickSort(int[] arr,int l, int r){
//递归退出条件
if(l>=r){
return;
}
int p = partition(arr, l, r);
quickSort(arr, l, p-1);
quickSort(arr, p+1, r);
}
//对arr[l,r]部分进行partition操作
//返回p,使得arr[l,p-1]<arr[p],arr[p+1,r]>=arr[p]
private int partition(int[] arr, int l, int r){
//标定点选择第一个元素,即arr[l]
int v=arr[l];
//i,j指针,使得arr[l+1,j]<v, arr[j+1,i)>=v
int j=l;
for(int i=l+1; i<=r; i++){
if(arr[i]<v){
j++;
Utils.swap(arr,j,i);
}
}
Utils.swap(arr, l, j);
return j;
}
基础版单路排序的缺陷:(递归树的不平衡导致递归深度的加深进而导致递归过程时间复杂度的恶化)
- 近乎有序的数组:基础版的快速排序在对近乎有序的数组排序时,速度很慢,在对完全有序的数据排序时,达到了最差情况O(n^2),远落后于递归排序。 原因是在对近乎有序的数据排序时,总是将第一个元素作为标定点,会导致快速排序生成的递归树是极其不平衡的,递归深度远不止logn,即递归的时间复杂度远不止logn级别,最差情况 (完全有序的数据) 达到n的级别,降低排序速率。相反,归并排序却可以严格的保持递归树的平衡,递归的时间复杂度仍为logn级别。
- 具有大量重复数据的数组:在对具有大量重复数据数组排序时,单路的快排总是会将重复数据都划分到标定点的一侧,导致递归树的不平衡,降低排序速度。
2.1 改进的双路快排
改进点:
- 标定点通过随机选取,而不是一味取第一个元素,这样可以平衡有序数组的递归树,使其递归深度的期望为logn,从而提升对近乎有序数组的排序的速度。
- 通过双指针从两端同时遍历的双路排序,可以提升具有大量重复数据的数组的排序速度。因为单路快排中会将所有重复数据都划分到标定点一侧,这样会导致递归树的不平衡,而双路快排从两侧进行划分,可以避免以上问题,从而提升对具有大量重复数据数组的排序速度。
//改进的快速排序
public void quickSortII(int[] arr, int l, int r){
//递归退出条件
if(l>=r){
return;
}
int p = positition(arr, l, r);
quickSortII(arr, l, p-1);
quickSortII(arr, p+1, r);
}
private int positition(int[] arr, int l, int r){
//改进1 随机选取标定点
int index = (int)(Math.random()*(r-l+1)+l); // index为[l, r]之间的一个随机数
Utils.swap(arr, l, index);
//改进2 双路快排
int i=l+1, j=r; //i为l+1, 而不是l
int v=arr[l];
//arr[l+1, i)<=v, arr(j, r]>=v
while (true){ //while判断为真, 可以保证即使只有两个元素时,即i=l+1=j 时,也可以进入循环
while (i<=r && arr[i]<v){ //i停下的位置一定是大于或等于v的位置
i++;
}
while (j>=l+1 && arr[j]>v){ //j停下的位置一定是小于或等于v的位置
j--;
}
if(i>j){ //只能取大于
break;
}
Utils.swap(arr, i, j);
i++; //防止交换操作后,arr[i]=v(或arr[j]=v) 而导致i(或j)指针无法移动,陷入死循环
j--;
}
Utils.swap(arr, l, j); // 最终,j落在最后一个小于等于v的位置, i落在第一个一个大于等于v的位置,最终i不等于j,要看l处需要的是大于还是小于v的值。
return j;
}
2.2 为处理大量重复数据而生的三路快排
//三路快排,用于优化数组中有大量重复数据的情况
//由于三路快排的每次position操作需要返回两个索引,这里的positition操作直接写在了quickSortIII中
//三路快排中,将arr[l, r]分为 <v, ==v, >v 三部分,之后递归对 <v, >v 的部分继续进行三路快排,省去了对 ==v部分的操作,特别适合于存在大量重复数据数组的排序
public void quickSortIII(int[] arr, int l, int r){
//递归退出条件
if(l>=r){
return;
}
//positition操作 在v交换前使得:arr[l+1, m]<v, arr[m+1, n-1]==v, arr[n, r]>v
int index=(int)(Math.random()*(r-l+1)+l);
Utils.swap(arr, l ,index);
int v=arr[l];
int m=l, n=r+1;
for(int i=l+1; i<n; ){
if(arr[i]==v){
i++;
}else if(arr[i]<v){
m++;
Utils.swap(arr, i, m);
i++;
}else{
n--;
Utils.swap(arr, i, n);
}
}
Utils.swap(arr,l,m); //交换后 arr[l,m-1]<v, arr[m, n-1]=v, arr[n, r]>v
//递归
quickSortIII(arr, l, m-1); //注意,这里的r=m-1, 因为最后一步的v与arr[m]进行了交换,此时 arr[l,m-1]<v, arr[m, n-1]=v, arr[n, r]>v
quickSortIII(arr, n, r);
}
归并排序与快速排序的延伸:
- 分治思想:顾名思义,分而治之,就是将原问题分割成同等结构的子问题,之后将子问题逐一解决后,原问题也就得到了解决。
- 归并排序与快速排序都使用了分治思想,归并排序中“分”的操作很简单,其主要侧重于“分”后的归并处理,而快速排序则侧重于如何进行“分”的操作
三.排序思想的应用
1. 求数组的逆序对
方式1.暴力求解,双重for循环实现,时间复杂度度为O(n^2).
方式2.利用归并排序思想,归并过程中是对两个有序的部分做处理,可以不用一组一组的作比较。从而降低时间复杂度为O(nlogn).
// 对于一个大小为N的数组, 其最大的逆序数对个数为 N*(N-1)/2, 非常容易产生整型溢出
//计算数组中逆序对的个数
public int reverseOrders(int[] arr, int l, int r){
//递归退出条件
if(l>=r){
return 0;
}
int mid = (r-l)/2+l;
int countl = reverseOrders(arr, l, mid); // 求出 arr[l...mid] 范围的逆序数
int countr = reverseOrders(arr, mid+1, r); // 求出 arr[mid+1...r] 范围的逆序数
return countl+countr+merge(arr, l, mid, r);
}
// merge函数求出在arr[l...mid]和arr[mid+1...r]有序的基础上, arr[l...r]的逆序数对个数
private int merge(int[] arr, int l, int mid, int r){
//辅助数组
int[] temp = new int[r-l+1];
for(int i = l; i<=r; i++){
temp[i-l]=arr[i];
}
int count=0; //记录逆序对个数 初始化为0.
int m=l, n=mid+1;
for(int k=l; k<=r; k++){
if(m>mid){
arr[k]=temp[n-l];
n++;
}else if (n>r){
arr[k]=temp[m-l];
m++;
}else {
if(temp[m-l]<=temp[n-l]){
arr[k] = temp[m-l];
m++;
}else{
// 此时, 因为右半部分k所指的元素小
// 这个元素和左半部分的所有未处理的元素都构成了逆序数对
// 左半部分此时未处理的元素个数为 mid - m + 1
arr[k] = temp[n-l];
n++;
count+=mid-m+1;
}
}
}
return count;
}
2.求数组中第k大的值 (leetcode 215)
方式1. 将数组进行一次完全的排序,在从中找出第k大的值,排序需要的时间复杂度为O(nlogn),也是总的时间复杂度。
方式2.利用快排的思想,可以降低时间复杂度为O(n).