自然算法

使用蜂群算法解决无解难题

James McCaffrey

下载代码示例

image: James McCaffrey
模拟蜂群 (SBC) 算法的模型基于蜜蜂的行为,用于解决艰深或无解的组合问题。在本文中,我将说明 SBC 算法的具体含义,介绍使用 SBC 算法可以解决的问题类型,并通过一个完整的端到端示例来演示如何使用 SBC 算法解决旅行商问题。

为了体会 SBC 算法并了解我要在本文中讨论的问题,您可以查看图 1 中所示的演示程序。该演示程序使用 SBC 算法来分析 20 个城市(标为 A 到 T),寻找每个城市只访问一次的最短路线。这些城市数据是人为构建的,以便最佳路线能够从 A 城市开始,然后依次经过每个城市,直到 T 城市。最佳路线的长度为 19.0 单位。

image: Simulated Bee Colony Demo

图 1 模拟蜂群演示

在后台,SBC 算法实例化一个包含 100 只模拟蜜蜂的蜂巢,每只蜜蜂都拥有一个随机的潜在解决方案。一开始,最佳随机解决方案的路线长度为 95.0 单位。SBC 算法进入一个处理循环,由基于文本的进度条指示,该循环模拟普通蜜蜂的觅食行为。在 SBC 处理循环结束时,发现的最佳解决方案在 20 个城市中找到了 16 个城市的正确位置,其路线长度为 26.5,已经接近(但不是非常接近)最佳解决方案了。

SBC 算法通常被称为元启发式算法,因为该算法提供了一个通用框架和一套指导原则来帮助创建问题解决方案,而不是提供非常详尽的具体解决方案。本文将通过一个示例来介绍如何使用 SBC 解决具体问题。在了解如何使用 SBC 解决问题后,您就可以采用本文中介绍的 SBC 算法来解决自己的问题了。正如本文所要演示的那样,SBC 算法最适合解决复杂的组合问题,此类问题通常没有经过实践验证的确定性解决方案。

本文假定您具备中级编程技能。本文中的示例使用 C# 编写,但我会列出所有代码,以便您轻松重构为其他编程语言。我相信,您会发现本文非常有趣,并且使用 SBC 算法的能力对您的个人技能不无裨益。

蜜蜂简介

随着时间的推移,意蜂 等普通蜜蜂会在蜂群内承担不同的角色。一个典型的蜂巢有 5,000 到 20,000 只蜜蜂。成熟的蜜蜂(生长了 20 到 40 天)通常会成为负责觅食的蜜蜂。负责觅食的蜜蜂通常承担以下三个角色之一:活跃觅食蜂、侦察蜂和不活跃觅食蜂。

活跃觅食蜂会飞到食物源,查看邻近的食物源,采集食物,然后返回蜂巢。

侦察蜂负责在蜂巢附近的区域(通常为 50 平方英里以内的区域)进行侦察,寻找有吸引力的新食物源。一个蜂巢中大约有 10% 的觅食蜂会充当侦察蜂。

任何时候都会有一些觅食蜂处于不活跃状态。这些不活跃觅食蜂会守候在蜂巢的入口附近。当活跃觅食蜂和侦察蜂返回蜂巢时,它们可能会向守候的不活跃觅食蜂跳一段摇摆舞,具体取决于它们刚刚访问过的食物源的质量。有充分的证据表明,这种摇摆舞是向不活跃觅食蜂传达信息,告诉它们食物源的位置和质量。不活跃觅食蜂从摇摆舞中收到食物源信息后,可能会变成活跃觅食蜂。

通常情况下,活跃觅食蜂会继续从特定食物源采集食物,直到将食物采集完,然后就会变为不活跃觅食蜂。

旅行商问题

旅行商问题 (TSP) 是计算机科学研究领域内研究最广的问题之一。有很多版本的 TSP,但是在非正式场合,这个问题是针对给定的一组城市,寻找每个城市只访问一次的最短路线。

如果您看一下图 1,就会发现 SBC 演示程序使用了一组 20 个城市,并随机标记为 A 到 T。有效的路线由按顺序排列、代表 20 个城市的标记字母所组成,其中每个字母只出现一次。因此,可能的路线总数为:20!= 2,432,902,008,176,640,000 条。

在后台,会有一个距离值与每一对城市相关联。为了简便起见,如果城市 c1 < 城市 c2,则 c1 和 c2 之间的距离恰好为城市标记字母之间的顺序距离的 1.0 倍。如果 c1 > c2,则 c1 和 c2 之间的距离为顺序距离的 1.5 倍。因此,从 A 到 B 的距离为 1.0 个任意单位,从 B 到 A 为 1.5 个单位,从 A 到 C 的距离为 2.0 个单位,依此类推。因此,每个城市只访问一次的最佳路线是 A -> B -> C -> ...-> T,而最佳(最短)路线长度则为 (20-1) * 1.0 = 19.0。

在大多数 TSP 场景中,城市之间的距离并不是人为计算的,而可能存储在某种形式的查找数据结构中。某些版本的 TSP 假设每个城市都与其他各个城市相连,而有些版本则假设不是所有的城市都互相连接。此外,有些版本的 TSP 假设从任一城市 c1 到城市 c2 的距离都与从城市 c2 到 c1 的距离相同,而另一些版本的 TSP 则不做这种双向假设。

除了在简化的情况下,否则使用强力方法来寻找最短路线不可行。例如,对于 20 个城市,即使您每秒能够计算 100 万条路线,检查全部 20! 条可能的路线都需要 77,000 年以上。这种极其困难的组合优化问题是 SBC 算法非常适合处理的问题类型。

虚拟 TSP 问题在一个名为 CitiesData 的类中实现,如图 2 所示。可以从 code.msdn.microsoft.com/mag0411BeeColony 获取 SBC 演示程序的完整源代码。

图 2 CitiesData 类定义

class CitiesData {
  public char[] cities;

  public CitiesData(int numberCities) {
    this.cities = new char[numberCities];
    this.cities[0] = 'A';
    for (int i = 1; i < this.cities.Length; ++i)
      this.cities[i] = (char)(this.cities[i - 1] + 1);
  }

