摘要:本文针对“无人机卡车结合的旅行商推销问题”,主要运用了动态规划、A*算法,通过合理的剪枝减少复杂度,自拟数据集进行了分析。实验结果表明:将无人机纳入送货系统能够极大地减少配送时间,并且通过合理的剪枝,算法往往确实能够得到最优解或者近似最优解,而时间复杂度能够得到大幅度的下降。
正文:
1、实验目的
经典的旅行商问题适用于目前的许多场景,如外卖骑手的路径规划,快递网点的分配。然而,近几年无人机技术的发展,也为配送行业开辟了新的道路。将无人机送货纳入经典卡车送货的系统,也为我们旅行商问题的算法设计提出了巨大的挑战。本实验旨在从经典的旅行商问题出发,提出一种“卡车-无人机”结合的最短路径规划,并通过近似算法努力降低算法的复杂度,使算法能够用于现实生活中节点数据集庞大的情况。
2、实验设计流程
通读论文,我将代码的实现分为如下几个部分:
1、标准TSP的动态规划实现
3、编写纳入无人机之后的TSP问题
4、利用已初始化的卡车—无人机TSP问题构建状态图
5、通过限制卡车节点的方式重构状态图以减少时间复杂度
6、利用A*算法实现最终代码解决“卡车-无人机送货”问题
- 标准TSP的动态规划实现
论文中已经给出了标准TSP动态规划的递归函数,但仍需解决其中数据表示的问题。c(v,w)可以用邻接矩阵表示,接下来我们重点解决“点的集合”应该用何种方式表示的问题。
点集合的表示方式:
如果将点集合的状态用数组来表示的话,将会产生极大的时间与空间开销。我最初的想法是放弃C++,运用python中的集合字典元组等数据集来实现点集合的存储。搜查资料发现,即使是Python实现的TSP算法,也完全没有用到这样的数据结构,而是运用了一种“状态压缩”的方法。实现起来也很简单,将点的状态映射到一个二进制数,此二进制数从低位开始编号,如果位数为1,表示此点在集合中,0则代表不在。如1010,代表1,3节点不在集合中,2,4节点在集合中。接下来我介绍围绕此种表示方法的一些基本运算。
当u∈S时,表示为S&(1<<(u-1)),当u在集合中时取1。
S\{w},表示为S-(1<<(w-1))
S\V,表示为S-V,等等
最后还有一个关键是计算S中节点的个数,我立马就想到了之前计算机系统基础做的实验,当然这里我们可以有更多的运算工具,以下是其实现函数。即简单的取余运算就可以完成。
按照论文中给出的递归关系,核心代码如下:
其中,循环的最外层是S按集合中点的个数从2到最大,结合nf1函数判断完成。递归的第二层是w点对整个集合的遍历,u点是对当前点集合的遍历。其中的p数组是用来存储每个节点的父节点方便最后回溯输出最短路径。
- 编写只有卡车节点的TSP问题
与标准TSP的动态规划相比,此算法的不同在于摒弃了单一的起点,将起点和终点都任意化。因为这一步已经进入了实际问题,是用来构建后来的状态函数,而这样做的目的在于为状态函数中的边权进行初始化。与经典TSP相比,此处对起点的任意化道理是一样的,无非是加一层循环。
核心代码如下:
- 编写纳入无人机之后的TSP问题
将无人机结合进来的具体实现过程,我们从动态规划的单个子问题着手分析。在只有卡车节点动态规划的单个子问题中,S中所有的点都是卡车节点。而现在的S中允许存在一个无人机节点,这样在卡车从v到w的过程中,可以发射一个无人机来解决其中一个节点的送货。我们在算法中将此节点在算法中设为d,通过对d的遍历找到最小值。无人机节点不可以是起点和终点。
核心代码实现如下:
- 利用已初始化的卡车—无人机TSP问题构建状态图
最开始读到这一步的时候,我感觉有些“多此一举”。之前的Dop算法似乎已经解决了问题,为什么还要创建一个状态函数的动态规划把问题复杂化呢?这篇论文我感觉最大的难点就在这里。前面的逻辑性都很强,顺水推舟一路下来,现在冷不丁冒出一个状态函数就让人很难理解。我猜测它的目的一定是用来降低算法的时间复杂度。我们先来理解一下所谓的状态函数。状态函数D(S,w)指从起点开始遍历完S中所有节点后以w为终点的最优解。这其实与最经典的旅行商TSP问题一样,只是这里这样做的目的是为了构造一个以状态函数为节点的图(经典的TSP问题以具体地点为节点),而两个节点的边权是从这个状态跳到另一个状态需要的代价(用Dop表示)。相比于以实际的地点为节点的图,这样表示显得很抽象,但概念本身不难理解。我们需要思考的是为什么要这么做——之后我们会看到这样做的目的是为了降低前两步的时间复杂度。
状态图中的边权就是连接两个状态函数所付出的代价,可以理解为S的一个子集-通过其对S的补集的Dop-构造出了一条通往S的路。
核心代码如下:
- 通过限制卡车节点的方式重构状态图以减少时间复杂度
接下来才是重头戏,原理很简单,规定一个Dt和Dop中的卡车节点不能超过k个,也就相当于直接舍弃Dt和Dop中点集合大于k+2的子问题(因为起点和终点是必不可少的卡车节点,所以这里是k+2)。当然,这样做并不一定是最优解,而是删除一些不可能是最优解的边,对最后的状态图进行一种近似。近似的目的是为了降低Dt与Dop的时间复杂度,那么为了降低它们的时间复杂度而对状态图进行“剪枝”是否合理呢?
近似算法的合理性:
在最优解中,大部分情况,卡车节点在一个状态图边权的占比都应该尽可能低。尤其是当无人机比卡车快的时候(这也是实际情况),最优解的策略一定是尽可能多分配节点给无人机,从而最大化地提高并行性。因此,在Dt和Dop中,卡车节点过多的子问题就可以舍去,因为它们几乎不可能在最优解中。
(对于其降低时间复杂度的程度方面将在之后一并讨论)
- 利用A*算法实现最终代码解决“卡车-无人机送货”问题
经典的A*算法:
- 将起点装入优先队列,并用一个估计函数估计其到终点的距离。
- 弹出优先队列中最上面的点并将其标记,将这个点周围所有没有标记过的点压入优先队列。(优先级的依据:当前节点已经包含的路径长度加上通过估计函数算出的未来估计值)
- 重复步骤2,直到队列为空或弹出终点。
其中的关键是估计函数的实现。论文中运用最小生成树产生估计函数的值,接下来需要探讨如何构造这个最小生成树。
假设一共有V个点,在此问题中,对任何一个子点集都需要构造一颗最小生成树。我这里选用了经典的prim算法,为每个子集构造一颗最小生成树。
核心代码如下:
接下来根据论文中的伪代码就可以写出具体代码了,需要注意要限制Dop中的卡车节点数。(在大循环中,将V变为k+2)
代码的具体实现如下:
4、代码实现
以下是完整的代码实现:
#include <iostream>
#include<algorithm>
#include<functional>
#include <cstdio>
#include <cstdlib>
#include<queue>
#include<vector>
#define INIT 1000;
using namespace std;
int nf1(int n)//计算一个二进制数中1的个数,用于确定集合中包含的点的个数
{
int count = 0;
while (n)
{
if (n % 2 == 1)
{
count++;
}
n /= 2;
}
return count;
}
typedef struct state {//用于最终的算法状态函数结构体
int node;
int S;
int costs;
int key;
bool operator < (const state& a) const//为优先队列重定位排序项
{
return key > a.key;
}
};
priority_queue<state>stateq;//初始化小顶堆队列
int main()
{/*第一阶段,实现只有卡车节点时的TSP算法,此处已是通过第三阶段限制卡车节点之后的代码*/
int*** Dt = new int** [13];//初始化卡车状态函数
for (int i = 1; i <= 12; i++) {
Dt[i] = new int* [13];
}
for (int i = 1; i <= 12; i++) {
for (int j = 1; j <= 12; j++) {
Dt[i][j] = new int[1 << 12];
}
}
int c[12][12]; //初始化领接矩阵
int V;
cin >> V;
for (int i = 1; i <= V; i++) {
for (int j = 1; j <= V; j++) {
cin >> c[i][j];
}
}
for (int i = 1; i <= V; i++) {
for (int j = 1; j <= V; j++) {
for (int k = 1; k <= ((1 << V) - 1); k++) {
Dt[i][j][k] = INIT;//初始化为正无穷
}
}
}
for (int i = 1; i <= V; i++) {
for (int j = 1; j <= V; j++) {
Dt[i][j][1 << (j - 1)] = c[i][j];//对所有其集合是单节点的状态函数,都初始化其为到起点的距离
}
}
/*此段代码及运用动态规划,将在实验报告中详细描述*/
for (int i = 2; i <= 4; i++) {
for (int j = 1; j <= ((1 << V) - 1); j++) {
if (nf1(j) == i) {
for (int v = 1; v <= V; v++) {
for (int w = 1; w <= V; w++) {
if ((1 << (w - 1)) & j) {
for (int u = 1; u <= V; u++) {
if ((1 << (u - 1)) & j) {
int z = Dt[v][u][j - (1 << (w - 1))] + c[u][w];
if (z < Dt[v][w][j]) {
Dt[v][w][j] = z;
}
}
}
}
}
}
}
}
}
/*第二阶段,包含无人机-卡车节点的TSP算法,此处已是在第三阶段对卡车节点限制之后的代码*/
int*** Dop = new int** [13];//初始化包含无人机节点的状态函数
for (int i = 1; i <= 12; i++) {
Dop[i] = new int* [13];
}
for (int i = 1; i <= 12; i++) {
for (int j = 1; j <= 12; j++) {
Dop[i][j] = new int[1 << 12];
}
}
int cd[12][12];//初始化无人机节点的邻接矩阵
for (int i = 1; i <= V; i++) {
for (int j = 1; j <= V; j++) {
cin >> cd[i][j];
}
}
for (int i = 1; i <= V; i++) {
for (int j = 1; j <= V; j++) {
for (int k = 1; k <= ((1 << V) - 1); k++) {
Dop[i][j][k] = INIT;
}
}
}
for (int i = 1; i <= V; i++) {
for (int j = 1; j <= V; j++) {
Dop[i][j][1 << (j - 1)] = Dt[i][j][1 << (j - 1)];//对所有只包含单节点的状态函数都初始化为其到起点的距离
}
}
/*此段代码及运用动态规划,将在实验报告中详细描述*/
for (int i = 2; i <= 4; i++) {
for (int j = 1; j <= ((1 << V) - 1); j++) {
if (nf1(j) == i) {
for (int v = 1; v <= V; v++) {
for (int w = 1; w <= V; w++) {
if ((1 << (w - 1)) & j) {
for (int u = 1; u <= V; u++) {
int j2 = j - (1 << (v - 1)) - (1 << (w - 1));
if ((1 << (u - 1)) & (j2)) {
int z = max(cd[v][u] + cd[u][w], Dt[v][w][j - (1 << (u - 1))]);
if (z < Dop[v][w][j]) {
Dop[v][w][j] = z;
}
}
}
}
}
}
}
}
}
int* spantree = new int[1 << V];//初始化最小生成树用于之后A*算法的估计函数
for (int i = 1; i <= ((1 << V) - 1); i++) {
spantree[i] = 0;
}
int ex[13];
for (int S = 1; S <= ((1 << V) - 1); S++) {
int nonu = 0;
int fs = 0;
int ld[13];
for (int i = 1; i <= 12; i++) {
ex[i] = 0;
}
for (int i = 1; i <= V; i++) {
if ((1 << (i - 1)) & S) {
fs = i;
ex[i] = 1;
nonu++;
}
for (int k = 1; k <= V; k++) {
if (ex[k]) {
ld[k] = c[fs][k];
}
}
}
ex[fs] = 0;
nonu--;
while (nonu > 0) {
int min = INIT;
int nownode = 0;
for (int j = 1; j <= V; j++) {
if (ex[j]) {
if (ld[j] < min) {
min = ld[j];
nownode = j;
}
}
}
nonu--;
ex[nownode] = 0;
spantree[S] += min;
for (int j = 1; j <= V; j++) {
if (ex[j]) {
if (c[nownode][j] < ld[j]) {
ld[j] = c[nownode][j];
}
}
}
}
}
/*第三阶段,运用限制了卡车节点的Dop函数以及A*算法以及动态规划,同样将在实验报告中详细展开*/
int** P = new int* [1 << 12];
for (int i = 1; i <= ((1 << V) - 1); i++) {
P[i] = new int[12];
}
for (int i = 1; i <= ((1 << V) - 1); i++) {
for (int j = 1; j <= V; j++) {
P[i][j] = 1;
}
}
state fs;
fs.node = 1;
fs.S = 1;
fs.costs = 0;
fs.key = spantree[((1 << V) - 1)];
stateq.push(fs);
while (!stateq.empty()) {
state nstate = stateq.top();
stateq.pop();
P[nstate.S][nstate.node] = 0;
if ((nstate.S == ((1 << V) - 1)) && (nstate.node == 1)) {
cout << nstate.costs << endl;
break;
}
for (int w = 1; w <= V; w++) {
for (int ops = 2; ops <= 4; ops++) {
for (int nS = 1; nS <= ((1 << V) - 1); nS++) {
if ((nf1(nS) == ops) && ((1 << (nstate.node - 1)) & nS)) {
if (P[nS | nstate.S | (1 << (w - 1))][w]) {
state newstate;
newstate.S = nS | nstate.S | (1 << (w - 1));
newstate.costs = nstate.costs + Dop[w][nstate.node][nS];
newstate.key = newstate.costs + spantree[((1 << V) - 1) - newstate.S + (1 << (w - 1))];
newstate.node = w;
stateq.push(newstate);
}
}
}
}
}
}
}
4、实验结果
(论文以及官网的文件均没有给出测试数据集,因此我选择自拟数据集)
由于较难拟出较大的数据集,并且我所设计的代码最多支持12个点的集合,因此我选取了节点较少的数据集进行测试。接下来的几个样例中,对于输入的矩阵数据,第一个矩阵代表以卡车速度为度量的邻接矩阵,第二个矩阵代表以无人机速度为度量的邻接矩阵。其中每个数据集都包含了三个对比样例:只有卡车节点的结果;包含无人机节点的A*算法结果;无人机速度较小时的A*算法近似结果;包含无人机节点的真实值;包含无人机节点且无人机速度较小时的真实值。
数据集1:
只有卡车节点
包含无人机节点的A*算法
无人机速度较小时的A*算法近似结果
包含无人机节点的真实值
包含无人机节点且无人机速度较小时的真实值
数据集2:
只有卡车节点
包含无人机节点的A*算法
无人机速度较小时的A*算法近似结果
包含无人机节点的真实值
包含无人机节点且无人机速度较小时的真实值
数据集3:
只有卡车节点
包含无人机节点的A*算法
无人机速度较小时的A*算法近似结果
包含无人机节点的真实值
包含无人机节点且无人机速度较小时的真实值
因为我是任意取的值,所选的数据集可能不具备代表性,但从实验结果的直观分析,无人机的速度对结果有很大影响;同时,纳入无人机也极大地提升了运送效率,因此我们可以说,无人机送货确实是未来的发展趋势。A*算法在节点数较小的时候近似效果比较好,而当节点数较大时就出现了一定的偏差。下面是论文中出现的一个证明,我用我自己的理解把它写了下来:
命题:一个把卡车节点限制为0的A*算法,最多是最优解两倍的时间。
5、复杂性分析
1、Dtsp(S,w)算法
假设节点数为n,共有2n个不同的集合S,n个不同的w,因此共有n*2n个需要解决的子问题;对于每一个子问题,又需要对其S中的每一个点进行遍历,数量级为n;所以算法的渐进上界为o(n2*2n)。
2、Dt(S,v,w)算法
相比于标准的Dtsp(S,w)算法,此算法相当于多遍历了一个点v,多了n倍,因此算法的渐进上界为o(n3*2n)
3、Dop(S,v,w)算法
此算法需要用到初始化完的Dt(S,v,w)算法,有一个预先的复杂度o(n3*2n),而其本身与Dt(S,v,w)算法的算法相同为o(n3*2n),所以总渐进上界为o(n3*2n)。
4、D(S,w)
粗估计:一共有n*2n个子问题,其中每一个子问题都需要对S进行与原问题类似的一个遍历(详见代码设计流程部分),最多需要n*2n个子问题,那么总渐进上界为o(n2*4n)。
更进一步估计:
此算法的渐进上界为(n2*3n)
5、限制卡车节点后的D(S,w)算法
将卡车节点限制在k之后,每一个算法的时间复杂度都会相应地减小。
Dt(S,v,w)算法:o(ni=1k+1nii2)
Dop(S,v,w)算法:o(ni=1k+1nii2)
D(S,w)算法:任然需要解决n*2^n个子问题,但是每一个子问题需要的操作只有nk+1,因此算法的渐进上界为o(2n*nk+2).
总结:
本实验是对一篇论文中的思想与算法的理解与实现,实现过程为从卡车节点、无人机节点的TSP算法到状态图的A*算法,最后通过限制卡车节点降低时间复杂度。其中算法的前几个过程都很好理解,但当进入状态图之后就变的难以理解起来。这是违背我定式思维的地方,我需要学习的地方。我们把状态图类比于电场与电势,普通图的边权是∆U=Ex,而状态图是∆U=U1-U2,我的理解是,这样做通过(U1-U2)实现最终算法,将Ex“解放出来”,从而可以想办法对其进行剪枝与近似。这一过程的设计中,我们不仅要考虑剪枝的合理性,而且还要考虑状态图本身的算法时间复杂度,从而判断这一过程是否值得。在此次算法中,这一过程是值得的,其在保证剪枝合理性的同时,降低了幂指函数的数量级。