快速排序算法_循环不变式,和快速排序算法的多种实现

a2681dbd78c017c5200646c1a646ce33.png

在实际的日常工作,写出一个不正确的逻辑是一件很正常的事。虽然永无 bug 是一件不切实际的幻想,但是我们可以期望使用一些方法来尽可能地来靠近这个幻想。比如构造多种场景进行测试,比如使用循环不变式来验证算法正确性。这两者的侧重点和使用领域不尽相同,实际上,本文的重点——循环不变式更多的是理论上证明,附带帮助我们理解算法的正确性。如果算法本身存在错误,即使通过测试,那也只可能是测试场景构造缺失。

在这篇文章里

  1. 将使用插入排序算法为示例介绍什么是循环不变式
  2. 讨论快速排序算法的多种实现,并在这个过程中使用循环不变式验证算法正确性

(以下所有代码使用 Java)

循环不变式

这是一段插入排序的实现,函数 insertionSort 将输入数组从小到大排列

private static void swap(int arr[], int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

public static void insertionSort(int arr[]) {
    for (int i = 1; i < arr.length; i++) {
        for (int j = i; j > 0 && arr[j - 1] > arr[j]; j--) {
            swap(arr, j - 1, j);
        }
    }
}

如果对插入排序有过了解,一定知道插入排序的过程——它从左到右扫描数组,并不断地把当前扫描到数插入之前数组中的适当位置,以保持当前已扫描数组的有序性。当扫描完成时,整个数组就完成了排序。这段话很冗长,如果用口述会更加令人费解,当然,实际上有更加简洁的描述,也可以类比一个非常形象化的比喻——斗地主摸牌,插入排序的过程就像边摸牌边把牌插入合适的位置。但如果我们使用循环不变式,这个过程会更形式化的让人理解和信服。

public static void insertionSort(int arr[]) {
    /*
    * 初始化
    * 外层 for 循环开始前, i = 1
    * 子数组 [0 .. j - 1] 即 [0 .. 0] 中只有一个元素 arr[0]
    * 只有一个元素的数组,显然是有序的
    * 这时,我们证明了在第一次迭代开始之前, 数组 [0 .. j - 1] 是有序的
    */
    for (int i = 1; i < arr.length; i++) {
        /*
        * 保持
        * 在外层 for 的每次循环中,数组 [0 .. j - 1] 有序的
        * 内层 for 循环将判断 arr[j] 是否是 [0 .. j] 中的最大数
        * 如果不是,则将会把 arr[j] 不断的和 arr[j - 1], arr[j - 2] ... 等元素比较
        * 并不断的把 arr[j] 往前移动,直到正确的位置
        * 当内层 for 循环结束后 
        * 保证了 [0 .. j] 是有序的,也就是下轮迭代开始前 [0 .. j - 1] 保持有序
        */
        for (int j = i; j > 0 && arr[j - 1] > arr[j]; j--) {
            swap(arr, j - 1, j); //把 arr[j] 往前移动一位
        }
    }
    /*
    * 终止
    * 当 j = arr.length,外层 for 循环结束时,根据上述过程描述
    * 可以得到 [0 .. arr.length - 1] 是有序的
    * 也就是整个数组是有序的
    */
}

在上面代码块的注释中,使用了循环不变式来证明函数 insertionSort 的正确性。一般而言,我们把循环不变式认为是一个逻辑断言或者说假设,在一个循环过程中,每一次迭代开始前(或者后)我们都会让这个假设保持为真(true),在循环结束时,就能得到一个最后假设,进而验证算法正确性,它包括三个部分——初始化、保持、终止。

初始化:循环初次执行的时候不变式为真
保持:如果在某次迭代开始的时候不变式为真,那么在下次迭代开始之前,它也应该保持正确。
终止:循环正确终止,当循环结束时根据不变式,即得正确性

如上代码所见,我们假设对于外层 for 循环每次迭代开始前 [0 .. j - 1] 数组是有序的来作为循环不变式,初始化 [0 .. 0] 有序,在 [0 .. j - 1] 的基础上,通过每次迭代移动 arr[j] 到合适位置来保持 [0 .. j] 有序, j = arr.length 时外层 for 循环终止,整个数组有序即得证。

更多关于循环不变式的系统化说明,参见

算法导论,第一部分,第 2 章 算法入门

这里有一段代码,有兴趣的可以考虑下

public static void insertionSort(int arr[]) {
    for (int i = 1; i < arr.length; i++) {
        int temp = arr[i];
        int j = i;
        for (; j > 0 && arr[j - 1] > temp; j--) {
            arr[j] = arr[j - 1];
        }
        arr[j] = temp;
    }
}

这段代码是插入排序的改进,虽然看起来要比之前那段复杂一点,但实际上它可以让编译器更有效的优化局部变量以及减少了赋值次数,所以会更加高效一些。它们的外层 for 循环是一样的,但是内层 for 有一定区别。但实际上,这两个实现中的内层 for 循环中,同样存在一个循环不变式,在前面的讨论了中,为了更加专注概念本身,有意的忽略了内层 for 循环的循环不变式的形式化讨论。那么,这两段代码中的内层 for 循环的循环不变式是什么,如何形式化的把三个部分都描述出来?

快速排序,改进,以及正确性

接下来部分将从一个最基本的快速排序实现开始,讨论多种快速排序的实现,介绍它们各自的特点以及正确性验证。本文并不准备对快速排序做出基本理论化研究,其中包括对分治法的介绍,快速排序时间复杂度的分析。在继续阅读以下部分前,希望对这些内容有基本的了解,可以参考:

算法导论,第二部分,第 7 章,快速排序
Quicksort - Wikipedia / 快速排序算法 - 百度百科

一个基本的实现

private static void swap(int arr[], int i, int j) {
    int temp = arr[i];
    arr[i] = arr[j];
    arr[j] = temp;
}

private static int partition(int arr[], int low, int high) {
    int pivot = arr[low];
    int middle = low;
    for (int i = low + 1; i <= high; i++) {
        if (arr[i] < pivot) {
            swap(arr, ++middle, i);
        }
    }
    swap(arr, middle, low);
    return middle;
}

public static void quickSort(int arr[], int low, int high) {
    if (high > low) {
        int p = partition(arr, low, high);
        quickSort(arr, low, p - 1);
        quickSort(arr, p + 1, high);
    }
}
//调用 quickSort(arr, 0, arr.length - 1) 对 arr 进行排序

上述逻辑,也就是 quickSort,排序输入数组 arr[low .. high],在具体逻辑中它将输入数组划分为两个子数组 arr[low .. m - 1] 和 arr[m + 1, high],其中 arr[low .. m - 1] 中的元素都小于 arr[m],而 arr[m + 1, high] 中的元素都大于等于 arr[m],然后在 arr[low .. m - 1] 和 arr[m + 1, high] 上递归调用该过程。当递归返回时,我们得到一个有序的 arr[low .. m - 1],arr[m],一个有序的 arr[m + 1, high],完成排序。

在递归过程中,实现的重点逻辑是 partition,也就是对子数组的划分以满足

  1. arr[i] < arr[m] when i < m
  2. arr[i] >= arr[m] when i > m

我们将使用循环不变式来证明 partition 能正确的完成这一过程

/*
* low                                                           high
* +--------------------------------------------------------------+
* |pivot | < pivot    |  >= pivot         |    ?   
* +--------------------------------------------------------------+
*                    ^                     ^       
*                    |                     |     
*                  middle                  i   
* 循环不变式 
* arr[low + 1 .. middle] < pivot &&
* arr[middle + 1 .. i - 1] >= pivot &&
* arr[low] == pivot
*/
private static int partition(int arr[], int low, int high) {
    int pivot = arr[low];
    int middle = low;
    /*
    * 初始化
    * arr[low] == pivot
    * arr[low + 1 .. middle] = arr[low + 1 .. low] 为空
    * arr[middle + 1 .. i - 1] = arr[low + 1 .. low] 为空
    * 不变式成立
    */
    for (int i = low + 1; i <= high; i++) {
        //保持
        if (arr[i] < pivot) {
            /*
            * 如果 arr[i] < pivot
            * 根据循环不变式 arr[middle + 1 .. i - 1] >= pivot
            * 又分为两种情况
            * 1. 
            * 当 middle + 1 != i 时,arr[middle + 1] >= pivot
            * 交换 arr[middle + 1] 和 arr[i]
            * 则 arr[middle + 1] < pivot & arr[i] >= pivot
            * 此时 
            * arr[low + 1 .. middle + 1] =
            * arr[low + 1 .. middle] U arr[middle + 1] < pivot
            * arr[middle + 2 .. i] = 
            * arr[middle + 2 .. i - 1] U arr[i] >= pivot
            * middle = middle +1
            * 下轮迭代开始时,i 自增,循环不变式保持为真
          * 2.
            * 当 middle + 1 == i 时,arr[middle + 1 .. i - 1] 为空
            * arr[middle + 1] 和 arr[i] 为同一个数,swap 无实质意义
            * arr[low + 1 .. middle + 1]
            * arr[low + 1 .. middle] U arr[middle + 1] < pivot
            * arr[middle + 2 .. i] 为空
            * middle = middle +1
            * 下轮迭代开始时,i 自增,循环不变式保持为真
            */
            swap(arr, ++middle, i);
        } else {
            /*
            * 如果 arr[i] >= pivot
            * 则 arr[low + 1 .. middle] 无改变
            * 已知 arr[middle + 1 .. i - 1] >= pivot,并且 arr[i] >= pivot
            * 则 arr[middle + 1 .. i] >= pivot
            * 循环不变式保持为真
            */
        }
    }
    /*
    * 终止,当 i == high + 1
    * low                                        high                 
    * +---------------------------------------------+
    * |  < pivot  | pivot |       >= pivot          |      
    * +---------------------------------------------+
    *               ^                                      
    *               |                                    
    *             middle                              
    * 根据不变式可得
    * arr[low + 1 .. middle] < pivot
    * arr[middle + 1 .. i - 1] = arr[middle + 1 .. high] >= pivot
    * arr[low] == pivot
    * 有两种情况
    * 1. middle > low
    * 根据不变式 arr[middle] < pivot
    * 交换 arr[low] 和 arr[middle],arr[low .. middle - 1] =
    * arr[low] + arr[low + 1 .. middle] - arr[middle] < pivot = arr[middle]
    * arr[middle + 1 .. high] >= pivot = arr[middle]
    * 2. middle == low
    * 交换 arr[low] 和 arr[middle] 无实质意义
    * 根据不变式 arr[low .. middle - 1] 为空
    * arr[middle + 1 .. high] = arr[low + 1 .. high] >= pivot
    */
    swap(arr, middle, low);
    return middle;
}

实际过程中,比如口述时,画一个示意图,很多过程可以用一两句话很容易的讲清楚。在这个 partition 的实现中,for 循环是从左到右进行的,实际上如果把 for 循环改成下面这种形式,可以得到一个更高效率的实现

private static int partition(int arr[], int low, int high) {
    int pivot = arr[low];
    int middle = high + 1, i = high + 1;
    do {
        while (arr[--i] < pivot)
            ;
        swap(arr, --middle, i);
    } while (i != low);
    return middle;
}

这个 partition 相对于之前那个,该实现使用 arr[low] 作为哨兵,省略了 for 循环结束后的 swap 语句以及减少了条件判断语句的判断次数,所以会略微高效一些。这个 partition 的 循环不变式验证和上述的相似,但是方向相反,在次不再赘述。有兴趣可以试着写出这个 partition 的 for 循环的循环不变式以验证 partition 的正确性。

一个适应性更强,交换次数更少的实现

private static int partition(int arr[], int low, int high) {
    int pivot = arr[low];
    int i = low, j = high + 1;
    while (true) {
        while (++i <= high && arr[i] < pivot)
            ;
        while (arr[--j] > pivot)
            ;
        if (i > j) {
            break;
        }
        swap(arr, i, j);
    }
    swap(arr, low, j);
    return j;
}

这个 partition 实现直观上的解释是——将从数组两端一起开始,不断相互逼近,然后将左边>= pivot 的数 和 <= pivot 的数同时交换,以减少交换次数。

这里额外提一下,为什么取 >= 和 <=,而不是 > 和 <= ?如果两边同时出现一个数和 pivot 相等,那么交换这两个数的操作实质是没有意义的——交换后数组保持原状。那么为什么实现还是这样做了,这是因为如果假设输入序列全部是一样的数字,且 partition 使用 > 和 <= 的组合,那么每次 partition 分割出来的将会是一个空数组和一个 (high - low) 长度的两个数组,这样快速排序递归深度将达到

,时间复杂度将会达到
。所以,虽然左边使用 >=,右边使用 <=,会导致交换次数增多,但是在数组划分中会更加均衡,从而在整体上减少时间复杂度。

现在我们对这个 partition 中的 while 循环的循环不变式进行验证。

/*
* low                                                           high
* +--------------------------------------------------------------+
* |pivot | <= pivot   |  ?         |    >= pivot   
* +--------------------------------------------------------------+
*                    ^              ^       
*                    |              |     
*                    i              j   
* 循环不变式 
* arr[low + 1 .. i] <= pivot &&
* arr[j .. high] >= pivot &&
* ((i <= j) || (i - j <= 2 && 循环退出)) &&
* arr[low] == pivot
*/
private static int partition(int arr[], int low, int high) {
    int pivot = arr[low];
    int i = low, j = high + 1;
    /*
    * 初始化
    * arr[low] == pivot
    * arr[low + 1 .. i] = arr[low + 1 .. low] 为空
    * arr[j .. high] = arr[high + 1 .. high] 为空
    * i = low < high + 1 = j,由 quickSort 第一条判断语句保证
    * 不变式成立
    */
    while (true) {
        /*
        * 保持
        * ++i,判断当前是否已经越界 或 arr[i] 是否小于 pivot
        * 如果判断为 true,则有 arr[i] < pivot
        * 根据不变式 arr[low + 1 .. i - 1] <= pivot (此时 i 已经自增)
        * arr[low + 1 .. i] = arr[low + 1 .. i - 1] U arr[i] <= pivot
        * 如果判断为 false,则不变式可能不满足,等后续修正 arr[i]
        */
        while (++i <= high && arr[i] < pivot)
            ;
        /*
        * --j,判断当前 arr[i] 是否大于 pivot
        * 这里不判断越界是因为当 j == low 时,必有 arr[j] == pivot,循环退出
        * 如果判断为 false,则有 arr[i] > pivot
        * 根据不变式 arr[j + 1 .. high] >= pivot (此时 j 已经自减)
        * arr[j .. high] = arr[j + 1 .. high] U arr[j] >= pivot
        * 如果判断为 false,则不变式可能不满足,等后续修正 arr[j]
        */
        while (arr[--j] > pivot)
            ;
        /*
        * 当进行到此步骤时,可知 arr[low + 1, i] 最后一个元素 和 arr[j .. high] 第一个元素可能不满足不变式
        * 但 arr[low + 1, i - 1] <= privot,arr[j + 1 .. high] >= pivot 必满足
        * 1. 如果 i <= j,则 arr[i] >= pivot 且 arr[j] <= pivot
        *  swap (i, j),使得 arr[low + 1, i] <= pivot,且 arr[j, high] >= pivot
        * 2. 如果 i > j
        * 2.1 如果 i - j > 1
        * 本次迭代不可能为第一次迭代(为什么?)
        * 由不变式可知 arr[j + 1 .. i - 1] = pivot
        * 那么上轮迭代,i' 自增到 i - 1 时,while 为 false,则 i' = i - 1
        * 同样,上轮迭代中,j' 自减到 j + 1 时,while 为 false,则 j' = j + 1
        * 如果 i - 1 > j + 1,则上轮迭代就会退出外部 while 循环,矛盾
        * 可得 i - j == 2,即可得上轮迭代后 i' == j' = i - 1,且 arr[i'] = pivot
        * 循环结束,此时 arr[low + 1 .. i - 1] <= pivot 且 arr[i - 1 .. high] >= pivot
        * 2.2 如果 i - j == 1
        * 循环结束,此时 arr[low + 1 .. i - 1] <= pivot 且 arr[i .. high] >= pivot
        */
        if (i > j) {
            break;
        }
        swap(arr, i, j);
    }
    /*
    * 终止,当 i > j 时
    * 因为 while 循环的每次迭代中 i 必然会自增至少 1, j 必然会自减至少 1
    * 循环必然会结束
    * low                                         high                 
    * +---------------------------------------------+
    * |  <= pivot | pivot |    >= pivot             |      
    * +---------------------------------------------+
    *               ^                                       
    *               |                                      
    *               j                                
    * 如果是由 2.1 退出循环
    * 此时 arr[low + 1 .. i - 1] <= pivot 且 arr[j + 1 .. high] >= pivot
    * 且 i - j == 2 
    * 则 arr[j] = arr[i - 2] <= pivot
    * swap(low, j) 后,由 pivot = arr[low] 可得
    * arr[j + 1 .. high] >= pivot
    * arr[j] = pivot
    * arr[low .. j - 1] = arr[i - 2] U arr[low + 1 .. i - 3] <= pivot
    * 如果是由 2.2 退出循环
    * 此时 arr[low + 1 .. i - 1] <= pivot 且 arr[i .. high] >= pivot
    * 且 i - j = 1
    * 则 arr[j] = arr[i - 1] <= pivot
    * swap(low, j) 后,由 pivot = arr[low] 可得
    * arr[j + 1 .. high] >= pivot
    * arr[j] = pivot
    * arr[low .. j - 1] = arr[i - 1] U arr[low + 1 .. i - 2] <= pivot
    */
    swap(arr, low, j);
    return j;
}

这个 partition 同样把数组化成了三个有序部分,相对于前面提到的 partition,为了验证正确性,在不变式中加入了 对 i 和 j 的位置约束。该约束关系到循环是否可以正确终止,相对情况会略微复杂一点。

使用同样双向逼近思想的 partition 过程有很多变种,比如下面这个

public static void quickSort(int arr[], int low, int high) {
    if (high > low) {
        int p = partition4(arr, low, high);
        quickSort(arr, low, p);
        quickSort(arr, p + 1, high);
    }
}

private static int partition(int arr[], int low, int high) {
    int pivot = arr[low];
    int i = low - 1, j = high + 1;
    while (true) {
        while (arr[++i] < pivot)
            ;
        while (arr[--j] > pivot)
            ;
        if (i >= j) {
            break;
        }
        swap(arr, i, j);
    }
    return j;
}
//调用 quickSort(arr, 0, arr.length - 1) 对 arr 进行排序

仔细观察,会发现 partition 对 i 的初始化和 自增的判断条件都有所不同,退出循环的条件从 > 变成了>=,另外在 quickSort 中,递归调用左边子数组的语句从 quickSort(arr, low, p - 1) 变为了 quickSort(arr, low, p)。那么这个快速排序的正确性又如何使用循环不变式验证,如果有兴趣可以试着写下。

快速排序 附

本文主要目的是使用循环不变式来验证算法逻辑的正确性。文中列举了快速排序算法的几种实现作为示例,同时简略提及具体实现的特点。但并未对 快速排序算法 的基础性理论做出讨论,比如时间复杂度分析。在此简略的提及一些快速排序的注意点。

  1. 上述所有的快速排序实现的平均时间复杂度都是
    。但是如果输入数组是有序的,算法时间复杂度会降至
    ,其中后两种实现可以在全部数字相等时避免进入最坏情况,但前面两者不行。一般而言,在每次 partition 过程中使用随机 pivot 可让每种实现在实践中表现良好(对于最先的两种实现,如果输入数组的元素全部相等,随机 pivot 没有优化效果),避免针对性的最坏输入数组。
  2. 即使在每次 partition 过程中使用随机 pivot,仍然有可能出现最坏情况,使得时间复杂度达到
    。如果要求严格避免这种情况,可以使用一个最坏
    的中位数选择算法来选择 pivot,使得快速排序的最坏时间复杂度降至
    。但系数很大,工程实践中用到这种方法的场景很少。这个中位数选择算法可以参考,『算法导论』第 9 章 第 3 节。
  3. 作为一个优化,当子数组划分到一定长度大小以下时,可以停止递归。函数 quickSort 返回后,在数组上使用 insertionSort 进行排序,因为 quickSort 结束后数组已经局部有序,所以选择好一定阈值,可以认为 insertionSort 的时间复杂度是线性的。
  4. 从上个世纪 60 年代以来,快速排序的改进就一直持续着,除了本文已经提到的两种以及相应的变种。快速排序还有其他几种实现方式,比如作为 Oracle’s Java 7 及以后版本中的默认排序算法 Dual-Pivot Quicksort,更多介绍可以参考 Wikipedia 。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值