LeetCode热题100算法题分类解析与JavaScript实现
LeetCode热题100是一组被广泛认为是面试中最常考的经典算法题目集合,涵盖数据结构和算法的核心知识点。本文将系统性地解析这些题目,并提供JavaScript实现,帮助读者更好地掌握算法思想。
一、数组类算法题
数组是编程中最基础的数据结构,热题100中有许多与数组相关的经典问题。以下是几道典型数组题的解析:
1. 移动零 (283. Move Zeroes)
题目描述:给定一个数组,将所有0移动到数组末尾,同时保持非零元素的相对顺序,要求原地操作。
解法思路:使用双指针法,一个指针pre追踪非零元素的位置,另一个指针cur遍历数组。当cur遇到非零元素时,与pre位置的元素交换,然后pre和cur都后移;若遇到0,仅cur后移。
JavaScript代码实现:
var moveZeroes = function(nums) {
let pre = -1; // 记录非零元素的下一个位置
for (let cur = 0; cur < nums.length; cur++) {
if (nums[cur] !== 0) {
// 遇到非零元素,交换到pre位置
[nums[pre + 1], nums[cur]] = [nums[cur], nums[pre + 1]];
pre++; // 更新非零元素的下一个位置
}
}
};
复杂度分析:
- 时间复杂度:O(n),每个元素最多被访问两次
- 空间复杂度:O(1),原地操作,无需额外空间
适用场景:数据整理、过滤和排序,例如在前端开发中过滤并整理用户输入的数据。
2. 盛最多水的容器 (11. Container With Most Water)
题目描述:给定n条垂线,找到两条线,使得它们与x轴构成的容器能容纳最多的水。
解法思路:双指针从两端向中间移动。容器的容量由较短的垂线高度和两线间距决定。每次移动较短垂线的指针,因为移动较长垂线的指针不会得到更大的容量。
JavaScript代码实现:
var maxArea = function(height) {
let left = 0, right = height.length - 1;
let max_area = 0;
while (left < right) {
// 计算当前容器的容量
max_area = Math.max(max_area, (right - left) * Math.min(height[left], height[right]));
// 移动较短的垂线指针
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return max_area;
};
复杂度分析:
- 时间复杂度:O(n),双指针线性遍历数组
- 空间复杂度:O(1),仅使用常数空间
适用场景:资源分配优化问题,如最大化利用有限空间存储物品。
3. 三数之和 (15. 3Sum)
题目描述:给定一个数组,找出所有和为0的三元组,且结果不能包含重复的三元组。
解法思路:先对数组进行排序,然后使用三指针法。固定一个指针i,另外两个指针j和k分别从i+1和数组末尾向中间移动,寻找满足条件的三元组。同时需要处理重复元素的情况。
JavaScript代码实现:
var threeSum = function(nums) {
nums.sort((a, b) => a - b);
const res = [];
for (let i = 0; i < nums.length - 2; i++) {
// 跳过重复的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 {
// 找到符合条件的三元组
res.push([nums[i], nums[left], nums[right]]);
// 跳过重复的left和right
while (left < right && nums[left] === nums[left + 1]) left++;
while (left < right && nums[right] === nums[right - 1]) right--;
left++;
right--;
}
}
}
return res;
};
复杂度分析:
- 时间复杂度:O(n²),排序O(n log n) + 双指针遍历O(n²)
- 空间复杂度:O(1),原地排序(假设使用原地排序算法)
适用场景:多条件筛选和组合优化,如在电商系统中筛选符合特定条件的组合商品。
二、字符串类算法题
字符串操作在算法题中占重要地位,热题100中有多个经典字符串问题。
1. 无重复字符的最长子串 (3. Longest Substring Without Repeating Characters)
题目描述:找出一个字符串中最长的无重复字符的子串的长度。
解法思路:使用滑动窗口算法。维护两个指针left和right表示当前窗口的左右边界。使用哈希表记录每个字符最后一次出现的位置。当遇到重复字符时,将左边界移动到重复字符上次出现位置的下一个位置。每次右边界移动时,计算当前窗口长度并更新最大值。
JavaScript代码实现:
function lengthOfLongestSubstring(s) {
const charMap = new Map(); // 存储字符及其最后出现的位置
let left = 0, maxLen = 0;
for (let right = 0; right < s.length; right++) {
const c = s[right];
// 如果字符已存在且在当前窗口内,则移动左指针
if (charMap.has(c) && charMap.get(c) >= left) {
left = charMap.get(c) + 1;
}
// 更新字符的最新位置
charMap.set(c, right);
// 计算当前窗口的长度并更新最大值
maxLen = Math.max(maxLen, right - left + 1);
}
return maxLen;
}
复杂度分析:
- 时间复杂度:O(n),每个字符最多被访问两次
- 空间复杂度:O(min(m, n)),其中m是字符集的大小,n是字符串的长度
适用场景:文本处理、数据验证等,如在前端开发中验证用户输入的唯一性。
2. 字母异位词分组 (49. Group Anagrams)
题目描述:将字符串数组中的字母异位词分组。字母异位词是指由相同字母重新排列而成的字符串。
解法思路:同一组的字母异位词排序后的字符串相同。使用哈希表,将排序后的字符串作为键,原字符串作为值存储。遍历每个字符串,排序后作为键,将原字符串添加到对应的哈希表中。
JavaScript代码实现:
var groupAnagrams = function(strs) {
const map = new Map(); // 键是排序后的字符串,值是原字符串数组
for (const str of strs) {
const sortedStr = str.split('').sort().join('');
if (!map.has(sortedStr)) {
map.set(sortedStr, []);
}
map.get(sortedStr).push(str);
}
return Array.from(map.values());
};
复杂度分析:
- 时间复杂度:O(nk log k),其中n是字符串的数量,k是字符串的平均长度
- 空间复杂度:O(nk),存储所有字符串的排序版本
适用场景:文本分析、搜索优化等,如在搜索引擎中对同义词进行分组。
三、链表类算法题
链表是面试中常见的数据结构,热题100中有几道经典链表问题。
1. 反转链表 (206. Reverse Linked List)
题目描述:反转一个单链表。
解法思路:使用迭代法,维护三个指针:pre(前驱节点)、curr(当前节点)、next(后继节点)。遍历链表时,依次将每个节点的next指针指向pre节点,然后移动pre和curr指针。
JavaScript代码实现:
function reverseList(head) {
let pre = null, curr = head;
while (curr) {
const next = curr.next; // 保存下一个节点
curr.next = pre; // 反转指针
pre = curr; // 移动pre指针
curr = next; // 移动curr指针
}
return pre; // 最后pre指向新链表的头节点
}
// 链表节点定义
function ListNode(val, next) {
this.val = (val===undefined ? 0 : val);
this.next = (next===undefined ? null : next);
}
复杂度分析:
- 时间复杂度:O(n),遍历整个链表
- 空间复杂度:O(1),仅使用常数空间
适用场景:数据存储和检索,如在浏览器历史记录中实现后退功能。
2. 回文链表 (234. Palindrome Linked List)
题目描述:判断一个单链表是否是回文链表。
解法思路:使用快慢指针找到链表中点。然后反转后半部分链表。最后比较前半部分和反转后的后半部分是否相同。
JavaScript代码实现:
var isPalindrome = function(head) {
// 快慢指针找到中点
let slow = head, fast = head;
while (fast && fast.next) {
slow = slow.next;
fast = fast.next.next;
}
// 反转后半部分链表
let prev = null, curr = slow;
while (curr) {
const next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
// 比较前半部分和反转后的后半部分
let left = head, right = prev;
while (right) {
if (left.val !== right.val) {
return false;
}
left = left.next;
right = right.next;
}
return true;
};
复杂度分析:
- 时间复杂度:O(n),遍历链表两次
- 空间复杂度:O(1),仅使用常数空间
适用场景:数据校验和模式识别,如在密码系统中验证回文结构。
四、树类算法题
树结构在算法题中占据重要位置,热题100中有多个经典树问题。
1. 二叉树的最大深度 (104. Maximum Depth of Binary Tree)
题目描述:计算二叉树的最大深度。
解法思路:使用递归法。树的最大深度等于左右子树深度的最大值加1。递归终止条件是当节点为空时,返回0。
JavaScript代码实现:
function maxDepth(root) {
if (!root) return 0;
// 递归计算左右子树的深度
const leftDepth = maxDepth(root.left);
const rightDepth = maxDepth(root.right);
// 返回较大值加1
return 1 + Math.max(leftDepth, rightDepth);
}
// 二叉树节点定义
function TreeNode(val, left, right) {
this.val = (val===undefined ? 0 : val);
this.left = (left===undefined ? null : left);
this.right = (right===undefined ? null : right);
}
复杂度分析:
- 时间复杂度:O(n),每个节点访问一次
- 空间复杂度:O(H),H是树的高度,最坏情况下O(n)
适用场景:数据结构分析,如在前端开发中分析组件树的深度。
2. 对称二叉树 (101. Symmetric Tree)
题目描述:判断一个二叉树是否是对称的。
解法思路:使用递归法。比较左子树和右子树是否对称。递归终止条件是两个节点都为空则对称;一个为空另一个不为空则不对称;节点值不相等则不对称。否则递归比较左子树的左孩子与右子树的右孩子,以及左子树的右孩子与右子树的左孩子。
JavaScript代码实现:
function isSymmetric(root) {
if (!root) return true;
return isMirror(root.left, root.right);
}
function isMirror(left, right) {
// 两个节点都为空,对称
if (!left && !right) return true;
// 一个为空另一个不为空,不对称
if (!left || !right) return false;
// 节点值不相等,不对称
if (left.val !== right.val) return false;
// 递归比较左子树的左孩子与右子树的右孩子,以及左子树的右孩子与右子树的左孩子
return isMirror(left.left, right.right) && isMirror(left.right, right.left);
}
// 二叉树节点定义
function TreeNode(val, left, right) {
this.val = (val===undefined ? 0 : val);
this.left = (left===undefined ? null : left);
this.right = (right===undefined ? null : right);
}
复杂度分析:
- 时间复杂度:O(n),每个节点访问一次
- 空间复杂度:O(H),H是树的高度,最坏情况下O(n)
适用场景:数据结构验证,如在前端开发中验证组件树的对称结构。
五、哈希表类算法题
哈希表是一种高效的数据结构,热题100中有多个经典哈希表问题。
1. 两数之和 (1. Two Sum)
题目描述:给定一个整数数组和一个目标值,找出数组中和为目标值的两个数的索引。
解法思路:使用哈希表存储每个数的索引。遍历数组时,检查哈希表中是否存在目标值减去当前数的结果。如果存在,返回当前索引和哈希表中存储的索引;否则,将当前数的索引存入哈希表。
JavaScript代码实现:
var twoSum = function(nums, target) {
const numMap = new Map(); // 存储数值和对应的索引
for (let i = 0; i < nums.length; i++) {
const complement = target - nums[i];
// 如果哈希表中存在补数,返回结果
if (numMap.has(complement)) {
return [numMap.get(complement), i];
}
// 存储当前数值和索引
numMap.set(nums[i], i);
}
return [];
};
复杂度分析:
- 时间复杂度:O(n),遍历数组一次
- 空间复杂度:O(n),哈希表存储所有元素
适用场景:数据匹配和查询优化,如在推荐系统中快速匹配用户偏好。
2. 只出现一次的数字 (136. Single Number)
题目描述:给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
解法思路:使用位运算中的异或操作。异或运算的三个性质:任何数和0异或结果不变;任何数和自身异或结果为0;异或满足交换律和结合律。因此,将所有元素依次异或,重复元素会相互抵消为0,最终结果就是只出现一次的元素。
JavaScript代码实现:
var singleNumber = function(nums) {
let result = 0;
for (const num of nums) {
result ^= num; // 异或操作
}
return result;
};
复杂度分析:
- 时间复杂度:O(n),遍历数组一次
- 空间复杂度:O(1),仅使用一个变量存储中间结果
适用场景:数据校验和错误检测,如在网络通信中检测数据包完整性。
六、动态规划类算法题
动态规划是解决最优化问题的有效方法,热题100中有多个经典动态规划问题。
1. 爬楼梯 (70. Climbing Stairs)
题目描述:你正在爬楼梯,每一步可以爬1阶或2阶。n阶楼梯有多少种不同的爬法?
解法思路:使用动态规划。定义dp[i]为爬到第i阶的步数。状态转移方程为dp[i] = dp[i-1] + dp[i-2]。可以优化为只使用两个变量存储前两个状态的值。
JavaScript代码实现:
var climbStairs = function(n) {
if (n <= 2) return n;
let prev = 1, curr = 2;
for (let i = 3; i <= n; i++) {
const temp = curr;
curr += prev;
prev = temp;
}
return curr;
};
复杂度分析:
- 时间复杂度:O(n),遍历n次
- 空间复杂度:O(1),仅使用两个变量
适用场景:路径规划和资源分配,如在物流系统中计算最优路径。
2. 不同路径 (62. Unique Paths)
题目描述:一个机器人位于一个m x n网格的左上角。机器人每次只能向下或向右移动一步。有多少种不同的路径可以让机器人到达右下角?
解法思路:使用动态规划。定义dp[i][j]为到达(i,j)位置的路径数。状态转移方程为dp[i][j] = dp[i-1][j] + dp[i][j-1]。初始化第一行和第一列为1,因为只有一种方式到达这些位置。
JavaScript代码实现:
var uniquePaths = function(m, n) {
const dp = new Array(m).fill(0).map(() => new Array(n).fill(0));
// 初始化第一行和第一列
for (let i = 0; i < m; i++) dp[i][0] = 1;
for (let j = 0; j < n; j++) dp[0][j] = 1;
for (let i = 1; i < m; i++) {
for (let j = 1; j < n; j++) {
// 状态转移方程
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
};
复杂度分析:
- 时间复杂度:O(mn),遍历整个网格
- 空间复杂度:O(mn),存储二维DP数组
适用场景:路径规划和组合优化,如在游戏开发中计算角色移动路径。
七、图论类算法题
图论问题在算法题中较为复杂,热题100中有几道经典图论问题。
1. 岛屿数量 (200. Number of Islands)
题目描述:给定一个由'1'(陆地)和'0'(水)组成的二维网格,计算网格中岛屿的数量。岛屿总是被水包围,并且每座岛屿只能由水平或竖直方向上相邻的陆地连接形成。
解法思路:使用深度优先搜索(DFS)或广度优先搜索(BFS)。遍历网格,当遇到'1'时,岛屿数量加1,并用DFS/BFS将整个岛屿标记为已访问(例如将'1'改为'0'),防止重复计数。
JavaScript代码实现(DFS):
var numIslands = function(grid) {
if (!grid || grid.length === 0 || grid[0].length === 0) return 0;
const m = grid.length, n = grid[0].length;
let count = 0;
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (grid[i][j] === '1') {
count++;
dfs(grid, i, j, m, n);
}
}
}
return count;
};
function dfs(grid, i, j, m, n) {
// 边界检查
if (i < 0 || i >= m || j < 0 || j >= n || grid[i][j] === '0') return;
grid[i][j] = '0'; // 标记为已访问
// 递归四个方向
dfs(grid, i + 1, j, m, n);
dfs(grid, i - 1, j, m, n);
dfs(grid, i, j + 1, m, n);
dfs(grid, i, j - 1, m, n);
}
JavaScript代码实现(BFS):
var numIslands = function(grid) {
if (!grid || grid.length === 0 || grid[0].length === 0) return 0;
const m = grid.length, n = grid[0].length;
let count = 0;
for (let i = 0; i < m; i++) {
for (let j = 0; j < n; j++) {
if (grid[i][j] === '1') {
count++;
bfs(grid, i, j, m, n);
}
}
}
return count;
};
function bfs(grid, i, j, m, n) {
const queue = [[i, j]];
grid[i][j] = '0'; // 标记为已访问
const directions = [[1, 0], [-1, 0], [0, 1], [0, -1]]; // 四个方向
while (queue.length > 0) {
const [x, y] = queue.shift();
// 遍历四个方向
for (const [dx, dy] of directions) {
const nx = x + dx, ny = y + dy;
// 检查边界和是否为陆地
if (nx >= 0 && nx < m && ny >= 0 && ny < n && grid[nx][ny] === '1') {
grid[nx][ny] = '0'; // 标记为已访问
queue.push([nx, ny]);
}
}
}
};
复杂度分析:
- 时间复杂度:O(mn),每个节点最多访问一次
- 空间复杂度:O(mn),最坏情况下递归栈或队列需要存储所有节点
适用场景:地图分析和区域划分,如在游戏开发中分析地图区域。
2. 课程表 (207. Course Schedule)
题目描述:判断是否可能完成所有课程的学习。课程之间存在先修关系,表示为二维数组prerequisites。
解法思路:使用拓扑排序算法。构建邻接表表示课程依赖关系,并使用入度数组记录每个课程的前置课程数量。将所有入度为0的课程加入队列,然后不断从队列中取出课程,减少其后续课程的入度。如果最终所有课程都被处理,则可以完成;否则存在环,无法完成。
JavaScript代码实现:
var canFinish = function(numCourses, prerequisites) {
const graph = new Array(numCourses).fill(0).map(() => []); // 邻接表
const inDegree = new Array(numCourses).fill(0); // 入度数组
// 构建邻接表和入度数组
for (const [course, prereq] of prerequisites) {
graph[prereq].push(course); // 添加后续课程
inDegree[course]++; // 增加入度
}
const queue = [];
// 将入度为0的课程加入队列
for (let i = 0; i < numCourses; i++) {
if (inDegree[i] === 0) {
queue.push(i);
}
}
let count = 0; // 已处理的课程数
while (queue.length > 0) {
const course = queue.shift();
count++;
// 遍历该课程的后续课程
for (const neighbor of graph[course]) {
inDegree[neighbor]}; // 减少入度
if (inDegree[neighbor] === 0) {
queue.push(neighbor);
}
}
}
return count === numCourses; // 是否所有课程都处理完毕
};
复杂度分析:
- 时间复杂度:O(N + M),N是课程数,M是先修关系数
- 空间复杂度:O(N + M),邻接表和入度数组的存储空间
适用场景:任务调度和依赖管理,如在构建系统中处理代码依赖。
八、堆与优先队列类算法题
堆结构在解决极值问题时非常高效,热题100中有几道经典堆问题。
1. 数组中的第K个最大元素 (215. Kth Largest Element in an Array)
题目描述:在给定的整数数组中,找出第K个最大的元素。
解法思路:使用最小堆。遍历数组,将元素依次插入堆中。当堆的大小超过K时,删除堆顶元素(最小值)。最终堆顶元素就是第K个最大的元素。
JavaScript代码实现:
class MinHeap {
constructor() {
this.heap = [];
}
// 交换元素
swap(i1, i2) {
[this.heap[i1], this.heap[i2]] = [this.heap[i2], this.heap[i1]];
}
// 获取父节点下标
parenting(i) {
return (i - 1) >> 1;
}
// 获取左子节点下标
leftChild(i) {
return i * 2 + 1;
}
// 获取右子节点下标
rightChild(i) {
return i * 2 + 2;
}
// 上移操作
shiftUp(index) {
if (index === 0) return;
const parentIndex = this parent(i);
if (this.heap[parentIndex] > this.heap[index]) {
this.swap(parentIndex, index);
this shiftUp(parentIndex);
}
}
// 下移操作
shiftDown(index) {
const leftIndex = this.leftChild(index);
const rightIndex = this.rightChild(index);
let smallest = index;
// 比较左子节点
if (leftIndex < this.heap.length && this.heap[leftIndex] < this.heap[smallest]) {
smallest = leftIndex;
}
// 比较右子节点
if (rightIndex < this.heap.length && this.heap[rightIndex] < this.heap[smallest]) {
smallest = rightIndex;
}
if (smallest !== index) {
this.swap(index, smallest);
this shiftDown(smallest);
}
}
// 插入元素
insert(value) {
this.heap.push(value);
this shiftUp(this.heap.length - 1);
}
// 删除堆顶元素
pop() {
if (this.heap.length === 1) return this.heap.pop();
const top = this.heap[0];
this.heap[0] = this.heap.pop();
this shiftDown(0);
return top;
}
// 获取堆顶元素
peek() {
return this.heap[0];
}
// 获取堆的大小
size() {
return this.heap.length;
}
}
var findKthLargest = function(nums, k) {
const heap = new MinHeap();
for (const num of nums) {
heap.insert(num);
if (heap.size() > k) {
heap.pop(); // 保持堆大小为k
}
}
return heap peek();
};
复杂度分析:
- 时间复杂度:O(n log k),插入和删除操作的时间复杂度
- 空间复杂度:O(k),堆的存储空间
适用场景:实时数据处理和排序优化,如在推荐系统中快速获取热门商品。
九、双指针类算法题
双指针法是一种高效的算法技巧,热题100中有几道经典双指针问题。
1. 移动零 (283. Move Zeroes)
题目描述:给定一个数组,将所有0移动到数组末尾,同时保持非零元素的相对顺序,要求原地操作。
解法思路:使用双指针法,一个指针pre追踪非零元素的位置,另一个指针cur遍历数组。当cur遇到非零元素时,与pre位置的元素交换,然后pre和cur都后移;若遇到0,仅cur后移。
JavaScript代码实现:
var moveZeroes = function(nums) {
let pre = -1; // 记录非零元素的下一个位置
for (let cur = 0; cur < nums.length; cur++) {
if (nums[cur] !== 0) {
// 遇到非零元素,交换到pre位置
[nums[pre + 1], nums[cur]] = [nums[cur], nums[pre + 1]];
pre++; // 更新非零元素的下一个位置
}
}
};
复杂度分析:
- 时间复杂度:O(n),每个元素最多被访问两次
- 空间复杂度:O(1),原地操作,无需额外空间
适用场景:数据整理和过滤,如在前端开发中过滤并整理用户输入的数据。
2. 盛最多水的容器 (11. Container With Most Water)
题目描述:给定n条垂线,找到两条线,使得它们与x轴构成的容器能容纳最多的水。
解法思路:双指针从两端向中间移动。容器的容量由较短的垂线高度和两线间距决定。每次移动较短垂线的指针,因为移动较长垂线的指针不会得到更大的容量。
JavaScript代码实现:
var maxArea = function(height) {
let left = 0, right = height.length - 1;
let max_area = 0;
while (left < right) {
// 计算当前容器的容量
max_area = Math.max(max_area, (right - left) * Math.min(height[left], height[right]));
// 移动较短的垂线指针
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return max_area;
};
复杂度分析:
- 时间复杂度:O(n),双指针线性遍历数组
- 空间复杂度:O(1),仅使用常数空间
适用场景:资源分配优化问题,如最大化利用有限空间存储物品。
十、滑动窗口类算法题
滑动窗口算法是一种高效的双指针技术,用于处理连续子数组或子串问题。
1. 滑动窗口最大值 (239. Sliding Window Maximum)
题目描述:给定一个整数数组nums,有一个大小为k的滑动窗口从数组的最左侧移动到数组的最右侧。每次只向右移动一位,返回滑动窗口中的最大值。
解法思路:使用单调队列(双端队列)来维护窗口中的元素,使得队列中的元素保持单调递减的顺序。队列中存储的是元素的索引,而非元素本身。这样可以方便地判断元素是否已经滑出窗口。
JavaScript代码实现:
function maxSlidingWindow(nums, k) {
if (nums.length === 0 || k === 0) return [];
if (k === 1) return nums;
const deque = [];
const result = [];
for (let i = 0; i < nums.length; i++) {
// 移除队首超出窗口左边界的元素
while (deque.length > 0 && deque[0] <= i - k) {
deque shift();
}
// 维护单调递减队列
while (deque.length > 0 && nums[deque[deque.length - 1]] <= nums[i]) {
deque pop();
}
deque push(i);
// 当窗口形成时,记录最大值
if (i >= k - 1) {
result push(nums[deque[0]]);
}
}
return result;
}
复杂度分析:
- 时间复杂度:O(n),每个元素最多被加入队列一次,最多被移出队列一次
- 空间复杂度:O(k),队列中最多容纳k个元素
适用场景:实时数据流分析,如在金融系统中监测股票价格波动。
十一、位运算类算法题
位运算是一种高效的算法技巧,热题100中有几道经典位运算问题。
1. 只出现一次的数字 (136. Single Number)
题目描述:给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。
解法思路:使用位运算中的异或操作。异或运算的三个性质:任何数和0异或结果不变;任何数和自身异或结果为0;异或满足交换律和结合律。因此,将所有元素依次异或,重复元素会相互抵消为0,最终结果就是只出现一次的元素。
JavaScript代码实现:
var singleNumber = function(nums) {
let result = 0;
for (const num of nums) {
result ^= num; // 异或操作
}
return result;
};
复杂度分析:
- 时间复杂度:O(n),遍历数组一次
- 空间复杂度:O(1),仅使用一个变量存储中间结果
适用场景:数据校验和错误检测,如在网络通信中检测数据包完整性。
十二、并查集类算法题
并查集是一种高效的数据结构,用于处理集合的合并和查询问题。
1. 冗余连接 (684. Redundant Connection)
题目描述:给定一个有向图,判断哪一条边可以删去,使得结果图是一个树。如果有多个答案,则返回二维数组中最后出现的边。
解法思路:使用并查集。遍历所有边,检查边的两个节点是否已经在同一集合中。如果是,则这条边是冗余边。否则,将这两个节点合并到同一集合中。
JavaScript代码实现:
class UnionFind {
constructor() {
this.parent = new Map();
}
// 查找元素所在集合
find(x) {
if (!this.parent.has(x)) {
this.parent.set(x, x); // 初始化父节点为自己
}
// 路径压缩
if (this.parent.get(x) !== x) {
this.parent.set(x, this.find(this.parent.get(x)));
}
return this.parent.get(x);
}
// 合并两个集合
union(p, q) {
const rootP = this.find(p);
const rootQ = this.find(q);
if (rootP === rootQ) return false; // 已在同一集合
// 合并两个集合
this.parent.set(rootP, rootQ);
return true;
}
}
var findRedundantConnection = function(edges) {
const uf = new UnionFind();
for (const edge of edges) {
const p = edge[0];
const q = edge[1];
if (uf.find(p) === uf.find(q)) {
return edge; // 找到冗余边
}
uf.union(p, q);
}
return [-1, -1]; // 没有冗余边
};
复杂度分析:
- 时间复杂度:O(nα(n)),α是 Ackerman函数的反函数,近似于常数
- 空间复杂度:O(n),存储并查集的父节点信息
适用场景:网络拓扑分析和连通性检测,如在社交网络中检测用户分组。
十三、贪心算法类算法题
贪心算法是一种通过局部最优解来达到全局最优解的方法。
1. 跳跃游戏 (55. Jump Game)
题目描述:给定一个非负整数数组,你最初位于数组的第一个位置。每个元素代表你在该位置可以跳跃的最大长度。判断你是否能够到达最后一个位置。
解法思路:使用贪心算法。维护一个最大可达位置maxReach。遍历数组时,更新maxReach为当前maxReach和i+nums[i]中的较大值。如果当前i超过maxReach,则无法到达,返回false。否则继续遍历,直到到达末尾。
JavaScript代码实现:
var canJump = function(nums) {
let maxReach = 0;
const n = nums.length;
for (let i = 0; i < n; i++) {
if (i > maxReach) return false; // 无法到达当前节点
maxReach = Math.max(maxReach, i + nums[i]); // 更新最大可达位置
if (maxReach >= n - 1) return true; // 已到达末尾
}
return true;
};
复杂度分析:
- 时间复杂度:O(n),遍历数组一次
- 空间复杂度:O(1),仅使用常数空间
适用场景:路径规划和资源分配,如在物流系统中规划最优路径。
2. 买卖股票的最佳时机II (122. Best Time to Buy and Sell Stock II)
题目描述:给定一个数组,其中第i个元素表示第i天的股票价格。你可以完成多次交易(多次买卖)。计算最大利润。
解法思路:使用贪心算法。只要第二天的价格高于前一天,就进行交易。利润是所有正差价的和。
JavaScript代码实现:
var maxProfit = function(prices) {
let profit = 0;
for (let i = 1; i < prices.length; i++) {
if (prices[i] > prices[i - 1]) {
profit += prices[i] - prices[i - 1];
}
}
return profit;
};
复杂度分析:
- 时间复杂度:O(n),遍历数组一次
- 空间复杂度:O(1),仅使用常数空间
适用场景:金融交易和投资策略,如在股票交易系统中计算最大利润。
十四、回溯算法类算法题
回溯算法是一种通过尝试所有可能的解来寻找最优解的方法。
1. 组合总和 (39. Combination Sum)
题目描述:给定一个无重复元素的整数数组candidates和一个目标整数target,找出candidates中可以使数字和为目标数target的所有不同组合。每个数可以被重复使用。
解法思路:使用回溯算法。遍历数组,对于每个元素,可以选择使用它或者不使用它。当sum等于target时,记录组合。当sum超过target时,回溯。为了避免重复组合,每次选择从当前元素开始。
JavaScript代码实现:
var combinationSum = function(candidates, target) {
candidates.sort((a, b) => a - b); // 先排序,方便剪枝
const result = [];
const backtrack = (path, start, sum) => {
if (sum === target) {
result.push([...path]); // 找到目标和,记录组合
return;
}
if (sum > target) return; // 超过目标,剪枝
// 遍历从start开始的元素
for (let i = start; i < candidates.length; i++) {
// 剪枝:如果当前元素加上sum已经大于target,后面的元素更大,直接跳过
if (sum + candidates[i] > target) continue;
path.push(candidates[i]); // 加入当前元素
backtrack(path, i, sum + candidates[i]); // 递归,允许重复使用
path.pop(); // 回溯,移除当前元素
}
};
backtrack([], 0, 0);
return result;
};
复杂度分析:
- 时间复杂度:O(2^n),最坏情况下所有元素都可能被选中
- 空间复杂度:O(n),递归栈和临时路径的存储空间
适用场景:组合优化和资源分配,如在推荐系统中生成不同的商品组合。
十五、学习路径建议
1. 分阶段学习计划
第一阶段(基础算法):
- 双指针:移动零、盛最多水的容器
- 哈希表:两数之和、只出现一次的数字
- 链表:反转链表、环形链表
- 树:二叉树的最大深度、对称二叉树
- 位运算:只出现一次的数字
第二阶段(进阶算法):
- 滑动窗口:无重复字符的最长子串、滑动窗口最大值
- 动态规划:爬楼梯、不同路径、最长递增子序列
- 贪心算法:跳跃游戏、买卖股票的最佳时机
- 图论:岛屿数量、课程表
第三阶段(高级算法):
- 回溯法:组合总和、N皇后
- 堆:数组中的第K个最大元素、合并K个有序链表
- 并查集:冗余连接
2. 五遍原则
- 第一遍:限时30分钟,尝试暴力解法
- 第二遍:对比最优解,理解优化思路
- 第三遍:独立实现最优解,调试通过
- 第四遍:总结通用模板(如动态规划的"状态+转移")
- 第五遍:定期复习,避免遗忘
十六、算法应用场景总结
| 算法类型 | 实际应用场景 | 典型题目 |
|---|---|---|
| 双指针 | 数据整理和过滤 | 移动零、盛最多水的容器 |
| 滑动窗口 | 实时数据流分析 | 无重复字符的最长子串、滑动窗口最大值 |
| 动态规划 | 资源分配和路径规划 | 爬楼梯、不同路径、最长递增子序列 |
| 贪心算法 | 金融交易和投资策略 | 跳跃游戏、买卖股票的最佳时机 |
| 哈希表 | 数据匹配和查询优化 | 两数之和、字母异位词分组 |
| 并查集 | 网络拓扑分析和连通性检测 | 冗余连接 |
| 回溯法 | 组合优化和资源分配 | 组合总和、N皇后 |
| 图论 | 社交网络分析和路径规划 | 岛屿数量、课程表 |
十七、总结与思考
LeetCode热题100涵盖了算法的核心知识点,通过系统性地学习这些题目,可以掌握多种算法思想和数据结构。滑动窗口算法的核心思想是通过维护一个动态的窗口区间,来避免重复计算,从而将时间复杂度从O(n²)优化到O(n) 。动态规划算法的核心是利用最优子结构和记忆化,自底向上或自顶向下求解 。并查集算法的核心是路径压缩和按秩合并优化,高效处理连通性问题 。
在实际应用中,算法的选择取决于问题的特性和需求。例如,对于连续子数组问题,滑动窗口算法通常比暴力解法更高效;对于最优化问题,动态规划算法通常能找到最优解;对于连通性问题,并查集算法通常能提供高效的解决方案。
通过本篇文章的讲解和代码示例,希望读者能够深入理解各类算法的原理和实现,并能够灵活应用这些算法解决实际问题。掌握这些算法不仅能够帮助通过技术面试,还能为实际工程中的高效数据处理提供有力工具。
在实际应用中,算法可以与其他数据结构结合使用,进一步扩展其解决能力。例如,当需要处理更复杂的窗口极值问题时,可以结合单调队列;当需要处理多维度的窗口状态时,可以结合哈希表或计数器。
通过系统学习和实战提升,逐步掌握算法精髓,开启算法高手之路!