  public double Distance(char firstCity, char secondCity) {
    if (firstCity < secondCity)
      return 1.0 * ((int)secondCity - (int)firstCity);
    else
      return 1.5 * ((int)firstCity - (int)secondCity);
  }

  public double ShortestPathLength() {
    return 1.0 * (this.cities.Length - 1);
  }

  public long NumberOfPossiblePaths() {
    long n = this.cities.Length;
    long answer = 1;
    for (int i = 1; i <= n; ++i)
      checked { answer *= i; }
    return answer;
  }

  public override string ToString() {
    string s = "";
    s += "Cities: ";
    for (int i = 0; i < this.cities.Length; ++i)
      s += this.cities[i] + " ";
    return s;
  }
}

用于表示特定问题的某些类或数据结构的定义可能会与此处显示的不同。 但是,一般来说,非常适合使用 SBC 算法解决的问题通常都能表示为非数值数组、非数值矩阵或非数值交错数组的数组。

CitiesData 构造函数接受表示城市数的值,然后为第一个城市分配标签 A,第二个城市分配标签 B,依此类推。

正如我先前所述,Distance 方法以单向方式定义,并假设可以从任意一个城市到达另一个城市。

ShortestPathLength 方法用于返回 Distance 定义条件下的最佳路线长度。 在大多数 SBC 场景当中,您都可能无法获得必要的信息来实现能够返回最佳解决方案的方法。

NumberOfPossiblePaths 方法用于计算 n!, 其中 n 是城市数。 在某些 TSP 场景中,可能的路线数为 n-1! (如果不考虑出发城市);而在其他一些场景当中,可能的路线数则为 n/2! (如果不考虑路线的方向)。

ToString 方法使用字符串连接而不是更有效的 StringBuilder 类,用描述性数据来组成字符串。 使用字符串连接是为了便于重构成其他编程语言。

在本文中,为了让代码保持相对简洁清晰,我采取了一些您不会在生产代码中使用的快捷方式,例如删除了大部分错误检查代码。 例如,如果结果大于 long.MaxValue,则方法 NumberOfPossiblePaths 不会处理数值溢出。

SBC 程序结构

图 1 显示的 SBC 算法以 C# 控制台应用程序的方式实现。 程序的整体结构如图 3 所示。 请注意,SBC 程序结构相对简单,只使用了基本的 System 命名空间。 共有三个类:Program 类:包含一个 Main 方法;Hive 类:包含所有的 SBC 算法逻辑;以及本文上一部分介绍的 CitiesData 类。 Hive 类通常命名为 Hive,而不是指定一个更具体的名称(如 TravelingSalesmanHive),尽管 SBC 算法的实现与它们要解决的具体问题有很大的关系。

图 3 程序的整体结构

using System;
namespace SimulatedBeeColony {
  class Program {
    static void Main(string[] args) {
      Console.WriteLine("\nBegin Simulated Bee Colony algorithm demo\n");
      .
.
.
Console.WriteLine("End Simulated Bee Colony demo");
    } 
  } 

  class Hive {
    public class Bee {
      .
.
.
}

    // Hive data fields here

    public override string ToString() { .
.
.
}
    
    public Hive(int totalNumberBees, int numberInactive,
      int numberActive, int numberScout, int maxNumberVisits,
      int maxNumberCycles, CitiesData citiesData) {
      .
.
.
} 

    public char[] GenerateRandomMemoryMatrix() { .
.
.
}
    
    public char[] GenerateNeighborMemoryMatrix(char[] memoryMatrix) { .
.
.
}
    
    public double MeasureOfQuality(char[] memoryMatrix) { .
.
.
}
    
    public void Solve() { .
.
.
}

    private void ProcessActiveBee(int i) { .
.
.
}

    private void ProcessScoutBee(int i) { .
.
.
}

    private void ProcessInactiveBee(int i) { .
.
.
}

    private void DoWaggleDance(int i) { .
.
.
}
  } 

  class CitiesData {
    .
.
.
}

} // ns

Main 方法非常简单。 在显示了一条开始消息之后,CitiesData 对象即被实例化:

Console.WriteLine(
  "Loading cities data for SBC Traveling Salesman Problem analysis");
CitiesData citiesData = new CitiesData(20);
Console.WriteLine(citiesData.ToString());
Console.WriteLine("Number of cities = " + citiesData.cities.Length);
Console.WriteLine("Number of possible paths = " + 
  citiesData.NumberOfPossiblePaths().ToString("#,###"));
Console.WriteLine("Best possible solution (shortest path) length = " + 
  citiesData.ShortestPathLength().ToString("F4"));

在许多 SBC 场景当中,您的问题数据都驻留在文本文件或 SQL 数据库等外部存储中,您将通过在 myProblemData.Load(dataFile) 代码行中使用一些加载构造函数或加载方法来加载问题数据。

接下来,就是准备并调用 Hive 构造函数:

int totalNumberBees = 100;
int numberInactive = 20;
int numberActive = 50;
int numberScout = 30;
int maxNumberVisits = 100; 
int maxNumberCycles = 3460;
       
Hive hive = new TravelingSalesmanHive(totalNumberBees,
  numberInactive, numberActive, numberScout, maxNumberVisits,
  maxNumberCycles, citiesData);

正如您将在本文的以下部分中进一步看到的,SBC 算法使用三种类型的蜜蜂:活跃蜂、不活跃蜂和侦察蜂。 尽管每种蜜蜂的数量都可以采用硬编码方式,但是在大多数场景中,以参数方式将这些数据传递给 Hive 构造函数会更好,因为这样更容易调整算法以实现更好的性能。

totalNumberBees 变量的值可以从其他三个变量得出,但是这个额外的变量提高了代码的可读性。 蜜蜂的总数将取决于您的具体问题。 蜜蜂的数量越多越好,但这会降低程序的执行速度。 关于各种蜂的比例,有研究表明,活跃蜂、不活跃蜂和侦察蜂的最佳比例通常分别为 75%、10% 和 15% 左右。

