装配线(工作站)问题的两种解法
上学的时候有一道题目一直困扰着我,那就是厨师摆盘子问题,问题的描述是这样的:
厨师的所有盘子都放在一个架子上,每天工作结束他都要将盘子按照从小到大的顺序排好,问题是架子不太稳,如果一次拿出一个或几个盘子,架子可能要倒掉,所以他必须只能从一边翻动盘子,由于他只有两只手,所以只能用两只手将拿起的盘子一起翻转。问题是当给出一个杂乱的盘子序列时,如何以最小的翻转次数将其排序。
当时用穷举的方法解决了这个问题,但是看到很多资料都说此类最优解的问题还可以用动态规划法解决,但是我一直没有找到分解最优子问题的方法,所以只好放弃了。前一段时间看《编程之美》,其中“一摞烙饼问题”那一章提到的一摞烙饼问题其实和厨师摆盘子是一回事儿,文中提到了可用动态规划法解决,但是没有给出解法,这又勾起了我的念头,我又翻出了《算法导论》,并动用了Google,百度和雅虎,但是仍然没有找到用动态规划法的解法,倒是看到一些关于这个问题无法用动态规划法解决的讨论。虽然没有找到答案,但是把以前的老代码翻出来了,看到了当时解决“装配线(工作站)问题”的代码,当时用动态规划法和穷举法两种方法解决了这个问题,现在就晒晒代码,先看看“装配线(工作站)问题”的描述:
Colonel汽车公司在有两条装配线的工厂内生产汽车,一个汽车底盘在进入每一条装配线后,在每个装配站会在汽车底盘上安装不同的部件,最后完成的汽车从装配线的末端离开。如图1.所示:

每一条装配线上有n个装配站,编号为j=1,2,...,n,将装配线i(i为1或2)的第j个装配站表示为S(i,j)。装配线1的第j个站S(1,j)和装配线2的第j个站S(2,j)执行相同的功能。然而这些装配站是在不同的时间建造的,并且采用了不同的技术,因此,每个站上完成装配所需要的时间也不相同,即使是在两条装配线相同位置的装配站也是这样。把每个装配站上所需要的装配时间记为a(i,j),并且,底盘进入装配线i需要的时间为e(i),离开装配线i需要的时间是x(i)。正常情况下,底盘从一条装配线的上一个站移到下一个站所花费的时间可以忽略,但是偶尔也会将未完成的底盘从一条装配线的一个站移到另一条装配线的下一站,比如遇到紧急订单的时候。假设将已经通过装配站S(i,j)的底盘从装配线i移走所花费的时间为t(i,j),现在的问题是要确定在装配线1内选择哪些站以及在装配线2内选择哪些站,以使汽车通过工厂的总时间最小,如图2.所示,最快的时间是选择装配线1的1,3和6装配站以及装配线2的2,4和5装配站。

按照《算法导论》书中的讨论,我首先给出了使用动态规划法的算法。动态规划的第一步是描述最优解的结构特征,也就是要先定义什么是最优解。对于装配线问题,通过S(i,j)的最优解就是通过S(i,j)的前一站的最优解加上用最短的时间通过S(i,j)站。对于有两条装配线的Colonel汽车公司来说,通过装配站S(1,j)的最快路线只能是以下二者之一:
1、通过装配站S(1,j-1)的最快路线,然后直接通过装配线S(1,j);
2、通过装配站S(2,j-1)的最快路线,然后从装配线2移到装配线1上,再通过装配线S(1,j);
同理类推,对于装配站S(2,j)的最快路线与之对称相反。由此可以看出,对于S(1,j)站的子问题就是通过S(1,j-1)和S(2,j-1)的最快路线,建立最优解的结构后,就可以执行动态规划的第二步,用子问题的最优解递归定义一个问题的最优解,根据前面的分析,很容易得到关于第j个站的最优解递归公式:

剩下的就是写算法实现问题了,下面就给出动态规划法的算法,首先定义一个数据结构,这个数据结构是为了方便中间数据存储和减少函数参数而定义的,并不是动态规划方法的一部分:
#define MAX_LINE_STATION 20
typedef struct tagFixLinePara
{
int a[2][MAX_LINE_STATION];
int t[2][MAX_LINE_STATION];
int e[2];
int x[2];
int l[2][MAX_LINE_STATION];
int ls;
int fs;
}FixLinePara;
int FindFastStationSequence(FixLinePara *para, int n)
{
int f[2][MAX_LINE_STATION];
if((para == NULL) || (n <= 0))
{
return -1;
}
f[0][0] = para->a[0][0] + para->e[0];
f[1][0] = para->a[1][0] + para->e[1];
for(int j = 1; j < n; j++)
{
if((f[0][j - 1] + para->a[0][j]) <= (f[1][j - 1] + para->t[1][j] + para->a[0][j]))
{
f[0][j] = f[0][j - 1] + para->a[0][j];
para->l[0][j] = 1;
}
else
{
f[0][j] = f[1][j - 1] + para->t[1][j] + para->a[0][j];
para->l[0][j] = 2;
}
if((f[1][j - 1] + para->a[1][j]) <= (f[0][j - 1] + para->t[0][j] + para->a[1][j]))
{
f[1][j] = f[1][j - 1] + para->a[1][j];
para->l[1][j] = 2;
}
else
{
f[1][j] = f[0][j - 1] + para->t[0][j] + para->a[1][j];
para->l[1][j] = 1;
}
}
if(f[0][n - 1] + para->x[0] <= f[1][n - 1] + para->x[1])
{
para->fs = f[0][n - 1] + para->x[0];
para->ls = 1;
}
else
{
para->fs = f[1][n - 1] + para->x[1];
para->ls = 2;
}
return 0;
}
求解完成后para->ls存放最有使用的装配站所属的装配线编号,para->l存放底盘在两条装配线上的转移记录,结合para->ls可以向前递推出底盘的装配顺序。para->fs保存最短时间。下面是打印结果的函数:
void PrintStations(FixLinePara *para, int n)
{
int i = para->ls;
printf("Line %d , Station %d/n", i, n);
for(int j = n - 1; j > 0; j--)
{
i = para->l[i - 1][j];
printf("Line %d , Station %d/n", i, j);
}
}
这个打印结果刚好和实际装配顺序是反的,因为它是通过para->ls向前递推得出的结果。《算法导论》提示可以通过递归的方法得出正序列的结果,但是是作为练习题提出的,没有给出算法,其实也不难,这里就给出一个:
void PrintNextStations(FixLinePara *para, int n, int line)
{
if(n == 0)
{
return;
}
int i = para->l[line - 1][n];
PrintNextStations(para, n - 1, i);
printf("Line %d , Station %d/n", i, n);
}
void PrintStations2(FixLinePara *para, int n)
{
int i = para->ls;
PrintNextStations(para, n - 1, i);
printf("Line %d , Station %d/n", i, n);
}
《算法导论》提到了可以通过穷举(通常使用递归)方法解决这个问题,这里也一并给出使用穷举搜索的方法,在装配站个数n比较小的情况下还是可以可以用的。同样,先定义一个数据结构:
typedef struct tagFixLineEnumeratePara
{
int a[2][MAX_LINE_STATION];
int t[2][MAX_LINE_STATION];
int e[2];
int x[2];
int l[MAX_LINE_STATION];
int fs;
int fl[MAX_LINE_STATION];
int ffs;
}FixLineEnumeratePara;
para->l存放搜索过程中的一个中间结果的装配站序列,para->fs是中间结果的装配时间,para->fl是最终的最优解的装配站序列,para->ffs就是最优解的装配时间。
int FindEnumerateStationSequence(FixLineEnumeratePara *para, int line, int station, int n)
{
if(station >= n) //到头了,整理一次结果
{
para->fs += para->a[line][station - 1];
para->fs += para->x[line];
para->l[station - 1] = line + 1;
if(para->fs < para->ffs)
{
para->ffs = para->fs;
memmove(para->fl, para->l, n * sizeof(int));
}
return 0;
}
int curCost = para->fs + para->a[line][station - 1];
para->l[station - 1] = line + 1;
station++;
para->fs = curCost;
FindEnumerateStationSequence(para, line, station, n);
para->fs = curCost;
int nextline = (line + 1) % 2;
para->fs += para->t[line][station - 1];
FindEnumerateStationSequence(para, nextline, station, n);
return 0;
}
int FindFastStationSequenceEnumerate(FixLineEnumeratePara *para, int n)
{
para->ffs = 0x0FFFFFFF;
para->fs = para->e[0];
FindEnumerateStationSequence(para, 0, 1, n);
para->fs = para->e[1];
FindEnumerateStationSequence(para, 1, 1, n);
return 0;
}
搜索是按照装配站的顺序递归的,所以结果就是正序列,打印结果的函数就很简单了:
void PrintStations3(FixLineEnumeratePara *para, int n)
{
for(int i = 0; i < n; i++)
{
printf("Line %d , Station %d/n", para->fl[i], i + 1);
}
}
以上就是两种方法的解,晒完收工。
厨师的所有盘子都放在一个架子上,每天工作结束他都要将盘子按照从小到大的顺序排好,问题是架子不太稳,如果一次拿出一个或几个盘子,架子可能要倒掉,所以他必须只能从一边翻动盘子,由于他只有两只手,所以只能用两只手将拿起的盘子一起翻转。问题是当给出一个杂乱的盘子序列时,如何以最小的翻转次数将其排序。
当时用穷举的方法解决了这个问题,但是看到很多资料都说此类最优解的问题还可以用动态规划法解决,但是我一直没有找到分解最优子问题的方法,所以只好放弃了。前一段时间看《编程之美》,其中“一摞烙饼问题”那一章提到的一摞烙饼问题其实和厨师摆盘子是一回事儿,文中提到了可用动态规划法解决,但是没有给出解法,这又勾起了我的念头,我又翻出了《算法导论》,并动用了Google,百度和雅虎,但是仍然没有找到用动态规划法的解法,倒是看到一些关于这个问题无法用动态规划法解决的讨论。虽然没有找到答案,但是把以前的老代码翻出来了,看到了当时解决“装配线(工作站)问题”的代码,当时用动态规划法和穷举法两种方法解决了这个问题,现在就晒晒代码,先看看“装配线(工作站)问题”的描述:
Colonel汽车公司在有两条装配线的工厂内生产汽车,一个汽车底盘在进入每一条装配线后,在每个装配站会在汽车底盘上安装不同的部件,最后完成的汽车从装配线的末端离开。如图1.所示:
图1. 装配线示意图
每一条装配线上有n个装配站,编号为j=1,2,...,n,将装配线i(i为1或2)的第j个装配站表示为S(i,j)。装配线1的第j个站S(1,j)和装配线2的第j个站S(2,j)执行相同的功能。然而这些装配站是在不同的时间建造的,并且采用了不同的技术,因此,每个站上完成装配所需要的时间也不相同,即使是在两条装配线相同位置的装配站也是这样。把每个装配站上所需要的装配时间记为a(i,j),并且,底盘进入装配线i需要的时间为e(i),离开装配线i需要的时间是x(i)。正常情况下,底盘从一条装配线的上一个站移到下一个站所花费的时间可以忽略,但是偶尔也会将未完成的底盘从一条装配线的一个站移到另一条装配线的下一站,比如遇到紧急订单的时候。假设将已经通过装配站S(i,j)的底盘从装配线i移走所花费的时间为t(i,j),现在的问题是要确定在装配线1内选择哪些站以及在装配线2内选择哪些站,以使汽车通过工厂的总时间最小,如图2.所示,最快的时间是选择装配线1的1,3和6装配站以及装配线2的2,4和5装配站。
图2. 装配线的一个最快时间路线
按照《算法导论》书中的讨论,我首先给出了使用动态规划法的算法。动态规划的第一步是描述最优解的结构特征,也就是要先定义什么是最优解。对于装配线问题,通过S(i,j)的最优解就是通过S(i,j)的前一站的最优解加上用最短的时间通过S(i,j)站。对于有两条装配线的Colonel汽车公司来说,通过装配站S(1,j)的最快路线只能是以下二者之一:
1、通过装配站S(1,j-1)的最快路线,然后直接通过装配线S(1,j);
2、通过装配站S(2,j-1)的最快路线,然后从装配线2移到装配线1上,再通过装配线S(1,j);
同理类推,对于装配站S(2,j)的最快路线与之对称相反。由此可以看出,对于S(1,j)站的子问题就是通过S(1,j-1)和S(2,j-1)的最快路线,建立最优解的结构后,就可以执行动态规划的第二步,用子问题的最优解递归定义一个问题的最优解,根据前面的分析,很容易得到关于第j个站的最优解递归公式:
剩下的就是写算法实现问题了,下面就给出动态规划法的算法,首先定义一个数据结构,这个数据结构是为了方便中间数据存储和减少函数参数而定义的,并不是动态规划方法的一部分:
#define MAX_LINE_STATION 20
typedef struct tagFixLinePara
{
int a[2][MAX_LINE_STATION];
int t[2][MAX_LINE_STATION];
int e[2];
int x[2];
int l[2][MAX_LINE_STATION];
int ls;
int fs;
}FixLinePara;
int FindFastStationSequence(FixLinePara *para, int n)
{
int f[2][MAX_LINE_STATION];
if((para == NULL) || (n <= 0))
{
return -1;
}
f[0][0] = para->a[0][0] + para->e[0];
f[1][0] = para->a[1][0] + para->e[1];
for(int j = 1; j < n; j++)
{
if((f[0][j - 1] + para->a[0][j]) <= (f[1][j - 1] + para->t[1][j] + para->a[0][j]))
{
f[0][j] = f[0][j - 1] + para->a[0][j];
para->l[0][j] = 1;
}
else
{
f[0][j] = f[1][j - 1] + para->t[1][j] + para->a[0][j];
para->l[0][j] = 2;
}
if((f[1][j - 1] + para->a[1][j]) <= (f[0][j - 1] + para->t[0][j] + para->a[1][j]))
{
f[1][j] = f[1][j - 1] + para->a[1][j];
para->l[1][j] = 2;
}
else
{
f[1][j] = f[0][j - 1] + para->t[0][j] + para->a[1][j];
para->l[1][j] = 1;
}
}
if(f[0][n - 1] + para->x[0] <= f[1][n - 1] + para->x[1])
{
para->fs = f[0][n - 1] + para->x[0];
para->ls = 1;
}
else
{
para->fs = f[1][n - 1] + para->x[1];
para->ls = 2;
}
return 0;
}
求解完成后para->ls存放最有使用的装配站所属的装配线编号,para->l存放底盘在两条装配线上的转移记录,结合para->ls可以向前递推出底盘的装配顺序。para->fs保存最短时间。下面是打印结果的函数:
void PrintStations(FixLinePara *para, int n)
{
int i = para->ls;
printf("Line %d , Station %d/n", i, n);
for(int j = n - 1; j > 0; j--)
{
i = para->l[i - 1][j];
printf("Line %d , Station %d/n", i, j);
}
}
这个打印结果刚好和实际装配顺序是反的,因为它是通过para->ls向前递推得出的结果。《算法导论》提示可以通过递归的方法得出正序列的结果,但是是作为练习题提出的,没有给出算法,其实也不难,这里就给出一个:
void PrintNextStations(FixLinePara *para, int n, int line)
{
if(n == 0)
{
return;
}
int i = para->l[line - 1][n];
PrintNextStations(para, n - 1, i);
printf("Line %d , Station %d/n", i, n);
}
void PrintStations2(FixLinePara *para, int n)
{
int i = para->ls;
PrintNextStations(para, n - 1, i);
printf("Line %d , Station %d/n", i, n);
}
《算法导论》提到了可以通过穷举(通常使用递归)方法解决这个问题,这里也一并给出使用穷举搜索的方法,在装配站个数n比较小的情况下还是可以可以用的。同样,先定义一个数据结构:
typedef struct tagFixLineEnumeratePara
{
int a[2][MAX_LINE_STATION];
int t[2][MAX_LINE_STATION];
int e[2];
int x[2];
int l[MAX_LINE_STATION];
int fs;
int fl[MAX_LINE_STATION];
int ffs;
}FixLineEnumeratePara;
para->l存放搜索过程中的一个中间结果的装配站序列,para->fs是中间结果的装配时间,para->fl是最终的最优解的装配站序列,para->ffs就是最优解的装配时间。
int FindEnumerateStationSequence(FixLineEnumeratePara *para, int line, int station, int n)
{
if(station >= n) //到头了,整理一次结果
{
para->fs += para->a[line][station - 1];
para->fs += para->x[line];
para->l[station - 1] = line + 1;
if(para->fs < para->ffs)
{
para->ffs = para->fs;
memmove(para->fl, para->l, n * sizeof(int));
}
return 0;
}
int curCost = para->fs + para->a[line][station - 1];
para->l[station - 1] = line + 1;
station++;
para->fs = curCost;
FindEnumerateStationSequence(para, line, station, n);
para->fs = curCost;
int nextline = (line + 1) % 2;
para->fs += para->t[line][station - 1];
FindEnumerateStationSequence(para, nextline, station, n);
return 0;
}
int FindFastStationSequenceEnumerate(FixLineEnumeratePara *para, int n)
{
para->ffs = 0x0FFFFFFF;
para->fs = para->e[0];
FindEnumerateStationSequence(para, 0, 1, n);
para->fs = para->e[1];
FindEnumerateStationSequence(para, 1, 1, n);
return 0;
}
搜索是按照装配站的顺序递归的,所以结果就是正序列,打印结果的函数就很简单了:
void PrintStations3(FixLineEnumeratePara *para, int n)
{
for(int i = 0; i < n; i++)
{
printf("Line %d , Station %d/n", para->fl[i], i + 1);
}
}
以上就是两种方法的解,晒完收工。