【算法】1 由插入排序看如何分析和设计算法

插入排序及其解决思路

算法的作用自然不用多说,无论是在校学生,还是已经工作多年,只要想在计算机这条道路走得更远,算法都是必不可少的。

就像编程语言中的“Hello World!”程序一般,学习算法一开始学的便是排序算法。排序问题在日常生活中也是很常见的,说得专业点:

输入是:n个数的一个序列<a1,a2,...,an1,an>
输出是:这n个数的一个全新的序列<a,1,a,2,...,a,n1,a,n>,其特征是a,1a,2...a,n1a,n

举个例子,在本科阶段学校往往要求做的实验中大多是“学生管理系统”、“信息管理系统”、“图书管理系统”这些。就比如“学生管理系统”中的分数,每当往里面添加一个新的分数时,便会和其他的进行比较从而得到由高到低或由低到高的排序。

我本人是不太喜欢做这种管理系统的…… 再举个比较有意思的例子。

大家肯定都玩过扑克牌,撇开部分人不说,相信大部分童鞋都热衷于将牌按序号排好。那么这其中就蕴含着算法的思想:

1)手中的扑克牌有2种可能:没有扑克牌(为空)或者有扑克牌且已排好序。
2)从桌上抓起一张牌后,从左往右(从右往左)依次进行比较,最终选择一个合适的位置插入。

简单的说,插入排序的精髓在于“逐个比较”。

在列出代码之前,先来看看下面的第一张图,我画的不是太好,就是有没有经过排序的 “8,7,4,2,3,9”几个数字,根据上面的描述,将排序过程描述为:

1)将第二个数字“7”和“8”比较,发现7更小,
于是将“8”赋值到“7”所在的位置,
然后将7赋值给“8”所在的位置。

2)将”4“移到”7“所在的位置,”7“和”8“后移一位。
3)同样的步骤,将”2“和”3“移到”4“的前面。
4)”9“比前面的数字都大,故不移动。

这里写图片描述

仅仅是这样的描述还是不够的,我们需要更加专业一点。

1)设置一个循环,从第二个数字开始(索引为1)不断与前面的数字相比。
2)每次循环开始时作为比较的数的索引为j,设置temp为其值。(因为在前面也看到了,将”8“赋值到”7“的位置时,如果不将”7“保存起来,那么便丢失了这个数字。)
3)取得j的前一位i4)只要i仍在数组中,并且索引为i处的值大于temp,就将i后一位的值设为i处的值,同时将i15)在i不在数组中或i处的值不必temp大时结束第四部的循环,然后将i后一位的值设置为temp。

将上面这部分描述翻译为insertion_sort函数,下面是完整的测试程序。

#include <iostream>
#include <cstdio>

using namespace std;

#define MAX_N 1000

int A[MAX_N];
int n;

void insertion_sort();

int main()
{
    printf("数组长度:
");
    scanf("%d",&n);
    printf("数组内容:
");
    for(int i=0;i<n;i++)
    {
        scanf("%d",&A[i]);
    }
    insertion_sort();
        for(int i=0;i<n;i++)
    {
        printf("%d ",A[i]);
    }
    return 0;
}

void insertion_sort()
{
    for(int j=1;j<n;j++)
    {
        int temp=A[j];
        int i=j-1;
        while(i>=0&&A[i]>temp)
        {
            A[i+1]=A[i];
            i=i-1;
        }
        A[i+1]=temp;
    }
}

下面是能够帮助我们理解算法的正确性的循环不变式的三条性质:

初始化:循环第一次迭代之前,它为真。
保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。
终止:在循环终止时,不变式能够提供一个有助于证明算法正确性的性质。

就比如上面排序的例子,终止意味着在最后一次迭代时,由传入数组元素构成的子数组元素都已排好序,因此此时子数组就等同与原数组,于是循环终止。

学习如何分析算法

继续分析排序算法,我们知道排序10000个数肯定要比排序10个数所花费的时间更长,但除了输入的项数外就没有其他的影响因素吗?当然有,比如说输入的序列的已被排序的程度,如果是“23456781”这个序列,我们仅仅需要将1放到首位就好,而输入是”87654321“,我们就需要将7到1依次与其前面的数字进行比较。

关于算法的分析也有两个定义:

1)输入规模,当考虑的是排序算法时,那么规模就指的是项数;如果考虑的是图算法,那么规模就是顶点数和边数。
2)运行时间,名义上来说就是算法执行的时间,但实际上我们在分析一个算法时考量的算法执行的操作数或步数。

