跳转至
跳转到正文

1. 算法分析

章节导引

本页从《FDS 数据结构基础讲义》拆分而来,保留原章节锚点,方便从旧总览页和旧链接跳转。

学习目标

能判断算法成本来自哪里,并用渐进记号描述主要增长项。

前置知识

基础 C/伪代码、循环、数组和简单函数增长。

建议用时

建议 4–5 小时:渐进记号 2 小时,最大子列和 1–2 小时,二分查找 1 小时。

练习建议

分析 4 段循环复杂度;比较最大子列和四种算法;手写二分查找并说明循环不变量。

参考资料与引用边界

  • 整理者:Lumner。
  • 课程来源:根据 FDS/ 目录下的课件整理;本章对应课件:FDS/DS01_Ch02_Algorithm Analysis(a)-2026.pdf, FDS/DS02_Ch02_Algorithm Analysis(b).ppt
  • 原始讲义文件:note/FDS_数据结构基础讲义.md
  • 引用边界:这是公开学习笔记,不替代课程正式教材、教师课件或考试要求;外部引用时请注明来自本网站整理版。

1.1 算法与程序

算法是有限条指令的集合,按照这些指令执行可以完成特定任务。课件给出的五个条件非常重要:

条件 含义 通俗解释
Input 有零个或多个外部输入 算法可以没有输入,例如打印固定字符串
Output 至少产生一个输出 必须给出结果,否则任务没有完成
Definiteness 每条指令清楚无歧义 “适当处理一下”不是算法步骤
Finiteness 对所有情况都在有限步后终止 死循环程序不是算法
Effectiveness 每条指令足够基本且可执行 步骤不能要求无法实际完成的魔法操作

程序是某种编程语言写出的实现,它不一定有限,例如操作系统长期运行;算法是解决问题的抽象步骤,必须保证有限终止。

1.2 分析什么

实际运行时间依赖机器、编译器、语言、缓存、输入分布等因素。算法课通常关心与机器无关的增长趋势:

  • 时间复杂度:基本操作执行次数随输入规模 \(N\) 如何增长。
  • 空间复杂度:额外内存随 \(N\) 如何增长。
  • 最坏情况:所有输入中代价最大的情况,常用于保证上界。
  • 平均情况:在输入分布已知或可假设时的期望代价。
  • 最好情况:一般只作参考,不能代表算法稳健性。

对循环的基本判断:

代码形态 复杂度
单层循环执行 \(N\) \(O(N)\)
双层独立嵌套循环 \(O(N^2)\)
每轮问题规模减半 \(O(\log N)\)
外层 \(N\) 次,内层每轮减半 \(O(N\log N)\)
递归分成两个规模为 \(N/2\) 的子问题并线性合并 \(O(N\log N)\)

1.3 渐进记号

渐进记号忽略常数和低阶项,保留增长级别。它解决的问题是:当输入规模很大时,哪个算法更能撑住。

记号 定义直觉 常用说法
\(T(N)=O(f(N))\) \(T\) 最多按 \(f\) 的级别增长 上界
\(T(N)=\Omega(g(N))\) \(T\) 至少按 \(g\) 的级别增长 下界
\(T(N)=\Theta(h(N))\) \(T\)\(h\) 同阶 紧确界
\(T(N)=o(p(N))\) \(T\)\(p\) 低一阶 严格小阶

典型增长顺序:

flowchart LR
    A["O(1)"] --> B["O(log N)"]
    B --> C["O(N)"]
    C --> D["O(N log N)"]
    D --> E["O(N^2)"]
    E --> F["O(N^3)"]
    F --> G["O(2^N)"]
    G --> H["O(N!)"]

常用规则:

  • \(T_1(N)=O(f(N))\)\(T_2(N)=O(g(N))\),则 \(T_1+T_2=O(\max(f,g))\)
  • \(T_1(N)=O(f(N))\)\(T_2(N)=O(g(N))\),则 \(T_1T_2=O(fg)\)
  • 多项式只保留最高次项:\(3N^2+10N+7=O(N^2)\)
  • 对数底数在渐进复杂度中只差常数:\(\log_a N=\Theta(\log_b N)\)

