求和问题总结

参考:

https://github.com/julycoding/The-Art-Of-Programming-By-July/blob/master/ebook/zh/02.02.md

http://tech-wonderland.net/blog/summary-of-ksum-problems.html

寻找和为定值的两个数

题目描述

输入一个数组和一个数字,在数组中查找两个数,使得它们的和正好是输入的那个数字。

要求时间复杂度是O(N)。如果有多对数字的和等于输入的数字,输出任意一对即可。

例如输入数组1、2、4、7、11、15和数字15。由于4+11=15,因此输出4和11。

分析与解法

咱们试着一步一步解决这个问题(注意阐述中数列有序无序的区别):

直接穷举,从数组中任意选取两个数,判定它们的和是否为输入的那个数字。此举复杂度为O(N^2)。很显然,我们要寻找效率更高的解法

题目相当于,对每个a[i],查找sum-a[i]是否也在原始序列中,每一次要查找的时间都要花费为O(N),这样下来,最终找到两个数还是需要O(N^2)的复杂度。那如何提高查找判断的速度呢?

答案是二分查找,可以将O(N)的查找时间提高到O(log N),这样对于N个a[i],都要花logN的时间去查找相对应的sum-a[i]是否在原始序列中,总的时间复杂度已降为O(N log N),且空间复杂度为O(1)。(如果有序,直接二分O(N log N),如果无序,先排序后二分,复杂度同样为O(N log N + N log N)= O(N log N),空间复杂度总为O(1))。

可以继续优化做到时间O(N)么?

解法一

根据前面的分析,a[i]在序列中,如果a[i]+a[k]=sum的话,那么sum-a[i](a[k])也必然在序列中。举个例子,如下:原始序列:

  • 1、 2、 4、 7、11、15

用输入数字15减一下各个数,得到对应的序列为:

  • 14、13、11、8、4、 0

第一个数组以一指针i 从数组最左端开始向右扫描,第二个数组以一指针j 从数组最右端开始向左扫描,如果第一个数组出现了和第二个数组一样的数,即a[*i]=a[*j],就找出这俩个数来了。如上,i,j最终在第一个,和第二个序列中找到了相同的数4和11,所以符合条件的两个数,即为4+11=15。怎么样,两端同时查找,时间复杂度瞬间缩短到了O(N),但却同时需要O(N)的空间存储第二个数组。

解法二

当题目对时间复杂度要求比较严格时,我们可以考虑下用空间换时间,上述解法一即是此思想,此外,构造hash表也是典型的用空间换时间的处理办法。

即给定一个数字,根据hash映射查找另一个数字是否也在数组中,只需用O(1)的时间,前提是经过O(N)时间的预处理,和用O(N)的空间构造hash表。

但能否做到在时间复杂度为O(N)的情况下,空间复杂度能进一步降低达到O(1)呢?

解法三

如果数组是无序的,先排序(N log N),然后用两个指针i,j,各自指向数组的首尾两端,令i=0,j=n-1,然后i++,j--,逐次判断a[i]+a[j]?=sum,

  • 如果某一刻a[i]+a[j] > sum,则要想办法让sum的值减小,所以此刻i不动,j--;
  • 如果某一刻a[i]+a[j] < sum,则要想办法让sum的值增大,所以此刻i++,j不动。

所以,数组无序的时候,时间复杂度最终为O(N log N + N)=O(N log N)。

如果原数组是有序的,则不需要事先的排序,直接用两指针分别从头和尾向中间扫描,O(N)搞定,且空间复杂度还是O(1)。

下面,咱们先来实现此思路(这里假定数组已经是有序的),代码可以如下编写:

void TwoSum(int data[], unsigned int length, int sum)
{
    //sort(s, s+n);   如果数组非有序的,那就事先排好序O(N log N)

    int begin = 0;
    int end = length - 1;

    //俩头夹逼,或称两个指针两端扫描法,很经典的方法,O(N)
    while (begin < end)
    {
        long currSum = data[begin] + data[end];

        if (currSum == sum)
        {
            //题目只要求输出满足条件的任意一对即可
            printf("%d %d\n", data[begin], data[end]);

            //如果需要所有满足条件的数组对,则需要加上下面两条语句:
            //begin++
            //end--
            break;
        }
        else{
            if (currSum < sum)
                begin++;
            else
                end--;
        }
    }
}

解法总结

