探索JavaScript中的Two-Sum

154 阅读3分钟

简介

二和面试题很有意思,因为它既有蛮力的逻辑解决方案,也有更省时的解决方案,可以展示强大的计算机科学基础知识。让我们来探索这两种潜在的解决方案,并希望能在此过程中有所收获

二和问题

首先,让我们了解一下二和问题。它通常是以下列某种形式提出的。

你被要求创建一个需要两个参数的函数。第一个参数,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的内存。这不是小事,但为了节省时间的复杂性,这可能是值得的。