基于Problem Solving with Algorithms and Data Structures using Python的学习记录(5)——Sorting

来源:互联网 发布:手机身份证ocr识别软件 编辑:程序博客网 时间:2024/05/01 01:02

5.6.排序

排序是以某种顺序从集合中放置元素的过程。例如,单词列表可以按字母顺序或按长度排序。城市列表可按人口,按地区或邮政编码排序。我们已经看到了许多能够从排序列表中获益的算法(回忆之前的回文例子和二分查找)。

有许多开发和分析的排序算法。表明排序是计算机科学的一个重要研究领域。对大量项进行排序可能需要大量的计算资源。与搜索一样,排序算法的效率与正在处理的项的数量有关。对于小集合,复杂的排序方法可能更麻烦,开销太高。另一方面,对于更大的集合,我们希望利用尽可能多的改进。在本节中,我们将讨论几种排序技术,并对它们的运行时间进行比较。

在分析特定算法之前,我们应该考虑可用于分析排序过程的操作。首先,必须比较两个值以查看哪个更小(或更大)。为了对集合进行排序,需要一些系统的方法来比较值,以查看是否有问题。比较的总数将是测量排序过程的最常见方法。第二,当值相对于彼此不在正确的位置时,可能需要交换它们。这种交换是一种昂贵的操作,并且交换的总数对于评估算法的整体效率也将是很重要的。

5.7.冒泡排序

冒泡排序需要多次遍历列表。它比较相邻的项并交换那些无序的项。每次遍历列表将下一个最大的值放在其正确的位置。实质上,每个项“冒泡”到它所属的位置。

Figure 1 展示了冒泡排序的第一次遍历。阴影项正在比较它们是否乱序。如果在列表中有 n 个项目,则第一遍有 n-1 个项需要比较。重要的是要注意,一旦列表中的最大值是一个对的一部分,它将不断地被移动,直到遍历完成。

这里写图片描述

在第二次遍历的开始,现在最大的值已经在正确的位置。有 n-1 个项留下排序,意味着将有 n-2 对。由于每次通过将下一个最大值放置在适当位置,所需的遍历的总数将是 n-1。 在完成 n-1 遍之后,最小的项肯定在正确的位置,不需要进一步处理。 ActiveCode 1 显示完整的 bubbleSort 函数。它将列表作为参数,并根据需要交换项来修改它。

交换操作,有时称为 swap,在 Python 中与在大多数其他编程语言略有不同。通常,交换列表中的两个元素需要临时存储位置(额外的内存位置)。

将交换列表中的第 i 项和第 j 项。没有临时存储,其中一个值将被覆盖。

在Python中,可以执行同时赋值。 语句 a,b = b,a 两个赋值语句同时完成(参见 Figure 2)。使用同时分配,交换操作可以在一个语句中完成。

ActiveCode 1 中的行 5-7 使用先前描述的三步过程执行 i 和第 i + 1 个项的交换。 注意,我们也可以使用同时分配来交换项目。

def bubbleSort(alist):    for passnum in range(len(alist) - 1, 0, -1):        #每趟冒泡下来,alist中的最后一位是有序的        for i in range(passnum):            if alist[i] > alist[i+1]:                alist[i], alist[i+1] = alist[i+1], alist[i]alist = [54, 26, 93, 17, 77, 31, 44, 55, 20]bubbleSort(alist)print(alist)

为了分析气泡排序,我们应该注意,不管项如何在初始列表中排列,将进行 n-1 次遍历以排序大小为 n 的列表。 Figure 1 展示了每次通过的比较次数。比较的总数是第 n-1 个整数的和。回想起来,前 n 个整数的和是 1/2n^2 + 1/2n。 第 n-1 个整数的和为 1/2n^2 + 1/2n -n,其为 1/2n^2 - 1/2n。 这仍然是 O(n^2 )比较。在最好的情况下,如果列表已经排序,则不会进行交换。 但是,在最坏的情况下,每次比较都会导致交换元素。 平均来说,我们交换了一半时间。

这里写图片描述

冒泡排序通常被认为是最低效的排序方法,因为它必须在最终位置被知道之前交换项。 这些“浪费”的交换操作是非常昂贵的。 然而,因为冒泡排序遍历列表的整个未排序部分,它有能力做大多数排序算法不能做的事情。特别地,如果在遍历期间没有交换,则我们知道该列表已排序。 如果发现列表已排序,可以修改冒泡排序提前停止。这意味着对于只需要遍历几次列表,冒泡排序具有识别排序列表和停止的优点。 ActiveCode 2 展示了这种修改,通常称为短冒泡排序

def shortBubbleSort(alist):    exchanges = True    passnum = len(alist) - 1    while passnum > 0 and exchanges:        exchanges = False        for i in range(passnum):            #当alist的剩余项不是相对有序(前一项小于下一项)的时候            if alist[i] > alist[i+1]:                #此时需要交换                exchanges = True                alist[i], alist[i+1] = alist[i+1], alist[i]        passnum = passnum - 1alist = [20, 30, 40, 90, 50, 60, 70, 80, 100, 110]shortBubbleSort(alist)print(alist)alist = [54, 26, 93, 17, 77, 31, 44, 55, 20]shortBubbleSort(alist)print(alist)

这里写图片描述

5.8.选择排序

选择排序改进了冒泡排序,每次遍历列表只做一次交换。为了做到这一点,一个选择排序在他遍历时寻找最大的值,并在完成遍历后,将其放置在正确的位置。与冒泡排序一样,在第一次遍历后,最大的项在正确的地方。 第二遍后,下一个最大的就位。遍历 n-1 次排序 n 个项,因为最终项必须在第(n-1)次遍历之后。

Figure 3 展示了整个排序过程。在每次遍历时,选择最大的剩余项,然后放置在其适当位置。第一遍放置 93,第二遍放置 77,第三遍放置 55 等。 该函数展示在 ActiveCode 1 中。

这里写图片描述

def selectionSort(alist):    for fillslot in range(len(alist)-1, 0, -1):        positionOfMax = 0        #使用location作为下标遍历alist的剩余项        for location in range(1, fillslot + 1):            #当alist中的当前项大于alist中的已知最大项的时候            if alist[location] > alist[positionOfMax]:                #将最大项的下标替换为当前项                positionOfMax = location        #当遍历结束后,将最大项放到剩余list的最后一项(交换剩余项的最后一项与最大项)        alist[fillslot], alist[positionOfMax] = alist[positionOfMax], alist[fillslot]alist = [54, 26, 93, 17, 77, 31, 44, 55, 20]selectionSort(alist)print(alist)

你可能会看到选择排序与冒泡排序有相同数量的比较,因此也是 O(n^2 )。 然而,由于交换数量的减少,选择排序通常在基准研究中执行得更快。 事实上,对于我们的列表,冒泡排序有 20 次交换,而选择排序只有 8 次。

这里写图片描述

5.9.插入排序

插入排序,尽管仍然是 O(n^2 ),工作方式略有不同。它始终在列表的较低位置维护一个排序的子列表。然后将每个新项 “插入” 回先前的子列表,使得排序的子列表称为较大的一个项。Figure 4 展示了插入排序过程。 阴影项表示算法进行每次遍历时的有序子列表。

这里写图片描述

我们开始假设有一个项(位置 0 )的列表已经被排序。在每次遍历时,对于每个项 1至 n-1,将针对已经排序的子列表中的项检查当前项。当我们回顾已经排序的子列表时,我们将那些更大的项移动到右边。 当我们到达较小的项或子列表的末尾时,可以插入当前项。

Figure 5 详细展示了第五次遍历。在该算法中的这一点,存在由 17,26,54,77 和 93 组成的五个项的排序子列表。我们插入 31 到已经排序的项。第一次与 93 比较导致 93 向右移位。 77 和 54 也移位。 当遇到 26 时,移动过程停止,并且 31 被置于开放位置。现在我们有一个六个项的排序子列表。

