浅谈快速排序法的一些小细节

说实话在下笔之前我的心中有一篇宏大的计划。作为一个深受快速排序法荼毒的辣鸡,每次遇到这样的问题心里总是无数个疑问,乃至于各种想法层出不穷,所以题目虽是浅谈,我却是想好好梳理一番,但限于能力(或者是想今天能早点入睡),还是只能浅谈。写这篇博文的动机是这样的。

引子

今天遇到了一个中规中矩的算法题目,来自华为的面试题。题目是这样的,在不事先排好序的情况下如何寻找一个数组中的中位数。这个问题看似简单,实际上并非那么容易。因为据我的印象,我们寻找一组数中的中位数就是先把它们排好序的,然后从头数到中间位置(如果是奇数则为第(n+1)/2位数,如果是偶数则是第n/2和(n/2+1)位数的平均值)。有些读者也许会跟我抬杠,不是啊,我怎么一下就看出来了?那请你不要吝啬自己的笔,多取几个数,直到你不能一眼看出来为止,再去思考一下怎么解决这个问题。

好吧,其实这个问题就很好地用到了快速排序法的分割区间的思想。我在这里为了节省时间,就不再复习快速排序法的细节了,今后我会在排序法汇总里再好好去聊一下它。这里也并不完全用到了快速排序法,但是非常类似。前面代码几乎可以复制快速排序法的大部分代码。这题的主要想法是这样的:
(1)设计一个函数partition,它有三个参数arr, low, high,分别表示数组,数组索引下限和数组索引上限。一开始,把数组的第一个元素标记为分点partialpoint,再同时设计两个指针i和j,分别从low位置和high位置递增和递减;
(2)在i<j的前提下:首先遍历j,如果arr[j]>partialpoint, j-=1,直到arr[j]<=partialpoint为止;
(3)遍历i,如果arr[i] <= partialpoint, i+=1, 直到arr[i] > partialpoint为止;
(4)交换arr[i], arr[j]。返回步骤(2),如果i>=j,执行(5);
(5)将arr[j]与arr[low]交换;

以上步骤完成以后,会发现原本定义为分点的那个元素不在原来的位置上了(一般情况下,当然如果这个元素是最小值,那仍待在原地),它被移到了数组中的某个位置,并且满足在分点左边的元素都小于这个分点的值,在这个分点右边的元素都大于等于这个分点的值。在后面的操作中,这个分点的位置再也不会改变了。
这也就是说,如果这个分点所在的位置正好时数组的二分位置的话,它就是中位数,我们返回这个分点的值就好了。但一般情况下不会那么巧合。但我刚才说了,每做一次(1)-(5),就会确定一个分点的位置且不会再改变。之所以不再改变,是因为下面的步骤:

(6)完成1-5后,看这个时候分点的索引位置index是多少,跟(n-1)//2比较,(考虑n是数组长度,因为索引总等于习惯上元素位置-1),若相等,返回找到的中位数;若小于(n-1)//2,则递归调用函数partition,此时把数组中第二个参数改成index+1;否则,递归调用函数partition,此时吧数组中第三个参数改成index-1。
(7)当找到这个数之后,它还不一定是中位数,此时考虑len(array)的奇偶性,若为奇数,则它是中位数,返回之;若为偶数,则返回float((arr[index]+arr[index+1])/2),这里使用强制数据类型转换主要是因为可能会出现小数位。

到这里,这个问题就已经解决了。但这并不是我的主要目的(所以你究竟想说啥?)之前说了,这里基本上是快速排序法代码的复用。那么我们分别贴一下这个问题的代码跟快排的代码,看看区别在哪里:

#如何在不排序的情况下求数组中的中位数

def partition(arr, low, high):
    partialpoint = arr[low]
    i = low
    j = high
    while j > i:
        while j > i and arr[j] > partialpoint:
            j -= 1
        while j > i and arr[i] <= partialpoint:
            i += 1
        arr[j], arr[i] = arr[i], arr[j]
    arr[low] = arr[j]
    arr[j] = partialpoint
    return j


