绿色健康小清新

耐得住寂寞,守得住繁华

回溯法思想与案例

说明


本文我先写在我的CSDN上后再转到该博客系统的,可能有些链接会跳转我的CSDN上。并且在CSDN上将内容分开了!


基本介绍

 当需要找出问题的解集,或者要求回答什么解是满足某些约束条件的最佳解时,往往要使用回溯法。

回溯法的基本做法是搜索,或是一种组织得井井有条的,能避免不必要搜索的穷举式搜索法。

 在问题的解空间树中,回溯法按深度优先策略,从根结点出发搜索解空间树。


基本思想

  • 扩展结点:一个正在产生儿子的结点
  • 活结点:一个自身已生成但其儿子还没有全部生成的节点
  • 死结点:一个所有儿子已经产生的结点
  • 确定了解空间的组织结构后,回溯法从开始节点出发,以深度优先方式搜索整个解空间,这个开始节点成为活节点,同时成为当前的扩展结点
  • 在当前的可扩展结点处,搜索向纵深方向移至一个新的结点,这个新结点成为当前的活结点,并成为扩展结点
  • 如果在当前的可扩展结点处不能向纵深方向移动(结点不包含问题解、没有可扩展的结点、到达叶子结点),则当前扩展结点变成死结点。此时,应回溯至最近的一个活动结点处,并使这个活动结点成为当前的扩展结点。
  • 回溯法以这种工作方式递归地在解空间中搜索,直至找到所要求的解或者解空间中已无活动结点为止

0-1背包举例

这里仅仅是将0-1背包问题的子集树列出来进行展示一下。具体的问题分析请看 👉 0-1背包问题-回溯法

在这里插入图片描述

常用剪枝函数:

 搜索左子树时:用约束函数在扩展结点处剪去不满足约束的左子树;
 搜索右子树时:用限界函数剪去得不到最优解的右子树。

 在上个例子中:

     约束函数cw+wi ≥ C时,剪掉左子树。即:cw+wi ≤ C时,搜索左子树

     限(上)界函数cp+r ≤ Bestv 时,剪掉右子树。即:cp+r > Bestv时,搜索右子树


解题步骤

  • 针对所给问题,定义问题的解空间
  • 确定易于搜索的解空间结构
  • 以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索

子集树与排列树

  • 子集树: 所给的问题是从n个元素的集合中找出满足某种性质的子集时, 相应的解空间称为子集树.

  子集树通常有2n个叶结点, 遍历子集树的任何算法均需Ω(2n)的计算时间.

  例如: 0-1背包问题的解空间为一棵子集树.

  • 排列树: 当所给的问题是确定n个元素满足某种性质的排列时, 相应的解空间称为排列树.

  排列树通常有(n-1)!个叶结点, 遍历排列树需要Ω(n!)的计算时间.

  例如: 旅行售货员问题的解空间为一棵排列树.

在这里插入图片描述








旅行售货员问题

问题描述

 某售货员要到若干城市去推销商品,已知各城市之间的路程,他要选定一条从驻地出发,经过每个城市一遍,最后回到住地的路线,使总的路程最短。
   

  结果为: 1 3 2 4 1



算法描述

 回溯法,序列树, 假设起点为 1。

 算法开始时 x = [1, 2, 3, …, n]

 x[1 : n]有两重含义 x[1 : i]代表前 i 步按顺序走过的城市, x[i + 1 : n]代表还未经过的城市。利用Swap函数进行交换位置。

 若当前搜索的层次i = n 时,处在排列树的叶节点的父节点上,此时算法检查图G是否存在一条从顶点x[n-1] 到顶点x[n] 有一条边,和从顶点x[n] 到顶点x[1] 也有一条边。若这两条边都存在,则发现了一个旅行售货员的回路即:新旅行路线),算法判断这条回路的费用是否优于已经找到的当前最优回路的费用bestcost,若是,则更新当前最优值bestcost和当前最优解bestx。

 若i < n 时,检查x[i - 1]至x[i]之间是否存在一条边, 若存在,则x [1 : i ] 构成了图G的一条路径,若路径x[1: i] 的耗费小于当前最优解的耗费,则算法进入排列树下一层,否则剪掉相应的子树。


递归回溯

  • 回溯法对解空间作深度优先搜索
  • 通常用递归方法实现回溯法