下面我们通过前面排序算法的伪代码来分析它的运行时间。

INSERTION-SORT(A)
1   for j = 2 to A.length                  // 代价c1,次数n   
2       temp=A[j];                         // 代价c2,次数n-1
3       // 将A[j]插入到已排序的A[1..j-1]      // 代价0,次数n-1
4       i=j-1;                             // 代价c4,次数n-1
5       while i>0 and A[i]>temp            // 代价c5  
6           A[i+1]=A[i];                   // 代价c6
7           i=i-1;                         // 代价c7       
8       A[i+1]=temp;                       // 代价c8,次数n-1

代价为c1处的次数为n应该比较好理解对吧,从j=1到j=n一共有n步,j=n也应该包括在内,因为这是算法终止的情况。而j=n时,程序直接终止了,所以在代价c2、c3、c7处次数都为n-1。

那么在while循环中呢,代价为c4的时候次数为多少呢,很显然应该是nj=2tj,而c5和c6在while循环里总有一次它不会去执行,因此次数为nj=2(tj1)

将代价和次数相乘,便得到了该算法所需的总时间:
T(n)=c1n+c2(n1)+c4(n1)+c5nj=2tj+c6nj=2(tj1)+c7nj=2(tj1)+c8(n1)

除此之外我们还可以来对算法进行最好和最坏情况的分析:
1)在最好情况下,也就是整个输入数组其实都是排好序的,那么它根本没法进入while循环,也就是说当i取初值j-1时,有A[i]temp,从而对j=2,3,4...n都有tj=1

那么算法的总时间也就可以算出来了:
T(n)=(c1+c2+c4+c5+c8)n(c2+c4+c5+c8)

2)在最坏情况下,也就是数组是逆向排好序的,那么就需要将A[j]与已排好序的数组A[1...j1]中的每个元素进行比较,从而对j=2,3,4...n都有tj=j

那么算法的总时间也就可以算出来了:
T(n)=(c52+c62+c72)n2+(c1+c2+c4+c52c62c72+c8)n(c2+c4+c5+c8)

渐近记号

Θ
在上面我已经求出了该排序算法的运行总时间,但我们可以对其做进一步的简化以及抽象,我们只考虑最坏情况,因为在实际中它更有意义。将运行时间表示为an2+bn+c,其中的abc都依赖于ci

让我们来做进一步的抽象,通过只考虑运行时间的增长率和增长量级。因为当程序足够大时,低阶项便不再重要了,甚至连高阶项的系数也可以忽略不计。于是,我们记插入排序在最坏情况下所需要的运行时间为Θ(n2)

现在是时候给出Θ的定义了:
Θ(g(n))={f(n):c1c2n0使nn0,0c1g(n)f(n)c2g(n)}

也就是说在跨过n0之后,f(n)就一直处于c1g(n)c2g(n)之间,其中c1g(n)是下界,c2g(n)是上界。

OΩ

OΘ相比,前者就只是后者的一半——只有渐近上界,而没有渐近下界。那么它的定义为:
O(g(n))={f(n):cn0使nn0,0f(n)cg(n)}

ΩΘ相比,前者就只是后者的一半——只有渐近下界,而没有渐近下界。那么它的定义为:
Ω(g(n))={f(n):cn0使nn0,0f(n)cg(n)cg(n)}

设计分治算法

前面的排序问题使用的方法叫做增量法,即在排序子数组A[1...j1]后,将单个元素A[j]插入到子数组的适当位置,然后参数排序好的数组A[1...j],它的精髓在于“逐个比较”。

现在我们再来介绍一种新的方法,叫做分治法,它同样也是鼎鼎大名。它的精髓在于“一分为二“,而驱动这个算法的这是递归。

分治算法在每层递归中都有三个步骤:

1)分解原问题为若干子问题,这些子问题是缩小版的原问题。(抽象的讲,将一个已经切成楔形的大块西瓜可以再切成多个小的楔形西瓜。)
2)解决这些子问题,递归地求解各个子问题。然后,若问题的规模足够小,则直接求解。(继续举例子,要吃完一大块西瓜,可以不断的吃小部分,当西瓜块足够小时,可以一口干掉。)
3)合并这些子问题的解成原问题的解。(吃完的那些小块西瓜加在一起就是刚才那一块很大的西瓜了。)

虽然西瓜的例子能够体现分治算法的思想,但用前面的扑克牌来演示则更加合适,毕竟它有数字呀。来来来,想象一下,桌上正有两堆牌,且分别都已经排号顺序,可是呢我们需要这两堆牌合并起来并且排序好。

