560 likes | 670 Views
Combinatorial Search. 由于现代计算机的超高速度,搜索法已经成为了一个解决问题的有效途径。搜索被称为 “ 通用解题法 ” ,在算法和人工智能中占有重要地位。 现代的个人计算机时钟频率为 GHz 级,即每秒运算 10 亿条指令。由于大多数有用的操作需要数百条指令(甚至更多),所以每秒能搜索几百万个元素就不错了。 一百万种排列约是 10 个元素的所有排列总数。一百万种子集约是 20 个元素的所有组合。 如果需要求解更大规模问题,我们需要在搜索中剪枝,以确保只会搜索到有必要搜索的元素。. Contents. 基础知识 广度优先搜索 深度优先搜索(回溯法)
E N D
由于现代计算机的超高速度,搜索法已经成为了一个解决问题的有效途径。搜索被称为“通用解题法”,在算法和人工智能中占有重要地位。由于现代计算机的超高速度,搜索法已经成为了一个解决问题的有效途径。搜索被称为“通用解题法”,在算法和人工智能中占有重要地位。 • 现代的个人计算机时钟频率为GHz级,即每秒运算10亿条指令。由于大多数有用的操作需要数百条指令(甚至更多),所以每秒能搜索几百万个元素就不错了。 • 一百万种排列约是10个元素的所有排列总数。一百万种子集约是20个元素的所有组合。 • 如果需要求解更大规模问题,我们需要在搜索中剪枝,以确保只会搜索到有必要搜索的元素。
Contents • 基础知识 • 广度优先搜索 • 深度优先搜索(回溯法) • 例题 • 构造所有子集 • 构造所有排列 • 八皇后问题 • 马的周游 • 魔板
问题的状态空间 • 状态是对问题在某一时刻的进展情况的数学描述。 • 搜索的过程实际上是遍历一个隐式图,图的顶点对应着状态,有向边对应于状态转移,这个图称为状态空间。 • 一个可行解就是一条从起始状态对应顶到出发到目标状态集中任意一个顶点的路径。
问题的状态空间 • 一个问题的解就是目标状态。 • 解向量:一个问题的解往往能够表示成一个n元组(x1,x2,…,xn)的形式。 • 显约束:对分量xi的取值限定。 • 隐约束:为满足问题的解而对不同分量之间施加的约束。 • 解空间:对于问题的一个实例,解向量满足显式约束条件的所有多元组,构成了该实例的一个解空间。 注意:同一个问题可以有多种表示,有些表示方法更简单,所需表示的状态空间更小(存储量少,搜索方法简单)。
0-1背包问题 给定n种物品和一背包。物品i的重量为wi,其价值为vi,背包的容量为c。问应如何选择装入背包中的物品,使得装入背包中的物品的总价值最大? 实例: n=3, w[ ]={16, 15, 15}, p[ ]={45, 25, 25}, c=30。任一状态可表示为(x1,x2,x3). xi为1或者0,表示是否选取物品i。初始状态(0,0,0)。
旅行商问题 某商人要到若干城市去推销商品,已知各城市之间的路程。他要选一条从驻地出发,经过每个城市一次,最后回到驻地的路线,使总的路程最小。 图论建模:设G=(V,E)是一个带权图。图中各边的费用(权)为正数。图中的一条周游路线是包括V中的每个顶点在内的一条回路。一条周游路线的费用是这条路线上所有边的费用之和。如何在图G中找出一条有最小费用的周游路线?
子集树与排列树 遍历子集树需O(2n)计算时间 遍历排列树需要O(n!)计算时间
生成问题状态的基本方法 • 扩展结点:一个正在产生儿子的结点称为扩展结点 • 活结点:一个自身已生成但其儿子还没有全部生成的节点称做活结点 • 死结点:一个所有儿子已经产生的结点称做死结点 • 宽度优先的问题状态生成法:在一个扩展结点变成死结点之前,它一直是扩展结点。 • 深度优先的问题状态生成法:如果对一个扩展结点R,一旦产生了它的一个儿子C,就把C当做新的扩展结点。在完成对子树C(以C为根的子树)的穷尽搜索之后,将R重新变成扩展结点,继续生成R的下一个儿子(如果存在)。
回溯法 • 有许多问题,当需要找出它的解集或者要求回答什么解是满足某些约束条件的最佳解时,往往要使用回溯法。 • 回溯法在问题的解空间树中,按深度优先策略,从根结点出发搜索解空间树。算法搜索至解空间树的任意一点时,先判断该结点是否包含问题的解。如果肯定不包含,则跳过对该结点为根的子树的搜索,逐层向其祖先结点回溯;否则,进入该子树,继续按深度优先策略搜索。
回溯法的基本思想 (1)针对所给问题,定义问题的解空间; (2)确定易于搜索的解空间结构; (3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。 用回溯法解题的一个显著特征是在搜索过程中动态产生问题的解空间。在任何时刻,算法只保存从根结点到当前扩展结点的路径。如果解空间树中从根结点到叶结点的最长路径的长度为h,则回溯法所需的计算空间通常为O(h)。而显式地存储整个解空间则需要O(2h)或O(h!)内存空间。
生成1~n的排列 要求:按照字典顺序输出1~n的排列。 Void print_permutation(序列A, 集合S) { if (S为空) 输出序列A; else 按照从小到大的顺序依次考虑S中的每个元素v { print_permutation(在A的末尾添加v后得到的新序列, S – {v}); } }
生成1~n的排列 void print_permutation(int n, int* A, int cur) { inti, j; if(cur == n) { // 递归边界 for(i = 0; i < n; i++) printf("%d ", A[i]); printf("\n"); } else for(i = 1; i <= n; i++) { // 尝试在A[cur]中填各种整数i int ok = 1; for(j = 0; j < cur; j++) if(A[j] == i) ok = 0; // 如果i已经在A[0]~A[cur-1]出现过,则不能再选 if(ok) { A[cur] = i; print_permutation(n, A, cur+1); // 递归调用 } } } 调用方式print_permutation(n, A, 0);
生成可重集的排列 // 输出数组P中元素的全排列。数组P中可能有重复元素,P已排序 void print_permutation(int n, int* P, int* A, int cur) { inti, j; if(cur == n) { for(i = 0; i < n; i++) printf("%d ", A[i]); printf("\n"); } else for(i = 0; i < n; i++) if(!i || P[i] != P[i-1]) { intc1 = 0, c2 = 0; for(j = 0; j < cur; j++) if(A[j] == P[i]) c1++; for(j = 0; j < n; j++) if(P[i] == P[j]) c2++; if(c1 < c2) { A[cur] = P[i]; print_permutation(n, P, A, cur+1); } } }
下一个排列 //从字典序最小排列开始,不停“求下一个排列”。 //适用于可重集。 #include<cstdio> #include<algorithm> using namespace std; int main() { int n, p[10]; scanf("%d", &n); for(inti = 0; i < n; i++) scanf("%d", &p[i]); sort(p, p+n); // 排序,得到p的最小排列 do {// 输出排列p for(inti = 0; i < n; i++) printf("%d ", p[i]); printf("\n"); } while(next_permutation(p, p+n)); // 求下一个排列 return 0; }
子集生成 • There are 2n subsets of an n-element set, say the integers {1,2,…,n}. • We set up an vector A[a1,a2,…,an], where the value ai (true or false) signifies whether the ith item is in the given subset.
子集生成-增量构造法 void print_subset(int n, int* A, int cur) { for(inti = 0; i < cur; i++) printf("%d ", A[i]); // 打印当前集合 printf("\n"); // 确定当前元素的最小可能值 ints = cur ? A[cur-1]+1 : 0; for(inti = s; i < n; i++) { A[cur] = i; print_subset(n, A, cur+1); // 递归构造子集 } } int main() { intA[10]; print_subset(5, A, 0); }
子集生成-位向量法 位向量B[i]=1当且仅当i在子集A中。 void print_subset(int n, int* B, int cur) { if(cur == n) { for(inti = 0; i < cur; i++) if(B[i]) printf("%d ", i); // 打印当前集合 printf("\n"); return; } B[cur] = 1; // 选第cur个元素 print_subset(n, B, cur+1); B[cur] = 0; // 不选第cur个元素 print_subset(n, B, cur+1); } 调用:intB[10]; print_subset(5, B, 0);
子集生成-二进制法 • 可以用二进制来表示{0,1,2,…,n-1}的子集S: 从右到左的第i位(从0开始编号)表示元素i是否出现在集合S中。 位运算与集合运算 A&B:集合交; A|B:集合并; A^B:集合对称差 空集为0,全集{0,1,2,…,n-1}为2n-1,即(1<<n)-1 集合A的补集为A ^ ((1<<n)-1)
子集生成-二进制法 //打印{0,1,2,…,n-1}的子集s void print_subset(int n, int s) { for(inti=0;i<n;i++) if (s&(1<<i)) printf(“%d ”,i); printf(“\n”); } //枚举子集 • //枚举各子集所对应的编码0,1,2,…,2n-1 • for(inti=0; i< (1<<n); i++) • print_subset(n,i);
回溯法 • 在递归构造过程中,生成和检查过程可以有机结合起来,从而减少不必要的枚举。
1 Q 2 Q 3 Q 4 Q 5 Q Q 6 7 Q 8 Q 1 2 3 4 5 6 7 8 N Queens Problem 在n×n格的棋盘上放置彼此不受攻击的n个皇后。按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。n后问题等价于在n×n格的棋盘上放置n个皇后,任何2个皇后不放在同一行或同一列或同一斜线上。
N Queens Problem • 应该如何合理地表示N皇后问题的解?这个解有多大? • 方法1:转化为子集枚举问题。 • 从64个格子中选一个子集,使得子集中恰好有8个格子,且任意两个选出的格子都不在同一行、同一列或同一个对角线上。 • 这种表示法的代价太高。对于8×8的棋盘,有264=1.84×1019个子集。
N Queens Problem • 应该如何合理地表示N皇后问题的解?这个解有多大? • 方法2:用解向量的第i个元素显式的给第i个皇后的格子编号。在这种表示法下ai是一个1到n2之间的整数,当且仅当所有n个位置被赋值时a对应一个合法解。第i个位置的候选集为前i-1个皇后没有攻击到的所有格子。 • 方法2分析:对于8×8的棋盘,约有648=2.81×1014个可能的解向量。这离通常所能承受的最大搜索空间大小(百万级)仍然相距甚远。
N Queens Problem • 应该如何合理地表示N皇后问题的解?这个解有多大? • 方法3:N皇后问题的合法解中,每行恰好有一个皇后,这样第i个皇后的位置只能在第i行的8个格子中,搜索空间大小缩小到88=1.677×107。 • 方法4:进一步,由于任意两个皇后所在的列也应互不相同,合法解中各个皇后所在的列一定是这N列的一个排列。这样,搜索空间大小只剩8!=40320。实现时用C[x]表示第x行皇后的列编号。
N Queens Problem intC[50], tot = 0, n = 8, nc = 0; void search(int cur) { inti, j; nc++; if(cur == n) //递归边界 { tot++; } else for(i = 0; i < n; i++) { int ok = 1; C[cur] = i;//尝试把第cur行皇后放在第i列 for(j = 0; j < cur; j++)//检查是否和前面的皇后冲突 if(C[cur] == C[j] || cur-C[cur] == j-C[j] || cur+C[cur] == j+C[j]) { ok = 0; break; } if(ok) search(cur+1); } } 调用: search(0); printf("%d\n", tot); printf("%d\n", nc);
N Queens Problem • 我们的实现可以计算多大规模的N皇后问题? • 如果不是需要求出N皇后问题的所有解,只需要找到一个可行解,有没有其他高效算法?
Search Pruning • Pruning is the technique of cutting off the search the instant we have established that a partial solution cannot be extended into a full solution. • For traveling salesman, we seek the cheapest tour that visit all vertices. Suppose that in the course of our search we find a tour t whose cost is Ct. Later, we may have a partial solution a whose edge sum CA>Ct. Is there any reason to continue exploring this node? No!
题目大意: 一个有限大小的棋盘上有一只马,马只能按日字方式走,如图所示。 给出初始时马的位置,找出一条马移动的路线,经过所有格子各一次。 1152 1153 马周游
1152 1153 马周游 • 解题思路: • 枚举马能走的所有路径,直至找到一条完成周游的路径; • 递归,回溯。
1152 1153 马周游 bool solve(int x, int y, intlev) { route[lev] = x * N + y; if (lev == M * N - 1) {print_route();return true;} visited[x][y] = true; grid grids[8]; intn = get_grid(grids,x,y); for (i=0; i<n; i++) if (solve(grids[i].x, grids[i].y, lev+1)) return true; visited[x][y] = false; return false; }
1152 1153 马周游 intget_grid(grid grids[], intx,int y) { intn=0; for (inti=0; i<8; i++) { intxx = x + direction[i][0]; intyy = y + direction[i][1]; if (xx>=0&&yy>=0&&xx<M&&yy<N&&!visited[xx][yy]) { grids[n].x = xx; grids[n].y = yy; n++; } } return n; }
1152 1153 马周游 • 以上程序速度过慢。 • 优化:改变搜索顺序。 • 优先搜索可行格较少的格子。 • 其他顺序。 • 修改get_grid()函数。
1152 1153 马周游 intget_grid(grid grids[], intx,int y) { int n=0; for (inti=0; i<8; i++) { intxx = x + direction[i][0] intyy = y + direction[i][1]; if (xx>=0&&yy>=0&&xx<M&&yy<N&&!visited[xx][yy]) { grids[n].x = xx; grids[n].y = yy; grids[n].count = get_count(xx, yy); n++; } } sort(grids,grids+n); return n; }
1152 1153 马周游 bool operator < (const grid &a, const grid &b) { return a.count < b.count; } intget_count(int x, int y) { inti, xx, yy, count = 0; for (i=0; i<8; i++) { xx = x + direction[i][0]; yy= y + direction[i][1]; if (xx>=0&&yy>=0&&xx<M&&yy<N&&!visited[xx][yy]) count++; } return count; }
1152 简单的马周游问题 不推荐! const • 隐藏算法: • 5*6规模比较小 • 仅30种输入 • 每种的输出仅30个整数 • 完全可以使用const大法 • 先在本机跑出所有结果,然后O(1)输出
1050 Numbers & Letters • 题目大意 • 给5个整数,使用+, -, *, / 四种运算,可任意安排顺序和加括号,求一个不超过某给定值的最优解 • 解题思路 • Dfs(S) //S为操作数的集合 • 从S中任取两个数a, b进行运算得c • dfs(S-{a,b}+{c}) • 复杂度:C(5,2)*4*C(4,2)*4*C(3,2)*4*C(2,2)*4 = 46080
1050 Numbers & Letters void dfs(int a[], int n) { if (n==1) return; intb[5],m=0; for(inti=0;i<n;i++) for(intj=i+i;j<n;j++) { for(intk=0;k<n;k++) if (k!=i && k!=j) b[m++]=a[k]; update_answer(b[m]=a[i]+a[j]); dfs(b,m+1); update_answer(b[m]=a[i]-a[j]); dfs(b,m+1); update_answer(b[m]=a[j]-a[i]); dfs(b,m+1); update_answer(b[m]=a[i]*a[j]); dfs(b,m+1); if (a[j]!=0 && a[i]%a[j]==0) {update_answer(b[m]=a[i]/a[j]); dfs(b,m+1); } if (a[i]!=0 && a[j]%a[i]==0) {update_answer(b[m]=a[j]/a[i]); dfs(b,m+1); } } }
1006 Team Rankings • 题目大意: • 对于两个排列p, q,定义 distance( p, q )为在p, q中出现的相对次序不同的元素的对数。相当于以p为基准,求q的逆序数。 • 给出n个5元排列,构造一个排列,使得该排列对n个排列的distance之和最小。 • n<=100
1006 Team Rankings • 解题思路: • 枚举所有5元排列,与n个排列一一比较5个元素之间顺序并累加; • 枚举方法可用递归。 • 求逆序数的算法 • 平方级枚举 n^2 • 规模较大时可采用归并排序 nlogn
1006 Team Rankings void dfs(char rank[],intlev) { if (lev==5) { rank[5]='\0'; cal(rank); return; } for (char c='A';c<='E';c++) if (!used[c]) { rank[lev]=c; used[c]=true; dfs(rank,lev+1); used[c]=false; } }
1006 Team Rankings void cal(char rank[]) { int count=0; for (inti=0;i<n;i++) count+=distance(ranks[i],rank); if (count<ans_count) { ans_count=count; strcpy(ans,rank); } }
1006 Team Rankings int distance(char a[],char b[]) { intcnt=0; for (inti=0;i<5;i++) { posa[a[i]]=i; posb[b[i]]=i; } for (char c1='A';c1<='E';c1++) for (char c2=c1+1;c2<='E';c2++) if ((posa[c1]-posa[c2])*(posb[c1]-posb[c2])<0) cnt++; return cnt; }
Tips • What is求逆序数的快速算法? • 归并排序的原理 • What is nlogn? • 通常出现的算法复杂度级别 • O(mn),O(n!),O(nm),O(n),O(logn),O(1) • 当n>10000时,至少要O(nlogn)
115011511515魔板 • 题目大意 • 魔板是2行4列的方格,八格分别标为1-8 • 初始状态为1 2 3 4 8 7 6 5 • 有三种操作: • 上下两行互换 1 2 3 4 8 7 6 5
115011511515魔板 • 题目大意 • 魔板是2行4列的方格,八格分别标为1-8 • 初始状态为1 2 3 4 8 7 6 5 • 有三种操作: • 上下两行互换 • 每行循环右移一格 1 2 3 4 8 7 6 5
115011511515魔板 • 题目大意 • 魔板是2行4列的方格,八格分别标为1-8 • 初始状态为1 2 3 4 8 7 6 5 • 有三种操作: • 上下两行互换 • 每行循环右移一格 • 中间四块顺时针转一格 1 2 3 4 8 7 6 5
115011511515魔板 • 题目大意 • 魔板是2行4列的方格,八格分别标为1-8 • 初始状态为1 2 3 4 8 7 6 5 • 有三种操作: • 上下两行互换 • 每行循环右移一格 • 中间四块顺时针转一格 • 给定一个终止状态,求最小操作数及方案 1 4 3 2 1 3 8 4 8 5 2 7 6 7 5 6
115011511515魔板 • 解题思路 • 对模板进行状态搜索 • 由一种状态可以转移到另外三种状态,搜索树为一棵三叉树 • 在这棵三叉树上搜索,目的是求出最优解
115011511515魔板 • 算法一:盲目DFS • 对这棵三叉树进行DFS • 若想求得最优解,需要遍历整棵树 • 需要进行重复扩展 • 优化: • 若已找到一个可行解,可剪去大于等于这个深度的所有子树 效果: 加优化后勉强可过1150 很傻很天真 评价: