启发式算法解决优化问题_通过启发式搜索在Java中更快地解决问题

通过搜索可能的解的空间来解决问题是人工智能中一种称为状态空间搜索的基本技术。 启发式搜索是状态空间搜索的一种形式,它利用有关问题的知识来更有效地找到解决方案。 启发式搜索在各个领域都取得了很大的成功。 在本文中,我们向您介绍了启发式搜索领域,并提出了Java编程语言中A *(最广泛使用的启发式搜索算法)的实现。 启发式搜索算法对计算资源和内存提出了很高的要求。 我们还展示了如何通过避免昂贵的垃圾收集并利用Java Collections Framework(JCF)的高性能替代方法来改进Java实现。 本文的所有代码都可以下载

启发式搜索

图形数据结构可以表示计算机科学中的许多问题,其中图形中的路径表示潜在的解决方案。 寻找最佳解决方案需要寻找最短路径。 例如,想象一个自主的视频游戏角色。 角色可以进行的每一个移动都对应于图形中的一条边,并且角色的目标是找到最短的路径来吸引相对的角色。

诸如深度优先搜索和广度优先搜索之类的算法是流行的图遍历算法。 但是它们被认为是无知的 ,并且通常会受到所能解决问题的大小的严重限制。 而且,不能保证深度优先搜索找到最优解(或者在某些情况下根本不求任何解),而宽度优先搜索只能保证在特殊情况下找到最优解。 相反,启发式搜索是一种明智的搜索,它利用关于问题的知识(采用启发式编码)来更有效地解决问题。 启发式搜索可以解决许多不为人知的算法无法解决的难题。

电子游戏寻路是启发式搜索的流行领域,但它也可以解决更复杂的问题。 2007年DARPA城市挑战无人驾驶汽车竞赛的获胜者使用启发式搜索来规划既平坦又直接的可驾驶路线(请参阅参考资料 )。 启发式搜索在自然语言处理中也取得了成功,在自然语言处理中,它用于文本的语法解析和语音识别中的堆栈解码。 它也已经应用于机器人技术和生物信息学领域。 与经典的动态编程方法相比,多序列比对(MSA)是一个经过充分研究的信息学问题,可以通过使用启发式搜索以更少的内存来更快地解决。

Java启发式搜索

由于Java编程语言对内存和计算资源的需求很高,因此尚未成为实现启发式搜索的流行选择。 由于性能原因,通常首选C / C ++。 我们证明Java是实现启发式搜索的合适编程语言。 我们首先说明,在解决流行的基准问题集时,A *的教科书实现确实很慢并且会耗尽可用内存。 我们通过重新审视一些关键的实现细节并利用JCF的替代方法来解决这些性能问题。

这项工作大部分是在本文作者共同撰写的学术论文中发表的工作的扩展(请参阅参考资料 )。 尽管最初的工作集中在C / C ++编程上,但在这里我们展示了许多相同的想法也适用于Java。

广度优先搜索

熟悉广度优先搜索的实现方式-一种更简单的算法,具有许多相同的概念和术语-可以帮助您了解实现启发式搜索的详细信息。 我们将以广度优先搜索为中心,以代理为中心 。 在以代理为中心的视图中,代理被称为处于某种状态 ,并且可以从该状态采取一组适用的操作 。 应用操作会将代理从其当前状态转换到新的后继状态。 这种观点很好地概括了许多类型的问题。

广度优先搜索的目标是设计一系列动作,以使代理从其初始状态进入目标状态。 从初始状态开始,广度优先搜索通过首先访问最近生成的状态来进行。 所有适用的操作都应用于每个访问状态,生成新状态,然后将其添加到未访问状态列表(也称为搜索边界 )中。 访问状态并生成其所有后继状态的过程通常称为扩展状态。

您可以将搜索过程视为生成树:树的根节点代表初始状态,子节点之间通过边缘相连,这些边缘代表了用于生成树的动作。 图1展示了此搜索树。 白色圆圈代表搜索边界中的节点。 灰色圆圈表示已扩展的节点。

