2.面试算法-数组之基础过关题

1. 基础过关题

1.1 数组问题常用思想

1.1.1 双指针思想

我们前面说过数组里的元素是紧紧靠在一起的,不能有空隙,后面的元素就要整体向前移动,同样如果在中间位置插入元素,那么其后的元素都要整体向后移动。很多算法问题需要多次反复移动,比如说连续删除多个元素,这就导致会频繁大量地移动元素,进而效率低下,执行超时。所以如何尽量减少大量元素移动的次数就是数组相关算法要突破的第一个问题。这里介绍一种非常简单,但是非常有效的——双指针。

所谓的双指针其实就是两个变量,不一定真的是指针,这里只是一个统称。看一个例子,从下面序列中删除重复元素{1,2,3,3,5,5,7,8},删除之后的结果应该为{1,2,3,5,7,8}。如果按照普通遍历的方式,删除一个移动一次,那么你在删除第3和5时都要将其后的元素向前移动一次,而使用双指针可以方便的解决这个问题。

在这里插入图片描述

首先我们定义两个指针slow、fast。slow表示当前以前及之前的位置都是单一的,fast则一直向后找与slow位置不一样的,找到之后将其复制到slow的后面,然后slow向前,fast则继续向后找。找完之后slow以前及之前的位置就是单一的。这种方式可以用最少的移动次数解决数组去重。这种称为双指针思想。

上面这种中称为快慢指针,实际用用的时候,还要灵活运用,比如从后向前的快慢指针等。

双指针的思想在处理数组、字符串等场景下有大量的应用,简单好用。

1.1.2 排序思想

在工程中很多问题排序一下就能轻松搞定,但是在算法中只有比较难的问题才允许你排序,不然的话就没思维含量了。我们在层次遍历和广度优先等稍微复杂的算法问题中,也会使用排序来降低实现难度,而且此时可以使用Arrays.sort()方法直接搞定。

1.1.3 集合和Hash

在处理重复等情况时,Hash是一个非常好用的方法,同样,只有比较难的问题才允许你使用,另外就是要注意将准作为Key放入Hash中,一般我们习惯将index作为key,但是有些问题是可以将key作为hash值,而将index作为结果的,这种做法会比较难理解,实现却非常容易。

1.1.4 带入尝试法

这是最low但是最有效的一种方式,数组最大的痛苦就是边界和判断条件很难判断,例如大于的时候要不要等于,小于的时候要不要等于,变量直接返回还是要返回变量-1。这些问题没有明确的规律,最有效的方式就是写几个开始和结束位置的元素来试一下。这方法在解决二维数组问题时更加有效,因为二维数组里要处理的边界和条件有很多个,只要一个有问题就废了。 我们接下来就是具体看一些题目。

1.2 单调数组问题

我们在写算法的时候,数组是否有序是一个非常重要的前提,有或者没有可能会采用完全不同的策略。而且有些地方会说序列是单调序列,这意味着序列中可能会有重复元素者都会直接影响我们怎么写算法。

先来思考一下什么是数组是否有序,或者是单调的。如果对于所有i <= j, A[i] <= A[j],那么数组A是单调递增的。如果对于所有i <= j, A[i] >= A[j],那么数组A是单调递减的。所以遍历数组执行这个判定条件就行了,由于有递增和递减两种情况。于是我们执行两次循环就可以了,代码如下:

public static boolean isMonotonic(int[] nums) {
    return isSorted(nums, true) || isSorted(nums, false);
}

public static boolean isSorted(int[] nums, boolean increasing) {
    int n = nums.length;
    if (increasing) {
        for (int i = 0; i < n - 1; ++i) {
            if (nums[i] > nums[i + 1]) {
                return false;
            }
        }
    } else {
        for (int i = 0; i < n - 1; ++i) {
            if (nums[i] < nums[i + 1]) {
                return false;
            }
        }
    }
    return true;
}

