更好的回溯版本

Better Version for Backtracking

本文关键字:版本 回溯 更好      更新时间:2023-10-16

给定 n 个任务,每个任务可以在 1 个单位时间内执行,任务可以并行执行。每个任务只能在给定的时间范围内完成,例如在时间 t1 和 t2(包括 t1 和 t2)(t1 <= t2)之间。目标是找到可以在 2 个时间时刻执行的最大任务。

示例:对于 5 个任务 (n=5),
任务 1:{1、5}
任务 2:{3, 4}
任务 3:{5, 6}
任务 4:{7, 12}
任务 5:{8, 100}

在这里,我们最多可以执行4个任务。
任务 1 和 2 可以在 [3, 4] 之间的时间时刻执行,任务 4 和 5 可以在 [8, 12] 之间的时间时刻执行。

或 任务
1 和 3 可以在时间时刻 5 执行,任务 4 和 5 可以在 [8, 12] 之间的时间时刻执行。

现在这是回溯算法C++版本:

int result = 0, n;
int task[1000][2];
//Checks if task overlaps with the time range [t1, t2]
bool taskFit(int &taskno, int &t1, int &t2){
if(task[taskno][1] &lt t1)return false;
else if(task[taskno][0] > t2)return false;
else return true;
}
void backtrack(int t1, int t2, int t3, int t4, int taskno, int grp1, int grp2){
//t1, t2 represultent time bounds for group1
//t3, t4 represultent time bounds for group2
//grp1: number of tasks that can be performed in time range of group1
//grp2: number of tasks that can be performed in second time stamp
result = max(grp1 + grp2, result);
if(result==n || taskno==(n+1))return;
//putting task in first group if it fits in the range of group1
if(taskFit(taskno, t1, t2))
backtrack(max(t1, task[taskno][0]), min(t2, task[taskno][1]), t3, t4, taskno+1, grp1+1, grp2);
//putting task in second group if it fits in its range
if(taskFit(taskno, t3, t4))
backtrack(t1, t2, max(t3, task[taskno][0]), min(t4, task[taskno][1]), taskno+1, grp1, grp2+1);
//simply ignoring the task
backtrack(t1, t2, t3, t4, taskno+1, grp1, grp2);
}
main(){
//...we have the value of n and time range of all n tasks
// for ith task time range in obtained as [task[i][0], task[i][1]]
//initially both groups are set in time range = [0, 2000000000]
//here we put the task1 in first group
//thus setting the new range for group 1
backtrack(task[0][0], task[0][1], 0, 2000000000, 2, 1, 0);
//ignoring the first task, the group ranges remain as it is
backtrack(0, 2000000000, 0, 2000000000, 2, 0, 0);
}


上面的回溯算法考虑一个任务的 3 种情况,它位于组 1 或组 2 中,或者不属于任何组。 最初,组范围
足够大,以便所有任务都可以放入其中,但是当我们向组中添加任务时,时间范围会收敛。
我知道这个算法工作正常,但对于某些输入,它的复杂性是指数级的。
因此,是否可以优化此算法,或者我应该采用另一种策略?如果应用了其他策略,请让我知道优化或概念。

我将解释一种算法,该算法(我认为)应该具有N3的时间复杂度和N2的内存复杂度(N是任务数)。我将尝试首先描述它的来源以及它是如何工作的,但如果你只想要伪代码,你可以跳到最后。

步骤 1:为输入数据重新编制索引

首先,我想以矩阵(或"涂鸦")的方式表示您的输入数据。如果我从您的示例中获取一个子集:

+--------+---+---+---+---+---+---+---+---+---+----+----+----+
|        | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 |
+--------+---+---+---+---+---+---+---+---+---+----+----+----+
| Task 1 | X | X | X | X | X |   |   |   |   |    |    |    |
| Task 2 |   |   | X | X |   |   |   |   |   |    |    |    |
| Task 3 |   |   |   |   | X | X |   |   |   |    |    |    |
| Task 4 |   |   |   |   |   |   | X | X | X | X  | X  | X  |
+--------+---+---+---+---+---+---+---+---+---+----+----+----+

目前,您的数据按行编制索引:task[taskIndex] // --> time interval of this task。如果我们按列重新索引会发生什么:availableTasks[timeIndex] // --> set of tasks that can be executed at this time

通过此数据结构的朴素实现,我们可以编写以下朴素算法(伪C++):

int MAX_TIME = 2000000000; // 2,000,000,000
// reindexed input
std::unordered_set<int>; availableTasks[MAX_TIME];
// main algorithm
int bestTotal = -1; // result
int r1, r2; // time values to get bestTotal
// brute force: try all pair of time values:
for (int t1 = 0; t1 <= MAX_TIME-1; t1++) {
for (int t2 = t1; t2 <= MAX_TIME; t2++) {
int count = union(availableTasks[t1], availableTasks[t2]).size();
if (count > bestCount) {
r1 = t1; r2 = t2;
bestCount = count;
}
}
}

好消息:时间复杂度不再是指数级的!坏消息:

  1. 巨大的内存使用:由于MAX_TIME,即使任务很少,availableTasks也会达到几GB。
  2. 荒谬的执行时间:双循环大约有 2*1018次迭代。在 4Ghz CPU 上,迭代/周期为 1 次(非常乐观),已经十多年了。

因此,任何实用的解决方案都不能暴力破解所有时间值对。

第 2 步:限制时间值候选者。

让我们举一个简单的案例,其中包含 2 个任务:

  • 任务 0:[1;1 499 999]
  • 任务 1: [500 000; 1 999 999]

直觉上,你会说没有必要测试 2,000,000 个时间值。您会认为有 4 个时间间隔的时间值(不包括上限):

  • 间隔 A: [1; 500 000[
  • 间隔 B: [500 000
  • ; 1 500 000[
  • 间隔 C: [1 500 000; 2 000 000[
  • 间隔 D: [2 000 000;MAX_TIME[

如果在同一时间间隔内选择了TaTb,则我们有availableTasks[Ta] == availableTasks[Tb].所以基本上,我们只需要在每个间隔中测试一个时间值。我将选择下限来表示每个区间:1 |500,000 |1,500,000 |2,000,000。

注意:在数学术语中,我们通过定义等价关系来划分时间值集:t1~t2 <=> availableTasks[t1] == availableTasks[t2],然后从每个等价类中选择一个代表性元素。

这些代表性价值从何而来?容易:

tasks[0][0] // 1
tasks[1][0] // 500,000
tasks[0][1] + 1 // 1,500,000
tasks[1][1] + 1 // 2,000,000

第一个候选限制规则:如果我们测试集合S={tasks[*][0], tasks[*][1]+1}中的所有对,我们一定会找到最佳解决方案。

这应该已经限制了足够的值,以使算法在合理的时间内工作,但我们可以做得更简单、更快。前一组中的值可以解释如下:

  1. t=tasks[k][0]:任务 k 不能在上一个间隔(以t-1结束)执行,但可以在从t开始的间隔内执行
  2. t=tasks[k][1]:任务 k 可以在前一个间隔内执行(结束于t-1),但不能在t处的间隔内执行

从这个角度来看,在情况 2 中从t开始的间隔是一个无用的候选者:除非有一个验证tasks[l][0] == tasks[k][1]+1l(意思是:在时间 t,有另一个任务变得可执行,所以 t 也落在情况 1 中),前一个区间中的任何时间值都需要一个更好的候选(因为availableTasks[t-1]严格包含availableTasks[t])。因此:

更好的候选限制规则:如果我们测试集合S={tasks[*][0]}中的所有对,我们肯定会找到最佳解决方案。

算法(未经测试,伪C++)

生成重新索引的输入数据:

int n = 1000; //number of tasks
// availableTasks[timeIndex] : set of tasks executable at timeIndex.
// Only candidate values are stored in the map.
std::map<int, std::unordered_set<int>> availableTasks;
for ( timeCandidate : task[*][0] ) {
auto& set = availableTasks[timeCandidate];
for ( int taskIndex = 0; taskIndex < n; taskIndex++) {
if (timeCandidate in task[taskIndex]) {
set.insert(task);
}
}
}

主要算法:

int bestTotal = -1; // result
int r1, r2; // time values to get bestTotal
auto& map = availableTasks; // alias
for (auto it1=map.begin(); it1 != map.end(): it1++) {
int t1 = it1->first;
auto& set1 = it1->second;
auto it2 = it1; // copy the iterator
it2++;
for (; it2 != map.end(); it2++) {
int t2 = it2->first;
auto& set2 = it2->second;
// assuming here that union().size() can be computed in O(n) time.
int count = union(set1, set2).size();
if (count > bestCount) {
r1 = t1; r2 = t2;
bestCount = count;
}
}
}

如果这个算法有效,我认为有可能在空间和时间复杂性方面进一步完善它。但是这篇文章已经很长^^了。首先,我建议您检查此内容是否正确,以及它是否满足您的性能要求。