3.面试算法-数组之大厂冲刺题

1. 大厂冲刺题

如果前面的问题搞清楚了,恭喜你,一维数组已经迈开一大步了。如果你志存高远,想超越自己,练习更多有挑战的题目,那我们继续看吧。

1.1 数组加法专题

数字加法,小学生都会的问题,但是如果让你用数组来表示一个数,如何实现加法呢?你可能会感觉这个简单,仍然从数组末尾向前挨着计算就行了,但是实现的时候会发现有很多问题,例如算到A[0]位置时发现还要进位该怎么办呢?

再拓展,假如一个是数组,一个是普通的整数,要一边遍历数组一边处理整数,你能轻松搞定吗?

再拓展 ,如果两个整数是用字符串表示的呢?如果要按照二进制加法的规则来呢?

再拓展,如果整数是用链表表示的呢?该问题我们到下一章链表再谈。这里先看看前面这几种情况。

1.1.1 数组实现整数加法

先看一个用数组实现逐个加一的问题。具体要求是由整数组成的非空数组所表示的非负整数,在其基础上加一。这里最高位数字存放在数组的首位,数组中每个元素只存储单个数字。并且假设除了整数 0 之外,这个整数不会以零开头。例如:

输入:digits = [1, 2, 3]
输出: [1, 2, 4]
解释:输入数组表示数字 123。

这个看似很简单是不?从后向前依次加就行了,如果有进位就标记一下,但是如果到头了要进位怎么办呢?

例如在示例2中,从后向前加的时候,到了A[0]的位置计算为0,需要再次进位但是数组却不能保存了,这该怎么办呢?如果你在网上找相关的实现,会发现很多实现特别麻烦,看的欲望都没有,这里我们可以利用java的特定大大 简化操作。

这里的关键是A[0]处什么时候出现再次进位的情况,我们知道此时一定是9,99,999,这样的结构才会出现加1之 后再次进位,而进位之后的结果一定是10,100,1000这样的结构,由于java中数组默认初始化为0,所以我们此时只要申请一个空间比A[]大一个的数组B[],然后将B[0]设置为1就行了。

这样代码就会变得非常简洁:

public static int[] plusOne(int[] digits) {
	int len = digits.length;
	for (int i = len - 1; i >= 0; i--) {
		digits[i]++;
		digits[i] %= 10;
		if (digits[i] != 0)
			return digits;
	}
	// 比较巧妙的设计
	digits = new int[len + 1];
	digits[0] = 1;
	return digits;
}

这里使用数组默认初始化为0的特性来大大简化了我们的处理复杂度。如果使用的是C等默认值不是0的语言,我们只要在申请的时候先将所有的元素初始化为0就行了。

再看一个实现两个数相加的问题,具体要求:

对于非负整数 X 而言,X 的数组形式是每位数字按从左到右的顺序形成的数组。
例如,如果 X = 1231,那么其数组 形式为 [1, 2, 3, 1]。给定非负整数 X 的数组形式 A,返回整数 X+K 的数组形式。

示例1:
输入:A = [1, 2, 0, 0],K = 34
输出: [1, 2, 3, 4]
解释: 1200 + 34 = 1234

