面试官问我快排的时间复杂度为什么是O(NlogN)

60 阅读1分钟

快速排序作为十大排序算法的重要一节,可以在O(NlogN)O(NlogN)的时间内完成排序操作。它不仅是笔试和面试场上常见的考点,也是《数据结构和算法》课程的重点考察内容。你知道荷兰国旗问题吗?你知道快排有哪三个版本吗?你知道快排的时间复杂度为什么是O(NlogN)O(NlogN)吗?无论你是在准备笔试、面试,还是在准备期末考或是考研,看完这篇文章,相信都能给你带来帮助。

荷兰国旗问题

版本一

荷兰国旗问题一共有两个版本,版本一的问题是这样的:随机给定一个无序数组nums和一个基准值base,请你在O(n)O(n)的时间内把数组调整成这样的状态:<= base的元素全都在数组的左侧,> base的元素全都在数组的右侧,而且要求不能使用额外存储空间。

举个例子,对这样的一个数组[5, 3, 7, 4, 2, 6, 1, 8, 4],使用数组最右侧的4作为基准,希望经过一番操作之后数组被拆分成左右两部分,左侧的元素都是<=4的,我们称之为小于等于区,右侧的元素都是>4的,我们称之为大于区,而针对这两个区域的内部,我们并不要求有序。例如,数组[1, 2, 3, 4, 4, 5, 6, 7, 8]是符合要求的,[4, 3, 4, 1, 2, 8, 6, 5, 7]也是符合要求的。

设置一个指针p,含义是nums[0...p]是数组的小于等于区,当算法流程结束后,nums[p+1...n-1]是数组的大于区。初始状态p = -1,意味着小于等于区为空。设置一个指针i指向nums[0],如果nums[i] <= base,那么将nums[i]小于等于区的下一个元素交换位置,小于等于区右扩一位,也就是p指针向右偏移一位,i指针也向右偏移一位;否则不做交换,i指针向右偏移一位。

以上面的数组为例,[5, 3, 7, 4, 2, 6, 1, 8, 4],初始状态p = -1,指针i指向数组0位置的55 > 4,所以不做交换,i指针右移一位。

此时,i指针指向1位置的33 < 4,所以将1位置的3小于等于区的下一个元素,也就是0位置的5交换位置,i指针再向右偏移一位,p指针也向右偏移,小于等于区右扩一位。

此时,i指针指向2位置的77 > 4,所以不做交换,i指针直接右移一位。

i指针指向3位置的44 = 4,和小于等于区的下一个元素交换,ip指针均偏移一位,小于等于区右扩一位。

i指针指向4位置的22 < 4,和小于等于区的下一个元素交换,ip指针向右偏移,小于等于区右扩一位。

i指针指向5位置的66 > 4,位置不交换,i指针右移。

i指针指向6位置的11 < 4,交换位置,ip均右移,小于等于区右扩。

i指针指向7位置的88 > 4,不交换位置,i指针右移。

i指针指向8位置的44 = 4,交换位置,ip均右移,小于等于区右扩。

至此,i指针已经超出了数组范围,流程结束,p指针指向的就是小于等于区的最后一个元素。

需要强调的是,由于我们是以数组的最右侧元素作为基准的,所以i走到最后一个位置时,一定会命中“nums[i] <= base,交换位置,小于等于区右扩”这个分支,所以整个流程结束之后,p指针指向的元素一定等于base

上述流程的代码如下

/**
 * @return 小于等于区的最右侧元素位置
 */
private static int dutchNationalFlag(int[] nums, int base) {
    if (nums == null || nums.length < 1) {
        return -1;
    }

    int n = nums.length;

    // nums[0...p] 小于区,nums[p+1 ... n-1]等于区,nums[p+1...n-1]大于区
    int p = -1;

    for (int i=0; i<n; i++) {
        if (nums[i] <= base) {
            DUtils.swap(nums, ++p, i);
        }
    }

    return p;
}

版本二(leetcode75 颜色排序)

通常情况下的荷兰国旗问题我们一般是指下面这个版本:还是随机给定一个无序数组nums和一个基准值base,请你在O(n)O(n)的时间内把数组调整成这样的状态:<base的元素全都在数组的左侧,=base的元素在中间,>base的元素在数组的右侧,同样要求不能使用额外存储空间。

大家可以网上搜一下,荷兰国旗就是上中下三个颜色分成三个部分,这个问题也是将数组分成三个部分,所以被称为荷兰国旗问题。