1.4 最大子列和:同一问题的四种算法

问题:给定可能含负数的整数序列 \(A_1,A_2,\ldots,A_N\),求连续子列的最大和。如果所有数都为负,课件约定最大和为 0。

算法 1:三重循环,枚举所有区间

int MaxSubsequenceSum(const int A[], int N) {
    int ThisSum, MaxSum = 0;
    for (int i = 0; i < N; ++i) {
        for (int j = i; j < N; ++j) {
            ThisSum = 0;
            for (int k = i; k <= j; ++k)
                ThisSum += A[k];
            if (ThisSum > MaxSum)
                MaxSum = ThisSum;
        }
    }
    return MaxSum;
}

区间有 \(O(N^2)\) 个,每个区间求和又可能是 \(O(N)\),总复杂度 \(O(N^3)\)

算法 2:枚举右端点时累加

int MaxSubsequenceSum(const int A[], int N) {
    int ThisSum, MaxSum = 0;
    for (int i = 0; i < N; ++i) {
        ThisSum = 0;
        for (int j = i; j < N; ++j) {
            ThisSum += A[j];
            if (ThisSum > MaxSum)
                MaxSum = ThisSum;
        }
    }
    return MaxSum;
}

内层不再重复求区间和,总复杂度降为 \(O(N^2)\)。这个优化的核心是复用前一次区间和。

算法 3:分治

最大子列要么完全在左半边,要么完全在右半边,要么跨过中点。

flowchart TD
    A["A[left..right]"] --> B["左半最大"]
    A --> C["右半最大"]
    A --> D["跨中点最大"]
    B --> E["取三者最大"]
    C --> E
    D --> E

递推式为 \(T(N)=2T(N/2)+O(N)\),因此复杂度为 \(O(N\log N)\)

算法 4:在线算法

在线算法只扫描一遍,任何时刻都能给出当前前缀的答案。

int MaxSubsequenceSum(const int A[], int N) {
    int ThisSum = 0, MaxSum = 0;
    for (int j = 0; j < N; ++j) {
        ThisSum += A[j];
        if (ThisSum > MaxSum)
            MaxSum = ThisSum;
        else if (ThisSum < 0)
            ThisSum = 0;
    }
    return MaxSum;
}

关键直觉:如果当前前缀和已经小于 0,那么它只会拖累后面的子列,应该丢弃并从下一个位置重新开始。复杂度 \(O(N)\),额外空间 \(O(1)\)

1.5 二分查找与对数复杂度

二分查找要求数组有序。每次比较后,搜索区间至少减半。

int BinarySearch(const ElementType A[], ElementType X, int N) {
    int Low = 0, High = N - 1;
    while (Low <= High) {
        int Mid = (Low + High) / 2;
        if (A[Mid] < X)
            Low = Mid + 1;
        else if (A[Mid] > X)
            High = Mid - 1;
        else
            return Mid;
    }
    return -1;
}

如果问题规模每次变为原来的一半,最多能减半 \(\log_2 N\) 次,所以时间复杂度为 \(O(\log N)\)

1.6 检查复杂度分析

课件给出一种很实用的实验检验方法:看输入翻倍时运行时间大约乘以多少。

假设复杂度 输入从 \(N\)\(2N\) 理论比值
\(O(N)\) \(T(2N)/T(N)\) 约 2
\(O(N^2)\) \(T(2N)/T(N)\) 约 4
\(O(N^3)\) \(T(2N)/T(N)\) 约 8
\(O(\log N)\) 增长很慢 \(\log(2N)/\log N\)

实验不能证明复杂度,但能发现明显错误。例如你以为算法是 \(O(N)\),测出来翻倍后接近 4 倍,就要检查是否有隐藏的嵌套循环。