第72题 多米诺骨牌均衡状态

124 阅读8分钟

问题描述

小S玩起了多米诺骨牌,他排列了一行骨牌,并可能将某些骨牌向左或向右推倒。随着骨牌连锁反应的进行,一些骨牌可能因为左右两侧受力平衡而保持竖立。现在小S想要知道在所有动作完成后,哪些骨牌保持竖立。

给定一个表示骨牌初始状态的字符串,其中:

  • "L" 表示该位置的骨牌将向左倒。
  • "R" 表示该位置的骨牌将向右倒。
  • "." 表示该位置的骨牌初始时保持竖立。

模拟整个骨牌倒下的过程,求出最终仍然保持竖立的骨牌的数目和位置。

问题分析

这道题的本质是通过模拟多米诺骨牌的倒下过程,找到最终仍然保持竖立的骨牌的位置和数量。骨牌的倒下遵循以下规则:

  1. 向左倒(L)的骨牌会推动其左侧相邻的骨牌倒向左边。
  2. 向右倒(R)的骨牌会推动其右侧相邻的骨牌倒向右边。
  3. 若一个竖立的骨牌同时受到 LR 的作用,由于受力平衡,它将保持竖立。
  4. 如果一个竖立的骨牌仅受到一个方向的作用,它会被推倒并传播这种倒向。

根据题意,我们需要反复模拟骨牌的倒下过程,直到所有骨牌都稳定为止。最后统计剩下的竖立骨牌。


模拟思路

我们可以通过逐轮更新骨牌状态的方式,来模拟整个倒下过程,直到状态不再发生变化。以下是详细的模拟步骤:


思路解析

1. 初始化状态

我们将输入字符串转化为一个字符数组,分别用 prevnow 表示上一轮和当前轮的骨牌状态:

  • prev:上一轮骨牌的状态。
  • now:当前轮骨牌的状态(用于计算新状态)。

初始状态直接从输入中获取。


2. 模拟骨牌倒下的规则

我们逐个遍历数组 now 中的骨牌状态,依据规则进行处理:

  1. 当前骨牌为 L

    • 它会尝试向左推倒左侧的骨牌。如果左侧骨牌是 .(竖立),则变为 L(倒向左)。
    • 如果左侧骨牌是 R,说明两股力量平衡,左侧骨牌恢复为 .(竖立)。
    • 当前骨牌本轮处理完毕,标记为 X,表示已完成传播。
  2. 当前骨牌为 R

    • 它会尝试向右推倒右侧的骨牌。如果右侧骨牌是 .,则变为 R(倒向右)。
    • 如果右侧骨牌是 L,说明两股力量平衡,当前骨牌与右侧骨牌形成三角,因此标记下一张骨牌为X
    • 当前骨牌本轮处理完毕,标记为 X
  3. 当前骨牌为 .X

    • 若为 .,说明该骨牌本轮不受影响,继续保持竖立。
    • 若为 X,说明该骨牌已经完成本轮传播,跳过。
  4. 边界处理

    • 如果当前骨牌为最左侧(i == 0),则无法向左传播。
    • 如果当前骨牌为最右侧(i == num-1),则无法向右传播。

3. 判断结束条件

每次更新完成后,我们检查:

  • 如果本轮更新后的状态与上一轮状态完全相同,说明所有骨牌已经稳定,退出循环。
  • 否则,将 now 的状态赋值给 prev,并开始下一轮更新。

4. 统计竖立骨牌

当状态稳定后,我们统计最终仍然竖立的骨牌:

  • 遍历数组,检查状态为 . 的位置,将其索引加入结果列表。
  • 统计竖立骨牌的数量和索引,并返回结果。

代码详解

以下是代码的逐步解释:

def solution(num, data):
    # 初始化 prev 和 now
    prev = list(data)  # 上一轮状态
    now = list(data)   # 当前轮状态

    while True:  # 模拟循环
        i = 0
        while i < num:  # 遍历所有骨牌
            if now[i] == ".":  # 如果当前骨牌是竖立状态
                i += 1  # 跳过
            elif now[i] == "L":  # 如果当前骨牌向左倒
                if i != 0:  # 检查左侧是否有骨牌
                    if now[i-1] == ".":  # 如果左侧骨牌竖立
                        now[i-1] = "L"  # 推倒左侧骨牌
                    elif now[i-1] == "R":  # 如果左侧骨牌向右倒
                        now[i-1] = "."  # 受力平衡,恢复竖立
                now[i] = "X"  # 标记当前骨牌处理完成
                i += 1
            elif now[i] == "R":  # 如果当前骨牌向右倒
                now[i] = "X"  # 标记当前骨牌处理完成
                if i != num - 1:  # 检查右侧是否有骨牌
                    if now[i+1] == ".":  # 如果右侧骨牌竖立
                        now[i+1] = "R"  # 推倒右侧骨牌
                        i += 2  # 跳过下一骨牌(已处理)
                        continue
                    elif now[i+1] == "L":  # 如果右侧骨牌向左倒
                        now[i+1] = "X"  # 受力平衡,但并非竖立
                i += 1
            else:  # 如果当前骨牌已标记为 X
                i += 1
        
        # 检查是否稳定
        if prev == now:
            break
        else:  # 否则更新 prev 和 now
            prev = now
            now = list(now)

    # 统计竖立的骨牌
    c = 0
    index = []
    for i in range(num):
        if prev[i] == ".":  # 找到竖立的骨牌
            c += 1
            index.append(str(i + 1))  # 记录位置

    if c != 0:
        return f"{c}:{','.join(index)}"
    else:
        return "0"

为什么这样实现

  1. 分离状态更新与稳定检查

    • 使用两个状态数组 prevnow,确保上一轮状态不受本轮更新的影响。
    • 避免因为实时更新导致的连锁反应错误。
  2. 逐步遍历,规则简单清晰

    • 将骨牌倒下的规则按照 LR. 分别处理,逻辑清晰。
  3. 状态稳定后退出

    • 通过对比 prevnow 的状态,判断是否稳定,避免无限循环。

复杂度分析

  1. 时间复杂度

    • 外层 while 循环最多执行 O(num)OO(num)O 次(每次至少有一部分骨牌状态更新)。
    • 内层 for 遍历 O(num)O(num)
    • 总体复杂度为 O(num2)O(num^2)
  2. 空间复杂度

    • 主要存储两个数组 prevnow,空间复杂度为 O(num)O(num)

优化思路:双向扫描

对于这类涉及方向性传播的模拟问题,暴力法虽然直观,但会多次重复更新状态。可以通过一次性处理所有的力量传播,减少不必要的循环。一个更优的解决方案是双向扫描法,它通过记录每个骨牌受力的最终方向来快速得出结果。


核心思想

  1. 左向扫描

    • 从左到右遍历骨牌字符串,记录每个骨牌受右侧 R 推力的影响。
    • 如果遇到 R,开始施加右推力,推力逐渐减弱。
    • 如果遇到 L,右推力终止。
  2. 右向扫描

    • 从右到左遍历骨牌字符串,记录每个骨牌受左侧 L 推力的影响。
    • 如果遇到 L,开始施加左推力,推力逐渐减弱。
    • 如果遇到 R,左推力终止。
  3. 合并结果

    • 对于每个骨牌位置,比较左右两方向的推力:

      • 如果左推力强于右推力,骨牌向左倒(L)。
      • 如果右推力强于左推力,骨牌向右倒(R)。
      • 如果推力相等或没有推力,骨牌保持竖立(.)。

这种方法只需要两次扫描(线性时间),因此效率更高。


代码实现

def solution(num, data):
    # 初始化推力数组
    left_force = [0] * num  # 左向推力
    right_force = [0] * num  # 右向推力

    # 从左到右计算右向推力
    force = 0  # 当前右向推力值
    for i in range(num):
        if data[i] == "R":
            force = num  # 最大推力
        elif data[i] == "L":
            force = 0  # 遇到 L,右推力归零
        elif force > 0:
            force -= 1  # 推力随距离递减
        right_force[i] = force

    # 从右到左计算左向推力
    force = 0  # 当前左向推力值
    for i in range(num - 1, -1, -1):
        if data[i] == "L":
            force = num  # 最大推力
        elif data[i] == "R":
            force = 0  # 遇到 R,左推力归零
        elif force > 0:
            force -= 1  # 推力随距离递减
        left_force[i] = force

    # 合并左右推力,确定最终状态
    result = []
    for i in range(num):
        if left_force[i] > right_force[i]:
            result.append("L")
        elif right_force[i] > left_force[i]:
            result.append("R")
        else:
            result.append(".")

    # 统计竖立的骨牌
    upright_count = 0
    upright_positions = []
    for i in range(num):
        if result[i] == ".":
            upright_count += 1
            upright_positions.append(str(i + 1))  # 索引从 1 开始

    # 返回结果
    if upright_count > 0:
        return f"{upright_count}:{','.join(upright_positions)}"
    else:
        return "0"

复杂度分析

  1. 时间复杂度

    • 左右两次扫描的时间复杂度均为 O(n)O(n),合并结果也是 O(n)O(n),总复杂度为 O(n)O(n)
  2. 空间复杂度

    • 需要两个额外的数组 left_forceright_force,空间复杂度为 O(n)O(n)

总结

这道题如果使用暴力模拟法,核心是正确模拟规则,同时在代码实现中保证状态更新的正确性和高效性。时间复杂度为 O(num2)O(num^2),但对于中等规模的数据足够有效。

如果骨牌数目很大,可以用双向扫描法进行优化。相比暴力模拟法,双向扫描法更加高效:

  1. 只需两次线性扫描即可完成所有推力计算,无需反复迭代更新。
  2. 推力计算逻辑清晰易懂,适用于类似的力传播问题。