这样虽然实现了功能了,貌似有点繁琐,还要遍历两次,面试官可能会感觉不太够,能否一次遍历实现呢?当然可以,假如我们在i和i+1位置出现了a[i]>a[i+1],而在另外一个地方和i+1出现了a[i]<a[i+1],那是不是说明就不是单调了呢?这样我们就可以使用两个变量标记一下就行了,代码如下:

public boolean isMonotonic(int[] nums) {
		boolean inc = true, dec = true;
  	int n = nums.length;
		for (int i = 0; i < n - 1; ++i) {
				if (nums[i] > nums[i + 1]) {
						inc = false;
				}
				if (nums[i] < nums[i + 1]) {
						dec = false;
				}
		}
		return inc || dec;
}

很多时候需要将特定元素插入到有序序列中,并保证插入后的序列仍然有序,例如这个问题:

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按 顺序插入的位置。

示例1:
输入:nums = [1, 3, 5, 6], target = 5 。
存在5,并且在索引为2的位置,所以输出:2

示例2:
输入:nums = [1, 3, 5, 6], target = 2
不存在2,2插入之后在索引为1的位置,所以输出:1

这个问题没有让你将新元素插入到原始序列中,还是比较简单的,只要遍历一下就找到了。如果面试官再问你,该如何更快的找到目标元素呢?那他其实是想考你二分查找。凡是提到在单调序列中查找的情况,我们应该马上就要想到是否能用二分来提高查找效率。二分的问题我们后面可以专门介绍讨论,这里只看一下实现代码:

public int searchInsert(int[] nums, int target) {
		int n = nums.length;
		int left = 0, right = n - 1, ans = n;
		while (left <= right) {
				int mid = ((right - left) >> 1) + left;
				if (target <= nums[mid]) {
						ans = mid;
						right = mid - 1;
				} else {
						left = mid + 1;
				}
		}
		return ans;
}

这个题想告诉大家的就是数组问题可以很简单,但是也可能非常难,难就难在如果涉及高级问题你要清楚是在考什么,如果看不出来就只能黯然离场了。

1.3 数组合并专题

数组合并就是将两个或者多个有序数组合并成一个新的。这个问题的本身不算难,但是要写的够出彩才可以。还有就是在排序中,归并排序本身就是多个小数组的合并,所以研究该问题也是为了后面打下基础。

1.3.1 合并两个有序数组

先来看如何合并两个有序数组,完整要求:

给你两个按 非递减顺序 排列的整数数组 nums1 和 nums2,另有两个整数 m 和 n,分别表示 nums1 和 nums2 中的元素数目。
请你合并 nums2 到 nums1中,使合并后的数组同样按 非递减顺序 排列。

注意:最终,合并后数组不应由函数返回,而是存储在数组 nums1中。为了应对这种情况,nums1 的初始长度为 m + n,其中前 m 个元素表示应合并的元素,后 n 个元素为 0,应忽略。nums2 的长度为 n 。

例子1:
输入:nums1 = [1, 2, 3, 0, 0, 0],m = 3,nums2 = [2, 5, 6],n = 3
输出:[1, 2, 2, 3, 5, 6]
解释:需要合并 [1, 2, 3] 和 [2, 5, 6]
合并结果是 [1, 2, 2, 3, 5, 6]

对于有序数组的合并,一种简单的方法,先不考虑顺序问题,将B合并到A上,然后再对A进行排序。而排序我们可以直接使用Arrays提供的排序方法,也就是这样:

public void merge(int[] nums1, int m, int[] nums2, int n) {
		for (int i = 0; i < n; ++i) {
			nums1[m + i] = nums2[i];
		}
		Arrays.sort(nums1);
}

很明显,这么写只是为了开拓思路,而面试官会不喜欢,因为就是要你自己实现,即使排序也要自己实现。而且这样的代码在实现的时候可能会存在一个漏洞,你知道是什么吗?

