(UVA 1354) Mobile Computing(子集枚举+深搜剪枝)

原题连接:UVA 1354

题目大意
(UVA 1354) Mobile Computing(子集枚举+深搜剪枝)_第1张图片
(UVA 1354) Mobile Computing(子集枚举+深搜剪枝)_第2张图片
题目分析
可以把挂坠和横放的木棍都看成结点,则整个天平就是一个二叉树,且每个结点要么是叶子结点要么是有2个孩子的内部结点,例如上图中的3种天平就对应于下图3个二叉树:
(UVA 1354) Mobile Computing(子集枚举+深搜剪枝)_第3张图片
而且不同的天平之间可以重叠。同时,对于一棵确定的二叉树,可以计算出每个天平的确切位置,进而计算出整个天平的宽度。所以,本题的核心是:如何枚举出所有需要的二叉树。下面介绍两种方法:

自底向上枚举

因为二叉树有s个叶子结点,每个内部结点2个孩子,所以一共有s-1个内部结点,即总共2s-1个结点。
所以我们可以:

  • 将初始的s个挂坠看成s个子树
  • 每次选择两个挂坠形成新的子树加入,然后递归s-1次,则一共形成了2s-1个结点
  • 注意每次形成新子树的时候,都需要判断一下宽度是否溢出,若是则剪枝
  • 例如以4个挂坠{1,1,2,3}为例,下面画出解答树的一部分:(每个结点所能形成的所有子树并未完全画出)
  • (UVA 1354) Mobile Computing(子集枚举+深搜剪枝)_第4张图片
#include
#include
#include
#include
#include

using namespace std;
// 自下而上的递归
const int maxs = 6; // 最多的挂坠数
const double EPS = 1E-9; // 最大误差精度
struct node {
	double w; // 重量
	double left, right; // 左右子树的最大宽度
	node() :left(0.0), right(0.0), w(0) {}
}Node[2 * maxs]; // s个叶子结点和s-1个内部结点,一共2s-1个结点
int vis[2 * maxs];
double r;
int s; // 宽度和挂坠数目
double maxr; // 求得的最大宽度

int isRight(node n) {
	// 检验宽度是否合格
	double width = n.left + n.right;
	if (width <= r + EPS) return 1;
	else return 0;
}
void init() {
	maxr = -1;
	memset(vis, 0, sizeof(vis));
	cin >> r >> s;
	for (int i = 0; i < s; i++) {
		cin >> Node[i].w;
		Node[i].left = Node[i].right = 0;
	}
}

void dfs(int index) {
	// 构造第index个结点 0-2s-2
	if (index == 2 * s - 1) {
		// 已经构造了 2s - 1 个结点
		if (Node[index - 1].left + Node[index - 1].right > maxr)
			maxr = Node[index - 1].left + Node[index - 1].right;
		return;
	}
	// 否则从 0 到 index - 1 中找节点
	for (int i = 0; i < index; i++) {
		if (vis[i]) continue;
		vis[i] = 1;
		for (int j = 0; j < index; j++) {
			if (vis[j]) continue;
			vis[j] = 1;
			Node[index].w = Node[i].w + Node[j].w;
			// i 做左子树,j做右子树
			double left = Node[j].w / Node[index].w;
			double right = Node[i].w / Node[index].w;

			Node[index].left = max(left + Node[i].left, Node[j].left - right);
			Node[index].right = max(Node[i].right - left, right + Node[j].right);

			if (isRight(Node[index])) dfs(index + 1);
			vis[j] = 0; // 恢复原状
		}// for
		vis[i] = 0; // 恢复原状
	}// for
}
int main() {
	int t;
	cin >> t;
	while (t--) {
		init();
		if (s == 1)
			printf("%.10lf\n", 0.0); // 特例
		else {
			dfs(s);
			if (maxr == -1) printf("-1\n");
			else printf("%.16lf\n", maxr);
		}
	}
	return 0;
}

自顶向下枚举(记忆化搜索)

上述的枚举方法有一定的优化空间,例如有些树结点被枚举了多次(上图种画黑框的部分)。所以,我们可以使用 自顶向下 的方式构造,每次枚举左右子树需要用到的子集。

  • 之前也总结过类似的 子集生成博客,这里我们使用的是二进制表示法,使用一个二进制数来表示子集的状态,即例如有s个元素,则我们用s个二进制数来表示一个子集,当第k位为1时表示第k个元素包含在子集中;
  • 这样我们同时还能用一个数组vis[1 << s]来表示该子集是否被枚举过了

例如我们有三个元素{1,2,3},则一共有8个子集,用二进制表示为

