课堂笔记2026

资料:https://mooc1.chaoxing.com/mooc-ans/coursedata?classId=138902371&courseId=260345163&type=1&ut=s&enc=74091711251f6f13a8b4c1f2360f0c3c&cpi=337151807&openc=0ae2f0ec24f98f30a68f4eec7c043ed3

写在前面

算法分析的基本步骤是: 执行的频次有多少;每次执行的开销是多少。

比较复杂度:区分理论复杂度和实际复杂度

实际复杂度:包括额外空间开销等【比如当数据量较小的时候用插入排序而不是归并(需要递归,额外空间开销等,在CPU上执行更慢(小数据上))】

stable or not

stable:插入,归并,计数,LSD基数

not stable:堆排序

67Intractability(难解性)(P&NP)

Brute-force search:暴力求解

tractable可解的

Intractable(难解/不可解)

概念上理解即可,不需要深入理解

bipartite graph 的 perfect matching

def:

  • 边独立(Disjoint Edges): 如果两条边没有共同的端点,就称它们是独立的。

  • 匹配(Matching): 图中一个任意两条边都没有共同端点的边集合。这意味着,在匹配集合里,任何一个顶点最多只能连接一条边(即没有顶点被"脚踩两只船")。

  • 完美匹配(Perfect Matching): 在匹配的基础上,要求所有顶点都必须被连上(即没有任何一个顶点被"剩下")。【本质是这样一个边的集合P,满足:没有点被剩下,没有点同时连接着P中的多条边】

找一个数的一个因子(nontrivial factor):difficlut

误区:统计是基于占用的比特位来计算的

SAT:布尔可满足性问题

问题定义 (Search Problem): 给定一个布尔方程组(a system of boolean questions),寻找一组使整个方程组都为真(true)的真值指派 (Satisfying Truth Assignment)。如果根本不存在这样的组合,则报告无解。

多项式时间定义 an^b(a,b are constant)

常用复杂度及其英语

注意:nlogn也是poly,2^n不是!

65Randomness

划分:随机的&确定的算法

随机性和stable是什么关系:是两个完全不同的概念

不stable的算法也可以是确定性的,因为随机性必须要有概率、有shuffle这样的操作

例子:下面讲的算法,除了随机快速排序(pivot划分成左右两个子数组,随机选择,所以是随机的)之外,都是确定性算法

uniform distribution:均匀分布

Knuth shuffle:随机排序的实现

在这个过程中,数组被切分为两部分:左边是已经洗牌完毕的区域,右边是还未处理的原始区域。每次迭代,算法都把右边区域的第一个元素,随机插入到左边已洗好的区域中。

对比:LV&MC

拉斯维加斯:结果绝对正确,但耗时看运气。

特性 拉斯维加斯算法 (Las Vegas) 蒙特卡洛算法 (Monte Carlo)
正确性 100% 绝对正确 可能出错(概率可控)
运行时间 随机、不固定(平均很快,最坏很慢) 绝对固定(通常非常快)
核心关注点 怎么用随机性规避最坏时间复杂度 怎么用随机性在有限时间内逼近答案
典型例子 随机化快速排序 (Quicksort)、Quickselect 之前作业里的渗透阈值模拟 (Percolation)

13_45DynamicProgramming动态规划memoization

**动机:**问题解决过程中存在overlapping(重复计算)【重叠子问题】

**第一步:**确定什么是子问题(或者说dpi代表什么)

**状态转移:**从dpn-x得到dpn,转移的方式很多,包括但不限于加减乘除、min-max操作

**tip:**在构造状态转移方程的时候,可以大胆假设dpn-x已经求好了(把他当成已知条件),专注于在dpn-x已经求好这一条件下的状态转移。

**边界处理:**最小子问题的默认值

具体实现还要考虑:

3️⃣确定子问题的计算顺序 (Determine the subproblem order)

  • 核心动作:决定是自底向上(Tabulation,填表)还是自顶向下。如果是填表,必须严格保证在计算当前状态时,它所依赖的所有子状态都已经全部计算完毕。

4️⃣计算最优值并缓存结果 (Compute optimal values while caching results)

  • 核心动作:真正执行计算。

    • Memoization (记忆化搜索):自顶向下递归,查表防止重复计算。

    • Tabulation (迭代填表):自底向上循环,直接顺序推导。

考虑回溯:很多时候我们不仅需要最大价值(比如 3天 或 50元),还需要知道具体怎么凑出来的(比如具体走哪条路、选了哪些物品)。这需要用到 Backtracing (回溯)

