写在前面: 本题非常有意思, 因此我在编程前后和撰写本文前, 在网上查询并阅读了大量关于本题的解题报告. 当中有两种似是而非但提交后却可以 AC 的解法引起了我的兴趣, 一是原作者宣称的 “DFS 解”, 二是原作者宣称的 “扩展的找零问题解”. 我对这两种解法的源码进行了细致的分析, 找到了它们的问题之所在. 以下我会先对这两种错误解法进行分析和讨论, 然后再给出正确的思路.
错误解法 1: 依照价值从大到小的顺序尝试凑数 (不回溯).
因为裁判的測试数据不够全面, 基于这样的思路设计出的程序在提交之后是可以 AC 的. 然而有一个典型的反例: “0 0 3 0 3 1”, 这是一个可分的组合, 但用上述思路则会判定为不可分.
其实, 对于绝大多数编程者来说, 这样的思路在通常应该会被第一时间在头脑中否决. 然而一些编程者在对 DFS 的尝试中逻辑出现了错误, 导致程序实质上实现的正是上述思路. 因为可以 AC, 所以编程者会误觉得自己编写出了正确的 DFS 版程序. 以下提供原文链接, 供有兴趣自行研究的读者參考.
- http://blog.csdn.net/lyy289065406/article/details/6661449
- http://blog.csdn.net/li4951/article/details/7434598
错误解法 2: 循环尝试最大化选取单种价值的石头 (有回溯).
具个详细的样例: 首先尝试以 half 价值为限, 最大化选取价值为 6 的石头 (全选或选择 int( half / 6 ) 块, 设为 n6) 再以 half - n6 价值为限, 最大化选取价值为 5 的石头, 以此类推. 若发现无法凑出 half 价值, 则回溯, 尝试以 half 价值为限, 最大化选取价值为 5 的石头, 以此类推. 编程者觉得这样的方法是 “扩展的找零问题解法”.
其实, 这样的解法相同是错误的, 原因在于这样的解法从根本上讲是基于一个命题, 即 “对于随意一个价值限度, 假设可以凑得出, 则必然存在一种最大化选取某一种价值的石头的凑法”. 此命题的反例非常easy找到: “0 0 0 2 2 2”. 但是基于上述思路编写的程序提交后仍能 AC, 所以重新使编程者误觉得自己的思路是正确的. 以下提供原文链接, 供有兴趣自行研究的读者參考.
解题要点 1: 选择核心算法.
尝试提交过 DFS 版的代码就会发现无法达到题目要求的时限, 因此 DP 才是这道题的唯一 (严谨点说的话是 “非常可能是唯一”) 可行算法. 大致流程例如以下:
- 若石头的总价值为奇数, 则马上判定为不可分.
- 建立长度为 6 × 20000 / 2 + 1 = 60001 的旗标阵列用于标识给定的石头可以组合出的价值, 并设位置 0 处的旗标为 true (一块也不选, 就可以组合出价值为 0 的组合).
- 遍历每块石头, 设当前石头价值为 value (1 ≤ value ≤6), 嵌套逆序遍历旗标阵列, 设当前序号为 i. 若某个序号 i 相应的旗标为 true (用之前的石头可以组合出该价值), 则设定序号 i + value 相应的旗标为 true (价值 i 加上当前石头的价值). 此步骤可以进行多项优化以提升算法效率, 详见兴许的解题要点.
- 若总价值的一半所相应的旗标为 true, 则判为可分; 否则判定为不可分.
解题要点 2: 对每种价值的石头进行二进制组合.
一个广为人知的小原理:
对于任一满足 2k<n<2k+1 的自然数 n, 将其拆分成下列数列之和:
20,21,...,2k,n?2k则 1 ~ n 中的任一自然数都可以用上述数列中某些项之和来表示.
每种价值的石头原本是以单体的形式存在的. 利用上述原理, 就可以使每种价值的石头以 “装箱” 的形式存在, 同一时候保证这些 “石头箱” 所可以生成的价值组合全然等同于原本的石头单体所可以生成的价值组合. 如此一来, 解题要点 1 中算法流程第 2 步的第一重循环的次数便会大幅降低.
解题要点 3: 剪枝.
仅依据前面介绍的两个解题要点来编程, 就已可以达到相当快的执行速度 (请參考 “极简版代码”). 然而实际上还是可以依据题目的特点在前两个解题要点的基础上做进一步的优化. 详细例如以下:
- 对石头进行二进制组合后, 对 “石头箱” 价值阵列进行从小到大的排序, 为剪枝做准备.
- 设总价值的一半为 half, 若价值阵列的最后一项大于 half , 则马上判定为不可分.
- 设第一重循环已进行到价值为 value 的某个 “石头箱”. 在第二重循环之前, 检查序号为 half - value 的旗标 (排序的目的之中的一个), 若为 true, 则马上判定为可分.
- 尽量降低第二重循环的初始值以降低循环次数. 设初始值为 m, 则 m 的初值为 0. 每次第二重循环结束后, 改动初始值为 m = min( m + value, half ). 因为之前已对价值进行了从小到大的排序, 因此 m 的增长速度达到了最慢.
经过上述优化, 提交后程序的执行时间少于 1 ms. 请參考 “优化版代码”.
极简版代码例如以下:
在这一版的代码中, 我希望尽量缩减代码行数, 因此就连解题要点 2 中提到的优化方案都没有全然採用. 详细地说, 算法流程的第 1 步被后移, 算法流程第 3 步中的 “逆序遍历” 无用武之地. 其执行速度虽可以轻松达到裁判的要求, 但还是略慢于优化版的代码. 虽然如此, 我相信这一版的代码一定可以从某种角度启示到读者的思路.
#include <iostream>
#include <bitset>
using namespace std;
int main() {
const int REGROUP[15] = { 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 16384 };
bitset<60001> bits, tmpBits;
int n = 0;
while( ++n ) {
bits.reset();
bits.set( 0 );
int tmp, total = 0;
for( int i = 1; i < 7; ++i ) {
cin >> tmp;
total += tmp * i;
for( int j = 0; j < 15; ++j ) {
if( tmp >= REGROUP[j] ) {
bits |= ( tmpBits = bits << ( i << j ) );
tmp -= REGROUP[j];
} else if( tmp != 0 ) {
bits |= ( tmpBits = bits << ( tmp * i ) );
break;
} else {
break;
}
}
}
if( total == 0 ) {
break;
}
cout << "Collection #" << n << ":" << endl
<< ( ( ( total & 1 ) == 0 ) && bits[total >> 1] ?
"Can" : "Can‘t" )
<< " be divided." << endl << endl;
}
}
优化版代码例如以下:
#include <iostream>
#include <vector>
#include <bitset>
#include <algorithm>
using namespace std;
bool divisionTest( vector<int>& marble, const int& half ) {
static bitset<60001> bits;
if( *( marble.end() - 1 ) > half ) { // 剪枝: 若存在价值超过 half 的石头组则直接判为不可分
return false;
}
bits.reset();
bits.set( 0 );
int tmp, curMax = 0;
for( vector<int>::const_iterator pos = marble.begin(); pos < marble.end(); ++pos ) {
if( bits[half - *pos] ) { // 剪枝: 若已达成条件则直接判为可分
return true;
}
for( int i = curMax; i >= 0; --i ) {
if( bits[i] && ( tmp = i + *pos ) <= half ) {
bits.set( tmp );
}
}
curMax = min<int>( curMax + *pos, half ); // 剪枝: 尽量降低 i 的上限
}
return bits[half];
}
int main() {
const int REGROUP[15] = { 1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048,
4096, 8192, 16384 }; // 题设上限为 20000, 故最大值设为 2 的 14 次方
vector<int> marble;
int n = 0;
while( ++n ) {
marble.clear();
int tmp, total = 0;
for( int i = 1; i < 7; ++i ) { // 1 ~ 6 共 6 种价值
cin >> tmp;
total += tmp * i;
for( int j = 0; j < 15; ++j ) { // 对每种价值的石头进行二进制组合
if( tmp >= REGROUP[j] ) {
marble.push_back( i << j );
tmp -= REGROUP[j];
} else if( tmp != 0 ) {
marble.push_back( tmp * i );
break;
} else {
break;
}
}
}
if( total == 0 ) {
break;
}
sort( marble.begin(), marble.end() ); // 为剪枝做准备
cout << "Collection #" << n << ":" << endl;
if( ( ( total & 1 ) == 0 ) && divisionTest( marble, total >> 1 ) ) {
cout << "Can be divided." << endl << endl;
} else {
cout << "Can‘t be divided." << endl << endl;
}
}
}