111
110
101
100
011
010
001
000

有s个元素的子集的二进制生成如下:

for (int i = 0; i < (1 << s); i++) {
	i 为 000111
}
for (int i = (1 << s) - 1; i >= 0; i--) {
	i 为 111000
}

下面我们讨论如何枚举当前子集的所可能生成的二叉树的所有情况

  • 显然,二叉树的终止状态应该是该子集只包含一个元素,也就是该二进制数只包含一个1
  • 假设该二进制数包含不止一个1,则我们可以继续拆分该子集
    例如如果一个子集表示为1101,即其包含了{1,3,4}三个位置的元素,其可以继续拆分,例如左子树含有第{1,3}个元素,即0101,则右子树就含有第4个元素,即1000,而且这样的操作可以用异或来表示,即1101 = 0101 ^ 1000。则拆分1101有以下这几种情况:(标红表示是叶子结点)
    (UVA 1354) Mobile Computing(子集枚举+深搜剪枝)_第5张图片
  • 而且我们还注意到,一个子集的子集的二进制数肯定比它本身的二进制数要小,因为包含的1的个数要小,则划分子树的操作可以这样进行:
void split(int subset) {
	// 划分子集
	for (int left = (subset - 1) & subset; left; left = (left - 1)&subset) {
		// & 表示清除掉原来集合中不为1的位置
		// 例如 1101 集合下 1001 和 1011 实际上是一样的(因为第2个位置不可能是1),
		// 即 & 的结果应该一样
		int right = left ^ subset; // 异或,表示右子树的子集
	}
}
  • 同时对于每个结点(表示一个子集的状态),我们需要存储其能枚举的所有二叉树,用一个数组。
/* Mobile Computing */
// 困难的天平 二进制子集筛选
// 自上而下构造
#include
#include
#include
#include
#include
#include

using namespace std;
const int maxr = 6;

struct node{
	double l,r; // 左右子树的最远距离
	node():l(0),r(0) {}
};
int vis[1 << maxr]; // 用来表示当前子集是否枚举过了
double sum[1 << maxr]; // 用来计算子集包含挂坠的重量
double w[maxr]; // 存储初始挂坠的重量
vector<node> Tree[1 << maxr]; // 来存储该子集可能的二叉树
double r; int s;

void init() {
	cin >> r >> s;
	for (int i = 0; i < s; i++) cin >> w[i];
	// 计算子集的重量,当然也可以在dfs的时候计算
	for (int i = 0; i < (1 << s); i++) {
		vis[i] = 0;
		sum[i] = 0;
		Tree[i].clear();
		for (int j = 0; j < s; j++) {
			if (i & (1 << j)) sum[i] += w[j]; // 该子集包含j这个元素
		}
	}
}

void dfs(int subset) {
	// 遍历subset这个子集,枚举其可能的二叉树
	if (vis[subset]) return;
	vis[subset] = 1;
	bool hasechild = false; // 是否是叶子结点
	for (int left = (subset - 1) & subset; left; left = (left - 1)&subset) {
		hasechild = true;
		int right = left ^ subset; // 异或,表示右子树的子集

		double d1 = sum[right] / sum[subset]; // 左子树长度
		double d2 = sum[left] / sum[subset]; // 右子树长度

		dfs(left); dfs(right);
		// 从叶子结点回溯返回
		for (int i = 0; i < Tree[left].size(); i++) {
			for (int j = 0; j < Tree[right].size(); j++) {
				// 遍历所有左右子树的孩子情况,可以重叠
				node t;
				t.l = max(Tree[left][i].l + d1, Tree[right][j].l - d2);
				t.r  = max(Tree[right][j].r + d2, Tree[left][i].r - d1);
				if (t.l + t.r <= r) Tree[subset].push_back(t);
			}
		}
	}// for
	// 如果是叶子结点,则只包含一个1,没有孩子
	if (!hasechild) 
		Tree[subset].push_back(node()); 
}
int main() {
	int t; cin >> t;
	while (t--) {
		init();
		if (s == 1) 
			printf("%.16lf\n", 0.0);
		else {
			int root = (1 << s) - 1; //根结点
			dfs(root);
			double ans = -1;
			for (int i = 0; i < Tree[root].size(); i++) {
				if (Tree[root][i].l + Tree[root][i].r > ans)
					ans = Tree[root][i].l + Tree[root][i].r;
			}// for
			printf("%.16lf\n", ans);
		}
	}
	return 0;
}

摘自《算法竞赛入门经典 第二版 》 第 7.4 节

你可能感兴趣的:(#,回溯算法,#,搜索,#,UVA程序设计)