任务调度器

题目介绍

力扣621题:https://leetcode-cn.com/problems/task-scheduler/
给你一个用字符数组 tasks 表示的 CPU 需要执行的任务列表。其中每个字母表示一种不同种类的任务。任务可以以任意顺序执行,并且每个任务都可以在 1 个单位时间内执行完。在任何一个单位时间,CPU 可以完成一个任务,或者处于待命状态。

然而,两个 相同种类 的任务之间必须有长度为整数 n 的冷却时间,因此至少有连续 n 个单位时间内 CPU 在执行不同的任务,或者在待命状态。

你需要计算完成所有任务所需要的 最短时间 。
任务调度器
任务调度器

分析

调度(Schedule)是操作系统的核心功能之一,保证多任务多线程的应用能够高效的利用硬件资源。本题就是模拟了操作系统中CPU任务调度的过程。

不同的任务可以依次执行,说明现在是单核CPU串行运作,同一时间只能执行一个任务。而同类型任务之间设置了冷却时间,主要也是为了保证各类型任务能够尽量均匀地占有CPU,不要出现长时间运行同一类任务的情况。

我们发现,由于任务是串行的,所以每个任务的执行时间不可能缩短,至少需要tasks.length个时间单位。而现在要想总执行时间最短,其实就是让额外的冷却时间最短。

方法一:模拟法

一种简单的想法是,我们按照时间顺序,依次给每一个时间单位分配任务。这就相当于我们模拟了一个CPU时钟。如果当前有多种任务可以执行,我们可以采用贪心策略:每次选择剩余执行次数最多的那个任务。这样将每种任务的剩余执行次数尽可能平均,就可以使得 CPU 处于待命状态的时间尽可能少。

具体实现上,我们可以定义两个状态列表,分别保存每个任务当前剩余的个数,以及最早可执行的时间。然后可以设置一个循环,模拟时间time不停向前推进。
具体执行过程:

  • 首先遍历还没做完任务,找到可执行时间最早的那个;
  • 然后判断时间time是否到了,如果还没到,直接推进到执行任务的时间;
  • 由于有可能当前时间time,已经超过了多个任务的最早可执行时间,所以我们还要遍历这些任务,找到可执行次数最多的那个;
  • 执行任务,更新状态

代码如下:

// 方法一:模拟法
public int leastInterval1(char[] tasks, int n){
    // 用HashMap统计每个任务出现的次数
    HashMap<Character, Integer> countMap = new HashMap<>();
    for (char task: tasks)
        countMap.put(task, countMap.getOrDefault(task, 0) + 1);

    // 任务种类数量
    int t = countMap.size();

    // 定义两个状态列表
    ArrayList<Integer> restCount = new ArrayList<>(countMap.values());    // 任务剩余次数
    ArrayList<Integer> nextAvailableTime = new ArrayList<>(Collections.nCopies(t, 1));    // 任务下次可执行时间

    int time = 0;    // 模拟CPU时钟

    // 遍历任务选择执行
    for (int i = 0; i < tasks.length; i++){
        time ++;

        int minNextAvailableTime = Integer.MAX_VALUE;
        // 1. 获取所有任务中最早可执行的时间
        for (int j = 0; j < t; j++){
            // 取还没做完的任务
            if (restCount.get(j) != 0){
                minNextAvailableTime = Math.min(minNextAvailableTime, nextAvailableTime.get(j));
            }
        }

        // 2. 直接推进时间,执行任务
        time = Math.max(time, minNextAvailableTime);

        // 3. 选取可执行任务中,剩余次数最多的那个执行
        int maxRestCountTask = -1;    // 保存要执行任务的索引
        for (int j = 0; j < t; j ++){
            if (restCount.get(j) > 0 && nextAvailableTime.get(j) <= time){
                // 如果比之前保存的最大剩余任务数还大,就更新
                if (maxRestCountTask == -1 || restCount.get(j) > restCount.get(maxRestCountTask)){
                    maxRestCountTask = j;
                }
            }
        }

        // 4. 执行任务,更新状态列表
        nextAvailableTime.set(maxRestCountTask, time + n + 1);
        restCount.set(maxRestCountTask, restCount.get(maxRestCountTask) - 1);
    }

    return time;
}