稍后将介绍 maxNumberVisits 变量使用的值 100,该值与给定解决方案的邻近解决方案数量有关。

maxNumberCycles 变量用于控制 Solve 例程将要迭代的次数,这是必不可少的,因为在 SBC 算法场景中,您通常不知道何时会得到最佳解决方案(如果您知道最佳解决方案,也就没有问题需要解决了)。 在本例中,maxNumberCycles 的值被限制为 3,460,因此 SBC 算法未能生成最佳结果。 在这里需要说明的是:尽管 SBC 算法可以生成最佳结果,但是您通常没有办法得知这一点,因此必须愿意接受一个“好”结果。

构造函数执行时会创建一个蜜蜂集合,其中每只蜜蜂都拥有一个随机的解决方案。 Hive 对象会跟踪蜂巢中任意蜜蜂发现的整体最佳(最短)路线,以及最佳解决方案的相关路线长度。

在调用 Hive 构造函数后,Main 方法会执行以下任务然后结束:使用 ToString 方法显示最初随机生成的 Hive 值,调用 Solve 方法并使用一个参数来指示应当显示基于文本的进度条,然后显示所发现的最佳路线及其相关的路线长度:

...
Console.WriteLine("\nInitial random hive");
  Console.WriteLine(hive);

  bool doProgressBar = true;
  hive.Solve(doProgressBar);

  Console.WriteLine("\nFinal hive");
  Console.WriteLine(hive);
  Console.WriteLine("End Simulated Bee Colony demo");
}

Bee 类

图 3 所示,Hive 类包含一个嵌套的 Bee 类定义。 图 4 列出了 Bee 定义的详细信息。

图 4 Bee 类定义

public class Bee {
  public int status;
  public char[] memoryMatrix;
  public double measureOfQuality; 
  public int numberOfVisits;

  public Bee(int status, char[] memoryMatrix, 
    double measureOfQuality, int numberOfVisits) {
    this.status = status;
    this.memoryMatrix = new char[memoryMatrix.Length];
    Array.Copy(memoryMatrix, this.memoryMatrix, memoryMatrix.Length);
    this.measureOfQuality = measureOfQuality;
    this.
numberOfVisits = numberOfVisits;
  }

  public override string ToString() {
    string s = "";
    s += "Status = " + this.status + "\n";
    s += " Memory = " + "\n";
    for (int i = 0; i < this.memoryMatrix.Length-1; ++i)
      s += this.memoryMatrix[i] + "->";
    s += this.memoryMatrix[this.memoryMatrix.Length-1] + "\n";
    s += " Quality = " + this.measureOfQuality.ToString("F4");
    s += " Number visits = " + this.
numberOfVisits;
    return s;
  }
}

Bee 类有三个对所有 SBC 实现通用的数据字段,还有一个针对具体问题的数据字段。 针对具体问题的字段名为 memoryMatrix。 每个 SBC 实现都必须使用一些方法来表示一个解决方案。 在本文的 TSP 示例中,可以使用 char 类型的数组来表示解决方案。 需要强调的是,用于表示解决方案的对象在很大程度上取决于问题,因此必须单独分析每个问题以生成有意义的解决方案表示形式。

字段 status 保存一个 int 值,用于表明 Bee 对象的类型:0 表示不活跃蜂;1 表示活跃蜂;2 表示侦察蜂。 如果您使用的编程语言支持枚举类型,则可能希望将 status 字段重构为枚举类型。

字段 measureOfQuality 保存一个双精度值,用于表示 Bee 对象的 memoryMatrix 的好坏程度。 而对于 TSP 来说,衡量解决方案质量的自然指标就是由 memoryMatrix 对象表示的路线长度。 请注意,在这种情况下,较短的路线长度优于较长的路线长度,因此 measureOfQuality 字段的值越小,其表示的解决方案就越好。 每个 SBC 实现都必须使用一些计算方法来衡量解决方案的质量。 在很多情况下,用 double 类型的值表示这个指标是最好的选择。

Bee 类中第三个通用的数据字段是 numberOfVisits。 这个字段保存一个 int 值,用于表示 Bee 对象在未找到更好的邻近解决方案时访问特定食物源/问题解决方案的次数。 这个字段用于模拟蜜蜂访问食物源,直到食物源耗尽的情形。 对于活跃蜂,当 numberOfVisits 字段的值超过某个阈值时,模拟蜜蜂将耗尽食物供应并转入不活跃状态(而不活跃蜂将转入活跃状态)。

Bee 构造函数接受以下四个数据字段的值:status、memoryMatrix、measureOfQuality 和 numberOfVisits。 请注意,Bee 构造函数必须接受一个 measureOfQuality 值,因为 Bee 无法直接通过其 memoryMatrix 字段计算此值,而质量好坏的确定则取决于针对具体问题的 CitiesData 对象中存储的信息。

Bee 类定义包含一个 ToString 方法,该方法用于提供四个数据字段的值。 Bee.ToString 方法并不是绝对必要的,但该方法在 SBC 开发过程中可以通过使用 WriteLine 语句发现错误,因而可能非常有用。

蜂巢数据字段

图 5 显示了 Hive 类代码。 共有 14 个蜂巢数据字段,只有了解每个字段的作用,才能理解如何实现具体的 SBC 算法。

图 5 14 个蜂巢数据字段

static Random random = null;

public CitiesData citiesData;

public int totalNumberBees; 
public int numberInactive; 
public int numberActive;
public int numberScout;

public int maxNumberCycles;
public int maxNumberVisits; 

public double probPersuasion = 0.90;
public double probMistake = 0.01; 

public Bee[] bees;
public char[] bestMemoryMatrix;
public double bestMeasureOfQuality;
public int[] indexesOfInactiveBees;

为了简便起见,并简化 WriteLine 语句的调试,所有 14 个数据字段都定义在公共作用域中。 您可能希望将这些字段限制到专用作用域中,并为这些字段创建需要从类定义外部访问的属性。

