集合分区比差分结果更好
Posted
技术标签:
【中文标题】集合分区比差分结果更好【英文标题】:Better results in set partition than by differencing 【发布时间】:2015-11-28 00:35:04 【问题描述】:Partition problem 被认为是 NP 难的。根据问题的具体实例,我们可以尝试动态编程或一些启发式算法,例如差分(也称为 Karmarkar-Karp 算法)。
后者似乎对于具有大量数字的实例非常有用(这使得动态编程变得难以处理),但并不总是完美的。找到更好的解决方案(随机、禁忌搜索、其他近似)的有效方法是什么?
PS:这个问题背后有一些故事。自 2004 年 7 月以来,SPOJ 有一个挑战 Johnny Goes Shopping。到目前为止,该挑战已被 1087 名用户解决,但其中只有 11 个用户的得分高于正确的 Karmarkar-Karp 算法实现(当前评分,Karmarkar-Karp 给出 11.796614点)。如何做得更好? (最想要的被接受的提交支持的答案,但请不要透露您的代码。)
【问题讨论】:
原谅我的无知 - 但是分数不是取决于输入的选择吗?有些算法可能比其他算法更好地处理某些数据集...... 听起来像是 Computer Science 的工作... 尝试阅读***页面上的下一部分:en.wikipedia.org/wiki/Partition_problem#Other_approaches 我对 SPOJ 挑战问题的经验是,通常以下方面的组合会产生非常好的解决方案: (1) 将精确方法与“快速”近似方法相结合,即通过计算初始解决方案使用近似算法,然后使用 DP/branch&bound 对其进行改进。 (2) 使用随机交换或其他形式的局部搜索来改进初始解决方案。使用所有时间,即通过测量运行时间 (3) 使用不同的算法并使用最佳结果。 不幸的是,无论是否经常,还涉及一些特定于测试用例的优化,因为通常只有很少的真正大的集合,而且这些通常是随机生成的。 【参考方案1】:不管有什么价值,[Korf88] 中“完整的 Karmarkar Karp”(CKK) 搜索过程的简单、未优化的 Python 实现——仅稍作修改以在给定时间限制后退出搜索(例如,4.95秒)并返回迄今为止找到的最佳解决方案 - 足以在 SPOJ 问题上得分 14.204234,超过 Karmarkar-Karp 的得分。 在撰写本文时,我是#3 on the rankings(请参阅下面的编辑#2)
可以在 [Mert99] 中找到关于 Korf 的 CKK 算法的更易读的介绍。
编辑 #2 - 我已经实现了 Evgeny Kluev's 混合启发式应用 Karmarkar-Karp 直到数字列表低于某个阈值,然后切换到确切的 Horowitz-Sahni 子集枚举方法[HS74](可以在 [Korf88] 中找到简明的描述)。正如怀疑的那样,与他的 C++ 实现相比,我的 Python 实现需要降低切换阈值。经过反复试验,我发现阈值 37 是允许我的程序在时间限制内完成的最大值。然而,即使在这个较低的阈值下,我也能够获得 15.265633 的分数,足以达到second place 的水平。
我进一步尝试将这种混合 KK/HS 方法合并到 CKK 树搜索中,主要是通过使用 HS 作为一种非常激进且昂贵的修剪策略。在普通的 CKK 中,我无法找到与 KK/HS 方法相匹配的切换阈值。但是,使用 CKK 和 HS(阈值为 25)的 ILDS(见下文)搜索策略进行剪枝,我能够获得比之前的分数非常小的增益,高达 15.272802。在这种情况下,CKK+ILDS 的性能优于普通 CKK 可能不足为奇,因为根据设计,它可以为 HS 阶段提供更多样化的输入。
编辑 #1 - 我尝试了对基本 CKK 算法的两个进一步改进:
“改进的有限差异搜索”(ILDS) [Korf96] 这是搜索树中自然 DFS 路径排序的替代方法。它倾向于比常规的深度优先搜索更早地探索更多样化的解决方案。
“加速 2-Way Number Partitioning”[Cerq12] 这将 CKK 中的修剪标准之一从叶节点的 4 级内的节点推广到叶节点以上的 5、6 和 7 级内的节点。
在我的测试用例中,与原始 CKK 相比,这两种改进通常在减少探索的节点数量(在后者的情况下)和更快地获得更好的解决方案(在前者的情况下)方面提供了明显的好处.然而,在 SPOJ 问题结构的范围内,这些都不足以提高我的分数。
鉴于这个 SPOJ 问题的特殊性质(即:5 秒的时间限制和只有一个特定且未公开的问题实例),很难就什么可以实际提高分数给出建议* .例如,我们是否应该继续采用替代搜索排序策略(例如:Wheeler Ruml listed here 的许多论文)?或者我们应该尝试将某种形式的局部改进启发式结合到 CKK 找到的解决方案中以帮助修剪?或者也许我们应该完全放弃基于 CKK 的方法并尝试动态编程方法? PTAS怎么样?在不了解 SPOJ 问题中使用的实例的具体形状的情况下,很难猜测哪种方法会产生最大的收益。每个都有其优点和缺点,具体取决于给定实例的特定属性。
*除了简单地更快地运行相同的东西,比如说,用 C++ 而不是 Python 实现。
参考文献
[Cerq12] Cerquides、Jesús 和 Pedro Meseguer。 “加速 2 路号码分区”。 ECAI。 2012, doi:10.3233/978-1-61499-098-7-223
[HS74] 霍洛维茨、埃利斯和 Sartaj Sahni。 “Computing partitions with applications to the knapsack problem.”ACM 杂志 (JACM) 21.2 (1974): 277-292。
[Korf88] Korf, Richard E. (1998), "A complete anytime algorithm for number partitioning", 人工智能 106 (2): 181–203, doi:10.1016/S0004-3702(98)00086-1,
[Korf96] Korf,Richard E. “Improved limited discrepancy search。” AAAI/IAAI,卷。 1. 1996 年。
[Mert99] Mertens, Stephan (1999), 一个完整的任意时间平衡数划分算法,arXiv:cs/9903011
【讨论】:
算法的实现做得很好:)【参考方案2】:有许多论文描述了用于集合划分的各种高级算法。这里只有其中两个:
"A complete anytime algorithm for number partitioning" Richard E. Korf。 "An efficient fully polynomial approximation scheme for the Subset-Sum Problem" Hans Kellerer 等人。老实说,我不知道他们中的哪一个提供了更有效的解决方案。可能这些高级算法都不需要解决 SPOJ 问题。 Korf 的论文还是很有用的。那里描述的算法非常简单(易于理解和实现)。他还概述了几个更简单的算法(在第 2 节中)。因此,如果您想了解 Horowitz-Sahni 或 Schroeppel-Shamir 方法的详细信息(如下所述),您可以在 Korf 的论文中找到它们。此外(在第 8 节中)他写道,随机方法并不能保证足够好的解决方案。因此,您不太可能通过爬山、模拟退火或禁忌搜索等方式获得显着改进。
我尝试了几种简单的算法及其组合来解决大小高达 10000、最大值高达 1014、时间限制为 4 秒的分区问题。他们在随机均匀分布的数字上进行了测试。并且为我尝试的每个问题实例找到了最佳解决方案。有些问题实例的最优性是由算法保证的,有些问题的最优性不是100%保证的,但是得到次优解的概率很小。
对于最大为 4 的尺寸(左侧的绿色区域)Karmarkar-Karp 算法始终给出最佳结果。
对于最大 54 的大小,蛮力算法足够快(红色区域)。可以在 Horowitz-Sahni 或 Schroeppel-Shamir 算法之间进行选择。我使用了 Horowitz-Sahni,因为它对于给定的限制似乎更有效。 Schroeppel-Shamir 使用的内存要少得多(所有内容都适合 L2 缓存),因此当其他 CPU 内核执行一些内存密集型任务或使用多个线程进行设置分区时,它可能更可取。或者在没有严格时间限制的情况下解决更大的问题(Horowitz-Sahni 只是内存不足)。
当大小乘以所有值的总和小于 5*109(蓝色区域)时,适用动态规划方法。图上的蛮力和动态编程区域之间的边界显示了每种算法表现更好的地方。
右边的绿色区域是 Karmarkar-Karp 算法以几乎 100% 的概率给出最优结果的地方。这里有很多完美的分区选项(delta 0 或 1),以至于 Karmarkar-Karp 算法几乎肯定会找到其中之一。可以发明 Karmarkar-Karp 总是给出次优结果的数据集。例如 17 13 10 10 10 ...。如果将其乘以某个大数,KK 和 DP 都无法找到最优解。幸运的是,这样的数据集在实践中是非常不可能的。但是问题制定者可以添加这样的数据集,使比赛更加困难。在这种情况下,您可以选择一些高级算法以获得更好的结果(但仅适用于图表上的灰色和右绿色区域)。
我尝试了两种方法来实现 Karmarkar-Karp 算法的优先级队列:最大堆和排序数组。排序数组选项似乎使用线性搜索稍快,而使用二分搜索则明显更快。
黄色区域是您可以在保证最佳结果(使用 DP)或仅具有高概率的最佳结果(使用 Karmarkar-Karp)之间进行选择的地方。
最后是灰色区域,这里的简单算法本身都不能提供最佳结果。在这里,我们可以使用 Karmarkar-Karp 对数据进行预处理,直到它适用于 Horowitz-Sahni 或动态规划。在这个地方也有很多完美的分区选项,但比绿色区域少,所以 Karmarkar-Karp 本身有时会错过正确的分区。更新:正如@mhum 所指出的,没有必要实现动态编程算法来使事情正常进行。带有 Karmarkar-Karp 预处理的 Horowitz-Sahni 就足够了。但是对于 Horowitz-Sahni 算法来说,在所述时间限制内处理高达 54 的大小以(几乎)保证最佳分区是必不可少的。所以C++或其他具有良好优化编译器和快速计算机的语言是首选。
以下是我将 Karmarkar-Karp 与其他算法相结合的方法:
template<bool Preprocess = false>
i64 kk(const vector<i64>& values, i64 sum, Log& log)
log.name("Karmarkar-Karp");
vector<i64> pq(values.size() * 2);
copy(begin(values), end(values), begin(pq) + values.size());
sort(begin(pq) + values.size(), end(pq));
auto first = end(pq);
auto last = begin(pq) + values.size();
while (first - last > 1)
if (Preprocess && first - last <= kHSLimit)
hs(last, first, sum, log);
return 0;
if (Preprocess && static_cast<double>(first - last) * sum <= kDPLimit)
dp(last, first, sum, log);
return 0;
const auto diff = *(first - 1) - *(first - 2);
sum -= *(first - 2) * 2;
first -= 2;
const auto place = lower_bound(last, first, diff);
--last;
copy(last + 1, place, last);
*(place - 1) = diff;
const auto result = (first - last)? *last: 0;
log(result);
return result;
Link to full C++11 implementation.这个程序只确定分区和之间的差异,它不报告分区本身。 警告:如果您想在可用内存小于 1 Gb 的计算机上运行它,请减小 kHSLimit
常量。
【讨论】:
很棒的帖子!我认为这种逐案区分正是导致在这些挑战问题中取得非常好的成绩的原因。 @EvgenyKluev 你能说出你的方法在 SPOJ 问题实例上的表现吗? @mhum:我不打算添加实际的子集重建或在 SPOJ 上尝试。我最感兴趣的是在随机数据上比较不同算法(及其性能)。至于 SPOJ 问题,如果它不包含任何特定的 anti-Karmarkar-Karp 数据集,则该方法应该获得最高分,否则就更少。 @EvgenyKluev 好的。很公平。如果我有时间,我将尝试在 Python 中实现您的混合 KK/HS 方法(并弄清楚如何恢复实际分区)并看看它是如何实现的。虽然,我怀疑 Python 和 C++ 之间的性能差距可能使我的实现无法充分利用这种方法的强大功能(例如:我可能需要设置一个低得多的 kHSlimit)。 @mhum:好点子!这种方法似乎不需要DP。将kHSlimit
减少到 54 以下已经给出了稍微不完美且可能次优的结果,最大数字接近 10^14。也许 CKK 将能够解决有问题的实例......你怎么看?【参考方案3】:
编辑这是一个从 Karmarkar-Karp 差分开始的实现,然后尝试优化生成的分区。
时间允许的唯一优化是从一个分区向另一个分区提供 1,并在两个分区之间交换 1 换 1。
我在开始时对 Karmarkar-Karp 的实现一定是不准确的,因为仅使用 Karmarkar-Karp 得到的分数是 2.711483 而不是 OP 引用的 11.796614 分。使用优化时,得分为 7.718049。
SPOILER WARNING C# 提交代码如下
using System;
using System.Collections.Generic;
using System.Linq;
public class Test
// some comparer's to lazily avoid using a proper max-heap implementation
public class Index0 : IComparer<long[]>
public int Compare(long[] x, long[] y)
if(x[0] == y[0]) return 0;
return x[0] < y[0] ? -1 : 1;
public static Index0 Inst = new Index0();
public class Index1 : IComparer<long[]>
public int Compare(long[] x, long[] y)
if(x[1] == y[1]) return 0;
return x[1] < y[1] ? -1 : 1;
public static void Main()
// load the data
var start = DateTime.Now;
var list = new List<long[]>();
int size = int.Parse(Console.ReadLine());
for(int i=1; i<=size; i++)
var tuple = new long[] long.Parse(Console.ReadLine()), i ;
list.Add(tuple);
list.Sort((x, y) => if(x[0] == y[0]) return 0; return x[0] < y[0] ? -1 : 1; );
// Karmarkar-Karp differences
List<long[]> diffs = new List<long[]>();
while(list.Count > 1)
// get max
var b = list[list.Count - 1];
list.RemoveAt(list.Count - 1);
// get max
var a = list[list.Count - 1];
list.RemoveAt(list.Count - 1);
// (b - a)
var diff = b[0] - a[0];
var tuple = new long[] diff, -1 ;
diffs.Add(new long[] a[0], b[0], diff, a[1], b[1] );
// insert (b - a) back in
var fnd = list.BinarySearch(tuple, new Index0());
list.Insert(fnd < 0 ? ~fnd : fnd, tuple);
var approx = list[0];
list.Clear();
// setup paritions
var listA = new List<long[]>();
var listB = new List<long[]>();
long sumA = 0;
long sumB = 0;
// Karmarkar-Karp rebuild partitions from differences
bool toggle = false;
for(int i=diffs.Count-1; i>=0; i--)
var inB = listB.BinarySearch(new long[]diffs[i][2], Index0.Inst);
var inA = listA.BinarySearch(new long[]diffs[i][2], Index0.Inst);
if(inB >= 0 && inA >= 0)
toggle = !toggle;
if(toggle == false)
if(inB >= 0)
listB.RemoveAt(inB);
else if(inA >= 0)
listA.RemoveAt(inA);
var tb = new long[]diffs[i][1], diffs[i][4];
var ta = new long[]diffs[i][0], diffs[i][3];
var fb = listB.BinarySearch(tb, Index0.Inst);
var fa = listA.BinarySearch(ta, Index0.Inst);
listB.Insert(fb < 0 ? ~fb : fb, tb);
listA.Insert(fa < 0 ? ~fa : fa, ta);
else
if(inA >= 0)
listA.RemoveAt(inA);
else if(inB >= 0)
listB.RemoveAt(inB);
var tb = new long[]diffs[i][1], diffs[i][4];
var ta = new long[]diffs[i][0], diffs[i][3];
var fb = listA.BinarySearch(tb, Index0.Inst);
var fa = listB.BinarySearch(ta, Index0.Inst);
listA.Insert(fb < 0 ? ~fb : fb, tb);
listB.Insert(fa < 0 ? ~fa : fa, ta);
listA.ForEach(a => sumA += a[0]);
listB.ForEach(b => sumB += b[0]);
// optimize our partitions with give/take 1 or swap 1 for 1
bool change = false;
while(DateTime.Now.Subtract(start).TotalSeconds < 4.8)
change = false;
// give one from A to B
for(int i=0; i<listA.Count; i++)
var a = listA[i];
if(Math.Abs(sumA - sumB) > Math.Abs((sumA - a[0]) - (sumB + a[0])))
var fb = listB.BinarySearch(a, Index0.Inst);
listB.Insert(fb < 0 ? ~fb : fb, a);
listA.RemoveAt(i);
i--;
sumA -= a[0];
sumB += a[0];
change = true;
else break;
// give one from B to A
for(int i=0; i<listB.Count; i++)
var b = listB[i];
if(Math.Abs(sumA - sumB) > Math.Abs((sumA + b[0]) - (sumB - b[0])))
var fa = listA.BinarySearch(b, Index0.Inst);
listA.Insert(fa < 0 ? ~fa : fa, b);
listB.RemoveAt(i);
i--;
sumA += b[0];
sumB -= b[0];
change = true;
else break;
// swap 1 for 1
for(int i=0; i<listA.Count; i++)
var a = listA[i];
for(int j=0; j<listB.Count; j++)
var b = listB[j];
if(Math.Abs(sumA - sumB) > Math.Abs((sumA - a[0] + b[0]) - (sumB -b[0] + a[0])))
listA.RemoveAt(i);
listB.RemoveAt(j);
var fa = listA.BinarySearch(b, Index0.Inst);
var fb = listB.BinarySearch(a, Index0.Inst);
listA.Insert(fa < 0 ? ~fa : fa, b);
listB.Insert(fb < 0 ? ~fb : fb, a);
sumA = sumA - a[0] + b[0];
sumB = sumB - b[0] + a[0];
change = true;
break;
//
if(change == false) break;
/*
// further optimization with 2 for 1 swaps
while(DateTime.Now.Subtract(start).TotalSeconds < 4.8)
change = false;
// trade 2 for 1
for(int i=0; i<listA.Count >> 1; i++)
var a1 = listA[i];
var a2 = listA[listA.Count - 1 - i];
for(int j=0; j<listB.Count; j++)
var b = listB[j];
if(Math.Abs(sumA - sumB) > Math.Abs((sumA - a1[0] - a2[0] + b[0]) - (sumB - b[0] + a1[0] + a2[0])))
listA.RemoveAt(listA.Count - 1 - i);
listA.RemoveAt(i);
listB.RemoveAt(j);
var fa = listA.BinarySearch(b, Index0.Inst);
var fb1 = listB.BinarySearch(a1, Index0.Inst);
var fb2 = listB.BinarySearch(a2, Index0.Inst);
listA.Insert(fa < 0 ? ~fa : fa, b);
listB.Insert(fb1 < 0 ? ~fb1 : fb1, a1);
listB.Insert(fb2 < 0 ? ~fb2 : fb2, a2);
sumA = sumA - a1[0] - a2[0] + b[0];
sumB = sumB - b[0] + a1[0] + a2[0];
change = true;
break;
//
if(DateTime.Now.Subtract(start).TotalSeconds > 4.8) break;
// trade 2 for 1
for(int i=0; i<listB.Count >> 1; i++)
var b1 = listB[i];
var b2 = listB[listB.Count - 1 - i];
for(int j=0; j<listA.Count; j++)
var a = listA[j];
if(Math.Abs(sumA - sumB) > Math.Abs((sumA - a[0] + b1[0] + b2[0]) - (sumB - b1[0] - b2[0] + a[0])))
listB.RemoveAt(listB.Count - 1 - i);
listB.RemoveAt(i);
listA.RemoveAt(j);
var fa1 = listA.BinarySearch(b1, Index0.Inst);
var fa2 = listA.BinarySearch(b2, Index0.Inst);
var fb = listB.BinarySearch(a, Index0.Inst);
listA.Insert(fa1 < 0 ? ~fa1 : fa1, b1);
listA.Insert(fa2 < 0 ? ~fa2 : fa2, b2);
listB.Insert(fb < 0 ? ~fb : fb, a);
sumA = sumA - a[0] + b1[0] + b2[0];
sumB = sumB - b1[0] - b2[0] + a[0];
change = true;
break;
//
if(change == false) break;
*/
// output the correct ordered values
listA.Sort(new Index1());
foreach(var t in listA)
Console.WriteLine(t[1]);
// DEBUG/TESTING
//Console.WriteLine(approx[0]);
//foreach(var t in listA) Console.Write(": " + t[0] + "," + t[1]);
//Console.WriteLine();
//foreach(var t in listB) Console.Write(": " + t[0] + "," + t[1]);
【讨论】:
Karmarkar-Karp 优于贪婪方法,为什么我不会从 KK 的结果开始? @kuszi - 请指出一个 Karmarkar-Karp 的实现,它会产生实际的分区,而不仅仅是它们总和的差异。 wikipedia 上的伪代码只返回一个整数 (en.wikipedia.org/wiki/Partition_problem#Differencing_algorithm) 每个分区中的值是输出 OPs 引用问题的答案所必需的。 @LouisRicci Karmarkar 和 Karp 的原始论文版本可以在here (pdf) 找到。 2.1 节描述了如何恢复分区。***中的介绍不完整。 @mhum - 谢谢,记录每一步的值和差异,然后在阅读第 (2.1) 节之前我没有想到向后工作。当我有机会时,我会用 Karmarkar-Karp 的伪代码作为初始估计来更新我的答案。以上是关于集合分区比差分结果更好的主要内容,如果未能解决你的问题,请参考以下文章