代码改变世界

Introduction to graphs and their data structures Section II[翻译]

2007-04-09 21:41  老博客哈  阅读(1000)  评论(10编辑  收藏  举报

                                              Introduction to graphs and their data structures
                                                                       Section 2
                【原文见: http://www.topcoder.com/tc?module=Static&d1=tutorials&d2=graphsDataStrucs2
                                                                                     作者:      By gladius
                                                                                                       Topcoder Member
                                                                                      翻译:      农夫三拳@seu(drizzlecrj@gmail.com)

 

Basic methods for searching graphs
Introduction
Stack
Depth First Search
Queue
Breadth First Search

Basic methods for searching graphs
Introduction

    到目前为止我们已经学习了如何在内存中表示图,现在我们将利用这些信息来进行讨论。图搜索当中有两个非常常用的方法,它们将是后面讨论的一些高级算法的基础。这两个方法就是深度优先搜索(Depth First Search )和广度优先搜索(Breadth First Search)。

    我们首先以深度优先搜索开始,它使用的是一个栈。这个栈可以显式的表示(通过在程序中使用一个栈的数据类型),也可以在递归函数中隐式的表示。

Stack
    栈是可以使用的数据结构中最简单的一种。在栈上主要有4种操作:
 1. Push-把元素加到栈的顶部
 2. Pop-把栈的顶部元素移除
 3. Top-返回栈的顶部元素
 4. Empty-测试栈是否为空
    在C++中,可以使用STL中的stack类:

#include <stack>
stack myStack;

    在Java中,使用Stack类:

import java.util.*;
Stack stack 
= new Stack();

    在C#中,我们使用Stack类:

using System.Collections;
Stack stack 
= new Stack();

Depth First Search
    现在我们用搜索来解决一个实际问题!dfs对于解决一个要求我们找到某个问题的解决方案(不用必须是最短路径),或者访问图中的所有结点的问题恰到好处。 TopCoder最近有一个问题就是dfs的一个经典应用,flood-fill。flood-fill操作对于那些使用绘图软件的人非常的熟悉。大概意思就是用单个颜色去填充一个封闭的区域,而避免填充到边界的外部。   这个概念和dfs对应的非常好。基本意思就是访问一个结点,然后将待访问的结点放到栈中。为了找到下一个访问的结点,我们只需简单的弹出栈中的一个结点,并把与那个结点相连的所有结点放到栈中。我们重复的进行这些操作直到所有的结点都被访问。dfs的关键点就在于我们将不会多于一次的访问同一个结点,否则的话我们很有可能无限递归。这个操作允许我们访问图中存在的所有路径;尽管如此,对大的图,这几乎不可行,因此我们有的时候会忽略掉标记结点而把它们当作没有访问,目的是在图中找到一条合法的路径(这在大多数情况下都足够好了)

    基本的结构将像下面一样:

dfs(node start) {
 stack s;
 s.push(start);
 
while (s.empty() == false{
  top 
= s.top();
  s.pop();
  mark top 
as visited;

  check 
for termination condition

  add all of top
's unvisited neighbors to the stack.
  mark top as not visited;
 }

}


另外一种方法,我们可以像下面这样定义递归函数:

dfs(node current) {
 mark current 
as visited;
 visit all of current
's unvisited neighbors by calling dfs(neighbor)
 mark current as not visited;
}

    我们将要讨论的问题是grafixMask,,SRM211中Division 1的500分问题。问题的本质是要我们找出栅格中的被某些值填充的离散区域的数量。将这个栅格看成图是一个非常强的技巧,这使得问题变得相当的简单。

     我们将定义这样一个图,每个节点和它上,左,右,下相邻的4个结点连接。尽管如此,我们可以不需要构建任何新的数据结构,而在栅格中隐式的进行表示,。我们将要用来表示grafixMask中的栅格的数据结构是一个二值的二维数组,那些已经被填充值的区域将被设置成true,而那些没有被填充的设置为false。

     建立起来的数组非常简单,像下面这样:

bool fill[600][400];
initialize fills to 
false;

foreach rectangle in Rectangles
    
set from (rectangle.left, rectangle.top) to (rectangle.right, retangle.bottom) to true

    现在我们有了一个初始的连通栅格。当我们需要从栅格的(x,y)位置移动时,我们可以向上,下,左,右进行。例如我们想向上移动,我们可以检查栅格(x, y - 1)位置来看它是true还是false。 如果栅格中的位置是false,我们可以移动到那,如果是true,我们不可以。

    现在我们需要计算剩下来的每块区域的大小。我们不想将每块区域数两次,也不想把每个点计算两次,因此当我们访问过(x,y)处的结点时,我需要将fill[x][y]设置成true.这将允许我们使用深度优先搜索遍历连通区域的所有结点,而不会访问任何一个结点两次,这其实就是问题描述想要我们做的事情!因此我们设置结束之后的循环像下面一样:

int[] result;

for x = 0 to 599
 
for y = 0 to 399
  
if (fill[x][y] == false)
   result.addToBack(doFill(x,y));

    这些代码所做的事情就是检查我们是否已经填充了(x,y)的位置,然后调用doFill()来填充那个区域。这里我们有个选择,我们可以递归定义doFill(这通常是实现深度优先搜索的最快最容易的方法),也可以显式的使用stack类来定义。我首先将谈到递归的方法,但是很快我们将看到对于这个问题来说递归有着一些严重的问题。

    我们现在定义doFill来返回连通区域的大小和区域的开始位置:

int doFill(int x, int y) {
// Check to ensure that we are within the bounds of the grid, if not, return 0
 if (x < 0 || x >= 600return 0;
// Similar check for y
 if (y < 0 || y >= 400return 0;
// Check that we haven't already visited this position, as we don't want to count it twice
 if (fill[x][y]) return 0;

// Record that we have visited this node
 fill[x][y] = true;

 
// Now we know that we have at least one empty square, then we will recursively attempt to
 
// visit every node adjacent to this node, and add those results together to return.
 return 1 + doFill(x - 1, y) + doFill(x + 1, y) + doFill(x, y + 1+ doFill(x, y - 1);
}


    这个解决方案可以很好的工作,然而这里有一个由于计算机程序的架构而造成的限制。隐式的栈的空间,就是我们上面递归所使用到的,比起一般的堆空间要受限的多。在这种情况下,我们很可能由于递归而造成栈溢出,因此下面我们将要谈谈使用显式的方法来解决这个问题。

    附注:
    只要你调用函数,都会使用到栈空间;编译器为你将函数参数压入到栈中。当使用一个递归函数的时候,变量不断的压栈直到函数返回值。同样的,编译器需要保存的函数调用间的变量都必须被压入到栈中。因此你很难预测自己是否会遇到栈上面的问题。我推荐都使用显式的深度优先搜索如果你稍微关注下递归深度的话。

    在这个问题中我们也许递归了最多600*400次(想想初始的空的栅格,和dfs怎样执行的,它首先访问0,0然后1,0然后2,0然后3,0直到599,0)。然后它将会到599,1然后598,1然后597,1等等直到到达599,399。在最好情况下,这将会向栈中压入600*400*2个整数,不同的编译器可能会包含更多的信息。由于一个整数需要占用4个字节,我们将1,920,000个字节的内存压入到栈中,这就是我们可能会碰到麻烦的一个好的标志。

    我们将使用相同的函数定义,因此函数的结构将相当的类似,仅仅是不再使用任何递归:

class node int x, y; }

int doFill(int x, int y) {
 
int result = 0;

 
// Declare our stack of nodes, and push our starting node onto the stack
 stack s;
 s.push(node(x, y));

 
while (s.empty() == false{
  node top 
= s.top();
  s.pop();

// Check to ensure that we are within the bounds of the grid, if not, continue
  if (top.x < 0 || top.x >= 600continue;
// Similar check for y
  if (top.y < 0 || top.y >= 400continue;
// Check that we haven't already visited this position, as we don't want to count it twice
  if (fill[top.x][top.y]) continue;

  fill[top.x][top.y] 
= true// Record that we have visited this node

  
// We have found this node to be empty, and part
  
// of this connected area, so add 1 to the result
  result++;

  
// Now we know that we have at least one empty square, then we will attempt to
  
// visit every node adjacent to this node.
  s.push(node(top.x + 1, top.y));
  s.push(node(top.x 
- 1, top.y));
  s.push(node(top.x, top.y 
+ 1));
  s.push(node(top.x, top.y 
- 1));
 }


 
return result;
}


正如你所看到的,这个函数在显式维护栈结构上花费了一些开销,但是好处是我们可以使用我们问题中可用的所有内存空间,在这种情况下,有必要使用更多的信息。尽管如此,结构上是非常类似的,并且如果你对比这两个实现时,会发现他们几乎等价。

    祝贺你,我们已经使用深度优先搜索解决了第一个问题!现在我们将讨论和深度优先搜索关系非常密切的广度优先搜索。

    如果你想练习一下基于DFS的问题,下面是一些不错的题目:
           TCCC 03 Quarterfinals - Marketing - Div 1 500
           TCCC 03 Semifinals Room 4 - Circuits - Div 1 275

Queue
     队列是栈数据类型的一种扩展。然而,栈是FILO(first-in last-out先进后出)的数据结构,而队列是FIFO(first-in first-out先进先出)的数据结构。这意味着你向队列中加入的第一个对象,当你执行pop()操作的时候将最先得到。

    队列有四个主要操作:
1. Push-向队列的尾部增加一个元素
2. Pop-移除队列首部的元素
3. Front-返回队列的首部元素
4. Empty-测试队列是否为空

    在C++中,可以使用STL中的queue类完成:

#include <queue>
queue myQueue;

    在Java中,很不幸我们没有Queue类,因此我们将使用LinkedList类来近似模拟。链表上的操作和队列上的操作互相映射(事实上,有的时候队列像链表一样实现),因此这将不会太难。

    映射到LinkedList类上的操作如下:
1. Push - boolean LinkedList.add(Object o)
2. Pop - Object LinkedList.removeFirst()
3. Front - Object LinkedList.getFirst()
4. Empty - int LinkedList.size()

import java.util.*;
LinkedList myQueue 
= new LinkedList();

    在C#中,我们使用Queue类:
    Queue类上的相应操作如下:
1. Push - void Queue.Enqueue(Object o)
2. Pop - Object Queue.Dequeue()
3. Front - Object Queue.Peek()
4. Empty - int Queue.Count

using System.Collections;
Queue myQueue 
= new Queue();

Breadth First Search
    广度优先搜索是一个非常有用的搜索技巧。它与深度优先搜索不同之处在于它使用一个队列来进行搜索,因此结点被访问的顺序大不一样。广度优先搜索有一个相当有用的性质,就是如果图中所有的边都没有权重(或者具有相同权重),那么第一个被访问的结点就是从原节点到该节点的最短路径。你可以通过思考使用一个队列之后的搜索顺序来验证这个性质。当我们访问一个结点并将它所有的邻居结点加入到队列中,然后弹出队列的下一个对象,我们将在队列中得到第一个结点的邻接结点作为第一个元素。这是由于队列的FIFO的属性而得到的,并且这个是一个非常有用的属性。我们在进行广度优先搜索时需要注意的是不要重复的访问同样一个结点,否则我们将会丢掉当访问一个结点时,是从源结点到该节点的最快路径的性质。

    广度优先搜索的基本结构像下面这样:

void bfs(node start) {
 queue s;
 s.push(start);
 
while (s.empty() == false{
  top 
= s.front();
  s.pop();
  mark top 
as visited;
 }

}

    检查终止条件(我们到达我们想要到的结点了吗?)将top所有的没有访问的邻接结点加到队列中。

    注意广度优先搜索和深度优先搜索的区别,不同之处仅在于我们使用了不同的数据结构,并且我们没有将top再次标记为没有访问。

    我们将要谈到的和广度优先搜索相关的问题要比前面的例子难一些,因为我们将要处理一个更加复杂的搜索空间。这个问题是SRM156 Division 1中的1000分的问题,Pathfinding。同样的,我们再次遇到了一个基于栅格的问题,因此我们可以隐式的用图来表示这个栅格。

    这个问题的大意是,我们想要交换栅格中的两个玩家的位置。栅格中有一些无法通过的地方,用'X'表示,也有一些可以走的地方,用'.'表示。由于我们有两个玩家,因此我哦们的结点的结构变得稍微有些复杂,我们需要表示person A和personB的位置。我们也将不可能简单的再使用数组来表示已经访问的位置,我们将使用辅助的数据结构来帮忙。在这个问题中,我们可以进行斜线方向的移动,因此我们现在有了9个选择,我们可以向8个方向进行移动或者待在原地。另外一个我们需要注意的地方是,玩家不能够在单个回合中交换位置,因此我们需要对结果状态进行验证。

    首先,我们建立结点的结构和访问数组:

class node {
 
int player1X, player1Y, player2X, player2Y;
 
int steps; // The current number of steps we have taken to reach this step
}


bool visited[20][20][20][20];

这里一个结点被表示成玩家1和玩家2的位置(x,y)。它还包含我们到当前状态所走的步数,我们需要这个是因为问题要我们求解最少交换两个玩家的次数。通过广度优先搜索的性质,我们可以保证当我们第一次访问结点时,它将是最快的(我们这里的边的耗费为就1)。

    访问数组是结点的一个直接表示,第一维表示player1X,第二维表示player1Y,等等。注意我们不需要在访问数组中保存路径。

    现在我们已经建立起基本的结构了,我们可以解决这个问题了(注意这段代码是无法通过编译的):

int minTurns(String[] board) {
 
int width = board[0].length;
 
int height = board.length;

 node start;
 
// Find the initial position of A and B, and save them in start.

 queue q;
 q.push(start);
 
while (q.empty() == false{
  node top 
= q.front();
  q.pop();

  
// Check if player 1 or player 2 is out of bounds, or on an X square, if so continue
  
// Check if player 1 or player 2 is on top of each other, if so continue

  
// Make sure we haven't already visited this state before
  if (visited[top.player1X][top.player1Y][top.player2X][top.player2Y]) continue;
  
// Mark this state as visited
  visited[top.player1X][top.player1Y][top.player2X][top.player2Y] = true;

  
// Check if the current positions of A and B are the opposite of what they were in start.
  
// If they are we have exchanged positions and are finished!
  if (top.player1X == start.player2X && top.player1Y == start.player2Y &&
      top.player2X 
== start.player1X && top.player2Y == start.player1Y)
      
return top.steps;

  
// Now we need to generate all of the transitions between nodes, we can do this quite easily using some
  
// nested for loops, one for each direction that it is possible for one player to move.  Since we need
  
// to generate the following deltas: (-1,-1), (-1,0), (-1,1), (0,-1), (0,0), (0,1), (1,-1), (1,0), (1,1)
  
// we can use a for loop from -1 to 1 to do exactly that.
  for (int player1XDelta = -1; player1XDelta <= -1; player1XDelta++{
   
for (int player1YDelta = -1; player1YDelta <= -1; player1YDelta++{
    
for (int player2XDelta = -1; player2XDelta <= -1; player2XDelta++{
     
for (int player2YDelta = -1; player2YDelta <= -1; player2YDelta++{
     
// Careful though!  We have to make sure that player 1 and 2 did not swap positions on this turn
      if (top.player1X == top.player2X + player2XDelta && top.player1Y == top.player2Y + player2YDelta &&
         top.player2X 
== top.player1X + player1XDelta && top.player2Y == top.player1Y + player1YDelta)
        
continue;

     
// Add the new node into the queue
      q.push(node(top.player1X + player1XDelta, top.player1Y + player1YDelta,
              top.player2X 
+ player2XDelta, top.player2Y + player2YDelta,
              top.steps 
+ 1));
     }

    }

   }

  }

 }


 
// It is not possible to exchange positions, so
 
// we return -1.  This is because we have explored
 
// all the states possible from the starting state,
 
// and haven't returned an answer yet.
 return -1;
}


这个比起基本的广度优先搜索实现要稍微的复杂那么一点点,但是你在代码中仍然可以见到所有的基本要点。如果你现在要练习更多的关于广度优先搜索的问题,试试下面的:

Inviational 02 Semifinal Room 2 - Div 1 500 - Escape

继续阅读第三节