第一个字段被命名为 random,类型为 Random。 SBC 算法为概率型算法,random 对象用于生成伪随机数字,以实现几个目的。 random 对象将在 Hive 构造函数中实例化。

第二个数据字段是一个类型为 CitiesData 的对象。 SBC 实现需要了解所解决问题的详细信息。 在大多数情况下(如本示例),问题数据被表示为一个对象。 前面曾提到,CitiesData 包含一个城市标签列表,以及能够返回任意两个城市间距离的方法。

第三到第六个数据字段是 int 变量,用于保存蜜蜂的总数、不活跃蜂的数量、活跃蜂的数量以及侦察蜂的数量。 正如前文所述,因为每只蜜蜂都代表一个潜在的解决方案,所以蜂巢中的蜜蜂数量越多越好。 但是,蜜蜂的数量越多,程序的性能就越低。

第七个数据字段 maxNumberCycles 是一个阈值,用于限制 Solve 方法运行的时间。 一个周期表示对蜂巢中的所有蜜蜂处理一次。

第八个数据字段 maxNumberVisits 是一个阈值,用于防止蜜蜂在某个解决方案中停留的时间过久。 在 Solve 方法的主处理循环的每一次迭代中,如果某只蜜蜂未发现邻近有质量更好的食物源,该蜜蜂的 numberOfVisits 计数器就会递增。 如果某个 Bee 对象中的 numberOfVisits 计数器超过了 maxNumberVisits 阈值,该蜜蜂就会转入不活跃状态。

第九个数据字段 probPersuasion 是一个概率阈值,用于确定不活跃蜂在观察返回蜂巢的蜜蜂跳摇摆舞时,如果后者提供了更好的解决方案,该不活跃蜂是否会被说服以使用该解决方案来更新自己的内存。

probPersuasion 的值被硬编码为 0.90,表示不活跃蜂被说服以接受更好解决方案的概率为 90%。 对于 probPersuasion,0.90 这个值是根据研究发现得出的,但您可能希望尝试其他值。 如果使用更大的值,相应的 SBC 算法生成解决方案的速度就更快,但生成非最佳解决方案的概率也更高。

第十个数据字段 probMistake 是一个概率阈值,用于确定活跃蜂是否会犯错,即,错误地拒绝比该蜜蜂当前的解决方案更好的邻近解决方案;或错误地接受比当前解决方案更差的邻近解决方案。 probMistake 的值被硬编码为 0.01,表示活跃蜂在评估邻近解决方案时的出错概率为 1%。

第 11 个数据字段 bees 是一个由 Bee 对象构成的数组。 前面曾提到,每只蜜蜂都具备一个状态(活跃、不活跃、侦察)、一个解决方案 (memoryMatrix)、一个用于衡量解决方案质量的指标 (measureOfQuality) 以及一个用于记录在未找到更好的邻近食物源的情况下访问某个虚拟食物源的次数的计数器 (numberOfVisits)。 因为 Bee 被定义为一个类,所以 bees 数组中的每个条目都是对 Bee 对象的引用。

第 12 个数据字段 bestMemoryMatrix 是一个字符数组,用于表示 bees 数组中的最佳解决方案。 前面曾提到,因为模拟蜂群算法是元启发式算法的具体实现,所以各个问题的解决方案的表示形式也会有所不同。 对解决方案类型的定义进行硬编码的替代设计方法是,将此数据字段参数化为泛型类型。 当我使用 SBC 算法时,通常会尝试解决某个具体问题,因此倾向于从头开始为每个新 SBC 实现重新编写代码。

第 13 个数据字段 bestMeasureOfQuality 是用于衡量质量的指标,与 bestMemoryMatrix 解决方案相对应。

最后一个蜂巢数据字段 indexesOfInactiveBees 是一个 int 数组。 这个数组保存蜂巢中当前处于不活跃状态的蜜蜂的索引。 前面曾提到,活跃蜂能够转入不活跃状态,不活跃蜂也能转入活跃状态。 SBC 算法实现必须在活跃蜂跳虚拟摇摆舞时,频繁确定哪些蜜蜂当前处于不活跃状态。与迭代整个 bees 数组以检查每只蜜蜂的状态数据字段相比,将不活跃蜂的索引存储起来可以提高性能。

图 6 以可视化的形式显示了可能的 Hive 对象。 显示的蜂巢有 10 只蜜蜂:5 只活跃蜂、2 只侦察蜂和 3 只不活跃蜂。 在 bees 数组中,当前处于不活跃状态的蜜蜂的索引编号为 2、7 和 4。 CitiesData 对象有五个城市。 当前最佳的解决方案是:

A->B->E->C-D

此解决方案的路线长度为(以距离单位为单位):

1.0 + (3 * 1.0) + (2 * 1.5) + 1.0 = 8.0

请注意,citiesData 字段是对在蜂巢对象之外定义的 CitiesData 对象的引用。

image: The Hive Representation

图 6 蜂巢表示形式

Hive 构造函数

图 7 中显示了 Hive 构造函数的代码。 Hive 构造函数接受七个输入参数。 其中六个参数是标量,一个是对象 (citiesData)。 totalNumberBees 参数可以通过 numberInactive、numberActive 和 numberScout 参数计算得出,因此从这个意义上来说是冗余的,但是我觉得增加此代码可以提高代码的可读性,因此值得这么做。

图 7 Hive 构造函数