1
2
3
4
5
6
7
8
9
10
void backtrack (int t)
{
if (t>n) output(x);// t>n时已搜索到一个叶结点, output(x)对得到的可行解x进行记录或输出处理.
else{
for (int i=f(n,t);i<=g(n,t);i++) { // 函数f和g分别表示在当前扩展结点处未搜索子树的起止编号.
x[t]=h(i); //h(i)表示在当前扩展结点处x[t]的第i个可选值
if (constraint(t)&&bound(t)) backtrack(t+1);
} //for循环结束后, 已搜索遍当前扩展结点的所有未搜索子树.
}
} //Backtrack(t)执行完毕, 返回t-1层继续执行, 对未测试过的x[t-1]的值继续搜索.
  • if (Constraint(t)&&Bound(t) ) Backtrack(t + 1);if语句含义:Constraint(t)和Bound(t)表示当前扩展节点处的约束函数和限界函数。
  • Constraint(t): 返回值为true时,在当前扩展节点处x[1:t]的取值问题的约束条件,否则不满足问题的约束条件,可剪去相应的子树
  • Bound(t): 返回的值为true时,在当前扩展节点处x[1:t]的取值为时目标函数越界,还需由Backtrack(t+1)对其相应的子树做进一步搜索。否则,当前扩展节点处x[1:t]的取值是目标函数越界,可剪去相应的子树
  • for循环作用:搜索遍当前扩展的所有未搜索过的子树。
  • 递归出口:Backtrack(t)执行完毕,返回t-1层继续执行,对还没有测试过的x[t-1]的值继续搜索。当t=1时,若以测试完x[1]的所有可选值,外层调用就全部结束。

迭代回溯

  采用树的非递归深度优先遍历算法,可将回溯法表示为一个非递归迭代过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void iterativeBacktrack ( )
{
int t=1;
while (t>0) {
if (f(n,t)<=g(n,t))
for (int i=f(n,t);i<=g(n,t);i++) {// 函数f和g分别表示在当前扩展结点处未搜索子树的起止编号.
x[t]=h(i);
if (constraint(t)&&bound(t)) {
if (solution(t)) output(x); //solution(t)判断当前扩展结点处是否已得到问题的一个可行解
else t++;} //solution(t)为假,则仅得到一个部分解,需继续纵深搜索
}
else t--;
} //while循环结束后,完成整个回溯搜索过程
}

子集树与排列树

  • 子集树: 所给的问题是从n个元素的集合中找出满足某种性质的子集时, 相应的解空间称为子集树.

  子集树通常有2n个叶结点, 遍历子集树的任何算法均需Ω(2n)的计算时间.

  例如: 0-1背包问题的解空间为一棵子集树.

  • 排列树: 当所给的问题是确定n个元素满足某种性质的排列时, 相应的解空间称为排列树.

  排列树通常有(n-1)!个叶结点, 遍历排列树需要Ω(n!)的计算时间.

  例如: 旅行售货员问题的解空间为一棵排列树.


代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80

#include <bits/stdc++.h>
using namespace std;
const int max_ = 0x3f3f3f;
const int NoEdge = -1;
int citynum;
int edgenum;
int currentcost;
int bestcost;
int Graph[100][100];
int x[100];
int bestx[100];
void InPut()
{
int pos1, pos2, len;
scanf("%d %d", &citynum, &edgenum);
memset(Graph, NoEdge, sizeof(Graph));
for(int i = 1; i <= edgenum; ++i)
{
scanf("%d %d %d", &pos1, &pos2, &len);
Graph[pos1][pos2] = Graph[pos2][pos1] = len;
}
}
void Initilize()
{
currentcost = 0;
bestcost = max_;
for(int i = 1; i <= citynum; ++i)
{
x[i] = i;
}
}
void Swap(int &a, int &b)
{
int temp;
temp = a;
a = b;
b = temp;
}
void BackTrack(int i) //这里的i代表第i步去的城市而不是代号为i的城市
{
if(i == citynum)
{
if(Graph[x[i - 1]][x[i]] != NoEdge && Graph[x[i]][x[1]] != NoEdge && (currentcost + Graph[x[i - 1]][x[i]] + Graph[x[i]][x[1]] < bestcost || bestcost == max_))
{
bestcost = currentcost + Graph[x[i - 1]][x[i]] + Graph[x[i]][x[1]];
for(int j = 1; j <= citynum; ++j)
bestx[j] = x[j];
}
}
else
{
for(int j = i; j <= citynum; ++j)
{
if(Graph[x[i - 1]][x[j]] != NoEdge && (currentcost + Graph[x[i - 1]][x[j]] < bestcost || bestcost == max_))
{
Swap(x[i], x[j]); //这里i 和 j的位置交换了, 所以下面的是currentcost += Graph[x[i - 1]][x[i]];
currentcost += Graph[x[i - 1]][x[i]];
BackTrack(i + 1);
currentcost -= Graph[x[i - 1]][x[i]];
Swap(x[i], x[j]);
}
}
}
}
void OutPut()
{
cout << "路线为:" << endl;
for(int i = 1; i <= citynum; ++i)
cout << bestx[i] << " ";
cout << "1" << endl;
}
int main()
{
InPut();
Initilize();
BackTrack(2);
OutPut();
}



