使用 1 表示奇数,使用 -1 表示偶数的方法确实是正确的。我们将这些值称为增量(因此包括减量)。
以示例输入为例,您可以将这些增量可视化如下:
2 5 6 0 8 3 4 5 2 7 4 8 2 6 6 5 7
-1 1 -1 -1 -1 1 -1 1 -1 1 -1 -1 -1 -1 -1 1 1
然后您可以将其下方的这些增量的累积总和可视化:
2 5 6 0 8 3 4 5 2 7 4 8 2 6 6 5 7
-1 1 -1 -1 -1 1 -1 1 -1 1 -1 -1 -1 -1 -1 1 1
0 -1 0 -1 -2 -3 -2 -3 -2 -3 -2 -3 -4 -5 -6 -7 -6 -5
\ / \
\ / \
\
\
\ / \ / \ / \
\ / \ / \ / \
\
\
\
\
\ /
\ /
\ /
\ /
请注意,具有相同数量的偶数和奇数的范围对应于增量总和为 0 的范围。您可以通过选择开始/结束点来识别此类范围,以便开始之前的累积和为等于该范围结束后的累积和。这就像在上面的“图形”中画一条水平线并取最远的交叉点。
例如,总和中第一次出现两个 -1 值表示带有[5, 6] 的子数组是有效范围(其中奇数和偶数的数量相等)。寻找其他这样的范围,我们可以发现取最左边和最右边的 -3 会产生更大的结果:[3 4 5 2 7 4]。我们也可以将 -2 作为边界值:[8 3 4 5 2 7]。
我们还可以看到,最长范围必须对应于介于 0 和 end-sum 之间的总和(示例中为 -5)。以不在此范围内的为例:示例中的 -6。由于 0 和 -5 都在 -6 的同一侧,我们确信使用 -5 可以获得更好的结果(在图中将水平线向上移动)。对于所有超出 0 范围和最终总和的中间总和值都是如此。
可以得出的另一个结论是,总是有可能找到左端点与方向变化对应的最优解。在示例中,第一个点的总和为 -3。
算法
您可以创建一个递归算法,只要找到符合上述规则的点就会递归:
- 添加起点值之前的总和在0和最终总和的范围内
- 起始位置的增量与之前的不同(或之前没有),并且与从开始到结束的整体方向相反。这对应于上图中的一个山谷,但当最终总和为正时,它会是一个山顶。
当总和等于最终总和时,递归停止。这会立即产生大小。这个大小返回给调用者(递归树中的上一级),并且数组的末尾被缩短,直到最终的总和等于在该递归级别上查看的总和。这再次导致大小。这两种尺寸中最好的一个返回给调用者,......等等。
算法中不创建数组,递归生成的调用堆栈除外。但如果数组是完全随机的,那么递归调用的平均次数应该很少,因为总和在统计上的期望值为 0。
时间复杂度是O(n),因为总和的计算显然是O(n),另外两个循环要么移动起点,要么移动终点数组的一个方向的结束索引,不再访问相同的元素。
JavaScript 实现
此代码使用最简单的 JavaScript 语法,因此算法清晰:
function value(x) {
// Return 1 when the given value is odd, else -1
return (x % 2) || -1;
}
function largestRange(a) {
var sign, sumEnd, end;
// Calculate final sum
sumEnd = 0;
for (end = 0; end < a.length; end++) {
sumEnd = sumEnd + value(a[end]);
}
// ... and its sign (1 or -1 or 0)
sign = Math.sign(sumEnd);
function recurse(start, sumStart) {
var sum, i, size, val;
// End of recursion:
if (sumStart === sumEnd) return end - start;
sum = sumStart
for (i = start; sum !== sumEnd; i++) {
val = value(a[i]);
// Got closer to sumEnd, and now moving away from it
if (val !== sign && Math.sign(sum - sumStart) == sign) break;
sum = sum + val;
}
// Get longest range size for this particular sum
size = recurse(i, sum);
// Get range size for sumStart
while (sumEnd !== sumStart) {
end--;
sumEnd = sumEnd - value(a[end]);
}
// Retain the best of both:
if (end - start > size) size = end - start;
return size;
}
// Initiate the recursion and return result
return recurse(0, 0);
}
// Sample input
var a = [2, 5, 6, 0, 8, 3, 4, 5, 2, 7, 4, 8, 6, 6, 5, 7];
// Calculate
var size = largestRange(a);
// Output size of longest range
console.log(size);
性能
由于随机数组的预期总和预计接近 0,因此大多数中间总和值有可能超出 0 和最终总和的范围,这意味着它们不会占用太多时间和空间。
我做了一些性能测试,将它与为每个遇到的总和创建带有键的哈希的解决方案进行比较。对于较短的输入数组,这些算法更快,但对于较大的数组(如 1000 个条目),上述算法表现更好。当然,可以调整基于散列的解决方案以考虑我上面确定的规则,然后它对于更大的数组也会表现得更好。递归带来了一些哈希映射没有的开销。
但是当您对 cmets 表示有兴趣看到没有哈希映射的解决方案时,我选择了这个解决方案。