示例2:
输入:A = [9,9,9,9,9,9,9,9,9,9],K = 1
输出: [1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
解释:9999999999 + 1 = 10000000000

这个问题不需要太复杂,根据加法的原理逐个位置相加就行,但是这里需要先处理K,采用取余和除法来逐步获得所有低位元素,再将其与数组数字加在一起就行了。例如计算 123+912,我们从低位到高位依次计算 3+23+2、2+12+1 和 1+91+9。任何时候,若加法的结果大于等于 1010,把进位的 11 加入到下一位的计算中,所以最终结果为 10351035。

public List<Integer> addToArrayForm(int[] num, int k) {
	List<Integer> res = new ArrayList<Integer>();
	int n = num.length;
	for (int i = n - 1; i >= 0; --i) {
		int sum = num[i] + k % 10;
		k /= 10;
		if (sum >= 10) {
			k++;
			sum -= 10;
		}
		res.add(sum);
	}
	for (; k > 0; k /= 10) {
		res.add(k % 10);
	}
	Collections.reverse(res);
	return res;
}

1.1.2 字符串加法与二进制加法

上面这个问题如果都使用两个数组A[]和B[]来存就毫无难度了,加数使用K就是为了提高实现的难度,假如这里将整数换成字符串或者二进制会怎么样呢?我们继续看:

字符串加法就是使用字符串来表示数字,然后计算他们的和,具体要求如下:

给定两个字符串形式的非负整数 num1 和num2 ,计算它们的和并同样以字符串形式返回。
你不能使用任何內建的用于处理大整数的库(比如 BigInteger),也不能直接将输入的字符串转换为整数形式。

例如:
输入:num1 = “456”, num2 = “77”
输出: “533”

我们先想一下小学里如何计算两个比较大的数相加的,经典的竖式加法是这样的:
在这里插入图片描述

从低到高逐位相加,如果当前位和超过 1010,则向高位进一位?因此我们只要将这个过程用代码写出来即可。具体实现也不复杂,我们定义两个指针 ii 和 jj 分别指向num1和num2的末尾,即最低位,同时定义一个变量 add 维 护当前是否有进位,然后从末尾到开头逐位相加即可。你可能会想两个数字位数不同怎么处理,这里我们统一在指 针当前下标处于负数的时候返回 00,等价于对位数较短的数字进行了补零操作,这样就可以除去两个数字位数不同情况的处理,具体可以看下面的代码:

public String addStrings(String num1, String num2) {
	int i = num1.length() - 1, j = num2.length() - 1, add = 0;
	StringBuffer ans = new StringBuffer(); 
  	while (i >= 0 || j >= 0 || add != 0) {
		int x = i >= 0 ? num1.charAt(i) - '0' : 0;
		int y = j >= 0 ? num2.charAt(j) - '0' : 0;
		int result = x + y + add;
		ans.append(result % 10);
		add = result / 10;
		i--;
		j--;
	}
	// 计算完以后的答案需要翻转过来 
	ans.reverse();
	return ans.toString();
}

这个问题虽然需要在两头都使用字符串函数转换一下,但是难度不大,我们再看一下,如果这里是二进制该怎么处理呢? 看一下题目要求:

给你两个二进制字符串,这个字符串是用数组保存的,返回它们的和(用二进制表示)。 输入为非空字符串且只包含数字 1 和 0。

示例1:
输入 : a = “11”, b = “1”
输出 : “100”

示例2:
输入 : a = “1010”, b = “1011”
输出 : “10101”

这个题也是用字符串来表示数据的,也要先转换为字符数组。我们熟悉的十进制,是从各位开始,逐步向高位加,达到10就进位,而对于二进制则判断相加之后是否为二进制的10,是则进位。本题解中大致思路与上述一致,但由于字符串操作原因,不确定最后的结果是否会多出一位进位,所以会有 2 种处理方式:

  • 第一种,在进行计算时直接拼接字符串,会得到一个反向字符,需要最后再进行翻转
  • 第二种,按照位置给结果字符赋值,最后如果有进位,则在前方进行字符串拼接添加进位

这两种方法都可以用,关键看具体题目适合哪一种,我们这里采用第二种实现。

public String addBinary(String a, String b) {
	StringBuilder ans = new StringBuilder();
	int ca = 0;
	for(int i = a.length() - 1, j = b.length() - 1;i >= 0 || j >= 0; i--, j--) {
		int sum = ca;
		sum += i >= 0 ? a.charAt(i) - '0' : 0;
		sum += j >= 0 ? b.charAt(j) - '0' : 0;
		ans.append(sum % 2);
		ca = sum / 2;
	}
	ans.append(ca == 1 ? ca : "");
	return ans.reverse().toString();
}

这里还有人会想,先将其转换成十进制,加完之后再转换成二进制可以吗?这么做实现非常容易,而且可以使用 jdk的方法直接转换,但是还是那句话,工程里可以这么干,稳定可靠,但是算法里不行,太简单了。

1.2 进制转换问题

进制转换是一个说起来简单,实现起来非常头疼的问题,主要是转换时建立映射需要很大的代码量,例如十进制转 16进制时, ABCDEF 的处理,至少要写6个if else,即使使用Switch也不简单。另外还有正负等问题,那该办呢?

我们先看一个简单的问题,将一个整数转换成七进制数,并以字符串的形式输出,例如

示例1;
输入 : num = 100
输出 : “202”

示例2:
输入 : num = -7
输出 : “-10”

这个问题其实可以用jdk的方法一行搞定:

public String convertToBase7(int num) {
   return Integer.toString(num, 7);
}

很明显,这适合工作中用,在这里我们还是要手写,这个题目也不需要太多技巧,按照计算规则写就可以了:

public String convertToBase7(int num) {
   	boolean flag = true;
   	if(num<0){
   			flag = false;
   			num = (-1)*num;
   	}
   	if(num==0){
   			return 0+"";
   	}
   	int a = 0;
   	a=num%7;
   	num=num/7;
   	int i=1;
   	while(num>0){
   			a+=(num%7)*(int)Math.pow(10,i);
   			i++;
   	num=num/7;
   	}
   	if(flag){
   			return a+"";
   	}else{
   			return "-"+a;
   	}
}

上面这个问题还比较简单,再看一个考察更频繁,难度也更大,技巧也更多的问题,先看题意:

给定一个十进制数M,以及需要转换的进制数N。将十进制数M转化为N进制数。 M是32位整数, 2<=N<=16.

这个题目的思路不难,但是想写正确却很不容易,关键问题在于超过10进制之后如何准确映射,这会有大量的if 判 断,如果一个个定义,会出现了写了一坨,结果越写越乱的情况。

另外还需要对结果进行一次转置,还需要判断负号,是非常考验编程功底的题。为此采取三个措施:

  • 定义大小为16的数组F,保存的是2到16的各个值对应的标记,这样赋值时只计算下表,必考虑每种进制的 转换关系了。

  • 使用StringBuffer完成数组转置等功能。

  • 通过一个flag来判断正数还是负数。

/**
* 进制转换
* @param M int整型  给定整数
* @param N int整型  转换到的进制
* @return string字符串 
*/
// 因为要考虑到  余数  > 9 的情况, 2<=N<=16.
public static final String[] F = {"0", "1", "2", "3", "4", "5", "6", "7", "8", "9",
"A", "B", "C", "D", "E", "F"};

public String solve (int M, int N) {
	Boolean flag=false;
	if(M<0) {
		flag=true;
		M*=-1;
	}
	StringBuffer sb=new StringBuffer();
	int temp;
	while(M!=0){
		temp=M%N;
		//精华之一:通过数组F[]解决了大量繁琐的不同进制之间映射的问题 
    	sb.append(F[temp]);
		M=M/N; 
    }
	//精华之二:使用StringBuffer的reverse()方法,让原本麻烦的转置瞬间美好 
  	sb.reverse();
	//精华之三:最后处理正负的问题,不要从一开始就揉在一起。 
  	return (flag? "-":"")+sb.toString();
}

上面的写法是我找到的最简洁,最好实现的方式。

1.3 数组重排问题

先看题目要求:

找出数组中重复的数字。在一个长度为 n 的数组 nums 里的所有数字都在 0~n-1 的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。请找出数组中任意一个重复的数字。

例如:
输入:[2, 3, 1, 0, 2, 5, 3]
因为2和3有重复,所以可以输出: 2 或 3

对于重复元素, Hash和集合是一个常用策略,我们首先想到可以用集合来记录数组的各个数字,当查找到重复数字则直接返回就行了,具体说来,

  • 初始化: 新建 HashSet ,记为 set;

  • 然后遍历数组 nums 中的每个数字 num :

    • 当 num在 set 中,说明重复,直接返回 num
    • 将 num 添加至 set 中;

如果不存在则返回-1 。本题中说一定有重复数字,因此遇到了直接返回就行了。

所以代码就这样子:

public int findRepeatNumber(int[] nums) {
	Set<Integer> set = new HashSet<>();
	for(int num : nums) {
		if(set.contains(num))
			return num;
		set.add(num);
	}
	return -1;
}

如果是在工程里遇到这个问题,就足够了,但是在算法里还不行,因为你需要开辟一个O(n)的空间。到这里你只能得个及格分。面试官可能会问你还有其他方法吗?然后貌似排序也可以是吧,从排序的数组中找出重复的数字只需 要从头到尾扫描即可,所以先排序再查找的时间复杂度主要就取决于排序算法,一般为O(nlogn)。但是为了一个搜 索就执行排序这样的重量级操作,还将原始数组给改了,还不好,继续找。

我们希望得到一种不消耗额外空间的算法,也就是本题的第三种解法: 数组重排。由于题目中告诉我们所有的数字都在0到n-1的范围内,因此如果没有重复,那么所存储的值也正好是0到n-1这n个数字,我们把原数组重新排列为 一个元素和对应下标值相同的数组。具体思路如下:

从头到尾扫描整个数组中的数字,当扫描到下标为i的数字时,首先比较这个数字(用arr[i]表示)是不是等于下标 i,如果是,接着比较下一个数字;如果不是,则将其与索引位置为arr[i]的数字比较,若与其相同,则说明它就是一个重复数字,如果不同,就将其与第 m个数字进行交换,也就是把它放到自己应在的位置去。重复这个过程,直 到该位置上的数与下标相同为止。这种思路就像收拾房间一样,物品如果不在自己的位置上就将其拿过去,这样假 如你买了两包一样的零食,骗来一个妹子几张一样的照片都可以清楚地知道了。

该算法看起来是两层循环,但是每个数字最多进行两次交换就会找到属于自己的位置,因为总的时间复杂度还是 O(n),不需要额外内存也就是这样子:

在这里插入图片描述

{2,3,1,0,2,5,3}具体调整过程是:

  • 0(索引值)和2(索引值位置的元素)不相等,并且2(索引值位置的元素)和1(以该索引值位置的元素2为索引值的位 置的元素)不相等,则交换位置,数组变为: {1,3,2,0,2,5,3};
  • 0(索引值)和1(索引值位置的元素)仍然不相等,并且1(索引值位置的元素)和3(以该索引值位置的元素1为索引值 的位置的元素)不相等,则交换位置,数组变为: {3,1,2,0,2,5,3};
  • 0(索引值)和3(索引值位置的元素)仍然不相等,并且3(索引值位置的元素)和0(以该索引值位置的元素3为索引值 的位置的元素)不相等,则交换位置,数组变为: {0,1,2,3,2,5,3};
  • 0(索引值)和0(索引值位置的元素)相等,遍历下一个元素;
  • 1(索引值)和1(索引值位置的元素)相等,遍历下一个元素;
  • 2(索引值)和2(索引值位置的元素)相等,遍历下一个元素;
  • 3(索引值)和3(索引值位置的元素)相等,遍历下一个元素;
  • 4(索引值)和2(索引值位置的元素)不相等,但是2(索引值位置的元素)和2(以该索引值位置的元素2为索引值的位置的元素)相等,则找到了第一个重复的元素。

实现代码如下:

public static int duplicate(int numbers[]) { 
  	int length = numbers.length;
	if (numbers == null || length < 1) {
		return -1;
	}
	for (int i = 0; i < length; i++) {
		//每个元素最多被交换两次就可以找到自己的位置,依次复杂度是O( n)
		while (numbers[i] != i) {
			if (numbers[numbers[i]] == numbers[i]) {
				return numbers[i];
			} else {
				int temp = numbers[numbers[i]]; //交换 
				//将numbers[i]放到属于他的位置上
				numbers[numbers[i]] = numbers[i];
				numbers[i] = temp;
			}
		}
	}
	return -1;
}

这里虽然效率更高了,但是仍然改变了原始数组的内容,有没有不改变的方法呢?还有一种思路简单,但是实现比较麻烦的方式,这种思路类似折半查找,这里说一下思路。

以长度为8的数组{2,3,5,4,3,2,6,7}为例,根据题目要求,这个长度为8的数组,所有元素都在1到7的范围内,中间的数字4将1—7分成两部分,分别为1—4和5—7,接下来统计1—4在数组中出现的次数,发现是5次,则说明这4个数字中一定有重复数字。接下来再把1—4分成1 、2和3 、4两部分, 1和2一共出现了两次, 3和4一共出现了3次,说明3和4中有一个重复,再分别统计即可得到是3重复了。这并不保证找出所有的重复数字,比如2就没有找到。

实际上,这种二分查找时间复杂度也达到了O(nlogn),不如用哈希表空间换时间来的直观。

1.4 出现次数问题

数组还有一种类型就是让你解决元素出现次数,虽然看起来简单,但是直观想到的不一定是最优解,所以我们还是要认真看一下的。

1.4.1 数组中出现次数超过一半的数字

数组中有一个数字出现的次数超过数组长度的一半,请找出这个数字。

例如:输入如下所示的一个长度为9的数组{1, 2, 3, 2, 2, 2, 5, 4, 2}。由于数字2在数组中出现了5次,超过数组长度 的一半,因此输出2。如果不存在则输出0。

分析

对于没有思路的问题,我们的策略都是先在脑子里快速过一遍常见的数据结构和常见的算法策略,看看谁能解决问 题,所以很多问题就会自然而然的出现多种解法。

首先,想想排序行不行?这里说一定有了,那么先对数组进行排序,在一个有序数组中次数超过一半的必定是 中位数,那么可以直接取出中位数,然后遍历数组,看中位数是否出现次数超过一半。 OK,没问题,第一种方法就出来了,这种方法的的时间复杂父取决于排序的时间复杂度,最快为O(nlogn)。

其次, Hash行不行?遍历数组, HashMap的key是元素的值, value是已经出现的次数,这样可以从map中直接判断是否有超过一半的数字。 OK,第二种方法出来了。这种算法的时间复杂度为O(n),但是这个性能提升是用 O(n)的空间复杂度换来的。代码就是:

public int moreThanHalfNum(int [] array) {
	if(array==null)
		return 0;
	Map<Integer,Integer> res=new HashMap<>();
	int len = array.length;
	for(int i=0;i<array.length;i++){
		res.put(array[i],res.getOrDefault(array[i],0)+1);
		if(res.get(array[i])>len/2)
			return array[i];
	}
	return 0;
}

上面的方式虽然能解决问题,但是不够好,面试的时候如果你实在想不出来更好的方式。但是将前面这两种方 式实现得很漂亮,那就得了80分了。

本题的一种最优方式是:根据数组特点,数组中有一个数字出现的次数超过数组长度的一半,也就是说它出现的次 数比其他所有数字出现的次数之和还要多。因此,我们可以在遍历数组的时候设置两个值:一个是数组中的数

result,另一个是出现次数times。当遍历到下一个数字的时候,如果与result相同,则次数加1,不同则次数减一,当次数变为0的时候说明该数字不可能为多数元素,将result设置为下一个数字,次数设为1。这样,当遍历结束后,最后一次设置的result的值可能就是符合要求的值(如果有数字出现次数超过一半,则必为该元素,否则不存 在),因此,判断该元素出现次数是否超过一半即可验证应该返回该元素还是返回0。这种思路是对数组进行了两 次遍历,复杂度为O(n)。

在这里times最小为0,如果等于0了,遇到下一个元素就开始+1。看两个例子,例如 [1, 2, 1, 3, 1, 4, 1]和 [2, 1, 1, 3, 1, 4, 1]两个序列。

首先看 [1, 2, 1, 3, 1, 4, 1]:

开始的时候result=1 ,times为1 然后result=2 ,times减一为0

然后result=1,times已经是0了,遇到新元素就加一为1 然后result=3,times减一为0

然后result=1,times已经是0了,遇到新元素就加一为1 然后result=4,times减一为0

然后result=1,times加一为1

所以最终重复次数超过一半的就是1了。

这里可能有人会有疑问,假如1不是刚开始的元素会怎样呢?例如假如是序列[2, 1, 1, 3, 1, 4, 1],你按照上面的过程写 一下,会发现扛到最后的还是result=1,此时times为1。

还有一种情况假如是偶数,而元素个数恰好一半会怎么样呢?例如[1, 2, 1, 3, 1, 4],很明显最后结果是0,只能说明没 有存在重复次数超过一半的元素。代码如下:

public int moreThanHalfNum(int [] array) {
	if(array==null||array.length==0)
		return 0;
	int len = array.length;
	int result=array[0];
	int times=1;
	for(int i=1;i<len;i++){
		if(times==0){
			result=array[i];
			times=1;
			continue;
		}
		if(array[i]==result)
			times++;
		else
			times--; 
    }
	//检查是否符合 
  	times=0;
	for(int i=0;i<len;i++){
		if(array[i]==result)
			times++;
		if(times>len/2)
			return result;
	}
	return 0;
}

1.4.2 数组中只出现一次的数字

题目要求:

给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元 素。

示例1:
输入: [2, 2, 1]
输出: 1

示例2:
输入: [4, 1, 2, 1, 2]
输出:4

分析

这个题貌似使用Set集合比较好, Set集合不存储重复值, add()方法返回值为boolean类型,这一特点可以利用。题目明确说其他元素都是出现两次,我们也可以利用这个操作,当要添加的数与集合中已存在的数重复时,不会再进行添加操作,返回false,这时再进行remove操作,将集合中已存在的那个与要添加的数相同的元素移除,这样将作为方法参数传递过来的整型数组遍历完后,到最后集合中就只剩下了那个只出现了一次的数字。

public static Integer findOneNum(int[] arr) {
	Set<Integer> set = new HashSet<Integer>();
	for(int i : arr) {
		if(!set.add(i))//添加不成功返回false,前加上!运算符变为true 
        	set.remove(i);//移除集合中与这个要添加的数重复的元素
	}
	//注意边界条件的处理
	if(set.size() == 0)
		return null;
	//如果Set集合长度为0,返回null表示没找到
	return set.toArray(new Integer[set.size()])[0];
}

上面要注意,必须存在那个只出现了一次的数字,否则Set集合长度将为0,最后一句代码运行时会出错,改进一下 的话,所以前面加了个空来拦截可能的错误。

这个题如果真的遇到了,面试官可能不让你用集合或者Hash,或者直接提示你要用位运算来做,该怎么办呢?

我们继续拓展!使用异或运算符^!

0与其他数字异或的结果是那个数字,相等的数字异或得0。要操作的数组中除了某个数字只出现了一次之外,其他数字都出现了两次,所以可以定义一个变量赋初始值为0,用这个变量与数组中每个数字做异或运算,并将这个变 量值更新为个运算结果,直到数组遍历完毕,最后得到的变量的值就是数组中只出现了一次的数字了。这种方法只需遍历一次数组,提高了程序运行的效率。

总结一下:这种解法要用到异或运算的几个规则:

0^0 = 0;

0^a = a;

a^a = 0;

a ^ b ^ a = b.

实现代码就是:

public static int findOneNum(int[] arr) {
   	int flag = 0;
   	for(int i : arr) {
   			flag ^= i;
   	}
   	return flag;
}

由此,也回到我们刚开始说的问题,数组的问题不会做,不是说明你数组没学好,而是要学习Hash、集合、位运算等等很多高级问题。

1.5 继续谈回重复数字问题

我们在前面删除数组元素专题部分提到了如何删除重复项的问题,假如重复的元素可以保留两个,或者K个该怎么办呢?

1.5.1 重复项保留K次

我们在上面删除数组元素的专题中分析了删除值为特定元素的情况,也分析了如果有序数组中存在重复项,我们只保留一个的情况。既然能保留一个,那是否可以将条件改为最多保留2个,3个甚至K个呢?看一下具体要求:

给你一个有序数组 nums ,请你原地删除重复出现的元素,使每个元素最多出现两次 ,返回删除后数组的新长度。不要使用额外的数组空间,你必须在原地修改输入数组并在使用 O(1) 额外空间的条件下完成。

输入:nums = [1, 1, 1, 2, 2, 3] 输出:5,nums = [1, 1, 2, 2, 3]
解释:函数应返回新长度 length = 5, 并且原数组的前五个元素被修改为 1, 1, 2, 2, 3 。 不需要考虑数组中超出新长度后面的元素

输入:nums = [0, 0, 1, 1, 1, 1, 2, 3, 3]
输出: 7,nums = [0, 0, 1, 1, 2, 3, 3]
解释:函数应返回新长度 length = 7,并且原数组的前五个元素被修改为 0, 0, 1, 1, 2, 3, 3 。 不需要考虑数组中超出新长度后面的元素。

看到这个题,你是否会认同文章开头说的,算法就是给基本数据结构操作换个条件,来回折腾?这里如果改成三 次、四次、 K次是不是就是面试可能会遇到的题?

为了让解法更有普适性,我们就将保留2个修改为保留K个。 对于此类问题,我们应该进行如下考虑:

(1)由于是保留 k 个相同数字,对于前k 个数字,我们可以直接保留

(2)对于后面的任意数字,能够保留的前提是:与当前写入的位置前面的第 k 个元素进行比较,不相同则保留还是看个例子吧:

[0, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 3],假如K是5。

(1)我们先让前 2 位直接保留,得到 0,1

(2)对后面的每一位继续遍历,能够保留的前提是与当前位置的前面 2个元素不同(也就是上一步的0 1),因此 我们会保留一个1,得到 0,1,1

(3)之后,会跳过所有的1,找到2,得到 0, 1, 1, 2,然后保留后的一个2,得到0, 1, 1, 2, 2

(4)依次类推,得到: 0, 1, 1, 2, 2, 3

对于两个的情况,我们仍然可以使用快慢指针。 slow之前的都已经处理完毕了,而fast则是当前访问的位置。

public int removeDuplicates(int[] nums) {
	int n = nums.length;
	if (n <= 2) {
		return n;
	}
	//前两个元素不必管
	//slow=2表示索引为2的位置可以被替换 
  	int slow = 2, fast = 2;
	while (fast < n) {
		if (nums[slow - 2] != nums[fast]) {
			nums[slow] = nums[fast];
			++slow;
		}
		++fast;
	}
	return slow;
}

再回到本题的要求, 2个相同则保留,我们几乎什么都不动,传入的参数k设置为2就行了:

public int removeDuplicates(int[] nums) {
	return process(nums, 2);
}

看到了吗?假如面试官给你任意的K,你还怕吗?如果我们刷到这个程度,刷题才是真正有效的,而不会刷了很多题还是一脸懵。

我们来造题:

既然是换条件再折腾,那我们自己造一个题:在有序数组中,凡是重复的一个都不要,该怎么做呢?

这里貌似用双指针就不够了,需要借助Hash和双指针才行,第二种方法是通过三个指针来实现,请读者思考。

1.5.2 数组中的元素两次重复

假定有些元素出现两次而有的只出现了一次,该怎么找到所有出现两次的元素呢?先看一下完整要求:

给定一个整数数组 a,其中1 ≤ a[i] ≤ n( n为数组长度) , 其中有些元素出现两次而其他元素出现一次。找到所有出现两次的元素。

你可以不用到任何额外空间并在O(n)时间复杂度内解决这个问题吗?

示例:
输入:[4, 3, 2, 7, 8, 2, 3, 1]
输出:[2, 3]

这个题目使用Hash是可以的, value也是出现的次数,但是申请一个O(n)空间还是不占优势,有没有更巧妙的方法呢?可以这么干:

由于1<=nums[i]<=n(数组长度),所以(nums[i]-1)可以成为 nums中的下标,记index=nums[i]-1又因为 1<=nums[i]<=n,

所以可以通过nums[i]每出现过一次之后对nums[index]+=n,确保当nums[index]>2*n时, index+1(即nums[i])出现过两次。

小细节:这里通过对index取模来保证其仍在下标范围内。

代码如下:

public List<Integer> findDuplicates(int[] nums) {
	List<Integer> res=new ArrayList<Integer>();
	int n=nums.length;
	for(int i=0;i<nums.length;i++) {
		int index=(nums[i]-1)%n;
		nums[index]+=n;
		if(nums[index]>2*n)
          	res.add(index+1);
	}
	return res;
}

如果想不明白,可以看一个例子,对于序列 [4, 3, 2, 7, 8, 2, 3, 1]

首先这里元素个数为8,所有元素的值也不超过8,所以每一个值都可以作为索引。然后执行如下操作: 首先index=(nums[0]-1)%8=3,所以将nums[3]+8=15,所以数组变成了[4, 3, 2, 15, 8, 2, 3, 1]

继续向前, index=(nums[1]-1)%8=2,所以将nums[2]+8=10,数组变成了[4, 3, 10, 15, 8, 2, 3, 1]

继续向前, index=(nums[2]-1))%8=1,所以将nums[1]+8=12,数组变成了[4, 12, 10, 15, 8, 2, 3, 1]

