
期末复习周最让人头疼的往往不是题目本身而是整本书里二三十个经典算法全都似曾相识真到考场上让你手写伪代码脑子却一片空白。算法设计与分析这门课考试核心其实就一句话给你一个经典问题让你写出求解过程的伪代码再算一算时间复杂度和空间复杂度。无论是武汉理工还是其他高校期末题型的套路基本都围绕“分治、动态规划、贪心、回溯、分支限界、图算法”这几个大模块展开而伪代码就是所有模块的通用语言。这篇文章我按课程重点顺序把最常考的算法全部过一遍每个算法都给出可以直接背、可以直接用的伪代码模板并标注了容易丢分的细节和复杂度推导思路。不管是平时上课听得云里雾里还是考前三天才打算突击这篇笔记都能当你的“急救包”。建议把每个伪代码都自己动手抄一遍、跑一遍边界用例因为你以为你看懂了和你能在手不抖的情况下写对中间隔着无数次手抖。1. 整体复习思路与考点分布很多同学复习算法设计与分析容易陷入一个误区把大量时间花在看书和看PPT上觉得“看懂了”就等于“会了”。期末考里的伪代码大题恰恰不会给你编程环境甚至不要求你跑通真实代码老师看的只是你的逻辑是否顺畅、边界条件是否完备、复杂度分析是否正确。所以这门的复习主旋律应该是手写、手推、手算。1.1 为什么期末必考伪代码伪代码介于自然语言和真实代码之间不需要关注分号、括号、头文件这种语法细节却又要求你把算法的顺序、分支、循环、递归结构全部表达清楚。老师能在半页纸内把代码逻辑看完也特别方便挖坑来考比如二分查找的区间边界快速排序的partition终止条件动态规划数组是从0开始填充还是从1开始填充。这些细节用真实编程语言写容易因为工具链问题出bug但用伪代码考察的是纯粹的算法思维反而更见基本功。1.2 常见题型与复习优先级我翻了最近几年各大高校的期末试卷包括平时作业和往年考题题目风格大致可以归纳成下面几类复习优先级也一并列出来题型考察内容优先级选择题/填空题时间复杂度阶、算法思想匹配、各类算法适用条件高最好拿分手写伪代码分治、DP、贪心、回溯、分支限界经典算法最高大题基本在这里复杂度推导递归树、主方法、递推方程展开高会背公式也要会算手算过程题填动态规划表、构造哈夫曼树、找关键路径高需要熟练手动模拟算法应用题用学过的方法解决一个“新问题”中套路为先从优先级就能看出来伪代码背诵和手算能力是绝对主体。而这两项能力没有捷径只能靠“重复拆解默写”。1.3 一条可以照做的复习路线如果你时间紧我建议按照“分治一横、DP一纵”的思路复习先花一天把分治类的二分、归并、快排、最接近点对摸熟因为它们的核心是“递归结构合并操作”是后面很多算法的基础再用两天把动态规划几个经典题目彻底吃透因为DP是期末大题的重灾区之后花一天整理贪心经典场景再花一天熟悉回溯和分支限界的代码模板最后留出时间刷图算法和并查集顺带把复杂度的证明套路背下来。下面每一章我都会给出可以直接“抄作业”的伪代码。2. 分治算法必背模板分治法的思想再简单不过把一个规模为n的问题拆成若干个规模更小的子问题分别求解后再合并。但考场上真正写分治伪代码时很多人会漏掉“递归出口”和“合并”这一步。记住一个框架Algorithm DivideAndConquer(P) if |P| n0 return Conquer(P) // 直接求解小规模问题 divide P into P1, P2, ..., Pk // 分解 for i 1 to k yi DivideAndConquer(Pi) // 递归求解 return Merge(y1, y2, ..., yk) // 合并2.1 二分搜索边界条件是最大扣分点二分搜索虽然简单但每次考试都能筛掉一批人问题基本都出在区间边界上。这里我给一个左闭右闭区间版本避免混淆Algorithm BinarySearch(A[0..n-1], x) // 输入有序数组A目标值x // 输出x的下标若不存在返回-1 l 0, r n - 1 while l r mid floor((l r) / 2) if A[mid] x return mid else if A[mid] x r mid - 1 else l mid 1 return -1为什么退出条件是l r而不是l r因为当区间内只剩一个元素时l r这个元素仍然需要再检查一次。如果把等号漏掉就会漏判边界值。同里r mid - 1和l mid 1一定要记得加减1否则可能陷入死循环。复杂度推导也很固定每次规模减半递推方程T(n) T(n/2) O(1)由主方法得O(log n)。空间复杂度如果是递归实现是O(log n)的栈深迭代实现是O(1)。2.2 归并排序合并过程就是看细节的归并排序考的不仅是排序本身还经常顺带考“求逆序对”。它的框架是递归地把数组分成两半分别排好序再合并。核心伪代码如下Algorithm MergeSort(A, l, r) if l r return mid floor((l r) / 2) MergeSort(A, l, mid) MergeSort(A, mid 1, r) Merge(A, l, mid, r) Algorithm Merge(A, l, mid, r) // 将A[l..mid]与A[mid1..r]合并为有序序列存到临时数组tmp中 i l, j mid 1, k 0 while i mid and j r if A[i] A[j] tmp[k] A[i] else tmp[k] A[j] while i mid tmp[k] A[i] while j r tmp[k] A[j] for p 0 to k - 1 A[l p] tmp[p]归并排序的时间复杂度是O(n log n)这一点大家都记得可递推方程要会写T(n) 2T(n/2) O(n)用主方法或递归树展开最后一层总代价是O(n)共有log n层所以总计O(n log n)。如果题目考“求逆序对”只需要在 Merge 里加一行当A[i] A[j]时说明左半边剩余的所有元素从 i 到 mid都和当前A[j]构成逆序对答案累加mid - i 1。这是最常考的一个变体一定要理解这个计数原理。2.3 快速排序partition是灵魂快速排序考的频率不比归并排序低尤其喜欢考“一趟排序后的序列状态”或者“Partition 函数的写全”。我推荐最不容易写错的 Lomuto 划分版本Algorithm QuickSort(A, l, r) if l r return p Partition(A, l, r) QuickSort(A, l, p - 1) QuickSort(A, p 1, r) Algorithm Partition(A, l, r) // 选择A[r]作为主元 pivot A[r] i l - 1 for j l to r - 1 if A[j] pivot i i 1 swap(A[i], A[j]) swap(A[i 1], A[r]) return i 1Lomuto 划分的思路是维护一个“小于等于主元”的区间i是区间最后一个位置j是扫描指针扫描到比主元小的元素就把它换到前面。这个写法比 Hoare 的双向扫描更难写错期末答题优先用它。注意快速排序平均情况是O(n log n)最坏情况O(n^2)比如数组已经有序且每次都取最后一个元素为主元。很多同学只写“平均O(n log n)”而忘记最坏情况考试是要扣分的。快速排序不稳定但归并排序稳定这个也常出填空题。2.4 大整数乘法与矩阵乘法能写思路就行大整数乘法和 Strassen 矩阵乘法在有些学校是选讲内容但期末选择题偶尔会问它们的复杂度。Karatsuba 算法把两个n位大数相乘的复杂度从O(n^2)降到O(n^(log2 3))约等于O(n^1.585)Strassen 矩阵乘法把普通矩阵乘法的O(n^3)降到O(n^(log2 7))约等于O(n^2.807)。如果考伪代码通常只需要写出“把矩阵分块成四个子矩阵用7次乘法和若干次加法递归计算”这个结构不要求把7个式子全默写出来但最好能说出“相比于直接分块8次乘法减少了一次矩阵乘法”这句话。3. 动态规划必背模板动态规划是算法期末的重头戏分值大、题型多也是最容易拉开差距的部分。很多人觉得动态规划难是因为没有形成固定套路。其实期末考的DP题基本都是经典模板题解题顺序完全可以固定下来定义状态、写转移方程、确定初始条件、确定遍历顺序最后手算验证一个例子。3.1 0-1背包二维DP和一维优化都要会0-1背包问题描述有n个物品每个物品重量w[i]、价值v[i]背包容量W每件物品只能选一次求能装下的最大价值。定义dp[i][j]表示“前i个物品中选出总重量不超过j的物品能获得的最大价值”。状态转移if j w[i] dp[i][j] dp[i-1][j] else dp[i][j] max(dp[i-1][j], dp[i-1][j-w[i]] v[i])这里i从1到nj从0到W。初始化dp[0][j] 0。最终答案就是dp[n][W]。需要格外注意的是在考场中如果你写的是二维DP物品下标建议从1开始这样转移方程里dp[i-1]不会出现负下标。但数组本身在纸上写的时候下标从0还是从1要和你的伪代码保持一致不要一会儿从0一会儿从1。一维优化的写法也很常考。因为dp[i][j]只依赖于dp[i-1][…]我们可以把第一维压掉但要小心内层遍历容量时必须倒序。for i 1 to n for j W downto w[i] dp[j] max(dp[j], dp[j - w[i]] v[i])为什么j一定要从大到小因为正序的话dp[j - w[i]]可能在当前这一轮已经被更新过了也就是同一个物品被重复选了第二次这就变成了完全背包。这个知识点几乎是必考必须理解清楚。3.2 最长公共子序列LCS要会填表还要会回溯LCS 问题定义给定两个字符串X[1..m]、Y[1..n]求它们最长公共子序列的长度。设dp[i][j]表示X[1..i]和Y[1..j]的 LCS 长度。转移方程if X[i] Y[j] dp[i][j] dp[i-1][j-1] 1 else dp[i][j] max(dp[i-1][j], dp[i][j-1])初始化dp[0][j] 0dp[i][0] 0。时间复杂度O(m*n)空间复杂度O(m*n)。期末考试喜欢考“画出DP表并写出LCS”。光会用递推公式不够还要会回溯从dp[m][n]出发如果X[i] Y[j]说明这个字符在LCS中向左上移动并记录否则比较dp[i-1][j]和dp[i][j-1]向较大的那个方向移动。如果相等任选一个方向即可因为这时候两个方向的LCS长度相等但具体的LCS序列可能不同。3.3 矩阵链乘法区间DP的典范矩阵链乘法Matrix Chain Multiplication给定一系列矩阵A1, A2, ..., An矩阵Ai的规模是p[i-1] × p[i]要求完全加括号使标量乘法次数最少。定义dp[i][j]为“计算Ai…Aj所需的最小乘法次数”转移方程dp[i][j] min{ dp[i][k] dp[k1][j] p[i-1] * p[k] * p[j] } 其中 i k j初始化dp[i][i] 0而对角线之外的dp[i][j]初始化为无穷大。遍历顺序非常关键先枚举区间长度 len再枚举左端点 i然后枚举分割点 k。for len 2 to n for i 1 to n - len 1 j i len - 1 dp[i][j] INF for k i to j - 1 dp[i][j] min(dp[i][j], dp[i][k] dp[k1][j] p[i-1] * p[k] * p[j])很多新手第一次写会直接for i 1 to n、for j i1 to n然后发现dp[k1][j]还没算出来。原因就是没有按区间的长度从短到长递推。期末如果考这个答题时一定要把“最外层循环是区间长度”写在显眼位置老师一眼就能看出你理解了区间DP的本质。3.4 编辑距离三种操作一个都不能少编辑距离Edit Distance将字符串A变成B允许插入、删除、替换一个字符求最少操作次数。定义dp[i][j]表示A[1..i]变成B[1..j]的最少操作次数初始化dp[i][0] idp[0][j] j。转移if A[i] B[j] dp[i][j] dp[i-1][j-1] else dp[i][j] 1 min(dp[i-1][j-1], dp[i-1][j], dp[i][j-1])其中dp[i-1][j-1]对应替换dp[i-1][j]对应删除A[i]dp[i][j-1]对应在A中插入B[j]。编辑距离时间复杂度O(m*n)空间也能优化到O(min(m,n))期末如果只让写伪代码写二维版本即可但最好在复杂度分析时提一句“可以用滚动数组优化空间”。4. 贪心算法必背模板贪心算法和动态规划最大的区别是贪心每一步都做出当前看起来最优的选择并且之后不回头。它不是对所有问题都有效期末考贪心题时除了写算法还经常要求“证明贪心选择的正确性”。很多学生只背代码不会证明这里我把常用的证明套路一并写出来。4.1 活动选择问题按结束时间排序是标准解活动选择问题是贪心最经典的入门题。有n个活动每个活动有开始时间s[i]和结束时间f[i]同一时间只能做一个活动求能参与的最多活动数。贪心策略按结束时间从小到大排序每次都选“结束时间最早的且与已选活动不冲突”的活动。Algorithm ActivitySelect(s[1..n], f[1..n]) // 假设活动已按f[i]升序排序 A {1} j 1 for i 2 to n if s[i] f[j] A A ∪ {i} j i return A正确性证明用“交换论证”假设最优解中第一个活动是 k而贪心选择的是结束时间最早的1因为f[1] f[k]所以把 k 换成1不会影响后续活动选择也不会减少活动数量。以此类推不断替换贪心解不差于最优解。这个证明套路在期末考试里可以直接用。4.2 哈夫曼编码优先队列加贪心合并哈夫曼编码常用于构造最优前缀码。给定n个字符出现的频率要求构造一棵带权路径长度WPL最小的二叉树。贪心策略很明确每次从优先队列中取出两个权值最小的节点合并成一个新节点新节点权值为两者之和再放回队列直到只剩一个节点。Algorithm Huffman(C) Q C // 每个字符作为单节点树按权值入优先队列 for i 1 to n - 1 z new Node z.left x ExtractMin(Q) z.right y ExtractMin(Q) z.weight x.weight y.weight Insert(Q, z) return ExtractMin(Q)如果期末让你“给定一组权值构造哈夫曼树”一定要手动模拟这个过程不能只写代码。WPL的计算方式是其值等于所有叶子节点的权值乘以它们所在的深度之和也可以理解为每次合并时把两个子节点的权值累加到总代价中即WPL x.weight y.weight。这两种方法算出来的结果一样后者在模拟时更好用不容易漏加。4.3 单源最短路径Dijkstra是必考算法Dijkstra算法适用于边权非负的图求解从源点s到所有其他顶点的最短路径长度。Algorithm Dijkstra(G, w, s) for each vertex v in V dist[v] INF visited[v] false dist[s] 0 使用优先队列Q初始插入(s, 0) while Q is not empty u ExtractMin(Q) if visited[u] continue visited[u] true for each edge (u, v) in E if dist[v] dist[u] w(u, v) dist[v] dist[u] w(u, v) 将(v, dist[v])插入Q return dist期末考试经常考手动执行过程从源点出发每次从未访问顶点中选出距离最小的点松弛它出边连接的顶点。这个手算流程一定要练熟。复杂度方面用普通数组实现是O(V^2)用二叉堆/优先队列实现是O((VE) log V)。优先队列实现时要注意一个顶点可能被重复插入队列多次所以取出时要判断是否已经访问过。4.4 最小生成树Prim和Kruskal都要会最小生成树两种经典算法Prim算法适合稠密图从任意一个顶点开始不断把“连接已选顶点集合和未选顶点集合的最小权值边”加入树中代码结构和Dijkstra很像区别是更新的数组从“到源点距离”变成了“到当前已选集合的距离”。Kruskal算法适合稀疏图把所有边按权值从小到大排序然后用并查集判断是否会形成环不会形成环就加入。Algorithm Kruskal(G, w) A {} 将所有边按权值从小到大排序 for each edge (u, v) in sorted edges if Find(u) ! Find(v) A A ∪ {(u, v)} Union(u, v) return A如果考手算Kruskal只需要“从小到大选边不成环就选”这一句话就能模拟Prim 则需要每次都检查当前已选点集的所有邻边选取最小的一条并保证不产生环。两者复杂度要记清楚Prim 用邻接矩阵是O(V^2)Kruskal 主要开销在排序复杂度O(E log E)。5. 回溯与分支限界必背模板回溯和分支限界都是系统搜索解空间的方法。回溯对应深度优先搜索分支限界对应广度优先搜索或优先队列式搜索。期末对回溯的代码要求更高因为它的解空间树结构子集树、排列树经常单独出题。5.1 回溯算法的通用框架回溯法最通用的伪代码如下Algorithm Backtrack(t) if t n output(x) // 找到一个解 else for i 1 to 可选值个数 x[t] 选择i if Constraint(t) and Bound(t) Backtrack(t 1)这里的Constraint(t)是约束函数Bound(t)是限界函数。期末喜欢考“画出解空间树”“判断某个剪枝是否合理”。如果你对一棵子集树调用回溯复杂度是O(2^n)排列树是O(n!)。这两个阶别必须能脱口而出。5.2 n皇后问题回溯的典型代表在 n×n 棋盘上放置n个皇后使它们互不攻击。用一维数组x[1..n]表示第i行皇后所在的列号。当在第 t 行第 i 列放置皇后时约束条件是不同的皇后不能在同一列x[k] ! x[t]不能在同一对角线|x[k] - x[t]| ! |k - t|。Algorithm NQueens(t) if t n count count 1 // 找到一个解 else for i 1 to n x[t] i if Place(t) NQueens(t 1) Algorithm Place(t) for k 1 to t - 1 if x[k] x[t] or abs(x[k] - x[t]) abs(k - t) return false return truen皇后问题的解空间树是排列树的变种因为每一行只能放一个皇后每一列也只能被一个皇后占用。手算时常用的技巧是“对角线判断用行差和列差是否相等”这一点在考试中写abs函数即可不需要过度展开。5.3 装载问题与0-1背包回溯限界函数的威力0-1背包用回溯法时除了约束cw w[i] W当前重量加新物品重量不能超过容量之外更重要是限界函数。如果当前已装价值cp加上剩余所有物品的总价值rp都无法超过当前最优值bestp就可以直接剪枝。伪代码可以这样写Algorithm BacktrackKnap(i) if i n bestp max(bestp, cp) return if cw w[i] W cw cw w[i] cp cp v[i] BacktrackKnap(i 1) cw cw - w[i] cp cp - v[i] if cp rp - v[i] bestp BacktrackKnap(i 1) rp 剩余物品的总价值初始给定很多同学会漏掉最后一步“不选当前物品”时的限界条件。如果不加这个剪枝回溯就会退化成暴力枚举所有子集复杂度仍然是O(2^n)加上限界函数之后实际搜索规模会大幅缩小。注意限界条件写成cp rp bestp是一种更保守但仍正确的写法也可以写成cp rp - v[i] bestp含义是“即使不选i剩余物品全选上仍有可能超过当前最优就继续搜”这两种写法考试中都能接受但最后一种剪得更狠。如果要求写出0-1背包的“子集树”结构每一层代表对一个物品决定“选”或“不选”左子树表示选右子树表示不选叶子节点数量是2^n。5.4 分支限界法FIFO式与优先队列式的区别分支限界法用广度优先或“最小耗费优先”方式搜索解空间树。0-1背包的分支限界法常用优先队列按当前已装价值或上界排序每次优先扩展上界最大的节点找到的第一个可行解往往就是最优解。 FIFO队列版则是一层层扩展逻辑简单但搜索空间更大。考试中分支限界不一定要求写完整代码但至少要能够说明队列式分支限界是用队列管理活结点优先队列式是用优先队列管理活结点分支限界和回溯的区别在于回溯是DFS深度优先、一次只保留一条路径分支限界是BFS/优先队列式、需要维护一个活结点表。6. 图算法与并查集速记图算法这部分内容看着多但期末常考的核心其实就几个DFS、BFS的伪代码、拓扑排序、并查集。关键路径在部分学校的考纲里会出现但相对低频。6.1 DFS与BFS两种遍历必须背熟DFS可以用递归写也可以用显式栈考试优先写递归版本最省事Algorithm DFS(G, v) visited[v] true for each neighbor u of v if not visited[u] DFS(G, u)BFS用队列Algorithm BFS(G, s) for all v in V, visited[v] false visited[s] true Q {s} while Q is not empty u Dequeue(Q) for each neighbor v of u if not visited[v] visited[v] true Enqueue(Q, v)DFS 常用来判断连通分量数量、检测环BFS 可以用来求无权图的最短路径。两种遍历的时间复杂度都写O(V E)或者O(n m)注意写成O(n*m)会直接丢分。6.2 拓扑排序Kahn算法拓扑排序针对有向无环图。Kahn 算法的核心是反复找入度为0的顶点Algorithm TopologicalSort(G) for each vertex v in G compute indegree[v] 将入度为0的顶点入队Q count 0 while Q is not empty u Dequeue(Q) 输出u count count 1 for each neighbor v of u indegree[v] indegree[v] - 1 if indegree[v] 0 Enqueue(Q, v) if count ! V 说明图中有环如果期末问“能否用DFS求拓扑排序”答案是可以DFS搜索完成顺序的反序就是拓扑序。但Kahn算法更好写也更好证明正确性优先背这个。6.3 并查集路径压缩和按秩合并Kruskal算法以及很多图相关的题目都依赖并查集。并查集两个核心操作找根和合并代码很短Algorithm Find(x) if parent[x] ! x parent[x] Find(parent[x]) // 路径压缩 return parent[x] Algorithm Union(x, y) rootX Find(x) rootY Find(y) if rootX rootY return if rank[rootX] rank[rootY] parent[rootX] rootY else if rank[rootX] rank[rootY] parent[rootY] rootX else parent[rootY] rootX rank[rootX] rank[rootX] 1期末考试如果让手写并查集通常不会卡在“按秩合并”这一步但路径压缩不写会扣分。复杂度方面同时使用路径压缩和按秩合并时单次操作的均摊时间复杂度近似O(alpha(n))其中alpha是阿克曼函数的反函数增长极慢可以当作常数阶。7. 考场高频陷阱与背题技巧最后这部分我不再列新算法而是把前面所有内容里最容易在考场上“莫名丢分”的点梳理一遍。这些不是理论问题全是实践经验。7.1 三个最容易写错的地方第一个是动态规划的初始化。0-1背包、LCS、编辑距离都有独立的初始化边界很多同学在写伪代码时直接写双重循环忘了dp[i][0] 0或dp[0][j] 0。应该记住动规题先写初始化、再写转移顺序不要乱。第二个是回溯里的恢复现场。选了当前物品递归下去之后要把cw和cp减回来把visited标回false否则下一次分支会带着错误状态继续搜索。考试时哪怕整体思路对恢复现场漏写代码就不完整分数会大打折扣。第三个是复杂度分析写错。比如归并排序递推方程写错成T(n) 2T(n/2) n^2这种二分搜索写成O(n^2)Dijkstra 用堆优化却写成O(V^2)。建议考前把每个高频算法的递推方程和最终复杂度列成一张表反复看几遍。7.2 手算题怎么练才高效遇到“填DP表”或者“构造哈夫曼树”这类手算题技巧是“慢一点但别跳步”。我复习时会找一张A4纸把0-1背包问题画成一个行数为物品数1、列数为容量1的表格一格一格填。填到一半如果发现某个位置不知道值怎么来就说明转移方程还没吃透这时回头看伪代码比闷头背十遍都有效。哈夫曼树的手算也类似每合并一次就把两个最小的权值圈出来再画一个新节点直到所有节点合并成一棵树。最后算WPL时建议在纸上把每个叶子节点的权值和深度写在旁边防止漏算。7.3 关于“拓展考点”的一点提醒有些高校的期末试卷会出现看似超纲的内容比如 MapReduce 相关的伪代码、以及一些算法设计理念的应用题。这类题目本质上是把已经学过的思想搬到一个新场景里比如 Map 阶段对应并行拆分Reduce 阶段对应结果合并本质上还是分治的思路。遇到不会的题不要慌往“分治、贪心、动态规划、回溯”几个方向上去套。如果把 MapReduce 类的题出成了编程题那就需要你在真实代码里实现一个 mapper 和 reducer这已经不是纯伪代码能解决的了需要额外练一练实际编程。7.4 考前两天的背诵清单这里是我个人比较推荐的一个“应急清单”把它当作最后冲刺的目录就行模块必背内容自查标志分治二分搜索、归并排序、快速排序能默写边界条件能写出递推方程DP0-1背包、LCS、矩阵链乘、编辑距离能填表能回溯能说清遍历顺序贪心活动选择、哈夫曼、Dijkstra、Prim/Kruskal能用交换论证证明活动选择回溯/分支限界n皇后、0-1背包回溯、通用框架能画出子集树能写剪枝函数图算法DFS/BFS、Kahn拓扑排序、并查集能说出复杂度能处理环的判断最后再分享一个我自己的小习惯每次背完一个算法的伪代码我会在本子上手写一遍然后用一个非常小的样例去“人肉跑”一遍比如二分查找就在纸上写[1,3,5,7]找5一步一步把l、r、mid的变化全写出来。这个过程会暴露大量你以为自己知道但其实不知道的细节。算法设计与分析这门课向来不是光靠看就能过的拿起笔把每个伪代码“写”进肌肉记忆考场上那些看似突然冒出来的大题其实都只是你练过无数遍的旧相识。