5

最近我需要实现非递归 DFS 作为更复杂算法的一部分,准确地说是 Tarjan 算法。递归实现非常优雅,但不适用于大图。当我实现迭代版本时,我对它最终变得多么不优雅感到震惊,我想知道我是否做错了什么。

迭代 DFS 有两种基本方法。首先,您可以一次将一个节点的所有子节点压入堆栈(似乎更为常见)。或者你可以只推一个。我将专注于第一个,因为这似乎每个人都是这样做的。

我在使用这个算法时遇到了各种问题,最终我意识到要有效地做到这一点,我需要的不是 1 个,不是 2 个,而是 3 个布尔标志(我不一定意味着你需要三个显式布尔变量,你可以间接存储信息通过通常是整数的变量的特殊值,但是您需要以一种或另一种方式访问​​这 3 条信息。三个标志是:1)已访问。这是为了防止孩子被非常冗余地推入堆栈。2) 完成。防止对同一节点进行冗余处理。3) 上升/下降。指示孩子是否已经被压入堆栈。伪代码如下所示:

while(S)
    if S.peek().done == True
        S.pop()
        continue

    S.peek().visited = True

    if S.peek().descending == True
        S.peek().descending = False
        for c in S.peek().children
            if c.visited == False
                S.push(c)
        doDescendingStuff()    
    else
        w = S.pop()
        w.done = True
        doAscendingStuff()

一些注意事项:1)技术上你不需要上升/下降,因为你可以看看孩子们是否都完成了。但是在密集图中它的效率很低。

2),主要问题:访问/完成的事情似乎没有必要。这就是为什么(我认为)你需要它。在堆栈上访问它们之前,您无法标记访问过的东西。如果这样做,您可能会以错误的顺序处理事物。例如,假设 A 链接到 B 和 C,B 链接到 D,并且 D 链接到 C。然后从 A,您将 B 和 C 压入堆栈。从 B 你将 D 压入堆栈……然后呢?如果您在将它们压入堆栈时标记已访问的内容,则此处不会将 C 压入堆栈。但这是错误的,应该从 D 访问 C,而不是从图中的 A 访问(假设 A 在 C 之前访问 B)。因此,在处理它们之前,您不会标记访问过的东西。但是,您将 C 在堆栈上两次。所以你需要另一个标志来表明你已经完全完成了它,所以你不需要第二次处理 C。

我不知道如何避免这一切,以拥有一个完全正确的非递归 DFS,它支持卷绕和展开的动作。但本能地感觉很粗糙。有没有更好的办法?我在网上咨询过的几乎每个地方都对如何实际实现非递归 DFS 进行了掩饰,说可以做到并提供了一个非常基本的算法。当算法是正确的(就正确支持到同一节点的多条路径而言)很少见时,它很少正确支持在卷绕和展开时做一些事情。

4

7 回答 7

2

我认为最优雅的基于堆栈的实现将在堆栈上包含子代的迭代器,而不是节点。将迭代器视为在其子节点中存储一个节点和一个位置。

while (!S.empty)
  Iterator i = S.pop()
  bool found = false
  Iterator temp = null
  while (i.hasNext())
    Node n = i.next()
    if (n.visited == false)
      n.visited = true
      doDescendingStuff(n)
      temp = n.getChildrenIterator()
      break
  if (!i.hasNext())
    doAscendingStuff(i.getNode())
  else
    S.push(i)
  if (temp != null)
    S.push(temp)

以上可以通过将节点和位置分成2个堆栈来优化存储空间。

于 2013-02-03T02:36:40.767 回答
1

您的代码没有完全模拟递归 DFS 实现所发生的情况。在递归 DFS 实现中,每个节点在任何时候都只在堆栈中出现一次。

Dukeling给出的解决方案是一种迭代方式。基本上,您一次只需要在堆栈中推送一个节点,而不是一次全部推送。

