Skip to content

本文是为那些刚刚开始学习算法,并好奇算法对提升他们的职业发展或编程技能会有多大影响的人而写的。同时,本文也适合那些想知道为什么像谷歌、脸书和亚马逊这样的大公司会聘请那些在优化算法方面极为出色的程序员的人阅读。

什么是算法

通俗地说,算法只不过是对解决问题的步骤的一种描述。它们本质上就是一种解决方案。

例如,一个用于解决求阶乘问题的算法可能是下面这样的:

问题:求 n 的阶乘

初始化阶乘值 fact = 1
对于从 1 到 n 范围内的每一个值 v:
将 fact 乘以 v
此时 fact 中存储的就是 n 的阶乘值

上面这个算法是用中文写的。如果它是用编程语言编写的,我们就会称其为代码。下面是一段用 C++ 语言求一个数的阶乘的代码。

c++
int factorial(int n) {
    int fact = 1;
    for (int v = 1; v <= n; v++) {
        fact = fact * v;
    }
    return fact;
}

编程完全围绕着数据结构和算法展开。数据结构用于存储数据,而算法则用于利用这些数据来解决问题。

数据结构与算法(DSA)会详细研究标准问题的解决方案,让你深入了解使用每一种方案的效率情况。它还会教你评估算法效率的科学方法。这能让你从多种选择中选出最佳方案。

利用数据结构和算法使你的代码具备可扩展性

时间是宝贵的

假设,Alice和Bob正尝试解决一个简单问题即求前1011个自然数的和。当Bob还在写算法的时候,Alice已经解决了这个问题,Alice证明,解决这个问题就像dis川普一样简单。

算法(Bob提供)

初始化 sum = 0
循环从1到10^11(包含10^11),对每一个数n
  把n加到sum
sum就是要的结果

代码(Alice提供)

c++
int findSum() {
    int sum = 0;
    for (int v = 1; v <= 100000000000; v++) {
        sum += v;
    }
    return sum;
}

Alice和Bob对自己几乎没花什么时间就完成了自己的成果感到欣喜若狂。让我们悄悄走进他们的工作间,听听他们的对话吧。

Alice:咱们运行一下这段代码,然后算出总和吧。
Bob:我几分钟前就运行了这段代码,但它到现在还没显示出结果。这是怎么回事啊?

啊西八,出问题了!计算机是最具确定性的机器。回过头再试着运行一遍可解决不了问题。所以,咱们来分析一下这段简单的代码出了什么毛病吧。

对于一个计算机程序而言,时间内存是两种最宝贵的资源。

计算机运行代码所花费的时间是:

代码运行时间 = 指令数量 × 每条指令的执行时间

指令的数量取决于你使用的代码,每一个代码执行的时间取决于你的机器和编译器

在上面的例子中,执行指令的总数量(称为x)是

x=1+(1011+1)+1011+1,也就是x=21011+3

我们假设一台计算机每秒可以执行108条指令(具体数值会因机器配置不同而有所变化)。那么运行上述代码所花费的时间是

运行y条指令所需时间 = 1 秒
运行 1 条指令所需时间 = 1/y 秒
运行 x 条指令所需时间 = x × (1/y) 秒 = x/y 秒
因此,
代码运行时间 = x/y
= (2×10^11+3)/10^8(超过 33 分钟)

有没有可能优化这个算法,让Alice和Bob每次运行这个代码的时候不用等半个钟头呢?

我想你已经猜到了正确的方法,前N个自然数的和由这个公式求得:

Sum = N * (N + 1) / 2

转换成代码,就是这样:

c++
int sum(int N) {
  return N * (N + 1) / 2;
}

这段代码仅通过一条指令就能执行完毕并完成任务,无论数值是多少都没问题。哪怕这个数值比宇宙中原子的总数还要大,它也能瞬间得出结果。

如此解决问题,所花费的时间是1/y(也就是 10 纳秒)。顺便说一下,一颗氢弹的核聚变反应需要40到50纳秒,这意味着即使有人在你运行代码的同时向你的电脑扔一颗氢弹,你的程序也会成功完成。

注意

计算机在计算乘法和除法时会执行几条指令(并非一条)。刚才说只需要一条指令只是为了表述简便而已。

更多可扩展性

可扩展性(Scalability)是 “规模(scale)” 加上 “能力(ability)”,这意味着一个算法或系统处理更大规模问题的能力特质。

考虑布置一间能容纳 50 名学生的教室这一问题。其中一个最简单的解决方案就是预订一间教室,准备一块黑板和几支粉笔,这样问题就解决了。

但是如果问题的规模变大了呢?如果学生数量增加到200呢?

之前的解决方案仍然可行但是需要更多的资源。在这个例子里,你可能需要更大的屋子(可能得是一个礼堂)、一块投影屏幕以及一支电子笔。

如果学生数量增加到1000呢?

