题目地址:
https://www.lintcode.com/problem/russian-doll-envelopes/description
给定一个由长度为 2 2 2的数组组成的数组 A A A,要求找到最长链 [ ( a 0 , b 0 ) , ( a 1 , b 1 ) , ( a 2 , b 2 ) , . . . , ( a k , b k ) ] [(a_0,b_0),(a_1,b_1),(a_2,b_2),...,(a_k,b_k)] [(a0,b0),(a1,b1),(a2,b2),...,(ak,bk)]使得 a i < a i + 1 a_i<a_{i+1} ai<ai+1并且 b i < b i + 1 b_i<b_{i+1} bi<bi+1。返回这个最长链的长度。
此题的 O ( n 2 ) O(n^2) O(n2)时间的动态规划算法可以参考https://blog.csdn.net/qq_46105170/article/details/108660717。以下介绍最优解,可以达到 O ( n log n ) O(n\log n) O(nlogn)时间复杂度。但这个算法需要用到偏序集分解定理和对应的分解算法,可以参考https://blog.csdn.net/qq_46105170/article/details/108616895。
先将
A
A
A排序,第二关键字小者优先,如果一样大,就第一关键字大者优先(关于第一关键字为啥是大者优先,主要与反链分解算法的实现有关。可以继续看下面)。接着,对数对定义一个严格偏序,
x
<
y
⇔
x
1
<
y
1
∧
x
2
<
y
2
x<y\Leftrightarrow x_1<y_1\land x_2<y_2
x<y⇔x1<y1∧x2<y2(注意,上面的排序和这个严格偏序并不统一。这里的严格偏序的定义是因为题目要求和定理使用的要求)。根据严格偏序集的分解定理,其最长链从长度等于其最小反链分解的个数。我们考虑一下这里的反链是什么意思。两个数对
x
x
x和
y
y
y不可比较(不妨设
x
x
x在
A
A
A中的下标靠前),要么是
x
2
=
y
2
x_2=y_2
x2=y2,要么
x
1
≥
y
1
x_1\ge y_1
x1≥y1。所以这里的反链实际上就是指的非严格下降子序列。而针对这种问题的反链分解是有一个很好的算法的,具体来说如下:
1、遇到一个数字,如果其无法接到任何一个反链后面,就新开一个反链;
2、如果其能接到后面,那就找到一个末尾数字最小的数后面;这一点可以由二分来做(可以用二分处理反链是重点,正是由于能用二分,才能加快速度)。
以上接到后面的意思是,能接到反链后面依然形成反链。而分解出来的反链的末尾数字实际上是严格单调增的(数学归纳法可以证明),所以可以用二分法更新反链的末尾数字,也就是找到大于等于新来的数的最小的数。这样做的话,会比
O
(
n
2
)
O(n^2)
O(n2)时间的动态规划快很多,能做到
O
(
n
log
n
)
O(n\log n)
O(nlogn)时间复杂度。开一个数组
f
f
f,
f
[
i
]
f[i]
f[i]表示下标为
i
i
i的反链末尾数字,则反链的个数即为所求。
接下来我们就能看到,为什么在排序的时候,第一关键字是大者优先了。原因是这样的,如果是按照小者优先,那么在二分的时候,如果没找到比新来的数大的数,那也不能决定其要开一个新的反链,因为反链的条件是,第二关键字相等或者第一关键字前者大。如果遇到了第二关键字相等的情况,那就开了新的反链,但这事实上是不对的。反例如下:
A
=
[
(
2
,
1
)
,
(
4
,
1
)
,
(
5
,
1
)
]
A=[(2,1),(4,1),(5,1)]
A=[(2,1),(4,1),(5,1)],进行的算法如下(下面的说明里
f
f
f记录的是每条反链的最后一个pair,但实际代码里不需要记录第二关键字):
1、遇到
(
2
,
1
)
(2,1)
(2,1),开一个新反链,得
f
=
[
(
2
,
1
)
]
f=[(2,1)]
f=[(2,1)];
2、遇到
(
4
,
1
)
(4,1)
(4,1),此时前面去二分,发现没有比
4
4
4大于等于的数,于是开一个新反链,得
f
=
[
(
2
,
1
)
,
(
4
,
1
)
]
f=[(2,1),(4,1)]
f=[(2,1),(4,1)];
3、遇到
(
5
,
1
)
(5,1)
(5,1),此时前面去二分,发现没有比
5
5
5大于等于的数,于是开一个新反链,得
f
=
[
(
2
,
1
)
,
(
4
,
1
)
,
(
5
,
1
)
]
f=[(2,1),(4,1),(5,1)]
f=[(2,1),(4,1),(5,1)]。
但是,实际上
A
A
A自己本身就是个反链,最小反链数应该是
1
1
1,而不是
3
3
3!原因就在于,二分法是无法判断第二关键字的,所以就算新来的pair能接到前面已经开的反链里形成更大的反链,但是二分判不出来。但是,如果第一关键字下降排序,这时候就没问题了,因为即使第二关键字重复了,仍然能通过二分把新来的pair接到应该接到的反链上,并且显然单调增性质没有改变(数学归纳法可以证明)。这就是为什么排序的时候,第二关键字相等的时候,要第一关键字大者优先了。这里要注意,定理本身是没错的,算法本身也没错,错就错在二分在这里失效了(当然二分可以通过修改仍然把这题做出来,但不如排序的时候就把问题解决掉)。
归结起来,算法如下:
1、先将
A
A
A排序,第二关键字小者优先,如果一样大,就第一关键字大者优先;
2、开一个数组
f
f
f,
f
[
i
]
f[i]
f[i]存的是第
i
i
i条反链的最后一个数字(pair的第一关键字)。这里
i
i
i从
0
0
0开始计数;
3、开始遍历
A
A
A,遇到新pair,直接看第一关键字,如果在
f
f
f中能找到比它大于等于的数,则将
f
f
f中的那个数更新成这个第一关键字;否则如果
f
f
f中所有数字都比新来的第一关键字小,就新开一个反链,把这个数接到
f
f
f后面。
4、遍历完成后,返回
f
f
f的size即可。
f
f
f的size就是能开的最小反链数目,根据严格偏序集的分解定理,也等于最长链的长度。
代码如下:
import java.util.Arrays;
public class Solution {
/**
* @param envelopes: a number of envelopes with widths and heights
* @return: the maximum number of envelopes
*/
public int maxEnvelopes(int[][] envelopes) {
// write your code here
Arrays.sort(envelopes, (e1, e2) -> e1[1] != e2[1] ? Integer.compare(e1[1], e2[1]) : -Integer.compare(e1[0], e2[0]));
int[] f = new int[envelopes.length];
int idx = -1;
for (int i = 0; i < envelopes.length; i++) {
// 如果f里没有反链,或者最后一条反链的最后一个数小于新来的第一关键字,
// 就开一条新的反链,并赋值
if (i == 0 || f[idx] < envelopes[i][0]) {
f[++idx] = envelopes[i][0];
} else {
// 否则,在f里找最小的大于等于第一关键字的数,更新之;
// 由于f单调,所以可以用二分
int pos = binarySearch(f, idx, envelopes[i][0]);
f[pos] = envelopes[i][0];
}
}
// idx + 1就是开的反链个数
return idx + 1;
}
private int binarySearch(int[] f, int idx, int target) {
int l = 0, r = idx;
while (l < r) {
int m = l + (r - l >> 1);
if (f[m] >= target) {
r = m;
} else {
l = m + 1;
}
}
return l;
}
}
时间复杂度 O ( n log n ) O(n\log n) O(nlogn),空间 O ( n ) O(n) O(n)。