继续向前, index=(nums[3]-1)%8=6,所以将nums[6]+8=11,数组变成[4, 12, 10, 15, 8, 2, 11, 1]

继续向前, index=(nums[4]-1)%8=7,所以将nums[7]+8=9,数组变成[4, 12, 10, 15, 8, 2, 11, 9]

继续向前, index=(nums[5]-1)%8=1,所以将nums[1]+8=11,数组变成了[4, 20, 10, 15, 8, 2, 11, 9]

继续向前, index=(nums[6]-1)%8=2,所以将nums[2]+8=18,数组变成了[4, 20, 18, 15, 8, 2, 11, 9]

最后一次, index=(nums[7]-1)%8=0,所以将nums[0]+0=12,数组最终变成了[12, 20, 18, 15, 8, 2, 11, 9]

仔细观察这个过程你会发现,索引为1和2的位置被加了两次8,而本来就有值,所以最终结果一定是大于2n的,因此执行完这个过程之后,凡是满足nums[index]>2n 的都是重复的,这里恰好对应的就是2和3。

这个方法比较难想到,而且非常巧妙,我们可以熟悉这种方式,上述过程可以自己耐心写一下看看就会恍然大悟。

1.6 数组的区间专题

数组中表示的数组可能是连续的,也可能是不连续的,如果将连续的空间标记成一个区间,那么我们可以再造几道题,先看一个例子:

给定一个无重复元素的有序整数数组nums。返回恰好覆盖数组中所有数字的最小有序区间范围列表。也就是说, nums 的每个元素都恰好被某个区间范围所覆盖,并且不存在属于某个范围但不属于 nums 的数字 x 。

列表中的每个区间范围 [a,b] 应该按如下格式输出:

“a->b” ,如果 a != b “a” ,如果 a == b

示例1:
输入:nums = [0, 1, 2, 4, 5, 7]
输出: [“0->2”,“4->5”,“7”]
解释:区间范围是:
[0,2] --> “0->2”
[4,5] --> “4->5”
[7,7] --> “7”

示例2:
输入:nums = [0, 2, 3, 4, 6, 8, 9]
输出: [“0”,“2->4”,“6”,“8->9”]
解释:区间范围是:
[0,0] --> “0”
[2,4] --> “2->4”
[6,6] --> “6”
[8,9] --> “8->9”

这个题目是典型的容易让人眼高手低,结果一眼就看出来,而实现则很麻烦,一个是里面有很多条件要处理,另一 个是还要处理字符串的问题。

这个题使用双指针也可以非常方便的处理,i 指向每个区间的起始位置,j 从 i 开始向后遍历直到不满足连续递增(或 j 达到数组边界),则当前区间结束;然后将 i 指向更新为 j + 1,作为下一个区间的开始位置,j 继续向后遍历 找下一个区间的结束位置,如此循环,直到输入数组遍历完毕。