图1.二叉树上的广度优先搜索顺序
一棵查寻树的例证在广度优先查寻的。树的根节点表示初始状态,子节点之间通过边缘相连,这些边缘表示用于生成树根的动作。白色(空)圆圈代表搜索边界中的节点。灰色(编号)圆圈表示已扩展的节点。

搜索树中的每个节点都代表某种状态,但是两个唯一节点可以代表同一状态。 例如,搜索树中不同深度的节点可以与树中较高的另一个节点具有相同的状态关联。 这些重复的节点表示在搜索问题中实现相同状态的两种不同方式。 重复会出现问题,因此必须记住所有访问的节点。

清单1显示了广度优先搜索的伪代码:

清单1.广度优先搜索的伪代码
function: BREADTH-FIRST-SEARCH(initial)
open ← {initial}
closed ← 0
loop do:
     if EMPTY(open) then return failure
     node ← SHALLOWEST(open)
     closed ← ADD(closed, node)
     for each action in ACTIONS(node)
          successor ← APPLY(action, node)
          if successor in closed then continue
          if GOAL(successor) then return SOLUTION(node)
          open ← INSERT(open, successor)

清单1中 ,我们将搜索的前沿保留在一个列表中,该列表称为开放列表 (第2行)。 被访问的节点保存在一个列表中,我们称为封闭列表 (第3行)。 封闭列表有助于通过重复访问任何节点来确保我们不会重复搜索工作。 仅当节点不在封闭列表中时,才会将其添加到边界。 搜索循环将继续进行,直到打开的列表为空或找到目标为止。

您可能已经在图1中注意到,广度优先搜索是在进入下一层之前,通过访问搜索树每个深度层的所有节点来进行的。 对于所有动作都具有相同代价的问题,搜索树中的所有边缘都具有相同的权重,在这种情况下,可以确保广度优先搜索找到最佳解决方案。 也就是说,生成的第一个目标是从初始状态开始的最短路径。

在某些领域,每个动作的成本都不同。 对于这些域,搜索树中的边缘具有不均匀的权重。 在这种情况下,解决方案的成本是沿从根到目标的路径上所有边缘权重的总和。 对于这些域,不能保证广度优先搜索找到最佳解决方案。 此外,广度优先搜索必须扩展树的每个深度层的所有节点,直到生成目标为止。 存储这些深度层所需的内存可能会Swift超过大多数现代计算机上的可用内存。 这将广度优先搜索限制在一系列小问题上。

Dijkstra的算法是广度优先搜索的扩展,该搜索按照从初始状态到达节点的成本对搜索边界上的节点进行排序(对打开列表进行排序)。 在行动成本统一或不统一的情况下(假设成本为非负数),保证找到最佳解决方案。 但是,它必须访问成本低于最佳解决方案的所有节点,因此仅限于较小的问题。 下一部分将介绍一种算法,该算法能够通过显着减少为找到最佳解决方案而必须访问的节点数量来解决大型问题。

A *搜索算法

A *算法或它的某些变体是使用最广泛的启发式搜索算法之一。 可以将A *视为Dijkstra算法的扩展,该算法利用有关问题的知识来减少找到解决方案所需的计算量,同时仍保证最优解。 A *和Dijkstra的算法是最佳优先图遍历算法的经典示例。 他们之所以成为最佳第一人,是因为他们首先访问了外观最好的节点,这些节点似乎在通向目标的最短路径上,然后才找到解决方案。 对于许多问题,找到最佳解决方案至关重要,这就是使A *之类的算法如此重要的原因。

A *与其他图遍历算法的区别在于它对启发式算法的使用。 启发式方法是有关一个问题的知识,这是一个经验法则,可以使您做出更好的决策。 在搜索算法的上下文中, 启发式具有特定含义:一种函数,用于估计从特定节点达到目标所需的剩余成本。 A *可以通过确定哪些节点似乎最有希望访问而利用启发式算法来避免不必要的计算。 A *试图避免访问图中似乎没有导致最佳解决方案的节点,并且这些节点通常比不那么了解情况的算法能够更快地找到解决方案,并且具有更少的内存。