斐波那契数组:

递归实现:

归并实现:

线性时间,常量空间

只维护f,g两个变量

复制代码
过程演示(以 n=5 为例):
| 迭代次数 | f(当前项) | g(下一项) |
|----------|-------------|-------------|
| 初始 | 0 | 1 |
| i=0 | 1 | 1 |
| i=1 | 1 | 2 |
| i=2 | 2 | 3 |
| i=3 | 3 | 5 |
| i=4 | 5 | 8 |

上图下方进一步优化:【没有重点讲】

房子喷漆问题

题目:给定一排 n 栋黑色房子,每栋房子 i 粉刷成橙色可以获得利润 profit(i),约束条件是不能粉刷相邻的两栋房子,目标是最大化总利润。

动态规划方法推导:

所有合法方案的可能性推导:斐波那契数列变体

不能粉刷相邻房子的方案数,是 n 的指数级(O (2ⁿ)),增长极快。

递归完成之后回溯

找零钱问题【下面的选项B就是最经典的解法】

动态规划可以保证全局最优,但是贪心法不能保证

动态规划在最短路径中的利用【DAG最短路径算法】

由于 DAG 没有环,因此我们可以先对图进行拓扑排序,得到一个节点序列,保证所有边 u→vu 排在 v 前面。 进行拓扑排序的意义在于,在计算 distTo(v) 时,所有 v 的前驱节点 udistTo(u) 都已经计算完成,无需回溯。

1️⃣标准,但构建反向表比较繁琐

2️⃣更简单实现

特性 Dijkstra 算法 DAG 最短路算法
核心驱动机制 贪心策略,每次从优先队列里挑当前距离最小的节点。 严格遵循拓扑排序的顺序,不需要维护优先队列。
负权边支持 ❌ 无法正确处理负权边。 能完美支持负权边(只要无环即可)。
时间复杂度 O(E \\log V)(使用二叉堆)。 \\Theta(E + V)(线性时间,效率极高)。

PS:最长路径算法可轻松转化得到

动态规划问题中的求最值规问题约到DAG最短路径问题

本质还是要确定dpi的意义,然后列出状态转移方程,状态转移

一般规约思路:

  • 最短路径问题-最值求解问题
  • 图的边权-求最值的对象

Seam Carving【图像缩放技术】

动态规划(DP)在计算机视觉领域最精妙、最著名的实际应用之一:智能图像缩放(Content-aware resizing) ,也被称为 Seam Carving(缝隙裁剪) 算法。

算法核心:如何把它映射成一张 DAG?

为了找到那条"最不重要"的缝隙,算法直接把整张图片看作了一张网格图(Grid Graph):

**节点(Vertices):**像素点

图片的每一个像素 (x, y) 就是图中的一个节点。

边(Edges):向下的 3 种选择

为了让裁剪出的缝隙从上到下保持连续(不能这里挖一个像素,那里挖一个像素,否则画面会断裂),算法规定:每一个像素只能连向它正下方、左下方、右下方的 3 个邻居像素。

  • SW(South-West,左下)

  • S(South,正下)

  • SE(South-East,右下)

这构成了天然的有向无环图(DAG),因为边永远从上指向下,绝不可能往回指。

点权/边权(Weight):"能量函数(Energy Function)"

怎么衡量一个像素"重不重要"?看它周围的颜色变化剧烈程度(通常利用 Sobel 算子求梯度)。

  • 如果一个像素和周围像素颜色差异极大(比如衣服和背景的交界处、人的五官),说明它是边缘/重要信息,能量值很高。

  • 如果一个像素和周围基本一样(比如大片的蓝天、白墙),说明它是冗余信息,能量值很低。

12_55【需要听老师讲,题没弄懂】

RLE

RLE)是一种针对连续重复数据的无损压缩算法,核心逻辑是:把「连续重复的比特」,用「重复次数 + 数据值」的方式来表示。

新的编码长度,取决于最大连续出现的长度

压缩率 16/40 = 40%

重要例题:理解为什么要留一种编码作为"分隔符"

模式串查找SA

思想:对所有后缀数组排序,关注其中前缀部分等于P(模式串)的哪些,因为整体经过排序,所以他们(前缀相同的)肯定分布在一块,SA就是要找到这个区间的左边界和右边界,这个区间内就是前缀为P的。

  • SAs存的是(在后缀数组中)排序为s的后缀所对应在T中的真实下标
  • sp,st是利用二分法找左边界过程中的子区间的左右边界
  • ep,et是利用二分法找右边界过程中的子区间的左右边界
