新年伊始,万象更新

这周是狗年春节,祝大家狗年旺旺旺。

第六章

基础的排序方法

为了我们的第一次在排序算法领域的征途,我们需学习一些基础的方法,这些基础方法适合一些小文件,或者适合一些特殊结构的文件。以下列出一些原因为什么我们需要详细地学习这些简单的排序算法。第一,它们提供环境,能够让我们理解一些排序算法的术语和基本原理,从而达到让我们拥有一个充足的背景知识去学习更复杂的算法。第二,在很多排序应用里,这些简单的方法实际上比那些强大通用的方法更加高效。第三,这些简单方法让那些强大通用的方法变得更好,也还可以给那些复杂方法提高效率。
这一章的目的不仅仅是为了介绍基础的排序算法,而且还开发一个排序框架可以让我们在后续的章节使用。我们会看看适合应用排序算法的不同情况,而且使用不同的输入文件,和寻找其他方式来对比排序方法并学习它们的属性。

游戏规则

在考虑具体的算法之前,我们先讨论通用术语和排序算法的一些假设,这会对我们有帮助。我们假设排序文件的方法里,都是含有键。这些概念都是现代编程环境中很自然的抽象。键(items),是项(items)的一部分,被用于控制排序。排序方法的目的时重新整理这些项,让它们的键根据一些定义好的排序规则来排序(通常是数字从大到小或字母顺序)。在不同的应用中,键和项的特性有很大的不同,但将键和与之关联的信息放入排序中是排序问题的特征。
如果要被排序的文件能都在内存里,这种排序方法被称为内部排序(internal)。而如果文件来自硬盘等外部设施,称为外部排序(external sorting)。这两者主要的区别是内部排序可以容易地访问任何项,而外部排序必须顺序的访问(access items sequentially)或者至少是一个大块(in large blocks)。我们会在第11章看一些外部排序,但我们更多考虑的算法是内部排序。
我们会考虑数组和链式列表。数组排序问题和链式列表排序问题都很有趣:在开发我们的算法的时候,我们会遇到一些基础任务非常适合序列配置,而一些基础任务适合链式列表的配置。一些经典方法因足够抽象,所以可以被实现在数组或链式列表。而其他同样带限制式访问的数据类型,一样有趣(Other types of access restrictions are also sometimes of interest)。
一开始,我们将专注于数组排序。程序6.1 展示许多我们实现的风格。一个驱动程序的构成包括:通过读取标准输入或生成随机的整型来填充数组,然后调用一个排序函数将这些整型进行排序,然后输出排序后的结果。

/**
程序6.1
这个程序展示约定,用于实现基础数组排序。main方法是一个驱动,用于初始化整型(随机生成或来自标准输入)。,然后调用一个排序函数将这些整型进行排序,然后输出排序后的结果。   
这个程序的排序函数是插入排序的一个版本(具体可看章节8.3),定义了要被排序的东西所对应的数据类型为项(Item),和为了项定义了些操作:操作less(比较两个key),操作exch(交换两个项),和操作compexch(比较两个项并如果后一项大于前一项时交换) 。我们在代码里通过typeof和简单宏实现Item是整型。其他数据类型是章节8.7主题,但这些并不影响排序。
**/
#include <stdio.h>
#include <stdlib.h>

//考虑移植性的问题,才把类型都定义为Item,这就可以随时改为浮点float都可以
typedef int Item;

#define key(A) (A)
//使用宏的一个好处,不用考虑参数的类型
#define less(A, B) (key(A) < key(B))
#define exch(A, B)  \
    {               \
    Item t = A; \
    A = B;      \
    B = t;      \
    }
#define compexch(A, B) \
    if(less(B, A))     \
    exch(A, B)
void sort(Item a[], int l, int r)
{
    int i, j;
    for(i = l + 1; i <= r; i++) {
    for(j = i; j > l; j--) {
        compexch(a[j - 1], a[j]);
    }
    }
}

main(int argc, char* argv[])
{
    int i, N = atoi(argv[1]), sw = atoi(argv[2]),j=0;
    int* a = malloc(N * sizeof(int));
    printf("%d items\n",N);
    if(sw)
    for(i = 0; i < N; i++) {
        a[i] = 1000 * (1.0 * rand() / RAND_MAX);
        printf("%d item is %d\n",i,a[i]);
    }
    else
/** 不知道是不是我的知识不够,书本上的例子这段代码运行不起来
    while(scanf("%d", &a[N]) == 1)
        N++; 
         */
    while(j<N){
        scanf("%d",&a[j++]);
    }
    //2.调用插入排序的函数对这些整型进行排序
    sort(a, 0, N - 1);
    //3.输出排序后的结果
    for(i = 0; i < N; i++)
        printf("%5d", a[i]);
    printf("\n");
}

