测试运行

禁忌算法和最大团

James McCaffrey

下载代码示例

James McCaffrey
在本月的专栏中,我将介绍先进的解决方案,以图最大团问题。解决方案使用了所谓的禁忌算法,和我将讨论如何设计和测试这些算法。最大团问题的想法是在所有连接到另一个图表中找到最大的一组节点。在图 1 中看看简单的图。图形中的九个节点和 13 边。这个图是加权 (有没有与边缘相关联的优先事项) 并无向 (所有边缘都是双向的)。2、 4 和 5 的节点形成大小三个集团。节点集 0、 1、 3 和 4,该窗体的大小四集团最大的集团。

 

 

Graph for a Tabu Maximum Clique Algorithm
图 1 图禁忌最大团算法

几个理由,最大团问题是有趣的。虽然并不清楚,从简单的图中, 图 1,最大团问题是计算机科学中最具挑战性。它出现在很多重要的实际问题,如社交网络通信分析,在节点代表人和边缘表示邮件或的关系。问题使用技术如贪婪算法和禁忌算法,重要的高级编程技术。你个人的图书馆都有最大团问题的解决方案可以是实际的有用工具,并了解所使用的算法可以将新技术添加到您的技能集。

这是使用说明高级编码和测试技术,但此列的最大团问题可以读不直接引用和前面两个系列中的第三列。在我 10 月的专栏中,"图形结构和最大团"(msdn.microsoft.com/magazine/hh456397),我描述了如何高效的数据结构,以保留在内存中的图形数据结构的代码。在我 11 月的列中,"贪婪算法最大团"(msdn.microsoft.com/magazine/hh547104),我描述了如何相对简单的算法可用于查找解决困难的最大团问题的办法。

在非正式的术语,贪心算法是一种算法,始于一个简单的、 不完整的解决方案,对于困难的问题,然后反复寻找提高解决方案的最佳途径。直至达到一些停车条件重复该过程。如何­过,贪婪算法通常有一个缺点: 他们多次将反复生成相同的解决方案。禁忌算法被为了对付这一弱点。Word 禁忌,有时拼写禁忌,禁止的手段。简单来说,禁忌算法维护禁数据的列表。处理算法的部分不允许使用的禁忌数据之前一些禁止时间已通过。禁忌算法使用一种固定的形式简单禁止时间。先进的禁忌算法动态地适应禁止时间算法执行时。

图 2 阐释了禁忌算法应用于最大团问题的重要思想,并显示您在我朝此列。

Tabu Maximum Clique Demo Run
图 2 禁忌最大集团演示运行

我有一个控制台应用程序加载到内存中对应于一个中所示的图形一开始就图 1。文件使用一种称为 DIMACS 的标准图形文件格式。设计和编码效率的图形数据结构是一个重大的问题本身。我 10 月的专栏文章中,我解释图形数据结构代码。

该算法通过随机选择单个节点和初始化集团与该节点开始。然后该算法反复尝试查找并添加的最佳的节点,将增加了集团的大小。我会解释什么最佳的节点意味着以后。如果算法找不到要添加的节点,它将尝试查找最佳集团从中删除节点。在幕后,算法记得最后一次每个节点被移动到当前的解决方案集团或迁出,集团。该算法使用此信息来禁止添加或删除最近用一定的时间节点。这是禁忌算法的一部分。

该算法为自行重新启动每隔一阵子时一直在寻找更好的 (较大) 集团不取得任何进展。算法以静默方式存储的解决方案 (派系) 以前见。算法可用于动态地适应了禁止该解决方案历史记录信息。如果算法不断遇到相同的解决方案,它增加了禁止时间阻止从正在使用最近使用的节点。如果该算法不会看到相同的解决方案,它会减少禁止的时间,所以有更多可供选择的节点。这是自适应的禁忌算法的一部分。

在大多数情况下,使用一种贪婪算法的位置,最佳解决方案不是知道的所以必须指定一个或多个停车条件。当该算法点击停止条件时,将显示找到最佳的解决方案。

在下面几节,我陪你走的截图中的代码通过图 2。完整的源代码是可在 code.msdn.microsoft.com/mag201112TestRun。此列假定您拥有中级 C 家庭语言或 Visual Basic 的编程技巧。网络语言。使用 C# 中,但我已经写代码,所以,如果你想,你会能够以另一种语言如 F # 或 Python 没有太多困难的重构。

