当前位置: 首页 > news >正文

编程算法学习——复杂度分析

个人使用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]为例,分区与递归流程简化如下:

关键细节解析:

复杂度与排序特性:

总结

四,最终总结/费曼讲解


一、复杂度分析

问题与动机

  • 问题:解决同一个问题,往往有多种算法。我们如何科学地、量化地评判哪个算法更好?

  • 动机

    1. 预测性能:在数据量很大时,一个“慢”的算法和一个“快”的算法带来的用户体验和系统成本是天壤之别。我们需要一种方式来预先估算算法的执行效率。

    2. 指导设计:复杂度分析为我们提供了设计高性能算法的理论方向。例如,当我们知道现有方案是O(n²),我们就会去寻找能否优化为O(n log n)。

    3. 抓住主要矛盾:它帮助我们忽略因编程语言、机器性能、临时状态等带来的微小波动,直接关注随着数据规模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)的某个常数倍。

分步骤详解

进行复杂度分析通常遵循以下步骤:

  1. 找出基本操作:在代码中,找出执行次数最多、与输入规模n最相关的操作(例如,循环内的比较、赋值、算术运算)。

  2. 计算执行次数T(n):建立一个函数,表示基本操作被执行了多少次。这个函数通常是关于输入规模n的表达式。

  3. 简化表达式,保留最高阶项:因为大O关注的是增长趋势。

  4. 忽略最高阶项的系数:因为系数在大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成线性关系的空间(如归并排序的临时数组、遍历二叉树时递归调用的栈深度)。

代码与实践

易错点总结

  1. 混淆最好、最坏、平均情况

    • 最好情况:算法在最理想输入下的复杂度。

    • 最坏情况:算法在最差输入下的复杂度。大O表示法通常指最坏情况,因为它给出了一个性能保证。

    • 平均情况:算法在所有可能输入下的期望复杂度。分析起来最复杂。

    • 示例:快速排序在最坏情况下是O(n²)(输入已排序),但平均情况下是O(n log n)。

  2. 错误估算循环复杂度

    • for (int i = 0; i < n; i *= 2) 是 O(log n)。

    • for (int i = 0; i < n; i += 10) 依然是 O(n),因为常数系数被忽略。

  3. 忽略递归的空间复杂度

    • 递归调用会在调用栈上占用空间。递归深度是多少,空间复杂度至少就是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。

  4. 多个数据规模:如果函数有两个输入,比如处理一个 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];
}
核心逻辑与优化原理:
  1. 记忆化数组的作用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]取结果,无需重新递归计算。

  2. 递归流程示例(以 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];
}
核心逻辑与动态规划思想:
  1. 状态定义dp[i] 明确表示 “爬到第 i 阶楼梯的方法数”,将原问题(求dp[n])分解为更小的子问题(求dp[1]dp[n-1])。

  2. 状态转移方程:对于i >= 3dp[i] = dp[i-1] + dp[i-2]。逻辑依据:到达第 i 阶的最后一步只能是 “从 i-1 阶爬 1 阶” 或 “从 i-2 阶爬 2 阶”,因此总方法数是这两种情况的和。

  3. 自底向上计算:与递归(自顶向下,从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;
}
核心逻辑与空间优化原理:
  1. 为什么可以优化空间?原动态规划(数组版)中,dp[i]的计算只依赖前两个值dp[i-1]dp[i-2],无需存储整个dp数组的所有历史值。因此可以用三个变量(firstsecondthird)滚动更新,替代数组存储,将空间复杂度从O(n)降至O(1)

  2. 变量含义与迭代过程(以 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] 的结果)。
关键细节与优势:
  • 变量更新顺序:必须先更新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)自底向上填表