那么怎么操作呢?很简单,一句话就能说清楚:不断从两堆牌的顶上选取较小的一张,然后放到新的扑克牌堆中。

首先我们将扑克牌定义成数组Apq以及r都是数组的下标,且pq<r,两段已排序好的子数组是A[p..q]A[q+1..r],我们需要做的是将其排序为A[p..r]。下面的伪代码便实现了这个思想:

MERGE(A,p,q,r)
1  n1 = (q - p) + 1 = q - p + 1
2  n2 = (r - (q + 1)) +1 = r - q
3  let L[1..n1+1] and R[1..n2+1] be new arrays
4  for i = 1 to n1
5      L[i] = A[p + i -1]
6  for j = 1 to n2
7      R[j] = A[q + j]
8  L[n1 + 1] = #
9  R[n2 + 1] = #
10 i = 1;
11 j = 1;
12 for k = p to r
13     if L[i] 小于等于 R[j]
14        A[k] = L[i];
15        i = i + 1;
16     else 
17        A[k] = R[j]
18        j = j + 1;

上面的”# “号就是传说中的哨兵牌,每当显露一张哨兵牌时,它不可能为较小的值,除非两个堆都已显露出哨兵牌。但是出现这种情况就意味着算法结束,所有非哨兵牌都已被放置到输出堆。

………………p     q       r………………
………………3 6 8 9 2 5 7 8………………
        k

L 3 6 8 9 #     R 2 5 7 8 #
  i               j

比较”3“和”2“,发现2更小,于是将2放到A数组的首位,并且将j移后一位。

………………p     q       r………………
………………2 6 8 9 2 5 7 8………………
          k

L 3 6 8 9 #     R 2 5 7 8 #
  i                 j

比较”3“和”5“,发现3更小,于是将3放到数组A的首位,并且将i移后一位。

………………p     q       r………………
………………2 3 8 9 2 5 7 8………………
            k

L 3 6 8 9 #      R 2 5 7 8 #
    i                j

以此类推,最终A数组就成排好了序……

………………p     q       r………………
………………2 3 5 6 7 8 8 9………………
                      k

L 3 6 8 9 #      R 2 5 7 8 #    
          i                j

将上面的思想以及伪代码写成如下程序,大家可以参考参考:

#include <iostream>
#include <cstdio>

using namespace std;

#define MAX_N 1000

int A[MAX_N];
int L[MAX_N/2];
int R[MAX_N/2];
int n,p,q,r;

void merge();

