【LeetCode 算法热题 天天看看】

六六 没事就看看


一、哈希

1. 两数之和

给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target 的那 两个 整数,并返回它们的数组下标。

你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
你可以按任意顺序返回答案。

示例 1:

输入:nums = [2,7,11,15], target = 9
输出:[0,1]
解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。

示例 2:

输入:nums = [3,2,4], target = 6
输出:[1,2]

示例 3:

输入:nums = [3,3], target = 6
输出:[0,1]

提示:

2 <= nums.length <= 104
-109 <= nums[i] <= 109
-109 <= target <= 109
只会存在一个有效答案

代码及思路

class Solution {
    // 解决思路: 用target 去减去第一个数组里面的数,然后放到map中,key为值,value为角标,然后进行循环下个是否存在,如果存在即可返回
    public int[] twoSum(int[] nums, int target) {
        int[] aa  = new int[2];
        Map<Integer,Integer> map = new HashMap();
        for(int i =0 ;i< nums.length;i++){
      		 if(map.get(nums[i]) != null){
					return new int[]{i,  map.get(nums[i])};
            }
            map.put(target - nums[i],i);
        }
        return null;
    }
}

官方题解

方法一:暴力枚举
思路及算法

最容易想到的方法是枚举数组中的每一个数 x,寻找数组中是否存在 target - x。

当我们使用遍历整个数组的方式寻找 target - x 时,需要注意到每一个位于 x 之前的元素都已经和 x 匹配过,因此不需要再进行匹配。而每一个元素不能被使用两次,所以我们只需要在 x 后面的元素中寻找 target - x。

代码

class Solution {
    public int[] twoSum(int[] nums, int target) {
        int n = nums.length;
        for (int i = 0; i < n; ++i) {
            for (int j = i + 1; j < n; ++j) {
                if (nums[i] + nums[j] == target) {
                    return new int[]{i, j};
                }
            }
        }
        return new int[0];
    }
}

时间复杂度:O(N^2),其中 N 是数组中的元素数量。最坏情况下数组中任意两个数都要被匹配一次。

空间复杂度:O(1)。

方法二:哈希表
思路及算法

注意到方法一的时间复杂度较高的原因是寻找 target - x 的时间复杂度过高。因此,我们需要一种更优秀的方法,能够快速寻找数组中是否存在目标元素。如果存在,我们需要找出它的索引。

使用哈希表,可以将寻找 target - x 的时间复杂度降低到从 O(N) 降低到 O(1)。

这样我们创建一个哈希表,对于每一个 x,我们首先查询哈希表中是否存在 target - x,然后将 x 插入到哈希表中,即可保证不会让 x 和自己匹配。

代码

class Solution {
    public int[] twoSum(int[] nums, int target) {
        Map<Integer, Integer> hashtable = new HashMap<Integer, Integer>();
        for (int i = 0; i < nums.length; ++i) {
            if (hashtable.containsKey(target - nums[i])) {
                return new int[]{hashtable.get(target - nums[i]), i};
            }
            hashtable.put(nums[i], i);
        }
        return new int[0];
    }
}

复杂度分析

时间复杂度:O(N),其中 N 是数组中的元素数量。对于每一个元素 x,我们可以 O(1) 地寻找 target - x。
空间复杂度:O(N),其中 N 是数组中的元素数量。主要为哈希表的开销。

2. 字母异位词分组

给你一个字符串数组,请你将 字母异位词 组合在一起。可以按任意顺序返回结果列表。

字母异位词 是由重新排列源单词的所有字母得到的一个新单词。

示例 1:

输入: strs = [“eat”, “tea”, “tan”, “ate”, “nat”, “bat”]
输出: [[“bat”],[“nat”,“tan”],[“ate”,“eat”,“tea”]]

示例 2:

输入: strs = [“”]
输出: [[“”]]

示例 3:

输入: strs = [“a”]
输出: [[“a”]]

提示:

1 <= strs.length <= 104
0 <= strs[i].length <= 100
strs[i] 仅包含小写字母

代码及思路

class Solution {
    public List<List<String>> groupAnagrams(String[] strs) {

        /**
         * Arrays.stream(strs): 将strs(一个字符串数组)转换成一个Stream流。
         * 
         * s.chars().sorted().collect(...): 对每个字符串s进行以下处理:
         * 
         * s.chars(): 将字符串转换成一个字符流(IntStream),其中每个字符是一个整数(代表其Unicode码点)。
         * .sorted(): 对字符流进行排序。
         * .collect(...): 收集排序后的字符流,生成一个新的字符串。这里使用了StringBuilder来收集字符,生成一个新的、排序后的字符串。
         * Collectors.groupingBy(...):
         * 使用上一步生成的排序后的字符串作为键,将原始字符串s收集到一个列表中,然后按照这些键对原始字符串数组进行分组。
         * 
         * 最终,collect变量将包含一个Map,其中键是排序后的字符串,值是一个列表,包含原始字符串数组中所有排序后与此键相同的字符串。
         */
        Map<String, List<String>> collect = Arrays.stream(strs)
                .collect(Collectors.groupingBy(s -> {
                    char[] sortedChars = s.toCharArray();
                    Arrays.sort(sortedChars);
                    return new String(sortedChars);
                }, Collectors.toList()));

        return new ArrayList<>(collect.values());

    }
}

官方题解

方法一:排序

由于互为字母异位词的两个字符串包含的字母相同,因此对两个字符串分别进行排序之后得到的字符串一定是相同的,故可以将排序之后的字符串作为哈希表的键。

 class Solution {
   public List<List<String>> groupAnagrams(String[] strs) {
    // 创建一个哈希映射,键是排序后的字符串,值是所有对应的异位词
    Map<String, List<String>> map = new HashMap<String, List<String>>();
    
    // 遍历输入的字符串数组
    for (String str : strs) {
        // 将当前字符串转换为字符数组
        char[] array = str.toCharArray();
        // 对字符数组进行排序
        Arrays.sort(array);
        // 将排序后的字符数组转换回字符串,作为哈希映射的键
        String key = new String(array);
        // 从哈希映射中获取键对应的异位词列表,如果不存在则返回一个新的列表
        List<String> list = map.getOrDefault(key, new ArrayList<String>());
        // 将当前字符串添加到异位词列表中
        list.add(str);
        // 将异位词列表放回哈希映射中
        map.put(key, list);
    }
    // 将哈希映射中的所有值(即所有的异位词列表)转换为一个列表返回
    return new ArrayList<List<String>>(map.values());
}

复杂度分析

时间复杂度:O(nklog⁡k),其中 n 是 strs 中的字符串的数量,k 是 strs 中的字符串的的最大长度。需要遍历 n 个字符串,对于每个字符串,需要 O(klog⁡k) 的时间进行排序以及 O(1)的时间更新哈希表,因此总时间复杂度是 O(nklog⁡k)。
空间复杂度:O(nk),其中 n 是 strs 中的字符串的数量,k 是 strs 中的字符串的的最大长度。需要用哈希表存储全部字符串。

方法二:计数

由于互为字母异位词的两个字符串包含的字母相同,因此两个字符串中的相同字母出现的次数一定是相同的,故可以将每个字母出现的次数使用字符串表示,作为哈希表的键。

由于字符串只包含小写字母,因此对于每个字符串,可以使用长度为 26的数组记录每个字母出现的次数。需要注意的是,在使用数组作为哈希表的键时,不同语言的支持程度不同,因此不同语言的实现方式也不同。

对每个字符串计数得到该字符串的计数数组,对于计数数组相同的字符串,就互为异位词。
因为数组类型没有重写 hashcode() 和 equals() 方法,因此不能直接作为 HashMap 的 Key 进行聚合,那么我们就 把这个数组手动编码变成字符串就行了。
比如将 [b,a,a,a,b,c] 编码成 a3b2c1,使用编码后的字符串作为 HashMap 的 Key 进行聚合。
  class Solution {
  public List<List<String>> groupAnagrams(String[] strs) {
    // 创建一个哈希映射,键是字符计数字符串,值是所有对应的异位词
    Map<String, List<String>> map = new HashMap<String, List<String>>();
    
    // 遍历输入的字符串数组
    for (String str : strs) {
        // 创建一个长度为26的数组,用于记录每个字符的出现次数
        int[] counts = new int[26];
        int length = str.length();
        // 遍历当前字符串,更新字符计数数组
        for (int i = 0; i < length; i++) {
            counts[str.charAt(i) - 'a']++;
        }
        // 创建一个字符串缓冲区,用于构建字符计数字符串
        StringBuffer sb = new StringBuffer();
        // 遍历字符计数数组,将每个出现次数大于0的字符和出现次数按顺序拼接成字符串
        for (int i = 0; i < 26; i++) {
            if (counts[i] != 0) {
                sb.append((char) ('a' + i));
                sb.append(counts[i]);
            }
        }
        // 将字符计数字符串作为哈希映射的键
        String key = sb.toString();
        // 从哈希映射中获取键对应的异位词列表,如果不存在则返回一个新的列表
        List<String> list = map.getOrDefault(key, new ArrayList<String>());
        // 将当前字符串添加到异位词列表中
        list.add(str);
        // 将异位词列表放回哈希映射中
        map.put(key, list);
    }
    // 将哈希映射中的所有值(即所有的异位词列表)转换为一个列表返回
    return new ArrayList<List<String>>(map.values());
}

复杂度分析

时间复杂度:O(n(k+∣Σ∣)),其中 n 是 strs 中的字符串的数量,k 是 strs 中的字符串的的最大长度,Σ是字符集,在本题中字符集为所有小写字母,∣Σ∣=26。需要遍历 n个字符串,对于每个字符串,需要 O(k) 的时间计算每个字母出现的次数,O(∣Σ∣) 的时间生成哈希表的键,以及 O(1) 的时间更新哈希表,因此总时间复杂度是 O(n(k+∣Σ∣))。

空间复杂度:O(n(k+∣Σ∣)),其中 n 是 strs 中的字符串的数量,k 是 strs 中的字符串的最大长度,Σ是字符集,在本题中字符集为所有小写字母,∣Σ∣=26。需要用哈希表存储全部字符串,而记录每个字符串中每个字母出现次数的数组需要的空间为 O(∣Σ∣),在渐进意义下小于 O(n(k+∣Σ∣)),可以忽略不计。

其他题解(存在溢出问题)

看到windliang评论还有一种思路,即利用每个字符对应质数;

算术基本定理,又称为正整数的唯一分解定理,即:每个大于1的自然数,要么本身就是质数,要么可以写为2个以上的质数的积,而且这些质因子按大小排列之后,写法仅有一种方式。

利用这个,我们把每个字符串都映射到一个正数上。

用一个数组存储质数 prime = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103}。

然后每个字符串的字符减去 ’ a ’ ,然后取到 prime 中对应的质数。把它们累乘。

例如 abc ,就对应 ‘a’ - ‘a’, ‘b’ - ‘a’, ‘c’ - ‘a’,即 0, 1, 2,也就是对应素数 2 3 5,然后相乘 2 * 3 * 5 = 30,就把 “abc” 映射到了 30。
在这里插入图片描述

public List<List<String>> groupAnagrams(String[] strs) {
    HashMap<Integer, List<String>> hash = new HashMap<>();
    //每个字母对应一个质数
    int[] prime = { 2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31, 41, 43, 47, 53, 59, 61, 67, 71, 73, 79, 83, 89, 97, 101, 103 };
    for (int i = 0; i < strs.length; i++) {
        int key = 1;
        //累乘得到 key
        for (int j = 0; j < strs[i].length(); j++) {
            key *= prime[strs[i].charAt(j) - 'a'];
        } 
        if (hash.containsKey(key)) {
            hash.get(key).add(strs[i]);
        } else {
            List<String> temp = new ArrayList<String>();
            temp.add(strs[i]);
            hash.put(key, temp);
        }

    }
    return new ArrayList<List<String>>(hash.values());
}

时间复杂度: O(nK)。

空间复杂度:O(NK)),用来存储结果。

但是也存在问题:
map的key用Integer会在字符串超过32的时候,溢出,要改成long类型;
但是针对出现zzzzzzzzzzzzzzzzzzzzzzzzzzzzzz等这种过长得字符也会出现问题;

3. 最长连续序列

给定一个未排序的整数数组 nums ,找出数字连续的最长序列(不要求序列元素在原数组中连续)的长度。

请你设计并实现时间复杂度为 O(n) 的算法解决此问题。

示例 1:

输入:nums = [100,4,200,1,3,2]
输出:4
解释:最长数字连续序列是 [1, 2, 3, 4]。它的长度为 4。

示例 2:

输入:nums = [0,3,7,2,5,8,4,6,0,1]
输出:9

提示:

0 <= nums.length <= 105
-109 <= nums[i] <= 109

代码及思路

class Solution {
    // public int longestConsecutive(int[] nums) {
    // // 不是最优的 .sorted() 的时候就nLogn了
    // // boxed()方法用于将基本类型的流(IntStream)转换为对象流(Stream<Integer>)
    // /**
    // * 在Java中,使用IntStream的distinct(),
    // * sorted()和collect()等方法来转换和收集数据涉及到一些操作的时间复杂度。对于IntStream,这些方法的时间复杂度大致如下:
    // *
    // * distinct(): 这个操作的时间复杂度是O(n),因为它需要遍历整个流来去除重复的元素。这里,n是流中元素的数量。
    // *
    // * sorted(): 对于IntStream,sorted()方法使用双枢轴快速排序(dual-pivot
    // * quicksort)算法,其平均时间复杂度是O(n log
    // * n),其中n是流中元素的数量。在最坏的情况下,时间复杂度可能会上升到O(n^2),但这种情况在实际应用中很少发生。
    // *
    // * collect(Collectors.toList()):
    // * 这个操作的时间复杂度是O(n),因为它只是简单地将流中的元素收集到一个列表中。这里,n是流中元素的数量。
    // *
    // *
    // 将这三个操作组合起来使用,例如ints.stream().distinct().sorted().collect(Collectors.toList()),总的时间复杂度主要由sorted()决定,因为distinct()和collect()都是线性的。因此,组合操作的整体时间复杂度是O(n
    // * log n)。
    // *
    // *
    // 请注意,这里的时间复杂度分析是基于算法的平均性能。在实际情况中,性能可能会受到许多因素的影响,包括数据分布、JVM性能、系统资源等。因此,在性能关键的场景中,最好通过基准测试来验证实际的性能表现。
    // */
    // List<Integer> ints =
    // Arrays.stream(nums).boxed().collect(Collectors.toList());
    // List<Integer> collect =
    // ints.stream().distinct().sorted().collect(Collectors.toList());
    // if (collect.size() == 0) {
    // return 0;
    // }
    // int big = 1;
    // int newBig = 1;
    // for (int i = 0; i < collect.size() - 1; i++) {
    // if (collect.get(i) + 1 == collect.get(i + 1)) {
    // big++;
    // } else {
    // newBig = newBig > big ? newBig : big;
    // big = 1;
    // }

    // }
    // return newBig > big ? newBig : big;

    // }


    /**
     *  按照要求进行处理的思路
     * 1、创建一个空的哈希集合,用于存储数组中的数字。
     * 2、遍历数组 nums,将每个数字添加到哈希集合中。
     * 3、初始化最长序列的长度为 0。
     * 4、遍历数组 nums 中的每个数字 num:
     *      如果 num-1 不在哈希集合中(即当前数字不是序列的起始数字),则执行以下步骤:
     *          1.初始化当前序列长度为 1(包含 num 本身)。
     *          2.递增 num,检查 num+1、num+2、... 是否在哈希集合中,并将当前序列长度递增,直到 num+k 不在哈希集合中为止。
     *          3.更新最长序列的长度为当前序列长度和最长序列长度的较大值。
     * 5、返回最长序列的长度。
     * 这个算法的时间复杂度是 O(n),其中 n 是数组 nums 的长度。遍历数组并将元素添加到哈希集合中需要 O(n)
     * 时间。然后,再次遍历数组,对于每个数字,最多需要执行 O(k) 次递增和查找操作,其中 k 是连续序列的长度。由于 k
     * 的总和不会超过数组的长度,因此总时间复杂度仍然是 O(n)。
     */
    public int longestConsecutive(int[] nums) {
        if (nums == null || nums.length == 0) {
            return 0;
        }

        // 使用HashSet存储数组中的数字
        Set<Integer> numSet = new HashSet<>();
        for (int num : nums) {
            numSet.add(num);
        }

        int longestStreak = 0;

        // 遍历数组中的每个数字
        for (int num : nums) {
            // 如果当前数字的前一个数字不在集合中,说明找到了一个新的序列的起点
            if (!numSet.contains(num - 1)) {
                int currentNum = num;
                int currentStreak = 1;

                // 递增检查序列长度
                while (numSet.contains(currentNum + 1)) {
                    currentNum++;
                    currentStreak++;
                }

                // 更新最长序列长度
                longestStreak = Math.max(longestStreak, currentStreak);
            }
        }

        return longestStreak;
    }

}

官方题解

方法一:哈希表

我们考虑枚举数组中的每个数 x,考虑以其为起点,不断尝试匹配 x+1,x+2,⋯ 是否存在,假设最长匹配到了 x+y,那么以 x 为起点的最长连续序列即为 x,x+1,x+2,⋯,x+y,其长度为 y+1,我们不断枚举并更新答案即可。

对于匹配的过程,暴力的方法是 O(n) 遍历数组去看是否存在这个数,但其实更高效的方法是用一个哈希表存储数组中的数,这样查看一个数是否存在即能优化至 O(1) 的时间复杂度。

仅仅是这样我们的算法时间复杂度最坏情况下还是会达到 O(n^2)(即外层需要枚举 O(n)个数,内层需要暴力匹配 O(n) 次),无法满足题目的要求。但仔细分析这个过程,我们会发现其中执行了很多不必要的枚举,如果已知有一个 x,x+1,x+2,⋯ ,x+y 的连续序列,而我们却重新从 x+1,x+2或者是x+y 处开始尝试匹配,那么得到的结果肯定不会优于枚举 x为起点的答案,因此我们在外层循环的时候碰到这种情况跳过即可。

那么怎么判断是否跳过呢?由于我们要枚举的数 x 一定是在数组中不存在前驱数 x−1 的,不然按照上面的分析我们会从 x−1开始尝试匹配,因此我们每次在哈希表中检查是否存在 x−1即能判断是否需要跳过了。


增加了判断跳过的逻辑之后,时间复杂度是多少呢?外层循环需要 O(n) 的时间复杂度,只有当一个数是连续序列的第一个数的情况下才会进入内层循环,然后在内层循环中匹配连续序列中的数,因此数组中的每个数只会进入内层循环一次。根据上述分析可知,总时间复杂度为 O(n),符合题目要求。

class Solution {
    public int longestConsecutive(int[] nums) {
        Set<Integer> num_set = new HashSet<Integer>();
        for (int num : nums) {
            num_set.add(num);
        }

        int longestStreak = 0;

        for (int num : num_set) {
	        /**
	        * 这个的原理就是之后当遍历开始的时候 只有当前节点不是中间节点  而是起始节点的时候才开始遍历
	        * 例如 5431 这个时候只会遍历两次 一个为1 一个为3 因为 5-1=4 存在集合中,不会进行遍历 
	        */
            if (!num_set.contains(num - 1)) {
                int currentNum = num;
                int currentStreak = 1;

                while (num_set.contains(currentNum + 1)) {
                    currentNum += 1;
                    currentStreak += 1;
                }

                longestStreak = Math.max(longestStreak, currentStreak);
            }
        }

        return longestStreak;
    }
}

复杂度分析

时间复杂度:时间复杂度:O(n),其中 n 为数组的长度。具体分析已在上面正文中给出。

空间复杂度:O(n)。哈希表存储数组中所有的数需要 O(n) 的空间。

二、双指针

1. 移动零

给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序。

请注意 ,必须在不复制数组的情况下原地对数组进行操作。

示例 1:

输入: nums = [0,1,0,3,12]
输出: [1,3,12,0,0]

示例 2:

输入: nums = [0]
输出: [0]

提示:

1 <= nums.length <= 104
-231 <= nums[i] <= 231 - 1

代码及思路

class Solution {
    public void moveZeroes(int[] nums) {
        int n = nums.length, left = 0, right = 0;
        // 右节点一直往前走,对于出现非0元素和第一个元素进行替换 左指针一直指向左侧非零的最后一个 知道右指针遇见0进行交换
        while (right < n) {
            // 如果没有0节点的话每个节点都会和自己进行交换
            if (nums[right] != 0) {
                swap(nums, left, right);
                left++;
            }
            right++;
        }
    }

    public void swap(int[] nums, int left, int right) {
        int temp = nums[left];
        nums[left] = nums[right];
        nums[right] = temp;
    }
}

官方题解

思路及算法

使用双指针,左指针指向当前已经处理好的序列的尾部,右指针指向待处理序列的头部。

右指针不断向右移动,每次右指针指向非零数,则将左右指针对应的数交换,同时左指针右移。

注意到以下性质:

	左指针左边均为非零数;

	右指针左边直到左指针处均为零。

因此每次交换,都是将左指针的零与右指针的非零数交换,且非零数的相对顺序并未改变。

代码

class Solution {
    public void moveZeroes(int[] nums) {
        int n = nums.length, left = 0, right = 0;
        // 右节点一直往前走,对于出现非0元素和第一个元素进行替换 左指针一直指向左侧非零的最后一个 知道右指针遇见0进行交换
        while (right < n) {
            // 如果没有0节点的话每个节点都会和自己进行交换
            if (nums[right] != 0) {
                swap(nums, left, right);
                left++;
            }
            right++;
        }
    }

    public void swap(int[] nums, int left, int right) {
        int temp = nums[left];
        nums[left] = nums[right];
        nums[right] = temp;
    }
}