不论原序列是有序还是无序,解决这类题有以下三种办法:

  • 1、二分(若无序,先排序后二分),时间复杂度总为O(N log N),空间复杂度为O(1);
  • 2、扫描一遍X-S[i] 映射到一个数组或构造hash表,时间复杂度为O(N),空间复杂度为O(N);
  • 3、两个指针两端扫描(若无序,先排序后扫描),时间复杂度最后为:有序O(N),无序O(N log N + N)=O(N log N),空间复杂度都为O(1)。

所以,要想达到时间O(N),空间O(1)的目标,除非原数组是有序的(指针扫描法),不然,当数组无序的话,就只能先排序,后指针扫描法或二分(时间 O(Nlog N),空间O(1)),或映射或hash(时间O(N),空间O(N))。时间或空间,必须牺牲一个,达到平衡。

综上,若是数组有序的情况下,优先考虑两个指针两端扫描法,以达到最佳的时O(N),空O(1)效应。否则,如果要排序的话,时间复杂度最快当然是只能达到O(N log N),空间O(1)则不在话下。

问题扩展

  1. 如果在返回找到的两个数的同时,还要求你返回这两个数的位置列?
  2. 如果需要输出所有满足条件的整数对呢?
  3. 如果把题目中的要你寻找的两个数改为“多个数”,或任意个数列?

举一反三

1、在二元树中找出和为某一值的所有路径输入一个整数和一棵二元树,从树的根结点开始往下访问一直到叶结点所经过的所有结点形成一条路径,然后打印出和与输入整数相等的所有路径。例如输入整数22和如下二元树

10  

/ \
5 12
/ \
4 7

则打印出两条路径:10, 12和10, 5, 7。其中,二元树节点的数据结构定义为:

struct BinaryTreeNode // a node in the binary tree
{
    int m_nValue; // value of node
    BinaryTreeNode *m_pLeft; // left child of node
    BinaryTreeNode *m_pRight; // right child of node
};

2、有一个数组a,设有一个值n。在数组中找到两个元素a[i]和a[j],使得a[i]+a[j]等于n,求出所有满足以上条件的i和j。

3、3-sum问题

给定一个整数数组,判断能否从中找出3个数a、b、c,使得他们的和为0,如果能,请找出所有满足和为0个3个数对。

4、4-sum问题

给定一个整数数组,判断能否从中找出4个数a、b、c、d,使得他们的和为0,如果能,请找出所有满足和为0个4个数对


前言:

做过leetcode的人都知道, 里面有2sum, 3sum(closest), 4sum等问题, 这些也是面试里面经典的问题, 考察是否能够合理利用排序这个性质, 一步一步得到高效的算法. 经过总结, 本人觉得这些问题都可以使用一个通用的K sum求和问题加以概括消化, 这里我们先直接给出K Sum的问题描述和算法(递归解法), 然后将这个一般性的方法套用到具体的K, 比如leetcode中的2Sum, 3Sum, 4Sum问题. 同时我们也给出另一种哈希算法的讨论.

leetcode求和问题描述(K sum problem):

K sum的求和问题一般是这样子描述的:给你一组N个数字(比如 vector<int> num), 然后给你一个常数(比如 int target) ,我们的goal是在这一堆数里面找到K个数字,使得这K个数字的和等于target。


注意事项(constraints):

注意这一组数字可能有重复项:比如 1 1 2 3 , 求3sum, 然后 target  = 6, 你搜的时候可能会得到 两组1 2 3, 1 2 3,1 来自第一个1或者第二个1, 但是结果其实只有一组,所以最后结果要去重。

K Sum求解方法, 适用leetcode 2Sum, 3Sum, 4Sum:

方法一: 暴力,就是枚举所有的K-subset, 那么这样的复杂度就是 从N选出K个,复杂度是O(N^K)

方法二: 排序,这个算法可以考虑最简单的case, 2sum,这是个经典问题,方法就是先排序,然后利用头尾指针找到两个数使得他们的和等于target, 这个2sum算法网上一搜就有,这里不赘述了,给出2sum的核心代码:

01//2 sum
02int i = starting; //头指针
03int j = num.size() - 1; //尾指针
04while(i < j) {
05    int sum = num[i] + num[j];
06    if(sum == target) {
07        store num[i] and num[j] somewhere;
08        if(we need only one such pair of numbers)
09            break;
10        otherwise
11            do ++i, --j;
12    }
13    else if(sum < target)
14        ++i;
15    else
16        --j;
17}

