在前端开发的职业道路上,算法题是绕不开的一道门槛,尤其是在大厂的面试过程中。本文整理了国内外各大互联网公司的前端面试算法真题,帮助大家了解面试趋势,提升算法能力,为面试做好充分准备。
1. 字符串类问题
1.1 有效的括号(腾讯、阿里巴巴、字节跳动)
题目描述:
给定一个只包括 '(',')','{','}','[',']' 的字符串 s,判断字符串是否有效。
有效字符串需满足:
- 左括号必须用相同类型的右括号闭合。
- 左括号必须以正确的顺序闭合。
- 每个右括号都有一个对应的相同类型的左括号。
解题思路:
使用栈来解决这个问题是非常直观的方法。遍历字符串,遇到左括号就入栈,遇到右括号就与栈顶元素匹配,如果匹配成功则弹出栈顶元素,否则返回false。最后,如果栈为空,说明所有括号都匹配成功。
代码实现:
javascript
/**
* @param {string} s
* @return {boolean}
*/
const isValid = function(s) {
const stack = [];
const map = {
'(': ')',
'[': ']',
'{': '}'
};
for (let i = 0; i < s.length; i++) {
const char = s[i];
if (char in map) {
// 左括号,入栈
stack.push(char);
} else {
// 右括号,检查是否匹配
const top = stack.pop();
if (map[top] !== char) {
return false;
}
}
}
// 如果栈不为空,说明有未匹配的左括号
return stack.length === 0;
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中 n 是字符串的长度。
- 空间复杂度: O(n)O(n),在最坏情况下,栈的大小为 n。
1.2 无重复字符的最长子串(阿里巴巴、腾讯、美团)
题目描述:
给定一个字符串 s,请你找出其中不含有重复字符的 最长子串 的长度。
解题思路:
使用滑动窗口的思想。维护一个窗口,窗口内的字符都是不重复的。使用两个指针,右指针不断向右移动,如果遇到重复字符,则左指针向右移动,直到窗口内不包含重复字符。
代码实现:
javascript
/**
* @param {string} s
* @return {number}
*/
const lengthOfLongestSubstring = function(s) {
const map = new Map(); // 字符 -> 索引
let max = 0;
for (let right = 0, left = 0; right < s.length; right++) {
const char = s[right];
if (map.has(char)) {
// 更新左指针的位置,确保窗口内没有重复字符
// Math.max确保左指针不会向左移动
left = Math.max(left, map.get(char) + 1);
}
// 更新字符的最新位置
map.set(char, right);
// 更新最大长度
max = Math.max(max, right - left + 1);
}
return max;
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是字符串的长度。
- 空间复杂度: O(min(m,n))O(min(m,n)),其中m是字符集的大小。对于ASCII字符集,m最多为128。
1.3 最长回文子串(字节跳动、百度、腾讯)
题目描述:
给你一个字符串 s,找到 s 中最长的回文子串。
解题思路:
中心扩展法是一种简单有效的方法。从每个位置开始,向两边扩展,找到以该位置为中心的最长回文子串。由于回文可能的中心是一个字符或两个字符,所以需要分别处理这两种情况。
代码实现:
javascript
/**
* @param {string} s
* @return {string}
*/
const longestPalindrome = function(s) {
if (!s || s.length < 1) return "";
let start = 0, end = 0;
for (let i = 0; i < s.length; i++) {
// 以i为中心的最长回文子串
const len1 = expandAroundCenter(s, i, i);
// 以i和i+1为中心的最长回文子串
const len2 = expandAroundCenter(s, i, i + 1);
const len = Math.max(len1, len2);
if (len > end - start) {
start = i - Math.floor((len - 1) / 2);
end = i + Math.floor(len / 2);
}
}
return s.substring(start, end + 1);
};
// 从中心向两边扩展,返回最长回文子串的长度
function expandAroundCenter(s, left, right) {
while (left >= 0 && right < s.length && s[left] === s[right]) {
left--;
right++;
}
// 返回回文长度
return right - left - 1;
}
复杂度分析:
- 时间复杂度: O(n2)O(n2),其中n是字符串的长度。
- 空间复杂度: O(1)O(1)。
2. 数组类问题
2.1 两数之和(几乎所有大厂)
题目描述:
给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出和为目标值 target 的那两个整数,并返回它们的数组下标。
解题思路:
最直观的方法是使用两层循环,但时间复杂度较高。更高效的方法是使用哈希表存储已经遍历过的元素及其索引,然后判断当前元素与目标值的差是否在哈希表中。
代码实现:
javascript
/**
* @param {number[]} nums
* @param {number} target
* @return {number[]}
*/
const twoSum = function(nums, target) {
const map = new Map(); // 值 -> 索引
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
if (map.has(complement)) {
return [map.get(complement), i];
}
map.set(nums[i], i);
}
return []; // 没有找到符合条件的两个数
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是数组的长度。
- 空间复杂度: O(n)O(n),哈希表最多存储n个元素。
2.2 三数之和(字节跳动、腾讯、阿里巴巴)
题目描述:
给你一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?请你找出所有和为 0 且不重复的三元组。
解题思路:
首先对数组进行排序,然后使用双指针技术。固定一个数,然后在剩余部分使用双指针找到满足条件的另外两个数。注意处理重复元素以避免重复解。
代码实现:
javascript
/**
* @param {number[]} nums
* @return {number[][]}
*/
const threeSum = function(nums) {
const result = [];
nums.sort((a, b) => a - b); // 排序
for (let i = 0; i < nums.length - 2; i++) {
// 跳过重复元素
if (i > 0 && nums[i] === nums[i - 1]) continue;
let left = i + 1, right = nums.length - 1;
while (left < right) {
const sum = nums[i] + nums[left] + nums[right];
if (sum < 0) {
left++;
} else if (sum > 0) {
right--;
} else {
// 找到一组解
result.push([nums[i], nums[left], nums[right]]);
// 跳过重复元素
while (left < right && nums[left] === nums[left + 1]) left++;
while (left < right && nums[right] === nums[right - 1]) right--;
left++;
right--;
}
}
}
return result;
};
复杂度分析:
- 时间复杂度: O(n2)O(n2),其中n是数组的长度。
- 空间复杂度: O(1)O(1),不考虑存储结果的空间。
2.3 接雨水(字节跳动、阿里巴巴、腾讯)
题目描述:
给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。
解题思路:
对于数组中的每个元素,我们需要找到其左右两边的最大高度,然后取两者的较小值减去当前高度,即为当前位置能接的雨水量。有多种方法可以解决这个问题,这里介绍双指针法。
代码实现:
javascript
/**
* @param {number[]} height
* @return {number}
*/
const trap = function(height) {
let left = 0, right = height.length - 1;
let leftMax = 0, rightMax = 0;
let result = 0;
while (left < right) {
if (height[left] < height[right]) {
// 左边的高度更低
if (height[left] >= leftMax) {
leftMax = height[left]; // 更新左边最大高度
} else {
result += leftMax - height[left];
}
left++;
} else {
// 右边的高度更低或相等
if (height[right] >= rightMax) {
rightMax = height[right]; // 更新右边最大高度
} else {
result += rightMax - height[right];
}
right--;
}
}
return result;
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是数组的长度。
- 空间复杂度: O(1)O(1)。
2.4 合并区间(字节跳动、谷歌、亚马逊)
题目描述:
给出一个区间的集合,请合并所有重叠的区间。
解题思路:
首先,按照区间的起始位置对区间进行排序。然后,我们可以将第一个区间加入到结果集中,并与之后的区间进行比较。如果当前区间的起始位置小于或等于结果集中最后一个区间的结束位置,则说明它们重叠,我们需要合并它们;否则,我们将当前区间加入到结果集中。
代码实现:
javascript
/**
* @param {number[][]} intervals
* @return {number[][]}
*/
const merge = function(intervals) {
if (intervals.length <= 1) return intervals;
// 按照区间起始位置排序
intervals.sort((a, b) => a[0] - b[0]);
const result = [intervals[0]];
for (let i = 1; i < intervals.length; i++) {
const current = intervals[i];
const last = result[result.length - 1];
if (current[0] <= last[1]) {
// 区间重叠,合并
last[1] = Math.max(last[1], current[1]);
} else {
// 区间不重叠,添加到结果集
result.push(current);
}
}
return result;
};
复杂度分析:
- 时间复杂度: O(nlogn)O(nlogn),其中n是区间的数量,排序需要 O(nlogn)O(nlogn) 时间。
- 空间复杂度: O(n)O(n),需要存储合并后的区间。
3. 链表类问题
3.1 反转链表(几乎所有大厂)
题目描述:
给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。
解题思路:
使用迭代法,维护前一个节点、当前节点和下一个节点三个指针,逐步反转每个节点的指向。
代码实现:
javascript
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} head
* @return {ListNode}
*/
const reverseList = function(head) {
let prev = null;
let current = head;
while (current) {
const next = current.next; // 保存下一个节点
current.next = prev; // 反转指针
prev = current; // 前一个节点向后移
current = next; // 当前节点向后移
}
return prev; // 最后prev指向反转后的头节点
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是链表的长度。
- 空间复杂度: O(1)O(1)。
3.2 合并两个有序链表(阿里巴巴、腾讯、美团)
题目描述:
将两个升序链表合并为一个新的升序链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。
解题思路:
使用递归或迭代的方法都可以解决。这里使用迭代的方法,比较两个链表当前节点的值,将较小值的节点接到结果链表上,然后该链表向后移动一步。
代码实现:
javascript
/**
* Definition for singly-linked list.
* function ListNode(val, next) {
* this.val = (val===undefined ? 0 : val)
* this.next = (next===undefined ? null : next)
* }
*/
/**
* @param {ListNode} list1
* @param {ListNode} list2
* @return {ListNode}
*/
const mergeTwoLists = function(list1, list2) {
// 创建一个哑节点作为合并后链表的头部
const dummy = new ListNode(-1);
let current = dummy;
while (list1 && list2) {
if (list1.val <= list2.val) {
current.next = list1;
list1 = list1.next;
} else {
current.next = list2;
list2 = list2.next;
}
current = current.next;
}
// 处理剩余节点
current.next = list1 || list2;
return dummy.next;
};
复杂度分析:
- 时间复杂度: O(m+n)O(m+n),其中m和n是两个链表的长度。
- 空间复杂度: O(1)O(1)。
3.3 环形链表(字节跳动、百度、腾讯)
题目描述:
给定一个链表,判断链表中是否有环。
解题思路:
使用快慢指针法。慢指针每次移动一步,快指针每次移动两步,如果链表中有环,那么快指针最终会追上慢指针。
代码实现:
javascript
/**
* Definition for singly-linked list.
* function ListNode(val) {
* this.val = val;
* this.next = null;
* }
*/
/**
* @param {ListNode} head
* @return {boolean}
*/
const hasCycle = function(head) {
if (!head || !head.next) return false;
let slow = head;
let fast = head;
while (fast && fast.next) {
slow = slow.next; // 慢指针每次移动一步
fast = fast.next.next; // 快指针每次移动两步
// 如果存在环,快慢指针最终会相遇
if (slow === fast) {
return true;
}
}
return false;
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是链表的长度。
- 空间复杂度: O(1)O(1)。
3.4 LRU缓存机制(字节跳动、阿里巴巴、腾讯)
题目描述:
设计并实现一个LRU (最近最少使用) 缓存机制。它应该支持以下操作:获取数据 get 和 写入数据 put 。
获取数据 get(key) - 如果关键字 (key) 存在于缓存中,则获取关键字的值(总是正数),否则返回 -1。 写入数据 put(key, value) - 如果关键字已经存在,则变更其数据值;如果关键字不存在,则插入该组「关键字/值」。当缓存容量达到上限时,它应该在写入新数据之前删除最久未使用的数据值,从而为新的数据值留出空间。
解题思路:
使用哈希表和双向链表实现LRU缓存。哈希表用于快速查找,双向链表用于维护缓存项的顺序。最近使用的项放在链表头部,最久未使用的项放在链表尾部。
代码实现:
javascript
/**
* @param {number} capacity
*/
class LRUCache {
constructor(capacity) {
this.capacity = capacity;
this.cache = new Map(); // 键 -> 节点
this.head = new DoubleLinkedNode(0, 0); // 头哨兵节点
this.tail = new DoubleLinkedNode(0, 0); // 尾哨兵节点
this.head.next = this.tail;
this.tail.prev = this.head;
}
/**
* @param {number} key
* @return {number}
*/
get(key) {
if (this.cache.has(key)) {
// 如果缓存中存在,将节点移到头部(表示最近使用)
const node = this.cache.get(key);
this.moveToHead(node);
return node.value;
}
return -1;
}
/**
* @param {number} key
* @param {number} value
* @return {void}
*/
put(key, value) {
if (this.cache.has(key)) {
// 如果键已存在,更新值并将节点移到头部
const node = this.cache.get(key);
node.value = value;
this.moveToHead(node);
} else {
// 如果键不存在,创建新节点
const node = new DoubleLinkedNode(key, value);
this.cache.set(key, node);
this.addToHead(node);
// 如果超出容量,删除尾部节点(最久未使用)
if (this.cache.size > this.capacity) {
const tail = this.removeTail();
this.cache.delete(tail.key);
}
}
}
// 将节点添加到头部
addToHead(node) {
node.prev = this.head;
node.next = this.head.next;
this.head.next.prev = node;
this.head.next = node;
}
// 移除节点
removeNode(node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
// 将节点移到头部
moveToHead(node) {
this.removeNode(node);
this.addToHead(node);
}
// 移除尾部节点
removeTail() {
const res = this.tail.prev;
this.removeNode(res);
return res;
}
}
// 双向链表节点
class DoubleLinkedNode {
constructor(key, value) {
this.key = key;
this.value = value;
this.prev = null;
this.next = null;
}
}
复杂度分析:
- 时间复杂度: O(1)O(1),所有操作都是常数时间复杂度。
- 空间复杂度: O(capacity)O(capacity),需要存储容量大小的缓存项。
4. 树类问题(续)
4.1 二叉树的层序遍历
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是二叉树的节点数。
- 空间复杂度: O(n)O(n),队列中最多可能有n/2个节点,即完美二叉树的最底层节点数。
4.2 二叉树的最大深度(阿里巴巴、腾讯、美团)
题目描述:
给定一个二叉树,找出其最大深度。二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。
解题思路:
可以使用递归的方法。一个二叉树的最大深度等于其左子树和右子树的最大深度中的较大值加1。
代码实现:
javascript
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {number}
*/
const maxDepth = function(root) {
// 基本情况:空树的深度为0
if (!root) return 0;
// 递归计算左右子树的深度
const leftDepth = maxDepth(root.left);
const rightDepth = maxDepth(root.right);
// 返回左右子树深度的较大值 + 1
return Math.max(leftDepth, rightDepth) + 1;
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是二叉树的节点数,每个节点只被访问一次。
- 空间复杂度: O(h)O(h),其中h是树的高度。递归调用的栈空间取决于树的高度。在最坏情况下,树是完全不平衡的,例如每个节点只有左子节点,递归调用深度为n,此时空间复杂度为O(n)O(n)。在最好情况下,树是完全平衡的,高度为lognlogn,此时空间复杂度为O(logn)O(logn)。
4.3 二叉树的路径总和(字节跳动、百度、美团)
题目描述:
给你二叉树的根节点 root 和一个表示目标和的整数 targetSum,判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和。
解题思路:
使用递归,从根节点开始,每次从目标和中减去当前节点的值,然后递归地检查左右子树是否存在路径满足条件。当到达叶子节点时,检查剩余目标和是否为0。
代码实现:
javascript
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @param {number} targetSum
* @return {boolean}
*/
const hasPathSum = function(root, targetSum) {
// 空树没有路径
if (!root) return false;
// 减去当前节点的值
targetSum -= root.val;
// 如果是叶子节点且目标和为0,说明找到了一条满足条件的路径
if (!root.left && !root.right) {
return targetSum === 0;
}
// 递归检查左右子树
return hasPathSum(root.left, targetSum) || hasPathSum(root.right, targetSum);
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是二叉树的节点数。
- 空间复杂度: O(h)O(h),其中h是树的高度。与最大深度问题类似,最坏情况下为O(n)O(n),最好情况下为O(logn)O(logn)。
4.4 验证二叉搜索树(腾讯、阿里巴巴、字节跳动)
题目描述:
给定一个二叉树,判断其是否是一个有效的二叉搜索树。假设一个二叉搜索树具有如下特征:
- 节点的左子树只包含小于当前节点的数。
- 节点的右子树只包含大于当前节点的数。
- 所有左子树和右子树自身必须也是二叉搜索树。
解题思路:
使用中序遍历。二叉搜索树的中序遍历结果是递增的,因此我们可以在中序遍历过程中检查当前节点的值是否大于前一个节点的值。
代码实现:
javascript
/**
* Definition for a binary tree node.
* function TreeNode(val, left, right) {
* this.val = (val===undefined ? 0 : val)
* this.left = (left===undefined ? null : left)
* this.right = (right===undefined ? null : right)
* }
*/
/**
* @param {TreeNode} root
* @return {boolean}
*/
const isValidBST = function(root) {
let prev = null; // 记录前一个节点的值
// 中序遍历
function inorder(node) {
if (!node) return true;
// 递归遍历左子树
if (!inorder(node.left)) {
return false;
}
// 检查当前节点的值是否大于前一个节点的值
if (prev !== null && node.val <= prev) {
return false;
}
// 更新前一个节点的值
prev = node.val;
// 递归遍历右子树
return inorder(node.right);
}
return inorder(root);
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是二叉树的节点数,中序遍历需要访问所有节点。
- 空间复杂度: O(h)O(h),其中h是树的高度,递归调用的栈空间取决于树的高度。
5. 动态规划类问题
5.1 爬楼梯(阿里巴巴、腾讯、百度)
题目描述:
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢?
解题思路:
这是一个经典的动态规划问题。定义状态dp[i]为爬到第i阶楼梯的方法数。状态转移方程为dp[i] = dp[i-1] + dp[i-2],因为爬到第i阶楼梯的方法可以分为两类:从第i-1阶爬1步到达,或从第i-2阶爬2步到达。
代码实现:
javascript
/**
* @param {number} n
* @return {number}
*/
const climbStairs = function(n) {
if (n <= 2) return n;
// 创建dp数组并初始化
const dp = new Array(n + 1);
dp[1] = 1; // 爬到第1阶有1种方法
dp[2] = 2; // 爬到第2阶有2种方法
// 状态转移
for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
};
// 优化空间复杂度的版本
const climbStairsOptimized = function(n) {
if (n <= 2) return n;
let prev = 1; // dp[1]
let curr = 2; // dp[2]
for (let i = 3; i <= n; i++) {
const next = prev + curr;
prev = curr;
curr = next;
}
return curr;
};
复杂度分析:
- 时间复杂度: O(n)O(n),需要计算从3到n的每一步。
- 空间复杂度: O(n)O(n),需要一个长度为n+1的dp数组。优化后的空间复杂度为O(1)O(1)。
5.2 最大子数组和(字节跳动、腾讯、阿里巴巴)
题目描述:
给你一个整数数组 nums,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
解题思路:
使用动态规划。定义状态dp[i]为以第i个元素结尾的连续子数组的最大和。状态转移方程为dp[i] = max(dp[i-1] + nums[i], nums[i]),即要么与前面的最大子数组连接,要么重新开始一个子数组。
代码实现:
javascript
/**
* @param {number[]} nums
* @return {number}
*/
const maxSubArray = function(nums) {
if (!nums.length) return 0;
// 创建dp数组并初始化
const dp = new Array(nums.length);
dp[0] = nums[0]; // 第一个元素的最大子数组和就是元素本身
let maxSum = dp[0]; // 记录全局最大和
// 状态转移
for (let i = 1; i < nums.length; i++) {
dp[i] = Math.max(dp[i - 1] + nums[i], nums[i]);
maxSum = Math.max(maxSum, dp[i]);
}
return maxSum;
};
// 优化空间复杂度的版本
const maxSubArrayOptimized = function(nums) {
if (!nums.length) return 0;
let currentMax = nums[0]; // 当前最大子数组和
let globalMax = nums[0]; // 全局最大子数组和
for (let i = 1; i < nums.length; i++) {
currentMax = Math.max(nums[i], currentMax + nums[i]);
globalMax = Math.max(globalMax, currentMax);
}
return globalMax;
};
复杂度分析:
- 时间复杂度: O(n)O(n),需要遍历一次数组。
- 空间复杂度: O(n)O(n),需要一个长度为n的dp数组。优化后的空间复杂度为O(1)O(1)。
5.3 零钱兑换(字节跳动、蚂蚁金服、阿里巴巴)
题目描述:
给定不同面额的硬币 coins 和一个总金额 amount,计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
解题思路:
使用动态规划。定义状态dp[i]为组成金额i所需的最少硬币数。状态转移方程为dp[i] = min(dp[i], dp[i - coin] + 1),其中coin是硬币的面额。
代码实现:
javascript
/**
* @param {number[]} coins
* @param {number} amount
* @return {number}
*/
const coinChange = function(coins, amount) {
// 创建dp数组并初始化为无穷大
const dp = new Array(amount + 1).fill(Infinity);
dp[0] = 0; // 组成金额0需要0个硬币
// 遍历每种硬币
for (const coin of coins) {
// 遍历从coin到amount的每个金额
for (let i = coin; i <= amount; i++) {
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
// 如果dp[amount]仍然是无穷大,说明无法组成该金额
return dp[amount] === Infinity ? -1 : dp[amount];
};
复杂度分析:
- 时间复杂度: O(amount×n)O(amount×n),其中n是硬币的种类数。
- 空间复杂度: O(amount)O(amount),需要一个长度为amount+1的dp数组。
5.4 最长递增子序列(字节跳动、阿里巴巴、腾讯)
题目描述:
给你一个整数数组 nums,找到其中最长严格递增子序列的长度。子序列是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。
解题思路:
使用动态规划。定义状态dp[i]为以第i个元素结尾的最长递增子序列的长度。状态转移方程为dp[i] = max(dp[j] + 1),其中0 <= j < i且nums[j] < nums[i]。
代码实现:
javascript
/**
* @param {number[]} nums
* @return {number}
*/
const lengthOfLIS = function(nums) {
if (!nums.length) return 0;
const n = nums.length;
// 创建dp数组并初始化为1(每个元素自成一个长度为1的子序列)
const dp = new Array(n).fill(1);
let maxLength = 1;
// 状态转移
for (let i = 1; i < n; i++) {
for (let j = 0; j < i; j++) {
if (nums[i] > nums[j]) {
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
maxLength = Math.max(maxLength, dp[i]);
}
return maxLength;
};
// 更优的解法,使用二分查找,时间复杂度为O(n log n)
const lengthOfLISOptimized = function(nums) {
if (!nums.length) return 0;
const n = nums.length;
// tail[i]表示长度为i+1的递增子序列的末尾元素的最小值
const tail = [nums[0]];
for (let i = 1; i < n; i++) {
// 如果当前元素大于tail的最后一个元素,直接追加
if (nums[i] > tail[tail.length - 1]) {
tail.push(nums[i]);
} else {
// 否则,使用二分查找找到tail中第一个大于等于nums[i]的位置,并替换它
let left = 0;
let right = tail.length - 1;
while (left < right) {
const mid = Math.floor((left + right) / 2);
if (tail[mid] < nums[i]) {
left = mid + 1;
} else {
right = mid;
}
}
tail[left] = nums[i];
}
}
return tail.length;
};
复杂度分析:
-
时间复杂度:
- 动态规划解法:O(n2)O(n2),其中n是数组的长度。
- 二分查找解法:O(nlogn)O(nlogn)。
-
空间复杂度:O(n)O(n),需要一个长度为n的dp数组或tail数组。
6. 回溯算法类问题
6.1 全排列(字节跳动、腾讯、阿里巴巴)
题目描述:
给定一个不含重复数字的数组 nums,返回其所有可能的全排列。
解题思路:
使用回溯算法。从空排列开始,每次尝试向当前排列添加一个尚未使用的数字,直到得到一个完整的排列。然后回退一步,尝试其他可能的数字,以此类推。
代码实现:
javascript
/**
* @param {number[]} nums
* @return {number[][]}
*/
const permute = function(nums) {
const result = [];
const path = [];
const used = new Array(nums.length).fill(false);
// 回溯函数
function backtrack() {
// 如果路径长度等于nums长度,说明找到了一个完整排列
if (path.length === nums.length) {
result.push([...path]); // 拷贝当前路径
return;
}
for (let i = 0; i < nums.length; i++) {
// 跳过已使用的数字
if (used[i]) continue;
// 选择当前数字
path.push(nums[i]);
used[i] = true;
// 继续递归
backtrack();
// 撤销选择(回溯)
path.pop();
used[i] = false;
}
}
backtrack();
return result;
};
复杂度分析:
- 时间复杂度: O(n×n!)O(n×n!),其中n是数组的长度。共有n!个排列,每个排列需要O(n)的时间复制到结果集中。
- 空间复杂度: O(n)O(n),递归调用栈的深度为n,用于记录路径和使用状态。
6.2 子集(字节跳动、阿里巴巴、腾讯)
题目描述:
给你一个整数数组 nums,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。解集不能包含重复的子集,你可以按任意顺序返回解集。
解题思路:
使用回溯算法。对于每个元素,我们有两种选择:将其加入子集,或不加入子集。通过递归地做出这些选择,我们可以生成所有可能的子集。
代码实现:
javascript
/**
* @param {number[]} nums
* @return {number[][]}
*/
const subsets = function(nums) {
const result = [];
const path = [];
// 回溯函数,start表示当前考虑的元素索引
function backtrack(start) {
// 每次都将当前子集加入结果
result.push([...path]);
// 从start开始,避免重复生成子集
for (let i = start; i < nums.length; i++) {
// 选择当前元素
path.push(nums[i]);
// 继续递归,考虑后面的元素
backtrack(i + 1);
// 撤销选择(回溯)
path.pop();
}
}
backtrack(0);
return result;
};
复杂度分析:
- 时间复杂度: O(n×2n)O(n×2n),其中n是数组的长度。共有2n2n个子集,每个子集需要O(n)的时间复制到结果集中。
- 空间复杂度: O(n)O(n),递归调用栈的深度最多为n。
6.3 组合总和(字节跳动、腾讯、阿里巴巴)
题目描述:
给定一个无重复元素的正整数数组 candidates 和一个正整数 target,找出 candidates 中所有可以使数字和为目标数 target 的唯一组合。
candidates 中的数字可以无限制重复被选取。如果至少一个所选数字数量不同,则两种组合是唯一的。
解题思路:
使用回溯算法。对于每个候选数,我们可以选择使用它(可以重复使用)或不使用它。使用target减去当前选择的数字,递归地找到和为剩余目标值的组合。
代码实现:
javascript
/**
* @param {number[]} candidates
* @param {number} target
* @return {number[][]}
*/
const combinationSum = function(candidates, target) {
const result = [];
const path = [];
// 排序,优化剪枝效果
candidates.sort((a, b) => a - b);
// 回溯函数,start表示当前考虑的起始索引,remain表示剩余目标值
function backtrack(start, remain) {
// 如果剩余目标值为0,说明找到了一个符合条件的组合
if (remain === 0) {
result.push([...path]);
return;
}
// 从start开始,避免重复组合
for (let i = start; i < candidates.length; i++) {
// 剪枝:如果当前数字已经大于剩余目标值,后面的数字肯定也大于,可以直接结束循环
if (candidates[i] > remain) break;
// 选择当前数字
path.push(candidates[i]);
// 继续递归,由于可以重复使用当前数字,所以仍然从i开始
backtrack(i, remain - candidates[i]);
// 撤销选择(回溯)
path.pop();
}
}
backtrack(0, target);
return result;
};
复杂度分析:
- 时间复杂度: 最坏情况下为O(ntarget/min)O(ntarget/min),其中n是候选数的数量,min是candidates中的最小值。
- 空间复杂度: O(target/min)O(target/min),递归调用栈的深度最多为target/mintarget/min。
6.4 N皇后问题
代码实现 :
javascript
/**
* @param {number} n
* @return {string[][]}
*/
const solveNQueens = function(n) {
const result = [];
// 初始化棋盘,全部为'.'
const board = Array(n).fill().map(() => Array(n).fill('.'));
// 检查(row, col)位置是否可以放置皇后
function isValid(row, col) {
// 检查同一列
for (let i = 0; i < row; i++) {
if (board[i][col] === 'Q') {
return false;
}
}
// 检查左上到右下对角线
for (let i = row - 1, j = col - 1; i >= 0 && j >= 0; i--, j--) {
if (board[i][j] === 'Q') {
return false;
}
}
// 检查右上到左下对角线
for (let i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
if (board[i][j] === 'Q') {
return false;
}
}
return true;
}
// 回溯函数,row表示当前处理的行
function backtrack(row) {
// 如果所有行都已经放置了皇后,说明找到了一个解
if (row === n) {
const solution = board.map(row => row.join(''));
result.push(solution);
return;
}
// 尝试在当前行的每一列放置皇后
for (let col = 0; col < n; col++) {
// 检查是否可以在(row, col)位置放置皇后
if (isValid(row, col)) {
// 放置皇后
board[row][col] = 'Q';
// 继续递归放置下一行的皇后
backtrack(row + 1);
// 撤销放置(回溯)
board[row][col] = '.';
}
}
}
backtrack(0);
return result;
};
// 优化版本,使用三个集合跟踪列、对角线,避免重复检查
const solveNQueensOptimized = function(n) {
const result = [];
const board = Array(n).fill().map(() => Array(n).fill('.'));
// 使用集合跟踪已经攻击的位置
const cols = new Set(); // 列
const diag1 = new Set(); // 主对角线(左上到右下),行-列的值相同
const diag2 = new Set(); // 副对角线(右上到左下),行+列的值相同
function backtrack(row) {
if (row === n) {
const solution = board.map(row => row.join(''));
result.push(solution);
return;
}
for (let col = 0; col < n; col++) {
// 检查当前位置是否可以放置皇后
if (cols.has(col) || diag1.has(row - col) || diag2.has(row + col)) {
continue; // 当前位置不可放置
}
// 放置皇后
board[row][col] = 'Q';
cols.add(col);
diag1.add(row - col);
diag2.add(row + col);
// 继续递归放置下一行的皇后
backtrack(row + 1);
// 撤销放置(回溯)
board[row][col] = '.';
cols.delete(col);
diag1.delete(row - col);
diag2.delete(row + col);
}
}
backtrack(0);
return result;
};
复杂度分析:
- 时间复杂度: O(n!)O(n!),n皇后问题的解的数量大约是O(n!)O(n!)。
- 空间复杂度: O(n2)O(n2),需要存储棋盘。
7. 贪心算法类问题
7.1 跳跃游戏(字节跳动、腾讯、阿里巴巴)
题目描述:
给定一个非负整数数组 nums,你最初位于数组的第一个下标。数组中的每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个下标。
解题思路:
使用贪心算法。维护一个变量,记录能够到达的最远位置。遍历数组,更新最远位置,如果当前位置已经无法到达,则返回false。
代码实现:
javascript
/**
* @param {number[]} nums
* @return {boolean}
*/
const canJump = function(nums) {
let maxPos = 0; // 能够到达的最远位置
const n = nums.length;
for (let i = 0; i < n; i++) {
// 如果当前位置已经无法到达,返回false
if (i > maxPos) {
return false;
}
// 更新能够到达的最远位置
maxPos = Math.max(maxPos, i + nums[i]);
// 如果已经可以到达最后一个位置,返回true
if (maxPos >= n - 1) {
return true;
}
}
return true;
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是数组的长度。
- 空间复杂度: O(1)O(1),只需要常数空间。
7.2 买卖股票的最佳时机(阿里巴巴、腾讯、美团)
题目描述:
给定一个数组 prices,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。你只能选择某一天买入这只股票,并选择在未来的某一个不同的日子卖出该股票。设计一个算法来计算你所能获取的最大利润。
解题思路:
使用贪心算法。维护一个变量记录历史最低价格,然后计算当前价格与最低价格的差值,不断更新最大利润。
代码实现:
javascript
/**
* @param {number[]} prices
* @return {number}
*/
const maxProfit = function(prices) {
let minPrice = Infinity; // 历史最低价格
let maxProfit = 0; // 最大利润
for (let i = 0; i < prices.length; i++) {
// 更新历史最低价格
minPrice = Math.min(minPrice, prices[i]);
// 计算当前价格卖出的利润,并更新最大利润
maxProfit = Math.max(maxProfit, prices[i] - minPrice);
}
return maxProfit;
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是数组的长度。
- 空间复杂度: O(1)O(1),只需要常数空间。
7.3 分发糖果(字节跳动、阿里巴巴、腾讯)
题目描述:
n 个孩子站成一排。给你一个整数数组 ratings 表示每个孩子的评分。你需要按照以下要求,给这些孩子分发糖果:
- 每个孩子至少分配到 1 个糖果。
- 相邻两个孩子评分更高的孩子会获得更多的糖果。 请你给出这 n 个孩子分发糖果的最少总数。
解题思路:
使用贪心算法。从左到右遍历一次,确保右边评分更高的孩子获得更多糖果;然后从右到左遍历一次,确保左边评分更高的孩子获得更多糖果。最后,每个位置取两次遍历中的较大值作为最终分配的糖果数。
代码实现:
javascript
/**
* @param {number[]} ratings
* @return {number}
*/
const candy = function(ratings) {
const n = ratings.length;
const candies = new Array(n).fill(1); // 初始每个孩子至少有1个糖果
// 从左到右遍历,确保右边评分更高的孩子获得更多糖果
for (let i = 1; i < n; i++) {
if (ratings[i] > ratings[i - 1]) {
candies[i] = candies[i - 1] + 1;
}
}
// 从右到左遍历,确保左边评分更高的孩子获得更多糖果
for (let i = n - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1]) {
candies[i] = Math.max(candies[i], candies[i + 1] + 1);
}
}
// 计算总糖果数
return candies.reduce((sum, val) => sum + val, 0);
};
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是数组的长度。
- 空间复杂度: O(n)O(n),需要一个长度为n的数组存储每个孩子的糖果数。
8. 图论类问题
8.1 课程表(字节跳动、阿里巴巴、百度)
题目描述:
你这个学期必须选修 numCourses 门课程,记为 0 到 numCourses-1。在选修某些课程之前需要一些先修课程。例如,想要学习课程 0,你需要先完成课程 1,我们用一个匹配来表示他们:[0,1]。给定课程总量以及它们的先决条件,判断是否可能完成所有课程的学习?
解题思路:
这是一个典型的有向图环检测问题。如果图中有环,意味着存在循环依赖,无法完成所有课程。可以使用拓扑排序或深度优先搜索来解决。
代码实现(使用拓扑排序):
javascript
/**
* @param {number} numCourses
* @param {number[][]} prerequisites
* @return {boolean}
*/
const canFinish = function(numCourses, prerequisites) {
// 构建邻接表和入度数组
const graph = Array(numCourses).fill().map(() => []);
const inDegree = Array(numCourses).fill(0);
for (const [course, prereq] of prerequisites) {
graph[prereq].push(course); // prereq -> course
inDegree[course]++; // 增加course的入度
}
// 将所有入度为0的节点加入队列
const queue = [];
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) {
queue.push(i);
}
}
let count = 0; // 记录已学习的课程数
// 进行拓扑排序
while (queue.length) {
const curr = queue.shift(); // 学习一门课程
count++;
// 将所有依赖当前课程的课程的入度减1
for (const next of graph[curr]) {
inDegree[next]--;
// 如果入度变为0,加入队列
if (inDegree[next] === 0) {
queue.push(next);
}
}
}
// 如果所有课程都学习了,返回true
return count === numCourses;
};
代码实现(使用深度优先搜索):
javascript
/**
* @param {number} numCourses
* @param {number[][]} prerequisites
* @return {boolean}
*/
const canFinish = function(numCourses, prerequisites) {
// 构建邻接表
const graph = Array(numCourses).fill().map(() => []);
for (const [course, prereq] of prerequisites) {
graph[prereq].push(course); // prereq -> course
}
// 0: 未访问,1: 访问中(当前路径上),2: 已访问
const visited = Array(numCourses).fill(0);
// 深度优先搜索检测环
function dfs(node) {
// 如果节点在当前路径上,说明有环
if (visited[node] === 1) {
return false;
}
// 如果节点已经访问过且没有环,直接返回true
if (visited[node] === 2) {
return true;
}
// 标记节点为访问中
visited[node] = 1;
// 访问所有相邻节点
for (const neighbor of graph[node]) {
if (!dfs(neighbor)) {
return false; // 如果发现环,直接返回false
}
}
// 标记节点为已访问
visited[node] = 2;
return true;
}
// 对每个节点进行DFS
for (let i = 0; i < numCourses; i++) {
if (!dfs(i)) {
return false;
}
}
return true;
};
复杂度分析:
- 时间复杂度: O(V+E)O(V+E),其中V是节点数(课程数),E是边数(先修关系数)。
- 空间复杂度: O(V+E)O(V+E),用于存储图的邻接表和访问状态。
8.2 岛屿数量(字节跳动、阿里巴巴、腾讯)
题目描述:
给你一个由 '1'(陆地)和 '0'(水)组成的的二维网格,请你计算网格中岛屿的数量。岛屿总是被水包围,并且每座岛屿只能由水平方向和/或竖直方向上相邻的陆地连接形成。此外,你可以假设该网格的四条边均被水包围。
解题思路:
使用深度优先搜索或广度优先搜索。当我们找到一个陆地格子,就进行搜索,将与之相连的所有陆地格子标记为已访问,然后岛屿数量加1。继续寻找下一个未访问的陆地格子,重复上述过程。
代码实现(使用深度优先搜索):
javascript
/**
* @param {character[][]} grid
* @return {number}
*/
const numIslands = function(grid) {
if (!grid || !grid.length) return 0;
const rows = grid.length;
const cols = grid[0].length;
let count = 0;
// 深度优先搜索,将与(r, c)相连的所有陆地标记为已访问
function dfs(r, c) {
// 检查边界条件和是否为陆地
if (r < 0 || r >= rows || c < 0 || c >= cols || grid[r][c] === '0') {
return;
}
// 标记为已访问
grid[r][c] = '0';
// 访问上、下、左、右四个方向
dfs(r - 1, c); // 上
dfs(r + 1, c); // 下
dfs(r, c - 1); // 左
dfs(r, c + 1); // 右
}
// 遍历整个网格
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] === '1') {
count++; // 找到一个新的岛屿
dfs(r, c); // 将整个岛屿标记为已访问
}
}
}
return count;
};
代码实现(使用广度优先搜索):
javascript
/**
* @param {character[][]} grid
* @return {number}
*/
const numIslands = function(grid) {
if (!grid || !grid.length) return 0;
const rows = grid.length;
const cols = grid[0].length;
let count = 0;
// 广度优先搜索,将与(r, c)相连的所有陆地标记为已访问
function bfs(r, c) {
const queue = [[r, c]];
grid[r][c] = '0'; // 标记为已访问
// 四个方向:上、下、左、右
const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]];
while (queue.length) {
const [row, col] = queue.shift();
// 检查四个方向
for (const [dr, dc] of directions) {
const newRow = row + dr;
const newCol = col + dc;
// 检查边界条件和是否为陆地
if (newRow >= 0 && newRow < rows && newCol >= 0 && newCol < cols && grid[newRow][newCol] === '1') {
queue.push([newRow, newCol]);
grid[newRow][newCol] = '0'; // 标记为已访问
}
}
}
}
// 遍历整个网格
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
if (grid[r][c] === '1') {
count++; // 找到一个新的岛屿
bfs(r, c); // 将整个岛屿标记为已访问
}
}
}
return count;
};
复杂度分析:
- 时间复杂度: O(mn)O(mn),其中m和n分别是网格的行数和列数。
- 空间复杂度: O(min(m,n))O(min(m,n)),在最坏情况下,整个网格都是陆地,队列的大小取决于网格的边长。
9. 设计类问题
9.1 最小栈(字节跳动、腾讯、阿里巴巴)
题目描述:
设计一个支持 push,pop,top 操作,并能在常数时间内检索到最小元素的栈。
- push(x) -- 将元素 x 推入栈中。
- pop() -- 删除栈顶的元素。
- top() -- 获取栈顶元素。
- getMin() -- 检索栈中的最小元素。
解题思路:
使用两个栈,一个正常栈用于存储元素,另一个辅助栈用于存储当前最小值。每次push操作时,除了将元素加入正常栈外,还需要比较该元素与辅助栈栈顶的大小,将较小的那个加入辅助栈。
代码实现:
javascript
/**
* initialize your data structure here.
*/
class MinStack {
constructor() {
this.stack = []; // 正常栈
this.minStack = []; // 辅助栈,存储当前最小值
}
/**
* @param {number} val
* @return {void}
*/
push(val) {
this.stack.push(val);
// 如果辅助栈为空或当前值小于等于辅助栈栈顶,将当前值加入辅助栈
if (this.minStack.length === 0 || val <= this.minStack[this.minStack.length - 1]) {
this.minStack.push(val);
}
}
/**
* @return {void}
*/
pop() {
// 如果弹出的元素与辅助栈栈顶相同,也需要弹出辅助栈的栈顶
if (this.stack.pop() === this.minStack[this.minStack.length - 1]) {
this.minStack.pop();
}
}
/**
* @return {number}
*/
top() {
return this.stack[this.stack.length - 1];
}
/**
* @return {number}
*/
getMin() {
return this.minStack[this.minStack.length - 1];
}
}
复杂度分析:
- 时间复杂度: O(1)O(1),所有操作都是常数时间复杂度。
- 空间复杂度: O(n)O(n),其中n是栈中元素的数量。
9.2 实现Trie(前缀树)(字节跳动、阿里巴巴、百度)
题目描述:
实现一个 Trie (前缀树),包含 insert, search, 和 startsWith 这三个操作。
解题思路:
Trie是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补完和拼写检查。
代码实现:
javascript
class TrieNode {
constructor() {
this.children = {}; // 子节点映射
this.isEndOfWord = false; // 标记当前节点是否是一个单词的结尾
}
}
class Trie {
constructor() {
this.root = new TrieNode();
}
/**
* @param {string} word
* @return {void}
*/
insert(word) {
let node = this.root;
for (const char of word) {
// 如果当前字符不存在,创建一个新节点
if (!node.children[char]) {
node.children[char] = new TrieNode();
}
// 移动到下一个节点
node = node.children[char];
}
// 标记单词结尾
node.isEndOfWord = true;
}
/**
* @param {string} word
* @return {boolean}
*/
search(word) {
const node = this.searchPrefix(word);
// 如果找到了节点并且是单词结尾,则返回true
return node !== null && node.isEndOfWord;
}
/**
* @param {string} prefix
* @return {boolean}
*/
startsWith(prefix) {
// 如果找到了节点,则返回true
return this.searchPrefix(prefix) !== null;
}
/**
* 帮助方法,返回前缀的最后一个节点,如果不存在则返回null
* @param {string} prefix
* @return {TrieNode|null}
*/
searchPrefix(prefix) {
let node = this.root;
for (const char of prefix) {
if (!node.children[char]) {
return null;
}
node = node.children[char];
}
return node;
}
}
复杂度分析:
-
时间复杂度:
- insert: O(m)O(m),其中m是单词的长度。
- search: O(m)O(m),其中m是单词的长度。
- startsWith: O(m)O(m),其中m是前缀的长度。
-
空间复杂度: O(nk)O(nk),其中n是插入的单词数量,k是单词的平均长度。
10. 前端特有算法问题
10.1 实现防抖函数(几乎所有大厂)
题目描述:
实现一个防抖函数,即触发事件后在n秒内函数只能执行一次,如果n秒内又触发了事件,则会重新计算函数执行时间。
解题思路:
使用闭包和定时器实现防抖函数。每次调用函数时,先清除之前的定时器,然后设置一个新的定时器,在指定延迟后执行函数。
代码实现:
javascript
/**
* 防抖函数
* @param {Function} fn 需要防抖的函数
* @param {number} delay 延迟时间,单位毫秒
* @param {boolean} immediate 是否立即执行
* @return {Function} 防抖后的函数
*/
function debounce(fn, delay, immediate = false) {
let timer = null;
return function(...args) {
const context = this;
// 如果已经设置过定时器,清除上一次的定时器
if (timer) clearTimeout(timer);
// 如果是立即执行,且当前没有定时器
if (immediate && !timer) {
fn.apply(context, args);
}
// 设置新的定时器
timer = setTimeout(() => {
// 如果不是立即执行,执行函数
if (!immediate) {
fn.apply(context, args);
}
timer = null; // 清空定时器
}, delay);
};
}
// 使用示例
const handleInput = debounce(function(e) {
console.log('Input event:', e.target.value);
}, 500);
// 将防抖函数应用于输入事件
document.getElementById('search').addEventListener('input', handleInput);
复杂度分析:
- 时间复杂度: O(1)O(1),每次调用都只是设置或清除定时器。
- 空间复杂度: O(1)O(1),只需要存储一个定时器和函数的引用。
10.2 实现节流函数(几乎所有大厂)
题目描述:
实现一个节流函数,即规定一个单位时间,在这个单位时间内,只能有一次触发事件的回调函数执行,如果在同一个单位时间内某事件被触发多次,只有一次能生效。
解题思路:
使用闭包和时间戳实现节流函数。记录上次执行的时间戳,每次调用函数时,判断当前时间距离上次执行的时间是否已经超过了给定的时间间隔。如果超过了,则执行函数并更新时间戳;否则,不执行。
代码实现:
javascript
/**
* 节流函数
* @param {Function} fn 需要节流的函数
* @param {number} interval 时间间隔,单位毫秒
* @return {Function} 节流后的函数
*/
function throttle(fn, interval) {
let lastTime = 0; // 上次执行的时间戳
return function(...args) {
const context = this;
const currentTime = Date.now();
// 如果当前时间与上次执行的时间间隔大于给定的时间间隔,执行函数
if (currentTime - lastTime > interval) {
fn.apply(context, args);
lastTime = currentTime;
}
};
}
// 另一个版本的节流函数,使用定时器实现
function throttleWithTimer(fn, interval) {
let timer = null;
return function(...args) {
const context = this;
// 如果没有定时器,设置一个新的定时器
if (!timer) {
timer = setTimeout(() => {
fn.apply(context, args);
timer = null; // 清空定时器
}, interval);
}
};
}
// 使用示例
const handleScroll = throttle(function() {
console.log('Scroll event:', window.scrollY);
}, 200);
// 将节流函数应用于滚动事件
window.addEventListener('scroll', handleScroll);
复杂度分析:
- 时间复杂度: O(1)O(1),每次调用只是简单地进行时间比较或设置定时器。
- 空间复杂度: O(1)O(1),只需要存储上次执行的时间戳或定时器。
10.3 实现深拷贝(阿里巴巴、腾讯、字节跳动)
题目描述:
实现一个对象的深拷贝函数,确保拷贝后的对象与原对象完全独立,修改拷贝后的对象不会影响原对象。
解题思路:
使用递归实现深拷贝。对于基本数据类型,直接返回;对于引用数据类型,需要递归地拷贝每个属性。同时,需要处理循环引用的情况。
代码实现:
javascript
/**
* 深拷贝函数
* @param {*} obj 需要拷贝的对象
* @param {Map} [map=new Map()] 用于处理循环引用的Map
* @return {*} 拷贝后的对象
*/
function deepClone(obj, map = new Map()) {
// 处理基本类型和null
if (obj === null || typeof obj !== 'object') {
return obj;
}
// 处理日期对象
if (obj instanceof Date) {
return new Date(obj);
}
// 处理正则表达式
if (obj instanceof RegExp) {
return new RegExp(obj.source, obj.flags);
}
// 检查是否已经拷贝过该对象(处理循环引用)
if (map.has(obj)) {
return map.get(obj);
}
// 创建新的对象或数组
const cloneObj = Array.isArray(obj) ? [] : {};
// 将当前对象加入到map中,防止循环引用
map.set(obj, cloneObj);
// 递归拷贝所有属性
for (const key in obj) {
if (Object.prototype.hasOwnProperty.call(obj, key)) {
cloneObj[key] = deepClone(obj[key], map);
}
}
return cloneObj;
}
// 使用示例
const original = {
a: 1,
b: {
c: 2,
d: [3, 4]
},
e: new Date(),
f: /pattern/ig
};
// 创建循环引用
original.g = original;
const clone = deepClone(original);
console.log(clone);
console.log(clone.g === clone); // true,正确处理了循环引用
复杂度分析:
- 时间复杂度: O(n)O(n),其中n是对象的属性数量(包括嵌套对象)。
- 空间复杂度: O(n)O(n),需要存储与原对象相同数量的属性,以及一个Map来处理循环引用。
10.4 实现 Event Emitter(字节跳动、腾讯、阿里巴巴)
题目描述:
实现一个简单的事件发布订阅系统(Event Emitter),支持事件的监听(on)、触发(emit)和取消监听(off)。
解题思路:
使用哈希表存储事件名称到回调函数列表的映射。当事件被触发时,执行对应的所有回调函数。
代码实现:
javascript
class EventEmitter {
constructor() {
this.events = new Map(); // 存储事件和对应的回调函数列表
}
/**
* 监听事件
* @param {string} event 事件名称
* @param {Function} callback 回调函数
* @return {EventEmitter} 返回this,支持链式调用
*/
on(event, callback) {
if (!this.events.has(event)) {
this.events.set(event, []);
}
this.events.get(event).push(callback);
return this;
}
/**
* 触发事件
* @param {string} event 事件名称
* @param {...*} args 传递给回调函数的参数
* @return {boolean} 如果事件有监听器返回true,否则返回false
*/
emit(event, ...args) {
if (!this.events.has(event)) {
return false;
}
const callbacks = this.events.get(event);
callbacks.forEach(callback => {
callback.apply(this, args);
});
return true;
}
/**
* 移除事件的监听器
* @param {string} event 事件名称
* @param {Function} [callback] 如果提供,则只移除这个回调函数;否则移除所有监听器
* @return {EventEmitter} 返回this,支持链式调用
*/
off(event, callback) {
if (!this.events.has(event)) {
return this;
}
if (!callback) {
// 如果没有提供回调函数,移除该事件的所有监听器
this.events.delete(event);
return this;
}
// 移除特定的回调函数
const callbacks = this.events.get(event);
this.events.set(event, callbacks.filter(cb => cb !== callback));
// 如果没有回调函数了,删除这个事件
if (this.events.get(event).length === 0) {
this.events.delete(event);
}
return this;
}
/**
* 只监听一次事件
* @param {string} event 事件名称
* @param {Function} callback 回调函数
* @return {EventEmitter} 返回this,支持链式调用
*/
once(event, callback) {
const onceWrapper = (...args) => {
callback.apply(this, args);
this.off(event, onceWrapper);
};
return this.on(event, onceWrapper);
}
}
// 使用示例
const emitter = new EventEmitter();
// 监听事件
emitter.on('message', data => {
console.log('Received message:', data);
});
// 只监听一次
emitter.once('init', () => {
console.log('Initialized once');
});
// 触发事件
emitter.emit('message', 'Hello World'); // 输出: Received message: Hello World
emitter.emit('init'); // 输出: Initialized once
emitter.emit('init'); // 不会有输出,因为'once'只执行一次
复杂度分析:
-
时间复杂度:
- on: O(1)O(1),添加监听器。
- emit: O(n)O(n),其中n是该事件的监听器数量。
- off: O(n)O(n),其中n是该事件的监听器数量。
- once: O(1)O(1),添加一次性监听器。
-
空间复杂度: O(m+n)O(m+n),其中m是事件的数量,n是所有事件的监听器总数。
总结
通过详细解析这些大厂面试算法题,我们可以发现一些共同点:
- 数据结构基础:熟练掌握数组、链表、栈、队列、树、图、哈希表等基础数据结构是必不可少的。
- 算法思想:贪心算法、动态规划、回溯、分治、双指针等算法思想频繁出现在面试题中。
- 时间空间复杂度分析:能够准确分析算法的时间复杂度和空间复杂度是面试官评判候选人的重要标准。
- 前端特有问题:除了通用算法题外,前端面试还会考察一些特有的问题,如防抖节流、深拷贝、事件系统等。
- 编码规范与效率:不仅要实现功能,还要追求代码的简洁、易读和高效。
对于前端开发者来说,建议平时多刷算法题,尤其是这些大厂常考的题型,掌握解题技巧和思路。在面试前,系统性地复习数据结构与算法,并结合前端特有的知识点进行针对性准备。同时,理解算法背后的原理比单纯记忆解法更为重要,这样才能举一反三,灵活应对各种变形题。
希望这份大厂算法真题解析能帮助你在前端面试中脱颖而出,祝你面试顺利!
参考资源
- LeetCode: leetcode.com/
- MDN Web Docs: developer.mozilla.org/
- JavaScript 数据结构与算法:github.com/trekhleb/ja...
- Front-end Interview Questions: github.com/h5bp/Front-...
以上资源可以帮助你进一步深入学习和理解这些算法题目,提高自己的算法能力和前端开发水平。