往期回顾:
[见题拆题] 大厂面试算法真题解析 - 第一期开张
这不是标题党,今天我们真的要上Uber的车~
题目
[来自Uber]
Given an array of integers, return a new array such that each element at index i of the new array is the product of all the numbers in the original array except the one at i
给定一个整数数组,返回一个新数组,使得位于位置i
的元素,等于给定数组中位置i
以外的所有元素的乘积。
举个例子:
如果给定的数组是[1, 2, 3, 4, 5],那么你输出的数组就应该是[120, 60, 40, 30, 24]
如果给定的数组是[3, 2, 1],那么你返回的数组就是[2, 3, 6]
解析
将题干中的叙述直接翻译成代码就可以得到最“淳朴”的暴力解法了(为了表达的方便,在以下伪码中,result
代表算法返回的新数组,并且新数组中每一个元素的初始值都是1):
function arrayOfProducts(array, result):
for i=1 to n
for j=1 to n
if j != i
result[i] = result[i] * array[j]
return result
在计算新数组中每一个元素的时候,我们都要遍历给定数组,以便求出相应的乘积,这样一来,暴力解法的时间复杂度就定格在了缓慢的O(n^2)
。
观察暴力解法的计算过程,反复遍历给定数组显然是最浪费时间的步骤。头脑灵活的小伙伴肯定已经想到了:为什么不用除法来计算新数组中元素的值呢!
优化
对于一个给定的数组,只要遍历一次,就可以得到整个数组全部元素的乘积。如果我们用a[i]
来代表给定数组中位于位置i
的元素,P
代表给定数组中全部元素的乘积;用t[i]
来代表新数组中位于位置i
的元素;那么我们可以用下面这个表达式来表达t[i]
: t[i] = P / a[i]
有了这个简洁漂亮的表达式,我们就可以改写暴力解法的“丑陋”伪码了:
function arrayOfProducts(array, result):
let P = 1
for i=1 to n
P = P * array[i]
for j=1 to n
result[j] = P / array[j]
return result
我们只需要遍历两次给定数组,就可以生成新数组了。所以经过优化的算法,它的时间复杂度直接下降为O(n)
。
那么我们就大功告成了吧?且慢!在上一期的文章里,我们已经强调过,在进行算法面试时,务必要考虑输入、输出以及操作中的边缘情况、极端情况和异常情况。
改进
在除法运算中,最常见的一种异常情况,就是除数为零所造成的运算错误。重读一遍题目,给定的数组是一个整数数组,所以数组中出现等于零的元素是很合理的情况。而当这种情况出现的时候,我们的优化算法就会抛出异常,无法给出正确答案了!为了挽救我们的面试,接下来,我们就要进一步改写算法,来正确的应对元素值等于零的特殊情况。
当给定数组a
的所有元素乘积P
不等于零时,我们可以确定a
中不包含任何等于零的元素,此时我们可以放心的使用上面讲解过的优化算法。
而当P
等于零时,则说明a
中包含至少一个等于零的元素,这时我们就需要新的方法来处理题目了。
在这种情况下,我们仍然要在遍历a
数组的过程中来生成新数组t
。如果a[i]
不等于零,那就说明t[i]
一定等于零,因为t[i]
的值是a
中除了a[i]
以外所有元素的乘积,而这些元素一定包含至少一个等于零的元素,不然总乘积P
就不会等于零了。这个情况其实也可以用我们上面讲过的优化解法来解答:t[i] = P / a[i] = 0 / a[i] = 0
而如果a[i]
等于零,那么由于除数不能为零的原则,我们无法使用t[i] = P / a[i]
的表达式来计算t[i]
,而只能像暴力解法那样,遍历一遍数组a
,使用乘法运算来计算出t[i]
的值了。
这样一来,大家可能就有点泄气了。辛苦了半天,我们怎么又回到暴力解法去了... 那我们这个改进版的“优化”解法的时间复杂度是不是又退化到O(n^2)
了?其实不是的。让我们再仔细的看一下解法。在我们遇到了第一个等于零的a[i]
,并且使用乘法运算计算出了t[i]
的值之后,我们就没有必要继续计算t
中其他元素的值了。因为数组t
中余下的所有元素都必定会因为乘以a[i]
这个等于零的值而变成零。也就是说,在我们遇到第一个等于零的a[i]
之后,我们就可以停止遍历数组a
,而将数组t
中剩余的元素全部设为零就大功告成了。所以,这个改进后的算法,至多也不过是多遍历了一次数组a
而已。算法的整体时间复杂度仍然保持在O(n)
。
思路理清了,接下来就让我们上伪码:
function arrayOfProducts(array, result):
let P = 1
for i=1 to n
P = P * array[i]
let j = 1
while j <= n
if array[j] != 0
result[j] = P / array[j]
j++
else
for k=1 to n
if k != j
result[j] = result[j] * array[k]
break
for l=j+1 to n
result[l] = 0
return result
算法优化过了,异常情况也处理了,听完你眉飞色舞的讲解,面试官频频点头。正在你松了一口气的时候,面试官的嘴角露出了一丝狡黠的微笑~事情并没有那么简单,面试官问到:如果不允许你使用除法运算呢?
别慌!在算法面试中,面试官常常会在你给出了一个优化解法之后,再提出更高的要求,或是再改动题目的某些条件,使得题目的难度加大。这样不仅可以进一步考察候选人的解题能力,还能测试候选人在突然出现的压力之下,是否能保持冷静思考的能力。
既然除法运算被禁止了,那我们就再回归到暴力解法的乘法运算,想一想除了利用除法运算来减少遍历给定数组的次数之外,还有什么其他方法可以避免重复的遍历给定数组呢?
在算法设计过程中,有一个一般原则,就是“用空间换时间”。也就是说,在条件允许的情况下,可以尝试计算和存储更多的“中间值”,或是使用更复杂的数据结构,来达到缩短运算时间的目的。这道题目并没有限制解法的空间复杂度,所以我们不妨使用“以空间换时间”这个思路来试一试。
举个例子来分析,假设给定的数组包含四个元素,即a = [ a1, a2, a3, a4 ]
那么最终生成的新数组就应该是t = [ a2*a3*a4, a1*a3*a4, a1*a2*a4, a1*a2*a3 ]
从a
直接跳跃到t
(并且不能使用除法运算)需要花费较长的时间。那么有没有可能在这中间增加一些“中间值”,以便来加速我们的算法呢?
让我们试着把t
中的元素分解一下。t
数组中有些元素是a
数组中几个相邻的元素的乘积,比如说t1 = a2*a3*a4
。还有些元素包含了a
中几个不相邻的元素的乘积,比如说t2 = a1*a3*a4
。如果我们根据a
中元素是否相邻这个标准,把t
的元素分解一下,那么t
数组就会变成这样:
t1 = 1 * (a2*a3*a4)
t2 = a1 * (a3*a4)
t3 = (a1*a2) * a4
t4 = (a1*a2*a3) * 1
根据这个分解,如果我们可以事先准备好两个数组,一个是left = [ 1, a1, a1*a2, a1*a2*a3 ]
,另一个是right = [ a2*a3*a4, a3*a4, a4, 1 ]
,那么我们只要将left
和right
中的元素分别相乘,就可以得到最终的数组t
了!
这里的left
和right
两个数组,就是我们为了降低暴力解法的时间复杂度,而计算出的“中间值”。只要遍历给定数组a
两次,就可以计算出这两个中间值的数组了:第一次从左到右遍历a
,生成left = [ 1, a1, a1*a2, ... , a1*a2*...*a(n-1) ]
;第二次从右到左遍历a
,生成right = [ a2*...*a(n-1)*an, ... , a(n-1)*an, an, 1 ]
。所以,这个算法最终的空间复杂度和时间复杂度都是O(n)
,和可以使用除法运算的解法一样快,完美应对了面试官的“刁难”~
总结
这道题目和上一期Google的题目有异曲同工之妙。它们都不涉及像动态规划那样“隆重”的解题套路,但是题目的编排中却有不少陷阱等着你。
一开始使用除法运算来优化暴力解法是题目的第一道关卡,不过这个关卡难度不高,相信大部分应聘者都能答出来。
而第二个关卡就是对于“除数可能为零”这个异常情况的处理。经验少的应聘者很可能栽倒在这一关。成功优化了暴力解法所带来的兴奋,很容易让应聘者忽视了思维的缜密性,这样你就要输给更冷静更严谨的竞争对手啦。
而题目最后加上的限制条件(禁止使用除法运算)则是直线拉升了题目的难度。坦白地说,最后这一个优化算法确实有难度,如果有应聘者在面试的紧张状态下能讲出这个解法,那我只能说佩服佩服,你值得坐上Uber的驾驶座~
以上就是这一期的算法拆解。大家跟着一起做题辛苦啦~好好总结一下吧~
[转载请注明]
欢迎关注课程: