`
frank-liu
  • 浏览: 1667214 次
  • 性别: Icon_minigender_1
  • 来自: 北京
社区版块
存档分类
最新评论

2-sum, 3-sum, 4-sum问题分析

 
阅读更多

简介

    2-sum, 3-sum这两个问题其实都比较简单。他们演化的思路需要稍微费一点转折,不过当明白其中的一个诀窍之后后面的各种变形也都很简单了。最早牵涉到这个问题还是在12年我们组织面试招人的时候,海涛同学出了个这样的问题,求满足等式a^3 + b^3 + c^3 = d^3 的所有a, b, c, d值。其中a, b, c,  d所在区间为[1, 100000]。对于这种问题,似乎最不济的人也可以想到一个办法,就是我纯粹的去遍历每个数值这么蛮算。当时也是一时没有找到很好的办法。

    好了,我们先不急着考虑怎么解决这个问题,先来看看一个更加简单的问题形式。有时候,很多复杂的问题其实就是从一些简单的问题本身衍生出来的。

 

2-sum

    我们先来看这个问题的简单描述,假设有一组数字int[] a,给定一个数字sum,我们希望能够找到数组中是否有两个数字a, b, 他们的和等于数字sum。也就是我们希望找到两个数字使得a + b = sum。

    对于这种问题,我们不能说完全没有思路,只是有一个选择好坏的问题。因为这里只是给定了一组数字,它们是无序排列的。如果要找到所有符合两个数字相加等于目标数字的组合,可以使用最简单的一个双重遍历循环,它的伪代码如下:

 

for(int i = 0; i < array.length - 1; i++) {
    for(int j = i + 1; j < array.length; j++) {
        if(a[i] + a[j] == sum)
            // process and record work
    }  
}

    通过这种方式我们肯定可以找到所有的数对,只是针对这样一个问题,它们的时间复杂度就达到了O(N * N) 。那么,有没有更好的办法使得它更加有效率一些呢?

    这个时候,如果我们想到一些排序的算法,它们的时间复杂度多半都是在O(NlogN)这个级别的。如果对这些数字排序之后,我们能不能使得查找的效率更高一些呢?假定一个数组排序了,我们如果要查找里面的元素,不就可以直接用二分搜索了吗?而二分搜索的时间复杂度仅仅是O(logN),这样不就可以加快执行速度了么?

    所以,这里的一种改进思路就是首先对数组排个序,这样花的时间为O(NlogN),然后既然要查找里面a + b = sum的值,我们可以遍历整个数组,然后来查找数组里是否有sum - a的元素。这样这个遍历加上查找的时间复杂度也是O(NlogN)。有了这个思路,我们也就可以很容易得到如下的代码了:

 

public static int twoSum(int sum, int[] array) {
        Arrays.sort(array);
        int length = array.length;
        int count = 0;
        for(int i = 0; i < length; i++) {
            int position = find(sum - array[i], array);
            if(position > 0) {
                System.out.printf("value: %d, position: %d\n", 
                    array[i], position);
                count++;
            }
        }

        return count;
    }

    在代码里为了简化一些步骤,直接使用了java.util.Arrays的sort方法。这里的排序实际上是用到了快速排序的过程。这里find方法就是我们定义的一个二分查找方法,在以前的一些文章里也有谈到过,这里就直接把代码贴过来了:

 

public static int find(int v, int[] a) {
        int l = 0;
        int r = a.length - 1;
        int mid;
        while(l <= r) {
            mid = l + (r - l) / 2;
            if(a[mid] > v)
                r = mid - 1;
            else if(a[mid] < v)
                l = mid + 1;
            else
                return mid;
        }
        return -1;
    }

     这样,我们通过将数组排序,得到了一个O(NlogN)时间复杂度的解决方法。现在我们再来更进一步看看3-sum的问题。

 

3-sum

    如果说前面2-sum对应的是a + b = c这样的问题,这里相当于增加了一个维度,我们希望对应一个a + b + c = d。这里增加了这么一个变量,也增加了一点复杂度。原来对应两个元素,我们遍历数组,根据指定遍历的元素来查找sum - b的值。那么这里增加的元素我们也可以做一个类似的思考,我们最终来根据a求sum - b -c的值。 那么从实现的角度来说,我们只是需要多增加一个循环,在这个循环里取和前面不同的值。这种思路是在2-sum的基础上延伸出来的,因为比较简单,我们就列一下它的伪代码实现:

 

public static int count(int[] array) {
    Arrays.sort(array);
    int length = array.length;
    int count = 0;
    for(int i = 0; i < length; i++) {
        for(int j = i + 1; j < length; j++) {
            if(find(sum - a[i] - a[j], array) > 0) 
                count++;
        }
    }
    return count;
}

     如果用这种办法的话,我们的时间复杂度为O(N*NlogN)。虽然看起来还是可以接受,但是有没有更好的办法呢?

    假定我们有一个如下的排序的数组:

    

        如果我们要求两个数,使得它们的和为某个特定的值,那么我们的目的就是要找到所有符合这个条件的数值对。假定在这里我们要求使得任意两个数字的和为9, 那么对于这个给定的数字来说。其实从数学的角度我们可以这样来看。如果我们取这个目标数字9的一半,也就是4.5,用它来划分这个数组的话,它左边的数字表示比它小的,它右边的数字表示比它大的。那么这里对应的一个划分则如下图所示:

    实际上,如果我们需要去求得和为9的两个数字,肯定他们中间有一个在前面这个部分,一个在后面这个部分。而且还有一个更加有意思的特点,比如说这里的2 + 7 = 9。那么我们将左边的数字往右一个,右边的往左一个,他们还是有可能凑成一对符合条件的数字。这样我们就找到另外一种有意思的规律。

    我们一开始从数组的开头和结尾去这两个数字,如果存在两个数字它们的和等于目标数字。它们肯定是一个大于我们给定数字的一半而另一个小于它的一半。我们可以先计算这两个数字的和,用它们来比较目标数字。如果大于目标数字,则右边的下标往左移一步,相当于将它们的和调小一点,如果小于的话,则移动左边的下标往右一步。这样我们就可以通过这样一次循环找到所有符合条件的数字了。

     按照这种思路,我们可以得到如下的代码:

 

public static void threeSums(int sum, int[] a) {
        Arrays.sort(a);
        for(int i = 0; i < a.length; i++) {
            System.out.println(findAllMatches(sum - a[i], a));
        }
    }

    public static int findAllMatches(int v, int[] a) {
        int l = 0;
        int r = a.length - 1;
        int count = 0;
        while(l < r) {
            if(a[l] + a[r] < v)
                l++;
            else if(a[l] + a[r] > v)
                r--;
            else {
                System.out.printf("l: %d, r: %d\n", l, r);
                count++;
                l++;
                r--;
            }
        }

        return count;
    }

     按照前面的分析,我们对数组排序用的时间为O(NlogN),我们匹配两个数字的和,用的时间为O(N),但是这一步外面还嵌套一个遍历数组的循环,所以它的时间复杂度为O(N*N)。这样, 整个问题的时间复杂度为O(N^2)。

    我们这样就解决了3-sum的一般问题。实际上还有很多这种问题的变化。比如最开始我提到的那个问题,还有一种就是我要求的这个sum不是固定的,它也是在这个数组里。这个时候,我们解决问题的办法还是差不多,只是对于这个变化的sum要做一个遍历。

 

4-sum

  现在,我们再把问题复杂化一步,假设来求一个4-sum的问题。就是给定一个数组,要求里面是否存在a + b + c + d = sum的情况。在前面两种情况下,我们都是将问题归结为a + b = sum, a + b = sum - c等这样的情况。提高问题解决效率的关键点在于将数组排序。在更加多了一个参数的情况下,是否可以利用原来的思路呢?

  在2-sum的情况下,我们只要给定一个a,然后取查找数组里是否存在等于sum - a的情况,也可以采用线性查找的方法,从数组的两头并进,找到符合条件的数对。3-sum的情况也类似,只是要循环遍历整个数组。这里所有遍历的元素就是构成可能满足条件的数组元素。对于4-sum的情况,我们可以将条件转化为a + b = sum - c - d。粗看来,这样没有多少帮助。这里相当于要找到两个数的和,它们等于sum减去另外两个数。嗯,原来的等式其实也相当于a + b = sum - (c + d)。这样概括起来的话,不就是相当于求两个数的和,它们等于sum减去另外两个数的和吗?

  到了这一步,就到了问题的关键了。两个数的和,对于一个给定的数组来说,它们构成了一个集合,假设数组元素个数为n,那么任意两个数的和构成一个C(n, 2)的组合,它们也构成了一个集合。相当于有(n * n + 1) / 2个元素。也可以笼统的称之为n^2个元素。而如果我们将前面数组中两个元素的和当做一个元素的话,这不就相当于求a = sum - b吗?只是这里a, b都是由原来数组里两个元素的和构成。这样,原来的问题就可以归结为从n^2的元素里求两个数的和为sum的问题,也就是一个2-sum的问题了。

  更细化一步,解决这个问题需要如下几个步骤:

1. 构造任意两个元素相加得到的集合。

2. 对这个集合排序。

3. 查找集合里所有符合a + b = sum的元素对。

  从第一步得到的两个元素和的集合有n^2个,时间复杂度为O(N^2)。而第二步进行排序的时间复杂度为O(N^2logN^2),也就是O(N^2logN)。

  结合前面的讨论,对这个问题的详细代码实现就比较简单了。这里仅对第1,2步的描述做一个实现。详细代码如下:

public int[] generateSums(int[] a) {
    int n = a.length;
    int size = n * (n + 1) / 2;
    int k = 0;
    int[] result = new int[size];
    for(int i = 0; i < n; i++) {
        for(int j = i + 1; j < n; j++)  {
            result[k] = a[i] + a[j];
            k++;
        }
    }

    return Arrays.sort(result);
}

    这部分代码比较简单,就是一个生成所有相加元素的组合, 这个问题的核心其实已经演化为一个元素集合查找的问题了。当然,在需要输出对应的元素i, j的时候,我们可能需要将它们和对应的和元素关联起来。这里可以通过将定义的数组result修改为一个自定义的类型,里面包含进行比较的元素以及关联的两个索引元素就可以。这部分就不再赘述。

 

 

总结

    2-sum, 3-sum的问题在一定的问题规模下,是可以找到有效的方法来解决的。我们常用的办法就是首先针对这个数组排序,然后再去通过二分法查找目标值。还有一种就是通过从排序后的数组两头来凑这个给定的目标值。基本上所有这些问题的变体都可以通过这几种方法来解决。而且这些解决方法的时间复杂度还是相当客观的,对于2-sum来说它达到了O(NlogN),对于3-sum来说,它可以达到O(N^2)。以前也有人总结过,说碰到这种和数组的东西打交道时,如果实在没什么思路,干脆就不管三七二十一给它排个序,也许思路就有了,看来还是有点道理的。

 这是最近的一次补充,在参考一些文章之后,发现针对多个元素求和的问题其实还有一些新的思路,主要是将一些元素的和当做一个集合,这样任何两个或者若干个元素的和它们都可以当做一个对等的集合。这样我们的问题就归结为在一个广义的集合里求更少元素的和问题。精妙之处就在于此。有了这个基础,我们在这个基础上做更多元素的类推,也找到了一个更加有力的方法。

 

参考材料

Algorithms

Introduction to algorithms

http://mp.weixin.qq.com/s?__biz=MjM5ODIzNDQ3Mw==&mid=200464914&idx=1&sn=5ddb9863a92a496810ab33342b86d44b#rd

  • 大小: 4.7 KB
  • 大小: 8.3 KB
分享到:
评论
3 楼 frank-liu 2016-04-12  
wentianxin 写道
对于 three-sum 的算法,会造成 很多重复选择的情况。

第一种错误情况:

比如: -4 1 3 这样一个简单的例子, a + b + c  = 0;

第一次 : 第一重中选取 -4; 然后你会从首尾两端遍历,拿取 1 和 3

爹二次 : 第一重中选取 1;首尾两端遍历, 拿去 -4 和 3 ,依然成立;

第二种错误情况:

-4 2 3 ; a + b + c = 0

根据上述代码的逻辑,

第一重for循环 : i = 1; a[i] = 2;

while 循环中却依然是从头开始遍历: 会造成选取了 -4 2 2 这种成立 却 不存在的情况。

是的,上述的代码实现没有去除重复的情况。在具体要求不能重复的地方还需要额外的考虑。像leetcode上要求不能重复的条件下也有一些其他的方法。我这边有针对不允许重复的情况的讨论:http://shmilyaw-hotmail-com.iteye.com/blog/2285268
2 楼 wentianxin 2016-04-12  
对于 three-sum 的算法,会造成 很多重复选择的情况。

第一种错误情况:

比如: -4 1 3 这样一个简单的例子, a + b + c  = 0;

第一次 : 第一重中选取 -4; 然后你会从首尾两端遍历,拿取 1 和 3

爹二次 : 第一重中选取 1;首尾两端遍历, 拿去 -4 和 3 ,依然成立;

第二种错误情况:

-4 2 3 ; a + b + c = 0

根据上述代码的逻辑,

第一重for循环 : i = 1; a[i] = 2;

while 循环中却依然是从头开始遍历: 会造成选取了 -4 2 2 这种成立 却 不存在的情况。



1 楼 bairongdong1 2014-11-27  
感谢,,,原来那个a^3 + b^3 + c^3 = d^3 是一样的啊,,直接三次乘出来,然后变成a+b+c=d,然后变成a+b = d - c, 然后,,,就是一个排序以及归并匹配N*NLogN。。。。。。配合java自带的timsort......预计服务器一分钟之内能给出答案。

相关推荐

Global site tag (gtag.js) - Google Analytics