Columns


CUG Product Focus

AISEARCH — C++ Search Class Library

Victor R. Volkman


Victor R. Volkman received a BS in Computer Science from Michigan Technological University. He has been a frequent contributor to C/C++ Users Journal since 1987. He is currently employed as Senior Analyst at H.C.I.A. of Ann Arbor, Michigan. He can be reached by dial-in at the HAL 900 BBS (313) 663-4173 or by Usenet mail to sysop@hal9k.com.

This article is abstracted from documentation written by Peter Bouthoorn (peter@icce.rug.nl, peter@freedom.nmsu. edu), the developer of AISEARCH.

Introduction

Solving problems is a basic human activity. AI research has shown that computer programs solve many problems at least as easy as we do. Writing a problem-solving program requires an exact description of the problem, in a form that can be translated into a computer program. This article presents AISEARCH, a C++ library that helps programmers write problem-solving programs. AISEARCH was briefly describe in "CUG New Releases," CUJ, July, 1994. It is available from the CUG Library as CUG Volume 412.

The AISEARCH library contains a number of search algorithms that may be used to solve a wide variety of problems. The purpose of the library is to enable the programmer to concentrate on the representation of the problem, not the implementation of a particular search algorithm. As a side benefit, the library encourages standardization by declaring a number of functions virtual, thus forcing the programmer to actively attend to their implementation.

Problem Representation and Search Techniques

Two common techniques used for problem representation are the state space representation and problem reduction. This section describes the former, but both techniques are discussed at more length in the manual, included with CUG Volume 412.

As a sample problem, consider the 8-puzzle. The 8-puzzle consists of eight numbered, movable tiles set in a 3 X 3 frame. One of the cells of the frame is always empty, which makes it possible to move the tiles.

A sample 8-puzzle is shown in the figure below. Consider the problem of transforming the first configuration into the second one, the goal.

The 8-puzzle

2 1 6     1 2 3
4 0 8 --> 8 0 4
7 5 3     7 6 5
Solving this puzzle involves trying out various moves, producing new configurations until the goal is reached. More formally, solving this sort of problem is a process that starts with an initial configuration, and progresses toward the goal configuration by the application of operators.

An operator transforms one configuration into another. In the case of the 8-puzzle it is most natural to think of four such operators, each corresponding to moving the empty tile: move empty tile left, move empty tile right, move empty tile up, move empty tile down.

The preceding description defines the problem-solving process in terms of a state space search. In a state space search the object is to reach a certain goal state (configuration), having started in an initial state, As it moves from an initial state to the goal, a problem such as the 8-puzzle can be in a finite number of possible configurations. All of these configurations together, i.e., all possible configurations, make up the state space. Defining the state space explicitly by enumerating all states is usually impossible; fortunately, in most cases providing rules specifying how each state can be derived from another will define the state space implicitly. For most problems the state space is so large that it would be impossible to explore the entire space, but often this is not needed, because finding one solution to a problem will be enough — i.e., one path leading from the start state to the goal state. Finding this one path usually entails not an exhaustive search, but can be accomplished by searching only a portion of the state space. The problem of course is, which portion?

In addition to selecting a portion of state space to search, the would-be problem solver must also specify a search direction. A search may proceed in two directions: if it moves forward from the start state, it is called forward reasoning. If the search begins with the goal as the start state and moves backward (often just as viable a strategy), it is called backward reasoning. These two techniques can be combined, resulting in a bidirectional search: it proceeds forward from the start and backward from the goal simultaneously, until the two paths meet somewhere in between. Choosing the right technique can make a big difference in the efficiency of the search.

Generating Search Paths with Trees and Graphs

To perform a systematic search of state space, a control strategy is needed that decides which operator to apply next during the search. These strategies may commonly be represented with trees: construct a tree with the initial state as its root, next generate all the offspring of the root by applying all of the applicable operators to the initial state, next for every leaf node generate its successors by applying all of the applicable operators, etc. When these steps are performed, a structure such as displayed in Figure 1 will arise. In this representation every leaf node corresponds to a state, with the root node representing the initial state. Each operator application is represented by a connection between two nodes.

