Back to notebook

算法设计与分析:期中典型题与模拟试卷

This post is not yet available in English. Showing the original version.
Table of Contents
Table of Contents

一、期中全真模拟导引与规范答题范式

本篇全面覆盖算法设计与分析课程前四章的代表性题型,包括渐近复杂度计算、分治算法设计、六大经典动态规划实验题、贪心算法交换论证,并附带一套标准的 100 分期中模拟试题。

算法设计规范六步法(评分标准对齐):

  1. 数学建模与问题形式化: 清晰定义符号、输入输出映射与优化目标函数;
  2. 状态/循环不变量严密定义: 明确每个状态维度的物理含义与边界;
  3. 算法核心逻辑与伪代码: 写出关键转移方程、贪心选择决策或划分策略;
  4. 算法正确性证明: 运用数学归纳法、循环不变量或交换论证法(Exchange Argument)展开严密证明,杜绝“显而易见”;
  5. 计算复杂度与存储空间分析: 明确给出时间复杂度紧确界 Θ\Theta 与辅助空间复杂度;
  6. 边界条件与特判: 针对空输入、全负数、单元素、极值等边界情况进行处理说明。

二、渐近复杂度与基础概念分析

典型题 1-1:多项式与指数的渐近阶数判定

题意:求 3n2+10n3n^2+10n、n2/10+2nn^2/10+2^n、21+1/n21+1/n 的渐近阶。

推理:

易错辨析:渐近表达式说明的是函数的极限增长阶,而非精确代数消去。若仅写 OO,给出的是上界;要求精确增长阶时应尽量给出紧确界 Θ\Theta。

典型题 1-2:O(1)O(1) 与 O(2)O(2) 的渐近等价性辨析

渐近函数集合完全相同,因为常数 2 可被渐近定义中的正实数常数系数吸收。两次具体操作在物理指令执行上确实可能比一次慢,但在渐近分析中均属于不随输入规模 nn 变化的常数增长阶,因此二者属于同一等价类。

典型题 1-4:计算能力提升对不同复杂度问题规模的影响

设旧机器在时间预算内可处理规模为 nn 的输入,新规模为 NN,满足方程 T(N)≤64⋅T(n)T(N) \le 64 \cdot T(n):

原时间函数不等式推导新规模上限增长特征
3⋅2n3 \cdot 2^n2N≤64⋅2n=2n+62^N \le 64 \cdot 2^n = 2^{n+6}N≤n+6N \le n + 6加法常数级增长(仅增加 6)
n2n^2N2≤64n2  ⟹  N≤8nN^2 \le 64n^2 \implies N \le 8nN≤8nN \le 8n乘法线性级增长(扩大 8 倍)
常数 8时间与规模无关无理论上限在该抽象模型中运行时间不约束规模

由此可明确:对于指数阶算法,即使算力发生巨大提升,输入规模也仅能获得微小的加法增长,必须依靠算法设计优化到多项式时间。


三、递归分治经典题型精解

典型题 2-1:多重集众数与重数问题

题意:给定多重集 SS,求出现次数最多的元素(众数)及其重数。例如 S={1,2,2,2,3,5}S = \{1, 2, 2, 2, 3, 5\},输出众数 2,重数 3。

方法设计:
先执行归并排序(耗时 O(nlog⁡n)O(n\log n)),随后自左向右线性扫描一次(耗时 O(n)O(n))。相同元素在排序后必定物理连续,最大的连续段长度即为众数的重数。

std::pair<int, int> mode(std::vector<int> a) {
    if (a.empty()) return {-1, 0};
    std::sort(a.begin(), a.end());
    int answer = a[0], best = 0;
    for (int i = 0; i < static_cast<int>(a.size()); ) {
        int j = i + 1;
        while (j < static_cast<int>(a.size()) && a[j] == a[i]) ++j;
        if (j - i > best) {
            best = j - i;
            answer = a[i];
        }
        i = j;
    }
    return {answer, best};
}

时间复杂度 O(nlog⁡n)O(n\log n),辅助空间 O(n)O(n)。需注意:众数(Mode)不等于多数元素(Majority Element)。多数元素要求重数严格大于 n/2n/2,而众数仅要求重数在所有候选者中最大。