您断言这将需要更多存储是错误的:通过您的实现,一个节点可以在堆栈上多次出现。事实上,如果你从一个非常密集的图(所有顶点上的完整图)开始,这将会发生。使用 Dukeling 解决方案,堆栈的大小为 O(顶点数)。在您的解决方案中,它是 O(边数)。

于 2015-06-06T07:53:51.867 回答
1

算法 BFS(G, v)

enqueue(Q, v)
mark v as visited

while Q is not empty do
    let w = front(Q)
    visit(w)
    dequeue(Q)

    for each vertex u adjacent to w do
        if u is not marked
            enqueue(Q, u)
            mark u as visited

算法 DFS(G, v)

push(S, v)
mark v as visited
visit(v)

while S is not empty do
    let w = top(S)
    pop(S)

    find the first umarked vertex u that is adjacent to w 

    if found such vertex u
        push(S, u)
        mark u as visited
        visit(u)

    else if not found such vertex u
        pop(S)
于 2017-02-11T06:53:46.683 回答
0

Robert Sedgewick 在 cpp book 中的算法讨论了一个特殊的堆栈,它只保留一个项目的一个副本,而忘记旧的副本。不完全确定如何执行此操作,但它消除了堆栈中有多个项目的问题。

于 2015-02-18T13:33:28.217 回答
0

Tl; dr 是您不需要多个标志。

实际上,您可以通过显式执行编译器对运行时堆栈所做的操作,将递归 DFS 转换为迭代。该技术使用gotos 来模拟调用和返回,但这些可以转换为更具可读性的循环。我将在 C 中工作,因为您实际上可以编译中间结果:

#include <stdio.h>
#include <stdlib.h>
#define ARITY 4

typedef struct node_s {
  struct node_s *child[ARITY];
  int visited_p;
} NODE;

// Recursive version.
void dfs(NODE *p) {
  p->visited_p = 1;
  for (int i = 0; i < ARITY; ++i)
    if (p->child[i] && !p->child[i]->visited_p)
      dfs(p->child[i]);
}

// Model of the compiler's stack frame.
typedef struct stack_frame_s {
  int i;
  NODE *p;
} STACK_FRAME;

// First iterative version.
void idfs1(NODE *p) {
 // Set up the stack.
 STACK_FRAME stack[100];
 int i, sp = 0;
 // Recursive calls will jump back here.
 start:
  p->visited_p = 1;
  // Simplify by using a while rather than for loop.
  i = 0;
  while (i < ARITY) {
    if (p->child[i] && !p->child[i]->visited_p) {        
      stack[sp].i = i;   // Save params and locals to stack.
      stack[sp++].p = p;
      p = p->child[i];   // Update the param to its new value.
      goto start;        // Emulate the recursive call.
      rtn: ;             // Emulate the recursive return.
    }
    ++i;
  }
  // Emulate restoring the previous stack frame if there is one.
  if (sp) {
    i = stack[--sp].i;
    p = stack[sp].p;
    goto rtn;   // Return from previous call.
  }
}

现在对代码做一些“代数”以开始摆脱gotos。

void idfs2(NODE *p) {
 STACK_FRAME stack[100];
 int i, sp = 0;
 start:
  p->visited_p = 1;
  i = 0;
  loop:
  while (i < ARITY) {
    if (p->child[i] && !p->child[i]->visited_p) {
      stack[sp].i = i;
      stack[sp++].p = p;
      p = p->child[i];
      goto start;
    }
    ++i;
  }
  if (sp) {
    i = stack[--sp].i + 1;
    p = stack[sp].p;
    goto loop;
  }
}

不断转变,我们最终会在这里:

void idfs3(NODE *p) {
  STACK_FRAME stack[100];
  int i, sp = 0;
  p->visited_p = 1;
  i = 0;
  for (;;) {
    while (i < ARITY) {
      if (p->child[i] && !p->child[i]->visited_p) {
        stack[sp].i = i; 
        stack[sp++].p = p;
        p = p->child[i];
        p->visited_p = 1;
        i = 0;
      } else {
        ++i;
      }
    }
    if (!sp) break;
    i = stack[--sp].i + 1; 
    p = stack[sp].p;
  }
}

这可以。还有一个可选的“抛光”步骤。我们最初可以将根压入堆栈以稍微简化外部循环:

void idfs3(NODE *p) {
  STACK_FRAME stack[100];
  p->visited_p = 1;
  stack[0].i = 0
  stack[0].p = p;
  int sp = 1;
  while (sp > 0) {
    int i = stack[--sp].i; 
    p = stack[sp].p;
    while (i < ARITY) {
      if (p->child[i] && !p->child[i]->visited_p) {
        stack[sp].i = i + 1; 
        stack[sp++].p = p;
        p = p->child[i];
        p->visited_p = 1;
        i = 0;
      } else {
        ++i;
      }
    }
  }
}

在这一点上,很明显您确实在使用一堆迭代器:指向节点的指针和反映当前搜索该节点的子节点的进度的索引。使用像 Java 这样的语言,我们可以明确这一点。(不利的一面是我们在处理子代时无法访问父代,这在某些情况下可能是个问题。)

在这里,我将使用一个单独的集合来保留访问过的节点。这是更可取的,因为它使不止一个搜索和部分搜索更简单。

void search(Node p) {
  Set<Node> visited = new HashSet<>();
  Deque<Iterator<Node>> stack = new ArrayDeque<>();
  visited.add(p); // Visit the root.
  stack.push(p.children.iterator());
  while (!stack.isEmpty()) {
    Iterator<Node> i = stack.pop(); // Backtrack to a child list with work to do.
    while (i.hasNext()) {
      Node child = i.next(); 
      if (!visited.contains(child)) {
        stack.push(i);  // Save progress on this child list.
        visited.add(child); // Descend to visit the child.
        i = child.children.iterator(); // Process its children next.
      }
    }
  }
}

作为最后的微优化,您可以跳过将耗尽的迭代器推入堆栈(在 C 中,数组i末尾的值child),因为它们在弹出后被忽略。

void search(Node p) {
  Set<Node> visited = new HashSet<>();
  Deque<Iterator<Node>> stack = new ArrayDeque<>();
  visited.add(p); // Visit the root.
  if (!p.children.isEmpty()) stack.push(p.children.iterator());
  while (!stack.isEmpty()) {
    Iterator<Node> i = stack.pop(); // Backtrack to a child list with work to do.
    while (i.hasNext()) {
      Node child = i.next(); 
      if (!visited.contains(child)) {
        if (i.hasNext()) stack.push(i);  // Save progress on this child list.
        visited.add(child); // Descend to visit the child.
        i = child.children.iterator(); // Process its children next.
      }
    }
  }
}
于 2017-02-11T07:54:09.903 回答
0

这是一个 java 程序的链接,它显示了遵循递归和非递归方法的 DFS,还计算了发现完成时间,但没有边缘分类。

    public void DFSIterative() {
    Reset();
    Stack<Vertex> s = new Stack<>();
    for (Vertex v : vertices.values()) {
        if (!v.visited) {
            v.d = ++time;
            v.visited = true;
            s.push(v);
            while (!s.isEmpty()) {
                Vertex u = s.peek();
                s.pop();
                boolean bFinished = true;
                for (Vertex w : u.adj) {
                    if (!w.visited) {
                        w.visited = true;
                        w.d = ++time;
                        w.p = u;
                        s.push(w);
                        bFinished = false;
                        break;
                    }
                }
                if (bFinished) {
                    u.f = ++time;
                    if (u.p != null)
                        s.push(u.p);
                }
            }
        }
    }
}

完整来源在这里。这也是一个很好的视频解释DFS

于 2020-03-25T07:21:15.160 回答
-1

为了使用堆栈进行 DFS 遍历,从堆栈中弹出一个节点(记得将初始节点压入堆栈)并检查它是否已被访问。如果已经访问过,则忽略并弹出下一个,否则输出弹出的节点,将其标记为已访问并将其所有邻居推入堆栈。继续这样做,直到堆栈为空。

于 2014-08-28T06:18:59.317 回答