时光小少年
IP:
0关注数
0粉丝数
0获得的赞
工作年
编辑资料
链接我:

创作·61

全部
问答
动态
项目
学习
专栏
时光小少年

第 15 关| 超大规模数据常见问题:1.青铜挑战——用 4KB 的内存寻找重复元素

在大部分算法中,默认给定的数据量都很小的,例如只有几个或者十几个元素,但是如果将数据量提高到百万甚至十几亿,那处理逻辑就会发生很大差异,这也是算法考查中,经常出现的一类问题。关卡名用 4KB 内存寻找重复元素我会了 ✔️内容1.  理解如何用4KB内存寻找重复元素✔️本关所有题目的重点都是理解如何解决就好,面试问的时候能够将问题描述清楚,不用写代码。在讲义里的代码也是简单演示用的。在海量数据中,此时普通的数组、链表、Hash、树等等结构有无效了 ,因为内存空间放不下了。而常规的递归、排序,回溯、贪心和动态规划等思想也无效了,因为执行都会超时,必须另外想办法。这类问题该如何下手呢?这里介绍三种非常典型的思路:1.使用位存储,使用位存储最大的好处是占用的空间是简单存整数的1/8。例如一个40亿的整数数组,如果用整数存储需要16GB左右的空间,而如果使用位存储,就可以用0.5GB的空间,这样很多问题就能够解决了。2.如果文件实在太大 ,无法在内存中放下,则需要考虑将大文件分成若干小块,先处理每个块,最后再逐步得到想要的结果,这种方式也叫做外部排序。这样需要遍历全部序列至少两次,是典型的用时间换空间的方法。3.堆,如果在超大数据中找第K大、第K小,K个最大、K个最小,则特别适合使用堆来做。而且将超大数据换成流数据也可以,而且几乎是唯一的方式,口诀就是“查小用大堆,查大用小堆”。1. 用4KB内存寻找重复元素题目要求:给定一个数组,包含从1到N的整数,N最大为32000,数组可能还有重复值,且N的取值不定,若只有4KB的内存可用,该如何打印数组中所有重复元素。分析:本身是一道海量数据问题的热身题,如果去掉“只有4KB”的要求,我们可以先创建一个大小为N的数组,然后将这些数据放进来,但是整数最大为32000。如果直接采用数组存,则应该需要320004B=128KB的空间,而题目有4KB的内存限制,我们就必须先解决该如何存放的问题。 如果只有4KB的空间,那么只能寻址84*2^10个比特,这个值比32000要大的,因此我们可以创建32000比特的位向量(比特数组),其中一个比特位置就代表一个整数。利用这个位向量,就可以遍历访问整个数组。如果发现数组元素是v,那么就将位置为v的设置为1,碰到重复元素,就输出一下。public class FindDuplicatesIn32000 { public void checkDuplicates(int[] array) { BitSet bs = new BitSet(32000); for (int i = 0; i < array.length; i++) { int num = array[i]; int num0 = num - 1; if (bs.get(num0)) { System.out.println(num); } else { bs.set(num0); } } } class BitSet { int[] bitset; public BitSet(int size) { this.bitset = new int[size >> 5]; } boolean get(int pos) { int wordNumber = (pos >> 5);//除以32 int bitNumber = (pos & 0x1F);//除以32 return (bitset[wordNumber] & (1 << bitNumber)) != 0; } void set(int pos) { int wordNumber = (pos >> 5);//除以32 int bitNumber = (pos & 0x1F);//除以32 bitset[wordNumber] |= 1 << bitNumber; } } } #include <stdio.h> #include <stdlib.h> typedef struct { int *bitset; } BitSet; BitSet *createBitSet(int size) { BitSet *bs = (BitSet *)malloc(sizeof(BitSet)); bs->bitset = (int *)calloc(size / 32 + 1, sizeof(int)); return bs; } int get(BitSet *bs, int pos) { int wordNumber = pos / 32; int bitNumber = pos % 32; return (bs->bitset[wordNumber] >> bitNumber) & 1; } void set(BitSet *bs, int pos) { int wordNumber = pos / 32; int bitNumber = pos % 32; bs->bitset[wordNumber] |= 1 << bitNumber; } void checkDuplicates(int *array, int length) { BitSet *bs = createBitSet(320000); for (int i = 0; i < length; i++) { int num = array[i]; int num0 = num - 1; if (get(bs, num0)) { printf("%d\n", num); } else { set(bs, num0); } } free(bs->bitset); free(bs); } int main() { int array[] = {1, 2, 3, 4, 4, 5, 5}; int length = sizeof(array) / sizeof(int); checkDuplicates(array, length); return 0; } def check_duplicates(array): bitset = [0] * (32000 // 32) for num in array: num0 = num - 1 if (bitset[num0 // 32] >> (num0 % 32)) & 1: print(num) else: bitset[num0 // 32] |= 1 << (num0 % 32)
0
0
0
浏览量989
时光小少年

第十六关 | 经典刷题思想之滑动窗口:2.白银挑战—滑动窗口经典问题

1. 最长子串专题先来看一道高频算法题:无重复字符的最长子串。具体要求是给定一个字符串 s ,请你找出其中不含有重复字符的最长子串的长度。例如,输入: s = "abcabcbb" 则输出3,因为无重复字符的最长子串是 "abc",所以其长度为3。怎么做后面再说,如果再变一下要求,至多包含两个不同字符的最长子串,该怎么做呢?再变一下要求,至多包含 K 个不同字符的最长子串,该怎么做呢?到这里是否感觉,这不在造题吗?是的!上面就分别是LeetCode3、159、340题,而且这几道题都可以用滑动窗口来解决。学会之后,我们就总结出滑动窗口的解题模板了。接下来,我们就一道一道看。1.1. 无重复字符的最长子串LeetCode3 给定一个字符串 s ,请你找出其中不含有重复字符的最长子串的长度。例如:输入: s = "abcabcbb" 输出: 3 解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。 要找最长子串,必然要知道无重复字符串的首和尾,然后再从中确定最长的那个,因此至少两个指针才可以,这就想到了滑动窗口思想。即使使用滑动窗口,深入分析会发现具体处理起来有多种方式。这里介绍一种经典的使用Map的思路。我们定义一个K-V形式的map,key表示的是当前正在访问的字符串,value是其下标索引值。我们每访问一个新元素,都将其下标更新成对应的索引值。具体过程如下图:如果是已经出现过的,例如上述示例中的 abcabc,当第二次遇到a时,我们就更新left成为第一个b所在的位置,此时惊奇的发现left要移动的位置恰好就是map.get(’a‘) + 1=1,我们将'a'用序列来表示,放在一起就是map.get(s.charAt(i)) + 1。其他情况可以参考图示依次类推。有一种特殊情况我们需要考虑,例如abba,我们第二次访问b时,left=map.get(’b‘) + 1=2。然后继续访问第二个a,此时left=map.get(’a‘) + 1=1,也就是left后退了,显然不对。我们应该让left在2的基础上继续向前,那该怎么办呢?和原来的对比一下,将最大的加1就可以了,也就是:left = Math.max(left,map.get(s.charAt(i)) + 1);完整的代码如下:public int lengthOfLongestSubstring(String s) { if (s.length() == 0) return 0; HashMap<Character, Integer> map = new HashMap<Character, Integer>(); int max = 0; int left = 0; for (int right = 0; right < s.length(); right++) { if (map.containsKey(s.charAt(right))) { left = Math.max(left, map.get(s.charAt(right)) + 1); } map.put(s.charAt(right), right); max = Math.max(max, right - left + 1); } return max; } def lengthOfLongestSubstring(self, s): if not s: return 0 left = 0 lookup = set() n = len(s) max_len = 0 cur_len = 0 for i in range(n): cur_len += 1 while s[i] in lookup: lookup.remove(s[left]) left += 1 cur_len -= 1 if cur_len > max_len: max_len = cur_len lookup.add(s[i]) return max_len int lengthOfLongestSubstring(std::string s) { if (s.empty()) return 0; std::unordered_map<char, int> map; int max = 0; int left = 0; for (int right = 0; right < s.length(); right++) { if (map.count(s[right]) > 0) { left = std::max(left, map[s[right]] + 1); } map[s[right]] = right; max = std::max(max, right - left + 1); } return max; } 除了上述方法,不用Hash存储索引,也可以用滑动窗口思想来解决,感兴趣的可以研究一下。1.2. 至多包含两个不同字符的最长子串给定一个字符串 s ,找出 至多 包含两个不同字符的最长子串 t ,并返回该子串的长度,这就是LeetCode159题。例如:输入: "eceba" 输出: 3 解释: t 是 "ece",长度为3。 .我们仍然使用left和right来锁定一个窗口,然后一边向右移动一边分析。我们用一个序列来看一下:aabbcccd。我们接下来需要解决两个问题,一个是怎么判断只有2个元素,另一个是移除的时候怎么知道移除谁,以及移除之后left是什么。要判断只有2个元素,还是Hash好用,每一个时刻,这个 hashmap 包括不超过 3 个元素。这里还要考虑到要移除谁,所以我们要设计一下Hash的Key-Value的含义。我们把字符串里的字符都当做键,在窗口中的最右边的字符位置作为值。此时使用下面的代码就可以确定要删除谁,以及窗口left的新位置:del_idx = Collections.min(hashmap.values()); left = del_idx + 1; 为什么呢?我们还是画图看一下:所以我们可以充分利用Map的工具来解决该问题:public int lengthOfLongestSubstringTwoDistinct(String s) { if (s.length() < 3) { return s.length(); } int left = 0, right = 0; HashMap<Character, Integer> hashmap = new HashMap<>(); int maxLen = 2; while (right < s.length()) { if (hashmap.size() < 3) hashmap.put(s.charAt(right), right++); // 如果大小达到了3个 if (hashmap.size() == 3) { // 最左侧要删除的位置 int del_idx = Collections.min(hashmap.values()); hashmap.remove(s.charAt(del_idx)); // 窗口left的新位置 left = del_idx + 1; } maxLen = Math.max(maxLen, right - left); } return maxLen; } def lengthOfLongestSubstringTwoDistinct(self, s): n = len(s) if n < 3: return n left, right = 0, 0 hashmap = defaultdict() max_len = 2 while right < n: if len(hashmap) < 3: hashmap[s[right]] = right right += 1 if len(hashmap) == 3: del_idx = min(hashmap.values()) del hashmap[s[del_idx]] left = del_idx + 1 max_len = max(max_len, right - left) return max_len int lengthOfLongestSubstringTwoDistinct(std::string s) { int n = s.length(); if (n < 3) { return n; } int left = 0, right = 0; std::unordered_map<char, int> hashmap; int max_len = 2; while (right < n) { if (hashmap.size() < 3) { hashmap[s[right]] = right; right++; } else { int del_idx = *std::min_element(hashmap.begin(), hashmap.end()); hashmap.erase(del_idx); left = del_idx + 1; } max_len = std::max(max_len, right - left + 1); } return max_len; } 1.3. 至多包含 K 个不同字符的最长子串如果再提高一下难度, 至多包含 K 个不同字符的最长子串该怎么办呢?这就是LeetCode340题。题目的完整要求是:给定一个字符串 s,找出 至多 包含 k 个不同字符的最长子串T。示例:输入: s = "eceba", k = 2 输出: 3 解释: 则 T 为 "ece",所以长度为 3。 本题与上面的题几乎没有区别,只要将判断hash大小为2改成k就可以,超过2就是k+1。十分钟实现:public int lengthOfLongestSubstringKDistinct(String s, int k) { if (s.length() < k + 1) { return s.length(); } int left = 0, right = 0; HashMap<Character, Integer> hashmap = new HashMap<>(); int maxLen = k; while (right < s.length()) { if (hashmap.size() < k + 1) hashmap.put(s.charAt(right), right++); // 如果大小达到了k个 if (hashmap.size() == k + 1) { int del_idx = Collections.min(hashmap.values()); hashmap.remove(s.charAt(del_idx)); // 窗口left的新位置 left = del_idx + 1; } maxLen = Math.max(maxLen, right - left); } return maxLen; } def lengthOfLongestSubstringKDistinct(self, s, k): n = len(s) if k == 0 or n == 0: return 0 left, right = 0, 0 hashmap = defaultdict() max_len = 1 while right < n: hashmap[s[right]] = right right += 1 if len(hashmap) == k + 1: del_idx = min(hashmap.values()) del hashmap[s[del_idx]] left = del_idx + 1 max_len = max(max_len, right - left) return max_len int lengthOfLongestSubstringKDistinct(std::string s, int k) { if (s.length() < k + 1) { return s.length(); } int left = 0, right = 0; std::unordered_map<char, int> hashmap; int maxLen = k; while (right < s.length()) { if (hashmap.size() < k + 1) { hashmap[s[right]] = right++; } // 如果大小达到了k个 if (hashmap.size() == k + 1) { // 找到最小的值并删除,更新窗口左边界 int del_idx = *std::min_element(hashmap.begin(), hashmap.end()); hashmap.erase(s[del_idx]); left = del_idx + 1; } maxLen = std::max(maxLen, right - left); } return maxLen; } 2. 长度最小的子数组LeetCode209.长度最小的子数组,给定一个含有 n 个正整数的数组和一个正整数 target 。找出该数组中满足其和 ≥ target 的长度最小的 连续子数组 [numsl, numsl+1, ..., numsr-1, numsr] ,并返回其长度。如果不存在符合条件的子数组,返回 0 。输入:target = 7, nums = [2,3,1,2,4,3] 输出:2 解释:子数组 [4,3] 是该条件下的长度最小的子数组。 本题可以使用双指针来解决,也可以视为队列法,基本思路是先让元素不断入队,当入队元素和等于target时就记录一下此时队列的容量,如果队列元素之和大于target则开始出队, 直到小于target则再入队。如果出现等于target的情况,则记录一下此时队列的大小,之后继续先入队再出队。每当出现元素之和等于target时我们就保留容量最小的那个。实现代码如下:public int minSubArrayLen(int target, int[] nums) { int left = 0, right = 0, sum = 0, min = Integer.MAX_VALUE; while (right < nums.length) { sum += nums[right++]; while (sum >= target) { min = Math.min(min, right - left); sum -= nums[left++]; } } return min == Integer.MAX_VALUE ? 0 : min; } def minSubArrayLen(self, s, nums): if not nums: return 0 n = len(nums) ans = n + 1 start, end = 0, 0 total = 0 while end < n: total += nums[end] while total >= s: ans = min(ans, end - start + 1) total -= nums[start] start += 1 end += 1 return 0 if ans == n + 1 else ans int minSubArrayLen(int target, int* nums, int numsSize) { int left = 0, right = 0, sum = 0, min = INT_MAX; while (right < numsSize) { sum += nums[right++]; while (sum >= target) { min = std::min(min, right - left); sum -= nums[left++]; } } return min == INT_MAX ? 0 : min; } 3. 盛水最多的容器LeetCode11.给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0) 和 (i, height[i]) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。返回容器可以储存的最大水量。示例:输入:[1,8,6,2,5,4,8,3,7] 输出:49 解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。 本题看似复杂,但其实简单的很。设两指针 i , j ,指向的水槽板高度分别为 h[i] , h[j] ,此状态下水槽面积为S(i,j) 。由于可容纳水的高度由两板中的 短板 决定,因此可得如下面积公式 :S(i,j)=min(h[i],h[j])×(j−i)在每个状态下,无论长板或短板向中间收窄一格,都会导致水槽底边宽度−1 变短:若向内移动短板 ,水槽的短板min(h[i],h[j]) 可能变大,因此下个水槽的面积可能增大 。若向内移动长板 ,水槽的短板min(h[i],h[j]) 不变或变小,因此下个水槽的面积一定变小 。因此,只要初始化双指针分列水槽左右两端,循环每轮将短板向内移动一格,并更新面积最大值,直到两指针相遇时跳出;即可获得最大面积。 public int maxArea(int[] height) { int i = 0, j = height.length - 1, res = 0; while(i < j) { res = height[i] < height[j] ? Math.max(res, (j - i) * height[i++]): Math.max(res, (j - i) * height[j--]); } return res; } def maxArea(self, height): i, j, res = 0, len(height) - 1, 0 while i < j: if height[i] < height[j]: res = max(res, height[i] * (j - i)) i += 1 else: res = max(res, height[j] * (j - i)) j -= 1 return res int maxArea(int* height, int heightSize) { int i = 0, j = heightSize - 1, res = 0; while (i < j) { res = std::max(res, std::max(height[i], height[j]) * (j - i)); if (height[i] > height[j]) { i++; } else { j--; } } return res; } 4. 寻找子串异位词(排列)如果两个字符串仅仅是字母出现的位置不一样,则称两者相互为对方的一个排列,也称为异位词。如果判断两个字符串是否互为排列,是字符串的一个基本算法。现在我们给增加难度。看LeetCode567和438两个题。4.1. 字符串的排列LeetCode567.给你两个字符串s1和s2 ,写一个函数来判断 s2是否包含 s1的排列。如果是,返回 true ;否则,返回 false 。换句话说,s1 的排列之一是 s2 的子串 。其中s1和s2都只包含小写字母。示例: 输入:s1 = "ab" s2 = "eidbaooo" 输出:true 解释:s2 包含 s1 的排列之一 ("ba"). 本题因为字符串s1的异位词长度一定是和s2字符串的长度一样的,所以很自然的想到可以以s1.length()为大小截图一个固定窗口,然后窗口一边向右移动,一边比较就行了。此时可以将窗口内的元素和s1先做一个排序,然后再比较即可,但是这样做的问题是排序代价太高了,我们需要考虑性能更优的方法。所谓的异位词不过两点:字母类型一样,每个字母出现的个数也是一样的。题目说s1和s2都仅限小写字母,因此我们可以创建一个大小为26的数组,每个位置就存储从a到z的个数,为了方便操作,索引我们使用index=s1.charAt(i) - 'a'来表示,这是处理字符串的常用技巧。此时窗口的right向右移动就是执行:charArray2[s2.charAt(right) - 'a']++;而left向右移动就是执行:int left = right - sLen1; charArray2[s2.charAt(left) - 'a']--; 所以,完整代码如下:public boolean checkInclusion(String s1, String s2) { int sLen1 = s1.length(), sLen2 = s2.length(); if (sLen1 > sLen2) { return false; } int[] charArray1 = new int[26]; int[] charArray2 = new int[26]; //先读最前面的一段来判断。 for (int i = 0; i < sLen1; ++i) { ++charArray1[s1.charAt(i) - 'a']; ++charArray2[s2.charAt(i) - 'a']; } if (Arrays.equals(charArray1, charArray2)) { return true; } for (int right = sLen1; right < sLen2; ++right) { charArray2[s2.charAt(right) - 'a']++; int left = right - sLen1; charArray2[s2.charAt(left) - 'a']--; if (Arrays.equals(charArray1, charArray2)) { return true; } } return false; } def checkInclusion(self, s1, s2): n = len(s1) n2 = len(s2) dic1 = {} for i in s1: if i not in dic1: dic1[i] = 1 else: dic1[i] += 1 for i in range(n2 - n + 1): dic2 = {} for j in s2[i:i + n]: if j not in dic2: dic2[j] = 1 else: dic2[j] += 1 if dic2 == dic1: return True return False bool checkInclusion(string s1, string s2) { int n = s1.length(), m = s2.length(); if (n > m) { return false; } vector<int> cnt1(26), cnt2(26); for (int i = 0; i < n; ++i) { ++cnt1[s1[i] - 'a']; ++cnt2[s2[i] - 'a']; } if (cnt1 == cnt2) { return true; } for (int i = n; i < m; ++i) { ++cnt2[s2[i] - 'a']; --cnt2[s2[i - n] - 'a']; if (cnt1 == cnt2) { return true; } } return false; } 4.2. 找到字符串中所有字母异位LeetCode438.找到字符串中所有字母异位词,给定两个字符串 s 和 p,找到 s 中所有 p 的 异位词 的子串,返回这些子串的起始索引。不考虑答案输出的顺序。注意s和p仅包含小写字母。异位词 指由相同字母重排列形成的字符串(包括相同的字符串)。例如:输入: s = "cbaebabacd", p = "abc" 输出: [0,6] 解释: 起始索引等于 0 的子串是 "cba", 它是 "abc" 的异位词。 起始索引等于 6 的子串是 "bac", 它是 "abc" 的异位词。 本题的思路和实现与上面几乎一模一样,唯一不同的是需要用一个List,如果出现异位词,还要记录其开始位置,那直接将其add到list中就可以了。完整代码:public List<Integer> findAnagrams(String s, String p) { int sLen = s.length(), pLen = p.length(); if (sLen < pLen) { return new ArrayList<Integer>(); } List<Integer> ans = new ArrayList<Integer>(); int[] sCount = new int[26]; int[] pCount = new int[26]; //先分别初始化两个数组 for (int i = 0; i < pLen; i++) { sCount[s.charAt(i) - 'a']++; pCount[p.charAt(i) - 'a']++; } if (Arrays.equals(sCount, pCount)) { ans.add(0); } for (int left = 0; left < sLen - pLen; left++) { sCount[s.charAt(left) - 'a']--; int right=left + pLen; sCount[s.charAt(right) - 'a']++; if (Arrays.equals(sCount, pCount)) { //上面left多减了一次,所以 ans.add(left + 1); } } return ans; } def findAnagrams(self, s, p): s_len, p_len = len(s), len(p) if s_len < p_len: return [] ans = [] s_count = [0] * 26 p_count = [0] * 26 for i in range(p_len): s_count[ord(s[i]) - 97] += 1 p_count[ord(p[i]) - 97] += 1 if s_count == p_count: ans.append(0) for i in range(s_len - p_len): s_count[ord(s[i]) - 97] -= 1 s_count[ord(s[i + p_len]) - 97] += 1 if s_count == p_count: ans.append(i + 1) return ans vector<int> findAnagrams(string s, string p) { int sLen = s.size(), pLen = p.size(); if (sLen < pLen) { return vector<int>(); } vector<int> ans; vector<int> sCount(26); vector<int> pCount(26); for (int i = 0; i < pLen; ++i) { ++sCount[s[i] - 'a']; ++pCount[p[i] - 'a']; } if (sCount == pCount) { ans.emplace_back(0); } for (int i = 0; i < sLen - pLen; ++i) { --sCount[s[i] - 'a']; ++sCount[s[i + pLen] - 'a']; if (sCount == pCount) { ans.emplace_back(i + 1); } } return ans; }
0
0
0
浏览量790
时光小少年

第 14 关 | 刷题模板之堆结构:1.青铜挑战——堆结构

1. 堆的概念与特征堆是将一组数据按照完全二叉树的存储顺序,将数据存储在一个一维数组中的结构。堆有两种结构,一种称为大顶堆,一种称为小顶堆,如下图。小顶堆:任意节点的值均小于等于它的左右孩子,并且最小的值位于堆顶,即根节点处。大顶堆:任意节点的值均大于等于它的左右孩子,并且最大的值位于堆顶,即根节点处。有些地方也叫大根堆、小根堆,或者最大堆、最小堆都一个意思。大和小的特征等都是类似的,只是比较的时候是按照大还是小来定,我们本章在原理方面的介绍就按照最大堆来进行,后面的题目再根据情况来定。既然是将一组数据按照树的结构存储在一维数组中,而且还是完全二叉树,那么父子之间关系的建立就很重要了。有个概念需要注意一下,我们在做题时经常会看到有些地方叫堆,有些地方叫优先级队列,两者到底啥关系呢?优先队列: 说到底还是一种队列,他的工作就是poll()/peek()出队列中最大/最小的那个元素,所以叫带有优先级的队列。能够实现优先功能的策略不一定只有堆,例如二项堆、平衡树、线段树、C++里会用二进制分组的vector来实现一个优先队列。堆: 堆是一个很大的概念 他并不一定是完全二叉树。我们之所以用完全二叉树是因为这个很容易被数组储存,但是除了这种二叉堆之外,我们还有二项堆、斐波那契堆、这种堆就不属于二叉树。所以说,优先队列和堆不是一个同一个Level 的概念 ,但是 Java 的 PriorityQueue 就是堆实现的,因此Java 领域可以认为堆就是优先级队列,优先级队列就是堆,换做其他场景则不行。2. 堆的构造过程使用数组构建堆时,就是先按照层次将所有元素依次填入二叉树中,使其成为二叉树,然后再不断调整,最终使其符合堆结构。这里先假设一个节点的下标为 i :当 i = 0 时,为根节点。当i>=1时,父节点为(i - 1)/2。size 就是元素的个数,从1开始计数。下面就看一下如何建立一个大堆:将元素依次排到完全二叉树节点上去,如下左图所示。int i = (size - 2)/2 = 4(思考一下这里为什么是size-2而不是size-1)。找到数组中的4号下标。65大于其孩子,满足大堆性质,所以不用交换。如下右图2. 然后i= i-1;然后用2和其孩子比较,2和204交换。交换之后204所在的子树满足大堆,如下左图。c54和其孩子比较,3. 54和92交换。此时92所在子树满足大堆,如下右图。4. 继续,23和其孩子比较,23和204交换,交换完之后,23的子树却不满足了,所以还需调整它的子树。 如下两图所示。5. 12 和 204交换,仍然出现不平衡的情况,以此类推,直到根节点也满足要求就完毕了。这样我们就建好了一个大顶堆,从图中可以看到,根元素是整个树中值最大的那个,而第二大和第三大就是其左右子树,具体是哪个更大则是未知的,需要比较一下才知道。另外,对于同一组数据,如果输入的序列不一样,那最终构造的树是否也会不一样呢?非常有可能,那这样的树有什么意义呢?我们后面再看,这里先理解堆是这么构建的就行了。3. 插入操作从上面可以看到根节点和其左右子节点是堆里的老大老二和老三,其他结点则没有太明显的规律,那如果要插入一个新元素,该怎么做呢?直接说规则,将元素插入到保持其为完全二叉树的最后一个位置,然后顺着这条支路一直向上调整,每前进一层就要保证其子树都满足堆否则就去处理子树,直到完全满足要求。看一个例子,如下图,要插入300, 我们将其插入到31的右孩子位置,然后不断向上爬,31<300,所以两者要交换,再向上发现300比65大,所以两者要交换。最后300比根元素204大,两者也交换。最后就得到了新的堆。完整过程如下所示:4. 删除操作堆本身比较特殊,一般对堆中的数据进行操作都是针对堆顶的元素,即每次都从堆中获得最大值或最小值,其他的不关心,所以我们删除的时候,也是删除堆顶。如果直接删掉堆顶,整个结构被破坏了,群龙无首就不易管理了。所以实际策略是先将堆中最后一个元素(假如为A)和堆顶元素进行替换,然后删除堆中最后一个元素。之后再从根开始逐步与左右比较,谁更大谁上位。然后A再继续与子树比较,如果有更大的继续交换,直到自己所在的子树也满足大顶堆。上面的过程可以理解为皇上突然驾崩了,这时候先找个顾命大臣维持局面,大臣先看左右两个皇子谁更强谁就是老大。然后大臣自己再逐步隐退,直到找到属于自己的位置。最后新的堆结构如下:说了这么多,你觉得这东西的价值在哪里呢?价值就在于大顶堆的根节点是整个树最大的那个,增加时会根据根的大小来决定要不要加,而删除操作只删除根元素。这个特征可以在很多场景下有奇妙的应用,后面的算法题全都基于这一点。这里可能有些人还有疑问,感觉不管插入还是删除,堆的操作都不简单,那为什么还说堆的效率比较高呢?这是因为堆元素的数量是有限制的,一般不用将所有的元素都放到堆里。后面题目中可以看到,在序列中找K大,则堆的大小就是K。如果K个链表合并,那么堆就是K。原理后面详细展开。说了这么多堆的性质,我们来看一下堆到底怎么解决问题的。关于堆的问题,记住口诀:查找:找大用小,大的进;找小用大,小的进。 排序:升序用小,降序用大。 查找的口诀解释一下就是:是找K大,则用小堆,后续数据只有比根元素更大时才允许进入堆。如果是找K小,则对应反过来。后面我们结合例子分析为什么。
0
0
0
浏览量1627
时光小少年

第 18 关 | 经典刷题思想之回溯:3.黄金挑战—继续看回溯问题

回溯有很多比较难的问题,这里我们看两个,整体来说这两个只是处理略复杂,还不是最难的问题,感兴趣的同学可以继续研究。关卡名继续看回溯问题我会了✔️内容1.复习递归和N叉树,理解相关代码是如何实现的✔️2.理解回溯到底怎么回事✔️3.掌握如何使用回溯来解决二叉树的路径问题✔️1. 复原IP地址这也是一个经典的分割类型的回溯问题。LeetCode93.有效IP地址正好由四个整数(每个整数位于 0 到 255 之间组成,且不能含有前导 0),整数之间用 '.' 分隔。例如:"0.1.2.201" 和 "192.168.1.1" 是有效 IP 地址,但是 "0. 011. 255 .245"、"192.168.1.312" 和 "192.168@1.1" 是无效IP地址。给定一个只包含数字的字符串s,用以表示一个IP地址,返回所有可能的有效IP地址,这些地址可以通过在s中插入 '.' 来形成。你不能重新排序或删除s中的任何数字。你可以按任何顺序返回答案。示例1: 输入:s = "25525511135" 输出:["255.255.11.135","255.255.111.35"] 该问题的思路与与前面的分割回文串基本一致,也是切割问题。回溯的第一步就是使用枚举将所有可能性搜出来,找到一个符合要求的就先切下来,后面的部分继续进行枚举和切割,如果到了最后发现不符合要求,则开始回溯。本题的难度明显比上一题要大,主要是判断是否合法的要求更高了,比如第一个元素我们可以截取2、25、255、2552,很显然到了2552之后就不合法了,此时就要回溯。后面也一样,假如我们第一层截取的是2,第二层就从”5525511135“中截取,此时可以有5,55,552,显然552已经不合法了,依次类推。画出图来就如下所示:当然这里还要判断是0的情况等等,在字符串转换成数字一章,我们讲解了很多种要处理的情况,为此我们可以写一个方法单独来执行相关的判断。代码如下:// 判断字符串s在左闭⼜闭区间[start, end]所组成的数字是否合法 private Boolean isValid(String s, int start, int end) { if (start > end) { return false; } // 0开头的数字不合法 if (s.charAt(start) == '0' && start != end) { return false; } int num = 0; for (int i = start; i <= end; i++) { // 遇到⾮数字字符不合法 if (s.charAt(i) > '9' || s.charAt(i) < '0') { return false; } num = num * 10 + (s.charAt(i) - '0'); if (num > 255) { // 如果⼤于255了不合法 return false; } } return true; } 另外,IP地址只有四段,不是无限分割的,因此本题只会明确的分成4段,不能多也不能少。所以不能用切割线切到最后作为终止条件,而是分割的段数到了4就必须终止。考虑到我们构造IP地址时还要手动给添加三个小数点,所以我们用变量pointNum来表示小数点数量,pointNum为3说明字符串分成了4段了。要手动添加一个小数点,这要增加一个位置来存储,所以下一层递归的startIndex要从i+2开始。其他的主要工作就是递归和回溯的过程了。这里的撤销部分要注意将刚刚加入的分隔符删掉,并且pointNum也要-1,完整代码如下:class Solution { static final int SEG_COUNT = 4; List<String> ans = new ArrayList<String>(); int[] segments = new int[SEG_COUNT]; public List<String> restoreIpAddresses(String s) { segments = new int[SEG_COUNT]; dfs(s, 0, 0); return ans; } public void dfs(String s, int segId, int segStart) { // 如果找到了 4 段 IP 地址并且遍历完了字符串,那么就是一种答案 if (segId == SEG_COUNT) { if (segStart == s.length()) { StringBuffer ipAddr = new StringBuffer(); for (int i = 0; i < SEG_COUNT; ++i) { ipAddr.append(segments[i]); if (i != SEG_COUNT - 1) { ipAddr.append('.'); } } ans.add(ipAddr.toString()); } return; } // 如果还没有找到 4 段 IP 地址就已经遍历完了字符串,那么提前回溯 if (segStart == s.length()) { return; } // 由于不能有前导零,如果当前数字为 0,那么这一段 IP 地址只能为 0 if (s.charAt(segStart) == '0') { segments[segId] = 0; dfs(s, segId + 1, segStart + 1); return; } // 一般情况,枚举每一种可能性并递归 int addr = 0; for (int segEnd = segStart; segEnd < s.length(); ++segEnd) { addr = addr * 10 + (s.charAt(segEnd) - '0'); if (addr > 0 && addr <= 0xFF) { segments[segId] = addr; dfs(s, segId + 1, segEnd + 1); } else { break; } } } } class RestoreIpAddresses: def __init__(self): self.result = [] def restoreIpAddresses(self, s: str) -> List[str]: ''' 本质切割问题使用回溯搜索法,本题只能切割三次,所以纵向递归总共四层 因为不能重复分割,所以需要start_index来记录下一层递归分割的起始位置 添加变量point_num来记录逗号的数量[0,3] ''' self.result.clear() if len(s) > 12: return [] self.backtracking(s, 0, 0) return self.result def backtracking(self, s: str, start_index: int, point_num: int) -> None: # Base Case if point_num == 3: if self.is_valid(s, start_index, len(s)-1): self.result.append(s[:]) return # 单层递归逻辑 for i in range(start_index, len(s)): # [start_index, i]就是被截取的子串 if self.is_valid(s, start_index, i): s = s[:i+1] + '.' + s[i+1:] self.backtracking(s, i+2, point_num+1) # 在填入.后,下一子串起始后移2位 s = s[:i+1] + s[i+2:] # 回溯 else: # 若当前被截取的子串大于255或者大于三位数,直接结束本层循环 break def is_valid(self, s: str, start: int, end: int) -> bool: if start > end: return False # 若数字是0开头,不合法 if s[start] == '0' and start != end: return False if not 0 <= int(s[start:end+1]) <= 255: return False return True class RestoreIpAddresses { private: vector<string> result;// 记录结果 // startIndex: 搜索的起始位置,pointNum:添加逗点的数量 void backtracking(string& s, int startIndex, int pointNum) { if (pointNum == 3) { // 逗点数量为3时,分隔结束 // 判断第四段子字符串是否合法,如果合法就放进result中 if (isValid(s, startIndex, s.size() - 1)) { result.push_back(s); } return; } for (int i = startIndex; i < s.size(); i++) { if (isValid(s, startIndex, i)) { // 判断 [startIndex,i] 这个区间的子串是否合法 s.insert(s.begin() + i + 1 , '.'); // 在i的后面插入一个逗点 pointNum++; backtracking(s, i + 2, pointNum); // 插入逗点之后下一个子串的起始位置为i+2 pointNum--; // 回溯 s.erase(s.begin() + i + 1); // 回溯删掉逗点 } else break; // 不合法,直接结束本层循环 } } // 判断字符串s在左闭又闭区间[start, end]所组成的数字是否合法 bool isValid(const string& s, int start, int end) { if (start > end) { return false; } if (s[start] == '0' && start != end) { // 0开头的数字不合法 return false; } int num = 0; for (int i = start; i <= end; i++) { if (s[i] > '9' || s[i] < '0') { // 遇到非数字字符不合法 return false; } num = num * 10 + (s[i] - '0'); if (num > 255) { // 如果大于255了不合法 return false; } } return true; } public: vector<string> restoreIpAddresses(string s) { result.clear(); if (s.size() < 4 || s.size() > 12) return result; // 算是剪枝了 backtracking(s, 0, 0); return result; } }; 2. 电话号码问题LeetCode17.电话号码组合问题,也是热度非常高的一个题目,给定一个仅包含数字 2-9 的字符串,返回所有它能表示的字母组合。 给出数字到字母的映射如下(与电话按键相同)。注意 1 不对应任何字母,9对应四个字母。示例1: 输入:digits = "2| 3 4567" 输出:["ad","ae","af","bd","be","bf","cd","ce","cf"] 我们说回溯仍然会存在暴力枚举的情况,这个题就很典型,例如如果输入23,那么,2就有 a、b、c三种情况,3有d、e、f三种情况。组合一下就一共就有3*3=9种,如果是233,那么就是27种。这里要注意的9对应4个字母,而1则没有,那该怎么建立字母和数字之间的映射呢?我们用一个数组来保存,而不写一堆的if else。而为了保证遍历时index也恰好与数组的索引一致,我们按照如下的方式来定义数组:String[] numString = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"};接下来我们就用回溯来解决n层循环的问题,输入23对应的树就是这样的:树的深度就是输入的数字个数,例如输入23,树的深度就是2。而所有的叶子节点就是我们需要的结果["ad", "ae", "af", "bd", "be", "bf", "cd", "ce", "cf"]。所以这里的终止条件就是,如果当前执行的index 等于输入的数字个数(digits.size)了。使用for循环来枚举出来,然后循环体内就是回溯过程了。基本实现过程如下:class LetterCombinations { List<String> list = new ArrayList<>(); public List<String> letterCombinations(String digits) { if (digits == null || digits.length() == 0) { return list; } //初始对应所有的数字,为了直接对应2-9,新增了两个无效的字符串"" String[] numString = {"", "", "abc", "def", "ghi", "jkl", "mno", "pqrs", "tuv", "wxyz"}; backTracking(digits, numString, 0); return list; } //每次迭代获取一个字符串,所以会设计大量的字符串拼接,所以这里选择更为高效的 StringBuild StringBuilder temp = new StringBuilder(); //比如digits如果为"23",num 为0,则str表示2对应的 abc public void backTracking(String digits, String[] numString, int num) { //遍历全部一次记录一次得到的字符串 if (num == digits.length()) { list.add(temp.toString()); return; } //str 表示当前num对应的字符串 String str = numString[digits.charAt(num) - '0']; for (int i = 0; i < str.length(); i++) { temp.append(str.charAt(i)); backTracking(digits, numString, num + 1); //剔除末尾的继续尝试 temp.deleteCharAt(temp.length() - 1); } } } class LetterCombinations: def __init__(self): self.answers: List[str] = [] self.answer: str = '' self.letter_map = { '2': 'abc', '3': 'def', '4': 'ghi', '5': 'jkl', '6': 'mno', '7': 'pqrs', '8': 'tuv', '9': 'wxyz' } def letterCombinations(self, digits) : self.answers.clear() if not digits: return [] self.backtracking(digits, 0) return self.answers def backtracking(self, digits, index) : # 回溯函数没有返回值 if index == len(digits): # 当遍历穷尽后的下一层时 self.answers.append(self.answer) return # 单层递归逻辑 letters: str = self.letter_map[digits[index]] for letter in letters: self.answer += letter # 处理 self.backtracking(digits, index + 1) # 递归至下一层 self.answer = self.answer[:-1] # 回溯 class LetterCombinations { public: vector<string> letterCombinations(string digits) { vector<string> combinations; if (digits.empty()) { return combinations; } unordered_map<char, string> phoneMap{ {'2', "abc"}, {'3', "def"}, {'4', "ghi"}, {'5', "jkl"}, {'6', "mno"}, {'7', "pqrs"}, {'8', "tuv"}, {'9', "wxyz"} }; string combination; backtrack(combinations, phoneMap, digits, 0, combination); return combinations; } void backtrack(vector<string>& combinations, const unordered_map<char, string>& phoneMap, const string& digits, int index, string& combination) { if (index == digits.length()) { combinations.push_back(combination); } else { char digit = digits[index]; const string& letters = phoneMap.at(digit); for (const char& letter: letters) { combination.push_back(letter); backtrack(combinations, phoneMap, digits, index + 1, combination); combination.pop_back(); } } } }; 3. 括号生成问题本题是一道非常典型的回溯问题,LeetCode22.数字 n 代表生成括号的对数,请你设计一个函数,用于能够生成所有可能的并且 有效的 括号组合。示例1: 输入:n = 3 输出:["((()))","(()())","(())()","()(())","()()()"] 要解决该问题,我们首先要明确一个问题,左右括号什么时候可以获得。我们知道左括号出现的数量一定等于n,观察题目给的示例,只要剩余左括号的数量大于0,就可以添加"(",例如"("、"(()"、”(()(“、”()((“都是可以的,因为我们只要再给加几个右括号就行,例如可以将其变成"()"、"(())"、”(()()“、”()(())“。那添加右括号的要求呢?结论是:序列中左括号的数量必须大于右括号的数量,例如上面的"("、"(()"、”(()(“、”()((“,都可以,但是如果")"、"())"、”)()(“、”()((“就不可以了,此时的情况都是右括号数量大于等于左括号。所以我们可以得到两条结论只要剩余左括号的数量大于0,就可以添加"("。序列中左括号的数量必须大于右括号的数量才可以添加"(",并且")"的剩余数量大于0.接下来看如何用回溯解决,我们将添加"("视为left,将")"视为right,这样"("和")"各可以出现n次。我们以n=2为例画一下的图示:图中红叉标记的位置是左括号大于有括号了,不能再向下走了,这个操作也称为剪枝。通过图示,可以得到如下结论:当前左右括号都有大于 0 个可以使用的时候,才产生分支,否则就直接停止。产生左分支的时候,只看当前是否还有左括号可以使用;产生右分支的时候,除了要求还有右括号,还要求剩余右括号数量一定大于左括号的数量;在左边和右边剩余的括号数都等于 0 的时候结束。而且,从上图可以看到,不管是剪枝还是得到一个结果,返回的过程仍然可以通过回溯来实现:class GenerateParenthesis { public List<String> generateParenthesis(int n) { List<String> ans = new ArrayList<String>(); backtrack(ans, new StringBuilder(), 0, 0, n); return ans; } /** * @param ans 当前递归得到的结果 * @param cur 当前的括号串 * @param open 左括号已经使用的个数 * @param close 右括号已经使用的个数 * @param max 序列长度最大值 */ public void backtrack(List<String> ans, StringBuilder cur, int open, int close, int max) { if (cur.length() == max * 2) { ans.add(cur.toString()); return; } //本题需要两次回溯,比较少见的情况 if (open < max) { cur.append('('); backtrack(ans, cur, open + 1, close, max); cur.deleteCharAt(cur.length() - 1); } if (close < open) { cur.append(')'); backtrack(ans, cur, open, close + 1, max); cur.deleteCharAt(cur.length() - 1); } } } class GenerateParenthesis: def generateParenthesis(self, n) : ans = [] def backtrack(S, left, right): if len(S) == 2 * n: ans.append(''.join(S)) return if left < n: S.append('(') backtrack(S, left+1, right) S.pop() if right < left: S.append(')') backtrack(S, left, right+1) S.pop() backtrack([], 0, 0) return ans class GenerateParenthesis { void backtrack(vector<string>& ans, string& cur, int open, int close, int n) { if (cur.size() == n * 2) { ans.push_back(cur); return; } if (open < n) { cur.push_back('('); backtrack(ans, cur, open + 1, close, n); cur.pop_back(); } if (close < open) { cur.push_back(')'); backtrack(ans, cur, open, close + 1, n); cur.pop_back(); } } public: vector<string> generateParenthesis(int n) { vector<string> result; string current; backtrack(result, current, 0, 0, n); return result; } };
0
0
0
浏览量1996
时光小少年

第 7 关 | 算法真正开始了 —— 递归与二叉树 : 3.迭代法实现二叉树的遍历

1. 迭代法实现前序遍历前序遍历是中左右,如果还有左子树就一直向下找。完了之后再返回从最底层逐步向上向右找。 不难写出如下代码: (注意代码中,空节点不入栈)public List<Integer> preOrderTraversal(TreeNode root) { List<Integer> res = new ArrayList<Integer>(); if (root == null) { return res; } Deque<TreeNode> stack = new LinkedList<TreeNode>(); TreeNode node = root; while (!stack.isEmpty() || node != null) { while (node != null) { res.add(node.val); stack.push(node); node = node.left; } node = stack.pop(); node = node.right; } return res; } class Solution: def preorderTraversal(self, root): res = list() if not root: return res stack = [] node = root while stack or node: while node: res.append(node.val) stack.append(node) node = node.left node = stack.pop() node = node.right return res 此时会发现貌似使用迭代法写出前序遍历并不复杂,我们继续看中序遍历:2. 迭代法实现中序遍历再看中序遍历,中序遍历是左中右,先访问的是二叉树左子树的节点,然后一层一层向下访问,直到到达树左面的最底部,再开始处理节点(也就是在把节点的数值放进res列表中)。在使用迭代法写中序遍历,就需要借用指针的遍历来帮助访问节点,栈则用来处理节点上的元素。 看代码:/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { public List<Integer> inorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<>(); Deque<TreeNode> stack = new LinkedList<TreeNode>(); while(!stack.isEmpty() || root!= null){ while(root != null){ stack.push(root); root = root.left; } root = stack.pop(); res.add(root.val); root = root.right; } return res; } } class Solution { public: vector<int> inorderTraversal(TreeNode* root) { vector<int> res; stack<TreeNode*> stk; while (root != nullptr || !stk.empty()) { while (root != nullptr) { stk.push(root); root = root->left; } root = stk.top(); stk.pop(); res.push_back(root->val); root = root->right; } return res; } }; def inorderTraversal(self,root): if not root: return [] res = [] stack = [] while stack or root: while root: stack.append(root) root = root.left node = stack.pop() res.append(node.val) root = node.right return res 3. 迭代法实现后续遍历后序遍历的非递归实现有三种基本的思路:反转法、访问标记法、和Morris法,可惜三种理解起来都有些难度,如果头发不够,可以等一等再学习。个人感觉访问标记法是最难理解的方法,而Morris法是一个老外发明的巧妙思想:不使用栈,而是用好树中的null指针,但是实现后序仍然非常麻烦,我们这里不再展开,感兴趣的同学可以查一下,我们这里只介绍一种好理解又好实现的方法:反转法。如下图,我们先观察后序遍历的结果是seq={9 5 7 4 3},如果我们将其整体反转的话就是new_seq={3 4 7 5 9}。你有没有发现要得到new_seq的方法和前序遍历思路几乎一致,只不过是左右反了。前序是先中间,再左边然后右边,而这里是先中间,再后边然后左边。那我们完全可以改造一下前序遍历,得到序列new_seq之后再reverse一下就是想要的结果了,代码如下:/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { public List<Integer> postorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<>(); if(root == null){ return res; } Stack<TreeNode> stack = new Stack<>(); TreeNode node = root; while(!stack.isEmpty() || node != null){ while(node != null){ res.add(node.val); stack.push(node); node = node.right; } node = stack.pop(); node = node.left; } Collections.reverse(res); return res; } } def postorderTraversal(self, root): res = [] if root is None: return res stack = [] node = root while stack or node: while node: res.append(node.val) stack.append(node) node = node.right node = stack.pop() node = node.left res.reverse() return res public: vector<int> postorderTraversal(TreeNode* root) { vector<int> res; if (root == nullptr) { return res; } stack<TreeNode*> stack; TreeNode* node = root; while (!stack.empty() || node != nullptr) { while (node != nullptr) { res.push_back(node->val); stack.push(node); node = node->right; } node = stack.top(); stack.pop(); node = node->left; } reverse(res.begin(), res.end()); return res; } };
0
0
0
浏览量676
时光小少年

第 11 关 | 刷题模板之位运算:1.青铜挑战——理解位运算的规则

1. 数字在计算机中的表示机器数 一个数在计算机中的二进制表示形式,叫做这个数的机器数。机器数是带符号的,在计算机用一 个数的最高位存放符号,正数为0,负数为1。比如,十进制中的数 +3 ,计算机字长为8位,转换成二进 制就是00000011。如果是 -3 ,就是 10000011 。这里的 00000011 和 10000011 就是机器数。真值 因为机器数第一位是符号位,所以机器数的形式值就不等于真正的数值。例如上 面的有符号数 10000011,其最高位1代表负,其真正数值是 -3 而不是形式值131 (10000011转换成十进制等于131)。所以,为了好区别,将带符号位的机器数对应 的真正数值称为机器数的真值。例:0000 0001的真值 = +000 0001 = +1,1000 0001的真值 = –000 0001 = –1。计算机对机器数的表示进一步细化:原码, 反码, 补码。原码 就是符号位加上真值的绝对值,即用第一位表示符号,其余位表示值, 比如如 果是8位二进制:[+1] 原 = 0000 0001 [-1] 原 = 1000 0001 第一位是符号位,因为第一位是符号位,所以8位二进制数的取值范围就是:[1111 1111 , 0111 1111],也即 [-127 , 127]反码 的表示方法是:正数的反码是其本身,而负数的反码是在其原码的基础上,符号位不变,其余各个位取反。例如:[+1] = [00000001]原 = [00000001]反 [-1] = [10000001]原 = [11111110]反 可见如果一个反码表示的是负数,人脑无法直观的看出来它的数值,通常要将其转换 成原码再计算。在应用中,因为补码 能保持加和减运算的统一,因此应用更广,其表示方法是:正数的补码就是其本身;负数的补码是在其原码的基础上,符号位不变,其余各位取反,最后+1(即在反码 的基础上+1)。[+1] = [00000001]原 = [00000001]反 = [00000001]补 [-1] = [10000001]原 = [11111110]反 = [11111111]补 对于负数, 补码表示方式也是人脑无法直观看出其数值的,通常也需要转换成原码在计算其数值。拓展:为什么会有原码,反码和补码既然原码就能表示数据,那为什么实际软件中更多使用的是补码呢?接下来我们就看一看。现在我们知道了计算机可以有三种编码方式表示一个数,对于正数因为三种编码方式 的结果都相同:[+1] = [00000001]原 = [00000001]反 = [00000001]补但是对于负数:[-1] = [10000001]原 = [11111110]反 = [11111111]补 可见原码, 反码和补码是完全不同的。既然原码才是被人脑直接识别并用于计算表示 方式,为何还会有反码和补码呢?首先,因为人脑可以知道第一位是符号位,在计算的时候我们会根据符号位选择对真值区域的加减。但是计算机要辨别"符号位"就必须获得全部的位的数据才可以,显然 会让计算机的基础电路设计变得十分复杂! 于是人们想出了将符号位也参与运算的方法。 我们知道, 根据运算法则减去一个正数等于加上一个负数, 即: 1-1 = 1 + (-1) = 0,所以机器可以只有加法而没有减法,这样计算机运算的设计就更简单了。于是 人们开始探索 将符号位参与运算,并且只保留加法的方法。看个例子,计算十进制的表达式: 1-1=0,首先看原码的表示:1 - 1 = 1 + (-1) = [00000001]原 + [10000001]原 = [10000010]原 = -2如果用原码表示,让符号位也参与计算,显然对于减法来说,结果是不正确的,这也 是为何计算机内部不使用原码表示一个数。为了解决原码做减法的问题就出现了反码,此时计算十进制的表达式为: 1-1=01 - 1 = 1 + (-1) = [0000 0001]原 + [1000 0001]原 = [0000 0001]反 + [1111 1110]反 = [1111 1111]反 = [1000 0000]原 = -0 可以看到用反码计算减法结果的真值部分是正确的,但是"0"的表示有点奇怪,+0和-0是一样的,而且0带符号是没有任何意义,而且要浪费[0000 0000]原和[10000000]原两个编码来表示0。于是补码的出现,解决了0的符号以及两个编码的问题:1-1 = 1 + (-1) = [0000 0001]原 + [1000 0001]原 = [0000 0001]补 + [1111 1111]补 = [0000 0000]补=[0000 0000]原 这样0用[0000 0000]表示, 而以前出现问题的-0则不存在了,而且可以用[10000000]表示-128:(-1) + (-127) = [1000 0001]原 + [1111 1111]原 = [1111 1111]补 + [1000 0001]补 = [1000 0000]补 -1-127的结果应该是-128,我们正好可以用[1000 0000]来表示-128,这样使用补码表示的范围为[-128, 127],这一点也比原码的[-127,127]好。拓展一下,对于编程中常用到的32位int类型,可以表示范围是: [-2^31, 2^31-1] ,这也是我们在应用中经常见到的定义方式。2. 位运算规则本节的内容很多你可能学过,但是请再认真思考一遍,因为大量的算法解决思路都是 从这里引申出来的计算机采用的是二进制,二进制包括两个数码:0,1。在计算机的底层,一切运算都 是基于位运算实现的,所以研究清楚位运算可以加深我们对很多基础原理的理解程 度。在算法方面,不少题目都是基于位运算拓展而来的,而且还有一定的技巧,如果不提 前学一学,面试时很难想到。位运算主要有:与、或、异或、取反、左移和右移,其中左移和右移统称移位运算, 移位运算又分为算术移位和逻辑移位。2.1. 与、或、异或、取反与运算的符号为 &,运算规则是:对于每个二进制位,当两个数对应的位都是 1 的时候,结果才是 1,否则结果为 00 & 0 = 0 0 & 1 = 0 1 & 0 = 0 1 & 1 = 1 或运算的符号是 | ,运算规则是:对于每个二进制位,当两个数对应的位都是0的时候,结果才是 0 ,否则都是 1.0 | 0 =0 0 | 1 = 1 1 | 1 = 1 1 | 0 = 1 异或运算的符号是 ⊕(在代码中用∧ 表示异或),运算规则是:对于每个二进制位, 当两个数对应的位相同时,结果为 0,否则结果为 1。 0⊕0=0 0⊕1=1 1⊕0=1 1⊕1=0 取反运算的符号是 ~,运算规则是:对于一个数的每二进制位进行取反操作,0 变成 1,1 变成 0 。~1 = 0 ~0 = 1 以下例子显示上述四种位运算符的运算结果,参与运算的数字都采用有符号的 8 位二 进制表示。46 的二进制表示是 00101110,51 的二进制表示是 00110011。考虑以下位运算的 结果。46&51的结果是34,对应的二进制表示是 00100010。46|51 的结果是63,对应的二进制表示是 00111111。46⊕51 的结果是29,对应的二进制表示是 00011101。∼46 的结果是−47,对应的二进制表示是 11010001。∼51 的结果是 −52,对应的二进制表示是 11001100。2.2. 移位运算移位运算按照移位方向分类可以分成左移和右移,按照是否带符号分类可以分成算术 移位和逻辑移位。原始:0000 0110 6右移一次:0000 0011 3 相当于除以2左移一次:0000 1100 12 相当于乘以26*3 =>6*(2+1)=> 6*2+6*1 66*33=>66*(32+1) 66*32+66*1 左移运算的符号是 <<,左移运算时,将全部二进制位向左移动若干位,高位丢弃, 低位补 0。对于左移运算,算术移位和逻辑移位是相同的。右移运算的符号是 >>。右移运算时,将全部二进制位向右移动若干位,低位丢弃, 高位的补位由算术移位或逻辑移位决定:算术右移时,高位补最高位;逻辑右移时,高位补 0。以下例子显示移位运算的运算结果,参与运算的数字都采用有符号的 8 位二进制表示。示例1:29 的二进制表示是 00011101。29左移 2 位的结果是 116,对应的二进 制表示是 01110100;29 左移 3 位的结果是 −24,对应的二进制表示是 11101000。示例2:50的二进制表示是 00110010。50 右移 1 位的结果是 25,对应的二进 制表示是 00011001;50 右移 2 位的结果是 12,对应的二进制表示是 00001100。对于 0和正数,算术右移和逻辑右移的结果是相同的。示例3:-50的二进制表示是 11001110(补码)。-50 算术右移 2 位的结果是 −13,对应的二进制表示是 11110011;−50 逻辑右移 2位的结果是 51,对应的 二进制表示是 00110011。右移运算中的算术移位和逻辑移位是不同的,计算机内部的右移运算采取的是哪一种 呢?对于 C/C++ 而言,数据类型包含有符号类型和无符号类型,其中有符号类型使 用关键字signed 声明,无符号类型使用关键字 unsigned 声明,两个关键字都不 使用时,默认是有符号类型。对于有符号类型,右移运算为算术右移;对于无符 号类型,右移运算为逻辑右移。对于 Java 而言,不存在无符号类型,所有的表示整数的类型都是有符号类型,因 此需要区分算术右移和逻辑右移。在Java 中,算术右移的符号是 >>,逻辑右移 的符号是 >>>。2.3. 移位运算和乘除法的关系观察上面的例子可以看到,移位运算可以实现乘除操作。由于计算机的底层的一切运 算都是基于位运算实现的,因此使用移位运算实现乘除法的效率显著高于直接乘除法的。左移运算对应乘法运算。将一个数左移 k位,等价于将这个数乘以 2^k。例如,29 左 移 2 位的结果是 116,等价于 29×4。当乘数不是 2 的整数次幂时,可以将乘数拆成 若干项 2 的整数次幂之和,例如,a×6 等价于 (a<<2)+(a<<1)。对于任意整数,乘 法运算都可以用左移运算实现,但是需要注意溢出的情况,例如在 8 位二进制表示 下,29 左移 3 位就会出现溢出。算术右移运算对应除法运算,将一个数右移 k 位,相当于将这个数除以 2^k。例如, 50 右移 2 位的结果是 12,等价于 50/4,结果向下取整。从程序实现的角度,考虑程序中的整数除法,是否可以说,将一个数(算术)右移 k 位,和将这个数除以 2^k等价?对于 0 和正数,上述说法是成立的,整数除法是向 0 取整,右移运算是向下取整,也是向 0 取整。但是对于负数,上述说法就不成立了, 整数除法是向 0 取整,右移运算是向下取整,两者就不相同了。例如,(−50)>>2 的 结果是 −13,而 (−50)/4 的结果是 −12,两者是不相等的。因此,将一个数(算 术)右移 k 位,和将这个数除以 2^k是不等价的。算法出题这早就考虑到了这一点, 因此在大部分算法题都将测试数据限制在正数和0的情况,因此可以放心的左移或者右移。2.4. 位运算常用技巧位运算的性质有很多,此处列举一些常见的性质,假设以下出现的变量都是有符号整数。幂等律:a &a=a,a ∣ a=a(注意异或不满足幂等律);交换律:a & b=b & a,a ∣ b=b ∣ a,a⊕b=b⊕a;结合律:(a & b) & c=a & (b & c),(a ∣ b) ∣ c=a ∣ (b ∣ c),(a⊕b)⊕c=a⊕(b⊕c);分配律:(a & b) ∣ c=(a ∣ c) & (b ∣ c),(a ∣ b) & c=(a & c) ∣ (b & c),(a⊕b)& c=(a & c)⊕(b & c);德摩根律:∼(a & b)=(∼a) ∣ (∼b),∼(a ∣ b)=(∼a) & (∼b);取反运算性质:−1=∼0,−a=∼(a−1);与运算性质:a & 0=0,a & (−1)=a,a & (∼a)=0;或运算性质:a ∣ 0=a;异或运算性质:a⊕0=a,a⊕a=0;根据上面的性质,可以得到很多处理技巧,这里列举几个a & (a−1) 的结果为将 a 的二进制表示的最后一个 1 变成 0;(补码)a & (−a)的结果为只保留 a 的二进制表示的最后一个 1,其余的 1 都变成 0。 处理位操作时,还有很多技巧,不要死记硬背,理解其原理对解决相关问题有很大帮 助。下面的示例中,1s和0s分别表示与x等长的一串1和一串0:而如何获取、设置和更新某个位的数据,也有固定的套路。例如:获取该方法是将 1 左移 i 位,得到形如 00010000 的值,接着堆这个值与num执行”位与“操作,从而将i位之外的所有位清零,最后检查该结果是否为零。不为 零说明i位为1,否则i位为0。代码如下:boolean getBit(int num,int i){ return ((num&(1<<i))!=0); } 设置setBit先将1左移i位,得到形如00010000的值,接着堆这个值和num执行”位或“操作,这样只会改变i 位的数据。这样除i位外的位均为零,故不会影响num的其余位。代码如下:int setBit(int num,int i){ return num | (1 << i); } 清零该方法与setBit相反,首先将1左移i位获得形如00010000的值,对这个值取反进而得到类似11101111的 值,接着对该值和num执行”位与“,故而不会影响到num的其余位,只会清零i位。int clearBit(int num,int i){ int mask = ~(1 << i); return num &mask; } 更新这个方法是将setBit和clearBit合二为一,首先用诸如11101111的值将num的第i位清零。接着将待写入 值v左移i位,得到一个i位为v但其余位都为0的数。最后对之前的结果执行”位或“操作,v为1这num的i 位更新为1,否则为0:int updateBit(int num,int i,int v){ int mask=~(1<<i); return (num&mask)|(v<<i); } 上面几种方式最好在理解的情况下使用,这样很多棘手的问题就能逐步拆解出解决的方法。
0
0
0
浏览量239
时光小少年

第 09 关 | 心有灵犀的二分查找与二叉树的中序遍历 :2.白银挑战——二分查找与搜索树高频问题

二分查找很经典,但是面试的时候不一定直接考察这个问题,而是考察其变形题,我们一起看一下。 另外,二分查找与二叉搜索树有异曲同工之妙,我们这里也一起看一下。关卡名二分查找与搜索树高频问题我会了 ✔️内容1.  山脉数组的峰顶索引✔️2.  旋转数字的最小数字✔️3.  寻找缺失数字✔️4.  优化求平方根✔️5.  中序与搜索树原理✔️6.  二叉搜索树中搜索特定值✔️7.  验证二叉搜索树✔️基于二分查找思想,可以拓展出很多算法问题的,而且很多都是考察的热门, 这里我们整理了几道经典的问题。二分在算法中的应用非常多,也是很多大厂钟爱的考察类型,感兴趣的同学可以继续研究一下:CC150面试题53:在排序数组中查找数字,要求:统一几个数字在排序数组中出现的次数,例如,输入排序数组{1,2,3,3,3,3,4,5}和数字3,由于3在数组中出现了4次,因此输出4。leetcode 34. 在排序数组中查找元素的第一个和最后一个位置LeetCode875.爱吃香蕉的珂珂LeetCode29.两数相除在前面我们发现很多题使用前序、后序或者层次遍历都可以解决,但几乎没有中序遍历的。这是因为中序与前后序相比有不一样的特征,例如中序可以和搜索树结合在一起,但是前后序则不行。在理解了二分搜索之后,我们会发现中序搜索与二分查找简直就是一个娘养的,实在太像了。这里我们就来研究一下中序搜索的问题。1. 基于二分查找的拓展问题1.1. 山脉数组的峰顶索引LeetCode852.这个题的要求有点啰嗦,核心意思就是在数组中的某位位置i开始,从0到i是递增的,从i+1 到数组最后是递减的,让你找到这个最高点。详细要求是:符合下列属性的数组 arr 称为山脉数组 :arr.length >= 3存在 i(0 < i < arr.length - 1)使得:arr[0] < arr[1] < ... arr[i-1] < arr[i]arr[i] > arr[i+1] > ... > arr[arr.length - 1给你由整数组成的山脉数组 arr ,返回任何满足 arr[0] < arr[1] < ... arr[i - 1] < arr[i] > arr[i + 1] > ... > arr[arr.length - 1] 的下标 i 。这个题其实就是前面找最小值的相关过程而已,最简单的方式是对数组进行一次遍历。当我们遍历到下标i时,如果有arr[i-1]<arr[i]>arr[i+1],那么i就是我们需要找出的下标。其实还可以更简单一些,因为是从左开始找的,开始的时候必然是arr[i-1]<a[i],所以只要找到第一个arr[i]>arr[i+1]的位置即可。代码就是:class Solution { public int peakIndexInMountainArray(int[] arr) { int n = arr.length; int ans = -1; for(int i = 1; i < n; i++){ if(arr[i] > arr[i + 1]){ ans = i; break; } } return ans; } } int peakIndexInMountainArray(int* arr, int arrSize) { int n = arrSize; int ans = -1; for (int i = 1; i < n - 1; ++i) { if (arr[i] > arr[i + 1]) { ans = i; break; } } return ans; } class Solution: def peakIndexInMountainArray(self, arr) : n = len(arr) ans = -1 for i in range(1, n - 1): if arr[i] > arr[i + 1]: ans = i break return ans 这个题能否使用二分来优化一下呢?当然可以。对于二分的某一个位置 mid,mid 可能的位置有3种情况:mid在上升阶段的时候,满足arr[mid]>a[mid-1] && arr[mid]<arr[mid+1]mid在顶峰的时候,满足arr[i]>a[i-1] && arr[i]>arr[i+1]mid在下降阶段,满足arr[mid]<a[mid-1] && arr[mid]>arr[mid+1]因此我们根据 mid 当前所在的位置,调整二分的左右指针,就能找到顶峰。class Solution { public int peakIndexInMountainArray(int[] arr) { if(arr.length == 3){ return 1; } int left = 1,right = arr.length - 2; while(left < right){ int mid = left + ((right - left) >>1); if (arr[mid] > arr[mid - 1] && arr[mid] > arr[mid + 1]) return mid; if (arr[mid] < arr[mid + 1] && arr[mid] > arr[mid - 1]) left = mid + 1; if (arr[mid] > arr[mid + 1] && arr[mid] < arr[mid - 1]) right = mid - 1; } return left; } } int peakIndexInMountainArray(int* arr, int arrSize) { int n = arrSize; int left = 1, right = n - 2, ans = 0; while (left <= right) { int mid = (left + right) / 2; if (arr[mid] > arr[mid + 1]) { ans = mid; right = mid - 1; } else { left = mid + 1; } } return ans; } class Solution: def peakIndexInMountainArray(self, arr) : n = len(arr) left, right, ans = 1, n - 2, 0 while left <= right: mid = (left + right) // 2 if arr[mid] > arr[mid + 1]: ans = mid right = mid - 1 else: left = mid + 1 return ans 1.2. 旋转数字的最小数字我们说刷算法要按照专题来刷,这样才能看清很多题目的内在关系,二分查找也是如此,很多题目看似与二分无关,但是就是在考察二分查找,我们一起看一下。LeetCode153 已知一个长度为 n 的数组,预先按照升序排列,经由1到n次旋转后,得到输入数组。例如原数组 nums = [0,1,2,4,5,6,7] 在变化后可能得到:若旋转 4 次,则可以得到 [4,5,6,7,0,1,2]若旋转 7 次,则可以得到 [0,1,2,4,5,6,7]注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。示例1: 输入:nums = [4,4,4,5,1,2,3] 输出:1 解释:原数组为 [1,2,3,4,5] ,旋转 3 次得到输入数组。 示例2: 输入:nums = [4,5,6,7,0,1,2] 输出:0 解释:原数组为 [0,1,2,4,5,6,7] ,旋转 4 次得到输入数组。 本部分都摘自LeetCode一个不包含重复元素的升序数组在经过旋转之后,可以得到下面可视化的折线图:其中横轴表示数组元素的下标,纵轴表示数组元素的值。图中标出了最小值的位置,是我们需要查找的目标。我们考虑数组中的最后一个元素 x:在最小值右侧的元素(不包括最后一个元素本身),它们的值一定都严格小于 x;而在最小值左侧的元素,它们的值一定都严格大于 x。因此,我们可以根据这一条性质,通过二分查找的方法找出最小值。在二分查找的每一步中,左边界为 low,右边界为 high,区间的中点为 pivot,最小值就在该区间内。我们将中轴元素 nums[pivot] 与右边界元素 nums[high] 进行比较,可能会有以下的三种情况:第一种情况是nums[pivot]<nums[high]。如下图所示,这说明nums[pivot] 是最小值右侧的元素,因此我们可以忽略二分查找区间的右半部分。第二种情况是 nums[pivot]>nums[high]。如下图所示,这说明nums[pivot] 是最小值左侧的元素,因此我们可以忽略二分查找区间的左半部分。由于数组不包含重复元素,并且只要当前的区间长度不为 1,pivot 就不会与high 重合;而如果当前的区间长度为 1,这说明我们已经可以结束二分查找了。因此不会存在 nums[pivot]=nums[high] 的情况。当二分查找结束时,我们就得到了最小值所在的位置。public int findMin(int[] nums) { int low = 0; int high = nums.length - 1; while (low < high) { int pivot = low + ((high - low) >>1); if (nums[pivot] < nums[high]) { high = pivot; } else { low = pivot + 1; } } return nums[low]; } int findMin(int* nums, int numsSize) { int low = 0; int high = numsSize - 1; while (low < high) { int pivot = low + (high - low) / 2; if (nums[pivot] < nums[high]) { high = pivot; } else { low = pivot + 1; } } return nums[low]; } def findMin(self, nums): low, high = 0, len(nums) - 1 while low < high: pivot = low + (high - low) // 2 if nums[pivot] < nums[high]: high = pivot else: low = pivot + 1 return nums[low] 这里你是否注意到high = pivot;而不是我们习惯的high = pivot-1呢?这是为了防止遗漏元素,例如[3,1,2],执行的时候nums[pivot]=1,小于nums[high]=2,此时如果high=pivot-1,则直接变成了0。所以对于这种边界情况,很难解释清楚,最好的策略就是多写几种场景测试一下看看。这也是二分查找比较烦的情况,一般来说解释比较困难,也不容易理解清楚,所以写几个典型的例子试一下,面试的时候大部分case能过就能通过。我们可以再拓展一下,如果在上面的基础上存在重复元素会怎么样呢?感兴趣的同学可以研究一下LeetCode154这道题。1.3. 找缺少数字剑指offer题目: 一个长度为n-1的递增排序数组中的所有数字都是唯一的,并且每个数字都在范围0~n-1之内。在范围0~n-1内的n个数字中有且只有一个数字不在该数组中,请找出这个数字。这个题很简单是不?从头到尾遍历一遍即可确定,但是这么简单肯定不是面试需要的。那这个题要考什么呢?就是二分查找。对于有序的也可以用二分查找,这里的关键点是在缺失的数字之前,必然有nums[i]==i,在缺失的数字之后,必然有nums[i]!=i。因此,只需要二分找出第一个nums[i]!=i,此时下标i就是答案。若数组元素中没有找到此下标,那么缺失的就是n。代码如下:public int missingNumber (int[] a) { int left = 0; int right = a.length-1; while(left < right){ int mid = (left+right)/2; if(a[mid]==mid){ left = mid+1; }else{ right = mid-1; } } return left; } def missingNumber(a): left = 0 right = len(a) - 1 while left < right: mid = (left + right) // 2 if a[mid] == mid: left = mid + 1 else: right = mid - 1 return left int missingNumber(int* a, int length) { int left = 0; int right = length - 1; while (left < right) { int mid = (left + right) / 2; if (a[mid] == mid) { left = mid + 1; } else { right = mid - 1; } } return left; } 1.4. 优化求平方根剑指offer题目实现函数 int sqrt(int x).计算并返回x的平方根这个题的思路是用最快的方式找到n*n=x的n。如果整数没有平方根,一般采用向下取整的方式得到结果。采用折半进行比较的实现过程是:public int sqrt (int x) { int l=1,r=x; while(l <= r){ int mid = l + ((r - l)>>1); if(x/mid > mid){ l = mid + 1; } else if(x / mid < mid){ r = mid - 1; } else if(x/mid == mid){ return mid; } } return r; } int mySqrt(int x) { int l = 0, r = x, ans = -1; while (l <= r) { int mid = l + (r - l) / 2; if ((long long)mid * mid <= x) { ans = mid; l = mid + 1; } else { r = mid - 1; } } return ans; } class Solution: def mySqrt(self, x) : l, r, ans = 0, x, -1 while l <= r: mid = (l + r) // 2 if mid * mid <= x: ans = mid l = mid + 1 else: r = mid - 1 return ans 这种优化思想要记住,凡是在有序区间查找的场景,都可以用二分查找来优化速度。 如果有序区间是变化的,那就每次都针对这个变化的区间进行二分查找。这种题目在LeetCode中特别多的。2. 中序与搜索树定理在前面我们发现很多题使用前序、后序或者层次遍历都可以解决,但几乎没有中序遍历的。这是因为中序与前后序相比有不一样的特征,例如中序可以和搜索树结合在一起,但是前后序则不行。二叉搜索树是一个很简单的概念,但是想说清楚却不太容易。简单来说就是如果一棵二叉树是搜索树,则按照中序遍历其序列正好是一个递增序列。比较规范的定义是:若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值;若它的右子树不空,则右子树上所有节点的值均大于它的根节点的值;它的左、右子树也分别为二叉排序树。下面这两棵树一个中序序列是{3,6,9,10,14,16,19},一个是{3,6,9,10},因此都是搜索树:搜索树的题目虽然也是用递归,但是与前后序有很大区别,主要是因为搜索树是有序的,就可以根据条件决定某些递归就不必执行了,这也称为“剪枝”。2.1. 二叉搜索树中搜索特定值LeetCode 700.给定二叉搜索树(BST)的根节点和一个值。 你需要在BST中找到节点值等于给定值的节点。 返回以该节点为根的子树。 如果节点不存在,则返回 NULL。例如: 4 / \ 2 7 / \ 1 3 你应该返回如下子树: 2 / \ 1 3 本题看起来很复杂,但是实现非常简单,递归:如果根节点为空 root == null 或者根节点的值等于搜索值 val == root.val,返回根节点。如果 val < root.val,进入根节点的左子树查找 searchBST(root.left, val)。如果 val > root.val,进入根节点的右子树查找 searchBST(root.right, val)。public TreeNode searchBST(TreeNode root, int val) { if (root == null || val == root.val) return root; return val < root.val ? searchBST(root.left, val) : searchBST(root.right, val); } def searchBST(self, root, val) : if root is None: return None if val == root.val: return root return self.searchBST(root.left if val < root.val else root.right, val) TreeNode *searchBST(TreeNode *root, int val) { if (root == nullptr) { return nullptr; } if (val == root->val) { return root; } return searchBST(val < root->val ? root->left : root->right, val); } 如果采用迭代方式,也不复杂:如果根节点不空 root != null 且根节点不是目的节点 val != root.val:如果 val < root.val,进入根节点的左子树查找 root = root.left。 如果 val > root.val,进入根节点的右子树查找 root = root.right。public TreeNode searchBST(TreeNode root, int val) { while (root != null && val != root.val) root = val < root.val ? root.left : root.right; return root; } class Solution: def searchBST(self, root, val) : while root: if val == root.val: return root root = root.left if val < root.val else root.right return None class Solution: def searchBST(self, root, val) : while root: if val == root.val: return root root = root.left if val < root.val else root.right return None 2.2. 验证二叉搜索树LeetCode98.给你一个二叉树的根节点 root ,判断其是否是一个有效的二叉搜索树。有效 二叉搜索树定义如下:节点的左子树只包含 小于 当前节点的数。节点的右子树只包含 大于 当前节点的数。所有左子树和右子树自身必须也是二叉搜索树。示例:示例1: 输入:root = [2,1,3] 输出:true 示例2: 输入:root = [5,1,4,null,null,3,6] 输出:false 解释:根节点的值是 5 ,但是右子节点的值是 4 。 根据题目给出的性质,我们可以进一步知道二叉搜索树「中序遍历」得到的值构成的序列一定是升序的,在中序遍历的时候实时检查当前节点的值是否大于前一个中序遍历到的节点的值即可。long pre = Long.MIN_VALUE; public boolean isValidBST(TreeNode root) { if (root == null) { return true; } // 如果左子树下某个元素不满足要求,则退出 if (!isValidBST(root.left)) { return false; } // 访问当前节点:如果当前节点小于等于中序遍历的前一个节点,说明不满足BST,返回 false;否则继续遍历。 if (root.val <= pre) { return false; } pre = root.val; // 访问右子树 return isValidBST(root.right); } bool helper(TreeNode* root, long long lower, long long upper) { if (root == nullptr) { return true; } if (root -> val <= lower || root -> val >= upper) { return false; } return helper(root -> left, lower, root -> val) && helper(root -> right, root -> val, upper); } bool isValidBST(TreeNode* root) { return helper(root, LONG_MIN, LONG_MAX); } class Solution: def isValidBST(self, root: TreeNode) -> bool: def helper(node, lower = float('-inf'), upper = float('inf')) -> bool: if not node: return True val = node.val if val <= lower or val >= upper: return False if not helper(node.right, val, upper): return False if not helper(node.left, lower, val): return False return True return helper(root) 如果这个题理解了,可以继续研究LeetCode530.二叉搜索树的最小绝对差和LeetCode501.二叉搜索树中的众数两个题。
0
0
0
浏览量1220
时光小少年

第 5 关 | 算法的备胎 hash 和 找靠山的队列:1.青铜挑战——队列和 Hash 的特征

1. Hash 基础1.1. Hash 的概念以及基本特征哈希(Hash)也称为散列,就是把任意长度的输入,通过散列算法,变成固定长度的输出,这个输出值就是散列值。很多人可能想不明白,这里的映射是什么意思,为啥访问的时候时间复杂度为O(1)?我们只要看存和读的时候分别怎么映射就知道了。我们现在假设数组 array 存放的是 1 到 15 这些数,现在要存一个大小为 7 的 Hash 表,应该怎么存了》我们存储的位置公式是index = number % 7这时候我们将 1 到 6 存入的时候,图示如下:这个没有疑问,因为就是简单地取模,然后继续存 7 到 13 ,结果就是下面这个样子:最后再存 14 和 15:这个时候就我们会发现,有些数据被存到了同一个位置,我们后面再讨论。接下俩,我们看看如何取。假如我要测试 13 在不在这个结构里面,这同样使用上面这个公式来进行,很明显,13 % 7 =6 ,直接访问 array【6】这个位置,很明显,返回 true。假如我要测试 20 在不在这个结构里面,同样使用上面这条公式,很明显 20 % 7 = 6,所以直接访问array【6】这个位置,会发现该位置只有 6 和 13,所以返回 false。理解这个例子我们就理解了 Hash 是如何进行最基本的映射的,以及为什么有时候访问的复杂度是O(1).1.2. 碰撞处理方法在上面这个例子中,我们发现有些数字在Hash 中可能很多个位子要存两个甚至以上个元素,很明显单纯的数组是不行的,这种两个不同的输入值,根据同一个散列函数计算出的散列值相同的现象称之为碰撞。那应该怎么解决呢?常见的方法有:开放地址法和链地址发、再哈希法、建立公共溢出区,后面两种用的比较少,我们重点看前面两个。1.2.1. 开放地址法开放地址发就是一旦发生冲突了,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。例如,上面这个如果要继续存 7、8、9的时候,7没问题,可以直接存到索引为 0 的位置,8本来就应该存在索引 为1 的位置,但是已经满了,所以继续往后找,索引3的位置是空的,所以 8 存到 3 位置,同理 9 存在索引 6 位置。这里你是否有一个疑惑:这样鸠占鹊巢的方法会不会引起混乱?再比如存入3和6的话,本来自己的位置是好好的,但是给外来户占领了,该如何处理呢?这个问题在 Java 里面的 ThreadLocal 解开了,具体的过程可以学习一下相关内容,这里主要说一下基本思想。ThreadLoacl有专门一个存储元素的ThreadLoaclMap,每次 get 和 set 的时候,会先将目标位置前后的空间搜索一下,将标记为 null 的位置回收掉,这样大部分不用的位置就收回起来了。这就像假期后的你回到公司,每个人都将自己的位置打扫得非常干净,结果整个工作区就很干净了。当然那Hash 处理该问题的整个过程非常复杂,涉及弱引用等等。1.2.2. 链地址法将哈希表的每个单元作为链表的头结点,所有哈希地址为 i 的元素构成一个同义词链表。即发生冲突的时候,把该关键字链在以该单元作为头结点的链表的尾部。例如:这种处理方法的问题就是处理起来代价非常高,要落地很多优化,例如 java 中的 ConcurrentHashMap 中就使用了这种方式,其中涉及元素尽量均匀、访问和操作速度要快、线程安全、扩容等许多问题。接下里来看这个 Hash 结构,下面的图中有两处非常明显的错误,请你先想想是啥。首先是数组的长度必须是 2 的 n 次幂,这里的长度是 9 ,,面向有错,然后 entry 的个数不能大于数组长度的 75%,如果大于就会触发扩容机制进行扩容,这里明显是大于 75%的,正确的图如下:数组的长度是 2 的 n 次幂,而且他的size又不大于数组长度的 75%。HashMap 实现的原理是想要找到存放数组的下标,如果是空的就存进去,如果不是空的就判断 key 值是否一样,如果一样就替换,如果不一样就以链表的形式存在(从 JDK 8 开始,根据元素的数量选择使用链表还是红黑树存储)。2. 队列基础知识2.1. 队列的概念和基本特征队列的特点就是节点的排队次序和入队次序按入队时间先后确定,先入队者先出队,后入队者否出队,也就是我常说的FIFO(First In Frist Out)先进先出,队列的实现有两种方式,基于数组和链表。基于链表的方式可能多一点,因为链表的长度是可变的,实现起来比较建党,如果基于数组,可能会有点麻烦,我们将其放在黄金挑战里面,这里只看一下基于链表实现的方法。2.2. 实现队列基于链表实现队列还是比较好处理的,只要在尾部添加元素,在 front 删除元素即可。package com.qinyao.queue; import org.w3c.dom.Node; /** * @ClassName LinkQueue * @Description * @Version 1.0.0 * @Author LinQi * @Date 2023/09/10 */ public class LinklistQueue<T> { private Node front; private Node rear; private int size; public LinklistQueue() { this.front = new Node(0); this.rear = new Node(0); } /** * 入队 */ public void push(int value){ Node newNode = new Node(value); Node temp = front; while(temp.next != null){ temp = temp.next; } temp.next = newNode; rear = newNode; size++; } /** * 出队 */ public T pull(){ if(front.next == null){ System.out.println("队列为空,没有要出队的元素"); } Node firstNode = front.next; front.next = firstNode.next; size--; return (T) firstNode.data; } /** * 遍历队列 */ public void traverse(){ if(front.next == null){ System.out.println("队列为空"); return; } Node temp = front.next; while(temp != null){ System.out.println(temp.data + "\t"); temp = temp.next; } } private class Node<T> { T data; Node next; public Node(T data, Node next) { this.data = data; this.next = next; } public Node(T data) { this.data = data; } } }
0
0
0
浏览量1790
时光小少年

第 20 关 | 图算法 —— 中看不中用:3. 黄金挑战—常见的图算法介绍

图里的算法是很多的,这里我们介绍一些常见的图算法。这些算法一般都比较复杂,我们这里介绍这些算法的基本含义,适合面试的时候装*,如果手写,那就不用啦。关卡名常见的图算法我会了✔️内容1.  理解图的一些常见算法✔️图分析算法,以图论为驱动,进行算法优化,结合应用工程,业务形态研究,不同领域场景模拟不同网络结构,通过自由刻画网络图形关系,验证结构合理性,如边的有向和无向及权重,从而辅助分析图形关系、图结构分析、网络结构分析等研究工作。我们通过清林情报分析师的应用插件“图分析”功能,通过算法计算图形生成容易理解的图形解释22种图算法。1、最小生成树(Minimum Spanning Tree):主要是三种算法:Prim算法 、Kruskal算法、Sollin(Boruvka)算法。(1)Prim算法 ,普里姆算法,图论中的一种算法,基于一种贪心的思想,可在加权连通图里搜索最小生成树。意即由此算法搜索到的边子集所构成的树中,不但包括了连通图里的所有顶点(英语:Vertex (graph theory)),且其所有边的权值之和亦为最小。该算法于1930年由捷克数学家沃伊捷赫·亚尔尼克(英语:Vojtěch Jarník)发现;并在1957年由美国计算机科学家罗伯特·普里姆(英语:Robert C. Prim)独立发现;1959年,艾兹格·迪科斯彻再次发现了该算法。因此,在某些场合,普里姆算法又被称为DJP算法、亚尔尼克算法或普里姆-亚尔尼克算法。Prime算法本质是动态规划(2)Kruskal算法,中文名克鲁斯卡尔算法,本质是贪心算法,是求连通网的最小生成树的另一种方法。与普里姆算法不同,它的时间复杂度为O(eloge)(e为网中的边数),所以,适合于求边稀疏的网的最小生成树。(3)Sollin(Boruvka)算法,Sollin(Brouvka)算法虽然是最小生成树最古老的一个算法之一,其实是前面介绍两种算法的综合,每次迭代同时扩展多课子树,直到得到最小生成树T。2、连通结构(Connected Components)无向图G的极大连通子图称为G的连通分量( Connected Component)。任何连通图的连通分量只有一个,即是其自身,非连通的无向图有多个连通分量。这种结构称作连通结构。3、双联通结构(Biconnected Components)任意两点之间都有多于一条的路径,则称为双连通图,也叫双连通分量,双连通分量的术语是biconnected components,简称为BCC,这种结构为双联通结构。任何一对顶点之间至少存在有两条路径, 在删去某个顶点及与该顶点相关联的边时, 也不破坏图的连通性。对于无向图的一个子图是双连通的,则称为双连通子图。极大的双连通子图称为双连通分量。一个无向图可以有多个双连通分量,一个点也算是双连通分量。4、强联通结构(Strongly Connected Components)有向图的极大强连通子图称为的强连通分量,强连通图只有一个强连通分量,即是其自身。非强连通的有向图有多个强连通分量。如果任意两点之间都能到达,则称为强连通图。如果对于有向图的一个子图是强连通的,则称为强连通子图,这种结构称为强联通结构。5、可达性(Reachability)在图论中,可达性是指在图中从一个顶点到另一个顶点的容易程度。在无向图中,可以通过识别图的连接分量来确定所有顶点对之间的可达性。我们的产品解决方案,通过定义一个实体为原点,通过原点链接计算出图中有向可达路径范围和无向可达路径范围,无向可达范围一般大于有向可达。常用算法为:Floyd-Warshall,Thorup,Kameda这三种算法。(1)Floyd-Warshall算法Floyd-Warshall算法(Floyd-Warshall algorithm)是解决任意两点间的最短路径的一种算法,可以正确处理有向图或负权的最短路径问题,同时也被用于计算有向图的传递闭包。Floyd-Warshall算法的时间复杂度为O(N),空间复杂度为O(N*N)。(2)Thorup 算法对于平面有向图,一种更快的方法是如Mikkel Thorup在2004年所提出的算法。计算复杂度为 ,其中为增长速度非常缓慢的inverse-Ackermann函数。该算法还可以提供近似最短路径距离以及路由信息。(3)Kameda算法如果图形是平面的,非循环的,并且还表现出以下附加属性,则可以使用由1975年的T.Kameda 提出的更快的预处理方法:所有0-indegree和所有0-outdegree顶点出现 (通常假设为外面),并且可以将该面的边界分割为两个部分,使得所有0个不等的顶点出现在一个部分上,并且所有的0度外的顶点出现在另一个部分上 (即两种类型的顶点不交替)。6、K核算法(K-Core)k-Core算法是一种经典图算法,用于寻找一个图中符合指定核心度的顶点的集合,即要求每个顶点至少与该子图中的其他k个顶点相关联。k-Core算法用于寻找一个图中符合指定核心度的顶点的集合,求每个顶点至少与该子图中的其他k个顶点相关联。这个我们提供1-5Core的图计算,在图谱中可以分别找出1-5Core的团结果发现,并可以用于子图分类。适用于图推演、生物学、社交网络、金融风控等场景。7、全路径(All Paths)全路径,就是网络图中的路径集合。分有向和无向,有向路径通过源到目标方向不可逆,无向路径通过源点和目标之间产生的图关系。在同一图形中,无向路径远多于有向。源点,是设定的初始点,目标是设置的需要通过源点要到达的点。有几种基本情况,一是源点和目标点同一设置,即自循环,有向情况下,自循环就是1个节点。二是无向情况下,自循环和有向情况一样,但二个节点以上则会多种混合循环体。产品可以通过设置源点和目标,进行分析源点和目标之间产生的有向无向关系。8、链结构(ALL Chains)链结构,包含循环或路径,结构从图形结构树的基本循环集派生而来。通过优先搜索图形结构,把图中链分解成一组循环或路径,从原点出发有向或无向远离根原点后又回到原点则为基本环。如果没有回到原点则为一条路径而不是一个环。每个循环或路径称为链。这种结构称为链结构。9、Single SourceSingle Source,称为单源,意为只有一个源为基础。首先是不允许有负环,单源实体到所有实体的最短路径构成一棵最短路径树。通过单源路径算法可以通过选中实体定义源,找出以这个实体源为中心或起始点的图结果。10、环结构(Cycles)环结构,即网络的循环结构,通过有向或无向路径最后,形成回到起点闭环。可以理解为形成一个“圈”。网络的基础循环是循环的最小集合,使得网络中的任何循环都可以写成基础中的循环总和。循环基数很有用,如单循环(自循环)、双向循环(双实体双向关系)、三角循环(三个实体循环路径)、四方循环(四个实体循环路径)、五边形以此类推。11、度中心性(Degree Centrality)(1)概念这一概念起源于社会网络研究。最初对社会网络感兴趣的是英国著名的人类学家布朗,他在对社会结构的关注中,以相对来说非技术的形式提出了“社会网络”的思想。从20世纪30年代到70年代,越来越多的社会人类学家和社会学家开始构建布朗的“社会结构”和“社会网络”概念,一些关键概念也应运而生,诸如“密度”、“中心度”、“三方关系”等概念如雨后春笋,纷纷涌现。 其中,美国加州大学艾尔温分校社会学系和数理行为科学研究所的研究教授林顿 C·弗里曼于1979年在美国社会网络杂志上发表《社会网络中心度的概念说明》一文中正式提出了度中心性的概念。(2)图论在图论与网络分析中,中心性是判定网络中节点重要性的指标,是节点重要性的量化。这些中心性度量指标最初应用在社会网络中,随后被推广到其它类型网络的分析中。在社会网络中,一项基本任务是需要鉴定一群人中哪些人比其他人更具有影响力,帮助研究人员分析和理解扮演者在网络中担当的角色。为完成这种分析,这些人以及人与人之间的联系被模型化成网络图,网络图中的节点代表人,节点之间的连边表示人与人之间的联系。基于建立起来的网络结构图,使用一系列中心性度量方法就可以计算出哪个个体比其他个体更重要。(3)应用度中心性,可以分为度中心度、度中心性及出度中心性。在一个有向图中,我们可以有出度和入度的中心性二种衡量方式。在网络中,一个节点的度越大,就意味着这个节点的度中心性就越高,就说明在网络中这个节点越重要。主要应用与社会网络分析、网络用户行为分析、信用网络分析、图论研究、复杂网络研究,脑功能网络分析、欺诈网络分析等场景。12、重心(Weight Centrality)重心,即权重中心性。每个实体节点为顶点,而实体节点之间的关系边。实体节点之间的相互作用是形成边权重。每个顶点的关联边数越多边权重占每个独立图中权重就越大。13、图中心(Graph Centrality)图中心,图中最强中心性的一个或多个实体节点,一个图中心,实体节点权重最大且唯一,多个图中心,在有向图中,二个以上实体节点在图中权重值相同且权重值相同。图中中心性用实体大小、颜色深浅进行区分,最高权重实体节点实体越大颜色最深,低权重或中心性低的实体节点则相反。14、中介中心性(Node Edge Betweeness Centrality)中介中心性,又叫中间中心性,中间性,居间中心性等等。主要计算实体节点在图中的中间性。以经过某个节点的最短路径数目来刻画节点的重要性指标。对于加权图,边权重必须大于零。零边权重可以在节点对之间产生无限数量的等长路径。对于有向图和无向图,源图(实体节点或实体节点关系集合)和目标之间的路径总数的计数方式不同。有向路径很容易计算。如果实体节点子集和目标子集相同,则我们要对无向路径进行计算。15、近亲中心性(Closeness Centrality)近亲中心性,又称接近度中心性,通过计算图网络中每个实体节点的接近程度。通过各实体节点的接近度在图中可良好比对呈现,接近度的中心性被归一化,每个实体节点的在本图网络中的中心分布。计算出每个实体节点所在位置链接部分的中心度。如果图形中的未完全链接的子网络,则计算按该子网络大小单独缩放的每个已连接实体节点或实体子网络的接近度中心性。16、特征向量中心性(Eigenvector Centrality)一个节点的重要性即取决于其邻居节点的数量(即该节点的度),也取决与每个邻居节点的重要性。与之相连的邻居节点越重要,则该节点就越重要。17、Page RankPageRank,Google的网页排序算法,又称网页排名、谷歌左侧排名,是一种由搜索引擎根据网页之间相互的超链接计算的技术,而作为网页排名的要素之一,谷歌的两位创始人,拉里·佩奇 (Larry Page) 和尔盖·布林 (Sergey Brin) 对网页排序问题进行研究。后来以拉里·佩奇(Larry Page)之姓来命名。Google用它来体现网页的相关性和重要性,在搜索引擎优化操作中是经常被用来评估网页优化的成效因素之一。当初主要是为了用来评估构成网络中的每一个节点的重要性。18、链子结构(Chains)链子结构,即链图中的独立子链结构,需要三个或以上实体节点,二个或以上边的条件形成。实体节点通过边形成有向或无向的链结构且无环路结构。有向链中方向流向一致。19、环子结构(Cycles)环子结构,即图中的独立子环结构,需要三个或以上实体节点,二个或以上边的条件形成。实体节点通过边形成有向或无向的环形子结构,且整个子结构的环中的环外链接有唯一实体节点链接,且链接唯一。20、星型子结构(Stars)星型子结构,是星型结构中包含至少一个子结构。星型结构以实体节点为中心,并用单独的线路使这个中心实体节点与其他各节点相连,相邻节点之间的链接都要通过中心节点,相当于单层结构。星型子结构包含最小独立星型和网络图中的星型子结构,最小独立星型即无向图三个实体节点形成的链,有向图三个实体节点形成方向向外的链,子结构,在图中的一个顶点为中心的边点链接形成星型结构,有向图方向有链接,但不包含这个链接,关联实体链接直接关联二条以上向外方向的边点结构的图结构,无向图符合基础结构。且子结构上不包含其他边点。21、树状子结构(Trees)树状子结构,是图中的树形结构,树形结构是一层次的嵌套结构。 一个树形结构的外层和内层有相似的结构, 所以这种结构多可以递归的表示。树形结构是一层次的嵌套结构。 一个树形结构的外层和内层有相似的结构, 所以这种结构多可以递归的表示。树状子结构和星型子结构类似,但树状子结构是多层的,星型子结构相当于单层结构,在图中想树杈一样可以不断向外扩散。22、派系子结构(Cliques)派系子结构,是图中产生的派系结构,派系结构早图论中叫做团,是实体节点集合(顶点集合)的一个完全子图,如果一个团不被其他任一团所包含,即它不是其他任一团的真子集,则称该团为图中的极大团(maximal clique)。顶点最多的极大团,称之为图中的最大团(maximum clique)。最大团问题的目标就是要找到给定图的最大团。
0
0
0
浏览量1913
时光小少年

第 19 关 | 经典刷题思想之动态规划:1.青铜挑战—动态规划是怎么回事

动态规划是最热门、最重要的算法思想之一,在面试中大量出现,而且题目整体都偏难一些。对于大部人来说,最大的问题是不知道动态规划到底是怎么回事。很多人看教程等,都被里面的状态、子问题、状态转移方程等等劝退了。其实,所谓的状态就是一个数组,动态规划里的状态转移方程就是更新这个数组的方法。这一关,我们先理解回溯到底怎么回事,后面我们会大量刷题。关卡名认识动态规划思想我会了✔️内容1.  理解动态规划里的基本概念✔️2.  理解路径专题涉及的题目✔️虽然谁都知道动态规划(Dynamic Programming,简称DP)难,但是没几个人能说清啥是DP。而且即使将DP的概念写出来也没几个人看懂到底啥意思。下面我们就从一个简单例子来逐步拆解这个复杂的解释。1. 热身:如何说一万次“我爱你”首先来感受一下什么是重复计算和记忆化搜索。曾经参加过一次线上小活动,看谁说更多的“我爱你”,我当时写了这么一段代码:public class FibonacciTest { public static int count = 0; public static void main(String[] args) { fibonacci(20); System.out.println("count:" + count); } public static int fibonacci(int n) { System.out.println("我爱你"); count++; if (n == 0) { return 1; } if (n == 1 || n == 2) return n; else { return fibonacci(n - 1) + fibonacci(n - 2); } } } 这个就是斐波那契数列,当n为20时,count是21891次。而当n=30 的时候结果是2692537,也就是接近270万。如果纯粹只是算斐波那契数列,我们可以直接循环:public static int count_2 = 0; public int fibonacci(int n) { if (n <= 2) { count_2++; return n; } int f1 = 1; int f2 = 2; int sum = 0; for (int i = 3; i <= n; i++) { count_2++; sum = f1 + f2; f1 = f2; f2 = sum; } return sum; } n为30时也不过计算二十几个数的累加,但是为什么采用递归竟然高达270万呢?因为里面存在大量的重复计算,数越大,重复越多。例如当n=8的时候,我们看下面的结构图就已经有很多重复计算了:上面我们在计算f(8)时,可以看到f(6)、f(5)等等都需要计算,这就是重叠子问题。怎么对其优化一下呢?可以看到这里主要的问题是很多数据都会频繁计算,如果将计算的结果保存到一个一维数组里。把 n 作为我们的数组下标,f(n) 作为值,也就是 arr[n] = f(n)。执行的时候如果某个位置已经被计算出来了就更新对应位置的数组值,例如 f(4) 算完了,就将其保存到arr[4]中,当后面再次要计算 f(4) 的时候,我们判断f(4)已经计算过,因此直接读取 f(4) 的值,不再递归计算。代码如下:// 我们实现假定 arr 数组已经初始化好的了。 public static int[] arr = new int[50]; public static int count_3 = 0; Arrays.fill(arr, -1); arr[0] = 1; int fibonacci(int n){ if (n == 2 || n == 1) { count_3++; arr[n] = n; return n; } if (arr[n] != -1) { count_3++; return arr[n]; } else { count_3++; arr[n] = fibonacci(n - 1) + fibonacci(n - 2); return arr[n]; } } 在上面代码里,在执行递归之前先查数组看是否被计算过,如果重复计算了,就直接读取,这就叫”记忆化搜索“,就这么简单。2. 路径连环炮要解释清楚DP,还要再结合一些实例来分析,本部分我们通过多个路径相关的问题来分析。路径问题本身就是大热门,同时其特点是易于画图,方便理解,能循序渐进展示DP的内涵。2.1. 第一炮:基本问题:统计路径总数LeetCode62:一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” ),问总共有多少条不同的路径?示例1: 输入:m = 3, n = 7 输出:28 示例2: 输入:m = 3, n = 2 输出:3 解释: 从左上角开始,总共有 3 条路径可以到达右下角。 1. 向右 -> 向下 -> 向下 2. 向下 -> 向下 -> 向右 3. 向下 -> 向右 -> 向下 本题是经典的递归问题,第一炮,我们来研究如何通过递归来解决此问题。如下图所示,从起点开始的每一个位置,要么向右,要么向下。每一种都将导致剩下的区间的减少了一行或者一列,形成两个不同的区间。而每个区间都可以继续以红点为起点继续上述操作,所以这就是一个递归的过程。图中红色表示起止点,绿色表示接下来要走的位置,灰色表示不能再走了。我们先从一个3x2的情况来分析:我们的目标是从起点到终点,因为只能向右或者向下,从图中可以可以看到:1.如果向右走,也就是图1的情况,后面是一个3x1的矩阵,此时起点下面的两个灰色位置就不会再访问了,只能从绿色位置一直向下走,只有一种路径。2.如果是向下走,我们可以看到原始起点右侧的就不能再访问了,而剩下的又是一个2X2的矩阵,也就是从图中绿色位置到红色位置,此时仍然可以选择向右或者向下,一共有两种路径。所以上面的情况加起来就是一共有3种。从上面我们可以看到,对于一个3X2的矩阵,总的路径数就是从起点开始再分别统计arr一个2X2的数组和一个1X3的数组,之后累加起来就行了。如果再复杂一点,是一个3X3的矩阵呢?我们还是直接看图:可以看到,一个3X3的矩阵下一步就变成了一个3X2或者2X3的矩阵,而总路径数,也是是两者各自的路径之和。因此,对于一个mxn的矩阵,求路径的方法search(m,n)就是:search(m-1,n)+search(m,n-1);递归的含义就是处理方法不变,但是问题的规模减少了,所以这里的代码就是:public class uniquePaths { public int uniquePaths (int m, int n) { return search(m,n); } public int search(int m,int n){ if(m==1 || n==1){ return 1; } return search(m-1,n)+search(m,n-1); } } int uniquePaths(int m, int n) { return search(m, n); } int search(int m, int n) { if (m == 1 || n == 1) { return 1; } return search(m - 1, n) + search(m, n - 1); } def uniquePaths(m, n): if m == 1 or n == 1: return 1 return uniquePaths(m-1, n) + uniquePaths(m, n-1) 上面这个过程,我们也可以用二叉树表示出来:例如对于3X3的矩阵,过程图就是:而总的路径数就是叶子节点数,在图中是6个,这与二叉树的递归遍历本质上是一样的。2.2. 第二炮:使用二维数组优化递归第二炮,我们来优化递归的问题,研究如何结合二维数组来实现记忆化搜索。从上面这个树也可以看到在递归的过程中存在重复计算的情况,例如{1,1}出现了两次,如果是一个NXN的空间,那{1,0}和{0,1}的后续计算也是一样的。从二维数组的角度,例如在位置(1,1)处,不管从(0,1)还是(1,0)到来,接下来都会产生2种走法,因此不必每次都重新遍历才得到结果。为此,我们可以采取一个二维数组来进行记忆化搜索,算好的就记录在数组中,也就是这样子:每个格子的数字表示从起点开始到达当前位置有几种方式,这样我们计算总路径的时候可以先查一下二维数组有没有记录,如果有记录就直接读,没有再计算,这样就可以大量避免重复计算,这就是记忆化搜索。根据上面的分析,我们可以得到两个规律:1.第一行和第一列都是1。2.其他格子的值是其左侧和上方格子之和。对于其他m,n的格子,该结论一样适用的,例如:比如图中的4,是有上面的1和左侧的3计算而来,15是上侧的5和左侧的10计算而来。如果用公式表示就是:我们可以直接写出如下代码:class Solution { public int uniquePaths(int m, int n) { int[][] res = new int[m][n]; res[0][0] = 1; for(int i = 0;i < m;i++){ for(int j = 0; j < n;j++){ if(i > 0 && j > 0){ res[i][j] = res[i - 1][j] + res[i][j - 1]; }else if(i > 0){ res[i][j] = res[i - 1][j]; }else if (j > 0){ res[i][j] = res[i][j - 1]; } } } return res[m - 1][n - 1]; } } def uniquePaths(self, m: int, n) : dp = [[1]*n] + [[1]+[0] * (n-1) for _ in range(m-1)] #print(dp) for i in range(1, m): for j in range(1, n): dp[i][j] = dp[i-1][j] + dp[i][j-1] return dp[-1][-1] int uniquePaths(int m, int n) { vector<vector<int>> f(m, vector<int>(n, 0)); f[0][0] = 1; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (i > 0 && j > 0) { f[i][j] = f[i - 1][j] + f[i][j - 1]; } else if (i > 0) { f[i][j] = f[i - 1][j]; } else if (j > 0) { f[i][j] = f[i][j - 1]; } } } return f[m - 1][n - 1]; } 2.3. 第三炮:滚动数组:用一维代替二维数组第三炮,我们通过滚动数组来优化此问题。上面的缓存空间使用的是二维数组,这个占空间太大了,能否进一步优化呢?我们再看一下上面的计算过程:在上图中除了第一行和第一列都是1外,每个位置都是其左侧和上访的格子之和,那我可以用一个大小为n的一维数组解决来:第一步,遍历数组,将一维数组所有元素赋值为1第二步,再次从头遍历数组,除了第一个,后面每个位置是其原始值和前一个位置之和,也就是这样:第三步:重复第二步:除了第一个,后面每个位置仍然是其原始值和前一个位置之和,也就是这样:继续循环,题目给的m是几就循环几次,要得到结果,输出最后一个位置的15就可以了。上面这几个一维数组拼接起来,是不是发现和上面的二维数组完全一样的?而这里我们使用了一个一维数组就解决了,这种反复更新数组的策略就是滚动数组.计算公式是:dp[j] = dp[j] + dp[j - 1]其实就是这么回事,代码如下:class Solution { public int uniquePaths(int m, int n) { int[] dp = new int[n]; Arrays.fill(dp,1); for(int i = 1;i < m; ++i){ for(int j = 1;j < n;++j){ dp[j] = dp[j - 1] + dp[j]; } } return dp[n - 1]; } } def uniquePaths(self, m, n) : cur = [1] * n for i in range(1, m): for j in range(1, n): cur[j] += cur[j-1] return cur[-1] int uniquePaths(int m, int n) { vector<int> dp(n); for (int i = 0; i < n; i++) { dp[i] = 1; } for (int i = 1; i < m; i++) { for (int j = 1; j < n; j++) { dp[j] += dp[j - 1]; } } return dp[n - 1]; } 这个题目涵盖了DP里的多个方面,比如重复子问题、记忆化搜索、滚动数组等等。这就是最简单的动态规划了,只不过我们这里的规划是dp[j] = dp[j] + dp[j - 1];不用进行复杂的比较和计算。这个问题非常重要,学好了对后面理解递归、动态规划等算法都有非常大的作用。2.4. 第四炮:题目拓展:最小路径和上面的题目还有两个重要问题体现的不明显:最优子结构,我们再结合一个例子来研究。LeetCode64.给定一个包含非负整数的 m x n 网格 grid ,请找出一条从左上角到右下角的路径,使得路径上的数字总和为最小。说明:每次只能向下或者向右移动一步。示例:输入:grid = [[1,3,1],[1,5,1],[4,2,1]] 输出:7 解释:因为路径 1→3→1→1→1 的总和最小。 这道题是在上面题目的基础上,增加了路径成本概念。由于题目限定了我们只能「往下」或者「往右」移动,因此我们按照当前位置可由哪些位置转移过来 进行分析:●当前位置只能通过「往下」移动而来,即有f[i][j] = f[i-1][j] + grid[i][j]●当前位置只能通过「往右」移动而来,即有 f[i][j] = f[i][j-1] + grid[i][j]●当前位置既能通过「往下」也能「往右」移动,即有f[i][j] = min(f[i][j-1],f[i-1][j]) + grid[i][j]二维数组的更新过程,我们可以图示一下:我们现在可以引入另外一个概念状态:所谓状态就是下面表格更新到最后的二维数组,而通过前面格子计算后面格子的公式就叫状态转移方程。如果用数学表达就是:定义 f[i][j]为从 (0,0) 开始到达位置 (i,j)的最小总和。那么 f[m-1][n-1]就是我们最终的答案,f[0][0]=grid[0][0]是一个显而易见的起始状态。如果令f[i][j]表示从f(0,0)走到格子(i,j)的路径的最小数字总和,则使用表达式来表示上面的关系就是:所谓的确定状态转移方程就是要找递推关系,通常我们会从分析首尾两端的变化规律来入手,后面题目我们会继续分析。本题的代码实现就是:class Solution { public int minPathSum(int[][] grid) { int m = grid.length ,n = grid[0].length; int [][] res = new int[m][n]; for(int i = 0;i < m;i++){ for(int j = 0 ; j < n ; j++){ if(i == 0 && j == 0){ res[i][j] = grid[i][j]; continue; } int top = i - 1 >= 0 ? res[i - 1][j] + grid[i][j] : Integer.MAX_VALUE; int left = j - 1 >= 0? res[i][j - 1] + grid[i][j]:Integer.MAX_VALUE; res [i][j] = Math.min(top,left); } } return res[m - 1][n - 1]; } } def minPathSum(self, grid) : for i in range(len(grid)): for j in range(len(grid[0])): if i == j == 0: continue elif i == 0: grid[i][j] = grid[i][j - 1] + grid[i][j] elif j == 0: grid[i][j] = grid[i - 1][j] + grid[i][j] else: grid[i][j] = min(grid[i - 1][j], grid[i][j - 1]) + grid[i][j] return grid[-1][-1] int minPathSum(vector<vector<int>>& grid) { int m = grid.size(); int n = grid[0].size(); vector<vector<int>> f(m, vector<int>(n, 0)); int result = 0; for (int i = 0; i < m; i++) { for (int j = 0; j < n; j++) { if (i == 0 && j == 0) { f[i][j] = grid[i][j]; } else { int top = i - 1 >= 0 ? f[i - 1][j] + grid[i][j] : INT_MAX; int left = j - 1 >= 0 ? f[i][j - 1] + grid[i][j] : INT_MAX; f[i][j] = min(top, left); } } } for (int i = m - 2; i >= 0; i--) { for (int j = n - 2; j >= 0; j--) { if (f[i][j] != INT_MAX) { result += f[i][j]; } } } return result; } 2.5. 第五炮:题目拓展:三角形最小路径和本题是上面一条的简单变型,LeetCode120.给定一个三角形 triangle ,找出自顶向下的最小路径和。每一步只能移动到下一行中相邻的结点上。相邻的结点 在这里指的是 下标 与 上一层结点下标 相同或者等于 上一层结点下标 + 1 的两个结点。也就是说,如果正位于当前行的下标 i ,那么下一步可以移动到下一行的下标 i 或 i + 1 。示例1: 输入:triangle = [[2],[3,4],[6,5,7],[4,1,8,3]] 输出:11 解释:如下面简图所示: 2 3 4 6 5 7 4 1 8 3 自顶向下的最小路径和为 11(即2 + 3 + 5 + 1 = 11)。 在看解析之前,我们先看一下这个题是什么意思。本题就是1.2中最小路径和的简单变换,为了方便处理,我们可以先处理成对角线结构。如果上面的图你能想明白,就可以直接写代码了 , 但是我们想通过本题我们来理解另外一个概念”无后效性“,确定一道题目是否可以用 DP 解决,要从有无后效性进行分析。所谓无后效性就是我们转移某个状态需要用到某个值,但是并不关心该值是如何而来的。更加装*的表述是:当前某个状态确定后,之后的状态转移与之前的决策无关,因此我们可以确定使用 DP 进行求解。在本题中,既然是从上到下的路径,那么最后一个点必然落在最后一行。对于最后一行的某个位置的值,根据题意只能从上一行的某一个位置或者某两个位置之一转移而来。同时,我们只关注前一位的累加值是多少,特别是最小累加值是多少,而不关心这个累加值结果是由什么路径而来的,这就满足了「无后效性」的定义。接下来的问题是该如何确定「状态定义」呢?这就是要找递推关系,通常会从首尾两端的变化规律来入手。对于本题,我们结合两者可以猜一个 DP 状态:f[i][j]代表到达某个点的最小路径和,那么 min(f[n-1][i])(最后一行的每列的路径和的最小值)就是答案。通过观察可以发现以下性质(令 i 为行坐标,j 为列坐标):每一行 i具有 i+1个数字(i从0开始)只要不是第一列(j!=0)位置上的数,都能通过「左上方」转移过来只要不是每行最后一列(j!=i)位置上的数,都能通过「上方」转移而来该过程可以推广并覆盖所有位置的,至此,状态转移方程也能不重不漏的枚举到每一条路径,因此这个 DP 状态定义可用,代码:class Solution { public int minimumTotal(List<List<Integer>> triangle) { int n = triangle.size(); int res = Integer.MAX_VALUE; int[][] f = new int[n][n]; f[0][0] = triangle.get(0).get(0); for(int i = 1; i < n; i++){ for(int j = 0; j < i + 1;j++){ int val = triangle.get(i).get(j); f[i][j] = Integer.MAX_VALUE; if(j != 0){ f[i][j] = Math.min(f[i][j],f[i - 1][j - 1] + val); } if(j != i){ f[i][j] = Math.min(f[i][j],f[i - 1][j] + val); } } } for(int i = 0; i < n;i ++){ res = Math.min(res,f[n - 1][i]); } return res; } } def minimumTotal(self, triangle) : n = len(triangle) f = [[0] * n for _ in range(n)] f[0][0] = triangle[0][0] for i in range(1, n): f[i][0] = f[i - 1][0] + triangle[i][0] for j in range(1, i): f[i][j] = min(f[i - 1][j - 1], f[i - 1][j]) + triangle[i][j] f[i][i] = f[i - 1][i - 1] + triangle[i][i] return min(f[n - 1]) int minimumTotal(vector<vector<int>>& tri) { int n = tri.size(); int ans = INT_MAX; int[][] f = new int[n][n]; f[0][0] = tri[0][0]; for (int i = 1; i < n; i++) { for (int j = 0; j < i + 1; j++) { int val = tri[i][j]; f[i][j] = INT_MAX; if (j != 0) { f[i][j] = min(f[i][j], f[i - 1][j - 1] + val); } if (j != i) { f[i][j] = min(f[i][j], f[i - 1][j] + val); } } } for (int i = 0; i < n; i++) { ans = min(ans, f[n - 1][i]); } return ans; } 与本题非常类似的题目还有LeetCode931题”下降路径最小和“,以及LeetCode1289题”下降路径最小和II“,感兴趣的同学可以研究一下。3. 理解动态规划经过上面这么多例子,我们终于可以完整的分析什么是动态规划了。首先,DP能解决哪类问题?直观上,DP一般是让找最值的,例如最长公共子序列等等,但是最关键的是DP问题的子问题不是相互独立的,如果递归分解直接分解会导致重复计算指数级增长(想想前面的热身题)。而DP最大的价值是为了消除冗余,加速计算。其次,严格来说,DP要满足「有无后效性」,也就是能进行“狗熊掰棒子,只管当下,不管之前”,对于某个状态,我们可以只关注状态的值,而不需要关注状态是如何转移过来的话,满足该要求的可以考虑使用 DP 解决。 为了理解这一点,我们来看一下这个问题:上面路径的问题,从左上角走到右下角,我们设置两个问题,请问哪个是动态规划问题:A 求有多少种走法 B 输出所有的走法我们说动态规划是无后向型的,只记录数量,不管怎么来的,因此A是DP问题,而B不能用DP。如果你理解上一章回溯的原理的话,就知道回溯可以记录所有的路径,因此B是个回溯的问题。回溯:能解决,但是解决效率不高DP:计算效率高,但是不能找到满足要求的路径。因此区分动态规划和回溯最重要的一条是:动态规划只关心当前结果是什么,怎么来的就不管了,所以动态规划无法获得完整的路径,这与回溯不一样,回溯能够获得一条甚至所有满足要求的完整路径。DP的基本思想是将待求解问题分解成若干个子问题,先求子问题,再从这些子问题中得到原问题的解。既然要找“最”值那必然要做的就是穷举来找所有的可能,然后选择“最”的那个,这就是为什么在DP代码中大量判断逻辑都会被套上min()或者max(),而这也是导致DP看起来很难的原因之一。接下来,既然穷举,那为啥还要有DP的概念?这是因为穷举过程中存在大量重复计算,效率低下,所以我们要使用记忆化搜索等方式来消除不必要的计算,所谓的记忆化搜索就是将已经计算好的结果先存在数组里,后面直接读就不再重复计算了。接下来,既然记忆化能解决问题,为啥DP这么难,因为DP问题一定具备“最优子结构”,这样才能让记忆时得到准确的结果。至于什么是最优子结构,我们还是要等后面具体问题再看。接下来,有了最优子结构之后,我们还要写出正确的“状态转移方程”,才能正确的穷举。也就是递归关系,但是在DP里,大部分递推都可以通过数组实现,因此看待的代码结构一般是这样的for循环,这就是DP代码的基本模板:、// 初始化base case,也就是刚开始的几种场景 ,有几种枚举几种 dp[0][0][...]=base case // 进行状态转移 for 状态1 状态1的所有取值 for 状态2 in 状态2的所有取值 for .... dp[状态1][状态2][...]=求最值Max(选择1,选择2,...) } 我们一般写的动态规划只有一两层,不会太深,因此你会发现动态规划的代码特别简洁。动态规划的常见类型也比较多,从形式上看,有坐标型、序列型、划分型、区间型、背包型和博弈型等等。不过没必要刻意研究这些类型到底什么意思,因为解题基本思路是一致的。一般说来,动态规划题目有以下三种基本的类型:1.计数有关,例如求有多少种方式走到右下角,有多少种方式选出K个数使得*等等,而不关心具体路径是什么。 2.求最大最小值,最多最少等等,例如最大数字和、最长上升子序列长度、最长公共子序列、最长回文序列等等。 3.求存在性,例如取石子游戏,先手是否必胜;能不能选出K个数使得等等。但是不管哪一种解决问题的模板也是类似的,都是:第一步:确定状态和子问题,也就是枚举出某个位置所有的可能性,对于DP,大部分题目分析最后一步更容易一些,得到递推关系,同时将问题转换为子问题。第二步:确定状态转移方程,也就是数组要存储什么内容。很多时候状态确定之后,状态转移方程也就确定了,因此我们也可以将第一二步作为一个步骤。第三步:确定初始条件和边界情况,注意细心,尽力考虑周全。第四步:按照从小到大的顺序计算:f[0]、f[1]、f[2]...虽然我们计算是从f[0]开始,但是对于大部分的DP问题,先分析最后一个往往更有利于寻找状态表达式,因此我们后面的问题基本都是从右向左找递归,从左向右来计算这个也是我们分析DP问题的核心模板。上面的模板,用大白话就是:我们要自始至终,都要在大脑里装一个数组,要看这个数组每个元素表示的含义是什么,要看每个数组位置是根据谁来算的,然后就是从小到大挨着将数组填满,最后看哪个位置是我们想要的结果。再详细一点的解释:我们要自始至终,都要在大脑里装一个数组(可能是一维,也可能是二维),要看这个数组每个元素表示的含义是什么(也就是状态),要看每个数组位置是根据谁来算的(状态转移方程),然后就是从小到大挨着将数组填满(从小到大计算,实现记忆化搜索),最后看哪个位置是我们想要的结果。
0
0
0
浏览量85
时光小少年

第 17 关 | 经典刷题思想之贪心:跳跃游戏

跳跃游戏问题考察频率非常高,我们必须弄清楚,这里我们专门来研究一下关卡名理解与贪心有关的高频问题我会了✔️内容1.  理解跳跃游戏问题如何判断是否能到达终点✔️2.  如果能到终点,如何确定最少跳跃次数✔️1. 跳跃游戏leetCode 55 给定一个非负整数数组,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度,判断你是否能够到达最后一个位置。示例1: 输入: [2,3,1,1,4] 输出: true 解释: 从位置 0 到 1 跳 1 步, 然后跳 3 步到达最后一个位置。 示例2: 输入: [3,2,1,0,4] 输出: false 解释: 无论怎样,你总会到达索引为 3 的位置。但该位置的最大跳跃长度是 0 ,所以你永远不可能到达最后一个位置。 `如果当前位置元素如果是3,我究竟是跳几步呢,一步,两步,还是三步?这里的关键是判断能否到达终点,不用每一步跳跃到哪个位置,而是尽可能的跳跃到最远的位置,看最多能覆盖到哪里,只要不断更新能覆盖的距离,最后能覆盖到末尾就行了。例如上面的第一个例子,3能覆盖的范围是后面的{2,1,0},2接下来能覆盖后面的{1,0},而1只能覆盖到{0},所以无法到达4。而第二组序列,2能覆盖{3,1},3可以覆盖后面的{1,1,4},已经找到一条路了。1只能到下一个1,下一个1能到4,所以这里有{2,1,1,4}和{2,3,1,1,4}两种走法,加起来有3种跳法。我们可以定义一个cover表示最远能够到达的方位,也就是i每次移动只能在其cover的范围内移动,每移动一次,cover得到该元素数值(新的覆盖范围)的补充,让i继续移动下去。而cover每次按照下面的结果判断。如果cover大于等于了终点下标,直接return true就可以了:cover= max(该元素数值补充后的范围, cover本身范围)针对上图的两个序列再解释一下:1.在第二个图中,第一个元素,nums[0]=2,此时conver=2能覆盖到{3,1}两个元素。2.继续,第二个元素nums[1]=3,此时能继续覆盖的范围就是1+3,能覆盖{1,1,4}三个位置。此时cover=2,而”该元素数值补充后的范围“是1+3=4,所以新的conver=max{4,2},此时就是cover>=nums.length-1。其他情况都可以使用类似的方式来判断 ,所以代码就是:class Solution { public boolean canJump(int[] nums) { if(nums.length == 1){ return true; } int cover = 0; for(int i = 0; i <= cover;i++){ cover = Math.max(cover,i+nums[i]); if(cover >= nums.length - 1){ return true; } } return false; } } bool canJump(vector<int>& nums) { int n = nums.size(); int rightmost = 0; for (int i = 0; i < n; ++i) { if (i <= rightmost) { rightmost = max(rightmost, i + nums[i]); if (rightmost >= n - 1) { return true; } } } return false; } def canJump(self, nums) : n, rightmost = len(nums), 0 for i in range(n): if i <= rightmost: rightmost = max(rightmost, i + nums[i]) if rightmost >= n - 1: return True return False 这道题目的难点是要想到覆盖范围,而不用拘泥于每次究竟跳几步,覆盖范围是可以逐步扩展的,只有能覆盖就一定是可以跳过来的,不用管是怎么跳的。2. 最短跳跃游戏在上题再进一步,假设一定能到达末尾,然后让你求最少到达的步数该怎么办呢?这就是LeetCode45上面的例子。可以看到,有三种走法{2,3,4}、{2,1,1,4}和{2,3,1,1,4},那这时候该怎么办呢?具体该怎么实现呢?网上有很多解释,代码也基本雷同,而且也很明显是将上一题的代码修改了一下,但是难在不好理解,我现在给一个比较好理解的方式:贪心+双指针。我们重新观察一下结构图,为了便于分析,我们修改一下元素序列,我们需要四个变量:left用来一步步遍历数组steps用来记录到达当前位置的最少步数right表示当前步数下能够覆盖到的最大范围我们还需要一个临时变量conver,假如left到达right时才更新right在这个图中,开始的元素是 2,如果只走一步,step=1,可跳的范围是{3,1}。也就是如果只走一步,最远只能到达1,此时conver=nums[0]=2,因此我们用right=nums[2]来保存这个位置,这表示的就是走一步最远只能到nums[2]。接下来,我们必须再走一步,step=2,如下图,此时可选元素是{3,1}, 3能让我们到达的距离是left+nums[left]=1+3=4,而1能让我们到达的位置是left+nums[left]=2+1=3,而所以我们获得最远覆盖距离conver=4 。然后用left和right将step=2的范围标记一下:此时还没有到终点,我们要继续走,在这里我们可选择的元素是{2,4},如果选择2,则可以到达left+nums[left]=3+2=5,如果选择4则是left+nums[left]=4+4=8,已经超越边界了,所以此时一定将末尾覆盖了。这样我们就知道最少需要走3次。这个过程怎么用代码表示呢?看代码:public int jump(int[] nums) { int right = 0; int maxPosition = 0; int steps = 0; for (int left = 0; left < nums.length - 1; left++) { //找能跳的最远的 maxPosition = Math.max(maxPosition, nums[left] + left); if (left == right) { //遇到边界,就更新边界,并且步数加一 right = maxPosition; steps++; } //right指针到达末尾了。 if (right >= nums.length - 1) { return steps; } } return steps; } def jump(self, nums): n = len(nums) maxPos, end, step = 0, 0, 0 for i in range(n - 1): if maxPos >= i: maxPos = max(maxPos, i + nums[i]) if i == end: end = maxPos step += 1 return step int jump(vector<int>& nums) { int maxPos = 0, n = nums.size(), end = 0, step = 0; for (int i = 0; i < n - 1; ++i) { if (maxPos >= i) { maxPos = max(maxPos, i + nums[i]); if (i == end) { end = maxPos; ++step; } } } return step; }
0
0
0
浏览量679
时光小少年

第 14 关 | 刷题模板之堆结构:3. 黄金挑战——数据流的中位数

1. 数据流中中位数的问题来看一个有些难度的题目,LeetCode295,中位数是有序列表中间的数。如果列表长度是偶数,中位数则是中间两个数的平均值。例如:[2,3,4] 的中位数是 3[2,3] 的中位数是 (2 + 3) / 2 = 2.5设计一个支持以下两种操作的数据结构:void addNum(int num) - 从数据流中添加一个整数到数据结构中。double findMedian() - 返回目前所有元素的中位数。示例: addNum(1) addNum(2) findMedian() -> 1.5 addNum(3) findMedian() -> 2 进阶问题:1如果数据流中所有整数都在 0 到 100 范围内,你将如何优化你的算法?2如果数据流中 99% 的整数都在 0 到 100 范围内,你将如何优化你的算法?分析这是一道比较难的题目了,如果没专门学过,很难在面试时想到。中位数的题,我们一般都可以用 大顶堆 + 小顶堆来求解,下面我们通过直观的例子解释一下怎么做。小顶堆(minHeap):存储所有元素中较大的一半,堆顶存储的是其中最小的数。大顶堆(maxHeap):存储所有元素中较小的一半,堆顶存储的是其中最大的数。相当于,把所有元素分成了大和小两半,而我们计算中位数,只需要大的那半的最小值和小的那半的最大值即可。比如,我们依次添加 [1, 2, 3, 4, 5],砍成两半之后为 [1, 2] 和 [3, 4, 5],我们只要能快速的找到 2 和 3 即可。下面看看使用两个堆它们是怎么变化的:添加 1,进入到 minHeap 中,中位数为 1:添加 2,它比 minHeap 堆顶元素 1 大,进入minHeap,同时,minHeap 中元素超过了所有元素总和的一半,所以,要平衡一下,分一个给 maxHeap,中位数为 (1 + 2) / 2.0 = 1.5 :添加 3, 它比 minHeap 堆顶元素 2 大,进入 minHeap,中位数为 2: 2添加 4,它比 minHeap 堆顶元素 2 大,进入minHeap,同时,minHeap 中元素超过了所有元素总和的一半,所以,要平衡一下,分一个给 maxHeap,中位数为 (2 + 3) / 2.0 = 2.5:添加 5,它比 minHeap 堆顶元素 3 大,进入minHeap,中位数为 3:Java中的堆(即优先级队列)是使用完全二叉树实现的,我们这里的图也是以完全二叉树为例。理解了上述的过程,看代码就比较简单了,但是实现起来还是挺麻烦的,所以我们理解一下就好了。class MedianFinder { // 小顶堆存储的是比较大的元素,堆顶是其中的最小值 PriorityQueue<Integer> minHeap; // 大顶堆存储的是比较小的元素,堆顶是其中的最大值 PriorityQueue<Integer> maxHeap; /** initialize your data structure here. */ public MedianFinder() { this.minHeap = new PriorityQueue<>(); this.maxHeap = new PriorityQueue<>((a, b) -> b - a); } public void addNum(int num) { // 小顶堆存储的是比较大的元素,num比较大元素中最小的还大,所以,进入minHeap if (minHeap.isEmpty() || num > minHeap.peek()) { minHeap.offer(num); // 如果minHeap比maxHeap多2个元素,就平衡一下 if (minHeap.size() - maxHeap.size() > 1) { maxHeap.offer(minHeap.poll()); } } else { maxHeap.offer(num); // 这样可以保证多的那个元素肯定在minHeap if (maxHeap.size() - minHeap.size() > 0) { minHeap.offer(maxHeap.poll()); } } } public double findMedian() { if( minHeap.size() > maxHeap.size() ){ return minHeap.peek(); }else if(minHeap.size() < maxHeap.size() ) { return maxHeap.peek(); }else{ return ((minHeap.peek()+maxHeap.peek())/2.0; } } } 因为C++和Python里没找到提供的堆结构,需要自己构造,因此我们暂时不提供,感兴趣的同学,可以自行研究一下。
0
0
0
浏览量432
时光小少年

第 11 关 | 刷题模板之位运算: 3.黄金挑战——位运算如何实现存储

1. 用 4 KB 内存寻找重复元素题目要求:给定一个数组,包含从1到N的整数,N最大为32000,数组可能还有重复值,且N的取值不定,若只有4KB的内存可用,该如何打印数组中所有重复元素。分析:本身是一道海量数据问题的热身题,如果去掉“只有4KB”的要求,我们可以先创建一个大小为N的数组,然后将这些数据放进来,但是这里数组最大为32KB,而题目有4KB的内存限制,我们就必须先确定该如何存放这个数组。如果只有4KB的空间,那么只能寻址842^10个比特,这个值比32000要大的,因此我们可以创建32000比特的位向量(比特数组),其中一个比特位置就代表一个整数。利用这个位向量,就可以遍历访问整个数组。如果发现数组元素是v,那么就将位置为v的设置为1,碰到重复元素,就输出一下。下面的代码仅供参考,你能看懂就行,不用自己会写,面试的时候也不可能让你构造一个4k的数组来测试public class FindDuplicates32000{ public void checkDuplicates(int[] array) { BitSet bs = new BitSet(320000); for (int i = 0; i < array.length; i++) { int num = array[i]; int num0 = num - 1; if (bs.get(num0)) { System.out.println(num); } else { bs.set(num0); } } } class BitSet { int[] bitset; public BitSet(int size) { this.bitset = new int[size >> 5]; } boolean get(int pos) { int wordNumber = (pos >> 5);//除以32 int bitNumber = (pos & 0x1F);//取模32 return (bitset[wordNumber] & (1 << bitNumber)) != 0; } void set(int pos) { int wordNumber = (pos >> 5);//除以32 int bitNumber = (pos & 0x1F);//取模32 bitset[wordNumber] |= 1 << bitNumber; } } }
0
0
0
浏览量1757
时光小少年

第 2 关 | 两天写了三次的链表反转:2.白银挑战——链表反转的拓展问题

1. 指定区间反转1.1. 头插法方法一的缺点是:如果 left 和 right 的区域很大,恰好是链表的头节点和尾节点时,找到 left 和 right 需要遍历一次,反转它们之间的链表还需要遍历一次,虽然总的时间复杂度为 O(N),但遍历了链表 2次,可不可以只遍历一次呢?答案是可以的。我们依然画图进行说明,我们仍然以方法一的序列为例进行说明。反转的整体思想是,在需要反转的区间里,每遍历到一个节点,让这个新节点来到反转部分的起始位置。下面的图展示了整个流程。这个过程就是前面的带虚拟结点的插入操作,每走一步都要考虑各种指针怎么指,既要将结点摘下来接到对应的位置上,还要保证后续结点能够找到,请读者务必画图看一看,想一想到底该怎么调整。代码如下:/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode reverseBetween(ListNode head, int left, int right) { ListNode dummyNode = new ListNode(-1); dummyNode.next = head; ListNode pre = dummyNode; for(int i = 0; i< left - 1;i++){ pre = pre.next; } ListNode cur = pre.next; ListNode next; for(int i = 0; i < right - left;i++){ next = cur.next; cur.next = next.next; next.next = pre.next; pre.next = next; } return dummyNode.next; } } def reverseBetween(self, head, left, right) : # 设置 dummyNode 是这一类问题的一般做法 dummy_node = ListNode(-1) dummy_node.next = head pre = dummy_node for _ in range(left - 1): pre = pre.next cur = pre.next for _ in range(right - left): next = cur.next cur.next = next.next next.next = pre.next pre.next = next return dummy_node.next struct ListNode* reverseBetween(struct ListNode* head, int left, int right){ struct ListNode* dummyNode = (struct ListNode*)malloc(sizeof(struct ListNode)); dummyNode->next = head; struct ListNode* pre = dummyNode; for (int i = 0; i < left - 1; i++) { pre = pre->next; } struct ListNode* cur = pre->next; struct ListNode* next; for (int i = 0; i < right - left; i++) { next = cur->next; cur->next = next->next; next->next = pre->next; pre->next = next; } struct ListNode* newHead = dummyNode->next; free(dummyNode); return newHead; } 1.2. 穿针引线法这种方式能够复用我们前面讲的链表反转方法,但是实现难度仍然比较高一些。我们以反转下图中蓝色区域的链表反转为例:我们可以这么做:先确定好需要反转的部分,也就是下图的 left 到 right 之间,然后再将三段链表拼接起来。这种方式类似裁缝一样,找准位置减下来,再缝回去。这样问题就变成了如何标记下图四个位置,以及如何反转left到right之间的链表。算法步骤:●第 1 步:先将待反转的区域反转;●第 2 步:把 pre 的 next 指针指向反转以后的链表头节点,把反转以后的链表的尾节点的 next 指针指向 succ。编码细节我们直接看下方代码。思路想明白以后,编码不是一件很难的事情。这里要提醒大家的是,链接什么时候切断,什么时候补上去,先后顺序一定要想清楚,如果想不清楚,可以在纸上模拟,思路清晰。/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode reverseBetween(ListNode head, int left, int right) { // 因为头节点有可能发生变化,使用虚拟头节点可以避免复杂的分类讨论 ListNode dummyNode = new ListNode(-1); dummyNode.next = head; ListNode pre = dummyNode; // 第 1 步:从虚拟头节点走 left - 1 步,来到 left 节点的前一个节点 // 建议写在 for 循环里,语义清晰 for (int i = 0; i < left - 1; i++) { pre = pre.next; } // 第 2 步:从 pre 再走 right - left + 1 步,来到 right 节点 ListNode rightNode = pre; for (int i = 0; i < right - left + 1; i++) { rightNode = rightNode.next; } // 第 3 步:切出一个子链表 ListNode leftNode = pre.next; ListNode succ = rightNode.next; // 思考一下,如果这里不设置next为null会怎么样 rightNode.next = null; // 第 4 步:同第 206 题,反转链表的子区间 reverseLinkedList(leftNode); // 第 5 步:接回到原来的链表中 //想一下,这里为什么可以用rightNode pre.next = rightNode; leftNode.next = succ; return dummyNode.next; } private void reverseLinkedList(ListNode head) { // 也可以使用递归反转一个链表 ListNode pre = null; ListNode cur = head; while (cur != null) { ListNode next = cur.next; cur.next = pre; pre = cur; cur = next; } } } class Solution(object): # 完成链表反转的辅助方法 def reverse_list(self,head): # 也可以使用递归反转一个链表 pre = None cur = head while cur: next = cur.next cur.next = pre pre = cur cur = next def reverseBetween(self, head, left, right) : # 因为头节点有可能发生变化,使用虚拟头节点可以避免复杂的分类讨论 dummy_node = ListNode(-1) dummy_node.next = head pre = dummy_node # 第 1 步:从虚拟头节点走 left - 1 步,来到 left 节点的前一个节点 # 建议写在 for 循环里,语义清晰 for _ in range(left - 1): pre = pre.next # 第 2 步:从 pre 再走 right - left + 1 步,来到 right 节点 right_node = pre for _ in range(right - left + 1): right_node = right_node.next # 第 3 步:切断出一个子链表(截取链表) left_node = pre.next curr = right_node.next # 注意:切断链接 pre.next = None right_node.next = None # 第 4 步:同第 206 题,反转链表的子区间 self.reverse_list(left_node) # 第 5 步:接回到原来的链表中 pre.next = right_node left_node.next = curr return dummy_node.next 2. 两两交换链表中的节点这是一道非常重要的问题,读者务必理解清楚。LeetCode24.给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。如果要解决该问题,将上面小节的K换成2不就是这个题吗?道理确实如此,但是如果K为2的时候,可以不需要像K个一样需要先遍历找到区间的两端,而是直接取前后两个就行了,因此基于相邻结点的特性重新设计和实现就行,不需要上面这么复杂的操作。如果原始顺序是 dummy -> node1 -> node2,交换后面两个节点关系要变成 dummy -> node2 -> node1,事实上我们只要多执行一次next就可以拿到后面的元素,也就是类似node2 = temp.next.next这样的操作。两两交换链表中的节点之后,新的链表的头节点是 dummyHead.next,返回新的链表的头节点即可。指针的调整可以参考如下图示:完整代码是:public ListNode swapPairs(ListNode head) { ListNode dummyHead = new ListNode(0); dummyHead.next = head; ListNode temp = dummyHead; while (temp.next != null && temp.next.next != null) { ListNode node1 = temp.next; ListNode node2 = temp.next.next; temp.next = node2; node1.next = node2.next; node2.next = node1; temp = node1; } return dummyHead.next; } class SwapPairs: def swapPairs(self, head): dummyHead = ListNode(0) dummyHead.next = head temp = dummyHead while temp.next and temp.next.next: node1 = temp.next node2 = temp.next.next temp.next = node2 node1.next = node2.next node2.next = node1 temp = node1 return dummyHead.next struct ListNode* swapPairs(struct ListNode* head) { struct ListNode* dummyHead = (struct ListNode*)malloc(sizeof(struct ListNode)); dummyHead->next = head; struct ListNode* temp = dummyHead; while (temp->next != NULL && temp->next->next != NULL) { struct ListNode* node1 = temp->next; struct ListNode* node2 = temp->next->next; temp->next = node2; node1->next = node2->next; node2->next = node1; temp = node1; } struct ListNode* newHead = dummyHead->next; free(dummyHead); return newHead; } 3. 单链表加 1LeetCode369.用一个非空单链表来表示一个非负整数,然后将这个整数加一。你可以假设这个整数除了 0 本身,没有任何前导的 0。这个整数的各个数位按照 高位在链表头部、低位在链表尾部 的顺序排列。示例: 输入: [1,2,3] 输出: [1,2,4] 我们先看一下加法的计算过程:计算是从低位开始的,而链表是从高位开始的,所以要处理就必须反转过来,此时可以使用栈,也可以使用链表反转来实现。基于栈实现的思路不算复杂,先把题目给出的链表遍历放到栈中,然后从栈中弹出栈顶数字 digit,加的时候再考虑一下进位的情况就ok了,加完之后根据是否大于0决定视为下一次要进位 。public ListNode plusOne(ListNode head) { Stack<Integer> st = new Stack(); while (head != null) { st.push(head.val); head = head.next; } int carry = 0; ListNode dummy = new ListNode(0); int adder = 1; while (!st.empty() || carry > 0) { int digit = st.empty() ? 0 : st.pop(); int sum = digit + adder + carry; carry = sum >= 10 ? 1 : 0; sum = sum >= 10 ? sum - 10 : sum; ListNode cur = new ListNode(sum); cur.next = dummy.next; dummy.next = cur; adder = 0; } return dummy.next; } 上面代码,我们简单解释一下,carry的功能是记录进位的,相对好理解 。那这里的adder是做什么的很多人会感到困惑,这个主要表示就是需要加的数1,也就是为了满足单链表加1的功能,那这里能否直接使用1 ,而不再定义变量呢?也就是将上面的while循环改成这样子: while (!st.empty() || carry > 0) { int digit = st.empty() ? 0 : st.pop(); int sum = digit + 1 + carry; carry = sum >= 10 ? 1 : 0; sum = sum >= 10 ? sum - 10 : sum; ListNode cur = new ListNode(sum); cur.next = dummy.next; dummy.next = cur; } 很遗憾,这样不行的,否则的话 ,会将我们每个位置都加1,例如,如果原始单链表是{7,8}这里就会将其变成{8,9},而不是我们要的{7,9},导致这样的原因是循环处理链表每个结点元素的时候sum = digit + 1 + carry这一行会将每个位置都多加了一个1,所以我们要使用变量adder,只有第一次是加了1,之后该变量变成0了,就不会影响我们后续计算。class PlusOne: def plusOne(self, head): # ans head ans = ListNode(0) ans.next = head not_nine = ans # find the rightmost not-nine digit while head: if head.val != 9: not_nine = head head = head.next not_nine.val += 1 not_nine = not_nine.next while not_nine: not_nine.val = 0 not_nine = not_nine.next return ans if ans.val else ans.next 基于链表反转实现 如果这里不使用栈,使用链表反转来实现该怎么做呢?很显然,我们先将原始链表反转,这方面完成加1和进位等处理,完成之后再次反转。4. 链表加法相加相链表是基于链表构造的一种特殊题,反转只是其中的一部分。这个题还存在进位等的问题,因此看似简单,但是手写成功并不容易。LeetCode445题,给你两个非空链表来代表两个非负整数。数字最高位位于链表开始位置。它们的每个节点只存储一位数字。将这两数相加会返回一个新的链表。你可以假设除了数字 0 之外,这两个数字都不会以零开头。示例:示例: 输入:(6 -> 1 -> 7) + (2 -> 9 -> 5),即617 + 295 输出:9 -> 1 -> 2,即912 这个题目的难点在于存放是从最高位向最低位开始的,但是因为低位会产生进位的问题,计算的时候必须从最低位开始。所以我们必须想办法将链表节点的元素反转过来。怎么反转呢?栈和链表反转都可以,两种方式我们都看一下。(1) 使用栈实现思路是先将两个链表的元素分别压栈,然后再一起出栈,将两个结果分别计算。之后对计算结果取模,模数保存到新的链表中,进位保存到下一轮。完成之后再进行一次反转就行了。我们知道在链表插入有头插法和尾插法两种。头插法就是每次都将新的结点插入到head之前。而尾插法就是将新结点都插入到链表的表尾。两者的区别是尾插法的顺序与原始链表是一致的,而头插法与原始链表是逆序的,所以上面最后一步如果不想进行反转,可以将新结点以头插法。public static ListNode addInListByStack(ListNode head1, ListNode head2) { Stack<ListNode> st1 = new Stack<ListNode>(); Stack<ListNode> st2 = new Stack<ListNode>(); while (head1 != null) { st1.push(head1); head1 = head1.next; } while (head2 != null) { st2.push(head2); head2 = head2.next; } ListNode newHead = new ListNode(-1); int carry = 0; //这里设置carry!=0,是因为当st1,st2都遍历完时,如果carry=0,就不需要进入循环了 while (!st1.empty() || !st2.empty() || carry != 0) { ListNode a = new ListNode(0); ListNode b = new ListNode(0); if (!st1.empty()) { a = st1.pop(); } if (!st2.empty()) { b = st2.pop(); } //每次的和应该是对应位相加再加上进位 int get_sum = a.val + b.val + carry; //对累加的结果取余 int ans = get_sum % 10; //如果大于0,就进位 carry = get_sum / 10; ListNode cur = new ListNode(ans); cur.next = newHead.next; //每次把最新得到的节点更新到neHead.next中 newHead.next = cur; } return newHead.next; } class AddTwoNumbers: # 使用栈实现 def addTwoNumbers(self, l1, l2): st1 = [] st2 = [] while l1: st1.append(l1.val) l1 = l1.next while l2: st2.append(l2.val) l2 = l2.next carry = 0 dummy = ListNode(0) while st1 or st2 or carry: adder1 = st1.pop() if st1 else 0 adder2 = st2.pop() if st2 else 0 sum = adder1 + adder2 + carry carry = 1 if sum >= 10 else 0 sum = sum - 10 if sum >= 10 else sum cur = ListNode(sum) cur.next = dummy.next dummy.next = cur return dummy.next struct ListNode* addTwoNumbers(struct ListNode* l1, struct ListNode* l2){ struct ListNode* st1 = NULL; struct ListNode* st2 = NULL; struct ListNode* curr1 = l1; struct ListNode* curr2 = l2; while (curr1 != NULL) { struct ListNode* new_node = (struct ListNode*)malloc(sizeof(struct ListNode)); new_node->val = curr1->val; new_node->next = st1; st1 = new_node; curr1 = curr1->next; } while (curr2 != NULL) { struct ListNode* new_node = (struct ListNode*)malloc(sizeof(struct ListNode)); new_node->val = curr2->val; new_node->next = st2; st2 = new_node; curr2 = curr2->next; } struct ListNode* new_head = (struct ListNode*)malloc(sizeof(struct ListNode)); new_head->val = -1; new_head->next = NULL; int carry = 0; while (st1 != NULL || st2 != NULL || carry != 0) { struct ListNode* a = (struct ListNode*)malloc(sizeof(struct ListNode)); struct ListNode* b = (struct ListNode*)malloc(sizeof(struct ListNode)); if (st1 != NULL) { a = st1; st1 = st1->next; } else { a->val = 0; a->next = NULL; } if (st2 != NULL) { b = st2; st2 = st2->next; } else { b->val = 0; b->next = NULL; } int get_sum = a->val + b->val + carry; int ans = get_sum % 10; carry = get_sum / 10; struct ListNode* cur = (struct ListNode*)malloc(sizeof(struct ListNode)); cur->val = ans; cur->next = new_head->next; new_head->next = cur; } struct ListNode* result = new_head->next; free(new_head); return result; } (2)使用链表反转实现如果使用链表反转,先将两个链表分别反转,最后计算完之后再将结果反转,一共有三次反转操作,所以必然将反转抽取出一个方法比较好,代码如下:public class Solution { public ListNode addInList (ListNode head1, ListNode head2) { head1 = reverse(head1); head2 = reverse(head2); ListNode head = new ListNode(-1); ListNode cur = head; int carry = 0; while(head1 != null || head2 != null) { int val = carry; if (head1 != null) { val += head1.val; head1 = head1.next; } if (head2 != null) { val += head2.val; head2 = head2.next; } cur.next = new ListNode(val % 10); carry = val / 10; cur = cur.next; } if (carry > 0) { cur.next = new ListNode(carry); } return reverse(head.next); } private ListNode reverse(ListNode head) { ListNode cur = head; ListNode pre = null; while(cur != null) { ListNode temp = cur.next; cur.next = pre; pre = cur; cur = temp; } return pre; } } class ReverseList: def reverseList(self, head) : prev = None curr = head while curr: nextTemp = curr.next curr.next = prev prev = curr curr = nextTemp return prev def addTwoNumbersI(self, l1, l2): ans = ListNode(0, None) DUMMY_HEAD, res = ans, 0 p1, p2 = l1, l2 while p1 != None or p2 != None or res == 1: ans.next = ListNode(0, None) ans = ans.next if p1 != None and p2 != None: sums = p1.val + p2.val if sums + res < 10: ans.val = sums + res res = 0 else: ans.val = sums + res - 10 res = 1 p1, p2 = p1.next, p2.next elif p1 == None and p2 != None: sums = p2.val if sums + res < 10: ans.val = sums + res res = 0 else: ans.val = sums + res - 10 res = 1 p2 = p2.next elif p2 == None and p1 != None: sums = p1.val if sums + res < 10: ans.val = sums + res res = 0 else: ans.val = sums + res - 10 res = 1 p1 = p1.next else: ans.val = res res = 0 return DUMMY_HEAD.next #调用入口 def addTwoNumbers(self, l1, l2): return self.reverseList(self.addTwoNumbersI(self.reverseList(l1), self.reverseList(l2))) struct ListNode* reverse(struct ListNode* head) { struct ListNode* cur = head; struct ListNode* pre = NULL; while (cur != NULL) { struct ListNode* temp = cur->next; cur->next = pre; pre = cur; cur = temp; } return pre; } struct ListNode* addTwoNumbers(struct ListNode* head1, struct ListNode* head2){ head1 = reverse(head1); head2 = reverse(head2); struct ListNode* head = (struct ListNode*)malloc(sizeof(struct ListNode)); head->val = -1; head->next = NULL; struct ListNode* cur = head; int carry = 0; while (head1 != NULL || head2 != NULL) { int val = carry; if (head1 != NULL) { val += head1->val; head1 = head1->next; } if (head2 != NULL) { val += head2->val; head2 = head2->next; } struct ListNode* newNode = (struct ListNode*)malloc(sizeof(struct ListNode)); newNode->val = val % 10; newNode->next = NULL; cur->next = newNode; carry = val / 10; cur = cur->next; } if (carry > 0) { struct ListNode* newNode = (struct ListNode*)malloc(sizeof(struct ListNode)); newNode->val = carry; newNode->next = NULL; cur->next = newNode; } return reverse(head->next); } 上面我们直接调用了反转函数,这样代码写起来就容易很多,如果你没手写过反转,所有功能都是在一个方法里,那复杂度要高好几个数量级,甚至自己都搞不清楚了。既然加法可以,那如果是减法呢?读者可以自己想想该怎么处理。5. 再论链表的回文序列问题在上一关介绍链表回文串的时候,我们介绍的是基于栈的,相对来说比较好理解,但是除此之外还有可以使用链表反转来进行,而且还可以只反转一半链表,这种方式节省空间。我们姑且称之为“快慢指针+一半反转”法。这个实现略有难度,主要是在while循环中pre.next = prepre和prepre = pre两行实现了一边遍历一边将访问过的链表给反转了,所以理解起来有些难度,如果不理解可以在学完链表反转之后再看这个问题。public boolean isPalindrome(ListNode head) { if(head == null || head.next == null) { return true; } ListNode slow = head, fast = head; ListNode pre = head, prepre = null; while(fast != null && fast.next != null) { pre = slow; slow = slow.next; fast = fast.next.next; //将前半部分链表反转 pre.next = prepre; prepre = pre; } if(fast != null) { slow = slow.next; } while(pre != null && slow != null) { if(pre.val != slow.val) { return false; } pre = pre.next; slow = slow.next; } return true; } def isPalindrome(self, head): fake = ListNode(-1) fake.next = head fast = slow = fake while fast and fast.next: fast = fast.next.next slow = slow.next post = slow.next slow.next = None pre = head rev = None # 反转 while post: tmp = post.next post.next = rev rev = post post = tmp part1 = pre part2 = rev while part1 and part2: if part1.val != part2.val: return False part1 = part1.next part2 = part2.next return True bool isPalindrome(struct ListNode* head){ if (head == NULL || head->next == NULL) { return true; } struct ListNode* slow = head; struct ListNode* fast = head; struct ListNode* pre = head; struct ListNode* prepre = NULL; while (fast != NULL && fast->next != NULL) { pre = slow; slow = slow->next; fast = fast->next->next; // 将前半部分链表反转 pre->next = prepre; prepre = pre; } if (fast != NULL) { slow = slow->next; } while (pre != NULL && slow != NULL) { if (pre->val != slow->val) { return false; } pre = pre->next; slow = slow->next; } return true; }
0
0
0
浏览量1858
时光小少年

第 08 关 | 二叉树的深度优先经典问题:2.白银挑战——二叉树的深度和高度问题

今天我们来学习几道比较特别的题目——二叉树的深度和高度问题,这几道题对递归的考察要求更高一些,一起来看看关卡名二叉树的经典面试问题我会了✔️内容1.  最大深度问题✔️2.  判断平衡树✔️3.  最小深度✔️4.  N 叉树的最大深度✔️给定二叉树 [3,9,20,null,null,15,7],如下图 3 / \ 9 20 / \ 15 7 然后LeetCode给我们造了一堆的题目,现在一起来研究一下104、110和111三个题,这三个题看起来挺像的,都是关于深度、高度的。1. 最大深度问题首先看一下104题最大深度:给定一个二叉树,找出其最大深度。二叉树的深度为根节点到最远叶子节点的最长路径上的节点数。说明: 叶子节点是指没有子节点的节点。例如上面的例子返回结果为最大深度为3。我们先看一个简单情况: 3 3 3 3 / \ / \ / \ 9 20 9 20 对于node(3),最大深度自然是左右子结点+1,左右子节点有的可能为空,只要有一个,树的最大高度就是1+1=2。然后再增加几个结点:很显然相对于node(20),最大深度自然是左右子结点+1,左右子节点有的可能为空,只要有一个,树的最大高度就是1+1=2,用代码表示就是:int depth = 1 + max(leftDepth, rightDepth);而对于3,则是左右子树深度最大的那个然后再+1,具体谁更大,则不必关心。所以对于node(3)的判断逻辑就是:int leftDepth = getDepth(node.left); // 左 int rightDepth = getDepth(node.right); // 右 int depth = 1 + max(leftDepth, rightDepth); // 中 那什么时候结束呢,这里仍然是root == null返回0就行了。至于入参,自然是要处理的子树的根节点root,而返回值则是当前root所在子树的最大高度。所以合在一起就是:class Solution { public int maxDepth(TreeNode root) { if(root == null){ return 0; } int leftHight = maxDepth(root.left); int rightHight = maxDepth(root.right); return Math.max(leftHight,rightHight) + 1; } } int maxDepth(TreeNode* root) { if (root == nullptr) return 0; return max(maxDepth(root->left), maxDepth(root->right)) + 1; } def maxDepth(self, root): if root is None: return 0 else: left_height = self.maxDepth(root.left) right_height = self.maxDepth(root.right) return max(left_height, right_height) + 1 上面代码先拿到左右子树的结果再计算Math.max(left,right) + 1,这与后序遍历本质上一样的,因此可以看做后序遍历的拓展问题。我们继续分析这个题,如果确定树最大有几层是不是也就知道最大深度了? 是的,直接套用层序遍历的代码就可以。具体做法是:我们修改2.2层次遍历中的分层方法,每获得一层增加一下技术就可以了。/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { public int maxDepth(TreeNode root) { if (root == null) { return 0; } Queue<TreeNode> queue = new LinkedList<TreeNode>(); queue.offer(root); int ans = 0; while (!queue.isEmpty()) { //size表示某一层的所有元素数 int size = queue.size(); //size=0 表示一层访问完了 while (size > 0) { TreeNode node = queue.poll(); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } size--; } ans++; } return ans; } } int maxDepth(TreeNode* root) { if (root == nullptr) return 0; queue<TreeNode*> Q; Q.push(root); int ans = 0; while (!Q.empty()) { int sz = Q.size(); while (sz > 0) { TreeNode* node = Q.front();Q.pop(); if (node->left) Q.push(node->left); if (node->right) Q.push(node->right); sz -= 1; } ans += 1; } return ans; } def maxDepth(self, root: Optional[TreeNode]): # 空树,高度为 0 if root == None: return 0 # 初始化队列和层次 queue = [root] depth = 0 # 当队列不为空 while queue: # 当前层的节点数 n = len(queue) # 弹出当前层的所有节点,并将所有子节点入队列 for i in range(n): node = queue.pop(0) if node.left: queue.append(node.left) if node.right: queue.append(node.right) depth += 1 # 二叉树最大层次即为二叉树最深深度 return depth 2. 判断平衡树LeetCode110 判断平衡二叉树:给定一个二叉树,判断它是否是高度平衡的二叉树。本题中,一棵高度平衡二叉树定义为:一个二叉树每个节点 的左右两个子树的高度差的绝对值不超过 1 。这里的要求是二叉树每个节点的左右两个子树的高度差的绝对值不超过 1。先补充一个问题,高度和深度怎么区分呢?二叉树节点的深度:指从根节点到该节点的最⻓简单路径边的条数。二叉树节点的高度:指从该节点到叶子节点的最⻓简单路径边的条数。直接看图:关于根节点的深度究竟是1 还是 0,不同的地方有不一样的标准,leetcode的题目中都是以根节点深度是1,其他的就不管了。言归正传,我们仍然先看一个最简单的情况:很显然只有两层的时候一定是平衡的,因为对于node(3),左右孩子如果只有一个,那高度差就是1;如果左右子孩子都有或者都没有,则高度差为0。如果增加一层会怎么样呢?如下图:对于node(3),需要同时知道自己左右子树的最大高度差是否<2。当节点root 左 / 右子树的高度差 < 2,则返回节点 root 的左右子树中最大高度加 1 ( max(left, right) + 1 );参考上面的高度和深度的对比图思考一下,这里为什么是最大高度?当节点root 左 / 右子树的高度差 ≥2 :则返回 -1 ,代表 此子树不是平衡树 。也就是:int left = recur(root.left); int right = recur(root.right); return Math.abs(left - right) < 2 ? Math.max(left, right) + 1 : -1; 我们此时就写出了核心递归。假如子树已经不平衡了,则不需要再递归了直接返回就行,比如这个树中结点4:综合考虑几种情况 ,完整的代码如下:class Solution { public boolean isBalanced(TreeNode root) { return height(root) >= 0; } public int height(TreeNode root) { if (root == null) { return 0; } int leftHeight = height(root.left); int rightHeight = height(root.right); if (leftHeight == -1 || rightHeight == -1 || Math.abs(leftHeight - rightHeight) > 1) { return -1; } else { return Math.max(leftHeight, rightHeight) + 1; } } } int height( TreeNode* root) { if (root == NULL) { return 0; } int leftHeight = height(root->left); int rightHeight = height(root->right); if (leftHeight == -1 || rightHeight == -1 || fabs(leftHeight - rightHeight) > 1) { return -1; } else { return fmax(leftHeight, rightHeight) + 1; } } bool isBalanced( TreeNode* root) { return height(root) >= 0; } 3. 最小深度既然有最大深度,那是否有最小深度呢?LeetCode111就是:给定一个二叉树,找出其最小深度。最小深度是从根节点到最近叶子节点的最短路径上的节点数量,例如下面的例子返回结果为2。说明: 叶子节点是指没有子节点的节点。前两个题都涉及最大深度,那将Max改成Min能不能解决本题呢?不行!注意下图:这里的关键问题是题目中说的:最小深度是从根节点到最近叶子节点的最短路径上的节点数量,也就是最小深度的一层必须要有叶子结点,因此不能直接用。这里的核心问题仍然是分析终止条件:如果左子树为空,右子树不为空,说明最小深度是 1 + 右子树的深度。反之,右子树为空,左子树不为空,最小深度是 1 + 左子树的深度。最后如果左右子树都不为空,返回左右子树深度最小值 + 1 。代码如下:public int minDepth(TreeNode root) { if (root == null) { return 0; } if (root.left == null && root.right == null) { return 1; } int min_depth = Integer.MAX_VALUE; if (root.left != null) { min_depth = Math.min(minDepth(root.left), min_depth); } if (root.right != null) { min_depth = Math.min(minDepth(root.right), min_depth); } return min_depth + 1; } def minDepth(self, root) : if not root: return 0 if not root.left and not root.right: return 1 min_depth = 10**9 if root.left: min_depth = min(self.minDepth(root.left), min_depth) if root.right: min_depth = min(self.minDepth(root.right), min_depth) return min_depth + 1 int minDepth(struct TreeNode *root) { if (root == NULL) { return 0; } if (root->left == NULL && root->right == NULL) { return 1; } int min_depth = INT_MAX; if (root->left != NULL) { min_depth = fmin(minDepth(root->left), min_depth); } if (root->right != NULL) { min_depth = fmin(minDepth(root->right), min_depth); } return min_depth + 1; } 除了递归方式 ,我们也可以使用层次遍历,只要遍历时,第一次遇到叶子就直接返回其所在的层次即可,改一下2.2中的方法即可。public int minDepth(TreeNode root) { if (root == null) { return 0; } int minDepth = 0; LinkedList<TreeNode> queue = new LinkedList<TreeNode>(); queue.add(root); while (queue.size() > 0) { //获取当前队列的长度,这个长度相当于 当前这一层的节点个数 int size = queue.size(); minDepth++; for (int i = 0; i < size; ++i) { TreeNode t = queue.remove(); if (t.left == null && t.right == null) { return minDepth; } if (t.left != null) { queue.add(t.left); } if (t.right != null) { queue.add(t.right); } } } return 0; } 4. N 叉树的最大深度如果将二叉树换成N叉树又该怎么做呢?这就是LeetCode559.给定一个 N 叉树,找到其最大深度。最大深度是指从根节点到最远叶子节点的最长路径上的节点总数。N 叉树输入按层序遍历序列化表示,每组子节点由空值分隔(请参见示例)。示例1: 输入:root = [1,null,3,2,4,null,5,6] 输出:3 示例2: 输入:root = [1,null,2,3,4,5,null,null,6,7,null,8,null,9,10,null,null,11,null,12,null,13,null,null,14] 输出:5 这道题就是将二叉树换成了N叉树,不同点就在于N叉树结点比较多,我们使用List存,遍历时用for即可,我们先看N叉树的定义:class Node { public int val; public List<Node> children; public Node() {} public Node(int _val) { val = _val; } public Node(int _val, List<Node> _children) { val = _val; children = _children; } } 这个题的实现和上一个题的最大区别就是处理子树时加了个处理List的for循环public int maxDepth(Node root) { if (root == null) { return 0; } else if (root.children.isEmpty()) { return 1; } else { List<Integer> heights = new LinkedList<>(); for (Node item : root.children) { heights.add(maxDepth(item)); } return Collections.max(heights) + 1; } } int maxDepth(Node* root) { if (root == nullptr) { return 0; } int maxChildDepth = 0; vector<Node *> children = root->children; for (auto child : children) { int childDepth = maxDepth(child); maxChildDepth = max(maxChildDepth, childDepth); } return maxChildDepth + 1; } def maxDepth(self, root) : return max((self.maxDepth(child) for child in root.children), default=0) + 1 if root else 0
0
0
0
浏览量306
时光小少年

第 5 关 | 算法的备胎 hash 和 找靠山的队列:2.白银挑战——队栈和 Hash 的经典算法

1. 用栈实现队列栈的特点是后进先出,队列的特点是先进先出。两个栈将底部拼接到一起就能实现队列的效果了,通过队列也能实现栈的功能,通过队列也能实现栈的功能,在很多的地方能让你通过两个栈实现队列,很多地方也有两个队列实现栈的题目,所以干脆一次看一下如何做。这个正好对应 leetcode232 和 225 两道题上。232.用栈实现队列相关企业请你仅使用两个栈实现先入先出队列。队列应当支持一般队列支持的所有操作(push、pop、peek、empty):实现 MyQueue 类:void push(int x) 将元素 x 推到队列的末尾int pop() 从队列的开头移除并返回元素int peek() 返回队列开头的元素boolean empty() 如果队列为空,返回 true ;否则,返回 false说明:你 只能 使用标准的栈操作 —— 也就是只有 push to top, peek/pop from top, size, 和 is empty 操作是合法的。你所使用的语言也许不支持栈。你可以使用 list 或者 deque(双端队列)来模拟一个栈,只要是标准的栈操作即可。输入: ["MyQueue", "push", "push", "peek", "pop", "empty"] [[], [1], [2], [], [], []] 输出: [null, null, null, 1, 1, false] 解释: MyQueue myQueue = new MyQueue(); myQueue.push(1); // queue is: [1] myQueue.push(2); // queue is: [1, 2] (leftmost is front of the queue) myQueue.peek(); // return 1 myQueue.pop(); // return 1, queue is [2] myQueue.empty(); // return false class MyQueue { private Deque<Integer> inStack; private Deque<Integer> outStack; public MyQueue() { inStack = new LinkedList<Integer>(); outStack = new LinkedList<Integer>(); } public void push(int x) { inStack.push(x); } public int pop() { if(outStack.isEmpty()){ in2out(); } return outStack.pop(); } public int peek() { if(outStack.isEmpty()){ in2out(); } return outStack.peek(); } public boolean empty() { return inStack.isEmpty() && outStack.isEmpty(); } private void in2out(){ while(!inStack.isEmpty()){ outStack.push(inStack.pop()); } } } /** * Your MyQueue object will be instantiated and called as such: * MyQueue obj = new MyQueue(); * obj.push(x); * int param_2 = obj.pop(); * int param_3 = obj.peek(); * boolean param_4 = obj.empty(); */ 2. 用队列实现栈225.用队列实现栈请你仅使用两个队列实现一个后入先出(LIFO)的栈,并支持普通栈的全部四种操作(push、top、pop 和 empty)。实现 MyStack 类:void push(int x) 将元素 x 压入栈顶。int pop() 移除并返回栈顶元素。int top() 返回栈顶元素。boolean empty() 如果栈是空的,返回 true ;否则,返回 false 。注意:你只能使用队列的基本操作 —— 也就是 push to back、peek/pop from front、size 和 is empty 这些操作。你所使用的语言也许不支持队列。 你可以使用 list (列表)或者 deque(双端队列)来模拟一个队列 , 只要是标准的队列操作即可。示例: 输入: ["MyStack", "push", "push", "top", "pop", "empty"] [[], [1], [2], [], [], []] 输出: [null, null, null, 2, 2, false] 解释: MyStack myStack = new MyStack(); myStack.push(1); myStack.push(2); myStack.top(); // 返回 2 myStack.pop(); // 返回 2 myStack.empty(); // 返回 False 分析:这个问题首先想到的是使用两个队列来实现。为了满足栈的特性,即最后入栈的元素最先出栈,在使用队列实现栈的时候,应该满足队列前端的元素是最后入栈的元素。可以使用两个队列实现栈dev操作,其中 pre 用于存储栈内的元素,after 作为入栈操作的辅助队列。入栈的时候,首先将元素入队到 after,然后将 pre 的全部元素依次出队并且入队到 after,此时 after 的前端元素即为新入栈的元素,再将 pre 和 after互换,则 pre 的元素为栈内元素,pre 的前端和后端分别对应栈顶和栈底。由于每次入栈操作都确保 pre 的前端元素为栈顶元素,因此出栈操作和获取栈顶元素都可以简单实现。出栈操作只需要移除 pre 的前端元素并返回就可以了,获取栈顶元素操作只需要获得 pre 的前端元素并且返回就可以了(不移除元素)。由于 pre 用于存储栈内的元素,判断栈是否为空的时候,只需要判断 pre 是否为空就可以了。class MyStack { Queue<Integer> pre; Queue<Integer> after; public MyStack() { pre = new LinkedList<Integer>(); after = new LinkedList<Integer>(); } public void push(int x) { after.offer(x); while(!pre.isEmpty()){ after.offer(pre.poll()); } Queue<Integer> temp = pre; pre = after; after = temp; } public int pop() { return pre.poll(); } public int top() { return pre.peek(); } public boolean empty() { return pre.isEmpty(); } } /** * Your MyStack object will be instantiated and called as such: * MyStack obj = new MyStack(); * obj.push(x); * int param_2 = obj.pop(); * int param_3 = obj.top(); * boolean param_4 = obj.empty(); */ 3. n 数之和专题3.1. 两数之和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] class Solution { public int[] twoSum(int[] nums, int target) { Map<Integer,Integer> map = new HashMap<Integer,Integer>(); for(int i = 0;i < nums.length;i++){ if(map.containsKey(target - nums[i])){ return new int[]{map.get(target - nums[i]),i}; } map.put(nums[i],i); } return new int[0]; } } 3.2. 三数之和给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a ,b ,c , 使得 a + b + c = 0 ?请找出所有和为 0 且 不重复 的三元组。示例 1: 输入:nums = [-1,0,1,2,-1,-4] 输出:[[-1,-1,2],[-1,0,1]] 示例 2: 输入:nums = [] 输出:[] 示例 3: 输入:nums = [0] 输出:[] 本题看似就是增加两个数,但是难度增加了很多,我们可以使用三层循环来找,时间复杂度是 O(n ^ 3),太高了,放弃。也可以使用双层循环加上 Hash 的方法来实现,首先按照第一题两数之和的思路,我们可以固定一个数 target,再利用好两数之和的思想去 map 中存取或查找(-1) * target - num[j],但是这样的问题是无法消除重复结果的,例如我们输入【-1,0,1,2,-1,-4】,返回的结果是【[-1,1,0],[-1,-1,2],[0,1,-1][0,-1,1],[1,-1,0],[2,-1,-1]】,如果我们再增加一个去重方法,将会执行超时。那这时候,我们就要想其他方法,这个公认最好的方式就是“排序 + 双指针”。我们可以像先将输入排序来处理重复结果,然后还是固定一个元素,由于数组是排好序的,所以我们用双指针来不断寻找求解就可以了,代码如下:class Solution { public List<List<Integer>> threeSum(int[] nums) { int n = nums.length; Arrays.sort(nums); List<List<Integer>> result = 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(third == second){ 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]); result.add(list); } } } return result; } } 如果我们继续拓展,在前面的基础上再增加一个数呢?这就是 LeetCode 18 ,给你一个由 n 个整数组成的数组 nums ,和一个目标值 target 。请你找出并返回满足下述全部条件且不重复的四元组 [nums[a], nums[b], nums[c], nums[d]] 满足0 <= a, b, c, d < na、b、c 和 d 互不相同nums[a] + nums[b] + nums[c] + nums[d] == target,这个题最直接的想法就是在上一题的基础上再套一层 for 循环来解决,思路虽然简单,但是实现过程非常复杂,感兴趣的同学可以研究一下。如果我们再拓展一下,如果四个数字不是在一个数组里,而是分别在四个数组里面,如何从每个数组中分别获得一个元素,使得 nums1[i] + nums2[j] + nums3[k] + nums4[l] == 0,此时应该怎么做? 这个是 leetcode 454 题,感兴趣的可以去看一下。
0
0
0
浏览量151
时光小少年

第 19 关 | 经典刷题思想之动态规划 2.白银挑战—动态规划高频问题

动态规划是一个非常重要的问题,相关的题目也特别多,这里我们就一起学习几个难度适用的题目关卡名掌握动态规划如何解题的我会了✔️内容1.最少硬币数问题✔️2.最长连续递增子序列问题✔️3.最长递增子序列问题✔️4.最少完全平方数问题✔️5.再论青蛙跳问题✔️6.解法方法✔️7.路径中的障碍物问题✔️8.滚动数组处理技巧✔️本文我们就来盘点那些常见的动态规划问题,本章除了最后两个题,其他都使用一维数组就可以了,因此我们每道题都要先明白,这个基表arr的含义是什么,如何更新的:本章,我们首先通过详细的过程一步步来分析最少硬币数的问题,仔细观察每一步都是在做什么。代码如何从比较low的样子优化到比较合理。从2.2开始我们的讲解会更加精简。1. 最少硬币数LeetCode322.给你一个整数数组 coins ,表示不同面额的硬币,以及一个整数 amount ,表示总金额。计算并返回可以凑成总金额所需的最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1 。你可以认为每种硬币的数量是无限的。示例1: 输入:coins = [1, 2, 5], amount = 11 输出:3 解释:11 = 5 + 5 + 1 示例2: 输入:coins = [2,5, 7], amount = 27 输出:3 解释:21 = 7 + 7 + 7 画图分析这个题,其实使用上一章的回溯法也能做,问题就是效率太低了。假如coins = [2,5, 7], amount = 27,在求解过程中,每个位置都可以从{2,5,7}中选择 ,因此可以逐步将所有情况枚举出来,然后再找到要求的最少硬币数,图示如下:通过上面的图,我们发现f[20]等已经存在多次重复计算了,这就是存在大量的重复计算问题,效率低,所以可以使用动态规划来优化。另外貌似贪心也可以,直觉告诉我们尽量使用大的,例如假如要求的是27,此时应该先连续用7 + 7+7=21,最后的6没法了就用2,则就是3个7加3个2,一共6个,但我们可以这么做7+5+5+5+5=27 ,使用5枚硬币就够了,这就是贪心的思路,但对于本题就是错误的。使用动态规划能同时满足效率和准确性的最佳,自然地,我们设状态f(x)=最少用f(x)枚硬币能拼出x。对应到上面基表就表示最少用arr[i]个硬币能表示出i来。我们先看上面给的示例1的情况:上面图中,索引表示的是amount,而每个位置表示就是最少需要arr[i]个硬币就能拼出来。但是有些场景下可能不能将所有位置都能拼接出来的,例如示例2中的:我们注意上面有些位是放的是M,表示的就是不能拼出来的意思。接下来我们就以coins={2,5,7}来详细分析如何一步步实现DP。第一步:确定状态和子问题什么是状态?前面介绍过,解动态规划的时候需要一个数组,状态就是这个数组的每个元素f[i]表示什么。DP从思想上看仍然是递归,我们要找到将问题范围缩小的递推表达式,也就是状态转移方程。对于大部分DP的题目,先看最后一步的情况更容易分析出来,所以我们的武功心法第一条是从后向前找递归。这里的最后一步就是:虽然我们不知道最优策略是什么,但是我知道最后得到最优策略一定是K枚硬币,a1,a2,...ak,而且面值加起来是27,而除掉ak这枚硬币,前面硬币加起来就是27-ak。此时我们得到:这里貌似我们还不知道ak是多少?但是那一枚一定是2,5,7中的一个。如果ak是2,那么f(27)应该是f(27-2)+1,加上最后这一枚硬币2。 f(25)+1如果ak是5,那么f(27)应该是f(27-5)+1,加上最后这一枚硬币5。 f(22)+1如果ak是7,那么f(27)应该是f(27-7)+1,加上最后这一枚硬币7。 f(20)+1除此之外,没有其他可能了。那我们最后到底要使用哪一个呢?很简单,就是选上面三种情况最小的那个,也就是:f(n):n是我们是要拼的数,这里f(n) 表示拼成n所需的最少硬币数f(27)= min{ f(25)+1,f(22)+1,f(20)+1}f(27)= min{ f(25),f(22),f(20)}+1f(n)=min{f(n-2),f(n-5),f(n-7)}+1f(27)=min{f(27-2)+1,f(27-5)+1,f(27-7)+1}在上面的过程中,我们不关心前面的K-1枚硬币是怎么拼出27-ak的,前K-1步可能有一种拼法,也可能有100种,甚至我们也没确定ak和k是啥,但是能确定的是前面一定拼出了27-ak,并且此时使用的硬币总数m=k-1枚一定是最少的,否则就不是最优策略,这里本题的一个核心, 也是不太好理解的地方。到此,要处理的子问题就是”最少用多少枚硬币可以拼出27-ak,而原始问题是"最少用多少枚硬币拼出27"。这样,我们就将原问题转化成了一个子问题,而且规模更小27-ak。而至于最后一个ak,我们就是简单枚举了一下,要么是2,要么是5,要么是7,这就是局部枚举。而且到现在为止,我们也不知道到底用哪一个,唯一能确定的就是最后一个肯定是从2,5,7中选了一个,所以只是得到这样一个壳子:f(27)=min{f(25)+1,f(22)+1,f(20)+1}那接下来要怎么办呢?很简单,就是根据递归的思想再去算f(25)、f(22)和f(20),例如计算f(20)就是:f(20)=min{f(18)+1,f(15)+1,f(13)+1}到这里仍然是不知道结果的,那就继续算,例如计算f(13)就是:f(13)=min{f(11)+1,f(8)+1,f(6)+1}到这里还是不知道结果,那就继续算,例如计算f(6)就是:f(6)=min{f(4)+1,f(1)+1,f(-1)+1}到这里很明显就非常容易计算了。通过上面的例子,我们终于明白为什么找递推要从右向左,而计算的时候要从左向后了。当然这里很明显f(-1)不符合要求的,那就设置为正无穷就行了。f(1)=也是正无穷,而f(4)=2,所以f(6)=2+1=3,然后继续计算最大的,最后就得到我们想要的结果。第二步:确定状态转移方程子问题确定之后,状态转移方程就很容易了,为了简化定义,我们设状态为f(x)=最少使用多少枚硬币拼出X。根据上面的分析,这里的ak,只能是2,5或者7。而我们要求的最少硬币数,就是求下面三个的最小值:这个就是状态转移方程,在上一步我们已经分析出来了。再往后,就是采用一样的套路将f(25)、f(22)和f(20)一起交给后续递归过程继续算。第三步:确定初始条件和边界很明显,本题的初始条件是: f[0]=0对于上面的公式,有两个问题如果x-2,x-5,x-7,小于0怎么办?如果不能拼出x,就定义f[x]为正无穷,例如f[-1]=f[-2]=...=正无穷,表示拼不出1来。思考一下,这里为什么不能初始化为0?当然这里有一点要注意,我们上面不可达用的是M,而不是Integer.MAX,因为这样的公式溢出了:f(6)=min{2,Integer.MAX+1,Integer.MAX+1,}我们可以使用amount来代替Integer.MAX第四步:按顺序计算开始执行上面的f[x]计算公式,f[x]=min{f[x-2]+1,f[x-5]+1,f[x-7]+1}虽然推导我们是从后向前 ,但是计算我们一般是从前向后的,在第一步中已经解释过,我们的计算过程如下:初始条件:f[0]=0然后计算f[1],f[2],...f[27]这样的好处是计算f[x]的时候,f[x-2],f[x-5],f[x-7]都已经计算到结果了,因此效率更高。执行的结构图如下(f[x]表示最少用几个硬币和拼出X,无穷表示无法拼出):现在请你思考一下这里的数字是怎么逐步计算出来的。这里其实每一步计算都是尝试3种硬币,一共27步,但是与递归相比,没有任何重复计算。因此算法时间复杂度为27*3。而递归的时间复杂度远远大于该数。在这里每一步都尝试三种硬币,一共27步,对应的也就是一维数组的大小。可以看到,与递归相比没有任何重复计算,因此效率更高。第五步:代码实现经过上面的分析之后,我们接下来就要实现上面的逻辑。根据上面f[x]的公式,我们可以写出第一版的代码://不能执行,仅仅做学习思考 int coinChange(int[] coins, int amount) { int max = amount + 1; Interge.MAX int[] dp = new int[amount + 1]; Arrays.fill(dp, max); dp[0] = 0; for (int i = 1; i <= amount; i++) { if(check(i,coins)){ dp[i] = min(dp[i], dp[i - coins[0]] + 1,dp[i - coins[1]] + 1,dp[i - coins[2]] + 1); } } return dp[amount] > amount ? -1 : dp[amount]; } boolean check(int i,int []coins){ // 这里要保证if里使用的i - coins[j]等大于零 // 这里还要保证不越界,写起来比较复杂 ,我们理解功能即可 } 上面的min()方法我们暂时不实现,反正是从中找最小的那个。但这个代码不太优雅,dp的计算太长了,我们可以通过下面的方式来调整一下://不能执行,仅仅做学习思考 public int coinChange(int[] coins, int amount) { int max = amount + 1; int[] dp = new int[amount + 1]; Arrays.fill(dp, max); dp[0] = 0; for (int i = 1; i <= amount; i++) { if(check(i,coins) ){//简写 dp[i] = min(dp[i], dp[i - coins[0]] + 1); dp[i] = min(dp[i], dp[i - coins[1]] + 1); dp[i] = min(dp[i], dp[i - coins[2]] + 1); } } return dp[amount] > amount ? -1 : dp[amount]; } boolean check(int i,int []coins){ // 这里要保证if里使用的i - coins[j]等大于零 // 这里还要保证不越界,写起来比较复杂 ,我们理解功能即可 } 这样我们就可以通过三次计算dp分别来处理coins[]数组的情况。但是这么写仍然不好,如果coins[]数组比较大,if判断就会非常长。怎么办呢?好办,加个循环来解决。最终代码如下:public static int coinChange(int[] coins, int M) { int max = M + 1; int[] dp = new int[M + 1]; Arrays.fill(dp, max); dp[0] = 0; for (int i = 1; i <= M; i++) { for (int j = 0; j < coins.length; j++) { if (coins[j] <= i) { dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1); } } } return dp[M] > M ? -1 : dp[M]; } def coinChange(self, coins, amount) : dp = [float('inf')] * (amount + 1) dp[0] = 0 for coin in coins: for x in range(coin, amount + 1): dp[x] = min(dp[x], dp[x - coin] + 1) return dp[amount] if dp[amount] != float('inf') else -1 int coinChange(vector<int>& coins, int M) { int max = M + 1; int dp[max]; fill(dp, dp + max, max); dp[0] = 0; for (int i = 1; i <= M; i++) { for (int j = 0; j < coins.size(); j++) { if (coins[j] <= i) { dp[i] = min(dp[i], dp[i - coins[j]] + 1); } } } if (dp[M] > M) { return -1; } return dp[M]; } 这就是本题最终的实现方法。这里虽然是递归,但是是通过循环+数组来实现的,这就是为了消除冗余加速计算。总结我们现在来总结一下求最值型DP的步骤:1.确定状态和子问题,从最后一步开始(最优策略中使用的最后一枚硬币ak)推导f(n)与子问题之间的关系,然后将其化成子问题(最少的硬币拼出更小的面值27-ak)。2.通过状态,我们可以得到状态转移方程:f[x]=min{f[x-2]+1,f[x-5]+1,f[x-7]+1}3.处理初始条件和边界情况。f[0]=0,其他的如果不能拼出来就标记为f[X]=正无穷。4.从小到大开始计算。这里就是从f[0]、f[1]、f[2]..向后计算。2. 最长连续递增子序列LeetCode674.给定一个未经排序的整数数组,找到最长且连续递增的子序列,并返回该序列的长度。连续递增的子序列 可以由两个下标 l 和 r(l < r)确定,如果对于每个 l <= i < r,都有 nums[i] < nums[i + 1] ,那么子序列 [nums[l], nums[l + 1], ..., nums[r - 1], nums[r]] 就是连续递增子序列。示例1: 输入:nums = [1,3,5,4,7] 输出:3 解释:最长连续递增序列是 [1,3,5], 长度为3。 尽管 [1,3,5,7]也是升序的子序列, 但它不是连续的,因为5和7在原数组里被4隔开。 对于本题,不用动态规划也可以解决,例如我们前面介绍的《滑动窗口》就可以。如果使用动态规划的话,我们仍然先手动画一下,看看数组变成什么样子:很明显就是从前向后累计一下,如果不再是递增了,就将f[x]设置为1,然后继续向前走。这种问题也称为序列型动态规划,给定一个序列或者网格,需要找到序列中某个/些子序列或者网格中的某条路径,要求满足某种性质最大/最小,求计数或者判断存在性问题。第一步:确定状态和子问题分析最后一步,对于最优策略,一定有最后一个元素a[j]。我们先考虑简单情况:第一种情况:最优策略中最长连续上升子序列就是{a[j]},答案是1。第二种情况:子序列长度大于1,那么最优策略中a[j]前一个元素肯定是a[j-1],这种情况一定是a[j-1]<a[j]的。因为是最优策略,那么它选中的以a[j-1]结尾的连续上升子序列一定是最长的。这里我们也得到了子问题:求以a[j-1]结尾的最长连续上升子序列,而本来是求以a[j]结尾的最长连续上升子序列。状态:设f[j]=以a[j]结尾的最长连续上升子序列的长度。则转移方程就是:注意上图中红色线前面的是表达式,后面是要满足的条件。第二步:初始条件和边界情况2必须满足:j>0,即a[j]前面至少还有一个元素 并且a[j]>a[j-1]满足单调性。第四步:按照顺序计算计算f[0],f[1],f[2],...,f[n-1]和硬币组合题不一样的是,最终答案不一定是f[n-1],因为我们不知道最优策略中最后一个元素是哪个a[j]。所以答案是max{f[0],f[1],f[2],...,f[n-1]}。public int findLengthOfLCIS(int[] nums) { int[] dp = new int[nums.length]; for (int i = 0; i < dp.length; i++) { dp[i] = 1; } int res = 1; for (int i = 0; i < nums.length - 1; i++) { if (nums[i + 1] > nums[i]) { dp[i + 1] = dp[i] + 1; } res = res > dp[i + 1] ? res : dp[i + 1]; } return res; } def findLengthOfLCIS(self, nums: List[int]) -> int: if len(nums) == 0: return 0 result = 1 dp = [1] * len(nums) for i in range(len(nums)-1): if nums[i+1] > nums[i]: #连续记录 dp[i+1] = dp[i] + 1 result = max(result, dp[i+1]) return result int findLengthOfLCIS(vector<int>& nums) { if (nums.size() == 0) return 0; int result = 1; vector<int> dp(nums.size() ,1); for (int i = 1; i < nums.size(); i++) { if (nums[i] > nums[i - 1]) { // 连续记录 dp[i] = dp[i - 1] + 1; } if (dp[i] > result) result = dp[i]; } return result; } 3. 最长递增子序列LeetCode300.给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。示例1: 输入:nums = [10,9,2,5,3,7,101,1] 输出:4 解释:最长递增子序列是 [2,3,7,101],因此长度为4。 注意本题与上一题的区别就是没说一定是连续的,例如上面示例里2和7就不是连续的。本题该怎么做呢?我们还是先手动算一下看看:我们看一下使用DP解决问题的方法:第一步:确定状态最后一步:对于最优的策略,一定有最后一个元素a[j]。第一种情况:最优策略中最长上升子序列就是{a[j]},答案是1。第二种情况:子序列长度大于1,那么最优策略中a[j]前一个元素是a[i],并且a[i]<a[j]第二种情况:子序列长度大于1,那么最优策略中a[j]前一个元素是a[i],并且a[i]<a[j]因为是最优策略,那么它选中的以a[i]结尾的上升子序列一定是最长的。所以我们就得到子问题:因为不确定最优策略中a[j]前一个元素a[i]是哪一个,需要枚举每个i,求以a[j]结尾的最长上升子序列。化为子问题:i<j状态:设f[j]=以a[j]结尾的最长上升子序列的长度第三步:初始条件和边界情况情况2必须满足:①i>=0;② a[j]>a[i],也就是满足单调性。第四步:计算顺序计算f[0]、f[1]、f[2]、....f[n-1],答案就是这些数中最大的那个。本题的时间复杂度为O(n^2),空间复杂度O(n)。public int lengthOfLIS(int[] A) { int n=A.length; if(n==0){ return 0; } int []f=new int[n]; int i,j,res=0; for(j=0;j<n;j++){ f[j]=1; for(i=0;i<j;i++){ if(A[i]<A[j]&&f[i]+1>f[j]){ f[j]=f[i]+1; } } res=Math.max(res,f[j]); } return res; } def lengthOfLIS(self, nums): if not nums: return 0 dp = [] for i in range(len(nums)): dp.append(1) for j in range(i): if nums[i] > nums[j]: dp[i] = max(dp[i], dp[j] + 1) return max(dp) int lengthOfLIS(vector<int>& A) { int n = A.size(); if (n == 0) { return 0; } int f[n]; int i, j, res = 0; for (j = 0; j < n; j++) { f[j] = 1; for (i = 0; i < j; i++) { if (A[i] < A[j] && f[i] + 1 > f[j]) { f[j] = f[i] + 1; } } res = max(res, f[j]); } return res + 1; } 4. 最少完全平方数LeetCode279.给你一个整数 n ,返回和为n的完全平方数的最少数量。完全平方数 是一个整数,其值等于另一个整数的平方;换句话说,其值等于一个整数自乘的积。例如,1、4、9 和 16 都是完全平方数,而 3 和 11 不是。示例1: 输入:n = 12 输出:3 解释:12 = 4 + 4 + 4 示例2: 输入:n = 13 输出:2 解释:13 = 4 + 9 4 + 9 4 4 4 1 4 4 1 1 1 1 1 1 4 1 1 1 1 1 1 1 1 1 这个题如果通过暴力来算,一定会超时,我们还是考虑如何通过DP来做。首先我们看一下手动画一下看看数组的变化:第一步:确定状态先看序列的最后一步:关注最优策略中最后一个完全平方数j^2,那么最后策略中n-j^2也一定被划分成最少的完全平方数之和。因此需要知道n-j^2最少被分成几个完全平方数之和,而原问题是求n最少被分成接完全而平方数之和,这就是子问题。根据子问题,我们可以确定状态了:设f[i]表示i最少被分成几个完全平方数之和。第二步:确定状态转移方程设f[i]表示i最少被分成几个完全平方数之和。第三步:确定初始条件和边界条件初始条件:0被分成0个完全平方数之和。f[0]=0。然后依次计算f[1],...,f[N]答案就是f[N]。public int numSquares(int n) { int[] f = new int[n + 1]; f[0] = 0; for (int i = 1; i <= n; i++) { f[i] = Integer.MAX_VALUE; for (int j = 1; j * j <= i; j++) { if (f[i - j * j] + 1 < f[i]) { f[i] = f[i - j * j] + 1; } } } return f[n]; } def numSquares(n): f = [0] * (n+1) f[0] = 0 for i in range(1, n+1): f[i] = sys.maxsize for j in range(1, int(i**0.5)+1): if f[i-j**2] + 1 < f[i]: f[i] = f[i-j**2] + 1 return f[n] int numSquares(int n) { int f[n + 1]; f[0] = 0; for (int i = 1; i <= n; i++) { f[i] = 10000; for (int j = 1; j * j <= i; j++) { if (f[i - j * j] + 1 < f[i]) { f[i] = f[i - j * j] + 1; } } } return f[n]; } 5. 再论青蛙跳LeetCode55.跳跃游戏,给定一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位part置可以跳跃的最大长度。判断你是否能够到达最后一个下标。示例1: 输入:nums = [2,3,1,1,4] 输出:true 解释:可以先跳 1 步,从下标 0 到达下标 1, 然后再从下标 1 跳 3 步到达最后一个下标。 示例2: 输入:nums = [3,2,1,0,4] 输出:false 本题也是个典型的贪心问题,我们在贪心章节重点介绍过,我们现在从动态规划的角度考虑如何解决。第一步:确定状态最后一步:如果青蛙能跳到最后一块石头n-1,我们考虑它跳的最后一步,这一步是从石头i跳过来的,i<n-1。这需要同时满足两个条件:青蛙可以跳到石头i;最后一步不超过跳跃的最大距离:n-1-i<ai;第二步:确定状态转移方程那么我们需要知道青蛙能不能跳到石头i(i<n-1),而原始问题是我们要求青蛙能不能跳到石头n-1,这就是子问题。状态:设f[j]表示青蛙能不能跳到石头j(注意这里的f[j]是布尔类型)。因此可以确定状态转移方程了:这里使用or是因为只要存在一个就可以了。第三步:确定初始条件和边界情况设f[j]表示青蛙能不能跳到石头j。初始条件:f[0]=true,因为青蛙一开始就在石头0。第四步:按顺序计算根据第二步中定义的f[j]和状态转移方程,并根据第三步的初始条件开始计算f[1],f[2],....f[n-1]。f[n-1]就是我们最终想要的结果:public boolean canJump(int[] A) { if (A == null || A.length == 0) { return false; } int n = A.length; boolean[] f = new boolean[n]; f[0] = true; for (int j = 1; j < n; j++) { f[j] = false; for (int i = 0; i < j; i++) { if (f[i] && (i + A[i] >= j)) { f[j] = true; } } } return f[n - 1]; } def canJump(A): n = len(A) f = [False] * n f[0] = True for j in range(1, n): for i in range(j): if f[i] and (i + A[i] >= j): f[j] = True return f[n - 1] bool canJump(vector<int>& A) { if (A.empty() || A.size() == 0) { return false; } int n = A.size(); bool f[n]; f[0] = true; for (int j = 1; j < n; j++) { f[j] = false; for (int i = 0; i < j; i++) { if (f[i] && (i + A[i] >= j)) { f[j] = true; } } } return f[n - 1]; } 对于本题我们其实就是从i位置开始一直测试i+A[i]能不能到达A[n-1],因此本质上就是一个两次循环,因此时间复杂度是O(N^2),空间复杂度为O(N)。6. 解码方法LeetCode91.解码方法:一条包含字母 A-Z 的消息通过以下映射进行了 编码 :'A' -> "1" 'B' -> "2" ... 'Z' -> "26" 要解码已编码的消息,所有数字必须基于上述映射的方法,反向映射回字母(可能有多种方法)。例如,"11106" 可以映射为:"AAJF" ,将消息分组为 (1 1 10 6)"KJF" ,将消息分组为 (11 10 6)注意,消息不能分组为 (1 11 06) ,因为 "06" 不能映射为 "F" ,这是由于 "6" 和 "06" 在映射中并不等价。给你一个只含数字的非空字符串 s ,请计算并返回解码方法的总数 。示例1: 输入:s = "12" 输出:2 解释:它可以解码为 "AB"(1 2)或者 "L"(12)。 示例2: 输入:s = "226" 输出:3 解释:它可以解码为 "BZ" (2 26), "VF" (22 6), 或者 "BBF" (2 2 6) 示例3: 输入:s = "0" 输出:0 解释:没有字符映射到以 0 开头的数字。 含有 0 的有效映射是 'J' -> "10" 和 'T'-> "20" 。 由于没有字符,因此没有有效的方法对此进行解码,因为所有数字都需要映射。 本题第一感觉是个回溯问题,本题用回溯是可以的,问题仍然是容易超时,我们还是考虑一下DP怎么解决。第一步:确定状态解密成为字母串,最后一定有最后一个字母,A、B..或Z,这个字母加密时变成1、2、....、26.所以最后一个字母要么单独用了,要么和前面一个一起使用,假如说单独使用,例如下面这样子:假如此时有100中解密方式。而如果将最后两个一起使用,也就是下面这样子:假如此时有50种方法,那么总共方法就是100+50=150。这样我们就得到了子问题:设数字串长度为N ,要求数字串前N个字符的解密方式数,此时需要知道数字串前面N-1和N-2字符的解密方式数。所以本题的状态就是:设字符串S前i个数字解密成字母串有f[i]种方式。第二步:确定状态转移方程设数字S前i个数字解密成字母串有f[i]种方式,第三步:确定初始条件和边界情况初始条件f[0]=1,即空串有1种方式解密,解密成空串就行了。边界情况:如果i=1,只看最后一个数字就行了。第四步:按照顺序计算f[0],f[1],f[2],....,f[N],答案就是f[N]public static int numDecodings(String s) { int n = s.length(); int[] f = new int[n + 1]; f[0] = 1; for (int i = 1; i <= n; ++i) { if (s.charAt(i - 1) != '0') { f[i] += f[i - 1]; } if (i > 1 && (check(s, i))) { f[i] += f[i - 2]; } } return f[n]; } public static boolean check(String s, int i) { if (s.charAt(i - 2) == '0') { return false; } if ((s.charAt(i - 2) - '0') * 10 + (s.charAt(i - 1) - '0') > 26) { return false; } return true; } def numDecodings(s): n = len(s) f = [0] * (n + 1) f[0] = 1 for i in range(1, n + 1): if s[i - 1] != '0': f[i] += f[i - 1] if i > 1 and check(s, i): f[i] += f[i - 2] return f[n] def check(s, i): if s[i - 2] == '0': return False if (int(s[i - 2]) * 10 + int(s[i - 1])) > 26: return False return True int numDecodings(string s) { int n = s.length(); vector<int> f(n + 1, 0); f[0] = 1; for (int i = 1; i <= n; ++i) { if (s[i - 1] != '0') { f[i] += f[i - 1]; } if (i > 1 && check(s, i)) { f[i] += f[i - 2]; } } return f[n]; } bool check(string s, int i) { if (s[i - 2] == '0') { return false; } int num = (s[i - 2] - '0') * 10 + (s[i - 1] - '0'); if (num > 26) { return false; } return true; } 7. 路径中存在障碍物我们在上面路径部分介绍了多种路径的问题,但是本专题还有个重要的拓展问题,就是假如网格中存在障碍物该怎么办。我们这里补充一下。本题是在leetcode 62题要求的基础上,如果中间某个位置存在障碍物,那一共有多少种路径。假如在上面的网格中存在一个障碍物,你该怎么处理呢?示例1: 输入:obstacleGrid = [[0,0,0],[0,1,0],[0,0,0]] 输出:2 解释: 3x3 网格的正中间有一个障碍物。 从左上角到右下角一共有 2 条不同的路径: 1. 向右 -> 向右 -> 向下 -> 向下 2. 向下 -> 向下 -> 向右 -> 向右 这个处理方法不算复杂,假如没有障碍物的格子标记为0,有障碍物的标记为1,那么执行的时候如果当前位置dp[i][j]==1时,直接跳过就行了。这么写:public int uniquePathsWithObstacles(int[][] obstacleGrid) { int n = obstacleGrid.length, m = obstacleGrid[0].length; int[] dp = new int[m]; dp[0] = obstacleGrid[0][0] == 0 ? 1 : 0; for (int i = 0; i < n; ++i) { for (int j = 0; j < m; ++j) { if (obstacleGrid[i][j] == 1) { dp[j] = 0; continue; } if (j - 1 >= 0 && obstacleGrid[i][j - 1] == 0) { dp[j] += dp[j - 1]; } } } return dp[m - 1]; } def uniquePathsWithObstacles(obstacleGrid): n, m = len(obstacleGrid), len(obstacleGrid[0]) dp = [0] * m dp[0] = obstacleGrid[0][0] == 0 and 1 or 0 for i in range(n): for j in range(m): if obstacleGrid[i][j] == 1: dp[j] = 0 continue if j - 1 >= 0 and obstacleGrid[i][j - 1] == 0: dp[j] += dp[j - 1] return dp[m - 1] int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) { int n = obstacleGrid.size(); int m = obstacleGrid[0].size(); vector<int> dp(m); dp[0] = obstacleGrid[0][0] == 0 ? 1 : 0; for (int i = 0; i < n; ++i) { for (int j = 0; j < m; ++j) { if (obstacleGrid[i][j] == 1) { dp[j] = 0; continue; } if (j - 1 >= 0 && obstacleGrid[i][j - 1] == 0) { dp[j] += dp[j - 1]; } } } return dp[m - 1]; } 如果面试直接考63题,估计我们会崩溃,但是如果我们先将62题研究清楚了,63题是不是就很简单了?面试算法最喜欢的是换换条件不断折腾,那这里我们能否再当一次面试官,假如有k个障碍物,k<min(m,n),该怎处理呢?其实,从代码的角度,我们什么都不用干,上面的代码就能执行,因为问题的关键在于传入进来的obstacleGrid数组包含多个元素1罢了,我们在双层循环里的这个判断足以处理。if (obstacleGrid[i][j] == 1) { dp[j] = 0; continue; } 这也是我们一直说的,一个方法可以解决大量的问题。一个问题改改条件就能造出大量的题目,我们后面还会继续折腾!8. 滚动数组技巧我们前面介绍了很多滚动数组的问题,本小节通过杨辉三角再来学习一种使用滚动数组的技巧。杨辉三角是一种很出名的三角形,它的特点是每个元素都是其二维矩阵中左上方和右上方的元素之和,是一种对称结构,如下所示:在LeetCode中对应118和119两道题,不同之处,一个是打印整个三角形,第二个是只需要打印出最后一行就行了。我们现在重点研究一下只打印最后一行的情况。显然这个用二维数组比较可行,为了方便实现我们将其结构可以稍作改变,也就是:观察上面的图,我们可以得到几个结论:1.每一行最右侧和最左侧都是1。2.前两行都是1,从第三行才开始有累加的问题。也就是这里的2,之后每行的元素仍然满足是其左上角+右上角元素之和。3.每一行的元素数量都与其行数一样,所以可以为第N行建立一个大小为N的数组。此时就可以写出如下的代码:public class Yanghui { public static void main(String args[]) { //确定一个有10个数组(元素为数组)的二维数组 int a[][] = new int[10][]; //取出a[0],a[1]......a[9]十个数组 for (int i = 0; i < a.length; i++) { //为10个数组确定空间(元素数目) a[i] = new int[i + 1]; //将所有数组的第一个和最后一个元素元素赋值为1 a[i][0] = 1; a[i][i] = 1; } //取出a[0],a[1]......a[9]十个数组 for (int i = 0; i < a.length; i++) { //从第三个数组开始 if (i > 1) { for (int j = 0; j < a.length; j++) { //所有数组的第二个到倒数第二个元素,它们的值为前一个数组所对应的元素和前一个元素的和 //(a[2][1]=a[1][1]+a[1][0]) if (j > 0 && j < i) { a[i][j] = a[i - 1][j] + a[i - 1][j - 1]; } } } } //通过下标访问数组的元素 for (int i = 0; i < a.length; i++) { for (int j = 0; j < a[i].length; j++) { //这里使用print不进行自动换行,使用“\t”进行跳格(tab key即为空格键) System.out.print(a[i][j] + "\t"); } //这里每进行一次for循环,都将结果进行换行 System.out.println(); } } } 如果要得到最后一行,我们只要将二维数组的最后一行打印一下就可以了。那如果要求只能使用O(n)空间实现,该怎么做呢? 很显然我们可以使用上面提到的滚动数组来做,但是这里最大的问题是在二维数组的计算公式:arr[i][j]=arr[i-1][j-1]+arr[i-1][j]在计算dp[i]的时候,还需要上一轮的dp[i-1],但是这个值已经被覆盖了,如下图所示标记的几个位置以及后序位置都是这样子。例如图中的“3”号位置,我们需要使用上一轮的2和当前位置1相加,可惜如果只使用一个dp[i]数组的时候,这里的2已经被覆盖成3了。后面标记的6和4等等都是类似的情况,因此仅仅靠一个一维数组无法解决问题。那怎么办呢?简单!使用两个数组,也是O(n)空间,使用两个数组来轮流交换,其中黑色表示上一轮的结构。红色表示当前轮要更新的结果。从下面图示可以清楚看到如何执行的。对于网格上的动态规划,如果f[i][j]只依赖与本行的f[i][x]与前一行的f[i-1][y]那么就可以采用滚动数组来节省空间public List<Integer> getRow(int rowIndex) { List<Integer> pre = new ArrayList<Integer>(); for (int i = 0; i <= rowIndex; ++i) { List<Integer> cur = new ArrayList<Integer>(); for (int j = 0; j <= i; ++j) { if (j == 0 || j == i) { cur.add(1); } else { cur.add(pre.get(j - 1) + pre.get(j)); } } pre = cur; } return pre; } def get_row(row_index): pre = [] cur = [] for i in range(row_index + 1): cur = [] for j in range(i + 1): if j == 0 or j == i: cur.append(1) else: cur.append(pre[j - 1] + pre[j]) pre = cur return pre vector<int> getRow(int rowIndex) { vector<int> pre; for (int i = 0; i <= rowIndex; ++i) { vector<int> cur; for (int j = 0; j <= i; ++j) { if (j == 0 || j == i) { cur.push_back(1); } else { cur.push_back(pre[j - 1] + pre[j]); } } pre = cur; } return pre; } 这种通过两个数组来实现空间优化的技巧在一些DP问题中经常使用,这里请同学们务必理解清楚。如果说这里就是让你使用一个一维数组来完成,该怎么做呢?其实非常简单,观察上面的图我们会发现,当前行第 i 项的计算只与上一行第 i−1 项及第 i 项有关。因此我们可以倒着计算当前行,这样计算到第 i 项时,第i−1 项仍然是上一行的值。验证一下执行是否有问题public class Yanghui { public static List<Integer> generate(int rowIndex) { List<Integer> row = new ArrayList<Integer>(); row.add(1); for (int i = 1; i <= rowIndex; ++i) { row.add(0); for (int j = i; j > 0; --j) { row.set(j, row.get(j) + row.get(j - 1)); } } return row; } public static void main(String[] args) { List<Integer> yanghui = generate(6); for (int i = 0; i < yanghui.size(); i++) { System.out.print(yanghui.get(i) + " "); } } } class Yanghui: def generate(self,row_index): row = [1] for i in range(1, row_index + 1): row.append(0) for j in range(i): row[j] += row[j-1] return row # 创建一个 Yanghui 对象并调用方法 yanghui = Yanghui() print(yanghui.generate(6)) vector<int> generate(int rowIndex) { vector<int> row; row.push_back(1); for (int i = 1; i <= rowIndex; ++i) { row.push_back(0); for (int j = i; j > 0; --j) { row[j] += row[j - 1]; } } return row; } int main() { vector<int> yanghui = generate(6); for (int i = 0; i < yanghui.size(); i++) { cout << yanghui[i] << " "; } cout << endl; } 输出结果为:1 6 15 20 15 6 1
0
0
0
浏览量1954
时光小少年

第 10 关 | 天上的明月——快速排序和归并排序: 3.黄金挑战——归并排序

1. 归并排序原理归并排序(MERGE-SORT)简单来说就是把大的序列先视为若干个比较小的数组,分成几个比较小的结构,然后利用归并的思想实现的排序方法,该算法采用经典的分治策略(就是把问题分(divide) 成一些小的问题进行求解,而治(conquer) 就是把分的阶段得到的各个答案"合"在一起)。可以看到这种结构很像两棵套在一起的满二叉树。分阶段可以理解为就是递归拆分子 序列的过程,递归深度为logn。就是图中上面侧的满二叉树。再来看看治阶段,我们需要将两个已经有序的子序列合并成一个有序序列,就是下侧 的满二叉树。比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子 序列,合并为最终序列[1,2,3,4,5,6,7,8],这个操作与合并两个有序数组的完全一样, 不同的是这里是将数组的两个部分合并。再看一下遍历时处理元素的过程:private static void merge_sort(int[] arr, int l, int r) { // 递归结束条件 if (l >= r) return; // 以下都为逻辑部分 int mid = l + ((r - l) >> 1); merge_sort(arr, l, mid); merge_sort(arr, mid + 1, r); int[] tmp = new int[r - l + 1]; // 临时数组, 用于临时存储 [l,r]区间内排好序的数据 int i = l, j = mid + 1, k = 0; // 两个指针 // 进行归并 while (i <= mid && j <= r) { if (arr[i] <= arr[j]) tmp[k++] = arr[i++]; else tmp[k++] = arr[j++]; } while (i <= mid) tmp[k++] = arr[i++]; while (j <= r) tmp[k++] = arr[j++]; // 进行赋值 for (i = l, j = 0; i <= r; i++, j++) arr[i] = tmp[j]; }
0
0
0
浏览量2010
时光小少年

第 3 关 | 爱不起的数组和双指针思想 : 2. 白银挑战 —— 双指针思想以及应用

1. 双指针思想这里介绍一种简单但非常有效的方式——双指针。所谓的双指针其实就是两个变量,不一定真的是指针。双指针思想简单好用,在处理数组、字符串等场景下很常见。看个例子,从下面序列中删除重复元素[1,2,2,2,3,3,3,5,5,7,8],重复元素只保留一个。删除之后的结果应该为[1,2,3,5,7,8]。我们可以在删除第一个2时将将其后面的元素整体向前移动一次,删除第二个2时再将其后的元素整体向前移动一次,处理后面的3和5都一样的情况,这就导致我们需要执行5次大量移动才能完成,效率太低。如果使用双指针可以方便的解决这个问题,如图:首先我们定义两个指针slow、fast。slow表示当前位置之前的元素都是不重复的,而fast则一直向后找,直到找到与slow位置不一样的 ,找到之后就将slow向后移动一个位置,并将arr[fast]复制给arr[slow],之后fast继续向后找,循环执行。找完之后slow以及之前的元素就都是单一的了。这样就可以只用一轮移动解决问题。上面这种一个在前一个在后的方式也称为快慢指针,有些场景需要从两端向中间走,这种就称为对撞型指针或者相向指针,很多题目也会用到,我们接下来会看到很多相关的算法题。还有一种比较少见的背向型,就是从中间向两边走。这三种类型其实非常简单,看的只是两个指针是一起向前走(相亲相爱一起走),还是从两头向中间走(冲破千难万险来爱你),还是从中间向两头走(缘分已尽,就此拜拜)。2. 删除元素专题所谓算法,其实就是将一个问题改改条件多折腾,上面专题就是添加的变形,再来看几个删除的变形问题。2.1. 原地移除所有等于 val 的值LeetCode27.给你一个数组 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的元素的时候,如何避免反复向前移动呢?本题可以使用双指针方式,而且还有三种,我们都看一下:第一种:快慢双指针整体思想就是2.1.1中介绍的双指针的图示的方式,定义两个指针slow和fast,初始值都是0。Slow之前的位置都是有效部分,fast表示当前要访问的元素。这样遍历的时候,fast不断向后移动:如果nums[fast]的值不为val,则将其移动到nums[slow++]处。如果nums[fast]的值为val,则fast继续向前移动,slow先等待。图示如下:这样,前半部分是有效部分,后半部分是无效部分。public static int removeElement(int[] nums, int val) { int slow = 0; //fast充当了快指针的角色 for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != val) { nums[slow] = nums[fast]; slow++; } } //最后剩余元素的数量 return slow; } def removeElement(self, nums, val): fast = 0 slow = 0 while fast < len(nums): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 fast += 1 return slow int removeElement(int* nums, int numsSize, int val){ int slow = 0; for (int fast = 0; fast < numsSize; fast++) { if (nums[fast] != val) { nums[slow] = nums[fast]; slow++; } } return slow; } 第二种:对撞双指针对撞指针,有的地方叫做交换移除,核心思想是从右侧找到不是val的值来顶替左侧是val的值。什么意思呢?我们看图,我们以nums = [0,1,2,2,3,0,4,2], val = 2为例:上图完整描述了执行的思路,当left==right的时候,left以及左侧的就是删除掉2的所有元素了。实现代码:public int removeElement(int[] nums, int val) { int right = nums.length - 1; int left = 0; for (left = 0; left <= right; ) { if ((nums[left] == val) && (nums[right] != val)) { int tmp = nums[left]; nums[left] = nums[right]; nums[right] = tmp; } if (nums[left] != val) left++; if (nums[right] == val) right--; } return left ; } # 对撞型双指针 def removeElement2(self, nums, val): right = len(nums) - 1 left = 0 while left <= right: if (nums[left] == val) and (nums[right] != val): tmp = nums[left] nums[left] = nums[right] nums[right] = tmp if nums[left] != val: left = left + 1 if nums[right] == val: right = right - 1 return left public int removeElement(int[] nums, int val) { int right = nums.length - 1; int left = 0; for (left = 0; left <= right; ) { if ((nums[left] == val) && (nums[right] != val)) { int tmp = nums[left]; nums[left] = nums[right]; nums[right] = tmp; } if (nums[left] != val) left++; if (nums[right] == val) right--; } return left ; } 这样就是一个中规中矩的的双指针解决方法。拓展本题还可以进一步融合上面两种方式创造出:“对撞双指针+覆盖”法。当nums[left]等于val的时候,我们就将nums[right]位置的元素覆盖nums[left],继续循环,如果nums[left]等于val就继续覆盖,否则才让left++,这也是双指针方法的方法,实现代码:public int removeElement(int[] nums, int val) { int right = nums.length-1; for (int left = 0; left <= right; ) { if (nums[left] == val) { nums[left] = nums[right ]; right--; } else { left++; } } return right+1; } # 对撞+覆盖 def removeElement3(self, nums, val): right = len(nums) - 1 left = 0 while left <= right: if nums[left] == val: nums[left] = nums[right] right = right - 1 else: left = left + 1 return left int removeElement(int* nums, int numsSize, int val) { int right = numsSize - 1; int left = 0; for (left = 0; left <= right; ) { if (nums[left] == val) { nums[left] = nums[right]; right--; } else { left++; } } return right + 1; } 对撞型双指针的过程与后面要学习的快速排序是一个思路,快速排序要比较很多轮,而这里只执行了一轮,理解本题将非常有利于后面理解快速排序算法。另外,我们可以发现快慢型双指针留下的元素顺序与原始序列中的是一致的,而在对撞型中元素的顺序和原来的可能不一样了。2.2. 删除有序数组中的重复项LeetCode26 给你一个有序数组 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 。不需要考虑数组中超出新长度后面的元素。 本题使用双指针最方便,思想与2.1.1的一样,一个指针负责数组遍历,一个指向有效数组的最后一个位置。为了减少不必要的操作,我们做适当的调整,例如令slow=1,并且比较的对象换做nums[slow - 1],代码如下:public static int removeDuplicates(int[] nums) { //slow表示可以放入新元素的位置,索引为0的元素不用管 int slow = 1; //循环起到了快指针的作用 for (int fast = 0; fast < nums.length; fast++) { if (nums[fast] != nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; } ef removeDuplicates(self, nums): j = 1 for i in range(1, len(nums)): if nums[i] != nums[j - 1]: nums[j] = nums[i] j += 1 return j int removeDuplicates(int* nums, int numsSize) { int slow = 1; int fast; for (fast = 1; fast < numsSize; fast++) { if (nums[fast] != nums[slow - 1]) { nums[slow] = nums[fast]; slow++; } } return slow; } 3. 元素奇偶移动问题根据某些条件移动元素也是一类常见的题目,例如排序本身就是在移动元素,这里看一个奇偶移动的问题。LeetCode905,按奇偶排序数组。给定一个非负整数数组 A,返回一个数组,在该数组中, A 的所有偶数元素之后跟着所有奇数元素。你可以返回满足此条件的任何数组作为答案。例如: 输入:[3,1,2,4] 输出:[2,4,3,1] 输出 [4,2,3,1],[2,4,1,3] 和 [4,2,1,3] 也会被接受。 最直接的方式是使用一个临时数组,第一遍查找并将所有的偶数复制到新数组的前部分,第二遍查找并复制所有的奇数到数组后部分。这种方式实现比较简单,但是会面临面试官的灵魂之问:"是否有空间复杂度为O(1)的"方法。我们可以采用对撞型双指针的方法,图示与2.5.2中的对撞型基本一致,只不过比较的对象是奇数还是偶数。如下图所示:维护两个指针 left=0 和 right=arr.length-1,left从0开始逐个检查每个位置是否为偶数,如果是则跳过,如果是奇数则停下来。然后right从右向左检查,如果是奇数则跳过偶数则停下来。然后交换array[left]和array[right]。之后再继续巡循环,直到left>=right。public static int[] sortArrayByParity(int[] A) { int left = 0, right = A.length - 1; while (left < right) { if (A[left] % 2 > A[right] % 2) { int tmp = A[left]; A[left] = A[right]; A[right] = tmp; } if (A[left] % 2 == 0) left++; if (A[right] % 2 == 1) right--; } return A; } # 对撞型双指针法 def sortArrayByParity(self, nums): n = len(nums) res, left, right = [0] * n, 0, n - 1 for num in nums: if num % 2 == 0: res[left] = num left += 1 else: res[right] = num right -= 1 return res int* sortArrayByParity(int* A, int ASize, int* returnSize) { int left = 0; int right = ASize - 1; while (left < right) { if (A[left] % 2 > A[right] % 2) { int tmp = A[left]; A[left] = A[right]; A[right] = tmp; } if (A[left] % 2 == 0) { left++; } if (A[right] % 2 == 1) { right--; } } *returnSize = ASize; return A; } 你有没有发现这种解法与2.5.2里第二种解法对撞指针几乎一样,只是处理条件换了一下?因为这就是对撞型双指针的解题模板!4. 数组转轮问题先看题目要求,LeetCode189.给你一个数组,将数组中的元素向右轮转 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] 这个题怎么做呢?你是否想到可以逐次移动来实现?理论上可以,但是实现的时候会发现要处理的情况非常多,比较难搞。这里介绍一种简单的方法:两轮翻转。方法如下:首先对整个数组实行翻转,例如 [1,2,3,4,5,6,7] 我们先将其整体翻转成[7,6,5,4,3,2,1]。从 k 处分隔成左右两个部分,这里就是根据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; } } def rotate(self, nums, k): length = len(nums) k %= length nums[:] = nums[::-1] nums[:k] = nums[:k][::-1] nums[k:] = nums[k:][::-1] void rotate(int* nums, int numsSize, int k) { k %= numsSize; reverse(nums, 0, numsSize - 1); reverse(nums, 0, k - 1); reverse(nums, k, numsSize - 1); } 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; } } 5. 数组的区间问题数组中表示的数据可能是连续的,也可能是不连续的,如果将连续的空间标记成一个区间,那么我们可以再造几道题,先看一个例子:LeetCode228.给定一个无重复元素的有序整数数组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" 本题容易让人眼高手低,一眼就看出来结果,但是编程实现则很麻烦。这个题使用双指针也可以非常方便的处理,慢指针指向每个区间的起始位置,快指针从慢指针位置开始向后遍历直到不满足连续递增(或快指针达到数组边界),则当前区间结束;然后将 slow指向更新为 fast + 1,作为下一个区间的开始位置,fast继续向后遍历找下一个区间的结束位置,如此循环,直到输入数组遍历完毕。public static List<String> summaryRanges(int[] nums) { List<String> res = new ArrayList<>(); // slow 初始指向第 1 个区间的起始位置 int slow = 0; for (int fast = 0; fast < nums.length; fast++) { // fast 向后遍历,直到不满足连续递增(即 nums[fast] + 1 != nums[fast + 1]) // 或者 fast 达到数组边界,则当前连续递增区间 [slow, fast] 遍历完毕,将其写入结果列表。 if (fast + 1 == nums.length || nums[fast] + 1 != nums[fast + 1]) { // 将当前区间 [slow, fast] 写入结果列表 StringBuilder sb = new StringBuilder(); sb.append(nums[slow]); if (slow != fast) { sb.append("->").append(nums[fast]); } res.add(sb.toString()); // 将 slow 指向更新为 fast + 1,作为下一个区间的起始位置 slow = fast + 1; } } return res; } def summaryRanges(self, nums): n = len(nums) res = [] slow, fast = 0, 0 while fast < n: if fast < n - 1 and nums[fast + 1] == nums[fast] + 1: fast += 1 else: res.append((nums[slow], nums[fast])) slow = fast + 1 fast = fast + 1 # 此时res的内容如下: # [(0, 2), (4, 5), (7, 7)] print res # 转换成需要的字符串样式 def p(x): slow, fast = x if slow == fast: return str(slow) else: return str(slow) + '->' + str(fast) return list(map(p, res)) char* itoa(int val, int base) { static char buf[32] = {0}; int i = 30; for (; val && i; --i, val /= base) buf[i] = "0123456789abcdef"[val % base]; return &buf[i + 1]; } char** summaryRanges(int* nums, int numsSize, int* returnSize) { char** res = (char**)malloc(numsSize * sizeof(char*)); *returnSize = 0; int slow = 0; for (int fast = 0; fast < numsSize; fast++) { if (fast + 1 == numsSize || nums[fast] + 1 != nums[fast + 1]) { char* range = (char*)malloc(32 * sizeof(char)); if (slow != fast) { sprintf(range, "%d->%d", nums[slow], nums[fast]); } else { sprintf(range, "%d", nums[slow]); } res[*returnSize] = range; (*returnSize)++; slow = fast + 1; } } return res; } 这个实现的精华是"fast + 1 == nums.length || nums[fast] + 1 != nums[fast + 1]",我们是用fast+1来进行比较的,如果使用fast比较也可以,但是实现起来会有些case一直过不了,不信你可以试一下。拓展我们本着不嫌事大的原则,假如这里是要你在上面的情况反过来,找缺失的区间该怎么做呢?例如:示例: 输入: nums = [0, 1, 3, 50, 75], lower = 0 和 upper = 99, 输出: ["2", "4->49", "51->74", "76->99"] 这是LeetCode163题,你可以试着做一下。6. 字符串替换空格问题这是剑指offer中的题目,出现频率也很高:请实现一个函数,将一个字符串中的每个空格替换成“%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; } def replaceSpace(self, s): res = [] for c in s: if c == ' ': res.append("%20") else: res.append(c) return "".join(res) char* replaceSpace(char* str) { int len = strlen(str); char* res = (char*)malloc(len * 3 * sizeof(char)); // 分配足够的空间来容纳替换后的字符串 int resIndex = 0; for (int i = 0; i < len; i++) { if (str[i] == ' ') { res[resIndex++] = '%'; res[resIndex++] = '2'; res[resIndex++] = '0'; } else { res[resIndex++] = str[i]; } } res[resIndex] = '\0'; // 在替换后的字符串末尾添加一个空字符,表示字符串的结束 return res; } 对于第二种情况,我们首先想到的是从头到尾遍历整个字符串,遇到空格的时候就将其后面的元素向后移动2个位置,但是这样的问题在前面说过会导致后面的元素大量移动,时间复杂度为O(n^2),执行的时候非常容易超时。比较好的方式是可以先遍历一次字符串,这样可以统计出字符串中空格的总数,由此计算出替换之后字符串的长度,每替换一个空格,长度增加2,即替换之后的字符串长度为:新串的长度=原来的长度+2*空格数目接下来从字符串的尾部开始复制和替换,用两个指针fast和slow分别指向原始字符串和新字符串的末尾,然后:slow不动,向前移动fast:若指向的不是空格,则将其复制到slow位置,然后fast和slow同时向前一步;若fast指向的是空格,则在slow位置插入一个%20,fast则只移动一步。循环执行上面两步,便可以完成替换。详细过程如下:实现代码如下: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 fast = len - 1; //两个指针 int slow = (len + 2 * numOfblank) - 1; while (fast >= 0 && slow > fast) { char c = str.charAt(fast); if (c == ' ') { fast--; str.setCharAt(slow--, '0'); str.setCharAt(slow--, '2'); str.setCharAt(slow--, '%'); } else { str.setCharAt(slow, c); fast--; slow--; } } return str.toString(); } def replaceSpace2(self, s): counter = s.count(' ') res = list(s) # 每碰到一个空格就多拓展两个格子,1 + 2 = 3个位置存’%20‘ res.extend([' '] * counter * 2) # 原始字符串的末尾,拓展后的末尾 left, right = len(s) - 1, len(res) - 1 while left >= 0: if res[left] != ' ': res[right] = res[left] right -= 1 else: # [right - 2, right), 左闭右开 res[right - 2: right + 1] = '%20' right -= 3 left -= 1 return ''.join(res) char* replaceSpace(char* str) { if (str == NULL) return NULL; int numOfblank = 0; // 空格数量 int len = strlen(str); for (int i = 0; i < len; i++) { // 计算空格数量 if (str[i] == ' ') numOfblank++; } int newLen = len + 2 * numOfblank; char* newStr = (char*)malloc((newLen + 1) * sizeof(char)); // 分配足够的空间存储替换后的字符串(包括结尾的空字符'\0') int fast = len - 1; // 快指针 int slow = newLen - 1; // 慢指针 while (fast >= 0 && slow > fast) { char c = str[fast]; if (c == ' ') { fast--; newStr[slow--] = '0'; newStr[slow--] = '2'; newStr[slow--] = '%'; } else { newStr[slow] = c; fast--; slow--; } } newStr[newLen] = '\0'; // 在结尾添加空字符'\0' return newStr; }
0
0
0
浏览量288
时光小少年

第 11 关 | 刷题模板之位运算: 2.白银挑战——位运算的高频算法题

1. 位移的妙用位移操作是一个很重要的问题,可以统计数字中1的个数,在很多高性能软件中也大量应用,我们看几个高频题目。1.1. 位1的个数LeetCode191 编写一个函数,输入是一个无符号整数(以二进制串的形式),返回其二进制表达式中数字位数为 '1' 的个数。拓展问题:16进制时怎么统计0000 00 的示例1: 输入:00000000000000000000000000001011 输出:3 解释:输入的二进制串 00000000000000000000000000001011 中,共有三位为 '1'。 示例2: 输入:00000000000000000000000010000000 输出:1 解释:输入的二进制串 00000000000000000000000010000000 中,共有一位为 '1'。 首先我们可以根据题目要求直接计算,题目给定的 n 是 32 位二进制表示下的一个整数,计算位 1 的个数的最简单的方法是遍历 n 的二进制表示的每一位,判断每一位是否为 1,同时进行计数。那问题就是如何通过位运算来识别到1,例如:00001001001000100001100010001001,首先我们注意到要识别到最低位的1,可以这么做:00001001001000100001100010001001 & 00000000000000000000000000000001 = 00000000000000000000000000000001 也就说将原始数字和1进行&运算就能知道最低位是不是1了,那其他位置怎么算呢?我们可以有两种思路,让1不断左移或者将原始数据不断右移。例如将原始数据右移就是:00000100100100010000110001000100 & 00000000000000000000000000000001 = 00000000000000000000000000000000 很明显此时就可以判断出第二位是0,然后继续将原始数据右移就可以依次判断出每个位置是不是1了。因此是不是1,计算一下(n>>i) & 1就可以了,所以代码顺理成章:public class Solution { // you need to treat n as an unsigned value public int hammingWeight(int n) { int count =0 ; for(int i = 0;i < 32;i++){ count += (n >> i) & 1; } return count; } } 这个题也可以通过将1左移来实现的,有时间的话可以自己进行尝试上面的代码写出来,这个题基本就达标了,但这还不是最经典的解法,我们继续分析:按位与运算有一个性质:对于整数 n,计算n & (n−1) 的结果为将 n 的二进制表示的最后一个 1 变成 0。利用这条性质,令 n=n & (n−1),则 n 的二进制表示中的 1 的数量减少一个。重复该操作,直到 n 的二进制表示中的全部数位都变成 0,则操作次数即为 n 的位 1 的个数。什么意思呢?我们继续看上面的例子:n: 00000100100100010000110001000100 n-1: 00000100100100010000110001000011 n&(n-1)= 00000100100100010000110001000000 可以看到此时n&(n-1)的结果比n少了一个1,此时我们令n=n&(n-1),继续执行上述操作:n: 00000100100100010000110001000000 n-1: 00000100100100010000110000111111 n&(n-1)= 00000100100100010000110000000000 可以看到此时n&(n-1)的结果比上一个n又少了一个1,所以我们令n=n&(n-1),循环执行上述操作,我们统计一下循环执行的次数就能得到结果了。那循环该什么时候停下呢?很显然当n变成0的时候,否则说明数据里面还有1,可以继续循环。所以当且仅当 n=0 时,n 的二进制表示中的全部数位都是 0,代码也很好写了:public int hammingWeight(int n) { int count = 0; while (n != 0) { n = n & (n - 1); count++; } return count; } 上面两种解法,第一种的循环次数取决于原始数字的位数,而第二种的取决于1的个数,效率自然要高出不少,使用n = n & (n - 1)计算是位运算的一个经典技巧,该结论可以完美用到下面的题目中:1.2. 比特位计数LeetCode 338. 给你一个整数 n ,对于 0 <= i <= n 中的每个 i ,计算其二进制表示 1 的个数,返回一个长度为 n + 1 的数组 ans 作为答案。示例1: 输入:n = 2 输出:[0,1,1] 解释:0到n有 0 1 2 三个数字,每个数字含有1的个数分别为0 1 1 个,如下: 0 --> 0 1 --> 1 2 --> 10 示例2: 输入:n = 5 0 1 2 3 4 5 101 输出:[0,1,1,2,1,2] 解释:0到n有 0 1 2 3 4 5 六个数字,每个数字含有1的个数分别为0,1,1,2,1,2个,如下: 0 --> 0 1 --> 1 2 --> 10 3 --> 11 4 --> 100 5 --> 101 最直观的方法是对从 0 到 num 的每个数直接计算"一比特数"。每个int 型的数都可以用 32 位二进制数表示,只要遍历其二进制表示的每一位即可得到1 的数目。public int[] countBits(int num) { int[] bits = new int[num + 1]; for (int i = 0; i <= num; i++) { for (int j = 0; j < 32; j++) { bits[i] += (i >> j) & 1; } } return bits; } 利用位运算的技巧,可以提升计算速度。按位与运算(&)的一个性质是:对于任意整数 x,令 x=x&(x−1),该运算将 x 的二进制表示的最后一个 1 变成 0。因此,对 x 重复该操作,直到 x 变成0,则操作次数即为 x 的「一比特数」。public int[] countBits(int num) { int[] bits = new int[num + 1]; for (int i = 0; i <= num; i++) { bits[i] = countOnes(i); } return bits; } public int countOnes(int x) { int ones = 0; while (x > 0) { x &= (x - 1); ones++; } return ones; } 有没有发现比特位计数和位1的个数计算规则完全一样? 这就是为什么我们说研究清楚一道题,可以干掉一大票的题目。1.3. 颠倒无符号整数LeetCode190 .颠倒给定的 32 位无符号整数的二进制位。 提示:输入是一个长度为32的二进制字符串。示例1: 输入:n = 00000010100101000001111010011100 输出:964176192 (00111001011110000010100101000000) 解释:输入的二进制串 00000010100101000001111010011100 表示无符号整数 43261596, 因此返回 964176192,其二进制表示形式为 00111001011110000010100101000000。 示例2: 输入:n = 11111111111111111111111111111101 输出:3221225471 (10111111111111111111111111111111) 解释:输入的二进制串 11111111111111111111111111111101 表示无符号整数 4294967293, 因此返回 3221225471 其二进制表示形式为 10111111111111111111111111111111 。 首先这里说是无符号位,那不必考虑正负的问题,最高位的1也不表示符号位,这就省掉很多麻烦。我们注意到对于 n 的二进制表示的从低到高第 i 位,在颠倒之后变成第 31-i 位( 0≤i<32),所以可以从低到高遍历 n 的二进制表示的每一位,将其放到其在颠倒之后的位置,最后相加即可。看个例子,为了方便我们使用比较短的16位演示:原始数据:1001 1111 0000 0110(低位) 第一步:获得n的最低位0,然后将其右移16-1=15位,得到: reversed: 0*** **** **** **** n右移一位: 0100 1111 1000 0011 第二步:继续获得上面n的最低位1,然后将其右移15-1=14位,并与reversed相加得到: reversed:01** **** **** **** n右移一位:0010 0111 1100 0001 继续,一直到n全部变成0: 理解之后,实现就比较容易了。由于 Java不存在无符号类型,所有的表示整数的类型都是有符号类型,因此需要区分算术右移和逻辑右移,在Java 中,算术右移的符号是 >>,逻辑右移的符号是 >>>。原始数据:1001 1111 0000 0110(低位) 第一步:获得n的最低位0,然后将其右移16-1=15位,得到: reversed: 0*** **** **** **** n右移一位: 0100 1111 1000 0011 第二步:继续获得上面n的最低位1,然后将其右移15-1=14位,并与reversed相加得到: reversed:01** **** **** **** n右移一位:0010 0111 1100 0001 继续,一直到n全部变成0: 本题的解法还有很多,例如还有一种分块的思想, n 的二进制表示有 32 位,可以将 n 的二进制表示分成较小的块,然后将每个块的二进制位分别颠倒,最后将每个块的结果合并得到最终结果。这分治的策略,将 n 的 32 位二进制表示分成两个 16 位的块,并将这两个块颠倒;然后对每个 16 位的块重复上述操作,直到达到 1 位的块。为了方便看清楚,我们用字母代替01,如下图所示。具体做法是:下面的代码中,每一行分别将 n 分成16 位、8 位、4 位、2 位、1 位的块,即把每个块分成两个较小的块,并将分成的两个较小的块颠倒。同样需要注意,使用 Java 实现时,右移运算必须使用逻辑右移。由于是固定的32位,我们不必写循环或者递归,直接写:reverseBits(int n){ n = (n >>> 16) | (n << 16); n = ((n & 0xff00ff00) >>> 8) | ((n & 0x00ff00ff) << 8); n = ((n & 0xf0f0f0f0) >>> 4) | ((n & 0x0f0f0f0f) << 4); n = ((n & 0xcccccccc) >>> 2) | ((n & 0x33333333) << 2); n = ((n & 0xaaaaaaaa) >>> 1) | ((n & 0x55555555) << 1); return n; } 这种方法在JDK、Dubbo等源码中都能见到,特别是涉及协议解析的场景几乎都少不了位操作。积累相关的技巧,可以方便面试,也有利于阅读源码。2. 位实现加减乘除专题在计算机中,位运算的效率比加减乘数效率更高,因此在高性能软件的源码中大量应用,而且计算机里各种运算本质上都是位运算。本专题我们就研究几个相关问题。2.1. 位运算实现加法LeetCode371 给你两个整数 a 和 b ,不使用 运算符 + 和 - ,计算并返回两整数之和。示例1: 输入:a = 1, b = 2 输出:3 既然不能使用+和-,那只能使用位运算了。我们看一下两个二进制位相加的情况:[1] 0 + 0 = 0 [2] 0 + 1 = 1 [3] 1 + 0 = 1 [4] 1 + 1 = 0 (发生了进位,应该是10的) 两个位加的时候,我们无非就考虑两个问题:进位部分是什么,不进位部分是什么。从上面的结果可以看到,对于a和b两个数不进位部分的情况是:相同为0,不同为1,这不就是a⊕b吗?而对于进位,我们发现只有a和b都是1的时候才会进位,而且进位只能是1,这不就是a&b=1吗?然后位数由1位变成了两位,也就是上面的[4]的样子,那怎么将1向前挪一下呢?手动移位一下就好了,也就是(a & b) << 1。所以我们得到两条结论:●不进位部分:用a⊕b计算就可以了。●是否进位,以及进位值使用(a & b) << 1计算就可以了。于是,我们可以将整数 a 和 b 的和,拆分为 a 和 b 的无进位加法结果与进位结果的和,代码就是:public int getSum(int a, int b) { while (b != 0) { int sign = (a & b) << 1; a = a ^ b; b = sign; } return a; } 2.2. 递归乘法LeetCode里面试08.05,递归乘法。 写一个递归函数,不使用 * 运算符, 实现两个正整数的相乘。可以使用加号、减号、位移,但要吝啬一些。示例1: 输入:A = 1, B = 10 输出:10 如果不让用*来计算,一种是将一个作为循环的参数,对另一个进行累加,但是这样效率太低,所以我们还是要考虑位运算。首先,求得A和B的最小值和最大值,对其中的最小值当做乘数(为什么选最小值,因为选最小值当乘数,可以算的少),将其拆分成2的幂的和,即min = a_0 * 2^0 + a_1 * 2^1 + ... + a_i * 2^i + ...其中a_i取0或者1。其实就是用二进制的视角去看待min,比如12用二进制表示就是1100,即1000+0100。例如:13 * 12 = 13 * (8 + 4) = 13 * 8 + 13 * 4 = (13 << 3) + (13 << 2);上面仍然需要左移5次,存在重复计算,可以进一步简化:假设我们需要的结果是ans,定义临时变量:tmp=13<<2 =52计算之后,可以先让ans=52然后tmp继续左移一次tmp=52<<1=104,此时再让ans=ans+tmp这样只要执行三次移位和一次加法,实现代码:public int multiply(int A, int B) { int min = Math.min(A, B); int max = Math.max(A, B); int ans = 0; for (int i = 0; min != 0; i++) { //位为1时才更新ans,否则max一直更新 if ((min & 1) == 1) { ans += max; } min >>= 1; max+=max; } return ans; } 拓展 除法处理起来略微复杂的,感兴趣的同学可以研究一下LeetCode29,位运算实现除法。
0
0
0
浏览量506
时光小少年

第 1 关 | 原来链表怎么有用:2.白银挑战——链表高频面试算法题

1. 两个链表的第一个公共子节点这是一道经典的链表问题,剑指 offer 52 :输出两个链表,找出它们的第一个公共节点。例如下面的两个链表:两个链表的头结点都是已知的,相交之后成为一个单链表,但是相交的位置未知,并且在相交之前的结点数也是未知的,请设计算法找到两个算法的合并点。分析:首先来理解一下,这个题是什么意思。思考一下下面两个图,是否都满足单链表的要求,为什么?第二个图:解析:上面第一个图是满足单链表要求的,因为我们说链表环环相扣,核心是一个结点只能有一个后继,但不代表一个结点只能有一个被指向。第一个图中,c1 被 a2 和 b3 同时指向,这是没关系的。这就好比法律倡导一夫一妻,你只能爱一个人,但是可以多个人爱你。第二个图就不满足要求,因为 C1 有两个后继 a5 和b4。理解了题目含义之后,我们继续来看如何解决。没有思路如何解题?单链表中的每个节点只能指向唯一的下一个next,但是可以有多个指针指向同一个节点。例如上面的 C1 可以被多个节点同时指向,这种问题该如下下手呢?如果一时想不到该怎么办呢?告诉你一个屡试不爽的办法,把常用数据结构和常用算法想一遍,看看哪些能解决问题。常用的数据结构有数组、链表、队、栈、Hash、集合、树、堆。常用的算法思想有查找、排序、双指针、迭代、递归、分治、贪心、回溯和动态规划等等。首先想到的暴力法,类似于冒泡排序的方式,将第一个链表中的每一个结点一次和第二个链表相互比较,直到出现相等的结点指针时,即为相交结点。虽然建党,但是复杂度高,排除!再看Hash,先将第一个链表元素全部存到Map里,然后一边遍历第二个链表,一边检测当前元素是否在Hash中,如果两个链表有交点,那就找到了。OK,第二种方法出来了。既然Hash可以,那集合呢?和Hash一样用,也能解决,OK,第三种方法出来了。队列和栈呢?这里用队列没啥用,但用栈呢?现将两个链表分别压到两个栈里,之后一边同时出栈,一边比较出栈元素是否一致,如果一致则说明存在相交,然后继续找,最晚出栈的那组一致的节点就是要找的位置,于是就有了第四种方法。这时候可以直接和面试官说,应该可以用HashMap做,另外集合和栈应该也能解决问题。面试官很明显就会问你,怎么解决?然后你可以继续说HashMap、集合和栈具体应该怎么解决。假如你想错了,比如你开始说队列能,但后面发现根本解决不了,这时候直接对面试官说“队列不行,我想想其他方法”就可以了,一般对方就不会再细究了。算法面试本身也是一个相互交流的过程,如果有些地方你不清楚,他甚至会提醒你一下,所以不用紧张。除此上面的方法,还有两种比较巧妙的方法,比较巧妙的方法我们放在第六小节看,这里我们先看两种基本的方式。1.1. 哈希和集合先将一个链表元素全部存到Map里,然后一边遍历第二个链表,一边检测Hash中是否存在当前结点,如果有交点,那么一定能检测出来。 对于本题,如果使用集合更适合,而且代码也更简洁。思路和上面的一样,直接看代码:class Solution { ListNode getIntersectionNode(ListNode headA, ListNode headB) { Set<ListNode> set = new HashSet<>(); while(headA != null){ set.add(headA); headA = headA.next; } while(headB!= null){ if(set.contains(headB)){ return headB; } headB=headB.next; } return null; } # 方法1,使用集合 def getIntersectionNode(self, headA, headB): s = set() p, q = headA, headB while p: s.add(p) p = p.next while q: if q in s: return q q = q.next return None 由于在 C语言的基础包里面没有定义集合类型,所以这里如果需要用到集合的话需要我们自己先构建一个,或者引入外部资源包,这个更加麻烦,所以我们这里不提供代码。1.2. 使用栈这里需要使用两个栈,分别将两个链表的结点入两个栈,然后分别出栈,如果相等就继续出栈,一直找到最晚出栈的那一组。这种方式需要两个O(n)的空间,所以在面试时不占优势,但是能够很好锻炼我们的基础能力,所以花十分钟写一个吧:import java.util.Stack; public ListNode findFirstCommonNodeByStack(ListNode headA, ListNode headB) { Stack<ListNode> stackA=new Stack(); Stack<ListNode> stackB=new Stack(); while(headA!=null){ stackA.push(headA); headA=headA.next; } while(headB!=null){ stackB.push(headB); headB=headB.next; } ListNode preNode=null; while(stackB.size()>0 && stackA.size()>0){ if(stackA.peek()==stackB.peek()){ preNode=stackA.pop(); stackB.pop(); }else{ break; } } return preNode; } 由于在C语言基础包里没有提供栈类型,这里如果用集合需要先自己构建一个,或者引入外部包,更麻烦,因此这里我们不提供代码。看到了吗,从一开始没啥思路到最后搞出三种方法,熟练掌握数据结构是多么重要!!如果你想到了这三种方法中的两个,并且顺利手写并运行出一个来,面试基本就过了,至少面试官对你的基本功是满意的。但是对方可能会再来一句:还有其他方式吗?或者说,有没有申请空间大小是O(1)的方法。方法是有的,解决方式需要使用到双指针的问题,我们放到本文最后一小节再看。2. 判断链表是否是回文序列LeetCode 234. 这是一道简单的,但是很经典的链表题,判断一个链表是否是回文链表。示例 1: 输入: 1 -> 2 -> 2 -> 1 输出:true 进阶:你能否用 O(n)时间复杂度和 O(1)空间复杂度解决此题? 看到这个题你有几种思路解决,我们仍然是先将常见的数据结构和算法思想想一想,看看谁能解决问题。方法1:将链表元素都赋值到数组中,然后可以从数组两端向中间对比。这种方法会被视为逃避链表,面试不能这么干。方法2:将链表元素全部压栈,然后一边出栈,一边重新遍历链表,一边比较两者元素值,只要有一个不相等,那就不是。方法3:优化方法2,先遍历第一遍,得到总长度。之后一边遍历链表,一边压栈。到达链表长度一半后就不再压栈,而是一边出栈,一边遍历,一边比较,只要有一个不相等,就不是回文链表。这样可以节省一半的空间。方法4:优化方法3:既然要得到长度,那还是要遍历一次链表才可以,那是不是可以一边遍历一边全部压栈,然后第二遍比较的时候,只比较一半的元素呢?也就是只有一半的元素出栈, 链表也只遍历一半,当然可以。方法5:反转链表法, 先创建一个链表newList,将原始链表oldList的元素值逆序保存到newList中,然后重新一边遍历两个链表,一遍比较元素的值,只要有一个位置的元素值不一样,就不是回文链表。方法6:优化方法5,我们只反转一半的元素就行了。先遍历一遍,得到总长度。然后重新遍历,到达一半的位置后不再反转,就开始比较两个链表。方法7:优化方法6,我们使用双指针思想里的快慢指针 ,fast一次走两步,slow一次走一步。当fast到达表尾的时候,slow正好到达一半的位置,那么接下来可以从头开始逆序一半的元素,或者从slow开始逆序一半的元素,都可以。方法8:在遍历的时候使用递归来反转一半链表可以吗?当然可以,再组合一下我们还能想出更多的方法,解决问题的思路不止这些了,此时单纯增加解法数量没啥意义了。上面这些解法中,各有缺点,实现难度也不一样,有的甚至算不上一个独立的方法,这么想只是为了开拓思路、举一反三。我们选择最佳的两种实现,其他方法请同学自行写一下试试。这里看一下比较基本的全部压栈的解法。将链表元素全部压栈,然后一边出栈,一边重新遍历链表,一边比较,只要有一个不相等,那就不是回文链表了,代码:public boolean isPalindrome(ListNode head){ ListNode temp = head; Stack<Integer> stack = new Stack(); while(temp != null){ stack.push(temp.val); temp = temp.next; } //之后一边出栈,一遍比较 while(head!=null){ if(head.val != stack.pop()){ return false; } head = head.next; } return true; } 由于在 C语言基础包里面没有提供栈类型,这里如果用集合的话需要自己先构建一个,或者引入外部包,更麻烦,因此这里不提供代码。3. 合并有序链表数组中我们研究过合并的问题,链表同样可以造出两个或者多个链表合并的问题。两者有相似的地方,也有不同的地方,你能找到分别是什么吗?3.1. 合并两个有序链表LeetCode21 将两个升序链表合并为一个新的升序链表并返回,新链表是通过拼接给定的两个链表的所有节点组成的。本题虽然不复杂,但是很多题目的基础,解决思路与数组一样,一般有两种。一种是新建一个链表,然后分别遍历两个链表,每次都选最小的结点接到新链表上,最后排完。另外一个就是将一个链表结点拆下来,逐个合并到另外一个对应位置上去。这个过程本身就是链表插入和删除操作的拓展,难度不算大,这时候代码是否优美就比较重要了。先看下面这种:public ListNode mergeTwoLists(ListNode list1,ListNode list2){ ListNode newHead = new ListNode(-1); ListNode res = newHead; while(list1!=null || list2 != null){ if(list1.val ) } } LeetCode21 将两个升序链表合并为一个新的升序链表并返回,新链表是通过拼接给定的两个链表的所有节点组成的。本题虽然不复杂,但是很多题目的基础,解决思路与数组一样,一般有两种。一种是新建一个链表,然后分别遍历两个链表,每次都选最小的结点接到新链表上,最后排完。另外一个就是将一个链表结点拆下来,逐个合并到另外一个对应位置上去。这个过程本身就是链表插入和删除操作的拓展,难度不算大,这时候代码是否优美就比较重要了。先看下面这种:/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode mergeTwoLists(ListNode list1, ListNode list2) { ListNode newHead=new ListNode(-1); ListNode res=newHead; while(list1!=null||list2!=null){ //情况1:都不为空的情况 if(list1!=null&&list2!=null){ if(list1.val<list2.val){ newHead.next=list1; list1=list1.next; }else if(list1.val>list2.val){ newHead.next=list2; list2=list2.next; }else{ //相等的情况,分别接两个链 newHead.next=list2; list2=list2.next; newHead=newHead.next; newHead.next=list1; list1=list1.next; } newHead=newHead.next; //情况2:假如还有链表一个不为空 }else if(list1!=null&&list2==null){ newHead.next=list1; list1=list1.next; newHead=newHead.next; }else if(list1==null&&list2!=null){ newHead.next=list2; list2=list2.next; newHead=newHead.next; } } return res.next; } } def mergeTwoLists(self, list1, list2): phead = ListNode(0) p = phead while list1 and list2: if list1.val <= list2.val: p.next = list1 list1 = list1.next else: p.next = list2 list2 = list2.next p = p.next if list1 is not None: p.next = list1 else: p.next = list2 return phead.next struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2){ struct ListNode* newHead = (struct ListNode*)malloc(sizeof(struct ListNode)); newHead->val = -1; newHead->next = NULL; struct ListNode* res = newHead; while (list1 != NULL || list2 != NULL) { // 情况1:都不为空的情况 if (list1 != NULL && list2 != NULL) { if (list1->val < list2->val) { newHead->next = list1; list1 = list1->next; } else if (list1->val > list2->val) { newHead->next = list2; list2 = list2->next; } else { // 相等的情况,分别接两个链 newHead->next = list2; list2 = list2->next; newHead = newHead->next; newHead->next = list1; list1 = list1->next; } newHead = newHead->next; // 情况2:假如还有链表一个不为空 } else if (list1 != NULL && list2 == NULL) { newHead->next = list1; list1 = list1->next; newHead = newHead->next; } else if (list1 == NULL && list2 != NULL) { newHead->next = list2; list2 = list2->next; newHead = newHead->next; } } return res->next; 上面Python代码比较简洁,但是Java和C版本代码明显比较臃肿。上面这种方式能完成基本的功能,但是所有的处理都在一个大while循环里,代码过于臃肿,我们可以将其变得苗条一些:第一个while只处理两个list 都不为空的情况,之后单独写while分别处理list1或者list2不为null的情况,也就是这样:public ListNode mergeTwoLists(ListNode list1, ListNode list2) { ListNode newHead = new ListNode(-1); ListNode res = newHead; while (list1 != null && list2 != null) { if (list1.val < list2.val) { newHead.next = list1; list1 = list1.next; } else if (list1.val > list2.val) { newHead.next = list2; list2 = list2.next; } else { //相等的情况,分别接两个链 newHead.next = list2; list2 = list2.next; newHead = newHead.next; newHead.next = list1; list1 = list1.next; } newHead = newHead.next; } //下面的两个while最多只有一个会执行 while (list1 != null) { newHead.next = list1; list1 = list1.next; newHead = newHead.next; } while (list2 != null) { newHead.next = list2; list2 = list2.next; newHead = newHead.next; } return res.next; } def mergeTwoLists(self, list1, list2): phead = ListNode(0) p = phead while list1 and list2: if list1.val <= list2.val: p.next = list1 list1 = list1.next else: p.next = list2 list2 = list2.next p = p.next while list1 is not None: p.next = list1 while list2 is not None: p.next = list2 return phead.next struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2){ struct ListNode* newHead = (struct ListNode*)malloc(sizeof(struct ListNode)); struct ListNode* res = newHead; while (list1 != NULL && list2 != NULL) { if (list1->val < list2->val) { newHead->next = list1; list1 = list1->next; } else if (list1->val > list2->val) { newHead->next = list2; list2 = list2->next; } else { newHead->next = list2; list2 = list2->next; newHead = newHead->next; newHead->next = list1; list1 = list1->next; } newHead = newHead->next; } while (list1 != NULL) { newHead->next = list1; list1 = list1->next; newHead = newHead->next; } while (list2 != NULL) { newHead->next = list2; list2 = list2->next; newHead = newHead->next; } return res->next; } 拓展 进一步优化代码进一步分析,我们发现两个继续优化的点,一个是上面第一个大while里有三种情况,我们可以将其合并成两个,如果两个链表存在相同元素,第一次出现时使用if (l1.val <= l2.val)来处理,后面一次则会被else处理掉,什么意思呢?我们看一个序列。假如list1为{1, 5, 8, 12},list2为{2, 5, 9, 13},此时都有一个node(5)。当两个链表都到5的位置时,出现了list1.val == list2.val,此时list1中的node(5)会被合并进来。然后list1继续向前走到了node(8),此时list2还是node(5),因此就会执行else中的代码块。这样就可以将第一个while的代码从三种变成两种,精简了很多。第二个优化是后面两个小的while循环,这两个while最多只有一个会执行,而且由于链表只要将链表头接好,后面的自然就接上了,因此循环都不用写,也就是这样:/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode mergeTwoLists(ListNode list1, ListNode list2){ ListNode node = new ListNode(); ListNode res = node; while(list1 != null && list2 !=null){ if(list1.val <= list2.val){ node.next = list1; list1 = list1.next; }else{ node.next = list2; list2 = list2.next; } node = node.next; } node.next = list1 == null ? list2:list1; return res.next; } } def mergeTwoLists(list1: ListNode, list2: ListNode) -> ListNode: prehead = ListNode(-1) prev = prehead while list1 != None and list2 != None: if list1.val <= list2.val: prev.next = list1 list1 = list1.next else: prev.next = list2 list2 = list2.next prev = prev.next # 最多只有一个还未被合并完,直接接上去就行了,这是链表合并比数组合并方便的地方 if list1 == None: prev.next = list2 else: prev.next = list1 return prehead.next struct ListNode* mergeTwoLists(struct ListNode* list1, struct ListNode* list2){ struct ListNode* prehead = (struct ListNode*)malloc(sizeof(struct ListNode)); struct ListNode* prev = prehead; while (list1 != NULL && list2 != NULL) { if (list1->val <= list2->val) { prev->next = list1; list1 = list1->next; } else { prev->next = list2; list2 = list2->next; } prev = prev->next; } prev->next = list1 == NULL ? list2 : list1; return prehead->next; } 这种方式很明显更高级,但是面试时很难考虑周全,如果面试的时候遇到了,建议先用上面第二种写法写出来,面试官满意之后,接着说“我还可以用更精简的方式来解决这个问题”,然后再写尝试写第三种,这样不但不用怕翻车,还可以锦上添花。3.2. 合并 K 和链表合并k个链表,有多种方式,例如堆、归并等等。如果面试遇到,我倾向先将前两个合并,之后再将后面的逐步合并进来,这样的的好处是只要将两个合并的写清楚,合并K个就容易很多,现场写最稳妥:public static ListNode mergeKLists(ListNode[] lists){ ListNode res = null; for(ListNode list:lists){ res = merageTwoLists(res,list); } } public ListNode mergeTwoLists(ListNode list1, ListNode list2){ ListNode node = new ListNode(-1); ListNode res = node; while(list1 != null && list2 !=null){ if(list1.val <= list2.val){ node.next = list1; list1 = list1.next; }else{ node.next = list2; list2 = list2.next; } node = node.next; } node.next = list1 == null ? list2:list1; return res.next; } class MergeKLists: def mergeKLists(self, lists): def mergeTwoLists(a, b): merge = ListNode(-1) head = merge while a and b: if a.val > b.val: head.next = b b = b.next else: head.next = a a = a.next head = head.next head.next = a if a else b return merge.next if len(lists) == 0: return None res = None for i in range(0, len(lists)): res = mergeTwoLists(res, lists[i]) return res struct ListNode* mergeKLists(struct ListNode* lists[], int size) { struct ListNode* res = NULL; for (int i = 0; i < size; i++) { res = mergeTwoLists(res, lists[i]); } return res; } 3.3. 一道很无聊的好题目LeetCode1669:给你两个链表 list1 和 list2 ,它们包含的元素分别为 n 个和 m 个。请你将 list1 中下标从a到b的节点删除,并将list2 接在被删除节点的位置。1669题的意思就是将list1中的[a,b]区间的删掉,然后将list2接进去,如下图所示:你觉得难吗?如果这也是算法的话,我至少可以造出七八道题,例如:(1)定义list1的[a,b]区间为list3,将list3和list2按照升序合并成一个链表。(2)list2也将区间[a,b]的元素删掉,然后将list1和list2合并成一个链表。(3)定义list2的[a,b]区间为list4,将list2和list4合并成有序链表。看到了吗?掌握基础是多么重要,我们自己都能造出题目来。这也是为什么算法会越刷越少,因为到后面会发现套路就这样,花样随便变,以不变应万变就是我们的宗旨。具体到这个题,按部就班遍历找到链表1保留部分的尾节点和链表2的尾节点,将两链表连接起来就行了。/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode mergeInBetween(ListNode list1, int a, int b, ListNode list2) { ListNode preA = list1; for(int i = 0; i < a - 1;i++){ preA = preA.next; } ListNode preB = preA; for(int i = 0; i < b - a + 2;i++){ preB = preB.next; } preA.next = list2; while(list2.next != null){ list2 = list2.next; } list2.next = preB; return list1; } } def mergeInBetween(self, list1, a, b, list2): dummy = ListNode(0) dummy.next = list1 tmp1 = tmp2 = dummy l1, l2 = a, b + 2 while l1: tmp1 = tmp1.next l1 -= 1 while l2: tmp2 = tmp2.next l2 -= 1 tmp1.next = list2 while list2.next: list2 = list2.next list2.next = tmp2 return dummy.next struct ListNode* mergeInBetween(struct ListNode* list1, int a, int b, struct ListNode* list2){ struct ListNode* pre1 = list1; struct ListNode* post1 = list1; struct ListNode* post2 = list2; int i = 0, j = 0; // 寻找list1中的节点pre1和post1 while (pre1 != NULL && post1 != NULL && j < b) { if (i != a - 1) { pre1 = pre1->next; i++; } if (j != b) { post1 = post1->next; j++; } } post1 = post1->next; // 寻找list2的尾节点 while (post2->next != NULL) { post2 = post2->next; } // 链1尾接链2头,链2尾接链1后半部分的头 pre1->next = list2; post2->next = post1; return list1; } 这里需要留意题目中是否有开闭区间的情况,例如如果是从a到b,那就是闭区间[a,b]。还有的会说一个开区间 (a,b),此时是不包括a和b两个元素,只需要处理a和b之间的元素就可以了。比较特殊的是进行分段处理的时候,例如K个一组处理,此时会用到左闭右开区间,也就是这样子[a,b),此时需要处理a,但是不用处理b,b是在下一个区间处理的。此类题目要非常小心左右边界的问题。除了使用栈,我们也可以使用链表反转的方法来做,而且可以只反转一半的链表,之后一边遍历剩下的部分一边来比较,该方式我们放到链表反转的白银挑战里。感兴趣的同学可以提前看一下。4. 双指针专题4.1. 寻找中间节点LeetCode876 给定一个头结点为 head 的非空单链表,返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点。示例1 输入:[1,2,3,4,5] 输出:此列表中的结点 3 示例2: 输入:[1,2,3,4,5,6] 输出:此列表中的结点 4 这个问题用经典的快慢指针可以轻松搞定,用两个指针 slow 与 fast 一起遍历链表。slow 一次走一步,fast 一次走两步。那么当 fast 到达链表的末尾时,slow 必然位于中间。这里还有个问题,就是偶数的时候该返回哪个,例如上面示例2返回的是4, 而3貌似也可以,那该使用哪个呢?如果我们使用标准的快慢指针就是后面的4,而在很多数组问题中会是前面的3,想一想为什么会这样。/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode middleNode(ListNode head) { ListNode slow = head,fast = head; while(fast != null && fast.next != null){ slow = slow.next; fast = fast.next.next; } return slow; } } class MiddleNode: def middleNode(self, head): if head is None: return None slow = head fast = head while fast and fast.next: slow = slow.next fast = fast.next.next return slow if __name__ == '__main__': # 先构造题目要求的链表,简单构造即可 nums = [1, 2, 3, 4, 5, 6] list = init_list(nums) middleNode = MiddleNode() node = middleNode.middleNode(list) print node.val struct ListNode* middleNode(struct ListNode* head) { struct ListNode* slow = head; struct ListNode* fast = head; while (fast != NULL && fast->next != NULL) { slow = slow->next; fast = fast->next->next; } return slow; } 4.2. 寻找倒数第 k 个元素这也是经典的快慢双指针问题,先看要求:输入一个链表,输出该链表中倒数第k个节点。本题从1开始计数,即链表的尾节点是倒数第1个节点。 示例 给定一个链表: 1->2->3->4->5, 和 k = 2. 返回链表 4->5. 这里也可以使用快慢双指针,我们先将fast 向后遍历到第 k+1 个节点, slow仍然指向链表的第一个节点,此时指针fast 与slow 二者之间刚好间隔 k 个节点。之后两个指针同步向后走,当 fast 走到链表的尾部空节点时,slow 指针刚好指向链表的倒数第k个节点。这里需要特别注意的是链表的长度可能小于k,寻找k位置的时候必须判断fast是否为null,这是本题的关键问题之一,最终代码如下:/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode middleNode(ListNode head) { ListNode slow = head,fast = head; while(fast != null && fast.next != null){ slow = slow.next; fast = fast.next.next; } return slow; } } class GetKthFromEnd: def getKthFromEnd(self, head: ListNode, k: int) -> ListNode: former, latter = head, head for _ in range(k): if not former: return former = former.next while former: former, latter = former.next, latter.next return latter 4.3. 旋转链表Leetcode61.先看题目要求:给你一个链表的头节点 head ,旋转链表,将链表每个节点向右移动 k 个位置。示例1: 输入:head = [1,2,3, 4,5], k = 2 输出:[4,5,1,2,3] 这个题有多种解决思路,首先想到的是根据题目要求硬写,但是这样比较麻烦,也容易错。这个题是否在数组里见过类似情况?观察链表调整前后的结构,我们可以发现从旋转位置开始,链表被分成了两条,例如上面的{1,2,3}和{4,5},这里我们可以参考上一题的倒数K的思路,找到这个位置,然后将两个链表调整一下重新接起来就行了。具体怎么调整呢?脑子里瞬间想到两种思路:第一种是将整个链表反转变成{5,4,3,2,1},然后再将前K和N-K两个部分分别反转,也就是分别变成了{4,5}和{1,2,3},这样就轻松解决了。这个在后面学习了链表反转之后,请读者自行解决。第二种思路就是先用双指针策略找到倒数K的位置,也就是{1,2,3}和{4,5}两个序列,之后再将两个链表拼接成{4,5,1,2,3}就行了。具体思路是:因为k有可能大于链表长度,所以首先获取一下链表长度len,如果然后k=k % len,如果k == 0,则不用旋转,直接返回头结点。否则:1.快指针先走k步。2.慢指针和快指针一起走。3.快指针走到链表尾部时,慢指针所在位置刚好是要断开的地方。把快指针指向的节点连到原链表头部,慢指针指向的节点断开和下一节点的联系。4.返回结束时慢指针指向节点的下一节点。public ListNode rotateRight(ListNode head,int k){ if(head == null || k == 0){ return head; } ListNode temp = head; ListNode fast = head; ListNode slow = head; int len = 0; //当头结点不为空的时候一直遍历,然后 len++ // 遍历完之后 head 为空 while(head != null){ head = head.next; len++; } if((k % len) == 0){ return temp; } //从这里 fast 开始移动,取模是为了防止 k 大于 len 的情况出现 while((k % len) > 0){ k--; fast = fast.next; } while(fast.next != null){ fast = fast.next; slow = slow.next; } ListNode res = slow.next; slow.next = null; fast.next = temp; return res; } 如果使用链表反转怎么做呢?学习完后面《链表反转以及相关问题》之后,再来看。struct ListNode* rotateRight(struct ListNode* head, int k) { if (head == NULL || k == 0) { return head; } //这里三个变量都指向链表头结点 struct ListNode* temp = head; struct ListNode* fast = head; struct ListNode* slow = head; int len = 0; //这里head先走一遍,统计出链表的元素个数,完成之后head就变成null了 while (head != NULL) { head = head->next; len++; } if (k % len == 0) { return temp; } // 从这里开始fast从头结点开始向后走 //这里使用取模,是为了防止k大于len的情况 //例如,如果len=5,那么k=2和7,效果是一样的 while ((k % len) > 0) { k--; fast = fast->next; } // 快指针走了k步了,然后快慢指针一起向后执行 // 当fast到尾结点的时候,slow刚好在倒数第K个位置上 while (fast->next != NULL) { fast = fast->next; slow = slow->next; } struct ListNode* res = slow->next; slow->next = NULL; fast->next = temp; return res; } 事实上,本题不用这么麻烦,假如我们要找倒数第K个,那就是要找正数第Len-k+1个。因此可以先遍历一遍,计算出LEN,然后直接通过计算得到需要走的步数,只会从头开始遍历,到第Len-k+1即可。当然,我们也要通过取模来计算防止K越界。我们这里只看一下Python的实现:class RotateRight: def rotateRight(self, head, k): if head is None or head.next is None: return head num = 0 basic_head = head //统计长度 while head: if head.next is None: head.next = basic_head num += 1 break num += 1 head = head.next # print(num) //这里直接计算需要向前走多少步 xx = num - (k % num) for i in range(xx): if i == (xx - 1): flag = basic_head basic_head = basic_head.next flag.next = None break basic_head = basic_head.next return basic_head if __name__ == '__main__': # 先构造题目要求的链表,简单构造即可 nums = [1, 2, 3, 4, 5] rotateRight = RotateRight() list = init_list(nums) node = rotateRight.rotateRight(list, 2) 5. 删除链表元素专题5.1. 删除特定节点先看一个简单的问题,LeetCode 203:给你一个链表的头节点 head 和一个整数 val ,请你删除链表中所有满足 Node.val == val 的节点,并返回新的头节点 。示例1: 输入:head = [1,2,6,3,4,5,6], val = 6 输出:[1,2,3,4,5] 我们前面说过,我们删除节点cur时,必须知道其前驱pre节点和后继next节点,然后让pre.next=next。这时候cur就脱离链表了,cur节点会在某个时刻被gc回收掉。对于删除,我们注意到首元素的处理方式与后面的不一样。为此,我们可以先创建一个虚拟节点 dummyHead,使其指向head,也就是dummyHead.next=head,这样就不用单独处理首节点了。完整的步骤是:●1.我们创建一个虚拟链表头dummyHead,使其next指向head。●2.开始循环链表寻找目标元素,注意这里是通过cur.next.val来判断的。●3.如果找到目标元素,就使用cur.next = cur.next.next;来删除。●4.注意最后返回的时候要用dummyHead.next,而不是dummyHead。代码实现过程:/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode removeElements(ListNode head, int val) { if(head == null){ return head; } ListNode temp = new ListNode(0); temp.next = head; ListNode cur = temp; while(cur.next != null){ if(cur.next.val == val){ cur.next = cur.next.next; continue; } cur = cur.next; } return temp.next; } } class RemoveElements: def removeElements(self, head, val): while head and head.val == val: head = head.next if head is None: return head node = head while node.next: if node.next.val == val: node.next = node.next.next else: node = node.next return head if __name__ == '__main__': array = [1, 2, 6, 3, 4, 5, 6] val = 6 head = init_list(array) removeElements = RemoveElements() node = removeElements.removeElements2(head, 6) print node struct ListNode* removeElements(struct ListNode* head, int val) { struct ListNode* dummyHead = (struct ListNode*) malloc(sizeof(struct ListNode)); dummyHead->val = 0; dummyHead->next = head; struct ListNode* cur = dummyHead; while (cur->next != NULL) { if (cur->next->val == val) { struct ListNode* temp = cur->next; cur->next = cur->next->next; free(temp); } else { cur = cur->next; } } struct ListNode* newHead = dummyHead->next; free(dummyHead); return newHead; } 我们继续看下面这两个题,其实就是一个题:LeetCode 19. 删除链表的倒数第 N 个节点LeetCode 1474. 删除链表 M 个节点之后的 N 个节点。既然要删除倒数第N个节点,那一定要先找到倒数第N个节点,前面已经介绍过,而这里不过是找到之后再将其删除。5.2. 删除倒数第 n 个结点LeetCode19题要求:给你一个链表,删除链表的倒数第n个结点,并且返回链表的头结点。进阶:你能尝试使用一趟扫描实现吗?我们前面说过,遇到一个题目可以先在脑子里快速过一下常用的数据结构和算法思想,看看哪些看上去能解决问题。为了开拓思维,我们看看能怎么做:第一种方法:先遍历一遍链表,找到链表总长度L,然后重新遍历,位置L-N+1的元素就是我们要删的。第二种方法:貌似栈可以,先将元素全部压栈,然后弹出第N个的时候就是我们要的是不?OK,又搞定一种方法。第三种方法:我们前面提到可以使用双指针 来寻找倒数第K,那这里同样可以用来寻找要删除的问题。上面三种方法,第一种比较常规,第二种方法需要开辟一个O(n)的空间,还要考虑栈与链表的操作等,不中看也不中用。第三种方法一次遍历就行,用双指针也有逼格。接下来我们详细看一下第一和三两种。方法1:计算链表长度首先从头节点开始对链表进行一次遍历,得到链表的长度 L。随后我们再从头节点开始对链表进行一次遍历,当遍历到第L−n+1 个节点时,它就是我们需要删除的节点。代码如下:public ListNode removeNthFromEnd(ListNode head,int n){ ListNode dummy = new ListNode(0); dummy.next = head; int length = getLength(head); ListNode cur =dummy; for(int i = 1; i < length - n + 1;i++){ cur = cur.next; } cur.next = cur.next.next; ListNode ans = dummy.next; return ans; } public int getLength(ListNode head){ int length = 0; while(head != null){ head = head.next; length++; } return length; e } class RemoveNthFromEnd: def removeNthFromEnd(self, head, n): def getLength(head): length = 0 while head: length += 1 head = head.next return length dummy = ListNode(0, head) length = getLength(head) cur = dummy for i in range(1, length - n + 1): cur = cur.next cur.next = cur.next.next return dummy.next struct ListNode* removeNthFromEnd(struct ListNode* head, int n) { struct ListNode* dummy = (struct ListNode*) malloc(sizeof(struct ListNode)); dummy->val = 0; dummy->next = head; int length = getLength(head); struct ListNode* cur = dummy; for (int i = 1; i < length - n + 1; ++i) { cur = cur->next; } struct ListNode* temp = cur->next; cur->next = cur->next->next; free(temp); struct ListNode* ans = dummy->next; free(dummy); return ans; } int getLength(struct ListNode* head) { int length = 0; while (head != NULL) { ++length; head = head->next; } return length; } 法2:双指针我们定义first和second两个指针,first先走N步,然后second再开始走,当first走到队尾的时候,second就是我们要的节点。代码如下:/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode removeNthFromEnd(ListNode head, int n) { ListNode dummy = new ListNode(0); dummy.next = head; ListNode fast = head; ListNode slow = dummy; for(int i = 0;i < n;i++){ fast = fast.next; } while(fast!=null){ fast = fast.next; slow = slow.next; } slow.next = slow.next.next; return dummy.next; } } def removeNthFromEnd(self, head, n): dummy = ListNode(0, head) fast = head slow = dummy for i in range(n): fast = fast.next while fast: fast = fast.next slow = slow.next slow.next = slow.next.next return dummy.next struct ListNode* removeNthFromEnd(struct ListNode* head, int n) { struct ListNode* dummy = (struct ListNode*) malloc(sizeof(struct ListNode)); dummy->val = 0; dummy->next = head; struct ListNode* first = head; struct ListNode* second = dummy; for (int i = 0; i < n; ++i) { first = first->next; } while (first != NULL) { first = first->next; second = second->next; } struct ListNode* temp = second->next; second->next = second->next->next; free(temp); struct ListNode* ans = dummy->next; free(dummy); return ans; } 5.3. 删除重复元素5.3.1. 重复元素保留一个我们继续看关于结点删除的题:LeetCode 83 存在一个按升序排列的链表,请你删除所有重复的元素,使每个元素只出现一次。LeetCode 82 存在一个按升序排列的链表,请你删除链表中所有存在数字重复情况的节点,只保留原始链表中没有重复出现的数字。两个题其实是一个,区别就是一个要将出现重复的保留一个,一个是只要重复都不要了,处理起来略有差别。LeetCode 1836是在82的基础上将链表改成无序的了,难度要增加不少,感兴趣的同学请自己研究一下。5.3.1 重复元素保留一个LeetCode83 存在一个按升序排列的链表,给你这个链表的头节点 head ,请你删除所有重复的元素,使每个元素只出现一次 。返回同样按升序排列的结果链表。示例1: 输入:head = [1,1,2,3,3] 输出:[1,2,3] 由于给定的链表是排好序的,因此重复的元素在链表中出现的位置是连续的,因此我们只需要对链表进行一次遍历,就可以删除重复的元素。具体地,我们从指针 cur 指向链表的头节点,随后开始对链表进行遍历。如果当前 cur 与cur.next 对应的元素相同,那么我们就将cur.next 从链表中移除;否则说明链表中已经不存在其它与cur 对应的元素相同的节点,因此可以将 cur 指向 cur.next。当遍历完整个链表之后,我们返回链表的头节点即可。另外要注意的是 当我们遍历到链表的最后一个节点时,cur.next 为空节点,此时要加以判断,上代码:public ListNode deleteDuplicates(ListNode head){ if(head == null){ return head; } ListNode cur = head; while(cur.next != null){ if(cur.val == cur.next.val){ cur.next = cur.next.next; }else{ cur = cur.next; } } return head; } class DeleteDuplicates: def deleteDuplicates(self, head): if not head: return head cur = head while cur.next: if cur.val == cur.next.val: cur.next = cur.next.next else: cur = cur.next return head if __name__ == '__main__': head = [1, 1, 2, 3, 3] list = init_list(head) deleteDuplicates = DeleteDuplicates() node = deleteDuplicates.deleteDuplicates(list) print node struct ListNode* deleteDuplicates(struct ListNode* head) { if (head == NULL) { return head; } struct ListNode* cur = head; while (cur->next != NULL) { if (cur->val == cur->next->val) { struct ListNode* temp = cur->next; cur->next = cur->next->next; free(temp); } else { cur = cur->next; } } return head; } 5.3.2. 重复元素都不要LeetCode82:这个题目的要求与83的区别仅仅是重复的元素都不要了。例如:示例1: 输入:head = [1,2,3,3,4,4,5] 输出:[1,2,5] 当一个都不要时,链表只要直接对cur.next 以及 cur.next.next 两个node进行比较就行了,这里要注意两个node可能为空,稍加判断就行了。public ListNode deleteDuplicates(ListNode head){ if(head == null){ return head; } ListNode dummy = new ListNode(0); ListNode cur = dummy; while(cur.next != null && cur.next.next != null){ if(cur.next.val == cur.next.next.val){ int x= cur.next.val; while(cur.next != null && cur.next.val == x){ cur.next = cur.next.next; } }else{ cur = cur.next; } } return dummy.next; } class DeleteDuplicates: def deleteDuplicates(self, head) : if not head: return head dummy = ListNode(0, head) cur = dummy while cur.next and cur.next.next: if cur.next.val == cur.next.next.val: x = cur.next.val while cur.next and cur.next.val == x: cur.next = cur.next.next else: cur = cur.next return dummy.next struct ListNode* deleteDuplicates(struct ListNode* head) { if (head == NULL) { return head; } struct ListNode* dummy = (struct ListNode*)malloc(sizeof(struct ListNode)); dummy->val = 0; dummy->next = head; struct ListNode* cur = dummy; while (cur->next != NULL && cur->next->next != NULL) { if (cur->next->val == cur->next->next->val) { int x = cur->next->val; while (cur->next != NULL && cur->next->val == x) { struct ListNode* temp = cur->next; cur->next = cur->next->next; free(temp); } } else { cur = cur->next; } } return dummy->next; } 如果链表是未排序的该怎么办呢?如果先排序再操作代价太高了,感兴趣的同学可以继续研究一下。6. 再论第一个公共子节点问题6.1. 差和双指针设「第一个公共节点」为 node ,「链表 headA」的节点数量为 a ,「链表 headB」的节点数量为 b ,「两链表的公共尾部」的节点数量为 c,则有:头节点 headA 到 node 前,共有 a−c 个节点;头节点 headB 到 node 前,共有 b−c个节点;考虑构建两个节点指针 A,B ,分别指向两链表头节点 headA , headB ,做如下操作:指针 A 先遍历完链表 headA ,再开始遍历链表 headB ,当走到 node 时,共走步数为:a + (b - c)指针 B 先遍历完链表 headB。再开始遍历链表 headA,当走到 node 的时候,一共走了 b + (a - c)如下式所示,此时指针 A,B 重合,并且有两种情况:a+(b−c)=b+(a−c)若两链表 有 公共尾部(即 c > 0):指针 A,B 同时指向【第一个公共节点】 node.若两链表 无 公共尾部(即 c = 0):指针 A,B 同时指向 null。因此返回 A 就可以了。如下图所示:a = 5 , b = 3, c= 2 示例的算法执行过程。以下是对应的代码,我个人推荐是这个代码,因为其双指针思想更加巧妙一点。class Solution { ListNode getIntersectionNode(ListNode headA, ListNode headB) { ListNode A = headA,B = headB; while(A!=B){ A = A != null ? A.next:headB; B = B != null ? B.next:headA; } return A; } } 6.2. 拼接两个字符串先看一下下面的链表 A 和 B:A:0-1-2-3-4-5B :a - b -4 -5如果分别拼接成 AB 和 BA 会怎么样呢?AB:0-1-2-3-4-5-A-B-4-5BA:a-b-4-5-0-1-2-3-4-5我们可以发现,拼接从最后的4开始,两个链表是一样的,自然 4 就是我们要找的节点,所以可以通过拼接的方式来寻找交点。这么做的道理是什么?我们从几何的角度来分析。我们假定A和B有相交的位置,以交点为中心,可以将两个链表分别分为left_a和right_a,left_b和right_b这样四个部分,并且right_a和right_b是一样的,这时候我们拼接AB和BA就是这样的结构:我们说 right_a 和 right_b 是一样的,那这个时候分别遍历AB和BA是不是从某个位置开始恰好就找到了相交的点了?这里还可以进一步优化,如果建立新的链表太浪费空间了,我们只要在每个链表访问完了之后,调整到一下链表的表头继续遍历就行了,于是代码就出来了:这里很多人会对为什么循环体里if(p1!=p2)这个 判断有什么作用,我们在代码后面解释public ListNode findFirstCommonNode(ListNode pHead1,ListNode phead2){ if(pHead1 == null || pHead2 == null){ return null; } ListNode p1 = pHead1; ListNode p2 = phead2; while(p1 != p2){ p1 = p1.next; p2 = p2.next; if(p1 != p2){ if(p1 == null){ p1=pHead2; } if(p2 == null){ p2 =pHead1; } } } } 这种方式使用了两个指针,其实和第一种算法是差不多的,也可以算作双指针。接下来来解释一下为什么循环体里面需要判断 p1 != p2。简单来说,如果序列不存在交集的时候就陷入死循环了,例如 list1 是 1 2 3,list2 是 4 5,很明显,如果不加判断的话,list1 和 list2 会不断陷入循环,导致出不来。
0
0
0
浏览量979
时光小少年

第 4 关 | 站不住的栈:1.青铜挑战——理解手写栈

1. 栈基础知识1.1. 栈的特征栈和队列都是比较特殊的线性表,又称之为访问受限的线性表。栈是很多表达式,符号等运算的基础,也是递归的底层实现,理论上递归能做的题目,栈都可以,只是有限问题用栈会非常复杂。栈底层实现仍然是链表或者顺序表,栈和线性表的最大区别是数据的存取的操作被限制了,其插入和删除只允许在线性表的一段进行。一般而言,把允许操作的一端称为栈顶(Top),不可操作的一端称为栈底(Bottom),同时把插入元素的操作称为入栈(Push),把删除元素的操作称为出栈(Pop)。如果栈中没有任何元素,则称为空栈,栈的数据结构如下图所示:1.2. 栈的操作栈的常用操作主要有:push (E): 增加一个元素 Epop:弹出栈顶的元素 Epeek():显示栈顶元素,但是不出栈empty():判断栈是否为空我们在设计自己的栈的时候,不管用数组还是链表,都要实现以上几个方法。如何向测试一下自己对于栈的理解,只需要做下这道题就可以了:入栈顺序为 1234,所有可能得出栈序列是什么?这个题是什么意思呢?比如说,我先让 1 和 2 出栈,然后 2 和 1 出栈,然后再让 3 和 4 入栈后再依次出栈,这样就可以得到序列 2143。4 中元素的全排列一共有 24 种,栈要求符合先进后出,后进先出(FILO), 按照此标准,可以衡量排除后得到以下序列:1234 √ 1243 √ 1324 √ 1342 √ 1423 × 1432 √2134 √ 2143 √ 2314 √ 2341 √ 2413 × 2431 √3124 × 3142 × 3214 √ 3241 √ 3412 × 3421 √4123 × 4132 × 4213 × 4231 × 4312 × 4321 √14 种可能,10 种不可能,如上图所示。1.3. Java 中的栈Java 的 util 就提供了栈 Stack 类,所以使用并不复杂,看以下这一个例子就可以了:public class MainTest { public static void main(String[] args) { // 栈的构建 Stack<Integer> stack = new Stack<>(); // 压栈 stack.push(1); stack.push(2); stack.push(3); // 查看栈顶元素 System.out.println("栈顶元素为:" + stack.peek()); System.out.println("--------------------------------"); while (!stack.empty()) { // 对于栈顶元素进行显示,但是不出栈 System.out.println("栈顶元素:" + stack.peek()); // 出栈并且显示 System.out.println("出栈元素:" + stack.pop()); } } } 2. 基于数组实现栈2.1. 理论再看具体内容之前,先补充一点, top 有的地方直接指向栈顶元素,有的地方直接指向栈顶往上的一个空单位,这个根据题目要求设计就好了,如果是面试的时候就直接问面试官,top 指向哪里,本文采用指向栈顶的空位置。如果要自己实现,可以有数组,链表和 Java 提供的 LinkList 三种基本方式,我们都看一下。采用顺序表实现的栈,内部以数组为基础,实现对元素的存取操作。在应用中还要注意每次入栈前先判断栈的容量是否够用,如果不够用,可以进行扩容。入栈过程如下:出栈过程如图所示:2.2. 实操top 先将栈顶元素取出,然后执行 top-- ,实现的完整代码如下:public class MyStackByArray<T> { /** * 实现栈的数组 */ private Object[] stack; /** * 栈顶元素 */ private int top; public MyStackByArray() { // 初始化栈的容量为 10 stack = new Object[10]; } /** * 判断栈是否为空 */ public boolean isEmpty() { return top == 0; } /** * 返回栈顶元素,但是不出栈 */ public T peek() { T t = null; if (top > 0) { t = (T) stack[top - 1]; } return t; } /** * 入栈 * * @param t */ public void push(T t) { if (t == null) { System.out.println("插入元素不能为空"); return; } extendCapacity(top + 1); stack[top] = t; top++; System.out.println("数据 " + t + " 插入数据成功"); } /** * 出栈 * * @return 出栈元素 */ public T pop() { T t = peek(); if (top > 0) { stack[top - 1] = null; top--; } return t; } /** * 扩大容量 */ public void extendCapacity(int size) { int len = stack.length; if (size > len) { // 每次扩大 50 % size = size * 3 / 2 + 1; stack = Arrays.copyOf(stack, size); } } } 2.3. 测试public class MyStackByArrayTest { @Test public void testStack() { MyStackByArray<String> stack = new MyStackByArray<>(); System.out.println("栈顶元素:" + stack.peek()); System.out.println(stack.isEmpty()); stack.push("Java"); stack.push(null); stack.push("Python"); stack.push("C++"); System.out.println("----------------"); while (!stack.isEmpty()) { System.out.println("栈顶元素为:" + stack.peek()); System.out.println("出栈元素为:" + stack.pop()); } } } 3. 基于链表实现栈3.1. 原理链表其实可以使用栈实现,然后插入和删除都在头结点进行,如下图所示在链表那一章,我们介绍过没有虚拟节点时对链表头元素进行插入和删除操作的方法,而与这里基于链表实现栈完全一致。3.2. 实操代码实现也不复杂,完整实现如下:public class ListStack<T> { private class Node<T> { public T data; public Node next; } public Node<T> head; /** * 构造函数初始化指针 */ public ListStack() { head = null; } /** * 入栈 * * @param t */ public void push(T t) { if (t == null) { System.out.println("插入元素不能为空"); } // 如果头结点为空 if (head == null) { head = new Node<T>(); head.data = t; head.next = null; } else { // 创建一个临时节点,用于存放头结点 Node<T> temp = head; head = new Node<>(); head.data = t; head.next = temp; } } /** * 出栈 */ public T pop() { if (head == null) { System.out.println("头结点为空,返回元素为 null ."); return null; } T temp = head.data; head = head.next; return temp; } /** * 获取栈顶元素 */ public T peek() { if (head == null) { System.out.println("头结点元素为空"); return null; } T temp = head.data; return temp; } /** * 栈空 */ public boolean isEmpty() { if (head == null) { return true; } return false; } } 3.3. 测试public class ListStackTest { @Test public void testListStack() { ListStack<String> stack = new ListStack<String>(); System.out.println("链表是否为空:" + stack.isEmpty()); stack.push("Java"); stack.push(null); stack.push("Python"); stack.push("C++"); stack.push("C"); System.out.println("栈顶元素:"+stack.peek()); System.out.println("---------------------------"); while (!stack.isEmpty()) { System.out.println("出栈元素:" + stack.pop()); } } }
0
0
0
浏览量2021
时光小少年

Leetcode 刷题笔记:27.移除元素

1. 题目:27.移除元素从现在开始,组件属于我们自己的力扣刷题笔记,从最开始的两数之和开始,刷完力扣所有的题目,今天带来的题目是力扣的第 27 题:移除元素,一起加油!!!2. 题意给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素,并返回移除后数组的新长度。不要使用额外的数组空间,你必须仅使用 O(1) 额外空间并 原地 修改输入数组。元素的顺序可以改变。你不需要考虑数组中超出新长度后面的元素。说明:为什么返回数值是整数,但输出的答案是数组呢?请注意,输入数组是以**「引用」**方式传递的,这意味着在函数里修改输入数组对于调用者是可见的。你可以想象内部操作如下:// nums 是以“引用”方式传递的。也就是说,不对实参作任何拷贝 int len = removeElement(nums, val); // 在函数里修改输入数组对于调用者是可见的。 // 根据你的函数返回的长度, 它会打印出数组中 该长度范围内 的所有元素。 for (int i = 0; i < len; i++) { print(nums[i]); } 3. 示例示例 1: 输入:nums = [3,2,2,3], val = 3 输出:2, nums = [2,2] 解释:函数应该返回新的长度 2, 并且 nums 中的前两个元素均为 2。你不需要考虑数组中超出新长度后面的元素。例如,函数返回的新长度为 2 ,而 nums = [2,2,3,3] 或 nums = [2,2,0,0],也会被视作正确答案。 示例 2: 输入:nums = [0,1,2,2,3,0,4,2], val = 2 输出:5, nums = [0,1,4,0,3] 解释:函数应该返回新的长度 5, 并且 nums 中的前五个元素为 0, 1, 3, 0, 4。注意这五个元素可为任意顺序。你不需要考虑数组中超出新长度后面的元素。 4. 分析看看本题的关键词语: 原地 、 额外空间、返回移除后数组的新长度。是不是和我们刚刚做过的 026.删除有序数组中的重复项 很相似呢?事实上两题的思路也基本一致,如果题目的条件换成是链表,显而易见,只需要判断当下个节点的val与给定的val是否相等,相等的话,直接删除下个节点即可,但题目给我们的条件数组。放掉 原地 、额外空间 这两个条件,我们可以很快的写出来一个不错的题解。class Solution { public int removeElement(int[] nums, int val) { int j = 0; int[] res = new int[nums.length]; for(int i = 0;i < nums.length;i++) if(nums[i] != val) res[j++] = nums[i]; return j; } } 实际上 i 一直都大于等于 j ,并且每次需要判断的元素只有nums[i],所以其实方法中出现的 res 数组是多余的,我们完全可以利用已经判断过的数字,也就是nums[0 ~ i - 1]来存储res数组的内容,所以可以改进一下:class Solution { public int removeElement(int[] nums, int val) { int j = 0; for(int i = 0;i < nums.length;i++){ if(nums[i]!=val){ nums[j++] = nums[i]; } } return j; } } 轻松解决!5. 总结本题其实很容易就能从 026.删除有序数组中的重复项 获得灵感,Leetcode中的很多题目都是如此,比如 001.两数之和、015.三数之和、016.最接近的三数之和、018.四数之和,同样,面试的时候我们也会遇到的形形色色的问题,其中肯定就会存在一些在我们平时训练的题目基础上改编的题目,或者就是以我们常见的编程思想和数据结构来“改造”的题目,所以认真对待平时训练的题目,可能下一次它就披着另外一种面纱出现在我们的面前。
0
0
0
浏览量329
时光小少年

第 18 关 | 经典刷题思想之回溯: 2.白银挑战—回溯热门问题

回溯主要解决一些暴力枚举也搞不定的问题,例如组合、分割、子集、排列,棋盘等。这一关我们就看几个例子。关卡名回溯热门问题我会了✔️内容1.  组合总和问题✔️2.  分割回文串问题✔️3.  子集问题✔️4.  排列问题✔️5.  字母全排列问题✔️6.  单词搜索✔️1. 组合总问题LeetCode39题目要求:给你一个无重复元素的整数数组candidates和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有不同组合 ,并以列表形式返回。你可以按任意顺序返回这些组合。candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。 数组中的元素满足1 <= candidates[i] <= 200。例子:输入:candidates = [2,3,6,7], target = 7 输出:[[2,2,3],[7]] 解释: 2 和 3 可以形成一组候选,2 + 2 + 3 = 7 。注意2可以使用多次。 7 也是一个候选, 7 = 7 ,仅有这两种组合。 如果不考虑重复,本题与LeetCode113题就是一个题,如果可以重复,那是否会无限制取下去呢?也不会,因为题目给了说明,每个元素最小为1,因此最多也就target个1。我们画图看该怎么做,对于序列{2,3,6,7},target=7。很显然我们可以先选择一个2,然后剩下的target就是7-2=5。再选一个2,剩余5-2=3。之后再选一个2,剩余3-2=1。已经小于2了,我们不能继续向下了,要返回一下。看看有没有3。OK,序列中有3,那么就得到了第一个结果{2,2,3}。之后我们继续回退到只选了一个2的时候,这时候不能再取2了,而是从{3,6,7}中选择,如下图所示,没有符合要求的!依次类推,后面尝试从3、6和7开始选择。所以我们最终得到的结果就是{2,2,3}和{2,5}。为了方便,我们可以先对元素做个排序,然后将上面的过程画成这个一个树形图:这个图横向是针对每个元素的暴力枚举,纵向是递归,也是一个纵横问题,实现代码也不复杂:class CombinationSum { List<List<Integer>> res = new ArrayList<>(); //记录答案 List<Integer> path = new ArrayList<>(); //记录当前正在访问的路径 public List<List<Integer>> combinationSum(int[] candidates, int target) { dfs(candidates,0, target); return res; } public void dfs(int[] c, int u, int target) { if(target < 0){ return ; } if(target == 0) { res.add(new ArrayList(path)); return ; } for(int i = u; i < c.length; i++){ if( c[i] <= target) { path.add(c[i]); //当前层将target减掉了一部分,也就是子结构只要找是否有满足(target - c[i])就可以了 dfs(c,i,target - c[i]); // 因为可以重复使用,所以还是i path.remove(path.size()-1); //回溯 } } } } 如果用Python,代码会简洁很多:def combinationSum(self, candidates, target) : res = [] path = [] def backtrack(candidates,target,sum,startIndex): if sum > target: return if sum == target: return res.append(path[:]) for i in range(startIndex,len(candidates)): #如果 sum + candidates[i] > target 就终止遍历 if sum + candidates[i] >target: return sum += candidates[i] path.append(candidates[i]) #startIndex = i:表示可以重复读取当前的数 backtrack(candidates,target,sum,i) sum -= candidates[i] #回溯 path.pop() #回溯 candidates = sorted(candidates) #需要排序 backtrack(candidates,target,0,0) return res vector<vector<int>> combinationSum(vector<int>& candidates, int target) { vector<vector<int>> res; vector<int> path; dfs(candidates, 0, target, res, path); return res; } void dfs(vector<int>& c, int u, int target, vector<vector<int>>& res, vector<int>& path) { if (target < 0) { return; } if (target == 0) { res.push_back(path); return; } for (int i = u; i < c.size(); i++) { if (c[i] <= target) { path.push_back(c[i]); dfs(c, i, target - c[i], res, path); path.pop_back(); // 回溯,弹出当前节点 } } } 2. 分割回文串问题分割问题也是回溯要解决的典型问题之一,常见的题目有分割回文串、分割IP地址,以及分割字符串等。LeetCode131 分割回文串,给你一个字符串s,请你将s分割成一些子串,使每个子串都是回文串 ,返回s所有可能的分割方案。回文串是正着读和反着读都一样的字符串。示例1: 输入:s = "aab" 输出:[["a","a","b"],["aa","b"]] 字符串如何判断回文本身就是一个道算法题,本题在其之上还要再解决一个问题:如何切割?如果暴力切割,是非常困难的,如果从回溯的角度来思考就清晰很多:我们说回溯本身仍然会进行枚举,这里的也一样。切割线(就是图中的红线)切割到字符串的结尾位置,说明找到了一个切割方法。这里就是先试一试,第一次切'a',第二次切'aa',第三次切'aab'。这对应的就是回溯里的for循环,也就是横向方面。我们还说回溯仍然会进行递归,这里也是一样的,第一次切了'a',剩下的就是'ab'。递归就是再将其再切一个回文下来,也就是第二个'a',剩下的'b'再交给递归进一步切割。这就是纵向方面要干的事情,其他以此类推。至于回溯操作与前面是一样的道理,不再赘述。通过代码就可以发现,切割问题的回溯搜索的过程和组合问题的回溯搜索的过程是差不多的。class Partition { List<List<String>> lists = new ArrayList<>(); Deque<String> deque = new LinkedList<>(); public List<List<String>> partition(String s) { backTracking(s, 0); return lists; } private void backTracking(String s, int startIndex) { //如果起始位置大于s的大小,说明找到了一组分割方案 if (startIndex >= s.length()) { lists.add(new ArrayList(deque)); return; } for (int i = startIndex; i < s.length(); i++) { //如果是回文子串,则记录 if (isPalindrome(s, startIndex, i)) { String str = s.substring(startIndex, i + 1); deque.addLast(str); } else { continue; } //起始位置后移,保证不重复 backTracking(s, i + 1); deque.removeLast(); } } //判断是否是回文串 private boolean isPalindrome(String s, int startIndex, int end) { for (int i = startIndex, j = end; i < j; i++, j--) { if (s.charAt(i) != s.charAt(j)) { return false; } } return true; } } class Partition: def __init__(self): self.paths = [] self.path = [] def partition(self, s) : ''' 当切割线迭代至字符串末尾,说明找到一种方法 类似组合问题,为了不重复切割同一位置,需要start_index来做标记下一轮递归的起始位置(切割线) ''' self.path.clear() self.paths.clear() self.backtracking(s, 0) return self.paths def backtracking(self, s: str, start_index: int) -> None: # Base Case if start_index >= len(s): self.paths.append(self.path[:]) return # 单层递归逻辑 for i in range(start_index, len(s)): # 此次比其他组合题目多了一步判断: # 判断被截取的这一段子串([start_index, i])是否为回文串 if self.is_palindrome(s, start_index, i): self.path.append(s[start_index:i+1]) self.backtracking(s, i+1) # 递归纵向遍历:从下一处进行切割,判断其余是否仍为回文串 self.path.pop() # 回溯 else: continue def is_palindrome(self, s: str, start: int, end: int) -> bool: i: int = start j: int = end while i < j: if s[i] != s[j]: return False i += 1 j -= 1 return True bool isPalindrome(string s, int startIndex, int end) { for (int i = startIndex, j = end; i < j; i++, j--) { if (s[i] != s[j]) { return false; } } return true; } void backTracking(string s, int startIndex, vector<vector<string>>& lists, stack<string>& deque) { if (startIndex >= s.length()) { lists.push_back(deque.empty() ? vector<string>() : std::vector<string>(deque.top())); deque.pop(); return; } for (int i = startIndex; i < s.length(); i++) { if (isPalindrome(s, startIndex, i)) { string str = s.substr(startIndex, i - startIndex + 1); deque.push(str); } else { continue; } backTracking(s, i + 1, lists, deque); deque.pop(); } } vector<vector<string>> partition(string s) { vector<vector<string>> lists; stack<string> deque; backTracking(s, 0, lists, deque); return lists; } 3. 子集问题子集问题也是回溯的经典使用场景。回溯可以画成一种树状结构,子集、组合、分割问题都可以抽象为一棵树,但是子集问题与其他的类型有个明显的区别,组合问题一般找到满足要求的结果即可,而集合则要找出所有的情况。LeetCode78,给你一个整数数组nums,数组中的元素互不相同。返回该数组所有可能的子集(幂集)。解集不能包含重复的子集。你可以按任意顺序返回解集。示例1: 输入:nums = [1,2,3] 输出:[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]] 看上面的例子nums = [1,2,3],将子集抽象为树型结构如下:从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。这里什么时候要停下来呢?其实可以不加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了。而且求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。这样实现起来也比较容易。class Subsets { // 存放符合条件结果的集合 List<List<Integer>> result = new ArrayList<>(); // 用来存放符合条件结果 LinkedList<Integer> path = new LinkedList<>(); public List<List<Integer>> subsets(int[] nums) { //空集合也是一个子集 if (nums.length == 0){ result.add(new ArrayList<>()); return result; } subsetsHelper(nums, 0); return result; } private void subsetsHelper(int[] nums, int startIndex){ //「遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合」。 result.add(new ArrayList<>(path)); if (startIndex >= nums.length){ return; } for (int i = startIndex; i < nums.length; i++){ path.add(nums[i]); subsetsHelper(nums, i + 1); path.removeLast(); } } } class Subsets: def __init__(self): self.path: List[int] = [] self.paths: List[List[int]] = [] def subsets(self, nums) : self.paths.clear() self.path.clear() self.backtracking(nums, 0) return self.paths def backtracking(self, nums, start_index) : # 收集子集,要先于终止判断 self.paths.append(self.path[:]) # Base Case if start_index == len(nums): return # 单层递归逻辑 for i in range(start_index, len(nums)): self.path.append(nums[i]) self.backtracking(nums, i+1) self.path.pop() # 回溯 class Subsets { vector<vector<int>> result; vector<int> path; void backtracking(vector<int>& nums, int startIndex) { result.push_back(path); // 收集子集,要放在终止添加的上面,否则会漏掉自己 if (startIndex >= nums.size()) { // 终止条件可以不加 return; } for (int i = startIndex; i < nums.size(); i++) { path.push_back(nums[i]); backtracking(nums, i + 1); path.pop_back(); } } vector<vector<int>> subsets(vector<int>& nums) { result.clear(); path.clear(); backtracking(nums, 0); return result; } }; 上面代码里定义了全局变量result和path,这样整体比较简洁。这里可以转换成方法参数的形式,只是看起来比较复杂一些。4. 排列问题LeetCode46.给定一个没有重复数字的序列,返回其所有可能的全排列。例如:排列问题是典型的小学生都会,但是难道众人的问题。这个问题与前面组合等问题的一个区别是使用过的后面还要再用,例如1,在开始使用了,但是到了2 和3的时候仍然要再使用一次。这本质上是因为 [1,2] 和 [2,1] 从集合的角度看是一个,但从排列的角度看是两个。元素1在[1,2]中已经使用过了,但是在[2,1]中还要再使用一次,所以就不能使用startIndex了,为此可以使用一个used数组来标记已经选择的元素,完整过程如图所示:这里的终止条件怎么判断呢?从上图可以看出叶子节点就是结果。那什么时候是到达叶子节点呢?当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。class Permute { List<List<Integer>> result = new ArrayList<>(); LinkedList<Integer> path = new LinkedList<>(); boolean[] used; public List<List<Integer>> permute(int[] nums) { if (nums.length == 0){ return result; } used = new boolean[nums.length]; permuteHelper(nums); return result; } private void permuteHelper(int[] nums){ if (path.size() == nums.length){ result.add(new ArrayList<>(path)); return; } for (int i = 0; i < nums.length; i++){ if (used[i]){ continue; } used[i] = true; path.add(nums[i]); permuteHelper(nums); path.removeLast(); used[i] = false; } } } class Permute: def __init__(self): self.path = [] self.paths = [] def permute(self, nums) : ''' 因为本题排列是有序的,这意味着同一层的元素可以重复使用,但同一树枝上不能重复使用(usage_list) 所以处理排列问题每层都需要从头搜索,故不再使用start_index ''' usage_list = [False] * len(nums) self.backtracking(nums, usage_list) return self.paths def backtracking(self, nums, usage_list) : # Base Case本题求叶子节点 if len(self.path) == len(nums): self.paths.append(self.path[:]) return # 单层递归逻辑 for i in range(0, len(nums)): # 从头开始搜索 # 若遇到self.path里已收录的元素,跳过 if usage_list[i] == True: continue usage_list[i] = True self.path.append(nums[i]) self.backtracking(nums, usage_list) # 纵向传递使用信息,去重 self.path.pop() usage_list[i] = False class Permute { vector<vector<int>> result; vector<int> path; void backtracking (vector<int>& nums, vector<bool>& used) { // 此时说明找到了一组 if (path.size() == nums.size()) { result.push_back(path); return; } for (int i = 0; i < nums.size(); i++) { if (used[i] == true) continue; // path里已经收录的元素,直接跳过 used[i] = true; path.push_back(nums[i]); backtracking(nums, used); path.pop_back(); used[i] = false; } } vector<vector<int>> permute(vector<int>& nums) { result.clear(); path.clear(); vector<bool> used(nums.size(), false); backtracking(nums, used); return result; } }; 5. 字母全排列问题LeetCode784. 字母大小写全排列:给定一个字符串 s ,通过将字符串 s 中的每个字母转变大小写,我们可以获得一个新的字符串。返回所有可能得到的字符串集合。以任意顺序返回输出。示例1:输入:s = "a1b2" 输出:["a1b2", "a1B2", "A1b2", "A1B2"] 如果本题去掉数字,只告诉你两个字母ab,让你每个字母变化大小写,那就是ab、Ab、aB、AB四种情况,题目比2.6电话号码问题还简单,这里的数字就是干扰项,我们需要做的是过滤掉数字, 只处理字母。另外还要添加个大小写转换的问题。如下图所示:由于每个字符的大小写形式刚好差了32,因此在大小写装换时可以用c⊕32 来进行转换和恢复。代码如下:class LetterCasePermutation { public List<String> letterCasePermutation(String s) { List<String> ans = new ArrayList<String>(); dfs(s.toCharArray(), 0, ans); return ans; } public void dfs(char[] arr, int pos, List<String> res) { while (pos < arr.length && Character.isDigit(arr[pos])) { pos++; } if (pos == arr.length) { res.add(new String(arr)); return; } arr[pos] ^= 32; dfs(arr, pos + 1, res); arr[pos] ^= 32; dfs(arr, pos + 1, res); } } class LetterCasePermutation: def letterCasePermutation(self, s: str) -> List[str]: ans = [] def dfs(s: List[str], pos: int) -> None: while pos < len(s) and s[pos].isdigit(): pos += 1 if pos == len(s): ans.append(''.join(s)) return dfs(s, pos + 1) s[pos] = s[pos].swapcase() dfs(s, pos + 1) s[pos] = s[pos].swapcase() dfs(list(s), 0) return ans class LetterCasePermutation { public: void dfs(string &s, int pos, vector<string> &res) { while (pos < s.size() && isdigit(s[pos])) { pos++; } if (pos == s.size()) { res.emplace_back(s); return; } s[pos] ^= 32; dfs(s, pos + 1, res); s[pos] ^= 32; dfs(s, pos + 1, res); } vector<string> letterCasePermutation(string s) { vector<string> ans; dfs(s, 0, ans); return ans; } }; 6. 单词搜索LeetCode79.给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。示例1: 输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED" 输出:true 思路:从上到下,左到右遍历网格,每个坐标递归调用check(i, j, k)函数,i,j表示网格坐标,k表示word的第k个字符,如果能搜索到第k个字符返回true,否则返回false,check函数的终止条件有2种情况1如果i,j位置的字符和字符串位置k的字符不相等,则这条搜索路径搜索失败 返回false2如果搜索到了字符串的结尾,则找到了网格中的一条路径,这条路径上的字符正好可以组成字符串s两种情况都不满足则把当前网格节点加入visited数组,visited表示节点已经访问过了,然后顺着当前网格坐标的四个方向继续尝试,如果没找到k开始的子串,则回溯状态visited[i] [j] = false,继续后面的尝试。复杂度分析:时间复杂度O(MN⋅3^L),M,N 为网格的长度与宽度,L 为字符串 word 的长度,第一次调用check函数的时候,进行4个方向的检查,其余坐标的节点都是3个方向检查,走过来的分支不会反方向回去,所以check函数的时间复杂度是3^L,而网格有M*N个坐标,且存在剪枝,所以最坏的情况下时间复杂度是O(MN⋅3^L)。空间复杂度是O(MN),visited数组空间是O(MN),check递归栈的最大深度在最坏的情况下是O(MN)class Solution { public boolean exist(char[][] board, String word) { int h = board.length, w = board[0].length; boolean[][] visited = new boolean[h][w]; for (int i = 0; i < h; i++) { for (int j = 0; j < w; j++) { boolean flag = check(board, visited, i, j, word, 0); if (flag) { return true; } } } return false; } public boolean check(char[][] board, boolean[][] visited, int i, int j, String s, int k) { if (board[i][j] != s.charAt(k)) { return false; } else if (k == s.length() - 1) { return true; } visited[i][j] = true; int[][] directions = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}; boolean result = false; for (int[] dir : directions) { int newi = i + dir[0], newj = j + dir[1]; if (newi >= 0 && newi < board.length && newj >= 0 && newj < board[0].length) { if (!visited[newi][newj]) { boolean flag = check(board, visited, newi, newj, s, k + 1); if (flag) { result = true; break; } } } } visited[i][j] = false; return result; } } class Exist: def exist(self, board, word) : def dfs(i, j, k): if not 0 <= i < len(board) or not 0 <= j < len(board[0]) or board[i][j] != word[k]: return False if k == len(word) - 1: return True board[i][j] = '' res = dfs(i + 1, j, k + 1) or dfs(i - 1, j, k + 1) or dfs(i, j + 1, k + 1) or dfs(i, j - 1, k + 1) board[i][j] = word[k] return res for i in range(len(board)): for j in range(len(board[0])): if dfs(i, j, 0): return True return False class Exist { public: bool exist(vector<vector<char>>& board, string word) { rows = board.size(); cols = board[0].size(); for(int i = 0; i < rows; i++) { for(int j = 0; j < cols; j++) { if (dfs(board, word, i, j, 0)) return true; } } return false; } private: int rows, cols; bool dfs(vector<vector<char>>& board, string word, int i, int j, int k) { if (i >= rows || i < 0 || j >= cols || j < 0 || board[i][j] != word[k]) return false; if (k == word.size() - 1) return true; board[i][j] = '\0'; bool res = dfs(board, word, i + 1, j, k + 1) || dfs(board, word, i - 1, j, k + 1) || dfs(board, word, i, j + 1, k + 1) || dfs(board, word, i , j - 1, k + 1); board[i][j] = word[k]; return res; } };
0
0
0
浏览量212
时光小少年

第 1 关 | 原来链表这么有用 : 1.青铜挑战——小白也能学会链表

1. 单链表的概念首先看一下什么是链表,单向链表就如同铁链一样,元素之间相互连接,包含多个结点,每个节点有一个指向后继元素的 next 指针。表中最后一个元素的 next 指向 null。如下图:思考一下你是否理解了链表的含义,思考一下下面两个图,是否都满足单链表的要求,为什么?第一个图 :第二个图:解析:上面第一个图是满足单链表的要求的,因为我们说链表要求环环相扣,核心是一个结点只能有一个后继,但是不代表一个结点只能有一个被指向。第一个图中,c1 被 a2 和 b3 同时指向,这是没关系的。这就好比法律倡导一夫一妻的制度,但是你只能爱一个人,但是可以多个人同时爱你。第二个图就不满足要求了,因为 c1 有两个后继 a5 和 b4。另外再做题的时候比较要注意的是值还是结点,因为有时候两个结点的值可能相等,但是并不是同一个结点,例如下图中,有两个结点的值都是 1,但并不是同一个结点。2. 链表的相关概念节点和头节点在链表中,每个点都由值和指向下一个结点的地址组成的独立的单元,称为一个结点,有时候也称为节点,含义都是一样的。对于单链表,如果知道了第一个元素,就可以通过遍历访问整个链表,因此第一个结点最重要,一般称为头结点。虚拟节点在做题以及在工程里面经常可以看到虚拟节点的概念,其实就是一个节点 dummyNode,其 next 指针指向 head,也就是 dummyNode.next=head。因此,如果我们在算法里面使用了虚拟节点,则要注意如果获得 head 节点,或者从方法(函数)里返回的时候,应该使用 dummyNode.next。另外注意,dummyNode 的 val 不会被使用,初始化 0 或者 -1 都是可以的,既然不会被使用,那么虚拟节点有什么作用呢?简单来说,就是为了方便我们处理头部节点,否则我们需要在代码里单独处理首部节点的问题,在链表反转里面,我们会看到该方式大大降低解题难度。3. 创建链表我们看如何构造链表。首先,要理解 JVM 是如何构建出链表的,我们知道 JVM 里面有堆区以及栈区,栈区主要负责引用,也就是一个指向实际对象的地址,而堆区存的才是创建的对象,例如我们顶一个这样的类:public class Course{ Teacher teacher; Student student; } 这里的 teacher 和 student 就是指向堆的引用,假如我们这样定义:public class Course{ int val; Course next; } 这个时候 next 就指向了下一个同为 Course 类型的对象了,例如:这样通过栈的引用(也就是地址),就可以找到 val (1),然后 val (1)节点又存了指向 val (2)的地址,然后 val (3)又存了指向 val (4)的地址,就这样,构造出了一个链表访问结构。在 Java 配套代码中 BasicLink 类,我们 debug 一下看一下就会发现下图:这样就是一个简单的线性访问了,所以链表就是从 head 开始,逐个开始向后访问,然后每次所访问对象的类型都是一样的。根据面向对象的理论,在 Java 里面规范的链表定义如下:public class LinkList { private int data; private LinkList next; public LinkList(int data) { this.data = data; } public int getData() { return data; } public void setData(int data) { this.data = data; } public LinkList getNext() { return next; } public void setNext(LinkList next) { this.next = next; } } 但是在 LeetCode 中算法题中经常使用以下方式来创建链表:public class ListNode{ public int val; private ListNode next; ListNode (int x){ this.val = x; this.next = null; } } ListNode listnode= new ListNode(1); 这里的 val 就是当前节点的值,next 指向下一个结点。因为两个变量都是 public 的,创建对象后能直接使用 listnode.val 和 listnode.next 来操作,虽然这样不满足面向对象的设计要求,但是代码更加精简。因此在算法题目中应用更加广泛。4. 链表的增删改查4.1. 链表遍历对于单链表,不管进行什么操作,都要从头到后逐个访问,所以操作之后是否还能找到表头非常重要。一定要注意“狗熊掰棒子”问题,也就是只顾当前位置而将标记表头的指针丢掉了。/** * 获取链表长度 * @param head 链表的头结点 * @return */ public static int getListLength(ListNode head){ int length = 0; ListNode node = head; while(node != null){ length++; node = node.next; } return length; } /** * 链表遍历打印 * @param head */ public static void printNodeList(ListNode head) { if(head == null) { System.out.println("链表为空,无法打印!"); return; } int length = 0; //定义头结点 ListNode node = head; while (node != null) { System.out.println(node.data); length++; node = node.next; } } 4.2. 链表插入单链表的插入和数组的插入一样,分为三种情况,过程并不复杂,但是在编码的时候会发现都是坑。单链表的插入需要考虑三种操作:首部、中部和尾部。4.2.1. 链表头插入链表表头插入新节点非常简单,容易出错的是可能会经常忘记 head 需要重新指向表头,我们需要创建一个新节点 newNode,怎么连接到原来的链表呢?执行 newNode.next = head 就可以,之后我们要遍历新链表就要从 newNode 开始一路向下了是吧,但是我们海狮习惯用 head 来表示,所以让 head = newNode 就可以了,如下图:4.2.2. 链表中间插入在中间位置插入,首先遍历找到要插入的位置,然后当前位置接入到前驱节点和后继节点之间,但是到了还位置之后我们却不能获得前驱节点,也就无法将节点接入进来。这就好比一边过河一边拆桥,结果自己没办法回去了。为此,我们要在目标节点前一个位置停下来,也就是使用 cur.next 的值来判断 ,而不是用 cur 的值来判断,这是链表常用的策略。如下图,如果要在 7 前面插入,当 cur.next = node(7) 的时候就要停下来,此时 cur.val = 15,然后需要给newNode 前后接两根线,此时只能让 new.next = node(15).next(图中虚线),然后 node(15).next = new,而且顺序还不能出错。为什么不能颠倒顺序呢?由于每个节点都只有一个 next,因此执行了 node( 15) .next = new 之后,节点 15 和 7之间的连线就自动断开了,如下图所示:4.2.3. 单链表尾部插入节点表尾插入就比较容易了,我们只需要将尾部节点插入指向新节点就可以了。public static ListNode insertNode(ListNode head, ListNode nodeInsert, int position) { // 头结点为空,直接返回 if (head == null) { return nodeInsert; } // 已经存放的元素格式 int size = getListLength(head); if (position > size + 1 || position < 1) { System.out.println("位置参数越界"); return head; } //表头插入 if (position == 1) { nodeInsert.next = head; //这里可以直接返回 nodeInsert,也可以先将头结点位置前移 head = nodeInsert; return head; } // 定义一个临时节点用于遍历 ListNode pNode = head; int count = 1; // 这里的 position 被 size 限制住,所以不用考虑 pNode = null while (count < position - 1) { pNode = pNode.next; count++; } nodeInsert.next = pNode.next; pNode.next = nodeInsert; return head; } 这里补充一个点,如果 head = null 的时候,如果插入的节点是链表的头结点,也可以直接抛出不能插入的异常,两种处理都可以,一般来说我更加倾向于前者。如果链表是单调递增的,一般会让你把元素插入到合适的地方,然后序列保持单调。4.3. 链表删除4.3.1. 删除链表头结点执行head=head.next即可,如下图,将head向前移动一次后,原结点不可达,会被JVM回收。4.3.2. 删除链表尾部节点找到其前驱结点,令其指向null即可。例如下图,同样用cur.next = 40 找到其前驱结点,再执行cur.next=null即可,此时结点40不可达,被JVM回收。4.3.3. 删除链表中间节点同样用cur.next比较,找到位置后,将cur.next指针的值更新为cur.next.next即可。如下图:/** * 删除节点 * * @param head 链表头结点 * @param position 删除节点位置,取值从 1 开始 * @return 删除后的链表头结点 */ public static ListNode deleteNode(ListNode head, int position) { //判断头结点是否为空,为空的话直接不删除 if (head == null) { System.out.println("链表为空,无法删除!"); return null; } int size = getListLength(head); // 删除的话一般都尾部节点就可以了,因为尾部节点的下一个节点为空,所以不需要删除 if (position > size || position < 1) { System.out.println("输入参数有误!"); return head; } if(position == 1){ return head.next; } ListNode cur = head; int count = 1; while(count < position - 1){ cur = cur.next; count++; } cur.next = cur.next.next; return head; } 5. 总结5.1. 完整代码package com.qinyao.linklist; /** * @ClassName LinkList * @Description * @Version 1.0.0 * @Author LinQi * @Date 2023/09/04 */ public class ListNode { private int data; private ListNode next; public ListNode(int data) { this.data = data; } public int getData() { return data; } public void setData(int data) { this.data = data; } public ListNode getNext() { return next; } public void setNext(ListNode next) { this.next = next; } /** * 获取链表长度 * * @param head 链表的头结点 * @return */ public static int getListLength(ListNode head) { int length = 0; ListNode node = head; while (node != null) { length++; node = node.next; } return length; } /** * 链表遍历打印 * * @param head */ public static void printNodeList(ListNode head) { if (head == null) { System.out.println("链表为空,无法打印!"); return; } int length = 0; //定义头结点 ListNode node = head; while (node != null) { System.out.println(node.data); length++; node = node.next; } } /** * 链表插入 * * @param head 链表头结点 * @param nodeInsert 待插入节点 * @param position 待插入位置,从 1 开始 * @return 插入后得到的链表头结点 */ public static ListNode insertNode(ListNode head, ListNode nodeInsert, int position) { // 头结点为空,直接返回 if (head == null) { return nodeInsert; } // 已经存放的元素格式 int size = getListLength(head); if (position > size + 1 || position < 1) { System.out.println("位置参数越界"); return head; } //表头插入 if (position == 1) { nodeInsert.next = head; //这里可以直接返回 nodeInsert,也可以先将头结点位置前移 head = nodeInsert; return head; } // 定义一个临时节点用于遍历 ListNode pNode = head; int count = 1; // 这里的 position 被 size 限制住,所以不用考虑 pNode = null while (count < position - 1) { pNode = pNode.next; count++; } nodeInsert.next = pNode.next; pNode.next = nodeInsert; return head; } /** * 删除节点 * * @param head 链表头结点 * @param position 删除节点位置,取值从 1 开始 * @return 删除后的链表头结点 */ public static ListNode deleteNode(ListNode head, int position) { //判断头结点是否为空,为空的话直接不删除 if (head == null) { System.out.println("链表为空,无法删除!"); return null; } int size = getListLength(head); // 删除的话一般都尾部节点就可以了,因为尾部节点的下一个节点为空,所以不需要删除 if (position > size || position < 1) { System.out.println("输入参数有误!"); return head; } if(position == 1){ return head.next; } ListNode cur = head; int count = 1; while(count < position - 1){ cur = cur.next; count++; } cur.next = cur.next.next; return head; } } 5.2. 思考5.2.1. 如何构造链表?构造链表的 data 以及 next 既可,data 用于存储链表的数据,next 用于指向下一个数据5.2.2. 链表增加元素,首部、中间和尾部分别有什么问题,该如何处理?首部:如果插入链表的时候,链表为空的时候,这个时候可以返回异常或者返回插入的节点就可以中间:需要注意,需要遍历到插入的前一个节点就足够了,然后将链表对应位置的下一个节点先赋值给插入节点,然后链表的写一个节点指向插入节点既可尾部:需要注意,如果索引大于链表元素 2 个,则表示插入错误。5.2.3. 链表删除元素,首部、中间和尾部分别有什么问题。该如何处理?首部:如果链表头部为空,无法删除中间:删除的时候同插入,需要遍历到前一个节点尾部:插入的时候需要注意索引位置
0
0
0
浏览量2018
时光小少年

第 20 关 | 图算法——中看不中用:1. 青铜挑战—认识图结构

我们平时在工作、学习中会大量使用图结构,不过呢在使用代码进行具体实现的时候极少使用图,主要是图里容易产生环,难以处理。在算法里,考察图也不是很多,主要是图的表示非常复杂,初始化一个图就需要几十行代码,非常不利用面试。不过呢,在笔试、校招等场景还是可能考察图,所以为了提高自己的胜算,我们有必要掌握必要的图问题。关卡名常见图算法我会了✔️内容1.理解图的基本特征和常见概念✔️前面我们学了线性表和树,线性表局限于一个直接前驱和一个直接后继的关系,树也只能有一个直接前驱也就是父节点,当我们需要表示多对多的关系时,就用到了图。1. 认识图图是一种数据结构,其中结点可以具有零个或多个相邻元素,两个结点之间的连接称为边, 结点也可以称为顶点。最典型的就是地铁线路图:为了方便处理 ,我们会将图抽象为只有顶点(vertex)和边的结构(edge),如下图所示:根据边是否有向,可以将图分为有向图和无向图。而根据顶点之间的边是否有权重,又分为带权图和不带权的图。而不同顶点之间能够连通的线路就称为路径(Path),例如在上述中间有向图中C到D的路径为”C->B->D“,但是从D到C则没有路径,这称为两个结点不可达。图结构常用来存储逻辑关系为“多对多”的数据。比如说,一个学生可以同时选择多门课程,而一门课程可以同时被多名学生选择,学生和课程之间的逻辑关系就是“多对多”。再举个例子,{V1, V2, V3, V4} 中各个元素之间具有的逻辑关系如下图所示:A->B 表示 A 和 B 之间存在单向的联系,由 A 可以找到 B,但由 B 找不到 A。上图中,从V1可以找到V3、V4、V2,从 V3、V4、V2也可以找到 V1,因此元素之间具有“多对多”的逻辑关系,存储它们就需要用到图结构。和链表不同,图中存储的各个元素被称为顶点(而不是节点)。拿上图来说,图中含有4个顶点,分别为顶点 V1、V2、V3 和 V4。通常情况下,我们习惯用 Vi 表示图中的顶点,且所有顶点构成的集合通常用 V 表示。比如说,图1中顶点的集合为 V={V1, V2, V3, V4}。上图中各个顶点之间的关系都是双向的,这种情况下我们更习惯用下图来表示各个元素之间的关系:A-B 表示 A 和 B 之间存在双向的联系,由 A 可以找到 B,同样由 B 也可以找到 A。类似图2这样,各个元素之间的联系都是双向的,这样的图结构称为无向图。如果元素之间存在单向的联系,那么这样的图结构称为有向图,例如:从上面几个图,我们可以得到两个重要的结论:图中各个顶点的地位是一样的,各个边的地位也是一样的。我们知道树是有根节点的,其他结点都要严格的满足树的要求,而图中各个顶点的等价的,你可以将任何一个视为起始点 ,任何一个视为终点对于无向图,如果一个图有n个顶点,那么最少需要n-1条边,最多有n(n-1)/2条边。例如,n=5时,最少和最多的边如下:2. 图的基本概念在系统地学习图存储结构之前,需要了解掌握术语以及它们各自代表的含义。2.1. 弧头和弧尾有向图中,无箭头一端的顶点通常被称为"初始点"或"弧尾",箭头一端的顶点被称为"终端点"或"弧头"。2.2. 入度和出度对于有向图中的一个顶点 V 来说,箭头指向 V 的弧的数量为 V 的入度(InDegree,记为 ID(V));箭头远离 V 的弧的数量为 V 的出度(OutDegree,记为OD(V))。拿图 2 中的顶点 V1来说,该顶点的入度为 1,出度为 2,该顶点的度为 3。2.3. (V1,V2) 和 <V1,V2> 的区别无向图中描述两顶点 V1 和 V2 之间的关系可以用 (V1, V2) 来表示;有向图中描述从 V1 到 V2 的"单向"关系可以用 <V1,V2> 来表示。由于图存储结构中顶点之间的关系是用线来表示的,因此 (V1,V2) 还可以用来表示无向图中连接 V1 和 V2 的线,又称为边;同样,<V1,V2> 也可用来表示有向图中从 V1 到 V2 带方向的线,又称为弧。2.4. 集合 VR图中习惯用 VR 表示图中所有顶点之间关系的集合。例如,图1中无向图的集合 VR={(v1,v2),(v1,v4),(v1,v3),(v3,v4)},图 2 中有向图的集合 VR={<v1,v2>,<v1,v3>,<v3,v4>,<v4,v1>}。2.5. 路径和回路无论是无向图还是有向图,从一个顶点到另一顶点途经的所有顶点组成的序列(包含这两个顶点),称为一条路径。如果路径中第一个顶点和最后一个顶点相同,则此路径称为"回路"(或"环")。在此基础上,若路径中各顶点都不重复,此路径被称为"简单路径";若回路中的顶点互不重复,此回路被称为"简单回路"(或简单环)。拿图 1 来说,从 V1 存在一条路径还可以回到 V1,此路径为 {V1,V3,V4,V1},这是一个回路(环),而且还是一个简单回路(简单环)。在有向图中,每条路径或回路都是有方向的。2.6. 权和网有些场景中,可能会为图中的每条边赋予一个实数表示一定的含义,这种与边(或弧)相匹配的实数被称为"权",而带权的图通常称为网。例如,图4就是一个网结构:2.7. 子图指的是由图中一部分顶点和边构成的图,称为原图的子图。3. 连通图前面讲过,图中从一个顶点到达另一顶点,若存在至少一条路径,则称这两个顶点是连通着的。例如图 1 中,虽然 V1 和 V3 没有直接关联,但从 V1 到 V3 存在两条路径,分别是 V1-V2-V3 和 V1-V4-V3,因此称 V1 和 V3 之间是连通的。顶点之间的连通状态示意图无向图中,如果任意两个顶点之间都能够连通,则称此无向图为连通图。例如,图 2 中的无向图就是一个连通图,因为此图中任意两顶点之间都是连通的。连通图示意图若无向图不是连通图,但图中存储某个子图符合连通图的性质,则称该子图为连通分量。前面讲过,由图中部分顶点和边构成的图为该图的一个子图,但这里的子图指的是图中"最大"的连通子图(也称"极大连通子图")。如图所示,虽然图 a) 中的无向图不是连通图,但可以将其分解为 3 个"最大子图"(图 b)),它们都满足连通图的性质,因此都是连通分量。连通分量示意图提示,图 3a) 中的无向图只能分解为 3 部分各自连通的"最大子图"。需要注意的是,连通分量的提出是以"整个无向图不是连通图"为前提的,因为如果无向图是连通图,则其无法分解出多个最大连通子图,因为图中所有的顶点之间都是连通的。强连通图有向图中,若任意两个顶点 Vi 和 Vj,满足从 Vi 到 Vj 以及从 Vj 到 Vi 都连通,也就是都含有至少一条通路,则称此有向图为强连通图。如图 4 所示就是一个强连通图。强连通图与此同时,若有向图本身不是强连通图,但其包含的最大连通子图具有强连通图的性质,则称该子图为强连通分量。强连通分量如上图所示,整个有向图虽不是强连通图,但其含有两个强连通分量。4. 生成树对连通图进行遍历,过程中所经过的边和顶点的组合可看做是一棵普通树,通常称为生成树。连通图及其对应的生成树如图所示,图 a) 是一张连通图,图 b) 是其对应的 2 种生成树。连通图中,由于任意两顶点之间可能含有多条通路,遍历连通图的方式有多种,往往一张连通图可能有多种不同的生成树与之对应。连通图中的生成树必须满足以下 2 个条件:1包含连通图中所有的顶点;2任意两顶点之间有且仅有一条通路;因此,连通图的生成树具有这样的特征,即生成树中边的数量 = 顶点数 - 1。生成森林生成树是对应连通图来说,而生成森林是对应非连通图来说的。我们知道,非连通图可分解为多个连通分量,而每个连通分量又各自对应多个生成树(至少是1棵),因此与整个非连通图相对应的,是由多棵生成树组成的生成森林。上图非连通图和连通分量如图所示,这是一张非连通图,可分解为 3 个连通分量,其中各个连通分量对应的生成树如图 所示:注意,图中列出的仅是各个连通分量的其中一种生成树。因此,多个连通分量对应的多棵生成树就构成了整个非连通图的生成森林。
0
0
0
浏览量911
时光小少年

第 2 关 | 两天写了三次的链表反转 : 1. 青铜挑战——手写链表反转

链表反转是一个出现频率特别高的算法题,老师过去这些年面试,至少遇到过七八次。其中更夸张的是曾经两天写了三次,上午YY,下午金山云,第二天快手。链表反转在各大高频题排名网站也长期占领前三。比如牛客网上这个No1 好像已经很久了。所以链表反转是我们学习链表最重要的问题,没有之一。为什么反转这么重要呢?因为反转链表涉及结点的增加、删除等多种操作,能非常有效考察思维能力和代码驾驭能力。另外很多题目也都要用它来做基础, 例如指定区间反转、链表K个一组翻转。还有一些在内部的某个过程用到了反转,例如两个链表生成相加链表。还有一种是链表排序的,也是需要移动元素之间的指针,难度与此差不多。因为太重要,所以我们用一章专门研究这个题目。LeetCode206 给你单链表的头节点 head,请你反转链表,并返回反转后的链表。示例1: 输入:head = [1,2,3,4,5] 输出:[5,4,3,2,1] 本题有两种方法,带头结点和不带头结点,我们都应该会,因为这两种方式都很重要,如果搞清楚,很多链表的算法题就不用做了。1. 建立虚拟头节点前面分析链表插入元素的时候,会发现如何处理头结点是个比较麻烦的问题,为此可以先建立一个虚拟的结点ans,并且令ans.next=head,这样可以很好的简化我们的操作。如下图所示,如果我们将链表{1->2->3->4->5}进行反转,我们首先建立虚拟结点ans,并令ans.next=node(1),接下来我们每次从旧的链表拆下来一个结点接到ans后面,然后将其他线调整好就可以了。如上图所示,我们完成最后一步之后,只要返回ans.next就得到反转的链表了,代码如下:class Solution { public ListNode reverseList(ListNode head) { ListNode ans = new ListNode(-1); ListNode cur = head; while(cur != null){ ListNode next = cur.next; cur.next = ans.next; ans.next = cur; cur = next; } return ans.next; } } 建立虚拟结点是处理链表的经典方法之一,虚拟结点在很多工具的源码里都有使用,用来处理链表反转也比较好理解,因此我们必须掌握好。2. 直接操作链表实现反转上面的方式虽然好理解应用也广,但是可能会被面试官禁止,为啥?原因是不借助虚拟结点的方式更难,更能考察面试者的能力。我们观察一下反转前后的结构和指针位置:我们再看一下执行期间的过程示意图,在图中,cur本来指向旧链表的首结点,pre表示已经调整好的新链表的表头,next是下一个要调整的。注意图中箭头方向,cur和pre是两个表的表头,移动过程中cur经过一次中间状态之后,又重新变成了两个链表的表头。理解这个图就够了,直接看代码:class Solution { public ListNode reverseList(ListNode head) { ListNode ans = null; ListNode cur = head; while(cur != null){ // 将下一个结点保存 ListNode temp = cur.next; // 将当前节点与下一节点断开进行前置 cur.next = ans; ans = cur; // 将当前指针指向下一个位置 cur = temp; } return ans; } } 将上面这段代码在理解的基础上背下来,是的,因为这个算法太重要3. 拓展上面我们讲解了链表反转的两种方法,带虚拟头结点方法是很多底层源码使用的,而不使用带头结点的方法是面试经常要考的,所以两种方式我们都要好好掌握。另外这种带与不带头结点的方式,在接下来的指定区间、K个一组反转也采用了,只不过为了便于理解 ,我们将其改成了“头插法”和“穿针引线法”。拓展 通过递归来实现反转,链表反转还有第三种常见的方式,使用递归来实现,这里不做重点,感兴趣的同学可以研究一下:public ListNode reverseList(ListNode head) { if (head == null || head.next == null) { return head; } ListNode newHead = reverseList(head.next); head.next.next = head; head.next = null; return newHead; }
0
0
0
浏览量2013
时光小少年

Acwing 算法数学知识模板——质数

Acwing 算法数学知识模板——质数数学模块第一章打卡题,质数模块,该部分介四种方法来解决三道关于质数的题目,分别是质数判定、分解质因数以及筛质数,感兴趣的可以看看。质数判定试除法判定质数 —— 模板题 AcWing 866. 试除法判定质数时间复杂度 O(sqrt(n)) public static boolean is_prime(int n) {      if(n < 2){          return false;       }       for(int i = 2;i <= n / i;i++){          if(n % i == 0){             return false;           }       }       return true;  } 试除法分解质因数 —— 模板题 AcWing 867. 分解质因数时间复杂度 O(log n)- O(sqrt(n)) void divide(int x){      for(int i = 2; i <= x/ i; i++){          if(x % i== 0){              int s = 0;              while(x % i == 0){                  x /= i;                  s++;             }              cout << i << ' ' << s<<endl;         }     }      if(x > 1){          cout << x << ' ' << 1 <<endl;     }      cout<<endl;  } 朴素筛法求素数 (埃氏筛)—— 模板题 AcWing 868. 筛质数时间复杂度O(nloglogn) const int N = 1000010;  int primes[N], cnt;     // primes[]存储所有素数  bool st[N];         // st[x]存储x是否被筛掉  ​  ​  void get_primes(int n){     for(int i = 2;i <= n; i++){         if(st[i]) continue;         primes[cnt++] = i;         for(int j = i + i; j <= n;j+=i){             st[j] = true;         }     }  } 线性筛法求素数 —— 模板题 AcWing 868. 筛质数时间复杂度 O(lnn) const int N = 1000010;  int primes[N], cnt;     // primes[]存储所有素数  bool st[N];         // st[x]存储x是否被筛掉  ​  ​  void get_primes(int n){      for(int i = 2; i<= n;i++){          if(!st[i]){              primes[cnt++]=i;         }          for(int j = 0; primes[j] <= n /i; j++){              st[primes[j] * i] = true;              if(i % primes[j] == 0){                  break;             }         }     }  }
0
0
0
浏览量1581
时光小少年

第 3 关 | 爱不起的数组和双指针思想 :3.黄金挑战——继续讨论数组问题

1. 数组中出现次数超过一半的数字这是剑指offer中的一道题目,数组中有一个数字出现的次数超过数组长度的一半,请找出这个数字。例如:输入如下所示的一个长度为9的数组{1,2,3,2,2,2,5,4,2}。由于数字2在数组中出现了5次,超过数组长度的一半,因此输出2,如果不存在则输出0。对于没有思路的问题,我们的策略都是先在脑子里快速过一遍常见的数据结构和常见的算法策略,看看谁能帮我们解决问题,所以很多问题就会自然而然的出现多种解法。首先,用排序行不行?这里说一定存在出现次数超过一半的数字了,那么先对数组进行排序。在一个有序数组中次数超过一半的必定是中位数,所以可以直接取出中位数。如果不放心,可以再遍历数组,确认一下这个数字是否出现次数超过一半。OK,没问题,第一种方法就出来了。这种方法的的时间复杂度取决于排序算法的时间复杂度,最快为O(nlogn)。由于排序的代价比较高,所以我们继续找其他方法。其次,用Hash行不行?我们先创建一个HashMap的key是元素的值,value是已经出现的次数,然后遍历数组来统计所有元素出现的次数。最后再次遍历Hash,找到出现次数超过一半的数字。OK,第二种方法出来了,代码就是: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; } def moreThanHalfNum(array): if array is None: return 0 res = {} length = len(array) for num in array: res[num] = res.get(num, 0) + 1 if res[num] > length / 2: return num return 0 int moreThanHalfNum(int array[], int length) { if (array == NULL) return 0; int res[100001] = {0}; // 假设数组中元素的范围在0到100000之间 for (int i = 0; i < length; i++) { res[array[i]]++; if (res[array[i]] > length / 2) return array[i]; } return 0; } 上面的方式虽然能解决问题,如果面试时写到这种程度,那就得了80分了。拓展本题有一种巧妙的解法。上面算法的时间复杂度为O(n),但是这是用O(n)的空间复杂度换来的。那是否有空间复杂度为O(1),时间复杂度为O(n)的算法呢?下面介绍的方法普适性并不好,而且实现也挺麻烦,可以先跳过。根据数组特点,数组中有一个数字出现的次数超过数组长度的一半,也就是说它出现的次数比其他所有数字出现的次数之和还要多。因此,我们可以在遍历数组的时候设置两个值:一个是数组中的数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,只能说明没有存在重复次数超过一半的元素。假如是奇数个,例如[1,2,1,3,1,4,1],此时结果是1,明显有超过一半的,所以我们最后还是要再遍历一遍来检查一下,代码如下:public int majorityElement(int[] nums) { int count = 0; Integer candidate = null; for (int num : nums) { if (count == 0) { candidate = num; } count += (num == candidate) ? 1 : -1; } return candidate; } int majorityElement(int* nums, int numsSize){ int candidate = -1; int count = 0; for (int index=0;index<numsSize ; index++) { if (nums[index] == candidate) ++count; else if (--count < 0) { candidate = nums[index] ; count = 1; } } return candidate; } class Solution: def majorityElement(self, nums): count = 0 candidate = None for num in nums: if count == 0: candidate = num count += (1 if num == candidate else -1) return candidate 2. 数组中只出现一次的数字LeetCode136.给定一个非空整数数组,除了某个元素只出现一次以外,其余每个元素均出现两次,找出那个只出现了一次的元素。示例1: 输入:[2,2,1] 输出:1 示例2: 输入:[4,1,2,1,2] 输出:4 这个题貌似使用Set集合比较好,Set集合不存重复值,这一特点可以利用。题目明确说其他元素都是出现两次,我们也可以利用这个操作,当要添加的元素key与集合中已存在的数重复时,不再进行添加操作,而是将集合中的key一起删掉,这样整个数组遍历完后,集合中就只剩下了那个只出现了一次的数字了。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]; } def findOneNum(arr): num_set = set() for num in arr: if num in num_set: num_set.remove(num) else: num_set.add(num) if len(num_set) == 0: return None return list(num_set)[0] int findOneNum(int arr[], int size) { int num; bool found = false; for (int i = 0; i < size; i++) { num = arr[i]; found = false; for (int j = 0; j < size; j++) { if (i == j) { continue; } if (num == arr[j]) { found = true; break; } } if (!found) { return num; } } return -1; } 上面要注意,必须存在那个只出现了一次的数字,否则Set集合长度将为0,最后一行代码运行时会出错。第二种方法:位运算这个题面试官可能还会让你用位运算来做,该怎么办呢?异或运算的几个规则是:0^0 = 0; 0^a = a; a^a = 0; a ^ b ^ a = b. 0与其他数字异或的结果是那个数字,相等的数字异或得0。要操作的数组中除了某个数字只出现了一次之外,其他数字都出现了两次,所以可以定义一个变量赋初始值为0,用这个变量与数组中每个数字做异或运算,并将这个变量值更新为那个运算结果,直到数组遍历完毕,最后得到的变量的值就是数组中只出现了一次的数字了。这种方法只需遍历一次数组即可,代码如下:public static int findOneNum(int[] arr) { int flag = 0; for(int i : arr) { flag ^= i; } return flag; } int findOneNum(int arr[], int size) { int flag = 0; for (int i = 0; i < size; i++) { flag ^= arr[i]; } return flag; } def find_one_num(arr): flag = 0 for i in arr: flag ^= i return flag 由此,也回到我们刚开始说的问题,数组的问题不会做,不是说明你数组没学好,而是要学习Hash、集合、位运算等等很多高级问题。元素次数是非常重要的专题,而且难度略大的问题,而各个大厂又非常喜欢考察。因此,我们提前将常见的场景都研究一遍是非常必要的,重复问题除了我们上面提到的之外,还有大量相关的问题,感兴趣的同学可以继续研究:剑指offer 题目1:找出数组中的重复数字剑指offer题目2:不修改数组找出重复的数字LeetCode137:在一个数组中除一个数字出现一次之外,其他数字都出现了三次,请找出那个只出现一次的数字。3. 颜色分类问题(荷兰国旗问题)这个也是非常经典的算法问题,LeetCode75,也称为荷兰国旗问题。给定一个包含红色、白色和蓝色、共 n 个元素的数组 nums ,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。必须在不使用库的sort函数的情况下解决这个问题。示例: 输入:nums = [2,0,2,1,1,0] 输出:[0,0,1,1,2,2] 如果你查一下会发现一个有趣的现象,很多几个国家的国旗如下:你是否感觉这些号称创新力很高的国家竟然这么敷衍,这也太像了吧!这道题你是否感觉叫法国国旗或者意大利国旗更合适啊,那为什么叫荷兰国旗呢?因为这个题的发明者正是大名鼎鼎的荷兰计算机科学家Dijkstra,在图算法中我们已经认识他了。这个题是非常经典的双指针问题,而且还可以使用多种方式的双指针。这里我们分析两种方法,一种与冒泡排序非常类似,一种与快速排序非常类似。1.基于冒泡排序的双指针(快慢指针)冒泡排序我们都知道,就是根据大小逐步和后面的比较,慢慢调整到整体有序。这种方法还是稳定的排序方法。我们可以考虑对数组进行两次遍历。在第一次遍历,我们将数组中所有的 0 交换到数组的头部,这样第二次遍历只需要处理1和2的问题就行了,而这两次寻找本身又是非常漂亮的双指针。代码如下:public void sortColors(int[] nums) { int n = nums.length; int left = 0; //将所有的0交换到数组的最前面 for (int right = 0; right < n; right++) { if (nums[right] == 0) { int temp = nums[right]; nums[right] = nums[left]; nums[left] = temp; left++; } } //将所有的1交换到2的前面 for (int right = left; right < n; ++right) { if (nums[right] == 1) { int temp = nums[right]; nums[right] = nums[left]; nums[left] = temp; ++left; } } } void sortColors(int nums[], int n) { int left = 0; // 将所有的0交换到数组的最前面 for (int right = 0; right < n; right++) { if (nums[right] == 0) { int temp = nums[right]; nums[right] = nums[left]; nums[left] = temp; left++; } } // 将所有的1交换到2的前面 for (int right = left; right < n; right++) { if (nums[right] == 1) { int temp = nums[right]; nums[right] = nums[left]; nums[left] = temp; left++; } } } def sortColors(nums): n = len(nums) left = 0 # 将所有的0交换到数组的最前面 for right in range(n): if nums[right] == 0: nums[right], nums[left] = nums[left], nums[right] left += 1 # 将所有的1交换到2的前面 for right in range(left, n): if nums[right] == 1: nums[right], nums[left] = nums[left], nums[right] left += 1 上面的方式能解决问题,而且效率还不错。但是面试官可能又给你出幺蛾子,能否将两次遍历变成一次搞定?这个稍微有些难 ,如果感觉太烧脑,可以暂时放下,头发长齐了再看。如果要求只用一次遍历就要解决问题,该怎么办呢?我们隐约感觉到要使用三个指针才行:●left指针,表示left左侧的元素都是0●right指针 ,表示right右侧的元素都是2●index指针,从头到尾遍历数组,根据nums[index]是0还是2决定与left交换还是与right交换。index位置上的数字代表着我们当前需要处理的数字。当index为数字1的时候,我们什么都不需要做,直接+1即可。如果是0,我们放到左边,如果是2,放到右边。如果index=right,则可以停止。我们看一下图示:这里的重点和难点index位置为2进行交换后为什么只进行right--,而不用index++呢?这是因为我们right位置交换过来的元素可能是0,也可能是1。如果是0自然没问题,但是如果是1则执行index++就将1跳过了无法处理了。所以我们先不动index,在下一次循环时继续判断这个index位置元素是不是0。那为啥index位置是0的时候执行swap就可以index++了呢,这是因为如果index前面位置如果存在位置都会被swap到right位置去了,这里只需要处理0和1的情况就可以了。代码如下:public void sortColors(int[] nums) { int left=0,right=nums.length-1; int index=0; while(index<=right){ if(nums[index]==0) swap(nums,index++,left++); else if(nums[index]==2) swap(nums,index,right--); else index++; } } private void swap(int[] nums,int i,int j){ int temp=nums[i]; nums[i]=nums[j]; nums[j]=temp; } void sortColors(int* nums, int numsSize) { int left = 0; int right = numsSize - 1; int index = 0; while (index <= right) { if (nums[index] == 0) { swap(&nums[index], &nums[left]); index++; left++; } else if (nums[index] == 2) { swap(&nums[index], &nums[right]); right--; } else { index++; } } } void swap(int* a, int* b) { int temp = *a; *a = *b; *b = temp; } def sortColors(nums): left = 0 right = len(nums) - 1 index = 0 while index <= right: if nums[index] == 0: nums[index], nums[left] = nums[left], nums[index] index += 1 left += 1 elif nums[index] == 2: nums[index], nums[right] = nums[right], nums[index] right -= 1 else: index += 1
0
0
0
浏览量1317
时光小少年

第 3 关 | 爱不起的数组与双指针思想:1. 青铜挑战——爱不起的数组

1. 线性表基础1.1. 线性表我们先搞清楚几个基本概念,在很多地方会看到线性结构、线性表这样的表述,那什么是线性结构?与数组、链表等有什么关系?常见的线性结构又有哪些呢?所谓线性表就是具有相同特征数据元素的一个有限序列,其中所含元素的个数称为线性表的长度,从不同的角度看,线性表可以有不同的分类,例如:从语言实现的角度顺序表有两种基本实现方式,一体式和分离式,如下:图a为一体式结构,存储表信息的单元与元素存储区以连续的方式安排在一块存储区里,两部分数据的整体形成一个完整的顺序表对象。这种结构整体性强,易于管理。但是由于数据元素存储区域是表对象的一部分,顺序表创建后,元素存储区就固定了。C和C++都是一体式的结构。图b为分离式结构,表对象里只保存与整个表有关的信息(即容量和元素个数),实际数据元素存放在另一个独立的元素存储区里,通过链接与基本表对象关联。Java和python是分离式结构。从存储的角度从存储的角度看,可以分为顺序型和链表型。顺序性就是将数据存放在一段固定的区间内,此时访问元素的效率非常高,但是删除和增加元素代价比较大,如果要扩容只能整体搬迁。而在链表型里,元素之间是通过地址依次连接的,因此访问时必须从头开始逐步向后找,因此查找效率低,而删除和增加元素非常方便,并且也不需要考虑扩容的问题。链表的常见实现方式又有单链表、循环链表、双向链表等等等。从访问限制的角度栈和队列又称为访问受限的线性表,插入和删除受到了限制,只能在固定的位置进行。而Hash比较特殊,其内部真正存储数据一般是数组,但是访问是通过映射来实现的,因此大部分材料里并不将Hash归结到线性表中,这里为了学习更紧凑,我们将其与队栈一起学习。线性表的知识框架如下:线性表的常见操作有初始化、求表长、增删改查等,事实上每种数据结构都至少要有这几种操作,大部分的基础算法题都是基于此扩展的。从扩容的角度采用分离式结构的顺序表,若将数据区更换为存储空间更大的区域,则可以在不改变表对象的前提下对其数据存储区进行了扩充,所有使用这个表的地方都不必修改。只要程序的运行环境(计算机系统)还有空闲存储,这种表结构就不会因为满了而导致操作无法进行。人们把采用这种技术实现的顺序表称为动态顺序表,因为其容量可以在使用中动态变化。扩充的两种策略:●第一种:每次扩充增加固定数目的存储位置,如每次扩充增加10个元素位置,这种策略可称为线性增长。特点:节省空间,但是扩充操作频繁,操作次数多。●第二种:每次扩充容量加倍,如每次扩充增加一倍存储空间。特点:减少了扩充操作的执行次数,但可能会浪费空间资源。以空间换时间,推荐的方式。具体到每种结构语言中的结构,实现方式千差万别。其中Java基本是扩容时加倍的方式。而在Python的官方实现中,list实现采用了如下的策略:在建立空表(或者很小的表)时,系统分配一块能容纳8个元素的存储区;在执行插入操作(insert或append)时,如果元素存储区满就换一块4倍大的存储区。但如果此时的表已经很大(目前的阀值为50000),则改变策略,采用加一倍的方法。引入这种改变策略的方式,是为了避免出现过多空闲的存储位置。1.2. 数组的概念数组是线性表最基本的结构,特点是元素是一个紧密在一起的序列,相互之间不需要记录彼此的关系就能访问,例如月份、星座等。数组用索引的数字来标识每项数据在数组中的位置,且在大多数编程语言中,索引是从 0 算起的。我们可以根据数组中的索引快速访问数组中的元素。数组有两个需要注意的点,一个是从0开始记录,也就是第一个存元素的位置是a[0],最后一个是a[length-1]。其次,数组中的元素在内存中是连续存储的,且每个元素占用相同大小的内存。另外需要注意的是数组空间不一定是满的,100的空间可能只用了10个位置,所以要注意数据个数的变量size和数组长度length可能不一样,解题时必须注意。1.3. 数组存储元素的特征在往期训练营中,发现很多学员对数组如何存储元素的并不太清楚,这里采用连环炮方式来说明。第一炮,我创建了一个大小为10的数组,请问此时数组里面是什么?答:不同的语言处理会不一样,在c语言里每个位置都是一个随机数。而在java里,默认会初始化为0。而python更为灵活可以直接指定是什么,例如a = [1,2,3,4],就是数组里有四个元素,而a = [0 for i in range(10)]这样定义的数组就是[0, 0, 0, 0, 0, 0, 0, 0, 0, 0]第二炮:是否可以只初始化一部分位置?初始化的本质是什么?答:当然可以,你可以将前面5个位置依次,后面的空着,此时数组内容为{1,2,3,4,5,0,0,0,0,0}。初始化的本质就是覆盖已有的值,用你需要的值覆盖原来的0,因为数组本来是{0,0,0,0,0,0,0,0,0,0},这里只不过被你替换成了{1,2,3,4,5,0,0,0,0,0}。如果此时你想知道有效元素的个数,就必须再使用一个额外的变量,例如size来标记。第三炮:上面已经初始化的元素之间是否可以空着,例如初始化为{1,0,0,4,5,0,2,0,3,0}。其中0位置仍然是未初始化的?答:不可以!绝对不可以!要初始化,就必须从前向后的连续空间初始化,不可以出现空缺的情况,这是违背数组的原则的。你正在进行某种运算期间可以先给部分位置赋值,而一旦稳定了,就不可以再出现空位置的情况。第四炮:如果我需要的数据就是在中间某一段该怎么办呢?例如{0,0,3,4,5,6,7,0,0,0},此时该怎么拿到从3到7的元素呢?答:你需要使用两个变量,例如left=2,right=6来表示区间[left,right]是有效的。第五炮:我删除的时候,已经被删除的位置该是什么呢?例如原始数组为{1,2,3,4,5,6,7,8,0,0},我删除4之后,根据数组的移动原则,从5开始向前移动,变成{1,2,3,5,6,7,8,?,0,0},那原来8所在的位置应该是什么呢?答:仍然是8,也就是删除4之后的结构为{1,2,3,5,6,7,8,8,0,0},此时表示元素数量的size会减1变成7,原来8的位置仍然是8。因为我们是通过size来标记元素数量的,所以最后一个8不会被访问到。第六炮:这个里8看起来很不爽啊,是否可以再优化一下?答:不爽就不爽,习惯就好!不用优化,优化了也没啥用。2. 数组基本操作在面试中,数组大部分情况下都是int类型的,所以我们就用int类型来实现这些基本功能。2.1. 数组创建和初始化创建一维数组的方法不同的语言不一样的,如下:int[] arr = new int[10]; a[]={1,2,2,1,0,2,4,2,3,1}; list = [] 接下来的代码,不同的语言大同小异,也比较简单,我们就不再提供每种语言的实现了,同学们可以自己来写一写。初始化数组最基本的方式是循环赋值:for(int i = 0 ; i < arr.length ; i ++){ arr[i] = i; } 但是这种方式在面试题中一般不行,因为很多题目会给定若干测试数组让你都能测试通过,例如给你两个数组[0,1,2,3,5,6,8] 和 [1,4,5,6,7,9,10] 。那这时候该如何初始化呢?显然不能用循环了,可以采用下面的方式:int[] arr = new int[]{0,1,2,3,4,5,6,8}; //这么写也可以 int[] nums = {2,5,0,4,6,-10}; 如果要测试第二组数据,直接将其替换就行了。这种方式很简单,在面试时特别实用,但是务必记住写法,否则面试时可能慌了或者忘了,老写不对,这会让你无比着急,想死的心都有! 我们练习算法的一个目标就是熟悉这些基本问题,避免阴沟里翻船。另外要注意上面在创建数组时大小就是元素的数量,是无法再插入元素的,如果需要增加新元素就不能这么用了。作业:将int[] nums = {2, 5, 0, 4, 6, -10}这种赋值方法背下来。是的!!!背下来,没什么好说的。2.2. 查找一个元素为什么数组的题目特别多呢,因为很多题目本质就是查找问题,而数组是查找的最佳载体。很多复杂的算法都是为了提高查找效率的,例如二分查找、二叉树、红黑树、B+树、Hash和堆等等。另一方面很多算法问题本质上都是查找问题,例如滑动窗口问题、回溯问题、动态规划问题等等都是在寻找那个目标结果。这里只写最简单的方式,根据值是否相等进行线性查找,基本实现如下:/** * @param size 已经存放的元素个数 * @param key 待查找的元素 */ public static int findByElement(int[] arr, int size, int key) { for (int i = 0; i < size; i++) { if (arr[i] == key) return i; } return -1; } def findByElement(arr, size, key): for i in range(size): if arr[i] == key: return i return -1 int findByElement(int arr[], int size, int key) { for (int i = 0; i < size; i++) { if (arr[i] == key) { return i; } } return -1; } 作业还有一种很常见的情况,如果数组是递增的,此时查找时如果相等或者当前位置元素比目标值更大就停下了。 你是否可以修改上面的代码来实现这个功能?2.3. 增加一个元素增加和删除元素是数组最基本的操作,看别人的代码非常容易,但是自己写的时候经常bug满天飞。能准确处理游标和边界等情况是数组算法题最基础重要的问题之一。所以务必自己亲手能写一个才可以,不要感觉挺简单就不写,其中涉及的问题在所有与数组有关的算法题中都会遇到。面试冒汗时别怪没提醒!!!!将给定的元素插入到有序数组的对应位置中,我们可以先找位置,再将其后元素整体右移,最后插入到空位置上。这里需要注意,算法必须能保证在数组的首部、尾部和中间位置插入都可以成功。该问题貌似一个for循环就搞定了,但是如果面试直接让你写并能正确运行,我相信很多人还是会折腾很久,甚至直接会挂。因为自己写的时候会发现游标写size还是size-1,判断时要不要加等于等等,这里推荐一种实现方式。/** * @param arr * @param size 数组已经存储的元素数量,从1开始编号 * @param element 待插入的元素 * @return */ public static int addByElementSequence(int[] arr, int size, int element) { //问题①:是否应该是size>arr.length if (size >= arr.length) retrun -1; //问题②想想这里是否是index=0或者size-1? int index = size; //找到新元素的插入位置,问题③ 这里是否应该是size-1? for (int i = 0; i < size; i++) { if (element < arr[i]) { index = i; break; } } //元素后移,问题④想想这里为什么不是size-1 for (int j = size; j > index; j--) { arr[j] = arr[j - 1]; //index下标开始的元素后移一个位置 } arr[index] = element;//插入数据 return index; } def addByElementSequence(arr, size, element): if size >= len(arr): return -1 index = size for i in range(size): if element < arr[i]: index = i break for j in range(size, index, -1): arr[j] = arr[j - 1] arr[index] = element return index int addByElementSequence(int arr[], int size, int element) { if (size >= sizeof(arr) / sizeof(arr[0])) { return -1; int index = size; for (int i = 0; i < size; i++) { if (element < arr[i]) { index = i; break; } } for (int j = size; j > index; j--) { arr[j] = arr[j - 1]; } } } 上面的代码在往期课程里被提出疑问特别多,主要是标记编号的几个位置,这几个全都是边界问题。这里回答几个:问题①处,注意这里的size是从1开始编号的,表示的就是实际元素的个数。而arr.length也是从1开始的,当空间满的时候就是size=arr.length,此时就不能再插入元素了。问题② 处只能令index=size, 0或者size-1都不对。例如已有序列为{3,4,7,8},如果插入的元素比8大,例如9,假如index=0,则最后结果是{9,3,4,7,8}。假如index=size-1,最后结果就是{3,4,7,9,8}。问题③和④处,这个就不用解释了吧,请读者自己思考。作业除了上面的方式,还可以一开始就从后向前一边移动一边对比查找,找到位置直接插入。从效率上看这样更好一些,因为只遍历了一次,你是否可以实现一下呢?2.4. 删除一个元素对于删除,不能一边从后向前移动一边查找了,因为元素可能不存在。所以要分为两个步骤,先从最左侧开始查是否存在元素,如果元素存在,则从该位置开始执行删除操作。例如序列是 1 2 3 4 5 6 7 8 9 ,要删除5,则应先遍历,找到5,然后从5开始执行删除操作,也就是从6开始逐步覆盖上一个元素,最终将序列变成 1 2 3 4 6 7 8 9 [9]。这个方法和增加元素一样,必须自己亲自写才有作用,该方法同样要求删除序列最前、中间、最后和不存在的元素都能有效,下面给一个参考实现:/** * 从数组中删除元素key * @param arr 数组 * @param size 数组中的元素个数,从1开始 * @param key 删除的目标值 */ public int removeByElement(int[] arr, int size, int key) { int index = -1; for (int i = 0; i < size; i++) { if (arr[i] == key) { index = i; break; } } if (index != -1) { for (int i = index + 1; i < size; i++) arr[i - 1] = arr[i]; size--; } return size; } int removeByElement(int arr[], int size, int key) { int index = -1; for (int i = 0; i < size; i++) { if (arr[i] == key) { index = i; break; } } if (index != -1) { for (int i = index + 1; i < size; i++) { arr[i - 1] = arr[i]; } size--; } return size; } def removeByElement(arr, size, key): index = -1 for i in range(size): if arr[i] == key: index = i break if index != -1: for i in range(index + 1, size): arr[i - 1] = arr[i] size -= 1 return size 3. 算法热身——单调数组问题先看个热身问题,我们在写算法的时候,数组是否有序是一个非常重要的前提,有或者没有可能会采用完全不同的策略。 LeetCode 896.判断一个给定的数组是否为单调数组。分析:如果对于所有 i <= j,A[i] <= A[j],那么数组 A 是单调递增的。 如果对于所有 i <= j,A[i]> = A[j],那么数组 A 是单调递减的。所以遍历数组执行这个判定条件就行了,由于有递增和递减两种情况。于是我们执行两次循环就可以了,代码如下:public boolean isMonotonic(int[] nums) { return isSorted(nums, true) || isSorted(nums, false); } public boolean isSorted(int[] nums, boolean increasing) { int n = nums.length; for (int i = 0; i < n - 1; ++i) { if(increasing){ if (nums[i] > nums[i + 1]) { return false; } }else{ if (nums[i] < nums[i + 1]) { return false; } } } return true; } bool isSorted(int* nums, int numsSize, bool increasing) { for (int i = 0; i < numsSize - 1; ++i) { if (increasing) { if (nums[i] > nums[i + 1]) { return false; } } else { if (nums[i] < nums[i + 1]) { return false; } } } return true; } bool isMonotonic(int* nums, int numsSize) { return isSorted(nums, numsSize, true) || isSorted(nums, numsSize, false); } def isMonotonic(nums): return isSorted(nums, True) or isSorted(nums, False) def isSorted(nums, increasing): n = len(nums) for i in range(n-1): if increasing: if nums[i] > nums[i+1]: return False else: if nums[i] < nums[i+1]: return False return True 这样虽然实现功能了,貌似有点繁琐,而且还要遍历两次,能否优化一下呢?假如我们在i和i+1位置出现了nums[i]>nums[i+1],而在另外一个地方j和j+1出现了nums[j]<nums[j+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; } bool isMonotonic(int* nums, int numsSize) { bool inc = true, dec = true; for (int i = 0; i < numsSize - 1; ++i) { if (nums[i] > nums[i + 1]) { inc = false; } if (nums[i] < nums[i + 1]) { dec = false; } } return inc || dec; } def isMonotonic(nums): inc = True dec = True n = len(nums) for i in range(n - 1): if nums[i] > nums[i + 1]: inc = False if nums[i] < nums[i + 1]: dec = False return inc or dec 我们判断整体单调性不是白干的,很多时候需要将特定元素插入到有序序列中,并保证插入后的序列仍然有序,例如leetcode35:给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。示例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; } int searchInsert(int* nums, int numsSize, int target) { int left = 0, right = numsSize - 1, ans = numsSize; while (left <= right) { int mid = ((right - left) >> 1) + left; if (target <= nums[mid]) { ans = mid; right = mid - 1; } else { left = mid + 1; } } return ans; } def searchInsert(nums, target): n = len(nums) left = 0 right = n - 1 ans = n while left <= right: mid = (right - left) // 2 + left if target <= nums[mid]: ans = mid right = mid - 1 else: left = mid + 1 return ans 4. 算法热身—数组合并专题数组合并就是将两个或者多个有序数组合并成一个新的。这个问题的本身不算难,但是要写的够出彩才可以。还有后面要学的归并排序本身就是多个小数组的合并,所以研究该问题也是为了后面打下基础。先来看如何合并两个有序数组,LeetCode88:给你两个按非递减顺序 排列的整数数组 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排序,也就是这样:public void merge1(int[] nums1, int nums1_len, int[] nums2, int nums2_len) { for (int i = 0; i < nums2_len; ++i) { nums1[nums1_len + 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--]; } void merge(int nums1[], int nums1_len, int m,int nums2[], int nums2_len,int n) { int i = m + n - 1; int len1 = m - 1, len2 = n - 1; while (len1 >= 0 && len2 >= 0) { if (nums1[len1] <= nums2[len2]) { nums1[i--] = nums2[len2--]; } else { nums1[i--] = nums1[len1--]; } } // 假如A或者B数组还有剩余 while (len2 != -1) { nums1[i--] = nums2[len2--]; } while (len1 != -1) { nums1[i--] = nums1[len1--]; } } def merge(nums1, nums1_len, nums2, nums2_len): i = nums1_len + nums2_len - 1 len1, len2 = nums1_len - 1, nums2_len - 1 while len1 >= 0 and len2 >= 0: if nums1[len1] <= nums2[len2]: nums1[i] = nums2[len2] len2 -= 1 else: nums1[i] = nums1[len1] len1 -= 1 i -= 1 # 假如A或者B数组还有剩余 while len2 != -1: nums1[i] = nums2[len2] len2 -= 1 i -= 1 while len1 != -1: nums1[i] = nums1[len1] len1 -= 1 i -= 1
0
0
0
浏览量1176
时光小少年

第 2 关 | 两天写了三次的链表反转:3. 黄金挑战——K个一组反转

链表反转有几道很常见的拓展问题,这些都是面试的高频问题,这一关,我们来集中研究一下。关卡名手写链表反转我会了✔️内容1.  指定区间反转✔️2.  两两交换链表中的节点✔️3.  单链表加1✔️4.  链表加法✔️1. 指定区间反转1.1. 头插法方法一的缺点是:如果 left 和 right 的区域很大,恰好是链表的头节点和尾节点时,找到 left 和 right 需要遍历一次,反转它们之间的链表还需要遍历一次,虽然总的时间复杂度为 O(N),但遍历了链表 2次,可不可以只遍历一次呢?答案是可以的。我们依然画图进行说明,我们仍然以方法一的序列为例进行说明。反转的整体思想是,在需要反转的区间里,每遍历到一个节点,让这个新节点来到反转部分的起始位置。下面的图展示了整个流程。这个过程就是前面的带虚拟结点的插入操作,每走一步都要考虑各种指针怎么指,既要将结点摘下来接到对应的位置上,还要保证后续结点能够找到,请读者务必画图看一看,想一想到底该怎么调整。代码如下:/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode reverseBetween(ListNode head, int left, int right) { ListNode dummyNode = new ListNode(-1); dummyNode.next = head; ListNode pre = dummyNode; for(int i = 0; i< left - 1;i++){ pre = pre.next; } ListNode cur = pre.next; ListNode next; for(int i = 0; i < right - left;i++){ next = cur.next; cur.next = next.next; next.next = pre.next; pre.next = next; } return dummyNode.next; } } def reverseBetween(self, head, left, right) : # 设置 dummyNode 是这一类问题的一般做法 dummy_node = ListNode(-1) dummy_node.next = head pre = dummy_node for _ in range(left - 1): pre = pre.next cur = pre.next for _ in range(right - left): next = cur.next cur.next = next.next next.next = pre.next pre.next = next return dummy_node.next struct ListNode* reverseBetween(struct ListNode* head, int left, int right){ struct ListNode* dummyNode = (struct ListNode*)malloc(sizeof(struct ListNode)); dummyNode->next = head; struct ListNode* pre = dummyNode; for (int i = 0; i < left - 1; i++) { pre = pre->next; } struct ListNode* cur = pre->next; struct ListNode* next; for (int i = 0; i < right - left; i++) { next = cur->next; cur->next = next->next; next->next = pre->next; pre->next = next; } struct ListNode* newHead = dummyNode->next; free(dummyNode); return newHead; } 1.2. 穿针引线法这种方式能够复用我们前面讲的链表反转方法,但是实现难度仍然比较高一些。我们以反转下图中蓝色区域的链表反转为例:我们可以这么做:先确定好需要反转的部分,也就是下图的 left 到 right 之间,然后再将三段链表拼接起来。这种方式类似裁缝一样,找准位置减下来,再缝回去。这样问题就变成了如何标记下图四个位置,以及如何反转left到right之间的链表。算法步骤:●第 1 步:先将待反转的区域反转;●第 2 步:把 pre 的 next 指针指向反转以后的链表头节点,把反转以后的链表的尾节点的 next 指针指向 succ。编码细节我们直接看下方代码。思路想明白以后,编码不是一件很难的事情。这里要提醒大家的是,链接什么时候切断,什么时候补上去,先后顺序一定要想清楚,如果想不清楚,可以在纸上模拟,思路清晰。/** * Definition for singly-linked list. * public class ListNode { * int val; * ListNode next; * ListNode() {} * ListNode(int val) { this.val = val; } * ListNode(int val, ListNode next) { this.val = val; this.next = next; } * } */ class Solution { public ListNode reverseBetween(ListNode head, int left, int right) { // 因为头节点有可能发生变化,使用虚拟头节点可以避免复杂的分类讨论 ListNode dummyNode = new ListNode(-1); dummyNode.next = head; ListNode pre = dummyNode; // 第 1 步:从虚拟头节点走 left - 1 步,来到 left 节点的前一个节点 // 建议写在 for 循环里,语义清晰 for (int i = 0; i < left - 1; i++) { pre = pre.next; } // 第 2 步:从 pre 再走 right - left + 1 步,来到 right 节点 ListNode rightNode = pre; for (int i = 0; i < right - left + 1; i++) { rightNode = rightNode.next; } // 第 3 步:切出一个子链表 ListNode leftNode = pre.next; ListNode succ = rightNode.next; // 思考一下,如果这里不设置next为null会怎么样 rightNode.next = null; // 第 4 步:同第 206 题,反转链表的子区间 reverseLinkedList(leftNode); // 第 5 步:接回到原来的链表中 //想一下,这里为什么可以用rightNode pre.next = rightNode; leftNode.next = succ; return dummyNode.next; } private void reverseLinkedList(ListNode head) { // 也可以使用递归反转一个链表 ListNode pre = null; ListNode cur = head; while (cur != null) { ListNode next = cur.next; cur.next = pre; pre = cur; cur = next; } } } class Solution(object): # 完成链表反转的辅助方法 def reverse_list(self,head): # 也可以使用递归反转一个链表 pre = None cur = head while cur: next = cur.next cur.next = pre pre = cur cur = next def reverseBetween(self, head, left, right) : # 因为头节点有可能发生变化,使用虚拟头节点可以避免复杂的分类讨论 dummy_node = ListNode(-1) dummy_node.next = head pre = dummy_node # 第 1 步:从虚拟头节点走 left - 1 步,来到 left 节点的前一个节点 # 建议写在 for 循环里,语义清晰 for _ in range(left - 1): pre = pre.next # 第 2 步:从 pre 再走 right - left + 1 步,来到 right 节点 right_node = pre for _ in range(right - left + 1): right_node = right_node.next # 第 3 步:切断出一个子链表(截取链表) left_node = pre.next curr = right_node.next # 注意:切断链接 pre.next = None right_node.next = None # 第 4 步:同第 206 题,反转链表的子区间 self.reverse_list(left_node) # 第 5 步:接回到原来的链表中 pre.next = right_node left_node.next = curr return dummy_node.next 2. 两两交换链表中的节点这是一道非常重要的问题,读者务必理解清楚。LeetCode24.给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。如果要解决该问题,将上面小节的K换成2不就是这个题吗?道理确实如此,但是如果K为2的时候,可以不需要像K个一样需要先遍历找到区间的两端,而是直接取前后两个就行了,因此基于相邻结点的特性重新设计和实现就行,不需要上面这么复杂的操作。如果原始顺序是 dummy -> node1 -> node2,交换后面两个节点关系要变成 dummy -> node2 -> node1,事实上我们只要多执行一次next就可以拿到后面的元素,也就是类似node2 = temp.next.next这样的操作。两两交换链表中的节点之后,新的链表的头节点是 dummyHead.next,返回新的链表的头节点即可。指针的调整可以参考如下图示:完整代码是:public ListNode swapPairs(ListNode head) { ListNode dummyHead = new ListNode(0); dummyHead.next = head; ListNode temp = dummyHead; while (temp.next != null && temp.next.next != null) { ListNode node1 = temp.next; ListNode node2 = temp.next.next; temp.next = node2; node1.next = node2.next; node2.next = node1; temp = node1; } return dummyHead.next; } class SwapPairs: def swapPairs(self, head): dummyHead = ListNode(0) dummyHead.next = head temp = dummyHead while temp.next and temp.next.next: node1 = temp.next node2 = temp.next.next temp.next = node2 node1.next = node2.next node2.next = node1 temp = node1 return dummyHead.next struct ListNode* swapPairs(struct ListNode* head) { struct ListNode* dummyHead = (struct ListNode*)malloc(sizeof(struct ListNode)); dummyHead->next = head; struct ListNode* temp = dummyHead; while (temp->next != NULL && temp->next->next != NULL) { struct ListNode* node1 = temp->next; struct ListNode* node2 = temp->next->next; temp->next = node2; node1->next = node2->next; node2->next = node1; temp = node1; } struct ListNode* newHead = dummyHead->next; free(dummyHead); return newHead; } 3. 单链表加 1LeetCode369.用一个非空单链表来表示一个非负整数,然后将这个整数加一。你可以假设这个整数除了 0 本身,没有任何前导的 0。这个整数的各个数位按照 高位在链表头部、低位在链表尾部 的顺序排列。示例: 输入: [1,2,3] 输出: [1,2,4] 我们先看一下加法的计算过程:计算是从低位开始的,而链表是从高位开始的,所以要处理就必须反转过来,此时可以使用栈,也可以使用链表反转来实现。基于栈实现的思路不算复杂,先把题目给出的链表遍历放到栈中,然后从栈中弹出栈顶数字 digit,加的时候再考虑一下进位的情况就ok了,加完之后根据是否大于0决定视为下一次要进位 。public ListNode plusOne(ListNode head) { Stack<Integer> st = new Stack(); while (head != null) { st.push(head.val); head = head.next; } int carry = 0; ListNode dummy = new ListNode(0); int adder = 1; while (!st.empty() || carry > 0) { int digit = st.empty() ? 0 : st.pop(); int sum = digit + adder + carry; carry = sum >= 10 ? 1 : 0; sum = sum >= 10 ? sum - 10 : sum; ListNode cur = new ListNode(sum); cur.next = dummy.next; dummy.next = cur; adder = 0; } return dummy.next; } 上面代码,我们简单解释一下,carry的功能是记录进位的,相对好理解 。那这里的adder是做什么的很多人会感到困惑,这个主要表示就是需要加的数1,也就是为了满足单链表加1的功能,那这里能否直接使用1 ,而不再定义变量呢?也就是将上面的while循环改成这样子: while (!st.empty() || carry > 0) { int digit = st.empty() ? 0 : st.pop(); int sum = digit + 1 + carry; carry = sum >= 10 ? 1 : 0; sum = sum >= 10 ? sum - 10 : sum; ListNode cur = new ListNode(sum); cur.next = dummy.next; dummy.next = cur; } 很遗憾,这样不行的,否则的话 ,会将我们每个位置都加1,例如,如果原始单链表是{7,8}这里就会将其变成{8,9},而不是我们要的{7,9},导致这样的原因是循环处理链表每个结点元素的时候sum = digit + 1 + carry这一行会将每个位置都多加了一个1,所以我们要使用变量adder,只有第一次是加了1,之后该变量变成0了,就不会影响我们后续计算。class PlusOne: def plusOne(self, head): # ans head ans = ListNode(0) ans.next = head not_nine = ans # find the rightmost not-nine digit while head: if head.val != 9: not_nine = head head = head.next not_nine.val += 1 not_nine = not_nine.next while not_nine: not_nine.val = 0 not_nine = not_nine.next return ans if ans.val else ans.next 基于链表反转实现 如果这里不使用栈,使用链表反转来实现该怎么做呢?很显然,我们先将原始链表反转,这方面完成加1和进位等处理,完成之后再次反转。4. 链表加法相加相链表是基于链表构造的一种特殊题,反转只是其中的一部分。这个题还存在进位等的问题,因此看似简单,但是手写成功并不容易。LeetCode445题,给你两个非空链表来代表两个非负整数。数字最高位位于链表开始位置。它们的每个节点只存储一位数字。将这两数相加会返回一个新的链表。你可以假设除了数字 0 之外,这两个数字都不会以零开头。示例:示例: 输入:(6 -> 1 -> 7) + (2 -> 9 -> 5),即617 + 295 输出:9 -> 1 -> 2,即912 这个题目的难点在于存放是从最高位向最低位开始的,但是因为低位会产生进位的问题,计算的时候必须从最低位开始。所以我们必须想办法将链表节点的元素反转过来。怎么反转呢?栈和链表反转都可以,两种方式我们都看一下。(1) 使用栈实现思路是先将两个链表的元素分别压栈,然后再一起出栈,将两个结果分别计算。之后对计算结果取模,模数保存到新的链表中,进位保存到下一轮。完成之后再进行一次反转就行了。我们知道在链表插入有头插法和尾插法两种。头插法就是每次都将新的结点插入到head之前。而尾插法就是将新结点都插入到链表的表尾。两者的区别是尾插法的顺序与原始链表是一致的,而头插法与原始链表是逆序的,所以上面最后一步如果不想进行反转,可以将新结点以头插法。public static ListNode addInListByStack(ListNode head1, ListNode head2) { Stack<ListNode> st1 = new Stack<ListNode>(); Stack<ListNode> st2 = new Stack<ListNode>(); while (head1 != null) { st1.push(head1); head1 = head1.next; } while (head2 != null) { st2.push(head2); head2 = head2.next; } ListNode newHead = new ListNode(-1); int carry = 0; //这里设置carry!=0,是因为当st1,st2都遍历完时,如果carry=0,就不需要进入循环了 while (!st1.empty() || !st2.empty() || carry != 0) { ListNode a = new ListNode(0); ListNode b = new ListNode(0); if (!st1.empty()) { a = st1.pop(); } if (!st2.empty()) { b = st2.pop(); } //每次的和应该是对应位相加再加上进位 int get_sum = a.val + b.val + carry; //对累加的结果取余 int ans = get_sum % 10; //如果大于0,就进位 carry = get_sum / 10; ListNode cur = new ListNode(ans); cur.next = newHead.next; //每次把最新得到的节点更新到neHead.next中 newHead.next = cur; } return newHead.next; } class AddTwoNumbers: # 使用栈实现 def addTwoNumbers(self, l1, l2): st1 = [] st2 = [] while l1: st1.append(l1.val) l1 = l1.next while l2: st2.append(l2.val) l2 = l2.next carry = 0 dummy = ListNode(0) while st1 or st2 or carry: adder1 = st1.pop() if st1 else 0 adder2 = st2.pop() if st2 else 0 sum = adder1 + adder2 + carry carry = 1 if sum >= 10 else 0 sum = sum - 10 if sum >= 10 else sum cur = ListNode(sum) cur.next = dummy.next dummy.next = cur return dummy.next struct ListNode* addTwoNumbers(struct ListNode* l1, struct ListNode* l2){ struct ListNode* st1 = NULL; struct ListNode* st2 = NULL; struct ListNode* curr1 = l1; struct ListNode* curr2 = l2; while (curr1 != NULL) { struct ListNode* new_node = (struct ListNode*)malloc(sizeof(struct ListNode)); new_node->val = curr1->val; new_node->next = st1; st1 = new_node; curr1 = curr1->next; } while (curr2 != NULL) { struct ListNode* new_node = (struct ListNode*)malloc(sizeof(struct ListNode)); new_node->val = curr2->val; new_node->next = st2; st2 = new_node; curr2 = curr2->next; } struct ListNode* new_head = (struct ListNode*)malloc(sizeof(struct ListNode)); new_head->val = -1; new_head->next = NULL; int carry = 0; while (st1 != NULL || st2 != NULL || carry != 0) { struct ListNode* a = (struct ListNode*)malloc(sizeof(struct ListNode)); struct ListNode* b = (struct ListNode*)malloc(sizeof(struct ListNode)); if (st1 != NULL) { a = st1; st1 = st1->next; } else { a->val = 0; a->next = NULL; } if (st2 != NULL) { b = st2; st2 = st2->next; } else { b->val = 0; b->next = NULL; } int get_sum = a->val + b->val + carry; int ans = get_sum % 10; carry = get_sum / 10; struct ListNode* cur = (struct ListNode*)malloc(sizeof(struct ListNode)); cur->val = ans; cur->next = new_head->next; new_head->next = cur; } struct ListNode* result = new_head->next; free(new_head); return result; } (2)使用链表反转实现如果使用链表反转,先将两个链表分别反转,最后计算完之后再将结果反转,一共有三次反转操作,所以必然将反转抽取出一个方法比较好,代码如下:public class Solution { public ListNode addInList (ListNode head1, ListNode head2) { head1 = reverse(head1); head2 = reverse(head2); ListNode head = new ListNode(-1); ListNode cur = head; int carry = 0; while(head1 != null || head2 != null) { int val = carry; if (head1 != null) { val += head1.val; head1 = head1.next; } if (head2 != null) { val += head2.val; head2 = head2.next; } cur.next = new ListNode(val % 10); carry = val / 10; cur = cur.next; } if (carry > 0) { cur.next = new ListNode(carry); } return reverse(head.next); } private ListNode reverse(ListNode head) { ListNode cur = head; ListNode pre = null; while(cur != null) { ListNode temp = cur.next; cur.next = pre; pre = cur; cur = temp; } return pre; } } class ReverseList: def reverseList(self, head) : prev = None curr = head while curr: nextTemp = curr.next curr.next = prev prev = curr curr = nextTemp return prev def addTwoNumbersI(self, l1, l2): ans = ListNode(0, None) DUMMY_HEAD, res = ans, 0 p1, p2 = l1, l2 while p1 != None or p2 != None or res == 1: ans.next = ListNode(0, None) ans = ans.next if p1 != None and p2 != None: sums = p1.val + p2.val if sums + res < 10: ans.val = sums + res res = 0 else: ans.val = sums + res - 10 res = 1 p1, p2 = p1.next, p2.next elif p1 == None and p2 != None: sums = p2.val if sums + res < 10: ans.val = sums + res res = 0 else: ans.val = sums + res - 10 res = 1 p2 = p2.next elif p2 == None and p1 != None: sums = p1.val if sums + res < 10: ans.val = sums + res res = 0 else: ans.val = sums + res - 10 res = 1 p1 = p1.next else: ans.val = res res = 0 return DUMMY_HEAD.next #调用入口 def addTwoNumbers(self, l1, l2): return self.reverseList(self.addTwoNumbersI(self.reverseList(l1), self.reverseList(l2))) struct ListNode* reverse(struct ListNode* head) { struct ListNode* cur = head; struct ListNode* pre = NULL; while (cur != NULL) { struct ListNode* temp = cur->next; cur->next = pre; pre = cur; cur = temp; } return pre; } struct ListNode* addTwoNumbers(struct ListNode* head1, struct ListNode* head2){ head1 = reverse(head1); head2 = reverse(head2); struct ListNode* head = (struct ListNode*)malloc(sizeof(struct ListNode)); head->val = -1; head->next = NULL; struct ListNode* cur = head; int carry = 0; while (head1 != NULL || head2 != NULL) { int val = carry; if (head1 != NULL) { val += head1->val; head1 = head1->next; } if (head2 != NULL) { val += head2->val; head2 = head2->next; } struct ListNode* newNode = (struct ListNode*)malloc(sizeof(struct ListNode)); newNode->val = val % 10; newNode->next = NULL; cur->next = newNode; carry = val / 10; cur = cur->next; } if (carry > 0) { struct ListNode* newNode = (struct ListNode*)malloc(sizeof(struct ListNode)); newNode->val = carry; newNode->next = NULL; cur->next = newNode; } return reverse(head->next); } 上面我们直接调用了反转函数,这样代码写起来就容易很多,如果你没手写过反转,所有功能都是在一个方法里,那复杂度要高好几个数量级,甚至自己都搞不清楚了。既然加法可以,那如果是减法呢?读者可以自己想想该怎么处理。5. 再论链表的回文序列问题在上一关介绍链表回文串的时候,我们介绍的是基于栈的,相对来说比较好理解,但是除此之外还有可以使用链表反转来进行,而且还可以只反转一半链表,这种方式节省空间。我们姑且称之为“快慢指针+一半反转”法。这个实现略有难度,主要是在while循环中pre.next = prepre和prepre = pre两行实现了一边遍历一边将访问过的链表给反转了,所以理解起来有些难度,如果不理解可以在学完链表反转之后再看这个问题。public boolean isPalindrome(ListNode head) { if(head == null || head.next == null) { return true; } ListNode slow = head, fast = head; ListNode pre = head, prepre = null; while(fast != null && fast.next != null) { pre = slow; slow = slow.next; fast = fast.next.next; //将前半部分链表反转 pre.next = prepre; prepre = pre; } if(fast != null) { slow = slow.next; } while(pre != null && slow != null) { if(pre.val != slow.val) { return false; } pre = pre.next; slow = slow.next; } return true; } def isPalindrome(self, head): fake = ListNode(-1) fake.next = head fast = slow = fake while fast and fast.next: fast = fast.next.next slow = slow.next post = slow.next slow.next = None pre = head rev = None # 反转 while post: tmp = post.next post.next = rev rev = post post = tmp part1 = pre part2 = rev while part1 and part2: if part1.val != part2.val: return False part1 = part1.next part2 = part2.next return True bool isPalindrome(struct ListNode* head){ if (head == NULL || head->next == NULL) { return true; } struct ListNode* slow = head; struct ListNode* fast = head; struct ListNode* pre = head; struct ListNode* prepre = NULL; while (fast != NULL && fast->next != NULL) { pre = slow; slow = slow->next; fast = fast->next->next; // 将前半部分链表反转 pre->next = prepre; prepre = pre; } if (fast != NULL) { slow = slow->next; } while (pre != NULL && slow != NULL) { if (pre->val != slow->val) { return false; } pre = pre->next; slow = slow->next; } return true; }
0
0
0
浏览量1306
时光小少年

第 08 关 | 二叉树的深度优先经典问题:1.青铜挑战——二叉树的经典算法题

1. 二叉树的双指针1.1. 判断两棵树是否相同LeetCode100:给你两棵二叉树的根节点 p 和 q ,编写一个函数来检验这两棵树是否相同。如果两个树在结构上相同,并且节点具有相同的值,则认为它们是相同的。输入:p = [1,2,3], q = [1,2,3] 输出:true 输入:p = [1,2], q = [1,null,2] 输出:false 这个貌似就是两个二叉树同时进行前序遍历,先判断根节点是否相同, 如果相同再分别判断左右子节点是否相同,判断的过程中只要有一个不相同就返回 false,如果全部相同才会返回true。其实就是这么回事。看代码:public boolean isSameTree(TreeNode p, TreeNode q) { //如果都为空我们就认为他是相同的 if (p == null && q == null) return true; //如果一个为空,一个不为空,很明显不可能是相同的树,直接返回false即可 if (p == null || q == null) return false; //如果对应位置的两个结点的值不相等,自然也不是同一个棵树 if (p.val != q.val) return false; //走到这一步说明节点p和q是完全相同的,接下来需要再判断其左左和右右是否满足要求了 return isSameTree(p.left, q.left) && isSameTree(p.right, q.right); } def isSameTree(self, p, q): if not p and not q: return True elif not p or not q: return False elif p.val != q.val: return False else: return self.isSameTree(p.left, q.left) and self.isSameTree(p.right, q.right) bool isSameTree( TreeNode* p, TreeNode* q) { if (p == NULL && q == NULL) { return true; } else if (p == NULL || q == NULL) { return false; } else if (p->val != q->val) { return false; } else { return isSameTree(p->left, q->left) && isSameTree(p->right, q->right); } } 本题除了上述方式,还可以使用广度优先,感兴趣的同学可以试一试。1.2. 对称二叉树LeetCode101 给定一个二叉树,检查它是否是镜像对称的。例如下面这个就是对称二叉树: 1 / \ 2 2 / \ / \ 3 4 4 3 但是下面这个 [1,2,2,null,3,null,3] 则不是对称的: 1 / \ 2 2 \ \ 3 3 如果树是镜像的,下面这个图更直观一些:因为我们要通过递归函数的返回值来判断两个子树的内侧节点和外侧节点是否相等,所以准确的来说是一个树的遍历顺序是左右中,一 个树的遍历顺序是右左中。这里的关键还是如何比较和如何处理结束条件。 单层递归的逻辑就是处理左右节点都不为空,且数值相同的情况。比较二叉树外侧是否对称:传入的是左节点的左孩子,右节点的右孩子。比较 内侧是否对称,传入左节点的右孩子,右节点的左孩子。如果左右都对称就返回true ,有一侧不对称就返回false 。接下来就是合并和进一步简化:class Solution { //主方法 public boolean isSymmetric(TreeNode root) { if(root==null){ return true; } return check(root.left, root.right); } public boolean check(TreeNode p,TreeNode q){ if (p == null && q == null) { return true; } if (p == null || q == null) { return false; } if( p.val != q.val){ return false; } return check(p.left, q.right) && check(p.right, q.left); } } bool check(TreeNode *p, TreeNode *q) { if (!p && !q) return true; if (!p || !q) return false; if(p->val != q->val) return false; return check(p->left, q->right) && check(p->right, q->left); } bool isSymmetric(TreeNode* root) { return check(root, root); } class Solution(object): def isSymmetric(self, root): if not root: return True def dfs(left,right): if not (left or right): return True if not (left and right): return False if left.val!=right.val: return False return dfs(left.left,right.right) and dfs(left.right,right.left) # 用递归函数,比较左节点,右节点 return dfs(root.left,root.right) 1.3. 合并二叉树LeetCode617.给定两个二叉树,想象当你将它们中的一个覆盖到另一个上时,两个二叉树的一些节点便会重叠。你需要将他们合并为一个新的二叉树。合并的规则是如果两个节点重叠,那么将他们的值相加作为节点合并后的新值,否则不为 NULL 的节点将直接作为新二叉树的节点。输入: Tree 1: Tree 2: 1 2 / \ / \ 3 2 1 3 / \ \ 5 4 7 输出合并后的树: 3 / \ 4 5 / \ \ 5 4 7 两个二叉树的对应节点可能存在以下三种情况,对于每种情况使用不同的合并方式。● 如果两个二叉树的对应节点都为空,则合并后的二叉树的对应节点也为空;● 如果两个二叉树的对应节点只有一个为空,则合并后的二叉树的对应节点为其中的非空节点;● 如果两个二叉树的对应节点都不为空,则合并后的二叉树的对应节点的值为两个二叉树的对应节点的值之和,此时需要显性合并两个节点。对一个节点进行合并之后,还要对该节点的左右子树分别进行合并:public TreeNode mergeTrees(TreeNode t1, TreeNode t2) { if (t1 == null) { return t2; } if (t2 == null) { return t1; } TreeNode merged = new TreeNode(t1.val + t2.val); merged.left = mergeTrees(t1.left, t2.left); merged.right = mergeTrees(t1.right, t2.right); return merged; } TreeNode* mergeTrees(TreeNode* t1, TreeNode* t2) { if (t1 == nullptr) { return t2; } if (t2 == nullptr) { return t1; } auto merged = new TreeNode(t1->val + t2->val); merged->left = mergeTrees(t1->left, t2->left); merged->right = mergeTrees(t1->right, t2->right); return merged; } class Solution: def mergeTrees(self, t1, t2) : if not t1: return t2 if not t2: return t1 merged = TreeNode(t1.val + t2.val) merged.left = self.mergeTrees(t1.left, t2.left) merged.right = self.mergeTrees(t1.right, t2.right) return merged 如果这个感觉还是想不明白,可以直接对照题干给的例子来验证一下。最后我们来造个题:前面我们研究了两棵树相等和一棵树对称的情况,我们可以造一道题,判断两棵树是否对称的。如下就是一个对称的二叉树,那该如何写代码实现呢?请你思考。2. 路径专题关于二叉树有几道与路径有关的题目,我们统一看一下。初次接触你会感觉有些难,但是这是在为回溯打基础,因为很多回溯问题就是在找路径,甚至要找多条路径。2.1. 二叉树的所有路径LeetCode257:给你一个二叉树的根节点 root ,按 任意顺序 ,返回所有从根节点到叶子节点的路径。叶子节点是指没有子节点的节点。输入:root = [1,2,3,null,5] 输出:["1->2->5","1->3"] 我们可以注意到有几个叶子节点,就有几条路径,那如何找叶子节点呢?我们知道深度优先搜索就是从根节点开始一直找到叶子结点,我们这里可以先判断当前节点是不是叶子结点,再决定是不是向下走,如果是叶子结点,我们就增加一条路径,就像下面图中这样:这里还有个问题,当得到一个叶子结点容易,那这时候怎么知道它所在的完整路径是什么呢?例如上图中得到D之后,怎么知道其前面的A和B呢?简单,增加一个String类型的变量中,访问每个节点访问的时候先存到String中,到叶子节点的时候再添加到集合里 :public List<String> binaryTreePaths(TreeNode root) { List<String> res = new ArrayList<>(); dfs(root, "", res); return res; } void dfs(TreeNode root, String path, List<String> res) { if (root == null) return; if (root.left == null && root.right == null) { res.add(path + root.val); return; } dfs(root.left, path + root.val + "->", res); dfs(root.right, path + root.val + "->", res); } void construct_paths(TreeNode* root, string path, vector<string>& paths) { if (root != nullptr) { path += to_string(root->val); if (root->left == nullptr && root->right == nullptr) { // 当前节点是叶子节点 paths.push_back(path); // 把路径加入到答案中 } else { path += "->"; // 当前节点不是叶子节点,继续递归遍历 construct_paths(root->left, path, paths); construct_paths(root->right, path, paths); } } } //主方法入口 vector<string> binaryTreePaths(TreeNode* root) { vector<string> paths; construct_paths(root, "", paths); return paths; } class Solution: def binaryTreePaths(self, root): """ :type root: TreeNode :rtype: List[str] """ def construct_paths(root, path): if root: path += str(root.val) if not root.left and not root.right: # 当前节点是叶子节点 paths.append(path) # 把路径加入到答案中 else: path += '->' # 当前节点不是叶子节点,继续递归遍历 construct_paths(root.left, path) construct_paths(root.right, path) paths = [] construct_paths(root, '') return paths 本题可以作为回溯的入门问题,我们到回溯时再讲解。2.2. 路径总和上面我们讨论的找所有路径的方法,那我们是否可以再找一下哪条路径的和为目标值呢?这就是LeetCode112题:给你二叉树的根节点 root 和一个表示目标和的整数 targetSum ,判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum。叶子节点 是指没有子节点的节点。输入:root = [5,4,8,11,null,13,4,7,2,null,null,null,1], targetSum = 22 输出:true 输入:root = [1,2,3], targetSum = 5 输出:false 本题询问是否存在从当前节点 root 到叶子节点的路径,满足其路径和为 sum,假定从根节点到当前节点的值之和为 val,我们可以将这个大问题转化为一个小问题:是否存在从当前节点的子节点到叶子的路径,满足其路径和为 sum - val。不难发现这满足递归的性质,若当前节点就是叶子节点,那么我们直接判断 sum 是否等于 val 即可(因为路径和已经确定,就是当前节点的值,我们只需要判断该路径和是否满足条件)。若当前节点不是叶子节点,我们只需要递归地询问它的子节点是否能满足条件即可。public boolean hasPathSum(TreeNode root, int sum) { if (root == null) { return false; } if (root.left == null && root.right == null) { return sum == root.val; } boolean left= hasPathSum(root.left, sum - root.val) ; boolean right=hasPathSum(root.right, sum - root.val); return left|| right; } bool hasPathSum(TreeNode *root, int sum) { if (root == nullptr) { return false; } if (root->left == nullptr && root->right == nullptr) { return sum == root->val; } return hasPathSum(root->left, sum - root->val) || hasPathSum(root->right, sum - root->val); } class Solution: def hasPathSum(self, root, sum): if not root: return False if not root.left and not root.right: return sum == root.val return self.hasPathSum(root.left, sum - root.val) or self.hasPathSum(root.right, sum - root.val) 本题还有个拓展,既然找到是否存在路径了,那能把找到的路径打印出来吗?当然可以,这就是LeetCode113题,我们将本题做为入门题在回溯部分讲解。3. 翻转的妙用来看 LeetCode226 翻转二叉树,将二叉树整体反转。如下图所示:这个题也是剑指offer27题的要求,根据上图,可以发现想要翻转树,就是把每一个节点的左右孩子交换一下。关键在于遍历顺序,前中后序应该选哪一种遍历顺序。遍历的过程中去翻转每一个节点的左右孩子就可以达到整体翻转的效果。注意只要把每一个节点的左右孩子翻转一下,就可以达到整体翻转的效果。这是一道很经典的二叉树问题。显然,我们从根节点开始,递归地对树进行遍历,并从叶子节点先开始翻转。如果当前遍历到的节点 root 的左右两棵子树都已经翻转,那么我们只需要交换两棵子树的位置,即可完成以 root 为根节点的整棵子树的翻转。先看前序交换:public TreeNode invertTree(TreeNode root) { if (root == null) { return null; } TreeNode temp=root.left; root.left=root.right; root.right=temp; invertTree(root.left); invertTree(root.right); return root; } def invertTree(self, root): if root == None: return None temp = root.left root.left = root.right root.right = temp self.invertTree(root.left) self.invertTree(root.right) return root TreeNode* invertTree1( TreeNode* root) { if (root == NULL) { return NULL; } TreeNode* temp = root->left; root->left = root->right; root->right = temp; invertTree1(root->left); invertTree1(root->right); return root; } 再看后序:public TreeNode invertTree(TreeNode root) { if (root == null) { return null; } TreeNode left = invertTree(root.left); TreeNode right = invertTree(root.right); root.left = right; root.right = left; return root; } TreeNode* invertTree(TreeNode* root) { if (root == nullptr) { return nullptr; } TreeNode* left = invertTree(root->left); TreeNode* right = invertTree(root->right); root->left = right; root->right = left; return root; } class Solution(object): def invertTree(self,root): if root == None: return None left = self.invertTree(root.left) right = self.invertTree(root.right) root.left = right root.right = left return root 这道题目使用前序遍历和后序遍历都可以,主要区别是是前序是先处理当前节点再处理子节点,是自顶向下,后序是先处理子结点最后处理自己,一个是自下而上的。观察下图就明白了:本题还可以使用层次遍历实现,核心思想是元素出队时,先将其左右两个孩子不是直接入队,而是先反转再放进去,代码如下:class Solution { public TreeNode invertTree(TreeNode root) { if (root == null) { return null; } //将二叉树中的节点逐层放入队列中,再迭代处理队列中的元素 LinkedList<TreeNode> queue = new LinkedList<TreeNode>(); queue.add(root); while (!queue.isEmpty()) { //每次都从队列中拿一个节点,并交换这个节点的左右子树 TreeNode tmp = queue.poll(); TreeNode left = tmp.left; tmp.left = tmp.right; tmp.right = left; //如果当前节点的左子树不为空,则放入队列等待后续处理 if (tmp.left != null) { queue.add(tmp.left); } //如果当前节点的右子树不为空,则放入队列等待后续处理 if (tmp.right != null) { queue.add(tmp.right); } } return root; } } class Solution(object): def invertTree(self, root): if not root: return None # 将二叉树中的节点逐层放入队列中,再迭代处理队列中的元素 queue = [root] while queue: # 每次都从队列中拿一个节点,并交换这个节点的左右子树 tmp = queue.pop(0) tmp.left,tmp.right = tmp.right,tmp.left # 如果当前节点的左子树不为空,则放入队列等待后续处理 if tmp.left: queue.append(tmp.left) # 如果当前节点的右子树不为空,则放入队列等待后续处理 if tmp.right: queue.append(tmp.right) # 返回处理完的根节点 return root TreeNode* invertTree(TreeNode* root) { queue<TreeNode*> que; if (root != NULL) que.push(root); while (!que.empty()) { int size = que.size(); for (int i = 0; i < size; i++) { TreeNode* node = que.front(); que.pop(); swap(node->left, node->right); // 节点处理 if (node->left) que.push(node->left); if (node->right) que.push(node->right); } } return root; }
0
0
0
浏览量1691
时光小少年

第 9 关 | 心有灵犀的二分查找与中序遍历:1.青铜挑战——逢试必考的二分查找

1. 基本查找查找算法中顺序查找算是最简单的了,无论是有序的还是无序的都可以,也不需要排序,只需要一个个对比即可,但其实效率很低,我们来看下代码:int search(int[] a, int key) { for (int i = 0; i < a.length; i++) { if (a[i] == key) { return i; } } return -1; } 顺序查找是最简单的一种查找算法,对数据的要求也很随意,不需要排序即可查找。后面会介绍二分法查找,插值查找和斐波那契查找都是基于已经排序过的数据。2. 二分查找和分治在计算机科学中,分治法是一种很重要的算法。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如二分搜索、排序算法(快速排序,归并排序)等等……任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小,越容易直接求解,解题所需的计算时间也越少。例如,对于n个元素的排序问题,当n=1时,不需任何计算。n=2时,只要作一次比较即可排好序。n=3时只要作3次比较即可,…。而当n较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当困难的。二分查找就是将中间结果与目标进行比较,一次去掉一半,因此二分查找可以说是最简单、最典型的分治了。二分查找,不管是循环还是递归方式,我觉得应该达到写到闭着眼睛,一分钟就能写出来的地步。这里再补充一个问题, 分治和递归是一回事吗?很显然不是。这是两种完全不同的思想 ,二分查找是分支思想,我们可以使用递归或者循环的方式来做。而很多递归问题也不一定是分治的,因此两个完全不是一回事。2.1. 循环的方式常见的使用循环的方式来实现二分查找如下,请问你给多少分?public int binarySearch(int[] array, int low, int high, int target) { while (low <= high) { int mid = (low + high)/2; if (array[mid] == target) { return mid ; } else if (array[mid] > target) { // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除 high = mid -1; } else { // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除 low = mid + 1; } } return -1; } int binarySearch(int array[], int low, int high, int target) { while (low <= high) { int mid = (low + high) / 2; if (array[mid] == target) { return mid; } else if (array[mid] > target) { high = mid - 1; } else { low = mid + 1; } } return -1; } def binarySearch(array, low, high, target): while low <= high: mid = (low + high) // 2 if array[mid] == target: return mid elif array[mid] > target: high = mid - 1 else: low = mid + 1 return -1 在具体操作的时候可能有多种方式的,包括循环体中的 high = mid -1;和low = mid + 1也有多种方式的,这需要与if后面的条件配合,我们不要给自己添麻烦,在理解的基础上熟记这种方式就行了。如果代码写成这样子,只能得70分,因为有个很重要的细节没有处理。在计算机中,除的效率非常低,一般可以使用移位来代替,也就是将: int mid = (low + high) /2; 换成 int mid = (low + high)>>1; 如果这样的话,能得到80分,面试官可能会继续问,还会有什么问题。问题就是假如low和high很大的话,low + high可能会溢出。因此我们可以这么写:int mid = low+(high - low)>>1;只要 low和high没有溢出,上面的mid一定不会溢出。你觉得可以得到90分,很可惜是0分,因为当你测试的时候,可能会出现死循环,例如原始序列是1到8,搜索3的时候就死循环了,为什么呢?这是因为移位的运算符>>优先级比加减要低,所以上面的代码等价结构是这样的:(low+(high - low))>>1很明显这不是我们预期的。解决方法也很简单,加括号就行了。所以最终的代码就是:python语言里没有该问题,所以代码与上面的一样public static innt binarySearch(int[] array,intt low,int high.int target){ while(low <= high){ int mid = low + ((high - low) >> 1); if(array[mid] == target){ return mid; }else if(array[mid] > target){ // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除 high = mid - 1; }else if(array[mid] < target){ // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除 low = mid + 1; } } return -1; } int binarySearch(int array[], int low, int high, int target) { while (low <= high) { int mid = low + ((high - low) >> 1); if (array[mid] == target) { return mid; } else if (array[mid] > target) { high = mid - 1; } else { low = mid + 1; } } return -1; } 这样的话,面试官就提不出什么问题了,而且上面这个优先级问题很多人只是理解了,并没有上机测试,因此很多面试官也不会注意到这里会有死循环的情况。当然这里还没有考虑元素重复的问题,后面再说。2.2. 递归的方式递归就不必多说了,直接看代码:public int binarySearch1(int[] array, int low, int high, int target) { //递归终止条件 if(low <= high){ int mid = low + ((high - low) >> 1); if(array[mid] == target){ return mid ; // 返回目标值的位置,从1开始 }else if(array[mid] > target){ // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除 return binarySearch(array, low, mid-1, target); }else{ // 由于array[mid]不是目标值,因此再次递归搜索时,可以将其排除 return binarySearch(array, mid+1, high, target); } } return -1; //表示没有搜索到 } int binarySearch(int array[], int low, int high, int target){ if(low <= high){ int mid = low + ((high - low) >> 1); if(array[mid] == target){ return mid; }else if(target > array[mid]){ binarySearch() } } return -1; } def binarySearch1(array, low, high, target): if low <= high: mid = low + ((high - low) >> 1) if array[mid] == target: return mid elif array[mid] > target: return binarySearch1(array, low, mid - 1, target) else: return binarySearch1(array, mid + 1, high, target) return -1 这里的mid的计算和可能出现的问题与上面是一样的。这个也是面试时体现基本功的亮点。如果能写到这种程度,就是满分了,面试官基本不会再说什么了。3. 元素中有重复的二分查找假如在上面的基础上,元素存在重复,如果重复则找左侧第一个,请问该怎么做呢?这个我曾在面快手时遇到过。这里的关键是找到目标结果之后不是返回而是继续向左侧移动。第一种,也是最简单的方式,找到相等位置向左使用线性查找,直到找到相应的位置。public static int search(int [] nums,int target){ if(num == null || nums.length == 0){ return -1; } int left = 0; int right = nums.length - 1; while(left <= right){ int mid = (right - left) / 2 + left; if(nums[mid] < target){ left = mid + 1; }else if(nums[mid] > target){ right =mid - 1; }else{ //找到之后,往左边找 while (mid != 0 && nums[mid] == target) mid--; if (mid == 0 && nums[mid] == target) { return mid; } return mid + 1; } } } def search(nums, target): if nums is None or len(nums) == 0: return -1 left = 0 right = len(nums) - 1 while left <= right: mid = left + (right - left) // 2 if nums[mid] < target: left = mid + 1 elif nums[mid] > target: right = mid - 1 else: # 找到之后,往左边找 while mid != 0 and nums[mid] == target: mid -= 1 if mid == 0 and nums[mid] == target: return mid return mid + 1 return -1 int search(int *nums, int target, int numsSize) { if (nums == NULL || numsSize == 0) { return -1; } int left = 0; int right = numsSize - 1; while (left <= right) { int mid = left + (right - left) / 2; if (nums[mid] < target) { left = mid + 1; } else if (nums[mid] > target) { right = mid - 1; } else { // 找到之后,往左边找 while (mid != 0 && nums[mid] == target) { mid--; } if (mid == 0 && nums[mid] == target) { return mid; } return mid + 1; } } return -1; } 注意这里在找到之后的while循环结束后,为什么要返回mid+1,而不是mid呢?这是因为此时nums[mid]已经不等于target了,例如假如序列为{1, 2, 2, 2, 2, 3, 3},当target=3,当内层的while退出时,nums[mid]=2,因此我们必须返回mid+1。
0
0
0
浏览量1407
时光小少年

第 14 关 | 刷题模板之堆结构:2. 白银挑战——堆能高效解决的经典问题

1. 数组中找第 K 大的元素LeetCode215 给定整数数组nums和整数k,请返回数组中第k个最大的元素。 请注意,你需要找的是数组排序后的第k个最大的元素,而不是第k个不同的元素。示例1: 输入: [3,2,1,5,6,4] 和 k = 2 输出: 5 示例2: 输入: [3,2,3,1,2,4,5,5,6] 和 k = 4 输出: 4 这个题是一道非常重要的题,主要解决方法有三个,选择法,堆查找法和快速排序法。选择法很简单,就是先遍历一遍找到最大的元素,然后再遍历一遍找第二大的,然后再遍历一遍找第三大的,直到第K次就找到了目标值了。但是这种方法只适合在面试的时候预热,面试官不会让你这么简单就开始写代码,因为该方法的时间复杂度为O(NK)。比较好的方法是堆排序法和快速排序法。快速排序我们已经分析过,这里先看堆排序如何解决问题。这个题其实用大堆小堆都可以解决的,但是我们推荐“找最大用小堆,找最小用大堆,找中间用两个堆”,这样更容易理解,适用范围也更广。我们构造一个大小只有4的小根堆,为了更好说明情况,我们扩展一下序列[3,2,3,1, 2 ,4 ,5, 1,5,6,2,3]。堆满了之后,对于小根堆,并一定所有新来的元素都可以入堆的,只有大于根元素的才可以插入到堆中,否则就直接抛弃。这是一个很重要的前提。另外元素进入的时候,先替换根元素,如果发现左右两个子树都小该怎么办呢?很显然应该与更小的那个比较,这样才能保证根元素一定是当前堆最小的。假如两个子孩子的值一样呢?那就随便选一个。新元素插入的时候只是替换根元素,然后重新构造成小堆,完成之后,你会神奇的发现此时根的根元素正好是第4大的元素。这时候你会发现,不管要处理的序列有多大,或者是不是固定的,根元素每次都恰好是当前序列下的第K大元素。上面的图收篇幅所限,我们省略了部分调整环节,请读者自行画一下看看。上的代码自己实现是非常困难的,我们可以使用jdk的优先队列来解决,其思路是很简单的。由于找第 K 大元素,其实就是整个数组排序以后后半部分最小的那个元素。因此,我们可以维护一个有 K 个元素的最小堆:●如果当前堆不满,直接添加;●堆满的时候,如果新读到的数小于等于堆顶,肯定不是我们要找的元素,只有新遍历到的数大于堆顶的时候,才将堆顶拿出,然后放入新读到的数,进而让堆自己去调整内部结构。说明:这里最合适的操作其实是 replace(),即直接把新读进来的元素放在堆顶,然后执行下沉(siftDown())操作。Java 当中的 PriorityQueue 没有提供这个操作,只好先 poll() 再 offer()。优先队列的写法就很多了,这里只例举一个有代表性的,其它的写法大同小异,没有本质差别。public class Solution { public int findKthLargest(int[] nums, int k) { if(k>nums.length){ return -1; } int len = nums.length; // 使用一个含有 k 个元素的最小堆 PriorityQueue<Integer> minHeap = new PriorityQueue<>(k, (a, b) -> a - b); for (int i = 0; i < k; i++) { minHeap.add(nums[i]); } for (int i = k; i < len; i++) { // 看一眼,不拿出,因为有可能没有必要替换 Integer topEle = minHeap.peek(); // 只要当前遍历的元素比堆顶元素大,堆顶弹出,遍历的元素进去 if (nums[i] > topEle) { minHeap.poll(); minHeap.offer(nums[i]); } } return minHeap.peek(); } } 在C++里没有找到现成的二叉堆,要自己实现部分功能void maxHeapify(vector<int>& a, int i, int heapSize) { int l = i * 2 + 1, r = i * 2 + 2, largest = i; if (l < heapSize && a[l] > a[largest]) { largest = l; } if (r < heapSize && a[r] > a[largest]) { largest = r; } if (largest != i) { swap(a[i], a[largest]); maxHeapify(a, largest, heapSize); } } void buildMaxHeap(vector<int>& a, int heapSize) { for (int i = heapSize / 2; i >= 0; --i) { maxHeapify(a, i, heapSize); } } int findKthLargest(vector<int>& nums, int k) { int heapSize = nums.size(); buildMaxHeap(nums, heapSize); for (int i = nums.size() - 1; i >= nums.size() - k + 1; --i) { swap(nums[0], nums[i]); --heapSize; maxHeapify(nums, 0, heapSize); } return nums[0]; } }; 在Python里没有找到现成的二叉堆,要自己实现部分功能,感兴趣的可以研究一下这篇文章:leetcode.cn/problems/kt…堆查找与一般查找方法的优势是可以对超大数量的数据进行查找,还能对数量未知的流数据查找,例如LeetCode703.本题条件比较啰嗦,我们不再赘述,感兴趣的同学可以研究一下。本部分的重点要在理解的基础上记住一个结论:找第K大用小根堆,找第K小用大根堆。具体来说:1.K多大就建立多大固定大小的堆 2.找最大用小堆, 3.只有比根元素大的才让进入堆。 2. 堆排序原理查找:找小用大,找大用小排序:升序用小,降序用大。前面介绍了如何用堆来进行特殊情况的查找,堆的另一个很重要的作用是可以进行排序,那怎么排的呢?其实非常简单,我们知道在大顶堆中,根节点是整个结构最大的元素,我先将其拿走,剩下的重排,此时根节点就是第二大的元素,我再将其拿走,再排,依次类推。最后堆只剩一个元素的时候,是不是拿走的数据也就排好序了?具体来说,建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。这个过程有点类似上面讲的“删除堆顶元素”的操作,当堆顶元素移除之后,我们把下标为 n 的元素放到堆顶,然后再通过堆化的方法,将剩下的 n−1 个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是 n−1 的位置,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了。当然在上面的过程中,放到最后一个位置的元素就不参与排序和计算了。看一个例子,我们对上面第一章的序列 [12 23 54 2 65 45 92 47 204 31]进行排序,首先构建一个大顶堆,然后每次我们都让根元素出堆,剩下的继续调整为大顶堆:这时候你会发现出堆的序列刚好是:204、92、65、54、47、45...。也就是刚好是从大到小的顺序排列的。所以我们可以明白 ,如果是一个小顶堆,那自然是升序的。所以在排序的时候:排序:升序用小,降序用大。这个与前面的查找是相反的。明白了这几个堆的特征,再做相关题目就毫无压力了。3. 合并 K 个排序链表Leetcode23.给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,返回合并后的链表。示例1: 输入:lists = [[1,4,5],[1,3,4],[2,6]] 输出:[1,1,2,3,4,4,5,6] 解释:链表数组如下: [ 1->4->5, 1->3->4, 2->6 ] 将它们合并到一个有序链表中得到。 1->1->2->3->4->4->5->6 给了数组,就建立多大的固定堆 给了几个数组,就建立多大的堆,固定大小的 这个问题五六种方法,我们现在就来看堆排序如何解决。因为每个队列都是从小到大排序的,我们每次都要找最小的元素,所以我们要用小根堆,构建方法和操作与大顶堆完全一样,不同的是每次比较谁更小。 使用堆合并的策略是不管几个链表,最终都是按照顺序来的。每次都将剩余节点的最小值加到输出链表尾部,然后进行堆调整,最后堆空的时候,合并也就完成了。还有一个问题,这个堆应该定义为多大呢?给了几个链表,堆就定义多大。public ListNode mergeKLists(ListNode[] lists) { if (lists == null || lists.length == 0) return null; PriorityQueue<ListNode> q = new PriorityQueue<>(Comparator.comparing(node -> node.val)); for (int i = 0; i < lists.length; i++) { if (lists[i] != null) { q.add(lists[i]); } } ListNode dummy = new ListNode(0); ListNode tail = dummy; while (!q.isEmpty()) { tail.next = q.poll(); tail = tail.next; if (tail.next != null) { q.add(tail.next); } } return dummy.next; } 在C++和Python里需要自己构造堆,代码过于繁琐,我们这里就不提供了,对原理理解透彻的同学,可以自己研究一下。
0
0
0
浏览量879
时光小少年

第 06 关 | 其实很简单的数与层次遍历问题 :2.白银挑战——二叉树的层次遍历经典问题

1. 层次遍历简介广度优先在面试中出现的频率非常高,属于简单题,但是很多人在面试的时候遇到就放弃了,实在可惜,我们本章就来研究一下到底难在哪里。广度优先遍历又叫做层次遍历,基本过程如下:层次遍历就是从根节点开始,先访问根节点下面一层的全部元素,再访问之后的乘车,类似金字塔一样,一层层往下访问,我们可以看到这里就是从左到右一层层地区遍历二叉树,先访问 3 ,之后访问 1 的 左右孩子 9 和 20,之后分别访问 9 和 20 的左右孩子节点【8,13】和【15,17】,最后得到的结果如下:【3,9,20,8,13,15,17】这里的问题在于如何将遍历过后元素的子孩子节点进行保存了,例如访问 9 的时候,其左右孩子节点 8 和 13 应该先保存一下,然后直到 20 出现之后,才会进行处理,使用队列就能完美解决以上这个问题,如上面图中描述:1. 首先 3 入队 2. 然后 3 出队,之后将 3 的左右孩子节点 9 和 20 保存到队列中 3. 之后 9 出队,将 9 的 左右孩子 8 和 13 入队 4. 之后 20 出队,将 20 的左右孩子节点 15 和 7 入队 5. 之后8,13,15,17 分别出队,此时都是叶子节点,只要出队就可以了 该过程不复杂,如果能将树的每层次分开了,能否整点新花样?首先,能否将每层的元素顺序给反转一下呢?能否奇数行不变,只将偶数行反转呢?能否将输出层次从低到root逐层输出呢?再来,既然能拿到每一层的元素了,能否找到当前层最大的元素?最小的元素?最右的元素(右视图)?最左的元素(左视图)?整个层的平均值呢?很明显都可以!这么折腾有什么作用?没啥用!但这就是最层次遍历的高频算法题!这就是 LeetCode 里面经典的层次遍历问题:二叉树的层序遍历锯齿层序遍历二叉树的层次遍历 I二叉树的右视图二叉树的层平均值N 叉树的前序遍历在每个树行中找最大值填充每个节点的下一个右侧节点指针填充每个节点的下一个右侧节点纸质 II2. 基本的层序遍历以及变换我们先看看最简单的情况,仅仅遍历并输出全部元素,如下: 3 / \ 9 20 / \ 15 7 上面二叉树的输出结果是 【3 9 20 15 7】,方法上面已经图示了,这里来看一下怎么实现代码,先访问根节点,然后将其左右孩子放到队列里面,接着继续出队,出来的元素将其左右孩子节点放到队列里面,直到队列为空退出就可以了: List<Integer> simpleLevelOrder (TreeNode root){ if(root == null){ return new ArrayList<Integer>(); } List<Integer> res = new ArrayList<Integer>(); LinkedList<TreeNode> queue = new LinkedList<TreeNode>(); // 将根节点放入队列中,然后不断遍历序列 queue.add(root); // 有多少元素就执行多少次 while(queue.size() > 0){ // 获取当前队列的长度,这个长度相当于当前这一层的节点个数 TreeNode t = queue.remove(); res.add(t.val); if(t.left != null){ queue.add(t.left); } if(t.right != null){ queue.add(t.right); } } return res; } } def simple_level_order(self, root): averages = list() queue = collections.deque([root]) while queue: total = 0 size = len(queue) for _ in range(size): node = queue.popleft() print node.val left, right = node.left, node.right if left: queue.append(left) if right: queue.append(right) vector<vector<int>> levelOrder(TreeNode* root) { vector<vector<int>> ret; if (!root) { return ret; } queue<TreeNode*> q; q.push(root); while (!q.empty()) { int currentLevelSize = q.size(); ret.push_back(vector<int>()); for (int i = 1; i <= currentLevelSize; ++i) { auto node = q.front(); q.pop(); cout << node->val << " "; ret.back().push_back(node->val); if (node->left) q.push(node->left); if (node->right) q.push(node->right); } } return ret; } 根据树的结构可以看到,一个结点在一层访问之后,其子孩子都是在下层按照 FIFO 的顺序处理的,因此队列就是一个缓存的作用。如果你要将每层的元素分开,要怎么做呢?请看下一题:2.1. 二叉树的层序遍历LeetCode 102 题目要求:给你一个二叉树,请你返回其按层序遍历得到的节点值。(即逐层地,从左到右访问所有节点)。二叉树:[3,9,20,null,null,15,7], 3 / \ 9 20 / \ 15 7 返回其层序遍历结果: [ [3], [9,20], [15,7] ] 我们再观察执行过程图,我们先将根节点放到队列中,然后不断遍历队列。那如何判断某一层访问完了呢?简单,用一个变量size标记一下就行了,size表示某一层的元素个数,只要出队,就将size减1,减到0就说明该层元素访问完了。当size变成0之后,这时队列中剩余元素的个数恰好就是下一层元素的个数,因此重新将size标记为下一层的元素个数就可以继续处理新的一行了,例如在上面的序列中:1.首先拿根节点3,其左/右子结点都不为空,就将其左右放入队列中,因此此时3已经出队了,剩余元素9和20恰好就是第二层的所有结点,此时size=2。 2.继续,将9从队列中拿走,size--变成1,并将其子孩子8和13入队。之后再将20 出队,并将其子孩子15和7入队,此时再次size--,变成9了。当size=0,说明当前层已经处理完了,此时队列有四个元素,而且恰好就是下一层的元素个数。 最后,我们把每层遍历到的节点都放入到一个结果集中,将其返回就可以了:按层打印经典版代码:/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { public List<List<Integer>> levelOrder(TreeNode root) { if(root == null){ return new ArrayList<List<Integer>>(); } List<List<Integer>> res = new ArrayList<List<Integer>>(); LinkedList<TreeNode> queue = new LinkedList<TreeNode>(); //将根节点放入队列中,然后不断遍历队列 queue.add(root); while(queue.size() > 0 ){ //获取当前队列的长度,也就是当前这一层的元素个数 int size = queue.size(); ArrayList<Integer> tmp = new ArrayList<Integer>(); //将队列中的元素都拿出来(也就是获取这一层的节点),放到临时list中 //如果节点的左/右子树不为空,也放入队列中 for(int i = 0;i < size; i++){ TreeNode t = queue.remove(); tmp.add(t.val); if(t.left!= null){ queue.add(t.left); } if(t.right != null){ queue.add(t.right); } } res.add(tmp); } return res; } } vector<vector<int>> levelOrder(TreeNode* root) { vector<vector<int>> ret; if (!root) { return ret; } queue<TreeNode*> q; q.push(root); while (!q.empty()) { int currentLevelSize = q.size(); ret.push_back(vector<int>()); for (int i = 1; i <= currentLevelSize; ++i) { auto node = q.front(); q.pop(); cout << node->val << " "; ret.back().push_back(node->val); if (node->left) q.push(node->left); if (node->right) q.push(node->right); } } return ret; } class Solution(object): def levelOrder(self, root): """ :type root: TreeNode :rtype: List[List[int]] """ if not root: return [] res = [] queue = [root] while queue: # 获取当前队列的长度,这个长度相当于 当前这一层的节点个数 size = len(queue) tmp = [] # 将队列中的元素都拿出来(也就是获取这一层的节点),放到临时list中 # 如果节点的左/右子树不为空,也放入队列中 for _ in xrange(size): r = queue.pop(0) tmp.append(r.val) if r.left: queue.append(r.left) if r.right: queue.append(r.right) # 将临时list加入最终返回结果中 res.append(tmp) return res 上面的代码是本章最重要的算法之一,也是整个算法体系的核心算法之一,与链表反转、二分查找属于同一个级别,务必认真学习!理解透彻,然后记住!上面的算法理解了,那接下来一些列的问题就轻松搞定了。注意另外一个需要注意的是在java中实现队列的方法基础类不止一个,对于C++ 和Python等都有类似的情况,我们要有意识的记住这些用法。2.2. 层序遍历——自底向上LeetCode 107.给定一个二叉树,返回其节点值自底向上的层序遍历。(即按从叶子节点所在层到根节点所在的层,逐层从左向右遍历)。例如给定的二叉树为:返回结果为:[ [15,7], [9,20], [3] ] 如果要求从上到下输出每一层的节点值,做法是很直观的,在遍历完一层节点之后,将存储该层节点值的列表添加到结果列表的尾部。这道题要求从下到上输出每一层的节点值,只要对上述操作稍作修改即可,在遍历完一层节点之后,将存储该层节点值的列表添加到结果列表的头部。为了降低在结果列表的头部添加一层节点值的列表的时间复杂度,结果列表可以使用链表的结构,在链表头部添加一层节点值的列表的时间复杂度是 O(1)。在 Java 中,由于我们需要返回的 List 是一个接口,这里可以使用链表实现。/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { public List<List<Integer>> levelOrderBottom(TreeNode root) { if(root == null){ return new LinkedList<List<Integer>>(); } List<List<Integer>> levelOrder = new LinkedList<List<Integer>>(); Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while(queue.size() > 0){ List<Integer> level = new ArrayList<Integer>(); int size = queue.size(); for(int i = 0; i< size;i++){ TreeNode node = queue.poll(); level.add(node.val); if(node.left != null){ queue.offer(node.left); } if(node.right != null){ queue.offer(node.right); } } levelOrder.add(0,level);// 栈 } return levelOrder; } } 在C++版本,注意vector<vector > 这种定义,后面的>>之间要有空格 ,否则会报错vector<vector<int> > levelOrderBottom(TreeNode *root) { auto levelOrder = vector<vector<int> >(); if (!root) { return levelOrder; } queue<TreeNode *> q; q.push(root); while (!q.empty()) { auto level = vector<int>(); int size = q.size(); for (int i = 0; i < size; ++i) { auto node = q.front(); q.pop(); level.push_back(node->val); if (node->left) { q.push(node->left); } if (node->right) { q.push(node->right); } } levelOrder.push_back(level); } reverse(levelOrder.begin(), levelOrder.end()); return levelOrder; } def levelOrderBottom(self, root) : levelOrder = list() if not root: return levelOrder q = collections.deque([root]) while q: level = list() size = len(q) for _ in range(size): node = q.popleft() level.append(node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) levelOrder.append(level) return levelOrder[::-1] 2.3. 二叉树的锯齿形层序遍历LeetCode103 题,要求是:给定一个二叉树,返回其节点值的锯齿形层序遍历。(即先从左往右,再从右往左进行下一层遍历,以此类推,层与层之间交替进行)。例如:给定二叉树 [3,9,20,null,null,15,7]返回结果是:[ [3], [20,9], [15,7] ] 这个题也是102的变种,只是最后输出的要求有所变化,要求我们按层数的奇偶来决定每一层的输出顺序。如果当前层数是偶数,从左至右输出当前层的节点值,否则,从右至左输出当前层的节点值。这里只要采用以我们依然可以沿用第 102 题的思想,为了满足题目要求的返回值为「先从左往右,再从右往左」交替输出的锯齿形,可以利用「双端队列」的数据结构来维护当前层节点值输出的顺序。双端队列是一个可以在队列任意一端插入元素的队列。在广度优先搜索遍历当前层节点拓展下一层节点的时候我们仍然从左往右按顺序拓展,但是对当前层节点的存储我们维护一个变量 isOrderLeft 记录是从左至右还是从右至左的:● 如果从左至右,我们每次将被遍历到的元素插入至双端队列的末尾。● 从右至左,我们每次将被遍历到的元素插入至双端队列的头部。/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { public List<List<Integer>> zigzagLevelOrder(TreeNode root) { if(root == null){ return new LinkedList<List<Integer>>(); } List<List<Integer>> res = new LinkedList<List<Integer>>(); Queue<TreeNode> queue = new LinkedList<TreeNode>(); queue.offer(root); boolean isOrderLeft = true; while(!queue.isEmpty()){ Deque<Integer> levelList = new LinkedList<Integer>(); int size = queue.size(); for(int i = 0; i < size; i++){ TreeNode curnode = queue.poll(); if(isOrderLeft){ levelList.offerLast(curnode.val); }else{ levelList.offerFirst(curnode.val); } if(curnode.left != null){ queue.offer(curnode.left); } if(curnode.right != null){ queue.offer(curnode.right); } } res.add(new LinkedList<Integer>(levelList)); isOrderLeft = !isOrderLeft; } return res; } } vector<vector<int> > zigzagLevelOrder(TreeNode* root) { vector<vector<int> > ans; if (!root) return ans; deque<TreeNode*> que; que.push_back(root); bool zigzag = true; TreeNode* tmp; while (!que.empty()) { int Size = que.size(); vector<int> tmp_vec; while (Size) { if (zigzag) { // 前取后放 tmp = que.front(); que.pop_front(); if (tmp->left) que.push_back(tmp->left); if (tmp->right) que.push_back(tmp->right); } else { //后取前放 tmp = que.back(); que.pop_back(); if (tmp->right) que.push_front(tmp->right); if (tmp->left) que.push_front(tmp->left); } tmp_vec.push_back(tmp->val); --Size; } zigzag = !zigzag; ans.push_back(tmp_vec); } return ans; } def zigzagLevelOrder(self, root) : if not root: return [] res = [] q = collections.deque() q.append(root) while q: res_tmp = [] n = len(q) for i in range(n): tmp = q.popleft() res_tmp.append(tmp.val) if tmp.left: q.append(tmp.left) if tmp.right: q.append(tmp.right) res.append(res_tmp) for j in range(len(res)): if j % 2 == 1: res[j].reverse() return res 2.4. N叉树的层序遍历LeetCode429 给定一个 N 叉树,返回其节点值的层序遍历。(即从左到右,逐层遍历)。树的序列化输入是用层序遍历,每组子节点都由 null 值分隔(参见示例)。输入:root = [1,null,3,2,4,null,5,6](表述树的元素是这个序列) 输出:[[1],[3,2,4],[5,6]] N叉树的定义如下,就是一个值,加一个列表,其类型仍然是Node:class Node { public int val; public List<Node> children; } 这个也是102的扩展,很简单的广度优先,与二叉树的层序遍历基本一样,借助队列即可实现。/* // Definition for a Node. class Node { public int val; public List<Node> children; public Node() {} public Node(int _val) { val = _val; } public Node(int _val, List<Node> _children) { val = _val; children = _children; } }; */ class Solution { public List<List<Integer>> levelOrder(Node root) { if(root == null){ return new ArrayList<List<Integer>>(); } List<List<Integer>> res = new ArrayList<List<Integer>>(); Queue<Node>queue = new ArrayDeque<Node>(); queue.offer(root); while(!queue.isEmpty()){ int n = queue.size(); List<Integer> level = new ArrayList<Integer>(); for(int i = 0; i < n;i++){ Node cur = queue.poll(); level.add(cur.val); for(Node child:cur.children){ queue.offer(child); } } res.add(level); } return res; } } vector<vector<int>> levelOrder(Node* root) { if (!root) { return {}; } vector<vector<int>> ans; queue<Node*> q; q.push(root); while (!q.empty()) { int cnt = q.size(); vector<int> level; for (int i = 0; i < cnt; ++i) { Node* cur = q.front(); q.pop(); level.push_back(cur->val); for (Node* child: cur->children) { q.push(child); } } ans.push_back(move(level)); } return ans; } class Solution: def levelOrder(self, root: 'Node'): if not root: return [] ans = list() q = deque([root]) while q: cnt = len(q) level = list() for _ in range(cnt): cur = q.popleft() level.append(cur.val) for child in cur.children: q.append(child) ans.append(level) return ans 3. 几个处理每层元素的题目如果我们拿到了每一层的元素,那是不是可以利用一下造几个题呢?例如每层找最大值、平均值、最右侧的值呢?当然可以。LeetCode里就有三道非常明显的题目。515.在每个树行中找最大值(最小)637.二叉树的层平均值199.二叉树的右视图既然能这么干,我们能否自己造几个题:求每层最小值可以不?求每层最左侧的可以不?我们是不是可以给LeetCode贡献几道题了?3.1. 在每个树行中找最大值LeetCode 515题目要求:给定一棵二叉树的根节点 root ,请找出该二叉树中每一层的最大值。这里其实就是在得到一层之后使用一个变量来记录当前得到的最大值:/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { public List<Integer> largestValues(TreeNode root) { if(root == null){ return new ArrayList<>(); } List<Integer> res = new ArrayList<>(); Deque<TreeNode> deque = new ArrayDeque<>(); deque.add(root); while(!deque.isEmpty()){ int size = deque.size(); int levelMaxNum = Integer.MIN_VALUE; for (int i = 0; i < size; i++) { TreeNode node = deque.poll(); levelMaxNum = Math.max(node.val,levelMaxNum); if (node.left != null) deque.addLast(node.left); if (node.right != null) deque.addLast(node.right); } res.add(levelMaxNum); } return res; } } def largestValues(self, root): if root is None: return [] ans = [] q = [root] while q: maxVal = -inf tmp = q q = [] for node in tmp: maxVal = max(maxVal, node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) ans.append(maxVal) return ans vector<int> largestValues(TreeNode* root) { if (!root) { return {}; } vector<int> res; queue<TreeNode*> q; q.push(root); while (!q.empty()) { int len = q.size(); int maxVal = INT_MIN; while (len > 0) { len--; auto t = q.front(); q.pop(); maxVal = max(maxVal, t->val); if (t->left) { q.push(t->left); } if (t->right) { q.push(t->right); } } res.push_back(maxVal); } return res; } 3.2. 在每个树行中找平均值LeetCode 637 要求给定一个非空二叉树, 返回一个由每层节点平均值组成的数组。示例这个题和前面的几个一样,只不过是每层都先将元素保存下来,最后求平均就行了:public List<Double> averageOfLevels(TreeNode root) { List<Double> res = new ArrayList<>(); if (root == null) return res; Queue<TreeNode> list = new LinkedList<>(); list.add(root); while (list.size() != 0){ int len = list.size(); double sum = 0; for (int i = 0; i < len; i++){ TreeNode node = list.poll(); sum += node.val; if (node.left != null) list.add(node.left); if (node.right != null) list.add(node.right); } res.add(sum/len); } return res; } class Solution { public: vector<double> averageOfLevels(TreeNode* root) { auto averages = vector<double>(); auto q = queue<TreeNode*>(); q.push(root); while (!q.empty()) { double sum = 0; int size = q.size(); for (int i = 0; i < size; i++) { auto node = q.front(); q.pop(); sum += node->val; auto left = node->left, right = node->right; if (left != nullptr) { q.push(left); } if (right != nullptr) { q.push(right); } } averages.push_back(sum / size); } return averages; } }; class Solution: def largestValues(self, root): if root is None: return [] ans = [] q = [root] while q: maxVal = -inf tmp = q q = [] for node in tmp: maxVal = max(maxVal, node.val) if node.left: q.append(node.left) if node.right: q.append(node.right) ans.append(maxVal) return ans 3.3. 二叉树的右视图LeetCode 199题目要求是:给定一个二叉树的根节点 root,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。例如:这个题目出现频率还挺高的,如果没有提前思考过,面试现场可能会想不到怎么做。其实也很简单那,利用 BFS 进行层次遍历,记录下每层的最后一个元素。public List<Integer> rightSideView(TreeNode root) { List<Integer> res = new ArrayList<>(); if (root == null) { return res; } Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); while (!queue.isEmpty()) { int size = queue.size(); for (int i = 0; i < size; i++) { TreeNode node = queue.poll(); if (node.left != null) { queue.offer(node.left); } if (node.right != null) { queue.offer(node.right); } if (i == size - 1) { //将当前层的最后一个节点放入结果列表 res.add(node.val); } } } return res; } def rightSideView(self, root): rightmost_value_at_depth = dict() # 深度为索引,存放节点的值 max_depth = -1 queue = deque([(root, 0)]) while queue: node, depth = queue.popleft() if node is not None: # 维护二叉树的最大深度 max_depth = max(max_depth, depth) # 由于每一层最后一个访问到的节点才是我们要的答案,因此不断更新对应深度的信息即可 rightmost_value_at_depth[depth] = node.val queue.append((node.left, depth + 1)) queue.append((node.right, depth + 1)) return [rightmost_value_at_depth[depth] for depth in range(max_depth + 1)] vector<int> rightSideView(TreeNode* root) { vector<int> res; if (root == NULL) { return res; } queue<TreeNode*> q; q.push(root); while (!q.empty()) { int size = q.size(); for (int i = 0; i < size; i++) { TreeNode* node = q.front(); q.pop(); if (node->left != NULL) { q.push(node->left); } if (node->right != NULL) { q.push(node->right); } if (i == size - 1) { // 将当前层的最后一个节点放入结果列表 res.push_back(node->val); } } } return res; } 是不是很简单,这三题本质都是层次遍历的变形。我们来造题:如果将右视图换成左视图呢?请读者自行思考。再思考,俯视图行不行?答案是不行的,那为什么不行呢,请读者思考。3.4. 最底层最左边上面这个层次遍历的思想可以方便的解决,LeetCode513. 二叉树最底层最左边的值的问题:给定一个二叉树的 根节点root,请找出该二叉树的 最底层 最左边 节点的值。假设二叉树中至少有一个节点。示例1: 输入: root = [2,1,3] 输出: 1 示例2: 输入: [1,2,3,4,null,5,6,null,null,7] 输出: 7 我们在第二章介绍了很多次如何使用层次遍历,这里有两个问题:该怎么知道什么时候到了最底层呢?假如最底层有两个,该怎么知道哪个是最左的呢?我们继续观察层次遍历的执行过程:我们可以发现,正常执行层次遍历,不管最底层有几个元素,最后一个输出的一定是是最底层最右的元素7,那这里我们就想了,能否将该处理与上一次题的翻转结合一下,每一层都是先反转再放入队列,就可以让最后一个输出的是最左的呢?是的,这就是解决本题的关键。public int findBottomLeftValue(TreeNode root) { if (root.left == null && root.right == null) { return root.val; } Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); TreeNode temp = new TreeNode(-100); while (!queue.isEmpty()) { temp = queue.poll(); if (temp.right != null) { // 先把右节点加入 queue queue.offer(temp.right); } if (temp.left != null) { // 再把左节点加入 queue queue.offer(temp.left); } } return temp.val; } class Solution: def findBottomLeftValue(self, root: ) : curVal = curHeight = 0 def dfs(node: , height) : if node is None: return height += 1 dfs(node.left, height) dfs(node.right, height) nonlocal curVal, curHeight if height > curHeight: curHeight = height curVal = node.val dfs(root, 0) return curVal int findBottomLeftValue(TreeNode* root) { if (root == nullptr || root->left == nullptr && root->right == nullptr) { return root->val; } Queue<TreeNode*> queue; queue.push(root); TreeNode* temp = new TreeNode(-100); while (!queue.empty()) { temp = queue.front(); queue.pop(); if (temp->right != nullptr) { queue.push(temp->right); } if (temp->left != nullptr) { queue.push(temp->left); } } return temp->val; }
0
0
0
浏览量1252
时光小少年

第 18 关 | 经典刷题思想之回溯:1.青铜挑战—回溯是怎么回事

回溯是最重要的算法思想之一,主要解决一些暴力枚举也搞不定的问题,例如组合、分割、子集、排列,棋盘等。从性能角度来看回溯算法的效率并不高,但对于这些暴力都搞不定的算法能出结果就很好了,效率低点没关系。 这一关,我们先理解回溯到底怎么回事.关卡名认识回溯思想我会了✔️内容1.  复习递归和N叉树,理解相关代码是如何实现的✔️2.  理解回溯到底怎么回事✔️3.  掌握如何使用回溯来解决二叉树的路径问题✔️回溯可以视为递归的拓展,很多思想和解法都与递归密切相关,在很多材料中都将回溯都与递归同时解释,例如本章2.1的路径问题就可以使用递归和回溯两种方法来解决。因此学习回溯时,我们对比递归来分析其特征会理解更深刻。关于递归和回溯的区别,我们设想一个场景,某猛男想脱单,现在有两种策略:递归策略:先与意中人制造偶遇,然后了解人家的情况,然后约人家吃饭,有好感之后尝试拉人家的手,没有拒绝就表白。回溯策略:先统计周围所有的单身女孩,然后一个一个表白, 被拒绝就说“我喝醉了”,然后就当啥也没发生,继续找下一个。其实回溯本质就这么个过程,请读者学习本章时认真揣摩这个过程。回溯最大的好处是有非常明确的模板,所有的回溯都是一个大框架,因此透彻理解回溯的框架是解决一切回溯问题的基础。第一章我们只干一件事,那就是分析这个框架。回溯不是万能的,而且能解决的问题也是非常明确的,例如组合、分割、子集、排列,棋盘等等,不过这些问题具体处理时又有很多不同,本章我们梳理了多个最为热门的问题来解释,请同学们认真对待。回溯可以理解为递归的拓展,而代码结构又特别像深度遍历N叉树,因此只要知道递归,理解回溯并不难,难在很多人不理解为什么在递归语句之后要有个“撤销”的操作。 我们会通过图示轻松给你解释该问题。这里先假设一个场景,你谈了个新女朋友,来你家之前,你是否会将你前任的东西赶紧藏起来?回溯也一样,有些信息是前任的,要先处理掉才能重新开始。回溯最让人激动的是有非常清晰的解题模板,如下所示,大部分的回溯代码框架都是这个样子,具体为什么这样子我们后面再解释。void backtracking(参数) { if (终止条件) { 存放结果; return; } for (选择本层集合中元素(画成树,就是树节点孩子的大小)){ 处理节点; backtracking(); 回溯,撤销处理结果; } } 回溯是有明确的解题模板的,本章我们只干一件事——分析回溯的模板。注:本部分的代码只是演示功能,相当于伪码,所以不提供三种语言的实现1. 从 N叉树说起在解释回溯之前, 我们先看一下N叉树遍历的问题,我们知道在二叉树中,按照前序遍历的过程如下所示:void treeDFS(TreeNode root) { if (root == null) return; System.out.println(root.val); treeDFS(root.left); treeDFS(root.right); } class TreeNode{ int val; TreeNode left; TreeNode right; } 假如我现在是一个三叉、四叉甚至N叉树该怎么办呢?很显然这时候就不能用left和right来表示分支了,使用一个List比较好,也就是这样子:class TreeNode{ int val; List<TreeNode> nodes; } 遍历的代码:public static void treeDFS(TreeNode root) { //递归必须要有终止条件 if (root == null){ return; } // 处理节点 System.out.println(root.val); //通过循环,分别遍历N个子树 for (int i = 1; i <= nodes.length; i++) { treeDFS("第i个子节点"); } } 到这里,你有没有发现和上面说的回溯的模板非常像了?是的!非常像!既然很像,那说明两者一定存在某种关系。其他暂时不管,现在你只要先明白回溯的大框架就是遍历N叉树就行了。2. 为什么有的问题暴力搜索也不行我们说回溯主要解决暴力枚举也解决不了的问题,什么问题这么神奇,暴力都搞不定?看个例子:LeetCode77 :给定两个整数 n 和 k,返回 1 ... n 中所有可能的 k 个数的组合。例如,输入n=4,k=2,则输出:[[2,4], [3,4], [2,3], [1,2], [1,3], [1,4]]首先明确这个题是什么意思,如果n=4,k=2,那就是从4个数中选择2个,问你最后能选出多少组数据。这个是高中数学中的一个内容,过程大致这样:如果n=4,那就是所有的数字为{1,2,3,4}先取一个1,则有[1,2],[1,3],[1,4]三种可能。然后取一个2,因为1已经取过了,不再取,则有[2,3],[2,4]两种可能。再取一个3,因为1和2都取过了,不再取,则有[3,4]一种可能。再取4,因为1,2,3都已经取过了,所以直接返回null。所以最终结果就是[1,2],[1,3],[1,4],[2,3],[2,4],[3,4]。这就是我们思考该问题的基本过程,写成代码也很容易,双层循环轻松搞定:int n = 4; for (int i = 1; i <= n; i++) { for (int j = i + 1; j <= n; j++) { System.out.println(i + " " + j); } } 假如n和k都变大,比如n是200,k是3呢?也可以,三层循环基本搞定:int n = 200; for (int i = 1; i <= n; i++) { for (int j = i + 1; j <= n; j++) { for (int u = j + 1; u <= n; n++) { System.out.println(i + " " + j + " " + u); } } 如何这里的K是5呢?甚至是50呢?你需要套多少层循环?甚至告诉你K就是一个未知的正整数k,你怎么写循环呢?这时候已经无能为例了?所以暴力搜索就不行了。这就是组合类型问题,除此之外子集、排列、切割、棋盘等方面都有类似的问题,因此我们要找更好的方式。3. 回溯=递归+局部枚举+放下前任我们继续研究LeetCode77题,我们图示一下上面自己枚举所有答案的过程。n=4时,我们可以选择的n有 {1,2,3,4}这四种情况,所以我们从第一层到第二层的分支有四个,分别表示可以取1,2,3,4。而且这里 从左向右取数,取过的数,不在重复取。 第一次取1,集合变为2,3,4 ,因为k为2,我们只需要再取一个数就可以了,分别取2,3,4,得到集合[1,2] [1,3] [1,4],以此类推。横向:每次从集合中选取元素,可选择的范围会逐步收缩,到了取4时就直接为空了。继续观察树结构,可以发现,图中每次访问到一次叶子节点(图中红框标记处),我们就找到了一个结果。虽然最后一个是空,但是不影响结果。这相当于只需要把从根节点开始每次选择的内容(分支)达到叶子节点时,将其收集起来就是想要的结果。如果感觉不明显,我们再画一个n=5,k=3的例子:从图中我们发现元素个数n相当于树的宽度(横向),而每个结果的元素个数k相当于树的深度(纵向)。所以我们说回溯算法就是一纵一横而已。再分析,我们还发现几个规律:① 我们每次选择都是从类似{1,2,3,4},{1,2,3,4,5}这样的序列中一个个选的,这就是局部枚举,而且越往后枚举范围越小。② 枚举时,我们就是简单的暴力测试而已,一个个验证,能否满足要求,从上图可以看到,这就是N叉树遍历的过程,因此两者代码也必然很像。③ 我们再看上图中红色大框起来的部分,这个部分的执行过程与n=4,k=2的处理过程完全一致,很明显这是个可以递归的子结构。这样我们就将回溯与N叉树的完美结合在一起了。到此,还有一个大问题没有解决,回溯一般会有个手动撤销的操作,为什么要这样呢?继续观察纵横图:我们可以看到,我们收集每个结果不是针对叶子结点的,而是针对树枝的,比如最上层我们首先选了1,下层如果选2,结果就是{1,2},如果下层选了3,结果就是{1,3},依次类推。现在的问题是当我们得到第一个结果{1,2}之后,怎么得到第二个结果{1,3}呢?继续观察纵横图,可以看到,我可以在得到{1,2}之后将2撤掉,再继续取3,这样就得到了{1,3},同理可以得到{1,4},之后当前层就没有了,我们可以将1撤销,继续从最上层取2继续进行。这里对应的代码操作就是先将第一个结果放在临时列表path里,得到第一个结果{1,2}之后就将path里的内容放进结果列表resultList中,之后,将path里的2撤销掉, 继续寻找下一个结果{1.3},然后继续将path放入resultLit,然后再撤销继续找。现在明白为什么要手动撤销了吧,这个过程,我称之为"放下前任,继续前进",后面所有的回溯问题都是这样的思路。这几条就是回溯的基本规律,明白之后,一切都变得豁然开朗。如果还是不太明白,我们下一小节用更完整的图示解释该过程。到此我们就可以写出完整的回溯代码了:public List<List<Integer>> combine(int n, int k) { List<List<Integer>> resultList = new ArrayList<>(); if (k <= 0 || n < k) { return resultList; } // 用户返回结果 Deque<Integer> path = new ArrayDeque<>(); dfs(n, k, 1, path, res); return res; } public void dfs(int n, int k, int startIndex, Deque<Integer> path, List<List<Integer>> resultList) { // 递归终止条件是:path 的长度等于 k if (path.size() == k) { resultList.add(new ArrayList<>(path)); return; } // 针对一个结点,遍历可能的搜索起点,其实就是枚举 for (int i = startIndex; i <= n; i++) { // 向路径变量里添加一个数,就是上图中的一个树枝的值 path.addLast(i); // 搜索起点要加1是为了缩小范围,下一轮递归做准备,因为不允许出现重复的元素 dfs(n, k, i + 1, path, resultList); // 递归之后需要做相同操作的逆向操作,具体后面继续解释 path.removeLast(); } } 上面代码还有个问题要解释一下:startIndex和i是怎么变化的,为什么传给下一层时要加1。我们可以看到在递归里有个循环for (int i = startIndex; i <= n; i++) { dfs(n,k,i+1,path,res); } 这里的循环有什么作用呢?看一下图就知道了,这里其实就是枚举,第一次n=4,可以选择1 ,2,3,4四种情况,所以就有四个分支,for循环就会执行四次:而对于第二层第一个,选择了1之后,剩下的元素只有2 ,3, 4了,所以这时候for循环就执行3次,后面的则只有2次和1次。4. 图解为什么有个撤销的操作如果你已经明白上面为什么会有撤销过程,这一小节就不必看了。如果还是不懂,本节就用更详细的图示带你看一下。 回溯最难理解的部分是这个回溯过程,而且这个过程即使调试也经常会晕:path.addLast(i); dfs(n, k, i + 1, path, res); path.removeLast(); 为什么要remove呢?看下图,当第一层取1时,最底层的边从左向右依次执行“取2”、“取3”和“取4”,而取3的时候,此时list里面存的是上一个结果<1,2>,所以必须提前将2撤销,这就path.removeLast();的作用。用我们拆解递归的方法,将递归拆分成函数调用,输出第一条路径{1,2}的步骤如下如下:我们在递归章节说过,递归是“不撞南墙不回头”,回溯也一样,接下来画代码的执行图详细看一下其过程,图中的手绘的序号是执行过程:然后呢?{1,2}输出 之后会怎么执行呢?回归之后,假如我们将remove代码去掉,也就是这样子:注意上面的4号位置结束之后,当前递归就结束了,然后返回到上一层继续执行for循环体,也就是上面的5。进入5之后,接着开始执行第6步:path.addLast(i)了,此时path的大小是3,元素是{1,2,3},为什么会这样呢?因为path是一个全局的引用,各个递归函数共用的,所以当{1,2}处理完之后,2污染了path变量。我们希望将1保留而将2干掉,然后让3进来,这样才能得到{1,3},所以这时候需要手动remove一下。同样3处理完之后,我们也不希望3污染接下来的{1,4},1全部走完之后也不希望1污染接下来的{2,3}等等,这就是为什么回溯里会在递归之后有一个remove撤销操作。5. 回溯热身—再论二叉树的路径问题5.1. 输出二叉树的所有路径LeetCode257:给你一个二叉树的根节点root ,按任意顺序 ,返回所有从根节点到叶子节点的路径。叶子节点是指没有子节点的节点。示例: 输入:root = [1,2,3,null,5] 输出:["1->2->5","1->3"] 我们可以注意到有几个叶子节点,就有几条路径,那如何找叶子节点呢?我们知道深度优先搜索就是从根节点开始一直找到叶子结点,我们这里可以先判断当前节点是不是叶子结点,再决定是不是向下走,如果是叶子结点,我们就增加一条路径。我们现在从回溯的角度来分析,得到第一条路径ABD之后怎么找到第二条路径ABE,这里很明显就是先将D撤销,然后再继续递归就可以了class BinaryTreePaths { List<String> ans = new ArrayList<>(); public List<String> binaryTreePaths(TreeNode root) { dfs(root,new ArrayList<>()); return ans; } private void dfs(TreeNode root, List<Integer> temp){ if(root == null) return; temp.add(root.val); //如果是叶子节点记录结果 if(root.left == null && root.right == null){ ans.add(getPathString(temp)); } dfs(root.left,temp); dfs(root.right,temp); temp.remove(temp.size()-1); } //拼接结果 private String getPathString(List<Integer> temp){ StringBuilder sb = new StringBuilder(); sb.append(temp.get(0)); for(int i = 1;i < temp.size(); ++i){ sb.append("->").append(temp.get(i)); } return sb.toString(); } } def binaryTreePaths(self, root): res = [] get_paths(root, [], res) return res def get_paths(root, path, res): if root: path.append(str(root.val)) left = get_paths(root.left, path, res) right = get_paths(root.right, path, res) if not left and not right: # 如果root是叶子结点 res.append("->".join(path)) # 把当前路径加入到结果列表中 path.pop() # 返回上一层递归时,要让当前路径恢复原样 return True void traversal(TreeNode* cur, string path, vector<string>& result) { path += to_string(cur->val); // 中,中为什么写在这里,因为最后一个节点也要加入到path中 if (cur->left == NULL && cur->right == NULL) { result.push_back(path); return; } if (cur->left) { path += "->"; traversal(cur->left, path, result); // 左 path.pop_back(); // 回溯 '>' path.pop_back(); // 回溯 '-' } if (cur->right) { path += "->"; traversal(cur->right, path, result); // 右 path.pop_back(); // 回溯'>' path.pop_back(); // 回溯 '-' } } vector<string> binaryTreePaths(TreeNode* root) { vector<string> result; string path; if (root == NULL) return result; traversal(root, path, result); return result; } 感兴趣的同学可以和《树和递归》中介绍的方法来对比一下,看看两种递归方式有什么区别。5.2. 路径总和问题同样的问题是LeetCode113题,给你二叉树的根节点 root 和一个整数目标和 targetSum ,找出所有从根节点到叶子节点 路径总和等于给定目标和的路径。叶子节点 是指没有子节点的节点。示例1: 输入:root = [5,4,8,11,null,13,4,7,2,null,null,5,1], targetSum = 22 输出:[[5,4,11,2],[5,8,4,5]] 本题怎么做呢?我们直接观察题目给的示意图即可,要找的targetSum是22。我们发现根节点是5,因此只要从左侧或者右侧找到targetSum是17的即可。继续看左子树,我们发现值为4,那只要从node(4)的左右子树中找targetSum是13即可,依次类推,当我们到达node(11)时,我们需要再找和为2的子链路,显然此时node(7)已经超了,不是我们要的,此时就要将node(7)给移除掉,继续访问node(2).同样在根结点的右侧,我们也要找总和为17的链路,方式与上面的一致。完整代码就是:class PathSum { List<List<Integer>> res=new ArrayList<>(); public List<List<Integer>> pathSum(TreeNode root, int targetSum) { LinkedList<Integer> path=new LinkedList<>(); dfs(root,targetSum,path); return res; } public void dfs(TreeNode root,int targetSum,LinkedList<Integer> path){ if(root==null){ return; } //这个值有很关键的作用 targetSum-=root.val; path.add(root.val); if(targetSum==0&&root.left==null&&root.right==null){ res.add(new LinkedList(path)); } dfs(root.left,targetSum,path); dfs(root.right,targetSum,path); path.removeLast(); } } def pathSum(self, root, targetSum) : ret = list() path = list() def dfs(root: TreeNode, targetSum: int): if not root: return path.append(root.val) targetSum -= root.val if not root.left and not root.right and targetSum == 0: ret.append(path[:]) dfs(root.left, targetSum) dfs(root.right, targetSum) path.pop() dfs(root, targetSum) return ret class Pathsum { vector<vector<int>> result; vector<int> path; // 递归函数不需要返回值,因为我们要遍历整个树 void traversal(treenode* cur, int count) { if (!cur->left && !cur->right && count == 0) { // 遇到了叶子节点且找到了和为sum的路径 result.push_back(path); return; } if (!cur->left && !cur->right) return ; // 遇到叶子节点而没有找到合适的边,直接返回 if (cur->left) { // 左 (空节点不遍历) path.push_back(cur->left->val); count -= cur->left->val; traversal(cur->left, count); // 递归 count += cur->left->val; // 回溯 path.pop_back(); // 回溯 } if (cur->right) { // 右 (空节点不遍历) path.push_back(cur->right->val); count -= cur->right->val; traversal(cur->right, count); // 递归 count += cur->right->val; // 回溯 path.pop_back(); // 回溯 } return ; } vector<vector<int>> pathsum(treenode* root, int sum) { result.clear(); path.clear(); if (root == null) return result; path.push_back(root->val); // 把根节点放进路径 traversal(root, sum - root->val); return result; } }
0
0
0
浏览量1454
时光小少年

第 6 关 | 其实很简单的数与层次遍历问题: 1.青铜挑战——理解树的结构

1. 树的常见概念树是一个有n个有限节点组成的一个具有层次关系的集合,每个节点有0个或者多个子节点,没有父节点的节点称为根节点,也就是说除了根节点以外每个节点都有父节点,并且有且只有一个。树的种类有很多,我们最常见的就是二叉树,其基本结构如下:参考以上结构,可以很方便地理解树的以下概念:节点的度:一个节点含有的子节点的个数称为该节点的度;树的度:一棵树中,最大的节点的度称为树的度,注意与节点度的区别;叶子结点或终端节点:度为 0 的节点称为叶子节点;非终端节点或分支节点:度不为 0 的节点;双亲节点或者父节点:若一个节点含有子节点,则这个节点称为其子节点的父节点;孩子节点或者子节点:一个节点含有的子树的根节点称为该节点的子节点;兄弟节点:具有相同父节点的节点称为兄弟节点;节点的祖先:从根到该节点所经分支上的所有节点;子孙:以某节点为根的子树中任意一个节点都称为该节点的子孙森林:由 m (m >= 0 )棵互不相交的树的集合称为森林无序树:树中任意节点的子节点没有顺序关系,这中树称为无序树,也称为自由树有序树:树中任意节点的子节点之间有顺序关系,这种树称为有序树二叉树:每个节点最多包含两个子树的树称为二叉树;2. 树的性质性质1: 在二叉树的第 i 层最多有 2 ^(i - 1)个节点(i > 0)性质2: 深度为 k 的二叉树最多有 2 ^ (k - 1) 个节点(k > 0)性质3: 对于任意一颗二叉树,如果其叶子节点数位 N0,而度数为 2 的节点数为 N2,则 N0 = N2 + 1;性质4: 具有 n 个节点的完全二叉树的深度为 log2(n + 1)性质5: 对于完全二叉树,若从上到下、从左到右编号,则编号为 i 的节点,其左孩子编号必定为 2 i,右孩子节点必为 2i+ 1;其双亲编号必为满二叉树。这课二叉树为满二叉树,也就是说深度为 k = 4,有 2 ^ k - 1 = 15 个节点的二叉树。完全二叉树的定义如下:在完全二叉树中,除了最底层节点可能没有填满以外,其余每层的节点数都达到了最大值,并且最下面一层的节点都集中在该层最左边的若干位置。这个定义最邪乎了,估计大部分看了之后还不懂什么事完全二叉树,看这个图就可以了:前面两棵树的 n - 1 层都是满的,最后一层所有节点都集中在左侧区域,而且中间不能有空隙,最后一个为什么不是呢?因为有一节缺了一个左子节点。3. 树的定义与存储方式注意本关讲义我们主要看原理,不写可执行的代码,因此我们只用伪代码,不提供各种语言的定义定于树的原理和我们前面讲的链表的本质是一样的,只不过多了一个指针,如果是二叉树,只要在链表的定义上增加一个指针就可以了:public class TreeNode { int val; TreeNode left; TreeNode right; } 这里本质上就是有两个引用,分别指向两个位置,为了便于理解,我们分别命名为左 孩子和右孩子。如果是N叉树该如何定义呢?其实就是每个节点最多可以有N个指针 指向其他地方,这是不用left和right,使用一个List就可以了,也就是:public class TreeNode { int val; List<TreeNode> nodes; } 那能否用数组来存储二叉树呢?其实就是用数组来存储二叉树,顺序存储的方式如图:用数组来存储二叉树如何遍历的呢?如果父节点的数组下表是i,那么它的左孩子就是i * 2 + 1,右孩子就是 i * 2 + 2。但是用链式表示的二叉树,更有利于我们理解,所以 一般我们都是用链式存储二叉树。所以大家要了解,用数组依然可以表示二叉树。 使用数组存储的最大不足是可能存在大量的空间浪费。例如上图中如果b分支没有, 那么数组种1 3 4 位置都要空着,但是整个数组的大小仍然是7,因此很少使用数组来存储树。4. 树的遍历方式我们现在就来看一下树的常见遍历方法。二叉树的遍历方式有层次遍历和深度优先遍 历两种:深度优先遍历:先往深走,遇到叶子节点再往回走。广度优先遍历:一层一层的去遍历,一层访问完再访问下一层。这两种遍历方式不仅仅是二叉树,N叉树也有这两种方式的,图结构也有,只不过我 们更习惯叫广度优先和深度优先,本质是一回事。深度优先又有前中后序三种, 有同 学总分不清这三个顺序,问题就在不清楚这里前中后是相对谁来说的。记住一点:前指的是中间的父节点在遍历中的顺序,只要大家记住 前中后序指的就是中间节点的位置就可以了。看如下中间节点的顺序,就可以发现,访问中间节点的顺序就是所谓的遍历方式前序遍历:中左右中序遍历:左中右后序遍历:左右中大家可以对着如下图,看看自己理解的前后中序有没有问题 。后面的大量算法都和这四种遍历方式有关,有的题目根据角度不同,可以用层次遍历,也可以用一种甚至两种深度优先的方式来实现。5. 通过序列构造二叉树5.1. 前中序列复原二叉树前面我们已经介绍了前中后序遍历的基本过程,现在我们看一下如何通过给出的序列 来恢复原始二叉树,看三个序列:(1) 前序:1 2 3 4 5 6 8 7 9 10 11 12 13 15 14(2) 中序:3 4 8 6 7 5 2 1 10 9 11 15 13 14 12(3) 后序:8 7 6 5 4 3 2 10 15 14 13 12 11 9 1这里选择前序和中序,进行第一道题的讲解(1) 前序:1 2 3 4 5 6 8 7 9 10 11 12 13 15 14 (2) 中序:3 4 8 6 7 5 2 1 10 9 11 15 13 14 12 第一轮:根据前序和中序划分,可以得到根节点为 1 ,然后可以得到第一轮树的结构第二轮:先看两个序列的第一个数组:前序: 2 3 4 5 8 7 中序: 3 4 8 6 7 5 2此时又可以利用上面的结论划分了:根节点是 2 ,根据 2 的在中序的位置,可以划分为前序 : 2 【3 4 5 6 8 7】 中序:【3 4 8 6 7 5】 2所以第二轮树的结构为:第三轮 对 3 4 5 6 8 7 继续划分 前序:3 【4 5 6 8 7】 中序:3【4 8 6 7 5】,此时结构如下:第四轮:对 4 5 8 7 继续划分: 前序 4 【 5 6 8 7】 中序:4 【8 6 7 5】第五轮:对 5 6 8 7 继续划分:前序:5 【6 8 7】 中序:【8 6 7】5,最后得到的结果如下:同理可得完整图如下:5.2. 通过中序和后序序列恢复二叉树然后中序和后序也是可以恢复原始序列的,唯一不同的是后序序列的最后一个是根节点,中序也是一样的过程,这里我们可以用以下例子来试一下:为下为大致过程,第一轮 根节点为 5 ,所以,就可以得到左边为 【1 4 2】,右边为【7 6 8】,所以第一轮结构如下:第二轮先对左边进行排序,【1 4 2】,可以得到根节点为 4 ,然后 1 在左边,2 在右边,树的结构如下:然后另一边也是一样的,结果如下:5.3. 前序和后序没办法确定既然上面两种都行,那为什么前序和后序不行呢?我们看上面的例子:(1) 前序:1 2 3 4 5 6 8 7 9 10 11 12 13 15 14(2) 后序:8 7 6 5 4 3 2 10 15 14 13 12 11 9 1根据上面的说明,我们通过前序可以知道根节点是1,通过后序也能知道根节点是1, 但是中间是怎么划分的呢?其他元素哪些属于左子树,哪些属于右子树呢?很明显通 过两个序列都不知道,所以前序和后序序列不能恢复二叉树。如果将上述过程用代码实现该怎么做呢?通过前序和中序构造树就是LeetCode105 题,通过中序和后序构造树就是LeetCode106题,实现过程略微繁琐,感兴趣的同学可以研究一下。
0
0
0
浏览量1699
时光小少年

第 16 关 | 滑动窗口与堆结合:3. 黄金挑战——滑动窗口与堆结合

1. 堆与滑动窗口问题的结合我们在《堆》一章解释过堆的大小一般是有限的,而且能直接返回当前位置下的最大值或者最小值。而该特征与滑动窗口结合,碰撞出的火花可以非常方便的解决一些特定场景的问题。LeetCode239 给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位,返回滑动窗口中的最大值。输入:nums = [1,3,-1,-3,5,3,6,7], k = 3 输出:[3,3,5,5,6,7] 解释: 滑动窗口的位置 最大值 --------------- ----- [1 3 -1] -3 5 3 6 7 3 1 [3 -1 -3] 5 3 6 7 3 1 3 [-1 -3 5] 3 6 7 5 1 3 -1 [-3 5 3] 6 7 5 1 3 -1 -3 [5 3 6] 7 6 1 3 -1 -3 5 [3 6 7] 7 这种方法我们在基础算法的堆部分介绍过。对于最大值、K个最大这种场景,优先队列(堆)是首先应该考虑的思路。大根堆可以帮助我们实时维护一系列元素中的最大值。本题初始时,我们将数组 nums 的前 k个元素放入优先队列中。每当我们向右移动窗口时,我们就可以把一个新的元素放入优先队列中,此时堆顶的元素就是堆中所有元素的最大值。然而这个最大值可能并不在滑动窗口中,在这种情况下,这个值在数组 nums 中的位置出现在滑动窗口左边界的左侧。因此,当我们后续继续向右移动窗口时,这个值就永远不可能出现在滑动窗口中了,我们可以将其永久地从优先队列中移除。我们不断地移除堆顶的元素,直到其确实出现在滑动窗口中。此时,堆顶元素就是滑动窗口中的最大值。为了方便判断堆顶元素与滑动窗口的位置关系,我们可以在优先队列中存储二元组 (num,index),表示元素num 在数组中的下标为index。public int[] maxSlidingWindow(int[] nums, int k) { int n = nums.length; PriorityQueue<int[]> pq = new PriorityQueue<int[]>(new Comparator<int[]>() { public int compare(int[] pair1, int[] pair2) { return pair1[0] != pair2[0] ? pair2[0] - pair1[0] : pair2[1] - pair1[1]; } }); for (int i = 0; i < k; ++i) { pq.offer(new int[]{nums[i], i}); } int[] ans = new int[n - k + 1]; ans[0] = pq.peek()[0]; for (int i = k; i < n; ++i) { pq.offer(new int[]{nums[i], i}); while (pq.peek()[1] <= i - k) { pq.poll(); } ans[i - k + 1] = pq.peek()[0]; } return ans; } vector<int> maxSlidingWindow(vector<int>& nums, int k) { int n = nums.size(); priority_queue<pair<int, int>> q; for (int i = 0; i < k; ++i) { q.emplace(nums[i], i); } vector<int> ans = {q.top().first}; for (int i = k; i < n; ++i) { q.emplace(nums[i], i); while (q.top().second <= i - k) { q.pop(); } ans.push_back(q.top().first); } return ans; } def maxSlidingWindow(self, nums: List[int], k: int) -> List[int]: n = len(nums) # 注意 Python 默认的优先队列是小根堆 q = [(-nums[i], i) for i in range(k)] heapq.heapify(q) ans = [-q[0][0]] for i in range(k, n): heapq.heappush(q, (-nums[i], i)) while q[0][1] <= i - k: heapq.heappop(q) ans.append(-q[0][0]) return ans
0
0
0
浏览量1281
时光小少年

第 12 关 | 刷题模板之字符串:2.白银挑战——字符串经典基础面试题

1. 反转的问题我们知道反转是链表的一个重要考点,反转同样是字符串的重要问题。常见问题也就是在LeetCode中列举的相关题目:【1】LeetCode344. 反转字符串:编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。【2】LeetCode541. K个一组反转:给定一个字符串 s 和一个整数 k,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 字符中的前 k 个字符。【3】LeetCode.917. 仅仅反转字母:·给定一个字符串 S,返回 “反转后的” 字符串,其中不是字母的字符都保留在原地,而所有字母的位置发生反转。【4】LeetCode151. 反转字符串里的单词:给你一个字符串 s ,逐个反转字符串中的所有 单词 。【5】LeetCode.557. 反转字符串中的单词 III:给定一个字符串,你需要反转字符串中每个单词的字符顺序,同时仍保留空格和单词的初始顺序。这几个题目你是否发现前三道就是要么反转字符,要么反转里面的单词。针对字符的反转又可以变换条件造出多问题。我们就从基本问题出发,各个击破。1.1. 反转字符串LeetCode344. 题目要求:编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 s 的形式给出。不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。示例 1: 输入:s = ["h","e","l","l","o"] 输出:["o","l","l","e","h"] 示例 2: 输入:s = ["H","a","n","n","a","h"] 输出:["h","a","n","n","a","H"] 这是最基本的反转题,也是最简单的问题,使用双指针方法最直接。具体做法是:对于长度为 N 的待被反转的字符数组,我们可以观察反转前后下标的变化,假设反转前字符数组为 s[0] s[1] s[2] ... s[N - 1],那么反转后字符数组为 s[N - 1] s[N - 2] ... s[0]。比较反转前后下标变化很容易得出 s[i] 的字符与 s[N - 1 - i] 的字符发生了交换的规律,因此我们可以得出如下双指针的解法:将 left 指向字符数组首元素,right 指向字符数组尾元素。当 left < right:交换 s[left] 和 s[right]; left 指针右移一位,即 left = left + 1; right 指针左移一位,即 right = right - 1当 left >= right,反转结束,返回字符数组即可class Solution { public void reverseString(char[] s) { if(s == null || s.length == 0){ return ; } int n = s.length; for(int i = 0,j = n -1; i <j;i++,j--){ char temp = s[i]; s[i] = s[j]; s[j] = temp; } } } #include <stdio.h> void reverseString(char* s, int n) { if (s == NULL || n == 0) { return; } for (int left = 0, right = n - 1; left < right; ++left, --right) { char tmp = s[left]; s[left] = s[right]; s[right] = tmp; } } def reverseString(s): if s is None or len(s) == 0: return s left, right = 0, len(s) - 1 while left < right: s[left], s[right] = s[right], s[left] left += 1 right -= 1 return s 1.2. K 个一组反转LeetCode541 这个题,我感觉有点没事找事,先看一下要求:给定一个字符串 s 和一个整数 k,从字符串开头算起,每计数至 2k 个字符,就反转这 2k 字符中的前 k 个字符。如果剩余字符少于 k 个,则将剩余字符全部反转。如果剩余字符小于 2k 但大于或等于 k 个,则反转前 k 个字符,其余字符保持原样。输入:s = "abcdefg", k = 2 输出:"bacdfeg" 示例2: 输入:s = "abcd", k = 2 输出:"bacd" 我们直接按题意进行模拟就可以:反转每个下标从 2k的倍数开始的,长度为 k的子串。若该子串长度不足 k,则反转整个子串。class Solution { public String reverseStr(String s, int k) { if(s == null || s.length() == 0){ return s; } int n = s.length(); char[] arr = s.toCharArray(); for(int i = 0; i <n; i+= 2 *k){ reverse(arr,i,Math.min(i + k,n) - 1); } return new String(arr); } public void reverse(char[] arr, int left, int right) { while (left < right) { char temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } } } #include <stdio.h> #include <stdlib.h> #include <string.h> void reverse(char* arr, int left, int right) { while (left < right) { char temp = arr[left]; arr[left] = arr[right]; arr[right] = temp; left++; right--; } } char* reverseStr(char* s, int k) { if (s == NULL || strlen(s) == 0) { return s; } int n = strlen(s); for (int i = 0; i < n; i += 2 * k) { reverse(s, i, (i + k < n) ? i + k - 1 : n - 1); } return s; } //测试入口 int main() { char s[] = "Hello World"; int k = 3; printf("Original String: %s\n", s); reverseStr(s, k); printf("Reversed String: %s\n", s); return 0; } def reverseStr(s, k) if s is None or len(s) == 0: return s arr = list(s) n = len(arr) for i in range(0, n, 2 * k): arr[i:i+k] = reversed(arr[i:i+k]) return "".join(arr) 1.3. 仅仅反转字母LeetCode.917 这个题有点难度,我们来看一下:给定一个字符串 S,返回 “反转后的” 字符串,其中不是字母的字符都保留在原地,而所有字母的位置发生反转。示例1: 输入:"ab-cd" 输出:"dc-ba" 示例2: 输入:"a-bC-dEf-ghIj" 输出:"j-Ih-gfE-dCba" 示例3: 输入:"Test1ng-Leet=code-Q!" 输出:"Qedo1ct-eeLg=ntse-T!" 这里第一眼感觉不是特别复杂,同样从两头向中间即可,但问题是"-"不是均匀的有些划分的段长,有的短,这就增加了处理的难度。1.3.1. 方法1:使用栈将 s 中的所有字母单独存入栈中,所以出栈等价于对字母反序操作。(或者,可以用数组存储字母并反序数组。)然后,遍历 s 的所有字符,如果是字母我们就选择栈顶元素输出。class Solution { public String reverseOnlyLetters(String s) { Stack<Character> letters = new Stack(); for(char c : s.toCharArray()){ if(Character.isLetter(c)){ letters.push(c); } } StringBuilder res = new StringBuilder(); for(char c:s.toCharArray()){ if(Character.isLetter(c)){ res.append(letters.pop()); }else{ res.append(c); } } return res.toString(); } } def reverseOnlyLetters(S) : letters = [] for c in S: if c.isalpha(): letters.append(c) ans = "" for c in S: if c.isalpha(): ans += letters.pop() else: ans += c return ans string reverseOnlyLetters(string S) { stack<char> letters; for (char c : S) { if (isalpha(c)) { letters.push(c); } } string ans = ""; for (char c : S) { if (isalpha(c)) { ans += letters.top(); letters.pop(); } else { ans += c; } } return ans; } 1.3.2. 方法2:拓展 双转指针一个接一个输出 s 的所有字符。当遇到一个字母时,我们希望找到逆序遍历字符串的下一个字母。所以我们这么做:维护一个指针 j 从后往前遍历字符串,当需要字母时就使用它。class Solution { public String reverseOnlyLetters(String S) { if (S == null || S.length() == 0) { return S; } StringBuilder ans = new StringBuilder(); int j = S.length() - 1; for (int i = 0; i < S.length(); ++i) { if (Character.isLetter(S.charAt(i))) { while (!Character.isLetter(S.charAt(j))) j--; ans.append(S.charAt(j--)); } else { ans.append(S.charAt(i)); } } return ans.toString(); } } char* reverseOnlyLetters(char* S) { if (S == NULL || strlen(S) == 0) { return S; } int len = strlen(S); char* ans = (char*)malloc(sizeof(char) * (len + 1)); int j = len - 1; for (int i = 0; i < len; ++i) { if (isalpha(S[i])) { while (!isalpha(S[j])) j--; ans[i] = S[j--]; } else { ans[i] = S[i]; } } ans[len] = '\0'; return ans; } def reverseOnlyLetters(S): if not S: return S ans = [] j = len(S) - 1 for i in range(len(S)): if S[i].isalpha(): while not S[j].isalpha(): j -= 1 ans.append(S[j]) j -= 1 else: ans.append(S[i]) return ''.join(ans) S = "Hello World" print("Original String:", S) reversed_str = reverseOnlyLetters(S) print("Reversed String:", reversed_str) 1.4. 反转字符串里的单词LeetCode151 给你一个字符串 s ,逐个反转字符串中的所有 单词 。单词 是由非空格字符组成的字符串。s 中使用至少一个空格将字符串中的 单词 分隔开。请你返回一个反转 s 中单词顺序并用单个空格相连的字符串。说明:输入字符串 s 可以在前面、后面或者单词间包含多余的空格。反转后单词间应当仅用一个空格分隔。反转后的字符串中不应包含额外的空格。示例1: 输入:s = "the sky is blue" 输出:"blue is sky the" 示例2: 输入:s = "hello world" 输出:"world hello" 解释:输入字符串可以在前面或者后面包含多余的空格,但是反转后的字符不能包括。 这个题也经常出现在很多面试题中,我记得曾经见过有个题是这样出的,要你按照同样的方式反转“ I love youzan”。这个题的关键在于如何处理单词。本题难度并不大, 所以我们的重点就是从多种角度来分析该问题,1.4.1. 方法1:使用语言提供的方法来解决Java、Python、C++等很多语言提供了相关的特性,因此我们可以首先使用语言的特性来实现:很多语言对字符串提供了 split(拆分),reverse(反转)和 join(连接)等方法,因此我们可以简单的调用内置的 API 完成操作:使用 split 将字符串按空格分割成字符串数组;使用 reverse 将字符串数组进行反转;使用 join 方法将字符串数组拼成一个字符串。如图:public String reverseWords(String s) { if (s == null || s.length() == 0) { return s; } // 除去开头和末尾的空白字符,记住这个操作 s = s.trim(); // 正则匹配连续的空白字符作为分隔符分割 List<String> wordList = Arrays.asList(s.split("\s+")); Collections.reverse(wordList); return String.join(" ", wordList); } def reverseWords(self, s) : return " ".join(reversed(s.split())) C里没有提供类似的函数来调用,我们就不写了。上面这种方式,在面试的时候,一般也不会让用,所以,我们还是看下面的方式:1.4.2. 方法2:自己实现上述功能对于字符串可变的语言,就不需要再额外开辟空间了,直接在字符串上原地实现。在这种情况下,反转字符和去除空格可以一起完成。实现方法:class Solution: def trim_spaces(self, s) : left, right = 0, len(s) - 1 # 去掉字符串开头的空白字符 while left <= right and s[left] == ' ': left += 1 # 去掉字符串末尾的空白字符 while left <= right and s[right] == ' ': right -= 1 # 将字符串间多余的空白字符去除 output = [] while left <= right: if s[left] != ' ': output.append(s[left]) elif output[-1] != ' ': output.append(s[left]) left += 1 return output def reverse(self, l: list, left: int, right: int) -> None: while left < right: l[left], l[right] = l[right], l[left] left, right = left + 1, right - 1 def reverse_each_word(self, l: list) -> None: n = len(l) start = end = 0 while start < n: # 循环至单词的末尾 while end < n and l[end] != ' ': end += 1 # 翻转单词 self.reverse(l, start, end - 1) # 更新start,去找下一个单词 start = end + 1 end += 1 def reverseWords(self, s: str) -> str: l = self.trim_spaces(s) # 翻转字符串 self.reverse(l, 0, len(l) - 1) # 翻转每个单词 self.reverse_each_word(l) return ''.join(l) class Solution { public String reverseWords(String s) { StringBuilder sb = trimSpaces(s); // 翻转字符串 reverse(sb, 0, sb.length() - 1); // 翻转每个单词 reverseEachWord(sb); return sb.toString(); } public StringBuilder trimSpaces(String s) { int left = 0, right = s.length() - 1; // 去掉字符串开头的空白字符 while (left <= right && s.charAt(left) == ' ') { ++left; } // 去掉字符串末尾的空白字符 while (left <= right && s.charAt(right) == ' ') { --right; } // 将字符串间多余的空白字符去除 StringBuilder sb = new StringBuilder(); while (left <= right) { char c = s.charAt(left); if (c != ' ') { sb.append(c); } else if (sb.charAt(sb.length() - 1) != ' ') { sb.append(c); } ++left; } return sb; } public void reverse(StringBuilder sb, int left, int right) { while (left < right) { char tmp = sb.charAt(left); sb.setCharAt(left++, sb.charAt(right)); sb.setCharAt(right--, tmp); } } public void reverseEachWord(StringBuilder sb) { int n = sb.length(); int start = 0, end = 0; while (start < n) { // 循环至单词的末尾 while (end < n && sb.charAt(end) != ' ') { ++end; } // 翻转单词 reverse(sb, start, end - 1); // 更新start,去找下一个单词 start = end + 1; ++end; } } } class Solution { public: string reverseWords(string s) { // 反转整个字符串 reverse(s.begin(), s.end()); int n = s.size(); int idx = 0; for (int start = 0; start < n; ++start) { if (s[start] != ' ') { // 填一个空白字符然后将idx移动到下一个单词的开头位置 if (idx != 0) s[idx++] = ' '; // 循环遍历至单词的末尾 int end = start; while (end < n && s[end] != ' ') s[idx++] = s[end++]; // 反转整个单词 reverse(s.begin() + idx - (end - start), s.begin() + idx); // 更新start,去找下一个单词 start = end; } } s.erase(s.begin() + idx, s.end()); return s; } }; 2. 验证回文串LeetCode.125. 给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。说明:本题中,我们将空字符串定义为有效的回文串。回文问题在链表中是重点,在字符串中同样是个重点。当初我去美团面试第一轮技术面的第一个算法题就是让写判断字符串回文的问题。这个本身还是比较简单的,只要先转换成字符数组,然后使用双指针方法从两头到中间比较就行了。也许是过于简单了吧,面试时经常被加餐,例如LeetCode里的两道题。 一个是普通的验证回文串,第二个是找最长的子回文串。第二个问题需要动态规划等技术,有点难度,我们到高级算法里再看,这里先看一下基本的。示例1: 输入: "A man, a plan, a canal: Panama" 输出: true 解释:"amanaplanacanalpanama" 是回文串 示例2: 输入: "race a car" 输出: false 解释:"raceacar" 不是回文串 这个题我们可以有多种思路,最简单的方法是对字符串 s 进行一次遍历,并将其中的字母和数字字符进行保留,放在另一个字符串 sgood 中。这样我们只需要判断 sgood 是否是一个普通的回文串即可。如果不使用语言的特性,我们可以使用双指针思想来处理。初始时,左右指针分别指向 sgood 的两侧,随后我们不断地将这两个指针相向移动,每次移动一步,并判断这两个指针指向的字符是否相同。当这两个指针相遇时,就说明 sgood 时回文串。public boolean isPalindrome(String s) { if (s == null || s.length() == 0) { return s; } StringBuffer sgood = new StringBuffer(); int length = s.length(); for (int i = 0; i < length; i++) { char ch = s.charAt(i); if (Character.isLetterOrDigit(ch)) { sgood.append(Character.toLowerCase(ch)); } } int n = sgood.length(); int left = 0, right = n - 1; while (left < right) { if (Character.toLowerCase(sgood.charAt(left)) != Character.toLowerCase(sgood.charAt(right))) { return false; } ++left; --right; } return true; } class Solution: def isPalindrome(self, s) : sgood = "".join(ch.lower() for ch in s if ch.isalnum()) n = len(sgood) left, right = 0, n - 1 while left < right: if sgood[left] != sgood[right]: return False left, right = left + 1, right - 1 return True bool isPalindrome(string s) { string sgood; for (char ch: s) { if (isalnum(ch)) { sgood += tolower(ch); } } int n = sgood.size(); int left = 0, right = n - 1; while (left < right) { if (sgood[left] != sgood[right]) { return false; } ++left; --right; } return true; } 3. 字符串中的第一个唯一字符LeetCode387. 给定一个字符串,找到它的第一个不重复的字符,并返回它的索引。如果不存在,则返回 -1。示例: s = "leetcode" 返回 0 s = "loveleetcode" 返回 2 提示: 你可以假定该字符串只包含小写字母。我们可以对字符串进行两次遍历,在第一次遍历时,我们使用哈希映射统计出字符串中每个字符出现的次数。在第二次遍历时,我们只要遍历到了一个只出现一次的字符,那么就返回它的索引,否则在遍历结束后返回 -1。public int firstUniqChar(String s) { if (s == null || s.length() == 0) { return 0; } Map<Character, Integer> frequency = new HashMap<Character, Integer>(); for (int i = 0; i < s.length(); ++i) { char ch = s.charAt(i); frequency.put(ch, frequency.getOrDefault(ch, 0) + 1); } for (int i = 0; i < s.length(); ++i) { if (frequency.get(s.charAt(i)) == 1) { return i; } } return -1; } int firstUniqChar(string s) { unordered_map<int, int> frequency; for (char ch: s) { ++frequency[ch]; } for (int i = 0; i < s.size(); ++i) { if (frequency[s[i]] == 1) { return i; } } return -1; } def firstUniqChar(self, s) : frequency = collections.Counter(s) for i, ch in enumerate(s): if frequency[ch] == 1: return i return -1 4. 判定是否互为字符重排这是一道典型的看似吓人 ,其实很简单的问题。LeetCode242 给定两个字符串 s1 和 s2,请编写一个程序,确定其中一个字符串的字符重新排列后,能否变成另一个字符串。示例1: 输入: s1 = "abcadfhg", s2 = "bcafdagh" 输出: true 示例2: 输入: s1 = "abc", s2 = "bad" 输出: false 这个题第一眼看,感觉是个排列组合的题目,然后如果使用排列的算法来处理, 难度会非常大,而且效果还不一定好。用简单的方式就能解决。第一种方法:将两个字符串全部从小到大或者从大到小排列,然后再逐个位置比较,这时候不管两个原始字符串是什么,都可以判断出来。 代码也不复杂:public boolean checkPermutation(String s1, String s2) { // 将字符串转换成字符数组 char[] s1Chars = s1.toCharArray(); char[] s2Chars = s2.toCharArray(); // 对字符数组进行排序 Arrays.sort(s1Chars); Arrays.sort(s2Chars); // 再将字符数组转换成字符串,比较是否相等 return new String(s1Chars).equals(new String(s2Chars)); } class Solution { public: bool isAnagram(string s, string t) { if (s.length() != t.length()) { return false; } sort(s.begin(), s.end()); sort(t.begin(), t.end()); return s == t; } }; def isAnagram(s, t): if len(s) != len(t): return False sorted_s = ''.join(sorted(s)) sorted_t = ''.join(sorted(t)) return sorted_s == sorted_t 注意这里我们使用了不同语言的排序函数,你是否记得我们在数组一章提到过这个方法必须牢记。第二种方法:使用Hash,注意这里我们不能简单的存是否已经存在,因为字符可能在某个串里重复存在例如"abac"。我们可以记录出现的次数,如果一个字符串经过重新排列后,能够变成另外一个字符串,那么它们的每个不同字符的出现次数是相同的。如果出现次数不同,那么表示两个字符串不能够经过重新排列得到。这个代码逻辑不复杂,但是写起来稍微长一点:public boolean checkPermutation(String s1, String s2) { if (s1.length() != s2.length()) { return false; } char[] s1Chars = s1.toCharArray(); Map<Character, Integer> s1Map = getMap(s1); Map<Character, Integer> s2Map = getMap(s2); for (char s1Char : s1Chars) { if (!s2Map.containsKey(s1Char) || (int)s2Map.get(s1Char) != (int)s1Map.get(s1Char)) { return false; } } return true; } // 统计指定字符串str中各字符的出现次数,并以Map的形式返回 private Map<Character, Integer> getMap(String str) { Map<Character, Integer> map = new HashMap<>(); char[] chars = str.toCharArray(); for (char aChar : chars) { map.put(aChar, map.getOrDefault(aChar, 0) + 1); } return map; } class Solution { public: bool isAnagram(string s, string t) { if (s.length() != t.length()) { return false; } vector<int> table(26, 0); for (auto& ch: s) { table[ch - 'a']++; } for (auto& ch: t) { table[ch - 'a']--; if (table[ch - 'a'] < 0) { return false; } } return true; } }; def isAnagram(s, t): if len(s) != len(t): return False table = [0] * 26 for ch in s: table[ord(ch) - ord('a')] += 1 for ch in t: table[ord(ch) - ord('a')] -= 1 if table[ord(ch) - ord('a')] < 0: return False return True
0
0
0
浏览量1041
时光小少年

第 4 关 | 站不住的栈:2.白银挑战 —— 最大栈、最小栈、括号匹配问题解决

1. 括号匹配问题栈的典型题目还是比较明显的,括号匹配,表达式计算等等都离不开栈,本小节就针对两个经典的问题进行解析。首先看题目要求,LeetCode 20.给定一个只包含'(',')','{','}','[',']' 的字符串 s,判断字符串是否有效。有效的字符串满足:左括号必须用相同类型的右括号闭合。左括号必须以正确的顺序闭合。每个右括号都有一个对应的相同类型的左括号。示例 1: 输入:s = "()" 输出:true 示例 2: 输入:s = "()[]{}" 输出:true 示例 3: 输入:s = "(]" 输出:false 本题比较麻烦的点在于如何判断两个符号是不是同一组得,我们可以将哈希表将所有的符号进行存储,左半边做 key,右半边做 value,遍历字符串的时候,遇到左半边的符号就入栈,遇到右半边的符号就和栈顶的符号比较,不匹配就返回 false(题目没有使用哈希表,因为我个人感觉直接匹配可能更快)class Solution { public boolean isValid(String s) { if(s.length() <= 1){ return false; } Stack<Character> stack = new Stack<>(); for(int i = 0; i < s.length();i++){ char item = s.charAt(i); if( item == '(' || item == '[' || item =='{'){ stack.push(item); }else{ if(stack.size() == 0){ return false; } char left = stack.pop(); if((left == '{' && item == '}' ) || (left == '[' && item == ']') || (left == '(' && item == ')')){ continue; }else{ return false; } } } return stack.size() == 0; } } LeetCode 给我们赵乐十几个括号匹配的问题,都是条件变去,但是解决起来有难有易,如果感兴趣的话可以继续研究一下:LeetCode 20.有效的括号LeetCode 22 括号生成LeetCode 32 最长有效括号LeetCode 301 删除无效的括号LeetCode 856 括号的分数2. 最小栈LeetCode 155 ,设计一个支持push ,pop ,top 操作,并能在常数时间内检索到最小元素的栈。实现 MinStack 类:MinStack() 初始化堆栈对象。void push(int val) 将元素val推入堆栈。void pop() 删除堆栈顶部的元素。int top() 获取堆栈顶部的元素。int getMin() 获取堆栈中的最小元素。示例 1:输入: ["MinStack","push","push","push","getMin","pop","top","getMin"] [[],[-2],[0],[-3],[],[],[],[]] 输出: [null,null,null,null,-3,null,0,-2] 解释: MinStack minStack = new MinStack(); minStack.push(-2); minStack.push(0); minStack.push(-3); minStack.getMin(); --> 返回 -3. minStack.pop(); minStack.top(); --> 返回 0. minStack.getMin(); --> 返回 -2. 本题的关键在于理解 getMin() 到底表示什么,可以看一个例子上面的示例画成示意图如下:这里的关键在于理解对应的 Min 栈内,中间元素为什么是 -2 ,理解了本题就非常简单了。题目要求在常数时间内获取栈的最小值,因此不能在 getMin ( )的时候再去计算最小值,最好应该在 push 或者 pop 的时候就已经计算机好了当前栈中的最小值。对于栈来说,如果一个元素 a 在入栈的时候,栈里面有其他的元素 b,c,d,那么无论这个栈在之后经历了什么操作,只要在栈 a 中,b,c,d就一定在栈中,因为 a 被弹出之前,b,c,d 不会被弹出。因此,在操作过程中的任意一个时刻,只要栈顶的元素是 a,那么我们就可以确定栈里面现在的元素一定是 a,b,c,d.那么,我们可以在每个元素 a 入栈的时候就把当前栈的最小值 m 存储起来,然后在这之后,无论什么时候,如果栈顶元素是 a,那么我们就可以返回最小值 m 。按照上面的思路,我们只需要设计一个数据结构,使得每个元素 a 与其相应的最小值 m 时刻保持一一对应。因此我们可以使用一个辅助栈,与元素栈同步插入和删除,用于存储于每个元素对应的最小值。当一个元素要入栈的时候,我们取当前辅助栈的栈顶存储的最小值,与当前元素比较得出最小值,将这个最小值插入辅助栈中;当一个元素要出栈的时候,我们把辅助栈的栈顶元素也一并弹出;在任意一个时刻,栈内元素的最小值就存储在辅助栈的栈顶元素中。class MinStack { Deque<Integer> xStack; Deque<Integer> minStack; public MinStack() { xStack = new LinkedList<Integer>(); minStack = new LinkedList<Integer>(); minStack.push(Integer.MAX_VALUE); } public void push(int x) { xStack.push(x); minStack.push(Math.min(minStack.peek(), x)); } public void pop() { xStack.pop(); minStack.pop(); } public int top() { return xStack.peek(); } public int getMin() { return minStack.peek(); } } 在以上方法之后,我进行了一部分的改进,使用链栈实现最小栈查询,这样的话可以提高效率class MinStack { /** * 私有化头结点 */ private Node head; public MinStack() { } /** * ' * 入栈 * * @param val 插入的值 */ public void push(int val) { if (head == null){ head = new Node(val,val,null); return; } head = new Node(val,Math.min(val,head.min),head); } /** * 出栈 */ public void pop() { head = head.next; } /** * 查看栈顶元素 * * @return */ public int top() { return head.val; } /** * 获取栈中元素最小值 * * @return 最小值 */ public int getMin() { return head.min; } /** * 构造化节点类,用于实现链栈 */ private class Node { private int val; private int min; private Node next; public Node(int val, int min, Node next) { this.val = val; this.min = min; this.next = next; } public Node(int val, int min) { this(val, min, null); } } } /** * Your MinStack object will be instantiated and called as such: * MinStack obj = new MinStack(); * obj.push(val); * obj.pop(); * int param_3 = obj.top(); * int param_4 = obj.getMin(); */ 3. 最大栈LeetCode 716(力扣可能需要会员,可以上炼码 859 看看)设计一个支持push,pop,top,peekMax和popMax操作的最大栈。push(x) -- 将元素x添加到栈中。pop() -- 删除栈中最顶端的元素并将其返回。top() -- 返回栈中最顶端的元素。peekMax() -- 返回栈中最大的元素。popMax() -- 返回栈中最大的元素,并将其删除。如果有多于一个最大的元素,只删除最靠近顶端的一个元素。示例:输入: push(5) push(1) push(5) top() popMax() top() peekMax() pop() top() 输出: 5 5 1 5 1 5 这题与上一题相反,但是处理方法一致。一个普通的栈可以支持前三项操作:push(x)pop()top()所以我们仅仅需要考虑后两种操作就可以了,即 peekMax( ) 和 popMax( )。对于 peekMax(),我们可以另一个栈来存储每个位置到栈底的所有元素的最大值。例如,如果当前第一个栈中的元素为 [2,1,5,3,9],那么第二个栈中的元素也为【2,2,5,5,9】。在 push(x)操作的时候,只需要将第二个栈的栈顶和 xx 的最大值入栈,而在 pop() 操作的时候,只需要将第二个栈进行出栈。对于 popMax(),由于我们知道当前栈中最大的元素值,因此可以直接将两个栈同时出栈,并存储第一个栈出栈的所有值。当某个时刻,第一个栈的出栈元素等于当前栈中最大的元素值的时候,就找到最大的元素,此时我们将之前出第一个栈的所有元素重新入栈,并且同步更新第二个栈,就完成了 popMax 的操作。class MaxStack { Stack<Integer> stack; Stack<Integer> maxStack; public MaxStack() { stack = new Stack(); maxStack = new Stack(); } public void push(int x) { int max = maxStack.isEmpty() ? x : maxStack.peek(); maxStack.push(max > x ? max : x); stack.push(x); } public int pop() { maxStack.pop(); return stack.pop(); } public int top() { return stack.peek(); } public int peekMax() { return maxStack.peek(); } public int popMax() { int max = peekMax(); Stack<Integer> buffer = new Stack(); while (top() != max) buffer.push(pop()); pop(); while (!buffer.isEmpty()) push(buffer.pop()); return max; } } 这里我使用了链栈进行改进,不过 popMax 方法是一样的,其他方法和最小栈差不了多少。class MaxStack { private Node head; public MaxStack() { // do intialization if necessary } /* * @param number: An integer * @return: nothing */ public void push(int val) { // write your code here if (head == null){ head = new Node(val,val,null); return; } head = new Node(val,Math.max(val,head.max),head); } public int pop() { // write your code here int val = head.val; head = head.next; return val; } /* * @return: An integer */ public int top() { // write your code here return head.val; } /* * @return: An integer */ public int peekMax() { // write your code here return head.max; } /* * @return: An integer */ public int popMax() { // write your code here int max = peekMax(); Stack<Integer> buffer = new Stack(); while(top() != max) buffer.push(pop()); pop(); while(!buffer.isEmpty()) push(buffer.pop()); return max; } /** * 构造化节点类,用于实现链栈 */ private class Node { private int val; private int max; private Node next; public Node(int val, int max, Node next) { this.val = val; this.max = max; this.next = next; } public Node(int val, int max) { this(val, max, null); } } }
0
0
0
浏览量965
时光小少年

第 13 关 | 刷题模板之数学 :2.白银挑战——数学与数学高频问题

1. 数组实现加法专题数字加法,小学生都会的问题,但是如果让你用数组来表示一个数,如何实现加法呢?理论上仍然从数组末尾向前挨着计算就行了,但是实现的时候会发现有很多问题,例如算到A[0]位置时发现还要进位该怎么办呢?再拓展,假如给定的两个数,一个用数组存储的,另外一个是普通的整数,又该如何处理?再拓展 ,如果两个整数是用字符串表示的呢?如果要按照二进制加法的规则来呢?1.1. 数组实现加法先看一个用数组实现逐个加一的问题。LeetCode66.具体要求是由整数组成的非空数组所表示的非负整数,在其基础上加一。这里最高位数字存放在数组的首位, 数组中每个元素只存储单个数字。并且假设除了整数 0 之外,这个整数不会以零开头。例如:输入:digits = [1,2,3] 输出:[1,2,4] 解释:输入数组表示数字 123。 这个看似很简单是不?从后向前依次加就行了,如果有进位就标记一下,但是如果到头了要进位怎么办呢?例如如果digits = [9,9,9],从后向前加的时候,到了A[0]的位置计算为0,需要再次进位但是数组却不能保存了,该怎么办呢?这里的关键是A[0]什么时候出现进位的情况,我们知道此时一定是9,99,999...这样的结构才会出现加1之后再次进位,而进位之后的结果一定是10,100,1000这样的结构,由于java中数组默认初始化为0,所以我们此时只要申请一个空间比A[]大一个的数组B[],然后将B[0]设置为1就行了。这样代码就会变得非常简洁。如果是其他语言,则要注意先将申请的数组初始化为零再执行,代码如下:class Solution { public 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; } } 1.2. 字符串加法415.字符串相加我们继续看将数字保存在字符串中的情况: 字符串加法就是使用字符串来表示数字,然后计算他们的和。具体要求如下:给定两个字符串形式的非负整数 num1 和num2 ,计算它们的和并同样以字符串形式返回。你不能使用任何內建的用于处理大整数的库(比如 BigInteger), 也不能直接将输入的字符串转换为整数形式。我们先想一下小学里如何计算两个比较大的数相加的,经典的竖式加法是这样的: 从低到高逐位相加,如果当前位和超过 10,则向高位进一位。 因此我们只要将这个过程用代码写出来即可。先定义两个指针 i 和j 分别指向num1和num2的末尾,即最低位,同时定义一个变量 add 维护当前是否有进位,然后从末尾到开头逐位相加。 这里可能有个问题:两个数字位数不同该怎么处理?简单,补0即可。具体可以看下面的代码: 我们先想一下小学里如何计算两个比较大的数相加的,经典的竖式加法是这样的:从低到高逐位相加,如果当前位和超过 10,则向高位进一位。因此我们只要将这个过程用代码写出来即可。先定义两个指针 i 和j 分别指向num1和num2的末尾,即最低位,同时定义一个变量 add 维护当前是否有进位,然后从末尾到开头逐位相加。这里可能有个问题:两个数字位数不同该怎么处理?简单,补0即可。具体可以看下面的代码:class Solution { public String addStrings(String num1, String num2) { int i = num1.length() - 1; int j = num2.length() - 1; int 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.3. 二进制加法我们继续看,如果这里是二进制该怎么处理呢?详细要求:leetcode67.给你两个二进制字符串,这个字符串是用数组保存的,返回它们的和(用二进制表示)。其中输入为 非空 字符串且只包含数字 1 和 0。示例1: 输入: a = "11", b = "1" 输出: "100" 示例2: 输入: a = "1010", b = "1011" 输出: "10101" 这个题也是用字符串来表示数据的,也要先转换为字符数组。我们熟悉的十进制,是从各位开始,逐步向高位加,达到10就进位,而对于二进制则判断相加之后是否为二进制的10,是则进位。本题解中大致思路与上述一致,但由于字符串操作原因,不确定最后的结果是否会多出一位进位,下面 2 种处理方式都可以:● 第一种,在进行计算时直接拼接字符串,得到一个反向字符,最后再翻转。● 第二种,按照位置给结果字符赋值,最后如果有进位,则在前方进行字符串拼接添加进位我们这里采用第二种实现。class Solution { 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(); } } 这里还有人会想,先将其转换成十进制,加完之后再转换成二进制可以吗?这么做实现非常容易,而且可以使用语言提供的方法直接转换,但是还是那句话,工程里可以这么干,稳定可靠,但是算法里不行,太简单了。2. 幂运算幂运算是常见的数学运算,其形式为 a^b ,即 a 的b次方,其中 a 称为底数,b 称为指数,a^b为合法的运算(例如不会出现 a=0且b≤0 的情况)。幂运算满足底数和指数都是实数。根据具体问题,底数和指数的数据类型和取值范围也各不相同。例如,有的问题中,底数是正整数,指数是非负整数,有的问题中,底数是实数,指数是整数。力扣中,幂运算相关的问题主要是判断一个数是不是特定正整数的整数次幂,以及快速幂的处理。2.1. 求 2 的幂LeetCode231. 给你一个整数 n,请你判断该整数是否是 2 的幂次方。如果是,返回 true ;否则,返回 false 。如果存在一个整数 x 使得 n == 2^x ,则认为 n 是 2 的幂次方。示例 1: 输入:n = 1 输出:true 解释:20 = 1 示例 2: 输入:n = 16 输出:true 解释:24 = 16 示例 3: 输入:n = 3 输出:false 示例 4: 输入:n = 4 输出:true 本题的解决思路还是比较简单的,我们可以用除的方法来逐步缩小n的值,另外一个就是使用位运算。逐步缩小的方法就是如果 n 是 2 的幂,则 n>0,且存在非负整数 k 使得 n=2^k。首先判断 n 是否是正整数,如果 n 是0 或负整数,则 n 一定不是 2 的幂。当 n 是正整数时,为了判断 n 是否是 2 的幂,可以连续对 n 进行除以 2 的操作,直到 n 不能被 2 整除。此时如果 n=1,则 n 是 2 的幂,否则 n 不是 2 的幂。代码就是:class Solution { public boolean isPowerOfFour(int n) { if(n <= 0){ return false; } while(n % 2 == 0){ n =n / 2 ; } return n == 1; } } 如果采用位运算,该方法与我们前面说的统计数字转换成二进制数之后1的个数思路一致。当 n>0 时,考虑 n 的二进制表示。如果存在非负整数 k 使得 n=2^k,则 n 的二进制表示为 1 后面跟 k 个0。由此可见,正整数 n 是2 的幂,当且仅当 n 的二进制表示中只有最高位是 1,其余位都是 0,此时满足 n & (n−1)=0。因此代码就是:public boolean isPowerOfTwo(int n) { return n > 0 && (n & (n - 1)) == 0; } 2.2. 求 3 的幂leetcode 326 给定一个整数,写一个函数来判断它是否是 3 的幂次方。如果是,返回 true ;否则,返回 false 。整数 n 是 3 的幂次方需满足:存在整数 x 使得 n == 3^x对于这个题,可以直接使用数学方法来处理,如果n是3 的幂,则 n>0,且存在非负整数 k 使得 n=3^k。首先判断 n是否是正整数,如果 n是 0或负整数,则 n一定不是 3的幂。当 n 是正整数时,为了判断 n 是否是 3 的幂,可以连续对 n 进行除以 3 的操作,直到 n 不能被 3 整除。此时如果n=1,则 n 是 3 的幂,否则 n 不是 3 的幂。class Solution { public boolean isPowerOfFour(int n) { if(n <= 0){ return false; } while(n % 3 == 0){ n =n / 3 ; } return n == 1; } } 这个题的问题和上面2的次幂一样,就是需要大量进行除法运算,我们能否优化一下呢?这里有个技巧。由于给定的输入 n 是int 型,其最大值为 2^31-1。因此在int 型的数据范围内存在最大的 3 的幂,不超过 2^31-1 的最大的 3 的幂是 3^19=1162261467。所以如果在1~ 2^31-1内的数,如果是3的幂,则一定是1162261467的除数,所以这里可以通过一次除法就获得:class Solution { public boolean isPowerOfThree(int n) { return n > 0 && 1162261467 % n == 0; } } 当然这个解法只是拓展思路的,没必要记住1162261467这个数字。思考 如果这里将3换成4 ,5,6,7,8,9可以吗?如果不可以,那如果只针对素数 3 、5、 7、 11、 13可以吗?2.3. 求 4 的幂LeetCode342 给定一个整数,写一个函数来判断它是否是 4 的幂次方。如果是,返回 true ;否则,返回 false 。整数 n 是 4 的幂次方需满足:存在整数 x 使得 n == 4^x。第一种方法自然还是数学方法一直除,代码如下:class Solution { public boolean isPowerOfFour(int n) { if(n <= 0){ return false; } while(n % 4 == 0){ n =n / 4 ; } return n == 1; } } 这个题可以利用2的次幂进行拓展来优化,感兴趣的同学自行查阅一下吧。除了幂运算,指数计算的思路与之类似,感兴趣的同学可以研究一下LeetCode50,实现pow(x,n)这个题。
0
0
0
浏览量1408
时光小少年

第 7 关 | 算法的基础——递归和二叉树 2.白银挑战——理解二叉树的遍历

1. 深入理解前中后序遍历深度优先遍历有前中后三种情况,大部分人看过之后就能写出来,很遗憾大部分人只是背下来的,稍微变换一下就废了。我们再从二叉树的角度看递归,每次遇到递归,都按照前面说的四步来写,可以更好地写出正确的递归算法。通过二叉树可以非常方便的理解递归,递归只处理当前这一层和下一层之间的关系,并不关心下层和下下层之间的关系,这就像老子只管养好儿子,至于孙子怎么样,那是儿子的事,你也不能瞎掺合。具体来说,我们总结了四条行之有效的方法来分解递归:●第一步:从小到大递推●第二步:分情况讨论,明确结束条件●第三步:组合出完整方法●第四步:想验证,则从大到小画图推演我们接下来就一步步看怎么做:第一步:从小到大递推,分情况讨论我们就以这个二叉树为例: 3 / \ 9 20 / \ 15 7 我们先选一个最小的子树: 20 / \ 15 7 假如20为head,则此时前序访问顺序应该是:void visti1(){ list.add(root);//20被访问 root.left; // 继续访问 15 root.right; // 继续访问 7 } 然后再向上访问,看node(3)的情况:void visit2(){ list.add(root);//3被访问 root.left;//继续访问,得到9 root.right; //继续访问,得到20 } 这里的20是一个子树的父节点,访问方式与上面的visit1()一样,所以我们可以直接合并到一起就是:void visit2(){ list.add(root);//3被访问 root.left;//继续访问,得到9 root.right; //继续访问,得到20 } 这就是我们期待的递归方法。第二步:分情况讨论,明确结束条件上面有了递归的主体,但是这个递归什么时候结束呢?很明显应该是root=null的时候。一般来说链表和二叉树问题的终止条件都包含当前访问的元素为null。有些题目结束条件比较复杂,此时最好的方式就是先将所有可能的结束情况列举出来,然后整理一下就行了,这个我们后面在具体题目里再看。第三步:组合出完整方法到此为止,我们就能将完整代码写出来了,同时为了方便区分,我们将方法名换成preorder:public void preorder(TreeNode root, List<Integer> res) { if (root == null) { return; } res.add(root.val); preorder(root.left, res); preorder(root.right, res); } 第四步 从大到小 画图推演写完之后对不对呢?递归的方法是很难调试的,即使对的,你也可能晕,这里介绍一种简单有效的验证方法——调用过程图法。我们可以画个过程图看一下,因为是两个递归函数,如果比较复杂,我们可以少画几组。递归的特征是“不撞南墙不回头”,一定是在执行到某个root=null了才开始返回,下图中的序号就是递归的完整过程:从图中可以看到,当root的一个子树为null的时候还是会执行递归,进入之后发现root==null了,然后就开始返回。这里我们要特别注意res.add()的时机对不对,将其进入顺序依次写出来就是需要的结果。该过程明确之后再debug就容易很多,刚开始学习递归建议多画几次,熟悉之后就不必再画了。前序遍历写出来之后,中序和后序遍历就不难理解了,中序是左中右,后序是左右中。代码如下:public static void inOrderRecur(TreeNode head) { if (head == null) { return; } inOrderRecur(head.left); System.out.print(head.value + " "); inOrderRecur(head.right); } 再看后序的:public static void postOrderRecur(TreeNode head) { if (head == null) { return; } postOrderRecur(head.left); postOrderRecur(head.right); System.out.print(head.value + " "); } 另外需要注意一点,面试,以及LeetCode里提供的方法可能不能直接用来递归,需要我们再创建一个方法,例如:LeetCode144 二叉树前序遍历问题,此时给的函数preorderTraversal()就难以直接递归,我们可以自己再创建一个:class Solution { public: void preorder(TreeNode *root, vector<int> &res) { if (root == nullptr) { return; } res.push_back(root->val); preorder(root->left, res); preorder(root->right, res); } vector<int> preorderTraversal(TreeNode *root) { vector<int> res; preorder(root, res); return res; } }; /** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { public List<Integer> preorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<Integer>(); preorder(root,res); return res; } public void preorder(TreeNode root,List<Integer> res){ if(root == null){ return; } res.add(root.val); preorder(root.left,res); preorder(root.right,res); } } class Solution: def preorderTraversal(self, root) : def preorder(root): if not root: return res.append(root.val) preorder(root.left) preorder(root.right) res = list() preorder(root) return res
0
0
0
浏览量1377
时光小少年

第 10 关 | 天上的明月——快速排序和归并排序: 2.白银挑战——选择第 K 大的数字

1. 数组第 K 大Leetcode 215.数组中的第K个最大元素数组中的第 k 个最大元素。给定整数数组 nums 和 整数 k , 请很返回数组中 k 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。示例 1: 输入: [3,2,1,5,6,4], k = 2 输出: 5 示例 2: 输入: [3,2,3,1,2,4,5,5,6], k = 4 输出: 4 对于这道题,有三种做法,以下我会分别展示3种不同的做法,其实这三种做法大同小异,都是基于快速排序实现的,主要是最后的返回索引可能有所差异。1.1. 调用 API这种方法主要是调用 Arrays 中的排序 API ,其底层其是也是基于快速排序实现的,然后返回长度减 k 个元素既可,如果返回最大就减 1,第 2 大就减 2,以此类推。class Solution { public int findKthLargest(int[] nums, int k) { Arrays.sort(nums); return nums[nums.length -k]; } } 1.2. 手写快排降序我们再堆部分分析这个过程,这里看看是如何基于快速排序来做的, 这个题目出现的频率非常高,甚至在很多时候,面试官直接要求基于快速排序来解决这个问题。而且我们 要直接改造一下上面的快排来解决,而不是另起炉灶,只有这样平时的练习才有效果。为什么能用快速排序来解决呢?我们还是看上面排序的序列: {26,53,48,15,13,48,32,15}我们第一次选择了26为哨兵,进行一轮的排序过程为:上面红框位置表示当前已经被赋值给了pivot或者其他位置,可以空出来放移动来的新 元素了。我们可以看到26最终被放到了属于自己的位置上,不会再变化,而26的左右 两侧可以分别再进行排序。这里还有一个关键信息, 我们可以知道26的索引为3,所以递增排序之后26一定是第 4大的元素 。这就是解决本问题的关键,既然知道26是第4大,那如果我要找第2大, 一定是要到右边找。如果要找第6大,一定要到左边找(当然,如果降序排序就反过 来了),而不需要的那部分就不用管了。这就是为什么能用快速排序解决这个问题。我们采用降序排列:class Solution { public int findKthLargest(int[] nums, int k) { quickSort2(nums,0,nums.length - 1); return nums[k - 1]; } public static void quickSort2(int[] nums, int l, int r) { int start = l; int end = r; int pivot = nums[start + end >> 1]; // 每次循环都将大的放到左边,然后将小的部分放到右边 while(l < r){ while(nums[start] > pivot){ start++; } while(nums[end] < pivot){ end--; } // 如果 l >= r 说明左边的值全部大于等于mid的值,右边全是小的,直接退出 if(start >= end){ break; } // 交换元素 int temp = nums[start]; nums[start] = nums[end]; nums[end] = temp; if(nums[start] == pivot){ end--; } if(nums[end] == pivot){ start++; } } //退出循环防止栈溢出 if(start == end){ start++; end--; } if(l < end){ quickSort2(nums,l,end); } if(r > start){ quickSort2(nums,start,r); } } } 然后就是尽量能套用自己练习过的底阿妈,否则核心逻辑都要考虑相反的情况,会导致我们面试的时候现场书写变得非常困难。1.3. 升序代码然后在这里我也放一下升序的代码:class Solution { public int findKthLargest(int[] nums, int k) { quickSort(nums,0,nums.length - 1); return nums[nums.length -k]; } public static void quickSort(int[] q, int l, int r) { if (l >= r) return; int x = q[l+r>>1]; //Define positions of two pointers int i = l - 1; int j = r + 1; while (i < j) { do i++; while (q[i] < x); do j--; while (q[j] > x); //do Swap if (i < j) { int temp = q[i]; q[i] = q[j]; q[j] = temp; } } quickSort(q, l, j); quickSort(q, j + 1, r); } }
0
0
0
浏览量861
时光小少年

第 4 关 | 站不住的栈:3.黄金挑战——表达式问题

1. 计算器问题计算器也是非常常见的问题,我们看一个中等问题。LeetCode 227给你一个字符串表达式 s ,请你实现一个基本计算器来计算并返回它的值。整数除法仅保留整数部分。你可以假设给定的表达式总是有效的。所有中间结果将在 [-231, 231 - 1] 的范围内。注意: 不允许使用任何将字符串作为数学表达式计算的内置函数,比如 eval() 。示例 1: 输入:s = "3+2*2" 输出:7 示例 2: 输入:s = " 3/2 " 输出:1 示例 3: 输入:s = " 3+5 / 2 " 输出:5 解决运算问题,最好的工具就是栈,由于乘除优先于加减运算,所以可以考虑先进行所有的乘除运算,并且将这些乘除运算之后的整数值放回到原表达式的相应位置,然后整个表达式的值就等于一系列加减后的值。在此基础上,我们可以使用一个栈,保存这些(进行乘除运算后的值。对于加减后的数字,我们可以先采取把他压入栈中的方式,然后在最后进行加减;对于乘除的数字,可以直接和栈顶元素计算,并且替换栈顶元素后为计算后的结果。总体思路如下:遍历整个数组,然后用变量 preSign 将这些数字都记录下来,对于第一个数字,与其之前的运算符都看做加好,每次加到数字末尾的时候,就使用 preSign 来决定计算方式;加号: 数字直接压入栈减号:将相反的数字压入栈乘号:将栈顶元素出栈,与数字相乘之后再入栈除号:将栈顶元素出栈,除完之后重新入栈然后遍历到数字尾部或者读取到一个运算符之后,更新符号。遍历完字符串之后,将栈中元素累加,就是该字符表达式的值。class Solution { public int calculate(String s) { if(s == null || s.length() == 0) return 0; Deque<Integer> stack = new ArrayDeque<Integer>(); char preSign = '+'; int num = 0; int n = s.length(); for (int i = 0; i < n; ++i) { if (Character.isDigit(s.charAt(i))) { num = num * 10 + s.charAt(i) - '0'; } if (!Character.isDigit(s.charAt(i)) && s.charAt(i) != ' ' || i == n - 1) { switch (preSign) { case '+': stack.push(num); break; case '-': stack.push(-num); break; case '*': stack.push(stack.pop() * num); break; default: stack.push(stack.pop() / num); } preSign = s.charAt(i); num = 0; } } int ans = 0; while (!stack.isEmpty()) { ans += stack.pop(); } return ans; } } 2. 逆波兰表达式表达式是编译原理,自然语言处理、文本分析等领域非常重要的问题,这里我们看一个相对中等的问题,逆波兰表达式。LeetCode 150. 逆波兰表达式给你一个字符串数组 tokens ,表示一个根据 逆波兰表示法 表示的算术表达式。请你计算该表达式。返回一个表示表达式值的整数。注意:有效的算符为 '+'、'-'、'*' 和 '/' 。每个操作数(运算对象)都可以是一个整数或者另一个表达式。两个整数之间的除法总是 向零截断 。表达式中不含除零算。输入是一个根据逆波兰表示法表示的算术表达式。答案及所有中间计算结果可以用 32 位 整数表示。示例 1: 输入:tokens = ["2","1","+","3","*"] 输出:9 解释:该算式转化为常见的中缀算术表达式为:((2 + 1) * 3) = 9 示例 2: 输入:tokens = ["4","13","5","/","+"] 输出:6 解释:该算式转化为常见的中缀算术表达式为:(4 + (13 / 5)) = 6 示例 3: 输入:tokens = ["10","6","9","3","+","-11","*","/","*","17","+","5","+"] 输出:22 解释:该算式转化为常见的中缀算术表达式为: ((10 * (6 / ((9 + 3) * -11))) + 17) + 5 = ((10 * (6 / (12 * -11))) + 17) + 5 = ((10 * (6 / -132)) + 17) + 5 = ((10 * 0) + 17) + 5 = (0 + 17) + 5 = 17 + 5 = 22 本题看起来很复杂,但是其实很简单,首先表达式就是指可以计算出结果的式子,根据不同的计法,可以分为前中后缀三种,其区别在于运算符相对于操作数的位置,前缀表达式的运算符位于操作数之前,中缀和后缀同理,如下图,这就对应了树的前中后三种遍历方式。对应的三种表达式如下:中缀: 1 + (2 + 3) x 4 -5 前缀:-+ 1 x + 2 3 4 5 后缀:1 2 3 + 4 x + 5 - 从上面来看,中缀表达式是我们经常遇见的,然后前缀被称为波兰式,后缀则被称为逆波兰式子用栈来解释就是遇到数组进栈,遇到运算符就取出栈中最上面的两个元素进行计算,最后运算结果进栈即可。实现代码如下:class Solution { public int evalRPN(String[] tokens) { Stack<Integer> stack = new Stack<>(); int n = tokens.length; for(int i = 0;i < n;i++){ String token = tokens[i]; if (isNumber(token)) { stack.push(Integer.parseInt(token)); }else { int num2 = stack.pop(); int num1 = stack.pop(); switch (token) { case "+": stack.push(num1 + num2); break; case "-": stack.push(num1 - num2); break; case "*": stack.push(num1 * num2); break; case "/": stack.push(num1 / num2); break; default: } } } return stack.pop(); } public boolean isNumber(String token) { return !("+".equals(token) || "-".equals(token) || "*".equals(token) || "/".equals(token)); } } 3. 通关文牒
0
0
0
浏览量986
时光小少年

第 13 关 | 刷题模板之数学 : 1.青铜挑战——数学与基础问题

1. 数字统计专题统计一下特定场景下的符号,或者数字个数等是一类非常常见的问题。如果按照正常方式强行统计,可能会非常复杂,所以掌握一些技巧是非常重要的。1.1. 符号统计LeetCode1822 给定一个数组,求所有元素的乘积的符号,如果最终答案是负的返回-1,如果最终答案是正的返回1,如果答案是0返回0。仔细分析一下这道题目,如果将所有数都乘起来,再判断正负,工作量真不少,而且还可能溢出。我们发现,一个数如果是 -100 和 -1,对符号位的贡献是完全一样的,所以只需要看有多少个负数,就能够判断最后乘积的符号了。class Solution { public int arraySign(int[] nums) { int sign = 0; for(int i = 0;i < nums.length;i++){ if(nums[i] == 0){ return 0; } if(nums[i] < 0){ sign++; } } if (sign % 2 == 1){ return -1; } return 1; } } 1.2 阶乘0的个数很多数学相关算法的关键在于找到怎么通过最简洁的方式来解决问题,而不是硬算。例如:面试题16.05:设计一个算法,算出 n 阶乘有多少个尾随零。这个题如果硬算,一定会超时,其实我们可以统计有多少个 0,实际上是统计 2 和 5 一起出现多少对,不过因为 2 出现的次数一定大于 5 出现的次数,因此我们只需要检查 5 出现的次数就好了,那么在统计过程中,我们只需要统计 5、10、15、 25、 ... 5^n 这样 5 的整数倍项就好了,最后累加起来,就是多少个 0。代码就是:public int trailingZeroes(int n) { int cnt = 0; for (long num = 5; n / num > 0; num *= 5) { cnt += n / num; } return cnt; } 数学不仅与算法难以区分 ,很多算法问题还与位运算密不可分,有些题目真不好说是该归类到数学中呢,还是位运算中。我们干脆就放在一起来看。2. 溢出问题溢出问题是一个极其重要的问题,只要涉及到输出一个数字,都可能遇到,典型的题目有三个:数字反转,将字符串转成数字和回文数。 不过溢出问题一般不会单独考察,甚至面试官都不会提醒你,但他就像捕捉猎物一样盯着你,看你会不会想到有溢出的问题,所以凡是涉及到输出结果为数字的问题,必须当心!溢出处理的技巧都是一致的 ,接下来我们就看一下如何处理 。2.1. 整数反转LeetCode7 给你一个 32 位的有符号整数 x ,返回将 x 中的数字部分反转后的结果。如果反转后整数超过 32 位的有符号整数的范围 [−2^31, 2^31 − 1] ,就返回 0。假设环境不允许存储 64 位整数(有符号或无符号)。输入:x = 123 输出:321 输入:x = -123 输出:-321 输入:x = 120 输出:21 输入:x = 0 输出:0 这个题的关键有两点,一个是如何进行数字反转,另一个是如何判断溢出。反转好说,那为什么会有溢出问题呢?例如1147483649这个数字,它是小于最大的32位整数2147483647的,但是将这个数字反转过来后就变成了9463847411,这就比最大的32位整数还要大了,这样的数字是没法存到int里面的,所以就溢出了。首先想一下,怎么去反转一个整数。用栈?或者把整数变成字符串再反转字符串?都可以但都不好。我们只要一边左移一边处理末尾数字就可以了。以12345为例,先拿到5,再拿到4,之后是3,2,1,然后就可以反向拼接出一个数字了。那如何获得末尾数字呢?好办,循环取模运算即可。例如:1.将12345 % 10 得到5,之后将12345 / 10=1234 2.将1234 % 10 得到4,再将1234 / 10=123 3.将123 % 10 得到3,再将123 / 10=12 4.将12 % 10 得到2,再将12 / 10=1 5.将1 % 10 得到1,再将1 / 10=0 画成图就是:这样的话,是不是将循环的判断条件设为x>0就可以了呢?不行!因为忽略了负数的问题,应该是while(x!=0)。去掉符号,剩下的数字,无论正数还是负数,按照上面不断的/10这样的操作,最后都会变成0,所以判断终止条件就是!=0。有了取模和除法操作,就可以轻松解决第一个问题,如何反转。接下来看如何解决溢出的问题。我们知道32位最大整数是MAX=2147483647,如果一个整数num>MAX,那么应该有以下规律:nums/10 > MAX/10=214748364,也就是如果底数第二位大于4了,不管最后一位是什么都已经溢出了,如下:所以我们要从到最大数的1/10时,就要开始判断,也即:● 如果 num>214748364 那后面就不用再判断了,肯定溢出了。● 如果num= 214748364,这对应到上图中第三、第四、第五排的数字,需要要跟最大数的末尾数字比较,如果这个数字比7还大,说明溢出了。● 如果num<214748364,则没问题,继续处理。这个结论对于负数也是一样的,所以实现代码就是:class Solution { public int reverse(int x) { int res = 0; while(x!=0){ // 获取末尾的数字 int temp = x %10; // 判断是否大于最大 32 位整数,也可以使用 Integer.Max_Value /10 代替 214748364 // 断是否 大于 最大32位整数 if (res>214748364 || (res==214748364 && temp>7)) { return 0; } //判断是否 小于 最小32位整数 if (res<-214748364 || (res==-214748364 && temp<-8)) { return 0; } res = res * 10 +temp; x /= 10; } return res; } } 2.2. 字符串转整数LeetCode8.意思就是字符串转整数(atoi函数),题目比较长,解决过程中要涉及很多异常情况的处理,在《字符串》那一关进行了详细讲解,这里就不进行过多的赘述。class Solution { public int myAtoi(String str) { int len = str.length(); char[] charArray = str.toCharArray(); // 1、去除前导空格 int index = 0; while (index < len && charArray[index] == ' ') { index++; } // 2、如果已经遍历完成(针对极端用例 " ") if (index == len) { return 0; } // 3、如果出现符号字符,仅第 1 个有效,并记录正负 int sign = 1; char firstChar = charArray[index]; if (firstChar == '+') { index++; } else if (firstChar == '-') { index++; sign = -1; } // 4、将后续出现的数字字符进行转换 // 不能使用 long 类型,这是题目说的 int res = 0; while (index < len) { char currChar = charArray[index]; // 4.1 先判断不合法的情况 if (currChar > '9' || currChar < '0') { break; } // 题目中说只能存储 32 位大小的有符号整数,下面两个if分别处理整数和负数的情况。 // 提前判断乘以10以后是否越界,但res*11可能会越界,所以这里使用Integer.MAX_VALUE/10,这样一定不会越界。 // 这是解决溢出问题的经典处理方式 if (res > Integer.MAX_VALUE / 10 || (res == Integer.MAX_VALUE / 10 && (currChar - '0') > Integer.MAX_VALUE % 10)) { return Integer.MAX_VALUE; } if (res < Integer.MIN_VALUE / 10 || (res == Integer.MIN_VALUE / 10 && (currChar - '0') > -(Integer.MIN_VALUE % 10))) { return Integer.MIN_VALUE; } // 合法的情况下,才考虑转换,每一步都把符号位乘进去 // 想想这里为什么要带着sign乘 res = res * 10 + sign * (currChar - '0'); index++; } return res; } } 2.3. 回文数LeetCode9 .给你一个整数 x ,如果 x 是一个回文整数,返回 true ;否则,返回 false 。回文数是指正序(从左向右)和倒序(从右向左)读都是一样的整数。例如,121 是回文,而 123 不是。思路解析:映入脑海的第一个想法是将数字转换为字符串,并检查字符串是否为回文。但是,这需要额外的非常量空间来创建问题描述中所不允许的字符串。第二个想法是将数字本身反转,然后将反转后的数字与原始数字进行比较,如果它们是相同的,那么这个数字就是回文。 但是,如果反转后的数字大于int.MAX,我们将遇到整数溢出问题。按照第二个想法,为了避免数字反转可能导致的溢出问题,为什么不考虑只反转 int 数字的一半?毕竟,如果该数字是回文,其后半部分反转后应该与原始数字的前半部分相同。例如,输入 1221,我们可以将数字 “1221” 的后半部分从 “21” 反转为 “12”,并将其与前半部分 “12” 进行比较,因为二者相同,我们得知数字 1221 是回文。这个反转思路与链表的反转是一样的,不过要简单多了。这里还不能忘的问题就是,反转之后数字可能会溢出,因此必须做防范,方法我们上面说了,这里我们可以进一步简化一下:class Solution { public boolean isPalindrome(int x) { // 特殊情况: // 如上所述,当 x < 0 时,x 不是回文数。 // 同样地,如果数字的最后一位是 0,为了使该数字为回文, // 则其第一位数字也应该是 0 // 只有 0 满足这一属性 if(x < 0 || x % 10 == 0 && x != 0){ return false; } int reverterNumber = 0 ; while(x > reverterNumber){ reverterNumber = reverterNumber * 10 + x % 10; x/=10; } return x== reverterNumber || x == reverterNumber / 10; } } 3. 进制专题3.1. 七进制数LeetCode504.给定一个整数 num,将其转化为 7 进制,并以字符串形式输出。其中-10^7 <= num <= 10^7。示例 1: 输入: num = 100 输出: "202" 示例 2: 输入: num = -7 输出: "-10" 我们先通过二进制想一下7进制数的变化特征。在二进制中,先是0,然后是1,而2就是10(2),3就是11(2),4就是(100)。同样在7进制中,计数应该是这样的:0 1 2 3 4 5 6 10 11 12 13 14 15 16 20 21 22 ...给定一个整数将其转换成7进制的主要过程是循环取余和整除 ,最后将所有的余数反过来即可。例如,将十进制数100 转成七进制:100÷7=14 余 2 14÷7=2 余 0 2÷7=0 余 2 向遍历每次的余数,依次是 2、0、2,因此十进制数 100 转成七进制数是202 。如果num<0,则先对 num 取绝对值,然后再转换即可。使用代码同样可以实现该过程,需要注意的是如果单纯按照整数来处理会非常麻烦,既然题目说以字符串形式返回,那我们干脆直接用字符串类,代码如下:class Solution { public String convertToBase7(int num) { if(num == 0){ return "0"; } StringBuilder sb = new StringBuilder(); // 先拿到正负号 boolean sign = num < 0; //预处理一下,然后后面按照正数 if(sign){ num *= -1; } //循环取余和整除 do{ sb.append(num%7 + ""); num /= 7; }while(num > 0); //添加符号 if(sign){ sb.append("-"); } return sb.reverse().toString(); } } char* convertToBase7(int num) { char* result = (char*) malloc(sizeof(char) * 100); // 分配足够的空间存储结果字符串 int i = 0; while (num > 0) { int remainder = num % 7; if (remainder < 10) { result[i++] = remainder + '0'; } else { result[i++] = remainder - 10 + 'A'; } num /= 7; } result[i] = '\0'; // 添加字符串结束符 return result; } 3.2. 进制转换给定一个十进制数 M ,以及需要转换的进制数 N ,将十进制 M 转换为 N进制数,M是 32 位整数,2 <= N <= 16。这个题目的思路并不复杂,但是想要写正确却非常不容易,甚至越写越糊涂,本题有好几个问题需要处理:超过进制最大范围之后如何准确映射到其他进制,特别是ABCDEF这种情况。简单的方式是大量采用if 判断,但是这样会出现写了一坨,最后写不下去。需要对结果进行一次转置需要判断负号。以下是总结出的最精简,最容易理解的实现方案。注意采取三个措施来方便处理:定义大小为16的数组F,保存的是2到16的各个进制的值对应的标记,这样赋值时只计算下标,不必考虑不同进制的转换关系了。使用StringBuffer完成数组转置等功能,如果不记得这个方法,工作量直接飙升。通过一个flag来判断正数还是负数,最后才处理。 // 要考虑到 余数 > 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"}; //将十进制数M转化为N进制数 public String convert (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(); } char* convert(int M, int N) { char F[] = {'0','1','2','3','4','5','6','7','8','9','a','b','c','d','e','f'}; char* result = (char*) malloc(MAX_N + 1); // 分配内存空间 int flag = 0; // 判断正负 if (M < 0) { flag = 1; M = -M; } int i = 0; // 当前位数指针 while (M != 0) { int temp = M % N; // 取余数 M /= N; // 整除N if (temp < 0) { // 如果余数为负数,则将其转化为正数 temp += N; M += N; } result[i++] = F[temp]; // 将余数转换为字符并添加到结果中 } result[i] = '\0'; // 添加字符串结束符 if (flag) { // 如果为负数,则在结果前面添加负号 result[0] = '-'; } // result里的数字需要反转一下才可以 return result; }
0
0
0
浏览量2011
时光小少年

第 20 关 | 图算法 —— 中看不中用:2.白银挑战——图的存储与遍历

与前面的链表、树等相比,图的存储和遍历要复杂非常多,本文,我们就来看一下如何实现?关卡名常见的图算法我会了✔️内容1.  理解图的基本特征和常见概念✔️图的类型多、表示方式多,相关算法也很多,实现又过于复杂,多语言实现难度太大了。这些算法一般理解就好,不需要面试的时候手写,因此本文,我们只提供C/C++版本的实现。1. 图的实现方式图的表示方式比前面学习的几种结构都复杂,常见的有两种:二维数组表示(邻接矩阵);链表表示(邻接表)。邻接矩阵是表示图形中顶点之间相邻关系的矩阵,对于 n 个顶点的图而言,矩阵是的 row 和 col 表示的是 1....n个点,矩阵中的1表示有连线,0表示没有连线。在上图的邻接矩阵需要为每个顶点都分配n个边的空间,其实有很多边都是不存在,因此存储效率比较低。如果图比较稀疏的话,会造成大量的空间浪费,比如在地铁图中,一个站点多的也就与几个站相连,少的只有一个,而使用邻接矩阵则需要为每个站点都分配N个空间。邻接表的实现只关心存在的边,不关心不存在的边,因此没有空间浪费,邻接表由数组+链表组成。如果表示方式不同,自然定义图和后续操作的方式也不一样,因此这也是图算法不经常出现面试中的原因之一。1.1. 图的邻接矩阵表示法所谓的邻接矩阵就是使用上述的二维矩阵的方式来存储图。在实际中为了方便管理,我们会增加一些额外的定义。1.1.1. 基本结构定义我们现在可以看到如何来实现各种类型的图吧。#define MAX_VERtEX_NUM 20 //顶点的最大个数 #define VRType int //表示顶点之间的关系的变量类型 #define InfoType char //存储弧或者边额外信息的指针变量类型 #define VertexType int //图中顶点的数据类型 typedef enum { DG, DN, UDG, UDN }GraphKind; //枚举图的 4 种类型 typedef struct { VRType adj; //对于无权图,用 1 或 0 表示是否相邻;对于带权图,直接为权值。 InfoType* info; //弧或边额外含有的信息指针 }ArcCell, AdjMatrix[MAX_VERtEX_NUM][MAX_VERtEX_NUM]; typedef struct { VertexType vexs[MAX_VERtEX_NUM]; //存储图中顶点数据 AdjMatrix arcs; //二维数组,记录顶点之间的关系 int vexnum, arcnum; //记录图的顶点数和弧(边)数 GraphKind kind; //记录图的种类 }MGraph; 在不存在相同元素时,我们可以根据顶点本身数据,判断出顶点在二维数组中的位置,代码如下:int LocateVex(MGraph* G, VertexType v) { //遍历一维数组,找到变量v for (int i = 0;int i = 0 i < G->vexnum; i++) { if (G->vexs[i] == v) { break; } } //如果找不到,输出提示语句,返回-1 if (i == G->vexnum) { printf("no such vertex.\n"); return -1; } return i; } 如果我们想将图中所有元素都打印出来,方法如下:void PrintGrapth(MGraph G) { int i, j; for (i = 0; i < G.vexnum; i++) { for (j = 0; j < G.vexnum; j++) { printf("%d ", G.arcs[i][j].adj); } printf("\n"); } } 1.1.2. 构造有向图和无向图图有有向和无向两种基本的方式,无向图如下所示,就是只要两个结点V1 和V2之间有连线,则就可以相互访问。而有向图就是如下所示,带方向箭头的,例如V1 和V2之间有连线,则只能从V1到V2,而不能反向直接访问。void CreateDG(MGraph* G) { int i, j; //输入图含有的顶点数和弧的个数 scanf("%d,%d", &(G->vexnum), &(G->arcnum)); //依次输入顶点本身的数据 for (i = 0; i < G->vexnum; i++) { scanf("%d", &(G->vexs[i])); } //初始化二维矩阵,全部归0,指针指向NULL for (i = 0; i < G->vexnum; i++) { for (j = 0; j < G->vexnum; j++) { G->arcs[i][j].adj = 0; G->arcs[i][j].info = NULL; } } //在二维数组中添加弧的数据 for (i = 0; i < G->arcnum; i++) { int v1, v2; int n, m; //输入弧头和弧尾 scanf("%d,%d", &v1, &v2); //确定顶点位置 n = LocateVex(G, v1); m = LocateVex(G, v2); //排除错误数据 if (m == -1 || n == -1) { printf("no this vertex\n"); return; } //将正确的弧的数据加入二维数组 G->arcs[n][m].adj = 1; } } 我们再看一下无向图的实现:void CreateDN(MGraph* G) { int i, j; scanf("%d,%d", &(G->vexnum), &(G->arcnum)); for (i = 0; i < G->vexnum; i++) { scanf("%d", &(G->vexs[i])); } for (i = 0; i < G->vexnum; i++) { for (j = 0; j < G->vexnum; j++) { G->arcs[i][j].adj = 0; G->arcs[i][j].info = NULL; } } for (i = 0; i < G->arcnum; i++) { int v1, v2; int n, m; scanf("%d,%d", &v1, &v2); n = LocateVex(G, v1); m = LocateVex(G, v2); if (m == -1 || n == -1) { printf("no this vertex\n"); return; } G->arcs[n][m].adj = 1; G->arcs[m][n].adj = 1;//无向图的二阶矩阵沿主对角线对称 } } 1.1.3. 构造带权重的有向图和无向图在上面小节,我们构造的图中的连线,每条线的权重都是一样的,都是1。但是在实际中这是不可能的 ,有些连线代价高一些。有些低一些,因此不同的线权重是不一样的。例如,使用上述程序存储左图的有向网时,存储的两个数组如右图所示:在程序中,构建无向网和有向网时,对于之间没有边或弧的顶点,相应的二阶矩阵中存放的是 0。目的只是为了方便查看运行结果,而实际上如果顶点之间没有关联,它们之间的距离应该是无穷大(∞)。void CreateUDG(MGraph* G) { int i, j; scanf("%d,%d", &(G->vexnum), &(G->arcnum)); for (i = 0; i < G->vexnum; i++) { scanf("%d", &(G->vexs[i])); } for (i = 0; i < G->vexnum; i++) { for (j = 0; j < G->vexnum; j++) { G->arcs[i][j].adj = 0; G->arcs[i][j].info = NULL; } } for (i = 0; i < G->arcnum; i++) { int v1, v2, w; int n, m; scanf("%d,%d,%d", &v1, &v2, &w); n = LocateVex(G, v1); m = LocateVex(G, v2); if (m == -1 || n == -1) { printf("no this vertex\n"); return; } G->arcs[n][m].adj = w; } } 我们再构造无向网,和无向图唯一的区别就是二阶矩阵中存储的是权值void CreateUDN(MGraph* G) { int i, j; scanf("%d,%d", &(G->vexnum), &(G->arcnum)); for (i = 0; i < G->vexnum; i++) { scanf("%d", &(G->vexs[i])); } for (i = 0; i < G->vexnum; i++) { for (j = 0; j < G->vexnum; j++) { G->arcs[i][j].adj = 0; G->arcs[i][j].info = NULL; } } for (i = 0; i < G->arcnum; i++) { int v1, v2, w; int m, n; scanf("%d,%d,%d", &v1, &v2, &w); m = LocateVex(G, v1); n = LocateVex(G, v2); if (m == -1 || n == -1) { printf("no this vertex\n"); return; } G->arcs[n][m].adj = w; G->arcs[m][n].adj = w;//矩阵对称 } } 总结一下,本节主要详细介绍了使用数组存储图的方法,在实际操作中使用更多的是链式存储结构,例如邻接表、十字链表和邻接多重表,这三种存储图的方式放在下一节重点去讲。1.2. 邻接表计算顶点的出度和入度在有向图(网)中,顶点的入度指的是以当前顶点一端为弧头的弧的数量;顶点的出度指的是以当前顶点一端为弧尾的弧的数量。在邻接表中计算某个顶点的出度是非常简单的,只需要在顺序表中找到该顶点,然后计算该顶点所在链表中其它结点的数量,即为该顶点的出度。例如,图 1b) 中为 V1 构建的链表中有 2 个结点,因此 V1 的出度就是 2。在邻接表中计算某个顶点的入度,有两种实现方案:1遍历顺序表,找到该顶点,获取该顶点所在顺序表中的下标(假设为 K)。然后遍历所有单链表中的结点,统计数据域为 K 的结点数量,即为该顶点的入度。2建立一个逆邻接表,表中各个顶点的链表中记录的是以当前顶点一端为弧头的弧的信息。比如说,图 1a) 对应的逆邻接表如下图所示:逆邻接表以 V1 顶点为例,数据域为 3 的结点记录的是 <V4, V1> 这条弧。对于具有 n 个顶点和 e 条边的无向图,邻接表中需要构建 n 个首元结点和 2e 个表示边的结点;对于具有 n 个顶点和 e 条弧的有向图,邻接表需要构建 n 个首元结点和 e 个表示弧的结点。当图中边或者弧稀疏时,用邻接表比前一节介绍的邻接矩阵更加节省空间,边或弧相关信息较多时更是如此。最后,用邻接表存储有向图的 C 语言程序如下所示:#include<stdio.h> #include<stdlib.h> #define MAX_VERTEX_NUM 20//最大顶点个数 #define VertexType char//图中顶点的类型 typedef struct ArcNode { int adjvex;//存储弧,即另一端顶点在数组中的下标 struct ArcNode* nextarc;//指向下一个结点 }ArcNode; typedef struct VNode { VertexType data;//顶点的数据域 ArcNode* firstarc;//指向下一个结点 }VNode, AdjList[MAX_VERTEX_NUM];//存储各链表首元结点的数组 typedef struct { AdjList vertices; //存储图的邻接表 int vexnum, arcnum;//图中顶点数以及弧数 }ALGraph; void CreateGraph(ALGraph * graph) { int i, j; char VA, VB; ArcNode* node = NULL; printf("输入顶点的数目:\n"); scanf("%d", &(graph->vexnum)); printf("输入弧的数目:\n"); scanf("%d", &(graph->arcnum)); scanf("%*[^\n]"); scanf("%*c"); printf("输入各个顶点的值:\n"); for (i = 0; i < graph->vexnum; i++) { scanf("%c", &(graph->vertices[i].data)); getchar(); graph->vertices[i].firstarc = NULL; } //输入弧的信息,并为弧建立结点,链接到对应的链表上 for (i = 0; i < graph->arcnum; i++) { printf("输入弧(a b 表示弧 a->b):\n"); scanf("%c %c", &VA, &VB); getchar(); node = (ArcNode*)malloc(sizeof(ArcNode)); node->adjvex = '#'; node->nextarc = NULL; //存储弧另一端顶点所在顺序表中的下标 for (j = 0; j < graph->vexnum; j++) { if (VB == graph->vertices[j].data) { node->adjvex = j; break; } } //如果未在顺序表中找到另一端顶点,则构建图失败 if (node->adjvex == '#') { printf("弧信息输入有误\n"); exit(0); } //将结点添加到对应的链表中 for (j = 0; j < graph->vexnum; j++) { if (VA == graph->vertices[j].data) { //将 node 结点以头插法的方式添加到相应链表中 node->nextarc = graph->vertices[j].firstarc; graph->vertices[j].firstarc = node; break; } } if (j == graph->vexnum) { printf("弧信息输入有误\n"); exit(0); } } } //计算某个顶点的入度 int InDegree(ALGraph graph, char V) { int i, j, index = -1; int count = 0; //找到 V 在顺序表中的下标 for (j = 0; j < graph.vexnum; j++) { if (V == graph.vertices[j].data) { index = j; break; } } if (index == -1) { return -1; } //遍历每个单链表,找到存储 V 下标的结点,并计数 for (j = 0; j < graph.vexnum; j++) { ArcNode* p = graph.vertices[j].firstarc; while (p) { if (p->adjvex == index) { count++; } p = p->nextarc; } } return count; } //计算某个顶点的出度 int OutDegree(ALGraph graph, char V) { int j; int count = 0; for (j = 0; j < graph.vexnum; j++) { if (V == graph.vertices[j].data) { ArcNode* p = graph.vertices[j].firstarc; while (p) { count++; p = p->nextarc; } break; } } //如果查找失败,返回 -1 表示计算失败 if (j == graph.vexnum) { return -1; } return count; } 1.3. 图的其他存储方式上面我们采用二维矩阵的方式存储图,除此之外还有两种方式也能存储,分别是邻接矩阵和十字链法,本小节,我们就来看一下。1.3.1. 邻接表存储矩阵邻接表(Adjacency List)是图的一种链式存储结构,既可以存储无向图(网),也可以存储有向图(网)。邻接表存储图的核心思想是:将图中的所有顶点存储到顺序表中(也可以是链表),同时为各个顶点配备一个单链表,用来存储和当前顶点有直接关联的边或者弧(边的一端是该顶点或者弧的弧尾是该顶点)。举个简单的例子,下图是一张有向图和它对应的邻接表:有向图和它对应的邻接表以顶点 V1 为例,它对应的单链表中有两个结点,存储的值分别是 2 和 1。2 是 V3 顶点在顺序表中的位置下标,存储 2 的结点就表示 <V1, V3> 这条弧;同理,1 是 V2 顶点在顺序表中的位置下标,存储 1 的结点就表示 <V1, V2> 这条弧。也就是说,邻接表中存储边或弧的方法,就是存储边或弧另一端顶点在顺序表中的位置下标。继续分析图 1b) 中的另外 3 个单链表:●V2:由于图中不存在以 V2 为弧尾的弧,所以不需要为 V2 构建链表;●V3:以 V3 为弧尾的弧只有 <V3, V4>,V4 在顺序表对应的下标为 3,因此单链表中只有 1 个结点,结点中存储 3 来表示 <V3, V4>。●V4:以 V4 为弧尾的弧只有 <V4, V1>,V1 在顺序表对应的下标为 0,因此单链表中只有 1 个结点,结点中存储 0 来表示 <V4, V1>。实际上,邻接表就是由一个顺序表和多个单链表组成的,顺序表用来存储图中的所有顶点,各个单链表存储和当前顶点有直接关联的边或弧。存储顶点的顺序表,内部各个空间的结构如下图所示:data 为数据域,用来存储各个顶点的信息;next 为指针域,用来链接下一个结点。对于无向图或者有向图来说,单链表中存储边或弧的结点也可以用图 2 所示的结构来表示,data 数据域存储边或弧另一端顶点在顺序表中的下标,next 指针域用来链接下一个结点。对于无向网或者有向网来说,结点可以用下图所示的结构来表示:adjvex 数据域用来存储边或弧另一端顶点在顺序表中的下标;next 指针域用来链接下一个结点;info 指针域用来存储有关边或弧的其它信息,比如边或弧的权值。用 C 语言表示邻接表的实现代码如下:#define MAX_VERTEX_NUM 20//图中顶点的最大数量 #define VertexType int//图中顶点的类型 #define InfoType int*//图中弧或者边包含的信息的类型 typedef struct ArcNode{ int adjvex;//存储边或弧,即另一端顶点在数组中的下标 struct ArcNode * nextarc;//指向下一个结点 InfoType info;//记录边或弧的其它信息 }ArcNode; typedef struct VNode{ VertexType data;//顶点的数据域 ArcNode * firstarc;//指向下一个结点 }VNode,AdjList[MAX_VERTEX_NUM];//存储各链表首元结点的数组 typedef struct { AdjList vertices;//存储图的邻接表 int vexnum,arcnum;//记录图中顶点数以及边或弧数 int kind;//记录图的种类 }ALGraph; 以上各个结构体中的成员并非一成不变,根据实际场景的需要,可以修改它们的数据类型,还可以适当地删减。基于上述定义,我们可以再次实现前面说的有向图、无向图、以及带权重的图等相关代码,这里我们就不再实现了。1.3.2. 邻接表存储矩阵存储有向图(网),可以使用邻接表或者逆邻接表结构,也可以使用本节讲解的十字链表结构。用邻接表存储有向图(网),可以快速计算出某个顶点的出度,但计算入度的效率不高。反之,用逆邻接表存储有向图(网),可以快速计算出某个顶点的入度,但计算出度的效率不高。那么有没有一种存储结构,可以快速计算出有向图(网)中某个顶点的入度和出度呢?答案是肯定的,十字链表就是这样的一种存储结构。十字链表(Orthogonal List)是一种专门存储有向图(网)的结构,它的核心思想是:将图中的所有顶点存储到顺序表(也可以是链表)中,同时为每个顶点配备两个链表,一个链表记录以当前顶点为弧头的弧,另一个链表记录以当前顶点为弧尾的弧。举个简单的例子,用十字链表结构存储图 1a) 中的有向图,图的存储状态如图 1b) 所示:观察图 1b),顺序表中的各个存储空间分为 3 部分,各个链表中的结点空间分为 4 部分。顺序表中的空间用来存储图中的顶点,结构如下图所示:图 2 存储顶点的结构各部分的含义分别是:●data 数据域:用来存储顶点的信息;●firstin 指针域:指向一个链表,链表中记录的都是以当前顶点为弧头的弧的信息;●firstout 指针域:指向另一个链表,链表中记录的是以当前顶点为弧尾的弧的信息。链表的结点用来存储图中的弧,结构如下图所示:图 3: 存储弧信息的结点结构各部分的含义分别是:●tailvex数据域:存储弧尾一端顶点在顺序表中的位置下标;●headvex 数据域:存储弧头一端顶点在顺序表中的位置下标;●hlink 指针域:指向下一个以当前顶点作为弧头的弧;●tlink 指针域:指向下一个以当前顶点作为弧尾的弧;●info 指针:存储弧的其它信息,例如有向网中弧的权值。如果不需要存储其它信息,可以省略。在十字链表结构中,如果想计算某个顶点的出度,就统计 firstout 所指链表中的结点数量,每找到一个结点,再根据它的 tlink 指针域寻找下一个结点,直到最后一个结点。同样的道理,如果想计算某个顶点的入度,就统计 firstin 所指链表中的结点数量,每找到一个结点,再根据它的 hlink 指针域寻找下一个结点,直到最后一个结点。以图 1b) 中的 V1 顶点为例,计算出度的过程是:根据 V1 顶点的 firstout 指针,找到存储 <V1, V2> 弧的结点;根据 <V1, V2> 弧结点中的 tlink 指针,找到存储 <V1, V3> 弧的结点;由于 <V1, V3> 弧结点的 tlink 指针为 NULL,因此只找到了 2 个弧,V1 顶点的出度就为 2。计算 V1 顶点入度的过程是:根据 V1 顶点的 firstin 指针,找到存储 <V4, V1> 弧的结点;由于 <V4, V1> 弧结点的 hlink 指针为 NULL,因此只找到了 1 个弧,V1 顶点的入度就为 1。如果你已经学会了邻接表和逆邻接表,可以将十字链表想象成邻接表和逆邻接表的结合体。构建图的十字链表结构,对应的 C 语言代码如下:#define MAX_VERTEX_NUM 20 //图中顶点的最大数量 #define InfoType int* //表示弧额外信息的数据类型 #define VertexType char //图中顶点的数据类型 //表示链表中存储弧的结点 typedef struct ArcBox { int tailvex, headvex; //弧尾、弧头对应顶点在顺序表中的位置下标 struct ArcBox* hlik, * tlink; //hlik指向下一个以当前顶点为弧头的弧结点; //tlink 指向下一个以当前顶点为弧尾的弧结点; //InfoType info; //存储弧相关信息的指针 }ArcBox; //表示顺序表中的各个顶点 typedef struct VexNode { VertexType data; //顶点的数据域 ArcBox* firstin, * firstout; //指向以该顶点为弧头和弧尾的链表首个结点 }VexNode; //表示十字链表存储结构 typedef struct { VexNode xlist[MAX_VERTEX_NUM]; //存储顶点的顺序表 int vexnum, arcnum; //记录图的顶点数和弧数 }OLGraph; 以图 1a) 为例,十字链表结构存储此图的完整 C 语言程序如下所示: #include<stdio.h> #define MAX_VERTEX_NUM 20 //图中顶点的最大数量 #define InfoType int* //表示弧额外信息的数据类型 #define VertexType char //图中顶点的数据类型 //表示链表中存储弧的结点 typedef struct ArcBox { int tailvex, headvex; //弧尾、弧头对应顶点在顺序表中的位置下标 struct ArcBox* hlik, * tlink; //hlik指向下一个以当前顶点为弧头的弧结点; //tlink 指向下一个以当前顶点为弧尾的弧结点; //InfoType info; //存储弧相关信息的指针 }ArcBox; //表示顺序表中的各个顶点 typedef struct VexNode { VertexType data; //顶点的数据域 ArcBox* firstin, * firstout; //指向以该顶点为弧头和弧尾的链表首个结点 }VexNode; //表示十字链表存储结构 typedef struct { VexNode xlist[MAX_VERTEX_NUM]; //存储顶点的顺序表 int vexnum, arcnum; //记录图的顶点数和弧数 }OLGraph; int LocateVex(OLGraph* G, VertexType v) { int i; //遍历一维数组,找到变量v for (i = 0; i < G->vexnum; i++) { if (G->xlist[i].data == v) { break; } } //如果找不到,输出提示语句,返回 -1 if (i > G->vexnum) { printf("no such vertex.\n"); return -1; } return i; } //构建十字链表存储结构 void CreateDG(OLGraph* G) { int i, j, k; VertexType v1, v2; ArcBox* p = NULL; //输入有向图的顶点数和弧数 scanf("%d %d", &(G->vexnum), &(G->arcnum)); getchar(); //使用一维数组存储顶点数据,初始化指针域为NULL for (i = 0; i < G->vexnum; i++) { scanf("%c", &(G->xlist[i].data)); getchar(); G->xlist[i].firstin = NULL; G->xlist[i].firstout = NULL; } //存储图中的所有弧 for (k = 0; k < G->arcnum; k++) { scanf("%c %c", &v1, &v2); getchar(); //确定v1、v2在数组中的位置下标 i = LocateVex(G, v1); j = LocateVex(G, v2); //建立弧的结点 p = (ArcBox*)malloc(sizeof(ArcBox)); p->tailvex = i; p->headvex = j; //采用头插法插入新的p结点 p->hlik = G->xlist[j].firstin; p->tlink = G->xlist[i].firstout; G->xlist[j].firstin = G->xlist[i].firstout = p; } } //计算某顶点的入度 int indegree(OLGraph* G, VertexType x) { int i; int num = 0; //遍历整个顺序表 for (i = 0; i < G->vexnum; i++) { //找到目标顶点 if (x == G->xlist[i].data) { //从该顶点的 firstin 指针所指的结点开始遍历 ArcBox* p = G->xlist[i].firstin; while (p) { num++; //遍历 hlink 指针指向的下一个结点 p = p->hlik; } break; } } if (i == G->vexnum) { printf("图中没有指定顶点\n"); return -1; } return num; } //计算某顶点的出度 int outdegree(OLGraph* G, VertexType x) { int i; int num = 0; //遍历整个顺序表 for (i = 0; i < G->vexnum; i++) { //找到目标顶点 if (x == G->xlist[i].data) { //从该顶点的 firstout 指针所指的结点开始遍历 ArcBox* p = G->xlist[i].firstout; while (p) { num++; //遍历 tlink 指针指向的下一个结点 p = p->tlink; } break; } } if (i == G->vexnum) { printf("图中没有指定顶点\n"); return -1; } return num; } //删除十字链表结构 //每个顶点配备两个链表,选定一个链表(比如 firstout 所指链表),删除每个顶点中 firstout 所指链表上的结点 void DeleteDG(OLGraph* G) { int i; ArcBox* p = NULL, * del = NULL; for (i = 0; i < G->vexnum; i++) { p = G->xlist[i].firstout; while (p) { del = p; p = p->tlink; free(del); } //将第 i 个位置的两个指针全部置为 NULL,能有效避免出现野指针 G->xlist[i].firstout = NULL; G->xlist[i].firstin = NULL; } } 2. 图的遍历与树一样 ,图有深度优先和层次遍历两种方式,但是图没有根, 因此更多时候将层次遍历称为广度优先遍历BFS。2.1. 图的深度优先搜索深度优先搜索(Depth First Search)简称深搜或者 DFS,是遍历图存储结构的一种算法,既适用于无向图(网),也适用于有向图(网)。所谓图的遍历,简单理解就是逐个访问图中的顶点,确保每个顶点都只访问一次。首先通过一个样例,给大家讲解深度优先搜索算法是如何实现图的遍历的。图 1 无向图的整个过程是:初始状态下,无向图中的所有顶点都是没有被访问过的,因此可以任选一个顶点出发,遍历整个无向图。假设从 V1 顶点开始,先访问 V1 顶点,如下图所示:2.  紧邻 V1 的顶点有两个,分别是 V2 和 V3,它们都没有被访问过,从它们中任选一个,比如访问 V2,如下图所示:3.  紧邻 V2 的顶点有三个,分别是 V1、V4 和 V5,尚未被访问的有 V4 和 V5,从它们中任选一个,比如访问 V4,如下图所示:4.  紧邻 V4 的顶点有两个,分别是 V2 和 V8,只有 V8 尚未被访问,因此访问 V8,如下图所示:5.  紧邻 V8 的顶点有两个,分别是 V4 和 V5,只有 V5 尚未被访问,因此访问 V5,如下图所示:6.  和 V5 相邻的顶点有两个,分别是 V2 和 V8,它们都已经访问过了。也就是说,此时从 V5 出发,找不到任何未被访问的顶点了。这种情况下,深度优先搜索算法会回退到之前的顶点,查看先前有没有漏掉的、尚未访问的顶点:从 V5 回退到 V8,找不到尚未访问的顶点;从 V8 回退到 V4,还是找不到尚未访问的顶点;从 V4 回退到 V2,也还是找不到尚未访问的顶点;从 V2 回退到 V1,发现 V3 还没有被访问。于是,下一个要访问的顶点就是 V3,如下图所示:7.  紧邻 V3 的顶点有三个,分别是 V1、V6 和 V7,尚未访问的有 V6 和 V7,因此从它们中任选一个,比如访问 V6,如下图所示:8.  紧邻 V6 的顶点有两个,分别是 V3 和 V7,只有 V7 还没有访问,因此访问 V7,如下图所示:9.  紧邻 V7 顶点有 V6 和 V3,但它们都已经访问过了,此时面临的情况和第 6 步完全一样,深度优先搜索算法的解决方法也是一样的:从 V7 回退到 V6,依然找不到尚未访问的顶点;从 V6 回退到 V3,依然找不到尚未访问的顶点;从 V3 回退到 V1,依然找不到尚未访问的顶点;V1 是遍历图的起始顶点,回退到 V1 还找不到尚未访问的顶点,意味着以 V1 顶点为突破口,能访问的顶点全部已经访问完了。这种情况下,深度优先搜索算法会从图的所有顶点中重新选择一个尚未访问的顶点,从该顶点出发查找尚未访问的其它顶点。从图 9 可以看到,图中已经没有尚未访问的顶点了,此时深度优先搜索算法才执行结束。对于连通图来说,深度优先搜索算法从一个顶点出发就能访问图中所有的顶点。但是对于非连通图来说,深度优先搜索算法必须从各个连通分量中选择一个顶点出发,才能访问到所有的顶点。所谓深度优先搜索,就是从图中的某个顶点出发,不停的寻找相邻的、尚未访问的顶点:如果找到多个,则任选一个顶点,然后继续从该顶点出发;如果一个都没有找到,则回退到之前访问过的顶点,看看是否有漏掉的;假设从顶点 V 出发,则最终还会回退到顶点 V。此时,深度优先搜索算法会从所有顶点中重新找一个尚未访问的顶点,如果能找到,则以同样的方式继续寻找其它未访问的顶点;如果找不到,则算法执行结束。通常情况下,深度优先搜索算法访问图中顶点的顺序是不唯一的,即顶点的访问序列可能有多种(≥1)。图的存储结构有很多种,大体上可以分为顺序存储和链式存储(又细分为邻接表结构、十字链表结构和邻接多重表结构),各个存储结构有自己的特点。选用不同的存储结构,深度优先搜索算法的具体实现不同,但算法的思想是不变的。这里以图的顺序存储结构为例,深度优先搜索算法的 C 语言实现代码如下:程序中,为了确保每个顶点只访问一次,借助了一个名为 visited 的一维数组,专门用来存储各个顶点的访问状态,用 0 表示未被访问,用 1 表示已经访问过。visited 数组中的访问状态和 vexs 数组中存储的顶点是一一对应的,比如 vexs[1] 顶点的访问状态就记录在 visited[1] 的位置。2.2. 图的广度优先遍历广度优先搜索(Breadth First Search)简称广搜或者 BFS,是遍历图存储结构的一种算法,既适用于无向图(网),也适用于有向图(网)。所谓图的遍历,简单理解就是逐个访问图中的顶点,确保每个顶点都只访问一次。首先通过一个样例,给大家讲解广度优先搜索算法是如何实现图的遍历的。使用广度优先搜索算法,遍历图 1 中无向图的过程是:初始状态下,图中所有顶点都是尚未访问的,因此任选一个顶点出发,开始遍历整张图。比如从 V1 顶点出发,先访问 V1:2.  从 V1 出发,可以找到 V2 和 V3,它们都没有被访问,所以访问它们:注意:本图中先访问的是 V2,也可以先访问 V3。当可以访问的顶点有多个时,访问的顺序是不唯一的,可以根据找到各个顶点的先后次序依次访问它们。后续过程也会遇到类似情况,不再重复赘述。3) 根据图 3 中的顶点访问顺序,紧邻 V1 的顶点已经访问过,接下来访问紧邻 V2 的顶点。从 V2 顶点出发,可以找到 V1、V4 和 V5,尚未访问的有 V4 和 V5,因此访问它们:4.  根据图 4 中的顶点访问顺序,接下来访问紧邻 V3 的顶点。从 V3 顶点出发,可以找到 V1、V6 和 V7,尚未访问的有 V6 和 V7,因此访问它们:5.  根据图 5 中的顶点访问顺序,接下来访问紧邻 V4 的顶点。从 V4 顶点出发,可以找到 V2 和 V8,只有 V8 尚未访问,因此访问它:6.  根据图 6 的顶点访问顺序,接下来访问紧邻 V5 的顶点。观察图 6 中的无向图不难发现,与 V5 紧邻的 V2 和 V8 都已经访问过,无法再找到尚未访问的顶点。此时,广度优先搜索算法会直接跳过 V5,继续从其它的顶点出发。7.  广度优先搜索算法先后从 V6、V7、V8 出发,寻找和它们紧邻、尚未访问的顶点,但寻找的结果都和 V5 一样,找不到符合要求的顶点。8.  自 V8 之后,访问序列中再无其它顶点,意味着从 V1 顶点出发,无法再找到尚未访问的顶点。这种情况下,广度优先搜索算法会从图的所有顶点中重新选择一个尚未访问的顶点,然后从此顶点出发,以同样的思路继续寻找其它尚未访问的顶点。本例中的无向图是一个连通图,从 V1 出发可以找到所有的顶点,因此广度优先搜索算法继 V1 顶点之后无法再找到新的尚未访问的顶点,算法执行结束。对于连通图来说,广度优先搜索算法从一个顶点出发就能访问图中所有的顶点。但是对于非连通图来说,广度优先搜索算法必须从各个连通分量中选择一个顶点出发,才能访问到所有的顶点。所谓广度优先搜索,就是从图中的某个顶点出发,寻找紧邻的、尚未访问的顶点,找到多少就访问多少,然后分别从找到的这些顶点出发,继续寻找紧邻的、尚未访问的顶点。当从某个顶点出发,所有和它连通的顶点都访问完之后,广度优先搜索算法会重新选择一个尚未访问的顶点(非连通图中就存在这样的顶点),继续以同样的思路寻找未访问的其它顶点。直到图中所有顶点都被访问,广度优先搜索算法才会结束执行。图的存储结构有很多种,大体上可以分为顺序存储和链式存储(又细分为邻接表)结构、十字链表结构和邻接多重表结构),各个存储结构有自己的特点。选用不同的存储结构,广度优先搜索算法的具体实现不同,但算法的思想是不变的。这里以图的顺序存储结构为例,广度优先搜索算法的 C 语言实现代码如下:#include <stdio.h> #include <stdlib.h> #define MAX_VERtEX_NUM 20 //顶点的最大数量 #define VRType int //表示顶点之间关系的数据类型 #define VertexType int //顶点的数据类型 typedef enum { false, true }bool; //定义bool型常量 bool visited[MAX_VERtEX_NUM]; //设置全局数组,记录每个顶点是否被访问过 //队列链表中的结点类型 typedef struct Queue { VertexType data; struct Queue* next; }Queue; typedef struct { VRType adj; //用 0 表示不相邻,用 1 表示相邻 }ArcCell, AdjMatrix[MAX_VERtEX_NUM][MAX_VERtEX_NUM]; typedef struct { VertexType vexs[MAX_VERtEX_NUM]; //存储图中的顶点 AdjMatrix arcs; //二维数组,记录顶点之间的关系 int vexnum, arcnum; //记录图的顶点数和弧(边)数 }MGraph; //判断 v 顶点在二维数组中的位置 int LocateVex(MGraph* G, VertexType v) { int i; //遍历一维数组,找到变量v for (i = 0; i < G->vexnum; i++) { if (G->vexs[i] == v) { break; } } //如果找不到,输出提示语句,返回-1 if (i > G->vexnum) { printf("no this vertex\n"); return -1; } return i; } //构造无向图 void CreateDN(MGraph* G) { int i, j, n, m; int v1, v2; scanf("%d,%d", &(G->vexnum), &(G->arcnum)); for (i = 0; i < G->vexnum; i++) { scanf("%d", &(G->vexs[i])); } for (i = 0; i < G->vexnum; i++) { for (j = 0; j < G->vexnum; j++) { G->arcs[i][j].adj = 0; } } for (i = 0; i < G->arcnum; i++) { scanf("%d,%d", &v1, &v2); n = LocateVex(G, v1); m = LocateVex(G, v2); if (m == -1 || n == -1) { printf("no this vertex\n"); return; } G->arcs[n][m].adj = 1; G->arcs[m][n].adj = 1; } } int FirstAdjVex(MGraph G, int v) { int i; //对于数组下标 v 处的顶点,找到第一个和它相邻的顶点,并返回该顶点的数组下标 for (i = 0; i < G.vexnum; i++) { if (G.arcs[v][i].adj) { return i; } } return -1; } int NextAdjVex(MGraph G, int v, int w) { int i; //对于数组下标 v 处的顶点,从 w 位置开始继续查找和它相邻的顶点,并返回该顶点的数组下标 for (i = w + 1; i < G.vexnum; i++) { if (G.arcs[v][i].adj) { return i; } } return -1; } //初始化队列,这是一个有头结点的队列链表 void InitQueue(Queue** Q) { (*Q) = (Queue*)malloc(sizeof(Queue)); (*Q)->next = NULL; } //顶点元素v进队列 void EnQueue(Queue** Q, VertexType v) { Queue* temp = (*Q); //创建一个存储 v 的结点 Queue* element = (Queue*)malloc(sizeof(Queue)); element->data = v; element->next = NULL; //将 v 添加到队列链表的尾部 while (temp->next != NULL) { temp = temp->next; } temp->next = element; } //队头元素出队列 void DeQueue(Queue** Q, int* u) { Queue* del = (*Q)->next; (*u) = (*Q)->next->data; (*Q)->next = (*Q)->next->next; free(del); } //判断队列是否为空 bool QueueEmpty(Queue* Q) { if (Q->next == NULL) { return true; } return false; } //释放队列占用的堆空间 void DelQueue(Queue* Q) { Queue* del = NULL; while (Q->next) { del = Q->next; Q->next = Q->next->next; free(del); } free(Q); } //广度优先搜索 void BFSTraverse(MGraph G) { int v, u, w; Queue* Q = NULL; InitQueue(&Q); //将用做标记的visit数组初始化为false for (v = 0; v < G.vexnum; ++v) { visited[v] = false; } //遍历图中的各个顶点 for (v = 0; v < G.vexnum; v++) { //若当前顶点尚未访问,从此顶点出发,找到并访问和它连通的所有顶点 if (!visited[v]) { //访问顶点,并更新它的访问状态 printf("%d ", G.vexs[v]); visited[v] = true; //将顶点入队 EnQueue(&Q, G.vexs[v]); //遍历队列中的所有顶点 while (!QueueEmpty(Q)) { //从队列中的一个顶点出发 DeQueue(&Q, &u); //找到顶点对应的数组下标 u = LocateVex(&G, u); //遍历紧邻 u 的所有顶点 for (w = FirstAdjVex(G, u); w >= 0; w = NextAdjVex(G, u, w)) { //将紧邻 u 且尚未访问的顶点,访问后入队 if (!visited[w]) { printf("%d ", G.vexs[w]); visited[w] = true; EnQueue(&Q, G.vexs[w]); } } } } } DelQueue(Q); } int main() { MGraph G; //构建图 CreateDN(&G); //对图进行广度优先搜索 BFSTraverse(G); return 0; } 程序中,广度优先搜索算法的实现借助了队列存储结构,用来存储访问过的顶点。每次出队一个顶点,从该顶点出发寻找和它紧邻、尚未访问的顶点,然后将找到的顶点全部入队。3. 最小生成树问题3.1. 什么是最小生成树这两组算法本身又有一定的区别:“普里姆算法” ,主要解决最小生成树问题。“克鲁斯卡尔算法”,主要解决加权树的最小生成树问题。接下来我们就来具体看一下。一个连通图对应的生成树往往有很多种,例如:如果为连通图中的每条边赋予一个权值(可以理解为一个整数),这样的连通图又称为连通网。各个生成树包含的边不相同,因此它们的总权值(所有边的权值之和)也不相等。所谓最小生成树,指的就是总权值最小的生成树。注意,一个连通网对应的最小生成树可能有多个,图 2 就是一个很好的范例,图 2a) 连通网对应的最小生成树有两个。最小生成树可以用来解决一些实际问题。仍以图 2a) 为例,假设 A、B、C、D 这 4 个顶点各自代表一座城市,各个边表示两座城市之间可以铺设网线,边的权值表示铺设网线需要耗费的经费。如果想为这 4 座城市建立通信联络网,最节省经费的方案就是按照总权值为 7 的生成树铺设网线。对于给定的连通网,求最小生成树常用的算法有两个,分别叫做普里姆Prim算法和克鲁斯卡尔Kruskal算法。3.2. 普利姆算法先看一个应用场景的问题-修路问题:1) 有胜利乡有7个村庄 (A,B,C,D,E,F,G) ,现在需要修路把7个村庄连通2) 各个村庄的距离用边线表示 ( 权 ) ,比如 A – B 距离 5 公里3) 问:如何修路保证各个村庄都能连通,并且总的修建公路总里程最短**?思路 : 将10条边,连接即可,但是总的里程数不是最小,那如何尽可能的选择少的路线,并且每条路线最小,保证总里程数最少呢?最小生成树修路问题本质就是就是最小生成树问题, 先介绍一下最小生成树 (Minimum Cost Spanning Tree) ,简称 MST。 给定一个带权的无向连通图,如何选取一棵生成树,使树上所有边上权的总和为最小 , 这叫最小生成树。我们可以知道,如果最小,那么生成树一定满足:N 个顶点,最少 N-1 条边就可以将所有顶点连接起来。求最小生成树的算法主要是普里姆算法和克鲁斯卡尔算法。普利姆(Prim)算法求最小生成树,也就是在包含 n 个顶点的连通图中,找出只有(n-1)条边包含所有 n 个顶点的 连通子图,也就是所谓的极小连通子图。普利姆的算法如下:设G=(V,E)是连通网,T=(U,D)是最小生成树,V,U是顶点集合,E,D是边的集合。若从顶点u开始构造最小生成树,则从集合V中取出顶点u放入集合U中,标记顶点v的visited[u]=1。若集合U中顶点Ui与集合V-U中的顶点Vj之间存在边,则寻找这些边中权值最小的边,但不能构成回路,将顶点 vj 加入集合 U 中,将边(ui,vj)加入集合 D 中,标记 visited[vj]=1。重复步骤2,直到U与V相等,即所有顶点都被标记为访问过,此时D中有n-1条边。提示: 单独看步骤很难理解,我们通过代码来讲解,比较好理解。图解普利姆算法从A顶点开始,处理,选择距离最短的,显然到G最短为2,如图1。 从{A,G}里选连接其他元素最短的哪个,并且不能出现环,此时A还有5和7,而G有3、4和6连接,显然3连B最小,如图2。 从{A,B,G}继续找连接外部元素最短的那个,很明显是4连E,如图3。 从{A,B,G,E}继续找连接外部元素最短的那个,此时就是F了,如图4。 从{A,B,G,E,F}继续找连接外部元素最短的那个,此时就是D了,如图5。 最后是A伸出的7连接C,结束。3.3. 克鲁斯卡尔算法克鲁斯卡尔方法也是为了解决最小连通,克鲁斯卡尔(Kruskal)算法,是用来求加权连通图的最小生成树的算法。基本思想:按照权值从小到大的顺序选择n-1条边,并保证这n-1条边不构成回路。具体做法:首先构造一个只含n个顶点的森林,然后依权值从小到大从连通网中选择边加入到森林中,并使森林中不产生回路,直至森林变成一棵树为止。克鲁斯卡尔算法图解说明,以城市公交站问题来图解说明 克鲁斯卡尔算法的原理和步骤:在含有 n 个顶点的连通图中选择 n-1 条边,构成一棵极小连通子图,并使该连通子图中 n-1 条边上权值之和达到 最小,则称其为连通网的最小生成树。克鲁斯卡尔算法图解,以上图 G4 为例,来对克鲁斯卡尔进行演示(假设,用数组 R 保存最小生成树结果)。第 1 步:将边<E,F>加入 R 中。 边<E,F>的权值最小,因此将它加入到最小生成树结果 R 中。 第 2 步:将边<C,D>加入 R 中。 上一步操作之后,边<C,D>的权值最小,因此将它加入到最小生成树结果 R 中。 第 3 步:将边<D,E>加入 R 中。 上一步操作之后,边<D,E>的权值最小,因此将它加入到最小生成树结果 R 中。 第 4 步:将边<B,F>加入 R 中。 上一步操作之后,边<C,E>的权值最小,但<C,E>会和已有的边构成回路;因此,跳过边<C,E>。同理,跳过边<C,F>。将边<B,F>加入到最小生成树结果 R 中。 第 5 步:将边<E,G>加入 R 中。上一步操作之后,边<E,G>的权值最小,因此将它加入到最小生成树结果 R 中。 第 6 步:将边<A,B>加入 R 中。上一步操作之后,边<F,G>的权值最小,但<F,G>会和已有的边构成回路;因此,跳过边<F,G>。同理,跳过边<B,C>。将边<A,B>加入到最小生成树结果R中。 此时,最小生成树构造完成!它包括的边依次是:<E,F> <C,D> <D,E> <B,F> <E,G> <A,B>。 这个过程过与繁琐,我们就不再提供代码了4. 最短路径问题4.1. 什么是最短路径问题在图结构中,一个顶点到另一个顶点的路径可能有多条,最短路径指的就是顶点之间“最短”的路径。在不同的场景中,路径“最短”的含义也有所差异,比如途径顶点数量最少、总权值最小等。提到最短路径,往往指的是总权值最小的路径,所以常常在网结构(带权的图)中讨论最短路径问题,包括有向网和无向网。举个简单的例子:图 1 有向网图 1 是一张有向网,其中从 V0 到 V5 的路径有多条,包括:V0 -> V5,总权值为 100V0 -> V4 -> V5,总权值为 30+60 = 90V0 -> V4 -> V3 -> V5,总权值为 30+20+10 = 60V0 -> V2 -> V3 -> V5,总权值为 10+50+10 = 70通过比较这些路径的总权值,最终可以找到一条从 V0 到 V5 的最短路径。现如今,大家出行再也不用担心找不到路了,车上有车载导航,手机上也可以安装各种导航 App,只要输入目的地,导航会自动帮我们规划一条距离最短的路线,这是最短路径在实际生活中的典型应用之一。在指定的一张网中查找最短路径,该如何编码实现呢?解决最短路径问题,最常用的方案有两种,分别叫做迪杰斯特拉算法和弗洛伊德算法:迪杰斯特拉算法:查找某个顶点到其它顶点之间的最短路径;弗洛伊德算法:查找任意两个顶点之间的最短路径。“迪杰斯特拉算法”,图中两个点的最短路径问题“弗洛伊德算法”,图中每个顶点到其他所有顶点的最短路径。4.2. 迪杰特斯拉算法迪杰斯特拉(Dijkstra)算法是典型最短路径算法,用于计算一个结点到其他结点的最短路径。它的主要特点是以起始点为中心向外层层扩展(广度优先搜索思想),直到扩展到终点为止。如下图,各个点的距离用边线表示(权) ,比如 0 – 1 距离 5 公里,现在想求一下从0开始到后面1~6个位置的最短路径分别是多少。这个思想我们不用复杂的语言来解释,只看过程图:有个博客解释的非常清楚,也比较易懂,原地址blog.csdn.net/xiaoxi_haha…。首先,可以设置两个集合分别是A和B,A用来存放已经求出最短路径的点,B用来存放还未计算出最短路径的点。假设我们将源点source选择在” 0 "这个点。一开始所有点到达源点0的距离我们假设为∞,代表不可达。源点0到自己本身的距离为0,初始化如下:此时A集合为:{0},B集合为:{1,2,3,4,5,6},如下图·。第一步:从0点开始,更新和0邻接的所有点的距离,此时,因为与0邻接的有1和2,并且到这两个点的距离,小于原来的∞距离,所以要将这两个点到0的距离都进行更新如下图2。第二步:从B集合里面选择一个点加入A集合,这个点要满足距离0点的距离最短,因此我们选择2这个点添加到集合A,此时集合A变为:{0,2},集合B变为:{1,3,4,5,6},如下图3。第三步:选择刚刚加入的这个2点,更新所有与2点邻接的点,因为与2邻接的点有3和5,并且这两个点到0点的距离小于原来它们到0点的距离∞,如下图4。第四步:从B集合里面选择1这点加入到集合A中,因为1这个点在B集合中距离0最近,如下图,此时A集合变成:{0,1,2},B集合变成:{3,4,5,6}。第五步:选择刚刚加入的1这个点,更新1所有的邻接点,它的邻接点有3和4,因为此刻从0到3的距离为6,小于原来0到3的距离8,因此这个时候到6的距离更新为6(5+1),此时0到4的距离被更新为11。第六步:从B集合中选择一个距离0点最小距离的点,加入集合A,此时可以选择3这个点,因为3这个点在B集合里面距离0点最近,此时集合A变为:{0,1,2,3},集合B变为:{4,5,6}。第七步:从刚刚选择的这个3点出发,更新3所有的邻接点,3的邻接点有4和5,原来4到0的距离为11,3加进来之后,4到0的距离为7,小于原来的11,所以要更新,原来5到0的距离为10,3加进来之后,5到0的距离为8,所以也要更新。第八步:从B集合中选择一个距离0点最小距离的点,因此我们选择4点,因为此时4这个点距离0点最近,为7,于是集合A变成:{0,1,2,3,4},集合B变成:{5,6}。第九步:从刚刚选择的点4出发,更新它的所有邻接点,4的邻接点有6,原来6到0的距离为∞,此时4加进来之后6到0的距离变为14,它小于∞,因此要更新6到0的距离,更新为11。第十步:从集合B中选择一个距离0点最短距离的点,加入集合,此时我们可以选择5这个点,因为这个时候它在B集合中是距离0点最近的点,于是集合A变为:{0,1,2,3,4,5},集合B变为{6}。第11步:从刚刚选择的这个点出发,也就是从点5出发,更新它所有的邻接点,此时5的邻接点为6,原来0到6的距离为14,此时点5加进来之后,0到6的距离变为了11,因此需要更新0到6的距离。第12步:因为B集合只剩下一个点,为点6,直接将其加入A集合即可,此时A集合变为:{0,1,2,3,4,5,6},B集合变为:{ },至此,从0点到其它所有点的最短路径已经算出来了,为下图。至此,我们就得到了从0开始到所有结点的最短路径。可以看到上述过程与动态规划是非常类似的,都是从一个点开始,借助一个数组来缓存已经处理的结果,同时每迭代一次就刷新一次数组里的结果,直到最后完成。当然迪杰斯塔拉算法也是有局限性的:如果某个边的权重为负数是处理不了的,同时只能处理一个点到其他各个点的最短路径。4.3. 弗洛伊德算法和Dijkstra算法一样,弗洛伊德(Floyd)算法也是一种用于寻找给定的加权图中顶点间最短路径的算法。该算法 名称以创始人之一、1978 年图灵奖获得者、斯坦福大学计算机科学系教授罗伯特·弗洛伊德命名。弗洛伊德算法(Floyd)计算图中各个顶点之间的最短路径,而迪杰斯特拉算法用于计算图中某一个顶点到其他顶点的最短路径。迪杰斯特拉算法通过选定的被访问顶点,求出从出发访问顶点到其他顶点的最短路径;弗洛伊德算法中每一个顶点都是出发访问点,所以需要将每一个顶点看做被访问顶点,求出从每 一个顶点到其他顶点的最短路径。弗洛伊德算法也是一个动态规划的过程,不过我们不要被其吓住,先看怎么做,最后再来从动态规划的角度解释一下。弗洛伊德算法选取某个节点k作为i到j需要经过的中间节点,通过比较d(i,k)+d(k,j)和现有d(i,j)的大小,将较小值更新为路径长度,对k节点的选取进行遍历,以得到在经过所有节点时i到j的最短路径长度,通过不断加入中间点的方式更新最短路径。同时在path数组中存储i到j所经过的中间节点k,用于最后递归调用输出路径结果。我们先构造一个图,有向或者无向都可以,如果是有向图就是:1、初始化权值矩阵初始化表表示的就是从某个结点到下一个节点的最短距离,例如图中红色框表示的就是当前从1到2的距离为无穷大。MAX表示不可达,自己到自己也标记为不可达。2、选择A作为中间结点。所谓的中间结点,就是说如果某两个结点经过该中转一下的路径。例如下图中A和F本来是不通的,但是我们可以经过B中转一下就能通了。除了能让本来不通的通了,有时候通过某两个点之间的距离变短。比如A到E的距离,如果经过”ABFE“的路径距离为11,而如果我们经过D中转一下就是7,因此距离更短。3、选取B号节点作为中间点,更新矩阵,通过两层循环计算(i->B),(B->j)的路径是否比目前i到j的路径长度更短。此时可以将矩阵数值看作是将A、B作为中间点获得的多源最短路径长度。4.选取2号节点作为中间点,更新矩阵,通过两层循环计算(i->C),(C->j)的路径是否比目前i到j的路径长度更短。此时可以将矩阵数值看作是将A、B、C作为中间点获得的多源最短路径长度。可惜C不能再指向其他顶点,二维表没有变化。5 .继续选取D号节点作为中间点,更新矩阵,通过两层循环计算(i->D),(1->D)的路径是否比目前i到j的路径长度更短。此时可以将矩阵数值看作是将A、B、C、D为中间点获得的多源最短路径长度。6、选取E号节点作为中间点,更新矩阵,通过两层循环计算(i->E),(E->j)的路径是否比目前i到j的路径长度更短。此时可以将矩阵数值看作是将A、B、C、D、E作为中间点获得的多源最短路径长度。7、选取F号节点作为中间点,更新矩阵,通过两层循环计算(i->F),(F->j)的路径是否比目前i到j的路径长度更短。此时可以将矩阵数值看作是将A到F作为中间点获得的多源最短路径长度。8、选取G号节点作为中间点,更新矩阵,通过两层循环计算(i->G),(G->j)的路径是否比目前i到j的路径长度更短。此时可以将矩阵数值看作是将所有节点作为中间点获得的多源最短路径长度,遍历结束,得到最后结果。这个表就是我们需要的最终结果。这个是我们通过手算来进行的,如果要使用计算机来实现还要经过比较复杂的遍历才可以。上面这个过程就是动态规划的过程,其状态转移方程如下:其中matrix[i,j]表示i到j的最短距离,k是穷举i到j之间可能经过的中间点,当中间点为k时,对整个矩阵即从i到j的路径长度进行更新,对所有可能经过的中间点进行遍历以得到全局最优的最短路径。算法的单个执行将找到所有顶点对之间的最短路径长度,与迪杰斯特阿拉算法的计算目标有一些差异,迪杰斯特拉计算的是单源最短路径,而弗洛伊德计算的是多源最短路径,其时间复杂度为O(n³)。虽然它不返回路径本身的细节,但是可以通过对算法的简单修改来重建路径,我们利用这个思想,通过递归的方式访问每条路径经过的中间节点,对最终的路径进行输出。
0
0
0
浏览量785
时光小少年

LeetCode 刷题笔记: 273.整数的英语表示

1. 原题链接273.整数转换英文表示2. 题意将非负整数 num 转换为其对应的英文表示。3. 示例示例 1: 输入:num = 123 输出:"One Hundred Twenty Three" 示例 2: 输入:num = 12345 输出:"Twelve Thousand Three Hundred Forty Five" 示例 3: 输入:num = 1234567 输出:"One Million Two Hundred Thirty Four Thousand Five Hundred Sixty Seven" 4. 分析4.1. 方法一:迭代法求解使用迭代的方式得到每一组的英文表示。由于每一组最多有 333 位数,因此依次得到百位、十位、个位上的数字,生成该组的英文表示,注意只有非零位才会被添加到英文表示中。4.1.1. 迭代法代码class Solution { //个位数定义 0 - 9 String[] singles = {"Zero", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"}; // 二位数定义 10 - 19 String[] teens = {"Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"}; // 十位数定义 10、20、30 ······ 90 String[] tens = {"", "Ten", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"}; // 百位数定义 String[] thousands = {"", "Thousand", "Million", "Billion"}; public String numberToWords(int num) { if(num == 0){ return singles[0]; } StringBuffer sb = new StringBuffer(); for (int i = 3, unit = 1000000000; i >= 0; i--, unit /= 1000) { int curNum = num / unit; if (curNum != 0) { num -= curNum * unit; sb.append(toEnglish(curNum)).append(thousands[i]).append(" "); } } return sb.toString().trim(); } public String toEnglish(int num) { StringBuffer curr = new StringBuffer(); int hundred = num / 100; num %= 100; if (hundred != 0) { curr.append(singles[hundred]).append(" Hundred "); } int ten = num / 10; if (ten >= 2) { curr.append(tens[ten]).append(" "); num %= 10; } if (num > 0 && num < 10) { curr.append(singles[num]).append(" "); } else if (num >= 10) { curr.append(teens[num - 10]).append(" "); } return curr.toString(); } } 4.1.2. 复杂度分析时间复杂度:O(1)空间复杂度:O(1)4.2. 方法二 : 递归分析由于非负整数 num 的最大值为 的最大值为 ,因此最多有 10 位数。将整数转换成英文表示中,将数字按照 3 位一组划分,将每一组的英文表示拼接之后即可得到整数 num 的英文表示。每一组最多有 3 位数,可以使用递归的方式得到每一组的英文表示。根据数字所在的范围,具体做法如下:小于 20 的数可以直接得到其英文表示;大于等于 20 且小于 100 的数首先将十位转换成英文表示,然后对个位递归地转换成英文表示;大于等于100 的数首先将百位转换成英文表示,,然后对其余部分(十位和个位)递归地转换成英文表示。从高到低的每一组的单位依次是 ,每一组都有对应的表示单位的词,分别是 得到每一组的英文表示后,需要对每一组加上对应的表示单位的词,然后拼接得到整数 num 的英文表示。具体实现中需要注意以下两点:只有非零的组的英文表示才会拼接到整数 num 的英文表示中;如果 num = 0,则不适用上述做法,而是直接返回 “Zero"。4.2.1. 递归法代码class Solution { String[] singles = {"", "One", "Two", "Three", "Four", "Five", "Six", "Seven", "Eight", "Nine"}; String[] teens = {"Ten", "Eleven", "Twelve", "Thirteen", "Fourteen", "Fifteen", "Sixteen", "Seventeen", "Eighteen", "Nineteen"}; String[] tens = {"", "Ten", "Twenty", "Thirty", "Forty", "Fifty", "Sixty", "Seventy", "Eighty", "Ninety"}; String[] thousands = {"", "Thousand", "Million", "Billion"}; public String numberToWords(int num) { if (num == 0) { return "Zero"; } StringBuffer sb = new StringBuffer(); for (int i = 3, unit = 1000000000; i >= 0; i--, unit /= 1000) { int curNum = num / unit; if (curNum != 0) { num -= curNum * unit; StringBuffer curr = new StringBuffer(); recursion(curr, curNum); curr.append(thousands[i]).append(" "); sb.append(curr); } } return sb.toString().trim(); } public void recursion(StringBuffer curr, int num) { if (num == 0) { return; } else if (num < 10) { curr.append(singles[num]).append(" "); } else if (num < 20) { curr.append(teens[num - 10]).append(" "); } else if (num < 100) { curr.append(tens[num / 10]).append(" "); recursion(curr, num % 10); } else { curr.append(singles[num / 100]).append(" Hundred "); recursion(curr, num % 100); } } } 4.2.2. 复杂度分析时间复杂度:O(1)。非负整数 nums 按照 3 位一组划分最多有 4 组,分别得到每一组的英文表示,然后拼接得到整数 num 的英文表示,时间复杂度是常数。空间复杂度:O(1)。空间复杂度主要取决于存储英文表示的字符串和递归调用栈,英文表示的长度可以看成常数,递归调用栈不会超过 3 层。
0
0
0
浏览量741
时光小少年

第 15 关 | 超大规模数据场景常见问题 2.白银挑战——海量数据场景下的热门算法题

1. 从 40 亿中产生一个不存在的整数题目要求:给定一个输入文件,包含40亿个非负整数,请设计一个算法,产生一个不存在该文件中的整数,假设你有1GB的内存来完成这项任务。进阶:如果只有10MB的内存可用,该怎么办?本题不用写代码,如果能将方法说清楚就很好了 ,我们接下来一步步分析该如何做。1.1. 位图存储大数据的原理假设用哈希表来保存出现过的数,如果 40 亿个数都不同,则哈希表的记录数为 40 亿条,存一个 32 位整数需要 4B,所以最差情况下需要 40 亿*4B=160 亿字节,大约需要16GB 的空间,这是不符合要求的。40 亿 * 4B=160 亿字节,大约需要16GB40 亿 / 8 字节=5亿字节,大约0.5GB的数组就可以存下 40 亿个。如果数据量很大,采用位方式(俗称位图)存储数据是常用的思路,那位图如何存储元素的呢? 我们可以使用 bit map 的方式来表示数出现的情况。具体地说, 是申请一个长度为 4 294 967 295 的 bit 类型的数组 BitArr (就是 boolean 类型),bitArr 的每个位置只可以表示 0 或 1 状态。8 个 bit 为 1B,所以长度为 4 294 967 295 的 bit 数组只占用了 500 MB 空间,这就没主见题目所给的要求了。那怎么使用这个 bitArr 数组呢?就是遍历这 40 亿个无符号数,遇到所有的数时,就把 bitArr 相应位置的值设置为 1。例如,遇到 1000,就把bitArr[1000]设置为 1。遍历完成后,再依次遍历 bitArr,看看哪个位置上的值没被设置为 1,这个数就不在 40 亿个数中。例如,发现 bitArr[8001]==0,那么 8001 就是没出现过的数,遍历完 bitArr 之后,所有没出现的数就都找出来了。位存储的核心是:我们存储的并不是这40亿个数据本身,而是其对应的位置。这一点明白的话,整个问题就迎刃而解了。1.2. 使用 10 MB 来存储数据如果现在只有 10MB 的内存,此时位图也不能搞定了,我们要另寻他法。这里我们使用分块思想,时间换空间,通过两次遍历来搞定。40亿个数 需要500MB的空间,那如果只有10MB的空间,至少需要 50个块才可以。一般来说,我们划分都是使用2的整数倍,因此划分成64个块是合理的。首先,将0~4 294 967 295(2^32) 这个范围是可以平均分成 64 个区间的,每个区间是 67 108 864 个数,例如:第0 区间(0~67 108 863)第 1 区间(67 108 864~134 217 728)第 i 区间(67 108 864´I~67 108 864´(i+1)-1),……,第 63 区间(4 227 858 432~4 294 967 295)。因为一共只有 40 亿个数,所以,如果统计落在每一个区间上的数有多少,肯定有至少一个区间上的计数少于67 108 864。利用这一点可以找出其中一个没出现过的数。具体过程是通过两次遍历来搞定:第一次遍历,先申请长度为 64 的整型数组 countArr[0..63],countArr[i]用来统计区间 i 上的数有多少。遍历 40 亿个数,根据当前数是多少来决定哪一个区间上的计数增加。例如,如果当前数是 3 422 552 090 , 3 422 552 090/67 108 864=51 , 所以第 51 区间上的计数增加countArr[51]++。遍历完 40 亿个数之后,遍历 countArr,必然会有某一个位置上的值(countArr[i]) 小于 67 108 864,表示第 i 区间上至少有一个数没出现过。我们肯定会找到至少一个这样的区间。此时使用的内存就是countArr 的大小(64*4B),是非常小的。假设找到第 37 区间上的计数小于 67 108 864,那么我们对这40亿个数据进行第二次遍历:申请长度为 67 108 864 的 bit map,这占用大约 8MB 的空间,记为 bitArr[0..67108863]。遍历这 40 亿个数,此时的遍历只关注落在第 37 区间上的数,记为 num(num满足num/67 108 864==37),其他区间的数全部忽略。如果步骤 2 的 num 在第 37 区间上,将 bitArr[num - 67108864*37]的值设置为 1,也就是只做第 37 区间上的数的 bitArr 映射。遍历完 40 亿个数之后,在 bitArr 上必然存在没被设置成 1 的位置,假设第 i 个位置上的值没设置成 1,那么 67 108 864´37+i 这个数就是一个没出现过的数。总结一下进阶的解法:根据 10MB 的内存限制,确定统计区间的大小,就是第二次遍历时的 bitArr 大小。利用区间计数的方式,找到那个计数不足的区间,这个区间上肯定有没出现的数。对这个区间上的数做 bit map 映射,再遍历bit map,找到一个没出现的数即可。总结一下进阶的解法:根据 10MB 的内存限制,确定统计区间的大小,就是第二次遍历时的 bitArr 大小。利用区间计数的方式,找到那个计数不足的区间,这个区间上肯定有没出现的数。对这个区间上的数做 bit map 映射,再遍历bit map,找到一个没出现的数即可。1.3. 如何确定分块的我区间在上面的例子中,我们看到采用两次遍历,第一次将数据分成64块刚好解决问题。那我们为什么不是128块、32块、16块或者其他类型呢?这里主要是要保证第二次遍历时每个块都能放进这10MB的空间中。2^23<10MB<2^24,而2^23=8388608大约为8MB,也就说我们一次的分块大小只能为8MB左右。在上面我们也看到了,第二次遍历时如果分为64块,刚好满足要求。所以在这里我们最少要分成64块,当然如果分成128块、256块等也是可以的。2. 使用 2 GB 内存在 20 亿个整数中找出出现次数最多的数题目要求:有一个包含 20 亿个全是 32 位整数的大文件,在其中找到出现次数最多的数。要求,内存限制为 2GB。想要在很多整数中找到出现次数最多的数,通常的做法是使用哈希表对出现的每一个数做词频统计,哈希表的 key 是某一个整数,value 是这个数出现的次数。就本题来说,一共有 20 亿个数,哪怕只是一个数出现了 20 亿次,用 32 位的整数也可以表示其出现的次数而不会产生溢出,所以哈希表的 key 需要占用 4B,value 也是 4B。那么哈希表的一条记录(key,value)需要占用 8B,当哈希表记录数为 2 亿个时,需要至少 1.6GB 的内存。如果 20 亿个数中不同的数超过 2 亿种,最极端的情况是 20 亿个数都不同,那么在哈希表中可能需要产生 20 亿条记录,这样内存会不够用,所以一次性用哈希表统计 20 亿个数的办法是有很大风险的。解决办法是把包含 20 亿个数的大文件用哈希函数分成 16 个小文件,根据哈希函数的性质,同一种数不可能被散列到不同的小文件上,同时每个小文件中不同的数一定不会大于 2 亿种, 假设哈希函数足够优秀。然后对每一个小文件用哈希表来统计其中每种数出现的次数,这样我们就得到了 16 个小文件中各自出现次数最多的数,还有各自的次数统计。接下来只要选出这16 个小文件各自的第一名中谁出现的次数最多即可。把一个大的集合通过哈希函数分配到多台机器中,或者分配到多个文件里,这种技巧是处理大数据面试题时最常用的技巧之一。但是到底分配到多少台机器、分配到多少个文件,在解题时一定要确定下来。可能是在与面试官沟通的过程中由面试官指定,也可能是根据具体的限制来确定,比如本题确定分成 16 个文件,就是根据内存限制 2GB 的条件来确定的。3. 从 100 亿个 URL 中查找的问题题目:有一个包含 100 亿个 URL 的大文件,假设每个 URL 占用 64B,请找出其中所有重复的 URL。补充问题:某搜索公司一天的用户搜索词汇是海量的(百亿数据量),请设计一种求出每天热门 Top 100 词汇的可行办法。解答:原问题的解法使用解决大数据问题的一种常规方法:把大文件通过哈希函数分配到机器, 或者通过哈希函数把大文件拆成小文件,一直进行这种划分,直到划分的结果满足资源限制的要求。首先,你要向面试官询问在资源上的限制有哪些,包括内存、计算时间等要求。在明确了限制要求之后,可以将每条 URL 通过哈希函数分配到若干台机器或者拆分成若干个小文件, 这里的“若干”由具体的资源限制来计算出精确的数量。例如,将 100 亿字节的大文件通过哈希函数分配到 100 台机器上,然后每一台机器分别统计分给自己的 URL 中是否有重复的 URL,同时哈希函数的性质决定了同一条 URL 不可能分给不同的机器;或者在单机上将大文件通过哈希函数拆成 1000 个小文件,对每一个小文件再利用哈希表遍历,找出重复的 URL;还可以在分给机器或拆完文件之后进行排序,排序过后再看是否有重复的 URL 出现。总之,牢记一点,很多大数据问题都离不开分流,要么是用哈希函数把大文件的内容分配给不同的机器,要么是用哈希函数把大文件拆成小文件,然后处理每一个小数量的集合。补充问题最开始还是用哈希分流的思路来处理,把包含百亿数据量的词汇文件分流到不同的机器上,具体多少台机器由面试官规定或者由更多的限制来决定。对每一台机器来说,如果分到的数据量依然很大,比如,内存不够或存在其他问题,可以再用哈希函数把每台机器的分流文件拆成更小的文件处理。处理每一个小文件的时候,通过哈希表统计每种词及其词频,哈希表记录建立完成后,再遍历哈希表,遍历哈希表的过程中使用大小为 100 的小根堆来选出每一个小文件的 Top 100(整体未排序的 Top 100)。每一个小文件都有自己词频的小根堆(整体未排序的 Top 100),将小根堆里的词按照词频排序,就得到了每个小文件的排序后 Top 100。然后把各个小文件排序后的 Top 100 进行外排序或者继续利用小根堆,就可以选出每台机器上的 Top100。不同机器之间的 Top 100 再进行外排序或者继续利用小根堆,最终求出整个百亿数据量中的 Top 100。对于 Top K 的问题,除用哈希函数分流和用哈希表做词频统计之外,还经常用堆结构和外排序的手段进行处理。4. 40 亿个非负整数中找到出现两次的数题目要求:32 位无符号整数的范围是 0~4 294 967 295,现在有 40 亿个无符号整数,可以使用最多 1GB的内存,找出所有出现了两次的数。本题可以看做第一题的进阶问题,这里将出现次数限制在了两次。首先,可以用 bit map 的方式来表示数出现的情况。具体地说,是申请一个长度为4 294 967 295x2 的bit 类型的数组bitArr,用 2 个位置表示一个数出现的词频,1B 占用 8 个bit, 所以长度为 4 294 967 295x2 的 bit 类型的数组占用 1GB 空间。怎么使用这个 bitArr 数组呢?遍历这 40 亿个无符号数,如果初次遇到 num,就把bitArr[num2 + 1]和 bitArr[num2]设置为 01, 如果第二次遇到 num,就把bitArr[num2+1]和bitArr[num2]设置为 10,如果第三次遇到 num, 就把bitArr[num2+1]和bitArr[num2]设置为 11。以后再遇到 num,发现此时 bitArr[num2+1]和 bitArr[num2]已经被设置为 11,就不再做任何设置。遍历完成后,再依次遍历 bitArr,如果发现bitArr[i2+1]和bitArr[i2]设置为 10,那么 i 就是出现了两次的数。
0
0
0
浏览量691
时光小少年

第 17 关 | 经典刷题思想之贪心 : 1.青铜挑战——贪心其实很简单

1. 难以解释的贪心算法贪心的思想非常不好解释,而且越使用权威的语言解释越难懂。而且做题的时候根据自己的理解直接做可能能做出来,而非要解释一下怎么使用的贪心的话反而懵圈了。更郁闷的是贪心的题目没有固定的套路,一题一样,好在大部分的贪心算法题不是特别难,因此公认的贪心学习法则是”直接做题,不考虑贪不贪心“,本章我们就从一些经典题目中寻找一些“哲学规律”。贪婪算法(贪心算法)是指在对问题进行求解时,在每一步选择中都采取最好或者最优(即最有利)的选择,从而希望能够导致结果是最好或者最优的算法;贪婪算法所得到的结果不一定是最优的结果(有时候会是最优解),但是都是相对近似(接近)最优解的结果。那贪心是否一定能得到最优解呢?《算法导论》给出了最明确的答案——贪心算法不能保证一定能得到最优解,但是对很多问题确实可以得到最优解 。既然不能保证 ,我怎么知道某个解法是不是最优解呢?很遗憾,笔者查阅大量材料,也没有谁给出定论,大部分的解释其实就是——看上去是就是了。那我怎么知道什么时候该用贪心呢?这要求要解决的问题具有”最优子结构“,那什么是”最优子结构“呢?这个问题好比用高等数学证明”1+1=2“,解释不如不解释。既然贪心这么邪门,那该怎么学呢?笔者的观点是将常见的贪心题都找出来看看大致是什么样子的,面试经常遇到的贪心题目也是有限的,我们找出来学一学就行了。贪心常见的经典应用场景有如下这些,这些算法很多与图有关,本身比较复杂,也难以实现 ,我们一般掌握其思想即可:排序问题:选择排序、拓扑排序优先队列:堆排序赫夫曼压缩编码图里的Prim、Fruskal和Dijkstra算法硬币找零问题分数背包问题并查集的按大小或者高度合并问题或者排名任务调度部分场景一些复杂问题的近似算法所以贪心就像太极,无招胜有招,根据具体的题目特点直接想怎么做就行,不用考虑其他的。2. 贪心问题举例2.1. 分发饼干我们先看一个简单的题目,LeetCode455,分发饼干:假设你要给孩子们一些小饼干。但是每个孩子最多只能给一块饼干。每个孩子的饭量不同,对每个孩子 i,都有一个胃口值 g[i],这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j,都有一个尺寸 s[j] 。如果 s[j] >= g[i],我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。示例:其中g是胃口,s是拥有的饼干。输入: g = [1,2,3], s = [1,1] 输出: 1 解释: 你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。 虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。所以你应该输出1。 这里既要满足小孩的胃口,也不要造成饼干尺寸的浪费。大尺寸的饼干既可以满足胃口大的孩子也可以满足胃口小的孩子,那么就应该优先满足胃口大的。这里的局部最优就是大饼干喂给胃口大的,充分利用饼干尺寸喂饱一个,全局最优就是喂饱尽可能多的小孩。所以,这里可以使用贪心策略,先将饼干数组和小孩数组排序。 然后从后向前遍历小孩数组,用大饼干优先满足胃口大的,并统计满足小孩数量就可以了。也就是这样:这里我们就考虑胃口,大饼干先喂饱大胃口,最后看能满足几个孩子的需要就行。class Solution { public int findContentChildren(int[] g, int[] s) { Arrays.sort(g); Arrays.sort(s); int count = 0; int start = s.length - 1; for(int i = g.length - 1;i >= 0;i--){ if(start >= 0 && g[i] <= s[start]){ start--; count++; } } return count; } } def findContentChildren(self, g, s): g.sort() s.sort() m, n = len(g), len(s) wei_kou = bing_gan = count = 0 while wei_kou < m and bing_gan < n: while bing_gan < n and g[wei_kou] > s[bing_gan]: bing_gan += 1 if bing_gan < n: count += 1 wei_kou += 1 bing_gan += 1 return count public int findContentChildren(int g[], int s[]) { std::sort(g, g + g.size()); std::sort(s, s + s.size()); int count = 0; int start = s.size() - 1; // 遍历孩子的胃口 for (int index = g.size() - 1; index >= 0; index--) { if (start >= 0 && g[index] <= s[start]) { start--; count++; } } return count; } 2.2. 柠檬水找零这也是贪心的典型题目之一,先看题目要求:LeetCode860,在柠檬水摊上,每一杯柠檬水的售价为 5 美元。顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。注意,一开始你手头没有任何零钱。给你一个整数数组 bills ,其中 bills[i] 是第 i 位顾客付的账。如果你能给每位顾客正确找零,返回 true ,否则返回 false 。示例1: 输入:bills = [5,5,5,10,20] 输出:true 解释: 前 3 位顾客那里,我们按顺序收取 3 张 5 美元的钞票。 第 4 位顾客那里,我们收取一张 10 美元的钞票,并返还 5 美元。 第 5 位顾客那里,我们找还一张 10 美元的钞票和一张 5 美元的钞票。 由于所有客户都得到了正确的找零,所以我们输出 true。 示例2: 输入:bills = [5,5,10,10,20] 输出:false 解释: 前 2 位顾客那里,我们按顺序收取 2 张 5 美元的钞票。 对于接下来的 2 位顾客,我们收取一张 10 美元的钞票,然后返还 5 美元。 对于最后一位顾客,我们无法退回 15 美元,因为我们现在只有两张 10 美元的钞票。 由于不是每位顾客都得到了正确的找零,所以答案是 false。 这个题描述有点啰嗦,但是根据示例,不难看懂。这个题给小学生是不是也会做呢?然后当我们分析如何用代码实现时会有点懵,其实主要有三种情况:如果给的是5,那么直接收下。如果给的是10元,那么收下一个10,给出一个5,此时必须要有一个5才行。如果给的是20,那么优先消耗一个10元,再给一个5元。假如没有10元,则给出3个5元。上面情况三里,有10就先给10,没有才给多个5,这就是贪心选择的过程。为什么要优先消耗一个10和一个5呢?小学生都知道因为10只能给账单20找零,而5可以给账单10和账单20找零,5更万能!所以这里的局部最优就是遇到账单20,优先消耗美元10,完成本次找零。这就是局部最优可以推出全局最优,代码如下:public boolean lemonadeChange(int[] bills) { //这里只表示5元和10元纸币的数量,而不是总金额 int cash_5 = 0; int cash_10 = 0; for (int i = 0; i < bills.length; i++) { if (bills[i] == 5) { cash_5++; } else if (bills[i] == 10) { cash_5--; cash_10++; } else if (bills[i] == 20) { if (cash_10 > 0) { cash_10--; cash_5--; } else { cash_5 -= 3; } } if (cash_5 < 0 || cash_10 < 0) return false; } return true; } def lemonadeChange(self, bills): count = defaultdict(int) for bill in bills: if bill == 5: count[5] += 1 elif bill == 10: # 10块的收入只能用5块的找 count[10] += 1 if not count[5]: return False count[5] -= 1 else: count[20] += 1 # 由于两个5块可以组成一个10块因此优先找10块的 if count[10] >= 1: count[10] -= 1 if not count[5]: return False count[5] -= 1 else: if count[5] < 3: return False count[5] -= 3 return True bool lemonadeChange(vector<int>& bills) { int five = 0, ten = 0; for (auto& bill: bills) { if (bill == 5) { five++; } else if (bill == 10) { if (five == 0) { return false; } five--; ten++; } else { if (five > 0 && ten > 0) { five--; ten--; } else if (five >= 3) { five -= 3; } else { return false; } } } return true; } 2.3. 分发糖果这个题目虽然官方标记是困难,但其实特别简单。LeetCode135:n 个孩子站成一排。给你一个整数数组 ratings 表示每个孩子的评分。你需要按照以下要求,给这些孩子分发糖果,并返回需要准备的 最少糖果数目:每个孩子至少分配到 1 个糖果。相邻两个孩子评分更高的孩子会获得更多的糖果。示例1: 输入:ratings = [1,0,2] 输出:5 解释:你可以分别给第一个、第二个、第三个孩子分发 2、1、2 颗糖果。 示例2: 输入:ratings = [1,2,2] 输出:4 解释:你可以分别给第一个、第二个、第三个孩子分发 1、2、1 颗糖果。 第三个孩子只得到 1 颗糖果,这满足题面中的两个条件。 首先我们来看这个题是什么意思。假如有5个孩子,因为每个孩子至少一个糖果,所以一定要花出去的最少糖果是{1,1,1,1,1} 一共5个。然后是相邻孩子评分更高的能获得更多的糖果。假如评分为{1,2,3,2},则最少花出去的糖果为{1,2,3,1},因为前三个评分在增加,则糖果必须递增,因此分别要发的糖果最少为{1,2,3}个,最后一个因为评分低了,所以我们给最少1个。另外,假如评分相等,例如{1,2,2,2,2,},根据题目要求,则后面重复的都给一个的就行了,也就是分别给{1,2,1,1,1}个。首先,我们根据题意从左向后依次比较,确定第一轮要预发的糖果数量,只要右边的比左边的大,就一直加1;如果右边比左边小,就设置为1 ,然后继续向右比较。结果如下:此时有人就有疑问了,题目是要求相邻的孩子评分高的孩子必须获得更多的糖果,上面序列的后面几个评分为 4 、3、 2 但是得到的糖果却是一样的,那怎么办呢?很简单, 我们在上面的基础上,再从右向左走一轮。如果左边的比右边的小,则不管。如果左边的比右边的大,则不是简单的加一,而是要在{i+1}的基础上,先加1再赋值给{i}。看例子:最后四个评分为 {5 4 3 2 },第一轮结束之后应该发的糖果为left={2,1,1,1}。如果当我们只考虑从右向左的时候,很显然:最后一个评分为2得到1个糖果倒数第二个评分为3,得到2个糖果倒数第三个评分为4,得到2+1=3个糖果倒数第四个评分为5,得到3+1=4个糖果因此最后四个的right={4,3,2,1},接下来每个位置i我们只要从left[i]和right[i]中选最大就行了。不过这里我们其实不用两个数组,一个数组更新两次即可,首先从左向后给数组candyVec赋值,然后再从右向左更新数组元素,每次赋值之前先比较一下取max即可。如下图:public int candy(int[] ratings) { int[] candyVec = new int[ratings.length]; candyVec[0] = 1; for (int i = 1; i < ratings.length; i++) { if (ratings[i] > ratings[i - 1]) { candyVec[i] = candyVec[i - 1] + 1; } else { candyVec[i] = 1; } } for (int i = ratings.length - 2; i >= 0; i--) { if (ratings[i] > ratings[i + 1]) { candyVec[i] = Math.max(candyVec[i], candyVec[i + 1] + 1); } } int ans = 0; for (int s : candyVec) { ans += s; } return ans; } def candy(self, ratings): n = len(ratings) left = [0] * n for i in range(n): if i > 0 and ratings[i] > ratings[i - 1]: left[i] = left[i - 1] + 1 else: left[i] = 1 right = ret = 0 for i in range(n - 1, -1, -1): if i < n - 1 and ratings[i] > ratings[i + 1]: right += 1 else: right = 1 ret += max(left[i], right) return ret int candy(vector<int>& ratings) { int n = ratings.size(); vector<int> left(n); for (int i = 0; i < n; i++) { if (i > 0 && ratings[i] > ratings[i - 1]) { left[i] = left[i - 1] + 1; } else { left[i] = 1; } } int right = 0, ret = 0; for (int i = n - 1; i >= 0; i--) { if (i < n - 1 && ratings[i] > ratings[i + 1]) { right++; } else { right = 1; } ret += max(left[i], right); } return ret; }
0
0
0
浏览量231
时光小少年

第 09 关 | 心有灵犀的二分查找与二叉树的中序遍历:3.黄金挑战——两道有挑战的问题

1. 有序数组转化为二叉搜索树LeetCode108 给你一个整数数组 nums ,其中元素已经按 升序 排列,请你将其转换为一棵 高度平衡 二叉搜索树。高度平衡 二叉树是一棵满足「每个节点的左右两个子树的高度差的绝对值不超过 1 」的二叉树。示例1: 输入:nums = [-10,-3,0,5,9] 输出:[0,-3,9,-10,null,5] 解释:[0,-10,5,null,-3,null,9] 也将被视为正确答案: 理论上如果要构造二叉搜索树,可以以升序序列中的任一个元素作为根节点,以该元素左边的升序序列构建左子树,以该元素右边的升序序列构建右子树,这样得到的树就是一棵二叉搜索树。 本题要求高度平衡,因此我们需要选择升序序列的中间元素作为根节点,这本质上就是二分查找的过程:/** * Definition for a binary tree node. * public class TreeNode { * int val; * TreeNode left; * TreeNode right; * TreeNode() {} * TreeNode(int val) { this.val = val; } * TreeNode(int val, TreeNode left, TreeNode right) { * this.val = val; * this.left = left; * this.right = right; * } * } */ class Solution { public TreeNode sortedArrayToBST(int[] nums) { return helper(nums,0,nums.length - 1); } public static TreeNode helper(int[] nums,int left,int right){ if(left > right){ return null; } int mid = (left + right) >> 1; TreeNode root = new TreeNode(nums[mid]); root.left = helper(nums,left,mid - 1); root.right = helper(nums,mid + 1,right); return root; } } def sortedArrayToBST(self, nums): def helper(left, right): if left > right: return None # 总是选择中间位置左边的数字作为根节点 mid = (left + right) // 2 root = TreeNode(nums[mid]) root.left = helper(left, mid - 1) root.right = helper(mid + 1, right) return root return helper(0, len(nums) - 1) TreeNode *helper(vector<int> &nums, int left, int right) { if (left > right) { return nullptr; } // 总是选择中间位置左边的数字作为根节点 int mid = (left + right) / 2; TreeNode *root = new TreeNode(nums[mid]); root->left = helper(nums, left, mid - 1); root->right = helper(nums, mid + 1, right); return root; } TreeNode *sortedArrayToBST(vector<int> &nums) { return helper(nums, 0, nums.size() - 1); } 除了通过数组构造,是否可以通过一个个插入的方式来实现呢?当然可以,这就是LeetCode701题,如果要从中删除一个元素呢?这就是LeetCode405题, 感兴趣的同学可以自己研究一下。2. 寻找两个正序数组中的中位数这是一道比较难的问题,LeetCode4.给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的中位数 。算法的时间复杂度应该为 O(log (m+n)) 。示例1: 输入:nums1 = [1,3], nums2 = [2] 输出:2.00000 解释:合并数组 = [1,2,3] ,中位数 2 示例2: 输入:nums1 = [1,2], nums2 = [3,4] 输出:2.50000 解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5 对于本题,最直观的思路有以下两种:使用归并的方式,合并两个有序数组,得到一个大的有序数组。大的有序数组的中间位置的元素,即为中位数。这种方式的时间复杂度是O ( m + n),空间复杂度是 O(m+n)。另一种方式,不需要合并两个有序数组,只要找到中位数的位置即可。由于两个数组的长度已知,因此中位数对应的两个数组的下标之和也是已知的。维护两个指针,初始时分别指向两个数组的下标 00 的位置,每次将指向较小值的指针后移一位(如果一个指针已经到达数组末尾,则只需要移动另一个数组的指针),直到到达中位数的位置。这种方式可以将空间复杂度降到 O(1),但是时间复杂度仍是 O(m+n)。如何把时间复杂度降低到O(log(m+n)) 呢?如果对时间复杂度的要求有log,通常都考虑二分、快排或者堆三个方面。而对于有序序列,通常要考虑一下能否使用二分来解决。如果要使用二分,核心问题是基于什么规则将数据砍掉一半。而本题是两个序列,所以我们的核心问题是如何从两个序列中分别砍半,图示如下k=(m+n)/2:根据中位数的定义,当m+n 是奇数时,中位数是两个有序数组中的第(m+n)/2 个元素,当m+n 是偶数时,中位数是两个有序数组中的第(m+n)/2 个元素和第(m+n)/2+1 个元素的平均值。因此,这道题可以转化成寻找两个有序数组中的第 k 小的数,其中 k 为 (m+n)/2 或 (m+n)/2+1。假设两个有序数组分别是LA 和LB。要找到第 k 个元素,我们可以比较LA[k/2−1] 和LB[k/2−1]。由于LA[k/2−1] 和LB[k/2−1] 的前面分别有LA[0..k/2−2] 和 LB[0..k/2−2],即k/2−1 个元素,对于LA[k/2−1] 和LB[k/2−1] 中的较小值,最多只会有(k/2−1)+(k/2−1)≤k−2 个元素比它小,那么它就不能是第 k 小的数了。因此我们可以归纳出以下几种情况:如果LA[k/2−1]<LB[k/2−1],则比LA[k/2−1] 小的数最多只有LA 的前k/2−1 个数和LB 的前k/2−1 个数,即比LA[k/2−1] 小的数最多只有k−2 个,因此LA[k/2−1] 不可能是第 k 个数,LA[0] 到LA[k/2−1] 也都不可能是第 k 个数,可以全部排除。如果LA[k/2−1]>LB[k/2−1],则可以排除LB[0] 到LB[k/2−1]。也就是一次砍掉一半。如果LA[k/2−1]=LB[k/2−1],则可以归入第一种情况处理。可以看到,比较LA[k/2−1] 和LB[k/2−1] 之后,可以排除k/2 个不可能是第 k 小的数,查找范围缩小了一半。同时,我们将在排除后的新数组上继续进行二分查找,并且根据我们排除数的个数,减少 k 的值,这是因为我们排除的数都不大于第 k 小的数。以下边界情况需要特殊处理以下:如果LA[k/2−1] 或者LB[k/2−1] 越界,那么我们可以选取对应数组中的最后一个元素。在这种情况下,我们必须根据排除数的个数减少 k 的值,而不能直接将 k 减去k/2。如果k=1,我们只要返回两个数组首元素的最小值即可。实现代码:class Solution { public double findMedianSortedArrays(int[] nums1, int[] nums2) { int length1 = nums1.length, length2 = nums2.length; int totalLength = length1 + length2; if (totalLength % 2 == 1) { int midIndex = totalLength / 2; double median = getKthElement(nums1, nums2, midIndex + 1); return median; } else { int midIndex1 = totalLength / 2 - 1, midIndex2 = totalLength / 2; double median = (getKthElement(nums1, nums2, midIndex1 + 1) + getKthElement(nums1, nums2, midIndex2 + 1)) / 2.0; return median; } } public int getKthElement(int[] nums1, int[] nums2, int k) { int length1 = nums1.length, length2 = nums2.length; int index1 = 0, index2 = 0; while (true) { // 边界情况 if (index1 == length1) { return nums2[index2 + k - 1]; } if (index2 == length2) { return nums1[index1 + k - 1]; } if (k == 1) { return Math.min(nums1[index1], nums2[index2]); } // 正常情况 int half = k / 2; int newIndex1 = Math.min(index1 + half, length1) - 1; int newIndex2 = Math.min(index2 + half, length2) - 1; int pivot1 = nums1[newIndex1], pivot2 = nums2[newIndex2]; if (pivot1 <= pivot2) { k -= (newIndex1 - index1 + 1); index1 = newIndex1 + 1; } else { k -= (newIndex2 - index2 + 1); index2 = newIndex2 + 1; } } } } int getKthElement(const vector<int>& nums1, const vector<int>& nums2, int k) { int m = nums1.size(); int n = nums2.size(); int index1 = 0, index2 = 0; while (true) { // 边界情况 if (index1 == m) { return nums2[index2 + k - 1]; } if (index2 == n) { return nums1[index1 + k - 1]; } if (k == 1) { return min(nums1[index1], nums2[index2]); } // 正常情况 int newIndex1 = min(index1 + k / 2 - 1, m - 1); int newIndex2 = min(index2 + k / 2 - 1, n - 1); int pivot1 = nums1[newIndex1]; int pivot2 = nums2[newIndex2]; if (pivot1 <= pivot2) { k -= newIndex1 - index1 + 1; index1 = newIndex1 + 1; } else { k -= newIndex2 - index2 + 1; index2 = newIndex2 + 1; } } } double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) { int totalLength = nums1.size() + nums2.size(); if (totalLength % 2 == 1) { return getKthElement(nums1, nums2, (totalLength + 1) / 2); } else { return (getKthElement(nums1, nums2, totalLength / 2) + getKthElement(nums1, nums2, totalLength / 2 + 1)) / 2.0; } } def findMedianSortedArrays(self, nums1 nums2List[int]) : def getKthElement(k): index1, index2 = 0, 0 while True: # 特殊情况 if index1 == m: return nums2[index2 + k - 1] if index2 == n: return nums1[index1 + k - 1] if k == 1: return min(nums1[index1], nums2[index2]) # 正常情况 newIndex1 = min(index1 + k // 2 - 1, m - 1) newIndex2 = min(index2 + k // 2 - 1, n - 1) pivot1, pivot2 = nums1[newIndex1], nums2[newIndex2] if pivot1 <= pivot2: k -= newIndex1 - index1 + 1 index1 = newIndex1 + 1 else: k -= newIndex2 - index2 + 1 index2 = newIndex2 + 1 m, n = len(nums1), len(nums2) totalLength = m + n if totalLength % 2 == 1: return getKthElement((totalLength + 1) // 2) else: return (getKthElement(totalLength // 2) + getKthElement(totalLength // 2 + 1)) / 2
0
0
0
浏览量888

履历