if __name__ == "__main__":
	arr = [1, 7, 4, 3, 3, 1]
	print("The original array is:\n", arr)
	low = 0
	high = len(arr) - 1
	n = len(arr)
	while True:
		index = partition(arr, low, high)
		print(index)
		if index == (n-1)//2:
			break
		elif index > (n-1)//2:
			high = index - 1
		else:
			low = index + 1
	print(arr)
	print(arr[index])
	if len(arr) % 2 == 1:
		midNum = arr[index]
	else:
		midNum = float((arr[index] + arr[index+1])/2)
	print("The middleNum of the array is", midNum)

代码运行结果如下:

The original array is:
 [1, 7, 4, 3, 3, 1]
1
4
3
2
[1, 1, 3, 3, 4, 7]
3
The middleNum of the array is 3.0

接下来贴下快速排序法的参考代码:

def quickSortMethod(arr, low, high):
    if low <= high:
        partialpoint = arr[low]
        i = low
        j = high
        while i < j:
            while i < j and arr[j] > partialpoint:
                j -= 1
            while i < j and arr[i] <= partialpoint:
                i += 1
            arr[i], arr[j] = arr[j], arr[i]
        arr[low] = arr[j]
        arr[j] = partialpoint
        quickSortMethod(arr, low, j-1)
        quickSortMethod(arr, j+1, high)
        return arr 

测试及结果如下:

arr = [11, 13, 34, 22, 21, 45, 13, 43]
print("The original array is:\n", arr)
arr2 = quickSortMethod(arr, 0, len(arr)-1)
print("The present array is:\n", arr2)
The original array is:
 [11, 13, 34, 22, 21, 45, 13, 43]
The present array is:
 [11, 13, 13, 21, 22, 34, 43, 45]

可以看出代码重复率相当高。
区别在哪呢?首先,注意到快速排序法是要将数组排序好的,所以它会进行到最后一个分点在它该在的位置,这是一个递归函数,递归边界便是low>high(也就是第二行给它的逻辑反过来),而找中位数算法则不必,当然你也可以那样做,但是没有必要。只要找到我们想找到的分点位置就好了。另外,快排算法本身是一个递归函数,但partition这个函数并不是递归函数,只是在main里递归调用了而已。除了这些,感觉也没啥区别了。好了,这个算法可以告一段落了,接下来主谈快速排序算法。

快速排序法的小细节

不知道大家有没有困扰过。如果是自己编过的话,大概是能够体会这里的很多不解。比如说,在设计递归边界的时候,可以是low >= high吗?这似乎是可行的,因为在low = high 的时候也就是说明此时的数组只有一个元素了,它也没有办法再变化位置了啊。那么这样把第二句的“if low <= high”改成"if low < high "是否真的可行呢?还有,在遍历的时候,一般都是先移动尾指针j,再移动头指针i,可以颠倒顺序吗?还有,在程序的while循环里能不能把i<j改成i<=j呢?诸如此类的问题其实是很有趣的。

def quickSortMethod(arr, low, high):
    if low <= high:
        partialpoint = arr[low]
        i = low
        j = high
        while i < j:
            while i < j and arr[j] > partialpoint:
                j -= 1
            while i < j and arr[i] <= partialpoint:
                i += 1
            arr[i], arr[j] = arr[j], arr[i]
        arr[low] = arr[j]
        arr[j] = partialpoint
        quickSortMethod(arr, low, j-1)
        quickSortMethod(arr, j+1, high)
        return arr 

首先第一个问题,为什么写作low <= high 而不写作 low < high?应该说,前面的写法是没什么问题的,后面的写法,可能会有些问题。一般来说,如果只是单纯地去解决排序问题,至少目前我没有发现有什么问题,因为当要处理的数组只有一个元素的时候,是不存在交换的,它也交换不到哪儿去,所以也就可以不用管它。但是如果不是这样,则可能会有问题。下面是我在用快速排序法的思想解决一个具体数组问题时发现的问题:

#如何找出数组中第k小的数?
#采用快速排序法平均意义上是最快的,时间复杂度为O(N*log2N)