这个问题的关键是将B合并到A的仍然要保证有序。因为A是数组不能强行插入,如果从前向后插入,数组A后面的 元素会多次移动,代价比较高。为此我们可以借助一个新数组C来做,先将选择好的放入到C中,最后再返回。这样 虽然解决问题了,但是面试官可能会问你能否再优化一下,或者不申请新数组就能做呢?更专业一点的提问是:上面算法的空间复杂度为O(n),能否有O(1)的方法。

比较好的方式是从后向前插入, A和B的元素数量是固定的,所以排序后最远位置一定是A和B元素都最大的那个,依次类推,这一点比较绕,但是画画图能很容易想明白的。代码如下:

public void merge(int[] nums1, int nums1_len, int[] nums2, int nums2_len) {
		int i = nums1_len + nums2_len - 1;
		int len1 = nums1_len - 1, len2 = nums2_len - 1;
  	while (len1 >= 0 && len2 >= 0) {
				if (nums1[len1] <= nums2[len2])
						nums1[i--] = nums2[len2--];
				else if (nums1[len1] > nums2[len2])
						nums1[i--] = nums1[len1--];
		}
		//假如A或者B数组还有剩余
		while (len2 != -1) 
      	nums1[i--] = nums2[len2--];
		while (len1 != -1) 
      	nums1[i--] = nums1[len1--];
}

对于数组,最大的问题是很多处理非常灵活,稍微变一下就需要调整其他相关的条件,其实上面再分析,我们发现如果B已经遍历完了,其实剩余的A就不必再处理,因此可以继续精简一下,例如下面的实现也可以:

public void merge(int A[], int m, int B[], int n) {
		int indexA=m-1;
		int indexB=n-1;
		int index=m+n-1;
		while (indexA>=0&&indexB>=0){
				A[index--]=A[indexA]>=B[indexB]?A[indexA--]:B[indexB--];
		}
		//假如A或者B数组还有剩余
		while (indexB>=0){
				A[index--]=B[indexB--];
		}
}

1.3.2 合并n个有序数组

如果顺利将上面的代码写出来了就基本过关了,但是面试官可能会忍不住给加餐:如果是n个数组合并成一个该怎么办呢?不要怕,能解决说明你能力强,加薪过程到这里才开始。

这个问题题有三种基本的思路,一种是先将数组全部拼接到一个数组中,然后再排序。第二种方式是不断进行两两要有序合并,第三种方式是使用堆排序。

先合并再排序最简单了,新建一个N*L的数组,将原始数组拼接存放在这个大数组中,再调用Arrays.sort()进行排序,这种方式比较容易实现,如下。

public static int[] MergeArrays(int[][] array) {
		int N = array.length, L;
		if (N == 0) {
				return new int[0];
    }
    //数组内容校验
		L = array[0].length;
		for (int i = 1; i < N; i++) {
      	if (L != array[i].length) {
        		return new int[0];
      	}
    }	
		//开辟空间
		int[] result = new int[N * L]; //将各个数组依次合并到一起
		for (int i = 0; i < N; i++) {
      	for (int j = 0; j < L; j++) {
          	result[i * L + j] = array[i][j];
        }	
    }	
		//排序一下完事
		Arrays.sort(result);
		return result;
}

第二种方式不断两两合并就是归并的思想,实现稍微麻烦一些。第三种是使用优先级队列排序实现,这个用到堆的知识。可以后面专门开文章详细展开聊聊。

1.4 字符串替换空格问题

这个题是出现频率很高的题目用的就是双指针思想,要求是:

请实现一个函数,将一个字符串中的每个空格替换成“%20”。例如,当字符串为We Are Happy. 则经过替换之后的字 符串为We%20Are%20Happy。

基本思路:这个题首先要考虑用什么来存储这个字符串,如果是长度不可变的char数组,那么必须新申请一个更大的空间。如果使用长度可变的空间来管理原始数组,或者原始数组申请得足够大,这时候就可能要求你不能申请O(n)大小的空间来解决问题。这两种方式的处理方法和实现逻辑会不一样,我们一个个看。

首先是如果长度不可变,我们必须新申请一个更大的空间,然后将原始数组中出现空格的位置直接替换成%20即可,代码如下:

public String replaceSpace(StringBuffer str) {
		String res="";
		for(int i=0;i<str.length();i++){
				char c=str.charAt(i);
				if(c==' ')
						res += "%20";
				else
						res += c;
		}
		return res;
}

对于第二种情况,我们首先想到的是从头到尾遍历整个字符串,遇到空格的时候就将其后面的元素向后移动2个位置,但是这样的问题在前面说过会导致后面的元素大量移动,时间复杂度为O(n^2),执行的时候非常容易超时。

比较好的方式是可以先遍历一次字符串,这样可以统计出字符串中空格的总数,由此计算出替换之后字符串的长度,每替换一个空格,长度增加2,即替换之后的字符串长度为原来的长度+2*空格数目。接下来从字符串的尾部开始复制和替换,用两个指针P1和P2分别指向原始字符串和新字符串的末尾,然后向前移动P1,若指向的不是空格,则将其复制到P2位置, P2向前一步;若P1指向的是空格,则P1向前一步, P2之前插入%20, P2向前三步。这样,便可以完成替换,时间复杂度为O(n)。

详细过程如下:

在这里插入图片描述

public String replaceSpace(StringBuffer str) {
		if(str==null)
				return null;
  	//空格数量 
		int numOfblank = 0;
  	int len=str.length();
  	//计算空格数量 
		for(int i=0;i<len;i++){  
      	if(str.charAt(i)==' ')
						numOfblank++; 
    }
  	//设置长度 
		str.setLength(len+2*numOfblank); 
  	//两个指针
  	int oldIndex=len-1;  
		int newIndex=(len+2*numOfblank)-1;
	
		while(oldIndex>=0 && newIndex>oldIndex){
				char c=str.charAt(oldIndex);
				if(c==' '){
						oldIndex--;
						str.setCharAt(newIndex--,'0');
						str.setCharAt(newIndex--,'2');
						str.setCharAt(newIndex--,'%');
		  	}else{
						str.setCharAt(newIndex,c);
						oldIndex--;
						newIndex--;
				}
		}
		return str.toString();
}

测试方法如下:

public static void main(String[] args) {
		StringBuffer sb =new StringBuffer("We are happy.") ;
		System.out.println(replaceSpace(sb));
}

1.5 删除数组元素专题

所谓算法,其实就是将一个问题改改条件多折腾,上面专题就是添加的变形,再来看几个删除的变形问题。

1.5.1 原地移动所有数值等于val的元素

给你一个数组 nums 和一个值 val,你需要原地移除所有数值等于 val 的元素,并返回移除后数组的新长度。

要求:不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并原地修改输入数组。元素的顺序可以改变。你不需要 考虑数组中超出新长度后面的元素。

例子1 :
输入:nums = [3, 2, 2, 3],val = 3
输出:2,nums = [2, 2]

例子2:
输入:nums = [0, 1, 2, 2, 3, 0, 4, 2], val = 2
输出:5,nums = [0, 1, 4, 0, 3]

在删除的时候,从删除位置开始的所有元素都要向前移动下,所以这题的关键就是如果很多值为val的元素,如何避免反复向前移动。有两种处理方式,为了开拓思路,我们都看一下:

第一种方法:快慢双指针移动

将数组分成前后两段,定义两个指针i和j,初始值都是0。i之前的位置都是有效部分,j表示当前要访问的元素。

这样遍历的时候,j不断向后移动:

  • 如果array[j]的值不为val,则将其移动到array[++i]处。

  • 如果array[j]的值为val,则j继续向前移动