我们从第3章和第4章可以知道,有很多机制可以让我们使得这些排序实现也能适用于其他数据类型。我们会在章节6.7进行讨论。程序6.1使用了像我们章节4.1讨论那样的简单的行内数据,排序仅仅通过传入的参数和一些简单的数据操作。通常,这些方法允许我们使用同样的代码去排序其他数据类型。举个栗子,如果将程序6.1代码中的生成数据、存储数据、和输出随机keys中的整型替换为浮点,仅仅做出的改变就是将main方法外面的typedef的int改为float即可。为了提供如此灵活性,我们保留项中的数据类型,以不指定的数据类型的项进行排序。暂时,我们可以认为项就是int或float;在章节6.7中,我们考虑详细的数据类型的实现,允许我们使用我们的 排序实现 来排序任意项,包括浮点,数值,字符串,和其他类型的keys,我们可以用第3章和第4章说的来实现。
通常,我们最初的感兴趣的性能参数是我们算法的运行耗时。选择排序、插入排序、和冒泡排序会在章节6.2至6.4讨论,章节6.5讨论它们的时间复杂度,都是N^2。在第7章我们讨论的更高级的方法,通过IO排序N项只需NlogN,但这个方法不是一直都是很好的方法,需考虑较小的N值和特定的情况。在章节6.6,我们看一下更高级的方法(希尔排序shellsort),时间复杂度为N3/2 甚至更少。在章节6.10,我们看到一种特定的方法(索引键排序key-indexed排序),时间复杂度仅仅是N
额外内存的使用量也是排序算法中我们该考虑的第二重要的因素。基本上,这些方法可以分为三类:一些是不需要额外内存,除了也许用了一些小栈(a small stack)或表(table); 一些链式列表或通过指针数组坐标关联的数据,仅仅需要额外的内存给N个指针或数组坐标;一些需要足够的额外内存来保存需要排序的数组的副本。
我们经常使用排序方法来排序多个键的项——我们可能甚至排序一个集合的项通过多次访问不同keys。在这种情况,对于我们很重要的是,需意识到我们用的排序方法是否有一下属性:

定义6.1 一个排序方法是稳定的,则表明如果存在多个keys的情况下,保留了之前的顺序。 (我感觉就是通过项关联多个key)

6.2 选择排序(Selection Sort)

一个最简单排序算法如下。
第一,从数组中找到最小的值,与第一个位置的元素交换。
然后,从数组中找到第二小的值,与第二个位置的元素交换。
然后一直持续,直到整个数组都排好序。
这个方法被称为选择排序,是因为它的工作原理是重复在剩余的元素中选择最小的元素。程序6.2是该算法的实现。

//程序6.2
#include <stdio.h>
#include <stdlib.h>

//考虑移植性的问题,才把类型都定义为Item,这就可以随时改为浮点float都可以
typedef int Item;

#define key(A) (A)
//使用宏的一个好处,不用考虑参数的类型
#define less(A, B) (key(A) < key(B))
#define exch(A, B)  \
    {               \
    Item t = A; \
    A = B;      \
    B = t;      \
    }
#define compexch(A, B) \
    if(less(B, A))     \
    exch(A, B)
 

void selection(Item a[],int l,int r){
    int i,j;
    for(i=l;i<r;i++){
        int min = i;
        for(j=i+1;j<=r;j++)
            if(less(a[j],a[min])) min=j;
        exch(a[i],a[min]);
    }
}

main(int argc, char* argv[])
{
    int i, N = atoi(argv[1]), sw = atoi(argv[2]),j=0;
    int* a = malloc(N * sizeof(int));
    printf("%d items\n",N);
    if(sw)
    for(i = 0; i < N; i++) {
        a[i] = 1000 * (1.0 * rand() / RAND_MAX);
        printf("%d item is %d\n",i,a[i]);
    }
    else
/** 不知道是不是我的知识不够,书本上的例子这段代码运行不起来
    while(scanf("%d", &a[N]) == 1)
        N++; 
         */
    while(j<N){
        scanf("%d",&a[j++]);
    }
    //2.调用排序的函数对这些整型进行排序 
    printf("use selection sort method\n");
    selection(a,0,N - 1);
    //3.输出排序后的结果
    for(i = 0; i < N; i++)
        printf("%5d", a[i]);
    printf("\n");
}