public Hive(int totalNumberBees, int numberInactive,
  int numberActive, int numberScout, int maxNumberVisits,
  int maxNumberCycles, CitiesData citiesData) {

  random = new Random(0);
      
  this.totalNumberBees = totalNumberBees;
  this.
numberInactive = numberInactive;
  this.
numberActive = numberActive;
  this.
numberScout = numberScout;
  this.maxNumberVisits = maxNumberVisits;
  this.maxNumberCycles = maxNumberCycles;

  this.citiesData = citiesData;

  this.bees = new Bee[totalNumberBees];
  this.bestMemoryMatrix = GenerateRandomMemoryMatrix();
  this.bestMeasureOfQuality = 
    MeasureOfQuality(this.bestMemoryMatrix);

  this.indexesOfInactiveBees = new int[numberInactive]; 

  for (int i = 0; i < totalNumberBees; ++i) {
    int currStatus; 
    if (i < numberInactive) {
      currStatus = 0; // inactive
      indexesOfInactiveBees[i] = i; 
    }
    else if (i < numberInactive + numberScout)
      currStatus = 2; // scout
    else
      currStatus = 1; // active
    
    char[] randomMemoryMatrix = GenerateRandomMemoryMatrix();
    double mq = MeasureOfQuality(randomMemoryMatrix);
    int numberOfVisits = 0;

    bees[i] = new Bee(currStatus, 
      randomMemoryMatrix, mq, numberOfVisits); 
        
    if (bees[i].measureOfQuality < bestMeasureOfQuality) {
      Array.Copy(bees[i].memoryMatrix, this.bestMemoryMatrix, 
        bees[i].memoryMatrix.Length);
      this.bestMeasureOfQuality = bees[i].measureOfQuality;
    }
  } 
}

类作用域的 random 对象使用种子值 0 进行实例化。 提供种子值可以让您重现结果。 接下来,针对这些标量数据字段的六个输入参数值被复制到蜂巢数据字段中。 蜂巢对象共有 14 个数据字段;阈值 probPersuasion 和 probMistake 是硬编码的。

Hive 构造函数接受输入 citiesData 参数,然后将 citiesData 字段以引用的形式分配给该参数。 这种通过引用方式的替代方法是,按如下方式复制一份新的问题数据:

int n = citiesData.cities.Length;
this.citiesData = new CitiesData(n);

这种方法会使用更多内存,但避免了潜在的负面错误。 如果您要将此处的代码重构为不支持指针或引用的编程语言,则可以使用此替代方法。

此时,在 Hive 构造函数中,bees 数组中的所有条目都将为 null。 接下来,构造函数将初始化全局最佳解决方案(即蜂巢中所有蜜蜂的最佳解决方案)和相应的解决方案质量:

this.bestMemoryMatrix = GenerateRandomMemoryMatrix();
this.bestMeasureOfQuality = 
  MeasureOfQuality(this.bestMemoryMatrix);

GenerateRandomMemoryMatrix 帮助器方法会生成一个随机解决方案。 MeasureOfQuality 帮助器方法接受随机生成的解决方案并计算其质量。 本文稍后将讨论这两个帮助器方法的代码。

初始化全局最佳解决方案及其相应质量后,Hive 构造函数将使用 numberInactive 字段中的值分配 indexesOfInactiveBees 蜜蜂数组。 此时,此索引数组中的所有值都将为 0。

构造函数代码的下一部分将迭代 bees 数组中的每个 Bee 对象,并使用 Bee 构造函数对其进行实例化。 此循环中的逻辑假设 bees 数组中的第一部分 numberInactive 单元是不活跃蜂,接下来的 numberScout 单元是侦察蜂,剩下的单元为活跃蜂。

例如,如果有五只活跃蜂、两只不活跃蜂和三只侦察蜂,构造函数将初始化一个大小为 10 的 bees 数组,然后将单元 0 和 1 实例化为不活跃蜂;单元 2-4 实例化为侦察蜂;单元 5-9 实例化为活跃蜂。 此外,indexesOfInactiveBees 数组的大小将为 2,最开始包含的值为 0 和 1。

根据循环索引变量确定当前蜜蜂的状态后,就会创建随机生成的解决方案并计算其相应质量,将访问次数计数器显式设置为 0,然后调用 Bee 构造函数:

char[] randomMemoryMatrix = GenerateRandomMemoryMatrix();
double mq = MeasureOfQuality(randomMemoryMatrix);
int numberOfVisits = 0;
bees[i] = new Bee(currStatus, randomMemoryMatrix, 
  mq, numberOfVisits);

实例化每只蜜蜂后,将检查蜜蜂随机生成的解决方案的质量,以查看其是否优于全局最佳解决方案。 如果是,当前蜜蜂的解决方案及相应质量将被复制到 bestMemoryMatrix 和 bestMeasureOfQuality 字段中。 请注意,在检查全局最佳解决方案质量时,因为质量值是路线长度,而 TSP 希望最小化路线长度,因此值越小越好。

可以选择通过引用分配,而不将蜜蜂的内存显式复制到 bestMemoryMatrix 数组中。 这种方法可以提高性能,但是会增加复杂性。

三个基本 SBC 方法

每个 SBC 算法实现都必须具备三个针对具体问题的方法:生成随机解决方案的方法;生成给定解决方案的邻近解决方案的方法;计算给定解决方案的质量的方法。 在这个 TSP 示例中,每个方法都采用自定义并完全依赖于问题的方法来实现。

替代的设计方法是定义接口并实现这些接口。 接口编程方法与非接口编程方法各有优缺点,在我看来主要是个人喜好问题。 下面显示了用于生成随机解决方案的方法 GenerateRandomMemoryMatrix:

public char[] GenerateRandomMemoryMatrix() {
  char[] result = new char[this.citiesData.cities.Length];
  Array.Copy(this.citiesData.cities, result,
    this.citiesData.cities.Length);      
  for (int i = 0; i < result.Length; i++) {
    int r = random.Next(i, result.Length);
    char temp = result[r];
    result[r] = result[i];
    result[i] = temp;
  }
  return result;
}

因为 TSP 问题的解决方案是一个字符数组,其中每个字符都代表一个城市标签,所以 GenerateRandomMemoryMatrix 方法会返回一个字符数组。 本地的结果数组大小是根据蜂巢的 CitiesData 对象分配的,并且蜂巢对 CitiesData 对象的引用中存储的城市 ID 会复制到结果数组中。 随后会使用类作用域的 random 对象和 Fisher-Yates 混排算法(有时称为 Knuth 混排算法)对值在结果数组中的顺序进行随机化处理。

