news 2026/9/12 18:12:13

蓝桥杯算法题解析:从DOTA博弈到动态规划状态设计

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
蓝桥杯算法题解析:从DOTA博弈到动态规划状态设计

1. 项目概述:从一道算法题看“DOTA”背后的博弈逻辑

看到“ALGO-529 DOTA”这个标题,很多参加过蓝桥杯算法训练的同学可能会会心一笑。这可不是让你去玩那款著名的多人在线战术竞技游戏,而是一道经典的、以游戏为背景的算法题目。这类题目在蓝桥杯的ALGO(算法训练)板块中非常典型,它们擅长将复杂的现实问题或游戏规则,抽象成清晰的数学模型和算法问题,考察选手的逻辑分析、数学建模和编程实现能力。这道“DOTA”题,本质上是一道关于策略博弈的题目,它剥离了游戏中华丽的技能特效和复杂的操作,直指核心:在特定规则下,如何做出最优决策来赢得胜利。对于正在备赛蓝桥杯,尤其是对动态规划、博弈论或贪心策略感兴趣的同学来说,深入剖析这道题,其价值远超解出题本身,它能帮你建立起一套分析“游戏规则类”算法题的通用思维框架。

2. 核心需求与问题抽象化拆解

在动手写任何一行代码之前,我们必须像侦探一样,把题目给出的“案件描述”翻译成程序员能理解的“需求规格说明书”。这是解决所有算法题,尤其是情景应用题最关键的一步,走偏了后面全白费。

2.1 题目场景还原与规则理解

虽然我们无法看到原题的全部描述,但结合“DOTA”和算法题的普遍特点,我们可以合理推断并构建其核心模型。在DOTA类游戏中,一个核心机制是英雄的攻击与防御,通常涉及攻击力、护甲、生命值等属性。一道算法题很可能将其简化。

一个非常经典的简化模型是:有两个英雄(或单位)A和B进行对决。每个英雄有初始生命值(HP)。他们轮流进行攻击,每次攻击会造成固定的伤害。但这里可能引入一个“DOTA特色”的变量:护甲伤害格挡。例如,英雄B可能拥有一个技能或属性,使得他每次受到攻击时,有概率格挡掉部分或全部伤害。或者,题目可能简化为一个更纯粹的“回合制减血”模型,但加入了“先手优势”、“最大伤害限制”或“技能冷却”等约束条件。

核心要抓准的点

  1. 回合顺序:是标准的A先攻击B,然后B攻击A,如此交替,直到一方生命值≤0吗?还是有其他行动顺序规则?
  2. 伤害判定:每次攻击造成的伤害是固定的,还是基于攻击力、护甲计算后的结果?是否有概率性因素(如暴击、闪避)?如果有概率,题目通常会用“期望值”来转化为确定性计算。
  3. 胜利条件:是否是一方生命值先归零?还是存在其他胜利条件(如规定回合数内造成伤害最多)?
  4. 决策点:作为解题者,我们的决策是什么?是决定攻击的时机?选择攻击的目标?还是使用某种技能?题目最终要求我们输出什么?是获胜的概率、最优策略下的获胜方,还是确保胜利所需的最小初始属性?

注意:在蓝桥杯的算法题中,涉及概率的题目通常会转化为期望计算或最优策略选择,而不会让你去写一个随机模拟。因为判题机需要确定的输出。

2.2 从游戏到数学模型的关键抽象

假设我们推断题目是这样的一个经典博弈模型(这常见于许多编程竞赛):

  • 英雄A和B的生命值分别为HP_AHP_B
  • A先手攻击,每次攻击对B造成damage_A点伤害。
  • B后手攻击,每次攻击对A造成damage_B点伤害。
  • 两人轮流攻击,每次攻击结算后立即判断对方生命值,如果生命值≤0,则攻击方获胜。
  • 问:给定HP_A,HP_B,damage_A,damage_B,判断A是否一定能获胜(在双方都采取最优策略——实际上在这个简单模型里没有策略空间,就是轮流攻击)。

那么,这个问题就抽象成了一个数学计算问题。A获胜的条件是:A能在B杀死自己之前杀死B。计算A杀死B需要的攻击次数:attacks_needed_by_A = ceil(HP_B / damage_A)ceil是向上取整,因为即使最后一次攻击伤害溢出,也需要那次攻击)。同理,B杀死A需要的攻击次数:attacks_needed_by_B = ceil(HP_A / damage_B)

由于A先手,攻击顺序是:A, B, A, B, A, B...。A在第1, 3, 5...次出手时攻击。如果A需要的攻击次数attacks_needed_by_A小于等于B需要的攻击次数attacks_needed_by_B,那么A就能赢。因为当A发动第attacks_needed_by_A次攻击时(这是一个奇数序次的攻击),B还没有机会发动第attacks_needed_by_B次攻击(这是一个偶数或奇数序次,取决于数值)。

更复杂的抽象:如果题目引入了“技能”,比如A可以蓄力(跳过一回合以增加下一回合伤害),或者B可以治疗(恢复生命值)。那么,这就从一个简单的计算问题,升级为一个博弈搜索问题动态规划问题。状态空间可能包括:(当前A的HP, 当前B的HP, 当前回合轮到谁,A的技能冷却,B的技能冷却...)。我们需要在这个状态空间中,寻找一个必胜的策略。

3. 算法思路设计与方案选型

面对一个抽象好的模型,我们需要选择合适的数据结构和算法来攻克它。不同的模型复杂度,对应不同的方法。

3.1 基础计算模型的直接解法

对于上述最简单的轮流攻击模型,解法就是几行代码的事。核心就是计算攻击次数并比较。

#include <stdio.h> #include <math.h> int main() { int HP_A, HP_B, damage_A, damage_B; // 假设从输入读取数据 scanf("%d %d %d %d", &HP_A, &HP_B, &damage_A, &damage_B); // 计算所需攻击次数,注意向上取整 int attacks_A_needs = (HP_B + damage_A - 1) / damage_A; // 等价于ceil(HP_B / damage_A) int attacks_B_needs = (HP_A + damage_B - 1) / damage_B; // 等价于ceil(HP_A / damage_B) // 判断逻辑:A先手,所以A发动第attacks_A_needs次攻击的回合序次必须早于B发动第attacks_B_needs次攻击。 // 由于A在奇数回合攻击(1,3,5...),B在偶数回合攻击(2,4,6...) // A在第 (2*attacks_A_needs - 1) 回合发动最后一次攻击。 // B在第 (2*attacks_B_needs) 回合发动最后一次攻击。 // 如果 (2*attacks_A_needs - 1) < (2*attacks_B_needs),则A赢。 if ((2 * attacks_A_needs - 1) < (2 * attacks_B_needs)) { printf("A\n"); } else { printf("B\n"); } return 0; }

为什么这样比较?这是本题最易错的点。不能直接比较attacks_A_needsattacks_B_needs。因为A先手,A的第一次攻击发生在第1回合,B的第一次攻击发生在第2回合。所以,A发动第k次攻击的回合号是2k-1,B发动第k次攻击的回合号是2k。我们必须比较他们各自完成致命一击的回合号

3.2 引入策略选择后的搜索与动态规划

如果模型更复杂,比如英雄每回合可以选择“攻击”或“防御”(防御减伤),或者有魔法值可以释放技能,问题就变成了一个博弈论中的完全信息零和游戏。对于这类问题,常见的解法有:

  1. 极小化极大算法(Minimax):非常适合回合制、双方轮流行动、信息完全的博弈。算法会模拟双方的所有可能行动序列,假设对手总是做出对你最不利的选择(极小),而你则选择对自己最有利的走法(极大)。对于状态空间不大的题目,可以通过递归+记忆化搜索实现。

  2. 动态规划(DP):如果状态可以清晰地定义且无环(例如,生命值只会减少或按规则变化,不会无限循环),DP是更高效的解法。我们可以定义dp[hp_a][hp_b][turn][skill_cd...]表示在某个状态下,当前行动方的胜率(或是否必胜)。然后根据状态转移方程(执行某个行动后,转移到下一个状态,胜负关系随之改变)来递推或记忆化搜索。

