[前端]_一起刷leetcode 剑指 Offer 51. 数组中的逆序对

158 阅读5分钟

大家好,我是挨打的阿木木,爱好算法的前端摸鱼老。最近会频繁给大家分享我刷算法题过程中的思路和心得。如果你也是想提高逼格的摸鱼老,欢迎关注我,一起学习。

题目

剑指 Offer 51. 数组中的逆序对

在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。

 

示例 1:

输入: [7,5,6,4]
输出: 5

 

限制:

0 <= 数组长度 <= 50000

思路

常规操作可以暴力通过两个for循环去枚举每个小于前一个的元素,但是这道题目数组长度最多可能有5万条,双重for循环之后会达到一个非常大的数据,铁定是通不过循环的,所以需要找规律。

  1. 我们可以从后面往前遍历,同时对已遍历过的数据做一轮排序,从小到达排序;
  2. 找到第一个大于等于当前元素的索引值,这也是元素即将插入的位置;
  3. 如果这时索引的元素如果为-1,说明当前元素是最大值,那么可以形成当前数组长度个逆序对,结果加上当前数组长度,同时排序数组添加当前元素;
  4. 如果找到了大于等于当前元素的索引index,那么可以形成index个逆序对。结果加上index,同时排序数组添加当前元素。

常规实现

/**
 * @param {number[]} nums
 * @return {number}
 */
var reversePairs = function(nums) {
    if (nums.length < 2) return [];

    // 记录个数
    let result = 0;
    let stack = [ nums[nums.length - 1] ];
    // 往前往后构建单调递增序列
    for (let i = nums.length - 2; i >= 0; i--) {
        // 先判断是不是最大值,不是才进行比较
        if (nums[i] > stack[stack.length - 1]) {
            result += stack.length;
            stack.push(nums[i]);
            continue;
        }

        // 找到第一个比它大的元素
        let index = stack.findIndex(item => item >= nums[i]);

        // 找到了。说明能形成index个逆序对
        result += index;
        stack.splice(index, 0, nums[i]);
    }

    return result;
};

结果

image.png

时间花的太久了,于是想一下找索引的时候优化一下,先判断一下是否最大值,如果不是的话,通过二分查找索引。

二分查找优化

/**
 * @param {number[]} nums
 * @return {number}
 */
var reversePairs = function(nums) {
    if (nums.length < 2) return [];

    // 记录个数
    let result = 0;
    let stack = [ nums[nums.length - 1] ];
    // 往前往后构建单调递增序列
    for (let i = nums.length - 2; i >= 0; i--) {
        // 先判断是不是最大值,不是才进行比较
        if (nums[i] > stack[stack.length - 1]) {
            result += stack.length;
            stack.push(nums[i]);
            continue;
        }

        // 找到第一个比它大的元素
        let index = findInsertIndex(stack, nums[i]);

        // 找到了。说明能形成index个逆序对
        result += index;
        stack.splice(index, 0, nums[i]);
    }

    return result;
};

// 二分查找找到插入位置
function findInsertIndex(arr, val) {
    let left = 0, right = arr.length - 1;

    // 找到第一个大于等于元素的索引
    // 如何判断是第一个呢? 当前值大于等于元素,上一个值小于元素
    while (left < right) {
        let mid = Math.floor((left + right) / 2);
        if (arr[mid] >= val) {
            // 上一个值小于元素直接返回
            if (!mid || arr[mid - 1] < val) {
                return mid;
            }

            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }

    return left;
}

优化结果

image.png

可以看到在查找效率上已经提高了很多了,但是仍然还是差很多。说明这题目解法有更合适的,这条路走到这里已经没什么优化空间了。所以咱们再想想其他解法。

归并排序

我们可以把数组切割成一个个元素,然后两两组合递归排序,如果后半部分的数据小于前半部分的数据,说明可以形成前面数据长度个逆序对。

实现

/**
 * @param {number[]} nums
 * @return {number}
 */
var reversePairs = function(nums) {
    // 记录逆序对个数
    let count = 0;
    
    // 合并两个有序的数组
    function mergeTwoArray(arr1, arr2) {
        if (!arr2) return arr1;
        
        let result = [];

        while (arr1.length && arr2.length) {
            // 如果值比当前第一个序列的第一个元素小,在升序数组中就是最小的
            // 因而可以形成arr1.length个逆序对
            if (arr2[0] < arr1[0]) {
                result.push(arr2.shift());
                count += arr1.length;
            } else {
                result.push(arr1.shift());
            }
        }

        return result.concat([ ...arr1, ...arr2 ]);
    }

    // 切割成一个个小片段
    let sortArr = nums.map(v => [ v ]);

    // 朴实无华的归并排序
    while (sortArr.length > 1) {
        const len = Math.ceil(sortArr.length / 2);

        for (let i = 0; i < len; i++) {
            sortArr[i] = mergeTwoArray(sortArr[2 * i], sortArr[2 * i + 1]);
        }

        sortArr.length = len;
    }

    return count;
};

BOOM~

很遗憾,再次超时了

image.png

那么我们顺着思路,不进行来回的push和shift操作了,直接交换节点值试试。

我们可以记录要合并的两段代码的索引,一轮遍历完成交换节点。不过有个注意点是,把后面的节点交换到前面的时候,会打乱原本的顺序,所以需要提前复制一轮节点,用于映射。

最终代码

/**
 * @param {number[]} nums
 * @return {number}
 */
var reversePairs = function(nums) {
    let n = nums.length;
    // 记录逆序对个数
    let count = 0;
    
    // 合并两段有序的数组
    function mergeTwoArray(start, base, temp) {
        let p1 = start;
            end1 = p2 = p1 + base / 2, 
            end2 = p2 + base / 2;

        if (p2 >= nums.length) return;

        // 复制下来这个片段, 不然前面的插入了后面的顺序会乱
        temp = nums.slice(p1, end2);

        for (let i = start; i < end2; i++) {
            if (p1 >= end1) break;

            let temp1 = temp[p1 - start],
                temp2 = temp[p2 - start];

            // 如果值比当前第一个序列的第一个元素小,在升序数组中就是最小的
            if (p2 < end2 && temp2 < temp1) {
                nums[i] = temp2;
                p2++;
                // 生成end1 - p1个逆序对
                count += end1 - p1;
            } else {
                // 从缓存中拿,因为当前数组插入过后可能乱了
                nums[i] = temp1;
                p1++;
            }
        }
    }

    // 两两进行组合排序
    let base = 1;
    // 工具人,拿来做缓存
    let temp = [];
    // 朴实无华的归并排序
    while (n > 1) {
        base *= 2;
        n = Math.ceil(n / 2);
        for (let i = 0; i < n; i++) {
            mergeTwoArray(base * i, base, temp);
        }
        
    }

    return count;
};

最终结果

image.png

看懂了的小伙伴可以点个关注、咱们下道题目见。如无意外以后文章都会以这种形式,有好的建议欢迎评论区留言。