复杂度分析
时间复杂度:O(n),其中 n 为序列长度。每个位置至多被遍历两次。
空间复杂度:O(1)。只需要常数的空间存放若干变量。

其他题解

看到wangnixx评论还有一种思路: 直接循环前面为非零的元素,后面的直接补0

参考代码如下:

class Solution {
	public void moveZeroes(int[] nums) {
		if(nums==null) {
			return;
		}
		//第一次遍历的时候,j指针记录非0的个数,只要是非0的统统都赋给nums[j]
		int j = 0;
		for(int i=0;i<nums.length;++i) {
			if(nums[i]!=0) {
				nums[j++] = nums[i];
			}
		}
		//非0元素统计完了,剩下的都是0了
		//所以第二次遍历把末尾的元素都赋为0即可
		for(int i=j;i<nums.length;++i) {
			nums[i] = 0;
		}
	}
}	

2. 盛最多水的容器

给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。

找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。
返回容器可以储存的最大水量。

说明:你不能倾斜容器。

示例 1:
在这里插入图片描述

输入:[1,8,6,2,5,4,8,3,7]
输出:49
解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。

示例 2:

输入:height = [1,1]
输出:1

提示:

n == height.length
2 <= n <= 105
0 <= height[i] <= 104

代码及思路

这个问题可以使用双指针方法解决。假设我们有两条垂线,左边一条线的索引是left,右边一条线的索引是right,初始时left = 0,right = n - 1。
两条线之间的水量由它们中较短的线决定,因为较长的线无法提供额外的空间来容纳更多的水。因此,我们每次计算当前left和right索引对应的线之间的水量,即		Math.min(height[left], height[right]) * (right - left)。
然后,我们移动指向较短线的指针(left或right),因为移动较长的线的指针不会增加水量,而移动较短线的指针可能会增加水量。我们重复这个过程,直到left >= right为止。
class Solution {
    public int maxArea(int[] height) {
        /*
         * 这个题解的思路:
         * 双指针,这种我们计算面积就是两点坐标相差✖最低的高度;两个指针指向两侧,每次都是最低的那个移动,进行计算;
         * 如果高的移动,下面那个肯定没有这个大,
         * 因为低的已经拉低了下限
         * 所以每次移动低的,往下一个指针靠近,一直到相逢,找出最大的容器;
         */
        int left = 0;
        int right = height.length - 1;
        int maxArea = 0;
        while (left < right) {
            // 计算此时的容器大小
            int num = (right - left) * Math.min(height[left], height[right]);
            // 拿到最大的容器
            maxArea = Math.max(num, maxArea);
            if (height[left] < height[right]) {
                // 如果左指针小的取左边下一个 否则右指针
                left++;
            } else {
                right--;
            }

        }
        return maxArea;

    }
}

官方题解

思路及算法

说明
本题是一道经典的面试题,最优的做法是使用「双指针」。如果读者第一次看到这题,不一定能想出双指针的做法。
证明
为什么双指针的做法是正确的?

双指针代表了什么?

双指针代表的是 可以作为容器边界的所有位置的范围。在一开始,双指针指向数组的左右边界,表示 数组中所有的位置都可以作为容器的边界,因为我们还没有进行过任何尝试。在这之后,我们每次将 对应的数字较小的那个指针 往 另一个指针 的方向移动一个位置,就表示我们认为 这个指针不可能再作为容器的边界了。

为什么对应的数字较小的那个指针不可能再作为容器的边界了?

在上面的分析部分,我们对这个问题有了一点初步的想法。这里我们定量地进行证明。

考虑第一步,假设当前左指针和右指针指向的数分别为 x 和 y,不失一般性,我们假设 x≤y。同时,两个指针之间的距离为 t。那么,它们组成的容器的容量为:

min(x,y)∗t=x∗t

我们可以断定,如果我们保持左指针的位置不变,那么无论右指针在哪里,这个容器的容量都不会超过 x∗t了。注意这里右指针只能向左移动,因为 我们考虑的是第一步,也就是 指针还指向数组的左右边界的时候。

我们任意向左移动右指针,指向的数为 y1,两个指针之间的距离为 t1,那么显然有 t1<,并且 min⁡(x,y1)≤min⁡(x,y):

如果 y1≤y,那么 min⁡(x,y1)≤min⁡(x,y);
如果 y1>y,那么 min⁡(x,y1)=x=min⁡(x,y)。

因此有:

min⁡(x,y1)∗t1<min⁡(x,y)∗t

即无论我们怎么移动右指针,得到的容器的容量都小于移动前容器的容量。也就是说,这个左指针对应的数不会作为容器的边界了,那么我们就可以丢弃这个位置,将左指针向右移动一个位置,此时新的左指针于原先的右指针之间的左右位置,才可能会作为容器的边界。
这样以来,我们将问题的规模减小了 1,被我们丢弃的那个位置就相当于消失了。此时的左右指针,就指向了一个新的、规模减少了的问题的数组的左右边界,因此,我们可以继续像之前 考虑第一步 那样考虑这个问题:
求出当前双指针对应的容器的容量;
对应数字较小的那个指针以后不可能作为容器的边界了,将其丢弃,并移动对应的指针。
最后的答案是什么?
答案就是我们每次以双指针为左右边界(也就是「数组」的左右边界)计算出的容量中的最大值。

代码

public class Solution {
    public int maxArea(int[] height) {
        int l = 0, r = height.length - 1;
        int ans = 0;
        while (l < r) {
            int area = Math.min(height[l], height[r]) * (r - l);
            ans = Math.max(ans, area);
            if (height[l] <= height[r]) {
                ++l;
            }
            else {
                --r;
            }
        }
        return ans;
    }
}

