数据结构 DAY02 时间复杂度和空间复杂度分析

204 阅读6分钟
上一节我们聊了一下数据结构的基本概念,今天我们聊聊算法的相关的知识。同样,开始之前我们先思考以下几个问题:
  1. 什么是算法?
  2. 算法有哪些基本特征?
  3. 如何衡量一个好的算法?
  • 算法的基本特性

有一句话几乎成了计算机行业的公知,那就是“程序 = 数据结构 + 算法”,先不辩驳这句话的正确与否,至少体现了数据结构与算法在这个行业的重要性。

我们通过一个例子来阐明算法的基本特性,不知道大家小时候有没有搬过砖,农村自家造房子,为了省钱通常自家人把小货车运来的砖搬到师傅砌墙的地方(好像暴露年龄了)。假如,早上小明他爸跟他说,把门口的砖搬到二楼,今天师傅砌墙要用。那么小明把所有的砖搬到二楼的过程就相当于一个算法,眼前的砖相当于输入数据,把砖搬到二楼师傅砌墙的地方相当于输出算法是对特定问题求解步骤的一种描述,是指令的有限序列)。现在有以下几种情况出现:

  1. 小明同一块砖头反反复复搬到楼上又搬下来,被他爸发现揍了他一顿,因为小明这样没法完成搬砖的任务(违反了算法的有穷性,==必须在有限步骤结束==)
  2. 小明搬砖的思路千奇百怪,一会砖头放在隔壁二婶家,一会又藏在对面三叔家里,他爸发现又揍了他一顿,看不懂他到底在干什么。(违反了算法的确定性,==算法实现的每一个步骤不能存在歧义,相同的输入输出必须相同==)
  3. 他爸想让小明早点把砖头搬完,于是和小明说,小明你给我一次搬30块,小明怒了“你大爷的你来”,因为他根本搬不动。结果小明又被揍了,(违反了算法的可行性,==一个算法描述的操作必须是可以实现的==)
  4. 搬砖的时候,小明把隔壁的小花叫来说,你看我力气可大了,手里搬着几块砖头在小花面前秀,不利索的搬到二楼。被他爸发现又被揍了(违反了算法的输出,==一个算法必须有一个以上的输出==)
  5. 为了节省成本小明他爸和小明说,你直接去砖厂把砖搬回来吧,今天就不把砖拉到门口了,能节省拉货的钱,这就是零输入程序(算法的输入,==算法可以有零个或多个输入==)

以上便是一个算法五个重要特性。

  • 算法设计的要求