整体的程序结构

中显示的程序的总体结构图 2 介绍图 3。我决定要将所有代码都放在单个控制台应用程序为简单起见,使用静态方法,但您可能要到类库模块化代码的部分,并使用面向对象的方法。该程序是不是你可能会想看的那么复杂图 3 因为大多数的方法都是短期的帮助器方法。

图 3 综合程序结构

using System;
using System.Collections.Generic; // List<int>
using System.Text; // StringBuilder
using System.IO; // FileStream
using System.Collections; // Hashtable
namespace TabuMaxClique
{
  class TabuMaxCliqueProgram
  {
    static Random random = null;
    static void Main(string[] args) { ...
}
    static List<int> FindMaxClique(MyGraph graph, int maxTime,
      int targetCliqueSize) { ...  }
    static List<int> MakePossibleAdd(MyGraph graph,
      List<int> clique) { ...
}
    static bool FormsALargerClique(MyGraph graph,
      List<int> clique, int node) { ...
}
    static int GetNodeToAdd(MyGraph graph,
      List<int> allowedAdd, List<int> possibleAdd) { ...
}
    static int GetNodeToDrop(MyGraph graph, List<int> clique,
      List<int> oneMissing) { ...
}
    static List<int> MakeOneMissing(MyGraph graph,
      List<int> clique) { ...
}
    static List<int> SelectAllowedNodes(List<int> listOfNodes,
      int time, int prohibitPeriod, int[] lastMoved) { ...
}
    static int UpdateProhibitPeriod(MyGraph graph, List<int> clique,
      int bestSize, Hashtable history, int time, int prohibitPeriod,
      ref int timeProhibitChanged) { ...
}
    static string ListAsString(List<int> list) { ...
}
    private class CliqueInfo
    {
      // ...
}
  }
  public class MyGraph
  {
    // ...
private class BitMatrix
    {
      // ...
}
  }
} // ns

程序有两个高级别的类,这些类的每个,佣工子类。 TabuMaxCliqueProgram 类包含 Main 方法和所有的算法逻辑,连同子类 CliqueInfo,用来维护解决方案以前看到的集团的历史记录。 MyGraph 类封装高效的图形表示形式,并包含子类位阵,而用来存储节点邻接信息以浓缩的形式。 类范围随机对象命名随机用来初始化,集团以随机的节点,中断时有多个最佳添加或删除节点。

一些 WriteLine 语句和 try catch 代码中移除,Main 方法是:

Console.WriteLine("\nBegin tabu algorithm maximum clique demo\n");
string graphFile = "..
\\..
\\DimacsGraph.clq";
MyGraph graph = new MyGraph(graphFile, "DIMACS");
int maxTime = 50;
int targetCliqueSize = graph.NumberNodes;
List<int> maxClique = FindMaxClique(graph, maxTime, targetCliqueSize);
Console.WriteLine("\nSize of best clique found = " + maxClique.Count);
Console.WriteLine(ListAsString(maxClique));

图形表示为一个程序定义的 MyGraph 对象。 从使用的 DIMACS 文件格式的外部文本文件加载图形。 MyGraph 的主要方法是 AreAdjacent,返回 true,如果两个节点相连的。 我将 maxTime 设置为 50 迭代来建立的贪婪算法的一个绝对的停车条件。 这是人工小。 在一个真正的最大团问题的最长时间通常是在 1,000 到 10 的范围内。 我将建立第二个停车条件 ; targetClique 大小设置 如果集团发现有图中具有相同的节点数,最大的集团必须已经发现。 大部分的工作是通过 FindMaxClique 方法,它使用的贪婪、 自适应的禁忌算法搜索,最大可能的集团。 帮助器方法 ListAsString 只是创建一个列表 <int> 的字符串表示形式 对象。

FindMaxClique 方法

FindMaxClique 方法调用几个的帮助器方法,我将描述不久。 在高级别的伪代码中的 FindMaxClique 算法以图 4。 伪代码去掉几个重要的细节,为了清楚起见,但呈现的算法的要点。

图 4 禁忌最大团的自适应算法