方案选型考量

  • 数据范围:这是决定算法的第一因素。如果生命值、魔法值等参数范围很小(比如都在100以内),那么状态总数可能只有几万到几十万,记忆化搜索或DP是可行的。如果范围很大,就必须寻找数学规律或贪心策略。
  • 是否存在平局或循环:如果规则可能导致无限循环(例如双方都选择防御,都不掉血),那么Minimax递归可能需要设置深度限制或检测状态重复。DP则需要判断状态图是否有环。
  • 输出要求:是输出必胜/必败,还是输出最优策略下的具体操作序列?前者通常用布尔值DP,后者需要在DP时记录决策路径。

对于“ALGO-529 DOTA”,鉴于它是蓝桥杯算法训练题,其难度和考察点通常是动态规划带有技巧性的数学计算。极大概率,它需要我们定义出一个巧妙的状态,然后找到状态之间的转移关系。

4. 深度剖析:一个假设的复杂DOTA模型与DP解法

让我们构建一个更贴近“DOTA”名字、可能出现在蓝桥杯中的题目模型,并详细讲解如何用动态规划解决它。这个模型会比简单对砍复杂,但又在竞赛题常见范围内。

假设题目描述: 英雄A和B对决。A有生命值HP_A,B有生命值HP_B。A的先攻值比B高,所以A先手。 每回合,行动方可以选择以下两种行动之一:

  1. 普通攻击:对对方造成1点伤害。
  2. 蓄力攻击(技能):本回合不造成伤害,但下一回合你的普通攻击伤害变为2点。蓄力效果只能持续一回合,且不能叠加(即连续蓄力,第二回合的伤害仍是2,不会变成3)。

双方都采取最优策略,判断A是否必胜。

4.1 状态定义与DP数组设计

这是一个典型的博弈DP问题。我们需要用状态来描述“战局”。

状态参数

  • a:英雄A的当前生命值。
  • b:英雄B的当前生命值。
  • turn:当前轮到谁行动。0表示A行动,1表示B行动。
  • buff:一个标记,表示当前行动方是否处于上一回合自己蓄力带来的增益状态(即本回合普通攻击伤害为2)。注意,这个buff是附着在“当前行动方”身上的。因为A蓄力,增益的是A的下回合;B蓄力,增益的是B的下回合。

DP数组定义dp[a][b][turn][buff]

  • 其值的含义:在(a, b, turn, buff)这个状态下,当前行动方是否必胜。
  • 值 = 1 表示必胜, = 0 表示必败(假设对方也最优操作)。

初始状态与边界

  • 如果当前行动方是A(turn=0),且B的生命值b <= 0,那么A已经赢了,但这不是一个“轮到A行动”的合理状态。更合理的边界是:在任何状态,如果对方的生命值<=0,则当前行动方已经输了(因为上一回合对方已经把你打死了,游戏结束)。所以,我们在转移前先判断:if (turn==0 && b<=0) return 0; // B已死,现在是A的回合?不可能,说明A输了。实际上,我们应该在上一回合攻击后立即判断游戏结束。因此,在状态转移中,执行“攻击”动作后,如果对方生命值<=0,则当前行动方获得胜利,这个状态是必胜态。
  • 更严谨的边界在DP过程中体现:当做出一个攻击动作,使得对方生命值降至0或以下,则从该动作出发,当前行动方获胜。

4.2 状态转移方程与递归实现

我们用记忆化搜索(递归+缓存)来实现这个DP,思路更清晰。

对于当前状态(a, b, turn, buff)

  1. 当前行动方有两种选择:攻击蓄力
  2. 尝试每一种选择,模拟行动后的新状态,并查看新状态下对方是否必胜。
  3. 如果存在至少一种选择,使得新状态下对方必败,那么当前状态就是必胜的(因为我可以选择那个让对手陷入必败局面的操作)。
  4. 如果所有选择都导致新状态下对方必胜,那么当前状态就是必败的。

具体转移

  • 当前行动方是A (turn=0)
    • 选择攻击
      • 伤害值dmg = buff ? 2 : 1
      • 新B生命值nb = b - dmg
      • 如果nb <= 0,那么A直接获胜,此选择导致当前状态必胜。
      • 否则,游戏继续,轮到B行动。新状态是(a, nb, 1, 0)。注意,buff置0,因为这是A的buff,A行动完后buff就消耗了(如果是蓄力带来的),且轮到B时,B没有来自A的buff。
    • 选择蓄力
      • A本回合不造成伤害。
      • 新状态是(a, b, 1, 1)。轮到B行动,并且标记B面临一个buff?不对!这里是个关键点:buff表示的是当前行动方自己身上的增益。A蓄力,增益的是下一回合的A。所以当A选择蓄力,行动权交给B时,我们需要记录“下一回合A有buff”这个信息。但我们的状态buff是描述当前行动方的。因此,状态设计需要调整。

