前言
摸鱼久了,想做点leetCode,发现Easy题都要写不出来了。回头重新学习一下基础算法,记录一下。 该系列算法记录本意是留存着等我以后又忘光的时候能够快速回忆,所以写的会比较啰嗦。你要是觉得没什么用,或者太过于啰嗦,我不负责啊。(我看得懂就行)
本篇是通过拷问GPT进行的学习,结合自己理解进行记录。众所周知,GPT比较容易胡说八道,有哪里错误的话,欢迎指出
首先复习最基础的排序算法,学到哪记到哪(
这一篇将学习冒泡排序、选择排序、插入排序、快速排序和归并排序。
冒泡排序
最简单的排序方式,通过将最大数不停移动到另一端完成排序
例如[4,2,1,3]
第一次遍历后变为[2,1,3,4],通过两两互换将4移动到最右侧
private void bubbleSort(int[] nums){
int length = nums.length;
for (int i = 0; i < length - 1; i++) {
//对已经被排到最后方的最大数减少相应的循环次数,无需进行比较
//逻辑是和下一个数比,所以循环包含的下标是 最大值-1
for (int j = 0; j < length - i - 1; j++) {
int current = nums[j];
int next = nums[j+1];
//发现当前数比下一个数大,互换位置,将大数后移
if(current > next){
nums[j] = next;
nums[j+1] = current;
}
}
}
}
选择排序
冒泡排序的逻辑是通过不停的移动互换,将大数或小数不停移动到一端,这种操作有点暴力的感觉,这其中有个让我感觉很难受的点。
既然每次循环的换位操作只是为了把最大值放到最后的位置,那为什么不直接记住最值,最后再换位置呢?这个操作就是选择排序。
选择排序和冒泡排序的不同之处就在于,它的每次遍历是选择出数组中的最值,只在遍历结束更换最值的位置。同样是[4,2,1,3]:
第一次遍历选择出最小值1,和第一位的数字4互换,变为[1,2,4,3]
第二次对未排序的[2,4,3]部分选择出最小值2,数组不变
第三次对[4,3]排序为[3,4]
private void selectionSort(int[] nums){
int length = nums.length;
//循环内最大数所在下标
int index;
for (int i = 0; i < length - 1; i++) {
//新的循环,重置下边为第一个数
index = 0;
for (int j = 0; j < length - i; j++) {
//将下标修改为比它大的数字的下标
if(nums[j] > nums[index]){
index = j;
}
}
//将最大数和循环区间内最后一个数互换
int temp = nums[length - 1 - i];
nums[length - 1 - i] = nums[index];
nums[index] = temp;
}
}
插入排序
插入排序的实现逻辑,GPT告诉我的原理是:将数组和已排序数组比较,取出待排序数组中的每个数,从已排序数组的最后方开始,从后往前比较,并在顺序正确的地方插入。我寻思要自己随便编一个顺序数组,再合二为一?
其实不是,那个已排序的数组,其实就在原本数组之中。
老例子,数组[4,2,1,3]。将它拆开成两个部分看,[4],[2,1,3]。[4]本身虽然就一个数,但也说明他无需排序,就是个已排序数组。只需要将小的数插入左边,大的数插入它右边,就能实现排序。
第一次遍历,取出[2,1,3]第一个数2,和[4]进行比较,插入左边,组成[2,4],剩下[1,3]未排序。
第二次遍历,取出[1,3]中的第一个数1,对[2,4]从前往后进行比较(顺序随意),组成[1,2,4],剩余[3]。
第三次遍历,取出3,对[1,2,4]从前往后进行比较,组成[1,2,3,4]和[],排序完毕。
private void insertionSort(int[] nums){
int length = nums.length;
for (int i = 1; i < length; i++) {
for (int j = 0; j < i + 1; j++) {
//只需要比较到未排序部分的第一位,并将这个位置划入已排序部分中
if(nums[j] > nums[i]){
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
}
}
快速排序
应该是最常用的排序方式之一了,排序的方式也有点难懂,我追问了GPT好几遍才理解,有点笨哈哈。
快速排序相比于之前的算法,用到了分治的思想。快排的实现方式有挺多种,这边学的是快慢指针(应该是这个吧),挺妙的。
快排的实现分为两步,一步为排序,是确定一个数应该在数组的哪里,把小的放它前面,大的放它后面。另一步则是分治,由于确定了前小后大,只需要将这个数的前后部分分别执行排序逻辑,便能再次确定两个数的位置。循环往复,只到确定所有数的位置。
这里随机选择一个数,比如最后一位,定为数字X,我叫它分割数。设定一个下标lessIndex,lessIndex记录的是有几个数字比X小并且调换过。遍历数组时,每当遇到小于等于X的数,将lessIndex和当前位置的数对调,让lessIndex前进一位,目的是让[0,lessIndex]范围内的数都是比X小的,这样lessIndex+1的位置就是数字X应该在的地方。
例如对[2,4,1,3]排序,取3为X,排序后为[2,1,4,3],lessIndex为1。将数字3和下标为(lessIndex+1)的数对调,组成[2,1,3,4]。3左侧都比它小,右侧都比它大。取左右两部分[2,1]和[4],重复这个逻辑,组成[1,2]和[4],完成排序。
private void quickSort(int[] nums, int left, int right){
if(left < right){
//index表示选为分割数X的数字,在排序后数组里的下标
int pivotIndex = partition(nums, left,right);
//排序X左侧部分,重复排序逻辑
quickSort(nums,left,pivotIndex - 1);
//排序X右侧部分,重复排序逻辑
quickSort(nums,pivotIndex + 1,right);
}
}
/**
* 确定最后一位数应该在哪里,并移动数字的位置
* @param nums 数组
* @param left 遍历起点
* @param right 遍历终点
* @return 返回最后一位数排序后的位置
*/
private int partition(int[] nums, int left, int right) {
int less = nums[right];
int lessIndex = left;
for (int i = left; i < right; i++) {
//当前数比分割数X小,和lessIndex位置互换,lessIndex++
if(nums[i] <= less){
int temp = nums[lessIndex];
nums[lessIndex] = nums[i];
nums[i] = temp;
lessIndex++;
}
}
//将最后一位和lessIndex+1的位置互换,将分割数X放入正确的位置
nums[right] = nums[lessIndex];
nums[lessIndex] = less;
return lessIndex;
}
GPT采用了递归,也是最常用的方式,那自然要思考一下排序的实现方式。根据GPT提供的思路,迭代可以通过记录操作步骤的方式实现(也就是记录递归执行顺序)。
在递归方式中,每次递归取得left和right不同,其他没有任何区别。因此只需要修改quickSort这个入口,将每次排序的left和right记录下来即可。记录的方式使用任意队列都行,GPT选择的是栈,Stack。
按照递归逻辑中执行顺序的left和right值push进stack,不断pop取出left和right进行排序逻辑,直到stack为空。
这个思路可以把任意递归代码转变为迭代形式,你学废了吗?
我们只需要将quickSort修改,就能使用迭代来完成快速排序。
private void quickSort(int[] nums){
//判空
if(nums == null || nums.length == 0){
return;
}
//写入left和right,也就是下标0和下标length-1
Stack<Integer> stack = new Stack<>();
stack.push(0);
stack.push(nums.length - 1);
while (!stack.isEmpty()){
//栈是先进先出,因此pop和push的两个值是相反的
int right = stack.pop();
int left = stack.pop();
int pivotIndex = partition(nums,left,right);
if(pivotIndex - 1 > left){
stack.push(left);
stack.push(pivotIndex - 1);
}
if(pivotIndex + 1 < right){
stack.push(pivotIndex + 1);
stack.push(right);
}
}
}
归并排序
这个平时听到过的不多,所以我就不怎么关注了,这里稍微记录一下。归并也是分治的思路,相比快速排序的换位,它是纯粹的分治。
归并分为两种,一种自上而下,一种自下而上。
自上而下:将数组不断的两两拆分,直到N个长度为1的数组。将这些数组两两结合,并在组合的新数组中排好序。重复这个步骤,直到拼接的新数组长度和原本数组长度相同。
拆分:[4,3,2,1] -> [4,3],[2,1] -> [4],[3],[2],[1]
开始拼接排序: [4],[3],[2],[1] -> [3,4],[1,2] -> [1,2,3,4]
自下而上:区别在于直接暴力拆分整个数组为N个长度为1的数组,不进行二分。组合排序的部分则没有区别
拆分:[4,3,2,1] -> [4],[3],[2],[1]
拼接排序:同自上而下部分
这边只记录自上而下的部分(反正差不多,开摆)
/**
* 将数组不断的拆分,一直拆成N个只有一个数的数组,将它们不断拼接并排序,直到拼成原本长度的数组(进行二分法拆分)
* @param nums 数组
* @param left 数组区间起点下标
* @param right 数组区间结束位置下标
*/
private void mergeSort(int[] nums,int left,int right){
if(left >= right){
return;
}
//计算二分位置
int mid = left + (right - left) / 2;
//以mid为分界,拆分为两个数组
mergeSort(nums,left,mid);
mergeSort(nums,mid + 1,right);
//合并并排序
merge(nums,left,mid,right);
}
/**
* 合并mid位置两侧的区间,并排序
* @param nums 数组
* @param left 第一个区间开始下标
* @param mid 第一个区间结束下标(第二个区间开始)
* @param right 第二个区间结束下标
*/
private void merge(int[] nums, int left, int mid, int right) {
//临时记录区间,记录合并后的区间数据
int[] temp = new int[right - left + 1];
//第一个区间起始位
int i = left;
//第二个区间起始位(第一个区间包含了mid位置的数,后移一位避免重复)
int j = mid + 1;
//用来给临时记录数组temp赋值
int k = 0;
//对第一区间和第二区间开始比较,记录小的那个数,并将所在的那个区间的这个数跳过,直到有一个区间完全比对完
while (i <= mid && j <= right){
if(nums[i] <= nums[j]){
temp[k++] = nums[i++];
}else{
temp[k++] = nums[j++];
}
}
//由于每个区间拼接之前都是排好序的,存在未被比对完的数据的区间时,剩余的数直接拼在后面
//第一区间拼接
while(i <= mid){
temp[k++] = nums[i++];
}
//第二区间拼接
while (j <= right){
temp[k++] = nums[j++];
}
//temp的记录覆盖原数组内[left,right]的数据,完成局部排序
for (int m = 0; m < temp.length; m++) {
nums[left + m] = temp[m];
}
}
学累了,摸鱼去了~