5 Search

Intro #

问题求解与搜索算法 #

问题求解涉及到两个方面:

    1. 问题的表示,比如枚举对象、状态表示
    1. 求解的方法,比如自顶向下的递归,自底向上的动态规划

搜索算法是一种通用的问题求解方法: 首先把问题表示 转换为一个状态空间图,然后设计特定的图遍历方法在状态空间中搜索问题的答案。

为了提高搜索的效率,在遍历状态空间时需要添加优化技术,比如剪枝策略用于尽可能避免无效搜索,启发式信息用来加速朝目标状态逼近的速度。

状态空间图 #

一个问题用搜索算法求解时,往往需要把问题描述为状态空间图,包括以下要素

  • 状态 (State): 是为描述某类不同事物间的差别而引入的一组最少变量的有序集合,其矢量 形式为: 1 2 n ,其中每个分量 i 称为状态分量。
  • 操作符 (运算符): 是指把一个状态转换为另外一个状态的操作或者运算。操作符可以是规划、数学运算和问题场景中行为等。
  • 状态图: 如果把状态定义为图的结点,操作符定义为图的边,一个问题的全部可能状态则可 以表示为一个图,即状态图。
  • 路径: 通过操作符序列连接起来的状态图中的一个状态序列。
  • 路径耗散函数: 定义在路径上的一个数值函数,它反映了一条路径的性能度量或者求解问题 的代价。在求解最优化问题时,路径耗散函数往往与优化目标相关联。

状态空间图可以形式化地定义为一个四元组 (S,A,G,F)

  • S 表示问题的初始状态,它是搜索的起点。
  • A 是采取的操作符集合,初始状态和操作符隐含地定义了问题的状态图。
  • G 表示目标测试,它判断给定的状态是否为目标状态。它可以是表示目标状态的一个状态集合,也可以是一个判定函数。
  • F 代表路径耗散函数,它的定义需要具体问题具体分析。

搜索就是在状态空间图中从初始状态出发,执行特定的操作,试探地寻找目标状态的过 程。当然,也可以从目标结点到初始结点反向进行。状态空间图中从初始状态到目标状 态的路径则代表问题的解。解的优劣由路径耗散函数量度,最优解就是路径耗散函数值 最小的路径。

搜索算法类型 #

基于枚举的通用搜索 #

DFS #

深度优先搜索 (Depth First Search, DFS) 是一种通用的图和树的遍历方法,给 定图𝐺 = (𝑉, 𝐸),深度优先搜索的基本思想为:

  • 初始化
    • 任选一个结点𝑣作为源结点,DFS 访问源结点𝑣,并将其标记为已访问过
    • 能进则进,不进则退
  • 递归遍历
    • 扩展结点𝑣,即访问𝑣的下一个未访问邻接结点𝑤;
    • 递归处理以𝑤为根的所有子结点;
    • 退回到结点𝑣,继续扩展𝑣的其它未曾访问的邻接结点,直到𝑣的所有子结点均已访问过为止。
  • 循环处理
    • 若图𝐺中仍然存在未访问的结点,则另选一个未访问的结点作为新的源 结点重复上述过程,直到图中所有结点均被访问过为止。

基于栈的 DFS 算法框架:


function DFS(problem,stack){
    node = Make-node(Initial-State[problem]);
    stack <- insert(node,stack);
    while(1){
        if(stack == empty){
            return;
        }
        node = pop(stack);
        visit(node);
        if(state[node]== GOAL){
            return Solution(node);
        }
        sonNodes = Expand(node,problem);
        stack <- InsertAll(sonNodes,stack);
    }
}

基于递归的 DFS 算法框架:

function DFS(problem, node)
    if State[node] == Goal/Failure // 递归出口
        return Solution(node); 
    else
        visit(node); // 访问当前结点
        for(iterator sonNode = Init(node); sonNode <= Last(node); sonNode++)
            if notVisited(sonNode)
                DFS(sonNode); // 递归遍历其子树
    return;
//注解:for循环用于扩展node的所有未访问子结点,并依次递归 调用,其中Init表示node的第一个子结点,Last表示最后一个子结点。

BFS #

广度优先搜索 (Breadth First Search, BFS) 类似于树的按层次遍历的过程,是 另外一种通用的图和树的遍历方法。给定图𝐺 = (𝑉, 𝐸),BFS 基本思想为

  • 初始化
    • 任选一个结点𝑣作为源结点, BFS 先访问源结点𝑣,并将其标记为已访问
    • 由近及远,按层展开
  • 层次遍历
    • 从𝑣出发,依次访问𝑣的邻接点:𝑤1,𝑤2,…,𝑤𝑡,如果𝑤𝑖(𝑖 = 1,2,…,𝑡) 未访 问过,则标记𝑤𝑖为已访问,并插入到先进先出的队列;
    • 取出并扩展队列的头结点,把它未访问的子结点插入队列;
    • 依此类推,直到图中所有和源结点𝑣有路径相通的结点均已访问过为止。
