[编程练习思考5]--从初衷来理解快速排序算法

快速排序是分治算法的经典体现之一,但是我总是会忘,原因之一我觉得就是由于之前的快拍是C语言写的,因为C语言没有那么高级的容器,因此用C写的快排就需要额外思考数据存储的位置,将他们充分利用起来。但是这样的快排写出来是分治思想和存储方法夹杂在一起实现的,并不能直观反映快速排序的最直接的分治特性。

Python版本快速排序

       后来我用python实现了一组快速排序,用到了list这种略高端一些的数据结构,这样快排二分的目的就特别明显了,先上code!这个code只适用于数据之间相互不重复的情况。

arr=[5,3,2,7,1,9,4,6,8]
def quickSort(arr):
    if len(arr)<2:
        return arr
        
    pivot=arr[0];
    smallerList=[]
    largerList=[]

    for i in arr:
        if i<pivot:
            smallerList.append(i)
        elif i>pivot:
            largerList.append(i)
    return quickSort(smallerList)+[pivot]+quickSort(largerList)

从代码很明显可以看出,1)快速排序目的就是选择一个pivot枢轴,将小的扔到一边,大的扔到另一边。2)然后依次递归,用枢轴作为二分的标准,将数据进行分类。3) 递归截止条件,就是只剩下一个元素或者0个元素的情况,比方说[2,3] 按照2作为枢轴分开的话,会得到[ ] 和[3]这两部分,这个时候就没得分了,直接返回就好。


支持重复数据的Python版本快速排序

如果需要支持重复元素的话,那么有下面的代码。考虑重复,其实主要就是考虑枢轴重复的情况,如果枢轴有两个5,那么上面的代码只会保留一个5下来,其他的5剔除了,于是我们可以用一个list存取这些枢轴元素。

def quickSort(arr):
    if len(arr)<2:
        return arr
        
    pivotList=[];
    pivot=arr[0]
    smallerList=[]
    largerList=[]

    for i in arr:
        if i<pivot:
            smallerList.append(i)
        elif i>pivot:
            largerList.append(i)
        else:
            pivotList.append(i)
    return quickSort(smallerList)+pivotList+quickSort(largerList)

通过python版的快速排序我们可以很清晰的了解快速排序的根本动机,其实快排就是不断挑一个数字分成两堆,然后各堆再如法炮制。其实做法就像一个二叉树,或者更确切的是像决策树那样,根据某个值将特征进行二分。

那么什么时候这种二分的次数最少,也就是说分类二叉树的深度最少呢 ? 我们默认的快排是将集合里面的第一个作为pivot,其实最佳的pivot值应该是能够将集合均分成两堆的那种,比如1-10这十个数的集合,其实我们先用5分比较好,然后分出来的两堆分别用同样位于子集中部的值3和7来分比较好。这样整体所需的二分次数,也就是树的深度比较小。而如果我们默认按照第一数为枢轴的话,运气未必像之前这种情况这么好。比方说就是一个正序的1到10从大到小排序的序列,用快排的话,每一次都被分到一个堆里面了,这样的时候快排的性能是最差的。


C++版本快速排序

      接下来,我们再回过头来看C/C++版的快速排序,实质和python是一样的,就是二分。不同的地方在于C++版的存储位置是有限的,也就是有10个数,那么就有不多不少10个坑,不像我们用python那样,还会调用各种list帮忙存储和运算。

swap和printArray这两个是基本函数,无非是调换位置和显示数组元素。有意义的就是quickSort函数了。我们通过数组引用传递的方式,在quickSort函数里面实现对数组元素的操纵。其实最需要理解的就是下面这个两层循环。

在快速排序眼里,pivot就是一个标杆,理想状态下:pivot左边的应该是小于它的,pivot的右边的应该是小于它的。但是实际情况呢?开始肯定办不到谢谢,如何办呢?用python的方法最简单,直接拿pivot和数组的每个元素一个一个去比,大的放到一个筐子,小的放到另一个筐子去。在C和C++中(尽管我们也可以建立类似的容器)这样做也行,但是不够简洁了。C++版本的思路是,假定理想的目标排序状态是左小右大的,让pivot依次和右边比,再和左边比。跟右边比的时候,右边数比他大就是合格的(相当于是可以放在大的那个筐子里了),比他小的就是不服的。在右边遇到不服的数据,他就转过头来和左边的数据进行比较,左边的数比他小的是比较服气的,他跳过(相当于已经放在小的那个筐子里了),左边数字比他大的元素是不服气的。于是这个时候我们依次在左边和右边遇到两个不服气的主。

怎么办? 用python那种最自然的逐个处理的话,我们在右边遇到不服管,将不服管的放到左边的筐子里,然后在右边遇到不服管的,将他们放到左边的筐子里。但是假如用C++去做呢?我们先遇到右边不服管,不做处理,然后又遇到左边不服管的,这个时候通过swap函数,将他们进行交换,也就相当于完成两个不服管的元素的放置过程了。总结来说,python是一个元素判断,一个元素处理这样逐个进行的,而C++则是两个元素判断,通过swap函数完成两个元素的处理进行的,C++的解决手段更加利索,python更加自然。

	while (i != j){
		while ( (arr[j]>=pivot) && (i<j)) { j--; }
		while ((arr[i]<=pivot) && (i<j))  { i++; }
		swap(arr[i], arr[j]);
	}

