数组面试题
笔试和面试中,除了字符串,另一类出现频率极高的问题便是与数组相关的问题。解题思路:先一般考虑“万能的”暴力穷举(递归、回溯),如求n个数的全排列或八皇后(N皇后问题)。但因为穷举时间复杂度通常过高,所以需要考虑更好的方法。
如 分治法(通过分而治之,然后归并);
以及 空间换时间(如活用哈希表)。
此外,选择合适的 数据结构 可以显著提升效率,如寻找最小的k个数中,用 堆 代替 数组,也可借鉴快排思想,中枢分区方法。
再有,如果题目允许排序,则可以考虑 排序。
寻找和为定值的两个数中,先排序,然后用 前后两个指针 往中间扫描。
而如果如果已经排好序了(如杨氏矩阵查找中),则想想有无必要二分,二分法。但是,如果题目不允许排序呢?这个时候,
我们可以考虑 不改变数列顺序的 贪心算法(如 最小生成树Prim、Kruskal 及 最短路dijkstra)
或 动态规划(如 01背包问题,每一步都在决策)。最后,注意细节处理,不要忽略边界条件,如字符串转换成整数。
基础
int (*p)[10];// 首先(*p),p是一个指针,int [10],是一个包含10个int的数组,该指针p指向,这个数组// 是数组指针
int *p[10]; // 写成 int* p[10];比较合适,首先 [10]是一个包含 10个元素的数组,该元素类型为int*,即指向整形的指针// 是指针数组int a[10];// 整数数组
printf("%x\n",&a);// &a 是取一个数组的地址,是一个指针指向一个包含10个元素的数组,类型为 int (*p)[10];
printf("%x\n",a); // a 数组名,默认为数组首元素 a[0]的地址 和数组a的地址是同一个地址,该类型为指向一个整数的指针,即 int *p;
// 以上 &a 和 a 的值是一样的,是a[0]元素的存储地址,
// 但是含义不一样, &a指向了10个连续存储的整数, 而 a指向了一个 整数,即 a[0]// 类似的int (*p[])(int); // 函数指针数组。
// 首先(*p[]) 是一个指针数组,指向的类型为 int (int)是一个函数类型,输入一个int类型参数,输出一个int类型参数int (*p())[]; // 返回数组指针的函数。
// 首先 (*p()) 是一个返回指针类型的函数,该指针类型指向 int [] 即整形数组,即,p是一个返回整形数组指针的函数int *p()[] // 字面上可以解释为返回指针数组的函数,不过函数是不能返回数组的。
// () 是一个函数, 返回类型为 int* [],是一个指针数组int *(*a())() // 这是一个函数,它没有参数,它的返回值是一个函数指针,这个指针指向的函数,也没有参数,且返回值是int型的指针。
第一题 寻找集合中的最小的K个数
输入n个整数,输出其中最小的k个。
解法1 先排序再选择
要求一个序列中最小的k个数,按照惯有的思维方式,则是先对这个序列从小到大排序,然后输出前面的最小的k个数。至于选取什么的排序方法,我想你可能会第一时间想到快速排序我们知道,快速排序平均所费时间为n*logn,然后再遍历序列中前k个元素输出即可。
因此,总的时间复杂度:O(n * log n)+O(k)=O(n * log n)。
解法2 选择排序思想
解法二
咱们再进一步想想,题目没有要求最小的k个数有序,也没要求最后n-k个数有序。既然如此,就没有必要对所有元素进行排序。这时,咱们想到了用选择或交换排序,即:1、遍历n个数,把最先遍历到的k个数存入到大小为k的数组中,假设它们即是最小的k个数;2、对这k个数,利用选择或交换排序找到这k个元素中的最大值kmax(找最大值需要遍历这k个数,时间复杂度为O(k));3、继续遍历剩余n-k个数。假设每一次遍历到的新的元素的值为x,把x与kmax比较:如果x < kmax ,用x替换kmax,
并回到第二步重新找出k个元素的数组中最大元素kmax‘;如果x >= kmax,则继续遍历不更新数组,直到,k个数是此时最小的
每次遍历,更新或不更新数组的所用的时间为O(k)或O(0)。故整趟下来,时间复杂度为nO(k)=O(nk)。
解法3 利用最大堆 作为容器
更好的办法是维护容量为k的最大堆,原理跟解法二的方法相似:1、用容量为k的最大堆存储最先遍历到的k个数,同样假设它们即是最小的k个数;2、堆中元素是有序的,令k1<k2<...<kmax(kmax设为最大堆中的最大元素)3、遍历剩余n-k个数。假设每一次遍历到的新的元素的值为x,把x与堆顶元素kmax比较:如果x < kmax,用x替换kmax,然后更新堆(用时logk);否则不更新堆。这样下来,总的时间复杂度:O(k+(n-k)*logk)=O(n*logk)。此方法得益于堆中进行查找和更新的时间复杂度均为:O(logk)(若使用解法二:在数组中找出最大元素,时间复杂度:O(k))。
解法4 利用快排思想 得到大小数的 分区中枢
int Cut(int a[], int low, int high){int temp = a[low];// 区间第一个元素 可以随机选择 在替换到low的位置while(low < high){while((low < higt)&&(a[high] > temp)) --high;// 从右边向左寻找比 temp小的元素 a[higt]a[low] = a[high];// 把比temp小的元素 放在 原来temp的位置(左边小的区域)while((low < high)&&(a[low] < temp)) ++low;// 从左边寻找比 temp大的元素a[low] 放在右边 high的位置a[high] = a[low];}a[low] = temp; // 临时变量 放在 中枢位置return low; // 返回中枢位置}// 在数组a中选择k个最小的元素 块选 非递归调用void QuickSelest(int a[], int low, int high, int k){int index = Cut(a, 0, n-1);// 首先得到一个中枢while(index != k-1){// 非递归调用if(index > k - 1) index = Cut(a, low, index -1);// 左半部分小的元素个数大于k 在左半部分找 中枢else index = Cut(a, index+1, high);// 左半部分小的元素个数小于k 右半部分还有,在右半部分找 中枢}// 打印for(int i = 0; i<k; ++i) cout<<a[i];cout << endl;}// 而快排为 是递归调用void QuickSort(int a[], int low, int high){int index;if(low < high){index = Cut(a, low, high);//选取中枢位置QuickSort(a, low, index-1);//对左边快排QuickSort(a, index+1, high);//对右边快排}}// 二分查找 非递归版本int BinarySearch(int a[], int low, int high, int key){int mid;//中间元素while(low < high){int mid = low + ((high - low)>>1);//中间元素的 下标 在循环体内 不停的被改变if( key < a[mid] ) high = mid - 1;// 查找的元素小于中值 高区间移至 mid-1elae if(key > a[mid] ) low = mid + 1;// 查找元素大于中值, 将低区间移至 mid+1else return mid;}return -1;//未找打 返回-1}// 二分查找 递归版本int BinarySearch(int a[], int low, int higt, int key){int mid = low + ((high - low)>>1);//中间元素的 下标if(low < high){if(key < a[mid]) return BinarySearch(a, low, mid-1, key);else if(key > a[mid]) return BinarySearch(a, mid+1, high, key);else return mid;}return -1;
}
第二题 寻找和为定值的两个数
输入一个数组和一个数字,在数组中查找两个数,使得它们的和正好是输入的那个数字。要求时间复杂度是O(N)。如果有多对数字的和等于输入的数字,输出任意一对即可。例如输入数组1、2、4、7、11、15和数字15。由于4+11=15,因此输出4和11。
思路:暴力搜索 时间复杂度 n^2
其二,二分法(相当于用两个指针),从前后向中间搜索,看情况移动左右指针
代码如下
// 二分(若无序,先排序后二分),时间复杂度总为O(N log N),空间复杂度为O(1);void findTwo2(int a[], int low, int high, int sum){//sort(a, a+high+1); 如果数组非有序的,那就事先排好序O(N log N)while(low < high){if(a[low] + a[high] > sum) --high;//太大 right减少else if(a[low] + a[high] < sum) ++low; //太小 low增加else cout << a[low] << a[high] < endl;}cout << "Can't found" < endl;
}
总结
不论原序列是有序还是无序,解决这类题有以下三种办法: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)则不在话下。
举一反三:
输入:int[]nums int target 输出:符合要求的所有情况 给定一个int类型的数组,sum2、sum3、sum4分别输出2、3、4个元素的和为target的所有结果,不能重复,并且结果从小到大排序。【解法】: 三个题目均可以使用双指针的做法。首先对原始数组进行排序,排序的意义为:(1)保证最后结果的有序性;(2)从小到大的查找是否满足target,若小于他,则start指针往前走 ++start,若大于他,这end指针往回走 --end; 接着第一个指针从最小的元素开始,第二个指针从最大的元素开始,通过对和与target进行比较,判断start和end的走向。 sum2可以不用循环,只需要两个指针。同时sum2还可以有另一种解法,从左到右判断,采用一个集合保存判断过的结果,从集合中查找target-nums[i]是否存在;(n) sum3采用一层循环,start从循环处的下一个元素开始(n`2)sum4采用两层循环,第二层循环从第一层循环的i值开始,最内部start从第二层循环的下一个元素开始。(n`3)
第三题 寻找和为定值的多个数
题目描述
输入两个整数n和sum,从数列1,2,3…n 中随意取几个数,使其和等于sum,要求将其中所有的可能组合列出来。
01背包问题求解
解法一注意到取n,和不取n个区别即可,考虑是否取第n个数的策略,可以转化为一个只和前n-1个数相关的问题。如果取第n个数,那么问题就转化为“取前n-1个数使得它们的和为sum-n”,对应的代码语句就是sumOfkNumber(sum - n, n - 1);如果不取第n个数,那么问题就转化为“取前n-1个数使得他们的和为sum”,对应的代码语句为sumOfkNumber(sum, n - 1)。0-1背包问题0-1背包问题是最基础的背包问题,其具体描述为:有N件物品和一个容量为V的背包。放入第i件物品耗费的费用是Ci,得到的价值是Wi。求解将哪些物品装入背包可使价值总和最大。简单分析下:这是最基础的背包问题,特点是每种物品仅有一件,可以选择放或不放。用子问题定义状态:即F[i, v]表示前i件物品恰放入一个容量为v的背包可以获得的最大价值。则其状态转移方程便是:F[i, v] = max{F[i-1, v], F[i-1, v-Ci ] + Wi}根据前面的分析,我们不难理解这个方程的意义:“将前i件物品放入容量为v的背包中”这个子问题,若只考虑第i件物品的策略(放或不放),那么就可以转化为一个只和前 i-1 件物品相关的问题。即:如果不放第i件物品,那么问题就转化为“前i-1件物品放入容量为v的背包中”,价值为 F[i-1, v ];如果放第i件物品,那么问题就转化为“前i-1件物品放入剩下的容量为v-Ci的背包中”,此时能获得的最大价值就是F[i-1, v-Ci]再加上通过放入第i件物品获得的价值Wi。list<int>list1;void SumOfkNumber(int sum, int n){// 递归出口if (n <= 0 || sum <= 0)return;// 输出找到的结果if (sum == n){// 反转listlist1.reverse();for (list<int>::iterator iter = list1.begin(); iter != list1.end(); iter++)cout << *iter << " + ";cout << n << endl;list1.reverse()//此处还需反转回来}list1.push_front(n); //放置进背包 当前占了n 前面剩余sum-n空间 典型的01背包问题SumOfkNumber(sum - n, n - 1);//“放”n,前n-1个数“填满”sum-nlist1.pop_front();// 取出来 不放进去 前面还剩余sum的空间SumOfkNumber(sum, n - 1); //不“放”n,n-1个数“填满”sum}
第四题 最大连续子数组和
事实上,当我们令currSum为当前最大子数组的和,maxSum为最后要返回的最大子数组的和,当我们往后扫描时,对第j+1个元素有两种选择:要么放入前面找到的子数组,要么做为新子数组的第一个元素;如果currSum加上当前元素a[j]后不小于a[j],则令currSum加上a[j],否则currSum重新赋值,置为下一个元素,即currSum = a[j]。同时,当currSum > maxSum,则更新maxSum = currSum,否则保持原值,不更新。即:currSum = max(a[j], currSum + a[j])maxSum = max(maxSum, currSum)举个例子,当输入数组是1, -2, 3, 10, -4, 7, 2, -5,那么,currSum和maxSum相应的变化为:currSum: 0 1 -1 3 13 9 16 18 13maxSum : 0 1 1 3 13 13 16 18 18
参考代码如下:
int MaxSubArr(int* a, int num){
int currSum = 0;//初始化 当前和 为0
int maxSum = a[0];//初始化 第一个元素为 最大和
for(int j = 0; j < n; ++j ){
currSum = (currSum + a[j] > a[j] ) ? (currSum + a[j]) : a[j];
maxSum = maxSum > currSum ? maxSum : currSum;
}
return maxSum;
}
第五题 跳台阶问题
一个台阶总共有n 级,如果一次可以跳1 级,也可以跳2 级。求总共有多少总跳法,并分析算法的时间复杂度。
解法一:首先考虑最简单的情况,函数递归调用求解。
如果只有1级台阶,那显然只有一种跳法。如果有2级台阶,那就有两种跳的方法了:一种是分两次跳,每次跳1级;另外一种就是一次跳2级。现在我们再来讨论一般情况。我们把n级台阶时的跳法看成是n的函数,记为f(n)。当n>2时,第一次跳的时候就有两种不同的选择:一是第一次只跳1级,此时跳法数目等于后面剩下的n-1级台阶的跳法数目,即为f(n-1);另外一种选择是第一次跳2级,此时跳法数目等于后面剩下的n-2级台阶的跳法数目,即为f(n-2)。因此n级台阶时的不同跳法的总数f(n)=f(n-1)+f(n-2)。我们把上面的分析用一个公式总结如下:/ 1 n = 1f(n)= 2 n = 2\ f(n-1) + f(n-2) n > 2原来上述问题就是我们平常所熟知的Fibonacci数列问题。
可编写代码,如下:
longn long Fibonacci(unsigned int n){int result[] = [0, 1, 2];if (n <= 2) return result[n];else return Fibonacci(n-1) + Fibonacci(n-2);}
那么,如果一个人上台阶可以一次上1个,2个,或者3个呢?这个时候,公式是这样写的:
/ 1 n = 1f(n)= 2 n = 24 n = 3 //111, 12, 21, 3\ f(n-1) + f(n-2) + f(n-3) n > 3
可编写代码,如下:
longn long F(unsigned int n){int result[] = [0, 1, 2, 4];if (n <= 3) return result[n];else return F(n-1) + F(n-2) + F(n-3);}
解法二,循环递推公式求解
解法一用的递归的方法有许多重复计算的工作,事实上,我们可以从后往前推,一步步利用之前计算的结果递推。+
初始化时,dp[0]=dp[1]=1,
然后循环递推计算即可:dp[n] = dp[n-1] + dp[n-2],当前解 等于前两次的和
参考代码如下:
// 1,1,2,3,5,8,13,21..int ClimbStairs(int n){// n级台阶,可以一次调1个台阶和2个台阶两种选择,有多少种跳发int dp[3] = {1, 1};// 保存当前值 上一次值 上上次值的数组if(n <=1) return 1;else {for(int i = 2; i <= n; ++i){d[2] = d[1] + d[0];d[0] = d[1];d[1] = d[2];// 更新数组的值}}}
换硬币问题
// 想兑换100元钱,有1,2,5,10四种钱,问总共有多少兑换方法。 递推公式 arr[j] += arr[j - dimes[i]];const int N = 100;int dimes[] = { 1, 2, 5, 10 };int arr[N + 1] = { 1 };//存放每一个总钱数可以的兑换次数int coinExchange(int n) //非递归实现 {for (int i = 0; i < sizeof(dimes) / sizeof(int); ++i)//对于每一个面值{for (int j = dimes[i]; j <= n; ++j)// 遍历总钱数j ~ n{arr[j] = arr[j] + arr[j - dimes[i]];//分为两种情况,//总钱数j,如果没有换当前硬币i,那么是多少?arr[j]//加上,如果换了当前硬币dimes[i],总值减少为 j-dimes[i],此时又是多少种兑换方法?arr[j-dimes[i]]}}return arr[n];}
奇偶调序
输入一个整数数组,调整数组中数字的顺序,使得所有奇数位于数组的前半部分,所有偶数位于数组的后半部分。要求时间复杂度为O(n)。
方法1 利用快排思想 奇偶数分类下标 头指针,向右移动 尾指针,向左移动
借鉴partition 分治 的实现一,我们可以考虑维护两个指针,一个指针指向数组的第一个数字,我们称之为头指针,向右移动;一个指针指向最后一个数字,称之为尾指针,向左移动。这样,两个指针分别从数组的头部和尾部向数组的中间移动,如果第一个指针指向的数字是偶数而第二个指针指向的数字是奇数,我们就交换这两个数字。因为按照题目要求,最终是为了让奇数排在数组的前面,偶数排在数组的后面,所以头指针理应指向的就是奇数,尾指针理应指向的就是偶数,
故当头指针指向的是偶数且尾指针指向的是奇数时,我们就当立即交换它们所指向的数字。
代码实现:
// 判断是否为奇数bool isOddNumber(int n){return n & 1 == 1; // 奇数 的最后一个二进制位为1}// void OddEvenChange(int *a, int num){if(a == NULL|| num == 0) return;// 空指针 以及长度为0 直接返回int* low = a; // 头指针int* high = a + num - 1; // 尾指针while(low < high){if(!isOddNumber(*high) --high;//如果 high指针指向的是偶数,正常,向前移 减小else if(isOddNumber(*low) ++low;//如果 low指针指向的是奇数,正常,向后移 加大else //否则都不正常,交换//swap是STL库函数,声明为void swap(int& a, int& b);swap(*pBegin, *pEnd);}}
本方法通过头尾两个指针往中间扫描,一次遍历完成所有奇数偶数的重新排列,时间复杂度为O(n)。
荷兰旗 三色球 问题 三类的分配问题
现有n个红白蓝三种不同颜色的小球,乱序排列在一起,请通过两两交换任意两个球,使得从左至右,依次是一些红球(类1)、一些白球(类2)、一些蓝球(类3)。通过前面的分析得知,这个问题类似快排中partition过程,只是需要用到三个指针:一个前指针begin(指向类1),一个中指针current(指向类2),一个后指针end(指向类3),current指针遍历整个数组序列,当:current指针所指元素为 类1 时,与begin指针所指的元素交换,而后current++,begin++ ;current指针所指元素为 类2 时,不做任何交换(即球不动), 而后current++ ;current指针所指元素为 类3 时,与end指针所指的元素交换, 而后,current指针不动(end指向的元素可能为类1 ),end-- 。
参考代码如下:
while( current<=end ) { if( array[current] ==0 ) { swap(array[current],array[begin]); current++; begin++; } else if( array[current] == 1 ) { current++; } else //When array[current] =2 { swap(array[current],array[end]); end--; } }