典型题 2-3:半数集多重集计数与记忆化优化

题意:从自然数 nn 开始,可在其左侧添加一个不大于最近添加数一半的正整数,并递归继续;按多重集计数。例如 set⁡(6)={6,16,26,126,36,136}\operatorname{set}(6) = \{6, 16, 26, 126, 36, 136\},共 6 个。

递推推导:
设 F(n)F(n) 为半数集的大小。

F(n)=1+∑i=1⌊n/2⌋F(i).F(n) = 1 + \sum_{i=1}^{\lfloor n/2 \rfloor} F(i).

逐项手算:F(1)=1F(1)=1,F(2)=F(3)=2F(2)=F(3)=2,F(4)=F(5)=4F(4)=F(5)=4,F(6)=1+1+2+2=6F(6)=1+1+2+2=6。
引入前缀和数组 P(t)=∑i=1tF(i)P(t) = \sum_{i=1}^t F(i),则 F(n)=1+P(⌊n/2⌋)F(n) = 1 + P(\lfloor n/2 \rfloor),自底向上递推可在 O(n)O(n) 时间和 O(n)O(n) 空间内完成求解。

典型题 2-5:含重复元素的全排列生成

题意:输出给定元素集合的所有不同排列,并统计总数量。例如 aacc,输出 6 种不重复排列。

回溯与去重机制:
在递归树的每个位置上,仅针对互不相同的字符分别开设分支;递归前消耗该字符的剩余计数,回溯时恢复。若使用标准库:

std::sort(s.begin(), s.end());
long long count = 0;
do {
    std::cout << s << '\n';
    ++count;
} while (std::next_permutation(s.begin(), s.end()));

不同排列总数为多项式系数 P=n!/∏cfc!P = n! / \prod_c f_c!。输出所有排列的时间下界为 Ω(nP)\Omega(nP)。


四、动态规划经典题与六大实验题精解

典型题 3-1:双机独立任务最优调度

题意:每个作业只能交给机器 A 或机器 B,在两机上所需时间分别为 ai,bia_i, b_i。求完成全部作业所需的最短时间(两机完工时刻的较大者达到极小)。例如 a=(2,5,7,10,5,2)a=(2,5,7,10,5,2),b=(3,8,4,11,3,4)b=(3,8,4,11,3,4)。

状态定义与转移:
定义 Di[t]D_i[t] 为:前 ii 个作业分配完毕、机器 A 累计耗时恰为 tt 时,机器 B 所需的最小时间。

最终全局答案为 min⁡tmax⁡(t,Dn[t])\min_t \max(t, D_n[t])。设 S=∑iaiS = \sum_i a_i,算法时间复杂度为 O(nS)O(nS),空间滚动后为 O(S)O(S),属于伪多项式时间算法。

典型题 3-3:环形石子合并与区间动态规划

题意:环形排布的石子堆,每次合并相邻两堆,新增堆大小为本次得分,求合并为一堆的最小与最大总得分。例如 (4,4,5,9)(4,4,5,9),最小得分 43,最大得分 54。

破环成链与区间 DP:
将数组复制一倍拼接成长度为 2n2n 的线性链。定义 L[i][j]L[i][j] 与 H[i][j]H[i][j] 为合并连续区间 [i,j][i, j] 的最小与最大得分。前缀和为 W(i,j)W(i, j):

L[i][j]=min⁡i≤k<j{L[i][k]+L[k+1][j]+W(i,j)},H[i][j]=max⁡i≤k<j{H[i][k]+H[k+1][j]+W(i,j)}.\begin{aligned} L[i][j] &= \min_{i \le k < j} \big\{ L[i][k] + L[k+1][j] + W(i, j) \big\}, \\ H[i][j] &= \max_{i \le k < j} \big\{ H[i][k] + H[k+1][j] + W(i, j) \big\}. \end{aligned}

按区间长度 r=2→nr = 2 \to n 递增填表。最终扫描全部合法的长度为 nn 的区间 [i,i+n−1][i, i+n-1](1≤i≤n1 \le i \le n),取对应极值。时间复杂度为 O(n3)O(n^3),空间复杂度为 O(n2)O(n^2)。

