MIT-6-S905-困惑的人的编程笔记-全-

MIT 6.S905 困惑的人的编程笔记(全)

001:你们都会服从

概述

在本节课中,我们将学习如何通过编程解决一个关于队列中人群帽子朝向的谜题。我们将从问题描述开始,逐步分析,并最终用代码实现解决方案。这个谜题的核心是找到最少的指令,让队列中所有人的帽子朝向一致。

问题描述

你是一名棒球比赛的检票员。排队等候入场的人们都戴着帽子,有些人的帽子朝前戴,有些人的帽子朝后戴。你的任务是让所有人的帽子朝向一致(全部朝前或全部朝后),以便他们入场。每个人都知道自己在队列中的位置(索引从0开始)。你只能发出“翻转帽子”的指令,并且为了节省精力,你可以对连续区间内的人发出一个指令(例如,“位置1到位置2的人,翻转你们的帽子”)。目标是找出需要发出的最少指令数量。

初始思路与算法

上一节我们介绍了问题的基本设定,本节中我们来看看如何设计算法来解决它。

最直观的方法是先找出队列中所有帽子朝向相同的连续区间(我们称之为“区间”)。然后,比较朝前区间和朝后区间的数量,选择数量较少的那种朝向作为目标,并对属于该朝向的所有区间发出翻转指令。

以下是实现此思路的步骤:

  1. 遍历队列,识别并记录所有连续的帽子朝向区间。
  2. 统计朝前区间和朝后区间的数量。
  3. 选择区间数量较少的朝向作为目标。
  4. 针对目标朝向的每个区间,发出一个翻转指令。

优化:单次遍历算法

上一节我们介绍了一个需要两次遍历的算法,本节中我们来看看如何优化为一次遍历。

观察发现,最终选择翻转哪个朝向的帽子,实际上只取决于队列第一个人的帽子朝向。如果第一个人戴的是朝前的帽子,那么最终需要翻转的就是所有戴朝后帽子的人所在的区间,反之亦然。因此,我们可以在一次遍历中,直接根据第一个人的朝向,在遇到另一个朝向的连续区间开始时,立即发出翻转该区间的指令。

以下是优化后的算法步骤:

  1. 在队列末尾添加一个“虚拟”的人,其帽子朝向与第一个人相同,以简化边界处理。
  2. 从第二个人开始遍历队列(索引1)。
  3. 每当发现当前人的帽子朝向与前一个人不同时,就意味着一个新的区间开始了。根据第一个人的朝向决定是否需要为前一个区间发出指令。
  4. 遍历完成后,即得到了所有需要发出的指令。

代码实现

现在,让我们将上述算法转化为Python代码。

首先,我们实现初始的“两次遍历”算法:

def please_conform_naive(caps):
    """
    初始算法:两次遍历
    caps: 一个列表,包含‘F’(朝前)和‘B’(朝后)
    """
    intervals = []
    start = 0
    caps = caps + [‘END‘]  # 添加哨兵值简化边界处理

    # 第一次遍历:找出所有区间
    for i in range(1, len(caps)):
        if caps[start] != caps[i]:
            intervals.append((start, i-1, caps[start]))
            start = i

    # 统计区间数量
    f_count = sum(1 for interval in intervals if interval[2] == ‘F‘)
    b_count = len(intervals) - f_count

    # 决定翻转哪个朝向
    flip = ‘F‘ if f_count > b_count else ‘B‘

    # 第二次遍历:发出指令
    for interval in intervals:
        if interval[2] == flip:
            if interval[0] == interval[1]:
                print(f‘Person at position {interval[0]}, flip your cap!‘)
            else:
                print(f‘People in positions {interval[0]} through {interval[1]}, flip your caps!‘)

接下来,我们实现优化后的“单次遍历”算法:

def please_conform_one_pass(caps):
    """
    优化算法:单次遍历
    caps: 一个列表,包含‘F’(朝前)和‘B’(朝后)
    """
    # 在队列末尾添加第一个人的帽子朝向作为哨兵
    caps = caps + [caps[0]]

    for i in range(1, len(caps)):
        if caps[i] != caps[i-1]:
            if caps[i] != caps[0]:
                # 开始一个新的需要翻转的区间
                print(f‘People in positions {i}‘, end=‘‘)
            else:
                # 一个需要翻转的区间结束
                print(f‘ through {i-1}, flip your caps!‘)

总结

本节课中我们一起学习了“你们都会服从”这个编程谜题。我们从问题描述出发,首先设计了一个直观但需要两次遍历的算法来找出最少的翻转指令。接着,我们通过观察发现了关键优化点——只需关注队列第一个人的帽子朝向,从而将算法优化为仅需一次遍历。最后,我们用Python代码实现了这两种算法。这个谜题虽然简单,但很好地展示了如何通过深入分析问题来优化解决方案和代码。

002:谜题2-聚会的最佳时间 🎉

在本节课中,我们将学习如何解决一个关于选择最佳聚会时间以遇到最多名人的问题。我们将从最直观的暴力解法开始,然后逐步优化,最终得到一个更高效、更优雅的算法。

问题概述

假设你获得了一张名人派对的入场券,但只能在派对期间选择一个特定的一小时时间段参加。派对会公布每位名人出席的精确时间区间。你的目标是选择一个时间段,使得在该时间段内能遇到的名人数量最大化

例如,已知以下名人出席时间表(时间采用24小时制,区间左闭右开,例如 [6, 7) 表示6点整到7点整之间):

  • Beyonce: [6, 7)
  • Taylor: [7, 9)
  • Brad: [10, 11)
  • Katie: [10, 12)
  • Chong: [8, 10)

通过手动计算,我们可以发现,在10点开始的一小时内,可以遇到Brad、Katie和Chong三位名人,这是最优解。

方法一:暴力枚举法

最直接的思路是,枚举所有可能作为起始时间的小时(例如从最早的名人到达时间到最晚的名人离开时间),然后分别计算在每个时间段内有多少位名人在场。

以下是该算法的核心逻辑描述:

  1. 确定时间范围:遍历所有名人时间表,找到最早的开始时间 earliest_start 和最晚的结束时间 latest_end
  2. 枚举并计算:对于 earliest_startlatest_end 之间的每一个整数小时 t(代表时间段 [t, t+1)),执行以下操作:
    • 初始化计数器 count = 0
    • 遍历每一位名人的时间区间 [s, e)
    • 如果 t 满足 s <= t < e,则意味着该名人在时间段 [t, t+1) 内也在场,计数器 count 加1。
  3. 选择最大值:记录下所有 t 对应的 count 值,选择 count 最大的那个 t 作为最佳开始时间。

代码示例(Python):

def best_time_to_party_brute_force(schedule):
    # 1. 确定时间范围
    start = min(s[0] for s in schedule)
    end = max(s[1] for s in schedule)
    
    max_count = 0
    best_time = start
    
    # 2. 枚举每个可能的小时
    for t in range(start, end):
        count = 0
        # 检查每位名人
        for c in schedule:
            if c[0] <= t < c[1]: # 判断时间t是否在名人出席区间内
                count += 1
        # 3. 更新最大值
        if count > max_count:
            max_count = count
            best_time = t
            
    return best_time, max_count

算法分析
这个算法简单易懂,但效率较低。它的时间复杂度是 O(n * m),其中 n 是需要枚举的小时数,m 是名人数量。如果时间粒度更细(例如精确到分钟),或者名人数量很多,计算量会急剧增加。

方法二:基于事件的扫描线算法

上一节我们介绍了直观但低效的暴力枚举法。本节中,我们来看看如何通过观察问题的本质来设计一个更高效的算法。

核心洞察:在场名人数量只会在名人到达或离开的时刻发生变化。在其他任何时间点,人数都是保持不变的。因此,我们无需检查每一个可能的时间点,只需关注这些“事件点”。

以下是优化算法的步骤:

  1. 创建事件列表:将每位名人的到达时间标记为 +1(表示人数增加),离开时间标记为 -1(表示人数减少)。将所有事件点放入一个列表。
  2. 排序事件:将所有事件点按照时间先后顺序排序。如果多个事件发生在同一时间,通常将 +1 事件放在 -1 事件之前处理,以确保逻辑正确。
  3. 扫描并计算:初始化当前人数 current_count = 0 和最大人数 max_count = 0。按顺序处理排序后的事件列表:
    • 遇到 +1 事件:current_count += 1
    • 遇到 -1 事件:current_count -= 1
    • 每次更新 current_count 后,检查并更新 max_count记录下达到 max_count 时对应的事件时间
  4. 输出结果max_count 就是能遇到的最多名人数量,而记录下的时间就是最佳开始时间(注意:这个时间是一个名人到达的时间点)。

代码示例(Python):

def best_time_to_party_smart(schedule):
    events = []
    # 1. 创建事件列表
    for c in schedule:
        events.append((c[0], ‘start’)) # 到达事件
        events.append((c[1], ‘end’))   # 离开事件
        
    # 2. 排序事件:先按时间,再按类型(‘start’ 在 ‘end’ 前)
    events.sort(key=lambda x: (x[0], 0 if x[1] == ‘start‘ else 1))
    
    max_count = 0
    current_count = 0
    best_time = 0
    
    # 3. 扫描事件
    for time, event_type in events:
        if event_type == ‘start’:
            current_count += 1
            # 关键:最佳时间总是一个名人到达的时刻
            if current_count > max_count:
                max_count = current_count
                best_time = time
        else: # event_type == ‘end’
            current_count -= 1
            
    return best_time, max_count

算法分析
这个算法的时间复杂度主要取决于排序步骤,为 O(m log m),其中 m 是事件数量(名人数的两倍)。之后只需线性扫描一次事件列表。对于大规模数据,这比暴力枚举法要高效得多。

一个重要观察:从算法中我们可以发现,最佳开始时间总是与某位名人的到达时间重合。因为人数只有在到达时才会增加,我们总是在人数达到新的峰值时记录时间,而这个峰值是由到达事件触发的。

总结

本节课中我们一起学习了“聚会的最佳时间”这个谜题的两种解法。

  1. 暴力枚举法:思路简单,通过检查每一个可能的时间段来寻找最大值,但效率较低,适用于问题规模较小或对效率要求不高的场景。
  2. 扫描线算法:通过将问题转化为处理“到达”和“离开”事件,并对其进行排序和扫描,高效地找到了最优解。这种方法的核心是关注状态发生变化的关键点,避免了大量冗余计算。

理解第二种算法背后的思想——将区间问题转化为对端点的处理——对于解决许多其他类似的调度和区间问题非常有帮助。

003:读心术(经过一点校准)🎴

在本节课中,我们将要学习一个看似神奇的纸牌读心术背后的算法。通过这个谜题,我们将理解如何利用有限的信息(四张牌的顺序)来编码并传递关于第五张隐藏牌的信息。

概述

这个魔术的核心是:助手向观众展示五张随机抽取的牌,然后隐藏其中一张,并向“读心者”展示其余四张。仅凭这四张牌的顺序,“读心者”就能准确猜出隐藏的牌。这并非真正的魔法,而是一个巧妙的算法。

算法原理

上一节我们介绍了谜题的基本设定,本节中我们来看看其背后的数学原理和算法步骤。

第一步:找到重复花色

从五张牌中,我们总能找到至少两张花色相同的牌。这是因为只有四种花色(黑桃、红心、梅花、方块),根据鸽巢原理,五张牌中必然有至少一对花色相同。

第二步:确定“关键牌对”

在找到的同花色牌对中,我们需要选择一张作为向“读心者”展示的第一张牌,另一张则作为需要猜出的隐藏牌。选择的原则是:确保隐藏牌的点数与第一张展示牌的点数,在循环顺序(将A看作1,J=11,Q=12,K=13,然后13之后循环回1)中的距离不超过6。

公式:对于两张同花色牌的点数 ab,计算循环距离 d = (b - a + 13) % 13。我们需要选择 ab 的角色,使得 1 <= d <= 6

第三步:编码距离

现在,我们知道了隐藏牌的花色(由第一张牌决定)以及它与第一张牌的距离 d(1到6之间的一个数字)。剩下的三张牌将被用来编码这个数字 d

以下是编码方法:
我们将三张牌按预定义的全局顺序(例如,先按花色排序:梅花<方块<红心<黑桃,再按点数A到K排序)排列后,会得到一个“小-中-大”的顺序。通过改变这个顺序,我们可以编码1到6的数字。

例如,我们可以定义:

  • S M L (小, 中, 大) = 1
  • S L M = 2
  • M S L = 3
  • M L S = 4
  • L S M = 5
  • L M S = 6

助手根据计算出的距离 d,将三张牌按对应的顺序摆放。

第四步:解码与猜测

“读心者”看到四张牌后,执行逆过程:

  1. 看到第一张牌,确定隐藏牌的花色。
  2. 观察后三张牌的摆放顺序,解码出距离 d (1到6)。
  3. 以第一张牌的点数为起点,在循环点数顺序中向前数 d 步,即得到隐藏牌的点数。

代码实现

上一节我们介绍了算法的逻辑步骤,本节中我们来看看如何用代码实现这个魔术的编码部分。核心是计算哪张牌应该隐藏,以及如何排列剩余的三张牌。

以下是一个Python代码示例的关键部分,用于确定隐藏牌和第一张牌:

def find_hidden_and_first(cards):
    # cards 是一个包含五张牌信息的列表
    # 假设每张牌有 ‘suit‘ 和 ‘rank‘ 属性,rank为1-13
    # 1. 找到同花色的两张牌
    suit_count = {}
    for card in cards:
        suit_count.setdefault(card.suit, []).append(card)

    for suit, suited_cards in suit_count.items():
        if len(suited_cards) >= 2:
            card_a, card_b = suited_cards[0], suited_cards[1]
            # 2. 计算两种顺序的循环距离
            dist_ab = (card_b.rank - card_a.rank + 13) % 13
            dist_ba = (card_a.rank - card_b.rank + 13) % 13

            # 3. 选择距离在1-6之间的配置
            if 1 <= dist_ab <= 6:
                first_card, hidden_card = card_a, card_b
                distance = dist_ab
                return first_card, hidden_card, distance
            elif 1 <= dist_ba <= 6:
                first_card, hidden_card = card_b, card_a
                distance = dist_ba
                return first_card, hidden_card, distance
    # 理论上不会执行到这里,因为五张牌必有一对同花色且可配置
    return None, None, None

确定距离 d 后,我们需要排列剩余的三张牌。以下是排列三张牌以编码数字的示例代码:

def encode_distance(other_cards, distance):
    # other_cards 是剩下的三张牌
    # 1. 先按全局顺序排序,得到默认的 S M L 顺序(编码1)
    sorted_cards = sorted(other_cards, key=lambda c: (c.suit_order, c.rank))

    # 2. 根据目标距离,重新排列
    # 映射关系:1:SML, 2:SLM, 3:MSL, 4:MLS, 5:LSM, 6:LMS
    encoding_map = {
        1: [0, 1, 2],  # S, M, L
        2: [0, 2, 1],  # S, L, M
        3: [1, 0, 2],  # M, S, L
        4: [1, 2, 0],  # M, L, S
        5: [2, 0, 1],  # L, S, M
        6: [2, 1, 0],  # L, M, S
    }
    order = encoding_map[distance]
    arranged_cards = [sorted_cards[i] for i in order]
    return arranged_cards

练习与扩展

这个魔术的算法是确定的,但实现方式可以变化。一个有趣的练习是修改代码,使用不同的全局排序规则或不同的顺序编码映射。

此外,可以思考一个更具挑战性的问题:能否只用四张牌完成类似的魔术? 观众随机抽取四张牌,助手展示其中三张,读心者猜第四张。这需要编码 49 种可能性,而三张牌的排列只有 6 种,信息差距巨大。可能需要利用牌的位置(如放在左边还是右边)来传递额外信息,这是一个开放的思考题。

总结

本节课中我们一起学习了“五张牌读心术”的算法。其核心在于利用必然存在的同花色牌对,将隐藏牌的信息编码到第一张牌的花色以及后三张牌的排列顺序中。这个谜题巧妙地将组合数学原理(鸽巢原理、排列数)应用于实际问题,展示了如何用有限的信息通道传递超出其表面容量的信息。通过代码实现,我们可以反复练习并验证这个魔术的可靠性。

004:请打破水晶 🧊

在本节课中,我们将要学习一个经典的算法谜题——“水晶球谜题”。我们将分析如何用最少的尝试次数,确定一个水晶球从多高的楼层落下不会摔碎。我们将从最简单的情况开始,逐步深入,最终探讨一个适用于任意数量水晶球的通用策略。本节课的核心是最坏情况分析,这是算法分析中至关重要的概念。

概述与问题定义

我们有一个完全相同的水晶球,需要测试它的“硬度系数”。我们有一座128层的上海塔,楼层编号为1到128。

  • 硬度系数 F 定义为:从不高于 F 的楼层落下,球不会碎;但从 F+1 层落下,球就会碎。
  • 我们有一个单调性假设:如果球从某层落下没碎,那么从任何低于此层的楼层落下也不会碎。
  • 我们的目标是:使用 D 个完全相同的球,在最坏情况下,最小化确定精确硬度系数所需的“扔球”次数

情况一:只有一个球 (D=1)

如果我们只有一个球,并且必须得到精确的硬度系数,我们别无选择,只能从最低的楼层开始,一层一层地向上测试。

算法:从第1层开始扔球。如果没碎,就上到第2层再扔,以此类推,直到球摔碎。硬度系数就是球摔碎之前的那一层。

最坏情况分析:如果硬度系数是128(即球从128层落下也不会碎),我们需要测试所有128层。因此,最坏情况下的扔球次数是 128 次。

结论:对于单个球,最坏情况尝试次数等于总楼层数 N。没有优化空间。


情况二:有两个球 (D=2)

现在我们有两个球,可以设计更高效的策略。上一节我们看到了单球的局限性,本节中我们来看看如何利用第二个球来减少尝试次数。

一个直观的想法是使用二分查找:先从中间楼层(第64层)扔第一个球。

  • 如果碎了:我们知道硬度系数在1到63之间。但此时我们只剩下一个球,必须从第1层开始逐层测试,最多再试63次。总尝试次数 = 1 (第一次) + 63 = 64
  • 如果没碎:我们知道硬度系数在65到128之间。我们仍然拥有两个球,可以在剩下的64层中继续应用策略(例如,再从第96层尝试)。可以证明,这种情况下的最坏尝试次数会少于64。

然而,64次并不是最优解。我们可以采用非均匀间隔的测试策略。

优化策略:间隔测试法

思路是:用第一个球确定一个大致的范围,然后用第二个球在这个小范围内逐层测试。

假设我们每间隔 K 层用第一个球测试一次(即测试第K, 2K, 3K...层),直到它摔碎。然后我们用第二个球在最后一个“安全层”和“摔碎层”之间逐层测试。

最坏情况分析

  1. 第一个球测试次数: N/K 次(向上取整)。
  2. 第一个球在最后一次测试时摔碎,第二个球需要在长度为 K-1 的区间内逐层测试。
  3. 总最坏尝试次数为: N/K + (K-1)

我们的目标是选择一个 K 来最小化这个表达式。这是一个简单的优化问题。通过求导或观察可知,当 N/K ≈ K,即 K ≈ √N 时,总和最小。

对于 N=128, √N ≈ 11.3。代入公式:

  • 当 K=11 时, 128/11 + 10 ≈ 11.6 + 10 = 21.6,最坏情况约 22 次。
  • 当 K=12 时, 128/12 + 11 ≈ 10.7 + 11 = 21.7,最坏情况约 22 次。

实际上,通过更精细的调整(间隔不一定完全相等),可以做到 21 次。这比二分法的64次要快得多。

核心公式:两球策略的最坏尝试次数可优化至约 2√N 量级。


情况三:通用情况 (D 个球)

当我们有更多球(D > 2)时,策略可以进一步优化。思路是将楼层数表示为某种进制的 D 位数

进制表示法策略

  1. 选择进制 R,使得 R^D > N(总楼层数)。这保证了D位数足以表示所有楼层。
  2. 将目标硬度系数想象成一个D位的R进制数。
  3. 测试时,我们从最高位(最左侧)开始确定这个数的每一位:
    • 用第一个球测试最高位。我们依次测试该位为1, 2, ... (R-1)时对应的楼层(即间隔为 R^(D-1) 的楼层),直到球摔碎或所有可能都试完。这确定了最高位的值。
    • 第一个球摔碎后,我们转向第二个球,用它来确定次高位的数字,此时测试间隔变为 R^(D-2)。
    • 以此类推,每摔碎一个球,我们就用下一个球来确定下一位数字,测试间隔不断缩小。
    • 当只剩下一个球时,我们只能在其确定的小范围内逐层测试(相当于确定最后一位)。

举例:N=128, D=4。

  • 选择 R=4,因为 4^4 = 256 > 128。
  • 我们将楼层表示为4位4进制数。例如,(1, 0, 0, 0) 对应楼层 1*64 = 64。
  • 测试流程
    • 第一个球:测试最高位。先试 (1,0,0,0) 即64层。如果没碎,试 (2,0,0,0) 即128层。假设在128层摔碎。
    • 第二个球:现在知道最高位是1,硬度在 (1,0,0,1) (65层) 和 (1,3,3,3) (127层)之间。测试次高位:试 (1,1,0,0) 即96层。如果没碎,试 (1,2,0,0) 即112层...以此类推。
    • 第三、四个球:继续确定更低位的值。

最坏情况分析

在最坏情况下,每个球(对应每一位数字)都需要测试 R-1 次(即从1测试到R-1)。因此:

最坏尝试次数 ≈ (R - 1) * D

对于 N=128, D=4, R=4,最坏尝试次数 ≈ (4-1)*4 = 12 次。这比两球策略的21次要优秀得多。

核心概念:D球策略的本质是进行一场 D 位 R 进制数的“折半”搜索,每次摔球确定一位。最坏尝试次数与 D * R 成正比,而 R 约等于 N^(1/D)。


算法实现

以下是该策略的核心代码实现(Python风格伪代码):

def find_hardness(n_floors, d_balls):
    # 1. 找到合适的进制 R
    r = 1
    while (r ** d_balls) <= n_floors:
        r += 1

    # 2. 初始化当前测试楼层和数字表示
    current_floor = 0
    digits = [0] * d_balls  # D位数的列表,初始全0

    # 3. 主要测试循环
    for ball in range(d_balls):  # 对于每一个球(对应每一位)
        for digit_value in range(1, r):  # 测试该位从1到R-1
            # 计算要测试的楼层:将当前数字表示转换为实际楼层
            test_floor = current_floor + digit_value * (r ** (d_balls - 1 - ball))
            if test_floor > n_floors:
                test_floor = n_floors # 处理边界

            print(f"用第{ball+1}个球从{test_floor}层扔下")
            # 交互:球是否摔碎?
            broke = input("球摔碎了吗? (是/否): ") == "是"

            if broke:
                # 球碎了,该位数字确定,转入下一位(下一个球)
                digits[ball] = digit_value - 1  # 安全值是前一个
                current_floor += digits[ball] * (r ** (d_balls - 1 - ball))
                break  # 跳出内层循环,处理下一个球
            else:
                # 球没碎,继续增加该位数字
                digits[ball] = digit_value
                # 如果已经是该位最大测试值且没碎,则此位就是R-1
                if digit_value == r - 1:
                    current_floor += digits[ball] * (r ** (d_balls - 1 - ball))
    # 最终,current_floor 就是硬度系数 F
    return current_floor

总结

本节课中我们一起学习了“水晶球谜题”及其解决方案:

  1. 单球 (D=1):策略唯一,最坏尝试次数为楼层数 N。
  2. 双球 (D=2):可采用间隔测试法,最坏尝试次数优化至约 2√N
  3. 多球 (D个):可采用进制表示法,将问题转化为确定一个D位R进制数。通过合理选择 R (满足 R^D > N),最坏尝试次数约为 (R-1) * D,性能远优于低球数策略。

这个谜题生动地展示了最坏情况分析的重要性,以及如何通过增加资源(球) 来显著降低时间复杂度(尝试次数),体现了算法设计中经典的时空权衡思想。进制策略也提供了一个将连续搜索问题离散化、层次化的精彩范例。

005:八皇后问题 👑

概述

在本节课中,我们将学习一个经典的编程谜题——八皇后问题。我们将探讨如何使用穷举枚举的编程范式来寻找所有可能的解决方案,并比较不同数据结构(如二维列表和一维列表)对算法效率和代码简洁性的影响。通过从较小的棋盘(如2x2、3x3、4x4)开始分析,我们将逐步构建解决通用N皇后问题的算法思路。

问题描述与规则

八皇后问题要求在一个标准的8x8国际象棋棋盘上放置8个皇后,使得任意两个皇后都不能相互攻击

在国际象棋中,皇后可以沿水平、垂直和两个对角线方向移动任意格数。因此,放置皇后需要满足以下三个约束条件:

  1. 同一列上不能有两个皇后。
  2. 同一行上不能有两个皇后。
  3. 同一对角线上不能有两个皇后。

解决策略:穷举搜索与回溯

解决此类组合问题的核心策略是穷举所有可能的放置组合,并检查每种组合是否满足约束条件。由于组合数量随棋盘尺寸呈指数级增长(例如8x8棋盘有 8^8 种可能),直接暴力枚举效率低下。

我们将采用一种更聪明的逐列放置策略,并结合回溯法

  • 逐列放置:从左到右,依次在每一列上放置一个皇后。
  • 回溯:当在某一列上尝试了所有行都无法放置皇后(即与已放置皇后冲突)时,撤销最近一次放置皇后的决定,回到上一列尝试新的位置。

这种策略能系统性地探索所有可能性,同时避免大量无效的搜索。

从简单情况获得直觉

在编写通用代码前,我们先分析小规模问题,以理解算法流程和回溯的必要性。

  • 2x2棋盘:无解。放置第一个皇后后,棋盘上所有其他位置都会被攻击。
  • 3x3棋盘:无解。通过尝试所有起始位置并回溯,可以发现没有满足条件的放置方式。
  • 4x4棋盘:有解。通过手动回溯,我们可以找到至少一种解决方案(例如 [1, 3, 0, 2],表示第0列放第1行,第1列放第3行,以此类推)。这个过程清晰地演示了回溯:当在最后一列找不到安全位置时,需要返回前一列改变皇后的位置。

算法实现:冲突检查与数据表示

算法的核心是一个冲突检查函数,它判断在特定位置放置新皇后是否会与棋盘上已有的皇后冲突。我们将实现两种不同的数据表示方法。

方法一:使用二维列表(矩阵)

最直观的数据结构是使用二维列表(或数组)board 来表示棋盘,其中 board[i][j] = 1 表示 (i, j) 位置有皇后,0 则表示空。

以下是基于此表示的冲突检查函数的关键逻辑(以伪代码/思路表示):

def no_conflicts(board, current_col, current_row, n):
    # 检查同一列(由于我们逐列放置,此检查可省略)
    for i in range(n):
        if board[i][current_col] == 1 and i != current_row:
            return False

    # 检查同一行
    for j in range(n):
        if board[current_row][j] == 1 and j != current_col:
            return False

    # 检查主对角线(左上到右下)
    i, j = current_row, current_col
    while i >= 0 and j >= 0:
        if board[i][j] == 1 and (i, j) != (current_row, current_col):
            return False
        i -= 1
        j -= 1
    # ... 还需要检查其他三个对角线方向

    # 检查副对角线(右上到左下)
    i, j = current_row, current_col
    while i >= 0 and j < n:
        if board[i][j] == 1 and (i, j) != (current_row, current_col):
            return False
        i -= 1
        j += 1
    # ... 还需要检查其他三个对角线方向

    return True

使用二维列表的搜索算法需要多层嵌套循环(对于8皇后就是8层),代码冗长且不通用(难以扩展到N皇后)。

方法二:使用一维列表(优化表示)

我们可以利用“每列只有一个皇后”的约束,进行数据压缩。使用一个一维列表 board,其中 board[col] = row 表示第 col 列的皇后放在第 row 行。如果某列未放置,则值可以为 -1

这种表示法大大简化了冲突检查:

def no_conflicts(board, current_col, current_row):
    # 遍历已放置皇后的列进行检查
    for col in range(current_col):
        if board[col] == current_row: # 检查同一行
            return False
        # 检查对角线:列差绝对值 == 行差绝对值
        if abs(board[col] - current_row) == abs(col - current_col):
            return False
    return True

这个函数非常简洁:

  • 行冲突:检查新皇后的行号 current_row 是否已经存在于 board 列表中。
  • 对角线冲突:检查对于任何已放置的皇后,其列索引差 abs(col - current_col) 是否等于行索引差 abs(board[col] - current_row)。这在几何上等价于判断两点是否在同一对角线上。

使用一维列表的搜索算法可以更容易地通过递归实现通用解法,代码也更清晰。

总结

本节课我们一起学习了八皇后问题的经典解法。我们首先理解了问题规则和约束条件,然后引入了穷举枚举回溯法作为核心解决策略。通过分析小规模案例,我们直观感受到了回溯的必要性。

在实现层面,我们比较了两种数据结构:

  1. 二维列表:直观但代码冗长,冲突检查复杂,难以泛化。
  2. 一维列表:利用问题约束进行优化,数据结构更紧凑,冲突检查函数异常简洁(仅需几行代码),为编写通用的、递归的N皇后解法奠定了基础。

关键收获是:选择合适的数据结构可以极大地简化算法逻辑、提高代码效率与可读性。在八皇后问题中,一维列表的表示法完美契合了“每列一后”的约束,使得对角线冲突检查变得优雅而高效。

在接下来的课程中,我们将探索如何用更强大的编程范式(如递归)来美化代码,并实现一个能解决任意N皇后问题的通用程序。

006:众多的皇后

在本节课中,我们将学习如何使用递归这一强大的编程范式来解决复杂的“N皇后”问题。我们将从回顾迭代解法开始,逐步过渡到更优雅、更通用的递归解法,并理解递归的核心概念。

概述

上一节我们介绍了使用迭代方法解决八皇后问题,但代码冗长且不通用。本节中,我们将学习如何使用递归来重写该解决方案,使其更简洁、更灵活,并能处理任意大小的棋盘(N皇后问题)。

回顾:迭代解法与数据结构

我们首先回顾八皇后问题的迭代解法。问题是在一个N×N的棋盘上放置N个皇后,使得任意两个皇后都不能互相攻击(即不在同一行、同一列或同一对角线上)。

我们使用了一个一维列表来紧凑地表示棋盘状态。对于一个5×5的棋盘,列表 [2, 4, 1, 3, 0] 表示:

  • 第0列的皇后在第2行。
  • 第1列的皇后在第4行。
  • 以此类推。
  • -1 表示该列尚未放置皇后。

冲突检查是增量式的。当我们尝试在第 current 列放置一个皇后时,只需检查它是否与之前 0current-1 列已放置的皇后冲突,无需检查已放置皇后之间的冲突。

以下是冲突检查的核心逻辑,包含行冲突和对角线冲突检查:

def noConflicts(board, current):
    for i in range(current):
        # 检查行冲突:之前列皇后的行位置是否与当前列相同
        if board[i] == board[current]:
            return False
        # 检查对角线冲突:行差绝对值是否等于列差绝对值
        if current - i == abs(board[current] - board[i]):
            return False
    return True

迭代解法使用了N层嵌套循环来枚举所有可能的皇后位置组合,代码非常冗长且只能处理固定大小的棋盘。

引入递归

我们的目标是摆脱丑陋的嵌套循环,写出更通用、更简洁的代码。在深入N皇后问题之前,我们先看一个更简单的例子来理解递归:计算最大公约数(GCD)的欧几里得算法。

以下是迭代版本的GCD算法:

def gcd_iter(m, n):
    while n != 0:
        m, n = n, m % n
    return m

我们可以将其改写为递归版本:

def r_gcd(m, n):
    if m % n == 0:
        return n
    else:
        return r_gcd(n, m % n)

观察递归函数 r_gcd,我们需要注意两个关键点以确保递归正确工作:

  1. 基准情形(Base Case):必须存在一个条件分支,在该分支下函数直接返回结果,而不再进行递归调用。这是递归的终止条件。在 r_gcd 中,当 m % n == 0 时,函数返回 n
  2. 递归步骤(Recursive Step):在非基准情形下,函数调用自身,但传入的参数应该以某种方式“缩小”问题规模。在 r_gcd 中,参数从 (m, n) 变为 (n, m % n),使得问题向基准情形推进。

递归解决N皇后问题

现在,我们应用递归来解决N皇后问题。核心思想是:要解决在 size 列棋盘上放置皇后的问题,可以尝试在 current 列(从0开始)的每一行放置一个皇后,如果放置后不与之前列的皇后冲突,就递归地解决剩下的 current+1size-1 列的问题。

以下是递归解决方案的代码框架:

def nQueens(size):
    board = [-1] * size
    rQueens(board, 0, size)

def rQueens(board, current, size):
    # 基准情形:所有列都已成功放置皇后
    if current == size:
        print(board) # 找到一个解
        return True # 或返回False以继续搜索所有解
    else:
        for i in range(size):
            # 尝试将皇后放在当前列的 i 行
            board[current] = i
            # 如果放置后无冲突,则递归处理下一列
            if noConflicts(board, current):
                found = rQueens(board, current + 1, size)
                # 这里可以根据found的值决定是否提前返回(例如只找一个解)
        # 回溯:尝试当前列的其他行
        board[current] = -1
        return False

让我们分析这段递归代码:

  • 基准情形:当 current == size 时,意味着已经成功为所有 size 列放置了皇后,且没有冲突,此时我们找到了一个有效解。
  • 递归步骤:对于当前列 current,我们尝试将皇后放在每一行 i。如果放置后通过 noConflicts 检查,我们就递归调用 rQueens 来处理下一列 (current + 1)。
  • 参数“缩小”:递归调用时,current 参数递增,越来越接近 size,这意味着待处理的列数在减少,问题规模在缩小。
  • 回溯:在 for 循环结束后,我们将当前列重置为未放置状态 (-1),这是回溯法的体现,允许算法尝试其他可能性。

递归调用的展开与性能

理解递归执行的一种方式是将其可视化为一棵调用树。对于八皇后问题,根节点是 rQueens(board, 0, 8)。它可能产生最多8个子调用(对应第一列皇后的8个可能行位置)。每个子调用又可能产生最多8个子调用,依此类推。

然而,由于 noConflicts 函数的剪枝作用,许多分支在早期就被截断了(例如,当第一列和第二列的皇后冲突时,就不会继续探索该分支下第三列及以后的所有可能性)。这极大地减少了需要探索的组合数量,使其远小于朴素的 N^N

尽管如此,N皇后问题本质上是指数级复杂度的难题。对于较大的N(如N>25),即使有剪枝,计算所有解也可能需要非常长的时间。

总结

本节课中我们一起学习了如何运用递归解决N皇后问题。

  1. 我们首先回顾了使用一维列表和增量冲突检查的迭代解法。
  2. 接着,我们通过欧几里得算法的例子,引入了递归的两个核心要素:基准情形递归步骤(参数向基准情形推进)。
  3. 然后,我们将递归思想应用于N皇后问题,写出了简洁而通用的递归解法。该解法通过逐列放置皇后并递归处理剩余列来工作,并利用回溯探索所有可能解。
  4. 最后,我们讨论了递归调用的树形展开和剪枝对算法性能的重要影响,虽然递归代码简洁,但N皇后问题本身的计算复杂度仍然很高。

递归是一种强大的工具,能将复杂问题分解为相似的子问题,从而简化代码结构。掌握识别基准情形和设计递归步骤是编写正确递归函数的关键。

007:数独求解器 🧩

在本节课中,我们将学习如何编写一个计算机程序来解决数独谜题。我们将对比人类解决数独的“智能”方法与计算机的“暴力搜索”方法,并尝试将两者结合起来,创建一个更高效的求解器。核心思想是:在暴力搜索的基础上,通过“剪枝”来提前排除无效的搜索路径,从而大幅提升效率。


数独规则 📝

数独是一个经典的逻辑谜题。标准数独是一个9x9的网格,部分格子已预先填入数字(1到9)。游戏的目标是填满所有空格,同时满足以下三个约束条件:

  1. 每一行 必须包含数字1到9,且每个数字在每行中只能出现一次。
  2. 每一列 必须包含数字1到9,且每个数字在每列中只能出现一次。
  3. 每一个3x3的宫(网格被划分为9个这样的宫)必须包含数字1到9,且每个数字在每个宫中只能出现一次。

人类玩家通常通过“推理”来解题,例如进行“水平扫描”或“垂直扫描”,以确定某个数字在特定行、列或宫中唯一可能的位置。然而,对于较难的谜题,玩家可能需要进行“猜测”,然后通过“回溯”来纠正错误的猜测。


暴力搜索方法 💻

上一节我们介绍了数独的基本规则,本节中我们来看看如何用计算机程序来模拟解决过程。我们将采用一种类似于解决“八皇后问题”的递归回溯策略。

核心算法步骤