首先,从概念上看,方法 GenerateRandomMemoryMatrix 似乎属于 Bee 对象。 但是,因为生成随机解决方案在一定程度上取决于具体问题的数据(在本例中为 CitiesData),所以将随机解决方案生成方法放在蜂巢的整体定义中是更好的设计构思。

图 8 显示了生成邻近解决方案的方法 GenerateNeighborMemoryMatrix。

图 8 生成邻近解决方案

public char[] GenerateNeighborMemoryMatrix(char[] memoryMatrix) {
  char[] result = new char[memoryMatrix.Length];
  Array.Copy(memoryMatrix, result, memoryMatrix.Length);

  int ranIndex = random.Next(0, result.Length);
  int adjIndex;
  if (ranIndex == result.Length - 1)
    adjIndex = 0;
  else
    adjIndex = ranIndex + 1;

  char tmp = result[ranIndex];
  result[ranIndex] = result[adjIndex];
  result[adjIndex] = tmp;  return result;
}

SBC 算法中的一个关键概念是,每个代表一种解决方案的虚拟食物源都有某种形式的邻居。 没有邻居这个概念,基于蜜蜂行为的算法的整体构思将不成立。 对于 TSP,其解决方案能够以表示城市之间路线的城市 ID 数组来表示,而相对于当前解决方案的自然邻近解决方案则是当前解决方案的一种排列,其中两个相邻的城市被交换。

例如,如果当前的 TSP 解决方案为 A,B,C,D,E,则合理的邻近解决方案为 A,C,B,D,E。 如果某个排列是将任意两个城市交换(而不是两个相邻城市),那么这个排列表示的合理邻近解决方案就不那么明显。 对于上一个示例,A,D,C,B,E 是合理的邻近解决方案吗? 对于 SBC 算法,其邻近解决方案的定义取决于问题,并且通常涉及主观标准。

邻近解决方案的概念还在某种程度上阐明了为什么非数值组合问题特别适合用 SBC 算法来解决。 如果某个问题本质上是数值问题,那么“邻居”这个概念通常就很难定义。 如果某个问题本质上是组合问题,那么“邻居”这个概念通常能够以某种形式的数学排列或组合来准确定义。

GenerateNeighborMemoryMatrix 方法采用蜜蜂的表示某个解决方案的当前 memoryMatrix 作为输入参数,并将其复制到结果数组中。 该方法使用类作用域的 random 对象,在当前结果数组中选择一个随机索引。 如果该随机索引指向最后一个单元,则第一个和最后一个城市 ID 会被交换;如果该随机索引指向最后一个单元以外的任一单元,则随机索引指向的 ID 和下一个索引的 ID 会被交换。

邻近解决方案的概念与 maxNumberVisits 值相关联。 有研究表明,maxNumberVisits 的最佳值大约是任何给定解决方案可能具有的邻近解决方案数量的五倍。 例如,对于三个城市 (A,B,C),如果邻近解决方案被定义为将任意一对相邻城市进行交换,则会有三对可能的邻居(将 A 和 B 交换、B 和 C 交换、A 和 C 交换)。 因此,对于 20 个城市,合理的 maxNumberVisits 值约为 20 * 5 = 100。

用于评估蜜蜂解决方案质量的方法 MeasureOfQuality 如下:

public double MeasureOfQuality(char[] memoryMatrix) {
  double answer = 0.0;
  for (int i = 0; i < memoryMatrix.Length - 1; ++i) {
    char c1 = memoryMatrix[i];
    char c2 = memoryMatrix[i + 1];
    double d = this.citiesData.Distance(c1, c2);
    answer += d;
  }
  return answer;
}

若要使用 SBC 算法解决问题,该问题的本质特征应当包括:必须能够对任何解决方案进行评估,以提供解决方案质量的衡量指标。 从概念上来说,现实世界当中的组合优化问题几乎总是具有一些固有的、常识性的质量衡量指标。 但实际上,计算某个解决方案的质量衡量指标可能会非常困难和费时。

在本例中,MeasureOfQuality 方法只是在由参数 memoryMatrix 表示的解决方案中,对每一对连续的城市 ID 进行迭代,使用 CitiesData 对象的 Distance 方法确定每对城市之间的距离,然后累计总距离。 前面曾提到,城市数据是人为构建的,因此使用两个城市 ID 之间的顺序距离就可以快速轻松地计算任意两个城市之间的距离。 但是在实际问题中,两个城市之间的距离可能要在某种形式的数据结构中查找。 在 SBC 实现中,MeasureOfQuality 方法通常是决定程序运行时间的例程。 因此,有必要确保此方法已针对性能进行过优化。此外,在给定的主机系统的内存资源条件下,还要确保此方法是可行的。

Solve 方法

Solve 方法包含用于模拟觅食蜂行为以解决问题的所有逻辑。 Solve 方法不太复杂,使用三个帮助器方法:ProcessActiveBee、ProcessScoutBee 和 ProcessInactiveBee。 ProcessActiveBee 和 ProcessScoutBee 方法随后又使用了 DoWaggleDance 帮助器方法。 图 9 显示了 Solve 方法。

图 9 Solve 方法

public void Solve(bool doProgressBar) {
  bool pb = doProgressBar;
  int numberOfSymbolsToPrint = 10; 
  int increment = this.maxNumberCycles / numberOfSymbolsToPrint;
  if (pb) Console.WriteLine("\nEntering SBC Traveling Salesman Problem algorithm main processing loop\n");
  if (pb) Console.WriteLine("Progress: |==========|");
  if (pb) Console.Write("           ");
  int cycle = 0;
      
  while (cycle < this.maxNumberCycles) {
    for (int i = 0; i < totalNumberBees; ++i) {
      if (this.bees[i].status == 1)
        ProcessActiveBee(i);
      else if (this.bees[i].status == 2)
        ProcessScoutBee(i);
      else if (this.bees[i].status == 0)
        ProcessInactiveBee(i);
    } 
    ++cycle;

    if (pb && cycle % increment == 0)
      Console.Write("^");
  } 

  if (pb) Console.WriteLine("");
}

