算法(Algorithm),就是“计算方法”,指解决一个问题具体的步骤和方法。
对于计算机而言,就是一系列解决问题的清晰指令。也就是说,对于一个问题,我们可以通过算法来描述解决的策略和步骤;对于规范的输入,按照算法描述的步骤,就可以在有限时间内得到对应的输出。
所以,算法是程序员绕不过去的必修课,也是走向架构师的必经之路。
一个算法应该具有以下五个重要的特征:
基于算法的有穷性,我们可以知道算法运行消耗的时间不能是无限的。而对于一个问题的处理,可能有多个不同的算法,它们消耗的时间一般是不同的;运行过程中占用的空间资源也是不同的。
这就涉及到对算法的性能考察。主要有两方面:时间和空间。在计算机算法理论中,用时间复杂度和空间复杂度来分别从这两方面衡量算法的性能。
算法的时间复杂度,是指执行算法所需要的计算工作量。
一般来说,计算机算法是问题规模n 的函数f(n),算法的时间复杂度也因此记做:T(n)=Ο(f(n))。
问题的规模n 越大,算法执行的时间的增长率与f(n) 的增长率正相关,称作渐进时间复杂度(Asymptotic Time Complexity)。
算法的空间复杂度,是指算法需要消耗的内存空间。有时候做递归调用,还需要考虑调用栈所占用的空间。
其计算和表示方法与时间复杂度类似,一般都用复杂度的渐近性来表示。同时间复杂度相比,空间复杂度的分析要简单得多。
所以,我们一般对程序复杂度的分析,重点都会放在时间复杂度上。
要想衡量代码的“工作量”,我们需要将每一行代码,拆解成计算机能执行一条条“基本指令”。这样代码的执行时间,就可以用“基本指令”的数量来表示了。
真实的计算机系统里,基本指令包括:
算术指令(加减乘除、取余、向上向下取整)、数据移动指令(装载、存储、赋值)、控制指令(条件或无条件跳转,子程序调用和返回)。
第二个的时间复杂度计算如下:
int i = 0的运行时间是1
i < n的判断共进行了n+1次,所以运行时间是n+1
i++共运行了n次,所以运行时间是n
输出语句也运行了n次,所以运行时间是n
所以他们加起来就是1+(n+1)+n+n=3n+2
第三个的时间复杂度计算过程和第二个有点像,只不过是输出语句变成了一个遍历循环
因此计算公式变成了1+(n+1)+n+n(3n+2)=2n+2+3n2+2n=3n2+4n+2
回溯法 | 分支限界法 | |
---|---|---|
对解空间树的搜索方式 | DFS | BFS |
存储节点常用数据结构 | 堆栈 | 队列 |
应用场景 | 找出满足约束条件的所有解;找出全局最优解 | 找出满足约束条件的一个解;找出局部最优解 |
在程序设计中,为了处理方便,常常需要把具有相同类型的若干元素按有序的形式组织起来,这种形式就是数组(Array)。
数组是程序中做常见、也是最基本的数据结构。在很多算法问题中,都少不了数组的处理和转换。
对数组进行处理需要注意以下特点:
接下来,我们就以LeetCode上一些数组相同的题目要为例,来学习解决数组问题的算法
给定一个整数数组 nums 和一个目标值 target,请你在该数组中找出和为目标值的那两个整数,并返回他们的数组下标。
你可以假设每种输入只会对应一个答案。但是,你不能重复利用这个数组中同样的元素。
示例:
给定 nums = [2, 7, 11, 15], target = 9
因为 nums[0] + nums[1] = 2 + 7 = 9
所以返回 [0, 1]
看到一道算法题,首先考虑暴力解法,再进行优化。
暴力法其实非常简单:把所有数、两两组合在一起,计算它们的和,如果是target,就输出。
我们可以在代码中实现一下:
复杂度分析
为了对运行时间复杂度进行优化,我们需要一种更有效的方法来检查数组中是否存在目标元素。如果存在,我们需要找出它的索引。这可以使用哈希表来实现。
具体实现方法,最简单就是使用两次迭代。
在第一次迭代中,我们将每个元素的值和它的索引添加到表中;然后,在第二次迭代中,我们将检查每个元素所对应的目标元素 (target-nums[i]) 是否存在于表中。
代码如下:
复杂度分析
在上述算法中,我们对哈希表进行了两次扫描,这其实是不必要的。在进行迭代并将元素插入到表中的同时,我们可以直接检查表中是否已经存在当前元素所对应的目标元素。如果它存在,那我们已经找到了对应解,并立即将其返回。这样,只需要扫描一次哈希表,就可以完成算法了。
代码如下:
复杂度分析
import java.util.HashMap;
public class TwoSum {
//方法一:暴力法,穷举所有两数集合
public int[] TwoSum1(int[] nums, int target) {
int n = nums.length;
//双重for循环
for (int i = 0; i < n-1; i++) {
for (int j = i+1; j < n; j++) {
if (nums[i] + nums[j] == target) {
return new int[]{i,j};
}
}
}
//如果找不到,抛出异常
throw new IllegalArgumentException("no solution");
}
//方法二:哈希表保存所有数的信息
public int[] TwoSum2(int[] nums, int target) {
int n = nums.length;
//定义一个哈希表
HashMap<Integer, Integer> map = new HashMap<Integer,Integer>();
//1、遍历数组,将数据全部保存入哈希表
for (int i = 0; i < n; i++) {
map.put(nums[i],i);
}
//2、再次遍历数组,寻找每个数对应的那个数是否存在
for (int i = 0; i < n; i++) {
int thatNum = target - nums[i];
//如果那个数存在,且不是当前数自己,就直接返回结果
if (map.containsKey(thatNum) && map.get(thatNum) != i) {
return new int[]{i,map.get(thatNum)};
}
}
//如果找不到,抛出异常
throw new IllegalArgumentException("no solution");
}
//方法三:改进,遍历一次哈希表
public int[] TwoSum3(int[] nums, int target) {
int n = nums.length;
//定义一个哈希表
HashMap<Integer, Integer> map = new HashMap<Integer,Integer>();
//遍历数组,寻找每个数对应的那个数是否存在(只向前寻找)
for (int i = 0; i < n; i++) {
int thatNum = target - nums[i];
//如果那个数存在,且不是当前数自己,就直接返回结果
if (map.containsKey(thatNum) && map.get(thatNum) != i) {
return new int[]{map.get(thatNum),i};
}
map.put(nums[i],i);
}
//如果找不到,抛出异常
throw new IllegalArgumentException("no solution");
}
public static void main(String[] args) {
int[] input = {2,7,11,15};
int target = 9;
//定义一个大数组,进行性能测试
int[] input3 = new int[100000];
for (int i = 0; i<input3.length; i++) {
input3[i] = input3.length - i;
}
int target3 = 56789;
//为了计算系统程序运行时间,开始计算和计算完成分别计时
long startTime = System.currentTimeMillis();
TwoSum twoSum = new TwoSum();
int[] output = twoSum.TwoSum3(input3, target3);
long endTime = System.currentTimeMillis();
for (int i : output) {
System.out.println(i);
}
System.out.println("程序运行时间:" + (endTime-startTime) + "ms");
}
}
给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。
注意:答案中不可以包含重复的三元组。
示例:
给定数组 nums = [-1, 0, 1, 2, -1, -4],
满足要求的三元组集合为:
[
[-1, 0, 1],
[-1, -1, 2]
]
最简单的办法,当然还是暴力法。基本思路是,每个人都先去找到另一个人,然后再一起逐个去找第三个人。
很容易想到,实现起来就是三重循环:这个时间复杂度是 O(n3)。
代码如下:
运行一下,我们会发现,这个结果其实是不正确的没有去重,同样的三元组在结果中无法排除。比如-1,0,1会出现两次。而且时间复杂度非常高,是N3。
所以接下来,我们就要做一些改进,试图降低时间复杂度,而且解决去重问题。
要做去重,自然首先想到的,就是把结果保存到一张hash表里。仿照两数之和,直接存到HashMap里查找,代码如下:
时间复杂度降为N2,空间复杂度O(N)。
但是,我们加一个输入[0,0,0,0],会发现 结果不正确。
因为尽管通过HashMap存储可以去掉相同二元组的计算结果的值,但没有去掉重复的输出(三元组)。这就导致,0对应在HashMap中有一个值(0,List(0,0)),第三个0来了会输出一次,第四个0来了又会输出一次。
如果希望解决这个问题,那就需要继续加入其它的判断来做去重,整个代码复杂度会变得更高。
暴力法搜索时间复杂度为O(N3),要进行优化,可通过双指针动态消去无效解来提高效率。
双指针的思路,又分为左右指针和快慢指针两种。
我们这里用的是左右指针。左右指针,其实借鉴的就是分治的思想,简单来说,就是在数组头尾各放置一个指针,先让头部的指针(左指针)右移,移不动的时候,再让尾部的指针(右指针)左移:最终两个指针相遇,那么搜索就结束了。
(1)双指针法铺垫: 先将给定 nums 排序,复杂度为 O(NlogN)。
首先,我们可以想到,数字求和,其实跟每个数的大小是有关系的,如果能先将数组排序,那后面肯定会容易很多。
之前我们搜索数组,时间复杂度至少都为O(N2),而如果用快排或者归并,排序的复杂度,是 O(NlogN),最多也是O(N2)。所以增加一步排序,不会导致整体时间复杂度上升。
下面我们通过图解,来看一下具体的操作过程。
(2)初始状态,定义左右指针L和R,并以指针i遍历数组元素。
固定 3 个指针中最左(最小)数字的指针 i,双指针 L,R 分设在数组索引 (i, len(nums)) 两端,所以初始值,i=0;L=i+1;R=nums.length-1
通过L、R双指针交替向中间移动,记录对于每个固定指针 i 的所有满足 nums[i] + nums[L] + nums[R] == 0 的 L,R 组合。
两个基本原则:
(3)固定i,判断sum,然后移动左右指针L和R。
L,R 分设在数组索引 (i, len(nums)) 两端,当L < R时循环计算当前三数之和:
sum = nums[i] + nums[L] + nums[R]
并按照以下规则执行双指针移动:
换下一个数,i++,继续移动双指针:
初始同样还是L=i+1,R=nums.length-1。同样,继续判断sum。
找到一组解[-1,-1,2],保存,并继续右移L。判断sum,如果这时sum=-1+0+2>0,(R还没变,还是5),那么就让L停下,开始左移R。
如果又找到sum=0的一组解,把当前的[-1,0,1]也保存到结果数组。继续右移L。
复杂度分析
尽管时间复杂度依然为O(n2),但是过程中避免了复杂的数据结构,空间复杂度仅为常数级O(1),可以说,双指针法是一种很巧妙、很优雅的算法设计。
import java.util.*;
public class ThreeSum {
//方法一:暴力法
public List<List<Integer>> threeSum1(int[] nums) {
int n = nums.length;
List<List<Integer>> list = new ArrayList<>();
//三重for循环,枚举所有的三数组合
for (int i = 0; i < n-2; i++) {
for (int j = i + 1; j < n-1; j++) {
for (int k = j + 1; k < n; k++) {
if (nums[i] + nums[j] + nums[k] == 0) {
list.add(Arrays.asList(nums[i],nums[j],nums[k]));
}
}
}
}
return list;
}
//方法二:使用哈希表保存结果
public List<List<Integer>> threeSum2(int[] nums) {
int n = nums.length;
List<List<Integer>> list = new ArrayList<>();
//定义一个Hashmap
Map<Integer, List<Integer>> map = new HashMap<>();
//遍历数组,寻找每个数对应的那个数
for (int i = 0; i < n; i++) {
int thatNum = 0 - nums[i];
if (map.containsKey(thatNum)) {
//如果已经存在thatNum,就找到了一组解
List<Integer> arrayList = new ArrayList<>(map.get(thatNum));
arrayList.add(nums[i]);
list.add(arrayList);
}
//将当前数对应的两数组合都保存到map里
for (int j = 0; j < i; j++) {
//以两数之和作为key
int newKey = nums[i] + nums[j];
//如果key不存在,就直接添加进去
if (!map.containsKey(newKey)) {
List<Integer> arrayList = new ArrayList<>();
arrayList.add(nums[i]);
arrayList.add(nums[j]);
map.put(newKey,arrayList);
}
}
}
return list;
}
//方法三:双指针法
public List<List<Integer>> threeSum3(int[] nums) {
int n = nums.length;
List<List<Integer>> list = new ArrayList<>();
// 先对数组进行排序
Arrays.sort(nums);
for(int i = 0; i < n; i++){
if(nums[i] > 0) {
break;
}
if(i > 0 && nums[i] == nums[i-1]) {
continue;
}
// 定义左右指针(索引位置)
int lp = i + 1;
int rp = n - 1;
// 只要左右不重叠,就继续移动指针
while(lp < rp){
int sum = nums[i] + nums[lp] + nums[rp];
if(sum == 0){
list.add(Arrays.asList(nums[i], nums[lp], nums[rp]));
lp ++;
rp --;
while (lp < rp && nums[lp] == nums[lp - 1]) {
lp ++;
}
while (lp < rp && nums[rp] == nums[rp + 1]) {
rp --;
}
} else if(sum < 0) {
lp ++;
} else {
rp --;
}
}
}
return list;
}
public static void main(String[] args) {
int[] input = {-1,0,1,2,-1,-4};
ThreeSum threeSum = new ThreeSum();
List<List<Integer>> lists = threeSum.threeSum3(input);
System.out.println(lists);
}
}
实现获取下一个排列的函数,算法需要将给定数字序列重新排列成字典序中下一个更大的排列。
如果不存在下一个更大的排列,则将数字重新排列成最小的排列(即升序排列)。
必须原地修改,只允许使用额外常数空间。
以下是一些例子,输入位于左侧列,其相应输出位于右侧列。
1,2,3 → 1,3,2
3,2,1 → 1,2,3
1,1,5 → 1,5,1
最简单的想法就是暴力枚举,我们找出由给定数组的元素形成的列表的每个可能的排列,并找出比给定的排列更大的排列。
但是这个方法要求我们找出所有可能的排列,这需要很长时间,实施起来也很复杂。因此,这种算法不能满足要求。 我们跳过它的实现,直接采用正确的方法。
复杂度分析
首先,我们观察到对于任何给定序列的降序排列,就不会有下一个更大的排列。
例如,以下数组不可能有下一个排列:
[9, 5, 4, 3, 1]
这时应该直接返回升序排列。
所以对于一般的情况,如果有一个“升序子序列”,那么就一定可以找到它的下一个排列。具体来说,需要从右边找到第一对两个连续的数字 a[i] 和 a[i-1],它们满足 a[i]>a[i-1]。
所以一个思路是,找到最后一个的“正序”排列的子序列,把它改成下一个排列就行了。
不过具体操作会发现,如果正序子序列后没数了,那么子序列的“下一个”一定就是整个序列的“下一个”,这样做没问题;但如果后面还有逆序排列的数,这样就不对了。比如
[1,3,8,7,6,2]
最后的正序子序列是[1,3,8],但显然不能直接换成[1,8,3]就完事了;而是应该考虑把3换成后面比3大、但比8小的数,而且要选最小的那个(6)。接下来,还要让6之后的所有数,做一个升序排列,得到结果:
[1,6,2,3,7,8]
代码实现如下:
public class NextPermutation {
public void nextPermutation1(int[] nums) {
int k = nums.length - 2;
while (k >= 0 && nums[k] >= nums[k+1]) {
k--;
}
// 如果全部降序,以最小序列输出
if(k < 0){
reverse(nums,0,k-1);
return;
}
int i = k + 2;
while(i < nums.length && nums[i] > nums[k]) {
i++;
}
// 交换nums[k]和找到的nums[i-1]
swap(nums,k,i-1);
// k以后剩余的部分反转成升序
int start = k + 1;
int end = nums.length - 1;
reverse(nums,start,end);
}
//定义一个方法,交换数组中的两个元素
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
//定义一个翻转数组的方法
private void reverse(int[] nums, int start, int end) {
while (start < end) {
swap(nums,start,end);
start++;
end--;
}
}
public static void main(String[] args) {
int[] nums = {1,3,8,7,6,2};
NextPermutation nextPermutation = new NextPermutation();
nextPermutation.nextPermutation1(nums);
for (int num : nums) {
System.out.println(num);
}
}
}
复杂度分析
给定一个 n × n 的二维矩阵表示一个图像。
将图像顺时针旋转 90 度。
说明:
你必须在原地旋转图像,这意味着你需要直接修改输入的二维矩阵。请不要使用另一个矩阵来旋转图像。
示例 1:
给定 matrix =
[
[1,2,3],
[4,5,6],
[7,8,9]
],
原地旋转输入矩阵,使其变为:
[
[7,4,1],
[8,5,2],
[9,6,3]
]
示例 2:
给定 matrix =
[
[ 5, 1, 9,11],
[ 2, 4, 8,10],
[13, 3, 6, 7],
[15,14,12,16]
],
原地旋转输入矩阵,使其变为:
[
[15,13, 2, 5],
[14, 3, 4, 1],
[12, 6, 8, 9],
[16, 7,10,11]
]
旋转图像,这个应用在图片处理的过程中,非常常见。我们知道对于计算机而言,图像,其实就是一组像素点的集合(所谓点阵),所以图像旋转的问题,本质上就是一个二维数组的旋转问题。
我们可以利用矩阵的特性。所谓顺时针旋转,其实就是先转置矩阵,然后翻转每一行。
方法 1 使用了两次矩阵操作,能不能只使用一次操作的方法完成旋转呢?
为了实现这一点,我们来研究每个元素在旋转的过程中如何移动。
这提供给我们了一个思路,可以将给定的矩阵分成四个矩形并且将原问题划归为旋转这些矩形的问题。这其实就是分治的思想。
具体解法也很直接,可以在每一个矩形中遍历元素,并且在长度为 4 的临时列表中移动它们。
代码如下:
复杂度分析
大家可能也发现了,我们其实没有必要分成4个矩阵来旋转。这四个矩阵的对应关系,其实是一目了然的,我们完全可以在一次循环内,把所有元素都旋转到位。
因为旋转的时候,是上下、左右分别对称的,所以我们遍历元素的时候,只要遍历一半行、一半列就可以了(1/4元素)。
public class Rotate {
public void rotate1(int[][] matrix) {
int n = matrix.length;
// 转置矩阵
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
int tmp = matrix[i][j];
matrix[i][j] = matrix[j][i];
matrix[j][i] = tmp;
}
}
// 翻转行
for(int i = 0; i < n; i++) {
for(int j = 0; j < n/2; j++) {
int tmp = matrix[i][j];
matrix[i][j] = matrix[i][n-j-1];
matrix[i][n-j-1] = tmp;
}
}
}
public void rotate2(int[][] matrix) {
int n = matrix.length;
for (int i = 0; i < n / 2 + n % 2; i++) {
for (int j = 0; j < n / 2; j++) {
int[] tmp = new int[4];
int row = i;
int col = j;
for (int k = 0; k < 4; k++) {
tmp[k] = matrix[row][col];
// 定位下一个数
int x = row;
row = col;
col = n - 1 - x;
}
for (int k = 0; k < 4; k++) {
matrix[row][col] = tmp[(k + 3) % 4];
int x = row;
row = col;
col = n - 1 - x;
}
}
}
}
public void rotate3(int[][] matrix) {
int n = matrix.length;
// 不区分子矩阵,直接遍历每一个元素
for( int i = 0; i < (n + 1)/2; i++ ){
for( int j = 0; j < n/2; j++ ){
int temp = matrix[i][j];
matrix[i][j] = matrix[n-j-1][i];
matrix[n-j-1][i] = matrix[n-i-1][n-j-1];
matrix[n-i-1][n-j-1] = matrix[j][n-i-1];
matrix[j][n-i-1] = temp;
}
}
}
public static void main(String[] args) {
int[][] matrix1 = {
{1,2,3},
{4,5,6},
{7,8,9}
};
int[][] matrix2 = {
{1,2,3,4},
{5,6,7,8},
{9,10,11,12},
{13,14,15,16}
};
Rotate rotate = new Rotate();
rotate.rotate3(matrix1);
for (int[] ints : matrix1) {
for (int i : ints) {
System.out.print(i + " ");
}
System.out.println(" ");
}
}
}
二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法,前提是数据结构必须先排好序,可以在对数时间复杂度内完成查找。
二分查找事实上采用的就是一种分治策略,它充分利用了元素间的次序关系,可在最坏的情况下用O(log n)完成搜索任务。
它的基本思想是:假设数组元素呈升序排列,将n个元素分成个数大致相同的两半,取a[n/2]与欲查找的x作比较,如果x=a[n/2]则找到x,算法终止;如 果xa[n/2],则我们只要在数组a的右 半部继续搜索x。
二分查找问题也是面试中经常考到的问题,虽然它的思想很简单,但写好二分查找算法并不是一件容易的事情。
接下来,我们首先用代码实现一个对int数组的二分查找。
当然,我们也可以用递归的方式实现:
public class BinarySearch {
public static int binarySearch1(int[] a, int key){
int low = 0;
int high = a.length - 1;
if (key < a[low] || key > a[high]) {
return -1;
}
while (low <= high){
int mid = ( low + high ) / 2;
if(a[mid] < key) {
low = mid + 1;
} else if(a[mid] > key) {
high = mid - 1;
} else {
return mid; // 查找成功
}
}
// 未能找到
return -1;
}
public static int binarySearch2(int[] a, int key, int fromIndex, int toIndex){
if (key < a[fromIndex] || key > a[toIndex] || fromIndex > toIndex) {
return -1;
}
int mid = ( fromIndex + toIndex ) / 2;
if (a[mid] < key) {
return binarySearch2(a, key, mid + 1, toIndex);
} else if (a[mid] > key) {
return binarySearch2(a, key, fromIndex, mid - 1);
} else {
return mid;
}
}
public static void main(String[] args) {
int[] nums = {1,2,3,4,5,6,7,8,9,10};
BinarySearch binarySearch = new BinarySearch();
int i = binarySearch.binarySearch2(nums, 5,0,9);
System.out.println(i);
}
}
我们总结一下二分查找:
因此,二分查找方法适用于不经常变动而查找频繁的有序列表。使用条件:查找序列是顺序结构,有序。
编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,50]], target = 3
输出:true
示例 2:
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,50]], target = 13
输出:false
示例 3:
输入:matrix = [], target = 0
输出:false
提示:
既然这是一个查找元素的问题,并且数组已经排好序,我们自然可以想到用二分查找是一个高效的查找方式。
输入的 m x n 矩阵可以视为长度为 m x n的有序数组:
行列坐标为(row, col)的元素,展开之后索引下标为idx = row * n + col;反过来,对于一维下标为idx的元素,对应二维数组中的坐标就应该是:
row = idx / n; col = idx % n;
代码实现如下:
public class SearchMatrix {
public boolean searchMatrix(int[][] matrix, int target){
int m = matrix.length;
if (m == 0) {
return false;
}
int n = matrix[0].length;
int left = 0;
int right = m * n - 1;
// 二分查找,定义左右指针
while ( left <= right ){
int midIdx = (left + right) / 2;
int midElement = matrix[midIdx/n][midIdx%n];
if ( midElement < target ) {
left = midIdx + 1;
} else if ( midElement > target ) {
right = midIdx - 1;
} else {
return true; // 找到target
}
}
return false;
}
public static void main(String[] args) {
int[][] matrix = {
{1,2,3,4},
{5,6,7,8},
{9,10,11,12},
{13,14,15,16}
};
SearchMatrix searchMatrix = new SearchMatrix();
boolean b = searchMatrix.searchMatrix(matrix, 8);
System.out.println(b);
}
}
复杂度分析
给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。
示例 1:
输入: [1,3,4,2,2]
输出: 2
示例 2:
输入: [3,1,3,4,2]
输出: 3
说明:
怎样证明 nums 中存在至少一个重复值?其实很简单,这是“抽屉原理”(或者叫“鸽子洞原理”)的简单应用。
这里,nums 中的每个数字(n+1个)都是一个物品,nums 中可以出现的每个不同的数字(n个)都是一个 “抽屉”。把n+1 个物品放入n个抽屉中,必然至少会有一个抽屉放了2个或者2个以上的物品。所以这意味着nums中至少有一个数是重复的。
首先我们想到,最简单的办法就是,遍历整个数组,挨个统计每个数字出现的次数。
用一个HashMap保存每个数字对应的count数量,就可以直观地判断出是否重复了。
代码如下:
当然我们应该还能想到,其实没必要用HashMap,直接保存到一个Set里,就知道这个元素到底有没有了。
复杂度分析
尽管时间复杂度较小,但以上两种保存元素的方法,都用到了额外的存储空间,这个空间复杂度不能让我们满意。
可以先将数组进行排序后再遍历,发现相邻两个数字相同的就是重复数了
复杂度分析
这道题目中数组其实是很特殊的,我们可以从原始的 [1, N] 的自然数序列开始想。现在增加到了N+1个数,根据抽屉原理,肯定会有重复数。对于增加重复数的方式,整体应该有两种可能:
例如:1~9之间的10个数的数组,重复数是6:
1,2,5,6,6,6,6,6,7,9
本来最简单(重复数出现两次,其它1~9的数都出现一次)的是
1,2,3,4,5,6,6,7,8,9
现在没有3、4和8,所以6会多出现3次。
我们可以发现一个规律:
用数学化的语言描述就是:
我们把对于1~N内的某个数i,在数组中小于等于它的所有元素的个数,记为count[i]。
则:当i属于[1, target-1]范围内,count[i] <= i;当i属于[target, N]范围内,count[i] > i。
所以要找target,其实就是要找1~ N中这个分界的数。所以我们可以对1~N的N个自然数进行二分查找,它们可以看作一个排好序的数组,但不占用额外的空间。
这是一种比较特殊的思路。把nums看成是顺序存储的链表,nums中每个元素的值是下一个链表节点的地址。
那么如果nums有重复值,说明链表存在环,本问题就转化为了找链表中环的入口节点,因此可以用快慢指针解决。
比如数组
[3,6,1,4,6,6,2]
保存为:
整体思路如下:
第二次相遇时,应该有:
并且,快指针总路程是慢指针的2倍。所以:
把环内项移到同一边,就有:
这就很清楚了:从环外0开始,和从相遇点开始,走同样多的步数之后,一定可以在入口处相遇。所以第二阶段的相遇点,就是环的入口,也就是重复的元素。
通过快慢指针循环检测这样的巧妙方法,实现了在不额外使用内存空间的前提下,满足线性时间复杂度O(n)。