2sum的算法复杂度是O(N log N) 因为排序用了N log N以及头尾指针的搜索是线性的,所以总体是O(N log N),好了现在考虑3sum, 有了2sum其实3sum就不难了,这样想:先取出一个数,那么我只要在剩下的数字里面找到两个数字使得他们的和等于(target – 那个取出的数)就可以了吧。所以3sum就退化成了2sum, 取出一个数字,这样的数字有N个,所以3sum的算法复杂度就是O(N^2 ), 注意这里复杂度是N平方,因为你排序只需要排一次,后面的工作都是取出一个数字,然后找剩下的两个数字,找两个数字是2sum用头尾指针线性扫,这里很容易错误的将复杂度算成O(N^2 log N),这个是不对的。我们继续的话4sum也就可以退化成3sum问题(copyright @sigmainfy),那么以此类推,K-sum一步一步退化,最后也就是解决一个2sum的问题,K sum的复杂度是O(n^(K-1))。 这个界好像是最好的界了,也就是K-sum问题最好也就能做到O(n^(K-1))复杂度,之前有看到过有人说可以严格数学证明,这里就不深入研究了。

更新: 感谢网友Hatch提供他的K Sum源代码, 经供参考:

01class Solution {
02public:
03    vector< vector > findZeroSumInSortedArr(vector &num, int begin, int count, int target)
04    {
05        vector<vector > ret;
06        vector tuple;
07        set visited;
08        if (count == 2)
09        {
10            int i = begin, j = num.size()-1;
11            while (i < j)
12            {
13                int sum = num[i] + num[j];
14                if (sum == target && visited.find(num[i]) == visited.end())
15                {
16                    tuple.clear();
17                    visited.insert(num[i]);
18                    visited.insert(num[j]);
19                    tuple.push_back(num[i]);
20                    tuple.push_back(num[j]);
21                    ret.push_back(tuple);
22                    i++; j–;
23                }
24                else if (sum < target)
25                {
26                    i++;
27                }
28                else
29                {
30                    j–;
31                }
32            }
33        }
34        else
35        {
36            for (int i=begin; i<num.size(); i++)
37            {
38                if (visited.find(num[i]) == visited.end())
39                {
40                    visited.insert(num[i]);
41                    vector subRet = findZeroSumInSortedArr(num, i+1, count-1, target-num[i]);
42                    if (!subRet.empty())
43                    {
44                        for (int j=0; j<subRet.size(); j++)
45                        {
46                            subRet[j].insert(subRet[j].begin(), num[i]);
47                        }
48 
49                        ret.insert(ret.end(), subRet.begin(), subRet.end());
50                    }
51                }
52            }
53        }
54 
55        return ret;
56    }
57 
58    vector threeSum(vector &num) {
59        sort(num.begin(), num.end());
60        return findZeroSumInSortedArr(num, 0, 3, 0);
61    }
62 
63    vector fourSum(vector &num, int target) {
64        sort(num.begin(), num.end());
65        return findZeroSumInSortedArr(num, 0, 4, target);
66    }
67};

K Sum (2Sum, 3Sum, 4Sum) 算法优化(Optimization):

这里讲两点,第一,注意比如3sum的时候,先整体排一次序,然后枚举第三个数字的时候不需要重复, 比如排好序以后的数字是 a b c d e f, 那么第一次枚举a, 在剩下的b c d e f中进行2 sum, 完了以后第二次枚举b, 只需要在 c d e f中进行2sum好了,而不是在a c d e f中进行2sum, 这个大家可以自己体会一下,想通了还是挺有帮助的。第二,K Sum可以写一个递归程序很优雅的解决,具体大家可以自己试一试。写递归的时候注意不要重复排序就行了。

K Sum (2Sum, 3Sum, 4Sum) 算法之3sum源代码(不使用std::set)和相关开放问题讨论:

因为已经收到好几个网友的邮件需要3sum的源代码, 那么还是贴一下吧, 下面的代码是可以通过leetcode OJ的代码(又重新写了一遍, 于Jan, 11, 2014 Accepted), 就当是K sum的完整的一个case study吧, 顺便解释一下上面的排序这个注意点, 同时我也有关于结果去重的问题可以和大家讨论一下, 也请大家集思广益, 发表意见, 首先看源代码如下:

01class Solution {
02public:
03    vector threeSum(vector &num) {
04        vector vecResult;
05        if(num.size() < 3)
06            return vecResult;
07 
08        vector vecTriple(3, 0);
09        sort(num.begin(), num.end());
10        int iCurrentValue = num[0];
11        int iCount = num.size() - 2; // (1) trick 1
12        for(int i = 0; i < iCount; ++i) {
13            if(i && num[i] == iCurrentValue) { // (2) trick 2: trying to avoid repeating triples
14                continue;
15            }
16            // do 2 sum
17            vecTriple[0] = num[i];
18            int j = i + 1;
19            int k = num.size() - 1;
20            while(j < k) {
21                int iSum = num[j] + num[k];
22                if(iSum + vecTriple[0] == 0) {
23                    vecTriple[1] = num[j];
24                    vecTriple[2] = num[k];
25                    vecResult.push_back(vecTriple); // copy constructor
26                    ++j;
27                    --k;
28                }
29                else if(iSum + vecTriple[0] < 0)
30                    ++j;
31                else
32                    --k;
33            }
34            iCurrentValue = num[i];
35        }
36                // trick 3: indeed remove all repeated triplets
37                // trick 4: already sorted, no need to sort the triplets at all, think about why?
38        vector< vector >::iterator it = unique(vecResult.begin(), vecResult.end());
39        vecResult.resize( distance(vecResult.begin(), it) );
40        return vecResult;
41    }
42};

首先呢, 在K Sum问题中都有个结果去重的问题, 前文也说了, 如果输入中就有重复元素的话, 最后结果都需要去重, 去重有好几个办法, 可以利用std::set的性质(如leetcode上3sum的文章, 但是他那个文章的问题是, set没用好, 导致最终复杂度其实是O(N^2 * log N), 而非真正的O(N^2) ), 可以利用排序(如本文的方法)等, 去重本身不难(copyright @sigmainfy), 难的是不利用任何排序或者std::set直接得到没有重复的triplet结果集. 本人试图通过已经排好序这个性质来做到这一点(试图不用trick 3和4下面的两条语句), 但是经过验证这样的代码(没有trick 3, 4下面的两行代码, 直接return vecResult)也不能保证结果没有重复,于是不得不加上了trick 3, 4,还是需要通过在结果集上进一步去重. 笔者对于这个问题一直没有很好的想法, 希望这里的代码能抛砖引玉, 大家也讨论一下有没有办法, 或者利用排序的性质或者利用其它方法, 直接得到没有重复元素的triplet结果集, 不需要去重这个步骤.

那么还是解释一下源代码里面有四个trick, 以及笔者试图不利用任何std::set或者排序而做到去重的想法. 第一个无关紧要顺带的小trick 1, 是说我们排好序以后, 只需要检测到倒数第三个数字就行了, 因为剩下的只有一种triplet 由最后三个数字组成. 接下来三个trick都是和排序以及最后结果的去重问题有关的, 我一起说.

笔者为了达到不需要在最后的结果集做额外的去重, 尝试了以下努力: 首先对输入数组整体排序, 然后使用之前提到的3sum的算法, 每次按照顺序先定下triplet的第一个数字, 然后在数组后面寻找2sum, 由于已经排好序, 为了防止重复, 我们要保证triplet的第一个数字没有重复, 举个例子, -3, – 3, 2, 1, 那么第二个-3不应该再被选为我们的第一个数字了, 因为在第一个-3定下来寻找2 sum的时候, 我们一定已经找到了所有以-3为第一个数字的triplet(trick 2).  但是这样做尽管可以避免一部分的重复, 但是还有另一种重复无法避免: -3, -3, -3, 6, 那么在定下第一个-3的时候, 我们已经有两组重复triplet <-3, -3, 6>, 如何在不使用std::set的情况下避免这类重复, 笔者至今没有很好的想法. 大家有和建议? 望不吝赐教!

更新: 感谢网友stayshan的留言提醒, 根据他的留言, 不用在最后再去重. 于是可以把trick 3, 4下面的两行代码去掉, 然后把while里面的copy constructor这条语句加上是否和前一个元素重复的判断变成下面的代码就行了.

这样的做法当然比我上面的代码更加优雅, 虽然本质其实是一样的, 只不过去重的阶段变化了, 进一步的, 我想探讨的是, 我们能不能通过”不产生任何重复的triplet”的方法直接得到没有重复的triplet集合? 网友stayshan提到的方法其实还是可能生成重复的triplet, 然后通过和已有的triplet集合判断去重, 笔者在这里试图所做的尝试更加确切的讲是想找到一种方法, 可以保证不生成重复的triplet. 现有的方法似乎都是post-processing, i.e., 生成了重复的triplet以后进行去重. 笔者想在这里探讨从而找到一种我觉得可以叫他pre-processing的方法, 能够通过一定的性质(可能是排序的性质等)保证不会生成triplet, 从而达到不需任何去重的后处理(post-processing)手段. 感觉笔者抛出的砖已经引出了挺好的思路了啊, 太好了, 大家有啥更好的建议, 还请指教啊 :) 

