1. 排序
所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。
#稳定性: 假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
我们默认以升序的形式来介绍下面的各个排序算法:
1.1 插入排序
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列,这就是我们插入排序的思路。实际在玩扑克牌时,我们用到的就是插入排序的思想。
1.1.1 直接插入排序
当插入第i(i>=1)个元素时,前面的array[0],array[1],…,array[i-1]已经排好序,此时用array[i]的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。通过将第i个元素与前面已经有序的i-1个元素进行比较,插入合适的位置实现排序。
public void insertSort(int[] arr) {
//从第2个元素(1下标)开始进行插入排序,因为一个元素相当于有序
for (int i = 1; i < arr.length; i++) {
int tmp = arr[i]; //保存第i个还未进入有序序列的元素
int j = i-1;//从第i-1个元素开始往前逐个比较
for (; j >= 0; j--) {
if(arr[j] > tmp) {
arr[j+1] = arr[j];
}else {
break;
}
}
//在有效下标处或不大于他的元素后放
arr[j+1] = tmp;
}
}
直接插入排序特性总结:
- 对于越接近有序的元素集合,直接插入排序的时间效率越高
- 直接插入排序的时间复杂度:O(N^2)
- 稳定性:稳定
- 空间复杂度:O(1)
1.1.2 希尔排序
希尔排序法又称缩小增量法。希尔排序法的基本思想是:先选定一个整数gap,把待排序序列中所有记录分成多个组,所有距离相同的记录分在同一组内,并对每一组内的记录进行排序。然后,减小间隔gap,重复上述分组和排序的工作。当到达gap = 1
时,所有记录在统一组内排好序。
public void shellSort(int[] arr) {
int gap = arr.length;
while(gap > 1) {
gap /= 2;//分组不断缩小,直到间距缩小为1
//分组很大时,数组元素小,相当于有序;时间复杂度O(N)
//分组很小时,元素很大,此时元素也接近有序;时间复杂度O(N)
for (int i = 0; i < arr.length-gap; i++) {
int tmp = arr[i+gap];
int j = i;
//间距为gap
for (; j >= 0 ; j-=gap) {
if(arr[j] > tmp) {
arr[j+gap] = arr[j];
}else {
break;
}
}
arr[j+gap] = tmp;
}
}
}
希尔排序总结:
- 希尔排序是在直接插入排序上的优化
- 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。
- 希尔排序实际上将一组数据分为间隔为gap的多组数据,然后对多组数据进行交替排序:当gap>1时为预排序,当gap == 1时相当于直接插入排序
- 我们可以认为希尔排序的时间复杂度为:O(N*LogN)
- 空间复杂度:O(1)
- 稳定性:不稳定
1.2 选择排序
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
1.2.1 直接选择排序
直接选择排序是在集合中每一次遍历找到一个最小值,将它与为排序的元素中的第一位元素交换,使得交换后的元素有序,接着重复上述步骤,直到集合中剩余一个元素
我们可以在实现选择排序的时候优化一下,一次遍历中选出最大值和最小值,将他们与未排序元素的末端和首端进行交换:
public void selectSort(int[] arr) {
int left = 0;
int right = arr.length-1;
while(left < right) {
int maxIndex = left;
int minIndex = left;
for (int i = left+1; i <= right; i++) {
if(arr[maxIndex] < arr[i]) {
maxIndex = i;
}
if(arr[minIndex] > arr[i]) {
minIndex = i;
}
}
swap(arr,left,minIndex);
//判断maxIndex==left情况,最大值下标出被交换
if(left++ == maxIndex) {
maxIndex = minIndex;
}
swap(arr,right--,maxIndex);
}
}
优化后的直接选择排序需要注意的是,当进行最大值和最小值的交换问题时,可能出现最小值交换后,最大值的位置发生改变,所以在进行最大值交换时应该判断下最大值原来位置是否在最小值位置处。
if(left++ == maxIndex) {
maxIndex = minIndex;
}
直接选择排序总结:
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
1.2.2 堆排序
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆。
public void heapSort(int[] arr) {
//建堆:时间复杂度:O(N)
for (int i = (arr.length-1-1) / 2; i >= 0; i--) {
shilfDown(arr,i,arr.length);//向下调整
}
//将大根堆堆顶元素与尾元素交换,使得元素有序
//时间复杂度:O(N*LogN)
int len = arr.length;
while(len-1 != 0) {
swap(arr,0,--len);
shilfDown(arr,0,len);//交换后堆顶元素重新向下调整
}
}
//向下调整算法
private void shilfDown(int[] arr, int root, int length) {
int child = root * 2 + 1;
while(child < length) {
//找孩子节点中大的
if(child+1 < length && arr[child] < arr[child+1]) {
child++;
}
if(arr[child] > arr[root]) {
swap(arr,child,root);
//交换后可能打乱下面的堆,接着向下调整
root = child;
child = 2 * root + 1;
}else {
break;
}
}
}
堆排序的特性总结:
- 排升序要建大堆,排降序建小堆。
- 时间复杂度:O(N*LogN)
- 空间复杂度:O(1)
- 稳定性:不稳定
1.3 交换排序
所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
1.3.1 冒泡排序
两两元素相比较,前一个比后一个大就交换,直到将最大的元素交换到末尾位置。一趟排序将一个最大的数沉入底部,像泡泡一样,所以叫冒泡排序
public void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length-1;i++) {
boolean flg = false;
for (int j = 0; j < arr.length-1-i; j++) {
if(arr[j] > arr[j+1]) {
swap(arr,j,j+1);
flg = true;
}
}
if(!flg) {
break;//没有交换说明已经有序
}
}
}
冒泡排序特性总结:
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
1.3.2 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止
public void quickSort(int[] arr) {
//递归过程给定区间
_quickSort(arr,0,arr.length-1);
}
private void _quickSort(int[] arr,int left,int right) {
if(left >= right) {
return ;
}
//将基准排好序
int pivot = parttion1(arr,left,right);
//递归排序左右区间
_quickSort(arr,left,pivot-1);
_quickSort(arr,pivot+1,right);
}
快速排序特性的总结:
- 时间复杂度:O(N*LogN)
- 空间复杂度:O(LogN)
- 稳定性:不稳定
对于区间按照基准值划分为左右两部分(将基准排好序,即左区间所有元素均小于基准值;右区间所有元素均大于基准)中常见的方法有:
- 挖坑法
- Hoare版
- 前后指针法
1. 挖坑法
挖坑法思路:
- 选出基准值(最左边)保存在临时变量中,然后留下一个坑位。
- 右指针从上次位置开始往前寻找(第一次时从数组末尾向前寻找),直到遇到比基准值小的值,将该值放入到坑中,而右指针所指向位置形成新的坑。
- 然后将左指针从上次位置开始向后寻找(第一次从数组头开始向后寻找),直到遇到比基准大的值,将该值放入坑中,左指针指向位置称为新的坑。
- 重复上述2、3步骤,直到左右指针相遇。最后将基准值放入坑中。
//挖坑法
private int parttion1(int[] arr,int left,int right) {
int pivot = left;
int tmp = arr[left];
while(left < right) {
//从右往左找小:向从右往左找是为了确保左边<=基准,右边>=基准
while(left < right && arr[right] >= tmp) {
right--;
}
//找到小的后将左边的坑填上,右边形成新的坑
arr[pivot] = arr[right];
pivot = right;
//从左往右找大
while(left < right && arr[left] <= tmp) {
left++;
}
//找到大的后将右边的坑填上,左边形成新的坑
arr[pivot] = arr[left];
pivot = left;
}
//最后的位置就填上基准
arr[pivot] = tmp;
return pivot;
}
2. Hoare版
Hoare版思路:
- 选定一个基准值(以临时变量的形式保存起来),并记录好基准值所在的下标,基准值最好选定最左边或者最右边
- 确定两个指针left 和right 分别从左边和右边向中间遍历数组。
- 这里以选最左边为基准值为例:
3.1 让right指针先往前走,如果遇到小于基准值的数就停下来
3.2 然后左边的指针left在向后走,遇到大于基准的数就停下来
3.3 交换left和right指针所指向位置的值。然后重复上述操作,直到left == right ,此时将基准值与left(right)位置的值交换。
//2. Hoare法
private int parttion2(int[] arr,int left,int right) {
int tmp = arr[left];
int tmpIndex = left;
while(left < right) {
//从右往左找小
while(left < right && arr[right] >= tmp) {
right--;
}
//从左往右找大
while(left < right && arr[left] <= tmp) {
left++;
}
swap(arr,left,right);//将大的和小的所处的位置互换
}
//将相遇的位置和基准的位置换,形成左(不大于)右(不小于)
swap(arr,left,tmpIndex);
return left;
}
需要注意的是,如果选取最左边的为基准,一定要先让right指针找小,否则可能出错。
3. 前后指针法
前后指针法思路:
- 选定基准值,定义prev和cur指针(cur = prev + 1)
- cur先走,遇到小于基准值的数停下,然后将prev向后移动一个位置
- 当prev和cur不在同一个位置时,将prev对应值与cur对应值交换
- 重复上面的步骤,直到cur走出数组范围
- 最后将基准值与prev对应位置交换
//前后指针法:前一个往后一个带
private int parttion3(int[] arr,int left,int right) {
int pre = left;
int cur = left+1;
while(cur <= right) {
if(arr[cur] < arr[left] && arr[++pre] != arr[cur]) {
swap(arr,pre,cur);//比基准值小,且pre下一项和cur不重合,和下标处值不一样,就往后带
}
cur++;
}
swap(arr,pre,left);//最后将基准所在下标和pre所在换
return pre;
}
4. 快速排序的优化
上面就是快速排序递归的三种方法。但是上面的程序其实还存在一些缺陷:对于选取的基准值的值如果是待排序数组中的边界值(最大值或最小值)的情况下,我们会进行单分支的递归,这样单分支的递归会使得递归层次变深导致不必要的消耗。
为了解决上述提到的问题,我们通常采用以下两种方法进行优化:
- 三数取中法
- 小区间优化法
//1. 三数取中法:在在起始位置,中间位置,末尾位置中选出中间值,作为基准值。使得区间分配更加平均,不会出现单分支的情况
private int getMid(int[] arr, int left, int right) {
int mid = (right - left) / 2 + left;
if(arr[left] < arr[right]) {
if(arr[mid] < arr[left]) {
return left;
}else if(arr[mid] > arr[right]) {
return right;
}else {
return mid;
}
}else {
if(arr[mid] > arr[left]) {
return left;
}else if(arr[mid] < arr[right]) {
return right;
}else {
return mid;
}
}
}
//2. 小区间优化法:类似于二叉树,每个子树都会进行一次递归调用,越到下面递归调用会越多。为了减少递归调用,当到递归到下层时,我们可以使用其他的排序来替代。这里我们使用插入排序
经过优化后的快速排序:
public void quickSort(int[] arr) {
//递归过程给定区间
_quickSort(arr,0,arr.length-1);
}
private void _quickSort(int[] arr,int left,int right) {
if(left >= right) {
return ;
}
//优化:1)小区间优化法:递归到区间长度较小时,此时递归次数太多,
//同时小区间的数采用直接插入排序效率更高
if(right - left + 1 <= 7) {//小区间长度<=7时,进行优化
insertSortRange(arr,left,right);
return ; }
//2)三数取中
int mid = getMid(arr,left,right);
swap(arr,left,mid);//使快排左右区间更加平均
//将基准排好序
int pivot = parttion1(arr,left,right);
//递归排序左右区间
_quickSort(arr,left,pivot-1);
_quickSort(arr,pivot+1,right);
}
//小区间插入
private void insertSortRange(int[] arr, int left, int right) {
for (int i = 1+left; i <= right; i++) {
int tmp = arr[i];
int j = i-1;
for (; j >= left; j--) {
if(arr[j] > tmp) {
arr[j+1] = arr[j];
}else {
break;
}
}
//在有效下标处或不大于他的元素后放
arr[j+1] = tmp;
}
}
private void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
5. 快速排序的非递归
快速排序的非递归形式借助栈实现,通过栈不断压入排序好基准值的左右区间。当区间元素为1时代表该区间有序:
//快排的非递归:借助栈保存区间结点
public void quickSortNor(int[] arr) {
_quickSortNor(arr,0,arr.length-1);
}
private void _quickSortNor(int[] arr, int left, int right) {
//三数取中,使得区间更加平均
//int mid = getMid(arr,left,right);
//swap(arr,left,mid);
Stack<Integer> stack = new Stack<>();
if(right-left < 1) {
return ;
}
stack.push(left);
stack.push(right);
while(!stack.isEmpty()) {
right = stack.pop();
left = stack.pop();
int pivot = parttion1(arr,left,right);
if(pivot-left >= 2) {
stack.push(left);
stack.push(pivot-1);
}
if(right-pivot >=2) {
stack.push(pivot+1);
stack.push(right);
}
}
}
1.4 归并排序
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
归并排序主要是将两个有序区间进行合并,那么该如何使得归并的区间是有序的是我们主要关注的问题;对于数组进行划分,不断划分,直到每个划分数组的序列中只有一个数字,当只有一个元素时,我们认为它有序。
public void mergeSort(int[] arr) {
int[] tmp = new int[arr.length];
_mergeSort(arr,tmp,0,arr.length-1);
}
//归并排序:前提:两个有序区间; 将两个有序区间合并
private void _mergeSort(int[] arr, int[] tmp, int left, int right) {
if(left >= right) {//当只剩一个元素或者左边界大于右边界说明有序
return ;
}
int mid =( right - left) / 2 + left;
//使得左右区间有序
_mergeSort(arr,tmp,left,mid);
_mergeSort(arr,tmp,mid+1,right);
//合并两个有序区间
merge(arr,tmp,left,right);
}
private void merge(int[] arr, int[] tmp, int left, int right) {
int mid = (right - left) / 2 + left;
int index = left;
int begin1 = left;
int begin2 = mid+1;
while(begin1 <= mid && begin2 <= right) {
if(arr[begin1] < arr[begin2]) {
tmp[index++] = arr[begin1++];
}else {
tmp[index++] = arr[begin2++];
}
}
while(begin1 <= mid) {
tmp[index++] = arr[begin1++];
}
while(begin2 <= right) {
tmp[index++] = arr[begin2++];
}
for (int i = left; i <= right; i++) {
arr[i] = tmp[i];
}
}
归并排序特性的总结:
- 时间复杂度:O(N*LogN)
- 空间复杂度:O(N)
- 稳定性:稳定
归并排序的非递归
归并排序非递归:当区间为一个元素时有序。
非递归实现的思路是:逐渐把区间扩大进行排序,直到区间长度等于数组长度时排序完成
public void mergeSortNor(int[] arr) {
int gap = 1;
int[] tmp = new int[arr.length];
while(gap < arr.length) {
//每次将元素区间扩大2倍进行排序,即将原本gap大的两个有序的区间合并扩大
//i+=2*gap是跳到下一个待合并区间的改变条件
for (int i = 0; i < arr.length; i+=2*gap) {
int mid = i+gap-1;
if(mid >= arr.length) {
mid = arr.length-1;
}
int right = mid+gap;
//right= i+2*gap-1求得右边界
//判断右边界是否越界,越界就修正
if(right >= arr.length) {
right = arr.length-1;
}
//需要传入mid坐标,边界right值修改后可能会导致mid发生变化,所以要传mid
mergeNor(arr,tmp,i,mid,right);//合并两个有序区间成为一个新的大的有序区间
}
gap*=2;
}
}
private void mergeNor(int[] arr,int[] tmp,int left,int mid,int right) {
int begin1 = left;
int begin2 = mid+1;
int index = left;
while(begin1 <= mid && begin2 <= right) {
if(arr[begin1] < arr[begin2]) {
tmp[index++] = arr[begin1++];
}else {
tmp[index++] = arr[begin2++];
}
}
while(begin1 <= mid) {
tmp[index++] = arr[begin1++];
}
while(begin2 <= right) {
tmp[index++] = arr[begin2++];
}
for (int i = left; i <= right; i++) {
arr[i] = tmp[i];
}
}
在非递归时,由于区间的长度可能会因为区间扩大的过程而导致越界,这时候重新矫正区间长度,在合并有效区间时Mid
值会发生改变,使得不准确,所以我们将合并有序区间的方法修正,多了一个参数Mid,来记录正确Mid
。
1.5 计数排序
上面的排序都是基于大小的比较的排序,那么有没有不是基于数据大小的比较的排序呢?–计数排序
计数排序:适用于连续范围的数
- 统计相同元素出现次数
- 根据统计的结果将序列排序
public void countSort(int[] arr) {
int max = arr[0];
int min = arr[0];
for (int i = 1; i < arr.length; i++) {
if(max < arr[i]) {
max = arr[i];
}
if(min > arr[i]) {
min = arr[i];
}
}
//需要的计数数组大小==连续的数个数==max-min+1
int[] count = new int[max-min+1];
for (int i = 0; i < arr.length; i++) {
count[arr[i]-min]++;//使得数组从0下标开始
}
int index = 0;//从0下标开始
for (int i = 0; i < count.length; i++) {
while(count[i] != 0) {
arr[index++] = i+min;//i+min使数组值回到原来
count[i]--;
}
}
}
计数排序特性的总结:
- 时间复杂度:O(MAX(N,range))
- 空间复杂度:O(range)
- 稳定性:稳定