快速排序的三种分割策略
在前面《重谈快速排序》中,我们提到快速排序有两个关键点,分别是:1.枢纽元的选择;2.选定枢纽元后,如何对序列进行左右分割。枢纽元的选择有好几种方法,比如:选择第一个元素作为枢纽元,或者选择中间一个元素,抑或选择最后一个元素,怎么选都可以,也可以随机选择一个元素作为枢纽元。由于快速排序特点,如果枢纽元选择不当,其时间复杂度有可能变为O(N*N)。所以即便是采用伪随机的方式随机选择一个元素作为枢纽元,也有可能选择不当。另外,随机选择需要一定的消耗。除此之外,为了避免一次随机造成的不良影响,可以随机多次进行选择,比如随机选取其中三个元素,选择这三个元素中的中位数作为枢纽元。刚才提到,随机选择需要一定的消耗,所以一般情况下,往往没有必要做三次随机选择,更简单的方法是选择待排序序列中的第一个、最后一个、中间一个元素,从这三个元素中选择一个中位数作为枢纽元。而在《重谈快速排序》中,我们就是采用这种方法选择的枢纽元。
本文,我们不再对枢纽元如何选择作讨论了。我们重点讨论一下枢纽元选定后,应该如何对序列进行左右分割。
在《重谈快速排序》中,我们也有曾提到,对序列进行左右分割之所以在分割过程中有很多细节需要把握,而且分割方法也有多种,其原因在于,我们的分割是在原序列本地进行的分割,如果能够开辟一个额外的序列空间,那么分割逻辑将变得很简单,不过这样造成空间复杂度为O(N)。在本地进行分割可以节省空间,其空间复杂度为O(1)。另外,只要我们掌握好如何在本地进行分割,能够提高空间复杂度是值得的。
下面我们主要介绍三种不同的序列分割方法,对其进行一定的分析,然后给出具体的实现代码和相应注释说明。
1.第一种分割:先交换枢纽元,然后左右交换,最后再交换枢纽元
第一种分割方法就是我们在《重谈快速排序》中使用的分割方法,这里我们不对枢纽元的选择做过多考虑,所以,为了简便起见,我们选择第一个元素作为枢纽元。选定完枢纽元后,我们就对序列进行左右分割。
这种分割流程如下:
1).将枢纽元与序列中最后一个元素交换,当然同理也可以与最前面一个元素进行交换,这里我们只考虑一种情况。当枢纽元与最后一个元素交换后,我们要处理的序列就是除了最后一个元素外,其他的所有元素。
2).初始的左索引为i=left,有索引j=right-1,因为枢纽元的索引为right。左索引向有检测,右索引向左检测。现在的问题是检测到什么情况下,停止检测。当左索引指向的元素小于枢纽元的时候,继续向右走,如果左索引指向的元素大于、或者等于枢纽元的时候就停下来。同样的,当右索引大于枢纽元的时候,继续向左走,如果右索引指向的元素小于、或者等于枢纽元的时候就停下来。
这里,我们需要说明一下:左索引指向的元素小于枢纽元的时候继续向右走,这个很好理解,大于枢纽元的时候停下来也很好理解,那么为什么等于枢纽元的时候也要停下来呢?同理,右索引也是这个道理的。原因在于,如果待排序元素中所有元素都是一样的,那么会出现什么情况?如果等于顺纽元的时候不停下来,那么造成的结果是,对序列的分割及其不均匀,最终导致左半部分序列可能为空,或者右半部分序列为空,最终快速排序的时间复杂度变为O(N*N)。如果等于顺纽元就停下来,并进行交换,那么即便所有的元素都一样,我们的分割也是左右均衡的,其时间复杂度为O(N*logN)。左右索引的处理方式要一致,也就是说只要等于枢纽元就停下来,否则如果有其中一个不停下来,时间复杂度就变为O(N*N)。
当左右索引都停下来后,我们就要检测左右索引的关系。这里无非有三种关系:
i<j:这种情况下,我们直接对其各自指向的元素进行互换,互换完之后,我们需要对i自加,对j自减。因为,互换完之后,如果原来的arr[j]大于枢纽元,则交换完之后,左索引肯定要向右移动的,如果arr[j]等于枢纽元,对i自加也是允许的,因为序列中有其他元素和顺纽元一直的话,我们的这样的元素分割结果有可能落在左半部分,也有可能落在右半部分,只有这样,我们的分割才是均衡的,前面已经谈论过了。同理,对于arr[i]和枢纽元的关系也是这样的。所以,这种情况下,互换完之后,我们一定要对i自加,j自减。如果不这样做,i和j原来指向的元素都等于枢纽元,则造成死循环,程序无法终止。
i=j:首先,这种情况是不是存在的呢?是存在的。当互换的时候,i+2=j,然后对i自加、j自减,同时i和j也挺了下来,说明arr[i]>=pivot,arr[j]<=pivot,又因为i=j,arr[i]=arr[j],所以arr[i/j]=pivot。这种情况下,我们既可以对其进行互换(注意:如果这时进行互换,我们需要考虑一下swap函数的实现,因为互换的两个元素实质上是统一元素,所以其他两种互换操作在这里不再使用:a=a+b,b=a-b,a=a-b;a=a^b,b=a^b,=a^b),互换完之后对i自加、对j自减,继续操作。也可以终止检测,进行下面的操作。我们一般情况下,只有在i<j的时候才互换,如果i=j,我们的处理方式和i>j一样。
i>j:这种情况下,整个检测终止,这是i指向的元素大于等于枢纽元,j指向的元素小于等于枢纽元。注意:i=j的时候同样满足这个条件,所以我们将i=j也考虑在内。这时,我们将最右一个元素也就是顺纽元拿来与i元素进行交换。
3).当i>=j的时候,将最后一个元素即枢纽元与i元素进行交换。
以上就是整个分割算法的流程。在《重谈快速排序》我们实现了两个分割流程,原理都是一样的,只是细节上有些差别,第一个是我们先对i自加,j自减,i和j初始和第二个有些差别。第二个,我们是在交换完之后对i自加,j自减。
分割完之后,我们就要对左右部分进行递归调用。那么需不需要将中间的枢纽元考虑到左部分或者右部分呢?既然我们按照枢纽元进行了分割,左边的元素都是小于等于枢纽元的,右边的元素都是大于等于枢纽元的。所以,枢纽元不需要被纳入左半部分或右半部分。
如果被纳入进去会怎么样?首先,如果被纳入到右半部分,那么会造成死循环,因为如果只有两个元素,而且枢纽元是较小的一个,那么每次分割后,枢纽元在左边,而又被纳入右半部分。左半部分为空,这样会一直循环下去,造成死循环。
如果枢纽元被纳入左半部分,同样还是两个元素,枢纽元为较大一个,每次分割之后,左半部分还是两个元素,右半部分还是为空,下一次分割时,左半部分的枢纽元为较小的一个,第二次分割左右部分各一个元素,可以终止递归。但是如果如果每次选择最右边的作为枢纽元,同样会造成死循环。
并且,如果两个元素都一样的序列,如果枢纽元被划到左半部分会出现死循环,划到右半部怎不出现死循环。
所以,每次分割之后,枢纽元不应该被纳入左半部分或右半部分。
快速排序是个分治递归的过程,其递归结束条件时当待排序序列元素个数小于等1时。
下面,我们给出这种分割方法的实现代码。
// 第一种分割策略 #include <iostream> using namespace std; void swap(int& a, int& b) { int t = a; a = b; b = t; } void QSort1(int arr[], int left, int right) { if (left < right) // 如果left>=right说明待排序序列最多只有一个元素,不需要排序 { int pivot = arr[left]; // 选择第一个元素作为枢纽元 swap(arr[left], arr[right]); int i = left; // 左索引 int j = right - 1; // 右索引 for(;;) // i向右扫描,j向左扫描,直至i>=j { while (arr[i] < pivot) { ++i; } while (arr[j] > pivot) { --j; } if (i < j) { swap(arr[i], arr[j]); ++i; --j; } else { break; } } // 此时i指向的元素大于等于pivot,如果大于pivot,那么是最左边大于pivot的元素 // 交换i元素和最后一个元素即枢纽元 swap(arr[i], arr[right]); // 递归调用 QSort1(arr, left, i - 1); // 左半部分不包含i元素 QSort1(arr, i + 1, right); // 右半部分不包含i元素 } } // 对QSort1进行封装 void QuickSort1(int arr[], int n) { QSort1(arr, 0, n - 1); } void PrintArr(int arr[], int n) { for (int i = 0; i != n; ++i) { cout << arr[i] << ' '; } cout << endl; } int main() { int arr[] = {8, 1, 4, 9, 6, 3, 5, 2, 7, 0}; PrintArr(arr, sizeof (arr) / sizeof (*arr)); QuickSort1(arr, sizeof (arr) / sizeof (*arr)); PrintArr(arr, sizeof (arr) / sizeof (*arr)); int arr2[] = {9, 9, 9, 9, 9, 9, 9, 9, 9, 9}; PrintArr(arr2, sizeof (arr2) / sizeof (*arr2)); QuickSort1(arr2, sizeof (arr2) / sizeof (*arr2)); PrintArr(arr2, sizeof (arr2) / sizeof (*arr2)); return 0; }
2.第二种分割策略——挖坑填数
我们在《重谈快速排序》中简单的介绍了这种方法,并列举了几个网上实现的例子。这种分割方法在网上非常常见,但是其实现情况并不理想,典型情况就是当待排序序列的元素都是一样时,网上大多数的实现效果其时间复杂度都为O(N*N)。我们来探讨一下,这种分割方法是否能够将元素都是一样时,时间复杂度降为O(NlogN)。
首先,枢纽元的选择,我们是选择第一个,也可以选择其他,不同不管怎样,我们都会将枢纽元调到最前面或是最后面,方便剩下的元素处理。这里我们选择第一个元素作为枢纽元,并将第一个元素挖去,这是第一个位置上没有元素了,是一个坑。
该分割算法的流程是:
1.选择枢纽元,将枢纽元交换到第一个位置,记录枢纽元,并将第一个元素挖成坑。
2.左索引i=left,右索引j=right。i向右移动,j向左移动。因为i=left,i指向的是第一个位置,即坑,所以我必须先移动j,不能先移动i。移动j关键在于什么时候停下来。当arr[j]大于枢纽元的时候继续移动,当arr[j]等于枢纽元的时候应该怎么样?这里不能继续了,因为如果继续的话,当元素都一样是,时间复杂度将为O(N*N),所以当arr[j]小于等于枢纽元的时候就停下来,当大于枢纽元的时候继续移动。同理,当j指向坑时,arr[i]大于等于枢纽元的时候就停下来,当小于枢纽元的时候就继续移动。
i和j各有分工,i和j有一个指向坑,另一个则移动。
3.i或j停下来后怎么办。当j停下来时,将arr[j]这个数填到arr[i]这个坑上。这时,arr[j]变成了坑,然后移动i。当i或j停下来的时候,将当前数填到另一个指向的坑。但是需要注意一点:再填之前,需要检测i和j的关系。
i<j:这时应该直接填,并对被填的索引进行自加或自减。这样可以避免当被填元素等于枢纽元时出现死循环。
i=j:这时由于arr[i/j]背身就是一个坑,检测到坑了,也没有其他被填的元素,因为i和j都指向坑,所以应该终止检测。
i>j:这种情况会出现吗?如果不设置检测点,这种情况是有可能出现的,如果出现这种情况那么,i可能指向坑,j也可能指向坑,或许我们应该设置一个标示量。设置标示量使用补救的办法。但是如果我们在移动i和j的过程中就对i和j进行实时监测,就不会出现i>j的情况。i=j的时候,我们就会立即终止检测循环。
退出循环后,i=j,此时arr[i/j]就是坑,我们将暂存的枢纽元填入坑中。
填入坑中后,我们进行递归调用。这是枢纽元不再需要被考虑左右不分。如果被考虑左部分,如果有两个元素的序列,这两个元素且相等,那么就会进入死循环。如果被划入到右部分,则不会出现死循环。
既然我们按照枢纽元分割了序列,枢纽元肯定是大于等于左半部分,小于等于右半部分,所以我们在递归调用时,不应该在考虑中间的枢纽元了,如果考虑了,可能会造成死循环。
下面我们给出挖坑填数分割策略的实现代码。注意,当i或j指向的元素等于枢纽元时,就停下来,这样可以避免全部元素一直时,时间复杂度降为O(N*N),而是继续保持O(NlogN)。这样做的结果是,等于枢纽元的其他元素有可能分割在左半部分也有可能分割在右半部分。不过这样不影响排序的结果,反而提高时间复杂度。
// 第二种分割策略 #include <iostream> using namespace std; void swap(int& a, int& b) { int t = a; a = b; b = t; } void QSort2(int arr[], int left, int right) { if (left < right) // 如果left>=right说明待排序序列最多只有一个元素,不需要排序 { int pivot = arr[left]; // 选择第一个元素作为枢纽元 // 这时arr[left]变为坑了 int i = left; // 左索引 int j = right; // 右索引 for(; i < j;) // 这里保证i必须小于j,加之循环里面每个while、if都有检测i<j,当退出循环时,i必定等于j { // 由于i指向坑,所以需要先移动j // 而且每次需要检测i是否小于j,如果小于则继续 // 否则退出移动,这样保证,退出for循环时,i=j,并且指向的是坑 while (i < j && arr[j] > pivot) // 这里检测的是大于pivot,等于就终止,为了保证元素都一样时,时间复杂度为O(NlogN) { --j; } if (i < j) // 如果满足条件,进行填坑操作 { arr[i] = arr[j]; // 将a[j]填入a[i]坑中,这时a[j]变为了坑 ++i; // 同时对i自加,如果不这样,并且之前a[j]=pivot的话,会造成死循环 } // 可以在这了加个检测语句并且break,不过没有必要,因为for会根据条件终止,并且后面的while和if都有检测 while (i < j && arr[i] < pivot) // 这里可以参见j { ++i; } if (i < j) // 现在a[j]为坑,所以用a[i]填a[j] { arr[j] = arr[i]; --j; // 同时对j自减,这样可以避免死循环 } // 这里可以加个检测语句并且break,但不加也可以,for会根据条件终止 } // 退出了循环,这是i=j,并且指向坑,将枢纽元填入坑中 arr[i] = pivot; // 递归调用 QSort2(arr, left, i - 1); // 左半部分不包含i元素 QSort2(arr, i + 1, right); // 右半部分不包含i元素 } } // 对QSort2进行封装 void QuickSort2(int arr[], int n) { QSort2(arr, 0, n - 1); } void PrintArr(int arr[], int n) { for (int i = 0; i != n; ++i) { cout << arr[i] << ' '; } cout << endl; } int main() { int arr[] = {8, 1, 4, 9, 6, 3, 5, 2, 7, 0}; PrintArr(arr, sizeof (arr) / sizeof (*arr)); QuickSort2(arr, sizeof (arr) / sizeof (*arr)); PrintArr(arr, sizeof (arr) / sizeof (*arr)); int arr2[] = {9, 9, 9, 9, 9, 9, 9, 9, 9, 9}; PrintArr(arr2, sizeof (arr2) / sizeof (*arr2)); QuickSort2(arr2, sizeof (arr2) / sizeof (*arr2)); PrintArr(arr2, sizeof (arr2) / sizeof (*arr2)); return 0; }
3.第三种分割策略——移动枢纽元
前面两种分割策略,我们的枢纽元都是选定之后将其暂存到一个地方,最后终止检测循环时,将枢纽元放到该放的地方。并且在下一次递归调用的使用,不再考虑上一次的枢纽元,这样可以防止死循环。
下面,我们介绍第三种分割策略,这种分割策略会移动枢纽元。也就是说,枢纽元不仅仅是参照的对象,还参与整个序列的分割。
这种策略或许是最为直观的,也最容易理解。选定一个枢纽元后,我们分割的序列还是原来的序列,并不是少了最后一个(第一种策略),也并不是挖了个坑(第二种策略)。设置两个索引i和j,分别从两端向中间扫描。
1.选定一个枢纽元,这里选择第一个元素作为枢纽元。
2.i初始为left,j初始为right,i向右检测,j向左检测。当arr[i]小于枢纽元的时候,继续移动,当大于等于的时候停下来;当arr[j]大于枢纽元的时候继续移动,当小于等于的时候停下来。
3.当i和j都停下来后,交换arr[i]和arr[j]。对i自加,对j自减。i和j的关系有:
i<j:交换两个元素,对i自加,对j自减
i=j:这个时候,终止检测循环,退出循环后,我们不知道arr[i/j]的大小,在退出循环后,需要对其进行检测。根据检测结果,对递归调用进行确认。如果i=j不终止循环,进行内部检测,如果arr[i/j]大于枢纽元,则j自减,如果小于枢纽元,则i自加。如果等于,则i自加。
i>j:这时终止循环,j指向的应该是属于左半部分,i指向右半部分。
退出分割检测循环后,j属于左半部分,i属于左半部分,i用于比j大于1。
具体实现过程中,我们可以采用另外一种方式,就像《重谈快速排序》中的第一种处理方式:先对i自加、j自减。如果i<j则交换,继续检测循环。否则退出,退出时,i=j或者i>j。如果i=j,说明arr[i/j]等于枢纽元,这是将arr[i/j]应该划入哪部分? 如果划入右半部分,会进入死循环;如果划入左半部分,则不会出现死循环。如果i>j,i=j+1,i划入右半部分,j划入左半部分。
直观的理解:
确定好一个参照点后,从两端向中间扫描,如果如何交换条件,则交换,并继续扫描。直至两个左右两个索引相遇。这里存在两个问题:
1.相遇时,是i=j还是i>j
2.相遇后,退出检测循环后,如果进行如何确定左右部分的边界
下面我们给出实现的代码和相关的注释说明。
// 第三种策略 #include <iostream> using namespace std; void swap(int& a, int& b) { int t = a; a = b; b = t; } void QSort3(int arr[], int left, int right) { if (left < right) // 如果left>=right说明待排序序列最多只有一个元素,不需要排序 { int pivot = arr[left]; // 选择第一个元素作为枢纽元 // 这里仅仅是选择了一个元素作为枢纽元,被选择的元素与其他元素一样被处理 int i = left; // 左索引 int j = right; // 右索引 for(;;) // 这里把i<j这个中间条件删了,这样可以保证在下面if-else中i=j时,a[i/j]=pivot // 如果不删除这个条件的话,还需要在循环外部进行进一步的检测,然后再根据检测结果做进一步处理,处理的结果使得i>j(j自减或者i自加) { while (arr[i] < pivot) // 只有i元素小于枢纽元的时候,才移动,否则停下来,包括i元素等于枢纽元时也停下来 { ++i; } while (arr[j] > pivot) // 只有j元素大于枢纽元时才移动,否则停下来 { --j; } if (i < j) // 如果i在j左边 { swap(arr[i], arr[j]); ++i; // 继续移动i --j; // 继续移动j } else if (i == j) // 这时也有可能出现i=j的情况,如果出现了,说明a[i/j]=pivot { break;; } else // 这时 i > j, i指向的元素大于等于pivot,j指向的小于等于pivot { break;; } // i=j和i>j两种情况都会终止循环 } // 退出循环后,有两种情况:i=j和i>j // 这时需要确定左边和右边的边界 // 如果i>j,则i=j+1, j或i-1是左边的右边界,j+1或i是右边的左边界 // 如果i=j,a[i/j]=pivot, // 因为我们是选择第一个元素作为枢纽元,所以一开始i就停了下来 // 而j可能一直移动过来 // 如果选择第一个元素作为枢纽元,不会出现i一直移动到最右边,而j一直不动 // 因为一开始i就不移动,如果i移动了,那么j必定移动,如果j移动i不一定移动 // 所以不会出现左边占据n-1个元素的情况 // 如果左边出现n-1个元素,则是因为选择最右边的元素作为枢纽元 // 所以,为了避免死循环,a[i/j]应该划分到左边,因为这是选择的最左边的元素作为的枢纽元 // 这是j或i为左边的右边界,j+1或i+1为右边的左边界 // 综上所述,左右部分边界的表示如果有i表示 // 则需要考虑i与j的关系,如果i=j,则i-1,i为右左边界 // 如果i>j,则i,i+1为有左边界 // 如果用j来表示左右部分的边界则: // 如果i=j,则j,j+1为右左边界 // 如果i>j,则j,j+1为右左边界 // 所以如果我们采用i表示边界,需要根据情况选择合适的,并不一致 // 如果我们选择j表示边界,则表示形式上是统一的 // 所以我们选择j作为左边的右边界,j+1作为右边的左边界 // 递归调用,这里之前被选择枢纽元的元素也参与了分割,所以不会将这个元素独立出来 // 递归处理的两部分序列元素之和等于n而非n-1 QSort3(arr, left, j); QSort3(arr, j + 1, right); } } // 对QSort3进行封装 void QuickSort3(int arr[], int n) { QSort3(arr, 0, n - 1); } void PrintArr(int arr[], int n) { for (int i = 0; i != n; ++i) { cout << arr[i] << ' '; } cout << endl; } int main() { int arr[] = {8, 1, 4, 9, 6, 3, 5, 2, 7, 0}; PrintArr(arr, sizeof (arr) / sizeof (*arr)); QuickSort3(arr, sizeof (arr) / sizeof (*arr)); PrintArr(arr, sizeof (arr) / sizeof (*arr)); int arr2[] = {9, 9, 9, 9, 9, 9, 9, 9, 9, 9}; PrintArr(arr2, sizeof (arr2) / sizeof (*arr2)); QuickSort3(arr2, sizeof (arr2) / sizeof (*arr2)); PrintArr(arr2, sizeof (arr2) / sizeof (*arr2)); return 0; }
4.三种策略的比较和分析
我们介绍了快速排序算法中三种不同的分割策略。
策略 |
说明 |
递归处理的元素个数 |
总结 |
第一种 |
选择一个枢纽元后,将其与之后的进行交换;然后两端向中间扫描、检测、交换;当两个索引相遇时,终止;将最后面的枢纽元和此时i元素交换;进而继续递归 |
n-1 |
交换枢纽元;两端扫描、检测、交换;交换枢纽元 |
第二种 |
挖坑填数法,选择第一个元素作为枢纽元,并将第一个元素挖个坑,先将右边的索引向左扫描,检测到符合条件的元素,将其填入到坑中,此时j元素变为坑,继而将索引i向右扫描,i和j相互交互填坑直至相遇;相遇后,i=j指向坑,将枢纽元填入坑中;继续递归 |
n-1 |
挖坑填数;右左索引交替移动填数挖坑;最终将枢纽元填入坑中 |
第三种 |
第三种可能是最为直观的方法,选定一个枢纽元后,将该枢纽元作为参考点,设置两个所有索引向中间扫描、检测、交换;直至i和j相遇,相遇的时候需要了解i和j的关系,并确定左右部分的右左边界,继而递归 |
n |
选定一个枢纽元;左右向两端扫描、检测、交换;相遇时终止 |
不管采用哪种分割策略,我们都需要把握以下两个原则:
1.遇到与枢纽元相等的元素时需停下来,否则序列中元素一致是,时间复杂度将为O(N*N),这个原则也可以保证时间复杂度为O(NlogN)。
2.交换完,或者填完坑都要移动,否则可能出现死循环。
5.总结
快速排序算法思路很简单,但是在具体实现中存在各种细节性的问题。我们写的程序最理想的情况下应该能够处理所有的待排序序列,也就是说程序的鲁棒性尽可能做到完美。由于现实中各种各样的特例情况,我们在写程序中都应该一一考虑,并在程序中进行提现出来。能够应付现实中各种不同的情况,这说明程序具有挺强的适用性。除此之外,在能够处理多种情况之下,我们希望程序的书写尽可能漂亮、美观。这或许又会增加程序设计的难度。不过一切努力都是值得的。