📜  Python中的快速排序Quicksort

📅  最后修改于: 2020-09-03 04:53:21             🧑  作者: Mango

介绍

Quicksort是一种流行的排序算法,经常与Merge Sort一起使用。这是高效排序算法的一个很好的例子,平均复杂度为O(nlogn)。其受欢迎程度的部分原因还在于易于实施

我们将在本文的第一部分中使用简单的整数,但是将给出一个示例,说明如何更改此算法以对自定义类的对象进行排序。

快速排序是三种类型的排序算法的代表:分而治之就地,和不稳定的

  • 分而治之:Quicksort将数组拆分为较小的数组,直到最后得到一个空数组或一个只有一个元素的数组,然后递归地对较大的数组进行排序。
  • 到位:Quicksort不会创建该数组或其任何子数组的任何副本。但是,它所做的所有递归调用都需要堆栈内存。
  • 不稳定:一种稳定的排序算法是一种具有相同值的元素在排序数组中的出现顺序与在排序数组之前相同的相对顺序。一种不稳定的排序算法并不能保证这一点,它可以当然会发生,但不能保证。

当您对对象而不是原始类型进行排序时,这一点变得很重要。例如,假设您有几个Person具有相同对象的对象age,即Dave年龄21岁,Mike年龄21岁。如果要对同时包含Dave和Mike的集合(按年龄排序)使用Quicksort,则不能保证Dave会出现每次您运行算法时都要先于Mike,反之亦然。

快速排序

该算法的基本版本执行以下操作:

通过采用伪随机元素并将其用作支点,将集合分为两个(大致相等)部分。

小于枢轴的元素将移至枢轴的左侧,大于枢轴的元素将移至枢轴的右侧。

对枢轴左侧的集合以及枢轴右侧的元素数组重复此过程,直到对整个数组进行排序为止。

当我们将元素描述为比另一个元素“大”或“小”时-不一定意味着更大或更小的整数,我们可以按选择的任何属性进行排序。

如果我们有一个自定义类Person,并且每个人都有一个nameage,则可以按name(按字典顺序)或按年龄(按升序或降序)排序。

Quicksort如何运作

Quicksort通常不会将数组分成相等的部分。这是因为整个过程取决于我们如何选择支点。我们需要选择一个枢轴,使其大约大于元素的一半,因此比元素的另一半要小。虽然看起来很直观,但是很难做到。

仔细考虑一下-如何为阵列选择合适的支点?Quicksort的历史上已经提出了许多有关如何选择枢轴的想法-随机选择一个元素,这是行不通的,因为选择一个随机元素的“昂贵”方式虽然不能保证良好的枢轴选择,但它是无效的。从中间挑选一个元素;选择第一个,中间和最后一个元素的中位数;甚至更复杂的递归公式。

最简单的方法是简单地选择第一个(或最后一个)元素。具有讽刺意味的是,这导致Quicksort在已排序(或几乎已排序)的阵列上表现很差。

这就是大多数人选择实施Quicksort的方式,并且由于它很简单并且选择枢轴的这种方式是非常有效的操作(并且我们将需要重复执行),这正是我们将要做的。

现在我们已经选择了一个支点-我们将如何处理它?同样,有几种方法可以进行分区本身。我们将有一个指向枢轴的“指针”,一个指向“较小”元素的指针和一个指向“较大”元素的指针。

目标是移动元素,以便所有小于枢轴的元素都在其左侧,而所有较大元素都在其右侧。越来越大的元素并不一定要排序,我们希望它们位于轴的正确一侧。然后,我们递归地遍历枢轴的左侧和右侧。

逐步查看我们计划做的事情将有助于说明该过程。使用下面显示的数组,我们选择了第一个元素作为枢轴(29),紧随其后的是指向较小元素的指针(称为“低”),而指向较大元素的指针(称为“高”)从结尾开始。

  • 29是第一个枢轴,低点99点至44