cpp 复制代码
Algorithm SASearch(P, SA, T)
// T: 长度为 n 的文本串, P: 长度为 m 的模式串
// SA: T 的后缀数组

1.  sp <- 1, st <- n + 1
找左边界
2.  While sp < st do
3.      s <- floor((sp + st) / 2)
4.      if P > T[SA[s], SA[s] + m - 1]
5.          then sp <- s + 1
6.          else st <- s
7.  ep <- sp - 1, et <- n
在剩余区间中找右边界
8.  While ep < et do
9.      e <- ceil((ep + et) / 2) //ceil就是向上取整函数
10.     if P = T[SA[e], SA[e] + m - 1]
11.         then ep <- e
12.         else et <- e - 1
13. return (sp, ep)

Analysis

输入变量中,T 是原文本,P 是待查找的模式串,SA 是已经按字典序排好序的后缀数组。

因为 SA 是严格有序的,所以原串中所有以 P 为前缀的后缀,在 SA 数组里一定会紧紧挨在一起,形成一个连续的区间 [sp, ep]。整个算法的核心机制就是跑两次二分查找,分别锁定这个区间的左右边界

1. 寻找左边界(Line 1-6)

  • 目标 :找到第一个以 P 为前缀的后缀的索引 sp (Start Pointer)。

  • 机制 :在 [1, n+1] 区间内二分。每次取中点 s,提取该后缀的前 m 个字符与 P 进行字典序比较。如果 P 字典序更大(第4行),说明匹配项在右侧,左指针推进(sp <- s + 1);否则说明目标在当前位置或更左侧,右边界收缩(st <- s)。最终 sp 会精准停在匹配区间的起始位置。

2. 寻找右边界(Line 7-12)

  • 目标 :找到最后一个以 P 为前缀的后缀的索引 ep (End Pointer)。

  • 机制 :在 [sp-1, n] 区间内二分。因为左边界已经确定为 sp,所以搜索起点直接复用。

  • 精妙之处 :第9行在计算中点 e 时,使用了向上取整 ceil。这里设计很精妙,由于我们要找的是右边界,当 P 刚好等于目标前缀时(第10行),左指针会直接跃迁到 eep <- e)。如果向下取整,当 epet 只差 1 时会陷入死循环,使用 ceil 完美规避了这个问题。如果不匹配,说明找过了头,右指针往回缩(et <- e - 1)。

我们可以用如下结构直观理解这两次二分的作用域:

复制代码
SA 数组下标   对应的后缀 (假设 P="ab")
------------------------------------
...       |  ...
[sp - 1]  |  "aa..."    <- 字典序小于 P
[sp]      |  "aba..."   <- 第一段二分找到的左边界
...       |  "abb..."   
[e]       |  "abc..."   <- 第二段二分游标在区间内跳跃
...       |  "abz..."   
[ep]      |  "abz..."   <- 第二段二分找到的右边界
[ep + 1]  |  "ac..."    <- 字典序大于 P
...       |  ...

Effect

算法最终返回一个元组 (sp, ep)

这个返回值直接框定了所有成功匹配的后缀范围。如果 sp > ep,说明文本 T 中根本不存在模式串 P;如果 sp <= ep,那么 P 在原串中出现的总次数就是 ep - sp + 1

通过这种两次二分的隔离机制,无论 P 在原文本中出现了多少次,单次搜索的时间复杂度都被极其稳定地限制在了 O(m \\log n),完美利用了后缀数组的预处理价值。

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>

using namespace std;

// 核心函数:在文本 T 中利用后缀数组 SA 搜索模式串 P
pair<int, int> SASearch(const string& P, const vector<int>& SA, const string& T) {
    int n = T.length();
    int m = P.length();

    // 1. 寻找左边界 (Start Pointer)
    int sp = 0, st = n; // C++ 为 0-index,搜索区间为 [0, n)
    while (sp < st) {
        int s = sp + (st - sp) / 2; // 默认向下取整
        
        // 提取后缀的前 m 个字符(如果后缀长度不足 m,substr 会自动截取到末尾)
        string suffix_prefix = T.substr(SA[s], m);
        
        if (P > suffix_prefix) {
            sp = s + 1;
        } else {
            st = s;//当P=suffix_prefix时默认改区间右边界st,从而保证最终收敛到全局左边界
        }
    }

    // 2. 寻找右边界 (End Pointer)
    int ep = sp - 1, et = n - 1; 
    while (ep < et) {
        // 向上取整的整型实现:加上 1 使得偏向右侧中点
        int e = ep + (et - ep + 1) / 2; 
        string suffix_prefix = T.substr(SA[e], m);
        
        if (P == suffix_prefix) {
            ep = e;
        } else {
            et = e - 1;
        }
    }

    return {sp, ep};
}

