65.9K
CodeProject 正在变化。 阅读更多。
Home

优化狂热!!!

starIconstarIconstarIconstarIconemptyStarIcon

4.00/5 (4投票s)

2018年2月21日

CPOL

5分钟阅读

viewsIcon

6623

优化,还是不优化,这是个问题……

什么是过早优化?

你可能以前听过这个词,但它到底是什么意思?毕竟,优化是一件好事,应该融入到软件工程师所做的所有事情中,对吧?纯粹从学术角度来看,这绝对是真的。

然而,在现实情况中,我们需要维护代码。随着代码复杂性的增加,维护成本和风险也会随之增加。我们是在构建一个每秒处理数百万笔金融交易的系统吗?还是在构建一个搜索优惠的演唱会门票的系统?这个问题会对平衡点在哪里产生深远的影响。

即使在这些截然不同的场景下,系统的某些部分也需要比其他部分更多的优化或更少的优化。过滤客户交易记录与银行所有记录的系统可能需要疯狂优化,但我们真的需要为了改变排序方式从日期到金额而做到那种程度吗?

案例研究:素数

素数是只能被 1 和它本身整除的整数。这些数字在计算机科学的许多领域都发挥着重要作用。它们为何如此重要已超出了本文的范畴,但它们是处理复杂算法的有趣案例研究。我们将比较几种找出给定上限内的所有素数的方法。所有示例均以 Kotlin 编写,并且示例时间是通过在单元测试中平均运行 10 次来测量的。

暴力破解

在这个练习中,我们将遍历给定上限内的每一个数字,并确定它是否是素数。我们将通过检查到潜在素数平方根(任何大于平方根的数都不可能成为其因子)为止的所有潜在因子来做到这一点。如果没有找到因子,我们就将其放入一个数组。一旦我们到达上限,我们就停止,我们也就有了所有需要的素数。简而言之,我们遍历每一个数字,并尝试该数字的每一种可能的因子来找到素数。效率不高,对吧?

我们可以给 isPrime 检查增加一点智能,稍后我们会看到它对数字的影响。使用这个(稍微复杂一点的)算法,如果值为 1,我们直接返回 false;否则,如果值为 23,我们自动返回 true。一旦超过这一点,它就会排除所有能被 23 整除的数。然后,情况变得更有趣,它从下一个“已知素数” 5 开始,只要测试值的平方不超过 n,我们就测试它是否可整除,将其增加到下一个奇数,然后重复。如果我们发现任何因子,我们就返回 false。如果我们通过了循环,我们就返回 true

fun isPrime(n: Int): Boolean {

    val max = Math.sqrt(n.toDouble()).toInt()

    for (factor in 2..max) {
        if (n % factor == 0) {
            return false
        }
    }

    return true
}

fun isPrimeWithSmartCheck(n: Int): Boolean {

    if (n == 1) {
        return false
    } else if (n <= 3) {
        return true
    } else if (n % 2 == 0 || n % 3 == 0) {
        return false
    }

    var testValue: Int = 5

    while (testValue * testValue <= n) {
        if (n % testValue == 0) {
            return false
        }
        testValue += 2
    }

    return true
}

fun findPrimes(n: Int): List<Int> {

    var primes: MutableList<Int> = ArrayList()

    for (i in 2..n) {
        if (isPrime(i)) {
            primes.add(i)
        }
    }

    return primes
}

埃拉托斯特尼筛法

埃拉托斯特尼(Eratosthenes)是一位希腊数学家(兼职)。他是数论的先驱,并引入了埃拉托斯特尼筛法。这是一种相当知名的查找素数的有效方法。

我们首先创建一个长度为 n 的布尔值列表,默认值为 true。我们将大小设置为 n + 1 以包括零。我们立即排除零和一,将它们设置为 false,因为它们不被计为素数。

接下来,我们遍历布尔数组跟踪的可能性列表。对于每个数字,从 2 开始,我们检查该索引的布尔值是否为 true。如果是,我们就将其添加到素数列表中。然后,我们遍历该数字到布尔数组末尾的所有倍数,将任何倍数标记为 false。这意味着我们找到的下一个 true 值是一个素数,所以我们将其添加到素数列表并重复。这种方法的优点是,每次找到一个素数,我们就会减少剩余待检查值的数量。当我们到达布尔列表的末尾时,我们就得到了最终的素数列表。

fun sieveOfEratosthenes(n: Int): List<Int> {

    var primes: MutableList<Int> = ArrayList()
    var list = MutableList(n + 1, {i -> true})

    list[0] = false
    list[1] = false

    for (i in list.indices) {
        if (list[i]) {

            primes.add(i)
            var j = i

            while (j < list.count()) {
                list[j] = false
                j += i
            }
        }
    }

    return primes
}

棘手的数字

那么,埃拉托斯特尼筛法比暴力破解法效率高多少呢?嗯,这取决于。虽然它在处理大型 n 值时表现非常好,但在处理较小的数字时,暴力破解法实际上比筛法更快。通过添加智能检查,我们甚至可以使暴力破解法稍微更具竞争力,但即使如此,它也一直被原始的、野蛮的方法击败,直到 n 超过 1000

            average time in milliseconds
 n       |     sieve    |  brute force |  w/smart check
 --------------------------------------------------------
     10  |    0.649766  |    0.006483  |    0.496988
    100  |    0.639629  |    0.025545  |    0.579207
   1000  |    1.038445  |    0.235613  |    0.653132
  10000  |    3.082918  |    1.784386  |    1.767561
  50000  |    6.192984  |   11.570596  |    5.937535
 100000  |   12.807858  |   29.364065  |   14.411587
1000000  |  103.172073  |  622.648549  |  377.154029
2000000  |  329.524157  |  1673.22857  |  888.082857

如上表所示,一旦 n 达到 50,000,埃拉托斯特尼筛法就轻松夺冠。但有趣的是,代码量最少的原始暴力破解方法在 n 低于该阈值时,却远远领先于筛法。此外,通过添加一点点复杂性,将 isPrime 检查切换为使用更智能的方法,该阈值会接近 100,000,但在 n 低于 10,000 时,简单的暴力破解方法仍然优于智能检查。虽然在 200 万这个量级上的收益高达 4 倍,但在较小规模上,简单方法的收益速度快达 100 倍!

故事的寓意

直觉是人类大脑的一个强大表现,它在我们不知情的情况下进行了大量的计算,并将其过滤成一种“直觉”感觉。在识别代码中的瓶颈或低效之处时,开发人员常常会依赖这种机制。不幸,如上所示,一种事物的最优方法可能非常违反直觉。我绝不是建议我们在决策过程中忽视直觉。请务必相信你的直觉,但通过在盲目地为了优化而过度复杂化代码之前查阅指标,我们可以为自己节省巨大的麻烦。

© . All rights reserved.