【杀戮尖塔2】问号房类型概率的有限状态机模型
素有烟霞志
编辑于 2026年04月28日 12:41
杀戮尖塔2

问题背景

杀戮尖塔2问号房一共可能出现4种房间类型:怪物、宝箱、商店、事件,根据反编译的源码分析,4种房间类型中怪物、宝箱、商店有一个固定的基础概率,剩下的情况出现事件。并且遵循以下规律:

  • 怪物、宝箱、商店基础概率10%,2%,3%

  • 怪物、宝箱、商店被问号房开出后,该类型概率重置为基础概率,否则累加一次基础概率

  • 采用轮盘赌的随机数检测,按照怪物->宝箱->商店的顺序判定,怪物、宝箱、商店的实际概率不会归一化,这意味着如果三者的当前概率之和大于1,顺序靠后的类型出现概率会被截断

  • 进入新一层后会重置基础概率

参考:# 【杀戮尖塔2】问号出怪概率真的有变大吗?一个视频从源码角度讲清塔2问号机制!​

那么,我们自然想知道:

  • 当前局面下问号房出现各个事件的概率有多大

  • 长期来看问号房出现各个事件的占比有多少

先说结论,长期来看各个事件的占比:

  • 怪物: 27.320808%

  • 宝箱: 11.704681%

  • 商店: 14.441581%

  • 事件: 46.532930%

问题定义

  • 怪物(Monster)、宝箱(Treasure)、商店(Shop)的基础概率:P_M%2C%20P_T%2C%20P_S

  • 怪物、宝箱、商店、事件(Event)的当前概率:P_m%2C%20P_t%2C%20P_s%2C%20P_e

  • 当前连续未出现怪物、宝箱、商店的问号房次数:m%2C%20t%2C%20s

  • 约束 P_M%2C%20P_T%2C%20P_S%20%5Cin%20%5B0%2C1)%2C%20%5Cquad%20P_M%20%2B%20P_T%20%2B%20P_S%20%5Cleq%201%2C%20%5Cquad%20m%2C%20t%2C%20s%20%5Cin%20%5Cmathbb%7BN%7D

求任意状态下的当前概率,以及各事件的平稳分布

问题求解

表示目标

首先我们要表示当前概率,当前出现怪物房的概率和连续未出现怪物的次数有关,并且不会超过1: P_m%3D%5Cmin%7B((1%2Bm)*P_M%2C%201)%7D

同理,宝箱的概率也如此,但是有个额外限制是宝箱和怪物的概率和也不能超过1: P_t%3D%5Cmin%7B((1%2Bt)*P_T%2C%201-P_m)%7D

同理,得到商店和事件的概率: P_s%3D%5Cmin%7B((1%2Bt)*P_S%2C%201-P_m-P_t)%7D

P_e%3D1-(P_m%2BP_t%2BP_s)

定义状态空间

基础概率是固定死的,只需要知道连续未出现怪物、宝箱、商店的问号房次数m%2C%20t%2C%20s 就可以唯一地表示当前局面的概率了。

观察可以发现,由于保底机制的存在,出现概率随着连续未出现次数单调增加,并且最终达到1,因此某一种类型连续未出现次数存在最大值,记怪物、宝箱、商店连续未出现次数的最大值为M,T,S,则: M%3D%5Cleft%5Clceil%20%5Cfrac%7B1%7D%7BP_M%7D%20%5Cright%5Crceil%20-1%2C%5Cquad%0AT%3D%5Cleft%5Clceil%20%5Cfrac%7B1%7D%7BP_T%7D%20%5Cright%5Crceil%20-1%2C%5Cquad%0AS%3D%5Cleft%5Clceil%20%5Cfrac%7B1%7D%7BP_S%7D%20%5Cright%5Crceil%20-1,举例来说,怪物房基础概率0.1,那么问号房最多连续不出现怪物的次数是%5Cleft%5Clceil%20%5Cfrac%7B1%7D%7B0.1%7D%20%5Cright%5Crceil%20-1%3D9,因为连续9次不出现怪物后,第10次出现怪物的概率就变成1了。