这次我们设置两个指针p1p2,意思是nums[0...p1]是数组的小于区,里面的元素都是小于base的;nums[p2...n-1]是数组的大于区,里面的元素均大于base;当整个算法流程结束之后,nums[p1+1...p2-1]是数组的等于区,里面的元素均等于base。再设置一个指针i遍历数组,遍历的结束条件是i指针碰到了p2指针。那么针对遍历到的元素nums[i]有以下三种情况

  • nums[i] < base,此时将nums[i]小于区下一个位置的元素交换,p1i指针均右移一位,小于区右扩;
  • nums[i] == base,此时i指针右移一位即可;
  • nums[i] > base,此时将nums[i]大于区前一个位置的元素交换,p2指针左移一位,大于区左扩,i指针不动。

还是以上面的数组[5, 3, 7, 4, 2, 6, 1, 8, 4]为例,使用数组最右侧的4作为基准。初始状态下p1 = -1, p2 = n,意味着小于区大于区均为空,i指针指向数组0位置的5,发现5 > 4,命中第三个分支,所以将该元素与大于区的前一个元素交换位置,p2指针左移一位,大于区左扩,i指针不动。诶,大家看出来i指针为什么不动了吗?因为交换过来的这个元素也是还没有判断过的,如果i指针动了那么这个元素就漏判了。

接下来i指向0位置的44 = 4,命中分支2i指针右移。

此时,i指向1位置的33 < 4,命中分支1,将该元素与小于区的下一个元素交换位置,i指针右移,p1指针右移,小于区右扩。

i指向2位置的77 > 4,命中分支3,该元素与大于区的前一个元素交换位置,p2指针左移,大于区左扩,i指针不动。

此时,i指向2位置的88 > 4,命中分支3,该元素与大于区的前一个元素交换位置,p2指针左移,大于区左扩,i指针不动。

i指向2位置的11 < 4,命中分支1,该元素与小于区的后一个元素交换位置,p1指针右移,小于区右扩,i指针右移。

i指向3位置的4,命中分支2i指针右移。

i指向4位置的2,命中分支1,与小于区的下一个元素交换位置,小于区右扩,i指针右移。

i指针指向5位置的6,命中分支3,与大于区的前一个元素交换位置,大于区左扩,i指针不动。

到目前为止,i指针已经碰到了p2指针,说明数组中所有的元素都已经遍历完了,流程到此结束。

上述流程的代码实现如下

/**
 * @return 一个长度为2的数组,res[0]是最后一个小于base元素的index,res[1]是第一个大于base元素的index
 */
private static int[] dutchNationalFlag(int[] nums, int base) {
    if (nums == null || nums.length < 1) {
        return new int[] {-1, -1};
    }

    int n = nums.length;

    // nums[0...p1] 小于区,nums[p1+1 ... p2-1]等于区,nums[p2...n-1]大于区
    int p1 = -1, p2 = n;

    int i = 0;
    while (i < p2) {
        if (nums[i] < base) {
            DUtils.swap(nums, ++p1, i++);
        } else if (nums[i] == base) {
            i++;
        } else {
            // nums[i] > base
            DUtils.swap(nums, --p2, i);
        }
    }

    return new int[] {p1, p2};
}

我们也可以使用上述流程解决leetcode75题,大家快去试试吧!

快速排序

快速排序1.0

明白了上述的两个问题之后,我们就要回到本篇文章的主题:快速排序。解决荷兰国旗问题的流程在快速排序中又叫做partition,其实只要你明白了整个流程,叫什么并不重要。快速排序其实是一个递归的过程,首先在nums数组的全范围内做partition1.0,这样数组就被分成了小于等于区大于区两个部分,并且最终返回的指针p指向的元素一定等于base。因此,nums[p]左侧的元素都<= nums[p],右侧的元素都> nums[p],所以nums[p]一定已经来到了正确的位置。

然后我们在数组被partition分成的小于等于区大于区两个部分再分别递归地去做快排。递归的出口是排序部分的元素数量<= 1,这样我们就得到了如下所示的代码。

private static void quickSort(int[] nums) {
    if (nums == null || nums.length < 2) {
        return;
    }

    process(nums, 0, nums.length - 1);
}

/**
 * 在nums[left...right]区域内做快排
 */
private static void process(int[] nums, int left, int right) {
    if (left >= right) {
        return;
    }

    int mid = partition(nums, left, right);

    process(nums, left, mid - 1);
    process(nums, mid + 1, right);
}

/**
 * base = nums[right],将nums[left...right]分成两部分:
 * <= base         > base
 *
 * @return <=base的最后一个元素
 */
private static int partition(int[] nums, int left, int right) {
    // nums[left ... p1]:  <= 区域
    int p1 = left - 1;
    int i = left;

    int base = nums[right];
    while (i <= right) {
        if (nums[i] <= base) {
            DUtils.swap(nums, ++p1, i);
        }
        i++;
    }

    return p1;
}

在分析算法时间复杂度之前我们要先分析一下partition过程的时间复杂度。版本一的parition中,无论哪个分支,i指针都一定向后移动,所以最多执行n次;版本二的partition流程中,要么i指针向后移动,要么p2指针向前移动,循环条件是i < p2,所以循环的最大执行此时也是n次,所以无论是哪个版本的partition,时间复杂度都是O(n)O(n)

