算法: 最长连续序列

788 阅读2分钟

给定一个未排序的整数数组 nums ,例如[100, 4, 200, 1, 3, 2], 找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。请你设计并实现时间复杂度为 O(n) 的算法解决此问题。
示例 1:

输入: nums = [100,4,200,1,3,2]
输出: 4
解释: 最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。

示例 2:

输入: nums = [0,3,7,2,5,8,4,6,0,1]
输出: 9
解释:最长数字连续序列是[0, 1, 2, 3, 4, 5, 6, 7, 8]

提示:

  • 0 <= nums.length <= 10510^5
  • -10910^9 <= nums[i] <= 10910^9 要找出数字的连续的最长序列长度,一般会想到先排个序,然后再遍历一次找出最长的连续长度。首先要知道对于冒泡排序、快速排序等算法,时间复杂度为O(N2N^2)、O(NlogN), 所以先排序的方法肯定满足不了题目的要求(时间复杂度O(N))。
    在上一篇算法: 缺失的第一个正数中有提出哈希表,那么要求最长的连续序列,是不是也能找到有限范围,例如[100, 4, 200, 1, 3, 2]的有限范围为1至200,将对应的数字作为哈希表的索引,并在该位置做标记,例如hash[100] = 1。
/**
 * 参考“缺失的第一个正整数”算法,可结合哈希表实现
 * 设置一个长度等于数组最大值的哈希表,遍历数组,将值当着索引,把哈希表对应位置的value设置为1
 * 在遍历一次哈希表,其值为1的连续序列长度即为最大长度
 * @param {number[]} nums
 * @return {number}
 */
 var longestConsecutive = function(nums) {
    const hashArr = [], len = nums.length
    let max = -Infinity, continuousLen = 0

    for (let i = 0; i < len; i++) {
        hashArr[nums[i]] = 1
        max = Math.max(max, nums[i])
    }

    let tempMaxLen = 0
    for (let i = 0; i <= max; i++) {
        if (hashArr[i] === 1) { // 如果索引i处的value有值,可能连续,将tempMaxLen加1
            tempMaxLen++
        } else { // 连续被中断,将上一次连续值和结果continuousLen比较,保存最大值
            continuousLen = Math.max(continuousLen, tempMaxLen)
            // 清零tempMaxLen,重新计算连续长度
            tempMaxLen = 0
        }
    }
    continuousLen = Math.max(continuousLen, tempMaxLen)

    return continuousLen
}

在执行测试用例[0,-1]时,程序报错,因为这里只考虑了正数的情况,直接把-1给忽略了。如果要考虑负数,那么除了找出最大max,也需要找出最小min,然后i从min开始遍历,这样能保证所有范围都遍历到。改造部分如下:

    let max = -Infinity, min = Infinity, continuousLen = 0
    for (let i = 0; i < len; i++) {
        hashArr[nums[i]] = 1
        max = Math.max(max, nums[i])
        min = Math.min(min, nums[i])
    }

    let tempMaxLen = 0
    // i从min开始遍历,这样能保证所有范围都能遍历到
    for (let i = min; i <= max; i++) {
        if (hashArr[i] === 1) { // 如果索引i处的value有值,可能连续,将tempMaxLen加1
            tempMaxLen++
        } else { // 连续被中断,将上一次连续值和结果continuousLen比较,保存最大值
            continuousLen = Math.max(continuousLen, tempMaxLen)
            // 清零tempMaxLen,重新计算连续长度
            tempMaxLen = 0
        }
    }

以上代码在执行测试用例[0,1,2,4,8,5,6,7,9,3,55,88,77,99,999999999]时,程序报错,提示“超出时间限制”,用例最后一个数字为999999999,接近101010^{10},本来就只有15个数,结果遍历了101010^{10}次。所以遍历值域区间是不合理的,思考是否可以遍历原nums数组,然后根据每个元素来匹配连续性。改造如下,再次遍历nums数组,判断每个元素在hashArr的连续性。

    let tempMaxLen = 0
    for (let i = 0; i < len; i++) {
        let min = nums[i]
        while(hashArr[min] === 1) {
            tempMaxLen++
            min++
        }
        continuousLen = Math.max(continuousLen, tempMaxLen)
        tempMaxLen = 0
    }