大部分的实际工作都分给帮助器方法 ProcessActiveBee、ProcessScoutBee 和 ProcessInactiveBee 执行。 一个 Boolean 输入参数被传递给 Solve 方法,指明是否显示基于文本的基本进度条。 这在开发 SBC 算法以监视实现的速度并帮助发现性能瓶颈时非常有用。 此方法假设 Solve 方法是控制台应用程序的一部分。

Boolean 参数的值被传递给本地 Boolean 变量 pb,只是为了使用一个简短的变量名。 numberOfSymbolsToPrint 被设置为 10,这样状态栏中的每次递增都代表总进度的 10%,而总进度则由 maxNumberCycles 值确定(递增变量用于确定多少个周期表示 10% 的进度)。

在循环控制变量 cycle 被初始化为 0 之后,使用一个 while 循环处理蜂巢中的每只蜜蜂。 也可以轻松使用 for 循环。 在每个周期中都使用 for 循环对 bees 数组进行迭代,每个 Bee 对象都由适当的帮助器方法处理。 处理完每只蜜蜂后,如果 Boolean 参数 doProgressBar 为 true,代码会使用模数运算符 % 检查是否应当使用 ^ 字符在进度条中显示更新。

ProcessActiveBee 方法

ProcessActiveBee 方法是 SBC 算法的核心,就代码数量和分支而言是最复杂的方法。 图 10 显示了 ProcessActiveBee 方法。

图 10 ProcessActiveBee 方法

private void ProcessActiveBee(int i) {
  char[] neighbor = GenerateNeighborMemoryMatrix(bees[i].memoryMatrix);
  double neighborQuality = MeasureOfQuality(neighbor); 
  double prob = random.NextDouble();
  bool memoryWasUpdated = false;
  bool numberOfVisitsOverLimit = false; 

  if (neighborQuality < bees[i].measureOfQuality) { // better
    if (prob < probMistake) { // mistake
      ++bees[i].
numberOfVisits;
      if (bees[i].
numberOfVisits > maxNumberVisits)
        numberOfVisitsOverLimit = true;
    }
    else { // No mistake
      Array.Copy(neighbor, bees[i].memoryMatrix, neighbor.Length);
      bees[i].measureOfQuality = neighborQuality;
      bees[i].
numberOfVisits = 0; 
      memoryWasUpdated = true; 
    }
  }
  else { // Did not find better neighbor
    if (prob < probMistake) { // Mistake
      Array.Copy(neighbor, bees[i].memoryMatrix, neighbor.Length);
      bees[i].measureOfQuality = neighborQuality;
      bees[i].
numberOfVisits = 0;
      memoryWasUpdated = true; 
    }
    else { // No mistake
      ++bees[i].
numberOfVisits;
      if (bees[i].
numberOfVisits > maxNumberVisits)
        numberOfVisitsOverLimit = true;
    }
  }

  if (numberOfVisitsOverLimit == true) {
    bees[i].status = 0; 
    bees[i].
numberOfVisits = 0;
    int x = random.Next(numberInactive); 
    bees[indexesOfInactiveBees[x]].status = 1; 
    indexesOfInactiveBees[x] = i; 
  }
  else if (memoryWasUpdated == true) {
    if (bees[i].measureOfQuality < this.bestMeasureOfQuality) {
      Array.Copy(bees[i].memoryMatrix, this.bestMemoryMatrix,
        bees[i].memoryMatrix.Length); 
      this.bestMeasureOfQuality = bees[i].measureOfQuality
    }
    DoWaggleDance(i);
  }
  else {
    return;
  }
}

ProcessActiveBee 方法只接受一个输入参数 i,该参数是蜜蜂在 bees 数组中的索引。 活跃蜂首先获取 memoryMatrix 中存储的当前解决方案的邻近解决方案,然后确定该邻近解决方案的质量:

char[] neighbor =
  GenerateNeighborMemoryMatrix(bees[i].memoryMatrix); 
double neighborQuality = MeasureOfQuality(neighbor);

接下来,算法会设置三个本地变量,以备稍后使用:

double prob = random.NextDouble();)
bool memoryWasUpdated = false; 
bool numberOfVisitsOverLimit = false;

prob 变量的值介于 0.0 和 1.0 之间,该值将与 probMistake 字段值比较,以确定蜜蜂在评估邻近解决方案时是否犯错,即是否拒绝了更好的邻近解决方案或接受了更差的邻近解决方案。

Boolean 值 memoryWasUpdated 将用于确定活跃蜂是否应当向不活跃蜂跳摇摆舞(如果为 true 则跳,为 false 则不跳)。 Boolean 变量 numberOfVisitsOverLimit 将与 maxNumberVisits 字段比较,确定蜜蜂是否在未找到更好的邻近解决方案的情况下采完了特定的食物源,如果是这样,该蜜蜂应当从活跃状态转为不活跃状态。

如果当前蜜蜂找到了更好的邻近解决方案,算法将确定该蜜蜂是犯了错误,拒绝了更好的邻近解决方案,还是接受了更好的邻近解决方案。 同样,如果当前蜜蜂未找到更好的邻近解决方案,算法会确定该蜜蜂是犯了错误,接受了更差的邻近解决方案,还是未犯错,拒绝了更差的邻近解决方案。

请注意,可能有两种不同的错误,但这两种错误的几率相同,probMistake = 0.01。 有研究表明,为两种不同的错误使用不同的几率并不能提高 SBC 算法的有效性,但您可能想要尝试两种不同的阈值。

当前活跃蜂接受或拒绝邻近解决方案后,算法会检查访问次数计数器是否超过了 maxNumberVisits 阈值。 如果超过了该阈值,当前蜜蜂将转为不活跃状态,某个随机挑选的不活跃蜂将转为活跃状态,indexesOfInactiveBees 数组也会随之更新。 接下来,算法将查看蜜蜂的内存是否更新。 如果已更新,将检查新的解决方案是否为新的全局最佳解决方案,然后调用一个专用帮助器方法 DoWaggleDance,模拟蜜蜂返回到蜂巢,将有关新食物源的信息传达给不活跃蜂。

