算法
一、基础算法
1. 时间复杂度与空间复杂度
问题:什么是时间复杂度?什么是空间复杂度?如何计算时间复杂度?如何计算空间复杂度?常见的时间复杂度有哪些?如何分析算法?
答案:
时间复杂度(Time Complexity)是描述算法执行时间随数据规模增长的变化趋势的度量,不表示具体时间,而是表示增长趋势。
时间复杂度计算规则:
- 忽略常数项:O(2n+3) → O(n)
- 忽略低阶项:O(n²+n+1) → O(n²)
- 保留最高阶项:O(n! + 2^n + n³) → O(n!)
- 常见时间复杂度排序:O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2^n) < O(n!)
空间复杂度(Space Complexity)是描述算法运行过程中临时占用存储空间随数据规模增长的变化趋势,包括算法本身占用的空间、输入输出数据占用的空间、算法运行过程中临时占用的辅助空间。
常见时间复杂度对比表:
| 时间复杂度 | 名称 | 示例算法 | 增长趋势 |
|---|---|---|---|
| O(1) | 常数阶 | 数组随机访问、哈希表查找 | 最优 |
| O(log n) | 对数阶 | 二分查找、平衡树查找 | 优秀 |
| O(n) | 线性阶 | 顺序查找、遍历数组 | 良好 |
| O(n log n) | 线性对数阶 | 快速排序、归并排序 | 较好 |
| O(n²) | 平方阶 | 冒泡排序、选择排序 | 一般 |
| O(n³) | 立方阶 | 矩阵乘法(朴素) | 较差 |
| O(2^n) | 指数阶 | 汉诺塔、子集枚举 | 差 |
| O(n!) | 阶乘阶 | 全排列、旅行商问题(回溯) | 最差 |
时间复杂度计算方法:
// 示例1:O(1) 常数阶
function constantTime(n) {
return n * n; // 单次运算,与n大小无关
}
// 示例2:O(n) 线性阶
function linearTime(n) {
let sum = 0;
for (let i = 0; i < n; i++) { // 循环n次
sum += i;
}
return sum;
}
// 示例3:O(n²) 平方阶
function quadraticTime(n) {
let sum = 0;
for (let i = 0; i < n; i++) { // 外层循环n次
for (let j = 0; j < n; j++) { // 内层循环n次
sum += i * j; // 总共n²次操作
}
}
return sum;
}
// 示例4:O(log n) 对数阶
function logarithmicTime(n) {
let i = 1;
while (i < n) {
i = i * 2; // 每次乘以2,循环次数约log₂n
}
return i;
}
// 示例5:O(n log n) 线性对数阶
function linearithmicTime(n) {
let sum = 0;
for (let i = 0; i < n; i++) { // 外层循环n次
let j = 1;
while (j < n) { // 内层循环log n次
j = j * 2;
sum += i * j;
}
}
return sum;
}
空间复杂度计算方法:
// 示例1:O(1) 常数空间
function constantSpace(n) {
let a = 1; // 固定变量,不随n变化
let b = 2;
return a + b;
}
// 示例2:O(n) 线性空间
function linearSpace(n) {
let arr = new Array(n); // 数组长度n,占用n个存储单元
for (let i = 0; i < n; i++) {
arr[i] = i;
}
return arr;
}
// 示例3:O(n²) 平方空间
function quadraticSpace(n) {
let matrix = new Array(n);
for (let i = 0; i < n; i++) {
matrix[i] = new Array(n); // n×n矩阵,占用n²空间
for (let j = 0; j < n; j++) {
matrix[i][j] = i * j;
}
}
return matrix;
}
// 示例4:递归调用栈空间 O(n)
function recursiveSpace(n) {
if (n <= 1) return 1;
return n * recursiveSpace(n - 1); // 递归深度n,调用栈占用O(n)空间
}
算法分析步骤:
- 确定输入规模:通常用n表示问题规模大小
- 识别基本操作:找出算法中最主要的操作(比较、赋值、算术运算等)
- 建立数学模型:用数学表达式表示基本操作的执行次数
- 求解表达式:推导出时间/空间复杂度的渐进表示
- 评估效率:根据渐进复杂度判断算法优劣
补充说明:
- 时间复杂度关注时间增长趋势,空间复杂度关注空间增长趋势
- 大O表示法表示最坏情况复杂度,Θ表示平均复杂度,Ω表示最好情况复杂度
- 实际应用中需权衡时间与空间复杂度(时间换空间或空间换时间)
- 递归算法的时间复杂度分析常用递推公式或主定理
二、排序算法
2. 常见排序算法原理与实现
问题:常见的排序算法有哪些?冒泡排序、选择排序、插入排序、快速排序、归并排序、堆排序的原理和实现是什么?排序算法的对比如何?
答案:
排序算法分类:
- 比较类排序:通过比较元素间相对顺序进行排序,时间复杂度下界为O(n log n)
- 交换排序:冒泡排序、快速排序
- 插入排序:简单插入排序、希尔排序
- 选择排序:简单选择排序、堆排序
- 归并排序:二路归并排序、多路归并排序
- 非比较类排序:不通过比较元素间相对顺序进行排序,可突破O(n log n)下限
- 计数排序
- 桶排序
- 基数排序
排序算法对比表:
| 排序算法 | 平均时间复杂度 | 最坏时间复杂度 | 最好时间复杂度 | 空间复杂度 | 稳定性 | 适用场景 |
|---|---|---|---|---|---|---|
| 冒泡排序 | O(n²) | O(n²) | O(n) | O(1) | 稳定 | 教学示例,小规模数据 |
| 选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 | 不稳定,较少使用 |
| 插入排序 | O(n²) | O(n²) | O(n) | O(1) | 稳定 | 小规模或基本有序数据 |
| 快速排序 | O(n log n) | O(n²) | O(n log n) | O(log n) | 不稳定 | 大规模数据,通用排序 |
| 归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 | 链表排序,外部排序 |
| 堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 | 需要原地排序且O(1)空间 |
| 希尔排序 | O(n log n)~O(n²) | O(n²) | O(n log n) | O(1) | 不稳定 | 中等规模数据 |
| 计数排序 | O(n+k) | O(n+k) | O(n+k) | O(n+k) | 稳定 | 整数排序,范围小 |
| 桶排序 | O(n+k) | O(n²) | O(n) | O(n+k) | 稳定 | 均匀分布数据 |
| 基数排序 | O(d(n+k)) | O(d(n+k)) | O(d(n+k)) | O(n+k) | 稳定 | 多关键字排序 |
冒泡排序(Bubble Sort):
- 原理:重复遍历数组,比较相邻元素,如果顺序错误则交换,每次遍历将最大元素"冒泡"到正确位置
- 特点:稳定排序、原地排序、简单但效率低
function bubbleSort(arr) {
const n = arr.length;
for (let i = 0; i < n - 1; i++) { // 遍历n-1轮
let swapped = false; // 优化:如果一轮没有交换,说明已排序
for (let j = 0; j < n - 1 - i; j++) { // 每轮比较次数递减
if (arr[j] > arr[j + 1]) { // 如果前一个比后一个大
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]]; // 交换
swapped = true;
}
}
if (!swapped) break; // 已排序完成,提前退出
}
return arr;
}
// 测试
console.log(bubbleSort([64, 34, 25, 12, 22, 11, 90])); // [11, 12, 22, 25, 34, 64, 90]
选择排序(Selection Sort):
- 原理:每次从未排序部分选择最小(或最大)元素,放到已排序部分的末尾
- 特点:不稳定排序、原地排序、交换次数少但比较次数多
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;
}
}
if (minIndex !== i) { // 如果找到更小的值
[arr[i], arr[minIndex]] = [arr[minIndex], arr[i]]; // 交换
}
}
return arr;
}
// 测试
console.log(selectionSort([29, 10, 14, 37, 13])); // [10, 13, 14, 29, 37]
插入排序(Insertion Sort):
- 原理:将数组分为已排序和未排序两部分,每次将未排序部分的第一个元素插入到已排序部分的正确位置
- 特点:稳定排序、原地排序、对小规模或基本有序数据效率高
function insertionSort(arr) {
const n = arr.length;
for (let i = 1; i < n; i++) { // 从第二个元素开始
let key = arr[i]; // 当前要插入的元素
let j = i - 1;
// 从后向前扫描已排序部分,寻找插入位置
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j]; // 向后移动元素
j--;
}
arr[j + 1] = key; // 插入到正确位置
}
return arr;
}
// 测试
console.log(insertionSort([12, 11, 13, 5, 6])); // [5, 6, 11, 12, 13]
快速排序(Quick Sort):
- 原理:分治法思想,选取一个基准元素,将数组分为小于基准和大于基准的两部分,递归排序
- 特点:不稳定排序、原地排序(递归栈空间除外)、平均性能最好
function quickSort(arr, left = 0, right = arr.length - 1) {
if (left >= right) return arr;
// 分区操作,返回基准元素的最终位置
const pivotIndex = partition(arr, left, right);
// 递归排序左右子数组
quickSort(arr, left, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, right);
return arr;
}
function partition(arr, left, right) {
// 选择基准元素(这里选择最后一个元素)
const pivot = arr[right];
let i = left - 1; // 小于基准的区域的右边界
for (let j = left; j < right; j++) {
if (arr[j] <= pivot) {
i++;
[arr[i], arr[j]] = [arr[j], arr[i]]; // 交换到小于基准的区域
}
}
// 将基准元素放到正确位置
[arr[i + 1], arr[right]] = [arr[right], arr[i + 1]];
return i + 1;
}
// 测试
console.log(quickSort([10, 80, 30, 90, 40, 50, 70])); // [10, 30, 40, 50, 70, 80, 90]
归并排序(Merge Sort):
- 原理:分治法思想,将数组分成两半分别排序,然后合并两个有序数组
- 特点:稳定排序、非原地排序(需要额外空间)、适合链表排序
function mergeSort(arr) {
if (arr.length <= 1) return arr;
// 分割数组
const mid = Math.floor(arr.length / 2);
const left = arr.slice(0, mid);
const right = arr.slice(mid);
// 递归排序并合并
return merge(mergeSort(left), mergeSort(right));
}
function merge(left, right) {
const result = [];
let i = 0, 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), right.slice(j));
}
// 测试
console.log(mergeSort([38, 27, 43, 3, 9, 82, 10])); // [3, 9, 10, 27, 38, 43, 82]
堆排序(Heap Sort):
- 原理:将数组构建成最大堆,然后反复将堆顶最大元素与堆尾元素交换并调整堆
- 特点:不稳定排序、原地排序、时间复杂度稳定
function heapSort(arr) {
const n = arr.length;
// 构建最大堆(从最后一个非叶子节点开始)
for (let i = Math.floor(n / 2) - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 逐个提取堆顶元素
for (let i = n - 1; i > 0; i--) {
[arr[0], arr[i]] = [arr[i], arr[0]]; // 将堆顶(最大值)与堆尾交换
heapify(arr, i, 0); // 调整堆(大小减1)
}
return arr;
}
// 堆调整函数(将i为根的子树调整成最大堆)
function heapify(arr, n, i) {
let largest = i; // 假设根节点最大
const left = 2 * i + 1; // 左子节点
const right = 2 * i + 2; // 右子节点
// 如果左子节点存在且大于根节点
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 如果右子节点存在且大于最大值
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大值不是根节点
if (largest !== i) {
[arr[i], arr[largest]] = [arr[largest], arr[i]]; // 交换
heapify(arr, n, largest); // 递归调整被影响的子树
}
}
// 测试
console.log(heapSort([12, 11, 13, 5, 6, 7])); // [5, 6, 7, 11, 12, 13]
补充说明:
- 稳定排序:相等元素的相对顺序在排序前后保持一致
- 原地排序:除输入数组外只使用常数级别的额外空间
- 自适应排序:对基本有序数据性能更好(如插入排序)
- 外部排序:数据量太大无法全部加载到内存时使用(如归并排序)
- 选择排序算法时要考虑数据规模、数据分布、稳定性要求、空间限制等因素
三、查找算法
3. 常见查找算法原理与实现
问题:常见的查找算法有哪些?二分查找的原理和实现是什么?二分查找的适用场景是什么?哈希查找的原理和实现是什么?
答案:
查找算法分类:
- 静态查找:查找表不会变化,只需查找不需要插入删除
- 顺序查找(线性查找)
- 二分查找(折半查找)
- 插值查找
- 斐波那契查找
- 动态查找:查找表会动态变化,需支持插入删除操作
- 二叉搜索树
- 平衡树(AVL树、红黑树)
- B树/B+树
- 哈希表
查找算法对比表:
| 查找算法 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 数据要求 | 适用场景 |
|---|---|---|---|---|---|
| 顺序查找 | O(n) | O(n) | O(1) | 无序或有序 | 小规模数据,简单实现 |
| 二分查找 | O(log n) | O(log n) | O(1) | 有序数组 | 静态有序数据,查找频繁 |
| 插值查找 | O(log(log n)) | O(n) | O(1) | 有序且均匀分布 | 分布均匀的大规模数据 |
| 斐波那契查找 | O(log n) | O(log n) | O(1) | 有序数组 | 数据访问代价大的场景 |
| 哈希查找 | O(1) | O(n) | O(n) | 无特殊要求 | 快速查找,但不支持范围查询 |
| 二叉搜索树 | O(log n) | O(n) | O(n) | 无特殊要求 | 动态查找,支持范围查询 |
| 平衡树 | O(log n) | O(log n) | O(n) | 无特殊要求 | 动态查找,保证性能 |
| B树/B+树 | O(log n) | O(log n) | O(n) | 无特殊要求 | 数据库索引,文件系统 |
顺序查找(Sequential Search):
function sequentialSearch(arr, target) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === target) {
return i; // 找到返回索引
}
}
return -1; // 未找到
}
// 优化:哨兵模式,减少比较次数
function sequentialSearchWithSentinel(arr, target) {
const n = arr.length;
if (arr[n - 1] === target) return n - 1; // 先检查最后一个
const last = arr[n - 1]; // 保存最后一个元素
arr[n - 1] = target; // 将目标值放到最后作为哨兵
let i = 0;
while (arr[i] !== target) {
i++;
}
arr[n - 1] = last; // 恢复最后一个元素
if (i < n - 1 || last === target) {
return i; // 找到
}
return -1; // 未找到
}
二分查找(Binary Search):
// 迭代版本
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; // 未找到
}
// 递归版本
function binarySearchRecursive(arr, target, left = 0, right = arr.length - 1) {
if (left > right) return -1;
const mid = Math.floor((left + right) / 2);
if (arr[mid] === target) {
return mid;
} else if (arr[mid] < target) {
return binarySearchRecursive(arr, target, mid + 1, right);
} else {
return binarySearchRecursive(arr, target, left, mid - 1);
}
}
// 查找第一个等于目标值的元素
function binarySearchFirst(arr, target) {
let left = 0;
let right = arr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid] >= target) {
right = mid - 1;
} else {
left = mid + 1;
}
}
if (left < arr.length && arr[left] === target) {
return left;
}
return -1;
}
// 查找最后一个等于目标值的元素
function binarySearchLast(arr, target) {
let left = 0;
let right = arr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid] <= target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
if (right >= 0 && arr[right] === target) {
return right;
}
return -1;
}
// 测试
const sortedArr = [1, 3, 5, 7, 9, 11, 13, 15];
console.log(binarySearch(sortedArr, 7)); // 3
console.log(binarySearch(sortedArr, 8)); // -1
哈希查找(Hash Search):
class HashTable {
constructor(size = 53) {
this.table = new Array(size);
}
// 简单哈希函数(使用质数减少碰撞)
hash(key) {
let total = 0;
const PRIME = 31;
for (let i = 0; i < Math.min(key.length, 100); i++) {
const char = key[i];
const value = char.charCodeAt(0) - 96;
total = (total * PRIME + value) % this.table.length;
}
return total;
}
// 线性探测解决冲突
set(key, value) {
let index = this.hash(key);
// 线性探测寻找空位
while (this.table[index] !== undefined && this.table[index].key !== key) {
index = (index + 1) % this.table.length;
}
this.table[index] = { key, value };
}
get(key) {
let index = this.hash(key);
// 线性探测查找
while (this.table[index] !== undefined) {
if (this.table[index].key === key) {
return this.table[index].value;
}
index = (index + 1) % this.table.length;
}
return undefined;
}
// 链地址法解决冲突
setSeparateChaining(key, value) {
const index = this.hash(key);
if (!this.table[index]) {
this.table[index] = [];
}
// 检查是否已存在相同的key
for (let i = 0; i < this.table[index].length; i++) {
if (this.table[index][i][0] === key) {
this.table[index][i][1] = value; // 更新值
return;
}
}
// 添加新键值对
this.table[index].push([key, value]);
}
getSeparateChaining(key) {
const index = this.hash(key);
if (this.table[index]) {
for (let i = 0; i < this.table[index].length; i++) {
if (this.table[index][i][0] === key) {
return this.table[index][i][1];
}
}
}
return undefined;
}
}
// 测试
const ht = new HashTable(17);
ht.set('apple', 'red');
ht.set('banana', 'yellow');
ht.set('orange', 'orange');
console.log(ht.get('apple')); // 'red'
console.log(ht.get('grape')); // undefined
补充说明:
- 二分查找要求有序数组,时间复杂度O(log n),但插入删除代价高
- 哈希查找平均O(1),但最坏情况O(n),需处理哈希冲突
- 二叉搜索树在平衡时效率高,但可能退化为链表(最坏O(n))
- 平衡树保证O(log n)性能,但实现复杂
- B树/B+树适合磁盘存储,减少I/O操作
- 插值查找在数据均匀分布时比二分查找更快
- 斐波那契查找利用黄金分割原理,减少不必要的比较
四、递归与分治
4. 递归原理与分治算法
问题:什么是递归?递归的原理是什么?递归的终止条件是什么?递归与迭代的区别是什么?什么是分治算法?分治算法的应用场景有哪些?
答案:
递归(Recursion):函数直接或间接调用自身的过程,将复杂问题分解为相同但规模更小的子问题。
递归三要素:
- 递归定义:问题可以分解为相似子问题
- 递归边界(终止条件):最小问题的直接解
- 递归关系:问题与其子问题之间的关系
递归实现阶乘:
function factorial(n) {
// 递归边界:0! = 1
if (n === 0) return 1;
// 递归关系:n! = n * (n-1)!
return n * factorial(n - 1);
}
console.log(factorial(5)); // 120
递归实现斐波那契数列(低效版本):
function fibonacci(n) {
if (n <= 1) return n;
return fibonacci(n - 1) + fibonacci(n - 2);
}
console.log(fibonacci(10)); // 55
递归实现汉诺塔:
function hanoi(n, from, to, aux) {
if (n === 1) {
console.log(`Move disk 1 from ${from} to ${to}`);
return;
}
hanoi(n - 1, from, aux, to);
console.log(`Move disk ${n} from ${from} to ${to}`);
hanoi(n - 1, aux, to, from);
}
// 测试
hanoi(3, 'A', 'C', 'B');
// Move disk 1 from A to C
// Move disk 2 from A to B
// Move disk 1 from C to B
// Move disk 3 from A to C
// Move disk 1 from B to A
// Move disk 2 from B to C
// Move disk 1 from A to C
递归 vs 迭代对比表:
| 特性 | 递归 | 迭代 |
|---|---|---|
| 实现方式 | 函数调用自身 | 循环结构 |
| 代码可读性 | 高(接近数学定义) | 较低 |
| 空间复杂度 | O(n)(调用栈) | O(1)(通常) |
| 时间复杂度 | 可能较高(重复计算) | 通常较低 |
| 调试难度 | 较难(多层调用栈) | 较易 |
| 适用场景 | 树/图遍历、分治、回溯 | 简单循环、动态规划优化 |
递归优化方法:
- 尾递归优化:递归调用是函数体中最后一步操作,某些语言可优化为迭代
- 记忆化(Memoization):缓存已计算结果,避免重复计算
- 转换为迭代:用栈模拟递归调用过程
记忆化斐波那契数列:
function fibonacciMemo(n, memo = {}) {
if (n <= 1) return n;
if (memo[n] !== undefined) return memo[n];
memo[n] = fibonacciMemo(n - 1, memo) + fibonacciMemo(n - 2, memo);
return memo[n];
}
console.log(fibonacciMemo(50)); // 12586269025(快速计算)
分治算法(Divide and Conquer):
- 思想:将问题分解为若干规模较小的子问题,分别解决,再合并子问题的解得到原问题的解
- 步骤:分解 → 解决 → 合并
- 特点:通常通过递归实现,适合并行计算
分治算法经典问题:
- 归并排序(已实现)
- 快速排序(已实现)
- 二分查找(已实现)
- 大整数乘法(Karatsuba算法)
- 矩阵乘法(Strassen算法)
- 最近点对问题
分治算法模板:
function divideConquer(problem, params) {
// 递归终止条件(问题足够小直接求解)
if (problem is small enough) {
return solveDirectly(problem);
}
// 分解问题
const subproblems = splitProblem(problem, params);
// 解决子问题(递归调用)
const subresults = [];
for (const subproblem of subproblems) {
subresults.push(divideConquer(subproblem, params));
}
// 合并结果
return combineResults(subresults);
}
分治算法应用场景:
- 排序算法(归并排序、快速排序)
- 查找算法(二分查找)
- 数学计算(大整数乘法、矩阵乘法)
- 计算几何(最近点对、凸包)
- 快速幂运算
- 傅里叶变换
补充说明:
- 递归要注意栈溢出问题(深度过大)
- 分治算法要求子问题相互独立,且合并代价不能太高
- 递归深度与问题规模相关,需合理设计递归结构
- 尾递归可被某些编译器优化为迭代,减少空间消耗
由于篇幅限制,这里先提供算法答案的前四部分(基础算法、排序算法、查找算法、递归与分治)。后续部分(动态规划、贪心算法、回溯算法、图算法、树算法、哈希算法、字符串算法、位运算、数据结构)将根据相同模式继续编写,每部分包含问题概述、详细答案、代码示例和对比表格。