编程算法学习——复杂度分析
个人使用AI辅助进行编程的算法学习记录(现在主学Java语言)
文章目录
一、复杂度分析
问题与动机
核心思想(一句话概括)
核心原理
分步骤详解
复杂度分析(时间、空间)
时间复杂度
常见复杂度(从优到劣)
空间复杂度
代码与实践
二,相关题目链接与思路
1.【LC 70. Climbing Stairs】:
问题与动机
核心思想
图解过程
核心原理
复杂度分析
代码与实践
方法一:暴力递归(不推荐)
方法二:记忆化递归
核心逻辑与优化原理:
相比暴力递归的优势:
方法三:动态规划(空间O(n))
核心逻辑与动态规划思想:
复杂度与优势:
方法四:动态规划(空间O(1))
核心逻辑与空间优化原理:
关键细节与优势:
2.【LC 509. Fibonacci Number】:
问题与动机
核心思想
复杂度分析对比
代码实现
最优解:动态规划(空间O(1))
核心逻辑与迭代过程说明:
以n=5为例的迭代过程:
优势与复杂度分析:
关键细节:
3. LeetCode 283. Move Zeroes:
问题与动机
核心思想
图解过程
复杂度分析
代码实现
方法一:双指针(推荐)
核心逻辑与执行过程:
以示例数组nums = [0, 1, 0, 3, 12]为例,执行过程如下:
优势与复杂度分析:
关键细节:
方法二:双指针(一次遍历,交换法)
核心逻辑与执行过程:
以示例数组nums = [0, 1, 0, 3, 12]为例,执行过程如下:
关键细节解析:
优势与复杂度分析:
边界情况验证:
三,排序算法分析
1.冒泡排序
核心思想:
复杂度分析
代码实现
核心原理与执行逻辑:
以示例数组arr = [3, 1, 4, 2]为例,执行过程如下:
关键细节解析:
复杂度与排序特性:
2.归并排序
核心思想
复杂度分析
代码实现
核心原理与执行流程:
以示例数组arr = [3, 1, 4, 2]为例,执行流程简化如下:
关键细节解析:
复杂度与排序特性:
3.快速排序
核心思想
复杂度分析
代码实现
核心原理与执行流程:
以示例数组arr = [3, 1, 4, 2]为例,分区与递归流程简化如下:
关键细节解析:
复杂度与排序特性:
总结
四,最终总结/费曼讲解
一、复杂度分析
问题与动机
-
问题:解决同一个问题,往往有多种算法。我们如何科学地、量化地评判哪个算法更好?
-
动机:
-
预测性能:在数据量很大时,一个“慢”的算法和一个“快”的算法带来的用户体验和系统成本是天壤之别。我们需要一种方式来预先估算算法的执行效率。
-
指导设计:复杂度分析为我们提供了设计高性能算法的理论方向。例如,当我们知道现有方案是O(n²),我们就会去寻找能否优化为O(n log n)。
-
抓住主要矛盾:它帮助我们忽略因编程语言、机器性能、临时状态等带来的微小波动,直接关注随着数据规模n增大时,算法运行时间和空间需求的增长趋势。这正是“ scalability ”(可扩展性)的核心。
-
核心思想(一句话概括)
“量级”比“精确值”更重要,关注数据规模n增长时,算法代价(时间/空间)的增长趋势。
图解过程
想象一下,随着数据量n的增大,不同复杂度算法的增长曲线:
-
X轴:数据规模 n
-
Y轴:执行时间 T(n) 或 占用空间 S(n)
-
关键观察:当n足够大时,O(1), O(log n), O(n)的算法依然高效,而O(n²), O(2ⁿ)的算法会变得无法接受。
核心原理
大O表示法
大O表示法描述了算法在最坏情况下,运行时间或所需空间的渐进上界。它刻画的是算法执行时间的增长级别。
定义:如果存在正常数c和n₀,使得对于所有的n ≥ n₀,有 T(n) ≤ c * f(n),那么我们就说 T(n) = O(f(n))。
通俗讲:当n很大时,算法的运行时间T(n)不会超过f(n)的某个常数倍。
分步骤详解
进行复杂度分析通常遵循以下步骤:
-
找出基本操作:在代码中,找出执行次数最多、与输入规模n最相关的操作(例如,循环内的比较、赋值、算术运算)。
-
计算执行次数T(n):建立一个函数,表示基本操作被执行了多少次。这个函数通常是关于输入规模n的表达式。
-
简化表达式,保留最高阶项:因为大O关注的是增长趋势。
-
忽略最高阶项的系数:因为系数在大O定义中由常数c所吸收。
示例分析:
// 示例1: O(n)
public int sum(int n) {int result = 0; // 1次for (int i = 1; i <= n; i++) { // i<=n 执行 n+1 次result += i; // 执行 n 次}return result; // 1次
}
// 总执行次数 T(n) = 1 + (n+1) + n + 1 = 2n + 3
// 简化后:O(n)// 示例2: O(n²)
public void printPairs(int n) {for (int i = 0; i < n; i++) { // 循环 n 次for (int j = 0; j < n; j++) { // 循环 n 次System.out.println(i + ", " + j); // 执行 n * n 次}}
}
// 总执行次数 T(n) = n * n = n²
// 简化后:O(n²)
复杂度分析(时间、空间)
时间复杂度
描述算法运行时间随数据规模增长的趋势。
常见复杂度(从优到劣)
| 大O表示 | 名称 | 举例 |
|---|---|---|
| O(1) | 常数时间 | 数组按索引访问、哈希表查找 |
| O(log n) | 对数时间 | 二分查找 |
| O(n) | 线性时间 | 遍历数组、链表 |
| O(n log n) | 线性对数时间 | 快速排序、归并排序(最优情况) |
| O(n²) | 平方时间 | 冒泡排序、选择排序、嵌套循环 |
| O(2ⁿ) | 指数时间 | 斐波那契数列的递归求法(未优化) |
| O(n!) | 阶乘时间 | 旅行商问题暴力求解 |
空间复杂度
描述算法临时占用的存储空间随数据规模增长的趋势。指的是除开原始数据本身所占空间外,算法运行所需辅助空间的大小。
常见情况:
-
O(1):算法执行时,所需的临时空间与n无关(如原地排序的交换操作)。
-
O(n):算法执行时,需要额外开辟一个与n成线性关系的空间(如归并排序的临时数组、遍历二叉树时递归调用的栈深度)。
代码与实践
易错点总结:
-
混淆最好、最坏、平均情况:
-
最好情况:算法在最理想输入下的复杂度。
-
最坏情况:算法在最差输入下的复杂度。大O表示法通常指最坏情况,因为它给出了一个性能保证。
-
平均情况:算法在所有可能输入下的期望复杂度。分析起来最复杂。
-
示例:快速排序在最坏情况下是O(n²)(输入已排序),但平均情况下是O(n log n)。
-
-
错误估算循环复杂度:
-
for (int i = 0; i < n; i *= 2)是 O(log n)。 -
for (int i = 0; i < n; i += 10)依然是 O(n),因为常数系数被忽略。
-
-
忽略递归的空间复杂度:
-
递归调用会在调用栈上占用空间。递归深度是多少,空间复杂度至少就是O(深度)。
-
int fib(int n) { if (n <= 1) return n; return fib(n-1) + fib(n-2); }的时间复杂度是恐怖的O(2ⁿ),但空间复杂度是O(n),因为最深的一条调用链是 n -> n-1 -> ... -> 1。
-
-
多个数据规模:如果函数有两个输入,比如处理一个 m x n 的矩阵,复杂度应表示为 O(m * n),而不能简单地说是 O(n²)。
二,相关题目链接与思路
1.【LC 70. Climbing Stairs】:
分析递归解法和动态规划解法的时间、空间复杂度。
问题与动机
问题:爬楼梯,每次可以爬1阶或2阶,问爬到第n阶有多少种不同的方法。
动机:这是动态规划的入门经典问题,能很好地展示递归、记忆化搜索和动态规划之间的关系。
核心思想
动态规划:将大问题分解为小问题,利用子问题的解构建原问题的解。
图解过程
n=1: [1] → 1种
n=2: [1,1], [2] → 2种
n=3: [1,1,1], [1,2], [2,1] → 3种
n=4: [1,1,1,1], [1,1,2], [1,2,1], [2,1,1], [2,2] → 5种规律:f(n) = f(n-1) + f(n-2)
核心原理
-
状态定义:dp[i] 表示爬到第i阶楼梯的方法数
-
状态转移:dp[i] = dp[i-1] + dp[i-2]
-
边界条件:dp[1] = 1, dp[2] = 2
复杂度分析
-
暴力递归:时间O(2ⁿ),空间O(n)(递归栈)
-
记忆化递归:时间O(n),空间O(n)
-
动态规划:时间O(n),空间O(n) 或 O(1)
代码与实践
方法一:暴力递归(不推荐)
/*** 计算爬到第n阶楼梯的不同方法数(每次可爬1阶或2阶)* 采用暴力递归思路,直接基于递推关系分解问题* @param n 目标楼梯阶数(输入为正整数)* @return 爬到第n阶的方法总数*/
public int climbStairs(int n) {// 边界条件1:当n=1时,只有1种方法(直接爬1阶)if (n == 1) return 1;// 边界条件2:当n=2时,有2种方法(1+1 或 直接2阶)if (n == 2) return 2;// 递归逻辑:爬到第n阶的方法数 = 爬到n-1阶的方法数(再爬1阶) + 爬到n-2阶的方法数(再爬2阶)// 例如n=3时,等价于"n=2的方法数(再爬1阶)" + "n=1的方法数(再爬2阶)",即2+1=3return climbStairs(n - 1) + climbStairs(n - 2);
}
易错点:大量重复计算,指数级时间复杂度。
方法二:记忆化递归
/*** 计算爬到第n阶楼梯的不同方法数(每次可爬1阶或2阶)* 采用记忆化递归思路,通过缓存子问题结果避免重复计算* @param n 目标楼梯阶数(输入为正整数)* @return 爬到第n阶的方法总数*/
public int climbStairs(int n) {// 创建记忆化数组memo,用于存储已计算的子问题结果// 大小为n+1:因为楼梯阶数从1到n,索引n需对应第n阶的结果int[] memo = new int[n + 1];// 调用递归辅助方法dfs,传入目标阶数n和记忆数组memoreturn dfs(n, memo);
}/*** 递归辅助方法:计算爬到第n阶的方法数,并利用memo缓存结果* @param n 当前需要计算的楼梯阶数* @param memo 记忆数组,memo[i]存储爬到第i阶的方法数(已计算过的结果)* @return 爬到第n阶的方法数*/
private int dfs(int n, int[] memo) {// 边界条件1:n=1时,只有1种方法(直接爬1阶)if (n == 1) return 1;// 边界条件2:n=2时,有2种方法(1+1 或 直接2阶)if (n == 2) return 2;// 记忆化检查:如果memo[n]不为0,说明该子问题已计算过,直接返回缓存结果// 避免重复递归计算,这是优化的核心if (memo[n] != 0) return memo[n];// 递归计算:若未缓存,则计算n-1和n-2阶的方法数之和// 并将结果存入memo[n],实现"计算一次,永久复用"memo[n] = dfs(n - 1, memo) + dfs(n - 2, memo);// 返回当前计算的结果(已存入memo[n])return memo[n];
}
核心逻辑与优化原理:
-
记忆化数组的作用:
memo数组是优化的核心,它像一个 "缓存器",存储已经计算过的dfs(n)结果。例如计算dfs(5)时,会先计算dfs(4)和dfs(3),而dfs(4)又依赖dfs(3)和dfs(2)—— 此时dfs(3)的结果会被存入memo[3],后续dfs(4)再用到dfs(3)时,直接从memo[3]取结果,无需重新递归计算。 -
递归流程示例(以 n=3 为例):
- 调用
dfs(3, memo),此时memo[3] = 0(未计算)。 - 递归计算
dfs(2, memo)(返回 2)和dfs(1, memo)(返回 1),求和得 3,存入memo[3]。 - 后续若其他子问题(如
dfs(4))需要dfs(3),直接返回memo[3] = 3,无需重复计算。
- 调用
相比暴力递归的优势:
- 时间复杂度优化:从暴力递归的
O(2ⁿ)(指数级)降至O(n)(线性级),因为每个子问题dfs(1)到dfs(n)仅计算一次。 - 空间复杂度:
O(n)(memo数组占用O(n)空间 + 递归栈深度O(n)),虽未优化空间,但解决了时间效率问题。
方法三:动态规划(空间O(n))
/*** 计算爬到第n阶楼梯的不同方法数(每次可爬1阶或2阶)* 采用动态规划(自底向上)思路,通过迭代计算子问题结果,构建最终解* @param n 目标楼梯阶数(输入为正整数)* @return 爬到第n阶的方法总数*/
public int climbStairs(int n) {// 边界条件处理:当n<=2时,直接返回n(n=1→1种,n=2→2种,无需后续计算)if (n <= 2) return n;// 创建dp数组:dp[i]表示爬到第i阶楼梯的方法数// 数组大小为n+1:因为楼梯阶数从1到n,索引i需要覆盖1~n(避免索引越界)int[] dp = new int[n + 1];// 初始化基础子问题的解(边界状态)dp[1] = 1; // 第1阶:只有1种方法(直接爬1阶)dp[2] = 2; // 第2阶:有2种方法(1+1 或 直接2阶)// 从第3阶开始,迭代计算每个子问题的解(自底向上构建)// 核心逻辑:第i阶的方法数 = 第i-1阶的方法数(再爬1阶) + 第i-2阶的方法数(再爬2阶)for (int i = 3; i <= n; i++) {dp[i] = dp[i - 1] + dp[i - 2];}// dp[n]即为爬到第n阶的总方法数return dp[n];
}
核心逻辑与动态规划思想:
-
状态定义:
dp[i]明确表示 “爬到第 i 阶楼梯的方法数”,将原问题(求dp[n])分解为更小的子问题(求dp[1]到dp[n-1])。 -
状态转移方程:对于
i >= 3,dp[i] = dp[i-1] + dp[i-2]。逻辑依据:到达第 i 阶的最后一步只能是 “从 i-1 阶爬 1 阶” 或 “从 i-2 阶爬 2 阶”,因此总方法数是这两种情况的和。 -
自底向上计算:与递归(自顶向下,从
n分解到基础问题)不同,这里从最小的基础问题(dp[1]、dp[2])出发,逐步计算更大的子问题(dp[3]、dp[4]……dp[n]),最终得到原问题的解。这种迭代方式避免了递归的栈开销,且计算过程更直接。
复杂度与优势:
- 时间复杂度:
O(n),只需遍历一次从 3 到 n 的整数,每个dp[i]的计算都是常数时间。 - 空间复杂度:
O(n),主要来自存储子问题结果的dp数组(相比暴力递归,时间效率极大提升;相比记忆化递归,避免了递归栈的潜在风险)。
方法四:动态规划(空间O(1))
/*** 计算爬到第n阶楼梯的不同方法数(每次可爬1阶或2阶)* 采用动态规划的空间优化版本,通过滚动变量替代数组,进一步降低空间复杂度* @param n 目标楼梯阶数(输入为正整数)* @return 爬到第n阶的方法总数*/
public int climbStairs(int n) {// 边界条件处理:n<=2时直接返回n(n=1→1种,n=2→2种,无需后续计算)if (n <= 2) return n;// 定义滚动变量,替代原dp数组的功能:// first 代表 "第i-2阶的方法数"(初始对应dp[1] = 1)// second 代表 "第i-1阶的方法数"(初始对应dp[2] = 2)int first = 1, second = 2;// 从第3阶开始迭代计算,直到第n阶// 核心逻辑:第i阶的方法数 = 第i-1阶的方法数 + 第i-2阶的方法数for (int i = 3; i <= n; i++) {// 计算当前第i阶的方法数(等价于dp[i] = dp[i-1] + dp[i-2])int third = first + second;// 更新滚动变量:为下一次迭代做准备// 下一次循环中,"第i-2阶"会变成当前的"第i-1阶"(即second的值)first = second;// 下一次循环中,"第i-1阶"会变成当前计算的"第i阶"(即third的值)second = third;}// 循环结束后,second恰好对应第n阶的方法数(等价于原dp[n])return second;
}
核心逻辑与空间优化原理:
-
为什么可以优化空间?原动态规划(数组版)中,
dp[i]的计算只依赖前两个值dp[i-1]和dp[i-2],无需存储整个dp数组的所有历史值。因此可以用三个变量(first、second、third)滚动更新,替代数组存储,将空间复杂度从O(n)降至O(1)。 -
变量含义与迭代过程(以 n=5 为例):
- 初始状态(i=3 前):
first=1(dp[1]),second=2(dp[2])。 - i=3 时:
third = 1+2=3(dp [3]),更新后first=2(dp[2]),second=3(dp[3])。 - i=4 时:
third = 2+3=5(dp [4]),更新后first=3(dp[3]),second=5(dp[4])。 - i=5 时:
third = 3+5=8(dp [5]),更新后first=5(dp[4]),second=8(dp[5])。 - 循环结束,返回
second=8(即 dp [5] 的结果)。
- 初始状态(i=3 前):
关键细节与优势:
- 变量更新顺序:必须先更新
first = second,再更新second = third。若顺序颠倒,second会被提前覆盖,导致下一次计算丢失正确的dp[i-1]值。 - 适用场景:当 n 很大(如 n=10⁴或更大)时,相比数组版能显著节省内存,且时间复杂度仍保持
O(n)(与数组版一致)。
2.【LC 509. Fibonacci Number】:
同上,是分析时间/空间复杂度的经典案例。
问题与动机
问题:计算第n个斐波那契数。
动机:与爬楼梯问题本质相同,是理解递归和动态规划的绝佳例子。
核心思想
递推关系:F(n) = F(n-1) + F(n-2)
复杂度分析对比
| 方法 | 时间复杂度 | 空间复杂度 | 说明 |
|---|---|---|---|
| 暴力递归 | O(2ⁿ) | O(n) | 大量重复计算 |
| 记忆化递归 | O(n) | O(n) | 存储已计算结果 |
| 动态规划 | O(n) | O(n) | 自底向上填表 |
| 优化DP | O(n) | O(1) | 只保留前两个状态 |
代码实现
最优解:动态规划(空间O(1))
/*** 计算斐波那契数列的第n项(斐波那契定义:F(0)=0, F(1)=1, F(n)=F(n-1)+F(n-2) for n≥2)* 采用迭代法+空间优化,通过滚动变量存储前两项结果,高效计算目标值* @param n 目标项的索引(非负整数)* @return 斐波那契数列的第n项值*/
public int fib(int n) {// 边界条件处理:当n≤1时,直接返回n(符合F(0)=0,F(1)=1的定义)if (n <= 1) return n;// 定义滚动变量,存储计算过程中需要的前两项结果:// prev1 代表 "第i-2项的斐波那契值"(初始对应F(0)=0)// prev2 代表 "第i-1项的斐波那契值"(初始对应F(1)=1)int prev1 = 0, prev2 = 1;// 从第2项开始迭代计算,直到第n项(i表示当前计算的项的索引)for (int i = 2; i <= n; i++) {// 计算当前第i项的值:F(i) = F(i-1) + F(i-2),即prev2 + prev1int current = prev1 + prev2;// 更新滚动变量,为下一次迭代(计算i+1项)做准备:// 下一次的"第i-2项"就是当前的"第i-1项"(即prev2的值)prev1 = prev2;// 下一次的"第i-1项"就是当前计算的"第i项"(即current的值)prev2 = current;}// 循环结束后,prev2恰好存储第n项的斐波那契值(F(n))return prev2;
}
核心逻辑与迭代过程说明:
斐波那契数列的定义是递推关系:对于n≥2,F(n) = F(n-1) + F(n-2),且基础项为F(0)=0、F(1)=1。这段代码通过迭代法从基础项开始,逐步计算到目标项n,并通过滚动变量优化空间。
以n=5为例的迭代过程:
| 循环次数(i) | prev1(F(i-2)) | prev2(F(i-1)) | current(F(i)) | 迭代后 prev1 | 迭代后 prev2 |
|---|---|---|---|---|---|
| 初始状态 | 0(F(0)) | 1(F(1)) | - | - | - |
| i=2 | 0 | 1 | 0+1=1(F(2)) | 1(F(1)) | 1(F(2)) |
| i=3 | 1(F(1)) | 1(F(2)) | 1+1=2(F(3)) | 1(F(2)) | 2(F(3)) |
| i=4 | 1(F(2)) | 2(F(3)) | 1+2=3(F(4)) | 2(F(3)) | 3(F(4)) |
| i=5 | 2(F(3)) | 3(F(4)) | 2+3=5(F(5)) | 3(F(4)) | 5(F(5)) |
循环结束后,prev2=5,即F(5)=5,符合斐波那契数列定义。
优势与复杂度分析:
- 时间复杂度:
O(n),只需从 2 到 n 迭代一次,每次迭代为常数时间操作。 - 空间复杂度:
O(1),仅使用 3 个变量(prev1、prev2、current)存储中间结果,相比 “用数组存储所有前 n 项” 的方法(空间O(n)),极大节省了内存。 - 适用性:适合计算较大的
n(如n=10^6),既避免了递归的栈溢出问题,又解决了朴素迭代的空间浪费问题。
关键细节:
- 变量更新顺序不可颠倒:必须先更新
prev1 = prev2,再更新prev2 = current。若先更新prev2,会导致prev1无法正确获取上一轮的prev2值。 - 边界条件覆盖完整:
n=0返回 0,n=1返回 1,无需进入循环,直接处理,逻辑严谨。
3. LeetCode 283. Move Zeroes:
问题与动机
问题:将数组中的所有0移动到末尾,保持非零元素的相对顺序。
动机:考察双指针技巧,是数组操作的基础。
核心思想
双指针:一个指针遍历数组,另一个指针指向下一个非零元素应该放置的位置。
图解过程
初始: [0, 1, 0, 3, 12]
↑ ↑
i j步骤1: [1, 1, 0, 3, 12] // 把j位置的1移到i位置
↑ ↑
i j步骤2: [1, 3, 0, 3, 12] // 把j位置的3移到i位置
↑ ↑
i j步骤3: [1, 3, 12, 3, 12] // 把j位置的12移到i位置
↑ ↑
i j最后: [1, 3, 12, 0, 0] // 将i之后的位置置0
复杂度分析
-
时间复杂度:O(n),遍历数组一次
-
空间复杂度:O(1),原地操作
代码实现
方法一:双指针(推荐)
/*** 将数组中的所有0元素移动到数组末尾,同时保持非零元素的相对顺序* 采用双遍历原地操作,时间效率高且不使用额外空间* @param nums 输入的整数数组(会直接修改原数组)*/
public void moveZeroes(int[] nums) {// 定义非零元素的放置指针:nonZeroIndex表示"下一个非零元素应该存放的位置"// 初始值为0,因为数组第一个位置应该放第一个非零元素int nonZeroIndex = 0;// 第一次遍历:筛选所有非零元素,按原顺序移到数组前端// 遍历整个数组,找到所有非零元素并"向前紧凑排列"for (int i = 0; i < nums.length; i++) {// 若当前元素是非零元素,需要放到nonZeroIndex指向的位置if (nums[i] != 0) {// 将非零元素放到目标位置,然后指针后移(准备放下一个非零元素)nums[nonZeroIndex++] = nums[i];}// 若当前元素是0,不做处理,继续遍历下一个元素}// 第二次遍历:将数组剩余位置(从nonZeroIndex到末尾)填充为0// 此时nonZeroIndex之前的位置已全是非零元素,剩余位置原本可能是0或被覆盖的元素,需统一设为0for (int i = nonZeroIndex; i < nums.length; i++) {nums[i] = 0;}
}
核心逻辑与执行过程:
这段代码的核心思路是 **“分两步处理”**:先把所有非零元素按原顺序移到数组前端,再把剩下的位置全部填充为 0,从而实现 “零元素移到末尾且非零元素顺序不变” 的目标。
以示例数组nums = [0, 1, 0, 3, 12]为例,执行过程如下:
-
第一次遍历(处理非零元素):
i=0:元素是 0,不处理,nonZeroIndex仍为 0。i=1:元素是 1(非零),执行nums[0] = 1,nonZeroIndex变为 1。i=2:元素是 0,不处理,nonZeroIndex仍为 1。i=3:元素是 3(非零),执行nums[1] = 3,nonZeroIndex变为 2。i=4:元素是 12(非零),执行nums[2] = 12,nonZeroIndex变为 3。此时数组变为[1, 3, 12, 3, 12](前 3 个位置是非零元素,后 2 个位置待处理)。
-
第二次遍历(填充 0):
- 从
i=3到i=4(数组长度为 5),将这两个位置设为 0。最终数组变为[1, 3, 12, 0, 0],符合预期。
- 从
优势与复杂度分析:
- 时间复杂度:
O(n),其中n是数组长度。两次遍历数组,每次遍历都是线性时间,总时间为O(n) + O(n) = O(n)。 - 空间复杂度:
O(1),仅使用了nonZeroIndex和i两个额外变量,属于原地操作,不消耗额外空间(相比 “新建数组存储非零元素再填充 0” 的方法,节省了O(n)的空间)。 - 保持相对顺序:由于第一次遍历是按原数组顺序处理非零元素,因此非零元素的相对位置与原数组一致,符合题目要求。
关键细节:
nonZeroIndex的作用:始终指向 “下一个非零元素的存放位置”,确保非零元素按顺序紧凑排列。- 两次遍历的必要性:第一次遍历无法直接处理 “原非零元素位置变为 0” 的问题(会覆盖后续元素),因此需要第二次遍历统一填充 0。
- 边界情况处理:
- 若数组中没有 0:第一次遍历后
nonZeroIndex = nums.length,第二次遍历不执行,数组不变(正确)。 - 若数组全是 0:第一次遍历不执行(
nonZeroIndex=0),第二次遍历将所有元素设为 0(正确)。
- 若数组中没有 0:第一次遍历后
方法二:双指针(一次遍历,交换法)
/*** 将数组中的所有0元素移动到数组末尾,同时保持非零元素的相对顺序* 采用单遍历+原地交换策略,相比双遍历更高效,仅需一次遍历完成操作* @param nums 输入的整数数组(直接修改原数组)*/
public void moveZeroes(int[] nums) {// 非零元素放置指针:指向"下一个非零元素应该存放的位置"// 初始为0,即数组首个位置应存放第一个非零元素int nonZeroIndex = 0;// 遍历整个数组,通过交换将非零元素逐步移到前面,零元素自然被推到后面for (int i = 0; i < nums.length; i++) {// 若当前元素是非零元素,需要将其放到nonZeroIndex指向的位置if (nums[i] != 0) {// 交换nums[nonZeroIndex]和nums[i]:// - 把当前非零元素nums[i]放到目标位置nonZeroIndex// - 把原nonZeroIndex位置的元素(可能是0,也可能是已处理的非零元素)放到i位置int temp = nums[nonZeroIndex];nums[nonZeroIndex] = nums[i];nums[i] = temp;// 非零元素放置指针后移,准备接收下一个非零元素nonZeroIndex++;}// 若当前元素是0,不做处理,继续遍历(零元素会被后续非零元素交换到后面)}
}
核心逻辑与执行过程:
这段代码的核心是 “单遍历 + 交换”:通过一次遍历,将每个非零元素与 “下一个非零元素应在的位置”(nonZeroIndex)的元素交换,从而让非零元素逐步 “挤” 到数组前端,零元素自然被 “推” 到后端,同时保证非零元素的相对顺序不变。
以示例数组nums = [0, 1, 0, 3, 12]为例,执行过程如下:
| 循环变量 i | 当前元素 nums [i] | 操作逻辑(nums [i]≠0) | 交换后数组 | nonZeroIndex 变化 |
|---|---|---|---|---|
| 初始状态 | - | - | [0,1,0,3,12] | 0 |
| i=0 | 0(跳过) | 不进入 if | 不变 | 仍为 0 |
| i=1 | 1(非零) | 交换 nums [0] 和 nums [1] | [1,0,0,3,12] | 从 0→1 |
| i=2 | 0(跳过) | 不进入 if | 不变 | 仍为 1 |
| i=3 | 3(非零) | 交换 nums [1] 和 nums [3] | [1,3,0,0,12] | 从 1→2 |
| i=4 | 12(非零) | 交换 nums [2] 和 nums [4] | [1,3,12,0,0] | 从 2→3 |
循环结束后,数组变为[1,3,12,0,0],符合 “零在末尾,非零顺序不变” 的要求。
关键细节解析:
-
交换的作用:当
nums[i]是非零元素时,nonZeroIndex指向 “它应该在的位置”。交换后:nums[nonZeroIndex]被更新为当前非零元素(实现 “非零元素前移”);- 原
nums[nonZeroIndex]的元素(可能是 0,也可能是之前已处理的非零元素)被放到i位置。若原元素是 0,相当于 “将 0 后移”;若原元素是非零(仅当nonZeroIndex == i时,即非零元素已在正确位置),交换后数组不变(自交换,无意义但不影响结果)。
-
为什么能保持非零元素顺序?遍历是按数组顺序从左到右进行的,
nonZeroIndex始终≤i(因为每次交换后nonZeroIndex才 + 1)。非零元素按遍历顺序被交换到nonZeroIndex位置,因此相对顺序与原数组一致。 -
零元素的处理:零元素不会触发交换,随着
i增大,nonZeroIndex会跳过零元素指向更靠后的位置,后续非零元素与nonZeroIndex(此时指向零元素位置)交换时,零元素会被 “推” 到i位置,最终聚集在数组末尾。
优势与复杂度分析:
- 时间复杂度:
O(n),仅需一次遍历数组(n为数组长度),每次交换是常数时间操作,总时间线性。 - 空间复杂度:
O(1),仅使用nonZeroIndex、i、temp三个额外变量,属于原地操作,无额外空间消耗。 - 相比双遍历的优化:减少了一次遍历(无需单独填充 0),在大数据量下效率更高,逻辑更紧凑。
边界情况验证:
- 数组无零(如
[1,2,3]):每次i与nonZeroIndex相等,交换后数组不变,最终结果正确。 - 数组全是零(如
[0,0,0]):循环中不执行交换,数组不变,正确。 - 非零元素在开头(如
[1,0,2]):i=0时交换自己,nonZeroIndex变为 1;i=1是 0 跳过;i=2交换nums[1]和nums[2],结果[1,2,0],正确。
三,排序算法分析
1.冒泡排序
核心思想:
相邻比较交换:重复遍历数组,比较相邻元素,如果顺序错误就交换。
复杂度分析
-
时间复杂度:O(n²) - 最坏和平均情况
-
空间复杂度:O(1) - 原地排序
代码实现
/*** 对整数数组进行冒泡排序(升序)* 核心思想:通过相邻元素的比较和交换,将最大元素逐步"冒泡"到数组末尾,重复此过程直至整个数组有序* @param arr 待排序的整数数组(原地排序,直接修改原数组)*/
public void bubbleSort(int[] arr) {// 获取数组长度,用于控制循环范围int n = arr.length;// 外循环:控制排序的轮数(最多需要n-1轮)// 每轮会将当前未排序部分的最大元素"冒泡"到末尾,因此经过i轮后,末尾i个元素已确定for (int i = 0; i < n - 1; i++) {// 优化标记:记录当前轮是否发生过元素交换// 若一轮结束后未交换,说明数组已完全有序,可提前退出boolean swapped = false;// 内循环:负责每轮中相邻元素的比较和交换// 范围是[0, n-1-i):因为末尾i个元素已排序,无需再比较for (int j = 0; j < n - 1 - i; j++) {// 若当前元素大于下一个元素,说明顺序错误,需要交换if (arr[j] > arr[j + 1]) {// 交换arr[j]和arr[j+1](相邻元素交换)int temp = arr[j];arr[j] = arr[j + 1];arr[j + 1] = temp;// 标记本轮发生了交换swapped = true;}}// 若本轮未发生任何交换,说明数组已完全有序,直接退出外循环(优化点)if (!swapped) break;}
}
核心原理与执行逻辑:
冒泡排序的核心是 **“逐步上浮最大元素”**:每一轮通过相邻元素的比较和交换,将当前未排序部分的最大元素 “推” 到该部分的末尾(即数组的最终位置)。经过n-1轮后,所有元素都会被放到正确位置(最后一个元素无需单独处理)。
以示例数组arr = [3, 1, 4, 2]为例,执行过程如下:
| 外循环轮次(i) | 内循环范围(j) | 本轮操作与数组变化 | swapped | 结束后数组状态 |
|---|---|---|---|---|
| 初始状态 | - | - | - | [3, 1, 4, 2] |
| i=0(第 1 轮) | j=0~2(n-1-i=3) | j=0:3>1→交换→[1,3,4,2]; j=1:3<4→不交换; j=2:4>2→交换→[1,3,2,4] | true | [1, 3, 2, 4](末尾 4 已确定) |
| i=1(第 2 轮) | j=0~1(n-1-i=2) | j=0:1<3→不交换; j=1:3>2→交换→[1,2,3,4] | true | [1, 2, 3, 4](末尾 3、4 已确定) |
| i=2(第 3 轮) | j=0~0(n-1-i=1) | j=0:1<2→不交换 | false | [1, 2, 3, 4](数组已有序,提前退出) |
关键细节解析:
-
外循环轮数(i 的范围):最多需要
n-1轮,因为每轮确定一个元素的最终位置,n个元素需要n-1轮即可全部确定(最后一个元素自然有序)。 -
内循环范围(j 的范围):每轮内循环的范围是
[0, n-1-i),原因是:经过i轮排序后,数组末尾的i个元素已经是最大的i个元素(已有序),无需再参与比较,因此内循环的终点每次减少i,提高效率。 -
优化点(swapped 变量):若某一轮没有发生任何元素交换,说明数组中所有相邻元素都已满足
arr[j] <= arr[j+1],即数组已完全有序,此时可直接退出外循环,避免后续无意义的比较(例如已排序数组[1,2,3,4],第一轮就无交换,直接结束,时间复杂度从O(n²)降至O(n))。 -
交换逻辑:仅当
arr[j] > arr[j+1]时交换相邻元素,保证排序后为升序;若为降序,只需将条件改为arr[j] < arr[j+1]。
复杂度与排序特性:
-
时间复杂度:
- 最坏情况(完全逆序):
O(n²)(需n-1轮,每轮最多n-i次比较和交换)。 - 最好情况(已排序):
O(n)(仅 1 轮,无交换,直接退出)。 - 平均情况:
O(n²)。
- 最坏情况(完全逆序):
-
空间复杂度:
O(1),仅使用i、j、temp、swapped等常数级变量,属于原地排序。 -
稳定性:稳定排序。因为当
arr[j] == arr[j+1]时不发生交换,相等元素的相对顺序不会改变。
2.归并排序
核心思想
分治法:将数组分成两半,分别排序,然后合并。
复杂度分析
-
时间复杂度:O(n log n) - 所有情况
-
空间复杂度:O(n) - 需要临时数组
代码实现
/*** 对整数数组进行归并排序(升序)* 核心思想:分治法(Divide and Conquer)——将数组递归分解为子数组,排序后合并为有序数组* @param arr 待排序的整数数组(原地排序,通过临时数组辅助合并)*/
public void mergeSort(int[] arr) {// 边界条件:若数组长度≤1,本身就是有序的,直接返回if (arr.length <= 1) return;// 创建临时数组temp,用于合并阶段存储中间结果(避免频繁创建数组,优化性能)int[] temp = new int[arr.length];// 调用递归方法,开始分治排序(初始范围为整个数组:左边界0,右边界arr.length-1)mergeSort(arr, 0, arr.length - 1, temp);
}/*** 归并排序的递归核心方法:将数组[left, right]范围的元素排序* @param arr 待排序的原数组* @param left 当前子数组的左边界(闭区间)* @param right 当前子数组的右边界(闭区间)* @param temp 临时数组,用于合并阶段存储中间结果*/
private void mergeSort(int[] arr, int left, int right, int[] temp) {// 递归终止条件:若左边界≥右边界,说明子数组长度为0或1(已有序),直接返回if (left >= right) return;// 计算中间位置mid,将当前数组分为[left, mid]和[mid+1, right]两个子数组// 用left + (right - left)/2 替代 (left + right)/2,避免left+right过大导致整数溢出int mid = left + (right - left) / 2;// 分治步骤1:递归排序左半部分子数组[left, mid]mergeSort(arr, left, mid, temp);// 分治步骤2:递归排序右半部分子数组[mid+1, right]mergeSort(arr, mid + 1, right, temp);// 分治步骤3:将两个已排序的子数组[left, mid]和[mid+1, right]合并为一个有序数组merge(arr, left, mid, right, temp);
}/*** 合并两个已排序的子数组:将[left, mid]和[mid+1, right]合并为有序的[left, right]* @param arr 原数组* @param left 左子数组的左边界* @param mid 左子数组的右边界(右子数组的左边界为mid+1)* @param right 右子数组的右边界* @param temp 临时数组,用于存储合并后的结果*/
private void merge(int[] arr, int left, int mid, int right, int[] temp) {// 定义指针:// i:左子数组的起始索引(初始为left)// j:右子数组的起始索引(初始为mid+1)// k:临时数组temp中当前要填充的位置(初始为left,与原数组的起始位置对齐)int i = left, j = mid + 1, k = left;// 第一阶段:同时遍历左右两个子数组,比较元素大小,将较小的元素放入temp// 直到其中一个子数组遍历完毕while (i <= mid && j <= right) {// 若左子数组当前元素≤右子数组当前元素,取左元素放入temp,左指针后移if (arr[i] <= arr[j]) {temp[k++] = arr[i++];} else {// 否则取右元素放入temp,右指针后移temp[k++] = arr[j++];}}// 第二阶段:处理左子数组的剩余元素(若左子数组未遍历完)while (i <= mid) {temp[k++] = arr[i++];}// 第二阶段:处理右子数组的剩余元素(若右子数组未遍历完)while (j <= right) {temp[k++] = arr[j++];}// 第三阶段:将temp中合并好的有序部分([left, right])拷贝回原数组arr的对应位置// 实现原数组的原地更新System.arraycopy(temp, left, arr, left, right - left + 1);
}
核心原理与执行流程:
归并排序是 “分治法” 的经典实现,整个过程分为分解(Divide)、解决(Conquer)、合并(Merge) 三个步骤:
- 分解:将原数组递归拆分为两个等长(或近似等长)的子数组,直到子数组长度为 1(天然有序)。
- 解决:对子数组递归排序(长度为 1 时直接返回)。
- 合并:将两个已排序的子数组合并为一个有序数组,这是归并排序的核心步骤。
以示例数组arr = [3, 1, 4, 2]为例,执行流程简化如下:
-
分解阶段:原数组
[3,1,4,2]→ 拆分为[3,1]和[4,2];[3,1]拆分为[3]和[1];[4,2]拆分为[4]和[2](子数组长度为 1,停止分解)。 -
合并阶段:
- 合并
[3]和[1]→ 有序子数组[1,3]; - 合并
[4]和[2]→ 有序子数组[2,4]; - 合并
[1,3]和[2,4]→ 最终有序数组[1,2,3,4]。
- 合并
关键细节解析:
-
临时数组
temp的作用:合并两个子数组时,若直接在原数组上操作会覆盖未处理的元素,因此需要临时数组暂存合并结果,最后再拷贝回原数组。temp在主方法中创建一次,避免递归中频繁创建数组,优化内存开销。 -
mid的计算方式:用left + (right - left)/2而非(left + right)/2,是为了防止left + right的值超过整数最大值(Integer.MAX_VALUE)导致溢出(例如left=1e9,right=1e9时,left+right会溢出,而left + (right-left)/2不会)。 -
合并的稳定性:合并时判断条件为
arr[i] <= arr[j](而非<),当左右元素相等时优先取左子数组的元素,保证相等元素的相对顺序与原数组一致,因此归并排序是稳定排序。 -
递归终止条件:
left >= right表示子数组长度为 0 或 1(已无需排序),直接返回,避免无限递归。
复杂度与排序特性:
-
时间复杂度:
O(n log n)。分解阶段:数组被拆分为log n层(每一层子数组总数是n);合并阶段:每一层合并的总操作数是O(n);总时间 = 层数 × 每层操作数 =O(log n) × O(n) = O(n log n)。 -
空间复杂度:
O(n)。主要来自临时数组temp(大小为n),递归栈的深度为O(log n),可忽略。 -
适用场景:适合大规模数据排序(时间复杂度稳定为
O(n log n)),尤其适合链表排序(无需额外空间存储临时数组,通过指针调整即可合并)。
3.快速排序
核心思想
分治法 + 分区:选择一个基准元素,将数组分成小于基准和大于基准的两部分,递归排序。
复杂度分析
-
时间复杂度:平均O(n log n),最坏O(n²)
-
空间复杂度:平均O(log n),最坏O(n) - 递归栈
代码实现
/*** 对整数数组进行快速排序(升序)* 核心思想:分治法——通过选择基准元素将数组分区,递归排序分区后的子数组* @param arr 待排序的整数数组(原地排序,直接修改原数组)*/
public void quickSort(int[] arr) {// 调用重载的递归方法,初始排序范围为整个数组(左边界0,右边界arr.length-1)quickSort(arr, 0, arr.length - 1);
}/*** 快速排序的递归核心方法:对数组[low, high]范围的元素进行排序* @param arr 待排序的原数组* @param low 当前子数组的左边界(闭区间)* @param high 当前子数组的右边界(闭区间)*/
private void quickSort(int[] arr, int low, int high) {// 递归终止条件:若low >= high,说明子数组长度为0或1(已有序),无需排序if (low < high) {// 分区操作:将[low, high]分为两部分,返回基准元素的最终位置pivotIndex// 分区后,基准左侧元素均<=基准,右侧元素均>基准int pivotIndex = partition(arr, low, high);// 递归排序基准左侧的子数组[low, pivotIndex-1]quickSort(arr, low, pivotIndex - 1);// 递归排序基准右侧的子数组[pivotIndex+1, high]quickSort(arr, pivotIndex + 1, high);}
}/*** 分区操作:选择基准元素,将数组[low, high]划分为两部分,左侧<=基准,右侧>基准* @param arr 原数组* @param low 子数组左边界* @param high 子数组右边界* @return 基准元素在数组中的最终位置索引*/
private int partition(int[] arr, int low, int high) {// 选择最右侧元素作为基准(pivot),这是常见的选择策略(也可选择其他位置)int pivot = arr[high];// i:指向"小于等于基准区域"的最后一个元素(初始为low-1,代表该区域暂为空)// 作用:标记当前已确定的、小于等于基准的元素范围int i = low - 1;// j:遍历整个待分区的子数组(从low到high-1,因为high是基准位置)// 目的:找到所有小于等于基准的元素,放入i标记的区域for (int j = low; j < high; j++) {// 若当前元素arr[j] <= 基准,说明它属于左侧区域if (arr[j] <= pivot) {i++; // 扩大"小于等于基准区域"的范围(i后移)swap(arr, i, j); // 将arr[j]交换到左侧区域的末尾(i的位置)}// 若arr[j] > 基准,不处理,继续遍历(自然属于右侧区域)}// 遍历结束后,i+1的位置是基准元素的正确位置(左侧均<=基准,右侧均>基准)// 交换i+1和high(基准原来的位置),将基准放到最终位置swap(arr, i + 1, high);// 返回基准的索引,用于后续递归划分左右子数组return i + 1;
}/*** 辅助方法:交换数组中两个索引位置的元素* @param arr 原数组* @param i 第一个元素的索引* @param j 第二个元素的索引*/
private void swap(int[] arr, int i, int j) {int temp = arr[i];arr[i] = arr[j];arr[j] = temp;
}
核心原理与执行流程:
快速排序是 “分治法” 的经典实现,核心步骤为选择基准(Pivot)、分区(Partition)、递归排序:
- 选择基准:从当前子数组中选一个元素作为基准(这里选择最右侧元素)。
- 分区:将子数组划分为两部分,左侧元素均≤基准,右侧元素均 > 基准,基准被放到最终正确的位置。
- 递归排序:对基准左侧和右侧的子数组重复上述步骤,直至子数组有序。
以示例数组arr = [3, 1, 4, 2]为例,分区与递归流程简化如下:
- 初始调用:
quickSort(arr, 0, 3)(整个数组[3,1,4,2])。 - 第一次分区:
- 基准
pivot = arr[3] = 2,i = -1(初始)。 j=0:arr[0]=3 > 2→不处理;j=1:arr[1]=1 <= 2→i=0,交换arr[0]和arr[1]→数组变为[1,3,4,2];j=2:arr[2]=4 > 2→不处理;- 遍历结束,交换
i+1=1和high=3→数组变为[1,2,4,3],基准2的索引为1(pivotIndex=1)。 - 递归排序左侧:
quickSort(arr, 0, 0)(子数组[1],已无需排序)。 - 递归排序右侧:
quickSort(arr, 2, 3)(子数组[4,3])。- 分区:基准
pivot=3,i=1,j=2:arr[2]=4 > 3→不处理;交换i+1=2和high=3→数组变为[1,2,3,4],基准3的索引为2。 - 递归排序左侧
[2,1](无效范围)和右侧[3,3](已排序)。
- 分区:基准
- 最终数组有序:
[1,2,3,4]。
关键细节解析:
-
基准的选择:代码中选择最右侧元素作为基准(
pivot = arr[high]),这是最简单的策略。实际中还可选择随机元素、中间元素等,以避免极端情况(如已排序数组)下的性能退化。 -
分区中
i和j的作用:j是遍历指针,负责扫描所有待分区元素(除基准外)。i是边界指针,标记 “小于等于基准区域” 的右边界。当arr[j] <= pivot时,i右移并交换arr[i]与arr[j],确保i左侧(含i)的元素均≤基准。
-
基准的最终位置:遍历结束后,
i+1是基准的正确位置 —— 因为i左侧均≤基准,i+1右侧(未处理的j)均 > 基准,交换i+1与high(基准原位置)后,基准就处于 “左小右大” 的中间。 -
递归终止条件:当
low >= high时,子数组长度为 0 或 1(天然有序),停止递归,避免无限循环。
复杂度与排序特性:
-
时间复杂度:
- 平均情况:
O(n log n)(每次分区将数组分为大致相等的两部分,递归深度为log n,每层总操作数为O(n))。 - 最坏情况:
O(n²)(如数组已排序且选择最右元素为基准,每次分区只能减少一个元素,递归深度为n)。(可通过随机选择基准优化,使最坏情况概率极低)。
- 平均情况:
-
空间复杂度:
O(log n)(递归调用栈的深度,平均为log n,最坏为n)。 -
特性:
- 原地排序(无需额外数组,仅用常数级空间)。
- 不稳定排序(相等元素的相对顺序可能因交换改变)。
- 适用于大规模数据(平均性能优于归并排序,因缓存友好性)。
总结
-
爬楼梯/斐波那契:就像计算到达目的地有多少条路径,不要傻傻地每条路都走一遍(递归),而是记住已经算过的路径(动态规划)。
-
移动零:就像整理书架,把所有书(非零元素)先拿到前面摆好,空出来的位置再放垫书板(零)。
-
冒泡排序:像水中的气泡,轻的(小的)往上冒,每次比较相邻的两个。
-
归并排序:像合并两个有序的队伍,总是比较两个队伍的排头,选小的出来。
-
快速排序:像选举班长,先选个基准(候选人),把支持他的人放左边,反对的放右边,然后在两边继续选举。
复杂度分析的意义:就像预测交通,O(n)是顺畅的高速公路,O(n²)是上下班高峰的市区道路,O(2ⁿ)是节假日的景区道路——数据量稍大就"堵死"了。
四,最终总结/费曼讲解
想象一下,你要把一堆书从A房间搬到B房间。
-
O(1):无论有多少书,你都能用“任意门”一次搬完。时间是固定的。
-
O(n):你一次拿一本。书的数量(n)翻倍,你的工作时间也翻倍。这是最常见的“好”算法。
-
O(n²):你每拿起一本书,都要和房间里所有其他书比较一下再放下。书翻一倍,你的工作量变成四倍。这在n很大时会非常累。
-
O(2ⁿ):你每思考要不要搬一本书,这个决定都会衍生出两个全新的、同样复杂的问题。书稍微多一点,你这辈子就搬不完了。这是要极力避免的。
-
O(log n):你每次都能把书分成两堆,然后果断扔掉一堆不用管。书翻一倍,你只多需要一步。这是非常高效的算法,比如二分查找。