发现状态设计缺陷:我们的buff不能同时表示“A的下一回合增益”和“B的下一回合增益”。因为当轮到A时,buff表示A本回合是否有增益;轮到B时,buff表示B本回合是否有增益。但A蓄力产生的增益,在B行动时是“未来时”,无法用B当前的状态buff表示。

修正状态设计:我们需要将buff与具体英雄绑定。一个更清晰的设计是: 状态:(a, b, turn, a_buff, b_buff)

  • a_buff: 布尔值,表示如果下一回合轮到A,A的攻击是否具有增益(伤害为2)。
  • b_buff: 布尔值,表示如果下一回合轮到B,B的攻击是否具有增益。

这样,无论当前轮到谁,我们都能知道如果他/她攻击,伤害是多少。

修正后的转移逻辑(伪代码思路)

// 函数返回当前状态 (a, b, turn, a_buff, b_buff) 下,当前行动方是否必胜。 int dfs(int a, int b, int turn, int a_buff, int b_buff) { // 记忆化检索 if (dp已计算) return dp值; int can_win = 0; // 初始假设无法必胜 if (turn == 0) { // A的回合 // 选择1: 攻击 int dmg = a_buff ? 2 : 1; int nb = b - dmg; if (nb <= 0) { can_win = 1; // A直接获胜 } else { // 攻击后,A的buff被消耗,所以下一回合A的buff为0。 // 轮到B,B的buff保持不变(b_buff)。 // 注意:A攻击后,之前可能存在的“B的下一回合增益”b_buff依然有效,因为它描述的是B的回合。 int next_state = dfs(a, nb, 1, 0, b_buff); if (next_state == 0) { // 下一状态(B行动)下,B必败,意味着A赢了 can_win = 1; } } // 选择2: 蓄力 if (!can_win) { // 如果攻击选择还没能赢,尝试蓄力 // 蓄力后,本回合不造成伤害。但为下一回合A创建增益。 // 所以,新状态中,a_buff(表示下一回合A的增益)设为1。 // 轮到B,B的buff不变。 int next_state = dfs(a, b, 1, 1, b_buff); if (next_state == 0) { // 下一状态(B行动)下,B必败 can_win = 1; } } } else { // B的回合,逻辑对称 // 选择1: 攻击 int dmg = b_buff ? 2 : 1; int na = a - dmg; if (na <= 0) { // B直接获胜,对于当前状态(B行动)来说是必胜 can_win = 1; } else { // 攻击后,B的buff被消耗,下一回合B的buff为0。 // 轮到A,A的buff保持不变。 int next_state = dfs(na, b, 0, a_buff, 0); if (next_state == 0) { // 下一状态(A行动)下,A必败,意味着B赢了 can_win = 1; } } // 选择2: 蓄力 if (!can_win) { // 蓄力后,为下一回合B创建增益。 int next_state = dfs(a, b, 0, a_buff, 1); if (next_state == 0) { can_win = 1; } } } dp[a][b][turn][a_buff][b_buff] = can_win; return can_win; }

初始化调用:游戏开始时,A先手,双方都无增益。所以调用dfs(HP_A, HP_B, 0, 0, 0)。如果返回1,则A有必胜策略。

这个模型和DP思路,很好地体现了如何将一个带有策略选择的游戏问题,通过合理的状态定义,转化为一个可计算的动态规划问题。蓝桥杯的很多“游戏题”都遵循这个套路。

5. 常见陷阱、调试技巧与优化策略

即便思路正确,实现时也会踩很多坑。下面分享一些从这类题目中总结出的实战经验。

5.1 边界条件与游戏终止判断

这是最容易出错的地方之一。

  • 生命值非负:在DP状态中,生命值ab在作为数组下标时,必须确保非负。在递归时,一旦生命值小于0,应该立即视为“已死亡”,并返回相应的胜负结果,而不是继续用负值索引数组导致越界。通常,我们会把“攻击后对方生命值<=0”作为产生胜负结果的判断点,而不是把生命值<=0作为一个独立状态去查询DP值。
  • 胜负归属:明确DP值的定义。dp(state) = 1代表当前行动方必胜。那么,当一方攻击导致对方死亡,当前行动方立即获胜,这是一个“必胜”的终端状态。在递归函数中,应该在尝试“攻击”动作后立即判断并返回,而不是进入下一层递归。
  • 平局与循环:在这个假设的DOTA题里,如果双方都无限蓄力,游戏可能无法终止。我们的DP递归可能会因为没有终止条件而栈溢出或无限循环。在实际题目中,出题人通常会避免这种无限循环,或者明确说明“双方都采取最优策略”意味着会选择能赢或避免输的策略。但在更复杂的题目中,可能需要检测状态重复(通过访问标记)来判断平局。

5.2 记忆化搜索的实现细节

用C语言实现记忆化搜索,需要注意:

  1. DP数组大小与初始化:根据题目给出的生命值上限(比如HP_A, HP_B <= 100)来定义数组。buff是布尔值,大小为2。turn也是2。所以数组可能是dp[101][101][2][2][2]。初始化时用-1填充,表示未计算。
  2. 递归函数设计:函数参数就是状态。返回值是int(1胜0负)。在函数开头,先检查记忆化数组,如果已计算则直接返回。
  3. 递归深度:最坏情况下,递归深度可能是HP_A + HP_B的量级(每次攻击减1点血)。对于生命值上限100的情况,递归深度200左右,栈空间是安全的。但如果生命值上限到1000,递归深度可能达到2000,在某些环境下有栈溢出风险。这时可以考虑用递推(自底向上)的DP循环来代替递归。不过,对于博弈DP,记忆化搜索的写法通常更直观。

5.3 从搜索到DP的优化思路

如果状态空间太大(比如生命值上限1000,加上多个技能状态),记忆化搜索可能超时或超内存。这时需要思考:

  • 寻找规律,化简状态:例如,在上述假设模型中,可能存在着“先手优势”的数学规律。也许可以通过数学证明,当A的生命值和伤害满足某个不等式时,A总可以通过一种固定策略(比如一直攻击)获胜。竞赛中很多题目的正解都是找规律,而非暴力DP。
  • 对称性剪枝:如果游戏双方完全对称(除了先手),那么状态(a,b)(b,a)可能具有对称的胜负关系,可以减少一半的计算量。
  • DP状态压缩:如果某些状态参数是布尔值或范围很小,可以用位运算压缩到一个整数里,减少数组维度和缓存不命中的概率。

6. 解题框架总结与举一反三

回顾我们对“ALGO-529 DOTA”的整个分析过程,可以提炼出一个解决蓝桥杯乃至其他竞赛中“游戏博弈类”算法题的通用框架:

  1. 彻底理解规则与抽象模型:这是最重要的一步。忽略所有故事背景,用变量(生命值、攻击力、回合、状态标志)和规则(行动选择、状态转移、胜负判定)来精确描述游戏。画状态转移图有助于理解。
  2. 确定算法范式
    • 纯计算:如果双方没有选择,只是按固定规则交互(如简单对砍)。直接推导数学公式计算。
    • 博弈搜索/DP:如果每回合有不同选择。优先考虑动态规划或记忆化搜索。定义出包含所有必要信息的“状态”。
    • 贪心:在某些情况下,可能存在明显的最优单步策略(比如能斩杀时就攻击),可以用贪心简化。
  3. 精细定义DP状态:状态需要能唯一确定当前局面和后续发展。常见要素包括:各方核心属性(HP, MP)、当前回合、冷却时间、增益/减益效果等。务必注意状态的“视角”(是谁的回合)和“时效性”(效果持续多久)。
  4. 设计状态转移:模拟当前行动方的所有合法操作。对于每个操作,计算产生的新状态。关键点:操作后,行动权交换,在新状态下对方变成了“当前行动方”。因此,转移方程的核心逻辑是:如果存在一个操作,使得操作后的新状态是对方的必败态,那么当前状态就是必胜态
  5. 处理边界与记忆化:明确游戏终止条件(生命值<=0),并在转移中立即处理。使用数组或哈希表存储已计算的状态,避免重复计算。
  6. 代码实现与测试:用清晰的代码实现上述逻辑。用题目给的样例、边界情况(如生命值为1)以及自己构造的小数据(比如HP很小)进行测试,验证逻辑正确性。