initialize clique with a single node
get list of candidate nodes to add
loop until stop condition met
  if there are nodes which can be added
    get list of allowed nodes
    if there are allowed nodes to add
      find best node to add and add to clique
      mark added node as recently used
      record current clique
 else
   get list of allowed nodes to drop
   if there are allowed nodes which can be dropped
     find best node to drop and drop from clique
     mark dropped node as recently used
     record current clique
 else
   select a random node to drop and drop from clique
   mark dropped node as recently used
   record current clique
 if lack of progress
   restart algorithm
  update list of candidate nodes
  update prohibit time
end loop
return largest clique found
The FindMaxClique method definition begins:
static List<int> FindMaxClique(MyGraph graph, int maxTime,
  int targetCliqueSize)
{
  List<int> clique = new List<int>();
  random = new Random(1);
  int time = 0;
  int timeBestClique = 0;
  int timeRestart = 0;
  int nodeToAdd = -1;
  int nodeToDrop = -1;
...

本地集团的目标是,当前的解决方案集团。 随机的对象是任意的种子值为 1 的实例化。 时间变量是该算法的主要加工循环计数器。 TimeBestClique 和 timeRestart 变量用于确定是否存在已缺乏进展所以算法可以重新启动本身。 下一主题:

int prohibitPeriod = 1;
int timeProhibitChanged = 0;
int[] lastMoved = new int[graph.NumberNodes];
for (int i = 0; i < lastMoved.Length; ++i) {
  lastMoved[i] = int.MinValue;
}
Hashtable history = new Hashtable();
...

禁止期间被初始化为 1。 这意味着,— — 最初,至少 — 算法一直使用的节点后,它不能用于一次迭代。 如果我用一个值为 0,本来禁止时间根本没有影响。 大多数的禁忌算法用于禁止的时候,一个固定的值,但这种做法的问题是研究显示固定的禁止时间值的最佳各不相同的问题,问题。 自适应的方法,我提交的禁止时间更新,而运行算法,基于以前的解决方案的历史。 我叫此技术的自适应禁忌,但一些研究论文叫技术,无功或动态。

LastMoved 数组用于确定是否允许或禁止在任何给定的时间节点。 数组的索引表示一个节点,并相应的数组值是该节点感动最后的时间 (添加或删除)。 通过使用 lastMoved 数组,,我不再需要显式创建允许节点的列表 (或,相等,禁止节点)。 初始化所有单元格的 lastMoved 为 int。MinValue 以便以后可以确定是否从未使用过的节点。

历史上哈希表对象持有以前看到的解决方案派系的集合。 哈希表中的每个项目都是一个 CliqueInfo 对象,它我稍后将详细描述。 我可以使用泛型字典对象的非泛型 Hashtable 对象而不是。 FindMaxClique 继续:

int randomNode = random.Next(0, graph.NumberNodes);
clique.Add(randomNode);
List<int> bestClique = new List<int>();
bestClique.AddRange(clique);
int bestSize = bestClique.Count;
timeBestClique = time;
...

我从图形随机节点初始化当前解决方案集团。 我实例化 bestClique 列表来跟踪算法所发现的 (最大) 集团最佳解决方案。 接下来是:

List<int> possibleAdd = MakePossibleAdd(graph, clique);
List<int> oneMissing = MakeOneMissing(graph, clique);
...

MakePossibleAdd 方法构造通过 1 可添加到增加了集团的规模,目前集团的所有节点的列表。 换句话说,方法创建,不是集团,但已连接到集团的每个节点的节点的列表。 此 possibleAdd 列表可能计数为 0,或可能含有违禁的节点。 MakePossibleAdd 调用 FormsALargerClique 的帮助器方法。

MakeOneMissing 方法构造连接到所有但完全之一,目前集团中的节点的所有节点的列表。 它并不明显,但事实证明维持这样的列表创建聪明的方式,以确定哪些节点在当前集团是最佳节点以降,我稍后会解释。 主要加工循环开始:

while (time < maxTime && bestSize < targetCliqueSize)
{
  ++time;
  bool cliqueChanged = false;
...

当达到最大迭代次数时,或如果找到最佳的解决方案,与目标集团大小,主要处理循环将终止。 我使用的 cliqueChanged 变量控制分支逻辑之间添加和删除节点,您将看到。 将允许的节点添加逻辑所示图 5

添加允许的节点的逻辑图 5

if (possibleAdd.Count > 0) {
  List<int> allowedAdd = SelectAllowedNodes(possibleAdd, time,
    prohibitPeriod, lastMoved);
  if (allowedAdd.Count > 0) {
    nodeToAdd = GetNodeToAdd(graph, allowedAdd, possibleAdd);
    clique.Add(nodeToAdd);
    lastMoved[nodeToAdd] = time;
    clique.Sort();  cliqueChanged = true;
    if (clique.Count > bestSize) {
      bestSize = clique.Count;
      bestClique.Clear();
      bestClique.AddRange(clique);
      timeBestClique = time;
    }
  }
}

算法进行检查以查看是否有任何节点,将会增加,目前集团的大小。 如果这样,SelectAllowedNodes 方法将创建允许的节点的列表 — — 即不禁忌 — — 从列表中,可以添加的节点。

SelectAllowedNodes 中的键的行是:

if (time > lastMoved[currNode] + prohibitPeriod)
  result.Add(currNode); // Allowed

CurrNode 是 possibleAdd 列表中的节点之一。 逻辑检查以确定是否足够的时间有 elaspsed,因为这样的节点是过去禁止期上次使用的节点。 如果是这样,节点添加到 allowedAdd 节点列表。

现在,如果有,将增加了集团的大小以及允许的任何节点,方法 GetNodeToAdd 确定最佳节点将添加到这个集团。 最佳节点是从最 allowedAdd 列表中的节点连接到 possibleAdd 列表中的节点。 这个想法是算法的您要添加一个节点,它会给你找到一个节点的下一次迭代中添加的大多数机会。 可能有 allowedAdd 列表中的多个节点限制为最连接到 possibleAdd 中的节点。 如果是这样,一个绑定节点是随意选取。 后添加最佳节点,后面会讲的原因排序当前集团解决方案。 要删除一个节点的代码是:

if (cliqueChanged == false) {
  if (clique.Count > 0) {
    List<int> allowedInClique = SelectAllowedNodes(clique, time,
      prohibitPeriod, lastMoved);
    if (allowedInClique.Count > 0) {
      nodeToDrop = GetNodeToDrop(graph, clique, oneMissing);
      clique.Remove(nodeToDrop);
      lastMoved[nodeToDrop] = time;
      clique.Sort();  cliqueChanged = true;
    }
  }
}
...

如果算法不能添加一个节点,它会尝试回溯将产生一个解决方案,它将允许在下一次迭代中添加一个新的节点,希望删除允许的节点。 要获取节点不是禁忌的 SelectAllowedNodes 方法,当前的集团中的节点列表进行筛选。 GetNodeToDrop 挑选最好的这些节点删除使用 oneMissing 节点的列表。

这个想法是允许的节点放到了当前的集团,这将导致 possibleAdd 节点列表的最大升幅。 执行此操作的一种方法是在允许列表中的每个节点测试实际上它去除了当前的集团,然后计算所产生的 possibleAdd 列表的大小。 但有一种高效得多的方法,使用连接到所有但完全之一,目前集团中的节点的节点的列表。 使用 oneMissing 列表中的节点,可使用列表,如下所示。 在允许的集团节点中的每个节点进行扫描,并计数 oneMissing 列表中的节点数是 notconnected 集团的节点。 最小的集团允许的列表中的节点连接到节点在 oneMissing 列表是要放的最佳的节点。 后删除此最少连接的节点,oneMissing 列表中没有连接到首字下沉节点中的所有节点现在将完全连接到这个集团中的其他节点,因此成为新的 possibleAdd 节点。

此时的逻辑,它不本来有可能允许的节点中添加或删除允许的节点。 若要到 unstick 本身,算法,当前集团从滴眼液随机节点:

if (cliqueChanged == false) {
  if (clique.Count > 0) {
    nodeToDrop = clique[random.Next(0, clique.Count)];
    clique.Remove(nodeToDrop);
    lastMoved[nodeToDrop] = time;
    clique.Sort();
    cliqueChanged = true;
  }
}
...

下一步,如果一直缺乏进展,如果是这样,算法检查自行重新启动:

int restart = 100 * bestSize;
if (time - timeBestClique > restart && time - timeRestart > restart) {
  timeRestart = time;
  prohibitPeriod = 1;
  timeProhibitChanged = time;
  history.Clear();
...

重新启动变量是允许有没有改善的地方,或自上次重新启动的迭代次数。 在这里我使用值为 100 倍大小的当前最好的解决办法。 要使用了重启时间间隔的值不充分理解,并且您可能希望尝试的替代品。 (我用来生产截图中的多个重新启动的虚拟值为 2 图 2)。 我使用的值已经在实践中,我不过。 如果重新启动,算法重置禁止时间和清除持有解决方案派系,看到了历史的哈希表。 请注意该算法没有尚未更新历史哈希表。 重新启动代码不会,但是,重置 lastVisited 数组,其中存储时节点是最后添加或删除解决方案集团从有关的信息。 接下来是:

int seedNode = -1;
List<int> temp = new List<int>();
for (int i = 0; i < lastMoved.Length; ++i) {
  if (lastMoved[i] == int.MinValue) temp.Add(i);
}
if (temp.Count > 0)
  seedNode = temp[random.Next(0, temp.Count)];
else
  seedNode = random.Next(0, graph.NumberNodes);
...

该算法将尝试更新了解决方案集团与从未使用过的节点。 如果有多个未使用的节点,其中一个是随意选取。 如果有没有未使用的节点,则选择一个随机的节点。 使用一个随机的节点,而不是尚未开发的替代方法是选择的节点,最近最少感动。 重新启动代码完成以下列:

...
clique.Clear();
  clique.Add(seedNode);
}

主要加工循环和 FindMaxClique 环绕起来像这样:

...
possibleAdd = MakePossibleAdd(graph, clique);
    oneMissing = MakeOneMissing(graph, clique);
    prohibitPeriod = UpdateProhibitPeriod(graph, clique, bestSize,
      history, time, prohibitPeriod, ref timeProhibitChanged);
  } // Main processing loop
  return bestClique;
}