29 | 99(低),27,41,66,28,44,78,87,19,31,76,58,88,83,97,12,21,44 (高)

  • 我们high向左移动,直到找到一个低于轴心的值。

29 | 99(低),27,41,66,28,44,78,87,19,31,76,58,88,83,97,12,21 (高),44

  • 现在我们的变量指向21,这是一个比枢轴更小的元素,我们想要在数组的开头附近找到一个可以交换它的值。交换一个也小于枢轴的值没有任何意义,因此,如果low指向一个较小的元素,我们会尝试找到一个较大的元素。
  • 我们将变量向右移动,直到找到一个大于枢轴的元素。幸运的是,low已经定位在99上
  • 我们交换低位高位

29 | 21(低),27,41,66,28,44,78,87,19,31,76,58,88,83,97,12,99 (高),44

  • 我们这样做之后,我们进入到左边,向右(因为2199,现在是在正确的地方)
  • 再次,我们向左移,直到达到一个低于枢轴的值,即刻找到-12
  • 现在我们搜索量比较大的值支点通过移动到右侧,我们发现在第一次这样的价值41

这个过程一直持续到指针和指针最终在单个元素中相遇为止:

29 | 21,27,12,19,28 (低/高),44,78,87,66,31,76,58,88,83,97,41,99,44

  • 我们不再使用此枢轴,因此剩下的唯一要做的就是交换枢轴高位,然后完成此递归步骤:

28,21,27,12,19,29,44,78,87,66,31,76,58,88,83,97,41,99,44

如您所见,我们已经实现了所有小于29的值现在都在29的左侧,所有大于29的值都在右侧。

然后,算法对28,21,27,12,19(左侧)集合和44,78,87,66,31,76,58,88,83,97,41,99,44做相同的操作(右侧)集合。

实作

排序数组

Quicksort是一种自然的递归算法-将输入数组划分为较小的数组,将元素移至枢轴的适当侧,然后重复。

让我们看一下一些递归调用的外观:

  • 首次调用该算法时,我们考虑所有元素-从索引0n-1,其中n是数组中元素的数量。
  • 如果我们的枢轴最终在位置k处结束,则对元素0k-1以及从k + 1n-1的元素重复该过程。
  • 在将元素从k + 1排序到n-1时,当前的枢轴将终止于某个位置p。然后,我们将元素从k + 1排序为p-1,将p + 1排序为n-1,依此类推。

话虽如此,我们将利用两个函数- partition()quick_sort()。该quick_sort()函数将首先partition()收集集合,然后在分割的部分上递归调用自身。

让我们从partition()函数开始:

def partition(array, start, end):
    pivot = array[start]
    low = start + 1
    high = end

    while True:
        # If the current value we're looking at is larger than the pivot
        # it's in the right place (right side of pivot) and we can move left,
        # to the next element.
        # We also need to make sure we haven't surpassed the low pointer, since that
        # indicates we have already moved all the elements to their correct side of the pivot
        while low <= high and array[high] >= pivot:
            high = high - 1

        # Opposite process of the one above
        while low <= high and array[low] <= pivot:
            low = low + 1

        # We either found a value for both high and low that is out of order
        # or low is higher than high, in which case we exit the loop
        if low <= high:
            array[low], array[high] = array[high], array[low]
            # The loop continues
        else:
            # We exit out of the loop
            break

    array[start], array[high] = array[high], array[start]

    return high

最后,让我们实现quick_sort()功能:

def quick_sort(array, start, end):
    if start >= end:
        return

    p = partition(array, start, end)
    quick_sort(array, start, p-1)
    quick_sort(array, p+1, end)

在实现它们的同时,我们可以quick_sort()在一个简单的数组上运行:

array = [29,99,27,41,66,28,44,78,87,19,31,76,58,88,83,97,12,21,44]

quick_sort(array, 0, len(array) - 1)
print(array)

输出:

[12, 19, 21, 27, 28, 29, 31, 41, 44, 44, 58, 66, 76, 78, 83, 87, 88, 97, 99]

由于该算法不稳定,因此无法保证这两个44彼此是按照此顺序排列的。也许原来是切换了-尽管这在整数数组中意义不大。

排序自定义对象

您可以通过几种方法重写此算法以对Python中的自定义对象进行排序。一个非常Python化的方式是实行比较运营商给定类,这意味着我们实际上并不需要改变,因为该算法的实现>==<=等会我们的类对象上也工作。

另一个选择是允许调用者向我们的算法提供一种方法,然后该方法将用于执行对象的实际比较。以这种方式重写算法以用于自定义对象非常简单。但是请记住,该算法不稳定。

让我们从一个Person类开始:

class Person:
    def __init__(self, name, age):
        self.name = name
        self.age = age

    def __str__(self):
        return self.name

这是一个非常基本的类,只有两个属性nameage。我们希望将其age用作排序键,这将通过为排序算法提供自定义lambda函数来完成。

但首先,让我们看看如何在算法中使用此提供的函数。与其直接与<=or >=运算符不做直接比较,我们而是调用函数来告诉is哪个Person年龄更大: 

def partition(array, start, end, compare_func):
    pivot = array[start]
    low = start + 1
    high = end

    while True:
        while low <= high and compare_func(array[high], pivot):
            high = high - 1

        while low <= high and not compare_func(array[low], pivot):
            low = low + 1

        if low <= high:
            array[low], array[high] = array[high], array[low]
        else:
            break

    array[start], array[high] = array[high], array[start]

    return high
def quick_sort(array, start, end, compare_func):
    if start >= end:
        return

    p = partition(array, start, end, compare_func)
    quick_sort(array, start, p-1, compare_func)
    quick_sort(array, p+1, end, compare_func)

现在,让我们对这些对象的集合进行排序。您可以看到对象比较是quick_sort通过lambda 提供给调用的,该lambda可以对age属性进行实际比较: 

p1 = Person("Dave", 21)
p2 = Person("Jane", 58)
p3 = Person("Matthew", 43)
p4 = Person("Mike", 21)
p5 = Person("Tim", 10)

array = [p1,p2,p3,p4,p5]

quick_sort(array, 0, len(array) - 1, lambda x, y: x.age < y.age)
for person in array:
    print(person)

输出为: 

Tim
Dave
Mike
Matthew
Jane

通过以这种方式实现算法,只要我们提供适当的比较功能,它就可以与我们选择的任何自定义对象一起使用。

Quicksort的优化

鉴于Quicksort对给定数组的“一半”进行独立排序,这对于并行化非常方便。我们可以有一个单独的线程来对数组的每个“一半”进行排序,并且理想情况下,我们可以将排序所需的时间减半。

但是,如果我们在选择数据透视表时特别不走运,则Quicksort可能具有非常深的递归调用堆栈,并且并行化不如合并排序有效。

建议使用简单的非递归算法对小数组进行排序。甚至插入排序之类的简单操作在小型阵列上也比Quicksort更为有效。因此,理想情况下,我们可以检查子数组是否只有少量元素(大多数建议说的元素数少于或等于10),如果是,则可以使用插入排序对其进行排序。

Quicksort的一个流行变体是Multi-pivot Quicksort,它使用n-1个枢轴将原始数组分解为n个较小的数组。但是,大多数时候只使用两个枢轴,而不是更多。

有趣的事实:Java 7的排序实现中使用了Dual-pivot Quicksort,以及用于较小数组的插入排序。

结论

如前所述,Quicksort的效率在很大程度上取决于数据透视的选择-它可以“制造或破坏”算法的时间(和堆栈空间)复杂性。使用自定义对象时,算法的不Solidity也可能会破坏交易。

然而,尽管如此,Quicksort的平均时间复杂度O(n * log n)以及相对较低的空间使用率和简单的实现,使其成为一种非常有效且流行的算法。