样例测试

以前面的样例示范

输入

4 6
1 2 30
1 3 6
1 4 4
2 4 10
2 3 5
3 4 20


输出
1 3 2 4 1
在这里插入图片描述










装载问题

问题描述

 有n个集装箱要装上2艘载重量分别为c1和c2的轮船,其中集装箱i的重量为wi,且
          

问题:
是否有一个合理的装载方案,可将这n个集装箱装上这2艘轮船?如果有,找出一种装载方案。

例如:当n=3, c1=c2=50

(1)若w=[10, 40, 40]

   可将集装箱1和集装箱2装上第一艘轮船,而将集装箱3装上第二艘轮船;

(2)如果w=[20, 40, 40]

   则无法将这3个集装箱都装上船;



基本思路

 已证明,如果一个给定装载问题有解,则采用下面的策略可得到最优装载方案。

  1. 首先将第一艘轮船尽可能装满;
  2. 将剩余的集装箱装上第二艘轮船。

  将第一艘轮船尽可能装满等价于选取全体集装箱的一个子集,使该子集中集装箱重量之和最接近c1。由此可知,装载问题等价于以下特殊的0-1背包问题。



算法思路

表示解空间,则解为n元向量{x1, … ,xn }, xi∈{0, 1} 。

约束条件

 当前搜索的层i <= n时,当前扩展结点Z为子集树的内部结点,仅当满足cw+w[i] <= c时进入左子树,x[i]=1; 当cw+w[i] > c ,在以结点Z为根的子树中所有结点都不满足约束条件,因而该子树中解都是不可行解,因而将在该子树删去。

限界函数

 由于是最优化问题, 可利用最优解性质进一步剪去不含最优解的子树:
 设Z是解空间树第i层上的当前扩展结点。
 设     bestw: 当前最优载重量,
        cw : 当前扩展结点Z的载重量 ;
       r : 剩余集装箱的重量;
 在以Z为根的子树中任意叶结点所相应的载重量不超过cw + r。因此,当cw + r (限界函数) ≤ bestw时,可将Z的右子树剪去。即:cw + r > bestw 时搜索右子树,x[i]=0;


例子





演示分析过程


