前端开发者的Yandex.Blitz竞赛最近结束了,我想分析一个问题。(我再次重申,比赛已经结束)。在我看来,这个问题介于背包问题和用线段覆盖一组点之间。这是它的完整措辞:
有一个 devops Petya。在工作中,他需要在接下来的 100 天内在某些日子值班。彼佳乘地铁上班。订阅票被引入地铁,从第一次乘坐之日起的一定天数内有效。门票的持续时间越长,每天的费用就越低。我们需要帮助 Petya 省钱,并在考虑到他的值班时间的情况下,提前三个月计算出他需要购买哪些票,以使他们的总成本尽可能低。彼佳也不喜欢随身携带很多票,如果有几种最低成本相同的票,那么彼佳需要一张票少的。如果有多个这样的选项(最低成本和门票数量相同),那么其中任何一个都适合 Petya。getCheapestTickets(days, tickets),它将 Petya 的值班表 ( days) 和可能的订阅票选项 ( tickets) 作为输入,并作为输出给出 Petya 需要购买的票列表(以票选项输入数组中的索引的形式)。Petya 的值班时间表以排序后的数字数组形式给出(从 1 到 100 包括在内),每个数字表示值班日的序号:
// Петя должен дежурить на второй, пятый, десятый и сорок пятый день относительно текущей даты
[2, 5, 10, 45]
每个订阅票由以下接口描述:
interface Ticket {
// количество дней, в течение которых билет действует со дня первой поездки по нему,
// включая этот день (от 1 до 100 включительно)
duration: number;
// стоимость билета (от 1 до 100 включительно)
cost: number;
}
门票选择数量不超过10个,并保证所有门票价格不同,门票有效期越多,一日成本越低。
输入格式
days:
[1, 2, 4, 6, 7, 8, 9, 10, 20]
tickets:
[
{ cost: 3, duration: 1 },
{ cost: 10, duration: 7 },
{ cost: 20, duration: 30 }
]
输出格式
[0, 0, 1, 0]
我的问题是我的解决方案没有及时通过。首先,我尝试通过递归遍历整个决策树(JavaScript)来解决这个问题:
const getCheapestTickets = (days, tickets) => {
// Вспомогательная функция для обхода всех вариантов билетов.
// l указывает на текущий день из массива days.
const wrapper = (l) => {
let min = null;
// Обходим все билеты.
for (let i = 0; i < tickets.length; i += 1) {
const ticket = tickets[i];
let j = l;
// Считаем количество дней, которые покрывает данный билет.
const skip = days[l] + ticket.duration;
while (skip > days[j]) {
j += 1;
}
let curr;
if (j < days.length) {
// Рекурсивно ищем решение для оставшихся дней.
curr = wrapper(j);
curr.sum += ticket.cost;
} else {
curr = { sum: ticket.cost, includes: [] };
}
const currIncludes = [i, ...curr.includes];
curr = { ...curr, includes: currIncludes };
if (min === null) {
min = curr;
} else if (
(curr.sum === min.sum && curr.includes.length < min.includes.length)
|| curr.sum < min.sum) {
min = curr;
}
}
return min;
};
return wrapper(0, 0).includes;
};
在这里,我不明白如何丢弃决策分支。我看不出如何将其简化为经典的背包问题,通过矩阵寻求解决方案。接下来,我尝试编写相同的解决方案,但没有递归,也绕过所有可能的变化,但在树的帮助下。也没有及时过去。请帮助制定更优化的算法。解决方案是单线程的,这一点很重要。
这个问题与背包问题无关。根本区别在于,在背包问题(或分段覆盖)中,元素的数量是有限的,而在我们的问题中,我们可以购买无限数量的每种类型的票。
很容易看出,如果我们有一组天的最优解,并且可以分成 2 个部分的最优覆盖,那么我们再次得到 2 个子集的最优解。如果它们不是最优的,那么我们的原始解决方案也不是最优的:
因此,可以使用贪心策略动态解决问题。在这种情况下,复杂性是
,其中
n是工作天数。我将告诉您一项与此非常相似的任务,但是,与它不同。也许这会很有用。
给定集合对数字的因式分解
这个存储库是一种算法的实现,它允许您在给定的一组数字上分解一个数字。例如,我们有一组数字
1, 2, 2, 3, 4, 6。我们有多少种方法可以得到一个数字5?明显地:因此,我们已经通过了所有可能的解决方案。请注意,重复其中一种解决方案。考虑并避免不必要的重复很重要。
基线
让我们为一组数字分配一个位掩码:
然后,要获得解决方案,枚举所有位掩码就足够了。这种算法的复杂度为:$O(2^N)$,其中 $N$ 是掩码的大小。显然,已经有 22 个元素,您将不得不等待很长时间才能获得所有解决方案。
这里应该注意的是,任何解决方案都可以很快找到。但是如果我们想确保没有解,证明它是唯一的,或者找到所有的,那么算法的执行时间就会被延迟。
考虑一个我在实践中解决的应用问题。
一个任务
让我们去购物吧。我们知道总价格,即 所有商品的总金额和每件商品的价格。但是,客户退回了部分货物。需要了解客户购买了哪些商品。
继续
在某些情况下,我们想了解这个问题有多少解决方案?
解决方案
有货:
每一个都要花费一些东西。
还有出发和回程:
有一个解决方案:
通过在解决方案中依次包含和排除所有内容,我们可以找到所有可能的解决方案。
优化 - 1.(背包问题)
在这里阅读 更多 并递归迭代所有解决方案。因此,我们将回答任务的所有问题。这种方法的问题是我们不能迭代地执行它,这意味着我们必须将所有解决方案存储在内存中,而且可能有很多。
需要注意的是,这个问题是背包问题的一个特例。背包任务如下所示:
有一个背包,它有一个容量,我们还想在其中收集最大价值的物品,以便它们都适合。同时,每个项目都有其成本和重量(限制)。一项任务中可能存在多个约束。
В нашем случае всё проще. Во-первых, вес рюкзака и ограничение в данном случае совпадают.
Во-вторых, в отличие от задаче о рюкзаке, нам требуется составить из наших предметов в точности максимальный вес рюкзака. Не забываем, что вес совпадает с ограничением, либо сказать, что такого решения не существует.
Оптимизация - 2
Для того, чтобы генерировать решения итеративно, предлагается отранжировать элементы. В данном случае это возможно, покуда ограничение и вес совпадают. Затем, на каждом шаге, мы будем набивать рюкзак, итерируясь от максимального элемента к минимальному и будем пересчитывать остаток. Как только остаток станет меньше 0, возвращемся к предыдущему шагу и продолжаем поиск. Если остаток равен в точности 0, то возвращаем решение, а затем, с того же места продолжаем поиск.
请注意,此解决方案所需的时间大约是背包问题的 2 倍。同时,我们可以迭代地给出答案。