题目描述
给你一个整数数组 nums,返回 数组 answer ,其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积 。题目数据 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。请 不要使用除法,且在 O(n) 时间复杂度内完成此题。
解析
这似乎是一个简单的问题,可以在线性时间和空间内解决。先计算给定数组所有元素的乘积,然后对数组中的每个元素 xxx,将总的乘积除以 xxx 来求得除自身值的以外数组的乘积。而且在问题中说明了不允许使用除法运算。实际上题目中已经说了是用什么样的方法去解决:题目数据保证数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位整数范围内。说明算法是会用到前缀和后缀的乘积。
最容易想到的就是一次遍历然后计算前缀和后缀并保存下来,然后遍历去乘起来即可。
class Solution {
public int[] productExceptSelf(int[] nums) {
int len = nums.length;
int[] prefix = new int[len];
int[] suffix = new int[len];
suffix[0] = 1;
prefix[len - 1] = 1;
for(int i = 1; i < len; i++){
suffix[i] = suffix[i - 1] * nums[i - 1];
prefix[len - 1 - i] = prefix[len - i] * nums[len - i];
}
for(int i = 0; i < len; i++) {
nums[i] = prefix[i] * suffix[i];
}
return nums;
}
}
这种写法用到了两个额外的列表来存储,实际上可以优化一个列表,第二个列表完全可以用一个int变量来替代。
class Solution {
public int[] productExceptSelf(int[] nums) {
int len = nums.length;
int[] result = new int[len];
result[0] = 1;
for (int i = 1; i < len; i++) {
result[i] = result[i - 1] * nums[i - 1];
}
int right = 1;
for (int i = len - 1; i >= 0; i--) {
result[i] = result[i] * right;
right *= nums[i];
}
return result;
}
}
根据上面的优化方式,似乎还可以进行优化,因为结果是乘积,那么先乘全部后缀和先让某个后缀乘后再继续乘其他后缀实际上是一样的,那么这样就可以优化为一次循环。
class Solution {
public int[] productExceptSelf(int[] nums) {
int len = nums.length;
int[] res = new int[len];
Arrays.fill(res,1);
int prefix = 1;
int suffix = 1;
for(int i = 0; i < len; i++){
res[i] *= prefix;
prefix *= nums[i];
res[len - 1 - i] *= suffix;
suffix *= nums[len - 1 - i];
}
return res;
}
}
但这种方式写法虽然简洁,的时间效率实际上并不高(对于刷题而言),因为需要调用fill函数去给res填充1。
然后查看了1ms的代码,发现其实就是双列表的写法,只不过将一个循环拆开了,每个循环只计算前缀或者后缀。
public int[] productExceptSelf(int[] nums) {
int n = nums.length;
int[] res = new int[n];
int[] left = new int[n];
int[] right = new int[n];
int sum = 1;
left[0] = 1;
right[n-1] = 1;
for(int i=1;i<n;i++){
sum*= nums[i-1];
left[i] = sum;
}
sum =1;
for(int i=n-2;i>=0;i--){
sum*=nums[i+1];
right[i] = sum;
}
for(int i=0;i<n;i++){
res[i] = left[i]*right[i];
}
return res;
}
为什么拆开后效率会更高,下面是GPT4的回答:
- 缓存局部性(Cache Locality)
Solution1 和 Solution2 都使用了两个额外数组来存储前缀和后缀乘积。理论上说,这两种解法的时间复杂度是相同的(O(n))。然而,Solution1 在一个循环中同时计算 suffix 和 prefix,这可能导致 CPU 缓存使用不如 Solution2 中分开计算的情况效率高。在 Solution2 中,首先完全填充了 left 数组,然后再填充 right 数组,这种方式可能更好地利用了 CPU 的缓存机制,因为每个数组在内存中是连续的,且一次性被处理完毕,减少了缓存未命中的可能性。 - 指令级并行性(Instruction Level Parallelism)
在现代处理器中,代码的执行顺序和并行性可以影响其性能。Solution1 在一个循环里计算两个独立的数组,这可能阻碍了一些现代编译器和处理器进行更有效的指令重排序和并行执行。而 Solution2 将两个循环完全分开,使得处理器可能更有效地优化每个循环的执行。 - 循环的结构
Solution2 中的循环结构更为清晰,每个循环只做一件事(计算 left 或 right),这不仅有助于编译器优化代码,也使得代码更容易理解和维护。而 Solution1 的循环中同时更新两个数组,虽然看似高效,实际上可能导致处理器预测失误,从而降低执行效率。 - 微小的差异
尽管上述因素可能导致一定的性能差异,但在很多实际情况下,这种差异可能是微不足道的。不过,如果在高性能要求的应用中,这些小的差异可能会被放大。
两种解法的核心算法复杂度是相同的,差异可能来源于具体的实现方式,如缓存效率、编译器优化程度等。在实际开发中,应该基于具体的环境和性能测试结果来选择合适的实现方式。对于大多数应用,更清晰和可维护的代码通常更为重要。