DALL-E 描绘了戈弗雷·哈代抵达拉曼努詹所在医院的情景。
当你准备参加科技或金融科技公司的编码面试时,这不仅仅是关于写出功能性的代码。这些公司对你的思维方式、优化能力以及能否高效地解决问题很感兴趣。面试官常常会通过编码问题来测试你的技能,这些问题通常超出了基本实现,要求你展示更多高级技巧。这些问题往往要求你找到减少计算复杂度的策略,从而提供更优的解决方案。不仅要求找到正确的解决方案,还要找到性能更优的解决方案。
在真实的软件开发领域中,就像在这些面试中一样,很多时候并不是要找到理论上“最优”的解决方案,而是最实际可行的那个。在机器学习、数据分析或金融建模等学科中,你经常需要在庞大的参数空间中导航,平衡准确性和效率。有时,你需要运行大规模的模拟,在这种大规模模拟的情况下,即使节省少量的计算资源,在大规模操作下也会变得至关重要。在这种情况下,编码效率和调优成为了至关重要的技能。
其中一个常见的面试问题是的士数问题。这是一个既优雅又棘手的问题,经常出现在各大科技公司的编码挑战中。任务是找到所有可以表示为两个不同立方数之和的数字,并且要高效,上限为N(N可能非常大)。正如我们将会发现的,虽然暴力求解方法可能简单易行,但随着数字变大,计算成本会迅速增加,变得非常昂贵。而一个优化的算法则可以让你更快速地找到这些数字。
的士数之谜的起源故事“的士数”这个名称是从哪里来的?据说可以追溯到传说中的印度数学家Srinivasa Ramanujan,他常被视作有史以来最伟大的数学家之一。据传,Ramanujan 在医院生病时,哈代来看他。两人聊起了哈代去医院乘坐的出租车号码。他曾这样说过:
我记得有一次去看他,他当时在普特尼生病。我坐了一辆车牌号为1729的出租车,我觉得这个号码有点无聊,希望这不是个不吉利的预兆。“不,”他回答说,“其实是个很有趣的数字;它是最小的可以用两种不同方式表示为两个立方数之和的数。”
这两个数字实际上是1729=1³+12³和1729=9³+10³,表示这两个数字可以表示为两个立方数的和。这个著名的轶事引发了术语“出租车数”的出现,而寻找这类数字的有效算法仍然是一个有趣的挑战。
在这篇文章中,我将引导你通过一个高效的算法来生成出租车号码,展示如何利用优化技术来解决编程面试中常见的问题。
朴素的实现由于计程车数是一个正整数,它可以表示为两个不同立方数的和,因此暴力破解的方法是遍历所有可能的整数对,即 a, b, c, d 四个整数,满足 a³+b³=c³+d³。
下面的 Python 代码通过四个嵌套的循环来识别这些数字,并带有额外的约束 a ≠ c, a ≠ d, b ≠ d,来避免一些简单的重复解(例如,将相同的数字进行排列)。
如果我们想要检查所有组合,我们需要运行从1到³√N的循环迭代,其中³√N是N的立方根,因为0 + (³√N)³=N一定是上限配对。换句话说,任何能够形成的士数的数对中的数的立方必须在这个值之下。
对于不超过整数 N 的的士数(taxicab numbers)的一种简单的暴力搜索算法实现。此实现效率较低,它使用了四层for循环,因此其时间复杂度为 O(N⁴)。
尽管做了这些微小的优化,该算法在处理大型输入时仍然效率低下,因为包含了四个嵌套循环和重复的组合检查。算法的时间复杂度为 O(N^(4/3)),因为每个循环最多运行 N^(1/3) 次,这表明暴力法扩展性较差。如果我们运行到 N=10⁶(对于今天的标准来说仍然是一个适度的数值),则找到所有正确组合需要 30 秒。让我们看看能否做得更好!
对于大数字而言,暴力法表现不佳。
使用 itertools 实现的方法itertools 是一个强大的 Python 模块,旨在高效地处理迭代器,提供了一套快速且内存效率高的工具,用于创建和操作遵循组合或迭代模式的迭代。该模块提供了生成排列、组合、笛卡尔积(即元素的所有可能组合)以及其他许多基于迭代器的任务的函数,而无需手动编写和管理复杂的嵌套循环,就像我们在最初的简单实现中曾编写过的一样。在处理大型数据集或需要处理元素不同组合的场景下,使用 itertools
尤其有益,因为它避免了手动迭代和管理嵌套循环状态所需的额外工作。
但是它在这种问题中表现如何呢?一个简单的使用 itertools
的实现可以像下面这样。首先,生成器生成所有满足的士数条件的数对,针对给定的整数 n。通过四舍五入解决浮点数精度问题,并检查立方条件,确保立方条件下的计算结果准确。然后,我们遍历所有可能的 n
值,以及所有生成的数对,利用 itertools
优化遍历。函数 taxicab3
打印找到的每个的士数及其两种不同的分解。
注意,我们利用 itertools
的封装成功移除了一个(显式的)for 循环。以下是这段代码运行所需的时间。
使用 itertools
包装一个 for
循环确实有点帮助,但算法还是慢。
使用 itertools
使代码效率有所提升,相比之前的暴力破解方法。代码现在通过 generate_products
直接生成立方体组合,而不是通过四个嵌套的循环来迭代,只计算有效的立方体组合。整体时间复杂度从之前的 O(N^4/3) 下降到看起来像 O(N) 的更高效的策略,更短的执行时间也证明了这一点,但实际复杂度还取决于给定的 N
有多少有效的立方体组合。
结果发现原来有一种更好的方法来减少计算复杂度:哈希。
基于哈希的实现在之前的章节中,我们看到了当寻找出租车数时,随着输入规模的增长,暴力破解的方法会迅速变得低效。即使使用了例如 itertools
这样的库,大量的迭代也使这个问题在计算上变得非常昂贵。但是,如果我们不是直接检查每个数字组合,而是存储中间结果以避免重复计算,会怎么样呢?这就是 时间和空间的权衡 所在之处。
通过使用 Python 的 字典(实际上是一个哈希表——见下文),我们可以保存在计算过程中得到的立方和的结果。每当生成一个新的和时,我们不需要重新计算所有可能的先前配对,只需查看字典中是否已有这个和。这大大减少了需要进行的比较次数!这样做妥协是增加了内存的使用,但这样能避免冗余计算,从而提高速度。
Python 字典使用一种称为 哈希 的概念来存储键值对。当你向字典中添加一个元素时,键会经过一个 哈希函数(hash function) 处理,将其转换为一个唯一的索引(哈希值)。这个索引随后用来快速查找与键对应的值。由于这种机制,字典的查找时间平均为 O(1)——这意味着无论字典变得多大,检索或更新一个条目都只需要常量时间。
我們是用字典來解決出租车數問題的,方式如下:
在使用哈希实现时,我们采用了时间-空间权衡:我们首先构建并保存所有三次幂的和到一个字典中,然后利用Python字典中O(1)查找时间来检查新出现的和是否已经存在于字典中。
以下是一些关键点:
- 构建字典(
sum_dict
):当我们遍历整数对(a, b)
时,计算它们的立方并将这些立方和s
存储在字典中,同时存储生成该和的整数对。如果我们后来遇到相同的和s
但与不同的整数对,则我们知道找到了一个的士数。 - 高效查找:当我们计算一个新的和
s
时,可以立即使用字典检查是否之前见过该和,字典由于哈希机制在 O(1) 时间内完成查找。如果sum_dict
中已存在该和,我们知道两个不同的整数对产生了相同的和,这意味着我们找到了一个的士数。 - 时间与空间的权衡:我们不需要对每个可能的整数对重新计算和(这会很耗时),而是预先计算并将这些和存储在字典中。虽然这个方法确实使用了更多的内存,但大大减少了时间复杂度。
在时间复杂度方面,这种方法快很多,实际上要比暴力方法快得多。
- 对于每个整数
a
,我们计算其立方并将其存储在pow_dict
中(这是一个 O(1) 的操作)。然后,对于所有比a
小的整数b
,我们计算和s = pow_dict[a] + pow_dict[b]
。 - 检查
s
是否在字典中也是 O(1),因为使用了哈希,因此整体的时间复杂度为 O(N^2/3)(我们只需要检查每一对(a, b)
一次)。
这比之前的暴力方法有了一个重大改进。通过使用字典,我们把问题的时间复杂度从四次方降低到了二次方。这一点也从直接计算执行时间中得到了证实。
哈希算法比暴力方法快得多,能准确识别所有的出租车数字!
注:请注意,最后代码中的N与之前的实现所用的N不一样。此代码会构建直到整数N的所有出租车数,这些数的立方和相等,而之前的实现中,N是指定的最大出租车数,因此我们只需将循环运行到N的立方根。上述时间复杂度提到的N是指原始的N。如果我们想比较此代码与之前的代码速度,我们需要将其运行到10⁶的立方根,即10²。
结论部分通过利用 Python 的字典(及其背后的哈希算法),我们显著提高了寻找的士数问题的效率。这里的折衷是使用更多内存来存储中间结果(立方和),这样可以避免重复计算,从而显著提升速度。这是一个典型的 时间-空间权衡 例子,理解何时以及如何应用此类优化对于高效解决实际编码问题非常重要。
这种方法,如同许多优化技术一样,表明在牺牲内存使用的情况下节省计算时间往往是解决这类问题的关键。希望下次你在编程面试或是实际应用中碰到类似的问题时,能记得这个方法!
你可以在我的 gitlab 仓库 中查看所有实现。