目录
(注:所需要的方法在之前的笔记中都有)!
3. 交换排序
1. 基本思想
- 所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。
- 交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。(升序排列)
2. 冒泡排序
- 代码思路:趟数以及对数(相邻两个元素都要比较)的双层循环
时间复杂度:等差数列求和 - 【冒泡排序的特性总结】:
1) 冒泡排序是一种非常容易理解的排序
2) 时间复杂度:O(N^2)
3) 空间复杂度:O(1)
4) 稳定性:稳定
- 源码:
/**
* 冒泡排序:
* 趟数(n个数据搞n-1趟) 以及 每趟比较的对数(n-1-i):因为每经历一趟就可以减少1对数据比较
* 趟数+对数=总数据个数-1
* 时间复杂度:O(N^2)--等差数列求和
* 针对优化后的代码(也就是增加了flag标记):在有效情况下时间复杂度是O(N)
* 空间复杂度:O(1)
* 稳定性:稳定的。(在比较时如果加了=就是不稳定的)
*/
public static void bubbleSort(int[] arr) {
for (int i = 0; i < arr.length-1; i++) { // 趟数
boolean flag = false;
for (int j = 0; j < arr.length-1-i; j++) { // 每趟比较的对数
if(arr[j] > arr[j+1]) {
swap(arr,j,j+1);
// 如果检测到该对数据经过了交换,那就说明:该组数据还不是有序数据。
flag = true;
}
}
// 来到这儿,说明一趟遍历完成,此时如果经过交换就说明不是有效的,继续;
// 如果是false就说明不交换已经是有序的,直接over
if (!flag) {
break;
}
}
}
3. 快速排序
- 快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
(也就是说:按基准值划分为左右两部分)
1)Hoare版
- 代码思路:
1)基准值key,left++、right–,直到找到left所指的值>key,right值<key,就交换left所指的值与right所指的值。循环:直到left与right相遇。相遇位置的值又与key的值进行交换。然后对于目前key的左右位置分别使用上述left、right方法即可–递归。
2)先走右边,遇到小于基准值就停下;再走左边,同样遇到大于基准值停下,交换两个值后继续移动;直到相遇后与基准值交换。递归。
- 注意:为什么左边做key时右边要先走?
因为如果左边先走时,当找到left与right相遇时是right保持了之前的位置(即:大于key),而left走到了大于key的right位置,当此时与key进行了交换之后就会把大于key的值交换过去,就不满足key的左边小于key、右边大于key的准则了。
(简而言之:先走左边,相遇的数据是比基准大的数据) - 补充:
1)如果右边要做key就左边走。
2)快排使用场景是:无序。
如果发现数据已经趋于有序就使用直接插入排序or希尔排序。
有序数组使用快排可能会存在栈溢出情况(函数递归在栈上开辟栈帧)。
3)IDEA是可以修改栈帧开辟的大小的。
- 源码:
/**
* 快速排序
* Hoare版:左key,右先动;直到相遇交换key。
* 时间复杂度:理想情况下(每次都是均分)的时间复杂度是:O(N*logN) 每一层是N次,一共有logN层(相当于树的高度)
* 时间复杂度:最慢(即:有序or逆序情况下)是:O(N^2)--等差数列求和
* 空间复杂度: 最好(均分):O(logN) 最坏(单分支):O(N)--当N足够大时深度就足够大,栈溢出风险就越大
* 稳定性:不稳定
* 当有序数组中数据个数过大时进行快排可能hi出现栈溢出的情况!
* 为什么会出现栈溢出:因为递归的深度太深了,而函数的递归是在栈上开辟栈帧的
* 一般情况下,快排的使用场景是:无序场景。
* 如果发现数据趋于有序,就使用直接插入排序or希尔排序!
* 但是快排在有序的情况下栈溢出确实是目前存在的问题,我们需要就这个问题进行处理。
*
*/
public static void quickSort(int[] arr) {
quick(arr,0,arr.length-1);
}
// 快排方法
private static void quick(int[] arr, int left, int right) {
// 在求基准值过程中,如果数组直接是有序的,就会出现,left==right==pivot
// 此时说明基准值只有一个结点or没有结点
if(left >= right) {
return;
}
// 找到基准值
int pivot = partition(arr,left,right);
// 继续递归
quick(arr,left,pivot-1);
quick(arr,pivot+1,right);
}
// 找基准值
private static int partition(int[] arr, int start, int end) {
// 实现存储好key下标
int i = start;
int key = arr[start];
while(start<end) {
// 为什么前面还要加上start<end? 因为这是一个独立循环,又可能会出现key之后的all值都小于key值,
// 此时end一直会减小到end==-1,也就是错过了相遇点
// 为什么 arr[end]>=key、arr[start]<=key需要取等号? 因为不取等号可能会陷入死循环
while(start<end && arr[end]>=key) {
end--;
}
// 出来说明:①end==start ②arr[end]<key,需要找start
while(start<end && arr[start]<=key) {
start++;
}
// 此时进行交换
swap(arr,start,end);
}
// 完成交换后,start与end相遇,此时要与key下标进行交换
swap(arr,start,i);
//return i; // 新的基准下标,注意不是i,是start,因为交换的只是下标对应的值!!
return start; // 相遇处被交换到了新的基准值(新的基准下标)
}
2)挖坑法(优先考虑版)
- 代码思路:
其实就是:把最左边的元素作为key值,然后同时也作为坑位,然后从右边找,当找到<key的元素时,将该元素放至坑位,把现在的元素位置作为新的坑位;然后左边开始移动,找到>key的元素后将该元素放至新的坑位;然后右-左重复直至相遇时把key放到相遇处的坑位。然后就是相遇的左边、右边又分
别进行上述操作–递归。
- 挖坑法相较于Hoare法其实就是:在寻找基准值时会有变化,left与right进行交换,最后在left与right相遇处的坑位处填上key,也就是所找的基准值。
- 快排优先考虑使用挖坑法。
- 源码:
/**
* 快速排序优先考虑使用挖坑法!!
* 快速排序:
* 挖坑法:其实也就是相较于Hoare法在寻找基准值时会有变化而已,
* 这个相当于start与end交换,最后在相遇处的坑位填上key,也就是寻找的基准值
*
*/
public static void quickSort2(int[] arr) {
quick2(arr,0,arr.length-1);
}
// 快排方法
private static void quick2(int[] arr, int left, int right) {
// 在求基准值过程中,如果数组直接是有序的,就会出现,left==right==pivot
// 此时说明基准值只有一个结点or没有结点
if(left >= right) {
return;
}
// 找到基准值
int pivot = partitionHole(arr,left,right);
// 继续递归
quick(arr,left,pivot-1);
quick(arr,pivot+1,right);
}
// 找基准值
private static int partitionHole(int[] arr, int start, int end) {
int key = arr[start]; // 进行key的标记
while(start<end) {
// 为什么前面还要加上start<end? 因为这是一个独立循环,又可能会出现key之后的all值都小于key值,
// 此时end一直会减小到end==-1,也就是错过了相遇点
// 为什么 arr[end]>=key、arr[start]<=key需要取等号? 因为不取等号可能会陷入死循环
while(start<end && arr[end]>=key) {
end--;
}
// 出来说明:①end==start ②arr[end]<key
arr[start] = arr[end];
while(start<end && arr[start]<=key) {
start++;
}
arr[end] =arr[start];
}
// 完成交换后,start与end相遇,此时key值应该放入该坑位
arr[start] = key;
return start; // 相遇处被交换到了新的基准值(新的基准下标)
}
3)前后指针法
- 代码思路:
1)cur=start+1; cur比基准小的时候一直往后走,直到遇到小于基准值且cur与prev没有重叠时停下进行交换
2)当不满足循环(cur<=end)时,prev与start进行交换
- 源码:
/**
* 快速排序:
* 前后指针法:比基准小就一直走,且不重叠就进行交换
*/
public static void quickSort3(int[] arr) {
quick3(arr,0,arr.length-1);
}
// 快排方法
private static void quick3(int[] arr, int left, int right) {
// 在求基准值过程中,如果数组直接是有序的,就会出现,left==right==pivot
// 此时说明基准值只有一个结点or没有结点
if(left >= right) {
return;
}
// 找到基准值
int pivot = partitionPointer(arr,left,right);
// 继续递归
quick(arr,left,pivot-1);
quick(arr,pivot+1,right);
}
// 找基准值
private static int partitionPointer(int[] arr, int start, int end) {
// 注意:start是就是基准位!!!
int prev = start;
int cur = start+1;
while(cur <= end) {
if(arr[cur]<arr[start] && arr[++prev]!=arr[cur]) {
// 当前值小于基准值且pre下一个与cur值不重叠就进行交换
// 此时prev里存的是大于基准值的,cur是小于基准值的,两个进行交换
swap(arr,prev,cur);
}
// 说明:①cur值大于基准值 or ②重叠 or 上述进行了交换(cur>, prev<)
cur++;
}
// 说明 cur已经走完了
swap(arr,prev,start);
return prev;
}
4)快排优化
- 以上三种快排都存在一个问题:当数据有序且数据量大时,因为进行函数递归,而函数递归又是在栈中开辟栈帧的,所以会出现栈溢出问题!
- 单分支(其实就是有序数据:顺序or逆序):此时使用快排容易造成栈溢出
- 解决方案:
1)随机选取基准法:start基准,在start后面随机找一个下标和start进行交换,此时随机下标对应的值就作为新的基准值,然后进行移动比较交换、递归。
2)三数取中法:在start、end、middle所对应的三个值中选取一个中位数,然后把中位数换到start位置上作为新的基准值
3)小区间直插法:设二叉树深度h,则共有 (2 h -1 )个结点,而最后一层有 [
2(h-1)]个结点,基本上最后一层的节点个数是整棵树结点的一半。
其实在排序到最后两层的时候数据已经趋于有序,且已经递归到小区间时,则此时为了减少递归次数以及时间耗损就可以使用【直接插入排序】
1))三数取中法
/**
* 下面实现三数取中法:
*/
// 三个数找中位数下标:
private static int midNumIndex(int[] arr,int left,int right) {
int middle = (left+right)/2;
if(arr[left] < arr[right]) {
if(arr[middle] < arr[left]) {
return left;
} else if(arr[middle] > arr[right]) {
return right;
} else {
return middle;
}
} else {
if(arr[middle] > arr[left]) {
return left;
} else if(arr[middle] < arr[right]) {
return right;
} else {
return middle;
}
}
}
public static void quickModify(int[] arr, int left, int right) {
if(left >= right) {
return;
}
// 找中位数下标并及交换
int index = midNumIndex(arr,left,right);
swap(arr,index,left);
// 进行基准值位置更换确定
int pivot = partition(arr,left,right);
// 递归
quickModify(arr,left,pivot-1);
quickModify(arr,pivot+1,right);
}
2)小区间直插法
/**
* 快排优化2:
* 递归到小区间且数据已经趋于有序时,就使用【直接插入排序:指定区间】
*
* 注意:说道快排的时间复杂度一般是:O(N*logN),一般不说最慢O(N^2),因为如果有序数据直接使用直插
*/
// 指定区间的直接插入排序:
private static void insertSort2(int[] arr,int start, int end) {
// 从第二个元素开始排序
for (int i = start+1; i <= end; i++) { // end处取整,因为参数给的是right
int tmp = arr[i];
// 进行比较
int j = i-1;
for (; j >= start; j--) {
if(tmp<arr[j]) {
// 小于就j往前挪,然后j的值往后移动
arr[j+1] = arr[j];
// 注意这里不需要再次j--,条件已经在括号中
} else {
// 大于则直接插入
// arr[j+1] = tmp; // 也可以拿到循环外面,这样的话j要定义在外面!
// 结束循环
break;
}
}
// 大于则直接插入
arr[j+1] = tmp;
}
}
public static void quickModify2(int[] arr, int left, int right) {
if (left >= right) {
return;
}
// 判断小区间 + 【直接插入排序】:注意小区间的选取!!
// 主要优化了递归深度
if (right - left + 1 <= 7) {
insertSort2(arr, left, right);
return;
}
// 找中位数下标并及交换
// 三数取中:解决递归深度问题,有了三数取中后待排序序列基本上都是二分N*logN
int index = midNumIndex(arr, left, right);
swap(arr, index, left);
// 进行基准值位置更换确定
int pivot = partition(arr, left, right);
// 递归
quickModify2(arr, left, pivot - 1);
quickModify2(arr,pivot+1,right);
}
4)非递归的快排
- 代码思路:
非递归的快排:
使用栈(先进后出):先找到基准,然后将start / pivot-1 / pivot+1 / end的下标放入栈中,然后弹出两个pivot / end下标,然后对这两个值进行partition找新的基准下标(挖坑法);如果基准值左or右只有一个元素时就没必要把该元素放入栈中。
- 源代码:
1)使用栈进行非递归的快排
/**
* 注意:到目前,只有直接插入排序 和 冒泡排序 是稳定的,其余都是不稳定的!
*
* 非递归的快排:
* 使用栈
*/
public static void quickSortNo(int[] arr) {
Stack<Integer> stack = new Stack<>();
int left = 0;
int right = arr.length-1;
int pivot = partitionHole(arr,left,right);
// 下标入栈:只有当基准值左右有大于1个元素时才入展,只有一个元素是不入栈的!!
// 首先必须有一次的入栈!!
if(pivot > left+1) {
stack.push(left);
stack.push(pivot-1);
}
if(right > pivot+1) {
stack.push(pivot+1);
stack.push(right);
}
// 如果栈不为空就弹出两个元素,弹出的元素作为新的left和right!!!,继续进行partition找基准值,之后又进行入栈,直到栈为空
// 其实这个循环就是一边一边的进行寻找基准值以及排序
while(!stack.isEmpty()) {
// 出栈
right = stack.pop();
left = stack.pop();
// 找基准+排序
// 这里进行基准值下标的更新,否则会陷入死循环
pivot = partition(arr,left,right);
// 入栈
if(pivot > left+1) {
stack.push(left);
stack.push(pivot-1);
}
if(right > pivot+1) {
stack.push(pivot+1);
stack.push(right);
}
}
}
2)使用三数取中法进行优化
/**
* 非递归的快排:再优化:三数取中(使用partition都可以使用)
*/
public static void quickSortNo2(int[] arr) {
Stack<Integer> stack = new Stack<>();
int left = 0;
int right = arr.length-1;
// 三数取中:作为left
int index = midNumIndex(arr, left, right);
swap(arr, index, left);
// 寻找基准值
int pivot = partitionHole(arr,left,right);
// 下标入栈:只有当基准值左右有大于1个元素时才入展,只有一个元素是不入栈的!!
// 首先必须有一次的入栈!!
if(pivot > left+1) {
stack.push(left);
stack.push(pivot-1);
}
if(right > pivot+1) {
stack.push(pivot+1);
stack.push(right);
}
// 如果栈不为空就弹出两个元素,弹出的元素作为新的left和right!!!,继续进行partition找基准值,之后又进行入栈,直到栈为空
// 其实这个循环就是一边一边的进行寻找基准值以及排序
while(!stack.isEmpty()) {
// 出栈
right = stack.pop();
left = stack.pop();
// 三数取中+找基准+排序
// 这里进行基准值下标的更新,否则会陷入死循环
index = midNumIndex(arr, left, right);
swap(arr, index, left);
pivot = partition(arr,left,right);
// 入栈
if(pivot > left+1) {
stack.push(left);
stack.push(pivot-1);
}
if(right > pivot+1) {
stack.push(pivot+1);
stack.push(right);
}
}
}
5)快排总结
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
- 时间复杂度:O(N*logN) (logN层,每层N个数据/结点)
- 空间复杂度:O(logN)
- 稳定性:不稳定
4. 归并排序
1.基本思想
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and
Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
(即:子序列排序、合并)
2. 归并排序
- 代码思路:
- 先分解成一个一个的元素,然后合并得到有序序列,在进行合并。
步骤:1)下标标记 2)定义left、right下标 3)找中间位置 4)先整理左边(left~middle/right)
5)直到left==right(递归) 6)整理右边(当前middle+1,right)递归 7)合并(注意合并方法
merge)- 分解时:最后要分解为N个结点
合并时:logN层,每层N个数据
不管有序没序,时间复杂度是:O(N*logN)
- 【归并排序的特性总结】:
1) 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
2) 时间复杂度:O(N*logN)
3) 空间复杂度:O(N)
4) 稳定性:稳定
- 源码:
1)递归
/**
* 归并排序:
* 先分解 再整合
*
* 不管有序无序,时间复杂度是:O(N*logN)
* 空间复杂度:O(N):归并到新的数组
* 稳定性:有等号就稳定
*
* 三个稳定的排序:直插、冒泡、归并
*/
private static void mergeFunc(int[] arr,int left, int right) {
if(left >= right) {
return;
}
int mid = (left+right)/2;
// 分解左边
mergeFunc(arr,left,mid); // 是包含mid
// 分解右边
mergeFunc(arr,mid+1,right);
// 进行合并
merge(arr,left,right,mid);
}
// 合并
private static void merge(int[] arr, int left, int right, int mid) {
// 首先申请一个数组,大小是两边的元素个数之和
int[] tmpArr = new int[right-left+1];
// 定义数组下标
int k =0;
// 循环决定两个序列的顺序,注意循环条件是:两个序列都要有元素
// 只要一个为空就停止循环,另一个序列直接跟上
// 即:只有两个归并段都有数据时才进入循环比较大小
// 创建临时变量!
int s2 = mid+1;
int start = left;
while(left<=mid && s2<=right) {
if(arr[left] <= arr[s2]) { // 有无等号决定了稳定性:有稳定
/* tmpArr[k] = arr[left];
// 进入数组(小的那个段进行++移向下一个数据)
left++;
k++; // 临时数组存入数据后下标也要进行移动*/
// 其实以上就是left与k先使用再加加,即后置加加
tmpArr[k++] = arr[left++];
} else {
/*tmpArr[k] = arr[s2];
s2++;
k++;*/
tmpArr[k++] = arr[s2++];
}
}
// 判断是哪个序列需要再进行拷贝(循环),此时一个归并段没有了元素
while (left<=mid) {
tmpArr[k++] = arr[left++];
}
while (s2<=right) {
tmpArr[k++] = arr[s2++];
}
// 把排好序的数据拷贝会原来数组
// 注意拷贝回原数组时不是按0开始的,而是原来数组开始进行合并的初始与结束位,即:每次合并时位置都不一样
for (int i = 0; i < k; i++) {
arr[i+start] = tmpArr[i]; // 注意原数组的位置要+start!!!
}
}
public static void mergeSort(int[] arr) {
mergeFunc(arr,0,arr.length-1);
}
2)非递归
非递归实现归并排序:
1)把每个数据都看作一个段 2)两两进行归并
即:控制每一组数据的个数
/**
* 非递归实现归并排序:
* 控制每一组数据的个数
*/
public static void mergeSortNo(int[] arr) {
int gap = 1; // 每组的数据
while(gap < arr.length) {
// 每次进来(不管一组有多少个元素)都是要把all元素遍历完
for (int i = 0; i < arr.length; i+= (gap*2)) { // 注意变换条件!!
// 进入该循环说明i是合法的
int s1 = i;
int e1 = s1+gap-1;
// e1可能会越界,要进行修正!
if(e1>=arr.length) {
e1 = arr.length-1;
}
// 其实因为该过程中s2都是起辅助作用的,没有实际用到,所以可以不写出来
/*int s2 = e1+1;
if(s2>=arr.length) {
s2 = arr.length-1;
}
int e2 = s2+gap-1;*/
int e2 = e1+gap;
if(e2>=arr.length) {
e2 = arr.length-1;
}
merge(arr,s1,e2,e1);
}
gap *= 2;
}
}
// 数据量较大时:快排、归并、希尔、堆
3. 海量数据的排序问题
- 外部排序:排序过程需要在磁盘等外部存储进行的排序
- 前提:内存小数据大
- 因为内存中因为无法把所有数据全部放下,所以需要外部排序,而归并排序是最常用的外部排序
- 如:内存只有 1G,需要排序的数据有 100G。
- 先把文件切分成 200 份,每个 512 M
- 分别对 512 M 排序,因为内存已经能够放下,所以任意排序方式都可以
(即:将这些小文件拉到内存中按任意方式进行排序,排序后再写回到另外能够存储的小文件(该小文件在外存))- 进行 2路归并,同时对 200 份有序文件做归并过程,最终结果就有序了
(两两依次归并,此过程会产生很多临时文件。
(注意:后面的归并在磁盘上进行,临时文件也是
位于磁盘上的->超过内存大小))
THINK
- 交换排序(冒泡、快排)
- 归并排序
- 时间以及空间复杂度
- 快排的三种方法
- 稳定性:(三个稳定–>直插、冒泡、归并)