A *确定哪个节点看起来最有前途的方法是通过为每个节点计算一个值(我们将其称为f值)并按此值对开放列表进行排序。 使用其他两个值 (节点的g值和h值)计算f值。 节点的g值是从初始状态到达节点所需的所有操作的总成本。 从节点达到目标的估计成本是其h值。 该估计是启发式搜索中的启发式。 f值最低的节点似乎是最有希望访问的节点。

图2说明了此搜索过程:

图2. f值的A *搜索顺序
f值的A *搜索顺序图,其中白色节点标有f值

图2的示例中,边界具有三个节点。 两个的f值为5,一个的f值为3。f值最小的节点接下来被展开并立即导致目标。 如图3所示,这使A *不必访问其他两个节点下的任何子树,这使A *比诸如广度优先搜索之类的算法效率更高。

图3. A *不必访问具有较高f值的节点下的子树
该图显示A *不需要访问具有较高f值的节点下的子树

如果A *使用的启发式是允许的 ,则A *仅访问找到最佳解决方案所需的节点。 由于这个原因,A *很受欢迎。 没有其他算法可以通过允许的启发式访问比A *更少的节点来保证最佳解决方案。 为了使启发式估计可接受,它必须是一个下限:一个小于或等于达到目标成本的值。 如果启发式算法满足其他属性consistency ,则将通过最佳路径首次生成每个状态,并且该算法在处理重复项时会更有效。

与上一节中的广度优先搜索一样,A *维护两个数据结构。 已生成但尚未访问的节点存储在打开列表中 ,所有已访问的规范节点存储在关闭列表中 。 这些数据结构的实现及其使用方式,对性能有很大影响。 我们将在后面的部分中对此进行更详细的研究。 清单2显示了教科书A *搜索的完整伪代码:

清单2.用于A *搜索的伪代码
function: A*-SEARCH(initial)
open ← {initial}
closed ← 0
loop do:
     if EMPTY(open) then return failure
     node ← BEST(open)
     if GOAL(node) then return SOLUTION(node)
     closed ← ADD(closed, node)
     for each action in ACTIONS(node)
          successor ← APPLY(action, node)
          if  successor in open or successor in closed
              IMPROVE(successor)
          else
              open ← INSERT(open, successor)

清单2中 ,A *仅从打开列表中的初始节点开始。 在循环的每次迭代中,打开列表上的最佳节点都将被删除。 接下来,将应用针对open最佳节点的所有适用动作,从而生成所有可能的后继节点。 对于每个后继节点,我们检查其代表的状态是否已被访问。 如果还没有,我们将其添加到打开的列表中。 如果已经访问过它,我们需要确定是否通过更好的途径到达了此状态。 如果是这样,我们需要将该节点放在打开列表中,并删除次优节点。

我们可以使用两个关于要解决的问题的假设来简化此伪代码:我们假设所有动作的成本都相同,并且具有可容许且一致的启发式方法。 因为启发式方法是一致的,并且域中的所有操作都具有相同的成本,所以我们永远无法通过更好的途径重新访问状态。 还显示出,对于某些域,在打开列表中具有重复的节点比每次我们生成新节点时检查重复的节点更为有效。 因此,我们可以通过将所有新的后继节点附加到打开列表中来简化实现,而不管它们是否已经被访问过。 我们通过将清单2的最后四行合并为一行来简化伪代码。 我们仍然需要避免循环,因此我们必须在扩展节点之前检查重复项。 我们可以省略IMPROVE函数的细节,因为简化版本不再需要它。 清单3显示了简化的伪代码:

清单3.用于A *搜索的简化伪代码
function: A*-SEARCH(initial)
open ← {initial}
closed ← 0
loop do:
     if EMPTY(open) then return failure
     node ← BEST(open)
     if node in closed continue
     if GOAL(node) then return SOLUTION(node)
     closed ← ADD(closed, node)
     for each action in ACTIONS(node)
          successor ← APPLY(action, node)
          open ← INSERT(open, successor)

Java中A *的教科书实现

在本节中,我们将基于清单3中简化的伪代码,通过Java实现A *的教科书实现。 如您所见,此实现无法在30GB的内存限制内解决标准的启发式搜索基准。

我们希望我们的实现尽可能通用,因此我们首先定义一些接口来抽象A *将要解决的问题。 我们要使用A *解决的任何问题都必须实现Domain接口。 Domain接口提供以下方法:

  • 查询初始状态
  • 查询状态的适用动作
  • 计算状态的启发式值
  • 产生后继状态

