夜间模式暗黑模式
字体
阴影
滤镜
圆角
主题色
算法与数据结构(1):基础部分——以插入排序为例

算法与数据结构(1):基础部分——以插入排序为例

本文将会以插入排序为例,介绍算法与数据结构的基础部分。

插入排序

排序可以说是整个算法中最为基础,最为重要的一部分,而插入排序正是排序算法中最简单的一种解决办法。

什么是排序问题?

输入:n个数的一个序列 <$a_1$, $a_2$, $a_3$ … $a_n$>。

输出:输入序列的一个排列<$b_1$,$b_2$,$b_3$ … $b_n$>,同时满足$b_1$ <= $b_2$ <= $b_3$ <= … <= $b_n$。

而插入排序的思路和我们平时打扑克时整理排序的思路非常类似。开始时,我们手中没有牌,然后我们每次从桌上拿走一张牌,并将其插入到手中正确的位置,直到我们将牌整理完成。何为正确的位置?用规范一点的描述,可以认为,将第 n 张牌插入到已经排好序的 n-1 张牌中,并保证插入后依然保持有序。这就是插入排序的核心理念。

从感性上来说,我们需要整理多少张牌,就对应着需要插入几次,当然第一次插入时,因为手中没有牌,所以无需考虑什么地方是正确的位置。

如下图是一个插入排序的展示:

我们可以使用如下代码实现:

#include <stdio.h>

// 输出数组结果
int PrintArray(int* array, int array_length){
    int i;
    for(i = 0; i < array_length; i++){
        printf("%d ", array[i]);
    }
    printf("\n");
}

// 插入排序,结果按升序排列
int InsertionSort(int* array, int array_length){
    int i, j; // 循环使用的临时变量
    int num_to_insert; // 数组排序使用的临时变量

    for(i = 1; i < array_length; i++){
        num_to_insert = array[i];
        j = i - 1;
        // 将array[i]插入至已经排好序的array[0 ~ i-1]之间
        while(j >= 0 && array[j] > num_to_insert){ // 我是21行~
            array[j + 1] = array[j];
            j--;
        }
        array[j + 1] = num_to_insert;
    }
    return 0;
}


int main() {
    int array_to_sort[10] = {10, 9, 8, 7, 6, 5, 4, 3, 2, 1};
    InsertionSort(array_to_sort, 10);
    PrintArray(array_to_sort, 10);
    return 0;
}

一个算法最为重要的要求是正确性,在正确性的前提下尽量降低时间复杂度和空间消耗。

正确性分析

通常来说,一个值得我们研究的算法的核心部分是循环或者递归(因为如果不包含循环和递归,那么就意味着这个算法是线性的,那么关于这个算法也就没有性能上的区别了,那么就可以说这是一个不值得我们研究的算法),为了能够论证这个算法的循环部分(递归也是可以利用循环实现的)是正确的,我们通常会使用一种叫做循环不变式的概念。

利用循环不变式论证算法的正确性,主要分为三个步骤:

初始化:循环的第一次迭代之前,它为真。

保持:如果循环的某次迭代之前它为真,那么下次迭代之前它仍为真。

终止:循环终止时,不变式为我们提供了一个有用的性质,该性质有助于证明此算法是正确的。

看到这里,相比很多读者已经意识到,这就是数学中的“数学归纳法”。那么将这三步用于上文中的插入排序,效果会是如何呢?

初始化:在第一次迭代之前,即当 i = 1 时,循环不变式成立,因为子数组此时仅由1个元素组成,即array[0]。此数组必然是有序的。

保持当前 i 个元素组成的子数组已经有序后,我们准备插入第 i+1 个元素,代码的第21-24行的部分,保证了所有大于第 i+1 个元素的数据均在此元素右方,所有小于等于第 i+1 个元素的数据均在此元素左方,则当插入此元素后,前 i+1 个元素组成的子数组有序

终止:循环终止的条件是 i == array_length,即前 array_length 个元素组成的子数组有序,即整个数组有序

通过这三步分析,我们证明了这个算法的正确性,以后的文章我们提到的所有算法,均可以通过此方法分析其正确性。虽然我们在后期可能会更加强调一个算法的运行效率,对于一些显而易见的正确性可能会略过不讲,但这并不意味着正确性分析不重要,仅凭作者的经验来看,很多学习算法的同学容易陷入一个怪圈,只着眼于运行效率而忽略了算法本身正确性。

运行效率分析