程序6.2 是一个实现选择排序。内循环仅仅是比较当前元素与最小值。它非常简单。在内循环之后,交换元素,将此元素放入它最终的位置。所以交换次数是N-1(最后元素是不用交换,在调用此方法时传入的就是N-1),而外部循环的N-2次,因为第。运行耗时主要在逐个元素的比较上。在章节6.5,我们会展示选择排序的时间复杂度为N2,然后使用更加准确地预测总的耗时,和把选择排序与其他基本排序方法比较比较。 依次类推,共需要进行的比较次数是∑ =(n-1)+(n-2)+…+2+1=n(n-1)/2,即进行比较操作的时间复杂度为O(n2)。

A disadvantage of selection sort is that its running time depends only slightly on the amount of order already in the file.
此选择排序算法不好之处在于,它的运行时间依赖于数组是否已经排好序。在一轮找到最小值的过程中并没有给什么信息给下一轮去找新的最小值。例如,无聊是已经有顺序的数组,还是都全都相等的数组,或随机生成但已经排好序的数组,它们的耗时都一样。后面我们会看到,其他方法在这种排好序的情况下会更好。
尽管选择排序简单且暴力的方法,但它在一种很重要的应用中是好过那些复杂的方法:就是要排序的文件是巨大的items和小的keys。在这种应用里,比较成本(遍历成本)比移动的数据成本少,没有什么算法比排序算法移动更少的数据。for such application, the cost of moving the data dominates the cost of making comparisons, and no algorithm can sort a file with substantially less data movement than selection sort
选择排序在排序方法中的重要参数:
时间复杂度 N^2
内存使用量:不需要额外的内存
稳定性:稳定

Java实现版本:

package com.jc.algortithms.sortMethods;

import java.util.Random;


/**
 * 选择排序
 * <p>
 * 1. 从数组中找到最小的元素,与第一个位置的元素交换
 * <p>
 * 2. 然后从第二个位置开始找,找到最小的,与第二个位置的元素交换
 * <p>
 * 3. 然后一直持续,外围的i只需遍历到倒数第二个元素,内循环就要从i开始一直遍历到最后一个元素
 * <p>
 * 时间复杂度为N^2
 */
public class SelectionSort {


    public static void sort(int a[]) {
        System.out.println("after selection sort");
        for (int i = 0; i < a.length - 1; i++) {
            int min = i;
            for (int j = i + 1; j < a.length; j++) {
                if (a[j] < a[min])
                    min = j;
            }
            Sort.exch(a, i, min);
        }

    }

    public static void main(String[] args) {
        Random random = new Random();
        int a[] = new int[10];

        System.out.println(a.length + " items");
        for (int i = 0; i < a.length; i++) {
            a[i] = random.nextInt(1000);
            System.out.println(i + " item is " + a[i]);
        }

        sort(a);

        for (int e : a) {
            System.out.printf("%5d", e);
        }

    }
}

6.3 插入排序 Insertion Sort

bridge hands 一手牌
人们常常使用该方法来给自己一手牌排序,每次只考虑一个元素,并将它插入进已经排好序的元素之间(要保持排好的顺序)。在计算机实现这种方法,我们需要将大一点的元素都往右移一个位置空出位置来插入一个元素。前面程序6.1实现的就是这种方法,叫插入排序insertion sort。
就像选择排序,在排序的时候,当前坐标(索引、下标)的左边都是已经排好序的元素,但跟选择排序不同的是,插入排序的这些排好序的元素所在的位置并不是它们最终的位置,可能后续遇到更小的元素,还是需要腾出位置来让给最小的元素。只有当坐标(索引、下标)到达最右边时,才是完整的排序。
程序6.1实现的插入排序,很直接而且也不高效。我们现在考虑三个途径去优化,这也是我们实现的重复出现的主题:我们的目标是简洁、明了、而且还高效的代码,但这些目标往往是冲突的,所以我们必须从中找到个平衡。首先我们先自然地实现,然后再寻找优化(变形)方式,然后再校验每个优化(变形)方式。
第一,因为(当前坐标的)左边的子数组都是有序的,所以我们可以一旦遇到要被插入的key大于遇到的key则可以停止执行compexch方法。所以在程序6.1,如果less(a[j-1],a[j])返回true,我们就可以中断内部循环。这个修改将6.1改为了适应式排序,并且让程序更快,快速因子是2,具体可看程序6.2。(This modification changes the implementation into an adaptive sort, and speeds up the program by about a factor of 2 for randomly ordered keys)
根据前面一段落描述优化方法,我们有两种情况可以中断内部循环——我们重写代码,利用while循环来相应这个实际变化。一个更为微妙的改进是,我们不需要再判断j<l:这是因为第一步已将最小的放在第一个位置。一个常用的选择是保持a[1]到a[N]是有序的,和将哨兵键(sentinel key)在a[0]位置上,让a[0]上的值时最小的。然后判断是否遇到更小的key,这样把内部循环变得更小而且更快。程序6.3是该改进的实现。