def insertionSort(alist):    for index in range(1,len(alist)):        currentvalue = alist[index]        position = index        # 从位置alist[index-1]开始向左遍历到alist[0]        for j in range(index - 1, -1, -1):            if currentvalue < alist[j]:  # 若遇到大于a[i]的元素,将其右移(此时a[i]"悬空")                alist[j + 1] = alist[j]                position -= 1  # 位置标记左移一位        alist[position] = currentvaluealist = [54, 26, 93, 17, 77, 31, 44, 55, 20]insertionSort(alist)print(alist)

这里写图片描述

5.10.shell排序

shell排序(有时称为“递减递增排序”)通过将原始列表分解为多个较小的子列表来改进插入排序,每个子列表使用插入排序进行排序。 选择这些子列表的方式是shell排序的关键。不是将列表拆分为连续项的子列表,shell排序使用增量i(有时称为 gap),通过选择 i 个项的所有项来创建子列表。

这可以在 Figure 6 中看到。该列表有九个项。如果我们使用三的增量,有三个子列表,每个子列表可以通过插入排序进行排序。完成这些排序后,我们得到如 Figure 7 所示的列表。虽然这个列表没有完全排序,但发生了很有趣的事情。 通过排序子列表,我们已将项目移动到更接近他们实际所属的位置。

这里写图片描述

Figure 8 展示了使用增量为 1 的插入排序; 换句话说,标准插入排序。注意,通过执行之前的子列表排序,我们减少了将列表置于其最终顺序所需的移位操作的总数。对于这种情况,我们只需要四次移位完成该过程。

这里写图片描述

这里写图片描述

我们之前说过,增量的选择方式是 shell排序的独特特征。 ActiveCode 1中展示的函数使用不同的增量集。在这种情况下,我们从 n/2 子列表开始。下一次,n/4 子列表排序。 最后,单个列表按照基本插入排序进行排序。 Figure 9 展示了我们使用此增量的示例的第一个子列表。

#交换不相邻的而元素以此对数组进行局部排序,并最终用插入排序将局部有序的数组排序def shellSort(alist):    h = 1    while h < len(alist)/3:        h = h*3 + 1    while h >= 1:        for i in range(h, len(alist)):            for j in range(i, h-1, -h):                if alist[j] < alist[j-h]:                    alist[j], alist[j-h] = alist[j-h], alist[j]        h = int(h/3)alist = [54, 26, 93, 17, 77, 31, 44, 55, 20]shellSort(alist)print(alist)

乍一看,你可能认为 shell 排序不会比插入排序更好,因为它最后一步执行了完整的插入排序。 然而,结果是,该最终插入排序不需要进行非常多的比较(或移位),因为如上所述,该列表已经被较早的增量插入排序预排序。 换句话说,每个遍历产生比前一个“更有序”的列表。 这使得最终遍历非常有效。

虽然对 shell 排序的一般分析远远超出了本文的范围,我们可以说,它倾向于落在 O(n) 和 O(n^2 ) 之间的某处,基于以上所描述的行为。对于 Listing 5中显示的增量,性能为 O(n^2 ) 。 通过改变增量,例如使用2^k -1(1,3,7,15,31等等),shell排序可以在 O(n^3/2 )处执行。

这里写图片描述

5.11.归并排序

我们现在将注意力转向使用分而治之策略作为提高排序算法性能的一种方法。 我们将研究的第一个算法是归并排序。归并排序是一种递归算法,不断将列表拆分为一半。 如果列表为空或有一个项,则按定义(基本情况)进行排序。如果列表有多个项,我们分割列表,并递归调用两个半部分的合并排序。 一旦对这两半排序完成,就执行称为合并的基本操作。合并是获取两个较小的排序列表并将它们组合成单个排序的新列表的过程。 Figure 10 展示了我们熟悉的示例列表,它被mergeSort 分割。 Figure 11 展示了归并后的简单排序列表。

这里写图片描述

