【耀阳的读书笔记】算法导论(1)_一切始于排序

来源:互联网 发布:qq影音 知乎 编辑:程序博客网 时间:2024/05/21 02:36

一切始于排序

必须要说的是,这是我第三次打开算法导论这本书,它是如此的难(至少对我来说)以至于如果两次打开这本书的时间间隔超过1个月,就必须从头再次看起,才能慢慢找回思路。
所以我决定找回高中毕业后就丢掉的记笔记的习惯,以保持学习的连贯性。


目录

  • 一切始于排序
    • 目录
    • 什么是算法
    • 什么是算法分析
    • 输入规模和运行时间
    • 一切始于排序
      • 插入排序
        • 循环不变式
        • 运行时间分析
        • 最坏情况和平均情况
        • 增长量级
        • 插入排序的C语言实现
        • 插入排序的Python实现
      • 归并排序
        • 分治思想
        • 归并排序的循环不变式
        • 归并排序的执行时间
          • 分治算法的分析
          • 归并排序算法的分析
        • 归并排序的C语言实现
        • 归并排序的Python实现
  • 未完待续
  • 下一篇将会讨论一些数学问题

什么是算法

  • 书上说,算法就是任何良定义的计算过程,该过程取某个值或值的集合作为输入,并产生某个值或值的集合作为输出。简单说,就是把输入转换成输出的计算步骤的一个序列,看起来很好理解。

什么是算法分析

  • 算法本身很重要,但是本书的精华是算法分析,如果算法解决的是“是什么”的问题,算法分析就是解决“为什么”的问题,我们在实践中,往往关注算法本身,遇到一个问题,找到解决这个问题的算法,很少去关心为什么是这个算法,有没有更高效的算法,甚至不太关注为什么这个算法是对的。算法导论就是解决这些问题的,它关注的不仅是解决问题,而是更正确、更高效的解决问题。
  • 分析算法的结果意味着预测算法需要的资源。这里的资源更多的是指算法的运行时间,而不是内存、通信带宽或其他的计算机硬件资源。
  • 这里需要一点点的计算机结构和运行原理知识,一个算法之所以能够被分析,是基于算法序列(计算机指令)一条接一条的执行这个通用的计算模型(RAM模型,这里的RAM指计算机的指令集)。这些指令包括了像加、减、乘、除、取余等,还包括移位、控制指令(条件转移和无条件转移、子程序调用和返回)。这些指令的执行所需的时间均为常量(试想,如果存在一条排序指令,只需调用该指令,就可以完成排序,看起来很省事,但是它使得算法分析变的无比复杂且难以预测,事实上,基于冯诺依曼思想的任何计算机都不可能有这种指令);在RAM模型中,数据类型的字长是有限的,如果有汇编语言基础,这很好理解。

输入规模和运行时间

  • 一般来说,算法需要的时间与输入规模同步增长,所以通常把一个算法的运行时间描述成输入规模的函数。因此,需要定义“输入规模”。
  • 输入规模是依赖于具体问题的概念。如排序或离散傅立叶变换,会很自然的以输入的项数作为输入规模;对于其他问题,如两个整数相乘,最佳的输入规模度量是用二进制表示的输入所需要的总位数(因为在计算机指令中,乘法往往是对应于移位指令);如果输入是一个图,那么输入规模可以用图的顶点数和边数来描述。可以看到,在不同问题中,输入规模所代表的维度是不一样的。
  • 运行时间,是指执行一个算法所需要的基本操作数或步数,这样定义使得算法的运行时间跟具体的计算机硬件无关。

一切始于排序

排序算法简单且易于分析,所以通常算法的书籍都会以排序算法开始以解释一些必要的概念和分析方法基础。所以认真的分析排序算法是必要的!下面通过两种排序算法来学习一些基本的算法分析知识。

插入排序

插入排序是如此的简单,以至于我们可以随手写出它的伪代码。
插入排序的伪代码:

insert_sort(a) //a是一个数组1 for j = 2 to a.lengths2   key = a[j]3   i = j - 14   while i > 0 and a[i] > key5       a[i + 1] = a[i]6       i = i - 17   a[i + 1] = key