就这样,终止条件是左边的那个指示索引i和右边的指示索引j最终碰到了一起来了(i==j),然后我们将pivot枢轴元素(也就是数列的第一个元素)和第i个元素(或第j,因为i==j)进行调换,位于pivot元素左边的就是小的那一筐,位于pivot右边的就是pivot大的那个筐。然后依次递归。

那么问题来了,为什么将pivot元素和第i个元素调换? 第i个元素一定是属于左边的那筐数的么? 原因和我们的比较顺序息息相关。

       我们比较顺序是过程1:先和最右边的进行比较,遇到不服气的就停下来,过程2:再去和左边的元素去比。在过程2进行之前,我们可以知道,这个时候右边元素的指示索引j一定是指向不服管的那个值的。为什么?while ( (arr[j]>=pivot) && (i<j)) { j--; },这个的意思是一旦遇到服从管理的数,指示索引就自动跳过--了,反之遇到不服管的,就不会跳过,j也就会留在不服管的元素的位置,也就是比pivot值小的那个位置。 

举个例子,如果最后的情况是如下的样子,pivot=4,arr[i]=4,arr[j]=7,这个时候首先从右边动,右边7服从管理,因此跳过,j移动到3,在3处不服从管理j不再挪动了,不再挪动的意思是不挪动的值一定是小于pivot的,这个时候i从左边过来比,和右边的j撞到一起的时候,这个位置的元素一定是小于pivot的。因为右边先走,右边先到,先到的不服管的点一定是小于pivot的点。因此i和j相遇的点一定是属于左边那个筐的,于是我们安顿好pivot值的同时,也能正确的安顿好相遇位置那个点所处的筐。

       

因此,我们的代码是这样写的。

#include "stdafx.h"
#include <iostream>
using namespace std;

void swap(int &a, int&b){
	int temp= a;
	a = b;
	b = temp;
}
void quickSort(int arr[],int length){
	if (length <= 2){	return; }
	int &pivot = arr[0]; int i = 0;	int j = length-1;
	while (i != j){
		while ( (arr[j]>=pivot) && (i<j)) { j--; }
		while ((arr[i]<=pivot) && (i<j))  { i++; }
		swap(arr[i], arr[j]);
	}
	swap(pivot, arr[i]);
	quickSort(arr, i);
	quickSort(arr + i+1, length - i-1);

}
void printArray(int *arr){
	int length = sizeof(arr) / sizeof(arr[0]);
	for (int k = 0; k < length; k++){
		cout << arr[k] << " ";
	}
	cout << endl;
}
int _tmain(int argc, _TCHAR* argv[])
{
	int arr[9] = { 6, 3, 4, 2, 9, 1, 8, 5, 7, };
	int length = sizeof(arr) / sizeof(arr[0]);
	printArray(arr);
	quickSort(arr, length);
	printArray(arr);
	system("pause");
	return 0;
}


快速排序四个为什么不?

C++版本的快排尽管空间上受限制,但是使用了巧妙了两种swap(一次swap是不服管的两个数通过swap安置到正确的筐子,一次swap是在本筐排序结束后成功的将枢轴元素和相遇位置的元素进行了调换)成功的实现了快排算法。讲完了C++和pthon快速排序的思路,我们再复习一下,问几个为什么不?

1  C++版本的为什么不像python版本的一样,从头到尾直接逐个进行比较呢?

原因就是C++版本的实现是限制外存的。假如可以的话,我们需要将元素依次和pivot元素比较,大的放到数组的右侧,小的放到数组的左侧。但是数组原本的位置就有人啊,如果没有外存存储他们的话,我们把比较的元素放进去了,但是原来位置的人怎么办呢? 因为没有外存,我们无法进行类似append的这种操作,一个萝卜就是一个坑,有进必须有出,因此,C++版本的快排要想实现值的更新操作,swap交换的思想是唯一的途径,而我们那种紧凑的和右比,再和左比的思路也是这样来的。


2 如果快排分出来的一个子集是空集或者只有一个元素的集合,那么怎么处理?

python的方法使用的是值传递,这种情况下我们无需用pivot再分割了,因此直接返回空list,或者单元素list就好了。而C++的方法是引用传递,这个时候我们无需用pivot处理了,也就是无需对数组进行任何改动了,直接return;就好


3 为什么C++的while循环都是>=pivot,不带等号不行么?

不带等号就会变成这个样子。会造成不良影响么?

	while (i != j){
		while ( (arr[j]>pivot) && (i<j)) { j--; }
		while ((arr[i]<pivot) && (i<j))  { i++; }
		swap(arr[i], arr[j]);
	}

答案是不会,只不过交换过程有些不同了。不信你可以将C++的程序修改下,看看结果。