以上代码有两个问题,首先是每个元素都会重复判断连续性,例如nums为1到100的连续数组,i = 0, 判断了从nums[0]开始的连续性,求得连续长度为100,遍历i = 1还会再次求1之后的连续性,求得连续长度为99,一直重复无用的判断;另一个问题,每个元素只找了向右的连续性,没有判断向左的连续性。
以下代码首先添加了向左连续的判断,那么当前元素的最大连续性即为leftMaxLen + rightMaxLen - 1。对于重复判断,在while之前添加了continuousLen > len - i判断,如果已经计算出的最大连续长度大于剩下未遍历的元素,那么直接终止遍历。

    for (let i = 0; i < len; i++) {
        let rightMaxLen = 0, leftMaxLen = 0
        //如果连续长难度大于剩下的数组长度,则终止遍历
        if (continuousLen > len - i) {
            break;
        }
        let j = nums[i]
        while(hashArr[j] === 1) {
            // 计算索引j右连续长度
            rightMaxLen++
            j++
        }
        j = nums[i]
        while(hashArr[j] === 1) {
            // 计算索引j左连续长度
            leftMaxLen++
            j--
        }
        continuousLen = Math.max(continuousLen, leftMaxLen + rightMaxLen - 1)
    }

虽然以上的方法能通过所有的测试用例,但其耗时只超过26%的提交。算法还是有比较多的无效遍历,得考虑进一步优化。

最大连续序列长度耗时.png 如何减少无效的重复遍历,是否能够做到对于连续的序列,例如[3, 4, 5, 2, 1, 10, 20, 30],对于元素1,2, 3, 4, 5如果只遍历一次连续性,则能解决无效的重复遍历。在连续区间[1, 5],我们能观察到的是最小、最大值,那么我们是否可以通过某种方式,只在最小值、或最大值开始连续性判断,这里我们以最小值开始判断举例,可以通过左连续判断直接过滤掉中间值,例如遍历3时,判断hashArr是否存在2,如果存在则直接跳过,不存在则开始向右连续性判断。遍历流程如下:

i = 0, value = 3, 因为hashArr[2]存在,跳过;
i = 1, value = 4, 因为hashArr[3]存在,跳过;
...
i = 4, value = 1, hashArr[0]不存在,判断连续长度,maxlen = 5
i = 5, value = 10, hashArr[9]不存在,判断连续长度,maxlen = 1
...
    for (let i = 0; i < len; i++) {
        let rightMaxLen = 0, j = nums[i]
        // 如果j - 1存在,则跳过,直接用j - 1算连续长度
        if (hashArr[j - 1] === 1) {
            continue
        }
        while(hashArr[j] === 1) {
            // 计算索引j右连续长度
            rightMaxLen++
            j++
        }
        continuousLen = Math.max(continuousLen, rightMaxLen)
    }

通过以上的策略,最终求得最大长度为5,并且避免了重复判断。但以上算法的执行耗时还是不理想,耗时未超过30%的提交。 在做二次遍历时,其长度len为原数组的长度,假如nums有100个元素,但其中有50项重复的,那么程序执行了49次不必要的检查。基于此,考虑将hashArrr数组用JS原生对象Set或者Map替换,去掉重复项,然后直接遍历Set存储的值,这样只会产生51次遍历。

    const hashArr = new Set(), len = nums.length
    let continuousLen = 0
    for (let i = 0; i < len; i++) {
        hashArr.add(nums[i])
    }
    for (let i of hashArr) {
        let rightMaxLen = 0
        // 如果j - 1存在,则跳过,直接用j - 1算连续长度
        if (hashArr.has(i - 1)) {
            continue
        }
        let j = i
        while(hashArr.has(j)) {
            // 计算索引j右连续长度
            rightMaxLen++
            j++
        }
        continuousLen = Math.max(continuousLen, rightMaxLen)
    }

以上代码首先将nums数组的所以元素存储到类型为Set的hashArr中,去除重复的值。那么在判断当前元素的左连续值可通过hashArr.hash(i - 1)判断。最终的执行耗时超过了92%的提交,但内存消耗处于中等水平,看了下内存占用比较小的提交,部分提交的时间复杂度不满足题目要求,例如先做了排序,所以内存消耗占比也仅供参考吧。

算法系列目录地址: 算法系列

写在最后:
如果大家有其他问题可直接留言,一起探讨!\color{red}{如果大家有其他问题可直接留言,一起探讨!} 最近我会持续更新Vue源码介绍、前端算法系列。\color{red}{最近我会持续更新Vue源码介绍、前端算法系列。} 感兴趣的可关注一波。\color{red}{感兴趣的可关注一波。}