public List<String> summaryRanges(int[] nums) {
	List<String> res = new ArrayList<>();
	// i 初始指向第  1 个区间的起始位置 
  	int i = 0;
	for (int j = 0; j < nums.length; j++) {
		// j 向后遍历,直到不满足连续递增(即  nums[j] + 1 != nums[j + 1])
		// 或者  j 达到数组边界,则当前连续递增区间   [i, j] 遍历完毕,将其写入结果列表。 
    	if (j + 1 == nums.length || nums[j] + 1 != nums[j + 1]) {
			// 将当前区间   [i, j] 写入结果列表
			StringBuilder sb = new StringBuilder();
			sb.append(nums[i]);
			if (i != j) {
				sb.append("->").append(nums[j]);
			}
			res.add(sb.toString());
			// 将  i 指向更新为  j + 1,作为下一个区间的起始位置 
          	i = j + 1;
		}
	}
	return res;
}

我们本着不嫌事大的原则,假如这里是要你找缺失的区间该怎么做呢?例如:

示例:
输入 : nums = [0, 1, 3, 50, 75],lower = 0 和 upper = 99,
输出 : [“2”, “4->49”, “51->74”, “76->99”]

2. 总结

我们总结了一维数组的基本问题和一些常见面试题,万事开头难,练习好了,对于我们后面继续学习其他内容非常有好处。