PossibleAdd 和 oneMissing 列表将会重新生成。 另一种方法是保持辅助数据结构和更新的而不是重新从零开始的这两个列表。 UpdateProhibitPeriod 方法是禁忌算法的自适应部分的关键组件,不久我将描述它。

记得以前的解决方案

方法 UpdateProhibitPeriod 动态地增加或减少的禁止时间使用哈希表的以前看到的解决方案。 一种解决方案是,它是列表 <int> 集团召回 所有连接到另一个节点。 但我还需要存储解决方案最后露面时的时间。 因此,我将封装一个集团的解决方案和最后一次就看到有人在 CliqueInfo 类中所列图 6

图 6 CliqueInfo 类

private class CliqueInfo
{
  private List<int> clique;
  private int lastSeen;
  public CliqueInfo(List<int> clique, int lastSeen)
  {
    this.clique = new List<int>();
    this.clique.AddRange(clique);
    this.lastSeen = lastSeen;
  }
  public int LastSeen
  {
    get { return this.lastSeen; }
    set { this.lastSeen = value; }
  }
  public override int GetHashCode()
  {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < clique.Count; ++i) {
      sb.Append(clique[i]);
      sb.Append(" ");
    }
    string s = sb.ToString();
    return s.GetHashCode();
  }
  public override string ToString()
  {
    string s = "";
    for (int i = 0; i < clique.Count; ++i)
      s += clique[i] + " ";
      return s;
  }
}