因此状态空间定义为三元组(m%2Ct%2Cs)%20%5Cin%20%5B0%2CM%5D%20%5Ctimes%20%5B0%2CT%5D%20%5Ctimes%20%5B0%2CS%5D ,总状态数为 (M%2B1)%20%5Ctimes%20(T%2B1)%20%5Ctimes%20(S%2B1),每个状态节点有4条边转移到新的状态

状态转移

若当前状态为(m%2Ct%2Cs),根据各状态转移概率P_m%2C%20P_t%2C%20P_s%2C%20P_e

  • P_m的概率转移为(0%2Ct%2B1%2Cs%2B1)

  • P_t的概率转移为(m%2B1%2C0%2Cs%2B1)

  • P_s的概率转移为(m%2B1%2Ct%2B1%2C0)

  • P_e的概率转移为(m%2B1%2Ct%2B1%2Cs%2B1)

数值求解和模拟验证

我们带入官方的基础概率数值:P_M%3D0.1%2C%20P_T%3D0.02%2C%20P_S%3D0.03 用简单的Python脚本计算状态转移矩阵和平稳分布,计算结果和模拟1,000,000实验结果比对:

代码块
Python
自动换行
复制代码
import math
import itertools
import numpy as np


# ============================================================
# Step 1. 定义基础概率
# ============================================================

# 官方基础概率:
# 怪物 Monster = 10%
# 宝箱 Treasure = 2%
# 商店 Shop = 3%
BASE_PROBS = (0.10, 0.02, 0.03)

ROOM_NAMES = ["怪物", "宝箱", "商店", "事件"]

# ============================================================
# Step 2. 计算当前状态下的实际房间概率
# Step 3. 构造有限状态空间和状态转移矩阵
# Step 4. 计算平稳分布
# Step 5. 计算从新一层开始,前 N 次问号房的平均期望
# Step 6. 蒙特卡洛模拟验证
# ============================================================

# ...

# ============================================================
# Step 7. 主程序
# ============================================================

def main():
    # 构建马尔可夫链
    states, state_to_idx, trans_mat, room_probs = build_markov_chain(BASE_PROBS)

    # 计算平稳分布
    stationary = calc_stationary_distribution(trans_mat)

    # 长期来看,每类房间的出现概率
    long_run_probs = stationary @ room_probs

    print("状态总数:", len(states))

    print("\n长期平稳分布:")
    for name, p in zip(ROOM_NAMES, long_run_probs):
        print(f"{name}: {p:.6%}")

    print("\n前 1~10 次问号房平均期望:")
    print("| 问号房次数 | 怪物 | 宝箱 | 商店 | 事件 |")
    print("|---:|---:|---:|---:|---:|")

    first_n_result = calc_first_n_average_probs(BASE_PROBS, max_n=10)
    for n, probs in first_n_result:
        print(
            f"| {n} | "
            f"{probs[0]:.4%} | "
            f"{probs[1]:.4%} | "
            f"{probs[2]:.4%} | "
            f"{probs[3]:.4%} |"
        )

    # 模拟 1,000,000 次进行验证
    sim_probs = simulate(BASE_PROBS, num_trials=1_000_000, seed=42)

    print("\n模拟结果:")
    for name, p in zip(ROOM_NAMES, sim_probs):
        print(f"{name}: {p:.6%}")


if __name__ == "__main__":
    main()


# ============================================================
# 运行结果示例
# ============================================================
#
# 状态总数: 17000
#
# 长期平稳分布:
# 怪物: 27.320808%
# 宝箱: 11.704681%
# 商店: 14.441581%
# 事件: 46.532930%
#
# 前 1~10 次问号房平均期望:
# | 问号房次数 | 怪物 | 宝箱 | 商店 | 事件 |
# |---:|---:|---:|---:|---:|
# | 1 | 10.0000% | 2.0000% | 3.0000% | 85.0000% |
# | 2 | 14.5000% | 2.9800% | 4.4550% | 78.0650% |
# | 3 | 18.1000% | 3.9208% | 5.8227% | 72.1565% |
# | 4 | 20.6425% | 4.8053% | 7.0680% | 67.4842% |
# | 5 | 22.2382% | 5.6198% | 8.1661% | 63.9759% |
# | 6 | 23.1702% | 6.3544% | 9.1045% | 61.3710% |
# | 7 | 23.7394% | 7.0037% | 9.8804% | 59.3765% |
# | 8 | 24.1508% | 7.5666% | 10.5072% | 57.7755% |
# | 9 | 24.4916% | 8.0459% | 11.0037% | 56.4588% |
# | 10 | 24.7795% | 8.4479% | 11.3918% | 55.3807% |
#
# 模拟结果:
# 怪物: 27.313300%
# 宝箱: 11.687100%
# 商店: 14.454000%
# 事件: 46.545600%
复制成功

可以看到模拟结果符合我们的理论计算。也就是说,从长期的角度看,如果我们无限的进问号房,遇到怪物、宝箱、商店和事件的概率分别为:27.3%, 11.7%, 14.4%, 46.5%。当然我们不可能无限的进,这里根据单层进入1~10次问号房各事件的平均期望来看:问号走的越多,遇到怪物的可能性越高,但是宝箱和商店的可能性也越高

工具

数学问题解决了,有什么用呢?

  • 可以开发一个工具,直接计算当前局面问号房出现各事件出现的概率,辅助决策

  • 可以从长期角度看统计结论,辅助大局观上的决策

完整Python脚本

代码块
Python
自动换行
复制代码
import math
import itertools
import numpy as np


# ============================================================
# Step 1. 定义基础概率
# ============================================================

# 官方基础概率:
# 怪物 Monster = 10%
# 宝箱 Treasure = 2%
# 商店 Shop = 3%
BASE_PROBS = (0.10, 0.02, 0.03)

ROOM_NAMES = ["怪物", "宝箱", "商店", "事件"]


# ============================================================
# Step 2. 计算当前状态下的实际房间概率
# ============================================================

def calc_current_probs(state, base_probs):
    """
    根据当前状态 (m, t, s) 计算问号房实际出现概率。

    state:
        m: 连续未出现怪物的次数
        t: 连续未出现宝箱的次数
        s: 连续未出现商店的次数

    注意:
    游戏使用顺序轮盘赌:
        怪物 -> 宝箱 -> 商店 -> 事件

    因此:
        怪物概率不受后续类型影响;
        宝箱概率会被怪物概率截断;
        商店概率会被怪物 + 宝箱概率截断;
        事件概率是剩余概率。
    """
    m, t, s = state
    p_m_base, p_t_base, p_s_base = base_probs

    # 怪物概率:连续没出怪越久,概率越高,但最多为 1
    p_m = min((m + 1) * p_m_base, 1.0)

    # 宝箱概率:自身概率最多为 (t + 1) * 基础概率,
    # 但因为怪物先判定,所以不能超过剩余概率 1 - p_m
    p_t = min((t + 1) * p_t_base, max(0.0, 1.0 - p_m))

    # 商店概率:同理,会被怪物和宝箱截断
    p_s = min((s + 1) * p_s_base, max(0.0, 1.0 - p_m - p_t))

    # 事件概率:前三类都没有触发时才出现
    p_e = max(0.0, 1.0 - p_m - p_t - p_s)

    return np.array([p_m, p_t, p_s, p_e], dtype=np.float64)


# ============================================================
# Step 3. 构造有限状态空间和状态转移矩阵
# ============================================================