举一反三:这个框架不仅适用于“DOTA”,也适用于蓝桥杯题库里其他的游戏题,比如“取石子游戏”、“巧克力大战”、“高僧斗法”等。它们的本质都是完全信息零和博弈,都可以尝试用状态DP来求解。区别只在于状态的定义和转移规则的不同。

最后,关于这道题的具体实现,由于我们没有原题的精确描述,上述分析和模型是一个基于经验的、完整的解题推演。当你拿到真实题目时,请务必严格按照题目描述的规则来定义状态和转移。算法竞赛的魅力就在于,将天马行空的游戏,转化为严谨优美的逻辑与代码。希望这份拆解,能帮你下次遇到“ALGO-XXX 游戏名”时,心中不再慌张,而是能沉着地开始你的“状态设计”。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/2 4:17:12

仿人眼图像传感系统如何实现10倍目标识别加速

最近在做一个跟仿生视觉有关的项目&#xff1a;把图像传感系统从“全帧均匀采样”改成“仿人眼注意力采样”之后&#xff0c;目标识别速度直接拉了十倍。项目名就叫 Human Vision Image-Sensing System Provides 10 Faster Recognition。说出来有点玄乎&#xff0c;但原理其实很…

作者头像 李华
网站建设 2026/9/4 11:30:11

YC Startup School 2026:Claude Code 创造者 Boris Cherny 深度对谈全记录

【摘要】本文为 Y Combinator 2026 年 Startup School 活动现场对谈的完整实录。本次对话由 YC 合伙人 Diana Hu 主持&#xff0c;邀请到 Claude Code 创造者、Anthropic 首席工程师 Boris Cherny 作为核心嘉宾&#xff0c;围绕刚发布的 Claude Opus 5 模型展开深度分享。对谈内…

作者头像 李华
网站建设 2026/8/30 19:00:45

SAP ABAP传输请求释放失败:Return Code 8排查与解决指南

1. 问题引入&#xff1a;一个让ABAP开发者头疼的“8号”错误在SAP ABAP开发中&#xff0c;传输请求&#xff08;Transport Request&#xff0c;简称TR&#xff09;是我们的“生命线”&#xff0c;它承载着从开发系统到测试、生产系统的代码和配置变更。然而&#xff0c;当你信心…

作者头像 李华
网站建设 2026/8/30 8:52:33

论文AI率超标怎么办?2026年4招彻底去AI痕迹快速过审

最近发现好多学弟学妹写论文都离不开AI帮忙——要么用它捋文献框架&#xff0c;要么直接生成章节内容&#xff0c;效率确实拉满&#xff0c;但一到检测环节&#xff0c;AI率直接飙到90%&#xff0c;这不妥妥卡毕业节奏嘛&#xff01; 别慌&#xff01;我身边就有朋友用对了方法…

作者头像 李华
网站建设 2026/8/30 13:43:18

AI痕迹难清除?2026年10款降重工具:学生作家创作者高效利器

现在用AI创作内容的人越来越多&#xff0c;但随之而来的AIGC率超标、查重过不了的麻烦&#xff0c;真的难倒了一大片&#xff01;不管是学生写毕业论文、课程作业&#xff0c;还是自媒体博主、专业撰稿人产出内容&#xff0c;都怕卡在AI检测这一关。别慌&#xff0c;我整理了12…

作者头像 李华
网站建设 2026/8/31 1:14:54

USB-C PD快充必备:buck-boost充电IC的NVDC与HPBB架构解析

手里拿到过几百份电源方案之后&#xff0c;我总结出一个规律&#xff1a;凡是带USB-C PD口、又想把电池充得又快又稳的产品&#xff0c;最后几乎都会跑到buck-boost充电IC这条路上。以前5V/2A时代&#xff0c;一颗普通降压充电IC就能搞定&#xff1b;现在客户张口就要15V/3A、2…

作者头像 李华