数组是我们程序员最常用的数据结构,也是笔试和面试最喜欢出的题型。要想解决好一道数组题,需要的不仅是扎实的编程基础,更重要的是,要有清晰的思路,因为数组题经常是一些见都没有见过的数学题目,需要我们当场分析其中的规律。
考察数组,最主要的是这几个方面:查找,排序,递归和循环,而这往往考察的就是我们编写高效率代码的能力。编写能够运行的代码并不难,但要编写高效的代码却是一门需要花时间的功夫,甚至可以说与天赋挂上钩,有些人天生就是对算法非常敏感,能够一下子掌握算法的精髓。但事实就是,大部分正在编程的程序员都不具备这种能力,就像我一样。
所幸,真正的事实就是:大部分人的努力程度并不足以达到与别人拼天赋的程度。
依然是像我一样,努力的程度实在是太小了!根本没有资格抱怨自己与别人的智商差异!!
所以,像是我一样的平庸之辈,最好的方法就是老老实实的认清楚自己是个怎样的人:没有天赋,始终没有别人努力,理解能力差。。。然后我们才能决定自己接下来应该怎么办。
不断积累基础知识是非常好的方法,幸运的是,很大部分的编程并不需要太高深的编程技巧,只要我们对常见的编程技巧足够熟悉也能够完全胜任。
首先我们先从递归和循环开始说起,这在解决数组题目中是非常重要的基础。
题目一:写一个函数,输入n,求斐波那契数列的第n项。
几乎所有讲解递归的课本都会用这道题目。
斐波那契数列的数学表达式如下:
这就是递归的典型应用:在函数中调用自身。
数学表达式非常清晰的告诉了我们怎样编程:
long long Fibonacci(unsigned n) { if(n <= 0) { return 0; } else if(n == 1) { return 1; } else { return Fibonacci(n - 1) + Fibonacci(n - 2); } }
其中,long long就是int类型的扩展,一般的int占4个字节,范围是-32768~32767,而long long则是8个字节,范围是-922337203685775808~922337203685775807,这样是为了防止溢出,这在一些大数目的递归问题中非常重要。参数传递使用unsigned也是同样的道理。
可惜的是,那些课本并没有告诉我们这样的解法存在非常严重的效率问题!
我们可以用树型结构来看一下这个递归过程(所有的递归问题都可以用树型结构表示):
long long Fibonacci(unsigned n) { int result[2] = {0, 1}; if(n < 2) { return result[n]; } int fibNMinusTwo = 1; int fibNMinusOne = 0; int fibN = 0; for(unsigned int i = 2; i <= n; ++i) { fibN = fibNMinusOne + fibNMinusTwo; fibNMinusOne = fibNMinusTwo; fibNMinusTwo = fibN; } return fibN; }
这种做法正是用循环来代替递归以提高效率。
递归并不是可以被滥用的技巧,表面上使用递归似乎能让我们程序员看上去非常睿智,但实际上递归是一种低效的做法,因为每次函数调用是有时间和空间的消耗的,需要在内存栈中分配空间以保存参数,返回地址和临时变量,而且往栈里压入数据和弹出数据都需要消耗时间,更可怕的是,递归会引起调用栈溢出的问题,因为每次进程的栈容量是有限的,当递归的层次太多的时候,就会引发问题。
但是,递归确实可以让代码显得更加简洁,所以如果递归层级不是太多的情况,优先考虑递归。
很多实际的问题都可以看成斐波那契数列的应用,像是这样的题目:
一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶,求该青蛙跳上n级台阶总共有多少种跳法。
这道题目别说是程序题,我们经常在小学或者初中的课本中看过。仔细分析一下,就能发现,当一只青蛙跳上第1级台阶后,剩下的其实就是n - 1阶的总跳数;跳上第2阶台阶后,剩下的就是n - 2阶的总跳数,也就是f(n - 1)和f(n - 2)。
这是数学建模的能力,也是面试者所看重的。当然,我们可能无法一下子建好模,实际的情况就是我们部分程序员的数学素养实在是不高,就像我一样,对于那些高深的算法,很难一下子理解明白,但也并不是说完全没有办法,想想敏捷的开发思想,我们可以从最简单的测试用例开始:
首先是只有1阶的情况:f(1) = 1;
然后是只有2阶的情况:f(2) = 2;
接着是只有3阶的情况:f(3) = 3 = f(2) + f(1);
最后是只有4阶的情况:f(4) = 5 = f(3) + f(2) = f(2) + f(1) + f(2);
...
f(n) = f(n - 1) + f(n - 2);
也许程序员永远也不会成为数学家,因为我们都没有受过专业的数学训练,但是我们必须要有发现规律的洞察力,这样才能写出好的代码,就算一时无法洞察出规律,我们还有一个诀窍:测试用例,我们可以从最简单的测试用例开始,就像上面一样,只要一开始我们的代码能够通过f(1)的情况就行,然后再想办法通过f(2)和f(3),最后就是通过n的情况。这就是测试用力的用处,也是我们程序员最大的法宝,只要想办法通过当前的测试用例就行。
数组的排序和查找是非常重要的动作,很多实际的问题都需要用到排序和查找,也因为这样,排序和查找有很多种实现,它们都有各自的优缺点,但对于程序员,最重要的是快速排序和二分查找,因为使用它们能够显著的提高效率,尤其是在一些需要注意效率的题目中,就是变相的提示我们,需要使用到快速排序和二分查找。
实现快速排序的关键就是在数组中寻找一个数字,然后把数组分成两个部分:比该数字小的数字移到数组的左边,大的数字则移到右边:
int Partition(int data[], int length, int start, int end) { if(data == NULL || length <= 0 || start < 0 || end >= length) { throw new std :: exception("Invalid Parameters!"); } int index = RandomInRange(start, end); Swap(&data[index], &data[end]); int small = start - 1; for(index = start; index < end; ++index) { if(data[index] < data[end]) { ++small; if(small != index) { Swap(&data[index], &data[small]); } } ++small; Swap(&data[small], &data[end]); return small; } }
然后我们用递归的方法分别对每次选中的数字的左右两边进行排序:
void QuickSort(int data[], int length, int start, int end) { if(start == end) { return; } int index = Partition(data, length, start, end); if(index > start) { QuickSort(data, length, start, index - 1); } else { QuickSort(data, length, index + 1, end); } }
快速排序虽然在总体上的平均效率是最佳的,但并不是任何时候都是最佳的,比如说数组已经排好序,而每轮排序都是以最后一个数字作为比较的标准,快速排序的时间效率就只有O(N * N)。不过一般而言,对于一个n个元素的数组,快速排序的时间复杂度为O(N * log2N)。
相比其他查找来说,二分查找的比较次数少,查找速度快,平均性能好,但前提是在有序的条件下,所以一般都是先排序,再查找:
int Bisearch(int data[],int x,int start,int end) { if (start > end) { //判断是不是只有一个元素可以比较 return -1; } int mid = (start + end) / 2; if (x == data[mid]) { return mid; } else if (x < data[mid]) { return Bisearch(data, x, start, mid-1); } else { return Bisearch(data, x, mid+1, end); } }
我们来看下道题目:
题目二:把一个数组的若干个元素搬到数组的末尾,就是数组的旋转。输入一个递增排序的数组的一个旋转,输出旋转数组的最小元素。
如果光是看题目,我们一下子不知道到底是怎么回事,就先以一个测试用例开始:假设数组为{1, 2, 3, 4, 5},那么旋转数组就是{3, 4, 5, 1, 2},它的最小值就是1。
最直观的做法就是遍历该数组,然后找出最小值,这样的时间效率就是0(N)。但如果只是这么简单的话,就不会用旋转数组这样听都没有听过的名词了。以前高中的时候,数学老师就经常说:要求就是条件。这里也是如此,最好的解决方法就是利用旋转数组的特点。
我们来看一下旋转数组,就会发现,旋转数组实质上是以最小值作为分界线将整个数组分为两个部分,前面部分和后面部分都是大于等于最小值,说明这个数组已经是经过一定程度的排序,在经过排序的数组中查找某个值的最好做法就是使用二分查找,这样我们就能将时间效率控制到O(log2N)。
根据题意,两个递增数组的分界线就是最小值,我们先得到中间的元素,然后将它与第一个元素和最后一个元素进行比较,如果大于等于第一个元素,说明最小值是在中间元素后面,我们可以指向第二个元素,以此类推,如果中间元素小于等于最后一个元素,说明最小值是在中间元素前面,以此类推。
测试用例并不是一次就足够,我们还要尽可能的考虑更多的情况:
1.将0个元素放在最小值后面,也就是原来的数组的情况,这时上面的思路就不管用了,因为第一个值就是最小值;
2.考虑这样的数组{0, 1, 1, 1, 1},那么{1, 0, 1, 1, 1}就是它的旋转数组之一,但是我们无法用二分查找找出它的最小值,因为我们无法确定中间那个1到底是在前面的递增序列还是后面的递增序列。这时我们就要按照顺序来查找了。
结合上面的情况,我们可以写出这样的代码:
int Min(int* data, int length) { if(data == NULL || length <= 0) { throw new std :: exception("Invalid Parameters!"); } int index1 = 0; int index2 = length - 1; int indexMid = index1; while(data[index1] >= data[index2]) { if(index2 - index1 == 1) { indexMid = index2; break; } indexMid = (index1 + index2) / 2; if(data[index1] == data[index2] && data[indexMid] == data[index1]) { return MinInOrder(data, index1, index2); } if(data[indexMid] >= data[index1]) { index1 = indexMid; } else if(data[indexMid] <= data[index2]) { index2 = indexMid; } } return data[indexMid]; ] int MinInOrder(int* data, int index1, int index2) { int result = data[index1]; for(int i = index1 + 1; i <= index2; ++i) { if(result > data[i]) { result = data[i]; } } return result; }
当我们拿到一个从未见过的情境的时候,最好的方法就是尽可能的提出不同的测试用例,考察更多的情况,这样我们就能在不断通过这些测试的时候完成正确的代码。让代码正确地运行是我们首要的任务,然后才考虑优化的问题,当然,如果一开始就能根据题目的要求联想到最优解那自然是最好不过了。
二分查找体现的是分治策略,而分治策略和递归可以说是一对好朋友,像是下面这道题目:
题目三:找出数组中的最大值。
这是非常简单的题目,我们可以使用二分查找结合递归:
int GetMax(int* data, int start, int end) {
int maxValue;
if(data != NULL && length > 0)
{if(end - start <= 1) { if(data[start] >= data[end]) { return data[start]; } else { return data[end]; } } int maxL = GetMax(data, start, start + (end - start) / 2); int maxR = GetMax(data, start + (end - start) / 2 + 1, end); if(maxL > maxR) { maxValue = maxL; } else { maxValue = maxR; }
} return maxValue;
}
这就是所谓的分治策略。
二分查找是分治策略的一种实现,使用二分查找的条件之一就是我们知道想要找的是一个具体的值。
只要题目中要求我们寻找某个值,也就是一个查找动作,我们都可以考虑使用二分查找,像是查找而快速排序的使用情况更多了,凡是涉及到"快速"这个字眼,我们都可以想想是否可以用快速排序来组织一下数组。
二分查找需要经过排序的数组,但如果使用快速排序,就会破坏原来数组的结构,所以我们必须清楚最关键的一点:我们是否可以修改数组的结构?
如果上面的题目我们无法修改数组,那么我们就只能使用辅助数组,这也就是所谓的"用空间换取时间"的做法。
我们来看下面的这道题目:
题目四:找出数组中出现次数超过数组长度的数字。
同样是从一个测试用例开始:假设数组{1, 2, 3, 2, 2, 2, 5, 4, 2},那么该数字就是2。
这道题目同样是从数组中查找某个值,我们的脑海中就会闪过二分查找,因为题目中并没有说明是排序的,所以我们还得先排序一下,可以利用我们上面的函数Partition:
int MoreThanHalfNum(int* data, int length) { if(CheckInvalidArray(data, length) { return 0; } int middle = length / 2; int start = 0; int end = length - 1; int index = Partition(data, length, start, end); while(index != middle) { if(index > middle) { end = index - 1; index = Partition(data, length, start, end); } else { start = index + 1; index = Partition(data, length, start, end); } } int result = data[middle]; if(!CheckMoreThanHalf(data, length, result)) { result = 0; } return result; } bool g_bInputInvalid = false; bool CheckInvdlidInArray(int* data, int length) { g_bInputInvalid = false; if(data == NULL || length <= 0) { g_bInputInvalid = true; } return g_bInputInvalid; } bool CheckMoreThanHalf(int* data, int length, int number) { int times = 0; for(int i = 0; i < length; ++i) { if(data[i] == number) { times++; } } bool isMoreThanHalf = true; if(times * 2 <= length) { g_bInputInvalid = true; isMoreThanHalf = false; } return isMoreThanHalf; }
这里我们引入了全局变量g_InvalidInput,原因就是我们有两种无效输入情况,而且我们不能像之前那样只是简单的抛出异常就行,它们需要作为判断条件来使用,但是全局变量的使用有个问题,如果其他地方会修改它的状态,那么我们必须在使用它的时候重新设置它的初始值,以确保我们的代码能够拥有正确的前提条件,所以最好的做法就是:除非是返回该值,否则我们应该在测试完该条件后将它设置为初始值。
这样我们的时间效率就是O(N),但是我们会修改原来的数组,所以我们必须结合题目的特点想出另一种解法:
int MoreThanHalfNum(int* data, int length) { if(CheckInvalidArray(data, length) { return 0; } int result = data[0]; int times = 1; for(int i = 1; i< length; ++i)
{ if(times == 0) { result = data[i]; times = 1; } else if(data[i] == result) { times++; } else { times--; } } if(!CheckMoreThanHalf(data, length, result)) { result = 0; } return result; }
这样的解法的思路就是我们知道,要找的数字是数组中出现次数最多的,我们可以在遍历数组的时候保存两个值:出现的数字和它出现的次数,如果和它相同,次数加1,如果不相同,次数减一。
这种做法就不需要修改原来数组的结构,但也能达到O(N)的结果,也就是以少量的空间换取时间效率的典型做法。
在分析数组规律的时候,还有一种方法:动态规划。
动态规划是数学分析中求最优解的一种方法,它并不是一种算法,只是帮助我们更快找到规律的一种方法而已,让我们来看一道题目:
题目五:输入一个整型数组,既有负数又有正数,数组中一个或连续的多个整数组成一个子数组,求所有子数组的和的最大值,要求时间复制度为O(N)。
这是目前唯一一道指定时间复杂度为O(N)的题目,所以也就要求我们必须有分析算法时间复杂度的能力。
我们还是要从测试用例开始。
我们假设有这样的数组:{1, -2, 3, 10, -4, 7, 2, -5},根据题目要求,符合要求的子数组应该是{3, 10, -4, 7, 2},也就是输出的和为18。
这样我们好像也找不到任何规律可言,所以我们就按照直观的做法来分析一下:
我们从数组的第一个元素开始:
{1}:1;
{1, -2}:-1,这时我们注意到,累计和为-1,无论下面的元素是什么,累计起来的和也会比下面那个元素小,所以我们选择抛弃该子数组,重新从下一个元素开始;
{3}:3;
{3, 10}:13;
{3, 10, -4}:9;
...
按照这样的思路,我们可以确定,如果累积和不为负数,我们就可以将该子数组作为最大和子数组的部分,根据这样的思路,我们可以写出这样的代码:
bool g_InvalidInput = false; int FindGreatestSumOfSubArray(int* data, int length) { if(data == NULL || length <= 0) { g_InvalidInput = true; return 0; } g_InvalidInput = false; int curSum = 0; int greatestSum = 0; for(int i = 0; i < length; ++i) { if(curSum <= 0) { curSum = data[i]; } else { curSum += data[i]; } if(curSum > greatestSum) { greatestSum = curSum; } } return greatestSum; }
这里引入了一个全局变量,它的作用就是用来标识到底是输入无效还是子数组的和为0这两种情况。
如果是使用动态规划,我们用f(i)来表示以第i个数字结尾的子数组的最大和,然后根据我们上面的思路,可以得出这样的递归公式:
就像是求斐波那契数列一样,我们可以用递归来编码:
bool g_InvalidInput = false; int FindGreatestSumOfSubArray(int* data, int length) { if(data == NULL || length <= 0) { g_InvalidInput = true; return 0; } g_InvalidInput = false; int n = length - 1; int curSum = 0; if(n == 0 || data[n - 1] <= 0) { curSum = data[n]; } else if(n != 0 && data[n - 1] > 0) { curSum = data[n] + FindGreatestSumOfSubArray(data, length - 1); } return curSum; }
如果使用循环来代替递归,那么代码就会和上面是一样的。
动态规划适用于多阶段的决策问题,我们首先要确定问题的决策对象,这里是f(i),也就是到i为止的子数组的和,然后我们再对决策过程划分阶段,这里每个数组元素都可以视为一个阶段,接着再对各阶段确定状态变量,也就是确定条件,最后就是根据状态变量确定函数表达式和各个阶段状态变量的转移过程。
但是这种思想有它的局限性,它必须满足这样的条件:
1.最优化原理(最优子结构性质)
一个最优化策略必须具有这样的性质,不论过去状态和决策如何,对前面的决策所形成的状态而言,剩下的所有决策必须构成最优策略,也就是说,一个最优化策略的子策略总是最优的。
2.无后效性
将各个阶段按照一定的次序排列好后,对于某个给定的阶段状态,前面各个阶段的状态无法直接影响它未来的决策,而只能通过当前的这个状态,换句话说,每个状态都是过去历史的一个完整总结。
因为这是正式的数学方法,所以它非常严谨。我们来看一下第一个条件,虽然说得非常复杂,其实也就是一句话:每一步策略都必须确保是最优解,这点在实际的分析中都能达到。最关键的是第二个条件,它是确定我们是否能够使用动态规划的关键条件。无后效性的真正意思就是说,我们在这个状态采取这个策略,是因为它是当前状态的最优解,而不取决于过去的决策,像是我们上面在遇到子数组的和为负数的情况下,我们采取的策略就是舍弃过去的子数组的和,而直接从该元素开始,这是当前状态下的最优解,跟前面到底是舍弃还是直接从当前元素开始的决策没有任何影响。这就是无后效性。
动态规划的"动态"就是来源于此:我们可以根据当前的状态动态的更换策略。
动态规划在实现的过程中,为了解决冗余,会采取用空间换取时间的做法,也就是必须存储该过程中的各种状态,这也就意味着它的空间复杂度要大于其他做法。这种情况就非常值得我们商榷了,所以我们在面试中必须确定一件事:我们是否可以使用辅助数组。像是上面的代码,我们已经将空间复杂度控制为O(1)了。
关于上面那道题目的讨论并没有完,如果数组全为负数呢?
上面的代码对于这种情况的处理就是返回0,但也有可能会要求我们返回最大的负数或者其他情况,这些都需要和面试官商量好。
动态规划在数组问题中非常常见,像是下面这道题目:
题目六:写一个时间复杂度尽可能低的程序,求一个数组中最长递增子序列的长度。
我们还是先从一个测试用例开始着手:
假设一个数组为{1, -1, 2, -3, 4, -5, 6, 7},那么它的最长递增子序列为{1, 2, 4, 6},那么它的长度就是4。
我们来看看这个过程:
当i为0时,数组元素为1,所以最长递增序列为{1};
当i为1时,数组元素为-1 < 1,所以舍弃之前的序列,重新建立序列:{-1};
当i为2时,数组元素为2时,由于2比1,-1都要大,所以现在的递增序列为{1, 2}, {-1, 2};
...
我们可以看到,它满足了无后效性,之前我们的策略所建立的序列并不会对我们现在的策略产生影响,所以我们可以利用动态规划来求解。
使用动态规划,最重要的是要确定各状态下的函数表达式。假设在目标数组data的前i个元素中,最长递增子序列的长度为len[i - 1],那么:
len[i] = max{1, len[j] + 1}, 其中,data[i] > data[j], j <= i - 1。
只要data[i] > data[j],我们就可以将data[i]附加到前面i - 1个元素产生的递增子序列后面。
根据这样的式子,我们可以这样编码:
int GetLengthOfSubArray(int* data, int length) { int[] len = new int[length]; for(int i = 0; i < length; ++i) { length[i] = 1; for(int j = 0; j < i; ++j) { if(data[i] > data[j]) { length[i] = len[j] + 1; } } } return Max(len); }
但是这个代码的时间复杂度为O(N * N + N) = O(N * N),我们还可以进一步优化:
int GetLengthOfSubArray(int* data, int length) { int[] MaxValue = new int[length + 1]; MaxValue[1] = data[0]; MaxValue[0] = Min(data) - 1; int[] len = new int[length]; for(int i = 0; i < length; ++i) { len[i] = 1; } int MaxLen = 1; for(int i = 1; i < length; ++i) { int j;
len[i] = BinarySearch(data[i], MaxValue, 0, i);
if(len[i] > MaxLen) { MaxLen = len[i]; MaxValue[len[i]] = data[i]; } else if(MaxValue[j] < data[i]) { MaxValue[j + 1] = data[i]; } } return MaxLen; }
现在我们的思路就是找出前面i - 1个元素的最长递增序列,然后加上第i个元素,由于使用了二分查询,所以整体的时间复杂度为O(N * log2N)。
动态规划特别适合求子数组这种问题,因为该类问题的最优解都需要用到辅助数组,而且都满足无后效性。