01class Solution {
02public:
03    vector threeSum(vector &num) {
04        // same as above
05                // ...
06        for(int i = 0; i < iCount; ++i) {
07            // same as above
08                        // ...
09            while(j < k) {
10                int iSum = num[j] + num[k];
11                if(iSum + vecTriple[0] == 0) {
12                    vecTriple[1] = num[j];
13                    vecTriple[2] = num[k];
14                    if(vecResult.size() == 0 || vecTriple != vecResult[vecResult.size() - 1])
15                        vecResult.push_back(vecTriple); // copy constructor
16                    ++j;
17                    --k;
18                }
19                else if(iSum + vecTriple[0] < 0)
20                    ++j;
21                else
22                    --k;
23            }
24            iCurrentValue = num[i];
25        }
26        return vecResult;
27    }
28};

Hash解法(Other):

其实比如2sum还是有线性解法的,就是用hashmap, 这样你check某个值存在不存在就是常数时间,那么给定一个sum, 只要线性扫描, 对每一个number判断sum – num存在不存在就可以了。注意这个算法对(copyright @sigmainfy)有重复元素的序列也是适用的。比如 2 3 3 4 那么hashtable可以使 hash(2) = 1; hash(3) = 1, hash(4) =1其他都是0,  那么check的时候,扫到两次3都是check sum – 3在不在hashtable中,注意最后返回所有符合的pair的时候也还是要去重。这样子推广的话 3sum 其实也有O(N^2)的类似hash算法,这点和之前是没有提高的,但是4sum就会有更快的一个算法。

4sum的hash算法:

O(N^2)把所有pair存入hash表,并且每个hash值下面可以跟一个list做成map, map[hashvalue] = list,每个list中的元素就是一个pair, 这个pair的和就是这个hash值,那么接下来求4sum就变成了在所有的pair value中求 2sum,这个就成了线性算法了,注意这里的线性又是针对pair数量(N^2)的线性,所以整体上这个算法是O(N^2),而且因为我们挂了list, 所以只要符合4sum的我们都可以找到对应的是哪四个数字。

到这里为止有人提出这个算法不对 (感谢Jun提出这点!! See the comment below), 因为这里的算法似乎无法检查取出来的四个数字是否有重复的, 也就是说在转换成2sum问题得到的那些个pair中, 有可能会有重复元素, 比如说原来数组中的第一个元素其实是重复了两次才使得4 sum满足要求, 那么这样得到的四元组(四个数字的和等于给定的值), 其实只有三个原数组元素, 其中第一个元素用了两次, 那么这样就不对了. 如果仅从我上面的描述来看, 确实是没有办法检查重复的, 但是仔细想想我们只要在map中存pair的的时候记录下的不是原数组对应的值, 而是原数组的id,就可以避免这个问题了. 更加具体的, map[hashvalue] = list, 每个list的元素就是一个pair, 这个pair<int, int> 中的pair是原来的array id, 使得这两个id对应到元素组中的元素值的和就是这个hash值. 那么问题就没有了, 我们在转换成的2sum寻找所有pair value的2sum的同时要检查最后得到的四元组<id1, id2, id3, id4>没有重复id. 这样问题就解决了.

更新: 关于4Sum的Hash解法, 感谢网友Tenos和hahaer的评论, 笔者再三思考, 思来想去>_<对于hahaer提出的所有元素都是0, 而且Target也是0的这个case, 我想问题可能在这里.

首先如果要找出所有唯一的四元组(id1, id2, id3, id4)也就是id级别的四元组, 那么时间复杂度只能是O(N^4). 推理如下: 如果要找到所有的唯一的四元组(id1, id2, id3, id4)的话, 是一定要O(N^4)时间的, 因为在这个case里面, 就是一个组合问题, 在N个id里面任意取出4个不同的id, 都是符合我们条件的四元组, 光是这样, 答案就有 O(N^4)个, N个里面取四个的组合种数.

可是! 如果大家再去看看leetcode的题目的话, 其实题目要求是返回元素组成的四元组(而不是要求id组成的四元组唯一), 也就是元素级别的四元组(参考网友Jun和AmazingCaddy和我在评论中的讨论)在这个case中, 返回(0, 0, 0, 0)就好了, 而不是返回(id1, id2, id3, id4)也就是不用去管id的问题. 如果是这样的话我们就不需要比较id了, 利用set之类的post-processing的方法是可以得到唯一的(0, 0, 0, 0)的.

还是抛砖引玉吧, 如果大家在这个问题上还有什么想法, 还请留言指点 :P


  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值