大家好,我是挨打的阿木木,爱好算法的前端摸鱼老。最近会频繁给大家分享我刷算法题过程中的思路和心得。如果你也是想提高逼格的摸鱼老,欢迎关注我,一起学习。
题目
剑指 Offer 51. 数组中的逆序对
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
示例 1:
输入: [7,5,6,4]
输出: 5
限制:
0 <= 数组长度 <= 50000
思路
常规操作可以暴力通过两个for循环去枚举每个小于前一个的元素,但是这道题目数组长度最多可能有5万条,双重for循环之后会达到一个非常大的数据,铁定是通不过循环的,所以需要找规律。
- 我们可以从后面往前遍历,同时对已遍历过的数据做一轮排序,从小到达排序;
- 找到第一个大于等于当前元素的索引值,这也是元素即将插入的位置;
- 如果这时索引的元素如果为-1,说明当前元素是最大值,那么可以形成当前数组长度个逆序对,结果加上当前数组长度,同时排序数组添加当前元素;
- 如果找到了大于等于当前元素的索引
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;
};
结果
时间花的太久了,于是想一下找索引的时候优化一下,先判断一下是否最大值,如果不是的话,通过二分查找索引。
二分查找优化
/**
* @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;
}
优化结果
可以看到在查找效率上已经提高了很多了,但是仍然还是差很多。说明这题目解法有更合适的,这条路走到这里已经没什么优化空间了。所以咱们再想想其他解法。
归并排序
我们可以把数组切割成一个个元素,然后两两组合递归排序,如果后半部分的数据小于前半部分的数据,说明可以形成前面数据长度个逆序对。
实现
/**
* @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~
很遗憾,再次超时了
那么我们顺着思路,不进行来回的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;
};
最终结果
看懂了的小伙伴可以点个关注、咱们下道题目见。如无意外以后文章都会以这种形式,有好的建议欢迎评论区留言。