Java 排序算法
排序算法
排序的分类
按主要操作分类
- 比较排序:用比较的方法
- 插入排序:直接插入排序、折半插入排序、希尔排序
- 交换排序:冒泡排序、快速排序
- 选择排序:简单选择排序、堆排序
- 归并排序:2-路归并排序
- 基数排序:不比较元素大小,仅仅根据元素本身的取值确定其有序位置
按辅助空间分类
- 原地排序:辅助空间用量为O(1)的排序方法
- 非原地排序:辅助空间用量超过O(1)的排序方法
按稳定性分类
当对多关键字的记录序列进行LSD方法排序时,需要采用稳定的排序方法
- 稳定排序:数值相等的元素,排序后相对次序不变
- 非稳定性排序
按自然性分类
- 自然排序:输入数据越有序,排序速度越快的排序方法
- 非自然排序
排序的性能
类别 | 排序方法 | 说明 |
---|---|---|
插入排序 | 直接插入排序 | 适合原本就有序(顺序)和短数组 |
交换排序 | 冒泡排序 | 适合本身就有序的(正序) |
快速排序 | 不适合本身有序的数组,如果本身就有序就会退化成冒泡排序 | |
选择排序 | 简单选择排序 | 与初始状态无关 |
堆排序 | 与初始状态无关,适合元素个数比较多 | |
归并排序 | 与初始状态无关 |
交换排序
基本思想:两两比较,如果发生逆序则交换,直到所有记录都排好序位置
冒泡排序
基于简单交换思想
基本思想:每趟不断将记录两两比较,并按"前小后大"规则交换。
每一躺确定一个最大的元素,所以n个元素需要n-1躺
每一趟比较的次数 = n - 趟数。
class Solution {
public int[] sortArray(int[] nums) {
int n = nums.length;
int tmp;
for(int m=1;m<=n-1;m++){ //m表示趟数
boolean flag = true;
for(int i=0;i<n-m;i++){ //i表示比较次数
if(nums[i]>nums[i+1]){ //如果发生逆序交换
tmp = nums[i+1];
nums[i+1] = nums[i];
nums[i] = tmp;
flag = false;
}
if(flag) return nums; //优化:如果某一趟没有元素交换,说明已经排好序了。
}
}
return nums;
}
}
优点
每趟结束时,不仅能让最大值找到位置,还能同时部分理顺其他元素。
优化
如果某一趟比较时不出现记录交换,说明已经排好序了,可以提前计数了
算法分析
最好情况(正序)
比较次数:n-1
移动次数:0
最坏情况(逆序)
比较次数:1+2+...n-1
移动次数:3(比较次数)
最好时间复杂度:O(n)
最坏时间复杂度:O(n^2)
平均时间复杂度:O(n^2)
快速排序
基本思想
1.任取一个元素(如第一个)为中心
2.所以比中心小的往前放,比中心大的元素向后方,以中心元素为基准分成了左右两个子表。(小 中 大)
3.对各个子表重新选择中心元素并依此规则调整
4.直到每一个子表只剩下一个元素
通过一趟排序,将排序的数组分隔成了两个部分,基准元素也找到最终的位置。
子表的形成是采用从两头向中间交替逼近法
一个n个元素,那么就需要n-1趟。
使用递归三部曲
递归的参数、返回值和停止的条件
快速排序是改进的交换算法,在排序的过程中交换元素,所以是直接对数组进行排序,不需要返回值。
一趟排序后将数组分成了左右两个部分,左右两个部分继续进行相同的排序方法,所以我们需要用两个指针来划分数组的区间。
void QSort(int [] nums,int left,int right){
//递归终止条件
if(left>=right)return;
}
本层递归逻辑
本层递归需要先找到基准点的最终位置,根据基准点的位置将数组分成两部分的位置。
然后左右两部分继续按规则调整。
void QSort(int [] nums,int left,int right){
//递归终止条件长度大于1才开始划分
if(left < right){ //如果基准点在最后一个元素,那么从下一轮递归中index+1>right了,用只有一个元素left==right判断就会出问题
//找基准点的最终位置,返回索引值。
int index = partiton(nums,left,right);
QSort(nums,left,index-1);
QSort(nums,index+1,right);
}
}
int partiton(int [] nums,int left,int right){
int pivotkey = nums[left]; //假设数组的第一个元素为基准点
//右边的指针先行,如果找到比基准点小的就可以去覆盖左边指针位置的值,而左边指针位置的值就是我们的基准点,已经被存起来了
while(left<right){
while(left<right && nums[right]>=pivotkey)--right; //从后往前找比基准点小的值放前面
nums[left] = nums[right];
while(left<right && nums[left]<=pivotkey)++left;//从前往后找比基准点大的值放后面
nums[right] = nums[left];
}
//退出循环说明找到了找到了
nums[left] = pivotkey;
return left;
}
优化:随机选取基准值
可以降低初始顺序对快速排序效率的影响
1.随机选取一个下标
1.1 范围在[left,right]之间,需要生成[left,right]之间的随机整数
2.该下标的值与第一个元素交换
需要生成[left,right]之间的随机整数
random.nextInt(int n) 该方法的作用是生成一个随机的int值,该值介于[0,n)
Math.random() 随机 返回[0,1)随机数
假设需要[2,6]之间的随机整数
方法1:使用random.nextInt(int n)
Random random = new Random();
random.nextInt(right - left+1); //随机一个int值[0,5)
random.nextInt(right - left+1) +left; //随机一个int值[2,7)
方法2:使用Math.random()
Math.random()*(right-left+1) //[0,5)的随机数
(int)Math.random()*(right-left+1) //[0,5)的随机整数
left+(int)Math.random()*(right-left+1) //[2,7)的随机整数
Random random = new Random();
int randomIndex = left + 1 + random.nextInt(right - left+1);
int tmp = nums[left];
nums[left]= nums[randomIndex];
nums[randomIndex] = emp;
int pivot = nums[left];
算法分析
- 时间复杂度 O(nlogn)
快速排序是内排序(不适用外部内存的排序)中最快的一个
QSort()递归的深度使logn:O(logn)
partiton():O(n)
- 空间复杂度 O(logn)
快速排序不是原地算法
平均情况下:O(logn)的占空间
最坏情况下:占空间可达O(n)
- 稳定性 不稳定
快速排序是一种不稳定的排序算法
- 是否自然排序 不是自然排序
分别对逆序或顺序的数组进行快速排序,每一次划分之后得到的子序列1的长度为0,这时退化成为冒泡排序时间复杂度O(n^2)
快速排序不适合对原本有序或基本有序的记录序列进行排序
输入的数据次序越乱,所划分元素值的随机性越好,排序速度越快,快速排序不是自然排序方法
划分元素的选取是影响时间性能的关键。
归并排序
归并排序,当我们需要排序一个数组时,先将数组分成一半,然后想办法把左边的数组给排序,右边的数组给排序,之后再将它们归并起来。那现在对数组的排序就变成了对左边数组的排序和右边数组的排序。
当然了当我们对左边的数组和右边的素组进行排序的时候,再分别将左边的数组和右边的数组分成一半,然后对每一个部分归并。如图:
什么时候停止递归,只有一个元素时已经有序了,就不用再排序了,直接归并就好了。
这个思路不就是递归的思路吗!归并的过程就是在递归。归并排序是稳定排序
归并算法的实际复杂度 O(nlogn),递归的深度是logn 合并的的时间复杂度是n
使用递归三部曲解题
递归的参数和返回值
我们这个返回void直接对nums进行修改。
参数需要原数组和两个划分区间的指针left、right
void merge (int[]nums,int left,int right){
}
递归的终止条件
只有一个元素时,自然就是有序的
if(left==right) return;
本层递归需要做什么
本层递归需要把有序的两个部分合并成有序的一个部分。
public int[] sortArray(int[] nums) {
int len = nums.length;
merge(nums,0,len-1);
return nums;
}
void merge (int[]nums,int left,int right){
if(left>=right) return;
int mid = (left+right)>>1; //除以2的操作
merge(nums,left,mid); //左边的有序数组
merge(nums,mid+1,right);//右边的有序数组
//!!!!!!!如果数组的这个子区间本身有序,无需合并,这里是一个优化!!!!
if (nums[mid] <= nums[mid + 1]) {
return;
}
//对l1和l2进行合并
mergeSort(nums,left,mid,right); //左右两边有序合成一个有序数组
}
//合并两个
void mergeSort(int[] nums,int left,int mid,int right){
int l = left, m = mid +1;
int k = 0;
int [] arr = new int[right-left+1]; //记录排好序的结果
while(l<=mid && m<=right){
//相同元素原来靠前的排序以后依然靠前,所以这里必须写成<=才能保证稳定性
if(nums[l]<=nums[m]) arr[k++] = nums[l++];
else arr[k++] = nums[m++];
}
//把左右两个数组剩下的元素放入数组
while(l<=mid){
arr[k++] = nums[l++];
}
while(m<=right){
arr[k++] = nums[m++];
}
for(int i=0;i<arr.length;i++){ //新数组覆盖老数组
nums[left+i] = arr[i];
}
}
复杂度分析
- 时间复杂度:O(NlogN),这里 N 是数组的长度;
- 空间复杂度:O(N),辅助数组与输入数组规模相当
插入排序
基本思想(类似扑克牌):每步将一个待排序的对象,按其大小插入到前面已经排好序的数组中的适当位置,直到对象全部插入为止。
边插入边排序,保证子序列中随时都是排好序的。
如何找到插入位置?根据寻找插入位置的不同,将插入排序分成3类
- 顺序法定位插入位置:直接插入排序
- 二分法定位插入位置: 二分插入排序
- 缩小增量多遍插入顺序:希尔排序
直接插入排序
1.先把i位置的值存起来tmp
2.顺序查找插入位置,可以从后往前也可以从前往后,选择从后往前,边找边移动
class Solution {
public int[] sortArray(int[] nums) {
for(int i = 1; i < nums.length; i++){
int tmp = nums[i];
for(int j = i - 1; j >= 0; j--){
if(tmp < nums[j]){
nums[j + 1] = nums[j]; //元素后移
nums[j] = tmp;
}else{//找到了插入位置
break;
}
}
}
return nums;
}
}
优化
「将一个数字插入一个有序的数组」这一步,可以不使用逐步交换,使用先赋值给「临时变量」,然后「适当的元素」后移,空出一个位置,最后把「临时变量」赋值给这个空位的策略。
最好的情况,从小到大有序(顺序)
比较的次数:n个元素排序,n-1个需要和前面有序的进行比较
移动次数:0
提高查找速度
- 减少元素的比较次数
- 减少元素的移动次数
折半插入排序
查找插入位置时采用折半查找法,查找待排序元素位置时前面已经有序了,所以可以使用折半查找
public void insertSort(int nums[]){
int tmp;
for(int i = 0; i < nums.length; i++){
int low=0,high=i-1;
tmp = nums[i];//tmp待排序元素
while(low<high){
int mid = (low+high)>>1;
if(nums[mid]>tmp) high = mid-1;
else low = mid+1;
}//找到插入位置,high+1为插入位置
for(int j=i-1;j>=high+1;--j)nums[j+1] = nums[j];
nums[high+1] =tmp;
}
}
return nums;
}
查找时的比较次数与待排序对象的初始化排列无关。在插入第i个对象时,需要经过logi+1次比较
当n较大时,总关键码比较次数比直接插入排序的最坏情况要好得多,但比最好情况要差
- 减少了比较次数,但没有较少移动次数
- 平均性能优于直接插入排序
复杂度分析
- 时间复杂度O(n^2)
- 空间复杂度O(1)
- 稳定排序
希尔排序
基本思想
将整个待排记录序列分割成若干子序列,分别进行直接插入排序,待整个序列中的记录基本有序,再对全体记录进行一次插入排序
特点
- 缩小增量,增量序列递减,最后以一个必须是1,也就是对全体记录进行一次插入排序
- 多遍插入排序
- 一次移动,移动位置较大,跳跃式地接近排序后的最终位置
1.选择增量序列 D3=5,D2=3,D1=1
2.进行DK-间隔插入排序,每隔DK个的元素一起进行排序
算法分析
希尔排序算法效率与增量序列的取值有关
是不稳定的排序算法
选择排序
堆排序
堆的定义
堆实质使满足 二叉树中任意非叶子结点均小于(大于)它的孩子节点的 完全二叉树(除叶子节点都每一层都满,叶子节点从左到右填充)
在大根堆/小根堆 中堆顶是最大值(最小值),输出堆顶元素后,调整堆,依次输出堆 顶就是排好序的。
堆排序需要解决两个问题:
- 如何由一个无序序列建成一个堆?
- 如何输出堆顶元素后,调整剩余元素为新堆?
堆在很多语言被称为优先队列
调整新堆
大根堆为例
1.输出堆顶元素后,以堆中最后一个元素替代
//index:需要调整的下标
//end:待排序列尾元素索引
void adjust(int []nums, int index,int end){
while(2 * index + 1<=end){ //左节点
int tmp = nums[index];
int max = 2*index+1; //max记录大的子节点,暂时让它为左节点
if(max+1<=end && nums[max]<nums[max+1])max++; //如果右孩子节点存在,且右孩子>左孩子,那么max记录右孩子
if(tmp>nums[max]) break; //如果父节点指比大的孩子节点值还大,直接退出,用于建堆时
//说明还没有调整结束,大的孩子节作为根节点
nums[index] = nums[max] ;
nums[max] = tmp;
index = max ; //沿key较大的孩子节点向下筛选
}
}
无序序列建成一个堆
从一个无序序列建堆的过程就是一个反复筛选的过程。
在完全二叉树中,左右节点为空的子树是堆(叶子节点),所以叶子节点不用调整,从第一个非叶子节点开始调整。
最后一个元素为n,也就是最后一个叶子节点为n,那么第一个不为叶子节点的节点就是它的父节点n/2。
只需要依次以序号n/2....1的结点为根的子树均调整为堆即可。
private void heapify(int[] nums) {
int len = nums.length;
// 从(n-1)/2 因为节点从0开始
for (int i = (len - 1) / 2; i >= 0; i--) {
adjust(nums, i, len - 1);
}
}
堆排序
输出堆顶,然后重新排序
public int[] sortArray(int[] nums) {
//建初始堆
int len = nums.length;
int i;
for (i= (len - 1) / 2; i >= 0; i--) {
adjust(nums, i, len - 1); //建初始堆
}
//输出栈顶元素,根与最后一个元素交换,大根堆,从大到小输出
int tmp;
for(i=len-1;i>1;i--){//进行n-1躺排序,n个元素,堆顶
swap(nums,0,i); //根和待排序的最后一个元素交换,相当于把堆顶元素输出
adjust(nums,0,i-1);
}
return
}
void swap(int[]nums,int a,int b){ //a,b是局部变量不能直接交换
int tmp = nums[a];
nums[a]=nums[b];
nums[b]=tmp;
}
性能分析
- 初始化阶段所需时间不超过O(n)
- 排序阶段(不含初始堆化)
- 一次重新堆化所需时间不超过O(logn)
- n-1次循环所需时间不超过O(nlogn)
堆排序很稳定,不管正序逆序(与初始状态无关),最好最坏时间复杂度都是O(nlogn)
简单选择排序
在待排序的数组中选出最大/小的元素放在最终位置
基本操作
1.首先通过n-1次关键字比较,从n个记录中找出关键字最小的记录,将它与第一个记录交换
2.在通过n-2次比较,从剩余的n-1个记录中找出关键字最小的消极,与第二个记录交换
3.重复上述操作,共进行n-1躺排序后(因为每躺排序好一个元素),排序结束。
public class Solution {
public int[] sortArray(int[] nums) {
int len = nums.length;
int min;
for(int i=0;i<len;++i){
min =i ; //记录最小值下标
for(int j=i+1;j<len;j++){
if(nums[j]<nums[min]) min = j;
}
if(min!=i){
//如果第一个元素不是最小值则交换
int tmp = nums[min];
nums[min] = nums[i];
nums[i] = tmp;
}
}
return nums;
}
}
时间复杂度分析
移动次数:
最好情况:0
最坏情况:3(n-1)
比较次数:
都需要从前到后比较,n-1+n-2...+1