复杂度分析
时间复杂度:O(N),双指针总计最多遍历整个数组一次。
空间复杂度:O(1),只需要额外的常数级别的空间。

其他题解

看到nettee评论一样的思路,但是证明了原理

解题思路:
这道题目看似简单,做起来才发现不容易。分治法、动态规划都用不上,要想得到 O(n)的解法只有使用双指针一条路。即使看了答案知道了双指针解法,你也可能并不清楚这个解法为什么正确。为什么双指针往中间移动时,不会漏掉某些情况呢?

如果没有真正理解题目,即使一次对着答案做出来了,再次遇到这个题目,还是可能做不出来。要理解这道题的正确性和原理,需要从背后的 缩减搜索空间 的思想去考虑题解。下面我将用图片解释这道题的正确性和原理。
双指针解法的正确性
首先放上双指针解法的代码

代码如下:

public int maxArea(int[] height) {
    int res = 0;
    int i = 0;
    int j = height.length - 1;
    while (i < j) {
        int area = (j - i) * Math.min(height[i], height[j]);
        res = Math.max(res, area);
        if (height[i] < height[j]) {
            i++;
        } else {
            j--;
        }
    }
    return res;
}


用一句话概括双指针解法的要点:指针每一次移动,都意味着排除掉了一个柱子。

如下图所示,在一开始,我们考虑相距最远的两个柱子所能容纳水的面积。水的宽度是两根柱子之间的距离 d=8;水的高度取决于两根柱子之间较短的那个,即左边柱子的高度 h=3。水的面积就是 3×8=24
在这里插入图片描述
如果选择固定一根柱子,另外一根变化,水的面积会有什么变化吗?稍加思考可得:

	当前柱子是最两侧的柱子,水的宽度 d为最大,其他的组合,水的宽度都比这个小。
	左边柱子较短,决定了水的高度为 3。如果移动左边的柱子,新的水面高度不确定,一定不会超过右边的柱子高度 7。
	如果移动右边的柱子,新的水面高度一定不会超过左边的柱子高度 3,也就是不会超过现在的水面高度。

在这里插入图片描述

由此可见,如果固定左边的柱子,移动右边的柱子,那么水的高度一定不会增加,且宽度一定减少,所以水的面积一定减少。这个时候,左边的柱子和任意一个其他柱子的组合,其实都可以排除了。也就是我们可以排除掉左边的柱子了。

这个排除掉左边柱子的操作,就是双指针代码里的 i++。i 和 j 两个指针中间的区域都是还未排除掉的区域。随着不断的排除,i 和 j 都会往中间移动。当 i 和 j 相遇,算法就结束了。

图解双指针解法的原理

下面我们用更直观的方法来看看“排除掉一根柱子”、“指针移动”究竟代表着什么。

在这道题中,假设一共有 nnn 根柱子,编号 0,1,…,n−1,高度分别为 H0,H1,…,Hn−1。我们要寻找的是两根柱子 i,j,它们需要满足的约束条件是:

	i、j 都是合法的柱子下标,即 0≤i<n,0≤j<n
	i 在 jj的左边,即 i<j

而我们希望从中找到容纳水面积最大的柱子 (i,j)。以 n=8为例,这时候全部的搜索空间是:
在这里插入图片描述
由于 i、j的约束条件的限制,搜索空间是白色的倒三角部分。可以看到,搜索空间的大小是 (n^2) 数量级的。如果用暴力解法求解,一次只检查一个单元格,那么时间复杂度一定是 O(n2)。要想得到 O(n) 的解法,我们就需要能够一次排除多个单元格。那么我们来看看,本题的双指针解法是如何削减搜索空间的:

一开始,我们检查右上方单元格 (0,7),即考虑最左边的 0 号柱子和最右边的 7 号柱子,计算它们之间容纳水的面积。然后我们比较一下两根柱子的高度,关注其中较短的一根。
在这里插入图片描述

假设左边的 0 号柱子较短。根据刚才的推理,0 号柱子目前的水面高度已经到了上限。由于 7 号柱子已经是离 0 号柱子最远的了,水的宽度也最大,如果换其他的柱子和 0 号柱子配对,水的宽度只会更小,高度也不会增加,容纳水的面积只会更小。也就是说,0 号柱子和 6,5,…,1号柱子的配对都可以排除掉了。记录了 (0,7)这组柱子的结果之后,就可以排除 0 号柱子了。这相当于 i=0 的情况全部被排除。对应于双指针解法的代码,就是 i++;对应于搜索空间,就是削减了一行的搜索空间,如下图所示。

在这里插入图片描述

排除掉了搜索空间中的一行之后,我们再看剩余的搜索空间,仍然是倒三角形状。我们检查右上方的单元格 (1,7),即考虑 1 号柱子和 7 号柱子,计算它们之间容纳水的面积。然后,比较两根柱子的高度。

在这里插入图片描述

假设此时 7 号柱子较短。同理, 7 号柱子已经是离 1 号柱子最远的了,如果换其他的柱子和 1 号柱子配对,水的宽度变小,高度也不会增加,容纳水的面积只会更小。也就是说,7 号柱子和 2,3,…,6号柱子的配对都可以排除掉了。记录了 (1,7)这组柱子的结果之后,就可以排除 77号柱子了。这相当于 j=7的情况全部被排除。对应于双指针解法的代码,就是 j–;对应于搜索空间,就是削减了一列的搜索空间,如下图所示。
在这里插入图片描述
可以看到,无论柱子 i 和 j 哪根更长,我们都可以排除掉一行或者一列的搜索空间。经过 n 步以后,就能排除所有的搜索空间,检查完所有的可能性。搜索空间的减小过程如下面动图所示:
在这里插入图片描述