循环不变式


根据上面的伪代码,可以很容易的看出
1. a[1…j-1]是已排好序的序列。
2. a[j]是将要插入到已排好序的序列的元素。
3. a[1…j-1]就是原来数组1…j-1的元素(但是已排好序)。
上述性质,我们称为一个循环不变式。即每一次循环,都满足上面的三条性质。
循环不变式经常被用来证明算法的正确性。
  

如何证明一个循环不变式?
这里用到了数学归纳法
1. 初始化。循环之初,循环不变式为真。
2. 保持。假设某次循环,循环不变式为真,那么下一次循环仍为真。
3. 终止。循环结束时,得到预期的结果。
数学归纳法可知,当j = 1时,a[1…j-1]中只有一个元素,显然是排好序的,所以循环不变式成立;对于a[j]来说a[1…j-1]是排好序的,插入操作就是将a[1…j-1]依次右移,以找到a[j]的位置,找到后,a[1…j-1]变成a[1…j],所以2也成立;循环终止的条件是j=n+1,此时a[1…j-1]变为a[1…n],根据前两条,此时已排好序,因此,算法正确。

运行时间分析


根据RAM模型,执行每一行伪代码需要的时间是常量时间。那么假定执行1-7行的单次时间依次是常量c1-c7,在输入规模是n(a.length)的情况下,1-7行执行的次数分别是n, n-1, n-1, nj=2tj, nj=2(tj1), nj=2(tj1), n-1。其中tj表示第j次for循环的时候,其中的while循环执行的次数。
根据上述分析,我们知道,在输入规模为n的时候插入排序的运行时间T(n)为:

T(n) = c1*n + c2*(n-1) + c3*(n-1) + c4*nj=2tj + c5*nj=2(tj1) + c6*nj=2(tj1)+ c7*(n-1)

但是很显然,插入排序算法的运行时间依赖于给定的是该规模下的什么样的输入序列,这样就涉及到最好情况和最坏情况。
最好情况:输入的序列本身已经是排好序的,那么T(n)简化为:

T(n) = c1*n + c2*(n-1) + c3*(n-1) + c4(n-1) + c7*(n-1) = (c1+c2+c3+c4+c7)*n – (c2+c3+c4+c7)

该运行时间可以表示为T(n)=an+b,其中系数a,b依赖于常量c1-c7。因此它是n的线性函数
最坏情况:输入的序列是完全反向的,那么对于每一次循环,a[j]都要与
a[1…j-1]进行比较,那么每次循环tj = j,此时nj=2(tj)=n(n+1)21nj=2(tj1)=n(n1)2,T(n)变为:

T(n)=c1n+c2(n1)+c3(n1)+c4((n(n+1)21)+c5n(n1)2+c6n(n1)2+c7(n1)=(c4/2+c5/2+c6/2)n2+(c1+c2+c3+c4/2c5/2c6/2)n(c2+c3+c4+c7)

运行时间可以表示为T(n)=an2+bn+c, a,b,c依赖于常量c1-c7。因此,它是n的二次函数

最坏情况和平均情况


为什么我们只关注最坏情况?
1. 一个算法最坏情况的运行时间给出了任何输入的运行时间的上界。知道这个上界的意义重大,这意味着我们不必再做任何的复杂的分析猜测以期望得到更好的结果。
2. 在实际应用中,最坏情况往往更容易出现。最典型的是在查询数据库时,要查询的特定信息不在数据库中,则查询算法往往以最坏情况出现。
3. 我们可能更希望计算平均情况,然而在忽略了低阶项、系数和常数项后(当输入规模足够大时),平均情况往往更接近最坏情况。以插入排序为例,规模为n时,对于任意成员a[j],平均要j/2次查询才能找到它在a[i…j-1]中的位置,也就是将T(n)中的tj=j/2,简化后,T(n)也是n的二次函数。
  
更普遍的情况下,会引入概率分析来用于各种算法,因为对于特定的问题,什么是平均情况往往不容易确定。

增长量级


我们真正感兴趣的是运行时间随输入规模的增大而变化的增长率或增长量级,所以,只考虑T(n)中最重要的项an2,当n的值足够大时,低阶项和常数项都不重要。
这个时候,我们记插入排序的运行时间为Θ(n2),Θ是一种渐近符号,确定了函数的上界和下界,后续章节会详细介绍。

插入排序的C语言实现

void insertSort(int a[], const unsigned int length) {    int i = 0;    int j = 0;    int temp = 0;    if(length <= 1) {        return ;    }    for(i = 1; i < length; ++i) {        temp = a[i];        j = i - 1;        while((j >= 0) && (a[j] > temp)) {            a[j + 1] = a[j];            j = j - 1;        }        a[j + 1] = temp;    }    return ;}

插入排序的Python实现

def insert_sort(a):    for i in range(len(a)):        temp = a[i]        j = i - 1        while j >= 0 and a[j] > temp:            a[j + 1] = a[j]            j = j - 1        a[j + 1] = temp

归并排序

分治思想


分治思想后面会有专门的章节讨论,这里简单提到。
为了解决一个问题,算法一次或多次的递归调用自身以解决紧密相关的若干个子问题。

分治思想简单的理解为:为了解决一个复杂的问题,将原问题分解为几个规模较小但是类似于原问题的子问题,递归地求解这些子问题,然后再合并这些子问题的解来建立原问题的解。

归并排序就是运用分治思想的典型的算法。

归并排序的循环不变式


如果对于递归有较深的理解,可以比较容易的写出归并排序的伪代码。
归并排序的伪代码:

/*合并函数*/Merge(a,p,q,r)  //a是数组,p、q、r是数组下标,满足p<=q<r。且数组a[p,q] 和a[q+1, r]都是已排好序的数组。1   n1 = q-p+12   n2 = r-q3   let L[1…n1+1] and R[1…n2+1] be new arrays4   for i=1 to n15       L[i] = a[p+i-1]6   for j=1 to n27       R[j] = a[q+j]8   L[n1+1] = ∞ //∞用作“哨兵”,遍历到代表本数组的所有成员已放入排好序的数组中9   R[n2+1] = ∞10  i=111  j=112  for k=p to r13      if(L[i] < R[j])14          a[k]=L[i]15          i=i+116      if(L[i] > R[j])17          a[k] = R[j]18          j=j+1/*归并排序的主算法*/Merge_sort(a,p,r)  //a表示数组,p,r是数组下标,p <= r1   if p < r2      q=⌊(p+r)/2 //⌊⌋表示向下取整3      Merge_sort(a, p, q)4      Merge_sort(a, q+1,r)5      Merge(a,p,q,r)

注意到在Merge(a,p,q,r)中有如下循环不变式

  • 子数组a[p, k-1]包含了从L[1…n1+1]和R[1…n2+1]中复制过来的k-p个最小元素,且已按照从小到大的顺序排好序,而L[i]和R[j]中包含的是尚未复制到已排好序的数组的剩余成员。

归并排序的执行时间


分治算法的分析

我们还是假设当输入规模为n的时候,运行时间是T(n),当输入规模小于某个常数c的时候,可以花费常量时间解决该问题,即当
n <= c时,T(n)=Θ(1)。将问题分解成a个子问题,每一个子问题的规模是n/b,则解决子问题的运行时间是T(n/b),解决所有子问题的时间是aT(n/b),假设分解子问题的时间是D(n),合并子问题的时间是C(n),我们得到:

        Θ(1)                     n<=c   T(n) =          a*T(n/b) + D(n) + C(n)    n为其他
归并排序算法的分析

对于归并排序a=b=2,后面会讲到分治算法的一般分析方法,此处为了
好理解,我们假设输入规模为2n。那么,上述公式变为:
分解:分解只有一个计算中间值的操作所以,D(n) = Θ(1)。
合并:合并一个规模为n的数组需要的时间是Θ(n),所以
C(n) = Θ(n)。同时可知Θ(n)+ Θ(1)= Θ(n)。
递归:由于a=b=2,所以a*T(n) = 2*T(n/2)。

        Θ(1)                     n为 1T(n) =          2*T(n/2) +Θ(n)           n为其他

其中Θ(n) 是一个线性函数集合,这里我们简化为c*n,c是一个常数。
那么当n是2的幂的时候,我们得到如下递归树,
对于T(n):

            c*n      T(n/2)    T(n/2)

对于T(n/2):

             c*(n/2)        T(n/4)     T(n/4)

以此类推得到:

                     c*n        c*(n/2)               c*(n/2)     c*(n/4)   c*(n/4)     c*(n/4)  c*(n/4)     ⋮         ⋮            ⋮        ⋮     c         c           c        c 

由于n是2的幂,所以这棵递归树的高度是log2n+1,而且每一层的和都是cn
可知:

T(n)=cnlog2n+cn=cn(log2n+1)

当n足够大的时候:

T(n)=Θ(nlog2n)

可见,当输入足够大的时候,归并排序的运行时间远远小于插入排序。

归并排序的C语言实现

//这里没有使用哨兵,而是通过判断数组大小来确定是否完成排序,因为"哨兵"也有可能是数组的一员,所以使用哨兵不是最佳实现void merge(int a[], unsigned int begin, unsigned int middle, unsigned int end) {    unsigned int lLength = middle - begin + 1;    unsigned int rLength = end - middle;    int i = 0;    int j = 0;    int k = 0;    int * left = (int *)malloc(sizeof(int) * lLength);    if(NULL == left) {        return ;    }    memset(left, 0, sizeof(int)*lLength);    for(; i < lLength; ++i) {        left[i] = a[begin + i];    }    int * right = (int *)malloc(sizeof(int) * rLength);    if(NULL == right) {        return ;    }    memset(right, 0, sizeof(int) * rLength);    for(; j < rLength; ++j) {        right[j] = a[middle + j + 1];    }    i = j = 0;    for(; k < (end - begin + 1); ++k) {        if((i >= lLength) && (j < rLength)) {            a[begin + k] = right[j++];        } else if((i < lLength) && (j >= rLength)) {            a[begin + k] = left[i++];        } else if((i < lLength) && (j < rLength)) {            if(left[i] > right[j]) {                a[begin + k] = right[j++];            } else if(left[i] < right[j]) {                a[begin + k] = left[i++];            } else {                a[begin + k++] = left[i++];                a[begin + k] = right[j++];            }        } else {            break;        }    }    free(left);    left = NULL;    free(right);    right = NULL;    return ;}void mergeSort(int a[], unsigned int begin, unsigned int end) {    if(begin < end) {        unsigned int middle = (begin + end) / 2;        mergeSort(a, begin, middle);        mergeSort(a, middle+1, end);        merge(a, begin, middle, end);    } else {        return ;    }}

归并排序的Python实现

def merge(a, begin, middle, end):    left = a[begin:middle+1]    right = a[middle+1:end+1]    i = j = k = 0    while k < (end - begin + 1):        if(i < len(left) and j < len(right)):            if(left[i] > right[j]):                a[begin + k] = right[j]                j = j + 1                k = k + 1            elif(left[i] < right[j]):                a[begin + k] = left[i]                i = i + 1                k = k + 1            else:                a[begin + k] = left[i]                k = k + 1                a[begin + k] = right[j]                j = j + 1                i = i + 1                k = k + 1        elif(i >= len(left) and j < len(right)):            a[begin + k] = right[j]            j = j + 1            k = k + 1        elif(i < len(left) and j >= len(right)):            a[begin + k] = left[i]            i = i + 1            k = k + 1        else:            break;def mergeSort(a, begin, end):    if(begin < end):        middle = int((begin + end)/2)        mergeSort(a, begin, middle)        mergeSort(a, middle+1, end)        merge(a, begin, middle, end)    else:        return

未完待续…

下一篇,将会讨论一些数学问题。

原创粉丝点击