清单4显示了Domain接口的完整代码:

清单4. Domain接口的Java源
public interface Domain<T> {
  public T initial();
  public int h(T state);
  public boolean isGoal(T state);
  public int numActions(T state);
  public int nthAction(T state, int nth);
  public Edge<T> apply (T state, int op);
  public T copy(T state);   
}

A *搜索为搜索树生成边缘和节点对象,因此我们需要EdgeNode类。 每个节点包含四个字段:节点表示的状态,对父节点的引用以及节点的gh值。 清单5显示了Node类的完整代码:

清单5. Node类的Java源代码
class Node<T> {
  final int f, g, pop;
  final Node parent;
  final T state;
  private Node (T state, Node parent, int cost, int pop) {
    this.g = (parent != null) ? parent.g+cost : cost;
    this.f = g + domain.h(state);
    this.pop = pop;
    this.parent = parent;
    this.state = state;
  } 
}

每个边缘具有三个字段:边缘的成本或权重,用于生成边缘的后继节点的操作以及用于生成边缘的父节点的操作。 清单6显示了Edge类的完整代码:

清单6. Edge类的Java源代码
public class Edge<T> {
  public int cost; 
  public int action;   
  public int parentAction;    
  public Edge(int cost, int action, int parentAction) { 
    this.cost = cost;
    this.action = action;
    this.parentAction = parentAction;
  }  
}

A *算法本身将实现SearchAlgorithm接口,并且仅需要DomainEdge接口。 SearchAlgorithm接口仅提供一种以指定的初始状态执行搜索的方法。 search()方法返回SearchResult的实例。 SearchResult类提供有关搜索的统计信息。 清单7中显示了SearchAlgorithm interface的定义:

清单7. SearchAlgorithm接口的Java源代码
public interface SearchAlgorithm<T> {
  public SearchResult<T> search(T state);  
}

选择要用于打开和关闭列表的数据结构是一个重要的实现细节。 我们将使用Java的PriorityQueue来实现打开列表。 PriorityQueue是一个平衡的二进制堆的实现,其中O(log n )时间用于元素入队和出队,线性时间(用于测试元素是否在队列中)以及恒定的时间来访问队列的头部。 二进制堆是用于实现打开列表的流行数据结构。 稍后您会看到,对于某些域,可以使用称为存储桶优先级队列的更有效的数据结构来实现打开列表。

我们必须实现Comparator接口,以允许PriorityQueue对节点进行正确排序。 对于A *算法,我们需要按其f值对每个节点进行排序。 在具有许多具有相同f值的节点的域中,一种简单的优化方法是通过选择具有较高g值的节点来打破联系。 花一点时间说服自己,为什么用这种方式打破平局可以改善A *的性能。 (提示: h是一个估计值; g不是。)清单8包含我们Comparator实现的完整代码:

清单8. NodeComparator类的Java源代码
class NodeComparator implements Comparator<Node> {
  public int compare(Node a, Node b) {
    if (a.f == b.f) { 
      return b.g - a.g;
    }
    else {
      return a.f - b.f;
    }
  }    
}

我们需要实现的另一个数据结构是封闭列表。 一个明显的选择是Java的HashMap类。 HashMap类是哈希表的实现,具有预期的恒定时间,用于检索和添加元素,前提是我们使用了良好的哈希函数。 我们必须重写负责实现域状态的类的hashcode()equals()方法。 在下一节中,我们将介绍该实现。

最后,我们需要实现SearchAlgorithm接口。 为此,我们使用清单3中的伪代码实现search()方法。 清单9显示了A * search()方法的完整代码:

清单9. A * search()方法的Java源代码
public SearchResult<T> search(T init) {
  Node initNode = new Node(init, null, 0, 0 -1);    
  open.add(initNode);
  while (!open.isEmpty() && path.isEmpty()) {  
    Node n = open.poll();
    if (closed.containsKey(n.state)) continue;
    if (domain.isGoal(n.state)) {
      for (Node p = n; p != null; p = p.parent)
        path.add(p.state);
      break;
    }
    closed.put(n.state, n);
    for (int i = 0; i < domain.numActions(n.state); i++) {
      int op = domain.nthAction(n.state, i);
      if (op == n.pop) continue;
      T successor = domain.copy(n.state);
      Edge<T> edge = domain.apply(successor, op);      
      Node node = new Node(successor, n, edge.cost, edge.pop);
      open.add(node);
    }
  }
  return new SearchResult<T>(path, expanded, generated);
}

要评估我们的A *实现,我们需要运行一个问题。 在下一部分中,我们将介绍一个用于评估启发式搜索算法的流行领域。 该域中的所有操作都具有相同的成本,并且我们允许使用启发式,因此我们简化的实现就足够了。

15个拼图基准

出于本文的目的,我们将重点放在称为15拼图的玩具领域。 这个简单的域具有很好理解的属性,并且是评估启发式搜索算法的标准基准。 (有些人将这些谜题称为AI研究的“果蝇”。)15谜题是一种滑动砖块谜题,其15块瓦片排列在4x4的网格上。 磁贴具有16个可能的位置,其中一个位置始终为空。 与空白位置相邻的图块可以从一个位置滑动到另一位置。 目的是滑动瓷砖直到达到拼图的目标配置。 图4显示了带有随机配置的拼图的拼图:

图4. 15个难题的随机配置
15个谜题的随机配置和目标配置的说明

图5显示了目标配置中的图块:

图5. 15个难题的目标配置
15个难题的目标配置的例证。磁贴的编号顺序为1到15,从左上角开始,空白磁贴在右下角。

作为启发式搜索基准,我们希望使用尽可能少的动作,从一些初始配置开始,找到此难题的目标配置。

我们将用于此域的启发式方法称为曼哈顿距离启发式方法。 瓷砖的曼哈顿距离是瓷砖达到目标位置必须进行的垂直和水平移动的次数。 为了计算状态的启发式,我们将拼图中所有图块的曼哈顿距离之和忽略了空白。 对于任何状态,所有这些距离的总和保证为达到拼图目标状态的成本的下限,因为您永远无法通过减少移动来将图块移动到其每个目标配置。

乍一看似乎并不直观,但是我们可以通过将图块的每个可能配置表示为图中的节点,来用图对15个难题进行建模。 如果存在将一个配置转换为另一个配置的单个动作,则边缘会连接两个节点。 此领域的一个动作是将图块滑入空白区域。 图4说明了此搜索图:

图6. 15个难题的状态空间搜索图
15个难题的状态空间搜索图的插图

有16个! 可能的方式在网格上排列15个图块,但实际上“只有” 16!/ 2 = 10,461,394,944,000个可到达的配置或15个拼图的状态。 这是因为拼图的物理限制使我们可以达到所有可能配置的一半。 为了了解这个状态空间的大小,假设我们可以用一个字节表示一个状态(这是不可能的)。 要存储整个状态空间,我们将需要超过10TB的内存。 这将远远超过大多数现代计算机的内存限制。 我们将展示启发式搜索如何在仅访问状态空间的一小部分时最佳地解决难题。

运行实验

我们的实验使用了著名的15个拼图的起始配置集,称为Korf 100集。 该集合以Richard E. Korf的名字命名,Richard E. Korf发表了第一批结果,该结果表明,可以使用称为IDA *的A * 迭代加深变体来解决随机的15个拼图配置。 自发布这些结果以来,他在实验中使用的100个随机实例Korf已在启发式搜索的无数后续实验中重复使用。 我们还优化了实现方式,因此不再需要迭代加深技术。

我们一次解决一个启动配置。 每个启动配置都存储在单独的纯文本文件中。 在命令行参数中指定文件的位置以开始实验。 我们需要一个Java程序的入口点,该入口点将处理命令行参数,生成问题实例并运行A *搜索。 我们将此入口点类TileSolver

每次运行结束时,有关搜索性能的统计信息将打印到标准输出中。 我们最感兴趣的统计信息是挂钟时间。 我们将所有运行的挂钟时间相加得出该基准测试的总运行时间。

本文的源代码包含用于自动化实验的Ant任务。 您可以使用以下命令运行完整的实验:

ant TileSolver.korf100 -Dalgorithm="efficient"

您可以为algorithm指定efficienttextbook

我们还提供了用于运行单个实例的Ant目标。 例如:

ant TileSolver -Dinstance="12" -Dalgorithm="textbook"

并且,我们提供了一个用于运行基准测试子集的Ant目标,该目标排除了三个最困难的实例:

ant TileSolver.korf97 -Dalgorithm="textbook"

可能是您的计算机没有足够的内存来完成使用教科书实现的完整实验。 为避免交换,您应小心限制Java进程可用的内存量。 如果您在Linux上运行此实验,则可以使用ulimit类的shell命令来设置活动shell的内存限制。

起初我们没有成功

表1显示了我们使用的所有技术的结果。 教科书A *实施的结果在第一行。 (我们在随后的部分中描述打包状态HPPC及其相关结果。)

表1.求解97个Korf 100 15拼图基准的A *的三个变体的结果
算法 最高 内存使用情况 总运行时间
教科书 25GB 1,846秒
打包状态 11GB 1,628秒
高性能计算机 7GB 1,084秒

教科书的实施无法解决我们所有的测试实例。 我们未能解决三个最困难的实例,而对于我们可以解决的实例,则花费了超过1800秒的时间。 考虑到C / C ++的最佳实现可以在不到600秒的时间内解决所有100个实例,因此这些结果并不是很好的结果。

由于内存限制,我们未能解决最困难的三个实例。 在搜索的每次迭代中,从打开列表中删除一个节点并对其进行扩展通常会导致生成更多的节点。 随着生成的节点数量的增加,将其存储在打开列表中所需的内存量也随之增加。 但是,这种对内存的需求并不特定于我们的Java实现。 C / C ++中的等效实现也将失败。

在他们的论文中,Burns等人。 (请参阅参考资料 )表明,有效的A *搜索的C / C ++实现可以用不到30GB的内存解决这个基准问题-因此我们还没有准备好放弃Java A *实现。 在以下各节中讨论了其他技术,我们可以应用这些技术来更有效地使用内存。 结果是A *的高效Java实现,能够快速解决整个基准测试。

包装状态

当我们使用诸如VisualVM之类的探查器检查A *搜索的内存使用情况时,我们看到所有内存都由Node类占用,而更直接地由TileStateTileState 。 为了减少内存使用,我们需要重新访问这些类的实现。

每个图块状态都必须存储所有15个图块的位置。 为此,我们将每个图块的位置存储在15个整数数组中。 通过将它们打包为64位整数(Java中的long ),可以更简洁地表示这些位置。 当我们需要在打开列表中存储节点时,我们可以仅存储状态的打包表示形式。 这样做将为每个节点节省52个字节。 解决基准测试中最困难的实例需要存储大约5.33亿个节点。 通过打包状态表示,我们可以节省25GB以上的内存!

为了保持实现的一般性质,我们需要扩展SearchDomain接口以具有用于打包和解包状态的方法。 在将节点存储在打开列表上之前,我们现在将生成状态的压缩表示并将该压缩表示存储在Node类中,而不是将指针存储到状态。 当我们需要生成节点的后继者时,我们只需将状态打包即可。 清单10显示了pack()方法的实现:

清单10. pack()方法的Java源代码
public long pack(TileState s) {
  long word = 0;
  s.tiles[s.blank] = 0;
  for (int i = 0; i < Ntiles; i++)
    word = (word << 4) | s.tiles[i];
  return word;
}

清单11显示了unpack()方法的实现:

清单11. unpack()方法的Java源代码
public void unpack(long packed, TileState state) {
  state.h = 0;
  state.blank = -1;
  for (int i = numTiles - 1; i >= 0; i--) {
    int t = (int) packed & 0xF;
    packed >>= 4;
    state.tiles[i] = t;
    if (t == 0)
      state.blank = i;
    else
      state.h += md[t][i];
  }
}

因为打包表示是状态的规范形式,所以我们可以将打包表示存储在封闭列表中。 我们不能只将原语存储在HashMap类中。 它们需要包装在Long类的实例中。