这样,前半部分是有效部分,后半部分是无效部分。 其实只可以借助for的语法进一步简化实现:

public int removeElement(int[] nums, int val) {
		int ans = 0;
		for(int num: nums) {
				if(num != val) {
						nums[ans] = num;
						ans++;
				}
		}
		//最后剩余元素的数量 
  	return ans;
}

这里虽然只有一个变量ans ,但是for循环的写法帮助我们减少了一个变量的定义。

第二种方式:双指针交换移除

这里还有一种解法,有的地方叫做交换移除,思路就是我们还是从两端开始向中间遍历,left遇到num[i]=val的时候停下来,右侧继续。当右侧遇到num[j]!=val的位置的时候,将num[j]交换或者直接覆盖num[i]。之后i继续向右 走。

public int removeElement(int[] nums, int val) {
		int ans = nums.length;
		for (int i = 0; i < ans;) {
				if (nums[i] == val) {
						nums[i] = nums[ans - 1];
						ans--;
				} else {
		i++;
		}
		}
		return ans;
}

这个思路其实和快速排序中执行一轮的过程非常类似。快速排序就是标定一个元素,比它小的全在其左侧,比它大的全在右侧。这么做的坏处是元素的顺序和原来的可能不一样了。

1.5.2 删除有序数组中的重复项

我们再看一个问题:

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

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

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

使用双指针最方便,一个指针负责责数组遍历,一个指向有效数组的最后一个位置。当两个指针的值不一样时,才将指向有效位的向下移动。所以代码就可以这样写:

public static int removeDuplicates(int[] nums) {
		int n = nums.length;
		//j用来标记有效位 int j = 1;
		for (int i = 0; i < n; i++) {
				if (nums[i] != nums[j - 1]) {
						nums[j] = nums[i];
						j++;
				}
		}
		return j;
}

测试代码如下,注意这里更换测试序列的方法。

public static void main(String[] args) {
		int[] arr = new int[]{0, 0, 1, 1, 1, 2, 2, 3, 3, 4};
		int last = removeDuplicates(arr);
		for (int i = 0; i < last; i++) {
				System.out.print(arr[i] + "  ");
		}
}

输出结果为: 0 1 2 3 4

上面这题既然重复元素可以保留一个,那我是否可以最多保留2个,3个或者K个呢?甚至一个都不要呢?有一定的难度,我们在后面的大厂冲刺部分再讨论这个问题。

1.6 元素奇偶移动专题

移动元素也是数组部分非常重要的问题,典型的是数组的奇偶调整、排序等。

1.6.1 奇偶数分离

输入一个整数数组,通过一个函数来调整该数组中数字的顺序,使得所有的奇数位于数组的前半部分,所有的偶数位于数组的后半部分。

例如:
输入:[3, 1, 2, 4]
输出:[2, 4, 3, 1]
输出:[4, 2, 3, 1], [2, 4,1, 3] 和 [4, 2, 1, 3] 也会被接受。

这个题最直接的方式是使用一个临时数组,第一遍查找并将所有的偶数复制给新数组,第二遍查找并复制所有的奇数给数组。这种方式实现比较简单,会面临面试官的灵魂之问: "是否有空间复杂度为O(1)的"方法。我们采用双指针的方法,维护两个指针 i 和 j,循环保证每刻小于 i 的变量都是偶数(也就是 A[k] % 2 == 0 当 k < i),所有大于 j 的都是奇数。

所以,4 种情况针对 (A[i] % 2, A[j] % 2):

  • 如果是 (0, 1),那么万事大吉 i++ 并且 j–。

  • 如果是 (1, 0) ,那么交换两个元素,然后继续。

  • 如果是 (0, 0),那么说明 i 位置是正确的,只能 i++。

  • 如果是 (1, 1),那么说明 j 位置是正确的,只能 j–。

通过这 4 种情况,循环不变量得以维护,并且 j-1 不断变小。最终就可以得到奇偶有序的数组。

