Skip to content
HeZzz
Go back

算法导论-2021fa-回忆版

算法导论 2021年秋季学期 的回忆版真题,来源于计算机速通之家 | QQ 群号:468081841

本文连载于算法导论-2021fa-回忆版| HeZzz.

🙇‍♂️🙇‍♂️🙇‍♂️时间仓促,有不足之处烦请及时告知。邮箱hez2z@foxmail.com 或者在 速通之家 群里 @9¾

第一题(10分)

题目描述

给出了一个大整数乘法算法的描述,对于 nn 位乘法,仅作 33n/2n/2 位的乘法和 11nn 位的加法即可。

  1. 根据题意写出递归公式(5分)
  2. 求算法复杂度(5分)

解答

分治法 - 大整数乘法(Karatsuba算法):

设两个 nn 位数为 XXYY,将它们分成两半:

X=A×10n/2+BX = A \times 10^{n/2} + B Y=C×10n/2+DY = C \times 10^{n/2} + D

传统方法需要 4 次乘法:ACAC, ADAD, BCBC, BDBD

Karatsuba算法只需 3 次乘法:

则:

X×Y=P1×10n+(P3P1P2)×10n/2+P2X \times Y = P_1 \times 10^n + (P_3 - P_1 - P_2) \times 10^{n/2} + P_2

1. 递归公式:

T(n)=3T(n/2)+O(n)T(n) = 3T(n/2) + O(n)

其中 3T(n/2)3T(n/2) 表示 3 次 n/2n/2 位的乘法,O(n)O(n) 表示加法和移位操作。

2. 算法复杂度:

根据主定理(Master Theorem):T(n)=aT(n/b)+f(n)T(n) = aT(n/b) + f(n)

这里 a=3a = 3, b=2b = 2, f(n)=O(n)f(n) = O(n)

比较 nlogba=nlog23n1.585n^{\log_b a} = n^{\log_2 3} \approx n^{1.585}f(n)=O(n)f(n) = O(n)

因为 nlog23>nn^{\log_2 3} > n,满足主定理第一种情况,所以:

T(n)=O(nlog23)O(n1.585)T(n) = O(n^{\log_2 3}) \approx O(n^{1.585})

第二题(10分)

题目描述

现有程序设计比赛公示名单,已经按照学号非降序排列好了,现在插入学号为 xx 的学生。

  1. 写出算法描述,如何找到 xx 之前的学生的最大序号 iixx 之后的学生的最小序号 jj(5分)
  2. 最坏情况下的算法复杂度(5分)

解答

分治法 - 二分搜索:

1. 算法描述:

使用二分搜索在有序数组中找到插入位置:

算法:BinarySearchInsertPosition(A, n, x)
输入:有序数组A[1..n],待插入值x
输出:x之前的最大序号i和之后的最小序号j

begin
    left = 1, right = n
    
    while left <= right do
        mid = (left + right) / 2
        
        if A[mid] < x then
            left = mid + 1
        else if A[mid] > x then
            right = mid - 1
        else  // A[mid] == x,找到相等元素
            i = mid - 1
            j = mid + 1
            return (i, j)
        end if
    end while
    
    // 未找到相等元素,left是插入位置
    i = left - 1
    j = left
    return (i, j)
end

说明:

2. 时间复杂度:

二分搜索每次将搜索范围减半,递归公式为:

T(n)=T(n/2)+O(1)T(n) = T(n/2) + O(1)

根据主定理,时间复杂度为:

T(n)=O(logn)T(n) = O(\log n)

第三题(10分)

题目描述

开了个小卖部,给出 1-12 月各月的盈利,含负数(负数表示亏损),求连续月份的最大盈利和。

  1. 写出算法描述(5分)
  2. 最坏时间复杂度分析(5分)

解答

动态规划 - 最大子段和(Maximum Subarray):

1. 算法描述:

算法:MaxSubarraySum(profit, n)
输入:盈利数组profit[1..n],n=12(月份数)
输出:连续月份的最大盈利和

begin
    maxSum = profit[1]     // 全局最大值
    currentSum = profit[1] // 当前子段和
    
    for i = 2 to n do
        // 状态转移:要么加入当前元素,要么从当前元素重新开始
        currentSum = max(profit[i], currentSum + profit[i])
        // 更新全局最大值
        maxSum = max(maxSum, currentSum)
    end for
    
    return maxSum
end

动态规划思路:

定义 dp[i]dp[i] 为以第 ii 个月结尾的最大连续盈利和,则状态转移方程为:

dp[i]=max(profit[i],dp[i1]+profit[i])dp[i] = \max(profit[i], dp[i-1] + profit[i])

最终答案为:

max1indp[i]\max_{1 \leq i \leq n} dp[i]

2. 时间复杂度:

算法只需遍历一次数组,每次进行常数时间的比较和更新操作,因此时间复杂度为:

T(n)=O(n)T(n) = O(n)

空间复杂度可优化到 O(1)O(1)(使用两个变量代替数组)。

代码实现:

{% tabs algo-21fa-1 %}

def max_profit(profits):
    n = len(profits)
    if n == 0:
        return 0
    
    max_sum = current_sum = profits[0]
    
    for i in range(1, n):
        current_sum = max(profits[i], current_sum + profits[i])
        max_sum = max(max_sum, current_sum)
    
    return max_sum

# 示例
profits = [5, -3, 4, -2, 6, -1, 2, -8, 3, 7, -4, 2]
print(max_profit(profits))  # 输出最大连续盈利和

{% endtabs %}

第四题(20分)

题目描述

体育馆预约,给出十个人预约的开始时间与结束时间,现在进行安排。

  1. 写出算法描述(10分)
  2. 能安排多少人(5分)
  3. 能安排哪些人(5分)

解答

贪心算法 - 活动安排问题(Activity Selection Problem):

1. 算法描述:

算法:ActivitySelection(activities, n)
输入:n个活动,每个活动有开始时间start[i]和结束时间end[i]
输出:最多可安排的活动数量及活动列表

begin
    // 步骤1:按结束时间升序排序
    Sort activities by end time in ascending order
    
    // 步骤2:贪心选择
    selected = [1]  // 选择第一个活动
    lastEnd = end[1]  // 记录最后选择的活动的结束时间
    
    for i = 2 to n do
        // 如果当前活动的开始时间 >= 上一个活动的结束时间
        if start[i] >= lastEnd then
            selected.append(i)  // 选择该活动
            lastEnd = end[i]    // 更新结束时间
        end if
    end for
    
    return (selected.length, selected)
end

贪心策略: 总是选择结束时间最早的活动,这样能为后续活动留出更多时间。

正确性证明: 通过数学归纳法可证明,选择结束时间最早的活动不会影响最优解。

2 & 3. 具体计算示例:

假设十个人的预约时间如下(编号,开始时间,结束时间):

编号开始时间结束时间
114
235
306
457
539
659
7610
8811
9812
10214

排序后(按结束时间): 1(1,4), 2(3,5), 3(0,6), 4(5,7), 5(3,9), 6(5,9), 7(6,10), 8(8,11), 9(8,12), 10(2,14)

贪心选择过程:

答案:

代码实现:

def activity_selection(activities):
    # activities: [(id, start, end), ...]
    # 按结束时间排序
    activities.sort(key=lambda x: x[2])
    
    selected = [activities[0][0]]  # 选择第一个活动的ID
    last_end = activities[0][2]
    
    for i in range(1, len(activities)):
        activity_id, start, end = activities[i]
        if start >= last_end:
            selected.append(activity_id)
            last_end = end
    
    return len(selected), selected

# 示例
activities = [
    (1, 1, 4), (2, 3, 5), (3, 0, 6), (4, 5, 7), (5, 3, 9),
    (6, 5, 9), (7, 6, 10), (8, 8, 11), (9, 8, 12), (10, 2, 14)
]

count, selected_ids = activity_selection(activities)
print(f"最多安排 {count} 人")
print(f"安排的人:{selected_ids}")

第五题(20分)

题目描述

有五堆石子,数量已知。

  1. 只能取两个相邻石子合并,这一次合并的代价是两堆石子数量之和,求合并成一堆的最小代价(10分)
  2. 可以取任意两堆石子合并,这一次合并的代价是两堆石子数量之和,求合并成一堆的最小代价(10分)

解答

1. 相邻石子合并 - 动态规划

参考OJ题目:沙子的质量

算法描述:

定义 dp[i][j]dp[i][j] 为合并第 ii 到第 jj 堆石子的最小代价。

状态转移方程:

dp[i][j]=minik<j{dp[i][k]+dp[k+1][j]+sum[i][j]}dp[i][j] = \min_{i \leq k < j} \{dp[i][k] + dp[k+1][j] + sum[i][j]\}

其中 sum[i][j]sum[i][j] 表示第 ii 到第 jj 堆石子的总数量。

边界条件:

dp[i][i]=0(单堆石子无需合并)dp[i][i] = 0 \quad (单堆石子无需合并)

代码实现:

def min_merge_cost_adjacent(stones):
    n = len(stones)
    
    # 预计算前缀和
    prefix_sum = [0] * (n + 1)
    for i in range(n):
        prefix_sum[i + 1] = prefix_sum[i] + stones[i]
    
    def get_sum(i, j):
        return prefix_sum[j + 1] - prefix_sum[i]
    
    # dp[i][j] 表示合并区间[i, j]的最小代价
    dp = [[0] * n for _ in range(n)]
    
    # 枚举区间长度
    for length in range(2, n + 1):
        for i in range(n - length + 1):
            j = i + length - 1
            dp[i][j] = float('inf')
            
            # 枚举分割点k
            for k in range(i, j):
                cost = dp[i][k] + dp[k + 1][j] + get_sum(i, j)
                dp[i][j] = min(dp[i][j], cost)
    
    return dp[0][n - 1]

# 示例:五堆石子
stones = [1, 3, 5, 2, 4]
print(f"最小代价: {min_merge_cost_adjacent(stones)}")

示例计算: 石子数量 [1, 3, 5, 2, 4]

2. 任意石子合并 - 贪心算法(哈夫曼)

参考OJ题目:锯木棒、哈夫曼编码

算法描述:

使用哈夫曼算法(Huffman)的思想:每次选择最小的两堆合并。

算法:MinMergeCostAny(stones, n)
输入:n堆石子的数量数组
输出:最小合并代价

begin
    使用最小堆存储所有石子堆
    totalCost = 0
    
    while 堆中元素个数 > 1 do
        取出最小的两堆 a 和 b
        cost = a + b
        totalCost += cost
        将 cost 放回堆中
    end while
    
    return totalCost
end

贪心策略: 总是合并最小的两堆,使得代价较大的合并次数更少。

代码实现:

import heapq

def min_merge_cost_any(stones):
    # 使用最小堆
    heap = list(stones)
    heapq.heapify(heap)
    
    total_cost = 0
    
    while len(heap) > 1:
        # 取出最小的两堆
        first = heapq.heappop(heap)
        second = heapq.heappop(heap)
        
        # 合并代价
        cost = first + second
        total_cost += cost
        
        # 将合并后的结果放回堆中
        heapq.heappush(heap, cost)
    
    return total_cost

# 示例:五堆石子
stones = [1, 3, 5, 2, 4]
print(f"最小代价: {min_merge_cost_any(stones)}")

示例计算: 石子数量 [1, 3, 5, 2, 4]

第六题(15分)

题目描述

打算法比赛,还有五个小时,还有五个题目没做,给出题目价值和预计AC题目时间,利用回溯法求最优解。

  1. 什么集合类型(5分)
  2. 剪枝函数(5分)
  3. 画出解空间树(5分)

解答

回溯法 - 0-1背包问题:

参考OJ题目:Homework(试卷选择问题,本质是0-1背包)

问题建模:

1. 集合类型:

子集树 - 每个题目有两种选择:做或不做

对于 nn 个题目,解空间是一个 2n2^n 节点的完全二叉树:

2. 剪枝函数:

(1) 约束函数(分支剪枝):

cw+wiCcw + w_i \leq C

其中:

含义: 如果选择当前题目会超时,则不进入左子树。

(2) 限界函数(限界剪枝):

cp+rpbestpcp + rp \leq bestp

其中:

含义: 如果当前路径的价值上界都不如已找到的最优解,则不进入右子树。

更精确的上界估计:

rp=j=i+1nvj(假设剩余时间足够)rp = \sum_{j=i+1}^{n} v_j \quad (假设剩余时间足够)

或使用贪心策略估计上界(按性价比排序后的部分背包解)。

3. 解空间树示例:

假设有3个题目(简化示例):

                    根节点(0,0)
                   /          \
              选题1(10,2)    不选题1(0,0)
              /      \          /       \
        选题2(25,5) 不选题2   选题2(15,3) 不选题2(0,0)
         [剪枝]    (10,2)      /    \       /      \
                   /   \    选题3  不选题3  选题3   不选题3
                  ...  ... (23,5) (15,3)  (8,2)   (0,0)
                            [最优]

遍历过程:

  1. 根节点:cw=0, cp=0
  2. 选题1:cw=2, cp=10,继续
  3. 选题1且选题2:cw=5, cp=25,到达叶子
  4. 选题1不选题2:cw=2, cp=10,选题3:cw=4, cp=18
  5. 不选题1选题2:cw=3, cp=15,选题3:cw=5, cp=23 ✓ 最优

代码实现:

{% tabs algo-21fa-2 %}

class KnapsackBacktracking:
    def __init__(self, capacity, weights, values):
        self.capacity = capacity  # 背包容量
        self.weights = weights    # 物品重量
        self.values = values      # 物品价值
        self.n = len(weights)     # 物品数量
        self.best_value = 0       # 最优解价值
        self.best_solution = []   # 最优解方案
    
    def backtrack(self, i, cw, cv, solution):
        """
        i: 当前考虑第i个物品
        cw: 当前重量
        cv: 当前价值
        solution: 当前方案
        """
        if i == self.n:
            # 到达叶子节点
            if cv > self.best_value:
                self.best_value = cv
                self.best_solution = solution[:]
            return
        
        # 计算剩余物品的总价值(上界)
        remaining_value = sum(self.values[j] for j in range(i, self.n))
        
        # 限界剪枝:当前价值+剩余价值上界 <= 最优解,剪枝
        if cv + remaining_value <= self.best_value:
            return
        
        # 尝试选择第i个物品(左子树)
        if cw + self.weights[i] <= self.capacity:
            solution.append(i)
            self.backtrack(i + 1, cw + self.weights[i], cv + self.values[i], solution)
            solution.pop()
        
        # 尝试不选择第i个物品(右子树)
        self.backtrack(i + 1, cw, cv, solution)
    
    def solve(self):
        self.backtrack(0, 0, 0, [])
        return self.best_value, self.best_solution

# 示例:5个题目,5小时
times = [2, 3, 2, 1, 4]     # 所需时间
values = [10, 15, 8, 5, 20] # 题目价值
capacity = 5                 # 时间限制

kb = KnapsackBacktracking(capacity, times, values)
max_value, selected = kb.solve()
print(f"最大价值: {max_value}")
print(f"选择的题目: {[i+1 for i in selected]}")

{% endtabs %}

第七题(15分)

题目描述

给出一个图,点是学生,边表示学生之间存在闺蜜关系,求最大闺蜜团,要求利用分支限界法,使用优先队列。

  1. 什么集合类型(5分)
  2. 剪枝函数(5分)
  3. 画出解空间树(5分)

Clique

解答

分支限界法 - 最大团问题(Maximum Clique Problem):

问题定义:

1. 集合类型:

子集树 - 每个顶点有两种选择:加入团或不加入团

对于 nn 个顶点,解空间是一个 2n2^n 节点的完全二叉树:

2. 剪枝函数:

(1) 约束函数(分支剪枝):

加入顶点 viv_i 的条件:

vjCurrentClique,(vi,vj)E\forall v_j \in \text{CurrentClique}, \quad (v_i, v_j) \in E

含义: 当前顶点必须与已在团中的所有顶点都有边相连,否则不能进入左子树。

(2) 限界函数(限界剪枝):

num=cn+rnbestnnum = cn + rn \leq bestn

其中:

含义: 如果当前团大小加上剩余顶点数的上界都不超过已知最优解,则剪枝。

(3) 优先级(优先队列式分支限界):

使用 num=cn+rnnum = cn + rn 作为优先级:

3. 解空间树示例:

假设有4个学生,邻接关系如下:

图的邻接矩阵:
    1  2  3  4
1 [ 0  1  1  0 ]
2 [ 1  0  1  1 ]
3 [ 1  1  0  1 ]
4 [ 0  1  1  0 ]

边集:{(1,2), (1,3), (2,3), (2,4), (3,4)}

                      根节点
                    ([], rn=4)
                    num=4
                   /          \
              选v1([1],3)    不选v1([],3)
              num=4          num=3
              /      \          /       \
        选v2(剪枝)  不选v2   选v2([2],2) 不选v2([],2)
        v1-v2无边   ([1],2)   num=4      num=2
                    num=3      /    \       /      \
                    /    \   选v3  不选v3  选v3   不选v3
                 选v3  不选v3 ([2,3],1) ...
              ([1,3],1) ...
               num=2
                 /  \
              选v4  不选v4
             (剪枝) ([1,3],0)
             v1-v4  cn=2 ✓
             无边

优先队列遍历顺序(按num降序):

  1. 根节点(num=4)
  2. 选v1(num=4)或不选v1(num=3) → 优先选v1
  3. 选v1不选v2(num=3)或选v2([2],num=4) → 优先选v2
  4. 继续扩展…

最终找到最大团:{2, 3, 4} 或 {1, 2, 3}(大小为3)

代码实现:

{% tabs algo-21fa-3 %}

import heapq

class MaxCliqueNode:
    def __init__(self, current_clique, remaining, cn, rn):
        self.current_clique = current_clique  # 当前团
        self.remaining = remaining            # 剩余顶点
        self.cn = cn                          # 当前团大小
        self.rn = rn                          # 剩余顶点数
        self.num = cn + rn                    # 优先级
    
    def __lt__(self, other):
        # 最大堆:num越大优先级越高
        return self.num > other.num

class MaxCliqueBranchBound:
    def __init__(self, graph):
        self.graph = graph        # 邻接矩阵
        self.n = len(graph)       # 顶点数
        self.best_clique = []     # 最优解
        self.best_size = 0        # 最优解大小
    
    def is_connected_to_all(self, vertex, clique):
        """检查vertex是否与clique中所有顶点相连"""
        for v in clique:
            if not self.graph[vertex][v]:
                return False
        return True
    
    def solve(self):
        # 初始化优先队列
        pq = []
        initial_node = MaxCliqueNode([], list(range(self.n)), 0, self.n)
        heapq.heappush(pq, initial_node)
        
        while pq:
            node = heapq.heappop(pq)
            
            # 限界剪枝
            if node.num <= self.best_size:
                continue
            
            # 到达叶子节点
            if not node.remaining:
                if node.cn > self.best_size:
                    self.best_size = node.cn
                    self.best_clique = node.current_clique[:]
                continue
            
            # 选择第一个剩余顶点
            v = node.remaining[0]
            remaining = node.remaining[1:]
            
            # 尝试加入顶点v(左子树)
            if self.is_connected_to_all(v, node.current_clique):
                left_node = MaxCliqueNode(
                    node.current_clique + [v],
                    remaining,
                    node.cn + 1,
                    len(remaining)
                )
                heapq.heappush(pq, left_node)
            
            # 不加入顶点v(右子树)
            right_node = MaxCliqueNode(
                node.current_clique,
                remaining,
                node.cn,
                len(remaining)
            )
            heapq.heappush(pq, right_node)
        
        return self.best_size, self.best_clique

# 示例图
graph = [
    [0, 1, 1, 0],
    [1, 0, 1, 1],
    [1, 1, 0, 1],
    [0, 1, 1, 0]
]

mc = MaxCliqueBranchBound(graph)
max_size, max_clique = mc.solve()
print(f"最大团大小: {max_size}")
print(f"最大团顶点: {[v+1 for v in max_clique]}")

{% endtabs %}

算法复杂度:

最坏情况:O(2n)O(2^n)(遍历整个解空间树)

通过剪枝可大幅减少实际搜索节点数。


Share this post on:

上一篇
算法导论-2025fa-rxb作业
下一篇
算法导论-2023-回忆版