ActiveCode 1 中展示的 mergeSort 函数从询问基本情况开始。 如果列表的长度小于或等于1,则我们已经有有序的列表,并且不需要更多的处理。另一方面,长度大于 1,那么我们使用 Python 切片操作来提取左右两半。 重要的是要注意,列表可能没有偶数个项。这并不重要,因为长度最多相差一个。

def mergeSort(alist):    if len(alist) <= 1:        return alist    left = mergeSort(alist[:len(alist)//2])    right = mergeSort(alist[len(alist)//2:])    merged = []    while len(left) > 0 and len(right) > 0:        merged.append(left.pop(0) if left[0] <= right[0] else right.pop(0))    merged.extend(mergeSort(left) if len(left) > 0 else mergeSort(right))    return mergedalist = [54, 26, 93, 17, 77, 31, 44, 55, 20]alist = mergeSort(alist)print(alist)

一旦在左半部分和右半部分(行8-9)上调用 mergeSort 函数,就假定它们已被排序。函数的其余部分(行11-31)负责将两个较小的排序列表合并成一个较大的排序列表。请注意,合并操作通过重复从排序列表中取最小的项目,将项目逐个放回原始列表(alist)。

mergeSort 函数已经增加了一个打印语句(行2),以显示在每次调用开始时排序的列表的内容。 还有一个打印语句(第32行)来显示合并过程。 脚本显示了在我们的示例列表中执行函数的结果。 请注意,44,55 和 20的列表不会均匀分配。第一个分割出 [44],第二个 [55,20]。 很容易看到分割过程最终产生可以立即与其他排序列表合并的列表。

为了分析 mergeSort 函数,我们需要考虑组成其实现的两个不同的过程。首先,列表被分成两半。我们已经计算过(在二分查找中)将列表划分为一半需要 log^n 次,其中 n 是列表的长度。第二个过程是合并。列表中的每个项将最终被处理并放置在排序的列表上。因此,大小为 n 的列表的合并操作需要 n 个操作。此分析的结果是 log^n 的拆分,其中每个操作花费 n,总共 nlog^n 。归并排序是一种 O(nlogn) 算法。

回想切片 是 O(k),其中 k 是切片的大小。为了保证 mergeSort 是 O(nlog^n ),我们将需要删除 slice 运算符。这是可能的,如果当我们进行递归调用,我们简单地传递开始和结束索引与列表。我们把这作为一个练习。

重要的是注意,mergeSort 函数需要额外的空间来保存两个半部分,因为它们是使用切片操作提取的。如果列表很大,这个额外的空间可能是一个关键因素,并且在处理大型数据集时可能会导致此类问题。

这里写图片描述

def position(alist, leftMark, rightMark):    flag = alist[leftMark]    j = leftMark    for i in range(leftMark+1, rightMark+1):        if alist[i] < flag:            j += 1            alist[j], alist[i] = alist[i], alist[j]    alist[leftMark], alist[j] = alist[j], alist[leftMark]    return jdef quickSort(alist, leftMark, rightMark):    if leftMark >= rightMark:        return    mark = position(alist, leftMark, rightMark)    quickSort(alist, leftMark, mark-1)    quickSort(alist, mark+1, rightMark)alist = [54, 26, 93, 17, 77, 31, 44, 55, 20]quickSort(alist, 0, len(alist)-1)print(alist)

这里写图片描述

这里写图片描述

5.13.总结

  • 对于有序和无序列表,顺序搜索是 O(n)。
  • 在最坏的情况下,有序列表的二分查找是 O(log^n )。
  • 哈希表可以提供恒定时间搜索。
  • 冒泡排序,选择排序和插入排序是 O(n^2 )算法。
  • shell排序通过排序增量子列表来改进插入排序。它落在 O(n) 和 O(n^2 ) 之间。
  • 归并排序是 O(nlog^n ),但是合并过程需要额外的空间。
  • 快速排序是 O(nlog^n ),但如果分割点不在列表中间附近,可能会降级到O(n^2 ) 。它不需要额外的空间。
0 0
原创粉丝点击