典型题 3-4:数字三角形自底向上动态规划

题意:从数塔顶端走到最后一层,每次只能走向相邻下一层的两个位置,求沿途数字和的最大值。

自底向上转移:
最后一层状态初始化为节点原本数值,向上递推:

dp[i][j]=a[i][j]+max⁡(dp[i+1][j], dp[i+1][j+1]).dp[i][j] = a[i][j] + \max\big(dp[i+1][j],\ dp[i+1][j+1]\big).

答案为 dp[1][1]dp[1][1]。自底向上递推天然规避了边界越界特判,时间复杂度 Θ(n2)\Theta(n^2),原地更新空间仅需 O(n)O(n)。

典型题 3-6:租用游艇最短路径动态规划

题意:从起点 1 号码头到达终点 nn 号码头,已知任意两码头间的直达租金 r(i,j)r(i, j),求最小总花费。

转移方程:
定义 dp[i]dp[i] 为到达码头 ii 的最小费用(dp[1]=0dp[1] = 0):

dp[i]=min⁡1≤k<i{dp[k]+r(k,i)}.dp[i] = \min_{1 \le k < i} \big\{ dp[k] + r(k, i) \big\}.

时间复杂度 Θ(n2)\Theta(n^2),空间复杂度 O(n)O(n)。

典型题 3-8:序列最小 m 段和动态规划

题意:将包含 nn 个整数的序列恰好划分为 mm 个非空连续段,使得各段元素和的最大值达到极小。输入包含负数。

状态定义与推导:
前缀和记为 PP。定义 F[g][i]F[g][i] 为前 ii 个数恰好划分为 gg 段时的最小最大段和。枚举最后一段的起点 j+1j+1:

F[g][i]=min⁡g−1≤j<imax⁡(F[g−1][j], P[i]−P[j]).F[g][i] = \min_{g-1 \le j < i} \max\big(F[g-1][j],\ P[i] - P[j]\big).

典型题 3-13:数字串最大 k 乘积动态规划

题意:在给定的 nn 位数字串中插入 k−1k-1 个乘号(切分为 kk 个片段),求各片段数值相乘的最大积。

预处理子串数值 V(l,r)V(l, r)。定义 F[g][i]F[g][i] 为前 ii 位切分为 gg 个乘积段时的最大乘积:

F[g][i]=max⁡g−1≤j<i{F[g−1][j]×V(j+1,i)}.F[g][i] = \max_{g-1 \le j < i} \big\{ F[g-1][j] \times V(j+1, i) \big\}.

初始化 F[0][0]=1F[0][0] = 1,不可达状态记为 −1-1。时间复杂度 O(kn2)O(kn^2),空间复杂度 O(n2+kn)O(n^2 + kn)。


五、贪心算法典型题与交换论证推导

典型题 4-1:会场安排与最小会场数分配

题意:安排所有给定的活动,要求使用的会场总数最少。例如活动 (1,23),(12,28),(25,35),(27,80),(36,50)(1,23), (12,28), (25,35), (27,80), (36,50),输出 3。

贪心策略与小根堆维护:
按开始时间升序排列,使用小根堆维护各个会场当前的空闲时间(最后活动的结束时间)。若当前活动开始时间不早于堆顶,则复用该会场并更新其结束时间;否则新开辟会场。时间复杂度 O(nlog⁡n)O(n\log n)。

典型题 4-2:序列最优合并与哈夫曼树模型

题意:合并长度分别为 a,ba, b 的两个序列,比较次数为 a+b−1a+b-1。给定序列长度 5,12,11,25, 12, 11, 2,求最大与最小总比较次数。输出 78 52(最大在前)。

典型题 4-5:磁带程序最优存储问题

题意:磁带长度为 L=50L=50,程序长度分别为 2,3,13,8,80,202, 3, 13, 8, 80, 20。求能存下的最多程序个数。

贪心证明:按长度升序排列(2,3,8,13,20,802, 3, 8, 13, 20, 80)。前 5 个总长为 2+3+8+13+20=46≤502+3+8+13+20 = 46 \le 50,存 6 个总长达 126 越界,故答案为 5。由交换论证法,任意 kk 个程序的总长度不可能低于升序排列前 kk 个程序的和。

典型题 4-6:单窗口最优服务次序与平均等待时间

题意:所有顾客同时到达,服务耗时分别为 56,12,1,99,1000,234,33,55,99,81256, 12, 1, 99, 1000, 234, 33, 55, 99, 812。求使平均等待时间最小的服务次序。

推导:按服务时间升序排列(短作业优先 SJF)。
若相邻两人服务耗时满足 tπ(k)>tπ(k+1)t_{\pi(k)} > t_{\pi(k+1)},交换其服务次序后,仅有第 kk 位被服务顾客的等待时间减少,总等待时间净减少 tπ(k)−tπ(k+1)>0t_{\pi(k)} - t_{\pi(k+1)} > 0。通过消除所有逆序对可严格证明升序排列使总等待时间达到极小。


六、期中全真模拟试卷(自拟 100 分标准卷)

A. 简答论述题(5 题,每题 6 分,共 30 分)

  1. 渐近符号与最坏情况:符号 OO 是否必然代表最坏情况?若某算法的平均时间复杂度为 Θ(nlog⁡n)\Theta(n\log n),能否推断出其最坏情况时间复杂度同样为 Θ(nlog⁡n)\Theta(n\log n)?
  2. 归并排序开销分析:简述归并排序时间复杂度为 Θ(nlog⁡n)\Theta(n\log n) 的推导过程,并说明其辅助空间复杂度来源。
  3. 动态规划与贪心对比:动态规划与贪心算法均要求满足“最优子结构性质”,二者的根本区别何在?应用贪心策略时还必须额外证明何种性质?
  4. 0-1 背包空间压缩:说明 0-1 背包在压缩至一维数组后,容量循环必须逆序进行的数学依据。若题目要求“背包恰好装满”,数组应如何初始化?
  5. Dijkstra 与最小生成树的边权限制:Dijkstra 算法与最小生成树(MST)算法的优化目标有何不同?它们对于图中存在负权边的容忍度是否相同?
点击展开简答题参考答案与评分要点
  1. 参考答案:
    • 否。OO 记号是数学函数渐近上界的定义,可用于最好、最坏或平均情况函数(2分);
    • 不能。平均复杂度为 Θ(nlog⁡n)\Theta(n\log n) 仅能推断出最坏情况复杂度满足下界 Ω(nlog⁡n)\Omega(n\log n),最坏情况可能退化为更高阶(如快速排序最坏情况为 Θ(n2)\Theta(n^2))(4分)。
  2. 参考答案:
    • 归并排序递推式为 T(n)=2T(n/2)+Θ(n)T(n) = 2T(n/2) + \Theta(n),根据主定理 Case 2,每层双指针合并代价为 Θ(n)\Theta(n),递归树深度为 log⁡2n\log_2 n,总耗时为 Θ(nlog⁡n)\Theta(n\log n)(3分);
    • 空间开销包含递归调用栈深度 O(log⁡n)O(\log n) 以及暂存双指针合并结果的动态临时数组 O(n)O(n),因此辅助空间复杂度为主导的 Θ(n)\Theta(n)(3分)。
  3. 参考答案:
    • 动态规划在每个决策阶段保留并枚举所有候选分支,通过表格保存重叠子问题的解;贪心算法在每阶段依据局部准则直接锁定单条最优分支,不回溯(3分);
    • 贪心算法必须额外证明“贪心选择性质”(证明全局最优解集合中必定包含局部的贪心选择),通用证明工具为交换论证法(3分)。
  4. 参考答案:
    • 逆序循环(cc 从 CC 递减到 wiw_i)保证在计算 dp[c] 时,所引用的 dp[c - w_i] 尚未被当前阶段物品覆盖,保留着上一阶段的旧值,严格保证每件物品只选一次;若正序循环则较小容量先被修改,退化为完全背包(3分);
    • 恰好装满时,将 dp[0] 初始化为 0,其余所有 dp[c](c≥1c \ge 1)初始化为 −∞-\infty(不可达标记),转移时仅从大于等于 0 的合法状态扩展(3分)。
  5. 参考答案:
    • Dijkstra 优化源点到各顶点的单源累加路径长度;MST 优化连通全图所有顶点的树边权总和(3分);
    • Dijkstra 严格要求边权非负,负权边直接瓦解贪心定案的单调性依据;MST 算法(Prim/Kruskal)基于割性质,完全允许图中存在负权边,仅要求无向图连通(3分)。

B. 算法设计与分析题(3 题,共 70 分)

B1. 利用分治法统计逆序对(20 分)

给定整数数组 a[0…n−1]a[0 \dots n-1],若 i<ji < j 且 a[i]>a[j]a[i] > a[j],则称 (a[i],a[j])(a[i], a[j]) 为一个逆序对。

  1. 设计一个时间复杂度为 O(nlog⁡n)O(n\log n) 的分治算法;
  2. 说明数组 [3, 1, 2, 1] 的计算过程,给出重复元素的处理规则;
  3. 给出算法正确性证明与时空复杂度分析。
点击展开 B1 参考答案

1. 算法设计:
将逆序对分为三类:左半部分内部、右半部分内部、跨越左右两半。前两项通过递归求解,跨区间逆序对在双指针归并时统计:左右两半排序后,若右侧指针元素小于左侧指针元素,则其与左半段当前位置及其后的所有剩余元素均构成逆序对,一次性累加剩余跨度。

long long countInversions(std::vector<int>& a) {
    std::vector<int> buffer(a.size());
    auto solve = [&](auto&& self, int l, int r) -> long long {
        if (r - l <= 1) return 0;
        int mid = l + (r - l) / 2;
        long long count = self(self, l, mid) + self(self, mid, r);
        int i = l, j = mid, k = l;
        while (i < mid && j < r) {
            if (a[i] <= a[j]) buffer[k++] = a[i++];
            else { count += (mid - i); buffer[k++] = a[j++]; }
        }
        while (i < mid) buffer[k++] = a[i++];
        while (j < r)   buffer[k++] = a[j++];
        for (int p = l; p < r; ++p) a[p] = buffer[p];
        return count;
    };
    return solve(solve, 0, static_cast<int>(a.size()));
}

2. 实例与重复元素:
[3, 1] 内部产生 1 对;[2, 1] 内部产生 1 对;归并 [1, 3] 与 [1, 2] 时,右侧 1 不小于左侧 1(<= 优先出左侧,不产生逆序对),右侧 2 小于左侧 3(产生 1 对),总逆序对为 1+1+2=41 + 1 + 2 = 4。相等元素通过 <= 保持稳定性,不计入逆序对。

3. 证明与复杂度:
三类逆序对互斥且完备;左右局部有序性保证跨越项无遗漏且无重复统计。递推式 T(n)=2T(n/2)+O(n)  ⟹  O(nlog⁡n)T(n) = 2T(n/2) + O(n) \implies O(n\log n);辅助空间为临时数组 O(n)O(n) 与递归栈 O(log⁡n)O(\log n)。

B2. 矩阵连乘积动态规划与括号重建(25 分)

设有四个矩阵 ⟨A1,A2,A3,A4⟩\langle A_1, A_2, A_3, A_4 \rangle,其维度向量为 P=(5,10,3,12,5)P = (5, 10, 3, 12, 5)(即 A1:5×10A_1: 5\times 10,A2:10×3A_2: 10\times 3,A3:3×12A_3: 3\times 12,A4:12×5A_4: 12\times 5)。

  1. 建立动态规划状态转移方程与边界条件;
  2. 列出各区间长度下的状态值,给出最小标量乘法次数;
  3. 给出最优加括号结构,并说明如何通过辅助数组重构最优方案。
点击展开 B2 参考答案

1. 转移方程:
定义 M[i][j]M[i][j] 为子链 Ai⋯AjA_i \cdots A_j 的最少乘法次数:
M[i][j]=min⁡i≤k<j{M[i][k]+M[k+1][j]+pi−1pkpj},M[i][i]=0.M[i][j] = \min_{i \le k < j} \big\{ M[i][k] + M[k+1][j] + p_{i-1} p_k p_j \big\},\qquad M[i][i] = 0.