3. 三数之和

给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。

注意: 答案中不可以包含重复的三元组。

示例 1:

输入:nums = [-1,0,1,2,-1,-4]
输出:[[-1,-1,2],[-1,0,1]]
解释:
nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。
注意,输出的顺序和三元组的顺序并不重要。

示例 2:

输入:nums = [0,1,1]
输出:[]
解释:唯一可能的三元组和不为 0 。

示例 3:

输入:nums = [0,0,0]
输出:[[0,0,0]]
解释:唯一可能的三元组和为 0 。

提示:

3 <= nums.length <= 3000
-105 <= nums[i] <= 105

代码及思路

 * 对数组进行排序:将数组nums进行排序,这样相同的元素会被聚集在一起。排序可以使用Arrays.sort()方法。
 *
 * 初始化一个空列表:用于存储所有和为0且不重复的三元组。
 *
 * 三重循环遍历数组:
 *
 * 外层循环遍历数组的第一个元素nums[i],从索引0开始,直到nums.length - 3(因为需要至少三个不同的元素)。
 *  注意的是:如果nums[i]> 0的时候 ,可以直接进行返回;因为排序后后面的数都不会相加等于0了。
 * 
 * 中层循环遍历数组的第二个元素nums[j],从索引i + 1开始,直到nums.length - 2。
 * 内层循环遍历数组的第三个元素nums[k],从索引j + 1开始,直到nums.length - 1。
 * 检查三元组的和:在每次循环中,检查nums[i] + nums[j] + nums[k]是否等于0。
class Solution {
    /**
     *
     * 思路
     * 对数组进行排序:将数组nums进行排序,这样相同的元素会被聚集在一起。排序可以使用Arrays.sort()方法。
     *
     * 初始化一个空列表:用于存储所有和为0且不重复的三元组。
     *
     * 三重循环遍历数组:
     *
     * 外层循环遍历数组的第一个元素nums[i],从索引0开始,直到nums.length - 3(因为需要至少三个不同的元素)。
     * 中层循环遍历数组的第二个元素nums[j],从索引i + 1开始,直到nums.length - 2。
     * 内层循环遍历数组的第三个元素nums[k],从索引j + 1开始,直到nums.length - 1。
     * 检查三元组的和:在每次循环中,检查nums[i] + nums[j] + nums[k]是否等于0。
     *
     * 跳过重复元素:为了避免重复的三元组,需要在添加三元组到结果列表之前检查当前元素与前一个元素是否相同。
     * 如果相同,则跳过当前循环迭代。
     *
     * 添加三元组到结果列表:如果三元组的和为0,并且没有重复元素,则将其添加到结果列表中。
     *
     * 返回结果列表:完成所有循环后,返回存储了所有满足条件的三元组的列表。
     *
     * @param nums
     * @return
     */
    public List<List<Integer>> threeSum(int[] nums) {
        List<List<Integer>> result = new ArrayList<>();
        if (nums == null || nums.length < 3) {
            return result;
        }

        // 先对数组进行排序 针对左侧和右侧进行指针移动操作
        Arrays.sort(nums);

        for (int i = 0; i < nums.length - 2; i++) {
            // 跳过重复元素:为了避免重复的三元组,需要在添加三元组到结果列表之前检查当前元素与前一个元素是否相同。
            //如果相同,则跳过当前循环迭代。
            if (i > 0 && nums[i] == nums[i - 1]) {
                continue;
            }
            //因为已经排序好,所以后面不可能有三个数加和等于 000,直接返回结果。
            if( nums[i] > 0){
                return result;
            }

            int left = i + 1;
            int right = nums.length - 1;
            //
            while (left < right) {
                int sum = nums[i] + nums[left] + nums[right];
                if (sum == 0) {
                    // 找到一组解
                    result.add(Arrays.asList(nums[i], nums[left], nums[right]));

                    // 针对左右两边 重复跳过 跳过重复元素:为了避免重复的三元组
                    //需要在添加三元组到结果列表之前检查当前元素与前一个元素是否相同。如果相同,则跳过当前循环迭代。
                    while (left < right && nums[left] == nums[left + 1]) {
                        left++;
                    }
                    while (left < right && nums[right] == nums[right - 1]) {
                        right--;
                    }

                    // 继续寻找下一组解 这是不重复
                    left++;
                    right--;
                } else if (sum < 0) {
                    // 和太小,左指针右移
                    left++;
                } else {
                    // 和太大,右指针左移
                    right--;
                }
            }
        }

        return result;

    }
}

官方题解

思路及算法

题目中要求找到所有「不重复」且和为 0的三元组,这个「不重复」的要求使得我们无法简单地使用三重循环枚举所有的三元组。这是因为在最坏的情况下,数组中的元素全部为 0,即

[0, 0, 0, 0, 0, ..., 0, 0, 0]

任意一个三元组的和都为 0。如果我们直接使用三重循环枚举三元组,会得到O(N^3) 个满足题目要求的三元组(其中 N 是数组的长度)时间复杂度至少为O(N^3)。在这之后,我们还需要使用哈希表进行去重操作,得到不包含重复三元组的最终答案,又消耗了大量的空间。这个做法的时间复杂度和空间复杂度都很高,因此我们要换一种思路来考虑这个问题。

「不重复」的本质是什么?我们保持三重循环的大框架不变,只需要保证:

  • 第二重循环枚举到的元素不小于当前第一重循环枚举到的元素;
  • 第三重循环枚举到的元素不小于当前第二重循环枚举到的元素。

