前端手撕代码(作业帮三面完)

655 阅读7分钟

前端社招手撕代码

前言

最近面试了武汉的作业帮,一面二面很顺利以深挖项目为主,手撕代码给了两道很简单的题,比拼多多简单。

一面

一面给了一道求素数的题,当时惊到了我了,没想到这么简单,我直接实现了双循环暴力求解。

面试官问可以继续优化吗,我加了个if条件。

面试结束后我想起来了,这是大学谭浩强C语言那本书里面的老经典,优化方法就是限定内循环范围为根号n,以及只对已经求得的素数取模。

1. 暴力法

暴力法是最直接的方法,通过遍历每个数字并检查它是否为素数来构建数组。

function getPrimes(n) {
    const primes = [];
    for (let i = 2; i <= n; i++) {
        let isPrime = true;
        for (let j = 2; j < i; j++) {
            if (i % j === 0) {
                isPrime = false;
                break;
            }
        }
        if (isPrime) primes.push(i);
    }
    return primes;
}

优点:

  • 实现简单,容易理解。

缺点:

  • 时间复杂度较高,为O(n²),效率较低。

2. 优化暴力法

对暴力法进行优化,减少不必要的检查。例如,只需要检查到√i即可。

function getPrimes(n) {
    const primes = [];
    for (let i = 2; i <= n; i++) {
        let isPrime = true;
        for (let j = 2; j * j <= i; j++) {
            if (i % j === 0) {
                isPrime = false;
                break;
            }
        }
        if (isPrime) primes.push(i);
    }
    return primes;
}

优点:

  • 时间复杂度降低到O(n√n),效率有所提升。

缺点:

  • 仍然需要逐个检查每个数字。

3.暴力继续优化

素数其实就是只能拆成n=1*i,不能继续拆,素的不能再素。所以内循环可以只循环到根号n。那么其实还可以再缩小范围,因为根号n其实也可以是两个素数相乘,可以再拆。那么内循环只只需要遍历根号n以内的素数即可。

function getPrimes(n) {
    const primes = [];
    for (let i = 2; i <= n; i++) {
        let isPrime = true;
        for (let j = 0; j < primes.length && primes[j] * primes[j] <= i; j++) {
            if (i % primes[j] === 0) {
                isPrime = false;
                break;
            }
        }
        if (isPrime) primes.push(i);
    }
    return primes;
}

这种方法的时间复杂度仍然接近O(n√n),但实际运行效率会更高,因为减少了不必要的检查。

示例运行

假设n = 20,运行过程如下:

  • 初始时primes = []
  • 检查i = 2,没有素数可以检查,直接加入primes,此时primes = [2]
  • 检查i = 33 % 2 !== 0,加入primes,此时primes = [2, 3]
  • 检查i = 44 % 2 === 0,不是素数。
  • 检查i = 55 % 2 !== 05 % 3 !== 0,加入primes,此时primes = [2, 3, 5]
  • 以此类推,最终结果为[2, 3, 5, 7, 11, 13, 17, 19]

4. 递归实现

虽然不常见,但也可以通过递归的方式实现素数筛选。

function isPrime(num) {
    if (num <= 1) return false;
    for (let i = 2; i * i <= num; i++) {
        if (num % i === 0) return false;
    }
    return true;
}

function getPrimes(n) {
    if (n < 2) return [];
    const primes = getPrimes(n - 1);
    if (isPrime(n)) primes.push(n);
    return primes;
}

优点:

  • 代码简洁,逻辑清晰。

缺点:

  • 递归深度可能较大,效率较低。

二面

二面给了一道判断模块是否出现循环依赖的题。判断模块是否出现循环依赖是一个典型的图论问题,可以通过检测图中是否存在环来解决。在这个场景中,每个模块可以看作图中的一个节点,而模块的依赖关系可以看作是有向边。如果图中存在环,则说明存在循环依赖。 你说得有道理,使用一个Set来记录访问过的模块确实可以用于检测循环依赖,但这种方法需要结合递归调用回溯机制来确保正确性。不过,这种方法在逻辑上相对简单,但需要注意一些细节,比如如何处理回溯以及如何避免误判。

使用Set实现循环依赖检测

我们可以使用Set来记录当前路径中访问过的模块。如果在递归过程中遇到已经在Set中的模块,说明存在循环依赖。递归结束后,需要从Set中移除当前模块,以便不影响其他路径的判断。

以下是实现代码:

function hasCircularDependency(modules) {
    // 构建图的邻接表
    const graph = {};
    modules.forEach(module => {
        graph[module.name] = module.dependencies;
    });

    // 用于记录当前路径中访问过的模块
    const visiting = new Set();

    // 深度优先搜索
    function dfs(node) {
        if (visiting.has(node)) return true; // 如果当前模块已经在访问路径中,说明存在循环依赖
        if (!graph[node]) return false; // 如果没有依赖,直接返回

        visiting.add(node); // 将当前模块加入访问路径
        for (const neighbor of graph[node]) {
            if (dfs(neighbor)) return true; // 递归检查依赖模块
        }
        visiting.delete(node); // 回溯:移除当前模块
        return false;
    }

    // 遍历所有模块
    for (const module of modules) {
        if (dfs(module.name)) return true; // 如果从某个模块开始发现循环依赖,直接返回
    }
    return false; // 如果所有模块都遍历完成,没有发现循环依赖
}

// 示例用法
const modules = [
    { name: "A", dependencies: ["B", "C"] },
    { name: "B", dependencies: ["D"] },
    { name: "C", dependencies: ["B"] },
    { name: "D", dependencies: ["A"] }
];

console.log(hasCircularDependency(modules)); // 输出:true

关键点解析

  1. visiting的作用

    • visiting是一个Set,用于记录当前递归路径中访问过的模块。
    • 如果在递归过程中遇到已经在visiting中的模块,说明存在循环依赖。
  2. 回溯机制

    • 在递归调用结束后,需要从visiting中移除当前模块,以便不影响其他路径的判断。
    • 这是通过visiting.delete(node)实现的。
  3. 递归终止条件

    • 如果当前模块没有依赖(graph[node]不存在或为空),直接返回false
    • 如果在递归过程中发现循环依赖,直接返回true

示例分析

对于输入的模块数组:

[
    { name: "A", dependencies: ["B", "C"] },
    { name: "B", dependencies: ["D"] },
    { name: "C", dependencies: ["B"] },
    { name: "D", dependencies: ["A"] }
]
  • 构建的图结构为:
    {
        A: ["B", "C"],
        B: ["D"],
        C: ["B"],
        D: ["A"]
    }
    
  • DFS遍历过程中:
    • 从模块A开始:
      • 访问Bvisiting = {A, B}
      • 访问Dvisiting = {A, B, D}
      • 访问A,发现A已经在visiting中,说明存在循环依赖。

优点:

  • 实现相对简单,逻辑清晰。
  • 使用Set可以快速判断模块是否在当前路径中。

缺点:

  • 需要手动处理回溯,稍不注意可能会遗漏。
  • 如果模块数量非常多,递归深度可能会较大,可能会导致栈溢出。

三面

三面是给定m个数组,每个数组按照升序排列,现在要求从两个不同的数组取两个整数,求绝对值。要求绝对值最大。

输入:[[1,2,3],[4,5],[1,2,3]],
输出:4
解释:取15,求绝对值

解题思路

我们需要从多个已排序的数组中找到两个不同数组中的元素,使得它们的差的绝对值最大。为了高效地找到这个最大距离,可以考虑以下步骤:

  1. 遍历所有数组:记录每个数组的最大值和最小值,以及它们所在的数组索引。
  2. 比较不同数组的极值:通过比较不同数组之间的最大值和最小值,找到可能的最大距离。
  3. 处理特殊情况:例如当所有数组的最大值和最小值都在同一个数组中时,需要找到次大的最大值或次小的最小值。

解决代码

function maxDistance(arrays) {
    let max1 = -Infinity, min1 = Infinity;
    let maxIndex = -1, minIndex = -1;
    let max2 = -Infinity, min2 = Infinity;

    for (let i = 0; i < arrays.length; i++) {
        const arr = arrays[i];
        const currentMax = arr[arr.length - 1];
        const currentMin = arr[0];

        if (currentMax > max1) {
            max2 = max1;
            max1 = currentMax;
            maxIndex = i;
        } else if (currentMax > max2) {
            max2 = currentMax;
        }

        if (currentMin < min1) {
            min2 = min1;
            min1 = currentMin;
            minIndex = i;
        } else if (currentMin < min2) {
            min2 = currentMin;
        }
    }

    if (maxIndex !== minIndex) {
        return max1 - min1;
    } else {
        return Math.max(max1 - min2, max2 - min1);
    }
}

// 示例1
const arrays1 = [[1,2,3],[4,5],[1,2,3]];
console.log(maxDistance(arrays1)); // 输出4

// 示例2
const arrays2 = [[1],[1]];
console.log(maxDistance(arrays2)); // 输出0

代码解释

  1. 初始化变量max1min1分别记录当前遇到的最大值和最小值,maxIndexminIndex记录它们所在的数组索引。max2min2用于记录次大的最大值和次小的最小值。
  2. 遍历数组:对于每个数组,获取其最大值(数组最后一个元素)和最小值(数组第一个元素),并更新相关变量。
  3. 比较极值:如果最大值和最小值来自不同的数组,直接返回它们的差值。否则,比较最大值与次小值的差和次大值与最小值的差,取较大者作为结果。

这种方法确保我们能够高效地找到不同数组中的元素对的最大距离,同时处理了各种特殊情况。