复杂度分析

  • 时间复杂度:O(N ⋅∣Σ∣),其中N是数组tasks的长度,∣Σ∣是数组tasks 中出现任务的种类。在对time 的更新进行优化后,每一次遍历中我们都可以安排执行一个任务,所以总共进行n 次遍历,每次遍历的时间复杂度为O(∣Σ∣),相乘即可得到总时间复杂度。
  • 空间复杂度:O(∣Σ∣)。我们需要使用哈希表统计每种任务出现的次数,以及使用两个状态列表帮助我们进行遍历得到结果,这些数据结构的空间复杂度均为O(∣Σ∣)。

方法二:构造法

这种思路比较巧妙,我们可以构造一个二维矩阵,来可视化地表示每个任务执行的时间点。

我们首先考虑所有任务种类中,执行次数最多的那一类任务,记它为A,其执行次数为 maxCount。

我们使用一个宽为 n+1 的矩阵,可视化地展现执行任务A 的时间点。矩阵中任务是逐行顺序执行的,没有任务的格子对应 CPU 的待命状态。这里宽度定为n+1的目的,就是因为冷却时间为 n,我们只要将所有的 A 全部填入矩阵的第一列,就可以保证满足题目要求。
任务调度器
而且容易看出,如果只有A任务,这就是可以使得总时间最小的排布方法,对应的总时间为:
(maxCount−1)·(n+1) + 1

同理,如果还有其它也需要执行maxCount次的任务,我们也需要将它们依次排布成列。例如,当还有任务B和C时,我们需要将它们排布在矩阵的第二、三列。
任务调度器
果需要执行 maxCount次的任务的总数量为maxNum,那么类似地可以得到对应的总时间为:
(maxCount −1)·(n+1) + maxNum

处理完执行次数为maxCount次的任务,剩余任务的执行次数一定都小于 maxCount,那么我们应当如何将它们放入矩阵中呢?
一种构造的方法是,我们从倒数第二行开始,按照反向列优先的顺序(即先放入靠左侧的列,同一列中先放入下方的行),依次放入每一种任务,并且同一种任务需要连续地填入。例如还有任务D,E 和F 时,我们会按照下图的方式依次放入这些任务。
任务调度器
对于任意一种任务而言,一定不会被放入同一行两次,间隔时间一定达到了n。因此如果我们按照这样的方法填入所有的任务,那么就可以保证总时间不变,仍然为:(maxCount −1)·(n+1) + maxNum

代码如下:

// 方法二:构造法
 public int leastInterval(char[] tasks, int n){
     // 用HashMap统计每个任务出现的次数
     HashMap<Character, Integer> countMap = new HashMap<>();
     for (char task: tasks)
         countMap.put(task, countMap.getOrDefault(task, 0) + 1);

     // 任务种类数量
     int t = countMap.size();

     int maxCount = 0;
     int maxNum = 0;

     // 1. 计算maxCount
     for (int count: countMap.values()){
         maxCount = Math.max(maxCount, count);
     }

     // 2. 计算maxNum
     for (char task: countMap.keySet()){
         if (countMap.get(task) == maxCount)
             maxNum ++;
     }

     // 3. 按照公式直接返回
     return Math.max(tasks.length, (maxCount - 1) * (n + 1) + maxNum);
 }

杂度分析

  • 时间复杂度:O(N +∣Σ∣),其中N是数组tasks的长度,∣Σ∣是数组tasks
    中出现任务的种类,在本题中任务都用大写字母表示,因此∣Σ∣不会超过 26。
  • 空间复杂度:O(∣Σ∣)。
上一篇:Hexo 中使用 emoji 和 tasks


下一篇:.net 控制线程数量