ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

状态空间表示法详解:从四要素拆解到搜索算法实战

状态空间表示法详解:从四要素拆解到搜索算法实战 我最近在帮几个同学看人工智能导论作业的时候发现很多人卡在了“状态空间表示法”这一节。说实话这个知识点在整本教材里属于那种“看着简单做起题来全是坑”的内容。考试要考大作业要用的搜索算法也建立在它之上甚至后面学强化学习、规划算法回头看的还是这一套东西。所以这篇文章我打算把它彻底讲透——从定义到四要素拆解从手把手建模型到经典案例实操最后再聊聊那些教材上不会写的容易出错的地方。这篇文章适合正在学人工智能基础、准备人工智能大作业或者考研复习的同学阅读。我会用尽量直白的语言把“状态空间表示法”这件事讲明白保证你看完不仅能应付作业和考试还能建立一套真正可用的建模思维方式。1. 状态空间表示法到底解决什么问题1.1 从走迷宫说起问题求解的第一性原理我们先抛开各种定义不谈想一个最简单的情景你在一个迷宫里从入口走到出口。不管这个迷宫是纸上的、现实中的还是游戏里的你解决这个问题的过程其实都可以抽象成三个要素——你在哪里位置信息、你能怎么走动作规则、哪里是终点目标判定。你脑子里想的“下一步往哪走”就是在不停地做一件事根据当前位置选一个动作得到一个新位置再看看新位置是不是终点不是的话继续重复。这个过程放到人工智能里就叫“状态空间搜索”。我个人的理解是状态空间表示法的本质就是把一个实际问题翻译成计算机能理解和操作的形式化描述。你有一个初始状态有一组可以执行的动作有明确的目标条件然后机器通过尝试各种动作组合在“状态空间”里找到一条从起点到终点的路径。整个过程的核心就是三个问题我现在在哪、我能做什么、怎么算成功。1.2 形式化定义状态、算符、状态空间与目标教材上通常会把状态空间表示法定义成一个四元组或者类似的数学结构。当年我也觉得这些符号看得人头大但后来发现如果把它理解成三个卡槽难度就直线下降。状态空间表示法一般由四部分构成1状态描述问题在某一时刻的状况用一组变量或数据结构来表示。2算符也叫操作符或动作让状态发生改变的操作有“可用条件”和“执行结果”。3状态空间从初始状态出发所有可以通过算符到达的状态组成的集合加上状态之间的转移关系构成一个有向图。4目标状态判定问题是否解决的条件。这里最关键的认知转变在于“状态空间”不是事先列好的清单而是由“初始状态”和“算符”共同生成的。就像给你一副扑克牌和一个洗牌动作你不断执行这个动作可能产生的所有牌序就是状态空间。你不需要把所有的牌序都提前写出来只要知道起点和规则就能在需要的时候逐步展开。有了这四个要素任何问题都可以统一到同一个求解框架下“给定初始状态和算符集合搜索一条到达目标状态的路径。”这就是为什么叫“表示法”——它本身不直接给出答案但提供了一套建模的语法和思维范式。2. 核心四要素逐个拆解从抽象定义到具体例子2.1 状态State怎么描述一个问题在“某一时刻的样子”状态的选取是整个表示法中最需要经验的一步。同样一个实际问题状态选得好不好直接影响后面搜索的效率甚至可行性。举个最常见的例子——八数码问题华容道的数字版3乘3的九宫格里有8个数字滑块和一个空格目标是把数字摆成指定顺序。这个问题的状态怎么表示最直接的办法是把整个九宫格看成一个三元组结构每一行是一个向量三行拼在一起就是当前局面。例如初始状态可以记为[[2,8,3],[1,6,4],[7,0,5]]其中0代表空格。再比如经典的传教士与野人问题三个传教士和三个野人要过河小船一次最多坐两人任何时候在任意岸边野人数量不能超过传教士数量。这个问题的状态可以这样定用一个二元组(m, c)表示左岸传教士人数和野人人数再加上一个布尔变量表示船在左岸还是右岸合起来(m, c, b)就是一个完整的状态。为什么不用记录右岸的人数因为总人数是固定的右岸的人数可以用总数减左岸人数算出来没必要重复存储。这里有两条实操中我反复跟人强调的经验状态不是越详细越好要“够用且最小”。只要能从状态中恢复出整个问题的全部信息就行多余的信息都是浪费计算资源。状态需要满足“马尔可夫性”——从当前状态出发能否继续推导不依赖于历史信息。也就是说同一个状态下不管之前走过什么路径未来可选的动作都应该是一样的。如果做不到这一点说明你的状态设计有遗漏。2.2 算符Operator动作规则是状态转移的唯一驱动力有了状态还需要定义动作。算符通常写成前置条件和效果两个部分什么条件下可以用这个动作用了之后状态怎么变。以八数码为例跟空格相关的动作有四个空格上移、下移、左移、右移等价于把某个数字滑块移入空格。每个算符都有适用条件空格在第一行时不能上移在最后一行时不能下移在最左边时不能左移在最右边时不能右移。这些边界条件必须写清楚否则搜索过程会产生非法状态。传教士与野人问题的算符就要复杂一点了。船上的组合可以是1个传教士、1个野人、2个传教士、2个野人或者1传1野这五种合法情况。每个算符也要考虑船的当前位置——船在左岸时算符的效果是从左岸划到右岸船在右岸时则相反。执行算符之后还要立刻判断两岸是否满足“野人不过多”的约束不满足就直接丢弃这个操作。我在给同学辅导时经常打这样一个比方状态是游戏存档算符是操作按钮游戏规则决定哪些按钮在哪些情况可用、按了之后存档变成什么样。你不需要把整个游戏的结局背下来只需要从初始存档出发不断按合法的按钮找一条通向通关存档的路就行。2.3 状态空间从起点开始“生长”出来的有向图状态空间可以看作一张有向图节点是状态边是算符。从初始状态出发每应用一次算符就产生一个新节点不断重复这个过程整棵搜索树就长出来了。这里要区分两个概念搜索树和状态空间图。搜索树会包含重复节点因为同一个状态可能通过不同的路径到达比如八数码中先左移再右移会回到原始状态状态空间图则把所有位置相同的节点合并成一个。写代码时通常会维护一个“已访问集合”来避免重复扩展相当于从这个图中剪掉重复的枝丫。构建状态空间的时候大多数人会忽略一个关键问题状态空间的规模到底有多大还是用八数码举例9个格子放8个数字加1个空格排列总数是9的阶乘也就是362880种状态这个规模用BFS直接搜完全没问题。但如果你把规模往上提一点变成4乘4的十五数码状态数就到了16!约2.09乘以10的13次方这时候裸搜就完全行不通了必须引入启发式搜索。所以每次建模前先估算一下状态空间的量级是决定后续搜索策略的重要依据。2.4 目标状态怎么写判定条件才不出错目标的定义看起来最简单——把初始状态和目标状态放一起比较就完事了。但实际做题时目标状态的定义有一个常见的坑有些目标条件是约束性的而不是一个具体的状态。比如八数码的目标是某个具体的排列这很好判断。但传教士与野人问题的目标就写“所有人到达右岸”换算成状态就是(0, 0, 0)——左岸没人船在右岸。再比如一些约束满足问题目标可能是“任意相邻节点颜色不同”这种就不能用简单的等于某个状态来判断了必须写一个布尔判定函数。我推荐在动手写搜索代码之前把目标判定单独抽象成一个函数isGoal(state)这样以后扩展问题或者换搜索算法时不需要动其它代码。这也是一个很好的工程习惯——表示、搜索、判定三层解耦一层一层的修改成本最低。3. 从表示到求解搜索就是在这个空间里找路3.1 深度优先与广度优先何时选哪种表示法搭好之后求解就交给搜索算法了。AI导论课程里最先接触的两种无信息搜索策略是深度优先搜索DFS和广度优先搜索BFS。BFS的核心思想是一层一层往外扩展先扩展初始节点的所有邻居再扩展到邻居的邻居。实现上用队列先进先出。优点是只要解存在一定能找到且找到的是步数最少的解缺点是内存消耗大因为每一层的节点都要被存下来。八数码用BFS没问题但碰到状态空间很大的问题就会内存爆炸。DFS则是沿着一条分支一直往下走走不通了再回头换一条路。实现上用栈后进先出。优点是内存占用小只要保存当前路径上的节点缺点是不保证找到最短路径甚至可能一头扎进很深的死胡同里浪费大量时间。为了缓解这个问题实际项目中常用“迭代加深”Iterative Deepening DFS——反复用DFS但每次限制一个最大深度一层层加深直到找到解。我的建议是初学阶段拿八数码练手时两种都写一遍对比一下它们扩展的节点数和内存占用你就能直观感受到“算法策略对搜索开销的影响”这件事有多明显。3.2 启发式搜索用好“方向感”帮你抄近路无信息搜索在状态空间大的时候几乎是寸步难行的。这时候就要请出A*算法为代表的启发式搜索。A在BFS的框架基础上加了一个评价函数f(n) g(n) h(n)。其中g(n)是从起点到当前节点n已经花费的实际代价h(n)是从节点n到目标节点的估计代价也叫启发函数。A每次从优先队列里取出f(n)最小的节点进行扩展。关键就在h(n)怎么设计。以八数码为例一个常用的启发函数是“曼哈顿距离之和”——每个数字当前的位置到目标位置需要横着走几步加竖着走几步把所有数字的这个值加起来。这个启发函数满足可采纳性不会高估实际代价所以A*用它能保证找到最优解而且比BFS快得多。我来手算一个简单例子。假定某个数字3在位置(0, 1)它在目标状态中的位置是(2, 0)那它的曼哈顿距离就是|0-2| |1-0| 3。把所有数字的曼哈顿距离求和就得到了当前状态的h(n)值。这个计算非常轻量但信息量很大——它度量了当前局面“离目标还有多远”的直观感觉。很多同学问为什么不用“位置不同的数字个数”当启发函数当然可以它也是可采纳的但它的信息量比较小。可以想一下两个状态分别有4个和5个数字位置不对它们之间的距离差别可能很大但“错位数”这个指标区分不出来。相比之下曼哈顿距离能提供更细腻的估计搜索效率会高很多。3.3 搜索框架的工程细节OPEN表、CLOSED表和访问标记纸上谈兵了这么多我把一个通用搜索框架的伪代码写在这里方便大家用来做作业或者复现实验。以A*为例初始化OPEN表优先队列把初始状态放进去g 0计算h得到f。初始化CLOSED表或visited集合用来记录已经扩展过的状态。循环如果OPEN表为空搜索失败返回无解取出f值最小的节点如果是目标节点沿着父指针回溯输出路径否则生成该节点的所有后继状态逐个检查是否在CLOSED中不在的就计算g/h/f后放入OPEN表。如果后继状态已经在OPEN表里但新的g值更小就更新它的g/f值和父指针。有几个工程细节值得多说一句关于状态编码。不管你的状态内部结构是什么样放进visited集合的时候最好转成一个可哈希的字符串或元组。比如八数码可以把整个九宫格扁平化成一个元组(2,8,3,1,6,4,7,0,5)这样查重的时间复杂度是O(1)。关于解路径的恢复。扩展过程中每个节点都要记录“父节点是谁”以及“用了哪个算符到达这里”找到目标后从终点一直回溯到起点再反转就得到了完整路径。这个环节漏掉父指针的话找到目标也拿不出路径来作业里这种问题很常见。4. 经典案例实操三个问题看懂状态空间建模全流程4.1 八数码问题从状态定义到A*求解全流程我第一次觉得状态空间表示法真正“通了”就是把八数码从建模到求解一口气写完的时候。我来完整演示一遍流程。第一步状态表示。用三元组表示九宫格0代表空格。例如初始状态s0 (2, 8, 3, 1, 6, 4, 7, 0, 5)目标状态goal (1, 2, 3, 8, 0, 4, 7, 6, 5)第二步算符定义。根据空格位置生成可行动作。空格在位置index对应的行是index // 3列是index % 3。如果行大于0空格可以和上方的数字交换如果行小于2可以向下列大于0可以向左列小于2可以向右。第三步启发函数。我用曼哈顿距离对每个非0数字算一遍当前位置和目标位置的曼哈顿距离求和作为h(n)。第四步搜索。用优先队列作为OPEN表以f g h排序。每次取出最小f节点展开直到目标。这一步走下来你会看到A扩展的节点数比BFS少了不止一个数量级。我实际算过一个比较难的初始局面BFS可能扩展几万个节点A几千个甚至几百个就出结果了。这种对比我建议你亲自动手跑一遍那种“知识变成工具”的感觉就出来了。4.2 传教士与野人问题约束条件下状态表示的技巧传教士与野人问题在AI导论里出镜率极高因为它比八数码多了一层约束逻辑。状态表示(m, c, b)分别代表左岸传教士人数、左岸野人人数、船的位置1表示左岸0表示右岸。初始状态是(3, 3, 1)目标状态是(0, 0, 0)。算符设计就五种摆渡组合1个传教士过去、1个野人过去、2个传教士过去、2个野人过去、1传1野过去。每一种动作都要根据船的位置做方向判断——船在左岸时人数减少船在右岸时人数增加。每次执行完算符后要立刻检查约束条件任何一边的人数都不能是负数也不能超过总数。如果左岸传教士人数在1到2之间即传教士不完全在左岸也不完全在右岸左岸传教士人数必须不少于野人人数。右岸同理。这个问题的搜索空间比较小用BFS几分钟就能找到最短路路径长度我记得是11步。做好状态判定之后整个搜索过程其实非常快很适合用来验证你对“状态”、“算符”、“约束”三者的理解是否到位。4.3 旅行商问题TSP状态空间表示法的现实重要应用TSP属于组合优化的经典问题也大量用到状态空间表示法。城市集合{A, B, C, D}一个状态可以记为“当前所在城市”加上“已经访问过的城市集合”。比如(C, {A, B, C})表示现在人在C城已经走过A、B、C三个城市。算符就是在未访问过的城市中选一个作为下一站代价是两地之间的距离。目标状态是访问完全部城市并返回起点。TSP的状态空间有多大把已访问集合看成位掩码n个城市的状态总数是n * 2^n量级。这就是状态压缩动态规划的经典方程dp[mask][i] min(dp[mask without i][j] dist[j][i])第一次看到这个式子时可能觉得抽象但如果先从“状态”这个角度去理解(mask, i)就是一种状态表示dp就是在状态空间里做最短路径搜索。你会发现原来数据结构和算法课上学的东西跟人工智能导论里的状态空间表示法完全是同一套思维。5. 常见问题与避坑速查这些坑我替你们踩过了5.1 状态爆炸与搜索效率为什么我的程序跑不出来状态爆炸是搜索问题中最常见的困境。处理方法有下面几个方向优化状态表示压缩冗余信息。比如上面说的右岸人数不用记录就能算出来这属于状态编码层面的压缩。避免重复扩展visited集合一定不能省。没有查重的话搜索树里大量重复节点会让时间呈指数增长。更换搜索策略由BFS换成启发式搜索。同样的八数码问题启发函数选得好不好速度差距可能是上百倍。对状态做对称性剪枝。有些问题的对称状态其后续搜索结果完全一致只保留一个即可。5.2 建模时最容易忽略的四个细节我总结了几个新手特别容易踩的坑整理成了表格方便大家对照自查常见错误后果正确做法算符边界条件没写全产生非法状态甚至越界访问每个算符都详细列出前置条件状态表示不够“最小”内存占用高效率受影响能用总数推导的量不要写进状态忽略了约束条件的即时判定搜索过程持续很久最后才发现无解在新状态生成时立刻判定合法性目标判定写在扩展节点的循环之外目标节点先被加入OPEN却没及时识别程序多跑很久每次从OPEN表取出节点时先做目标判定启发函数不可采纳A*找到的不是最优解且很难察觉确认 h(n) 不会超过真实代价5.3 无解的判定策略处理不可达状态和环路还有一个实操上经常遇到的问题是如果问题本身无解程序怎么停下来比如八数码的某个初始局面就是无论怎么移动都无法到达目标因为八数码的可达状态空间只有一半的排列。这时候如果程序不做终止判定BFS会一直扩展直到状态穷尽然后OPEN表为空退出。这个机制本身就保证了无解情况下的正常终止但要注意如果状态空间很大且无解内存会先撑不住。所以我的建议是在搜索之前如果能通过理论分析判断解的可行性比如八数码可以预先计算逆序数奇偶性就直接在前期拦截掉省得浪费时间。此外环路问题也不容忽视。不查重的话A算法可能在两个状态之间来回横跳永远到达不了目标。对搜索树或者图来说这是一个细节但很重要的实现点——在执行算符后立即检查新状态是否已经在CLOSED表或当前路径中。6. 状态空间表示法之外与其它知识表示方法的横向对比6.1 为什么有了状态空间还要学谓词逻辑、产生式系统看到这里你可能会想状态空间表示法这么万能为什么《人工智能导论》还要花大篇幅讲谓词逻辑、产生式规则、框架表示、语义网络这些东西原因是不同的问题适合不同的表示方法。状态空间擅长表达“一步步操作”的过程性知识但表达“人类知道的事实”类知识就很别扭。比如“所有的鸟都有翅膀”这种一般性知识用谓词逻辑一句话就写清楚了但用状态空间去表达这种知识你得构造状态和算符非常不自然。产生式系统擅长表示“如果-那么”形式的规则适合专家系统框架表示适合表达对象的属性层级关系语义网络强调概念之间的语义联系。每一种表示法都有自己的适用面。它们的底层逻辑其实相通都是“选择合适的结构化方式把现实问题的关键信息编码成计算机可操作的形式”。6.2 学完这一节之后的路从课程作业到前沿AI状态空间表示法是整个人工智能体系的地基。往近了说强化学习里的马尔可夫决策过程MDP本质上就是状态空间表示法加上奖励函数和转移概率机器人路径规划里的配置空间Configuration Space也是状态空间思想的直接延伸现在热门的具身智能里机器人要学会在真实物理环境中做动作决策底层同样需要对“当前状况-动作-下一状况”做建模。所以如果你在学这一节时觉得知识很抽象我的建议是不要急先老老实实把八数码和传教士野人问题每个细节都搞明白动手写一遍代码然后再回去看那些复杂的概念会发现它们其实都长着差不多的骨架。结语碎碎念怎么判断自己真的学会了写到这里这篇关于状态空间表示法的内容也就差不多结束了。最后分享一个我自己检验“是否真学会”的小方法找一个新的、没做过的搜索问题比如倒水问题、农夫过河问题不看任何参考独立完成状态建模、算符设计和搜索实现。如果你能顺利走完这个过程说明这一节的知识已经不再是书本上的概念而是长在你脑子里的工具了。另外一个建议是平时看别人代码或论文时拿到一个算法先别急着直接看代码先问自己三个问题它的状态是什么算符有哪些目标怎么判定把这三件事想清楚后再去看实现整个逻辑会变得非常清晰。希望这些内容能对你的学习有帮助。如果你在状态空间建模的过程中还有其它搞不明白的地方或者发现了更有意思的建模思路欢迎在评论区一起交流探讨。
返回列表