learn-tech/专栏/重学数据结构与算法-完/18真题案例(三):力扣真题训练.md
2024-10-16 11:12:24 +08:00

253 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
18 真题案例(三):力扣真题训练
在备战公司面试的时候相信你一定也刷过力扣leetcode的题目吧。力扣的题目种类多样而且有虚拟社区功能因此很多同学都喜欢在上面分享习题答案。
毫无疑问,如果你完整地刷过力扣题库,在一定程度上能够提高你面试通过的可能性。因此,在本课时,我选择了不同类型、不同层次的力扣真题,我会通过这些题目进一步讲述和分析解决数据结构问题的方法。
力扣真题训练
在看真题前,我们再重复一遍通用的解题方法论,它可以分为以下 4 个步骤:
复杂度分析。估算问题中复杂度的上限和下限。
定位问题。根据问题类型,确定采用何种算法思维。
数据操作分析。根据增、删、查和数据顺序关系去选择合适的数据结构,利用空间换取时间。
编码实现。
例题 1删除排序数组中的重复项
【题目】 给定一个排序数组,你需要在原地删除重复出现的元素,使得每个元素只出现一次,返回移除后的数组和新的长度,你不需要考虑数组中超出新长度后面的元素。
要求:空间复杂度为 O(1),即不要使用额外的数组空间。
例如,给定数组 nums = [1,1,2],函数应该返回新的长度 2并且原数组 nums 的前两个元素被修改为 1, 2。又如给定 nums = [0,0,1,1,1,2,2,3,3,4],函数应该返回新的长度 5并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4。
【解析】 这个题目比较简单,应该是送分题。不过,面试过程中的送分题也是送命题。这是因为,如果送分题没有拿下,就会显得非常说不过去。
我们先来看一下复杂度。这里并没有限定时间复杂度,仅仅是要求了空间上不能定义新的数组。
然后我们来定位问题。显然这是一个数据去重的问题。
按照解题步骤,接下来我们需要做数据操作分析。 在一个去重问题中,每次遍历的新的数据,都需要与已有的不重复数据进行对比。这时候,就需要查找了。整体来看,遍历嵌套查找,就是 O(n²) 的复杂度。如果要降低时间复杂度,那么可以在查找上入手,比如使用哈希表。不过很可惜,使用了哈希表之后,空间复杂度就是 O(n)。幸运的是,原数组是有序的,这就可以让查找的动作非常简单了。
因此,解决方案上就是,一次循环嵌套查找完成。查找不可使用哈希表,但由于数组有序,时间复杂度是 O(1)。因此整体的时间复杂度就是 O(n)。
我们来看一下具体方案。既然是一次循环,那么就需要一个 for 循环对整个数组进行遍历。每轮遍历的动作是查找 nums[i] 是否已经出现过。因为数组有序,因此只需要去对比 nums[i] 和当前去重数组的最大值是否相等即可。我们用一个 temp 变量保存去重数组的最大值。
如果二者不等,则说明是一个新的数据。我们就需要把这个新数据放到去重数组的最后,并且修改 temp 变量的值,再修改当前去重数组的长度变量 len。直到遍历完就得到了结果。
最后,我们按照上面的思路进行编码开发,代码如下:
public static void main(String[] args) {
int[] nums = {0,0,1,1,1,2,2,3,3,4};
int temp = nums[0];
int len = 1;
for (int i = 1; i < nums.length; i++) {
if (nums[i] != temp) {
nums[len] = nums[i];
temp = nums[i];
len++;
}
}
System.out.println(len);
for (int i = 0; i < len; i++) {
System.out.println(nums[i]);
}
}
我们对代码进行解读 在这段代码中 34 行进行了初始化得到的 temp 变量是数组第一个元素len 变量为 1
接着进入 for 循环如果当前元素与去重的最大值不等 6 则新元素放入去重数组中 7 并且更新去重数组的最大值 8 再让去重数组的长度加 1 9 最后得到结果后再打印出来 1215
例题 2查找两个有序数组合并后的中位数
题目 两个有序数组查找合并之后的中位数给定两个大小为 m n 的正序从小到大数组 nums1 nums2请你找出这两个正序数组合在一起之后的中位数并且要求算法的时间复杂度为 O(log(m + n))
你可以假设 nums1 nums2 不会同时为空所有的数字全都不相等还可以再假设如果数字个数为偶数个中位数就是中间偏左的那个元素
例如nums1 = [1, 3, 5, 7, 9]
nums2 = [2, 4, 8, 12]
输出 5
解析 这个题目是我个人非常喜欢的原因是它所有的解法和思路都隐含在了题目的描述中如果你具备很强的分析和解决问题的能力那么一定可以找到最优解法
我们先看一下复杂度的分析这里的 nums1 nums2 都是有序的这让我们第一时间就想到了归并排序方法很简单我们把两个数组合并就得到了合在一起后的有序数组这个动作的时间复杂度是 O(m+n)接着我们从数组中就可以直接取出中位数了很可惜这并不满足题目的时间复杂度 O(log(m + n)) 的要求
接着我们来看一下这个问题的定位题目中有一个关键字那就是找出很显然我们要找的目标就藏在 nums1 nums2 这明显就是一个查找问题而在查找问题中我们学过的知识是分治法下的二分查找
回想一下二分查找适用的重要条件就是原数组有序恰好在这个问题中 nums1 nums2 分别都是有序的而且二分查找的时间复杂度是 O(logn)这和题目中给出的时间复杂度 O(log(m + n)) 的要求也是不谋而合因此经过分析我们可以大胆猜测此题极有可能要用到二分查找
我们再来看一下数据结构方面如果要用二分查找就需要用到若干个指针去约束查找范围除此以外并不需要去定义复杂的数据结构也就是说空间复杂度是 O(1)
好了接下来我们就来看一下二分查找如何能解决这个问题二分查找需要一个分裂点去把原来的大问题拆分成两个部分并在其中一部分继续执行二分查找既然是查找中位数我们不妨先试试以中位数作为切分点看看会产生什么结果如下图所示
经过切分后两个数组分别被拆分为 3 个部分合在一起是 6 个部分二分查找的思路是需要从这 6 个部分中剔除掉一些让查找的范围缩小那么我们来思考一个问题在这 6 个部分中目标中位数一定不会发生在哪几个部分呢
中位数有一个重要的特质那就是比中位数小的数字个数和比中位数大的数字个数是相等的围绕这个性质来看中位数就一定不会发生在 C D 的区间
如果中位数在 C 部分那么在 nums1 比中位数小的数字就会更多一些因为 4 < 5nums2 的中位数小于 nums1 的中位数所以在 nums2 比中位数小的数字也会更多一些最不济也就是一样多因此整体来看中位数不可能在 C 部分同理中位数也不会发生在 D 部分
接下来我们就可以在查找范围内剔除掉 C 部分永远比中位数大的数字 D 部分永远比中位数小的数字这样我们就成功地完成了一次二分动作缩小了查找范围然而这样并没结束剔除掉了 C D 之后中位数有可能发生改变这是因为C 部分的数字个数和 D 部分数字的个数是不相等的剔除不相等数量的小数大数会造成中位数的改变
为了解决这个问题我们需要对剔除的策略进行修改一个可行的方法是如果 C 部分数字更少为 p 则剔除 C 部分并只剔除 D 部分中的 p 个数字这样就能保证经过一次二分后剔除之后的数组的中位数不变
应该剔除 C 部分和 D 部分 D 部分更少因此剔除 D C 中的 9
二分查找还需要考虑终止条件对于这个题目终止条件必然是某个数组小到无法继续二分的时候这是因为每次二分剔除掉的是更少的那个部分因此在终止条件中查找范围应该是一个大数组和一个只有 12 个元素的小数组这样就需要根据大数组的奇偶性和小数组的数量拆开 4 个可能性
可能性一nums1 奇数个nums2 只有 1 个元素例如nums1 = [a, b, c, d, e]nums2 = [m]。此时,有以下 3 种可能性
如果 m < b则结果为 b
如果 b < m < c则结果为 m
如果 m > c则结果为 c。
这 3 个情况,可以利用 “A?B:C” 合并为一个表达式,即 m < b ? b : (m < c ? m : c)
可能性二nums1 偶数个nums2 只有 1 个元素例如nums1 = [a, b, c, d, e, f]nums2 = [m]。此时,有以下 3 种可能性
如果 m < c则结果为 c
如果 c < m < d则结果为 m
如果m > d则结果为 d。
这 3 个情况可以利用”A?B:C”合并为一个表达式即 m < c ? c : (m < d? m : d)
可能性三nums1 奇数个nums2 2 个元素例如nums1 = [a, b, c, d, e]nums2 = [m,n]。此时,有以下 6 种可能性
如果 n < b则结果为 b
如果 b < n < c则结果为 n
如果 c < n < d则结果为 max(c,m)
如果 n > dm < c则结果为 c
如果 n > dc < m < d则结果为 m
如果 n > dm > d则结果为 d。
其中46 可以合并为,如果 n > d则返回 m < c ? c : (m < d ? m : d)
可能性四nums1 偶数个nums2 2 个元素例如nums1 = [a, b, c, d, e, f]nums2 = [m,n]。此时,有以下 6 种可能性
如果 n < b则结果为 b
如果 b < n < c则结果为 n
如果 c < n < d则结果为 max(c,m)
如果 n > dm < c则结果为 c
如果 n > dc < m < d则结果为 m
如果 n > dm > d则结果为 d。与可能性 3 完全一致。
不难发现,终止条件都是 if 和 else 的判断,虽然逻辑有点复杂,但时间复杂度是 O(1) 。为了简便我们可以假定nums1 的数字数量永远是不少于 nums2 的数字数量。
因此,我们可以编写如下的代码:
public static void main(String[] args) {
int[] nums1 = {1,2,3,4,5};
int[] nums2 = {6,7,8};
int median = getMedian(nums1,0, nums1.length-1, nums2, 0, nums2.length-1);
System.out.println(median);
}
public static int getMedian(int[] a, int begina, int enda, int[] b, int beginb, int endb ) {
if (enda - begina == 0) {
return a[begina] > b[beginb] ? b[beginb] : a[begina];
}
if (enda - begina == 1){
if (a[begina] < b[beginb]) {
return b[beginb] > a[enda] ? a[enda] : b[beginb];
}
else {
return a[begina] < b[endb] ? a[begina] : b[endb];
}
}
if (endb-beginb < 2) {
if ((endb - beginb == 0) && (enda - begina)%2 == 0) {
int m = b[beginb];
int bb = a[(enda + begina)/2 - 1];
int c = a[(enda + begina)/2];
return (m < bb) ? bb : (m < c ? m : c);
}
else if ((endb - beginb == 0) && (enda - begina)%2 != 0) {
int m = b[beginb];
int c = a[(enda + begina)/2];
int d = a[(enda + begina)/2 + 1];
return m < c ? c : (m < d ? m : d);
}
else {
int m = b[beginb];
int n = b[endb];
int bb = a[(enda + begina)/2 - 1];
int c = a[(enda + begina)/2];
int d = a[(enda + begina)/2 + 1];
if (n<bb) {
return bb;
}
else if (n>bb && n < c) {
return n;
}
else if (n > c && n < d) {
return m > c ? m : c;
}
else {
return m < c ? c : (m<d ? m : d);
}
}
}
else {
int mida = (enda + begina)/2;
int midb = (endb + beginb)/2;
if (a[mida] < b[midb]) {
int step = endb - midb;
return getMedian(a,begina + step, enda, b, beginb, endb - step);
}
else {
int step = midb - beginb;
return getMedian(a,begina,enda - step, b, beginb+ step, endb );
}
}
我们对代码进行解读在第 16 行是主函数进入 getMedian() 入参分别是 nums1 数组nums1 数组搜索范围的起止索引nums2 数组nums2 数组搜索范围的起止索引并进入第 8 行的函数中
getMedian() 函数中 5364 行是二分策略 952 行是终止条件我们先看二分部分通过第 56 判断 a b 的中位数的大小关系决策剔除哪个部分并在第 58 行和 62 递归地执行二分动作缩小范围
终止条件的第一种可能性在第 2126 第二种可能性在 2732 第三种和第四种可能性完全一致 3352 另外 919 行中处理了两个特殊情况分别是第 911 处理了两个数组都只剩 1 个元素的情况 1219 处理了两个数组都只剩 2 个元素的情况
这段代码的逻辑并不复杂但写起来还是有很多情况需要考虑的希望你能认真阅读
总结
综合来看力扣的题目还是比较受到行业的认可的一方面是它的题库内题目数量多另一方面是很多人会在上面提交相同题目的不同解法和答案但对初学者来说它还是有一些不友好的这主要在于它的定位只是题库并不能提供完整的解决问题的思维逻辑和方法论
本课时虽然我们只是举了两个例题但其背后解题的思考方法是通用的建议你能围绕本课程学到的解题方法利用空闲时间去把力扣热门的题目都练习一遍
练习题
最后我们再给出一道练习题给定一个链表删除链表的倒数第 n 个节点
例如给定一个链表: 1 -> 2 -> 3 -> 4 -> 5, 和 n = 2。当删除了倒数第二个节点后链表变为 1 -> 2 -> 3 -> 5。
你可以假设,给定的 n 是有效的。额外要求就是,要在一趟扫描中实现,即时间复杂度是 O(n)。这里给你一个提示,可以采用快慢指针的方法。