算法导论总结

算法入门:

关于排序:
 我们为何要研究排序?
1.    有时候应用程序本身需要对信息进行排序。
2.    许多程序把排序程序作为关键子程序。
3.     排序是我们学习编程的最基本的训练,对于程序的优化有很重要的作用。了解排序使得我们可以编写出效率很高的程序。 
我们之前已经学过简单排序法和冒泡排序法。接下来我们介绍一下插入排序和合并排序。
1. 插入排序
输入:n个数(a1,a2,.....an)
输出:输入序列的一个排序(b1,b2....bn),使得b1 <= b2 <=....<=bn

插入排序的机制和打牌时整理手中的牌做法差不多。摸牌的时候,需要将摸到的牌插入到手中一把牌中的正确的位置上。为了要找到这张牌的位置,我们需要将它与手中每张牌从右到左进行比较。无论何时,左手中的牌都是排好序的。

这个算法中,所有的元素都是原地排序(sorted in place),就意味着这些数字就是在数组本身中重新排序的。
算法如下:
1、数组被分为两部分,一部分为排好序的,一部分为未排好序。
2、选取一个key值,就是将要排序的元素,通过比较方式,将其插入到已排好顺序的部分。
3、循环处理,直到该数组全部排好序。

伪代码:

代码(c语言实现):

//a 是一个数组,size_a是这个数组的元素个数
void insertion_sort(int *a,int size_a){
    
    int i,j;
    int key = 0;

    for(j = 1;j < size_a;j++){

        key = a[j];
        i = j - 1;
        while(i >= 0 && a[i] > key){
            a[i + 1] = a[i];
            i = i - 1;
        }
        a[i + 1] = key;
    }

    return ;
}

插入排序算法的时间复杂度是O(n2) 。当然算法的执行速度,和n的大小(输入规模)以及样本的结构有关系。考虑最坏的情况,就是输入的n个数为逆序排列,此时插入排序算法,随着n的增大,运算时间的增长与n2 同数量级。
2.分治法策略
很多算法在结构上是递归的:为了解决一个给定的问题,算法要一次或多次地递归调用其自身来解决相关的子问题。这些算法通常采用分治策略:将原问题划分为n个规模较小而结构与原问题相似的子问题;递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
分治模式在每一层递归上都有三个步骤:
分解(divide):讲原有问题分解成为一系列子问题;
解决(Conquer):递归地解各子问题。若子问题足够小,则直接求解;
合并(Combine):讲子问题的结果合并成原问题的解。

合并排序
合并排序的关键步骤在于合并步骤中的合并两个已排序子序列。为做合并,引入一个辅助过程merge(a,p,q,r),其中a是一个数组,p,q和r是下标,满足p <=q<  r。该过程的子数组 a[p...q]和a[q+1.... r] 都已排好序,并将他们合并成一个已排好的子数组代替当前子数组a[p.....r]。
merge过程的代价是O(n)。其中n = r - p + 1是待合并的元素个数。
以下为合并的伪代码:
为了能检查两个子数组是否是空,其想法是在每一个数组底部放一个“哨兵”,它包含了特殊值,用于简化代码。


具体来说,merge过程是这样工作的:第一行计算子数组a[p...q]的长度n1,第二行计算子数组a[q+1...r]的长度n2.在第三行中,创建了数组L和R,长度各位n1 +1,n2 + 1.第四到第五行中的for循环将子数组a[p...q]复制到L[1....n1]中去。第六到第七行中的for循环将子数组a[q+1...r]复制到R[1....n2]中去。第八九行讲哨兵置于L和R的末尾。第十到第十七行,是合并的具体过程。通过比较,将两子数组按照从小到大的方式合并,存入数组A中。

c语言描述如下所示
void merge(int* a,int p,int q,int r){

    int n1 = q - p + 1;
    int n2 = r - q;
    //为左数组和右数组分配空间,为max预留空间。
    //max为哨兵,标志数组的结束。
    int* L = (int *)malloc(sizeof(int) * (n1 + 1));
    int* R = (int *)malloc(sizeof(int) * (n2 + 1));

    for(int i = 0;i < n1;i++){
        L[i] = a[p + i];
    }
        
    for(int i = 0;i < n2;i++){
        R[i] = a[q + i + 1];
    }

    L[n1] = MAX;
    R[n2] = MAX;
    //从小到大将左数组和友数组合并。 
    int i,j,k;
    for(i = 0,j = 0,k = p;k <= r;k++){
        if(L[i] <= R[j]){
            a[k] = L[i];
            i++;
        }else{
            a[k] = R[j];
            j++;
        }
    }
    free(L);
    L = NULL;
    free(R);
    R = NULL;
    return
}
合并merge过程就可以作为合并排序中的一个子程序来使用。伪代码如下:
c语言描述过程为:
void merge_sort(int* a,int p,int r){
    int q = 0;

    if(p < r){
       q = (p + r) / 2
       merge_sort(a,p,q);
       merge_sort(a,q + 1,r);
       merge(a,p,q,r);
    }
    return ;
}
合并程序的具体图示:

关于分治法排序的简要分析:
我们将一个规模为n的问题,拆分成n个规模为1的子问题。拆分的过程经历了
lg n + 1层,在合并时,每一层的问题规模为n,则总代价为O (n * lg n  + n ) ,忽略低阶项和常数项,因此合并排序法的时间复杂度为O(n lg n)。

接下来会介绍一下堆排序和快速排序。以上的所有排序方法,都是比较排序。也就是说,他们通过对数组元素比较来实现排序。比较排序法是有极限的,从最坏的输入情况,比较排序法的时间复杂度是O(n lg n)。我们介绍的合并排序以及快速排序,都是渐进最优的比较排序方式。我们还会介绍可以突破比较排序极限的排序方式——计数排序。

堆排序

        (二叉)堆数据结构是一种数组对象,它可以被视为一颗完全二叉树。树中每个节点与数组中存放该结点值的那个元素对应。
        二叉堆有两种,最大堆和最小堆。
        最大堆的特性是:除了根节点,每个节点都不会大于其父节点。也就是说,每个节点的子节点的值都不会超过这个节点的值。这样,最大的元素放在根节点处
        最小堆的特征正好相反,除了根节点外,每个节点都不会小于其父节点,也就是说最小的元素放在根节点出。
        在排序算法中,我们通常是使用最大堆,最小堆通常在构造优先队列时使用。
堆可以被看做一棵树,堆的高度为树根的高度。
        为了创建一个最大堆,我们使用堆排序的子程序max_heapify,其输入为一个数组a和下标i。这个子程序的目的是使以i为根的子树成为最大堆。、
length(A)是数组A中元素个数,heap-size(A)是存放A中的堆的元素个数。也就是说,虽然A中可能有元素,但是在heap-size(A)之外的元素都不属于堆。heap-size(A)<= length(A)。LEFT(i) 表示节点i的左孩子的标号,RIGHT(i)表示i节点右孩子的标号。
c语言实现如下:
//heap_size 为一个整型全局变量,保存堆的大小。

void max_heapify(int *a,int i){
    int left    = 2 * i;            //完全二叉树标号为i的左孩子标号为2×i
    int right   = 2 * i + 1;        //完全二叉树标号为i的右孩子标号为2×i + 1

    int largest = 0;

    if( left < heap_size && a[left] > a[i]){
        largest = left;
    }else{
        largest = i;
    }

    if( right < heap_size && a[right] > a[largest]){
        largest = right;
    }

    if( largest != i ){
       a[largest] ^= a[i];
       a[i] ^= a[largest];
       a[largest] ^= a[i];

       max_heapify(a,largest);
    }
}
建堆:
        我们可以自底向上将一个数组构建为一个最大堆。使用完全二叉树的性质,子数组a[(n/2 +1)......n] 中的元素都是树中的叶子节点,因此每个都可以看做是只有一个元素的堆。build_max_heap是对树中每个其他节点都调用一次max_heapify
建堆的过程,时间复杂度为O(n)。当然,我们可以利用最小堆的概念,将这个建堆过程修改为创建一个最小堆。
c语言实现如下:

void build_max_heap(int *a,int size_a){
    int i;
    heap_size = size_a;
    
    for(i = size_a / 2 ;i >= 0;i--){
        max_heapify(a,i);
    }
}


堆排序算法:
        在排序开始前,我们先用build_max_heap,将数组a构造成一个最大堆。此时,数组最大元素就在树根,我们可以把它和最后一个元素互换位置。互换后,这个最大元素就可以从堆中移除,然后调用max_heapify,使新生成的二叉树修正为最大堆。这样,在堆中第二大的元素又会位于二叉树的树根。不断重复,直到堆中的元素降到2。
伪代码如下:
c语言实现如下:
void heap_sort(int *a,int size_a){

    build_max_heap(a,size_a);

    int i;
    for(i = size_a - 1;i >= 1;i--){

        a[0] ^= a[i];
        a[i] ^= a[0];
        a[0] ^= a[i];
        heap_size--;

        max_heapify(a,0);
    }
}


