简介
二和面试题很有意思,因为它既有蛮力的逻辑解决方案,也有更省时的解决方案,可以展示强大的计算机科学基础知识。让我们来探索这两种潜在的解决方案,并希望能在此过程中有所收获
二和问题
首先,让我们了解一下二和问题。它通常是以下列某种形式提出的。
你被要求创建一个需要两个参数的函数。第一个参数,nums ,是一个数组。第二个参数,total ,是一个单一的数字。该函数的输出应该是一个双元素数组,代表nums 中的一对数字,它们的总和为total 。
/**
* @param {number[]} nums
* @param {number} total
* @return {number[]}
*/
const twoSum = (arr, total) => {
// Solution here
};
通常情况下,我们会得到几个有效的输入/输出组合的例子。
input: nums = [1, 2, 3], total = 4
output: [1, 3]
input: nums = [3, 9, 12, 20], total = 21
output: [9, 12]
关于在面试中解决编码难题的简要说明
如果你在面试中解决任何编码挑战,在开始解决问题之前,最好先问一些澄清的问题。在双和案例中,你可能想问以下问题(可能还有一些我想不起来的问题)。
nums能否是一个数组以外的东西?total能否是一个数字之外的其他东西?nums中是否总是有两个数字相加为total?如果不是,当没有解决方案的时候,输出应该是什么?
在这篇博文中,我们将假设nums 永远是一个数组,total 永远是一个数字,并且永远有一个问题的解决方案(即nums 中的两个数字加起来永远是total )。
强行求解
我们的第一直觉可能是用蛮力解决。要做到这一点,我们可以使用以下程序。
- 从
nums中的第一个元素开始,遍历数组中的每一个剩余元素,检查它们是否相加为total - 转到
nums的第二个元素,然后遍历其余的每个元素,检查它们是否相加为total - 重复进行,直到找到匹配的总和
在代码中,我们将以嵌套循环的方式实现这一点。
/**
* @param {number[]} nums
* @param {number} total
* @return {number[]}
*/
const twoSum = (nums, total) => {
for (let i = 0; i < nums.length - 1; i++) {
for (let j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] === total) {
return [nums[i], nums[j]];
}
}
}
};
console.log(twoSum([1, 2, 3], 4)); // [1, 3]
console.log(twoSum([3, 9, 12, 20], 21)); // [9, 12]
真棒!这个解决方案有几个潜在的棘手之处;让我们快速探讨一下。
为什么外循环要停在i < nums.length - 1 ?
外循环不需要考虑nums 数组的最后一个元素,只需要考虑数组中倒数第二的元素。嵌套循环会考虑到最后一个元素。
为什么嵌套循环要从j = i + 1 开始?
正如我们上面所描述的,外循环从数组中的一个位置开始,内循环只需要从数组中后来出现的数字开始。任何包括数组中较早数字的组合都已经被尝试过了。
蛮力法的问题
用蛮力方法解决二和问题是很好的。它展示了坚实的推理和编码技能。尽管如此,能够阐明任何解决方案的问题是很有帮助的:意识到你的软件的局限性和相关的计算机科学基础知识,对未来的雇主来说是令人印象深刻的,对你成长为一个开发者来说也是很重要的。
那么问题出在哪里?嵌套循环使我们面临O(n2),或二次的时间复杂性。
了解O(n2)的时间复杂性
本质上,O(n2)时间复杂度意味着执行算法的时间与输入数的平方成正比。当我们看一下我们的蛮力方法时,这一点就很明显了:如果我们在nums ,我们的解决方案必须在每个嵌套循环中通过一个额外的元素,然后必须在整个双循环中做一个额外的时间。
让我们做一个实验来看看这个加法。我们将创建一个有100,000个元素的数组,解决方案的编号就是最后的两个元素。
const len = 100000;
const bigArr = new Array(len).fill(1);
bigArr[len - 2] = 9;
bigArr[len - 1] = 10;
const total = 19;
现在让我们来实现我们的蛮力二和解决方案,但这次我们将跟踪它的迭代次数以及大致所需时间。
const twoSum = (nums, total) => {
let iterations = 0;
const startTime = new Date();
for (let i = 0; i < nums.length - 1; i++) {
for (let j = i + 1; j < nums.length; j++) {
iterations++;
if (nums[i] + nums[j] === total) {
console.log(
`Iterations: ${iterations}`,
`Time: ${new Date() - startTime}ms`
);
return [nums[i], nums[j]];
}
}
}
};
twoSum(bigArr, total);
// Iterations: 4999950000 Time: 20032ms
蛮力解法经历了近50亿次的迭代,在我的电脑上,花了20秒。呀!让我们看看我们是否能做得更好。
对象的力量(以及更重要的,哈希表)
事实上,我们可以做得更好。与其创建一个嵌套循环,不如让我们只浏览一次nums 数组。为了跟踪我们已经看过的数组元素,我们要把它们作为键添加到一个对象中。对于数组中的每个元素,我们检查互补的键是否存在于我们的对象中。
这在段落中可能会让人感到困惑,所以这里是代码!
const twoSum = (nums, total) => {
// Keep track of previous array values
const previousValues = {};
for (let i = 0; i < nums.length; i++) {
// What previous value needs to exist for
// us to have found our solution?
const complement = total - nums[i];
if (previousValues[complement]) {
return [complement, nums[i]];
}
// This current array item now becomes
// a previous value
previousValues[nums[i]] = true;
}
};
console.log(twoSum([1, 2, 3], 4)); // [1, 3]
console.log(twoSum([3, 9, 12, 20], 21)); // [9, 12]
你可能会想:我们只有一个循环,当然,但我们的第二个循环被这个previousValues[complement] 查询所取代。这真的比第二个循环的效率高得多吗?
答案是肯定的,因为对象查找的时间复杂度是O(1)。这是由于JavaScript在对象中使用了哈希表的缘故!
由于对象查找是O(1),循环是O(n),我们的函数的时间复杂度现在是O(n)。让我们在我们之前使用的同一个大数组上试试我们的新算法。
const len = 100000;
const bigArr = new Array(len).fill(1);
bigArr[len - 2] = 9;
bigArr[len - 1] = 10;
const total = 19;
const twoSum = (nums, total) => {
let iterations = 0;
const startTime = new Date();
const previousValues = {};
for (let i = 0; i < nums.length; i++) {
iterations++;
const complement = total - nums[i];
if (previousValues[complement]) {
console.log(
`Iterations: ${iterations}`,
`Time: ${new Date() - startTime}ms`
);
return [complement, nums[i]];
}
previousValues[nums[i]] = true;
}
};
twoSum(bigArr, total);
// Iterations: 100000 Time: 4ms
快多了,快多了。
没有什么是免费的
虽然我们降低了时间复杂度,但我们增加了空间复杂度,因为我们需要在内存中创建一个新对象,previousValues 。对于非常大的对象(例如,一百万个元素的数量级),我们正在谈论10MB的内存。这不是小事,但为了节省时间的复杂性,这可能是值得的。