核心代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
template<classType>
void Loading<Type>::Backtrack(int i)
{ / /搜索第i层结点
if (i>n) {//到达叶结点
bestw=cw;
return
}
r - = w[i]; //搜索子树
if (cw+w[i]<=c){ //搜索左子树
x[i]=1
cw += w[i];
Backtrack (i+1);
cw - = w[i];
}
if (cw+r > bestw){ //搜索右子树
x[i]=0
Backtrack(i+1);
}
r+=w[i]

代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
#include <bits/stdc++.h>
using namespace std;
int n; //集装箱数
int cw; // 当前载重量, current weight
int bestw; //最优载重重量
int r; //剩余集装箱重量
int c1; //第一艘轮船的载重量
int c2; //第二艘轮船的载重量
int x[100]; //当前解
int bestx[100]; //当前最优解
int w[100]; //集装箱重量数组
void OutPut()
{
int restweight = 0;
for(int i = 1; i <= n; ++i)
if(bestx[i] == 0)
restweight += w[i];
if(restweight > c2)
cout<<"不能装入"<<endl;
else
{
cout<<"船1装入的货物为:";
for(int i = 1; i <= n; ++i)
if(bestx[i] == 1)
cout<<" "<<i;
cout<<endl<<"船2装入的货物为:";
for(int i = 1; i <= n; ++i)
if(bestx[i] != 1)
cout<<" "<<i;

}
}
void BackTrack(int i)
{
if(i > n)
{
if(cw > bestw)
{
for(int i = 1; i <= n; ++i)
bestx[i] = x[i];
bestw = cw;
}
return;
}
r -= w[i];
if(cw + w[i] <= c1) //约束条件
{
cw += w[i];
x[i] = 1;
BackTrack(i + 1);
x[i] = 0;
cw -= w[i];
}
if(cw + r > bestw) //限界函数
{
x[i] = 0;
BackTrack(i + 1);
}
r += w[i];

}
void Initialize()
{
bestw = 0;
r = 0;
cw = 0;
for(int i = 1; i <= n; ++i)
r += w[i];
}
void InPut()
{
cout<<"请输入箱子个数:";
cin>>n;

cout<<"请输入两艘船的最大载重量:";
cin>>c1>>c2;

cout<<"请输入箱子的重量:";
for(int i = 1; i <= n; ++i)
cin>>w[i];

}
int main()
{
InPut();
Initialize();
BackTrack(1);
OutPut();
}


测试样例

输入
请输入箱子个数:4
请输入两艘船的最大载重量:100 100
请输入箱子的重量:90 10 80 10

输出
船1装入的货物为: 1 2
船2装入的货物为: 3 4


输入
请输入箱子个数:4
请输入两艘船的最大载重量:100 100
请输入箱子的重量:90 20 90 10

输出
不能装入









0-1背包问题

问题描述

  • 给定n种物品和一个背包。物品i的重量是wi,其价值为vi,背包的容量为c。
  • 应如何选择装入背包的物品,使得装入背包中物品的总价值最大?
  • 在选择装入背包的物品时,对每种物品i只有2种选择,即装入背包或不装入背包。不能将物品i装入背包多次,也不能只装入部分的物品i。

算法描述

 0-1背包问题是子集选取问题。一般情况下,0-1背包问题是NP难得。0-1背包问题的解空间可用子集树 表示。在搜索解空间的时,只要其左儿子节点是一个可行节点,搜索就进去其左子树(约束条件)。当右子树中可能包含最优解时才进入右子树搜索(限界函数)。否则就将右子树剪去。

 计算右子树中解的上界的更好方法是将剩余物品依其单位重量价值排序,然后依次装入物品,直至装不下时,再装入物品的一部分而装满背包。由此得到的价值是右子树中解的上界

 为了便于计算上界,可先将物品按照单位重量价值由大到小排序,此后,只要按照顺序考察各个物品即可


 在实现时,由Bound计算当前结点处的上界。在解空间树的当前扩展结点处,仅当要进入右子树时才计算右子树的上界Bound,以判断是否将右子树剪去。

 进入左子树时不需要计算上界,因为其上界与其父节点上界相同。


计算步骤

  1. 计算每种物品单位重量的价值si=pi/wi
  2. 依贪心选择策略,将尽可能多的单位重量价值最高的物品装入背包。
  3. 若将这种物品全部装入背包后,背包内的物品总重量未超过C,则选择单位重量价值次高的物品并尽可能多地装入背包。
  4. 依此策略一直地进行下去,直到背包装满为止。

例子

问题描述
 假设有4个物品,物品的价值分别为p=[9, 10, 7, 4], 重量分别为w=[3, 5, 2, 1], 背包容量C=9,使用回溯方法求解此0-1背包问题,计算其最优值及最优解。要求画出求得最优解的解空间树, 给出具体求解过程。要求中间被剪掉的结点用×标记。

求解步骤
按照单位重量价值由大到小排好序,按照顺序考查是否装入物品

物品单位重量价值:[p1/w1, p2/w2, p3/w3, p4/w4] =(3, 2, 3.5, 4)
按照物品单位重量价值由大到小排序:

物品重量(w)价值(v)价值/重量(v/w)
1144
2273.5
3393
45102

上界计算
   先装入物品1,然后装入物品2 和 3。装入这三个物品后,剩余的背包容量为3,只能装入物品4的3/5(2 * 3/5 = 1)。 即上界为4+7+9+2*(9-6)=26


深度优先搜索解空间树,若左儿子是可行节点,才搜索进入左子树,否则将左子树剪掉;若右子树包含最优解,才搜索右子树,否则将右子树剪掉。

以第一个up=26为例,26=4+7+9+2*(9-6)
打x的部分因为up值已经小于等于Bestp了,所以没必要继续递归了。


样例分析演示


算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
#include <bits/stdc++.h>
using namespace std;
class Object
{
public:
int p;
int w;
int id;
};
int n, c, cp, cw, bestp, total_w, total_p;
int x[100], best_x[100];
Object O[100];
void Input()
{
cout<<"请输入物品个数和背包容量:";
scanf("%d %d", &n, &c);
cout<<"请输入每个物品的价值和重量:"<<endl;
for(int i = 1; i <= n; ++i)
{
scanf("%d %d", &O[i].p, &O[i].w);
O[i].id = i;
}
}
bool cmp(const Object &a, const Object &b)
{
return a.p / a.w > b.p / b.w;
}
void Initilize()
{
cp = cw = total_w = total_p = bestp = 0;
for(int i = 1; i <= n; ++i)
{
total_p += O[i].p;
total_w = O[i].w;
}
//依照物品单位重量价值排序
sort(O + 1, O + n + 1, cmp);
//for(int i = 1; i <= n; ++i)
//cout << O[i].id << endl;
}

void Backtrack(int i)
{
if(i > n)
{
bestp = cp;
for(int i = 1; i <= n; ++i)
best_x[i] = x[i];
return;
}
if(cw + O[i].w <= c)
{
x[O[i].id] = 1;
cw += O[i].w;
cp += O[i].p;
Backtrack(i + 1);
cp -= O[i].p;
cw -= O[i].w;
x[O[i].id] = 0;
}
//向右求解的约束条件
if(Bound(i + 1) > bestp)
{
x[O[i].id] = 0;
Backtrack(i + 1);
}
}

//计算up值
int Bound(int i)
{
int temp_cw = c - cw;
int temp_cp = cp;
while(i <= n && temp_cw >= O[i].w)
{
temp_cw -= O[i].w;
temp_cp += O[i].p;
++i;
}
//装满背包
if(i <= n)
temp_cp += O[i].p / O[i].w * temp_cw;
return temp_cp;


}

void OutPut()
{
printf("bestp = %d\n", bestp);
printf("选取的物品为 : \n");
for(int i = 1; i <= n; ++i)
{
if(best_x[i] == 1)
cout << i << " ";
}
}
int main()
{
Input();
Initilize();
Backtrack(1);
OutPut();
}

测试样例

排序后的样例,其实是一样的


测试用例

输入
请输入物品个数和背包容量:4 9
请输入每个物品的价值和重量:
9 3
10 5
7 2
4 1

输出
bestp = 23
选取的物品为 :
1 2 4

因为输入的数据是没有进行排序的,所以和例子中选取的1,3,4不同。但仅仅是顺序不同,其实是一样的。


将上面的例子排好序在进行测试

输入
请输入物品个数和背包容量:4 9
请输入每个物品的价值和重量:
4 1
7 2
9 3
10 5

输出
bestp = 23
选取的物品为 :
1 3 4










图的m着色问题

问题描述

 给定无向连通图G=(V, E)和m种不同的颜色,用这些颜色为图G的各顶点着色,每个顶点着一种颜色。是否有一种着色法使G中相邻的两个顶点有不同的颜色。这个问题是图的m可着色判定问题。若一个图最少需要m种颜色才能使图中每条边连接的两个顶点着不同颜色,则称这个数m为该图的色数。求一个图的色数m的问题称为图的m可着色优化问题。

例如:点个数n=7,颜色m=3的涂色方案

算法设计

 一般连通图的可着色问题,不仅限于可平面图。

 给定图G=(V,E)和m种颜色,如果该图不是m可着色,给出否定回答;若m可着色,找出所有不同着色方法。


算法思路

设图G=(V, E), |V|=n, 颜色数= m, 用邻接矩阵a表示G, 用整数1, 2…m来表示

m种不同的颜色。顶点i所着的颜色用x[i]表示。

问题的解向量可以表示为n元组x={ x[1],…,x[n] }. x[i]∈{1,2,…,m},

解空间树为排序树,是一棵n+1层的完全m叉树.

在解空间树中做深度优先搜索, 约束条件:如果a[j][i]=1 , x[i] ≠ x[j]

             

m=3,n=3时的解空间树

再举个例子

对于下图,写出图着色算法得出一种着色方案的过程。

顶点数量n=4, 色数:m=3

m=4,n=3时的解空间树

X[1]←1 , 返回 true
X[2]←1, 返回false; X[2]←2, 返回 true
X[3]←1 ,返回false; X[3]←2, 返回false;X[3]←3, 返回 true
X[4]←1, 返回false; X[4]←2, 返回false;X[4]←3, 返回 true

着色方案:(1,2,3,3)



复杂度分析

图m可着色问题的解空间树中,内结点个数是:
                 
对于每一个内结点,在最坏情况下,用ok检查当前扩展结点每一个儿子的颜色可用性需耗时O(mn)。

因此,回溯法总的时间耗费是

算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>
using namespace std;
int m;
int pointnum;
int edgenum;
int sum = 0;
int Graph[100][100];
int x[100];
void InPut()
{
int pos1, pos2;
scanf("%d %d", &pointnum, &m);
scanf("%d", &edgenum);
memset(Graph, 0, sizeof(Graph));
for(int i = 1; i <= edgenum; ++i)
{
scanf("%d %d", &pos1, &pos2);
Graph[pos1][pos2] = Graph[pos2][pos1] = 1;
}
}
bool IsOk(int i)
{
for(int j = 1; j < i; ++j)
if(Graph[i][j] == 1 && x[j] == x[i])
return false;
return true;
}
void BackTrack(int i)
{
if(i > pointnum)
{
sum += 1;
printf("方法 %d : ", sum);
for(int j = 1; j <= pointnum; ++j)
{
printf("%d ", x[j]);
}
printf("\n");
return;
}
else
{
for(int j = 1; j <= m; ++j)
{
x[i] = j;
if(IsOk(i))
BackTrack(i + 1);
x[i] = 0;
}
}
}
void OutPut()
{
printf("一共有 %d 中绘色方案", sum);
}
int main()
{
InPut();
BackTrack(1);
OutPut();
}

测试样例

输入:

5 4
8
1 3
1 2
1 4
2 3
2 4
2 5
3 4

4 5

输出:

方法 1 : 1 2 3 4 1
方法 2 : 1 2 3 4 3
方法 3 : 1 2 4 3 1
方法 4 : 1 2 4 3 4
方法 5 : 1 3 2 4 1
方法 6 : 1 3 2 4 2
方法 7 : 1 3 4 2 1
方法 8 : 1 3 4 2 4
方法 9 : 1 4 2 3 1
方法 10 : 1 4 2 3 2
方法 11 : 1 4 3 2 1
方法 12 : 1 4 3 2 3
方法 13 : 2 1 3 4 2
方法 14 : 2 1 3 4 3
方法 15 : 2 1 4 3 2
方法 16 : 2 1 4 3 4
方法 17 : 2 3 1 4 1
方法 18 : 2 3 1 4 2
方法 19 : 2 3 4 1 2
方法 20 : 2 3 4 1 4
方法 21 : 2 4 1 3 1
方法 22 : 2 4 1 3 2
方法 23 : 2 4 3 1 2
方法 24 : 2 4 3 1 3
方法 25 : 3 1 2 4 2
方法 26 : 3 1 2 4 3
方法 27 : 3 1 4 2 3
方法 28 : 3 1 4 2 4
方法 29 : 3 2 1 4 1
方法 30 : 3 2 1 4 3
方法 31 : 3 2 4 1 3
方法 32 : 3 2 4 1 4
方法 33 : 3 4 1 2 1
方法 34 : 3 4 1 2 3
方法 35 : 3 4 2 1 2
方法 36 : 3 4 2 1 3
方法 37 : 4 1 2 3 2
方法 38 : 4 1 2 3 4
方法 39 : 4 1 3 2 3
方法 40 : 4 1 3 2 4
方法 41 : 4 2 1 3 1
方法 42 : 4 2 1 3 4
方法 43 : 4 2 3 1 3
方法 44 : 4 2 3 1 4
方法 45 : 4 3 1 2 1
方法 46 : 4 3 1 2 4
方法 47 : 4 3 2 1 2
方法 48 : 4 3 2 1 4

一共有 48 中绘色方案

-------------本文结束感谢您的阅读-------------
六经蕴籍胸中久,一剑十年磨在手

欢迎关注我的其它发布渠道