表1中的第二行显示了以压缩状态表示形式运行实验的结果。 通过使用压缩状态表示,我们将使用的内存量减少了55%,并缩短了运行时间,但仍然无法解决整个基准测试问题。

Java Collections Framework的问题

如果您认为将所有压缩状态表示形式包装在Long实例中似乎有很多开销,那么您是正确的。 这是浪费内存,并且可能导致过多的垃圾回收。 JDK 1.5添加了对自动装箱的支持,该功能可自动将原始值转换为其对象表示形式(从longLong ),反之亦然。 对于大型集合,这些转换会降低内存和CPU性能。

JDK 1.5还引入了Java泛型:与C ++模板相比经常具有的功能。 伯恩斯等。 表明C ++模板为实现启发式搜索提供了巨大的性能优势。 泛型没有这种好处。 泛型使用type-erasure实现 ,它在编译时删除(擦除)所有类型信息。 结果,必须在运行时检查类型信息,这可能导致大型集合的性能问题。

HashMap类的实现揭示了一些额外的内存开销。 HashMap存储内部HashMap$Entry类的实例数组。 每次我们将元素添加到HashMap ,都会创建一个新条目并将其添加到数组中。 此类的实现通常包含三个对象引用和一个32位整数引用,每个条目总共32个字节。 由于我们的封闭列表中有5.33亿个节点,因此我们将拥有超过15GB的开销。

接下来,我们引入HashMap类的替代方法,使我们能够通过直接存储基元来进一步减少内存。

高性能原始资料集

因为我们现在仅在封闭列表中存储基元,所以我们可以利用高性能基元集合(HPPC)。 HPPC是一个替代的收集框架,使您可以直接存储原始值,而无需JCF的所有开销(请参阅参考资料 )。 与Java泛型相反,HPPC使用类似于C ++模板的技术,从而在编译时生成每个集合类和Java基本类型的单独实现。 当您将原始值存储在集合中时,这就不需要用LongInteger这样的类包装原始值。 作为副作用,它使您能够避免使用JCF进行其他必要的大量转换。

也可以使用JCF的其他替代方法来存储原始值。 Apache Commons Primitive Collections和fastutils是两个很好的例子。 但是,我们认为HPPC的设计对于实现高性能算法具有一个重要优势:它公开了每个集合类的内部数据存储。 直接访问此存储可以实现许多优化。 例如,如果我们要将打开或关闭列表存储在磁盘上,则可以通过直接访问基础数据数组而不是通过迭代器间接访问数据来更有效地做到这一点。

我们可以修改A *实现,以将LongOpenHashSet类的实例用于封闭列表。 我们需要进行的更改非常简单。 我们不再需要重写状态classhashcodeequals方法,因为我们仅存储原始值。 封闭列表是一个集合(它不包含重复的元素),因此我们只需要存储值,而不是键/值对。

表1中的第三行显示了使用HPPC代替JCF进行实验的结果。 借助HPPC,我们将内存使用量减少了27%,运行时间减少了33%。

现在,内存总共减少了82%,我们可以在内存限制内解决整个基准测试。 结果在表2的第一行中:

表2.解决Korf 100基准的所有100个实例的A *的三种变体的结果
算法 最高 内存使用情况 总运行时间
高性能计算机 30GB 1,892秒
嵌套存储区队列 30GB 1,090秒
避免垃圾收集 30GB 925秒

借助HPPC,我们可以使用30GB的内存来解决所有100个实例,但这需要1800多秒钟的时间。 表2中的其他结果反映了我们通过改进其他重要数据结构(即开放列表)来加快实现速度的方式。

PriorityQueue的问题

每次我们将元素添加到打开列表时,都需要再次对队列进行排序。 PriorityQueue队和出队操作时间为O(log( n ))。 在排序时这很有效,但是它肯定不是免费的,尤其是对于较大的n值。 回想一下,对于最困难的问题实例,我们将向开放列表中添加超过5亿个节点。 Moreover, because all of the actions in our benchmark problem have the same cost, the range of possible f values is small. So the benefit of using a PriorityQueue might not be worth the overhead.