当问题的规模增大时,这个解决方案要么无法奏效,要么就得消耗大量资源。这就意味着,你之前的解决方案不具备可扩展性。

那么,什么样的解决方案才是具有可扩展性的呢?

可汗学院(Khan Academy)这样的网站为例,数百万的学生能够同时观看视频、阅读解答内容,而且并不需要更多额外的资源。所以,这种解决方案能够在资源有限的情况下处理更大规模的问题。

如果你看一下我们之前第一个那个求前N个自然数之和的解决方案,是不具备可扩展性的。这是因为随着问题规模呈线性增长,其所需的时间也呈线性增长。这样的算法也被称为线性可扩展算法。

我们的第二种解决方案具有很强的可扩展性,并且在解决更大规模的问题时无需再花费更多时间。这类算法被称为常数时间算法。

内存是很昂贵的

内存并不总是充足可用的。当处理那些需要你存储或生成大量数据的代码或系统时,对于你的算法而言,尽可能地节省内存使用是至关重要的。

例如:在存储有关人员的数据时,你可以通过仅存储他们的出生日期而不是年龄来节省内存。你总是可以根据他们的出生日期和当前日期即时计算出年龄。

算法提高效率的一些例子

学习算法和数据结构可以让你做到以下:

例子1: 年龄组问题

像查找某个特定年龄组人群这样的问题,只要对二分查找算法稍作修改(假设数据是已排序的),就能够轻松解决。

那种逐个查看所有人,并检查其是否属于给定年龄组的朴素算法是线性可扩展的。然而,二分查找算法是一种对数可扩展的算法。这意味着如果问题的规模变为原来的平方,解决该问题所花费的时间仅仅会翻倍。

假如,从1000个人里找给定年龄的人二分查找需要1秒。那么对于100万人来说,

  • 二分查找算法仅仅需要2秒解决问题
  • 朴素算法可能需要1百万秒,大约是12天

同样的二分查找算法也可用于求一个数的平方根。


例子2: 魔方问题

想象你在写一个解魔方的程序。

这个看起来可爱的魔方竟然有着令人头疼的 43,252,003,274,489,856,000 种状态,而这些还仅仅只是状态而已!想象一下,为了达到那些错误的状态,可能会走过的路径数量。

幸运的是,解决这个问题的方式可以通过图数据结构。有一种被称为迪杰斯特拉的图算法(Dijkstra's algorithm),它能让你在线性时间内解决这个问题。没错,你没听错。这意味着它能让你以最少的状态数达到已还原的状态。


例子3: DNA问题

DNA是一种携带遗传信息的分子。它们由更小的单位组成,这些单位用罗马字母 A、C、T 和 G 来表示。

想象一下你在生物信息学领域工作。你被分配了一项任务,那就是找出某一特定模式在一条 DNA 链中出现的情况。

这是计算机科学学术领域里的一个著名问题。并且,最简单的算法所花费的时间与(DNA 链中的字符数量)*(模式中的字符数量)成正比。

一条典型的DNA链包含数百万个这样的单位。别担心。科克伦奥科特马努拉算法(KMP 算法)能够在与(DNA 链中的字符数量)+(模式中的字符数量)成正比的时间内完成这项任务。

将乘号(*)替换为加号(+)带来了很大的变化。

想一下,如果模式是由 100 个字符组成的,那么你的算法现在就快了 100 倍。如果模式是由1000个字符组成的,KMP 算法几乎会快1000倍。也就是说,如果你原本能够在1秒钟内找到模式出现的位置,现在只需要1毫秒。我们也可以换一种方式来理解。以前匹配1条链的时间,现在你可以在相同的时间里匹配1000条!

备注(非原文译)

我们这里来算一下:假设DNA链中字符是10000000,模式中字符是100
乘号的规模是:10000000100=1000000000
加号的规模是:10000000+100=10000100
乘号是加号的100倍
所以上文中,如果模式的字符编程增加到1000
乘号规模是:10000000
1000=10000000000
加号规模是:10000000+1000=10001000
因为DNA链中的字符数量远远的大,使用乘号规模又增大了10倍
加号忽略零头,几乎不怎么增加,所以KMP算法几乎会快1000倍

这样的例子数不胜数……


结束语

一般来说,软件开发涉及到每天学习新技术。你的项目里一边用这些技术,一边学这些技术,基本可以学个七七八八。但是算法不是这样的。

如果你不了解算法,你就不知道你现在写的代码能不能优化。人们期望你提前了解这些算法,并在任何可能且关键的地方应用它们。

我们专门讨论了算法的可扩展性。一个软件系统由许多这样的算法组成。对其中任何一个算法进行优化都会使整个系统变得更好。

然而,需要着重指出的是,这并非是让系统具备可扩展性的唯一方法。举例来说,有一种被称为分布式计算的技术,它能让一个程序中相互独立的部分同时在多台机器上运行,从而使系统更具可扩展性。