常用算法题与解析
一、排序算法
1. 冒泡排序
题目描述
给定一个数组,使用冒泡排序对其进行排序。
算法思路
- 比较相邻的元素,如果前一个比后一个大,则交换它们。
- 对每一对相邻元素执行同样的操作,从开始第一对到结尾的最后一对。
- 每一轮比较后,最大的元素会“冒泡”到数组的末尾。
- 重复以上步骤,每次比较的元素数量减少一个,直到没有任何一对数字需要比较。
代码实现
function bubbleSort(arr) {
const n = arr.length;
for (let i = 0; i < n - 1; i++) {
for (let j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 交换元素
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
}时间复杂度
- 最好情况:O(n),当数组已经有序时
- 最坏情况:O(n²),当数组逆序时
- 平均情况:O(n²)
2. 选择排序
题目描述
给定一个数组,使用选择排序对其进行排序。
算法思路
- 从未排序部分找到最小(或最大)元素,存放到排序序列的起始位置。
- 再从剩余未排序元素中继续寻找最小(或最大)元素,然后放到已排序序列的末尾。
- 重复步骤 2,直到所有元素均排序完毕。
代码实现
function selectionSort(arr) {
const n = arr.length;
for (let i = 0; i < n - 1; i++) {
let minIndex = i;
for (let j = i + 1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
// 交换元素
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]];
}
return arr;
}时间复杂度
- 最好情况:O(n²)
- 最坏情况:O(n²)
- 平均情况:O(n²)
3. 插入排序
题目描述
给定一个数组,使用插入排序对其进行排序。
算法思路
- 将第一个元素视为已排序部分。
- 取出下一个元素,在已排序部分从后向前扫描。
- 如果已排序部分的元素大于新元素,则将该元素向右移动一位。
- 重复步骤 3,直到找到已排序部分中小于或等于新元素的位置。
- 将新元素插入到该位置。
- 重复步骤 2-5,直到所有元素都插入完毕。
代码实现
function insertionSort(arr) {
const n = arr.length;
for (let i = 1; i < n; i++) {
let current = arr[i];
let j = i - 1;
while (j >= 0 && arr[j] > current) {
arr[j + 1] = arr[j];
j--;
}
arr[j + 1] = current;
}
return arr;
}时间复杂度
- 最好情况:O(n),当数组已经有序时
- 最坏情况:O(n²),当数组逆序时
- 平均情况:O(n²)
4. 快速排序
题目描述
给定一个数组,使用快速排序对其进行排序。
算法思路
- 选择一个基准元素(通常是数组的第一个或最后一个元素)。
- 将数组分为两部分,小于基准的元素放在左边,大于基准的元素放在右边。
- 对左右两部分递归应用快速排序。
代码实现
function quickSort(arr) {
if (arr.length <= 1) {
return arr;
}
const pivot = arr[0];
const left = [];
const right = [];
for (let i = 1; i < arr.length; i++) {
if (arr[i] < pivot) {
left.push(arr[i]);
} else {
right.push(arr[i]);
}
}
return [...quickSort(left), pivot, ...quickSort(right)];
}时间复杂度
- 最好情况:O(n log n)
- 最坏情况:O(n²),当数组已经有序时
- 平均情况:O(n log n)
5. 归并排序
题目描述
给定一个数组,使用归并排序对其进行排序。
算法思路
- 将数组分成两半。
- 对每一半递归地应用归并排序。
- 将两个已排序的半数组合并成一个有序数组。
代码实现
function mergeSort(arr) {
if (arr.length <= 1) {
return arr;
}
const mid = Math.floor(arr.length / 2);
const left = mergeSort(arr.slice(0, mid));
const right = mergeSort(arr.slice(mid));
return merge(left, right);
}
function merge(left, right) {
const result = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
if (left[i] < right[j]) {
result.push(left[i]);
i++;
} else {
result.push(right[j]);
j++;
}
}
return result.concat(left.slice(i)).concat(right.slice(j));
}时间复杂度
- 最好情况:O(n log n)
- 最坏情况:O(n log n)
- 平均情况:O(n log n)
二、查找算法
1. 线性查找
题目描述
在数组中查找指定元素的位置。
算法思路
从数组的第一个元素开始,逐个比较,直到找到目标元素或遍历完整个数组。
代码实现
function linearSearch(arr, target) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === target) {
return i;
}
}
return -1;
}时间复杂度
- 最好情况:O(1),目标元素在数组的第一个位置
- 最坏情况:O(n),目标元素在数组的最后一个位置或不存在
- 平均情况:O(n)
2. 二分查找
题目描述
在有序数组中查找指定元素的位置。
算法思路
- 找到数组的中间元素。
- 如果中间元素等于目标元素,则返回其位置。
- 如果中间元素大于目标元素,则在左半部分继续查找。
- 如果中间元素小于目标元素,则在右半部分继续查找。
- 重复步骤 1-4,直到找到目标元素或确定目标元素不存在。
代码实现
function binarySearch(arr, target) {
let left = 0;
let right = arr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid] === target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}时间复杂度
- 最好情况:O(1),目标元素是数组的中间元素
- 最坏情况:O(log n)
- 平均情况:O(log n)
三、链表操作
1. 反转链表
题目描述
反转一个单链表。
算法思路
- 初始化三个指针:prev(前一个节点)、current(当前节点)和 next(下一个节点)。
- 遍历链表,对于每个节点:
- 保存 next 节点
- 将 current 节点的 next 指向 prev
- 将 prev 移动到 current
- 将 current 移动到 next
- 当遍历完成后,prev 指向新的头节点。
代码实现
function reverseList(head) {
let prev = null;
let current = head;
while (current) {
const next = current.next;
current.next = prev;
prev = current;
current = next;
}
return prev;
}时间复杂度
- O(n),其中 n 是链表的长度
2. 检测链表环
题目描述
判断一个链表是否有环。
算法思路
使用快慢指针:
- 初始化两个指针,slow 和 fast,都指向链表的头节点。
- slow 指针每次移动一步,fast 指针每次移动两步。
- 如果链表有环,fast 指针会追上 slow 指针。
- 如果链表没有环,fast 指针会先到达链表的末尾。
代码实现
function hasCycle(head) {
if (!head || !head.next) {
return false;
}
let slow = head;
let fast = head.next;
while (slow !== fast) {
if (!fast || !fast.next) {
return false;
}
slow = slow.next;
fast = fast.next.next;
}
return true;
}时间复杂度
- O(n),其中 n 是链表的长度
四、树操作
1. 二叉树的前序遍历
题目描述
给定一个二叉树,返回其节点值的前序遍历。
算法思路
前序遍历的顺序是:根节点 -> 左子树 -> 右子树。
代码实现
// 递归实现
function preorderTraversal(root) {
const result = [];
function traverse(node) {
if (node) {
result.push(node.val);
traverse(node.left);
traverse(node.right);
}
}
traverse(root);
return result;
}
// 迭代实现
function preorderTraversal(root) {
const result = [];
const stack = [];
if (root) {
stack.push(root);
}
while (stack.length) {
const node = stack.pop();
result.push(node.val);
if (node.right) {
stack.push(node.right);
}
if (node.left) {
stack.push(node.left);
}
}
return result;
}时间复杂度
- O(n),其中 n 是二叉树的节点数
2. 二叉树的中序遍历
题目描述
给定一个二叉树,返回其节点值的中序遍历。
算法思路
中序遍历的顺序是:左子树 -> 根节点 -> 右子树。
代码实现
// 递归实现
function inorderTraversal(root) {
const result = [];
function traverse(node) {
if (node) {
traverse(node.left);
result.push(node.val);
traverse(node.right);
}
}
traverse(root);
return result;
}
// 迭代实现
function inorderTraversal(root) {
const result = [];
const stack = [];
let current = root;
while (current || stack.length) {
while (current) {
stack.push(current);
current = current.left;
}
current = stack.pop();
result.push(current.val);
current = current.right;
}
return result;
}时间复杂度
- O(n),其中 n 是二叉树的节点数
3. 二叉树的后序遍历
题目描述
给定一个二叉树,返回其节点值的后序遍历。
算法思路
后序遍历的顺序是:左子树 -> 右子树 -> 根节点。
代码实现
// 递归实现
function postorderTraversal(root) {
const result = [];
function traverse(node) {
if (node) {
traverse(node.left);
traverse(node.right);
result.push(node.val);
}
}
traverse(root);
return result;
}
// 迭代实现
function postorderTraversal(root) {
const result = [];
const stack = [];
let lastVisited = null;
let current = root;
while (current || stack.length) {
while (current) {
stack.push(current);
current = current.left;
}
current = stack[stack.length - 1];
if (!current.right || current.right === lastVisited) {
result.push(current.val);
stack.pop();
lastVisited = current;
current = null;
} else {
current = current.right;
}
}
return result;
}时间复杂度
- O(n),其中 n 是二叉树的节点数
4. 二叉树的层序遍历
题目描述
给定一个二叉树,返回其节点值的层序遍历。
算法思路
使用队列进行广度优先搜索(BFS):
- 将根节点加入队列。
- 当队列不为空时,执行以下操作:
- 记录当前队列的长度(即当前层的节点数)
- 遍历当前层的所有节点,将它们的值加入结果,并将它们的左右子节点加入队列
- 将当前层的结果加入总结果
代码实现
function levelOrder(root) {
const result = [];
if (!root) {
return result;
}
const queue = [root];
while (queue.length) {
const levelSize = queue.length;
const level = [];
for (let i = 0; i < levelSize; i++) {
const node = queue.shift();
level.push(node.val);
if (node.left) {
queue.push(node.left);
}
if (node.right) {
queue.push(node.right);
}
}
result.push(level);
}
return result;
}时间复杂度
- O(n),其中 n 是二叉树的节点数
五、动态规划
1. 爬楼梯
题目描述
假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶?
算法思路
- 状态定义:dp[i] 表示爬到第 i 阶的方法数。
- 状态转移方程:dp[i] = dp[i-1] + dp[i-2],因为爬到第 i 阶的方法数等于爬到第 i-1 阶的方法数(再爬 1 阶)加上爬到第 i-2 阶的方法数(再爬 2 阶)。
- 初始条件:dp[1] = 1, dp[2] = 2。
代码实现
function climbStairs(n) {
if (n === 1) {
return 1;
}
const dp = new Array(n + 1);
dp[1] = 1;
dp[2] = 2;
for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}时间复杂度
- O(n)
2. 最大子数组和
题目描述
给定一个整数数组 nums,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
算法思路
- 状态定义:dp[i] 表示以 nums[i] 结尾的最大子数组和。
- 状态转移方程:dp[i] = max(nums[i], dp[i-1] + nums[i]),因为以 nums[i] 结尾的最大子数组和要么是 nums[i] 本身,要么是 nums[i] 加上以 nums[i-1] 结尾的最大子数组和。
- 初始条件:dp[0] = nums[0]。
- 最终结果:max(dp)。
代码实现
function maxSubArray(nums) {
const dp = new Array(nums.length);
dp[0] = nums[0];
let max = dp[0];
for (let i = 1; i < nums.length; i++) {
dp[i] = Math.max(nums[i], dp[i - 1] + nums[i]);
max = Math.max(max, dp[i]);
}
return max;
}时间复杂度
- O(n)
3. 0-1 背包问题
题目描述
给定 n 个物品,每个物品有一个重量 w[i] 和一个价值 v[i],以及一个容量为 C 的背包。问如何选择物品,使得放入背包的物品总价值最大,且总重量不超过 C。
算法思路
- 状态定义:dp[i][j] 表示前 i 个物品,背包容量为 j 时的最大价值。
- 状态转移方程:
- 如果 w[i-1] > j(当前物品重量超过背包容量),则 dp[i][j] = dp[i-1][j]
- 否则,dp[i][j] = max(dp[i-1][j], dp[i-1][j - w[i-1]] + v[i-1])
- 初始条件:dp[0][j] = 0(没有物品时,价值为 0)。
代码实现
function knapsack(w, v, C) {
const n = w.length;
const dp = new Array(n + 1).fill().map(() => new Array(C + 1).fill(0));
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= C; j++) {
if (w[i - 1] > j) {
dp[i][j] = dp[i - 1][j];
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - w[i - 1]] + v[i - 1]);
}
}
}
return dp[n][C];
}时间复杂度
- O(nC),其中 n 是物品数量,C 是背包容量
六、图算法
1. 深度优先搜索(DFS)
题目描述
给定一个图,使用深度优先搜索遍历所有节点。
算法思路
- 从起始节点开始,访问该节点。
- 递归地访问该节点的所有未访问过的邻居节点。
- 重复步骤 2,直到所有节点都被访问。
代码实现
function dfs(graph, start, visited = new Set()) {
visited.add(start);
console.log(start);
for (const neighbor of graph[start]) {
if (!visited.has(neighbor)) {
dfs(graph, neighbor, visited);
}
}
return visited;
}时间复杂度
- O(V + E),其中 V 是节点数,E 是边数
2. 广度优先搜索(BFS)
题目描述
给定一个图,使用广度优先搜索遍历所有节点。
算法思路
- 从起始节点开始,将其加入队列。
- 当队列不为空时,执行以下操作:
- 取出队列中的第一个节点,访问它。
- 将该节点的所有未访问过的邻居节点加入队列。
- 重复步骤 2,直到所有节点都被访问。
代码实现
function bfs(graph, start) {
const visited = new Set();
const queue = [start];
visited.add(start);
while (queue.length) {
const node = queue.shift();
console.log(node);
for (const neighbor of graph[node]) {
if (!visited.has(neighbor)) {
visited.add(neighbor);
queue.push(neighbor);
}
}
}
return visited;
}时间复杂度
- O(V + E),其中 V 是节点数,E 是边数
3. 最短路径算法(Dijkstra)
题目描述
给定一个带权有向图,找到从起始节点到所有其他节点的最短路径。
算法思路
- 初始化一个距离数组,将起始节点的距离设为 0,其他节点的距离设为无穷大。
- 使用优先队列(最小堆)来选择当前距离最小的节点。
- 对于当前节点,遍历其所有邻居节点,计算从起始节点经过当前节点到邻居节点的距离,如果这个距离比邻居节点当前的距离小,则更新邻居节点的距离。
- 重复步骤 2-3,直到所有节点都被处理。
代码实现
function dijkstra(graph, start) {
const distances = {};
const priorityQueue = [];
// 初始化距离
for (const node in graph) {
distances[node] = Infinity;
}
distances[start] = 0;
// 优先队列初始化
priorityQueue.push({ distance: 0, node: start });
while (priorityQueue.length) {
// 按距离排序,取出距离最小的节点
priorityQueue.sort((a, b) => a.distance - b.distance);
const { distance, node } = priorityQueue.shift();
// 如果当前距离大于记录的距离,跳过
if (distance > distances[node]) {
continue;
}
// 遍历邻居节点
for (const [neighbor, weight] of Object.entries(graph[node])) {
const newDistance = distance + weight;
if (newDistance < distances[neighbor]) {
distances[neighbor] = newDistance;
priorityQueue.push({ distance: newDistance, node: neighbor });
}
}
}
return distances;
}时间复杂度
- O((V + E) log V),其中 V 是节点数,E 是边数
七、字符串处理
1. 最长回文子串
题目描述
给定一个字符串 s,找到 s 中最长的回文子串。
算法思路
使用中心扩展法:
- 遍历字符串的每个字符,将其作为回文的中心。
- 对于每个中心,向两边扩展,检查是否是回文。
- 记录最长的回文子串。
代码实现
function longestPalindrome(s) {
if (s.length < 2) {
return s;
}
let start = 0;
let maxLength = 1;
function expandAroundCenter(left, right) {
while (left >= 0 && right < s.length && s[left] === s[right]) {
if (right - left + 1 > maxLength) {
maxLength = right - left + 1;
start = left;
}
left--;
right++;
}
}
for (let i = 0; i < s.length; i++) {
// 奇数长度的回文
expandAroundCenter(i, i);
// 偶数长度的回文
expandAroundCenter(i, i + 1);
}
return s.substring(start, start + maxLength);
}时间复杂度
- O(n²),其中 n 是字符串的长度
2. 字符串匹配(KMP 算法)
题目描述
给定一个文本串 text 和一个模式串 pattern,在文本串中找到模式串的第一次出现位置。
算法思路
KMP 算法的核心是构建部分匹配表(next 数组),用于在匹配失败时快速回退模式串的指针。
代码实现
function kmpSearch(text, pattern) {
const n = text.length;
const m = pattern.length;
if (m === 0) {
return 0;
}
// 构建 next 数组
const next = new Array(m).fill(0);
for (let i = 1, j = 0; i < m; i++) {
while (j > 0 && pattern[i] !== pattern[j]) {
j = next[j - 1];
}
if (pattern[i] === pattern[j]) {
j++;
}
next[i] = j;
}
// 匹配过程
for (let i = 0, j = 0; i < n; i++) {
while (j > 0 && text[i] !== pattern[j]) {
j = next[j - 1];
}
if (text[i] === pattern[j]) {
j++;
}
if (j === m) {
return i - m + 1;
}
}
return -1;
}时间复杂度
- O(n + m),其中 n 是文本串的长度,m 是模式串的长度
八、贪心算法
1. 活动选择问题
题目描述
给定一组活动,每个活动有开始时间和结束时间,选择最多的活动,使得这些活动不重叠。
算法思路
贪心策略:每次选择结束时间最早的活动,这样可以为后续活动留出更多时间。
代码实现
function activitySelection(activities) {
// 按结束时间排序
activities.sort((a, b) => a.end - b.end);
const selected = [];
let lastEnd = -Infinity;
for (const activity of activities) {
if (activity.start >= lastEnd) {
selected.push(activity);
lastEnd = activity.end;
}
}
return selected;
}时间复杂度
- O(n log n),其中 n 是活动数量(排序的时间复杂度)
2. 零钱兑换
题目描述
给定不同面额的硬币 coins 和一个总金额 amount,编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。
算法思路
贪心策略:每次选择面值最大的硬币,尽可能多的使用它。
代码实现
function coinChange(coins, amount) {
// 按面值从大到小排序
coins.sort((a, b) => b - a);
let result = Infinity;
function dfs(index, currentAmount, count) {
const coin = coins[index];
// 如果已经找到一个比当前结果更好的解,直接返回
if (count >= result) {
return;
}
// 计算最多可以使用多少个当前硬币
const maxCoins = Math.floor(currentAmount / coin);
// 如果已经到了最后一种硬币
if (index === coins.length - 1) {
if (currentAmount % coin === 0) {
result = Math.min(result, count + maxCoins);
}
return;
}
// 尝试使用不同数量的当前硬币
for (let i = maxCoins; i >= 0; i--) {
dfs(index + 1, currentAmount - i * coin, count + i);
}
}
dfs(0, amount, 0);
return result === Infinity ? -1 : result;
}时间复杂度
- O(S^n),其中 S 是最大硬币面值,n 是硬币种类数(最坏情况)
九、位运算
1. 位 1 的个数
题目描述
编写一个函数,输入一个无符号整数,返回其二进制表达式中数字位数为 '1' 的个数(也被称为汉明重量)。
算法思路
使用位运算:
- n & 1 可以获取 n 的最低位
- n >> 1 可以将 n 右移一位
代码实现
function hammingWeight(n) {
let count = 0;
while (n) {
count += n & 1;
n >>= 1;
}
return count;
}时间复杂度
- O(1),因为无符号整数的位数是固定的
2. 两数之和
题目描述
给你两个整数 a 和 b,不使用运算符 + 和 -,计算并返回两数之和。
算法思路
使用位运算:
- a ^ b 计算无进位的和
- (a & b) << 1 计算进位
- 重复上述过程,直到进位为 0
代码实现
function getSum(a, b) {
while (b !== 0) {
const carry = (a & b) << 1;
a = a ^ b;
b = carry;
}
return a;
}时间复杂度
- O(1),因为整数的位数是固定的
十、总结
以上是一些常用的算法题及其解析,涵盖了排序、查找、链表、树、动态规划、图、字符串处理、贪心算法和位运算等多个领域。掌握这些算法对于提高编程能力和解决实际问题非常重要。
在学习算法时,建议:
- 理解算法的基本思路和原理
- 掌握算法的代码实现
- 分析算法的时间复杂度和空间复杂度
- 多做练习,熟悉不同类型的算法问题
- 尝试优化算法,提高效率
通过不断学习和实践,你将能够更高效地解决各种算法问题,提升自己的编程能力。