def build_markov_chain(base_probs):
    """
    构建有限状态机对应的马尔可夫链。

    返回:
        states: 所有状态列表
        state_to_idx: 状态到矩阵下标的映射
        trans_mat: 状态转移矩阵
        room_probs: 每个状态下对应的四类房间概率
    """
    p_m_base, p_t_base, p_s_base = base_probs

    # 连续未出现次数的最大值
    # 例如 p=0.1 时,最多连续 9 次没出;
    # 到第 10 次时,该类型概率达到 100%
    max_m = math.ceil(1.0 / p_m_base) - 1
    max_t = math.ceil(1.0 / p_t_base) - 1
    max_s = math.ceil(1.0 / p_s_base) - 1

    # 枚举所有状态 (m, t, s)
    states = list(itertools.product(
        range(max_m + 1),
        range(max_t + 1),
        range(max_s + 1)
    ))

    state_to_idx = {state: idx for idx, state in enumerate(states)}
    num_states = len(states)

    # trans_mat[i, j] 表示从状态 i 转移到状态 j 的概率
    trans_mat = np.zeros((num_states, num_states), dtype=np.float64)

    # room_probs[i] 表示状态 i 下 [怪物, 宝箱, 商店, 事件] 的概率
    room_probs = np.zeros((num_states, 4), dtype=np.float64)

    for i, state in enumerate(states):
        m, t, s = state

        # 当前状态下四类房间概率
        probs = calc_current_probs(state, base_probs)
        room_probs[i] = probs

        # 出怪物:
        # 怪物计数归零,宝箱和商店的连续未出现次数 +1
        next_if_monster = (
            0,
            min(t + 1, max_t),
            min(s + 1, max_s)
        )

        # 出宝箱:
        # 宝箱计数归零,怪物和商店的连续未出现次数 +1
        next_if_treasure = (
            min(m + 1, max_m),
            0,
            min(s + 1, max_s)
        )

        # 出商店:
        # 商店计数归零,怪物和宝箱的连续未出现次数 +1
        next_if_shop = (
            min(m + 1, max_m),
            min(t + 1, max_t),
            0
        )

        # 出事件:
        # 怪物、宝箱、商店都没出,所以三者连续未出现次数都 +1
        next_if_event = (
            min(m + 1, max_m),
            min(t + 1, max_t),
            min(s + 1, max_s)
        )

        next_states = [
            next_if_monster,
            next_if_treasure,
            next_if_shop,
            next_if_event
        ]

        # 写入状态转移矩阵
        for p, next_state in zip(probs, next_states):
            j = state_to_idx[next_state]
            trans_mat[i, j] += p

    return states, state_to_idx, trans_mat, room_probs


# ============================================================
# Step 4. 计算平稳分布
# ============================================================

def calc_stationary_distribution(trans_mat, tol=1e-15, max_iter=100000):
    """
    用幂迭代法计算平稳分布 pi。

    平稳分布满足:
        pi = pi @ trans_mat
    """
    num_states = trans_mat.shape[0]

    # 初始时假设所有状态均匀分布
    pi = np.ones(num_states, dtype=np.float64) / num_states

    for _ in range(max_iter):
        new_pi = pi @ trans_mat

        # 如果分布变化已经足够小,认为收敛
        if np.max(np.abs(new_pi - pi)) < tol:
            return new_pi

        pi = new_pi

    return pi


# ============================================================
# Step 5. 计算从新一层开始,前 N 次问号房的平均期望
# ============================================================

def calc_first_n_average_probs(base_probs, max_n=10):
    """
    计算从状态 (0, 0, 0) 开始,
    进入前 1~max_n 次问号房时,
    四类房间的平均出现概率。
    """
    states, state_to_idx, trans_mat, room_probs = build_markov_chain(base_probs)

    # 初始状态:新一层刚开始,三类特殊概率都处于基础值
    dist = np.zeros(len(states), dtype=np.float64)
    dist[state_to_idx[(0, 0, 0)]] = 1.0

    # 累计前若干次问号房的概率
    cumulative_probs = np.zeros(4, dtype=np.float64)

    result = []

    for n in range(1, max_n + 1):
        # 第 n 次问号房的四类房间概率
        current_room_probs = dist @ room_probs

        # 累加到前 n 次总期望中
        cumulative_probs += current_room_probs

        # 前 n 次平均概率
        average_probs = cumulative_probs / n

        result.append((n, average_probs.copy()))

        # 进入下一次问号房前,更新状态分布
        dist = dist @ trans_mat

    return result


# ============================================================
# Step 6. 蒙特卡洛模拟验证
# ============================================================

