题目:
给你一个用字符数组 tasks
表示的 CPU 需要执行的任务列表,用字母 A 到 Z 表示,以及一个冷却时间 n
。每个周期或时间间隔允许完成一项任务。任务可以按任何顺序完成,但有一个限制:两个 相同种类 的任务之间必须有长度为 n
的冷却时间。
返回完成所有任务所需要的 最短时间间隔 。
解法一(模拟):
一种容易想到的方法是,按照时间顺序,依次给每一个时间单元分配任务。如果当前有多种任务不在冷却中,我们应当选择剩余执行次数最多的那个任务,将每种任务的剩余执行次数尽可能平均,使得CPU处于待命状态的时间尽可能少。我们可以用一个二元组kyk(rest, nextValid)记录每个任务的状态,其中rest表示每个任务剩余的执行次数,nextValid表示每个因冷却限制最早可以执行的时间。我们用index记录当前的时间,根据我们的策略,我们需要选择不在冷却中并且剩余执行次数最多的那个任务。
我们需要找到满足nextValid≤time的并且rest最大的索引i,然后更新此任务下的(rest, nextValid)状态,记录任务i下一次冷却结束的时间以及剩余执行次数,如果更新后的rest==0,那么任务i全部做完,我们在遍历二元数组时可以忽略它了。而对于time的更新,我们可以选择将其不断增加1,模拟一个时间片。但这样会导致我们在CPU处于待命状态时,对二元数组进行不必要的遍历,为了减少时间复杂度,我们可以在每次遍历之前,将time更新为所有nextValid中的最小值,直接跳过待命状态,保证每一次对二元组的遍历都是有效的。需要注意的是,只有当这个最小值大于time时,才需要这样快速更新。这样通过不断地选择不在冷却中并且剩余执行次数最多的那个任务得到问题的解。如下为笔者实现代码:
class Solution {
public:
int leastInterval(vector<char>& tasks, int n) {
int result =0;
std::vector<std::vector<int>> numbers(26, std::vector<int>(2));
int length1 = tasks.size();
for(int i=0; i<length1; i++){
numbers[tasks[i]-'A'][0]++;
}
std::vector<std::vector<int>> kyk;
for(int i=0; i<26; i++){
if(numbers[i][0]!=0){
kyk.push_back(numbers[i]);
}
}
//index为记录的当前时间,可将index直接更新为所有nextValid中的最小值,直接跳过待命状态,保证每一次对二元组的遍历都是有效的
int index = 1;
sort(kyk.begin(),kyk.end());
while(!kyk.empty()){
int length = kyk.size();
int i = length-1;
while(i>=0){
vector<int>& current = kyk[i];
if(current[1]<index){
current[0]--;
current[1] = index + n;
if(current[0]==0){
kyk.erase(kyk.begin() + i);
}
break;
}
else{
i--;
}
}
index++;
sort(kyk.begin(),kyk.end());
}
index--;
return index;
}
};
对times(index)优化后的实现算法如下所示,其中task.size()为需要执行的所有任务的总次数,不需要保证数组为空时终止(每次执行时,剩余执行数量为0的数组进行删除),虽然等效但实现较复杂。第一个for循环为了使times跳过CPU等待时的遍历判断过程,直接达到数组中冷却时间最小的执行时间; 第二个for循环用于查找满足最小冷却执行时间前提要求下时,剩余执行数量任务的索引位置(best为确定出具体执行哪个任务)。
class Solution {
public:
int leastInterval(vector<char>& tasks, int n) {
unordered_map<char, int> freq;
for (char ch: tasks) {
++freq[ch];
}
// 任务种类数
int m = freq.size();
vector<int> nextValid, rest;
for (auto [_, v]: freq) {
nextValid.push_back(1);
rest.push_back(v);
}
int time = 0;
//tasks.size()为需要执行的所有任务的总次数,不需要保证数组为空时终止(等效但为空时实现较复杂)
for (int i = 0; i < tasks.size(); ++i) {
++time;
int minNextValid = INT_MAX;
//遍历所有任务中nextValid冷却结束时间最小的值,并记录给minNextValid中,让time直接跳转到冷却结束最小时间中
for (int j = 0; j < m; ++j) {
if (rest[j]) {
minNextValid = min(minNextValid, nextValid[j]);
}
}
time = max(time, minNextValid);
//best为选择的最先执行的哪个种类的任务。找到满足nextValid<=time并且剩余执行任务数量最多的那个任务作为本次执行的任务
int best = -1;
for (int j = 0; j < m; ++j) {
if (rest[j] && nextValid[j] <= time) {
if (best == -1 || rest[j] > rest[best]) {
best = j;
}
}
}
nextValid[best] = time + n + 1;
--rest[best];
}
return time;
}
};
时间复杂度:O(∣tasks∣⋅∣Σ∣),其中 ∣Σ∣ 是数组 task 中出现任务的种类,在本题中任务用大写字母表示,因此 ∣Σ∣ 不会超过 26。在对 time 的更新进行优化后,每一次遍历中我们都可以安排一个任务,因此会进行 ∣tasks∣ 次遍历,每次遍历的时间复杂度为 O(∣Σ∣),相乘即可得到总时间复杂度。空间复杂度:O(∣Σ∣)。我们需要使用哈希表统计每种任务出现的次数,以及使用数组 nextValid 和 test 帮助我们进行遍历得到结果,这些数据结构的空间复杂度均为 O(∣Σ∣)。
笔者小记:
1、本题中为了减少时间复杂度,我们可以在每次遍历之前,将time更新为所有nextValid中的最小值,直接跳过待命状态,保证每一次对二元组的遍历都是有效的。需要注意的是,只有当这个最小值大于time时,才需要这样快速更新。这样通过不断地选择不在冷却中并且剩余执行次数最多的那个任务得到问题的解。通过使用合适的数据结构,记录并更新过程中关键信息【元素】,实现较低时间复杂度算法的实现。(优化点:不需要不断增加1,模拟一个时间片,通过构建数据结构记录最小冷却执行时间,将哪些在times一次都遍历执行任务的过程跳过,降低时间复杂度)
2、有些实现过程,可以等效替换,降低代码书写量。例如task.size()为需要执行的所有任务的总次数,不需要保证数组为空时终止(每次执行时,剩余执行数量为0的数组进行删除),虽然等效但实现较复杂。通过best遍历所有任务的剩余未执行任务最多的是哪个任务索引位置(时间复杂度为O(n)),这样可以避免,每次执行完都进行一次对剩余未执行任务数量的排序操作(时间复杂度为O(nlog(n)))。
3、unordered_map<>哈希表是无序的,只能通过查找键值来找到,不能通过索引值进行查找(哈希表首部和尾部的键值对都是无序变化的,不存在顺序)0与vector<>数组,stack<>栈、queue<>队列、string字符串等数据结构的性质不同(均是有顺序的)。