先来详细描述下这道题。在一个全为正整数的数组中找到总和为给定值的子数组,给出子数组的起始下标(闭区间),举个例子:html
在[3 2 1 2 3 4 5]这个数组中,和为10的子数组是[1 2 3 4],因此答案应该是[2,5]。
和为15的子数组是[1 2 3 4 5],答案为[2,6]。
这是一道很是有意思的题,为何这么说?最简单的解法只要具有基本的编程知识就能写出,更优的解法须要你有数据结构和算法能力,越高效的解法越巧妙,可能你一会儿没法想出全部的解法,但我相信你看完这篇博客必定会感叹算法的神奇。 java
回到这道题上来,实际上它有着O(n^3)
、O(n^2)
、O(nlogn)
、O(n)
4种时间复杂度的解法,若是算上空间复杂度的差别的话总共5种解法,我以为仍是比较能考察到一我的的算法水平的。接下来让我带领你们由简入难看下从青铜到王者的5种解法,带你们吊打面试官。 面试
这里咱们设输入参数为(arr[],target),后续代码中我会用s和e来分别表示起始和终止位置。另外为了简化代码思路,咱们假设给定的参数里最多只有一个解(实际上多个解也不难,但会让代码变长,不利于描述思路,多解的状况就留给你当课后做业了)。算法
首先固然是最简单的暴力求解了,遍历起始位置s和结束位置e,而后求s和e之间全部数字的和。三层循环简单粗暴,不须要任何的技巧,相信你大一刚学会编程就能解出来。编程
public int[] find(int[] arr, int target) { for (int s = 0; s < arr.length; s++) { for (int e = s+1; e < arr.length; e++) { int sum = 0; for (int k = s; k <= e; k++) { // 求s到e之间的和 sum += arr[k]; } if (target == sum) { return new int[]{s, e}; } } } return null; }
咱们来分析下时间复杂度,很明显是O(n^3),当n超过1000时就会出现肉眼可见的慢,想一想如何优化?数组
上面代码中,咱们每次都须要算从s到e之间的数组的和sum[s,e],假设我以前已经求过了[1,10]之间的和sum[1,10],如今要求[2,10]之间的和sum[2,10],显然这中间有很大一部分是重叠的(sum[2,10]),能不能把这部分重复扫描给消除掉?这里就须要作下巧妙的变换了。数据结构
实际上sum[s, e] = sum[0, e] - sum[0, s-1]
, sum[0,i]咱们能够预先保存下来,而后重复使用。实际上sum数组咱们能够经过一遍数据预处理获取到。上图中,arr蓝色区域的和正好等于sum数组中红色减去绿色,即sum(arr[3]-arr[7]) = sum[7]-sum[2]
。数据结构和算法
回到代码上来,编码实现中我用了额外一个数组arrSum来存储0到i(0<=i<n)之间全部的和,为了处理方便sumArr下标从1开始,sumArr[i]表示远数组中sum[0, i-1]。有了sumArr以后,sum[s,e]就能够经过sumArr[e+1]-sumArr[s]间接获取到。完整代码以下:优化
public int[] find(int[] arr, int target) { int[] sumArr = new int[arr.length + 1]; for (int i = 1; i < sumArr.length; i++) { sumArr[i] = sumArr[i-1] + arr[i-1]; // 预处理,获取累计数组 } for (int s = 0; s < arr.length; s++) { for (int e = s+1; e < arr.length; e++) { if (target == sumArr[e+1] - sumArr[s]) { return new int[]{s, e}; } } } return null; }
经过上述用空间换时间的方式,咱们能够直接将时间复杂度从O(n^3)
下降到O(n^2)
。编码
细心的你可能已经发现了,由于给出的arr都是正整数,因此sumArr必定是递增且有序的,对于有序的数组,咱们能够直接采用二分查找。对于这道题而已,咱们能够遍历起点s,然在sumArr中二分去查找是否有终点e,若是s对于的e存在,那么sumArr[e]必定等于sumArr[s] + target,改造后的代码以下,相比于上面代码,增长了二分查找。
public int[] find(int[] arr, int target) { int[] sumArr = new int[arr.length + 1]; for (int i = 1; i < sumArr.length; i++) { sumArr[i] = sumArr[i-1] + arr[i-1]; } for (int s = 0; s < arr.length; s++) { int e = bSearch(sumArr, sumArr[s] + target); if (e != -1) { return new int[]{s, e}; } } return null; } // 二分查找 int bSearch(int[] arr, int target) { int l = 1, r = arr.length-1; while (l < r) { int mid = (l + r) >> 1; if (arr[mid] >= target) { r = mid; } else { l = mid + 1; } } if (arr[l] != target) { return -1; } return l - 1; }
由此,咱们又继续将时间复杂从O(n^2)下降到了O(nlogn)。
有序数组的查找除了能够用二分优化,还能够用hashMap来优化,借助HashMap O(1)的查询时间复杂度。咱们又一次用空间来换取了时间。
public int[] find(int[] arr, int target) { int[] sumArr = new int[arr.length + 1]; Map<Integer, Integer> map = new HashMap<>(); for (int i = 1; i < sumArr.length; i++) { sumArr[i] = sumArr[i-1] + arr[i-1]; map.put(sumArr[i], i-1); } for (int s = 0; s < arr.length; s++) { int e = map.getOrDefault(sumArr[s]+target, -1); if (e != -1) { return new int[]{s, e}; } } return null; }
咱们终于将时间复杂度下降到了O(n),这但是质的飞跃。
别急,还没结束,对于这道题还有王者解法。上文中咱们经过不断的优化,将时间复杂度从O(n^3)一步步下降到了,但咱们却一步步增长了存储的使用,从开始新增的sumArr数字,到最后的又增长的HashMap,空间复杂度从O(1)变为了O(n)。有没有办法把空间复杂度也给将下来?我能写到这那必然是有的。
这种算法叫作尺取法。尺取法,这个名字有点难理解。咱们直接举个具体的例子,假设有n调长度不一的绳子并列放在一块儿,你须要找出其中连续的一部分绳子组成一条长度为target的绳子,这里须要注意是连续。这时候你能够找一个长度为target的尺子,而后把绳子一段段往尺子上放,若是发现短了就日后面再接一根,若是发现长了,就把最头上的一根扔掉,直到长度刚好合适。
在使用中咱们并不须要这把尺子,只须要拿target做为标尺便可。提及来可能比较难理解,直接举个例子,下图演示了从数组中找到和为22的子数组的过程。
只要小了就右加,大了就左减,直到找到目标。
为何尺取法是对的?我理解尺取法实际上是解法二白银解法的一种优化,也是遍历了起点s,可是对终点e不作无效的遍历,若是e到某个位置后已经超了,由于数组里都是正数,再日后确定是超的,也就不必继续遍历e了。转而去调整s,若是s右移到某个位置后总和小了,s再往右总和只会更小,也就不必继续调整s了…… 整个过程就像是先固定s去遍历e,而后固定e再去遍历s ……,直到获得结果。
尺取法可用的基础在于e往右移动总和必定是增的,s往右移总和必定是减的,也就是说数组中全部的数必须是正的。 没有完美的算法能够解决任何问题,但对于特定的问题必定有最完美的解法。
说完尺取法,咱们来看下用尺取法是如何解决这道题的,代码比较简单,以下:
public int[] find(int[] arr, int target) { int s = 0, e = 0; int sum = arr[0]; while (e < arr.length) { if (sum > target) { sum -= arr[s++]; } else if (sum < target) { sum += arr[++e]; } else { return new int[]{s, e}; } } return null; }
只有一层循环,时间复杂度O(n)。没有额外的空间占用,空间复杂度O(1),这就是最完美的解法。
这道算法题乍看简单,细看其实真的不简单。可能你面试遇到,没办法一会儿想到最优的解,但给出一个可行的解总比没有解强。我以前面试问别人这个题,他一上来就是想着怎么最优解决,反而连最简单的青铜解法都没写出来。记得下次面试,实在是解不出来就先给个60分的答案,而后再想办法把分数提高上去,别最后交了白卷。 给出一个可行解,而后再持续迭代优化,我以为这也是解决一个复杂问题比较好的思路。
最后送你们一句鸡汤,没有人生下来就是王者,只是不断的努力成为了王者罢了。
欢迎关注个人面试专栏 中高级程序猿面试题精选, 持续更新,永久免费,本专栏会持续收录我遇到的中高级程序猿经典面试题,除了提供详尽的解题思路外,还会从面试官的角度提供扩展题,是你们面试进阶的不二之选,但愿能帮助你们找到更好的工做。另外,也征集面试题,若是你遇到了不会的题 私信告诉我,有价值的题我会给你出一篇博客。