跳到主要内容算法基础:特性、复杂度与经典实现解析 | 极客日志JavaScriptNode.js大前端算法
算法基础:特性、复杂度与经典实现解析
算法特性包括有穷性、确定性等五大要素,确保有效性与可靠性。时间复杂度分析通过大 O 符号评估性能上限,涵盖最佳、最坏及平均情况。递归、分治与动态规划是解决复杂问题的核心策略,结合 JavaScript 实战案例,涵盖阶乘、归并排序及背包问题,帮助开发者深入理解算法设计与优化。
1. 算法的特性
算法的定义
算法是解决特定问题的一系列清晰、准确的指令步骤的有限序列。每个步骤表示一个或多个明确的操作。
算法的五大特性
- 有穷性
- 步骤有穷:算法必须在执行有限步后终止,不能无限循环。
- 时间有穷:每个步骤都必须在有限时间内完成。
- 意义:保证了问题在理论上的可解性。一个永远不结束的过程不是算法。
- 确定性
- 指令明确:算法中的每一步都必须有精确、无歧义的定义。
- 结果确定:在相同的初始条件下,每次执行算法都必须遵循唯一的路径,并得到完全相同的结果。
- 意义:保证了算法的可预测性和可靠性。
- 可行性
- 操作可实现:算法中描述的所有操作,都可以通过已经实现的基本运算(如算术运算、逻辑判断、数据存取等)来执行。
- 有限次完成:这些基本运算可以在有限的次数内完成。
- 意义:保证了算法在现实中的可执行性,不是空想。
- 输入
- 算法有零个或多个输入。
- 这些输入来自特定的数据集合(例如整数、字符串、数组等)。
- 意义:刻画了算法处理对象的初始状态。'零个输入'意味着算法本身已包含了所需初始数据。
- 输出
- 算法必须有一个或多个输出。
- 输出是与输入有特定关系的量,即问题的解。
- 意义:算法必须有结果,没有输出的过程没有意义。
这五个特性是判断一个计算过程是否能称为'算法'的基本标准。它们共同确保了算法是一个有效、可靠且可落地的问题解决方法。
举例说明
问题:找出三个整数 a, b, c 中的最大值。
符合五大特性的算法描述(伪代码):
1. 输入三个数 a, b, c。 // 体现了【输入】
2. 设变量 max 等于 a。 // 初始化
3. 如果 b > max,那么令 max = b。 // 确定性、可行性
4. 如果 c > max,那么令 max = c。 // 确定性、可行性
5. 输出 max。 // 体现了【输出】
// 整个过程在有限步(5 步)内结束,体现了【有穷性】
这个简单的过程完全符合要求,因此它是一个合格的算法。
2. 算法复杂度
时间复杂度分析的三个层面
-
最佳情况时间复杂度
- 定义:算法在最理想的输入数据下执行所需的最少操作次数。
- 意义:给出了算法性能的下限,通常不是实际评估的主要依据。
- 例子:在有序数组中执行二分查找,最佳情况是第一次就找到目标元素,时间复杂度为 O(1)。
-
最坏情况时间复杂度
- 定义:算法在最不利的输入数据下执行所需的最多操作次数。
- 意义:给出了算法性能的上限(上界),是算法分析的最重要指标,因为它保证了在任何输入下性能都不会比这更差。
- 例子:在数组中顺序查找一个不存在的元素,需要遍历整个数组,时间复杂度为 O(n)。
平均情况时间复杂度
- 定义:算法在所有可能输入的期望运行时间,通常需要假设输入数据的概率分布。
- 意义:更全面地反映了算法的整体性能,但计算复杂,需要概率论知识。
- 例子:在无序数组中顺序查找一个元素,假设目标元素出现在每个位置的概率相等,平均需要查找约 n/2 次,时间复杂度为 O(n)。
平均比较次数 = 期望值 E[X]。
当 n 很大时,(n+1)/2 ≈ n/2。因为 n/2 与 (n+1)/2 的差异是常数 0.5,在大 O 表示法中忽略常数和低阶项,所以两者是等价的。
关系:最佳情况 ≤ 平均情况 ≤ 最坏情况。
在实际工程和理论分析中,最坏情况时间复杂度是最常用和最重要的标准。
渐进符号(时间复杂度表示法)
为了简化分析,我们忽略常数因子和低阶项,只关注随输入规模 n 增长的趋势。
三种主要的渐进符号
-
O(大 O 符号)- 渐进上界
- 定义:T(n) = O(f(n)),表示存在正常数 C 和 n₀,使得对于所有 n ≥ n₀,有 T(n) ≤ C * f(n)。
- 意义:表示算法运行时间的最坏情况增长趋势。
- 例子:3n² + 2n + 1 是 O(n²)。
-
Ω(大Ω符号)- 渐进下界
- 定义:T(n) = Ω(f(n)),表示存在正常数 C 和 n₀,使得对于所有 n ≥ n₀,有 T(n) ≥ C * f(n)。
- 意义:表示算法运行时间的最佳情况增长趋势。
-
Θ(大Θ符号)- 渐进紧确界
- 定义:T(n) = Θ(f(n)),当且仅当 T(n) = O(f(n)) 且 T(n) = Ω(f(n))。
- 意义:表示算法运行时间的精确增长量级。
常见的时间复杂度等级(从优到劣)
O(1) < O(log n) < O(n) < O(n log n) < O(n²) < O(n³) < O(2ⁿ) < O(n!)
实际分析示例
对于算法 T(n) = an² + bn + c:
- 我们忽略低阶项 bn 和常数项 c,因为当 n 很大时,n² 项占主导。
- 我们忽略最高阶项 an² 的系数 a,因为不同计算机的常数因子不同,我们只关心增长趋势。
- 最终,我们将其渐进时间复杂度表示为 O(n²)。
常见的时间复杂度与查找算法对应(JavaScript 示例)
1. 常数级 O(1) —— 哈希表(对象 / Map)查找
const phoneBook = {
"Alice": "123-4567",
"Bob": "987-6543",
"Charlie": "555-5555"
};
function findPhoneNumber(name) {
return phoneBook[name];
}
console.log(findPhoneNumber("Alice"));
⚠️ 注意:这是平均情况下的 O(1),最坏情况(大量哈希冲突)可能退化为 O(n),但现代 JS 引擎优化良好,通常视为 O(1)。
2. 对数级 O(log n) —— 二分查找(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;
}
const sortedArr = [1, 3, 5, 7, 9, 11, 13];
console.log(binarySearch(sortedArr, 7));
时间复杂度:每次循环将搜索范围减半 → O(log n)
3. 线性级 O(n) —— 顺序查找(Linear Search)
适用场景:无序数组、链表等无法利用结构特性的数据。
function linearSearch(arr, target) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === target) {
return i;
}
}
return -1;
}
const unsortedArr = [10, 25, 3, 42, 17];
console.log(linearSearch(unsortedArr, 42));
4. 平方级 O(n²) —— 暴力组合查找
严格来说,没有主流的'查找算法'是 O(n²)。但某些暴力匹配问题可能表现为 O(n²) 查找,例如查找数组中是否存在两个数之和等于目标值(暴力解法)。
function twoSumBruteForce(nums, target) {
for (let i = 0; i < nums.length; i++) {
for (let j = i + 1; j < nums.length; j++) {
if (nums[i] + nums[j] === target) {
return [i, j];
}
}
}
return null;
}
const nums = [2, 7, 11, 15];
console.log(twoSumBruteForce(nums, 9));
这不是'单值查找',而是组合查找,时间复杂度为 O(n²)。更优解可用哈希表实现 O(n)。
| 复杂度 | 查找类型 | JS 示例 | 数据要求 |
|---|
| O(1) | 哈希查找 | obj[key] 或 map.get(key) | 键唯一,哈希良好 |
| O(log n) | 二分查找 | binarySearch() | 数组已排序 |
| O(n) | 顺序查找 | linearSearch() | 无序或任意结构 |
| O(n²) | 暴力组合查找 | twoSumBruteForce() | 非标准查找,慎用 |
3. 递归
阶乘函数(Factorial)是递归定义和递归实现的经典范例。
一、阶乘的递归数学定义
$$
n! = \begin{cases}
1, & \text{if } n = 0 \quad \text{(边界条件 / base case)} \
n \times (n-1)!, & \text{if } n > 0 \quad \text{(递归体 / recursive case)}
\end{cases}
$$
✅ 注意:定义域:$ n \in \mathbb{N}_0 $(非负整数,包括 0)。$ 0! = 1 $ 是约定,也是组合数学的基础。
二、递归结构解析
| 组成部分 | 说明 |
|---|
| 边界条件 | n === 0 时返回 1,防止无限递归 |
| 递归体 | 将问题规模从 n 缩小为 n - 1,并用子问题结果构造原问题解 |
| 自相似性 | 每次调用形式相同,只是输入变小 |
三、JavaScript 递归实现
function factorial(n) {
if (n === 0 || n === 1) {
return 1;
}
return n * factorial(n - 1);
}
console.log(factorial(0));
console.log(factorial(5));
💡 也可以只写 n === 0 作为边界,因为 1! = 1 × 0! = 1,但显式包含 n === 1 可略早终止。
四、执行过程示例(以 factorial(4) 为例)
factorial(4) └─ 4 * factorial(3) └─ 3 * factorial(2) └─ 2 * factorial(1) └─ 1 * factorial(0) └─ 1 ← 返回 → 1 * 1 = 1 → 2 * 1 = 2 → 3 * 2 = 6 → 4 * 6 = 24
- 递归深度:
n + 1 层(从 n 到 0)
- 调用栈:每次调用压入栈,返回时弹出
五、复杂度分析
✅ 时间复杂度:O(n)
- 共进行
n 次递归调用(从 n 到 1)
- 每次调用执行常数时间操作(乘法 + 函数调用)
- 总时间 ∝ n → T(n) = T(n−1) + O(1) ⇒ T(n) = O(n)
✅ 空间复杂度:O(n)
- 由于递归调用栈深度为
n,每层保存局部变量和返回地址
- 在 JavaScript 中,若
n 过大(如 > 10,000),会抛出 'Maximum call stack size exceeded' 错误
function factorialIter(n) {
let result = 1;
for (let i = 2; i <= n; i++) {
result *= i;
}
return result;
}
六、注意事项
大数问题:JavaScript 的 Number 类型在 n > 170 时会溢出(返回 Infinity),可改用 BigInt。
function factorialBig(n) {
if (n === 0n) return 1n;
return n * factorialBig(n - 1n);
}
console.log(factorialBig(100n));
if (n < 0 || !Number.isInteger(n)) throw new Error("n must be a non-negative integer");
七、递归展开法分析
分析递归算法时间复杂度的一种经典方法是建立递推关系式。
例 1:线性递归(阶乘)
function factorial(n) {
if (n <= 1) return 1;
return n * factorial(n - 1);
}
递推式:
展开:
T(n) = T(n-1) + c = [T(n-2) + c] + c = T(n-2) + 2c = ... = T(1) + (n-1)c = O(n)
例 2:二分递归(二分查找)
function binarySearch(arr, low, high, target) {
if (low > high) return -1;
const mid = Math.floor((low + high) / 2);
if (arr[mid] === target) return mid;
if (target < arr[mid]) return binarySearch(arr, low, mid - 1, target);
else return binarySearch(arr, mid + 1, high, target);
}
递推式:
展开:
T(n) = T(n/2) + c = T(n/4) + 2c = ... = T(1) + c log n = O(log n)
4. 分治法
分治法(Divide and Conquer)的核心思想是'分而治之':把一个复杂的大问题,递归地分解为若干个规模更小、结构相同、相互独立的子问题,分别求解后再合并结果。
一、分治法的基本思想
✅ 适用条件
- 可分解性:原问题能分解为若干个相同类型的子问题;
- 子问题独立:子问题之间没有重叠(这是与动态规划的关键区别);
- 可合并性:子问题的解能高效合并为原问题的解;
- 小问题可直接解:当问题规模足够小时(如 n=1),可直接求解。
二、分治法的三个标准步骤
| 步骤 | 说明 |
|---|
| 1. 分解(Divide) | 将原问题划分为若干个规模较小的子问题(通常等分) |
| 2. 求解(Conquer) | 递归求解每个子问题;若子问题足够小,直接求解(base case) |
| 3. 合并(Combine) | 将子问题的解组合成原问题的解 |
三、经典案例:归并排序(Merge Sort)
归并排序是分治法的教科书级应用,完美体现上述三步。
📌 问题描述
✅ 分治三步解析
| 步骤 | 归并排序中的具体操作 |
|---|
| 分解 | 将数组从中间分成左右两个子数组(各约 n/2 个元素) |
| 求解 | 递归地对左右子数组分别排序(直到子数组长度 ≤ 1) |
| 合并 | 将两个已排序的子数组合并(merge) 成一个有序数组 |
四、JavaScript 实现
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++]);
} else {
result.push(right[j++]);
}
}
return result.concat(left.slice(i)).concat(right.slice(j));
}
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);
const sortedLeft = mergeSort(left);
const sortedRight = mergeSort(right);
return merge(sortedLeft, sortedRight);
}
console.log(mergeSort([38, 27, 43, 3, 9, 82, 10]));
五、时间复杂度分析
使用递推式 + 主定理(Master Theorem):
- 分解:O(1)
- 求解:2 个子问题,每个规模为 n/2 →
2T(n/2)
- 合并:遍历全部 n 个元素 → O(n)
递推式:T(n) = 2T(n/2) + O(n)
根据主定理:a = 2, b = 2, f(n) = n → log_b(a) = 1 → f(n) = Θ(n^log_b(a)) → 情况 2
无论最好、最坏、平均情况,归并排序都是 O(n log n),非常稳定!
空间复杂度:O(n)(用于临时合并数组 + 递归栈深度 O(log n),但主要开销是合并时的辅助数组)
六、分治法 vs 动态规划 vs 贪心
| 方法 | 子问题关系 | 是否重复计算 | 典型问题 |
|---|
| 分治法 | 相互独立 | 否 | 归并排序、快速排序、二分查找 |
| 动态规划 | 高度重叠 | 是(需记忆化) | 背包问题、最长公共子序列 |
| 贪心算法 | 不一定有子问题 | 否(只做局部最优) | 活动选择、霍夫曼编码 |
✅ 记住:'独立子问题 → 分治;重叠子问题 → DP'
5. 动态规划
动态规划(Dynamic Programming, DP)的核心是重叠子问题和最优子结构,以及通过列表存储(记忆化)来避免重复计算的关键思想。
动态规划的设计步骤
- 定义状态:用一个数组(一维、二维或更高维)
dp[i] 或 dp[i][j] 来表示。参数定义了子问题的规模或范围。
- 建立状态转移方程:描述了如何从一个或多个已知的、规模较小的子问题的解,推导出当前规模更大的子问题的解。
- 确定初始条件和边界情况:明确最小的、不可再分的子问题的解,并正确地初始化状态数组。
- 确定计算顺序并计算:确保在计算
dp[当前状态] 时,它所依赖的 dp[子状态] 都已经被计算并存储好了。
- 构造最优解:有时我们不仅需要知道最优值,还需要知道如何得到它。
经典示例:斐波那契数列
问题:计算第 n 个斐波那契数 F(n),其中 F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2) (n>=2)。
- 状态定义:
dp[i] 表示 F(i) 的值。
- 状态转移方程:
dp[i] = dp[i-1] + dp[i-2]。
- 初始条件:
dp[0] = 0, dp[1] = 1。
- 计算顺序:自底向上,从
i=2 计算到 i=n。
动态规划解法(迭代法)
function fibDP(n) {
if (n <= 1) return n;
const dp = new Array(n + 1);
dp[0] = 0; dp[1] = 1;
for (let i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
console.log(fibDP(10));
console.log(fibDP(50));
空间优化版本
function fibOptimized(n) {
if (n <= 1) return n;
let prev1 = 1;
let prev2 = 0;
for (let i = 2; i <= n; i++) {
const current = prev1 + prev2;
prev2 = prev1;
prev1 = current;
}
return prev1;
}
console.log(fibOptimized(10));
案例 2:0/1 背包问题
问题描述:有 n 个物品,每个物品有重量 w 和价值 v,背包容量为 W。每个物品只能选择 0 个或 1 个,求不超过背包容量的最大价值。
function knapsack01(weights, values, capacity) {
const n = weights.length;
const dp = new Array(n + 1);
for (let i = 0; i <= n; i++) {
dp[i] = new Array(capacity + 1).fill(0);
}
for (let i = 1; i <= n; i++) {
for (let w = 1; w <= capacity; w++) {
const skip = dp[i - 1][w];
const currentWeight = weights[i - 1];
const currentValue = values[i - 1];
let take = 0;
if (currentWeight <= w) {
take = dp[i - 1][w - currentWeight] + currentValue;
}
dp[i][w] = Math.max(skip, take);
}
}
const maxValue = dp[n][capacity];
const selectedItems = [];
let w = capacity;
for (let i = n; i > 0; i--) {
if (dp[i][w] !== dp[i - 1][w]) {
selectedItems.push(i - 1);
w -= weights[i - 1];
}
}
return { maxValue, selectedItems: selectedItems.reverse() };
}
const weights = [2, 3, 4, 5];
const values = [3, 4, 5, 6];
const capacity = 8;
const result = knapsack01(weights, values, capacity);
console.log('最大价值:', result.maxValue);
console.log('选择的物品索引:', result.selectedItems);
为什么必须建整张表?
既然最终背包容量是 5,为什么还要算容量 0、1、2、3、4 的情况?直接算容量 5 不行吗?
答案是:不行!因为'容量 5 的最优解'依赖于'更小容量的最优解'。
想象你要知道'用前 2 个物品装满 5kg 背包最多值多少钱',你就必须知道:'如果我拿第 2 个物品(比如书,3kg),那剩下的 5−3=2kg 空间,用前 1 个物品(苹果)最多能值多少钱?'
这就必须提前知道'容量 2'时的答案!如果你只算容量 5,你就不知道'剩下 2kg 能装多少价值',也就无法判断'拿书是否划算'。
动态规划的每一步都站在前一步的肩膀上。容量 w 的解,依赖于所有小于 w 的解。所以,虽然你只关心容量 5,但容量 0~4 是通往答案的必经之路,缺一不可。
总结
通过这些 JavaScript 示例,我们可以看到动态规划的核心模式:
- 定义状态:明确
dp 数组的含义
- 状态转移方程:如何从已知状态推导新状态
- 初始条件:最小的子问题解
- 计算顺序:确保依赖的状态先被计算
- 构造最优解:如果需要具体方案
动态规划将指数级的时间复杂度优化到多项式级别,是解决最优化问题的强大工具。
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- Keycode 信息
查找任何按下的键的javascript键代码、代码、位置和修饰符。 在线工具,Keycode 信息在线工具,online
- Escape 与 Native 编解码
JavaScript 字符串转义/反转义;Java 风格 \uXXXX(Native2Ascii)编码与解码。 在线工具,Escape 与 Native 编解码在线工具,online
- JavaScript / HTML 格式化
使用 Prettier 在浏览器内格式化 JavaScript 或 HTML 片段。 在线工具,JavaScript / HTML 格式化在线工具,online
- JavaScript 压缩与混淆
Terser 压缩、变量名混淆,或 javascript-obfuscator 高强度混淆(体积会增大)。 在线工具,JavaScript 压缩与混淆在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online