笔记(2)—— 面试官:不以第一个元素为基准的快速排序,你会写吗?

理解快速排序


如果你不知道什么是 快速排序 ,或者对 快速排序 的记忆已经模糊了,向你推荐下面这篇文章,它足够生动形象,面面俱到。

学习下面这篇文章时,你可能会有 一些疑问 ,或 记忆起来很吃力 ,没关系,因为这篇文章写的很详细,篇幅很长,信息量很大 —— “不识庐山真面目,只缘身在此山中”,等你结束了,可以带着问题,和我一起对这篇文章做一下 宏观把玩

记住快速排序


像前面所说的,学习过程中,注意力往往集中在每一步的实现上,很容易忽略整体过程,学到的知识是碎片化的,一步步跟着做会很简单,整体记忆就很困难了,”书读百遍,其意自现“ —— 或许就是一种组装碎片的过程吧。当然,既然你对每一步的实现都有所了解,这时我们可以试着 整体记忆 了。

首先,记住 快速排序 的两个过程,

  • 选择基准
  • 元素移动

最后,记住 元素移动 的两种方式,挖坑法指针交换法 ,总结记忆如下,

  • 挖坑法 ,动了哪个就一直动哪个,不能动时填坑,填完坑就切换指针,重复直到指针重叠,把基准放进去。开始下一轮。
  • 指针交换法,动了哪个就一直动哪个,不能动时,切换指针,都不动的时候交换所指元素,回到初始指针,重复直到指针重叠,与基准交换位置。开始下一轮。

快排核心


基础篇,对快速排序的讲解其实已经很深刻了,但之所以,称之为基础篇,因为文章的侧重点是元素移动。文章告诉你,随机选择基准 可以降低逆序带来的O(n^2)复杂度的可能性,然后实现的时候,,,默默选择了 第一个元素作为基准 ,,,好一个不问来路,只问归处,,,尤其是看了实现代码之后,更是黑人问号??
—— 如果不选择第一个元素为基准,快速排序该怎么写0.0??,网络上大多数文章,也都会告诉你 —— 下面我们选择第一个元素为基准!,真的很气人啊,,,

上文提到,快排的过程是 选择基准——元素移动 ,很显然,基准的选择,会影响元素的移动。基准的选择,才是快排核心的核心!

首先看下基准选择的三种方式:

  1. 固定基准 (比如第一个),当原始序列或子序列逆序时,复杂度接近O(n^2),所以应该避免使用这种方式。
  2. 随机选择 ,降低了逆序复杂度变高的可能性,但仍然不容乐观。
  3. 三数取中,随机选择三个数,取中间大小的作为基准,当然,本身三个数都是不确定的,随机选择无意义,直接取原始数列的头、中、尾三个元素,得出中间大小作为基准即可。进一步降低了数列逆序的可能性。

可以看到,即使是性能最差的 固定基准 ,也不一定是 选择第一个元素作为基准 ,所以,我们应该掌握的快排,是不以第一个元素为基准的快速排序0.0。

怎么写


既然,以 第一个元素为基准的快速排序 ,理解和代码实现都是最简单的,那么我们为什么不这样写呢?

无论你使用了哪种 基准选择 的方式,在 元素移动 之前,你都可以让你的 基准 先与 第一个元素 进行 交换,这样是不是就变成了你手到擒来的快速排序?

所以你需要记住快速排序的总过程是

  • 选择基准
  • 与第一元素交换
  • 元素移动

细心的同学可能会疑惑 —— 你说以 第一个元素为基准的快速排序最简单 ,选择基准后先转化成这种形式,可以理解,但是,如果我不转化成 第一个元素为基准 我就不能写了吗?

当然可以,但是会有很多坑,理解和代码实现都会变得复杂很多,下面我们将会进行 又臭又长 的分析。

上面的内容已经足够让你 记住并写出正确的快速排序 ,如果你没有这些疑惑,可以直接忽略下面这些 又臭又长 的分析。

挖坑法 — 随机选择基准


如果直接按照 第一个元素为基准的挖坑法 的实现代码去实现

public class QuickSort {

    public static void quickSort(int[] arr, int startIndex, int endIndex) {
        // 递归结束条件:startIndex大等于endIndex的时候
        if (startIndex >= endIndex) {
            return;
        }
        // 得到基准元素位置
        int pivotIndex = partition(arr, startIndex, endIndex);
        // 用分治法递归数列的两部分
        quickSort(arr, startIndex, pivotIndex - 1);

        quickSort(arr, pivotIndex + 1, endIndex);

    }

