2 - 复杂度收尾 + 链表经典OJ

目录

  • [1. 复杂度回顾与收尾](#1. 复杂度回顾与收尾)
    • [1.1 快速回顾:时间复杂度核心要点](#1.1 快速回顾:时间复杂度核心要点)
    • [1.2 常见时间复杂度与分水岭](#1.2 常见时间复杂度与分水岭)
  • [2. 递归的时间复杂度分析](#2. 递归的时间复杂度分析)
    • [2.1 阶乘递归 ------ O(N)](#2.1 阶乘递归 —— O(N))
    • [2.2 阶乘递归 + 内部循环 ------ O(N²)](#2.2 阶乘递归 + 内部循环 —— O(N²))
    • [2.3 递归时间复杂度的核心法则](#2.3 递归时间复杂度的核心法则)
    • [2.4 斐波那契递归 ------ O(2^N)](#2.4 斐波那契递归 —— O(2^N))
    • [2.5 错位相减法推导等比数列和](#2.5 错位相减法推导等比数列和)
    • [2.6 斐波那契递归的实际意义:几乎为零](#2.6 斐波那契递归的实际意义:几乎为零)
    • [2.7 斐波那契的正确写法:迭代优化](#2.7 斐波那契的正确写法:迭代优化)
    • [2.8 时间复杂度的实践意义](#2.8 时间复杂度的实践意义)
  • [3. 空间复杂度](#3. 空间复杂度)
    • [3.1 空间复杂度的定义](#3.1 空间复杂度的定义)
    • [3.2 冒泡排序的空间复杂度 ------ O(1)](#3.2 冒泡排序的空间复杂度 —— O(1))
    • [3.3 旋转数组的空间换时间 ------ O(N)](#3.3 旋转数组的空间换时间 —— O(N))
    • [3.4 递归的空间复杂度](#3.4 递归的空间复杂度)
    • [3.5 常见空间复杂度总结](#3.5 常见空间复杂度总结)
  • [4. 链表经典OJ题](#4. 链表经典OJ题)
    • [4.1 链表的倒数第K个节点](#4.1 链表的倒数第K个节点)
    • [4.2 链表的回文判断](#4.2 链表的回文判断)
    • [4.3 链表的相交问题](#4.3 链表的相交问题)
  • [5. 复习要点](#5. 复习要点)

1. 复杂度回顾与收尾

1.1 快速回顾:时间复杂度核心要点

复杂度的本质 :去计算一个算法的时间效率空间效率。上节课主要讲了时间效率的分析。

时间复杂度算的是什么? ------程序的执行次数,而不是具体时间。但它不揪那些细枝末节:

  • 循环内部有比较、交换等操作,不需要单独计算每一条语句
  • 计算机每秒运算速度在上亿次,这些零碎的操作差异可以忽略
  • 我们只需要算整体循环的次数

简化规则 :算出来的次数可能是多项构成的(比如 N 2 + N + 10 N^2 + N + 10 N2+N+10),我们只取最高阶项 ,去掉系数。因为当 N 很大时, 3 N 2 3N^2 3N2 和 N 2 N^2 N2 我们认为基本一样。

复杂度的实际价值 :拿到一个问题,可能有多种思路(比如旋转数组的"暴力右旋"和"三段逆置"),算一下各自的时间复杂度,不需要写代码就能直接比较优劣,跟具体硬件环境无关。

1.2 常见时间复杂度与分水岭

时间复杂度 量级 说明
O ( 1 ) O(1) O(1) 常数阶 520万次也是O(1),CPU上亿次/秒,瞬间完成
O ( log ⁡ N ) O(\log N) O(logN) 对数阶 二分查找
O ( N ) O(N) O(N) 线性阶 单层循环遍历
O ( N log ⁡ N ) O(N \log N) O(NlogN) 线性对数阶 快速排序等
O ( N 2 ) O(N^2) O(N2) 平方阶 两层嵌套循环(冒泡排序)
O ( N 3 ) O(N^3) O(N3) 立方阶 已经很慢了
O ( 2 N ) O(2^N) O(2N) 指数阶 基本废了,没有实际意义

提示: N 2 N^2 N2 是一个分水岭。 N 2 N^2 N2 以上的算法其实就有点慢了, N 2 N^2 N2 以下对计算机来说相对还行。

本节小结

时间复杂度是对算法执行次数的量级估算。 O ( 1 ) O(1) O(1) 最好, N 2 N^2 N2 是效率分水岭, 2 N 2^N 2N 基本没有实际意义。复杂度让我们在写代码之前就能比较不同思路的优劣。


2. 递归的时间复杂度分析

2.1 阶乘递归 ------ O(N)

引入 :之前我们算的都是循环的复杂度,循环很好算------看它跑几次就行。但递归怎么算?

先看阶乘递归函数 Fac(N)

c 复制代码
long long Fac(int N) {
    if (N == 0)
        return 1;
    return Fac(N - 1) * N;
}

分析思路

  1. 单次递归消耗 :每次递归内部只有一个乘法和一个判断,是常数次 操作,即 O ( 1 ) O(1) O(1)
  2. 递归调用次数Fac(N)Fac(N-1)Fac(N-2) → ... → Fac(1)Fac(0),从 N 到 0,共 N+1 次调用
  3. 合计 :每次 O ( 1 ) O(1) O(1) × 调用 N + 1 N+1 N+1 次 → 简化为 O ( N ) O(N) O(N)

注意: 有些同学会算成 N ! N! N!(阶乘),这是把结果的值执行次数 搞混了。Fac(N-1) * N 这里的乘号乘的是返回值 (计算结果),不是时间次数的相乘。递归的时间消耗应该是累加,不是累乘!

通俗理解:递归就像一个人不断打电话给下一个人,每个人只花固定时间处理,一共打了 N+1 个电话,总时间就是 N+1 份------加起来的。

2.2 阶乘递归 + 内部循环 ------ O(N²)

现在改造一下,在递归内部加一个循环:

c 复制代码
long long Fac(int N) {
    if (N == 0)
        return 1;
    for (int i = 0; i < N; i++) {
        // 某些操作...
    }
    return Fac(N - 1) * N;
}

分析

  • 第一次调用 Fac(N):内部循环跑 N
  • 第二次调用 Fac(N-1):内部循环跑 N-1
  • 第三次调用 Fac(N-2):内部循环跑 N-2
  • ...
  • 最后 Fac(0):循环跑 0

注意: 这里的 N 是一个变量,不是每次都是 N 次!随着递归深入,N 在不断减小。

总次数 : N + ( N − 1 ) + ( N − 2 ) + . . . + 1 + 0 N + (N-1) + (N-2) + ... + 1 + 0 N+(N−1)+(N−2)+...+1+0

这是一个等差数列!套公式:

总次数 = ( 首项 + 尾项 ) × 项数 2 = ( N + 0 ) × ( N + 1 ) 2 = N 2 + N 2 \text{总次数} = \frac{(首项 + 尾项) \times 项数}{2} = \frac{(N + 0) \times (N+1)}{2} = \frac{N^2 + N}{2} 总次数=2(首项+尾项)×项数=2(N+0)×(N+1)=2N2+N

最高阶项为 N 2 2 \frac{N^2}{2} 2N2,去掉系数 → O ( N 2 ) O(N^2) O(N2)

注意: 虽然有 Fac(N-1) * N,但这里的乘依然是结果相乘 ,不是次数相乘。递归的时间复杂度始终是所有递归调用消耗的累加

2.3 递归时间复杂度的核心法则

关键结论:递归的时间复杂度 = 所有递归调用消耗的累加。

普通函数的调用:只调一个函数,看这个函数内部跑多少次就行了。

递归函数的调用:它会自己调用自己,循环条件在变。相当于是多个函数调用的累加和

  • 先看每次递归内部消耗多少(单次递归复杂度)
  • 再看总共递归了多少次
  • 把每次递归的消耗加起来(不是乘起来!)
本节小结

递归复杂度 = 所有调用次数的消耗累加。阶乘递归每次O(1)累加N次→O(N);加了循环后变成等差数列累加→O(N²)。关键区分:乘的是结果值,不是时间次数。

2.4 斐波那契递归 ------ O(2^N)

引入 :什么程序的时间复杂度是 2 N 2^N 2N?经典答案就是------递归版斐波那契数列。

c 复制代码
long long Fib(int N) {
    if (N < 3)
        return 1;
    return Fib(N - 1) + Fib(N - 2);
}

分析 :关键在于每次递归调用产生了两个子调用。

复制代码
                    Fib(N)
                  /        \
           Fib(N-1)      Fib(N-2)
           /     \        /     \
      Fib(N-2) Fib(N-3) Fib(N-3) Fib(N-4)
       ...       ...       ...       ...

之前阶乘递归是"一个变一个",现在斐波那契是"一个变两个"------一生二、二生四、四生八......

逐层分析 (每次递归内部都是常数次 O ( 1 ) O(1) O(1)):

层数 该层调用次数
第 0 层 2 0 = 1 2^0 = 1 20=1
第 1 层 2 1 = 2 2^1 = 2 21=2
第 2 层 2 2 = 4 2^2 = 4 22=4
第 3 层 2 3 = 8 2^3 = 8 23=8
... ...
第 N-2 层 2 N − 2 2^{N-2} 2N−2

这是一个等比数列

总调用次数: 2 0 + 2 1 + 2 2 + . . . + 2 N − 2 2^0 + 2^1 + 2^2 + ... + 2^{N-2} 20+21+22+...+2N−2

注意: 实际上递归树并不是完全满的------右侧分支会比左侧提前结束(因为 Fib(N-2)Fib(N-1) 更快到达基准条件)。但这不影响量级判断,即使缺失了一部分,它仍然是 2 N 2^N 2N 这个量级。

2.5 错位相减法推导等比数列和

等比数列 2 0 + 2 1 + 2 2 + . . . + 2 N − 2 2^0 + 2^1 + 2^2 + ... + 2^{N-2} 20+21+22+...+2N−2 的和怎么算?可以用错位相减法来求解,后面讲堆的复杂度证明时还会再用。

设:

F ( N ) = 2 0 + 2 1 + 2 2 + . . . + 2 N − 2 F(N) = 2^0 + 2^1 + 2^2 + ... + 2^{N-2} F(N)=20+21+22+...+2N−2

两边同时乘以公比 2:

2 ⋅ F ( N ) = 2 1 + 2 2 + 2 3 + . . . + 2 N − 1 2 \cdot F(N) = 2^1 + 2^2 + 2^3 + ... + 2^{N-1} 2⋅F(N)=21+22+23+...+2N−1

错位相减(下式减上式):

2 F ( N ) − F ( N ) = 2 N − 1 − 2 0 2F(N) - F(N) = 2^{N-1} - 2^0 2F(N)−F(N)=2N−1−20

F ( N ) = 2 N − 1 − 1 F(N) = 2^{N-1} - 1 F(N)=2N−1−1

原理:乘了公比之后,每一项的指数都加了 1,和原式"错开了一位"。相减时中间项两两抵消,只剩下首尾两项。

补充说明: 错位相减法的核心:左右两边各乘一个公比,乘完之后前一项和后一项就相同了,一减中间就消掉了。前一项和后一项两两抵消,所以叫"错位"相减。

最终:总调用次数为 2 N − 1 − 1 2^{N-1} - 1 2N−1−1,忽略不影响量级的项 → 时间复杂度 O ( 2 N ) O(2^N) O(2N)

本节小结

斐波那契递归每次分裂成两个子调用,形成等比数列累加,用错位相减法可推导出总调用次数为 2 N − 1 − 1 2^{N-1}-1 2N−1−1,量级为 O ( 2 N ) O(2^N) O(2N)。这个方法在后续堆的复杂度证明中还会再用。

2.6 斐波那契递归的实际意义:几乎为零

有了时间复杂度的分析,我们可以直观感受 O ( 2 N ) O(2^N) O(2N) 到底有多慢:

N 2 N 2^N 2N 大约值 运行情况
30 2 30 ≈ 10 2^{30} \approx 10 230≈10 亿 还能算,几秒内出结果
40 2 40 ≈ 1 2^{40} \approx 1 240≈1 万亿 要算一会儿了
50 2 50 2^{50} 250 算半天都算不出来
100 2 100 2^{100} 2100 完全不可能

提示: 一个算法的时间复杂度如果算出来是 2 N 2^N 2N,这个算法就基本上废了------递归版斐波那契连 50 都算得非常费劲,没有实际意义。

换句话说 :递归版斐波那契只有理论意义,在实践中太慢了。N=50 就要算好久,你总不能让用户输入个 50 就告诉人家"你输入的数太大了"------那不扯淡嘛。

2.7 斐波那契的正确写法:迭代优化

把递归改成循环迭代 ,复杂度从 O ( 2 N ) O(2^N) O(2N) 直接降到 O ( N ) O(N) O(N)------巨大的进步!

核心思路:斐波那契数列第一项和第二项已知(都是 1),从第三项开始,每一项 = 前两项之和。用三个变量滚动计算即可。

c 复制代码
long long Fib(int N) {
    long long f1 = 1, f2 = 1, f3 = 0;
    for (int i = 3; i <= N; i++) {  // 从第3项算到第N项
        f3 = f1 + f2;   // 第i项 = 前两项之和
        f1 = f2;         // 滚动:f1后移
        f2 = f3;         // 滚动:f2后移
    }
    return f3;
}
  • 时间复杂度 : O ( N ) O(N) O(N)
  • N=50 瞬间出结果,N=100 也不在话下

注意: 斐波那契数列增长速度非常快(基本是翻倍增长),即使用了迭代优化,当 N 很大时 long long 也会溢出(long long 最大表示 2 63 − 1 2^{63}-1 263−1)。真正要算大数斐波那契,需要用大数运算------把数字存到字符串里,模拟手工的逐位加法和进位。这个在后续 C++ 课程中会涉及。

2.8 时间复杂度的实践意义

提示: 时间复杂度算的不是时间,但是算出量级之后,套个 N 进去,结合 CPU 每秒上亿次的常识,其实就能判断这个算法跑起来快不快。

实践感受

时间复杂度 N=10000时的次数 实际体验
O ( N ) O(N) O(N) 1万 瞬间完成
O ( N 2 ) O(N^2) O(N2) 1亿 还行,秒级完成
O ( N 2 ) O(N^2) O(N2),N=10万 100亿 很慢,要跑好久
O ( 2 N ) O(2^N) O(2N),N=50 约1000万亿 完全不可用

用户体验标准

  • 1~2 秒:最好的体验
  • 3 秒以上:勉强还行
  • 10 秒以上:用户就接受不了了

所以时间复杂度虽然不直接算时间,但帮我们提前判断一个算法在实际场景下是否可用。解决同一问题的不同算法,一看复杂度就知道谁有意义、谁没意义。

本节小结

时间复杂度的实践价值:套入数据规模 N,结合 CPU 每秒上亿次运算的常识,就能判断算法能不能用。 O ( 2 N ) O(2^N) O(2N) 实际中完全不可用, O ( N ) O(N) O(N) 和 O ( N log ⁡ N ) O(N \log N) O(NlogN) 是最实用的量级。


3. 空间复杂度

3.1 空间复杂度的定义

时间复杂度算的是时间效率,空间复杂度算的是空间效率 。但现在的设备存储空间都比较大,所以一般重点关注时间复杂度,不太关注空间复杂度。

不过在某些场景下也需要关注------比如嵌入式开发(冰箱、空调、扫地机器人上的程序),这些设备存储空间有限,就得省着用。

空间复杂度算的是什么? ------变量的个数,不是字节数。

为什么不算字节?因为单个对象的大小差异不大(1 字节和 100 字节),而:

  • 1 MB = 1024 × 1024 ≈ 100万字节
  • 1 GB10亿字节

一张照片都七八兆了,都不算大。所以单个变量那点字节数根本不在乎。

空间复杂度也使用大 O 渐进表示法,同样要简化。

3.2 冒泡排序的空间复杂度 ------ O(1)

冒泡排序用了多少额外变量?endexchangei------合计 3 个,是常数个 → O ( 1 ) O(1) O(1)

注意: 传入的数组不算 冒泡排序的空间!空间复杂度算的是算法中额外开的空间。那个数组是你存数据用的,不管用哪个排序算法你都得有这个数组,它不是排序算法额外需要的。
补充说明: 排序函数中传入的数组是调用方提供的数据,是要被排序的对象------跟排序算法本身无关,不是算法的思想过程中需要额外开辟的空间。

3.3 旋转数组的空间换时间 ------ O(N)

上节课的旋转数组我们用了三段逆置法( O ( N ) O(N) O(N) 时间, O ( 1 ) O(1) O(1) 空间),但三段逆置不好想。还有一种更直观的方法:

思路 :额外开一个临时数组 tmp,把后 K 个拷贝到 tmp 前面,前 N-K 个拷贝到 tmp 后面,再把 tmp 整体拷贝回原数组。

c 复制代码
void rotate(int* nums, int numsSize, int k) {
    k %= numsSize;
    int* tmp = (int*)malloc(sizeof(int) * numsSize);
    // 后K个拷贝到tmp前面
    memcpy(tmp, nums + numsSize - k, sizeof(int) * k);
    // 前N-K个拷贝到tmp后面
    memcpy(tmp + k, nums, sizeof(int) * (numsSize - k));
    // 整体拷贝回去
    memcpy(nums, tmp, sizeof(int) * numsSize);
    free(tmp);
}
  • 时间复杂度 : O ( N ) O(N) O(N)
  • 空间复杂度 : O ( N ) O(N) O(N)(额外开了一个 N 大小的数组)

这种方法叫做以空间换时间------用额外的空间开销来换取更简洁的实现或更好的时间效率。

注意: C99 支持变长数组(VLA),但如果编译器不支持,就需要用 malloc 动态申请。
提示: 画图非常重要!把下标标清楚:位置 0 到 N-K-1 是前 N-K 个元素,位置 N-K 到 N-1 是后 K 个元素。画图就非常清楚了。

3.4 递归的空间复杂度

阶乘递归的空间复杂度

每次递归调用都要建立一个栈帧 (stack frame),每个栈帧内是常数个变量。Fac(N) 递归调用 N+1 次,最深时同时存在 N+1 个栈帧。

空间复杂度 O ( N ) O(N) O(N)

注意: 虽然这些栈帧最终都会销毁(函数返回时释放),但空间复杂度算的是峰值占用 ------你最多的时候用了多少空间。不是说最后归零就不算了,否则所有空间都会归还,那不全是 O ( 1 ) O(1) O(1) 了吗?

斐波那契递归的空间复杂度

结论是:O ( N ) O(N) O(N) ,不是 O ( 2 N ) O(2^N) O(2N)。

为什么不是 O ( 2 N ) O(2^N) O(2N)?因为递归是深度优先 的,左边分支一路递归到底再回来,释放栈帧后才去递归右边分支。同一时刻最多占用的栈帧数等于递归的最大深度,即 N 层。

补充说明: 2 N 2^N 2N 的空间复杂度是不现实的,实际递归树虽然有 2 N 2^N 2N 个节点,但它们不是同时存在的。

3.5 常见空间复杂度总结

提示: 常见的空间复杂度只有三种,比时间复杂度简单多了。

空间复杂度 场景
O ( 1 ) O(1) O(1) 只开了常数个额外变量(冒泡排序、三段逆置)
O ( N ) O(N) O(N) 额外开了一个一维数组,或递归深度为 N
O ( N 2 ) O(N^2) O(N2) 额外开了一个二维数组

也存在 O ( log ⁡ N ) O(\log N) O(logN) 的空间复杂度,后面具体讲到时再说。

本节小结

空间复杂度算的是额外开辟的变量个数(不含输入数据),取峰值而非最终值。常见只有 O(1)、O(N)、O(N²) 三种。递归的空间复杂度看最大递归深度,不是总调用次数。


4. 链表经典OJ题

引入 :复杂度讲完了,接下来进入链表的经典题目。之前的课已经讲过链表的移除、反转、合并、查找中间节点等基础操作,今天在此基础上讲一些扩展性的经典问题

注意: 之前链表基础课(顺序表和链表部分)如果没看,一定要抓紧补上,否则后面会听不懂。

4.1 链表的倒数第K个节点

题目:给定一个单链表,找到链表的倒数第 K 个节点。

4.1.1 多种思路分析
思路 方法 时间复杂度 空间复杂度 备注
思路一 开数组存所有节点指针,直接下标访问 O ( N ) O(N) O(N) O ( N ) O(N) O(N) 可行但空间高
思路二 遍历两遍:先算长度,再找正数第 N-K+1 个 O ( N ) O(N) O(N) O ( 1 ) O(1) O(1) 可行但遍历两遍
思路三 快慢指针,只遍历一遍 O ( N ) O(N) O(N) O ( 1 ) O(1) O(1) 最优

提示: 面试中面试官可能会逐步加码约束条件------要求空间 O(1),只能遍历一遍......所以需要掌握多种思路。

4.1.2 快慢指针法(先走K步)

核心思想 :和查找中间节点的快慢指针不同------这里不是一个走两步一个走一步,而是先拉开 K 步的差距,再同时走

步骤

  1. 定义 fastslow 都指向 head
  2. fast 先走 K 步
  3. 然后 fastslow 同时走,每次都走一步
  4. fast == NULL 时,slow 就在倒数第 K 个节点

原理 :就是物理中的相对运动问题 。先拉开 K 的距离,同时走时距离始终保持 K。fast 走到尾(NULL),slow 自然在倒数第 K 个。

c 复制代码
struct ListNode* getKthFromEnd(struct ListNode* head, int k) {
    struct ListNode *fast = head, *slow = head;
    // 1. fast先走K步
    while (k--) {
        fast = fast->next;
    }
    // 2. 同时走,fast到NULL就结束
    while (fast) {
        slow = slow->next;
        fast = fast->next;
    }
    return slow;  // slow就是倒数第K个
}

注意:

  • 定义 *fast = head, *slow = head 写成一行时,每个变量前面都要有 *
  • while(k--) 是先判断 k 的值再减减,k=3 时会进入 3 次循环(3→2→1→0退出),正好走 K 步
  • 如果 K 可能大于链表长度,需要在 fast 先走时加判断(fast 为 NULL 就提前返回 NULL)。本题保证 K 有效,所以不需要

快慢指针找倒数第K个 = "先走K步,再同时走"

本节小结

快慢指针找倒数第K个节点:fast先走K步拉开差距,再同时走,fast到NULL时slow就是答案。原理是相对距离保持不变。时间O(N)、空间O(1)、只遍历一遍。

4.2 链表的回文判断

题目 :判断一个单链表是否为回文结构。要求时间 O ( N ) O(N) O(N),空间 O ( 1 ) O(1) O(1)。

回文 (Palindrome):从中间切开,左右对称。如 1→2→3→2→11→2→2→1

4.2.1 为什么不能直接双指针

数组判断回文很简单------左右各一个指针往中间走比较就行。但单链表不能往前走(没有 prev 指针),所以不能从尾部反向遍历。

用额外数组存储所有值?可以,但空间复杂度变成 O ( N ) O(N) O(N),不满足要求。

4.2.2 解题思路:查找中间节点 + 后半段逆置 + 逐一比较

步骤

  1. 查找中间节点(快慢指针,之前已学)
  2. 将后半段逆置(反转链表,之前已学)
  3. 两个指针分别从头和后半段逆置后的头开始,逐一比较
  4. 有一个走到 NULL 就结束

偶数个节点的情况1→2→2→1

  • 中间节点(取第二个):第二个 2
  • 后半段逆置:1→2
  • 比较:1==12==2,有一个到 NULL,结束 → 回文

奇数个节点的情况1→2→3→2→1

  • 中间节点:3
  • 后半段逆置:3 的后面 2→1 变成 1→2→3
  • 比较:1==12==2,然后 33 自己比------为什么?

补充说明: 虽然后半段逆置了,但前半段的最后一个节点(比如第二个 2)的 next 指针没有修改 ,它还是指向中间的 3。所以前半段走到 3 时,后半段也恰好走到 3------奇数个中间那个元素和自己比较,一定相等。

因此不需要区分奇偶,也不需要把前半段的尾节点的 next 置空(单链表找前一个节点本身就很麻烦)。

c 复制代码
// 复用之前已实现的两个函数
struct ListNode* middleNode(struct ListNode* head);   // 查找中间节点
struct ListNode* reverseList(struct ListNode* head);   // 反转链表

bool isPalindrome(struct ListNode* head) {
    struct ListNode* mid = middleNode(head);        // 1. 找中间节点
    struct ListNode* rHead = reverseList(mid);       // 2. 后半段逆置
    // 3. 逐一比较
    while (head && rHead) {  // 有一个为NULL就结束(用&&:都不为空才继续)
        if (head->val != rHead->val)
            return false;    // 不相等直接返回false
        head = head->next;
        rHead = rHead->next;
    }
    return true;             // 全部相等,是回文
}

注意:

  • 循环条件是 head && rHead ,都不为空才继续),不是"或"。"有一个为空就结束"是结束条件 ,写代码写的是继续条件------反过来就是"都不为空就继续"
  • 牛客上这道题可能没有 C 语言选项,只有 C++。但 C++ 兼容 C,直接用 C 语法写没问题
  • 这道题本质是一个组合题------把"查找中间节点"和"反转链表"两个已学的子问题组合起来

回文链表 = 找中间 + 逆后半 + 两头比

本节小结

单链表回文判断:找中间节点→后半段逆置→两个指针从头和逆置头分别走并比较。不需要区分奇偶。循环条件用"且"(都不为空才继续)。这是一道经典的组合题。

4.3 链表的相交问题

题目:给定两个单链表,判断它们是否相交。如果相交,找出第一个交点(交点节点)。

4.3.1 链表相交的形状:Y字型,不是X字型

提示: 很多同学以为链表相交是"X字型"------两条线交叉。但单链表不可能交叉!

为什么不是X字型? 因为单链表的每个节点只有一个 next 指针。如果两个链表在某个节点汇合,这个节点往后只能有一条路走------不可能分叉出去。

所以链表相交一定是 Y字型:两条链表在某个节点汇合后,共享同一条尾部。

复制代码
A: a1 → a2 ↘
              c1 → c2 → c3
B: b1 → b2 → b3 ↗
4.3.2 如何判断是否相交

最简单的方法 :分别找到两个链表的尾节点 ,比较尾节点的地址是否相同。

  • 相同 → 相交(共享同一条尾部)
  • 不相同 → 不相交

注意: 一定要用地址 判断,不能用值判断 !因为两个不相交的链表,尾节点的值也可能恰好相同(比如都是 4),但它们是不同的节点(不同的内存地址)。链表节点都是从堆上 malloc 出来的,每个节点有唯一的地址。

4.3.3 暴力查找交点 ------ O(M×N)

思路一 :链表 A 的每个节点 都跟链表 B 的所有节点比较地址。第一个地址相等的节点就是交点。

  • 时间复杂度: O ( M × N ) O(M \times N) O(M×N)(或认为都是 N 量级则 O ( N 2 ) O(N^2) O(N2))
  • 不够高效

注意:

  • 是 M × N M \times N M×N(乘法),不是 M + N M + N M+N(加法)。因为 A 的每一个 节点都要跟 B 的所有节点比一遍
  • 不能用"逆置"来找交点------逆置会破坏结构(交点有两个入边,逆置后其中一条链就断了)
  • 不能"同时走"比较------两个链表长度可能不同,同时走会错位,交点可能恰好被跳过
4.3.4 最优解法:长链表先走差距步 + 同时走 ------ O(N)

核心思想:如果两个链表相交,那么从交点到尾部的长度一定相同。差异只在交点之前的部分。

复制代码
A: [a1] → [a2] ↘
                  [c1] → [c2] → [c3]    ← 公共部分长度相同
B: [b1] → [b2] → [b3] → [b4] ↗

步骤

  1. 分别遍历两个链表,求出长度 lenAlenB(顺便判断尾节点是否相同)
  2. 如果尾节点不同 → 不相交,返回 NULL
  3. 计算长度差 gap = |lenA - lenB|
  4. 让长的链表先走 gap 步
  5. 然后两个链表同时走第一个地址相等的节点就是交点

时间复杂度 :遍历两遍求长度 O ( N + M ) O(N+M) O(N+M),先走差距步 + 同时走 O ( N ) O(N) O(N) → 总共约 O ( 3 N ) O(3N) O(3N) → O ( N ) O(N) O(N)

4.3.5 假设法:简化长短链表的判断逻辑

面对"长链表先走"的逻辑,很多同学会写:

c 复制代码
if (lenA > lenB) { /* 让A先走 */ }
else { /* 让B先走 */ }

两个分支的逻辑几乎重复。有一个更优雅的方式------假设法

c 复制代码
int gap = abs(lenA - lenB);

// 假设A长B短
struct ListNode* longList = headA;
struct ListNode* shortList = headB;

// 如果假设错了,修正一下
if (lenB > lenA) {
    longList = headB;
    shortList = headA;
}

// 后续逻辑不再关心到底A长还是B长,只用longList和shortList
// 长的先走gap步
while (gap--) {
    longList = longList->next;
}

// 同时走,第一个相等的就是交点
while (longList != shortList) {
    longList = longList->next;
    shortList = shortList->next;
}

return longList;  // 随便返回一个,此时它们指向同一节点

补充说明: 假设法会让逻辑变得更简单。如果写 if-else 各走各的逻辑,两段代码是重复的。用假设法,先假设 A 长 B 短,假设错了就修正,后面统一用 longList 和 shortList 走逻辑就行了。

假设法的好处

  • 如果 lenA == lenBgap 为 0,while(gap--) 不会进入循环,直接同时走 → 无需特殊处理
  • 避免 if-else 两段几乎相同的代码

相交链表 = 尾地址判断 + 长链表先走差距步 + 同时走找交点 + 假设法简化逻辑

注意:

  • 在求长度时注意:如果循环条件是 cur->next != NULL,最后尾节点不会被计数,会少算一个。但不影响差值的计算(两个都少一个,差还是一样)
  • 前面已经判断过"相交"(尾节点相同),所以同时走时一定能找到交点,最坏情况是最后一个节点才是交点
本节小结

链表相交判断用尾节点地址比较(一定不能用值!)。找交点用"长链表先走差距步+同时走"实现O(N)。假设法能优雅地处理"不知道谁长谁短"的逻辑,避免重复代码。


5. 复习要点

核心知识点

  • 递归时间复杂度法则 :所有递归调用消耗的累加(不是累乘)
  • 阶乘递归复杂度:每次O(1),调用N+1次 → O(N)
  • 递归+循环复杂度:等差数列累加 → O(N²)
  • 斐波那契递归复杂度:一个变两个,等比数列累加 → O(2^N)
  • 错位相减法:等比数列求和技巧,两边乘公比后相减消中间项
  • 斐波那契正确写法:改迭代,三变量滚动,O(N)
  • 空间复杂度定义:额外开辟的变量个数,取峰值,用大O表示
  • 常见空间复杂度:O(1)常数个变量、O(N)一维数组/递归深度、O(N²)二维数组
  • 快慢指针找倒数第K个:fast先走K步,再同时走,fast到NULL时slow就是答案
  • 链表回文判断:找中间 + 逆后半 + 两头比较,不用区分奇偶
  • 链表相交判断 :比较尾节点地址(不是值!)
  • 链表找交点:长链表先走差距步 + 同时走 + 假设法简化

重难点与高频考点

  1. 递归复杂度分析:区分"结果值的乘"和"时间次数的加" → 递归是累加不是累乘
  2. 斐波那契递归树分析 :画出二叉递归树,每层调用次数形成等比数列 → O ( 2 N ) O(2^N) O(2N)
  3. 等差/等比数列在复杂度中的应用:等差→O(N²),等比→O(2^N),要会套公式或用错位相减
  4. 空间复杂度看峰值:递归栈帧最深时的占用,不是总调用次数
  5. 快慢指针变体:找中间(一个走一步一个走两步)vs 找倒数第K个(先走K步再同时走)
  6. 链表相交必须用地址判断:值相同不代表是同一节点,地址相同才是
  7. 假设法编程技巧:处理"两个中不确定谁满足条件"的场景,先假设再修正,避免重复代码

易错点提醒

误区 正解
递归时间复杂度是每层消耗相乘 是每层消耗累加,乘号乘的是返回值不是次数
斐波那契递归空间复杂度是 O ( 2 N ) O(2^N) O(2N) 是 O ( N ) O(N) O(N),因为栈帧不是同时存在的,看最大递归深度
空间复杂度把输入数据也算进去 只算算法额外开辟的空间,输入数据不算
链表相交用值判断尾节点 必须用地址判断,不同节点的值可能恰好相同
循环条件"有一个为空就结束"写成 `

知识关联

复制代码
复杂度收尾(本节上半场)
    ├── 递归复杂度分析 → 为后续树、图等递归结构的分析打基础
    ├── 错位相减法 → 后续讲堆的时间复杂度证明还会用
    ├── 空间复杂度 → 空间换时间思想,后续哈希表等会频繁使用
    └── 斐波那契优化 → 递归改迭代的思想,后续动态规划的雏形

链表经典OJ(本节下半场)
    ├── 快慢指针 → 贯穿链表题的核心技巧(中间节点、倒数第K个、判环)
    ├── 链表回文 = 找中间 + 逆置(组合已学子问题)
    ├── 链表相交 → 假设法编程技巧,后续还会用
    └── 下节课预告:带环问题(非常经典)、复杂链表的复制

本节课上半场补全了复杂度分析的最后拼图(递归复杂度 + 空间复杂度),下半场通过链表OJ题展示了画图→分析→编码的数据结构做题方法论。每道题都要先画图、分析思路和复杂度,再动手写代码------"写代码 5 分钟,调试 1 小时"的根源往往是图没画清楚。

相关推荐
星马梦缘1 小时前
算法设计与分析 作业二 答案与解析
算法·图论·dfs·bfs·floyd-warshall·bellman_ford·多源最短路
玛丽莲茼蒿1 小时前
Leetcode hot100 每日温度【中等】
算法·leetcode·职场和发展
cjp5602 小时前
009.UG二次开发,任务环境草图优化3(高级功能生成直线)
算法
样例过了就是过了2 小时前
LeetCode热题100 分割等和子集
数据结构·c++·算法·leetcode·动态规划
逻辑驱动的ken2 小时前
Java高频面试考点18
java·开发语言·数据库·算法·面试·职场和发展·哈希算法
木木_王2 小时前
嵌入式Linux学习 | 数据结构 (Day05) 栈与队列详解(原理 + C 语言实现 + 实战实验 + 易错点剖析)
linux·c语言·开发语言·数据结构·笔记·学习
北顾笙9802 小时前
day38-数据结构力扣
数据结构·算法·leetcode
m0_629494732 小时前
LeetCode 热题 100-----14.合并区间
数据结构·算法·leetcode
xin_nai2 小时前
LeetCode热题100(Java)(5)普通数组
算法·leetcode·职场和发展