11_51StringSorts 20后面是自学的

索引计数排序

计数排序是一种非比较排序 ,它的时间复杂度是 O(n + k),特别适合固定长度的整数数据(这也为后面的LSD打下了基础)

  • 统计频次:开一个计数数组,统计每个数值出现多少次;
  • 求前缀和 :把计数数组变成「每个值最后应该排在哪个下标位置」;
  • 回填(这里的描述和课件相反,课件是正序) :从原数组从后往前 遍历,按前缀和给的位置放元素,放一个就前缀和减一,保证稳定
LSD 基数排序
  1. 从右向左,逐字节地进行排序,因此取R = 256(2^8)
  2. 对每字节的内部排序,用计数排序这个方法,复杂度是O(n+256)
  3. 所以总共的时间复杂度是 字节数*n

优势:LSD(Least Significant Digit)基数排序特别适合固定长度的整数数据(本质上是内部的计数排序特别适合)。对于 32 位整数,它只需执行约 4 次计数排序(每次处理 8 位)

注意:LSD只是强调了排序的顺序(从右向左排),但是对每一位,可以用任意的stable算法来进行排序。(正式是用计数排序来操作)

MSD
  • 对比:LSD 是从右往左、一轮轮处理所有数据;
  • MSD 是从左往右,先按最高位把数据分到不同的 "桶" 里,再对每个桶递归处理下一位。

这就像查字典:先按首字母分成 26 个组,再对每个组按第二个字母细分,以此类推。

和 LSD 的计数排序逻辑几乎一样,但有两点关键区别:

  1. 它只处理当前子数组 [lo..hi],而不是整个数组
  2. 计数排序完成后,数组按第 d 位分成了 R 个 "桶",每个桶里的元素当前位相同

复杂度:

  • 对于固定长度字符串,总复杂度仍是 O(w·n),和 LSD 一样是线性时间
  • 但在实际中,MSD 会提前终止(短的字符串或相同前缀的子数组),平均性能可能更好

对比例题:关键看MSD的递归深度

3way string quicksort

递归处理,传递参数:+当前排序按照哪一位d,排序组内范围lo-hi

按当前字符分三堆:小、等、大 → 小和大继续按这一位排相等的直接排下一位(++d)!

复杂度:

10_44 Shortest Paths(就是SPT):讲到p40好像就没讲了

(PS:ST是生成树)

SPT 和 MST 关系:完全两回事,没有必然包含 / 等价关系