优化DPO(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≥2F(n) = F(n-1) + F(n-2),且基础项为F(0)=0F(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=2010+1=1(F(2))1(F(1))1(F(2))
i=31(F(1))1(F(2))1+1=2(F(3))1(F(2))2(F(3))
i=41(F(2))2(F(3))1+2=3(F(4))2(F(3))3(F(4))
i=52(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 个变量(prev1prev2current)存储中间结果,相比 “用数组存储所有前 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]为例,执行过程如下:
  1. 第一次遍历(处理非零元素)

    • i=0:元素是 0,不处理,nonZeroIndex仍为 0。
    • i=1:元素是 1(非零),执行nums[0] = 1nonZeroIndex变为 1。
    • i=2:元素是 0,不处理,nonZeroIndex仍为 1。
    • i=3:元素是 3(非零),执行nums[1] = 3nonZeroIndex变为 2。
    • i=4:元素是 12(非零),执行nums[2] = 12nonZeroIndex变为 3。此时数组变为[1, 3, 12, 3, 12](前 3 个位置是非零元素,后 2 个位置待处理)。
  2. 第二次遍历(填充 0)

    • i=3i=4(数组长度为 5),将这两个位置设为 0。最终数组变为[1, 3, 12, 0, 0],符合预期。
优势与复杂度分析:
  • 时间复杂度O(n),其中n是数组长度。两次遍历数组,每次遍历都是线性时间,总时间为O(n) + O(n) = O(n)
  • 空间复杂度O(1),仅使用了nonZeroIndexi两个额外变量,属于原地操作,不消耗额外空间(相比 “新建数组存储非零元素再填充 0” 的方法,节省了O(n)的空间)。
  • 保持相对顺序:由于第一次遍历是按原数组顺序处理非零元素,因此非零元素的相对位置与原数组一致,符合题目要求。
关键细节:
  • nonZeroIndex的作用:始终指向 “下一个非零元素的存放位置”,确保非零元素按顺序紧凑排列。
  • 两次遍历的必要性:第一次遍历无法直接处理 “原非零元素位置变为 0” 的问题(会覆盖后续元素),因此需要第二次遍历统一填充 0。
  • 边界情况处理:
    • 若数组中没有 0:第一次遍历后nonZeroIndex = nums.length,第二次遍历不执行,数组不变(正确)。
    • 若数组全是 0:第一次遍历不执行(nonZeroIndex=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=00(跳过)不进入 if不变仍为 0
i=11(非零)交换 nums [0] 和 nums [1][1,0,0,3,12]从 0→1
i=20(跳过)不进入 if不变仍为 1
i=33(非零)交换 nums [1] 和 nums [3][1,3,0,0,12]从 1→2
i=412(非零)交换 nums [2] 和 nums [4][1,3,12,0,0]从 2→3

循环结束后,数组变为[1,3,12,0,0],符合 “零在末尾,非零顺序不变” 的要求。

关键细节解析:
  1. 交换的作用:当nums[i]是非零元素时,nonZeroIndex指向 “它应该在的位置”。交换后:

    • nums[nonZeroIndex]被更新为当前非零元素(实现 “非零元素前移”);
    • nums[nonZeroIndex]的元素(可能是 0,也可能是之前已处理的非零元素)被放到i位置。若原元素是 0,相当于 “将 0 后移”;若原元素是非零(仅当nonZeroIndex == i时,即非零元素已在正确位置),交换后数组不变(自交换,无意义但不影响结果)。
  2. 为什么能保持非零元素顺序?遍历是按数组顺序从左到右进行的,nonZeroIndex始终≤i(因为每次交换后nonZeroIndex才 + 1)。非零元素按遍历顺序被交换到nonZeroIndex位置,因此相对顺序与原数组一致。

  3. 零元素的处理:零元素不会触发交换,随着i增大,nonZeroIndex会跳过零元素指向更靠后的位置,后续非零元素与nonZeroIndex(此时指向零元素位置)交换时,零元素会被 “推” 到i位置,最终聚集在数组末尾。

优势与复杂度分析:
  • 时间复杂度O(n),仅需一次遍历数组(n为数组长度),每次交换是常数时间操作,总时间线性。
  • 空间复杂度O(1),仅使用nonZeroIndexitemp三个额外变量,属于原地操作,无额外空间消耗。
  • 相比双遍历的优化:减少了一次遍历(无需单独填充 0),在大数据量下效率更高,逻辑更紧凑。
边界情况验证:
  • 数组无零(如[1,2,3]):每次inonZeroIndex相等,交换后数组不变,最终结果正确。
  • 数组全是零(如[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](数组已有序,提前退出)
关键细节解析:
  1. 外循环轮数(i 的范围):最多需要n-1轮,因为每轮确定一个元素的最终位置,n个元素需要n-1轮即可全部确定(最后一个元素自然有序)。

  2. 内循环范围(j 的范围):每轮内循环的范围是[0, n-1-i),原因是:经过i轮排序后,数组末尾的i个元素已经是最大的i个元素(已有序),无需再参与比较,因此内循环的终点每次减少i,提高效率。

  3. 优化点(swapped 变量):若某一轮没有发生任何元素交换,说明数组中所有相邻元素都已满足arr[j] <= arr[j+1],即数组已完全有序,此时可直接退出外循环,避免后续无意义的比较(例如已排序数组[1,2,3,4],第一轮就无交换,直接结束,时间复杂度从O(n²)降至O(n))。

  4. 交换逻辑:仅当arr[j] > arr[j+1]时交换相邻元素,保证排序后为升序;若为降序,只需将条件改为arr[j] < arr[j+1]

复杂度与排序特性:
  • 时间复杂度

    • 最坏情况(完全逆序):O(n²)(需n-1轮,每轮最多n-i次比较和交换)。
    • 最好情况(已排序):O(n)(仅 1 轮,无交换,直接退出)。
    • 平均情况:O(n²)
  • 空间复杂度O(1),仅使用ijtempswapped等常数级变量,属于原地排序

  • 稳定性:稳定排序。因为当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(天然有序)。
  2. 解决:对子数组递归排序(长度为 1 时直接返回)。
  3. 合并:将两个已排序的子数组合并为一个有序数组,这是归并排序的核心步骤。
以示例数组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]
关键细节解析:
  1. 临时数组temp的作用:合并两个子数组时,若直接在原数组上操作会覆盖未处理的元素,因此需要临时数组暂存合并结果,最后再拷贝回原数组。temp在主方法中创建一次,避免递归中频繁创建数组,优化内存开销。

  2. mid的计算方式:用left + (right - left)/2而非(left + right)/2,是为了防止left + right的值超过整数最大值(Integer.MAX_VALUE)导致溢出(例如left=1e9right=1e9时,left+right会溢出,而left + (right-left)/2不会)。

  3. 合并的稳定性:合并时判断条件为arr[i] <= arr[j](而非<),当左右元素相等时优先取左子数组的元素,保证相等元素的相对顺序与原数组一致,因此归并排序是稳定排序

  4. 递归终止条件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)递归排序

  1. 选择基准:从当前子数组中选一个元素作为基准(这里选择最右侧元素)。
  2. 分区:将子数组划分为两部分,左侧元素均≤基准,右侧元素均 > 基准,基准被放到最终正确的位置。
  3. 递归排序:对基准左侧和右侧的子数组重复上述步骤,直至子数组有序。
以示例数组arr = [3, 1, 4, 2]为例,分区与递归流程简化如下:
  • 初始调用quickSort(arr, 0, 3)(整个数组[3,1,4,2])。
  • 第一次分区
  • 基准pivot = arr[3] = 2i = -1(初始)。
  • j=0arr[0]=3 > 2→不处理;
  • j=1arr[1]=1 <= 2i=0,交换arr[0]arr[1]→数组变为[1,3,4,2]
  • j=2arr[2]=4 > 2→不处理;
  • 遍历结束,交换i+1=1high=3→数组变为[1,2,4,3],基准2的索引为1pivotIndex=1)。
  • 递归排序左侧quickSort(arr, 0, 0)(子数组[1],已无需排序)。
  • 递归排序右侧quickSort(arr, 2, 3)(子数组[4,3])。
    • 分区:基准pivot=3i=1j=2arr[2]=4 > 3→不处理;交换i+1=2high=3→数组变为[1,2,3,4],基准3的索引为2
    • 递归排序左侧[2,1](无效范围)和右侧[3,3](已排序)。
  • 最终数组有序:[1,2,3,4]
关键细节解析:
  1. 基准的选择:代码中选择最右侧元素作为基准(pivot = arr[high]),这是最简单的策略。实际中还可选择随机元素、中间元素等,以避免极端情况(如已排序数组)下的性能退化。

  2. 分区中ij的作用

    • j是遍历指针,负责扫描所有待分区元素(除基准外)。
    • i是边界指针,标记 “小于等于基准区域” 的右边界。当arr[j] <= pivot时,i右移并交换arr[i]arr[j],确保i左侧(含i)的元素均≤基准。
  3. 基准的最终位置:遍历结束后,i+1是基准的正确位置 —— 因为i左侧均≤基准,i+1右侧(未处理的j)均 > 基准,交换i+1high(基准原位置)后,基准就处于 “左小右大” 的中间。

  4. 递归终止条件:当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):你每次都能把书分成两堆,然后果断扔掉一堆不用管。书翻一倍,你只多需要一步。这是非常高效的算法,比如二分查找。

