今天在LeetCode刷题时碰到一个“怪题”,说难也不难,说不难我又花了很长时间,并且一直被困扰其中。百度参考别人意见,到最后解决,这个过程思考了很多,现在记下来。
原题:633. 平方数之和
描述:
给定一个非负整数 c ,你要判断是否存在两个整数 a 和 b,使得 a2 + b2 = c。
示例1:输入: 5
输出: True
解释: 1 * 1 + 2 * 2 = 5示例2:
输入: 3
输出: False
题目不难,相信稍微思考一下就能写出下面的代码:
class Solution {
public boolean judgeSquareSum(int c) {
if(c < 0){
return false;
}
// 考虑到其实x y最大也就是c的开平方了
int middle = (int)Math.sqrt(c);
for(int x=0;x<=middle;x++){
for(int y=middle;y>=0;y--){
if(x*x + y*y == c){// 遍历找到满足条件的x y
return true;
}
}
}
return false;
}
}
上面这段代码简单易于理解,在输入值较小时可以“正常”工作,但是随着输入值的增大,效率会下降很快,因为两层循环嵌套导致完全遍历耗费大量的时间(这个也跟电脑CPU性能有关系),结果是我在本题运行这段代码正常,但是提交时运行大量单元测试时不通过,提示:超过时间限制。此时入参为Integer.MAX_VALUE也就是21亿多
。我花了很长时间思考,尝试过另外几种方式,不过本质都是基于基础数组遍历,最后发现这条路根本走不通,总是会提示超出时间限制。
最后经过搜索,在他人思路的提示下,使用如下代码:
class Solution {
public boolean judgeSquareSum(int c) {
if(c < 0){
return false;
}
// 考虑到其实x y最大也就是c的开平方了
int middle = (int)Math.sqrt(c);
// 最关键的就是引入HashSet
Set<Integer> set = new HashSet<>();
for(int x=0;x<=middle;x++){
set.add(x*x);// 将所有x y可能的值加入set
}
Iterator<Integer> ite = set.iterator();
while(ite.hasNext()){
int next = ite.next();
if(set.contains(c-next)){// 结果减去x平方的可能值就是y的平方值
return true;
}
}
return false;
}
}
上面这段代码可以完美提交通过,到了这里,相信你已经在思考了。如果你有兴趣,还可以把上面的HashSet换成List的ArrayList来实现同样的效果,结果当然也会提交不通过。
很明显,这道题不但在考你的基础算法实现,还涉及到算法效率优化问题。也就是必须要关注算法的时间复杂度。
既然如此,就趁这个机会加深一下ArrayList与HashSet元素查找的时间复杂度区别,实际上就是底层的实现区别。
上述第一套代码使用双层数组遍历的时间复杂度为O(n²),ArrayList本质就是通过数组实现的,所以如果上述第二套代码如果使用ArrayList实现也是同样的时间复杂度。
ArrayList判断是否包含某个元素的实现:
public boolean contains(Object o) {
return indexOf(o) >= 0;
}
public int indexOf(Object o) {
if (o == null) {
for (int i = 0; i < size; i++)
if (elementData[i]==null)
return i;
} else {
for (int i = 0; i < size; i++)// 通过遍历数组查找元素
if (o.equals(elementData[i]))
return i;
}
return -1;
}
而使用HashSet实现为什么可以呢?答案还是在它的内部实现,HashSet是通过HashMap的KeySet来实现的,判断是否包含某个元素的实现:
// HashSet
public boolean contains(Object o) {
return map.containsKey(o);// 通过HashMap实现
}
// HashMap
public boolean containsKey(Object key) {
return getNode(hash(key), key) != null;
}
// HashMap获取指定节点
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {// 直接通过hash定位到数组元素,而不是遍历获取
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))//部分情况下可能会继续遍历链表定位
return e;
} while ((e = e.next) != null);
}
}
return null;
}
看上面代码我添加第一行注释的位置,可以看到HashSet的元素定位(实际上是HashMap的key寻址)是一步到位(为什么可以这样实现?因为put元素的时候也是同样的方式实现,这里不讨论),也就是说时间复杂度为O(1)(取决于Hash算法是否均匀分布,可能存在需要遍历链表的情况,如上述第二部分的注释,通过每次插入操作后的rehash,实际上平均情况下HashMap能达到O(1))。
这就是为什么通过HashSet的元素检索实现不会出现效率问题,主要还是通过HashMap实现的键hash寻址实现的功劳。
因为这个问题,又找了点资料,顺便贴一下常见算法的时间复杂度:
O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<O(n^3)<O(2^n)<O(n!)
Ο(1)表示基本语句的执行次数是一个常数,一般来说,只要算法中不存在循环语句,其时间复杂度就是Ο(1)。其中Ο(log2n)、Ο(n)、 Ο(nlog2n)、Ο(n^2)和Ο(n^3)称为多项式时间,而Ο(2^n)和Ο(n!)称为指数时间。计算机科学家普遍认为前者(即多项式时间复杂度的算法)是有效算法,把这类问题称为P(Polynomial,多项式)类问题,而把后者(即指数时间复杂度的算法)称为NP(Non-Deterministic Polynomial, 非确定多项式)问题
就到这里,还是感触颇深,顺便又巩固一下基础,加深一下源码实现知识。