CliqueInfo 项目将添加到解决方案历史哈希表对象,因为我需要一个自定义的 GetHashCode 哈希函数。 编写自定义的哈希函数是非常棘手的事情,而且涉及的所有问题的全面讨论需要整列。 在这种情况下,使用最简单 — — 但不是最有效 — — 的做法。 我在集团字段中,由空格分隔创建节点的字符串表示形式,然后使用内置的 String.GetHashCode。 例如,如果集团包含节点 {3 5 8},生成"3 5 8" (带有尾随空格),然后从该字符串生成哈希代码。 记得这样它就不可能有一个集团派系都始终保持按排序顺序,{3 5 8} 和另一个集团 {8 3 5}。 所以,派的节点之间的空格处我 {3 5 8} 是有别于集团 {3 58}。

更新禁止期

方法 UpdateProhibitPeriod 自适应增加或减少基于先前见到的集团解决方案的禁止时间。 方法开始:

static int UpdateProhibitPeriod(MyGraph graph, List<int> clique,
  int bestSize, Hashtable history, int time, int prohibitPeriod,
  ref int timeProhibitChanged)
{
  int result = prohibitPeriod;
  CliqueInfo cliqueInfo = new CliqueInfo(clique, time);
.
.
.

该方法将返回禁止时间可能禁止当前时间相同。 实例化一个 CliqueInfo 对象持有的当前集团和当前时间,如下所示:

if (history.Contains(cliqueInfo.GetHashCode())) {
  CliqueInfo ci = (CliqueInfo)history[cliqueInfo.GetHashCode
  int intervalSinceLastVisit = time - ci.LastSeen;
  ci.LastSeen = time;
  if (intervalSinceLastVisit < 2 * graph.NumberNodes - 1) {
    timeProhibitChanged = time;
    if (prohibitPeriod + 1 < 2 * bestSize) return prohibitPeriod + 1;
    else return 2 * bestSize;
  }
}
else history.Add(cliqueInfo.GetHashCode(), cliqueInfo);
...

该代码检查以查看当前集团解决方案,在窗体的一个 CliqueInfo 对象,是否有没有过 — — 即是历史的哈希表中,集团吗? 如果之前见过,当前的集团,算法计算多长时间以来,集团的前身。 如果此间隔是足够短,禁止时间会增加 (取决于上限)。 这个想法是因为有人看见了当前的集团,这不是很长,因为该算法生成重复的解决方案。 如果禁止时间增加,会有更多的禁忌节点,因此少允许节点,减少生成重复集团解决方案的机会。 如果当前解决方案集团还没见过,它被添加到历史记录的哈希表。

"足够短"的时间间隔是在图中,少一个节点数的两倍。 这用于确定何时增加禁止时间。 这里使用的最佳值是最大的集团研究中另一个悬而未决的问题。 禁止时间的"上限值"是当前已知最佳解决方案的大小的两倍。 最佳的上限值,正如您可能已经猜到目前为止,开放研究的另一个问题。

在这一点上,要么,当前集团还没见过,或集团之前见过但增加禁止时间所需的时间间隔不是足够小。 所以算法现在检查看禁止期间能递减,这将减少禁忌节点的数目和增加允许节点,这反过来又使算法添加或删除多个节点:

...
if (time - timeProhibitChanged > 10 * bestSize) {
    timeProhibitChanged = time;
    if (prohibitPeriod - 1 > 1)
      return prohibitPeriod - 1;
    else
      return 1;
  }
  else {
    return result; // no change
  }
} // UpdateProhibitTime

而不是显式检查是否以来时间"较长"被认为,当前的集团,该算法间接检查多长时间以来,当前的集团被审查期间禁止的上次更改的时间。 再一次"较长"的最佳值不清楚地理解。 我使用一个值 10 倍于当前最好的解决办法。 请注意,禁止时间不能低于 1。

更多的研究

研究结果的最大团问题建议时性能和解决方案质量,考虑到贪婪的方法与自适应禁忌功能为提供最好的整体效果。 DIMACS 是一个创建一系列困难图集团基准问题的研究组织。 我跑在这里提出一个特别困难的 DIMACS 问题 (C2000.9) 和快速 (中小于两秒) 的代码发现集团与大小 76 77 解最著名的大小的 1.5%内的代码。

在此列中的几个问题,我的最大团问题上提到过的研究成果。 如果你有兴趣在这项研究中,我建议你搜索由以下写的学术论文: R. Battiti et al.,? w? Et al Pullan。 和 K. 片山 et al。 这三位作者和他们的同事的几篇论文是我主的引用。

此处提供的最大团算法有前途未开发的领域是把某种形式的所谓高原搜索。 记得第一次的最大团算法尝试将非禁忌节点添加到当前解决方案集团。 然后,如果这不是可能的算法将从当前解决方案集团删除节点。 高原搜索的想法是找到一个节点可以换用为节点不在集团集团当前解决方案中。 虽然这不会增加,目前集团的大小,它不会也减小了集团的大小。

**博士。**James McCaffrey适合伏信息科学 Inc.,他在管理技术培训为软件工程师工作在微软雷德蒙德,华盛顿,校园。他被在几个 Microsoft 产品,包括互联网资源管理器和 MSN 搜索。 博士。 麦卡弗里是作者"。网络测试自动化食谱"(很,2006年),可以在达到 jammc@microsoft.com

衷心感谢以下 Microsoft 技术专家对本文的审阅: Paul Koch, Dan Liebling, Ann Loomis ThompsonShane Williams