我们说算法要看清本质,看清内在逻辑。本质是什么呢?其实就是如何针对其特征进行高效增删改查。而每种特征 又可以扩展出大量的题目,也就是所谓的小专题了。因为每个特征都有自己的共性问题和基础问题,所以我们刷题的目标就是搞清楚这些共性问题如何处理。例如数组的基本问题就是准确控制指针的移动,而添加删除的时候要注 意如何处理数据大量移动的问题,进制问题要处理如何连锁修改数据的问题等等。

只有这些问题都刷清楚了,如果遇到新的变形题,那我们只需要关注如何针对新场景进行调整就行了。这才是我们要达到的目标。

另外,我们已经初步感受到了什么是融会贯通,上面的题目里已经用上了排序的三大重点:快速排序、归并排序和冒泡排序。不信请看:

  • 上面解题用到了双指针方法,很多是从数组两端向中间移动,而这个过程也是快速排序的基本原理,只不过快速排序要多次执行,更为复杂一些。

  • 多个数组合并可以采用先两两合并,再将合并的再次两两合并,这就是归并排序的基本思想。

  • 在奇偶移动部分为了保证调整后奇数之间以及偶数仍然与原始位置一样,我们采用了冒泡排序的思想,只要将比 较大小改成比较奇偶就可以了。

所以我们学习排序的时候就容易很多了,很多题目也有类似情况,看到这一点,我们才会真正明白为什么题目会越刷越少。

最后,常见的关于数组的问题还有很多,有些还需要不少技巧才可以,所以看似简单的数组并不简单,掌握了这些,遇到新问题见招拆招。

  • 7
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值