算法-分治法-计算右侧小于当前元素的个数(逆序数)
1 题目概述
1.1 题目出处
https://leetcode-cn.com/problems/count-of-smaller-numbers-after-self/
1.2 题目描述
给定一个整数数组 nums,按要求返回一个新数组 counts。数组 counts 有该性质: counts[i] 的值是 nums[i] 右侧小于 nums[i] 的元素的数量。
示例:
输入: [5,2,6,1]
输出: [2,1,1,0]
解释:
5 的右侧有 2 个更小的元素 (2 和 1).
2 的右侧仅有 1 个更小的元素 (1).
6 的右侧有 1 个更小的元素 (1).
1 的右侧有 0 个更小的元素.
2 分治法+归并+索引
2.1 解题思路
这个题首先想到的肯定是暴力扫描,即每个数都和右边所有数比,时间复杂度为O(N^2)。
有没有更好的方式呢?试试归并,复杂度O(N*logN)。
拆分成单独元素,合并排序时,记录下每个元素的原始位置以及计算出右侧小于当前元素的个数。
在第二次归并时,因为左侧元素肯定在右侧元素左边(对,这是句废话,但是很重要),且我们的元素已经排序。那么俩边比较的时候,只要左边元素L1比右边元素R1更大,也就意味着左边元素的下一个元素L2也比右边元素R1更大。
通过这个方式,我们就不用比较每个元素了,节约了大量时间。
此外,前面提到过要记录下每个元素的原始位置,这里因为整型元素可能重复,所以我们建立了一个Node来保存他的原始下标,顺带还存放了元素的值和右侧小于当前元素的个数,使用更方便。
2.2 代码
class Solution {
// 保存结果数组
private List<Integer> resultList = new ArrayList<>();
// Node中存放了元素的值、在原始数组中的下标、右侧小于当前元素的个数
private List<Node> nodeList = new ArrayList<>();
public List<Integer> countSmaller(int[] nums) {
if(nums.length == 0){
return resultList;
}
// 初始化resultList和nodeList
for(int i = 0; i < nums.length; i++){
resultList.add(0);
Node node = new Node(nums[i], i);
nodeList.add(node);
}
// 开始归并排序及计算右侧小于当前元素的个数
count(0, nums.length-1);
return resultList;
}
// Node中存放了元素的值、在原始数组中的下标、右侧小于当前元素的个数
class Node{
public int val;
public int index;
public int cnt = 0;
public Node(int val, int index){
this.val = val;
this.index = index;
}
}
// 拆分计算和归并
private void count(int low, int high){
if(low < high){
int middle = (low+high)/2;
count(low, middle);
count(middle + 1, high);
merge(low, middle, high);
}
}
// 归并过程和计算右侧小于当前元素的个数
private void merge(int low, int middle, int high){
// 暂存已按大小排序Node,用来复制回原List时使用
ArrayList<Node> tmpList = new ArrayList<>();
int i = low;
int j = middle + 1;
// 两次遍历之间左边元素计算得到的右侧小于当前元素的个数的增量
int increase = 0;
// 标识该次循环是否和上次循环使用同一个左边元素
boolean repeat = false;
while(i <= middle && j <= high){
Node left = nodeList.get(i);
Node right = nodeList.get(j);
if(left.val <= right.val){
// 左边小于等于右边元素
if(!repeat){
// 首次遍历该左边元素,此时他更小,就只是加上此前的增量
// 因为增量是前面遍历的更小的左边元素和右边元素比较得到的统计数,
// 意味着当前元素也比那些已遍历过的右边元素更大
left.cnt = left.cnt + increase;
// 更新该下标元素的`右侧小于当前元素的个数`
resultList.set(left.index, left.cnt);
}else{
// 重复遍历该元素,说明上一轮是改元素比右边元素大,已经更新过,这里不再更新
repeat = false;
}
// 将该元素放入tmpList,tmpList是按从小到大排序的list
tmpList.add(left);
// 准备遍历左边元素的下一个元素
i++;
}else{
// 左边大于右边元素
if(!repeat){
// 首次遍历该左边元素,此时他更大,就需要加上此前增量,
// 同时还需要加1,因为此时比右边元素大
left.cnt = left.cnt + 1 + increase;
}else{
// 重复遍历时,只需要将当前元素的`右侧小于当前元素的个数`加1即可
left.cnt += 1;
}
// 左边元素比右边元素更大,则每次将增量加一
increase++;
// 标记下次还要用自己
repeat = true;
// 更新该下标元素的`右侧小于当前元素的个数`
resultList.set(left.index, left.cnt);
tmpList.add(right);
// 准备遍历右边元素的下一个元素
j++;
}
}
if(i <= middle){
// 说明至少左边有一个较大元素比右边最大的元素还大
// 将该元素放入临时列表
tmpList.add(nodeList.get(i++));
// 从该元素的下一个元素开始处理,因为该元素已经在前面循环处理过
while(i <= middle){
Node left = nodeList.get(i++);
left.cnt += increase;
resultList.set(left.index, left.cnt);
tmpList.add(left);
}
}
if(j <= high){
// 说明至少右边有一个元素比左边最大元素还大
// 此时只需要更新他们的resultList
// 而不需要复制到tmpList,因为他们是靠右的已排序元素,
// 所以排序后依然位置不变
while(j <= high){
Node right = nodeList.get(j);
resultList.set(right.index, right.cnt);
j++;
}
}
// 将临时list复制到原始nodeList,此时nodeList的low->high位置的元素有序
for(int k = 0; k < tmpList.size(); k++, low++){
nodeList.set(low, tmpList.get(k));
}
}
}
2.3 时间复杂度
2.4 空间复杂度
O(NlogN)
3 分治法+归并+索引 优化一
3.1 解题思路
前面代码中merge
方法太过复杂,很容易写错。
思考了下,其实根本不用每次都判断是否重复,流程如下:
- 我们只需要在右边元素出列的时候将统计数字(rightCnt)加1,然后放入临时list
- 而左边元素出列的时候,将对应的
右侧小于当前元素的个数
更新为 原值 + rightCnt,然后放入临时list - 没处理完的左边元素,依次取出按步骤2处理
- 没处理完的右边元素,不需要处理,因为他们是靠右的已排序元素,所以排序后依然位置不变
这样处理完后,自然就得到已排序列表和他们对应的右侧小于当前元素的个数
了。
3.2 代码
class Solution {
// 保存结果数组
private List<Integer> resultList = new ArrayList<>();
// Node中存放了元素的值、在原始数组中的下标、右侧小于当前元素的个数
private List<Node> nodeList = new ArrayList<>();
public List<Integer> countSmaller(int[] nums) {
if(nums.length == 0){
return resultList;
}
// 初始化resultList和nodeList
for(int i = 0; i < nums.length; i++){
resultList.add(0);
Node node = new Node(nums[i], i);
nodeList.add(node);
}
// 开始归并排序及计算右侧小于当前元素的个数
count(0, nums.length-1);
return resultList;
}
// Node中存放了元素的值、在原始数组中的下标、右侧小于当前元素的个数
class Node{
public int val;
public int index;
public int cnt = 0;
public Node(int val, int index){
this.val = val;
this.index = index;
}
}
// 拆分计算和归并
private void count(int low, int high){
if(low < high){
int middle = (low+high)/2;
count(low, middle);
count(middle + 1, high);
merge(low, middle, high);
}
}
// 归并过程和计算右侧小于当前元素的个数
// 1. 我们只需要在右边元素出列的时候将统计数字(rightCnt)加1,然后放入临时list
// 2. 而左边元素出列的时候,将对应的`右侧小于当前元素的个数`更新为 原值 + rightCnt,然后放入临时list
// 3. 没处理完的左边元素,依次取出按步骤2处理
// 4. 没处理完的右边元素,不需要处理,因为他们是靠右的已排序元素,所以排序后依然位置不变
private void merge(int low, int middle, int high){
// 暂存已按大小排序Node,用来复制回原List时使用
ArrayList<Node> tmpList = new ArrayList<>();
int i = low;
int j = middle + 1;
// 右侧出列进入临时list的元素数量
int rightCnt = 0;
while(i <= middle && j <= high){
Node left = nodeList.get(i);
Node right = nodeList.get(j);
if(left.val <= right.val){
// 左边小于等于右边元素
left.cnt += rightCnt;
// 更新该下标元素的`右侧小于当前元素的个数`
resultList.set(left.index, left.cnt);
// 将该元素放入tmpList,tmpList是按从小到大排序的list
tmpList.add(left);
// 准备遍历左边元素的下一个元素
i++;
}else{
// 左边大于右边元素
rightCnt++;
tmpList.add(right);
// 准备遍历右边元素的下一个元素
j++;
}
}
// 处理比右边元素都大的左边元素
while(i <= middle){
Node left = nodeList.get(i++);
left.cnt += rightCnt;
resultList.set(left.index, left.cnt);
tmpList.add(left);
}
// 不处理比左边元素都大的右边元素
// 不需要复制到tmpList,因为他们是靠右的已排序元素,
// 所以排序后依然位置不变
// 将临时list复制到原始nodeList,此时nodeList的low->high位置的元素有序
for(int k = 0; k < tmpList.size(); k++, low++){
nodeList.set(low, tmpList.get(k));
}
}
}
3.3 时间复杂度
3.4 空间复杂度
O(NlogN)
4 分治法+归并+索引 优化二 索引数组
4.1 解题思路
优化一中仅仅去掉了repeat判断,但是从执行时间来看其实优化不大。
仔细看看原来代码,需要多次使用Node对象,jvm对对象处理是很花费时间的。
仔细观察Node中的元素:
class Node{
public int val;
public int index;
public int cnt = 0;
public Node(int val, int index){
this.val = val;
this.index = index;
}
}
-
cnt代表右侧小于当前元素的个数,这个其实可以通过resultList直接维护不需要存
-
val和index
放置在这里的原因就是index会变,而val是不会变的。那么我们只要能维护一个索引数组,在里面存放元素在原始数组的下标不就行了吗?还能通过这个来找到元素的值,一举两得。也就是说,要排序时,我们只需要交换这个索引数组就行。
4.2 代码
class Solution {
// 保存结果数组
private List<Integer> resultList = new ArrayList<>();
// 索引数组,存储每个元素在原始数组中的下标
// 后续我们交换元素时,只改变他们的索引数组
private int[] indexes;
// 暂存已按大小排序数字的索引
private int[] tmpIndexes;
public List<Integer> countSmaller(int[] nums) {
if(nums.length == 0){
return resultList;
}
indexes = new int[nums.length];
tmpIndexes = new int[nums.length];
// 初始化resultList和indexes
for(int i = 0; i < nums.length; i++){
resultList.add(0);
indexes[i] = i;
}
// 开始归并排序及计算右侧小于当前元素的个数
count(nums, 0, nums.length-1);
return resultList;
}
// 拆分计算和归并
private void count(int[] nums, int low, int high){
if(low < high){
int middle = (low+high)/2;
count(nums, low, middle);
count(nums, middle + 1, high);
merge(nums, low, middle, high);
}
}
// 归并过程和计算右侧小于当前元素的个数
// 1. 我们只需要在右边元素出列的时候将统计数字(rightCnt)加1,然后将元素下标放入tmpIndexes
// 2. 而左边元素出列的时候,将对应的`右侧小于当前元素的个数`更新为 原值 + rightCnt,然后将元素下标放入tmpIndexes
// 3. 没处理完的左边元素,依次取出按步骤2处理
// 4. 没处理完的右边元素,不需要处理,因为他们是靠右的已排序元素,所以排序后依然位置不变
private void merge(int[] nums, int low, int middle, int high){
int i = low;
int j = middle + 1;
// 右侧出列进入临时list的元素数量
int rightCnt = 0;
// 记录当前放入tmpIndexes的下标
int k = 0;
while(i <= middle && j <= high){
int left = nums[indexes[i]];
int right = nums[indexes[j]];
if(left <= right){
// 左边小于等于右边元素
// 更新该下标元素的`右侧小于当前元素的个数`
resultList.set(indexes[i], resultList.get(indexes[i]) + rightCnt);
// 将该元素下标放入tmpIndexes,tmpIndexes是按下标对应元素值从小到大排序的
tmpIndexes[k++] = indexes[i];
// 准备遍历左边元素的下一个元素
i++;
}else{
// 左边大于右边元素
rightCnt++;
tmpIndexes[k++] = indexes[j];
// 准备遍历右边元素的下一个元素
j++;
}
}
// 处理比右边元素都大的左边元素
while(i <= middle){
resultList.set(indexes[i], resultList.get(indexes[i]) + rightCnt);
tmpIndexes[k++] = indexes[i++];
}
// 不处理比左边元素都大的右边元素
// 不需要复制到tmpIndexes,因为他们是靠右的已排序元素,
// 所以排序后依然位置不变
// 将临时tmpIndexes复制到原始indexes,此时indexes索引对应的元素大小按low->high有序
// 注意这里必须用k,而不能用tmpIndexes.length,因为前面未把右边元素放入tmpIndexes
for(int l = 0; l < k; l++, low++){
indexes[low] = tmpIndexes[l];
}
}
}
4.3 时间复杂度
O(NlogN)
- 划分logN,N个元素
4.4 空间复杂度
O(N)
- O(N)的indexes和O(N)的tmpIndexes
5 插入排序法
5.1 解题思路
倒序遍历原数组,每次放入一个已按从小到大排序的数组中,只要找到合适位置后再统计前面有几个数就得到了右侧小于当前元素的个数
。
这个方法最好理解,但是时间复杂度超过归并排序法。
5.2 代码
public List<Integer> countSmaller2(int[] nums) {
// 保存结果数组
List<Integer> resultList = new ArrayList<>();
if(null == nums || nums.length == 0){
return resultList;
}
for(int i = 0; i < nums.length; i++){
resultList.add(0);
}
List<Integer> sortedList = new ArrayList<>();
for(int i = nums.length -1; i >= 0; i--){
sortedList.add(nums[i]);
int j = sortedList.size() - 2;
for(; j >= 0; j--){
if(nums[i] <= sortedList.get(j)){
sortedList.set(j+1, sortedList.get(j));
sortedList.set(j, nums[i]);
}else{
break;
}
}
resultList.set(i, j + 1);
}
return resultList;
}
5.3 时间复杂度
O(N^2)
太惨了
5.4 空间复杂度
O(N)
6 二叉搜索树(BST) - 循环版本
6.1 解题思路
前面插入排序因为时间复杂度O(N^2)导致超时无法通过,关于排序还可以想到BST、堆等数据结构。
但是这里堆不适用,应该使用BST。
6.2 代码
class Solution {
// 保存结果数组
private List<Integer> resultList = new ArrayList<>();
class Node{
int val;
// 表示左子树有多少节点
int cnt;
Node left;
Node right;
public Node(int val, int cnt){
this.val = val;
this.cnt = cnt;
}
}
public List<Integer> countSmaller(int[] nums) {
if(nums.length == 0){
return resultList;
}
for(int i = 0; i < nums.length; i++){
resultList.add(0);
}
Node head = new Node(nums[nums.length - 1], 0);
resultList.set(nums.length - 1, 0);
Node currentNode = head;
for(int i = nums.length - 2; i >= 0; i--){
Node newNode = new Node(nums[i], 0);
int count = 0;
while(currentNode != null){
if(newNode.val > currentNode.val){
// 新节点大于当前节点,需要更新count,加上当前节点的左子树节点总数以及当前节点的数量1
count = count + currentNode.cnt + 1;
if(currentNode.right != null){
// 右子树不为空就继续判断右子树
currentNode = currentNode.right;
}else{
// 否则就将新节点节点作为左子树
currentNode.right = newNode;
// 插入结束,将新节点的`右侧小于当前元素的个数`设为累积到的count
resultList.set(i, count);
break;
}
}else{
// 新节点小于等于当前节点,则当前节点左子树节点数量加1
currentNode.cnt++;
if(currentNode.left != null){
// 左子树不为空就继续判断左子树
currentNode = currentNode.left;
}else{
// 否则就将新节点节点作为左子树
currentNode.left = newNode;
// 插入结束,将新节点的`右侧小于当前元素的个数`设为累积到的count
resultList.set(i, count);
break;
}
}
}
currentNode = head;
}
return resultList;
}
}
6.3 时间复杂度
平均O(NlogN)
- 插入logN,一共N个元素
6.4 空间复杂度
O(N)
- 构建N个Node
7 二叉搜索树(BST) - 递归版本
7.1 解题思路
前面的循环版本有点复杂,我们用递归版本试试。
7.2 代码
class Solution {
// 保存结果数组
private List<Integer> resultList = new ArrayList<>();
class Node{
// 在原数组下标
int index;
int val;
// 表示左子树有多少节点
int cnt;
Node left;
Node right;
public Node(int index, int val, int cnt){
this.index = index;
this.val = val;
this.cnt = cnt;
}
}
private Node insertBst(Node head, Node newNode, int count){
if(head == null){
// head为空,代表之前插入时的节点的子树为空
// 此时结束插入,将当前新节点的统计结果写入resultList
resultList.set(newNode.index, count);
// 返回当前节点作为之前节点的子树
return newNode;
}
if(newNode.val > head.val){
// 新节点大于当前节点,需要更新count,加上当前节点的左子树节点总数以及当前节点的数量1
// 然后继续往右子树插入
head.right = insertBst(head.right, newNode, count + head.cnt + 1);
}else{
// 新节点小于等于当前节点,则当前节点左子树节点数量加1
head.cnt++;
// 继续往左子树插入
head.left = insertBst(head.left, newNode, count);
}
// 走到这里,说明将新节点插入到本节点的某个子树后边去了,
// 所以这里返回当前节点作为父节点的子树,保持不变
return head;
}
public List<Integer> countSmaller(int[] nums) {
if(nums.length == 0){
return resultList;
}
// 初始化resultList,以便后面使用
for(int i = 0; i < nums.length; i++){
resultList.add(0);
}
// 初始化树根节点,用的是最后一个、没有比自己更小的右边数字的那个元素
Node head = new Node(nums.length - 1, nums[nums.length - 1], 0);
// 从倒数第二个数开始往BST中插入
for(int i = nums.length - 2; i >= 0; i--){
insertBst(head, new Node(i, nums[i], 0), 0);
}
return resultList;
}
}
7.3 时间复杂度
平均O(NlogN)
- 插入logN,一共N个元素
虽然看着简单明了,但还慢了1ms?
7.4 空间复杂度
O(N)
- 构建N个Node