def simulate(base_probs, num_trials=1_000_000, seed=42):
    """
    用随机模拟验证理论结果。

    模拟逻辑严格按照:
        怪物 -> 宝箱 -> 商店 -> 事件
    的顺序轮盘赌进行。
    """
    rng = np.random.default_rng(seed)

    p_m_base, p_t_base, p_s_base = base_probs

    max_m = math.ceil(1.0 / p_m_base) - 1
    max_t = math.ceil(1.0 / p_t_base) - 1
    max_s = math.ceil(1.0 / p_s_base) - 1

    # 初始状态
    state = [0, 0, 0]

    # 统计 [怪物, 宝箱, 商店, 事件] 出现次数
    counts = np.zeros(4, dtype=np.int64)

    for _ in range(num_trials):
        probs = calc_current_probs(tuple(state), base_probs)
        r = rng.random()

        if r < probs[0]:
            # 出怪物
            room_idx = 0
            state = [
                0,
                min(state[1] + 1, max_t),
                min(state[2] + 1, max_s)
            ]

        elif r < probs[0] + probs[1]:
            # 出宝箱
            room_idx = 1
            state = [
                min(state[0] + 1, max_m),
                0,
                min(state[2] + 1, max_s)
            ]

        elif r < probs[0] + probs[1] + probs[2]:
            # 出商店
            room_idx = 2
            state = [
                min(state[0] + 1, max_m),
                min(state[1] + 1, max_t),
                0
            ]

        else:
            # 出事件
            room_idx = 3
            state = [
                min(state[0] + 1, max_m),
                min(state[1] + 1, max_t),
                min(state[2] + 1, max_s)
            ]

        counts[room_idx] += 1

    return counts / num_trials


# ============================================================
# Step 7. 主程序
# ============================================================

def main():
    # 构建马尔可夫链
    states, state_to_idx, trans_mat, room_probs = build_markov_chain(BASE_PROBS)

    # 计算平稳分布
    stationary = calc_stationary_distribution(trans_mat)

    # 长期来看,每类房间的出现概率
    long_run_probs = stationary @ room_probs

    print("状态总数:", len(states))

    print("\n长期平稳分布:")
    for name, p in zip(ROOM_NAMES, long_run_probs):
        print(f"{name}: {p:.6%}")

    print("\n前 1~10 次问号房平均期望:")
    print("| 问号房次数 | 怪物 | 宝箱 | 商店 | 事件 |")
    print("|---:|---:|---:|---:|---:|")

    first_n_result = calc_first_n_average_probs(BASE_PROBS, max_n=10)
    for n, probs in first_n_result:
        print(
            f"| {n} | "
            f"{probs[0]:.4%} | "
            f"{probs[1]:.4%} | "
            f"{probs[2]:.4%} | "
            f"{probs[3]:.4%} |"
        )

    # 模拟 1,000,000 次进行验证
    sim_probs = simulate(BASE_PROBS, num_trials=1_000_000, seed=42)

    print("\n模拟结果:")
    for name, p in zip(ROOM_NAMES, sim_probs):
        print(f"{name}: {p:.6%}")


if __name__ == "__main__":
    main()


# ============================================================
# 运行结果示例
# ============================================================
#
# 状态总数: 17000
#
# 长期平稳分布:
# 怪物: 27.320808%
# 宝箱: 11.704681%
# 商店: 14.441581%
# 事件: 46.532930%
#
# 前 1~10 次问号房平均期望:
# | 问号房次数 | 怪物 | 宝箱 | 商店 | 事件 |
# |---:|---:|---:|---:|---:|
# | 1 | 10.0000% | 2.0000% | 3.0000% | 85.0000% |
# | 2 | 14.5000% | 2.9800% | 4.4550% | 78.0650% |
# | 3 | 18.1000% | 3.9208% | 5.8227% | 72.1565% |
# | 4 | 20.6425% | 4.8053% | 7.0680% | 67.4842% |
# | 5 | 22.2382% | 5.6198% | 8.1661% | 63.9759% |
# | 6 | 23.1702% | 6.3544% | 9.1045% | 61.3710% |
# | 7 | 23.7394% | 7.0037% | 9.8804% | 59.3765% |
# | 8 | 24.1508% | 7.5666% | 10.5072% | 57.7755% |
# | 9 | 24.4916% | 8.0459% | 11.0037% | 56.4588% |
# | 10 | 24.7795% | 8.4479% | 11.3918% | 55.3807% |
#
# 模拟结果:
# 怪物: 27.313300%
# 宝箱: 11.687100%
# 商店: 14.454000%
# 事件: 46.545600%
复制成功