例题分析一
LeetCode 第 03 题:给定一个字符串,请你找出其中不含有重复字符的最长子串的长度。
解题思路:
在上述例题中,能否让慢指针不再一步一步地挪动,而是迅速地跳到字符 b 的位置?
可以用哈希表来记录每个字符以及它出现的位置,当遇到了字符 a 的时候,就知道跟它重复的前一个字符出现的位置,只需要让慢指针指向那个位置的下一个即可。(如果题目说所有字符都是字母的话,也可以用一个数组去记录。)
遇到字符 a,此时哈希表的记录 {d: 0, e: 1, a: 2, b: 3: c: 4},a 的位置是 2,把 2 加上 1 等于 3,就能让慢指针 i 指向下标为 3 的位置,即 b 字符的地方。但是在一些情况下,我们不能简单地将取出来的重复位置加 1,如下:快指针 j 指向的字符是 e,而 e 在哈希表里记录的位置是 1。
在这种情况下,没有必要让 i 重新指向 e 后面的 a。此时,i 应该保留在原地不动。因此,i 被移动到的新位置应该等于 max(i,重复字符出现位置 + 1)。
代码实现
class Solution {
public int lengthOfLongestSubstring(String s) {
if(s == null || s.length() == 0){
return 0;
}
Map<Character, Integer> map = new HashMap<>();
int res = 0;
//i为慢指针,j为快指针
for(int i = 0, j = 0; j < s.length(); j++){
if(map.containsKey(s.charAt(j))){
//可能不需要将指针指向后一个元素
i = Math.max(map.get(s.charAt(j)) + 1, i);
}
map.put(s.charAt(j), j);
res = Math.max(res, j - i + 1);
}
return res;
}
}
例题分析二
LeetCode 第 04 题给定两个大小为 m 和 n 的有序数组 nums1 和 nums2。请你找出这两个有序数组的中位数,并且要求算法的时间复杂度为 O(log(m+n))。你可以假设 nums1 和 nums2 不会同时为空。
class Solution {
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
//先交换两个数组,把小的交换在前面,大的交换在后面
if(nums1.length > nums2.length){
int[] nums = nums1;
nums1 = nums2;
nums2 = nums;
}
int m = nums1.length;
int n = nums2.length;
int totalLeft = (m + n + 1) / 2;
int left = 0;
int right = m;
while(left < right){
int i = left + (right - left + 1) / 2;
int j = totalLeft - i;
if(nums1[i-1] > nums2[j]){
//i在左半部分
right = i - 1;
}
else{
left = i;
}
}
//找到了合理划分位置的地方
int i = left;
int j = totalLeft - i;
int nums1LeftMax = i == 0 ? Integer.MIN_VALUE : nums1[i-1];
int nums1RightMin = i == m ? Integer.MAX_VALUE : nums1[i];
int nums2LeftMax = j == 0 ? Integer.MIN_VALUE : nums2[j-1];
int nums2RightMin = j == n ? Integer.MAX_VALUE : nums2[j];
//如果为偶数个,结果为左边的最大值和右边的最小值的平均值
if((m + n) % 2 == 0){
//注意:这里需要强制转换成double类型
return (double)(Math.max(nums1LeftMax, nums2LeftMax) + Math.min(nums1RightMin, nums2RightMin)) / 2;
}
//如果为奇数个,因为左边的个数都比右边多
else{
return Math.max(nums1LeftMax, nums2LeftMax);
}
}
}
扩展一
例题:如果给定的两个数组是没有经过排序处理的,应该怎么找出中位数呢?
快速选择算法
快速选择算法,可以在 O(n) 的时间内从长度为 n 的没有排序的数组中取出第 k 小的数,运用了快速排序的思想。
假如将 nums1[] 与 nums2[] 数组组合成一个数组变成 nums[]:{2, 5, 3, 1, 6, 8, 9, 7, 4},那么如何在这个没有排好序的数组中找到第 k 小的数呢?
1. 随机地从数组中选择一个数作为基准值,比如 7。一般而言,随机地选择基准值可以避免最坏的情况出现。
2. 将数组排列成两个部分,以基准值作为分界点,左边的数都小于基准值,右边的都大于基准值。
3. 判断一下基准值所在位置 p:
- 如果 p 刚好等于 k,那么基准值就是所求数,直接返回。
- 如果 k < p,即基准值太大,搜索的范围应该缩小到基准值的左边。
- 如果 k > p,即基准值太小,搜索的范围应该缩小到基准值的右边。此时需要找的应该是第 k - p 小的数,因为前 p 个数被淘汰。
4. 重复第一步,直到基准值的位置 p 刚好就是要找的 k。
package csdn.cn.dsa;
public class Test {
public static void main(String[] args) {
int[] nums = new int[]{2, 5, 3, 1, 6, 8, 9, 7, 4};
int res = new Test().findKthLargest(nums,9);
System.out.println(res);
}
public int findKthLargest(int[] nums, int k){
return quickSelect(nums, 0, nums.length - 1, k);
}
private int quickSelect(int[] nums, int low, int high, int k) {
if(low == high){
if(k - 1 == low){
return nums[low];
}
else{
return -1;
}
}
int pivot = nums[low];
int i = low;
int j = high;
while(i < j){
while(i < j && nums[j] >= pivot){
j --;
}
while(i < j && nums[i] <= pivot){
i++;
}
if(i != j){
//交换nums[i]和nums[j]
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
nums[low] = nums[i];
nums[i] = pivot;
if(i == k - 1){
return pivot;
}
//应该继续在右半部分找
if(i < k){
return quickSelect(nums, i + 1, high, k);
}
else{
return quickSelect(nums, low, i - 1, k);
}
}
}
时间复杂度
为了方便推算,假设每次都选择中间的那个数作为基准值。
- 设函数的时间执行函数为 T(n),第一次运行的时候,把基准值和所有的 n 个元素进行比较,然后将输入规模减半并递归,所以 T(n) = T(n/2) + n。
- 当规模减半后,新的基准值只和 n/2 个元素进行比较,因此 T(n/2) = T(n/4) + n/2。
- 以此类推:
T(n/4) = T(n/8) + n/4
…
T(2) = T(1) + 2
T(1) = 1
将上面的公式逐个代入后得到 T(n) = 1 + 2 + … + n/8 + n/4 + n/2 + n = 2×n,所以 O(T(n)) = O(n)。
空间复杂度
如果不考虑递归对栈的开销,那么算法并没有使用额外的空间,swap 操作都是直接在数组里完成,因此空间复杂度为 O(1)。
扩展二
例题:有一万个服务器,每个服务器上存储了十亿个没有排好序的数,现在要找所有数当中的中位数,怎么找?
对于分布式地大数据处理,应当考虑两个方面的限制:
- 每台服务器进行算法计算的复杂度限制,包括时间和空间复杂度
- 服务器与服务器之间进行通信时的网络带宽限制
限制 1:空间复杂度
假设存储的数都是 32 位整型,即 4 个字节,那么 10 亿个数需占用 40 亿字节,大约 4GB
- 归并排序至少得需要 4GB 的内存
- 快速排序的空间复杂度为 log(n),即大约 30 次堆栈压入
用非递归的方法去实现快速排序,代码如下。
class Range{
public int low;
public int high;
public Range(int low, int high){
this.low = low;
this.high = high;
}
}
private void quickSort(int[] nums){
Stack<Range> stack = new Stack<>();
Range range = new Range(0, nums.length - 1);
stack.push(range);
while(!stack.isEmpty()){
range = stack.pop();
int pivot = partition(nums, range.low, range.high);
if(pivot - 1 > range.low){
stack.push(new Range(pivot + 1, range.high));
}
if(pivot + 1 < range.high){
stack.push(new Range(pivot + 1, range.high));
}
}
}
private int partition(int[] nums, int low, int high) {
int pivot = nums[low];
int i = low;
int j = high;
while(i < j){
while(i < j && nums[j] >= pivot){
j --;
}
while(i < j && nums[i] <= pivot){
i++;
}
if(i != j){
//交换nums[i]和nums[j]
int tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
}
nums[low] = nums[i];
nums[i] = pivot;
return i;
}
如上,利用一个栈 stack 来记录每次进行快速排序时的范围。一旦发现基准值左边还有未处理完的数,就将左边的范围区间压入到栈里;如果发现基准值右边还有未处理完的数,就将右边的范围区间压入到栈里。其中,处理基准值的 partition 函数非常重要,之前已经介绍过。
限制 2:网络带宽
在实际应用中,这是最重要的考量因素,很多大型的云服务器都是按照流量来进行收费,如何有效地限制流量,避免过多的服务器之间的通信,就是要考量的重点,并且,实际上它与算法的时间复杂度有很大的关系。
解决方案
借助扩展一的思路。
1. 从 1万 个服务器中选择一个作为主机(master server)。这台主机将扮演主导快速选择算法的角色
2. 在主机上随机选择一个基准值,然后广播到其他各个服务器上。
3. 每台服务器都必须记录下最后小于、等于或大于基准值数字的数量:less count,equal count,greater count。
4. 每台服务器将 less count,equal count 以及 greater count 发送回主机。
5. 主机统计所有的 less count,equal count 以及 greater count,得出所有比基准值小的数的总和 total less count,等于基准值的总和 total equal count,以及大于基准值的总和 total greater count。进行如下判断。
- 如果 total less count >= total count / 2,表明基准值太大。
- 如果total less count + total equal count >= total count / 2,表明基准值即为所求结果。
- 否则,total less count + total equal count < total count / 2 表明基准值太小。
6. 后面两种情况,主机会把新的基准值广播给各个服务器,服务器根据新的基准值的大小判断往左半边或者右半边继续进行快速选择。直到最后找到中位数。
时间复杂度
整体的时间复杂度是 O(nlog(n)),主机和各个其他服务器之间的通信总共也需要 nlog(n)次,每次通信需要传递一个基准值以及三个计数值。
如果用一些组播网络(Multicast Network),可以有效地节省更多的带宽。
例题分析三
LeetCode 第 23 题:合并 k 个排好序的链表,返回合并后的排序链表。分析和描述算法的复杂度。
解题思路一:最小堆法
- 上述操作的时间复杂度是 O(k)。而针对找出最小的数,可以使用最小堆来提高效率。时间复杂度计算如下。
- 对 k 个链表头创建一个大小为 k 的最小堆,在第 2 课中提到创建一个大小为 k 的最小堆所需的时间是 O(k);
- 从堆里取出最小的数,都是 O(lg(k));
- 若每个链表的平均长度为 n,一共有 nk 个元素,即用大小为 k 的最小堆去过滤 nk 个元素;
- 整体的时间复杂度就是 O(nk×log(k))。
维护这个大小为 k 的最小堆,直到遍历完所有 k 个链表里的所有元素。
/**
* 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 mergeKLists(ListNode[] lists) {
if(lists == null || lists.length == 0){
return null;
}
PriorityQueue<ListNode> heap = new PriorityQueue<>(lists.length, new Comparator<ListNode>(){
public int compare(ListNode a, ListNode b){
return a.val - b.val;
}
});
ListNode dummy = new ListNode(-1);
ListNode p = dummy;
//插入每个结点的头部结点
for(int i = 0; i < lists.length; i++){
if(lists[i] != null){
heap.offer(lists[i]);
}
}
while(!heap.isEmpty()){
ListNode node = heap.poll();
p.next = node;
p = p.next;
if(node.next != null){
heap.offer(node.next);
}
}
return dummy.next;
}
}
解题思路三:分治法
当 k=1 的时候,直接返回结果;当 k=2 的时候,把这两个链表归并。当 k=3 的时候,我们可以把它们分成两组,分别归并完毕后再进行最后的归并操作,如下。
public ListNode mergeKLists(ListNode[] lists, int low, int high) {
if (low == high) return lists[low];
int middle = low + (high - low) / 2; // 从中间切一刀
return mergeTwoLists(
mergeKLists(lists, low, middle),
mergeKLists(lists, middle + 1, high)
); // 递归地处理左边和右边的链表,最后合并
}
public ListNode mergeTwoLists(ListNode a, ListNode b) {
if (a == null) return b;
if (b == null) return a;
if (a.val <= b.val) {
a.next = mergeTwoLists(a.next, b);
return a;
}
b.next = mergeTwoLists(a, b.next);
return b;
}
合并两个排好序的链表非常简单,此处使用递归函数,可以尝试非递归写法。
时间复杂度:O(nk×log(k))。
空间复杂度:O(1)。因为不像最小堆解法那样需要维护一个额外的数据结构。
提示:因为这道题针对的是链表,所以很多操作都直接在链表上进行。