以下是暴力搜索求解器的核心步骤:

  1. 寻找下一个空单元格:按顺序(例如从左到右、从上到下)扫描网格,找到第一个值为0(代表空)的格子。
    def findNextCellToFill(grid):
        for x in range(9):
            for y in range(9):
                if grid[x][y] == 0:
                    return x, y
        return -1, -1  # 如果没有空单元格,说明已解决
    

  1. 检查部分解的有效性:在尝试向一个空单元格填入数字e之前,检查该操作是否会违反数独的三大规则。

    def isValid(grid, i, j, e):
        # 检查行
        rowOk = all([e != grid[i][x] for x in range(9)])
        if rowOk:
            # 检查列
            columnOk = all([e != grid[x][j] for x in range(9)])
            if columnOk:
                # 检查3x3宫
                secTopX, secTopY = 3 * (i//3), 3 * (j//3)
                for x in range(secTopX, secTopX+3):
                    for y in range(secTopY, secTopY+3):
                        if grid[x][y] == e:
                            return False
                return True
        return False
    
  2. 递归求解函数:这是算法的主体。它尝试为当前找到的空单元格填入1到9的数字,并递归地调用自身来填充剩余的空格。如果某个分支导致矛盾,则回溯并尝试下一个数字。

    def solveSudoku(grid, i=0, j=0):
        i, j = findNextCellToFill(grid)
        if i == -1: # 没有空单元格了,已找到解
            return True
    
        for e in range(1, 10): # 尝试数字1到9
            if isValid(grid, i, j, e):
                grid[i][j] = e
                if solveSudoku(grid, i, j):
                    return True
                # 如果递归调用失败,撤销选择(回溯)
                grid[i][j] = 0
        return False # 触发回溯
    
  3. 性能度量:回溯次数:为了客观地衡量算法效率(不受计算机速度影响),我们引入一个全局变量来计数“回溯”发生的次数。每次算法撤销一个错误猜测时,计数器加一。

    backtracks = 0 # 全局变量
    
    def solveSudoku(grid, i=0, j=0):
        global backtracks
        i, j = findNextCellToFill(grid)
        if i == -1:
            return True
    
        for e in range(1, 10):
            if isValid(grid, i, j, e):
                grid[i][j] = e
                if solveSudoku(grid, i, j):
                    return True
                # 回溯
                backtracks += 1
                grid[i][j] = 0
        return False
    

这种朴素的暴力搜索方法可以解决任何有效的数独谜题,但对于较难的谜题,其回溯次数可能非常巨大(例如数万甚至数十万次),导致求解时间很长。


集成智能推理 🧠

上一节我们实现了一个基础但低效的暴力求解器。本节中我们来看看如何将人类的“推理”或“蕴含”策略集成到搜索过程中,从而减少不必要的猜测和回溯。

核心思想

在每次做出一个猜测(即向一个空单元格填入一个数字)之后,我们并不立即进行下一次递归猜测,而是先利用数独规则进行确定性推理,找出所有因此而被唯一确定的数字,并将它们填入网格。这个过程可以连续进行多轮,直到没有新的数字能被推理出来为止。

关键点:这些推理出的数字是基于当前(可能错误的)猜测的。如果最初的猜测是错误的,那么所有基于它的推理结果也都是错误的。因此,当我们回溯时,必须彻底清理这些推理出的数字,将它们重置为空。

算法修改

我们需要修改核心的solveSudoku函数,并用两个新函数makeImplicationsundoImplications替换简单的赋值和清零操作。

  1. makeImplications(grid, i, j, e)

    • 首先,将猜测值e填入单元格(i, j)
    • 然后,基于新的网格状态,运行推理逻辑(例如,检查每个宫、行、列,寻找那些只剩下一种可能数字的空格)。
    • 将所有推理出的数字及其位置记录在一个列表(称为implications列表)中,并将它们填入网格。
    • 返回这个implications列表,以便后续清理。
  2. undoImplications(grid, implications)

    • 接收implications列表。
    • 将列表中记录的所有单元格的值重置为0(空),以撤销所有基于错误猜测的推理。
  3. 修改后的求解函数

    def solveSudokuOpt(grid, i=0, j=0):
        global backtracks
        i, j = findNextCellToFill(grid)
        if i == -1:
            return True
    
        for e in range(1, 10):
            if isValid(grid, i, j, e):
                # 进行猜测并推理,而不是直接赋值
                implications = makeImplications(grid, i, j, e)
                if solveSudokuOpt(grid, i, j):
                    return True
                # 回溯:撤销所有推理和猜测
                backtracks += 1
                undoImplications(grid, implications)
        return False
    

一个简单的推理策略示例

以下是makeImplications函数可能采用的一种简单推理策略(针对每个3x3宫):

  1. 对于每一个宫,找出该宫内所有空单元格。
  2. 对于每个空单元格,根据其所在行和列已存在的数字,计算其可能填入的数字集合。
  3. 如果某个空单元格的可能数字集合缩小到只剩一个数字,那么这个数字就是被“蕴含”出来的,可以确定地填入。

通过将这种推理循环执行,直到网格不再发生变化,我们可以填入很多数字,从而大大减少需要递归搜索的深度和分支数量。

效果

集成这种简单的推理策略后,求解器的性能得到了显著提升。例如,对于某些谜题,回溯次数从数万次下降到了数千次甚至数百次。通过实现更强大的推理循环(持续推理直到无法推出新数字为止),性能还可以得到进一步改善。


总结 📚

本节课中我们一起学习了如何构建一个数独求解器。我们从最基础的递归回溯(暴力搜索)方法开始,理解了其工作原理和性能瓶颈(通过回溯次数衡量)。接着,我们探索了如何通过集成人类解题时使用的“智能推理”来优化搜索过程。关键在于,在每次猜测后运行一个确定性的推理过程来填充更多单元格,并在回溯时仔细清理这些推理结果。这种“猜测-推理-回溯”的框架,结合有效的剪枝策略,是解决许多约束满足问题的强大通用方法。

008:杂乱无章的修理工 🧰

在本节课中,我们将学习一个关于整理混乱的螺母和螺栓的谜题。这个谜题不仅有趣,还与一种非常流行的排序算法——快速排序——有着深刻的联系。我们将从最直观的解法开始,分析其效率,然后探索一种更巧妙的“分而治之”策略,最终将其与快速排序算法联系起来。

谜题设定

假设一位粗心的修理工有100个不同尺寸的螺母和100个与之配对的螺栓,但它们全部混在一个袋子里。目标是找到最高效的方法,将每个螺母与对应的螺栓配对。

唯一的检查方法是实际尝试将一个螺母拧到一个螺栓上。通过这个操作,你可以得到三种结果:

  1. 螺母完美匹配螺栓。
  2. 螺母大于螺栓(螺母会松动)。
  3. 螺母小于螺栓(螺母拧不进去)。

直观解法及其复杂度

最直接的方法是:随机拿起一个螺母,然后尝试袋子里所有的螺栓,直到找到匹配的那个。找到一对后,将其放在一边,问题规模就从 N 对减少到 N-1 对,然后重复这个过程。

以下是这个过程的步骤分析:

  • 寻找第一对匹配时,在最坏情况下需要进行 N 次比较(例如,最后一个尝试的螺栓才匹配)。
  • 接下来寻找第二对匹配,最坏需要 N-1 次比较。
  • 以此类推,直到最后一对。

因此,所需的总比较次数是:N + (N-1) + (N-2) + ... + 1

这个求和公式是:

总比较次数 ≈ N * (N+1) / 2

N 很大时,其增长速率 成正比。对于100对螺母螺栓,大约需要5000次比较;对于1000对,则需要约50万次比较。这种 级别的复杂度在问题规模增大时会变得非常低效。

分而治之策略

上一节我们介绍了直观但低效的解法,本节中我们来看看如何用“分而治之”的策略来改进。真正的分而治之通常将问题分解成多个规模更小的子问题,而不是每次只减少一个。

关键在于枢轴划分。思路如下:

  1. 从袋中随机选择一个螺栓作为“枢轴”。
  2. 用这个枢轴螺栓与每一个螺母进行比较。
    • 将比它大的螺母放入“右堆”。
    • 将比它小的螺母放入“左堆”。
    • 找到恰好匹配的螺母,将其放在一边。这个螺母就是“枢轴螺母”。
  3. 现在,使用找到的枢轴螺母剩下的每一个螺栓进行比较。
    • 将比它小的螺栓放入“左堆”。
    • 将比它大的螺栓放入“右堆”。
  4. 现在,你得到了两个独立的子问题:“左堆”里的螺母和螺栓彼此配对,“右堆”里的亦然。枢轴螺母和螺栓已经配对完成。
  5. 对左右两个堆递归地重复此过程。

这种方法的神奇之处在于,经过一轮枢轴操作后,我们得到了两个可以独立解决的子问题。如果每次划分都能大致将问题分成两半,那么解决问题的步骤数将呈对数级增长,整体复杂度可以接近 N log N,远比 高效。

从谜题到快速排序 🚀

我们刚刚在谜题中使用的枢轴划分策略,正是快速排序算法的核心。现在,让我们暂时忘掉螺母和螺栓,看看如何用这个思想来排序一组数字。

快速排序是一种“分而治之”的排序算法,其步骤与我们的谜题解法高度对应:

  1. 选择枢轴:从数组中选择一个元素作为枢轴(例如最后一个元素)。
  2. 划分:重新排列数组,使得所有小于枢轴的元素都在其左侧,所有大于枢轴的元素都在其右侧。枢轴元素的位置在此步骤后就被确定了。
  3. 递归:对枢轴左侧和右侧的子数组递归地执行快速排序。

这与合并排序不同。合并排序的“分”很简单(直接对半分),但“治”(合并步骤)需要额外空间和操作。而快速排序的“治”(划分步骤)是关键且需要技巧的,但在此之后,“合”几乎不需要额外工作。

快速排序的优势:原地排序

快速排序之所以被广泛使用,一个主要原因是它可以是原地排序算法。这意味着除了存储原始数据外,它只需要常数级别的额外存储空间(例如,用来暂存枢轴值)。

以下是一个原地划分的简要思路(对应谜题中同时移动螺母和螺栓的过程):

  • 设置两个指针,一个从数组左侧向右移动,寻找大于枢轴的元素;另一个从数组右侧向左移动,寻找小于枢轴的元素。
  • 当左指针找到大于枢轴的元素且右指针找到小于枢轴的元素时,交换这两个元素的位置。
  • 重复这个过程,直到两个指针相遇。相遇的位置就是枢轴元素的正确位置,将枢轴放入该位置。
  • 至此,划分完成,且所有操作都在原数组上进行,无需创建新数组。

这种原地特性使得快速排序在处理海量数据时非常节省内存,这也是它比需要 O(N) 额外空间的合并排序更受青睐的原因之一。在平均情况下(特别是当输入数据经过随机化时),快速排序的时间复杂度也能达到高效的 O(N log N)

总结

本节课中我们一起学习了“杂乱无章的修理工”这个有趣的谜题。

  • 我们从最直观的 O(N²) 解法开始,理解了其效率瓶颈。
  • 然后,我们引入了枢轴划分这一关键思想,将问题分解为可独立解决的子问题,实现了更高效的分而治之策略。
  • 最后,我们将此策略与快速排序算法联系起来,理解了快速排序通过枢轴划分确定元素位置、并递归排序子数组的核心过程,同时也认识了其原地排序平均高效的特点。

这个谜题完美地展示了,一个巧妙的算法思想如何既能解决一个具体的实际问题,又能构成计算机科学中一个强大工具的基础。

009:谜题10-难忘的周末 🎉

在本节课中,我们将学习如何解决一个关于周末派对安排的谜题。这个谜题的核心是判断一个社交关系图是否可以被“二分”,即能否将所有朋友分成两组,使得互相不喜欢的人不在同一组。我们将学习图论中的二分图概念,并使用递归深度优先搜索算法和Python的字典数据结构来解决它。


图与数据结构 📊

到目前为止,我们主要使用了Python列表这种数据结构。我们看过二维列表,但它们本质上还是列表。今天要解决的谜题涉及图结构。这是本课程中第一次涉及图的遍历。我们将以递归方式进行遍历,并使用字典来表示图。

字典是一种比列表更通用的数据结构。它允许你使用非零整数(如字符串或元组)作为索引来访问元素,而不仅仅是0到n的整数索引。在表示和遍历图时,字典非常方便。


谜题描述 🧩

这个谜题是关于周末晚餐安排的。你有一群朋友。我们可以用一个图来表示这个社交网络。

图中的每个节点代表一个朋友。我们用字母A到H来命名他们。

如果图中只有节点,那会非常无趣。我们需要添加。这些边代表什么?你喜欢你所有的朋友,但你的朋友们不一定互相喜欢。因此,图中节点之间的边代表一种不喜欢的关系。

例如,B和C之间的边意味着B不喜欢C。我们假设这种关系是对称的,即C也不喜欢B。因此,这是一个无向图。边B-C的存在意味着在遍历时,你可以从B到C,也可以从C到B。

你的任务是让所有朋友都开心。方法是在周末举办两场派对,比如周五派对和周六派对。你需要满足两个约束条件:

  1. 约束一:每个朋友必须参加恰好一场派对。不能有人被落下,也不能有人参加两场。
  2. 约束二:任何一对互相不喜欢的朋友不能被邀请在同一天。

我们需要一个算法来给出一个可行的日程安排。显然,安排可能不唯一。如果所有朋友都喜欢彼此,你可以随机将他们分成两组。这可以看作是一个划分问题:将一个节点集合分成两组,使得每个节点恰好属于一组,并且满足与节点间边相关的额外属性。


二分图与二着色 🎨

这个谜题的目的是判断是否能够完成这样的安排。对于某些图,可能无法同时满足这两个约束。例如,考虑一个包含三个节点的环(三角形),如果其中两人互相不喜欢,你将无法将他们分配到仅有的两天中而不违反规则。

能够以这种方式划分的图有一个特殊的名称,叫做二分图

之所以称为二分图,是因为你总是可以将其画成这样一种形式:所有节点被分成左右两组,并且所有的边都只存在于左右两组节点之间,而同一组内的节点之间没有边。这是一个拓扑性质。

二分图还有一个等价的性质,称为二着色。这意味着你可以用两种颜色(例如红色和蓝色,对应周五和周六)为每个节点着色,使得任何一条边连接的两个节点颜色都不相同。这正好等价于我们的谜题问题。

因此,我们现在的目标是:判断一个给定的图是否是二分图(即可二着色)。一旦知道一个图是二分图,我们就知道存在解决谜题的方案。


环与可着色性 🔄

让我们通过环来理解这个性质。一个三节点环(三角形)不是二分图。那么,任何环都会导致问题吗?并非如此。

考虑一个四节点环(A-B-C-D-A)。我们可以将A和C着为红色,B和D着为蓝色。这样,所有边连接的都是不同颜色的节点。因此,偶数环是允许的。

然而,对于一个五节点环,当你尝试用两种颜色交替着色时,最终会矛盾地要求同一个节点被着上两种颜色。因此,奇数环会导致图无法被二着色。

有趣的是,环的奇偶性决定了图是否是二分图。但这并不意味着一个只包含偶数环的图就一定是二分图,因为图中可能还存在其他导致问题的结构(例如,多个环交织在一起)。我们的算法需要确保图中没有奇数环,这正好等价于能用两种颜色为其着色。


算法与图表示法 🤖

显然,我们需要一种方法来停止遍历。当遇到环时,你可能会回到已访问的节点。如果是偶数环,可能没问题,但你不能在计算机程序中无休止地绕圈。我们将使用递归搜索,它包含有趣的终止条件。

与之前分治法或迭代枚举中明确的终止条件(如列表长度为1)不同,图遍历中的环检测,尤其是递归遍历,可能相当棘手。

首先,我们需要能够表示这个图结构。你可以用矩阵(或二维列表,称为邻接矩阵)来表示图,其中行和列都是节点,根据节点间是否有边来填充1或0。但在大多数情况下,这种表示法处理起来很麻烦。对于遍历图,使用字典结构要方便得多。


深度优先搜索算法 🧭

让我们通过一个稍有不同的例子来讨论算法如何工作。我们将使用深度优先搜索策略。

深度优先搜索的策略是:从一个节点开始,选择它的一个邻居,然后尽可能深地探索从这个邻居出发能到达的所有路径,直到遇到终止条件。然后回溯,再探索其他未访问的邻居。

这与广度优先搜索不同,广度优先搜索是同时探索当前节点的所有邻居,像 frontier 一样一层层向外扩展。

在我们的算法中,当遍历一条边到达一个新节点时,我们需要翻转颜色(例如,从红色变为蓝色,或从蓝色变为红色)。如果访问到一个已经着色的节点,我们需要检查其颜色是否与根据当前路径推导出的预期颜色一致。如果不一致,则发现了奇数环,图不是二分图。

此外,图可能是由多个互不连接的子图(森林)组成的。我们的算法需要能够处理这种情况,可能需要从多个未着色的节点分别启动遍历。


代码实现与字典操作 💻

我们将使用字典来表示图。字典是一组键值对,用花括号 {} 表示。键可以是字符串、元组等。在我们的图中,键是节点名(如 ‘B’),值是该节点的邻居列表(如 [‘C’])。

例如,一个图的字典表示可能如下:

graph = {
    ‘A‘: [],
    ‘B‘: [‘C‘],
    ‘C‘: [‘B‘, ‘D‘],
    ‘D‘: [‘C‘, ‘E‘, ‘F‘],
    # ... 其他节点
}

访问字典类似于访问列表:graph[‘A‘] 会返回节点A的邻居列表。如果键不存在,则会报错。你也可以修改字典,例如 graph[‘A‘].append(‘D‘) 会为A添加一个邻居D。

在我们的二分图检查代码中,我们不会修改图本身,只会遍历它。我们还需要另一个字典来记录每个节点的着色结果。


算法步骤详解 📝

以下是判断图是否可二分(可二着色)的递归算法核心步骤:

  1. 输入:图 graph(字典),起始节点 start,当前颜色 color,以及记录着色结果的字典 coloring(初始为空)。
  2. 基础检查:如果起始节点不在图中,返回 False
  3. 节点着色检查
    • 如果当前节点 start 尚未在 coloring 中着色,则用当前 color 为其着色(即 coloring[start] = color)。
    • 如果当前节点 start 已经着色:
      • 如果其现有颜色与当前 color 不同,则发现矛盾(奇数环),返回 False
      • 如果其现有颜色与当前 color 相同,说明此节点已正确着色且访问过,无需继续从此节点深入,直接返回 True(作为递归子调用成功的一部分)。
  4. 颜色翻转:为当前节点的邻居准备颜色 new_color,它是 color 的相反色。
  5. 递归遍历邻居:对于当前节点 start 的每一个邻居 vertex
    • 递归调用着色函数,传入图 graph、邻居节点 vertex、新颜色 new_color 和更新后的 coloring 字典。
    • 如果任何递归调用返回 False,则立即向上返回 False
  6. 返回成功:如果所有邻居的递归调用都成功,则返回 True

为了处理多个连通分量,我们需要在外层循环中,对图中每一个尚未着色的节点,都以其为起点调用一次上述函数。


总结 🏁

本节课中,我们一起学习了一个有趣的周末派对安排谜题。通过将其抽象为图论问题,我们引入了二分图二着色的概念。我们了解到,一个图是二分图当且仅当它不包含奇数环

为了用程序解决这个问题,我们使用了Python的字典数据结构来表示图,并实现了基于递归深度优先搜索的着色算法。该算法遍历图,尝试用两种颜色为节点着色,并在发现着色矛盾时判断图不是二分图。

这个谜题和算法不仅适用于派对安排,也是图论中一个基础且重要的问题,在任务调度、资源分配等领域都有应用。希望你能通过这个例子,对图遍历和递归算法有更深入的理解。

010:硬币排游戏

在本节课中,我们将学习一个有趣的硬币排游戏,并探讨如何通过算法来解决它。我们将从最直观的暴力解法开始,逐步引入递归策略,并最终学习一种强大的算法优化技术——动态规划。通过这个过程,你将理解如何将看似复杂的指数级时间复杂度问题,优化为高效的线性时间解决方案。

游戏规则

你面前有一排硬币,每个硬币都有一个正的价值。你的目标是选取硬币,使得所选硬币的总价值最大化。但有一个关键约束:如果你选取了一枚硬币,就不能选取紧挨着它的下一枚硬币

这意味着你可以选择“选取-跳过-选取”的模式,也可以跳过不止一枚硬币。我们的任务是找到在遵守此约束下的最大总价值。

暴力枚举法

首先,我们考虑一种最直接的方法:枚举所有可能的硬币选取组合。

思路分析

对于一个包含 n 枚硬币的集合,总共有 2^n 个子集(包括空集和全集)。我们可以生成所有这些子集,然后检查每个子集是否满足“无连续选取”的约束,最后从所有合法子集中找出总价值最大的一个。

关键步骤

以下是实现此方法的关键步骤:

  1. 生成所有子集:可以通过遍历从 02^n - 1 的所有整数,并将其二进制表示映射为选取方案(1表示选,0表示不选)。
  2. 检查约束:对于一个用二进制字符串表示的选取方案,检查其中是否出现连续的 1。如果出现,则该方案无效。
  3. 计算最大值:对所有有效方案计算总价值,并记录最大值。

算法局限性

虽然这种方法在硬币数量 n 较小时可行,但当 n 增大时,2^n 的增长速度极快(例如,n=50 时,子集数量超过千万亿),导致算法完全不可行。我们需要更高效的策略。

递归解法

上一节我们介绍了暴力枚举法,本节中我们来看看如何用递归思想更优雅地建模这个问题。

递归思路

我们定义函数 coin_row(coins),其目标是返回从硬币列表 coins 中能获得的最大价值。
考虑第一枚硬币 coins[0],我们有两种选择:

  • 选择它:那么我们不能选择 coins[1],因此剩余的子问题是 coin_row(coins[2:])。总价值为 coins[0] + coin_row(coins[2:])
  • 不选择它:那么我们可以直接考虑剩余的子问题 coin_row(coins[1:])。总价值就是 coin_row(coins[1:])

原问题的解就是这两种选择中价值较大的那个。递归的基线条件是:当列表为空时,价值为0;当列表只有一个元素时,价值就是该元素的价值。

递归伪代码

def coin_row(coins):
    if len(coins) == 0:
        return 0
    if len(coins) == 1:
        return coins[0]
    pick = coins[0] + coin_row(coins[2:])  # 选择第一枚硬币
    skip = coin_row(coins[1:])             # 不选第一枚硬币
    return max(pick, skip)                 # 返回较大值

递归的缺陷

这个递归解法虽然正确,但效率极低。因为它会反复计算相同的子问题。例如,计算 coin_row(coins[2:])coin_row(coins[1:]) 时,它们内部又会调用更小的重叠子问题。其时间复杂度与斐波那契数列递归计算类似,是指数级的 O(φ^n)(其中 φ 是黄金比例)。

动态规划与记忆化

上一节我们看到了递归解法因重复计算而低效。本节中,我们将引入记忆化技术来消除这种冗余,这正是动态规划的核心思想之一。

记忆化原理

记忆化的核心思想是“用空间换时间”。我们创建一个缓存结构(如字典或列表),在每次计算一个子问题的结果后,将其存储起来。当再次需要这个子问题的结果时,我们首先检查缓存,如果已经计算过,则直接返回缓存的结果,避免重复计算。

记忆化递归实现

我们只需对之前的递归代码做少量修改:

  1. 添加一个 memo 字典作为缓存。
  2. 在函数开始时,检查当前参数(如剩余硬币列表的长度或起始索引)是否已在 memo 中。如果在,直接返回缓存值。
  3. 在函数返回前,将计算结果存入 memo

记忆化代码示例

def coin_row_memo(coins, start, memo):
    # 检查缓存
    if start in memo:
        return memo[start]
    if start >= len(coins):
        return 0
    if start == len(coins) - 1:
        return coins[start]
    pick = coins[start] + coin_row_memo(coins, start + 2, memo)
    skip = coin_row_memo(coins, start + 1, memo)
    result = max(pick, skip)
    # 存入缓存
    memo[start] = result
    return result

通过记忆化,每个子问题(由 start 索引定义)只会被计算一次。由于子问题的数量是 O(n) 个,每个子问题的计算是常数时间,因此总时间复杂度优化到了 O(n),实现了从指数级到线性的巨大飞跃。

回溯构造最优解

仅仅知道最大价值通常不够,我们还想知道具体选取了哪些硬币。这可以通过回溯来实现。
我们利用存储了所有子问题最优解的 memo 表(或 table 列表):

  1. 从第一个硬币开始判断。
  2. 比较 memo[i](包含硬币 i 的最优解)和 memo[i+1](不包含硬币 i 的最优解)。
  3. 如果 memo[i] > memo[i+1],说明在最优解中我们选取了硬币 i(因为包含它价值更大)。然后我们跳到 i+2 继续判断(因为选了 i 就不能选 i+1)。
  4. 如果 memo[i] == memo[i+1],说明在最优解中我们没有选取硬币 i。然后我们跳到 i+1 继续判断。
  5. 重复此过程直到遍历完所有硬币。

回溯代码示例

def traceback(coins, table):
    i = 0
    selected_coins = []
    while i < len(coins):
        # 处理边界情况:只剩最后一枚硬币
        if i == len(coins) - 1 or table[i] > table[i + 1]:
            selected_coins.append(coins[i])
            i += 2  # 选了i,跳过i+1
        else:
            i += 1  # 没选i,检查i+1
    return selected_coins

总结

本节课中我们一起学习了硬币排游戏及其解决方案的演进过程。

  1. 我们从最直观的暴力枚举法开始,理解了问题搜索空间的规模(2^n)及其局限性。
  2. 接着,我们设计了递归解法,通过将大问题分解为相似的小问题,得到了正确的但效率低下的算法。
  3. 最后,我们引入了动态规划的核心思想——记忆化。通过缓存子问题的解,我们避免了递归中的大量重复计算,将算法的时间复杂度从指数级 O(φ^n) 优化到了线性级 O(n)。我们还学习了如何通过回溯,从存储的子问题最优解表中构造出具体的硬币选取方案。

动态规划是一种通过将复杂问题分解为重叠子问题,并存储子问题解以避免重复计算,从而高效解决优化问题的强大算法设计范式。硬币排问题是理解这一范式的经典入门案例。

011:铺砖难题 🧩

在本节课中,我们将学习一个与递归相关的铺砖难题。这个难题与之前的N皇后问题不同,它将引导我们探索一种称为“递归分治”的经典算法思想。我们将首先通过归并排序来理解分治的概念,然后将其应用于解决一个庭院铺砖问题。

递归分治与归并排序 🔄

上一节我们介绍了递归的基本概念。本节中,我们来看看一种特殊的递归策略:递归分治。它与N皇后问题中的递归有本质区别。在分治策略中,我们将一个大问题分解成若干个规模更小、结构相同的子问题,分别解决后再合并结果。这通常能带来更高的效率。

归并排序是递归分治的经典范例。它的核心思想是:将一个无序列表不断二分,直到每个子列表只剩一个元素(此时自然有序),然后再将这些有序的子列表两两合并成一个新的有序列表。

以下是归并排序中“合并”步骤的核心逻辑(即“双指针算法”):

def merge(left, right):
    result = []
    i = j = 0
    # 比较两个子列表的头部元素,将较小的放入结果
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:
            result.append(left[i])
            i += 1
        else:
            result.append(right[j])
            j += 1
    # 将剩余元素加入结果
    result.extend(left[i:])
    result.extend(right[j:])
    return result

归并排序的流程可以概括为以下步骤:

  1. 分解:将待排序列表递归地分成两半。
  2. 解决:递归地对两个子列表进行排序(当子列表长度为1时,直接返回)。
  3. 合并:将两个已排序的子列表合并成一个完整的已排序列表。

理解了递归分治的思想后,我们现在可以回到最初的铺砖难题。

庭院铺砖难题 🏡

现在,我们来看一个具体的难题:如何用L形的三格砖(又称“三联骨牌”)铺满一个边长为 2^n 的正方形庭院?

首先,我们遇到一个基本问题:一个 2^n × 2^n 的庭院总共有 (2^n)^2 = 2^{2n} 个方格。而每块L形砖覆盖3个方格。由于 2^{2n} 不是3的倍数,因此无法用L形砖恰好铺满一个完整的正方形庭院。

为了解决这个问题,我们修改规则:庭院中有一个方格被一座雕像占据,无需铺砖。因此,需要铺砖的方格数变为 2^{2n} - 1。这个数字是3的倍数吗?是的,因为 2^{2n} - 1 = (2^n - 1) × (2^n + 1),而 (2^n - 1)2^n(2^n + 1) 是三个连续整数,其中必有一个是3的倍数,因此它们的乘积 (2^n - 1) × 2^n × (2^n + 1) 能被3整除。虽然我们的表达式略有不同,但从数论上可以证明 2^{2n} - 1 总能被3整除。

所以,问题变为:给定一个 2^n × 2^n 的庭院,其中任意一个方格被雕像占据,能否用L形砖铺满剩余所有方格?

分治解决方案 🧱

我们可以借鉴归并排序的分治思想来解决这个二维铺砖问题。

核心思路是:将大庭院递归地分成四个更小的正方形子庭院。但这里有一个关键:直接分割后,只有包含雕像的那个子庭院是“有一个缺口的庭院”问题,其他三个是“完整庭院”问题,问题类型不统一。

为了让所有子问题变得相同,我们需要一个巧妙的操作:在第一次分割的中心位置,放置一块L形砖。这块砖会覆盖三个子庭院各一个角上的方格。这样,每个子庭院都变成了一个“边长为 2^{n-1} 且有一个缺口的正方形”问题,与原始问题类型完全一致,只是规模更小。

以下是解决问题的步骤概述:

  1. 基准情形:如果庭院是 2×2 大小,那么它正好有一个缺口。此时,只需放置一块L形砖即可铺满剩余三格。
  2. 分解:对于更大的庭院,找到中心点。将庭院分成四个大小相等的象限。
  3. 创造子问题:判断雕像在哪个象限。然后在中心放置一块L形砖,使其覆盖其他三个象限各一个角(即不覆盖雕像所在的象限)。这样,每个象限都变成了一个“有一个缺口的更小庭院”问题。
  4. 递归解决:对这四个子庭院递归地调用相同的铺砖算法。
  5. 合并:递归调用完成后,所有子庭院都被铺满,整个大庭院也就铺满了。

算法的关键在于,每次递归调用都确保将问题转化为四个完全相同类型的更小子问题。放置第一块L形砖的位置和方向,取决于原始雕像所在的位置。

总结 📝

本节课中我们一起学习了递归分治的思想及其应用。我们首先通过归并排序理解了分治如何将问题分解、解决再合并。然后,我们将这一思想应用于庭院铺砖难题。通过巧妙地放置第一块L形砖,我们将一个带有单个缺口的 2^n × 2^n 庭院铺砖问题,分解为四个规模更小的同类型问题,从而递归地解决了它。这个难题生动地展示了分治策略在解决复杂几何组合问题上的威力。

posted @ 2026-03-29 09:22  绝不原创的飞龙  阅读(12)  评论(0)    收藏  举报