先记全称:

  • SPT :Shortest Path Tree 最短路径树(单源到所有点最短路
  • MST :Minimum Spanning Tree 最小生成树(整张图连通总边权最小

车载系统,属于single destination:from every vertex to one destination

因为司机中途走错路了可以马上更新路径

  • 起点:当前车辆位置(随时变)
  • 终点:用户设置的目的地(固定)

edgeTo1:到1的路径上的最后那条边(方便从1回溯)

结论:从一个起点 s 到所有节点的最短路径,一定会构成一棵「有向树」,绝对不会是普通图

更新最短路径的过程叫做"松弛"【relaxing】

松弛操作:试图通过边 u→v,优化从起点到 v 的最短路径。

relax all edges incidentfrom v. 松弛所有从 v 出发的边。

incident to the vertex:指向这个顶点

Bellman-Fold
复制代码
// 外层循环:执行 V-1 轮(i从1到G.V()-1,共V-1次),
//第 k 轮循环(从 1 开始)后,可以保证:所有最多经过 k 条边的最短路径,都已经被算出来。
for (int i = 1; i < G.V(); i++) 
  // 中层循环:遍历每个顶点 v
  for (int v = 0; v < G.V(); v++) 
    // 内层循环:遍历顶点 v 的所有邻接边 e(即对每条边执行松弛)
    for (DirectedEdge e : G.adj(v))
      relax(e); // 执行松弛操作

算法的每一轮 "松弛",本质上是在确定路径上第 k 步能到达的最远距离

  • 第 1 轮松弛 :我们能找到所有只经过 1 条边的最短路径。
  • 第 2 轮松弛 :我们能找到所有经过 2 条边的最短路径。
  • ...
  • 第 k 轮松弛:可以保证:所有最多经过 k 条边的最短路径,都已经被算出来。

因为最短路径最多只有 V−1 条边,所以我们只需要执行 V−1 轮。

代码实现中的优化:

1️⃣如果节点 v 的最短距离 distTov 在第 i 轮没有变化,那么第 i+1 轮完全不需要松弛 v 的任何出边。(因为他一定不会带给相邻节点变化)

2️⃣队列实现

贝尔曼福德算法要求:有环可以,只要环的权值之和是正的

解释:

  • 因为如果非正,那么转一圈权值更小了,永远找不到最短路径
  • 如果是非负权环,那么最短路径一定不包含环,不然去掉环权重更小

对比迪杰斯特拉:要求所有权值非负

迪杰斯特拉

Dijkstra 是典型的贪心算法:每次从未标记顶点 中,选择 distTo 值最小的顶点 v,将其加入 S,然后松弛所有从 v 出发的边(更新邻居的 distToedgeTo)。

对比:

  • Dijkstra :累加路径,关心从起点走过来一共多远
  • Prim :只看单边,关心连进集合最便宜的一条边

类方法说明见下

复杂度分析:delMin的复杂度是logV(d叉堆,V节点的复杂度是dlogV,d是常数的话就是logV)

类方法说明(IndexMinPQ 接口)

方法 作用 对应 Dijkstra 中的场景
IndexMinPQ(int n) 创建一个索引范围为 0,1,...,n-1 的优先队列 初始化,容量为图的顶点数 V
void insert(int i, Key key) 将键 key 与索引 i 关联并插入队列 第一次访问顶点 i 时,插入其距离值
int delMin() 删除值最小的键,并返回其关联的索引 取出当前距离源点最近的未标记顶点
void decreaseKey(int i, Key key) 将索引 i 对应的键值更新为更小的 key 松弛边时,更新顶点 i 的最短距离
boolean isEmpty() 判断队列是否为空 判断所有顶点是否都已标记处理完成

维护两个数组:distTo和edgeTo【物理意义distTow:从s,经过当前所有的已标记的点,能到w的最短路径,以及这个路径上离w最近的节点(edgeTo)】

  • Dijkstra 是典型的贪心算法:每次从未标记顶点 中,选择 distTo 值最小的顶点 v,将其加入 S,然后松弛所有从 v 出发的边(更新邻居的 distToedgeTo)。这里的 "局部最优",就是 "当前离源点最近的未标记顶点"。

  • 贪心的全局最优保证:不是 所有贪心策略都能保证全局最优,但 Dijkstra 在非负权图上可以做到。

  • 证明:induction【数学归纳法】:

    • induction base:distTos = 0成立

    • 假设现在已标记的点成立,证明新加入的点也成立

一、核心区别

维度 SPT 最短路径树 MST 最小生成树
目标 固定一个源点 ,保证源到每个点路径最短 不固定源点,保证整棵树所有边权总和最小
关注点 单点到各点路径长度 整张图全局总权重
结构 以源为根的有向 / 无向树 无向连通生成树,无根概念

0943Trees(ST&MST)

  1. square:平方
  2. square root:平方根

ST的三个核心性质【解题重点!】

Kruskal:带权无向连通图

贪心策略:从小到大选边,不构成环就选,直到连通所有点。 本质:按边权升序排序,依次加边,用并查集判环

复杂度分析:

复杂度主要来自两部分:

  1. 对所有边排序 ElogE
  2. 查找,归并 近似常数

合起来接近 O(ElogE)

开销分析:ElogE

Prim:

Prim:从一个起点开始,每次贪心地选「连接已选集合、权值最小的那条边」,把新节点拉进来,直到所有点都加入。

Lazy版:

Prim:Eager

对比:

  • Lazy 在 visit :只是无脑把边加入队列(pq.insert(e)),不管好坏
  • Eager 在 visit :做了择优判断
    • 它维护了 distTo[] 数组,记录每个点到 MST 的当前最小距离
    • 如果新发现的边权(e.weight()比之前记录的更小( weight < distTow ,才更新队列(decreaseKey)。
    • 如果新边权更大 ,直接 忽略,不加入队列。

0904Graph

Priority Queue 【底层是堆】

  • swim
  • sink

选择:关键看,变化之后,影响了上面(父节点)还是下面(子节点),如果让当前节点和父节点的关系变得不确定, 就要swim;反之(与孩子节点)就要sink

delMin操作复杂度:logn【其中n是堆的节点的数量】

insert也是!logn

Mergesort【第三次作业,2.2】