小M的多任务下载器挑战 | 豆包MarsCode AI刷题

97 阅读6分钟

这次笔记,我们来分析一个关于线性扫描的算法题。虽然对于较少数据量且无性能限制的情况下可以特殊解答,但是对于新手来说,我们还是需要了解如何进行优化性能这一要点。

题目与示例

问题描述

小M的程序设计大作业是编写一个多任务下载器。在实现过程中,他遇到了一个问题:在一次下载过程中,总共有N个任务,每个任务会在第x秒开始,并持续y秒。小M需要知道,在同一时刻,最多有多少个任务正在同时下载,也就是计算出任务的最高并发数。

  • n 表示任务的数量。
  • array 是一个二维列表,每个元素为[x, y],表示任务的开始时间和持续时间,其中:
  • x 表示任务的开始时间;
  • y 表示任务的持续时间。

测试样例

样例1:

输入:n = 2 ,array = [[1, 2], [2, 3]]
输出:2

样例2:

输入:n = 4 ,array = [[1, 2], [2, 3], [3, 5], [4, 3]]
输出:3

样例3:

输入:n = 5 ,array = [[1, 3], [3, 4], [2, 2], [6, 5], [5, 3]]
输出:3

入手思路

相信正常初学新手在遇到这类类似于线性扫描类的题目,首先的第一反应就是对每个时间点下是否有存活的任务的扫描,同时统计每个时间点上正在执行的任务数量,进而找出最大并发数。这种想法与思路是很正常而且入手简单的方法,即暴力算法

它的主要优势就是对于每个时间点(每秒钟下)都会进行一次全任务列表访问,对于初学者来说至少不会漏缺一些任务或是思考方式不会过于繁琐。让我们先看看代码:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 计算任务的最高并发数
int maxConcurrency(int n, std::vector<std::vector<int>>& array) {
    
    // 第一部分
    // 找到所有任务中最早开始时间
    int earliestStart = array[0][0];
    for (const auto& task : array) {
        earliestStart = std::min(earliestStart, task[0]);
    }

    // 找到所有任务中最晚结束时间
    int latestEnd = array[0][0] + array[0][1];
    for (const auto& task : array) {
        int endTime = task[0] + task[1];
        latestEnd = std::max(latestEnd, endTime);
    }

    // 第二部分
    // 遍历每个时间点
    int maxConcurrencyNum = 0;
    for (int time = earliestStart; time < latestEnd; ++time) {
        int curConcurrency = 0;
        // 针对每个时间点,遍历所有任务判断是否正在执行
        for (const auto& task : array) {
            int start = task[0];
            int duration = task[1];
            if (start <= time && time < start + duration) {
                curConcurrency++;
            }
        }
        maxConcurrencyNum = std::max(maxConcurrencyNum, curConcurrency);
    }

    return maxConcurrencyNum;
}

1.确定时间范围(第一部分)

我们在此处先使用了两个for循环对原列表中最早开始时间 earliestStart 和最晚结束时间 latestEnd进行查找,由于每一个循环的时间复杂度为O(n),因此两个for循环查找的时间复杂度为O(n)
(两个for总和为O(2n),由公式 O(c × f(n)) = O(f(n)) 可得“2”被约去)

2.遍历时间点并统计并发数(第二部分)

外层 for 循环按照时间(m)顺序遍历从最早开始时间到最晚结束时间之间的每一个时间点。对于每个时间点,内层 for 循环会遍历所有的任务(n),通过判断当前时间点是否在任务的开始时间和结束时间之间(即 start <= time && time < start + duration)。如果在这个区间内,就说明该任务在这个时间点正在执行,将当前并发任务数量 curConcurrency 加 1。
此处的双循环结构会对每秒钟进行一次全列表数量扫描,也就是说总共下来总数为 n × m。则此处的时间复杂度应记作 O(n × m) 。(n为任务数、m为最大时间)

通过暴力算法下来,我们明显发现这个算法的时间复杂度为O(n × m),其原式应为O(n × m + n),由于当数据任务量和时间总值足够大时,此处的 (n) 可忽略。

深入探究

由暴力算法我们可以得知,当时间最大值越大或是任务量越大,时间复杂度上升的越快,因此这种方法仅仅适用于较小的场景下应用,高并发情况下肯定是撑不住的了。那该如何优化呢?

首先我们肯定从大头下手,即第二部分内容。既然我们是对每个时间下进行一次遍历,那其实我们可以换个角度来想:
原先我们是将所有任务单类似于复制一次到每个时间点,那为什么不把每个仅一份的任务单按顺序悬挂在“出餐表”上,有“餐”就挂上,完成就取下销毁。
恭喜你,你已经想到线性扫描的实现方式了~

虽然我们还是要对所有的时间表进行依次查询,但是这次我们只需要花费 O(nLog(n)) 的时间复杂度即可完成扫描。代码见下方:

#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 计算任务的最高并发数
int maxConcurrency(int n, std::vector<std::vector<int>>& array) {

    vector<int> startTimes;
    vector<int> endTimes;
    // 分别将任务的开始时间和结束时间存入不同的vector
    for (const auto& task : array) {
        startTimes.push_back(task[0]);
        endTimes.push_back(task[0] + task[1]);
    }
    // 对开始时间和结束时间向量分别进行排序
    sort(startTimes.begin(), startTimes.end());
    sort(endTimes.begin(), endTimes.end());

    int max_concurrency = 0;
    int cur_concurrency = 0;
    int startSize = startTimes.size();
    int endSize = endTimes.size();
    int i = 0, j = 0;
    // 线性扫描所有时间点
    while (i < startSize && j < endSize) {
        if (startTimes[i] < endTimes[j]) {
            cur_concurrency++;
            max_concurrency = max(max_concurrency, cur_concurrency);
            i++;
        }
        else {
            cur_concurrency--;
            j++;
        }
    }
    // 如果开始时间还有剩余,全部处理完(都算并发任务)
    while (i < startSize) {
        cur_concurrency++;
        max_concurrency = max(max_concurrency, cur_concurrency);
        i++;
    }
    return max_concurrency;
}

对于这个线性扫描算法来说,总结下来就是:
将原数组的数据分成两个vector分别储存开始时间与结束时间,此处我们就可以看作将任务压缩成线性状。
然后对两个vector进行排序,排序完成后使用两个指针 i 和 j 分别指向 startTimes 和 endTimes 向量当前处理的元素位置,然后通过 while 循环进行线性扫描。

  • 当 startTimes[i] < endTimes[j] 时,说明有新任务开始了,此时将当前并发任务数 cur_concurrency 加 1,并更新最大并发数 max_concurrency,然后移动 i 指针指向下一个开始时间,去检查下一个任务是否开始。
  • 当 startTimes[i] >= endTimes[j] 时,意味着有任务结束了,就将当前并发任务数 cur_concurrency 减 1,并移动 j 指针指向下一个结束时间,去处理下一个任务的结束情况。 最后剩余的循环用于处理可能存在开始时间还没处理完的情况(也就是开始时间的向量还有剩余元素),所以通过另一个 while 循环将它们依次处理从而完成整体结构。

总体来说,这次的代码直接使得整体的时间复杂度降低到 O(nLog(n)) 的程度,通过下图对比即可明显看到性能差异(仅从数学理论层面分析,具体)

性能理论拟合.jpg

红线为模拟O(n × m),蓝线为模拟O(nLog(n))

2024.11.30
NightToona