function BFS(problem,queue)
    node = Make-Node(Initial-State[problem]); 
    queue  Insert(node, queue);
    do while(1)
        if queue == Empty 
            return failure
        node  Remove-First(queue) visit(node);
        if State[node] == Goal
            return Solution(node)
        sonNodes = Expend(node, problem);
        queue  Insert-All(sonNodes, queue) //依次插入所有子状态

枚举 + 优化的通用搜索 #

回溯算法 #

深度优先搜索是一种依照“能进则进,不进则退”的策略进行的枚举算法,也就 意味着它可能遍历整个状态空间图,导致算法效率不高。给定一个问题的状态空 间表示,设计搜索算法时需要考虑以下两个事实:

  • 并不是所有的状态都是合法状态
  • 并不是所有的状态都能达到目标状态

回溯算法 = 深度优先搜索 + 剪枝策略

回溯算法需要定义问题的状态空间图,包括以下四个方面:

  1. 问题的状态表示: 一般用 k 元组表示,即𝑋 = [𝑥1, 𝑥2, ⋯ , 𝑥𝑘],其中向量的维度,以及每一个分量的值域是状态表示的关键问题
  2. 约束条件:X 中每一个分量自身的取值约束; 分量之间的取值约束。注意: 约束条件是 剪枝策略的基础,需要深入分析和挖掘
  3. 操作符集合: 从一个状态转换到另外一个状态的操作,在状态空间图中它构成边集。
  4. 问题解和解空间: 问题的解可以认为是一类特殊的状态,即目标状态。对于问题的一个 实例,所有满足约束条件的解向量就构成该问题实例的解空间。

注意: 在回溯算法中,状态空间图并不是显式地构造,也就是说,并没有在内存中生成一个完整的状态空间图。实际上,回溯算法只是根据初始状态,目标状态和操作符集合 (它产生后续状态),隐式地构造状态空间图。

剪枝策略

回溯算法需要设计合适的剪枝策略,尽量避免不必要的搜索。常用的剪枝策略包括两大类:

  1. 约束函数剪枝: 根据约束条件,状态空间图中的部分状态可能是不合法 的。因此,在状态空间图中以不合法状态为根的子图/子树是不可能包 含可行解的,故其子空间不需要搜索。
  2. 限界函数剪枝: 这种策略一般应用于最优化问题。假设搜索算法当前访 问的状态为𝑆,且存在一个判定函数: 它能判定以𝑺为根的子图/子树不 可能包含最优解,因此该子图/子树可以剪除而无需搜索

约束函数剪枝 可以剪除状态空间图中的不可行解

限界函数剪枝 用于剪除状态空间图中的可行但是非最优的解

DFS 回溯算法
搜索视角 遍历整棵完全二叉树 添加剪枝策略,尽量避免不必要的搜索
枚举视角 全局判定 [𝑥1,𝑥2,⋯,𝑥n] 局部预判 [𝑥1,?,?, ⋯] [𝑥1,𝑥2,?, ⋯]

回溯算法程序框架:

回溯算法设计通常包含以下 2 个主要步骤:

  • 针对所给问题,定义问题的状态空间图。
  • 以深度优先方式搜索状态图,并用剪枝策略避免无效搜索。