/**
 * 程序6.3
 * 此代码是程序6.1的改进,改进的第一点就将最小的元素放入数组中的第一个位置,将此元素作为哨兵。  
 * 第二点,在内循环中, 只是简单的赋值语句,而不是交换。  
 * 第三点,结束内循环的时候,才把元素插入进内循环后的位置。  
 * 每次循环i,它都是在排好序的且大于a[i]的a[l],...,a[i-1]里移动一个位置,然后再将a[i]放入合适的位置,a[l]...a[i]的排序就完成
 */

#include <stdio.h>
#include <stdlib.h>

//考虑移植性的问题,才把类型都定义为Item,这就可以随时改为浮点float都可以
typedef int Item;

#define key(A) (A)
//使用宏的一个好处,不用考虑参数的类型
#define less(A, B) (key(A) < key(B))
#define exch(A, B)  \
    {               \
    Item t = A; \
    A = B;      \
    B = t;      \
    }
#define compexch(A, B) \
    if(less(B, A))     \
    exch(A, B)
void insertionSort(Item a[], int l, int r)
{
    int i;
    for(i=l+1;i<=r;i++)
        compexch(a[l],a[i]);
    
    for(i = l + 2; i <= r; i++) {
        int j = i;
        Item v = a[i];
        while(less(v,a[j-1])){
            a[j] = a[j-1];
            j--;
        }
        a[j] = v;
    }
}

main(int argc, char* argv[])
{
    int i, N = atoi(argv[1]), sw = atoi(argv[2]),j=0;
    int* a = malloc(N * sizeof(int));
    printf("%d items\n",N);
    if(sw)
    for(i = 0; i < N; i++) {
        a[i] = 1000 * (1.0 * rand() / RAND_MAX);
        printf("%d item is %d\n",i,a[i]);
    }
    else
/** 不知道是不是我的知识不够,书本上的例子这段代码运行不起来
    while(scanf("%d", &a[N]) == 1)
        N++; 
         */
    while(j<N){
        scanf("%d",&a[j++]);
    }
    //2.调用插入排序的函数对这些整型进行排序
    insertionSort(a, 0, N - 1);
    //3.输出排序后的结果
    for(i = 0; i < N; i++)
        printf("%5d", a[i]);
    printf("\n");
}

哨兵(sentinel)有时候不太好使用:可能很小的值不太好定义,或者可能程序里没有额外的位置去存放此额外的键(perhaps the calling routine has no room to include an extra key)。程序6.3展示了一个途径来解决插入排序的两个问题:做一次而且也是第一次遍历数组,将最小的值与第一个位置交换,然后再排序剩下的数组,而第一项就是担当哨兵的角色。我们通常应该避免代码中的哨兵,因为通过显式的测试来理解代码通常更容易,但是我们应该注意到,在使程序更简单和更高效的情况下,哨兵可能是有用的(we generally shall avoid sentinels in our code, because it is often easier to understand code with explicit tests, but we shall note situations where sentinels might be useful in making programs both simpler and more efficient.)。
第三个改进就是,我们考虑在内循环中移除掉无关的指令。我们考虑到连续交换同样的元素是不高效的。例如我们有两个及以上交换,就会有

t=a[j]; a[j]=a[j-1]; a[j-1]=t;

然后再就

t=a[j-1]; a[j-1]=a[j-2]; a[j-2]=t;

如此来推。在这两次交换中,t其实是没有变的,但我们却花了时间去保存(交换)它,然后再下次交换中读取它。程序6.3移动了大量的元素往右移一个位置,而不是用交换,从而减少了浪费的时间。
同样是插入排序,程序6.3比程序6.1更高效9(在章节6.5,我们将会看到这个改进是差不多两倍的速度)。在这本书里,我们对简介高效的算法和简洁高效的算法实现都很感兴趣。在这个例子中,底层算法都不太一样——我们应该把程序6.1的排序函数称为非适应式插入排序(nonadaptive insertion sort)会更合适一些。对算法的属性有一个很好的理解是开发一个可以在应用程序中有效使用的实现的最佳指南(a good understanding of the properties of an algorithm is the best guide to developing an implementation that can be used effectively in an application)。
与选择排序不同,插入排序的时间取决于所输入的值的排序情况。例如,一个文件很大和keys都已经排序好(或者说接近于排好序),这时插入排序会很快,选择排序会很慢。在章节6.5我们会更详细地对比这两种算法。
插入排序在排序方法中的重要参数:
时间复杂度 N^2
内存使用量:不需要额外的内存
稳定性:稳定

