从不均匀分布的集中删除项目

时间:2012-01-05 17:59:59

标签: algorithm combinatorics

我有一个网站,用户提交问题(每天零,一或多个),投票并每天回答一个问题(更多详情here)。用户只能通过提交,投票或回答一次来查看问题。

我有一些玩家已经看过的问题。我需要每个月从池中删除30个问题。我需要选择要移除的问题,以便最大限度地提高游泳池中可用问题的数量,以便为玩家提供最少的问题。

包含5个问题的池的示例(需要删除3个):

  • 玩家A遇到过问题1,3和5
  • 玩家B见过问题1和4
  • 玩家C见过问题2和4

我虽然要删除顶级玩家所看到的问题,但这个位置会发生变化。按照上面的例子,玩家A只剩下2个问题要玩(2和4)。但是,如果我删除1,3和5,情况将是:

  • 玩家A可以玩问题2和4
  • 玩家B可以玩问题2
  • 玩家C不能玩任何东西,因为1,3,5被移除,他已经看过2和4。

此解决方案的得分为零,即可用问题数量最少的玩家没有可用的问题。

在这种情况下,最好删除1,3和4,给出:

  • 玩家A可以玩问题2
  • 玩家B可以玩问题2和5
  • 玩家C可以玩问题5

此解决方案的得分为1,因为可用问题最少的两位玩家可以提出一个问题。

如果数据量很小,我就可以强制解决问题。但是,我有数百个玩家和问题,所以我正在寻找一些算法来解决这个问题。

10 个答案:

答案 0 :(得分:4)

让我们假设你有一个通用的高效算法。专注于留下的问题,而不是删除的问题。

您可以使用这样的算法来解决问题 - 您最多可以选择T问题,以便每个用户至少有一个问题要回答吗?我认为这是http://en.wikipedia.org/wiki/Set_cover,我认为解决你的问题通常可以解决集合覆盖,所以我认为它是NP完全的。

至少有线性编程放松。将每个问题与0 <= Qi <= 1的范围内的变量Qi相关联。选择问题Qi使得每个用户具有至少X个可用问题等于约束SUM Uij Qj> = X,其在Qj中是线性的并且X,因此您可以使用线性变量X和Qj最大化目标函数X.不幸的是,结果不需要给你整数Qj - 考虑例如当所有可能的问题对与某个用户相关联并且你希望每个用户能够回答至少1个问题时,最多使用一半问题。对于所有i,最佳解是Qi = 1/2。

(但是考虑到线性编程放松,你可以将它用作http://en.wikipedia.org/wiki/Branch_and_bound中的界限。)

或者你可以写下这个问题并把它扔到一个整数线性编程包中,如果你有一个方便的话。

答案 1 :(得分:2)

为了完整的线程,这是一个简单的贪婪,近似的方法。

将解决的问题放在前面讨论的矩阵形式中:

Q0    X
Q1  XX
Q2    X
Q3  X  
Q4   XX
    223

按解决的问题数量排序:

Q0  X  
Q1   XX
Q2  X  
Q3    X
Q4  XX 
    322

在解决了大多数问题的玩家中找出问题最多X的问题。 (如果有的话,这可以保证减少我们的措施):

=======
Q1   XX
Q2  X  
Q3    X
Q4  XX 
    222

再次排序:

=======
Q1   XX
Q2  X  
Q3    X
Q4  XX 
    222

再次罢工:

=======
=======
Q2  X  
Q3    X
Q4  XX 
    211

再次排序:

=======
=======
Q2  X  
Q3    X
Q4  XX 
    211

再次罢工:

=======
=======
Q2  X  
Q3    X
=======
    101

O(n^2logn)没有优化,所以对于数百个问题来说,这是非常快的。它也很容易实现。

这不是最佳的,从这个反击示例可以看出有2次攻击:

Q0 X     
Q1      X
Q2 XXX
Q3    XXX
Q4  XXXX
Q5 222222

这里贪婪的方法将删除Q5Q2(或Q3)而不是Q2Q3,这对我们的衡量标准来说是最佳的

答案 2 :(得分:2)

我提出了一系列优化,基于这样的想法:你真的希望用最少的问题来最大化玩家看不见的问题的数量,并且不关心是否有一个玩家的问题数量最少或者10000名玩家拥有相同数量的问题。

步骤1:找到看不见的问题最少的玩家(在您的示例中,这将是玩家A)将此玩家称为p。

步骤2:找到玩家p看不到的问题数量中的30个以内的所有玩家。调用此套P. P.是唯一需要考虑的玩家,因为从任何其他玩家中移除30个看不见的问题仍会让他们留下比玩家p更多看不见的问题,因此玩家p仍然会更糟。

步骤3:找到P中玩家看到的所有问题的交集,你可以删除这个集合中的所有问题,希望将你从30减少到一些较小数量的问题去除,我们将调用r。 r&lt; = 30

步骤4:找到P中玩家看到的所有问题集合的联合,调用此集合U.如果U的大小为&lt; = r,则表示已完成,删除U中的所有问题,然后删除剩余的从你的所有问题中任意出现的问题,玩家p将失去U的大小并保持最少看不见的问题,但这是你能做的最好的。

你现在留下原来的问题,但可能只有很小的套装 您的问题集是U,您的播放器设置为P,您必须删除r问题。

蛮力方法需要时间(大小(U)选择r)*大小(P)。如果这些数字是合理的,你可以强行说出来。这种方法是从U中选择每组r问题,并针对P中的所有玩家进行评估。

由于您的问题似乎确实是NP-Complete,因此您可能希望的最佳结果是近似值。最简单的方法是设置一些最大尝试次数,然后随机选择并评估要删除的问题集。因此,随机执行U选择r的功能变得必要。这可以在时间O(r)完成,(事实上,我今天早些时候回答了如何做到这一点!)

Select N random elements from a List<T> in C#

您还可以通过加权每个问题的选择机会,将其他用户建议的任何启发式方法放入您的选择中,我相信上面的链接显示了如何在所选答案中执行此操作。

答案 3 :(得分:1)

假设您要从池中删除Y个问题。简单的算法是按照他们拥有的观点数对问题进行排序。然后,您删除查看次数最多的Y个问题。对于你的例子:1:2,2:1,3:1,4:2,5:1。显然,你最好删除问题1和4.但是这个算法没有实现目标。但是,这是一个很好的起点。要改进它,您需要确保每个用户在“清理”后最终都会遇到至少X个问题。

除了上面的数组(我们可以称之为“得分”)之外,还需要第二个有问题和用户的数组,如果用户看到问题则交叉将有1,如果没有,则为0。然后,对于每个用户,您需要找到评分最低的X个问题编辑:他还没有看到(得分越少越好,因为人们看到的问题越少,对整个系统来说,它“更有价值”。您将每个用户发现的X个问题合并到第三个数组中,我们称之为“安全”,因为我们不会从中删除任何问题。

作为最后一步,您只需删除Y最常见的问题(得分最高的问题),这些问题不属于“安全”数组。

该算法实现的目的还在于,如果删除说30个问题会使某些用户查看的问题少于X,则不会删除所有30个问题。我认为这对系统有利。

编辑:对此进行良好优化的方法不是追踪每个用户,而是通过一些活动基准来筛选只看到几个问题的人。因为如果有太多人只看到1个罕见的不同问题,那么什么都不能删除。过滤那些用户或改进安全阵列功能可以解决它。

如果我没有深入描述这个想法,请随意提问。

答案 4 :(得分:1)

您是否考虑过根据动态编程解决方案进行观察?

我认为您可以通过最大限度地利用可用问题的数量来实现这一目标 对所有玩家而言,没有一个玩家留下零开放问题。

以下链接提供了有关如何构建dynamic programming的详细概述 解决这些问题。

答案 5 :(得分:1)

在仍然可以玩的问题方面提出这个问题。我将问题编号从0到4而不是1到5,因为这在编程时更方便。

          01234
          -----
player A   x x   - player A has just 2 playable questions
player B   xx x  - player B has 3 playable questions
player C  x x x  - player C has 3 playable questions

我首先要描述的是一个看似非常天真的算法,但最后我将展示如何显着改进它。

对于5个问题中的每一个,您都需要决定是保留还是丢弃它。这将需要一个深度为5的递归函数。

vector<bool> keep_or_discard(5); // an array to store the five decisions

void decide_one_question(int question_id) {
    // first, pretend we keep the question
    keep_or_discard[question_id] = true;
    decide_one_question(question_id + 1); // recursively consider the next question

    // then, pretend we discard this question
    keep_or_discard[question_id] = false;
    decide_one_question(question_id + 1); // recursively consider the next question
}

decide_one_question(0); // this call starts the whole recursive search

第一次尝试将进入无限递归下降并超过数组的末尾。我们需要做的第一件事就是在question_id == 5时立即返回(即当所有问题0到4都已确定时。我们将此代码添加到decision_one_question的开头:

void decide_one_question(int question_id) {
    {
        if(question_id == 5) {
            // no more decisions needed.
            return;
        }
    }
    // ....

接下来,我们知道我们可以保留多少问题。请拨打此allowed_to_keep。在这种情况下,这是5-3,这意味着我们要保留两个问题。您可以将其设置为某个地方的全局变量。

int allowed_to_keep; // set this to 2

现在,我们必须在decision_one_question的开头添加进一步的检查,并添加另一个参数:

void decide_one_question(int question_id, int questions_kept_so_far) {
    {
        if(question_id == 5) {
            // no more decisions needed.
            return;
        }
        if(questions_kept_so_far > allowed_to_keep) {
            // not allowed to keep this many, just return immediately
            return;
        }
        int questions_left_to_consider = 5 - question_id; // how many not yet considered
        if(questions_kept_so_far + questions_left_to_consider < allowed_to_keep) {
            // even if we keep all the rest, we'll fall short
            // may as well return. (This is an optional extra)
            return;
        }
    }

    keep_or_discard[question_id] = true;
    decide_one_question(question_id + 1, questions_kept_so_far + 1);

    keep_or_discard[question_id] = false;
    decide_one_question(question_id + 1, questions_kept_so_far );
}

decide_one_question(0,0);

(注意这里的一般模式:我们允许递归函数调用“过深”一级。我发现在函数开头检查'无效'状态比尝试避免无效更容易函数调用首先。)

到目前为止,这看起来很天真。这是检查每一个组合。忍受我!

我们需要开始跟踪得分,以便记住最好的(并为以后的优化做准备)。首先是写一个函数calculate_score。并拥有一个名为best_score_so_far的全局。我们的目标是最大化它,因此应该在算法开始时将其初始化为-1

int best_score_so_far; // initialize to -1 at the start

void decide_one_question(int question_id, int questions_kept_so_far) {
    {
        if(question_id == 5) {
            int score = calculate_score();
            if(score > best_score_so_far) {
                // Great!
                best_score_so_far = score;
                store_this_good_set_of_answers();
            }
            return;
        }
        // ...

接下来,最好跟踪分数如何随着我们逐级递增而改变。让我们开始乐观;让我们假装我们可以保留每个问题并计算得分并称之为upper_bound_on_the_score。每次递归调用自身时,都会将此副本传递给函数,并且每次做出丢弃问题的决定时都会在本地更新

void decide_one_question(int question_id
                       , int questions_kept_so_far
                       , int upper_bound_on_the_score) {
     ... the checks we've already detailed above

    keep_or_discard[question_id] = true;
    decide_one_question(question_id + 1
          , questions_kept_so_far + 1
          , upper_bound_on_the_score
        );

    keep_or_discard[question_id] = false;

    decide_one_question(question_id + 1
          , questions_kept_so_far
          , calculate_the_new_upper_bound()
        );

请参阅最后一个代码段的末尾,根据丢弃问题'question_id'的决定,计算出一个新的(较小的)上限。

在递归的每个级别,此上限越来越小。每个递归调用都会保留问题(不对此乐观界限进行更改),否则它会决定丢弃一个问题(在递归搜索的这一部分中导致较小的界限)。

优化

现在我们知道了一个上限,我们可以在函数的最开始处进行以下检查,,无论此时已经确定了多少问题

void decide_one_question(int question_id
                       , int questions_kept_so_far
                       , upper_bound_on_the_score) {
        if(upper_bound_on_the_score < best_score_so_far) {
            // the upper bound is already too low,
            // therefore, this is a dead end.
            return;
        }
        if(question_id == 5) // .. continue with the rest of the function.

此检查确保一旦找到“合理”的解决方案,该算法将很快放弃所有“死胡同”搜索。然后(希望)能够快速找到更好更好的解决方案,然后它可以更加有力地修剪死枝。我发现这种方法在实践中对我很有效。

如果它不起作用,有许多途径可以进一步优化。我不会尝试列出所有这些,你当然可以尝试完全不同的方法。但我发现这可以在极少数情况下工作,我必须做这样的搜索。

答案 6 :(得分:1)

这是一个整数程序。如果玩家unseen(i, j)没有看到问题1i,请将常数j设为0。如果要保留问题kept(j),则将变量1设为j,否则设为0。让变量score成为目标。

maximize score                                       # score is your objective
subject to

for all i,  score <= sum_j (unseen(i, j) * kept(j))  # score is at most
                                                     # the number of questions
                                                     # available to player i

sum_j (1 - kept(j)) = 30                             # remove exactly
                                                     # 30 questions

for all j,  kept(j) in {0, 1}                        # each question is kept
                                                     # or not kept (binary)

(score has no preset bound; the optimal solution chooses score
 to be the minimum over all players of the number of questions
 available to that player)

答案 7 :(得分:1)

线性编程模型。

变体1。

Sum(Uij * Qj) - Sum(Dij * Xj) + 0     =  0 (for each i)
0             + Sum(Dij * Xj) - Score >= 0 (for each i)
Sum(Qj) = (Number of questions - 30)
Maximize(Score)
如果用户Uij没有看到问题1,则

ij,否则为0

Dij是单位矩阵的元素(如果Dij=1则为i=j,否则为0

Xj是辅助变量(每个用户一个)

变体2。

Sum(Uij * Qj) >= Score (for each i)
Sum(Qj) = (Number of questions - 30)
No objective function, just check feasibility

在这种情况下,LP问题更简单,但Score应由二进制和线性搜索确定。将当前范围设置为[0 ..用户最少看不见的问题],将Score设置为范围的中间,应用整数LP算法(具有小的时间限制)。如果找不到解,请将范围设置为[begin .. Score],否则将其设置为[Score .. end]并继续二分搜索。

(可选)使用二分搜索来确定精确解Score的上限。

从二进制搜索找到的最佳Score开始,使用Score应用整数LP算法,增加1,2,...... (并根据需要限制计算时间)。最后,你得到了精确的解决方案,或者一些很好的近似值。

以下是C表示GNU GLPK的示例代码(对于变体1):

#include <stdio.h>
#include <stdlib.h>
#include <glpk.h>

int main(void)
{
  int ind[3000];
  double val[3000];
  int row;
  int col;
  glp_prob *lp;

  // Parameters
  int users = 120;
  int questions = 10000;
  int questions2 = questions - 30;
  int time = 30; // sec.

  // Create GLPK problem
  lp = glp_create_prob();
  glp_set_prob_name(lp, "questions");
  glp_set_obj_dir(lp, GLP_MAX);

  // Configure rows
  glp_add_rows(lp, users*2 + 1);
  for (row = 1; row <= users; ++row)
  {
    glp_set_row_bnds(lp, row, GLP_FX, 0.0, 0.0);
    glp_set_row_bnds(lp, row + users, GLP_LO, 0.0, 0.0);
  }
  glp_set_row_bnds(lp, users*2 + 1, GLP_FX, questions2, questions2);

  // Configure columns
  glp_add_cols(lp, questions + users + 1);
  for (col = 1; col <= questions; ++col)
  {
    glp_set_obj_coef(lp, col, 0.0);
    glp_set_col_kind(lp, col, GLP_BV);
  }
  for (col = 1; col <= users; ++col)
  {
    glp_set_obj_coef(lp, questions + col, 0.0);
    glp_set_col_kind(lp, questions + col, GLP_IV);
    glp_set_col_bnds(lp, questions + col, GLP_FR, 0.0, 0.0);
  }
  glp_set_obj_coef(lp, questions+users+1, 1.0);
  glp_set_col_kind(lp, questions+users+1, GLP_IV);
  glp_set_col_bnds(lp, questions+users+1, GLP_FR, 0.0, 0.0);

  // Configure matrix (question columns)
  for(col = 1; col <= questions; ++col)
  {
    for (row = 1; row <= users*2; ++row)
    {
      ind[row] = row;
      val[row] = ((row <= users) && (rand() % 2))? 1.0: 0.0;
    }
    ind[users*2 + 1] = users*2 + 1;
    val[users*2 + 1] = 1.0;
    glp_set_mat_col(lp, col, users*2 + 1, ind, val);
  }

  // Configure matrix (user columns)
  for(col = 1; col <= users; ++col)
  {
    for (row = 1; row <= users*2; ++row)
    {
      ind[row] = row;
      val[row] = (row == col)? -1.0: ((row == col + users)? 1.0: 0.0);
    }
    ind[users*2 + 1] = users*2 + 1;
    val[users*2 + 1] = 0.0;
    glp_set_mat_col(lp, questions + col, users*2 + 1, ind, val);
  }

  // Configure matrix (score column)
  for (row = 1; row <= users*2; ++row)
  {
    ind[row] = row;
    val[row] = (row > users)? -1.0: 0.0;
  }
  ind[users*2 + 1] = users*2 + 1;
  val[users*2 + 1] = 0.0;
  glp_set_mat_col(lp, questions + users + 1, users*2 + 1, ind, val);

  // Solve integer GLPK problem
  glp_iocp param;
  glp_init_iocp(&param);
  param.presolve = GLP_ON;
  param.tm_lim = time * 1000;
  glp_intopt(lp, &param);
  printf("Score = %g\n", glp_mip_obj_val(lp));

  glp_delete_prob(lp);
  return 0;
}

在我的测试中,时间限制无法可靠地运行。看起来像GLPK中的一些错误...

变体2的示例代码(仅限LP算法,不自动搜索Score):

#include <stdio.h>
#include <stdlib.h>
#include <glpk.h>

int main(void)
{
  int ind[3000];
  double val[3000];
  int row;
  int col;
  glp_prob *lp;

  // Parameters
  int users = 120;
  int questions = 10000;
  int questions2 = questions - 30;
  double score = 4869.0 + 7;

  // Create GLPK problem
  lp = glp_create_prob();
  glp_set_prob_name(lp, "questions");
  glp_set_obj_dir(lp, GLP_MAX);

  // Configure rows
  glp_add_rows(lp, users + 1);
  for (row = 1; row <= users; ++row)
  {
    glp_set_row_bnds(lp, row, GLP_LO, score, score);
  }
  glp_set_row_bnds(lp, users + 1, GLP_FX, questions2, questions2);

  // Configure columns
  glp_add_cols(lp, questions);
  for (col = 1; col <= questions; ++col)
  {
    glp_set_obj_coef(lp, col, 0.0);
    glp_set_col_kind(lp, col, GLP_BV);
  }

  // Configure matrix (question columns)
  for(col = 1; col <= questions; ++col)
  {
    for (row = 1; row <= users; ++row)
    {
      ind[row] = row;
      val[row] = (rand() % 2)? 1.0: 0.0;
    }
    ind[users + 1] = users + 1;
    val[users + 1] = 1.0;
    glp_set_mat_col(lp, col, users + 1, ind, val);
  }

  // Solve integer GLPK problem
  glp_iocp param;
  glp_init_iocp(&param);
  param.presolve = GLP_ON;
  glp_intopt(lp, &param);

  glp_delete_prob(lp);
  return 0;
}

似乎变体2允许非常快速地找到非常好的近似值。 并且近似值比变体1更好。

答案 8 :(得分:0)

如果蛮力的选择太多,并且可能有许多接近最优的解决方案(听起来就是这种情况),请考虑使用蒙特卡罗方法。

你有一个明确定义的适应度函数,所以只需对结果进行一些随机分配。冲洗并重复,直到您的时间用完或满足其他一些标准。

答案 9 :(得分:0)

这个问题首先看起来很容易,但在深思熟虑之后,你才意识到硬度。

最简单的选择是删除最大数量的用户所看到的问题。但这并不考虑每个用户的剩余问题数量。删除后,某些用户可能会留下一些问题。

更复杂的解决方案是在删除问题后计算每个用户的剩余问题数量。您需要为每个问题和每个用户计算它。如果您有许多用户和问题,此任务可能会非常耗时。然后,您可以总结为所有用户留下的问题数量。并选择总和最高的问题。

我认为将用户剩余问题的数量限制在合理的值是明智的。你可以认为“好吧,这个用户有足够的问题来查看他是否有超过X个问题”。您需要这样做是因为在删除问题后,只有15个问题可能留给活跃用户,而500个问题可能留给罕见的访问用户。总和15和500是不公平的。相反,您可以将阈值定义为100。

为了便于计算,您只能考虑查看X个以上问题的用户。