    private static int partition(int[] arr, int startIndex, int endIndex) {
        // 坑的位置,初始等于pivot的位置
        int index = RandomUtils.nextInt(startIndex, endIndex+1);
        System.out.println(index);
        // 基准元pivot值
        int pivot = arr[index];
        int left = startIndex;
        int right = endIndex;
        
        // 大循环在左右指针重合或者交错时结束
        while (right >= left) {
            // right指针从右向左进行比较
            while (right >= left) {
                if (arr[right] < pivot) {
                    arr[index] = arr[right];
                    index = right;
                    left++;
                    break;
                }
                right--;
            }
            // left指针从左向右进行比较
            while (right >= left) {
                if (arr[left] > pivot) {
                    arr[index] = arr[left];
                    index = left;
                    right--;
                    break;
                }
                left++;
            }

        }

        arr[index] = pivot;
        return index;

    }

    public static void main(String[] args) {
        int[] arr = new int[]{4, 6, 3, 2, 1, 9, 4, 6, 8};
        quickSort(arr, 0, arr.length - 1);
        System.out.println(Arrays.toString(arr));
    }

}

上述代码两点需要注意

  1. 右指针的元素一旦用来填坑,左指针就右移。 想象下,如果我们选择的基准最初没有与左指针重合,右指针指向元素第一次填坑的时候,左指针如果直接右移会导致什么? 导致序列的最左元素被遗忘,无法参与排序。
    在这里插入图片描述

  2. 右指针可以一直左移,直至与左指针重合。 ,然而,如果我们的基准最初没有与左指针重合,坑在左右指针之间,那么先不说有坑无法移动的事实,如果左右指针同时出现在坑的一侧,会导致什么? 导致乱序 (左指针可以一直右移,因为左指针开始动,右指针肯定至少填过一次)
    在这里插入图片描述

所以我们需要给代码加限制条件

  • 在右指针元素替换逻辑中,除去left++,即,每次右指针填坑后,左指针不再直接右移,而是进入左指针循环,根据判断条件选择右移
  • 在右指针判断条件增加,right != index ,当右指针与坑重合时不再移动
    private static int partition(int[] arr, int startIndex, int endIndex) {
        // 坑的位置,初始等于pivot的位置
        int index = RandomUtils.nextInt(startIndex, endIndex+1);
        System.out.println(index);
        // 基准元pivot值
        int pivot = arr[index];
        int left = startIndex;
        int right = endIndex;
        // 大循环在左右指针重合或者交错时结束
        while (right >= left) {
            // right指针从右向左进行比较
            while (right >= left
                    && right != index) {//考虑到坑,不能一直左移(不以第一个元素为基准时)
                if (arr[right] < pivot) {
                    arr[index] = arr[right];
                    index = right;
//                    left++;//第一次填坑,left指针不一定会直接右移(left!=index,不以第一个元素为基准)
                    break;
                }
                right--;

            }

            // left指针允许一直右移,因为它遇到坑的时候,一定与right指针重合
            // left指针从左向右进行比较
            while (right >= left) {
                if (arr[left] > pivot) {
                    arr[index] = arr[left];
                    index = left;
                    right--;
                    break;
                }
                left++;

            }

        }

        arr[index] = pivot;
        return index;

    }

我们想一下,其实除了 右指针第一次填坑左指针的右移 需要判断,之后 ,右指针的填坑,左指针都是与坑重合 的,都可以直接进行左指针的右移,所以我们可以优化代码,将 右指针第一次填坑,左指针右移 的逻辑抽取出来,优化后的代码如下

    private static int partition(int[] arr, int startIndex, int endIndex) {
        // 坑的位置,初始等于pivot的位置
        int index = RandomUtils.nextInt(startIndex, endIndex+1);
        System.out.println(index);
        // 基准元pivot值
        int pivot = arr[index];
        int left = startIndex;
        int right = endIndex;

        int firstIndex = index;
        //第一次填坑,left指针不一定会直接右移(left!=index,不以第一个元素为基准)
        while (right >= left && right != index) {
            if (arr[right] < pivot) {
                arr[index] = arr[right];
                index = right;
                break;
            }
            right--;
        }
        // 补偿第一次填坑
        while (right >= left) {
            if (arr[left] > pivot) {
                arr[index] = arr[left];
                index = left;
                right--;
                break;
            }
            left++;
        }
        //如果没有发生填坑,直接返回基准元素的位置,开始下一次分割
        if(firstIndex == index)
            return index;

        // 大循环在左右指针重合或者交错时结束
        while (right >= left) {
            // right指针从右向左进行比较
            while (right >= left
                    && right != index) {//考虑到坑,不能一直左移(不以第一个元素为基准时)
                if (arr[right] < pivot) {
                    arr[index] = arr[right];
                    index = right;
                    left++;
                    break;
                }
                right--;

            }

            // left指针允许一直右移,因为它遇到坑的时候,一定与right指针重合
            // left指针从左向右进行比较
            while (right >= left) {
                if (arr[left] > pivot) {
                    arr[index] = arr[left];
                    index = left;
                    right--;
                    break;
                }
                left++;

            }

        }

        arr[index] = pivot;
        return index;

    }

现在,我们知道,

1,上述实现,右指针先移动,如果先移动左指针,除了改变两个循环的执行顺序,还要将判断条件对称一下!
2,坑的位置,即基准的位置并不会影响指针的移动顺序,但是会增加判断条件。

指针交换法 — 随机选择基准


如果直接按照 第一个元素为基准的指针交换法 的实现代码去实现

public class QuickSort1 {

    public static void quickSort(int[] arr, int startIndex, int endIndex) {

        // 递归结束条件:startIndex大等于endIndex的时候

        if (startIndex >= endIndex) {

            return;

        }

        // 得到基准元素位置

        int pivotIndex = partition(arr, startIndex, endIndex);

        // 根据基准元素,分成两部分递归排序

        quickSort(arr, startIndex, pivotIndex - 1);

        quickSort(arr, pivotIndex + 1, endIndex);

    }

   private static int partition(int[] arr, int startIndex, int endIndex) {

        // 初始pivot的位置
        int index = RandomUtils.nextInt(startIndex, endIndex + 1);
  
        int pivot = arr[index];
        int left = startIndex;
        int right = endIndex;

        while (left != right) {
            // 控制right指针比较并左移
            while (left < right && arr[right] >= pivot) {
                right--;
            }
            // 控制left指针比较并右移
            while (left < right && arr[left] <= pivot) {
                left++;
            }
            // 交换left和right指向的元素
            if (left < right) {
                int p = arr[left];
                arr[left] = arr[right];
                arr[right] = p;
            }
        }
        // pivot和指针重合点交换
        arr[index] = arr[left];
        arr[left] = pivot;
        return left;

    }

    public static void main(String[] args) {
        int[] arr = new int[]{4, 7, 6, 5, 3, 2, 8, 1, 9};

        quickSort(arr, 0, arr.length - 1);

        System.out.println(Arrays.toString(arr));

    }

}

上述代码中, 随机选择基准 后直接进入 元素移动,先不下结论代码是否正确,我们先来看下,如下这种极端情况

  1. 初始数列如下,基准元素 4 在最右
    在这里插入图片描述
  2. 依照 元素移动 的规则,无论是Right指针还是Left指针先移动,最终元素交换都是相同的
    在这里插入图片描述
    在这里插入图片描述
  3. 但是,当元素交换以后,在下一步,哪个指针先移动,直接影响了与 基准交换 的最终位置。如果Left指针优先移动,最终状态如下,很显然这是我们想要的,一个正确的子排序
    在这里插入图片描述
    但是,如果Right指针先移动,最终状态如下,显然是错的
    在这里插入图片描述

现在,我们知道,当基准位于元素最右,那么Left指针应该优先移动,否则排序失败,同理,当基准位于元素最左(第一个元素),那么Right指针应该优先移动,否则排序失败。
得出结论,

不同于挖坑法,在指针交换法中,基准的位置会影响指针移动的优先顺序。所以,在随机选择基准时,我们要根据基准位置,改变代码执行分支。

毫无疑问代码将变得复杂起来,需要增加额外的判断。所以,我们 随机基准选择 后往往与 第一元素 交换位置,使处理变得简单起来,代码如下

    private static int partition(int[] arr, int startIndex, int endIndex) {

        // 初始pivot的位置
        int index = RandomUtils.nextInt(startIndex, endIndex + 1);

        // 交换基准位置
        int pivot = arr[index];
        arr[index] = arr[startIndex];
        arr[startIndex] = pivot;

        int left = startIndex;
        int right = endIndex;

        while (left != right) {
            // 控制right指针比较并左移
            while (left < right && arr[right] >= pivot) {
                right--;
            }
            // 控制left指针比较并右移
            while (left < right && arr[left] <= pivot) {
                left++;
            }


            // 交换left和right指向的元素
            if (left < right) {
                int p = arr[left];
                arr[left] = arr[right];
                arr[right] = p;
            }

        }

        // pivot和指针重合点交换
        arr[startIndex] = arr[left];
        arr[left] = pivot;
        return left;

    }

总结


通过上述分析,我们知道,当 随机选择基准 时,无论

  • 挖坑法,增加判断条件
  • 指针交换法,影响指针优先移动顺序

都会增加代码复杂度,甚至,循环判断降低了代码性能。不妨,将复杂问题简单化,统一快速排序的过程

  • 选择基准
  • 与第一元素交换
  • 元素移动

关注作者公众号,随时了解更多干货!


在这里插入图片描述

往期推荐


  • 11
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值