Trees are a special case of a structure called a graph. A tree is a graph each of whose nodes has a unique parent (except for the root node, which has no parent). As every node in a tree only has one parent, there can only be one path leading to the node. In a graph, however, nodes usually have more than one parent, meaning that a given node may be part of several paths. This distinction is important, because traversing a graph as though it were merely a tree could cause the same node to be expanded more than once, which would be duplicating efforts.

To avoid duplication of effort, additional bookkeeping is necessary: instead of traversing a tree, the search algorithm traverses a directed graph. Every time a node is generated the algorithm examines the set of nodes generated so far to see if this node is already part of the graph. Nodes that are already part of the graph are ignored; only new nodes are added to the graph (for exceptions to this rule see the documentation included with the code). This strategy also solves a related problem: if the search algorithm did not check whether a node had been generated before, it would very likely end up in a cycle, in which the same set of nodes is generated over and over again. For instance, by applying operator 'move empty tile left,' then 'move empty tile right,' then 'move empty tile left' etc. to the 8-puzzle, the problem-solving process would go on producing the same nodes without end. Checking for the presence of nodes beforehand avoids this problem.

Control Strategies

The last section mentioned that a control strategy is needed to decide which operator to apply next when traversing a search tree. Two basic strategies are the depth-first and breadth-first search. The first strategy searches one entire branch of the tree before expanding the next branch, expanding the most recently generated node first. By contrast, a breadth-first search expands nodes in the order in which they are generated, which means that all nodes are expanded on the same level in different branches, before proceeding to the nodes in the next level.

Another search technique is the uniform-cost search, which is used to find the optimum solution to a problem. This technique is required when, for instance, finding the shortest route between two cities. In this case it is not sufficient just to find any route — only the shortest (the optimum solution) will do. To guarantee that the optimum solution (also called the cheapest path) will be found the algorithm associates a cost with every node in the search tree. These costs enable the algorithm to select the cheapest nodes so far for expansion. Instead of expanding paths of equal length (like the breadth-first method), the uniform-cost search technique expands paths of equal cost. The cost associated with a node consists of the cost associated with its parent plus the cost of getting from the parent to the node. The function used to compute this cost is usually called g(n).

Improving the Search Process

All of the methods described so far are called blind-search procedures because they do not use any specific information about the problem to be solved, i.e, the search process just continues until it happens on a solution. To improve the efficiency of the search it should be directed in some way toward the goal. An alogrithm may perform a more intelligent search by applying a heuristic (roughly, a rule-of-thumb) that guides the search to promising areas. The best-first search technique, also called the A* algorithm is one such technique, which calls a special heuristic function. This function gives an estimate of how far a node is removed from the goal node. The search algorithm uses this information to select nodes that have good heuristic values, i.e, nodes that are (or at least seem to be) closest to the goal. This heuristic function f(n) is the sum of two components: g(n), and h(n). Function g(n) is the function described earlier; function h(n) is an estimate of the additional cost of getting from the current node to a goal state. Put differently, h(n) is the function in which the real heuristic knowledge is embedded. Using function f(n), the algorithm selects the most promising of the nodes that have so far been generated but not expanded.

The Search Class Library

Before describing details of the AISEARCH library, this article will address a troubling question: why use C++ to solve an AI-type problem when so many specialized AI programming languages are available? One reason is simply to satisfy curiousity — the creators of AISEARCH wanted to know how much more effort would be required to use a lower-level programming language (compared to languages like Prolog) to program for this type of problem. C++ seemed excellent for this investigation because it is based on C, a third-generation programming language, and also because it follows the object-oriented paradigm. But the main reason for using C++ is that it supports inheritance. This feature enables the programmer to easily make use of existing software when developing new applications. Combined with C++'s ability to define virtual functions, inheritance makes it possible to design foundation classes that are of no use in themselves, but that can be easily extended for real applications.

The preceding discussion demonstrated that problems can be solved using standard techniques, such as the state-space representation and search. AISEARCH enables the programmer to easily incorporate these standard techniques into an application when designing problem-solving software. AISEARCH offers these standard techniques in the form of foundation classes. Each class implements a particular search algorithm while leaving open the exact nature of the problem to be solved. Using these classes the programmers can concentrate on the representation of the problem at hand without having to worry about how the problem has to be solved. This sort of freedom is also offered by declarative languages, such as Prolog; but unlike Prolog, AISEARCH does not restrict the programmer to a fixed search method. (Prolog implements depth-first seach only.)