也就是说,我们枚举的三元组 (a,b,c)满足 a≤b≤c,保证了只有 (a,b,c)这个顺序会被枚举到,而 (b,a,c)、(c,b,a)等等这些不会,这样就减少了重复。要实现这一点,我们可以将数组中的元素从小到大进行排序,随后使用普通的三重循环就可以满足上面的要求。

同时,对于每一重循环而言,相邻两次枚举的元素不能相同,否则也会造成重复。举个例子,如果排完序的数组为

[0, 1, 2, 2, 2, 3]
^  ^  ^

我们使用三重循环枚举到的第一个三元组为 (0,1,2),如果第三重循环继续枚举下一个元素,那么仍然是三元组 (0,1,2),产生了重复。因此我们需要将第三重循环「跳到」下一个不相同的元素,即数组中的最后一个元素 3,枚举三元组 (0,1,3)。

下面给出了改进的方法的伪代码实现:

nums.sort()
for first = 0 .. n-1
    // 只有和上一次枚举的元素不相同,我们才会进行枚举
    if first == 0 or nums[first] != nums[first-1] then
        for second = first+1 .. n-1
            if second == first+1 or nums[second] != nums[second-1] then
                for third = second+1 .. n-1
                    if third == second+1 or nums[third] != nums[third-1] then
                        // 判断是否有 a+b+c==0
                        check(first, second, third)

这种方法的时间复杂度仍然为 O(N^3),毕竟我们还是没有跳出三重循环的大框架。然而它是很容易继续优化的,可以发现,如果我们固定了前两重循环枚举到的元素 a 和 b,那么只有唯一的 c 满足 a+b+c=0。当第二重循环往后枚举一个元素 b′时,由于 b′>b,那么满足 a+b′+c′=0的 c′一定有 c′<c,即 c′ 在数组中一定出现在 c的左侧。也就是说,我们可以从小到大枚举 b,同时从大到小枚举 c,即第二重循环和第三重循环实际上是并列的关系。
有了这样的发现,我们就可以保持第二重循环不变,而将第三重循环变成一个从数组最右端开始向左移动的指针,从而得到下面的伪代码:

nums.sort()
for first = 0 .. n-1
    if first == 0 or nums[first] != nums[first-1] then
        // 第三重循环对应的指针
        third = n-1
        for second = first+1 .. n-1
            if second == first+1 or nums[second] != nums[second-1] then
                // 向左移动指针,直到 a+b+c 不大于 0
                while nums[first]+nums[second]+nums[third] > 0
                    third = third-1
                // 判断是否有 a+b+c==0
                check(first, second, third)

这个方法就是我们常说的「双指针」,当我们需要枚举数组中的两个元素时,如果我们发现随着第一个元素的递增,第二个元素是递减的,那么就可以使用双指针的方法,将枚举的时间复杂度从 O(N^2)减少至 O(N)。为什么是 O(N)呢?这是因为在枚举的过程每一步中,「左指针」会向右移动一个位置(也就是题目中的 b),而「右指针」会向左移动若干个位置,这个与数组的元素有关,但我们知道它一共会移动的位置数为 O(N),均摊下来,每次也向左移动一个位置,因此时间复杂度为 O(N)。

注意到我们的伪代码中还有第一重循环,时间复杂度为 O(N)O(N)O(N),因此枚举的总时间复杂度为 O(N^2)。由于排序的时间复杂度为 O(Nlog⁡N),在渐进意义下小于前者,因此算法的总时间复杂度为 O(N^2)。

上述的伪代码中还有一些细节需要补充,例如我们需要保持左指针一直在右指针的左侧(即满足 b≤c),具体可以参考下面的代码,均给出了详细的注释。

class Solution {
    public List<List<Integer>> threeSum(int[] nums) {
        int n = nums.length;
        Arrays.sort(nums);
        List<List<Integer>> ans = new ArrayList<List<Integer>>();
        // 枚举 a
        for (int first = 0; first < n; ++first) {
            // 需要和上一次枚举的数不相同
            if (first > 0 && nums[first] == nums[first - 1]) {
                continue;
            }
            // c 对应的指针初始指向数组的最右端
            int third = n - 1;
            int target = -nums[first];
            // 枚举 b
            for (int second = first + 1; second < n; ++second) {
                // 需要和上一次枚举的数不相同
                if (second > first + 1 && nums[second] == nums[second - 1]) {
                    continue;
                }
                // 需要保证 b 的指针在 c 的指针的左侧
                while (second < third && nums[second] + nums[third] > target) {
                    --third;
                }
                // 如果指针重合,随着 b 后续的增加
                // 就不会有满足 a+b+c=0 并且 b<c 的 c 了,可以退出循环
                if (second == third) {
                    break;
                }
                if (nums[second] + nums[third] == target) {
                    List<Integer> list = new ArrayList<Integer>();
                    list.add(nums[first]);
                    list.add(nums[second]);
                    list.add(nums[third]);
                    ans.add(list);
                }
            }
        }
        return ans;
    }
}


复杂度分析

时间复杂度:O(N^2),其中 N 是数组 nums 的长度。
空间复杂度:O(log⁡N)。我们忽略存储答案的空间,额外的排序的空间复杂度为 O(log⁡N)。然而我们修改了输入的数组 nums,在实际情况下不一定允许,因此也可以看成使用了一个额外的数组存储了 nums 的副本并进行排序,空间复杂度为 O(N)。


题目和部分解题代码来自:力扣(LeetCode)
链接:https://leetcode-cn.com/

  • 12
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值