堆排序算法时间代价为O(n lg n)。其中调用build_max_heap的时间为O(n),n-1次heap_max_heapify调用,每次调用时间代价是O(lgn)。

快速排序

快速排序也是一种分治模式。
分解:数组a[p...r] 被分为两个子数组a[p...q - 1 ] 和 a[ q + 1 ... r] 使得a[p...q-1]中的每个元素都小于等于a[q],而且,小于等于a[q + 1 ... r ]中的元素 。
解决:通过递归调用快速排序,对子数组a[p ... q - 1] 和 a[q +1 ... r]排序。
合并:因为两个子数组是就地排序,将他们合并不需要操作。

下面的过程实现了快速排序:
c语言代码:

void quick_sort(int *a,int p,int r){
    int q = 0;

    if(p < r){
        q = partition(a,p,r);
        quick_sort(a,p,q - 1);
        quick_sort(a,q + 1,r);
    }
}
为了排序一个完整数组a,最初调用的是quicksort(a,0,length(a))。
下面是partition过程,它对子数组a[p...r]进行就地排序:

c代码实现:
int partition(int *a,int p,int r){

    int x = a[r];
    int i = p - 1;          // i 标记了数组元素值小于a[r]的位置
    int temp;
 

    for(int j = p;j < r;j++){
        if(a[j] <= x){
            i++;
            temp = a[i];    //不要用异或交换,会出现自己异或清零情况
            a[i] = a[j];
            a[j] = temp;
        }
    }
    temp        =  a[i + 1];
    a[i + 1]    =  a[r];
    a[r]        =  temp;

   
    return i + 1;
}

快速排序的性能:
快速排序的运行时间与划分是否对称有关,而后者又与选择了那个元素来进行划分有关。如果划分是对称的,那么从算法的渐进意义上来讲,就与合并算法一样快,时间复杂度为O(n lg n) 。而如果划分不是对称的,那么算法渐进上就和插入算法一样慢,时间复杂度为O(n2)。

以上介绍的排序算法,都是比较排序算法。我们可以证明对含有n个元素的一个输入序列,任何比较排序算法在最坏的情况下都要用到O(n lg n)次来进行排序。所以,合并排序和堆排序是渐进最优的。

接下来我们来看看三种以线性时间运行的算法:计数排序、基数排序和桶排序。这些算法都用非比较的一些操作来确定排序顺序。

计数排序

计数排序假设n个输入元素中的每个都是介于0到k之间的整数,此处k为某个整数。当k = O(n)时,计数排序的运行时间是O(n)。
计数排序的基本思想就是对每一个输入元素x,确定出小于x的元素个数。有了这一信息,就可以把x直接放到它的最终输出的位置上。
在计数排序算法的代码中,我们假定输入是个数组a[1...n],length[a] = n 。另外还需要两个数组:存放排序的结果放在B[1...n],以及提供临时存储区c[0...k]。
c语言实现:
/*
 * 计数排序 数组a是需要排序的数组,结果放在数组b中
 * k表示数组中最大值
 *
 */

void counting_sort(int *a,const int size_a,int *b,int k){
    int* c = (int*)malloc(sizeof(int) * (k + 1));

    bzero(c,(k + 1)*sizeof(int));

    for(int j = 0;j < size_a;j++){   //c[i]中包含了等于i的元素个数
        c[a[j]]++;
    }

    for(int i = 1;i < k + 1;i++){
        c[i] = c[i] + c[i - 1];      //c[i]中包含了小于或等于i的元素个数
    }
    
    for(int j = size_a - 1;j >= 0;j--){
        b[c[a[j]] - 1] = a[j];
        c[a[j]]--;
    }
    
    free(c);
    c = NULL;
    
    return ;
}


c[i]中包含了关于大小为i的元素的个数信息。例如,当比5小的元素个数为2两个时,5这个元素应该处于的位置为3。所以,我们才能迅速定位正确的排序位置。计数排序的时间复杂度是O(n),其优于我们常用的比较排序。计数排序中根本不出现比较,而是用了输入元素的实际值来确定它在数组中的位置。它需要知道存储在数组中元素的最大值。并且,它需要一个临时数组来存放值域中所有的数值。速度的提升,需要付出空间上的代价。

计数排序的一个重要特性是它是稳定的:具有相同值的元素在输出数组中的相对次序与它们在输入数组中的次序相同。






来自为知笔记(Wiz)


posted on 2013-11-27 15:43  Tmacy  阅读(398)  评论(0编辑  收藏  举报

导航