为什么,因为没有等于条件,我们的pivot元素就是arr[0]。从右边开始比较是正常进行的,但是从左边比较就有些不同了,i=0的时候,由于arr[0]=pivot,导致关于i的while循环不满足进入条件,i一直等于0,无法++。之后j和i固定,开始进行交换。此时pivot和arr[j](这个arr[j]一定比pivot的值小)进行了交换,此后外层的swap函数进行交换操作,swap(pivot, arr[i]); 由于i一直是0,没有变化,那么其实i和j相遇的地方肯定是0的位置,因此这个语句其实就是pivot自己和自己交换,没有什么作用。

总结来说,这个时候我们每次while循环其实都是将筐子里的最小值替换成了枢轴pivot,然后将这个筐子用pivot切成了两个子集,一个子集是空,另一个子集是少了pivot的那个子集。这个时候我们可以知道,快速排序就退化成了一个每轮都求一个最小值放到数组最左边的过程。尽管结果是正确的,但是呢,递归次数肯定要多很多。

void quickSort(int arr[],int length){
	if (length <= 2){	return; }
	int &pivot = arr[0]; int i = 0;	int j = length-1;
	while (i != j){
		while ( (arr[j]>pivot) && (i<j)) { j--; }
		while ((arr[i]<pivot) && (i<j))  { i++; }
		swap(arr[i], arr[j]);   i
	}
	swap(pivot, arr[i]);
	quickSort(arr, i);
	quickSort(arr + i+1, length - i-1);

}

4 额,当数据重复的时候我们该怎么办?

答案是凉拌。

为什么呢?

因为我每次二分的时候都是根据这个数组的第一个值为枢轴,进行二分的,我们看下面这个循环,右边j先开始比较,while ( (arr[j]>=pivot) && (i<j)) { j--; },什么意思,也就是说右边j如果遇到另一个同样为枢轴的元素,那么他直接跳过,也就意味着这个元素和其他大于pivot的元素一起将被放到右边那个筐子进行比较。

快速排序通过跳过放到最右边那个筐子这种办法,将重复值的两个元素,一个处理为当前筐子的pivot元素,一个则放到右边子筐子里交给后面的递归去处理。因此他成功的将重复的元素进行了分离,和异步处理

	while (i != j){
		while ( (arr[j]>=pivot) && (i<j)) { j--; }
		while ((arr[i]<=pivot) && (i<j))  { i++; }
		swap(arr[i], arr[j]);    
	}
恩,那么问题又来了。

我们刚才知道,在数据不重复的情况,将while循环里面的等号去掉,我们照样可以排序。那么如果是数据重复的情况,这样做还可以么?想想看,在数据不重复的情况下,将等号去掉。由于第一个位置就是枢轴位置,因此i长久停在第一个位置,通过和j进行swap的机会,不断将pivot的值最小化,知道j==i,才进入下一个quickSort递归。

在数据重复的情况下若将等号去掉,会怎么样?首先我们理解这个推论,快速排序是二分的,每个筐无论多小,即使有2个元素,也可以进行快排二分。而重复的值至少有两个,因此我们可以断定,快排递归到某个层次的时候,一定会出现这样的状况:这个筐的枢轴pivot就是重复值,在同一个筐里面还有另一个相同大小的值在。那么会造成什么情况呢?

此前我们知道由于pivot=arr[0]因此i无法向右方移动。如果这个时候pivot为重复值的话,我们早晚会遇到这种情况arr[i]=pivot, arr[j]=pivot这个时候,不管i和j都无法动弹了,但是i!=j,这个时候就会出现里面的两个while循环都进不去,而外层的大while循环又只能不断循环。整个程序就会一直hold,无线等待中。。。。

	while (i != j){
		while ( (arr[j]>pivot) && (i<j)) { j--; }
		while ((arr[i]<pivot) && (i<j))  { i++; }
		swap(arr[i], arr[j]);   
	}


最后的感悟

快排就是二分法,就是用二叉树的思想做决策分类。

python的快排是本质的体现,C++的快排受到外存限制,使用两次比较一次swap的方式进行元素顺序调整。

C++版本数据重复的问题的解决,着眼点是pivot枢轴的重复情况如何解决,由于每次二分是用一个元素去分离一筐元素的,因此快排将重复的一个元素作为pivot处理,将其他的元素分到下个筐去处理了。

while等号添加不添加的问题,最终导致的是在一次二分过程中pivot的值变不变的问题。如果添加了等号,那么遇到pivot值就直接跳过,跳过就不会遇到swap交换pivot元素的情况,这个情况pivot就不变了。而去掉了等号,也就是i始终扎根在第一个元素不走了,那么它在最左侧,因此swap得到的结果一定是让这个值更小的,因此这个时候pivot元素就会不断被修改成筐内的最小值,而用这个最小值去二分的筐,自然就是一个空集,另一个是除了枢轴的其他集合,这样做没有什么意义。其实pivot要做的就是一个标杆而已,各家按照标杆来移动,标杆本身是不需要变化的。






 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值