跟着GPT学排序算法(一)

148 阅读6分钟

前言

摸鱼久了,想做点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];
    }
}

学累了,摸鱼去了~