然后分析一下快排1.0的时间复杂度。这个时间复杂度其实取决于每次递归时基准的选择。根据我们上面的分析,每一次递归的调用,都至少能确定一个元素的位置,也就是nums[p]

如果数组原本是有序的,也就是说我们每次确定的元素的位置都在排序范围的最边上,那么递归函数的调用次数等于n,递归函数的主体是partition,时间复杂度是O(n)O(n),所以此时整个递归调用的时间复杂度就是O(n2)O(n^2)

如果我们每次确定的元素位置都恰好在范围的最中间,将数组分成了均匀的两部分,根据递归函数的master公式

T(N)=a×T(Nb)+O(Nd)T(N) = a \times T(\frac{N}{b}) + O(N^d)
  • d<logbad < log_b a时,递归调用的时间复杂度为O(Nlogba)O(N^{log_b a})
  • d=logbad = log_b a 时,递归调用的时间复杂度为O(Nd×logN)O(N^d \times logN)
  • d<logbad < log_b a时,递归调用的时间复杂度为O(Nd)O(N^d)

这个递归调用的master公式就可以看成是

T(N)=2T(N2)+O(N)T(N) = 2 T(\frac{N}{2}) + O(N)

命中第二个分支,所以此时递归调用的时间复杂度就是O(NlogN)O(NlogN)

所以快排1.0的最好时间复杂度是O(NlogN)O(NlogN),最差时间复杂度是O(n2)O(n^2)

一个算法的时间复杂度一般都是以最差时间复杂度来估算的,所以快排1.0的时间复杂度是O(n2)O(n^2)

快速排序2.0

快排2.0是在1.0的基础上做了优化,使用版本二的partition代替1.0中版本一partition。这样做的好处是,1.0中的一次递归调用只能确定一个元素的位置,但2.0中的一次partition会把所有等于base的元素都放到数组的中间位置,也就是说一次递归可以确定一批元素的位置,时间复杂度也就得到了优化。

快排2.0的代码如下

private static void quickSort(int[] nums) {
    if (nums == null || nums.length < 2) {
        return;
    }

    process(nums, 0, nums.length - 1);
}

private static void process(int[] nums, int left, int right) {
    if (left >= right) {
        return;
    }

    int[] res = partition(nums, left, right);

    process(nums, left, res[0]);
    process(nums, res[1], right);
}


private static int[] partition(int[] nums, int left, int right) {
    int base = nums[right];

    int p1 = left - 1;
    int p2 = right + 1;

    int i = left;
    while (i < p2) {
        if (nums[i] < base) {
            DUtils.swap(nums, ++p1, i++);
        } else if (nums[i] == base) {
            i++;
        } else {
            // nums[i] > base
            DUtils.swap(nums, --p2, i);
        }
    }

    return new int[] {p1, p2};
}

但2.0版本的快排也没有解决当数组原本有序时,每次选择的base都位于范围的最边上,所以快排的最差时间复杂度也是O(n2)O(n^2)

快速排序3.0

快排的1.02.0版本中,我们都能找到一个存在的case,使得算法命中最差情况,时间复杂度是O(n2)O(n^2)

快排3.0又叫随机快排,它不再采用1.02.0两个版本中使用范围最右侧元素作为基准的策略,而是在nums[left...right]中随机选择一个元素作为base,再进行版本二的partition,然后分别在小于区大于区递归地进行快速排序,由于随机行为的存在,我们就没办法再找到一个实际的case,使其命中最差情况。这种时候再使用最差情况去估算算法的时间复杂度是不合理的,所以就使用平均复杂度作为算法的时间复杂度。经过计算,上述流程的平均复杂度也是O(NlogN)O(NlogN)。所以快排3.0版本的时间复杂度就是O(NlogN)O(NlogN)

随机快排的代码如下所示

private static void quickSort(int[] nums) {
    if (nums == null || nums.length < 2) {
        return;
    }

    process(nums, 0, nums.length - 1);
}

private static void process(int[] nums, int left, int right) {
    if (left >= right) {
        return;
    }

    int[] res = partition(nums, left, right);

    process(nums, left, res[0]);
    process(nums, res[1], right);
}

private static int[] partition(int[] nums, int left, int right) {
    int base = nums[DUtils.random(left, right)];

    int p1 = left - 1;
    // int p2 = right;
    int p2 = right + 1;

    int i = left;
    while (i < p2) {
        if (nums[i] < base) {
            DUtils.swap(nums, ++p1, i++);
        } else if (nums[i] == base) {
            i++;
        } else {
            // nums[i] > base
            DUtils.swap(nums, --p2, i);
        }
    }

    return new int[] {p1, p2};
}