为了以后搬砖顺利,小明他爸让小明先说说搬砖的方法,并给他提了几点要求:

  1. 必须把砖搬到正确的位置(算法的正确性,能够解决具体的问题)
  2. 搬砖的方法必须描述清楚,容易理解(算法的可读性,要易于理解)
  3. 搬砖的时候要注意安全戴好安全帽,防止小石子掉下来(算法的健壮性,具备处理非法数据的能力)
  4. 最好能够想个轻松又高效的法子把砖搬完(算法的时间复杂度和空间复杂度要求
  • 算法的时间复杂度分析

于是小明开始巴拉巴拉的说怎么实现高效轻松的搬砖,小明他爸根据小明描述的搬砖过程,评估搬砖需要的时间以及搬砖需要工具。这便是算法中的时间复杂度和空间复杂度分析。

说完了基本概念,我们现在来看看一个具体的例子,如下所示:

int main() {
    MovingBricks(3000);
}
void MovingBricks(int n) {
    int i = 1; // 以搬砖的块数
    while (i <= n) {
        printf("搬第 %d 块砖", i);
        i++;   // 没搬完一块砖+1
    }
}

现在我们来分析这个搬砖程序

int i = 1;                            // 执行一次
while (i <= n);                  // 执行3001次
printf("搬第 %d 块砖", i);  // 执行3000次
i++;                                 // 执行3000次
// 时间开销为T(n)=1+3001+2*3000
// 因此时间复杂度为 T(n)=1+(n+1)+2*n
我们假设小明吃了大力,每次搬砖的能力都是上一次两倍。搬砖程序如下所示:
int main() {
    MovingBricks(3000);
}
void MovingBricks2(int n) {
    int i = 1; // 每次搬砖的块数
    int s = 0; // 已搬砖的总数
    while (s <= n) {
        printf("搬第 %d 块砖", i);
        s = s + i;   // 没搬完一次+i
        i = i*2;     // 大力出奇迹
    }
}

我们再分析一下这个搬砖程序

int i = 1;    // 执行一次
int s = 0;    // 执行一次
while (i <= n);    // 执行⌈log2(3000+1)⌉+1次
printf("搬第 %d 块砖", i);    // 执行⌈log2(3000+1)⌉次
s = s + i;    // 执行⌈log2(3000+1)⌉次
i = i*2;    // 执行⌈log2(3000+1)⌉次
// 时间开销为T(n)=2+⌈log2(3000+1)⌉+1+3*⌈log2(3000+1)⌉
// 因此时间复杂度为 T(n)=2+⌈log2(n+1)⌉+1+3*⌈log2(n+1)⌉

从上面的分析过程是不是是发现有点复杂,现实生活中我们只考虑结束高的部分,比如在 MovingBricks 中 T(n) = 3*n + 2,我们通常只考虑 T(n) = 3*n,进一步去掉前面的常数项可得对用的时间复杂度为==T(n) = O(n)==,相应的MovingBricks2 中 ==O(log2(n))==,一个程序的时间复杂度具有以下规则:

// 多项相加,只保留最高阶项
T(n) = T1(n) + T2(n) = O(f(n)) + O(g(n)) = O(max(f(n), g(n)));
// 多项相乘,都保留
T(n) = T1(n)×T2(n) = O(f(n))×O(g(n)) = O(f(n)×g(n));

常见时间复杂度大小关系:

  • 算法的空间复杂度分析

我们都知道程序在执行的过程中需要占用一定的内存,除了程序本身所占有的内存之外,还需要保存一些程序执行过程中产生的中间数据,比如局部变量,函数调用信息等,以保证程序的正确执行。

我们再回到上面的搬砖程序,如下所示:

int main() {
    MovingBricks(3000);
}
void MovingBricks(int n) {
    int i = 1; // 以搬砖的块数
    while (i <= n) {
        printf("搬第 %d 块砖", i);
        i++;   // 没搬完一块砖+1
    }
}

从上面的例子我们可以看出程序在执行过程中,只需额外申请一个局部变量,且这个变量占有的内存和输入值 n 无关。由此可以得出,该程序空间复杂度为 ==S(n) = O(1)==,表示方法同时间复杂度,这里不在赘述。

看看另外一个例子,假设小明吃了大力,大力出奇迹,不论你有多少砖他都能一次给你整完,具体程序如下:

int main() {
    MovingBricks(3000);
}
void MovingBricks(int n) {
    int *s = (*int)malloc(sizeof(int)*n); // 搬砖的块数
    printf("搬砖结束");
}

上面的程序我们可以发现,程序占用的内存和输入参数有关,按照时间复杂都分析的方法我们可以得出该程序空间复杂度为 ==S(n) = O(n)== ,空间复杂度的分析和时间复杂度类似,因此计算时间复杂度的规则同样也适用于空间复杂的分析。

假设小明和隔壁小花表白,为了表现作为一个程序员的气质,他写了一段代码,如下:

int main() {
    LoveYou(5);
}
void LoveYou(int n) {
    if (n > 1) {
        LoveYou(n-1);
    }
    printf("I love you %d", n);
}

可见该程序内存消耗和输入的参数有关,其空间复杂度为 ==S(n) = O(n)== 假如小明的电脑内存不够大,而输入参数只比较大时,由于层层的递归调用,而每一次调用都会占用一定的内存,这样就可能导致内存不足,程序无法正常执行,因此我们在写程序的时候特别是使用递归函数的时候要慎重考虑内存消耗问题。

  • 衡量一个好的算法

一个好的算法是时间复杂度和空间复杂度的综合,不能一味的最求时间上的高效忽略内存消耗,也不能因为节省内存而过多的牺牲时间。当空间复杂度一定的情况下,时间复杂度越低(可参考前面的大小关系),则该算法越搞笑。

  • 总结
  • 获取更多知识请关注公众号——无涯的计算机笔记