One alternative is to use a bucket-based priority queue. Assuming the action costs in our domain fall within a narrow range of values, we can define a fixed range of buckets: one bucket per f value. When we generate a node, we simply put it in the bucket with the corresponding f value. When we need to access the head of the queue, we look in the buckets with the smallest f value first until we find a node. This type of data structure, called a 1-level bucket priority queue , enables constant-time enqueue and dequeue operations. Figure 7 illustrates this data structure:

Figure 7. 1-level bucket priority queue
Illustration of 1-level bucket priority queue

Astute readers will notice that if we implement a 1-level bucket priority queue as described here, we lose the ability to break ties between nodes using their g values. You should have convinced yourself earlier that breaking ties this way is a worthwhile optimization. To maintain this optimization, we can implement a nested bucket priority queue. One level of buckets is used to represent the range of f values, and the nested level is used to represent the range of g values. Figure 8 illustrates this data structure:

Figure 8. Nested bucket priority queue
Illustration of nested bucket priority queue

We can now update our A* implementation to use a nested bucket priority queue for the open list. The complete implementation of the nested bucket priority queue can be found in the BucketHeap.java file included with the source code for this article (see Download ).

The second row of Table 2 shows the results of running our experiment using the nested bucket priority queue. By using a nested bucket priority queue instead of PriorityQueue , we have improved our running time by nearly 58 percent, but it still takes over 1,000 seconds. We can do one more simple thing to improve the running time.

Avoiding garbage collection

Garbage collection is often considered a bottleneck in Java. Many excellent articles on the subject of tuning garbage collection in the JVM are available that apply here (see Related topics ), so we won't go into any details on that subject.

A* typically generates many short-lived state and edge objects and incurs a great deal of costly garbage collection. By reusing objects, we can reduce the amount of garbage collection that is required. We can make some simple changes to do this. In each iteration of the A* search loop, we allocate a new edge and a new state (533 million for the hardest problem). Instead of allocating new objects each time, we can reuse the same state and edge objects across all iterations of the loop.

To have reusable edge and state objects, we need to modify the Domain interface. Instead of the apply() method returning an instance of Edge , we need to provide our own instance that is modified by the call to apply() . The changes to edge are not incremental, so we don't need to worry about which values are stored in edge before we pass it to apply() . However, the changes that apply() makes to the state object are incremental. To properly generate all possible successor states, without needing to copy the state, we need a way to undo the changes that are made. For this, we must extend the Domain interface to have a undo() method. Listing 12 shows the changes to the Domain interface:

The updated Domain interface
public interface Domain<T> {
  ...
   public void apply(T state, Edge<T> edge, int op);
   public void undo(T state, Edge<T> edge);
  ...  
}

The third row in Table 2 shows the results of our final experiment. By recycling our state and edge objects, we avoid costly garbage collection and reduce our running time by more than 15 percent. With our highly efficient Java implementation of A*, we can now solve the entire benchmark set in 925 seconds using just 30GB of memory. This is an excellent result, considering that the best C/C++ implementation takes 540 seconds and requires 27GB. Our Java implementation is just 1.7 times slower and requires approximately the same amount of memory.

结论

In this article, we introduced you to heuristic search. We presented the A* algorithm and illustrated a textbook implementation in Java. We demonstrated that this implementation suffers from performance issues and is unable to solve a standard benchmark problem with reasonable time or memory constraints. We addressed these problems by taking advantage of HPPC and some techniques for reducing memory use and avoiding costly garbage collection. Our improved implementation was able to solve the benchmark with modest time and memory constraints, demonstrating that Java is a fine choice for implementing heuristic search algorithms. Furthermore, the techniques we present in this article can also be applied to many real-world Java applications. For example, in some cases using HPPC can immediately improve the performance of any Java application that stores a large number of primitive values.

致谢

We gratefully acknowledge support from the NSF (grants 0812141 and 1150068), DARPA (grant N10AP20029), and a University of New Hampshire Dissertation Year Fellowship.


翻译自: https://www.ibm.com/developerworks/java/library/j-ai-search/index.html

  • 1
    点赞
  • 0
    评论
  • 6
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

表情包
插入表情
评论将由博主筛选后显示,对所有人可见 | 还能输入1000个字符
©️2021 CSDN 皮肤主题: 编程工作室 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值