2. 填表计算:

  • 区间长度 2:
    • M[1][2]=5×10×3=150M[1][2] = 5 \times 10 \times 3 = 150(k=1k=1);
    • M[2][3]=10×3×12=360M[2][3] = 10 \times 3 \times 12 = 360(k=2k=2);
    • M[3][4]=3×12×5=180M[3][4] = 3 \times 12 \times 5 = 180(k=3k=3)。
  • 区间长度 3:
    • M[1][3]=min⁡(M[1][1]+M[2][3]+5×10×12, M[1][2]+M[3][3]+5×3×12)=min⁡(960,330)=330M[1][3] = \min(M[1][1]+M[2][3]+5\times10\times12,\ M[1][2]+M[3][3]+5\times3\times12) = \min(960, 330) = 330(k=2k=2);
    • M[2][4]=min⁡(M[2][2]+M[3][4]+10×3×5, M[2][3]+M[4][4]+10×12×5)=min⁡(330,960)=330M[2][4] = \min(M[2][2]+M[3][4]+10\times3\times5,\ M[2][3]+M[4][4]+10\times12\times5) = \min(330, 960) = 330(k=2k=2)。
  • 区间长度 4:
    • M[1][4]=min⁡(k=1:0+330+5×10×5=580, k=2:150+180+5×3×5=405, k=3:330+0+5×12×5=630)=405M[1][4] = \min(k=1: 0+330+5\times10\times5=580,\ k=2: 150+180+5\times3\times5=405,\ k=3: 330+0+5\times12\times5=630) = 405(k=2k=2)。

最少乘法次数为 405。

3. 方案重构:
最优断开点为 k=2k=2,对应划分为 (A1A2)×(A3A4)(A_1 A_2) \times (A_3 A_4)。引入标记数组 s[i][j]s[i][j] 记录取得极小值时的分割点 kk,通过递归前序遍历打印子区间并添加括号重建方案。

B3. 最小生成树 Kruskal 算法与并查集连通性(25 分)

无向连通带权图顶点为 {A,B,C,D}\{A, B, C, D\},边集为 {(A,B):1, (A,C):4, (B,C):2, (B,D):5, (C,D):3}\{ (A,B):1,\ (A,C):4,\ (B,C):2,\ (B,D):5,\ (C,D):3 \}。

  1. 采用 Kruskal 算法给出选边演练过程与最终生成树总权重;
  2. 解释并查集在该算法中的核心作用与实现逻辑;
  3. 说明算法的正确性依据与时间复杂度。若原图不连通,算法如何有效检测?
点击展开 B3 参考答案

1. 选边推演:
将边集按权重升序排列:(A,B):1, (B,C):2, (C,D):3, (A,C):4, (B,D):5(A,B):1,\ (B,C):2,\ (C,D):3,\ (A,C):4,\ (B,D):5。

  • 选 (A,B):1(A,B):1,端点属于不同分量,加入生成树;
  • 选 (B,C):2(B,C):2,端点属于不同分量,加入生成树;
  • 选 (C,D):3(C,D):3,端点属于不同分量,加入生成树;
  • 当前已选边数达到 V−1=3V - 1 = 3 条,算法终止。
    最小生成树总边权为 1+2+3=61 + 2 + 3 = 6。

2. 并查集作用:
维护顶点的连通分量集合。对于每条候选边 (u,v)(u, v),调用 find(u) 与 find(v) 检测其端点是否同属一个集合。若相同,说明加入该边必将产生环路,直接丢弃;若不同,调用 union(u, v) 合并集合并吸纳该边。

3. 正确性与复杂度:
割性质(Cut Property)保证每次选取的跨连通分量最轻量边必包含在某棵最小生成树中。边排序耗时 O(Elog⁡E)=O(Elog⁡V)O(E\log E) = O(E\log V),带路径压缩与按秩合并的并查集操作耗时 O(Eα(V))O(E\alpha(V)),总时间由边排序主导,为 O(Elog⁡V)O(E\log V)。

  • 不连通检测:若全图遍历完毕后,累计选取的有效边数小于 V−1V - 1,则证明原图不连通,不存在全局最小生成树(仅能得到生成森林)。


0 / 2000
Loading comments...