void backtrack(int t) {
    if (t > n)
        output(x);
    else {
        for (int i = Init(n, t); i <= Last(n, t); i++) {
            x[t] = Node(i);
            if (constraint(x) && bound(x)) {
                backtrack(t + 1);
            }
        }
    }

注解:for 循环体依次扩展当前结点的所有子结点,Init() 和 Last() 是一种抽象表示,返回开始子结点和末尾子结点的编号。for 循环执行完毕则回溯到父结点。

分支限界法 #

广度优先搜索是一种依照“由近及远,按层展开”的策略进行的枚举算法,也就 意味着它需要遍历整个状态空间图,导致算法效率不高。给定一个问题的状态空 间表示,设计搜索算法时需要考虑以下两个事实:

  • 并不是所有的分支都包含可行解
  • 并不是所有的分支都包含最优解

扩展子结点时,一次性生成它的 所有孩子结点,判断孩子结点是 舍弃还是保存?舍弃那些不可能 导致可行解或最优解的孩子结点, 其余的结点进入队列中

分支限界算法 = 广度优先搜索 + 剪枝策略

分支限界算法需要定义问题的状态空间图,包括以下四个方面:

  1. 问题的状态表示: 逻辑上用 k 元组表示,即𝑋 = [𝑥1, 𝑥2, ⋯ , 𝑥𝑘],其中向量的维度,以及每 一个分量的值域是状态表示的关键问题
  2. 约束条件:X 中每一个分量自身的取值约束; 分量之间的取值约束。注意: 约束条件是 剪枝策略的基础,需要深入分析和挖掘
  3. 操作符集合: 从一个状态转换到另外一个状态的操作,在状态空间图中它构成边集。
  4. 问题解和解空间: 问题的解可以认为是一类特殊的状态,即目标状态。对于问题的一个 实例,所有满足约束条件的解向量就构成该问题实例的解空间。

注意: 在分支限界算法中,状态空间图并不是显式地构造,也就是说,并没有在内存中生成一个完整的状态空间图。实际上,分支限界算法只是根据初始状态,目标状态和操作符集合 (它产生后续状态),隐式地构造状态空间图。

约束函数剪枝

  • 根据约束条件,状态空间图中的部分状态可能是不合法 的。因此,在状态空间图中以不合法状态为根的分支不包含可行解,故 其分支可以剪枝。

限界函数剪枝

  • 这种策略一般应用于最优化问题。假设搜索算法当前访 问的状态为𝑆,且存在一个判定函数: 它能判定以𝑺为根的分支不包含最 优解,因此该分支可以剪除而无需搜索

约束函数剪枝 可以剪除状态空间图中的不可行解

限界函数剪枝 用于剪除状态空间图中的可行但是非最优的解

1 BFS 分支限界算法
图搜索视角 遍历整棵完全二叉树 添加剪枝策略,尽量限制搜索无效的分支
枚举视角 全局判定 [𝑥1,𝑥2,⋯,𝑥n] 局部预判 [𝑥1,?, ?, ⋯] [𝑥1,𝑥2,?, ⋯]

分支限界算法设计通常包含以下 2 个主要步骤:

  1. 针对所给问题,定义问题的状态空间图
  2. 以广度优先方式搜索状态空间图,并在搜索过程中用剪枝策略避免无效搜索
function BFS(problem,queue)
    node = Make-Node(Initial-State[problem]); 
    queue  Insert(node, queue);
    do while(1)
        if queue == Empty 
            return failure
        node  Remove-First(queue) visit(node);
        if State[node] == Goal
            return Solution(node)
        while(sonNode = Next(problem,node)!=NULL){
            if(constraint(sonNode) && bound(sonNode)){
                Insert(queue,sonNode);
            }
        }

启发式搜索 #

最佳优先搜索 #

最佳优先搜索

  • 挖掘每一个状态描述估计其“启发值”,评估状态逼近目标状态的优势

  • 设计评估函数 f(N),估计状态 N 的启发值 (一般为实数),既有 f(N) >= 0 一般的, f(N) 估计结点的成本, f(N) 越小,结点 N 更有利于逼近目标状态

  • 最佳优先搜索则按照结点的评估值 f(N) 进行升序排列,优先扩展评估值比较小的结点如果多个结点的启发值 f(N) 相同,则这些结点随机排列

  • “最佳”不代表当前搜索路径是最优的

  • “最佳优先搜索”不一定能产生最优路径

评估函数设计

  • 评估函数 f(N) 的两种常见设计策略:
    • 经过结点 N 的路径的成本
      • f(N) = g(N) + h(N), 要求:
        • g(N) 表示从初始状态到状态 N 的成本
        • h(N) 表示从状态 N 到目标状态的成本估计
    • 从状态 N 到目标状态的成本
      • f(N) = h(N) → 贪心策略
  • 理论上评估函数可以是任何值域为正数的函数 f(N),但是不见得所有 函数对于搜索算法有帮助。
BestFirstSearch() {
    Open = [起始结点];
    Closed = [];
    while (Open表非空) {
        Open中取得一个结点X, 并从OPEN表中删除.if (X是目标结点) {
            求得路径PATH;
        }
        for (每一个X的合法子结点Y) {
            if (Y不在OPEN表和CLOSED表中) Y的估价值 f(Y);
            并将Y插入OPEN表中; // 还没有排序 else if( Y在OPEN表中 )
            if (Y的估价值小于OPEN表的估价值)
                更新OPEN表中的估价值;
            else { // Y在CLOSED表中
                if (Y的估价值小于CLOSED表的估价值) 更新CLOSED表中的估价值;
                CLOSED表中移出结点, 并放入OPEN表中;
            } // end for
            X结点插入CLOSED表中;
            按照估价值将OPEN表中的结点排序;
        } // end while } //end func
  • OPEN 表用来存放活结点表,一 般用优先队列实现;
  • CLOSE 表保存已扩展结点,它 用于求解最优路径,用线性表 实现;
  • 如果问题不需要求解最优路径, 而只需要最优值时,CLOSE 表可以省略。

启发式搜索

选择下一个扩展结点的策略总是选择评估函数 𝑓(∙) 值最 小的状态结点作为新扩展结点。

$$f (n) = g(n) + h(n)$$

  • BFS 与分支限界法
    • 如果𝑔(𝑛) 表示结点𝑛在解空间树中的深度, h(𝑛) = 0
  • DFS 与回溯法
    • 如果𝑔(𝑛) = 0,h(𝑛) 的定义满足:m 是 n 的儿子结点,则 h(𝑚) < h(𝑛)
  • A* 算法
    • 满足 h(𝑛) ≤ h∗(𝑛) ,其中 h∗(𝑛) 表示结点𝑛到目标结点的最佳路径成本

A* 算法 #

启发式搜索选择下一个扩展结点的策略总是选择评估函数 𝑓(∙) 值最小的状态结 点作为新扩展结点。

$$f (n) = g(n) + h(n)$$

如果𝒈(𝒏) 表示从初始状态到状态 N 的成本,𝒉(𝒏) 表示从状态 N 到目标状态的成本估计,𝒉∗(𝑵) 是从状态结点 N 到目标状态的最优路径成本,满足: $$𝟎 ≤ 𝒉(𝑵) ≤ 𝒉∗(𝑵),𝒘(𝑁,𝑁’) ≥𝜖> 0$$ 其中𝑤(𝑁, 𝑁’) 表示任何两个状态之间的路径成本,要求大于 0。则最佳优先搜索得到 的解总是最优解,对应搜索算法称之为 A* 算法。 如果 N 是目标状态 G,则定义𝐻(𝐺) = 0。

A 算法的最优性证明*

  • 【可采纳性定理】如果问题生成的状态空间图 G 和 h(𝑛) 满足稳定条件,即:
    • 1 图中的每个结点的后继结点是有限的;
    • 2 图中的弧的代价都大于某个正数𝜺 ;
    • 3 对图中的所有结点𝑛,满足 h(𝑛) ≤ h∗(𝑛) ,即估计成本不会超过实际成本。 并且状态空间图中存在一条从开始状态结点𝑠到目标状态结点𝑔的有限代价的路径, 那么 A算法总是在𝒔到𝒈的最佳路径上停止搜索,也称之为 A算法是可采纳的。
  • 证明思路
    • Step1: 如果问题存在可行解,则 A* 算法终止且返回可行解
    • Step2: 当 A* 算法扩展到目标状态 g 时,则其扩展路径对应最优解

对抗搜索 #

小结 #

搜索算法的基本原理

  • 问题表示的状态空间图
    • 状态表示
    • 操作集合
    • 路径/路径耗散函数
  • 在状态空间图中搜索从初 始状态到目标状态的路径/ 最优路径。

常用的搜索策略

  • 基于枚举的通用搜索
    • 深度优先搜索
    • 广度优先搜索
  • 基于“枚举 + 优化”的通用搜索
    • 回溯算法
    • 分支限界法
  • 基于规则的启发式搜索
    • 最佳优先搜索
    • A* 算法

e.g.s #

传教士问题 #

在河的左岸有三个传教士、一条船和三个野人,传教士们想用这条船将所有的成员都运 过河去,但是受到以下条件的限制:
1 教士和野人都会划船,但船一次最多只能装运两个;
2 在任何岸边野人数目都不得超过传教士,否则传教士会遭遇危险:被野人攻击甚至吃掉。 此外,假定野人会服从任何一种过河安排,试设计出一个确保全部成员安全过河的计划。
    1. 状态表示
    • 确定问题的状态表示,以及每一个状态变量的值域。 渡河问题包括三类对象: 传教士,野人和渡船,得三元组𝑺 =(𝒎,𝒄,𝒃),其中:
    • m 为左岸传教士数,有𝑚 ={0,1,2,3}; 对应右岸的传教士数为 3-𝑚。
    • c 为左岸的野人数,有𝑐 ={0,1,2,3}; 对应右岸野人数为 3-𝑐。
    • b 为左岸渡船数,有𝑏 = {0, 1},右岸的船数为 1 − 𝑏。
    • 初始状态只有一个,即𝑆0 =(3,3,1),表示全部成员在河的左岸; 目标状态也只一 个,即 𝑆𝑔 =(0,0,0),表示全部成员从河左岸渡河完毕。
    1. 操作符集合
    • 把船从左岸划向右岸定义为𝐿𝑖𝑗操作,第一下标𝑖表示船载的传教士数, 第二下标𝑗表示船载 的野人数。
    • 从右岸将船划回左岸称之为𝑅𝑖𝑗操作,下标的定义同前。 ➢ 则共有 10 种操作,操作集为
    • F={L01,L10,L11,L02,L20,R01,R10,R11,R02,R20}
    1. 状态表示: 𝑺 = (𝒎, 𝒄, 𝒃)
    • 初始状态𝑆0 =(3,3,1),目标状态𝑆𝑔 =(0,0,0)
    1. 操作符集合
    • F = {L01,L10,L11,L02,L20,R01,R10,R11,R02,R20}

8 数码问题 #

把左图的 8 数码排列通过空格的 上下左右移动,转换为右图的 8 数码排列

  • 状态表示 数字格与空格组成的排列
  • 操作符集合 空格向上、下、左和右移动
  • 路径耗散函数
    • 每一次移动代价为 1,路径耗散等于移 动次数

背包问题 #

【例-1】对于 𝑛 = 3 的0-1背包问题,其中背包容量𝑐 = 30,每个物品的重 量和价值分别为:𝑤 =< 16,15,15 >, 𝑝 =< 45,25,25 >。

旅行商问题 #

【例-2】某商人要到若干城市去推销商品,已知各城市之间的旅行费用,他要 选定一条从驻地出发,经过每个城市一遍,最后回到驻地的路线,使总的费用 最少。试构造下图对应问题实例的最优旅行方案。

装载问题 #

问题描述:有n个集装箱要装上2艘载重量分别为C1和C2的轮船,其中集装箱 i 的重量为Wi, 且 ∑ Wi ≤C1+C2。请设计算法确定是否有一个合理的装载方案可将这些集装箱上这2艘轮 船。 输入:多组测试数据。每组测试数据包括两行:第一行输入集装箱数目n(n<1000), 以及两艘轮船的载重C1和C2;第二行输入n个整数,表示每个集装箱的重量。 输出:如果存在合理装载方案,输出第一艘轮船的最大装载重量;否则,输出“No”。

把原问题简化为一艘轮船的装载问题。容易证明下述 结论: 如果一个给定装载问题实例有解,则采用下面 的策略可得到最优装载方案: (1) 首先将第一艘轮船尽可能装满; (2) 将剩余的集装箱装上第二艘轮船。

#define MaxBox 1000
int globalWeight[MaxBox], globalNum, globalC1; // 输入参数
int globalX[MaxBox],
    globalAns; // 保存状态X和最优值的全局变量
void loadingDFS(int t) {
    if (t == globalNum) { // 边界条件,判定一个n维-1向量是否是可行解 int
                          // sumWeight1=0;
        for (int i = 0; i < globalNum; i++) {
            sumWeight1 += globalX[i] * globalWeight[i];
        } // 计算装载量
        if ((sumWeight1 <= globalC1) && sumWeight1 > globalAns) {
            globalAns = sumWeight1;
        }
        return;
    } // end of if
    globalX[t] = 1;
    loadingDFS(t+1);    //扩展左子树
    globalX[t] = 0;
    loadingDFS(t+1);    //扩展右子树
}

DFS- 回溯算法 #

约束函数剪枝 #

限界函数剪枝 #

#define MaxBox 1000 int globalWeight[MaxBox], globalNum, globalC1;
int globalX[MaxBox];
int globalWt, globalBd, globalMaxWt;
void loadingBacktrack(int t) {
    if (t == globalNum) {
        globalMaxWt = globalWt;
        return;
    } // end of if
    globalBd -= globalWeight[t];
    if (globalWt + globalWeight[t] <= globalC1) {
        globalX[t] = 1;
        globalWt += globalWeight[t];
        loadingBacktrack(t + 1);
        globalWt -= globalWeight[t];
    }
    if (globalWt + globalBd > globalMaxWt) {
        globalX[t] = 0;
        loadingBacktrack(t + 1);
    }
    globalBd += globalWeight[t];
}

BFS- 分支限界算法 #

约束函数剪枝 #

限界函数剪枝 #

A* 算法 #