开始刷题吧!十道腾讯金三银四高频leetcode算法题分享(附解题思路及实现代码!)
Posted 天道酬勤——傻子王(AME)
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了开始刷题吧!十道腾讯金三银四高频leetcode算法题分享(附解题思路及实现代码!)相关的知识,希望对你有一定的参考价值。
1.最大子序和
方法一:动态规划
思路和算法
假设 nums 数组的长度是 n,下标从 0 到 n−1。
我们用 f(i) 代表以第 i 个数结尾的「连续子数组的最大和」,那么很显然我们要求的答案就是:
因此我们只需要求出每个位置的 f(i),然后返回 f 数组中的最大值即可。那么我们如何求 f(i) 呢?我们可以考虑 nums[i] 单独成为一段还是加入 f(i−1) 对应的那一段,这取决于 nums[i] 和 f(i−1)+nums[i] 的大小,我们希望获得一个比较大的,于是可以写出这样的动态规划转移方程:
不难给出一个时间复杂度 O(n)、空间复杂度 O(n) 的实现,即用一个 f 数组来保存 f(i) 的值,用一个循环求出所有 f(i)。考虑到 f(i) 只和 f(i−1) 相关,于是我们可以只用一个变量 pre 来维护对于当前 f(i) 的 f(i−1) 的值是多少,从而让空间复杂度降低到 O(1),这有点类似「滚动数组」的思想。
class Solution {
public int maxSubArray(int[] nums) {
int pre = 0, maxAns = nums[0];
for (int x : nums) {
pre = Math.max(pre + x, x);
maxAns = Math.max(maxAns, pre);
}
return maxAns;
}
}
复杂度
- 时间复杂度:O(n),其中 n 为 nums 数组的长度。我们只需要遍历一遍数组即可求得答案。
- 空间复杂度:O(1)。我们只需要常数空间存放若干变量。
2.10亿个数中如何高效地找到最大的一个数以及最大的第 K 个数
1. 10亿个数中如何高效地找到最大的一个数
将10亿个数据分成1000份,每份100万个数据,找到每份数据中最大的那个数据,最后在剩下的1000个数据里面找出最大的数据。 从100万个数据遍历选择最大的数,此方法需要每次的内存空间为10^6*4=4MB,一共需要1000次这样的比较。
2. 10亿个数中如何高效地找到第K个数
- 对于top K类问题,通常比较好的方案是分治+hash+小顶堆:
- 先将数据集按照Hash方法分解成多个小数据集
- 然后用小顶堆求出每个数据集中最大的K个数
- 最后在所有top K中求出最终的top K。
- 如果是top词频可以使用分治+ Trie树/hash +小顶堆:
- 先将数据集按照Hash方法分解成多个小数据集
- 然后使用Trie树或者Hash统计每个小数据集中的query词频
- 之后用小顶堆求出每个数据集中出频率最高的前K个数
- 最后在所有top K中求出最终的top K。
- 时间复杂度:建堆时间复杂度是O(K),算法的时间复杂度为O(NlogK)。
3. top K常用的方法
- 快排+选择排序:排序后的集合中进行查找
- 时间复杂度: 时间复杂度为O(NlogN)
- 缺点:需要比较大的内存,且效率低
- 局部淘汰:取前K个元素并排序,然后依次扫描剩余的元素,插入到排好序的序列中(二分查找),并淘汰最小值。
- 时间复杂度: 时间复杂度为O(NlogK) (logK为二分查找的复杂度)。
- 分治法:将10亿个数据分成1000份,每份100万个数据,找到每份数据中最大的K个,最后在剩下的1000*K个数据里面找出最大的K个,100万个数据里面查找最大的K个数据可以使用Partition的方法
- 时间复杂度: 时间复杂度为O(N+1000*K)
- Hash法: 如果这10亿个数里面有很多重复的数,先通过Hash法,把这10亿个数字去重复,这样如果重复率很高的话,会减少很大的内存用量,从而缩小运算空间,然后通过分治法或最小堆法查找最大的K个数。
- 小顶堆: 首先读入前K个数来创建大小为K的小顶堆,建堆的时间复杂度为O(K),然后遍历后续的数字,并于堆顶(最小)数字进行比较。如果比最小的数小,则继续读取后续数字;如果比堆顶数字大,则替换堆顶元素并重新调整堆为最小堆。
- 时间复杂度: 时间复杂度为O(NlogK)
- Trie树: 如果是从10亿个重复比较多的单词找高频词汇,数据集按照Hash方法分解成多个小数据集,然后使用Trie树统计每个小数据集中的query词频,之后用小顶堆求出每个数据集中出现频率最高的前K个数,最后在所有top K中求出最终的top K。
- 适用范围:数据量大,重复多,但是数据种类小可以放入内存
- 时间复杂度:O(Len*N),N为字符串的个数,Len为字符串长度
- 桶排序:一个数据表分割成许多buckets,然后每个bucket各自排序,或用不同的排序算法,或者递归的使用bucket sort算法。也是典型的divide-and-conquer分而治之的策略。
- 使用范围:如果已知了数据的范围,那么可以划分合适大小的桶,直接借用桶排序的思路
- 时间复杂度:O(N*logM),N 为待排序的元素的个数,M为桶的个数
- 计数排序:计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
- 适用范围:只能用在数据范围不大的场景
- 时间复杂度:O(N)
- 基数排序:将整数按位数切割成不同的数字,然后按每个位数分别比较。
- 适用范围:可以对字符串类型的关键字进行排序。
- 时间复杂度: O(N*M),M为要排序的数据的位数
4. 实际情况
(1)单机+单核+足够大内存
- 顺序遍历(或先用HashMap求出每个词出现的频率)
- 查找10亿个查询次(每个占8B)中出现频率最高的10个,考虑到每个查询词占8B,则10亿个查询次所需的内存大约是10^9 * 8B=8GB内存。如果有这么大内存,直接在内存中对查询次进行排序,顺序遍历找出10个出现频率最大的即可。
- 优点: 简单快速
(2)单机+多核+足够大内存
- partition + (1)
- 直接在内存总使用Hash方法将数据划分成n个partition,每个partition交给一个线程处理,线程的处理逻辑同(1)类似,最后一个线程将结果归并。
- 瓶颈:数据倾斜。每个线程的处理速度可能不同,快的线程需要等待慢的线程。
- 解决的方法:将数据划分成c×n个partition(c>1),每个线程处理完当前partition后主动取下一个partition继续处理,知道所有数据处理完毕,最后由一个线程进行归并。
(3)单机+单核+受限内存
- 分治 + (1)
- 将原文件中的数据切割成M小文件,如果小文件仍大于内存大小,继续采用Hash的方法对数据文件进行分割,直到每个小文件小于内存大小,这样每个文件可放到内存中处理。采用(1)的方法依次处理每个小文件。
(4)多机+受限内存
- 数据分发 + (3)
- 将数据分发到多台机器上,每台机器采用(3)中的策略解决本地的数据。可采用hash+socket方法进行数据分发。
- MapReduce
- top K问题很适合采用MapReduce框架解决,用户只需编写一个Map函数和两个Reduce 函数,然后提交到Hadoop
- 首先根据数据值或者把数据hash(MD5)后的值按照范围划分到不同的机器上,最好可以让数据划分后一次读入内存,这样不同的机器负责处理不同的数值范围,实际上就是Map。
- 得到结果后,各个机器只需拿出各自出现次数最多的前N个数据,然后汇总,选出所有的数据中出现次数最多的前N个数据,这实际上就是Reduce过程。
- 对于Map函数,采用Hash算法,将Hash值相同的数据交给同一个Reduce task;对于第一个Reduce函数,采用HashMap统计出每个词出现的频率,对于第二个Reduce 函数,统计所有Reduce task,输出数据中的top K即可。
- top K问题很适合采用MapReduce框架解决,用户只需编写一个Map函数和两个Reduce 函数,然后提交到Hadoop
3.爬楼梯
第一种思路
标签:数学
如果观察数学规律,可知本题是斐波那契数列,那么用斐波那契数列的公式即可解决问题,公式如下:
- 时间复杂度:O(logn)
第一种思路代码
class Solution {
public int climbStairs(int n) {
double sqrt_5 = Math.sqrt(5);
double fib_n = Math.pow((1 + sqrt_5) / 2, n + 1) - Math.pow((1 - sqrt_5) / 2,n + 1);
return (int)(fib_n / sqrt_5);
}
}
第二种思路
标签:动态规划
本问题其实常规解法可以分成多个子问题,爬第n阶楼梯的方法数量,等于 2 部分之和
- 爬上 n−1 阶楼梯的方法数量。因为再爬1阶就能到第n阶
- 爬上 n−2 阶楼梯的方法数量,因为再爬2阶就能到第n阶
所以我们得到公式 dp[n]=dp[n−1]+dp[n−2]
同时需要初始化 dp[0]=1 和 dp[1]=1
时间复杂度:O(n)
第二种思路代码
class Solution {
public int climbStairs(int n) {
int[] dp = new int[n + 1];
dp[0] = 1;
dp[1] = 1;
for(int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
}
4.合并两个有序链表
方法一:递归
思路
我们可以如下递归地定义两个链表里的 merge
操作(忽略边界情况,比如空链表等):
也就是说,两个链表头部值较小的一个节点与剩下元素的 merge
操作结果合并。
算法
我们直接将以上递归过程建模,同时需要考虑边界情况。
如果 l1
或者 l2
一开始就是空链表 ,那么没有任何操作需要合并,所以我们只需要返回非空链表。否则,我们要判断 l1
和 l2
哪一个链表的头节点的值更小,然后递归地决定下一个添加到结果里的节点。如果两个链表有一个为空,递归结束。
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
if (l1 == null) {
return l2;
} else if (l2 == null) {
return l1;
} else if (l1.val < l2.val) {
l1.next = mergeTwoLists(l1.next, l2);
return l1;
} else {
l2.next = mergeTwoLists(l1, l2.next);
return l2;
}
}
}
复杂度分析
-
时间复杂度:O(n+m),其中 n 和 m 分别为两个链表的长度。因为每次调用递归都会去掉
l1
或者l2
的头节点(直到至少有一个链表为空),函数 mergeTwoList 至多只会递归调用每个节点一次。因此,时间复杂度取决于合并后的链表长度,即 O(n+m)。 -
空间复杂度:O(n+m),其中 n 和 m 分别为两个链表的长度。递归调用 mergeTwoLists 函数时需要消耗栈空间,栈空间的大小取决于递归调用的深度。结束递归调用时 mergeTwoLists 函数最多调用 n+m 次,因此空间复杂度为 O(n+m)。
方法二:迭代
思路
我们可以用迭代的方法来实现上述算法。当 l1
和 l2
都不是空链表时,判断 l1
和 l2
哪一个链表的头节点的值更小,将较小值的节点添加到结果里,当一个节点被添加到结果里之后,将对应链表中的节点向后移一位。
算法
首先,我们设定一个哨兵节点 prehead
,这可以在最后让我们比较容易地返回合并后的链表。我们维护一个 prev
指针,我们需要做的是调整它的 next
指针。然后,我们重复以下过程,直到 l1
或者 l2
指向了 null
:如果 l1
当前节点的值小于等于 l2
,我们就把 l1
当前的节点接在 prev
节点的后面同时将 l1
指针往后移一位。否则,我们对 l2
做同样的操作。不管我们将哪一个元素接在了后面,我们都需要把 prev
向后移一位。
在循环终止的时候, l1
和 l2
至多有一个是非空的。由于输入的两个链表都是有序的,所以不管哪个链表是非空的,它包含的所有元素都比前面已经合并链表中的所有元素都要大。这意味着我们只需要简单地将非空链表接在合并链表的后面,并返回合并链表即可。
class Solution {
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
ListNode prehead = new ListNode(-1);
ListNode prev = prehead;
while (l1 != null && l2 != null) {
if (l1.val <= l2.val) {
prev.next = l1;
l1 = l1.next;
} else {
prev.next = l2;
l2 = l2.next;
}
prev = prev.next;
}
// 合并后 l1 和 l2 最多只有一个还未被合并完,我们直接将链表末尾指向未合并完的链表即可
prev.next = l1 == null ? l2 : l1;
return prehead.next;
}
}
复杂度分析
-
时间复杂度:O(n+m),其中 n 和 m 分别为两个链表的长度。因为每次循环迭代中,
l1
和l2
只有一个元素会被放进合并链表中, 因此 while 循环的次数不会超过两个链表的长度之和。所有其他操作的时间复杂度都是常数级别的,因此总的时间复杂度为 O(n+m)。 -
空间复杂度:O(1)。我们只需要常数的空间存放若干变量。
5.用 Rand7() 实现 Rand10()
方法一:拒绝采样
我们可以用拒绝采样的方法实现 Rand10()
。在拒绝采样中,如果生成的随机数满足要求,那么久返回该随机数,否则会不断生成直到一个满足要求的随机数为止。若我们调用两次 Rand7()
,那么可以生成 [1, 49]
之间的随机整数,我们只用到其中的 40
个,用来实现 Rand10()
,而拒绝剩下的 9
个数,如下图所示。
我们来分析这种方法在平均情况下需要调用 Rand7()
的次数。我们称连续调用两次 Rand7()
为一轮,在第一轮中,有 40/49
的概率不被拒绝,而有 9/49
的概率被拒绝,进入第二轮。在第二轮中也是如此,因此调用 Rand7()
的期望次数为:
class Solution extends SolBase {
public int rand10() {
int row, col, idx;
do {
row = rand7();
col = rand7();
idx = col + (row - 1) * 7;
} while (idx > 40);
return 1 + (idx - 1) % 10;
}
}
复杂度分析
- 时间复杂度:期望时间复杂度为 O(1),但最坏情况下会达到 O(∞)(一直被拒绝)。
- 空间复杂度:O(1)。
方法二:合理使用被拒绝的随机数
我们可以通过合理地使用被拒绝的采样,从而对方法一进行优化。
在方法一中,我们生成 [1, 49]
的随机数,若生成的随机数 x
在 [41, 49]
中,我们则拒绝 x
。然而在 x
被拒绝的情况下,我们得到了一个 [1, 9]
的随机数,如果再调用一次 Rand7()
,那么就可以生成 [1, 63]
的随机数。我们保留 [1, 60]
并拒绝 [61, 63]
:这是 [1, 3]
的随机数。我们继续调用 Rand7()
,生成 [1, 21]
的随机数,保留 [1, 20]
并拒绝 [1]
。此时 [1]
已经没有任何用处,若出现了拒绝 1
的情况,我们就重新开始生成 [1, 49]
的随机数。
使用类似的期望计算方法,我们可以得到调用 Rand7
的期望次数约为 2.2123。
class Solution extends SolBase {
public int rand10() {
int a, b, idx;
while (true) {
a = rand7();
b = rand7();
idx = b + (a - 1) * 7;
if (idx <= 40)
return 1 + (idx - 1) % 10;
a = idx - 40;
b = rand7();
// get uniform dist from 1 - 63
idx = b + (a - 1) * 7;
if (idx <= 60)
return 1 + (idx - 1) % 10;
a = idx - 60;
b = rand7();
// get uniform dist from 1 - 21
idx = b + (a - 1) * 7;
if (idx <= 20)
return 1 + (idx - 1) % 10;
}
}
}
复杂度分析
- 时间复杂度:期望时间复杂度为 O(1),但最坏情况下会达到 O(∞)(一直被拒绝)。
- 空间复杂度:O(1)。
6.AVL 树和红黑树有什么区别?
红黑树:
属性:
- 自平衡是通过用一种两种颜色(红色或黑色)绘制每个节点来提供的。
- 当 Tree 被修改时,新的树随后会被重新排列和重新绘制。
- 它需要树中每个节点的 1 位颜色信息。
红黑树维护的约束:
- 根总是黑色的。
- 所有 NULL 叶子都是黑色的,红色节点的两个孩子都是黑色的。
- 从给定节点到其任何后代叶子的每条简单路径都包含相同数量的黑色节点。
- 从根到最远叶子的路径不超过从根到最近叶子的路径的两倍。
AVL(Adelson-Velskii 和 Landis)树
属性:
- 节点左右子树的高度差应小于2。
- 当一个节点的两个子树的高度相差超过 1 时,就会进行重新平衡。
- 严格平衡的更快检索。
区别:
- AVL 树提供比红黑树**更快的查找,**因为它们更严格平衡。
- 红黑树提供比 AVL 树更快的插入和移除操作,因为由于相对宽松的平衡,完成的旋转更少。
- AVL 树存储每个节点的平衡因子或高度,因此每个节点需要存储一个整数,而红黑树每个节点只需要 1 位信息。
- 红黑树用于大多数语言库,如C++ 中的map、multimap、multiset,而 AVL 树用于需要更快检索的数据库中。
7.使用递归及非递归两种方式实现快速排序
前言
本题你可以选择直接调用库函数来对序列进行排序,但意义不大。由于排序算法有很多,本文只介绍三种常见的基于比较的复杂度较低的排序。
方法一:快速排序
思路和算法
快速排序的主要思想是通过划分将待排序的序列分成前后两部分,其中前一部分的数据都比后一部分的数据要小,然后再递归调用函数对两部分的序列分别进行快速排序,以此使整个序列达到有序。
我们定义函数 randomized_quicksort(nums, l, r)
为对 nums
数组里 [l,r] 的部分进行排序,每次先调用 randomized_partition
函数对 nums
数组里 [l,r] 的部分进行划分,并返回分界值的下标 pos
,然后按上述将的递归调用 randomized_quicksort(nums, l, pos - 1)
和 randomized_quicksort(nums, pos + 1, r)
即可。
那么核心就是划分函数的实现了,划分函数一开始需要确定一个分界值(我们称之为主元 pivot),然后再进行划分。而主元的选取有很多种方式,这里我们采用随机的方式,对当前划分区间 [l,r] 里的数等概率随机一个作为我们的主元,再将主元放到区间末尾,进行划分。
整个划分函数 partition
主要涉及两个指针 i 和 j,一开始 i = l - 1
,j = l
。我们需要实时维护两个指针使得任意时候,对于任意数组下标 k,我们有如下条件成立:
我们每次移动指针 j ,如果 nums[j]>pivot,我们只需要继续移动指针 j ,即能使上述三个条件成立,否则我们需要将指针 i 加一,然后交换 nums[i] 和 nums[j],再移动指针 j 才能使得三个条件成立。
当 j 移动到 r−1 时结束循环,此时我们可以由上述三个条件知道 [l,i] 的数都小于等于主元 pivot
,[i+1,r-1] 的数都大于主元 pivot
,那么我们只要交换 nums[i+1] 和 nums[r] ,即能使得 [l,i+1] 区间的数都小于 [i+2,r] 区间的数,完成一次划分,且分界值下标为 i+1,返回即可。
如下的动图展示了一次划分的过程,刚开始随机选了 44 作为主元,与末尾元素交换后开始划分:
class Solution {
public int[] sortArray(int[] nums) {
randomizedQuicksort(nums, 0, nums.length - 1);
return nums;
}
public void randomizedQuicksort(int[] nums, int l, int r) {
if (l < r) {
int pos = randomizedPartition(nums, l, r);
randomizedQuicksort(nums, l, pos - 1);
randomizedQuicksort(nums, pos + 1, r);
}
}
public int randomizedPartition(int[] nums, int l, int r) {
int i = new Random().nextInt(r - l + 1) + l; // 随机选一个作为我们的主元
swap(nums, r, i);
return partition(nums, l, r);
}
public int partition(int[] nums, int l, int r) {
int pivot = nums[r];
int i = l - 1;
for (int j = l; j <= r - 1; ++j) {
if (nums[j] <= pivot) {
i = i + 1;
swap(nums, i, j);
}
}
swap(nums, i + 1, r);
return i + 1;
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
复杂度分析
-
时间复杂度:基于随机选取主元的快速排序时间复杂度为期望 O(nlogn),其中 n 为数组的长度。详细证明过程可以见《算法导论》第七章,这里不再大篇幅赘述。
-
空间复杂度:O(h),其中 h 为快速排序递归调用的层数。我们需要额外的 O(h) 的递归调用的栈空间,由于划分的结果不同导致了快速排序递归调用的层数也会不同,最坏情况下需 O(n) 的空间,最优情况下每次都平衡,此时整个递归树高度为 logn,空间复杂度为 O(logn)。
方法二:堆排序
预备知识
- 堆
思路和算法
堆排序的思想就是先将待排序的序列建成大根堆,使得每个父节点的元素大于等于它的子节点。此时整个序列最大值即为堆顶元素,我们将其与末尾元素交换,使末尾元素为最大值,然后再调整堆顶元素使得剩下的 n−1 个元素仍为大根堆,再重复执行以上操作我们即能得到一个有序的序列。
如下两个动图展示了对 [4, 6, 8, 5, 9]
这个数组堆排序的过程:
class Solution {
public int[] sortArray(int[] nums) {
heapSort(nums);
return nums;
}
public void heapSort(int[] nums) {
int len = nums.length - 1;
buildMaxHeap(nums, len);
for (int i = len; i >= 1; --i) {
swap(nums, i, 0);
len -= 1;
maxHeapify(nums, 0, len);
}
}
public void buildMaxHeap(int[] nums, int len) {
for (int i = len / 2; i >= 0; --i) {
maxHeapify(nums, i, len);
}
}
public void maxHeapify(int[] nums, int i, int len) {
for (; (i << 1) + 1 <= len;) {
int lson = (i << 1) + 1;
int rson = (i << 1) + 2;
int large;
if (lson <= len && nums[lson] > nums[i]) {
large = lson;
} else {
large = i;
}
if (rson <= len && nums[rson] > nums[large]) {
large = rson;
}
if (large != i) {
swap(nums, i, large);
i = large;
} else {
break;
}
}
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i抓住金三银四的尾巴,解锁:模拟面试+刷题神器