public int[] sortArrayByParity(int[] A) {
			int i = 0, j = A.length - 1;
			while (i < j) {
					if (A[i] % 2 > A[j] % 2) {
							int tmp = A[i];
							A[i] = A[j];
							A[j] = tmp;
					}
					if (A[i] % 2 == 0) i++;
					if (A[j] % 2 == 1) j--;
			}
			return A;
}

拓展:上面的代码适合面试,比较容易想出来。如果比较熟了可以采用下面这种更紧凑的方式:

public int[] reOrderArray (int[] array) {
		int left=0, right=array.length-1;
		while(left<right){
				while(array[left]%2!=0 && left<right) left++;
				while(array[right]%2==0 && left<right ) right--;
				int temp=array[left];
				array[left]=array[right];
				array[right]=temp;
		}
		return array;
}

1.6.2 调整后的顺序与原始数组的顺序一致

对于第二题,这里对顺序也有要求,上面的方式就不行了,为此考虑冒泡的方式交换奇偶,就是比较的时候如果左边是偶数右边是奇数,就进行交换,否则就继续扫描。而冒泡排序比较的是大小,因此两者的原理是一致的。

public int[] reOrderArray (int[] array) {
		if (array == null || array.length == 0)
				return new int[0];
		int n = array.length;
		for (int i=0; i<n; i++) {
				for (int j=0; j<n-1-i; j++) {
						// 左边是偶数 , 右边是奇数的情况
						if ((array[j] & 1) == 0 && (array[j+1] & 1) == 1) {
								int tmp = array[j];
								array[j] = array[j+1];
								array[j+1] = tmp;
						}
				}
		}
		return array;
}

这里需要注意的是, j 的循环终止条件为什么是 j<n-1-i,而不是其他的呢?比如 n-i 呢?这里其实就可以用我们说的将两端带入的方式,第一次循环i=0,我们要让j走到底,所以应该是 n-1 的位置,所以此时就是 n-i-1 了。

1.7 数组轮转问题

先看题目要求:

给你一个数组,将数组中的元素向右轮转 k 个位置,其中 k 是非负数。

例如:
输入 : nums = [1, 2, 3, 4, 5, 6, 7], k = 3
输出 : [5, 6, 7, 1, 2, 3, 4]
解释 :
向右轮转 1 步 : [7, 1, 2, 3, 4, 5, 6] 向右轮转 2 步 : [6, 7, 1, 2, 3, 4, 5] 向右轮转 3 步 : [5, 6, 7, 1, 2, 3, 4]

这个题怎么做呢?你是否想到可以逐个移动来实现?理论上可以,但是实现的时候会发现困难重重,很多条件不好 处理。这里直接介绍一种很简单,但是很奇妙的方法:两轮翻转。

思路如下:

  • 首先对整个数组实行翻转,这样子原数组中需要翻转的子数组,就会跑到数组最前面。

  • 这时候,从 k 处分隔数组,左右两数组,各自进行翻转即可。

例如 [1, 2, 3, 4, 5, 6, 7] 我们先将其整体翻转成[7, 6, 5, 4, 3, 2, 1],

然后再根据k将其分成两组 [7, 6, 5] 和 [4, 3, 2, 1],

最后将两个再次翻转: [5, 6, 7] 和[1, 2, 3, 4],这时候就得到了最终结果[5, 6, 7, 1, 2, 3, 4] 。

代码如下:

public void rotate(int[] nums, int k) {
		k %= nums.length;
		reverse(nums, 0, nums.length - 1);
		reverse(nums, 0, k - 1);
		reverse(nums, k, nums.length - 1);
}

public void reverse(int[] nums, int start, int end) {
		while (start < end) {
				int temp = nums[start];
				nums[start] = nums[end];
				nums[end] = temp;
				start += 1;
				end -= 1;
		}
}
  • 12
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值