Using the Library

The search algorithms that the library supports can be divided into three groups: uni-directional (this is normal search), bidirectional, and AND/OR-search (search routines related to problem reduction). AISEARCH implements these three kinds of search techniques as three base classes, which should never be used for direct derivation. The programmer sees only the classes derived from these base classes, and these derived classes implement the actual search algorithms:

To make use of any of these algorithms the programmer designs a new class and derives this class from the desired search class, as in

class PUZZLE_: public DEPTH_GRAPH
// select depth first
{
   ...
};
The names of the search classes correspond to the names of the search techniques, graph and tree search being differentiated by a different "extension," _GRAPH_and_TREE_respectively.

Determining the search algorithm is only one part of the problem-solving process. What's left is developing the problem representation. The programmer puts together this representation by using class NODE_ or one of its derivatives. Class NODE_ specifies a general structure that is to be processed by different search classes. (Note that some search classes must be used in combination with a derivative of class NODE_ rather than with NODE_ itself, see manual.) The programmer turns the problem representation into a class that is derived from class NODE_ (or from one of its derivatives, depending on the search class that is used), like this:

class PNODE_: public NODE_{ ..... };
Of course there is more to using the search class library than just deriving from the classes in the library. The program must exchange information wwith these classes. This occurs in two ways: through constractors, and through functions that have been declared virtual in the libray. Every class derived from one of the search classes must pass this search class the start node and goal node of the problem to be solved, and the number of operators that may be applied to each node. For example, to solve an 8-puzzle problem using the depth-first algorithm, a class PUZZLE_ might have the following constructor:

PUZZLE_::PUZZLE_(PNODE_ *start, PNODE_ *goal )
   :DEPTH_GRAPH_(start, goal, 4)
{  // pass start node, goal node and number of
}  // operators to DEPTH_GRAPH_'s constructor
The second way of passing information comes into play when search classes containing virtual functions are used. Class BEST_, an implementation of the A* algorithm, has two virtual functions: compute_g( ) and compute_h( ), which form the framework for computing the heuristic value of a node. The library declares these functions virtual because the heuristic function described in the earlier section is problem-dependent; the functions must be implemented by the programmer, who has knowledge of the problem.

Class NODE_ also has a number of virtual functions that must be instantiated by the programmer. One of the most important is do_operator, used for expanding a node. do_operator's definition appears as follows:

NODE_ *do_operator(int) const
// apply operator n and
// return new node or NULL
The integer passed to do_operator tells the function which operator must be applied. If the operator can be applied do_operator returns the node resulting from the operator application; if not, it returns a null.

Some problems, however, do not have a fixed number of operators at each node. For example, in the route-finding problem, there generally will be a variable number of roads leading from one city to other cities. In this case it would be easier to apply all the operators (if they can be called that) at once and return the resulting new nodes together. Such is the operation of function expand, which returns a linked list of all node successors:

NODE_ *expand(int) const
// expand node and return all of
// its successors in a linked list
AISEARCH contains a couple of other virtual functions like these. All of them are explained in the manual included with the code. Implementing these functions is mandatory (some have been declared pure virtual) because without them many of the routines in the library will not work. As noted in the introduction these functions also help standardize the development of the problem representation — developing the representation is like filling in the necessary gaps.

Acknowledgements

Peter Bouthourn wishes to thank Erik Tjong Kim Sang (erik@let.rug.ul) for his help and encouragement in writing the document on which this article is based.

Bibliography

Winston, P.H., Artificial Intelligence, (2nd ed.). London: Addison-Wesley, 1984.

Barr, A., Feigenbaum, E.A. The Handbook of Artificial Intelligence. Los Altos: Kaufmann, 1983.

Nilsson, N.J. Problem Solving Methods in Artificial Intelligence. New York: McGraw-Hill, 1971.

Nilsson, N.J. Principles of Artificial Intelligence. Los Altos: Kaufmann, 1986.

Knuth, D.E. The Art of Computer Programming, (2nd ed.). London: Addison-Wesley, 1979.

Pearl, J. Heuristics: Intelligent Strategies for Computer Problem Solving. London: Addison-Wesley, 1984.

Rich, E. Artificial Intelligence, New York: McGraw-Hill, 1983.