int main()
{
    printf("数组长度:
");
    scanf("%d",&n);
    printf("数组内容:
");
    for(int i=0;i<n;i++)
    {
        scanf("%d",&A[i]);
    }
    printf("输入p q r
");
    scanf("%d %d %d",&p,&q,&r);
    merge();

    for(int i=0;i<n;i++)
    {
        printf("%d ",A[i]);
    }
    return 0;
}

void merge()
{
    int n1=q-p+1;
    int n2=r-q;
    for(int i=0;i<n1;i++)
        L[i]=A[p+i];
    for(int j=0;j<n2;j++)
        R[j]=A[q+j+1];
    L[n1]=100;
    R[n2]=100;
    for(int k=p,i=0,j=0;k<=r;k++)
    {
        if(L[i]<=R[j])
        {
            A[k]=L[i];
            i=i+1;
        }
        else
        {
            A[k]=R[j];
            j=j+1;
        }
    }
}

下面没有用图示而是用了代码显示块来显示,应该也能看的吧?就是不断的归并,最终合成一个完整的排好序的数组。

…………………………2 2 5 6 7 8 8 9…………………………
……………………………………归并……………………………………
…………………2 5 6 8………………2 7 8 9…………………
……………………归并…………………………归并……………………
……………2 6…………5 8…………2 9…………7 8……………
……………归并…………归并………归并……………归并……………
…………6……2………8……5………2……9………8……7…………

要完成上面这个程序,我们可以利用前面写好的merge函数呢,下面是伪代码实现。

MERGE-SORT(A,p,r)
1  if p < r
2      q = 小于或等于(p+r)/2的最大整数
3      MERGE-SORT(A,p,q)
4      MERGE-SORT(A,q+1,r)
5      MERGE(A,p,q,r)

然后为了完成这其中q的参数传递,将它们设置为全局变量已经不合适了,具体的程序可以参考如下:

#include <iostream>
#include <cstdio>

using namespace std;

#define MAX_N 1000

int A[MAX_N];
int L[MAX_N/2];
int R[MAX_N/2];


void merge(int A[],int p,int q,int r);
void merge_sort(int A[],int p,int r);

int main()
{
    int n, p, q, r;
    printf("数组长度:
");
    cin >> n;
    printf("数组内容:
");
    for (int i = 0;i < n;i++)
    {
        cin >> A[i];
    }
    printf("输入p r
");
        cin >> p >> r;

    merge_sort(A, p, r);

    for (int i = 0;i < n;i++)
    {
        printf("%d ", A[i]);
    }

    return 0;
}

void merge_sort(int A[],int p,int r)
{
    if(p<r)
    {
        int q=(p+r)/2;
        merge_sort(A,p,q);  
        merge_sort(A,q+1,r);
        merge(A,p,q,r);
    }
}

void merge(int A[], int p, int q, int r)
{
    int n1 = q - p + 1;
    int n2 = r - q;
    for (int i = 0;i<n1;i++)
        L[i] = A[p + i];
    for (int j = 0;j<n2;j++)
        R[j] = A[q + j + 1];
    L[n1] = 100;
    R[n2] = 100;
    for (int k = p, i = 0, j = 0;k<=r;k++)
    {
        if (L[i] <= R[j])
        {
            A[k] = L[i];
            i = i + 1;
        }
        else
        {
            A[k] = R[j];
            j = j + 1;
        }
    }
}

分析分治算法

当我们的输入足够小时,比如对于某个常量c,nc,则直接求解需要常量时间,并写作Θ(1)

对于复杂的问题,我们将其分解成a个子问题,每个子问题的规模是原问题的1/b.对于这规模为n/b的子问题,累计需要T(n/b)的时间,所以需要aT(n/b)的时间来求解这a个子问题。而这其中分解的过程也需要消耗一定的时间,记作D(n),合并这些子问题也需要一定的时间,记作C(n)。于是又得到了一个递归式:

nc时,T(n)=Θ(1)

其他情况时,T(n)=aT(n/b)+D(n)+C(n)

下面来通过分治模式在每层递归时都有的三个步骤来分析归并排序算法,我们所考虑的是n个数的最坏情况下的运行时间。同样的,归并排序一个元素需要常量时间,当n>1时有如下3个步骤:

分解:分解步骤仅仅计算子数组的中间位置,需要常量时间,因此D(n)=Θ(1)
解决:我们递归地求解两个规模均为n/2的子问题,将贡献2T(n/2)的运行时间。
合并:一个具有n个元素的子数组上过程MERGE需要Θ(n)的时间,所以C(n)=Θ(n)

因此D(n)+C(n)便等于Θ(n)Θ(n)相加,可是结果依然为Θ(n)。接着可以得到归并排序的最坏情况运行时间的递归式了。

n=1时,T(n)=Θ(1)

n>1时,T(n)=2T(n/2)+Θ(n)

我们对上式稍作变换如下:

n=1时,T(n)=c

n>1时,T(n)=2T(n/2)+cn

这个式子可以不断的做递归,最后形成一个递归树的。树的第一层是cn,第二层是cn/2,第三层是cn/4,直到最后一层为c。第二层有2棵子树,第三层有4棵子树,直到最后一程有n棵子树,因此每层的代价总共为cn。而整个树共有lgn+1层,因此总代价为cnlgn+cn

忽略低阶项和系数项,最终记为Θ(nlgn)

递归和迭代

这两个概念也许很多童鞋依旧是老虎老鼠傻傻分不清楚,下面通过求解斐波那契数来看看它们俩的关系吧。

斐波那契数的定义:

f0=0

f1=1

fi=fi1+fi2(i>1)

递归:

(factorial 6)
(* 6 (factorial 5))
(* 6 (* 5 (factorial 4)))
(* 6 (* 5 (* 4 (factorial 3))))
(* 6 (* 5 (* 4 (* 3 (factorial 2)))))
(* 6 (* 5 (* 4 (* 3 (2 (factorial 1))))))
(* 6 (* 5 (* 4 (* 3 (* 2 1)))))
(* 6 (* 5 (* 4* 3 2))))
(* 6 (* 5 (* 4 6)))
(* 6 (* 5 24))
(* 6 120)
720

迭代:

(factorial 6)
(factorial 1 1 6)
(factorial 1 2 6)
(factorial 2 3 6)
(factorial 6 4 6)
(factorial 24 5 6)
(factorial 120 6 6)
(factorial 720 7 6)
720

递归的核心在于:不断地回到起点
迭代的核心在于:不断地更新参数

在下面的代码中:

递归的核心是sum的运算,sum不断的累乘,虽然运算的数值不同,但形式和意义一样。

而迭代的核心是product和counter的不断更新。如上表中,product就是factorial的前2个参数不断的累乘更新成第一个参数;而第二个参数则是counter,其不断的加1来更新自己。

product <- counter * product 
counter < - counter + 1
#include <iostream>

using namespace std;

int factorialRecursive(int n);
int factorialIteration(int product, int counter, int max_count);

int main()
{
    int n;
    cout<<"Enter an integer:"<<endl;
    cin>>n;
    cout<<factorialRecursive(n)<<endl;
    cout<<factorialIteration(1,1,n)<<endl;

    return 0;
}

int factorialRecursive(int n)
{
    int sum=1;
    if(n==1)
        sum*=1;
    else
        sum=n*factorialRecursive(n-1);
    return sum;
}

int factorialIteration(int product, int counter, int max_count)
{
    int sum=1;
    if(counter>max_count)
        sum*=product;
    else
        factorialIteration((counter*product),(counter+1),max_count);
}

补充问题:

关于上面的factorialIteration函数,今天收到一份邮件,我也通过再次分析学到了很多,这里罗列一下。


第一个问题:

首先来看相对简单的问题,该童鞋在函数内以两种不同方式加上another_sum=2却有着不同的结果。

int factorialIteration(int product, int counter, int max_count)
{
    int sum=1;
    int another_sum=2;
    if(counter>max_count)
    {
        sum*=product;
        another_sum*=product;
    }    
    else
        factorialIteration((counter*product),(counter+1),max_count);
}
int factorialIteration(int product, int counter, int max_count)
{
    int sum=1;
    int another_sum=2;
    if(counter>max_count)
    {
        another_sum*=product;
        sum*=product;

    }    
    else
        factorialIteration((counter*product),(counter+1),max_count);
}

因为这个函数声明的是int型的返回类型,但没有用return语句,所以C++自动将其运行的最后一行语句作为了返回语句。所以这两个函数类似于:

int factorialIteration(int product, int counter, int max_count)
{
    int sum=1;
    int another_sum=2;
    if(counter>max_count)
    {
        sum*=product;
        return another_sum*=product;
    }
    else
        factorialIteration((counter*product),(counter+1),max_count);
}


int factorialIteration(int product, int counter, int max_count)
{
    int sum=1;
    int another_sum=2;
    if(counter>max_count)
    {
        another_sum*=product;
        return sum*=product;
    }
    else
        factorialIteration((counter*product),(counter+1),max_count);
}

然而我在CodeBlocks中写的代码不用return是可以的,但在Visual Studio中却是会报错的。


现在来看另一个问题:

#include <iostream>
using namespace std;

int test(int n);
int sum;

int main()
{
    cout<<test(1)<<endl;
    return 0;
}
int test(int n)
{
    sum = 1;
    sum += n;
    if (sum < 5)   
        test(n+1);
}

如果设sum为全局变量,那么会在test函数中每一次调用sum=1时都将sum重新赋值为1。整个程序最后输出为5。这个应该没有什么悬念吧?

如果设sum给test内的局部变量,则会在每一次执行int sum=1语句时都会创建一个新的sum对象,它的存放地址和之前的sum并不相同。然后整个程序最后输出意外的是4。

#include <iostream>
using namespace std;

int test(int n);

int main()
{
    cout<<test(1)<<endl;
    return 0;
}
int test(int n)
{
    int sum = 1;
    sum += n;
    if (sum < 5)
        return test(n+1);
    // return sum;   此处有这一行代码命名为程序1,没有这行代码命名为程序2
}

程序1的输出是5,程序2的输出是4。具体函数执行过程如下:

第一步,调用test(1):

int sum=1
sum=2
return test(2)

第二步,调用test(2):

int sum=1
sum=3
return test(3)

第三步,调用test(3):

int sum=1
sum=4
return test(4)

第四步,调用test(4):

int sum=1
sum=5

执行到第四步的时候,由于sum以及不比5小了,所以程序1没有进入if语句而是执行下一句return sum,所以输出为1。

而如果是程序2,也就是没有return sum语句,那么程序在执行完第四步后就会返回到第三步,最终调用(return) sum=4,输出4。



感谢您的访问,希望对您有所帮助。 欢迎大家关注、收藏以及评论。


为使本文得到斧正和提问,转载请注明出处:
http://blog.csdn.net/nomasp


原文地址:https://www.cnblogs.com/NoMasp/p/4540433.html