对于一个算法的运行效率最直接的判断方式是,将其写为一个程序并运行测速。但在大部分情况下,这是不合理的。例如同一个算法由不同的程序员编写,用不同的语言编写,用不同的运行环境来测试,或者用不同的硬件设备来运行,都会有着巨大的差异,如果有的算法非常复杂或者数据量很大,需要几个小时甚至几天才能运行完成,我们需要一直等它运行完成,才能评判这个算法的运行效率吗?显然用实际运行并计时的方式来评判一个算法的运行效率在大部分情况下是不合理的。

对此,我们引入了一种思想模型——随机访问机(RAM)来评估一个算法的运行效率。

RAM模型包含真实计算机中常见的指令:算术指令(如加法,减法,乘法,除法,取余、向下取整,向上取整),数据移动指令(装入、存储、复制)和控制指令(条件跳转,无条件跳转,子程序调用与返回),上述这些指令所需时间均为常量,即这些指令运行时间不变)。同时,在实际中,计算机包含多级存储,但为了简化问题,我们一般不考虑高速缓存,虚拟内存等多级存储,简化为只有硬盘与内存的区别。再说了,现在技术变的这么快,说不定以后就没cache了呢,考虑啥呀真的是。这一段没有看懂不要急,下面会举一个例子,看了例子后相信大部分读者就能明白了。

有了模型,现在就需要考虑如何进行评估。对于一个算法,输入规模能够很大程度的影响运行时间,例如插入排序中对10个数进行排序和对10亿个数进行排序。对此,我们引入了输入规模与运行时间的概念。

输入规模:输入规模很大程度上依赖于具体问题来进行分析。例如排序中,输入规模一般认为是需要排序的数据个数;如果分析两个整数相乘,则输入规模一般认为是两个数的总位数;如果需要研究图论问题,那么输入规模一般认为是图中的顶点数与边数。什么是输入规模没有一个具体的规定,但一般而言就是指这个影响这个算法运行时间的最重要的那一个属性,这个需要读者们多学习积累经验,才能迅速的分析出一个问题的关键所在。

运行时间:这个运行时间不是指实际运行时间,而是在RAM模型中需要执行的基本操作步数。什么是基本操作步数?也就是我们上文中提到的RAM模型中的常见指令,运行一条指令就是一步。虽然不同指令的运行时间不同,但他们之间的差异都是常量级的,例如加法指令可能需要1ms运行完成,乘法指令需要10ms运行完成,他们的比例是1:10,运行100条加法指令与运行100条乘法指令所需时间比例依然是1:10,这个常数时间的差异在我们后面面对的动辄 指数级 差异的比较之下,就可以忽略不计了。

为什么我们会忽略运行时间的常数差异呢?一个很简单的道理,如果我们的计算规模很小,例如排序十个数,不同算法之间差距可能只有不到1ms,这点时间与你点击运行,电脑突然卡了一下等事情需要花费的时间相比,简直微不足道,甚至都没必要去优化算法了。而如果计算规模很大,例如要排序全国人民的身份证号,差一点的算法(插入排序同学,说的就是你,别东张西望了)可能需要几年,而好一点的算法可能只需要几个小时,不同算法之间的运行时间差异甚至是指数级的,这一点常数差异真的没有什么影响,除非两个指令之间运行时间比例能达到百万级之类的,那你干脆把差距这么大的指令也当做算法来研究研究优化一下了吧。等学习深入以后,读者们也会发现,我们大部分时候只考虑运行时间输入规模之间的关系,如是指数关系还是线性关系等,同时评价一个算法好不好,大部分情况下也是考虑输入规模较大的时候的增长趋势

运行效率分析案例

现在我们以上文中的插入排序代码为例,进行一下运行效率的分析,我们此次分析会将常数差异考虑进去,以印证我们前文所说的,当输入规模较大时,常数差异不重要的结论。

表格中 n 表示输入规模,即array_length,$t_{x}$ 表示执行while循环的次数,由于 } 并不占用运行时间,故运行一次所需代价为0。

那么我们将上述所有运行时间相加,所得结果为
$$
T(n)=c_{1}*n+c_{2}*(n-1)+c_{3}*(n-1)
$$

$$
+c_{4}* \sum_{x=2}^n t_{x} +c_{5}* \sum_{x=2}^n (t_{x}-1) +c_{6}* \sum_{x=2}^n (t_{x}-1) +c_{7}*(n-1)
$$

现在我们又面临一个新的问题,对于同一规模的输入,如果输入不同,其运行时间也是不同的。例如在此例中,如果输入的数组就是已经升序的,那么

$$
t_{x}==1
$$
化简一下,
$$
T(n)=c_{1}*n+c_{2}*(n-1)+c_{3}*(n-1)+c_{4}*(n-1) +c_{7}*(n-1)
$$

$$
=(c_{1}+c_{2}+c_{3}+c_{4}+c_{7})*n-(c_{2}+c_{3}+c_{4}+c_{7})
$$

可以将此记为
$$
an+b
$$
即在插入排序的最好情况下,最后的运行时间与输入规模之间是线性关系

如果输入的数组是降序的,那么
$$
\sum_{x=2}^n t_{x}= \frac{n*(n+1)}{2} -1
$$

$$
\sum_{x=2}^n (t_{x}-1)= \frac{n*(n-1)}{2}
$$

化简一下,
$$
T(n)=c_{1}*n+c_{2}*(n-1)+c_{3}*(n-1)+c_{4}* (\frac{n*(n+1)}{2} -1) +c_{5}* \frac{n*(n-1)}{2}+c_{6}* \frac{n*(n-1)}{2} +c_{7}*(n-1)
$$

$$
=(\frac{c_{4}+c_{5}+c_{6}}{2})*n^2+(c_{1}+c_{2}+c_{3}+\frac{c_{4}-c_{5}-c_{6}+c_{7}}{2})*n-(c_{2}+c_{3}+c_{4}+c_{7})
$$

可以将此记为
$$
a*n^2+b*n+c
$$
即在插入排序的最坏情况下,最后的运行时间与输入规模之间是二次函数关系

运行效率分析总结

在我们上面的例子中,我们既研究了最佳情况,也研究了最坏情况,但一般而言,实际中我们的分析主要在于最坏情况,我们以后的文章也将会主要着眼于最坏情况的分析。对此,我们有以下理由:

  • 一个算法的最坏情况给出了任何输入的运行时间的一个上界,我们可以保证该算法不需要更长的时间,无需有更多担心。
  • 在实际中,最坏情况经常出现,例如我们编写一个网页,用户登录时,我们需要将登陆数据与数据库中的数据进行对比,在很多时候,由于用户的错误输入或者恶意输入,数据库中是没有这条记录的,所以需要检索整个数据库,也就是最坏情况。我们不能让这个最坏情况太差,因为如果只是让用户最快登陆时间从0.1秒增加到0.2秒是可以接受的,但当一个用户错误登陆时,需要所有用户一起卡一天,建议以“蓄意破坏计算机信息系统罪”立即去公安机关自首或者直接击毙
  • 一般而言,平均情况与最坏情况差距不大,可能只有一个常数的差异。如插入排序中,如果插入第n个数时,平均情况下是在第n/2个数时插入,最坏情况是在第n个数插入,只有一个2倍关系,所以平均情况的运行时间只是将最坏情况的运行时间除以2而已,依然是二次函数关系,并没有本质上的区别。
  • 当然我觉得还有另外一个原因:最坏情况很好算!!!而平均情况很多时候真的很难算的!!!为了一个只需要花5分钟写完的算法,我花了10分钟去分析它的平均情况,我也很难的好吧,这么多时间我拿去拯救世界不香吗。

增长量级

虽然说我们可以具体计算出一个算法的最坏情况,但也挺花时间的。同时我们也注意到,对于一个二次函数关系,当n足够大时,只有二次项才是影响其大小的关键,这也就引出了数学中的渐进分析的概念,也就是我们的增长量级,即大家常说的那个讨人厌的大O符号,小O符号。具体含义我们将在下一篇文章中介绍,在这里我们只需要先知道一个结论,在实际的算法效率分析中,我们真正感兴趣的是运行时间的增长率或者增长量级,即公式中最为重要的项,例如插入排序中的二次项。因为当n真的很大的时候,低阶项相对来说不太重要,而常数项在n逐渐变大的过程中,也逐渐不太重要了(在实际中,我们往往只关心数量级,例如百万级的数据,亿级的数据,在这种情况下,常数项是1还是10就没有必要关心了,毕竟这种时候它带来的影响连低阶项都比不上了)

结语

这是算法与数据结构系列的第一篇文章,下一章我将会以归并排序为例介绍时间复杂度的概念,再后面就是具体的算法与数据结构的分析了,从最基础的时间复杂度、空间复杂度开始,一步一步深入,比如搜索,排序,基本数据结构,各种神奇的树,图,矩阵等等,后面应该也会介绍一些动态规划,字符串处理等常用算法,以及一些简单的数论算法和数学知识吧。如果有兴趣的话我大家也可以关注我一波,没有意外情况的话,我应该可能或许说不定不会鸽吧

原文链接:albertcode.info

个人博客:albertcode.info

暂无评论

发送评论 编辑评论


				
上一篇
下一篇