Удаление элементов из неравномерно распределенного набора
У меня есть веб-сайт, на котором пользователи отправляют вопросы (ноль, один или несколько в день), голосуют за них и отвечают на один вопрос в день (Подробнее здесь). Пользователь может увидеть вопрос только один раз, отправив, проголосовав или ответив на него.
У меня есть пул вопросов, которые игроки уже видели. Мне нужно удалять 30 вопросов из пула каждый месяц. Мне нужно выбрать вопросы для удаления таким образом, чтобы я максимизировал количество доступных вопросов, оставшихся в пул для игрока с наименее доступными вопросами.
пример с пулом из 5 вопросов (и нужно удалить 3):
- игрок видел вопросы 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
оценка для этого решения одна, потому что у двух игроков с наименьшим количеством доступных вопросов для игры есть один доступный вопрос.
Если бы размер данных был небольшим, я мог бы грубо заставить решение. Тем не менее, у меня есть сотни игроков и вопросов, поэтому я ищу некоторые алгоритм решения этой проблемы.
10 ответов
модели линейного программирования.
Вариант 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
is 1
если не видел вопрос j
, иначе 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 .. наименьшее количество невидимых вопросов для пользователя], set Score
к середине диапазона примените алгоритм integer LP (с небольшим ограничением по времени). Если решение не найдено, установите range в [begin .. Score
], в противном случае установите ее в положение [Score
.. end] и продолжить двоичный поиск.
(опционально) использовать двоичный поиск для определения верхней границы для точного решения Score
.
начиная с самого лучшего Score
, найденный бинарным поиском, примените алгоритм integer LP с Score
, увеличено на 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(¶m);
param.presolve = GLP_ON;
param.tm_lim = time * 1000;
glp_intopt(lp, ¶m);
printf("Score = %g\n", glp_mip_obj_val(lp));
glp_delete_prob(lp);
return 0;
}
ограничение по времени не работает надежно в моих тестах. Выглядит как баг в ГЛПК...
пример кода для варианта 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(¶m);
param.presolve = GLP_ON;
glp_intopt(lp, ¶m);
glp_delete_prob(lp);
return 0;
}
похоже, что вариант 2 позволяет найти довольно хорошее приближение довольно быстро. И приближение лучше, чем для варианта 1.
предположим, что у вас есть общий эффективный алгоритм для этого. Сосредоточьтесь на оставшихся вопросах, а не на удаленных.
вы можете использовать такой алгоритм для решения проблемы - можете ли вы выбрать не более T вопросов, чтобы каждый пользователь мог ответить хотя бы на один вопрос? Я думаю, что это http://en.wikipedia.org/wiki/Set_cover, и я думаю, что решение вашей проблемы в целом позволяет решить заданную крышку, поэтому я думаю, что это NP-полный.
есть как минимум линейной релаксации программирования. Свяжите каждый вопрос с переменной Qi в диапазоне 0= X, которая является линейной в Qj и X, поэтому вы можете максимизировать для целевой функции X с линейными переменными X и Qj. К сожалению, результат не должен давать вам целое число Qj-рассмотрим, например, случай, когда все возможные пары вопросы связаны с некоторым пользователем, и вы хотите, чтобы каждый пользователь мог ответить хотя бы на 1 вопрос, используя не более половины вопросов. Оптимальное решение Qi = 1/2 для всех i.
(но с учетом релаксации линейного программирования вы можете использовать его в качестве границы в http://en.wikipedia.org/wiki/Branch_and_bound).
в качестве альтернативы вы можете просто записать проблему и бросить ее в целочисленный пакет линейного программирования, если у вас есть один удобный.
для полноты потока, вот простой жадный, aproximating подход.
поместите решенные вопросы в ранее обсуждавшуюся матричную форму:
Q0 X
Q1 XX
Q2 X
Q3 X
Q4 XX
223
сортировка по количеству решенных вопросов:
Q0 X
Q1 XX
Q2 X
Q3 X
Q4 XX
322
вычеркнуть вопрос с наиболее X
S среди игроков с большинством проблем решена. (Это гарантированно уменьшит нашу меру, если что-то есть):
=======
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
здесь жадный подход позволяет удалить Q5
и Q2
(или Q3
) вместо Q2
и Q3
что было бы оптимальным для нашей меры.
Я предлагаю кучу оптимизаций, основанных на идее, что вы действительно хотите максимизировать количество невидимых вопросов для игрока с минимальным количеством вопросов, и не волнует, есть ли 1 игрок с минимальным количеством вопросов или 10000 игроков с таким же количеством вопросов.
Шаг 1: Найдите игрока с минимальным количеством незримых вопросов (в вашем примере это был бы Игрок A) вызовите этого игрока p.
Шаг 2: Найти все игроки с в пределах 30 из числа вопросов, невидимых игроком p. P являются единственными игроками, которые должны быть рассмотрены, так как удаление 30 невидимых вопросов от любого другого игрока все равно оставит их с более невидимыми вопросами, чем игрок p, и, таким образом, игрок p все равно будет хуже.
Шаг 3: Найдите пересечение всех наборов проблем, замеченных игроками в P, вы можете удалить все проблемы в этом наборе, надеюсь, сбросив вас с 30 до некоторых меньших количество проблем для удаления, которые мы будем называть r. r
Шаг 4: Найдите объединение всех наборов проблем, замеченных игроками в P, назовите этот набор U. Если размер U
вас остается ваш оригинальный проблема, но скорее с значительно меньшие наборы.
Ваш набор проблем-U, ваш набор игроков-P,и вы должны удалить проблемы R.
подход грубой силы требует времени(размер (U) выберите r) * размер (P). Если эти цифры разумны, вы можете просто грубо заставить его. Этот подход состоит в том, чтобы выбрать каждый набор задач r из U и оценить его против всех игроков в P.
поскольку ваша проблема кажется NP-полной, лучшее, на что вы можете надеяться, - это приближение. Простой способ для этого необходимо установить максимальное количество попыток, а затем случайным образом выбрать и оценить наборы проблем для удаления. Таким образом, функция для выполнения U выбрать R случайным образом становится необходимым. Это можно сделать вовремя O (r), (на самом деле, я ответил, как это сделать сегодня раньше!)
выберите N случайных элементов из списка
вы также можете поместить любую из эвристик, предложенных другими пользователями, в свой выбор, взвесив шанс каждой проблемы быть выбранным, I поверьте, ссылка выше показывает, как это сделать в выбранном ответе.
предположим, вы хотите удалить Y
вопросы из пула. Простым алгоритмом было бы сортировать вопросы по количеству просмотров, которые у них были. Затем вы удалите Y
из самых просматриваемых вопросов. Для вашего примера: 1: 2, 2: 1, 3: 1, 4: 2, 5: 1. Очевидно, вам лучше удалить вопросы 1 и 4. Но этот алгоритм не достигает цели. Однако это хорошая отправная точка. Чтобы улучшить его, вы должны убедиться, что каждый пользователь имеет по крайней мере X
вопросы после "очистка."
в дополнение к вышеуказанному массиву (который мы можем назвать "оценка"), вам нужен второй с вопросами и пользователями, где пересечение будет иметь 1, если пользователь видел вопрос, и 0, если он этого не сделал. Тогда для каждого пользователя вам нужно найти X
вопросы с самым низким баллом edit: что он еще не видел (чем меньше их оценка, тем лучше, так как меньше людей видели вопрос, тем более "ценным" он для системы в целом). Вы объединяете все найденное X
вопросы от каждого пользователя в третий массив, назовем его "безопасным", так как мы не будем удалять их из него.
в качестве последнего шага вы просто удалить Y
лучшие просмотренные вопросы (те, у которых самый высокий балл), которые не находятся в "безопасном" массиве.
этот алгоритм также достигает того, что при удалении скажем 30 вопросов некоторые пользователи будут иметь меньше X
вопросы для просмотра, он не удалит все 30. Что, я думаю, хорошо для система.
Edit: хорошей оптимизацией для этого было бы отслеживать не каждого пользователя, но иметь некоторый тест активности для фильтрации людей, которые видели только несколько вопросов. Потому что если есть слишком много людей, которые видели только 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 были решены. Добавим этот код в начало decide_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
теперь мы должны добавить дополнительные проверки в начало decide_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.
эта проверка гарантирует, что как только "разумное" решение будет найдено, алгоритм быстро откажется от всех "тупиковых" поисков. Затем он (надеюсь) быстро найдет лучшие и лучшие решения, и тогда он может быть еще более агрессивным в обрезке мертвых ветвей. Я обнаружил, что этот подход работает достаточно хорошо для меня на практике.
если это не так работы, есть много путей для дальнейшей оптимизации. Я не буду пытаться перечислить их все, и вы, безусловно, можете попробовать совершенно разные подходы. Но я обнаружил, что это работает в тех редких случаях, когда мне приходится выполнять какой-то поиск.
вот целые программы. Пусть константа unseen(i, j)
be 1
если игрок i
не видел вопрос 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)
Если есть слишком много вариантов грубой силы и, вероятно, есть много решений, которые близки к оптимальным (звучит так), рассмотрим методы Монте-Карло.
У вас есть четко определенная функция фитнеса, так что просто сделать некоторые случайные назначения оценка результата. Промыть и повторять, пока не закончится время или не будут выполнены некоторые другие критерии.
вопрос сначала кажется легким, но после более глубокого размышления вы понимаете твердость.
самым простым вариантом будет удаление вопросов, которые были замечены максимальным количеством пользователей. но это не учитывает количество оставшихся вопросов для каждого пользователя. некоторые очень немногие вопросы могут быть оставлены для некоторых пользователей после удаления.
более сложным решением будет вычислять количество оставшихся вопросов для каждого пользователя после удаления вопрос. Вы должны вычислить его для каждого вопроса и каждого пользователя. Эта задача может занять много времени, если у вас много пользователей и вопросы. Затем вы можете суммировать количество вопросов, оставшихся для всех пользователей. И выберите вопрос с наибольшей суммой.
Я думаю, было бы разумно ограничить количество оставшихся вопросов для пользователя разумным значением. Вы можете подумать: "хорошо, у этого пользователя достаточно вопросов для просмотра, если у него больше X вопросов". Вам это нужно, потому что после удаления вопрос, только 15 вопросов могут быть оставлены для активного пользователя, в то время как 500 вопросов могут быть оставлены для редко посещающего пользователя. Нечестно суммировать 15 и 500. Кроме того, можно определить пороговое значение 100.
чтобы упростить вычисление, вы можете рассматривать только пользователей, которые просмотрели более X вопросов.