def findSmallK(arr, low, high, k):
	if k > len(arr):
		print("The k's value is out of the range of the array.")
		return
	if low < high:
		i = low
		j = high
		tmp = arr[low]
		while i < j:
			while i < j and arr[i] <= tmp:
				i += 1
			while j >= i and arr[j] >= tmp:
				j -= 1
			if i < j:
				arr[i],arr[j] = arr[j],arr[i]
				print(array)
		arr[low] = arr[j]
		arr[j] = tmp
        #记录分点在arr[low-high]中下标的偏移量
		index = j - low
		if index == k - 1:
			return arr[j]
		elif index > k - 1:
			return findSmallK(arr, low, j-1, k)
		elif index < k - 1:
			return findSmallK(arr, j+1, high, k-(index+1))

我并不打算谈论这个算法的细节,读者只要注意到此时我把第五行的"if low <= high"里的等号去掉就好了。
这里是希望找到数组中第k小的数,我们考虑在一个长度为三的数组中找到第二小的数组

array = [11, 10, 2]
print("The original array is\n", array)
k = 2		#第k小的数,注意k不能超过数组的长度
element = findSmallK(array, 0, len(array)-1, k)
print("The present array is\n", array)
print("The",k,"-th smallest number in this array is",element,".")

注意到最后一句应该是要输入第二小的数是10的,然而结果:

The original array is
 [11, 10, 2]
The present array is
 [2, 10, 11]
The 2 -th smallest number in this array is None .

这是一个非常明显的错误,追溯其原因,实际上是因为我们为了省事,在待排序数组中只有一个元素的时候忽略它,直接结束程序。那么这里数组[10]实际上就被忽略了,也就是findSmallK(arr, 1,1)的返回值实际上是None。所以说为了避免这种不必要的麻烦,我们一定不要吝惜等号的作用,不要想着为程序省事,这往往会带给你一些令你抓狂的结果。

在这里插入图片描述

下一个问题,程序中while循环里的条件i<j能不能改成i<=j呢?
要解决这个问题,就要思考i == j的情况是怎样的。如果i == j即跳出循环执行后续操作会不会有什么问题。
事实上是会的,只要考虑分点元素是当前数组的最小的元素就不行:

def quickSortMethod(arr, low, high):
    if low <= high:
        partialpoint = arr[low]
        i = low
        j = high
        while i <= j:
            while i <= j and arr[j] > partialpoint:
                j -= 1
            while i <= j and arr[i] <= partialpoint:
                i += 1
            arr[i], arr[j] = arr[j], arr[i]
        arr[low] = arr[j]
        arr[j] = partialpoint
        quickSortMethod(arr, low, j-1)
        quickSortMethod(arr, j+1, high)
        return arr 
The original array is:
 [11, 16, 16, 22, 13, 13, 13, 43]

---------------------------------------------------------------------------
IndexError                                Traceback (most recent call last)
<ipython-input-26-b5ed97d36e9e> in <module>()
      1 arr = [11, 16, 16, 22, 13, 13, 13, 43]
      2 print("The original array is:\n", arr)
----> 3 arr2 = quickSortMethod(arr, 0, len(arr)-1)
      4 print("The present array is:\n", arr2)

<ipython-input-25-0e6b489c66f5> in quickSortMethod(arr, low, high)
     13         arr[j] = partialpoint
     14         quickSortMethod(arr, low, j-1)
---> 15         quickSortMethod(arr, j+1, high)
     16         return arr

<ipython-input-25-0e6b489c66f5> in quickSortMethod(arr, low, high)
     13         arr[j] = partialpoint
     14         quickSortMethod(arr, low, j-1)
---> 15         quickSortMethod(arr, j+1, high)
     16         return arr

<ipython-input-25-0e6b489c66f5> in quickSortMethod(arr, low, high)
     13         arr[j] = partialpoint
     14         quickSortMethod(arr, low, j-1)
---> 15         quickSortMethod(arr, j+1, high)
     16         return arr

<ipython-input-25-0e6b489c66f5> in quickSortMethod(arr, low, high)
     13         arr[j] = partialpoint
     14         quickSortMethod(arr, low, j-1)
---> 15         quickSortMethod(arr, j+1, high)
     16         return arr

<ipython-input-25-0e6b489c66f5> in quickSortMethod(arr, low, high)
     13         arr[j] = partialpoint
     14         quickSortMethod(arr, low, j-1)
---> 15         quickSortMethod(arr, j+1, high)
     16         return arr

<ipython-input-25-0e6b489c66f5> in quickSortMethod(arr, low, high)
     13         arr[j] = partialpoint
     14         quickSortMethod(arr, low, j-1)
---> 15         quickSortMethod(arr, j+1, high)
     16         return arr

<ipython-input-25-0e6b489c66f5> in quickSortMethod(arr, low, high)
     13         arr[j] = partialpoint
     14         quickSortMethod(arr, low, j-1)
---> 15         quickSortMethod(arr, j+1, high)
     16         return arr

<ipython-input-25-0e6b489c66f5> in quickSortMethod(arr, low, high)
      9             while i <= j and arr[i] <= partialpoint:
     10                 i += 1
---> 11             arr[i], arr[j] = arr[j], arr[i]
     12         arr[low] = arr[j]
     13         arr[j] = partialpoint

IndexError: list index out of range

溢出。这是因为j找不到自己的位置了于是就放飞自我了。
因此这个i <= j的等号是否需要的争论也可以休矣。

第三个问题:先遍历i和先遍历j会有影响吗?

def quickSortMethod(arr, low, high):
    if low <= high:
        partialpoint = arr[low]
        i = low
        j = high
        while i < j:
            while i < j and arr[i] <= partialpoint:
                i += 1
            while i < j and arr[j] > partialpoint:
                j -= 1
            arr[i], arr[j] = arr[j], arr[i]
        arr[low] = arr[j]
        arr[j] = partialpoint
        quickSortMethod(arr, low, j-1)
        quickSortMethod(arr, j+1, high)
        return arr 

考虑极端情况[1, 1, 1, 1, 2]:

arr = [1, 1, 1, 1, 2]
print("The original array is:\n", arr)
arr2 = quickSortMethod(arr, 0, len(arr)-1)
print("The present array is:\n", arr2)
The original array is:
 [1, 1, 1, 1, 2]
The present array is:
 [1, 1, 1, 2, 1]

排序显然是错误的。
这是因为i想要停留的位置是大于1的元素的位置,这种情况下它走到了尾部并且仍然不是i想停留的位置,只是限于i<j的条件才被迫停止,而j此时已经没有遍历的机会了,只能硬着头皮跟partialpoint交换,而这实际上是没有考虑到j的感受的。因为j所在的位置并不是它想要在的位置,这是典型的遗漏。那么为什么之前的程序就没有发生这样的问题呢?

因为arr[j]如果能与分点partialpoint交换的话,必须保证此时的arr[j] <= partialpoint,而且arr[j]前面的元素已经被i遍历过了。因为交换的是j所在的位置元素arr[j],我们希望主动权能够掌握在j上,所以j要先走,这样j才能始终待在它想待的地方,如果j先走到i与i相遇,i要么没走,要么就是在满足arr[i] < partialpoint的这个位置,两种情况都没有问题。即使是i先走到j的位置,j所在的位置元素已经是和i交换后的了,也就是满足arr[j] <= partialpoint的。这就是为什么让j先遍历没有问题的原因。换句话说,这个问题事实上是肯定可以做到的,只是需要修改后面的代码,只要严格限制i停留的位置,交换i与partialpoint各自的元素即可。至于j是不是总在正确的位置上我们并不关心。

总之,partialpoint并不是非要与尾指针交换,或者跟头指针交换,这不是必须的,但交换时,i和j必须有一个呆在各自合法的位置上。如果两者都能严格遵守规定,那么交换哪一个其实是无所谓的。

最后想说的是,即使是快速排序法这样简单的代码,也是可以有其他花样多,它的双指针并不会非要从两端出发,而是可以同向出发。甚至指针的速度快慢都可以设置。这就不是这篇博文所能阐述清楚的了。

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值