http://www.dtcms.com/a/603655.html

相关文章:

  • 营销型手机网站建设随州网站建设有限公司
  • 小公司做网站还是微博网站建设分几块
  • 苏州相城区做网站域名解析好了怎么做网站
  • 西安企业网站导航网站设计方案
  • 深圳企业网站建设推广外包服务商有哪些好的印花图案设计网站
  • 分享影视资源的网站怎么做韩国小清新网站模板
  • 面向无监督行人重识别的摄像头偏差消除学习
  • 网站建设程序策划书wordpress外贸询盘插件
  • 创建公司网站难吗新浪云怎么做淘宝客网站
  • 网站开发公司地址中国城乡建设部人力网站首页
  • 西安建站免费模板品牌设计logo图片
  • 西宁网站搭建能源网站模板
  • 沈阳做网站的科技公司tp框架做商城网站怎么用缓存
  • 苏州网站建设永阳网络网站如何调用手机淘宝做淘宝客
  • 网站上线倒计时 模板网站里面的数据库是怎么做的
  • 网站推广优化技巧可以将自己做的衣服展示的网站
  • 移动网站开发语言WordPress搜索引擎链接提交
  • 蓝色脚手架织梦企业网站模板手工做衣服网站
  • MySQL不停机迁移完全指南
  • 网页截图快捷键ctrl+shift重庆seo管理平台
  • 使用python做网站变装小说wordpress
  • c++小游戏编程
  • 建模网站素材怎么做网站门户
  • 示范学校建设专题网站网站开发技术服务合同范本
  • 自己做网站用花钱么北京网站建设外包
  • 邯山手机网站建设wordpress显示空白页
  • 做网站需要用什么语言开发微采服企腾网
  • 宁夏建设教育协会网站天津重型网站建设方案公司
  • Go语言反编译工具: 深入理解Go语言反编译的技术与应用
  • 服务器512m内存做网站国家中小学智慧教育平台