6.4 冒泡排序(Bubble Sort)

冒泡排序可能是大家学的第一个排序,因为它够简单:不停的遍历数组,交换相邻的元素(没有排好序相邻的元素),一直持续到数组被排序好。冒泡排序的主要功能很容易实现,但实际是否比插入排序或选择排序更容易,这就存在争议了。冒泡排序比其他两个方法(插入和选择)更慢,但为了完整性,我们简单地说一下冒泡排序。
我们总是从右到左的遍历数组。第一轮,我们不关什么时候会遇到最小的元素,但我们总是把最小的元素与它左边的元素交换,最终将最小的元素放入数组的最左边。在第二轮,第二小的元素会放到最终的位置,如此类推。因此,几轮过后,冒泡排序的操作就像一种选择排序,尽管冒泡排序做了很多操作才把每一个元素放入最终的位置。程序6.4是该算法的实现。

/**
* 程序6.4
* 对于从l到r-1的每个i,内循环inner(j)将a[i],....,a[r]中最小的值放入a[i]中,持续地比较交换元素。
* 最小值从右往左一直在比较,就好像“冒泡”到最右边。  
* 就像在选择排序中,坐标i从左到右的遍历。在坐标的左边的元素都是在最终的位置
* /
#include <stdio.h>
#include <stdlib.h>

//考虑移植性的问题,才把类型都定义为Item,这就可以随时改为浮点float都可以
typedef int Item;

#define key(A) (A)
//使用宏的一个好处,不用考虑参数的类型
#define less(A, B) (key(A) < key(B))
#define exch(A, B)  \
    {               \
    Item t = A; \
    A = B;      \
    B = t;      \
    }
#define compexch(A, B) \
    if(less(B, A))     \
    exch(A, B)
void bubbleSort(Item a[], int l, int r)
{
    int i, j;
    for(i=l;i<r;i++){
        for(j=r;j>i;j--)
            compexch(a[j-1],a[j]);
    }
}

main(int argc, char* argv[])
{
    int i, N = atoi(argv[1]), sw = atoi(argv[2]),j=0;
    int* a = malloc(N * sizeof(int));
    printf("%d items\n",N);
    if(sw)
    for(i = 0; i < N; i++) {
        a[i] = 1000 * (1.0 * rand() / RAND_MAX);
        printf("%d item is %d\n",i,a[i]);
    }
    else
/** 不知道是不是我的知识不够,书本上的例子这段代码运行不起来
    while(scanf("%d", &a[N]) == 1)
        N++; 
         */
    while(j<N){
        scanf("%d",&a[j++]);
    }
    //2.调用冒泡排序的函数对这些整型进行排序
    bubbleSort(a, 0, N - 1);
    //3.输出排序后的结果
    for(i = 0; i < N; i++)
        printf("%5d", a[i]);
    printf("\n");
}

我们可以类似章节6.3那样优化插入排序地去优化程序6.4。对比代码,程序6.4出现了非适应式插入排序(nonadaptive insertion sort)。不过不同的是,程序6.1的插入排序是移动左边已经排好序的元素,而冒泡排序是移动右边未被排序的元素。

程序6.4只使用compexch指令和非适应式插入排序(nonadaptive insertion sort),当我们还是可以改进它,让它更高效,当数组是已经接近排好序,通过一轮遍历即可知道不用交换(所以如果数组时已经排好序的,我们就可以中断外部循环)。加上这些改进,会让冒泡排序在一些数据会快一些,但一般它不会像插入排序那样中断内循环后那么块,这一点会在章节6.5讨论。
冒泡排序在排序方法中的重要参数:
时间复杂度 N^2
内存使用量:不需要额外的内存
稳定性:稳定

参考:
Algorithms in C, Parts 1-4_ Fundamentals, Data Structures, Sorting, Searching (3rd Edition) (Pts. 1-4).pdf


电脑杂技集团
208 声望32 粉丝

这家伙好像很懂计算机~