DoWaggleDance 方法

DoWaggleDance 帮助器方法模拟活跃蜂或侦察蜂返回蜂巢,向不活跃蜂跳摇摆舞,以便传达有关食物源位置和质量的信息。 下面是 DoWaggleDance 方法:

private void DoWaggleDance(int i) {
  for (int ii = 0; ii < numberInactive; ++ii) {
    int b = indexesOfInactiveBees[ii]; 
    if (bees[i].measureOfQuality < bees[b].measureOfQuality) {
      double p = random.NextDouble(); 
      if (this.probPersuasion > p) {
        Array.Copy(bees[i].memoryMatrix, bees[b].memoryMatrix,
          bees[i].memoryMatrix.Length);
        bees[b].measureOfQuality = bees[i].measureOfQuality;
      } 
    } 
  } 
}

输入参数 i 是当前跳虚拟摇摆舞的蜜蜂的索引。 当前蜜蜂解决方案的质量指标将与每只不活跃蜂的质量指标进行比较。 如果当前蜜蜂的解决方案更好,并且当前不活跃蜂被说服(几率 probPersuasion = 0.90),则当前蜜蜂的内存将被复制到该不活跃蜂的内存中。

请注意,很有多机会可以在本文介绍的代码中插入错误检查代码。 例如,在 DoWaggleDance 的 for 循环中,您可能想要使用以下代码来检查当前不活跃蜂的状态:

if (bees[b].status != 0) throw new Exception(.
.
.);

或者,您可能想要使用以下代码来验证不活跃蜂的访问次数计数器:

if (bees[b].
numberOfVisits != 0) throw new Exception(.
.
.);

ProcessScoutBee 和 ProcessInactiveBee

Solve 方法使用的 ProcessScoutBee 帮助器方法模拟侦察蜂的行为,随机搜索有吸引力的食物源。 图 11 显示了 ProcessScoutBee 方法。

图 11 ProcessScoutBee 方法

private void ProcessScoutBee(int i) {
  char[] randomFoodSource = GenerateRandomMemoryMatrix();
  double randomFoodSourceQuality = 
    MeasureOfQuality(randomFoodSource);
  if (randomFoodSourceQuality < bees[i].measureOfQuality {
    Array.Copy(randomFoodSource, bees[i].memoryMatrix,
      randomFoodSource.Length);
    bees[i].measureOfQuality = randomFoodSourceQuality;
    if (bees[i].measureOfQuality < bestMeasureOfQuality) {
      Array.Copy(bees[i].memoryMatrix, this.bestMemoryMatrix,
        bees[i].memoryMatrix.Length);
      this.bestMeasureOfQuality = bees[i].measureOfQuality;
    } 
    DoWaggleDance(i);
  }
}

输入参数 i 表示侦察蜂在 bees 数组中的索引。 侦察蜂会生成一个随机解决方案,检查该随机解决方案是否比内存中的当前解决方案更好;如果更好,就将该随机解决方案复制到内存中。 前面曾提到,质量值越小越好。 如果侦察蜂找到了更好的解决方案,算法将查看新的解决方案是否为全局最佳解决方案。

请注意,与活跃蜂不同,本 SBC 实现中的侦察蜂在评估食物源质量时从不犯错。 目前没有关于侦察蜂错误影响的研究。

ProcessInactiveBee 方法如下:

`private void ProcessInactiveBee(int i) {
  return;
}
private void ProcessInactiveBee(int i) {
  return;
}

在本 SBC 实现中,不活跃蜂就是不活跃的,因此 ProcessInactiveBee 方法只是一个占位符,在您希望为某只不活跃蜂实现一些取决于问题的逻辑时发挥作用。 可能进行的修改是,使用非常小的几率值,随机更改某只不活跃蜂的内存。

总结

实现 SBC 算法的整体过程从识别问题开始。 没有经过实践验证的确定性解决方案的复杂、非数值、组合优化问题通常都适合采用 SBC 解决方案。 目标问题必须有办法表示解决方案(通常表示为数组或矩阵),并且每个解决方案都必须具备某种形式的邻近解决方案,以及衡量解决方案质量的指标。

例如,请考虑这样一个问题:将一个图分成两个部分,让每个部分中的连接数最多,并且两个部分之间的连接数最少。 这个图划分问题是一个组合问题,并且没有什么算法能够快速找到最佳解决方案(尽管存在确定性算法,能够寻找近似最优解决方案)。 有许多其他 NP 完全 (NP-complete) 问题和 NP 困难 (NP-hard) 问题可以用 SBC 解决。

SBC 算法以自然系统的行为为基础。 还有其他此类算法,包括基于自然选择和进化的遗传算法 (GA)、基于蚂蚁行为的蚁群算法 (ACO) 以及基于金属冷却时的物理特性的模拟退火算法 (SA)。

与确定性方法相比,基于自然系统的算法通常更容易实现。 但是,基于自然系统的算法通常需要对某些参数的值进行规范化,而这些参数对解决方案的推演速度和准确性方面影响很大。 对于 SBC,必须针对每个问题进行微调的敏感参数包括:每种蜜蜂的数量、食物源耗尽之前的最大访问次数、不活跃蜂被说服的几率阈值以及活跃蜂的犯错几率。

尽管 SBC 算法并不适用于每一个问题,但在某些情况下,它们可以成为非常强大的工具。 

James McCaffrey博士 供职于 Volt Information Sciences, Inc.,在该公司他负责管理对华盛顿州雷蒙德市沃什湾 Microsoft 总部园区的软件工程师进行的技术培训。他参与过多项 Microsoft 产品的研发工作,包括 Internet Explorer 和 MSN Search。McCaffrey 博士是《.NET 软件测试自动化之道》(Apress,2006)一书的作者,您可以通过以下地址与他联系:jammc@microsoft.com

衷心感谢以下技术专家对本文的审阅:Microsoft 研究中心的 Dan LieblingAnne Loomis Thompson