第九章 数据结构:区间、数组、矩阵和树状数组
子数组与前缀和
Subarry
PrefixSum[i] = A[0] + A[1] + ... + A[i-1]
PrefixSum[0] = 0;
构造花费 O(n) 时间,O(n) 空间
Sum(i to j) = prefixsum[j+1] - prefixsum[i];
- Maximum subarray
- Subarray Sum
- Subarray sum closest
- 排序前缀和数组
两个排序数组的中位数
题目描述
在两个排序数组中,求他们合并到一起之后的中位数
时间复杂度要求:O(log(n+m)),其中 n, m 分别为两个数组的长度
解法
这个题有三种做法:
1. 基于 FindKth 的算法。整体思想类似于 median of unsorted array 可以用 find kth from unsorted array 的解题思路。
算法描述
- 先将找中点问题转换为找第 k 小的问题,这里直接令
k = (n + m) / 2
。那么目标是在 logk = log((n+m)/2) = log(n+m) 的时间内找到A和B数组中从小到大第 k 个。 - 比较 A 数组的第 k/2 小和 B 数组的第 k/2 小的数。谁小,就扔掉谁的前 k/2 个数。
- 将目标
寻找第 k 小
修改为寻找第 (k-k/2) 小
- 回到第 2 步继续做,直到 k == 1 或者 A 数组 B 数组里已经没有数了。
F.A.Q
Q: 如何 O(1) 时间移除数组的前 k/2 个数?
A: 给两个数组一个起始位置的标记参数(相当于一个offset,偏移位),把这个起始位置 + k/2 就可以了。
Q: 不是让我们找中点么?怎么变成了找第 k 小?
A: 找第 k 小如果能在 log(k) 的时间内解决,那么找中点就可以在 log( (n+m)/2 ) 的时间内解决。
Q: 如何证明谁的第 k/2 个数比较小就扔掉谁的前 k/2 个数这个理论?
A: 直观的,我们看一个例子
A=[1,3,5,7]
B=[2,4,6,8]
假如我们要找第 4 小。也就是 k = 4。算法会去比较两个数组中第 2 小的数。也就是 A[1]=3 和 B[1]=4 这两个数的大小。然后会发现,3比较小,然后就决定扔掉 A 的前 k/2 = 2 个数。也就是,接下来,需要去找
A=[5,7]
B=[2,4,6,8]
中的第 k-k/2=2 小的数。这里我们扔掉了 [1,3],扔掉的这些数中,一定不会包含我们要找的第 4 小的数——4。因为从位置上,他们在 A 和 B合并到一起之后,都会排在 4 的前面。
抽象的证明一下:
我们需要回顾一下 Merge Two Sorted Arrays 这道题目。算法的做法是,每一次比较两个数组中比较小的数,然后谁小,谁先被拿出来,放到最后的合并结果中。那么假设 A 和 B中 A[k/2 - 1] <= B[k/2 - 1](反之同理)。我们会决定扔掉A[0..k/2-1],因为这些数在 A 与 B 做简单的 Merge 的过程中,会优先于目标第 k 个数现出来。为什么?因为既然A[k/2-1] <= B[k/2-1],那么当我们用最简单的 Merge Two Sorted Arrays 的算法一个个从A和B里拿数出来的时候,当 A[k/2 - 1] 出来的时候,B[k/2 - 1] 一定还没有被拿出来,那么此时A里出来了 k/2 个数,B里出来的数一定不够 k/2 个(因为第 k/2 个数都还没出来),所以加起来总共出来的数肯定不够k个,所以第k小的数一定还留在AB数组中。
因此我们证明了:扔掉较小的一部分的前 k/2 个数,不会扔掉要找的第 k 小的数。
2. 基于中点比较的算法。一头一尾各自丢掉一些,去掉一半的时候,整个问题的形式不变。可以推广到 median of k sorted arrays.
3. 基于二分的方法。二分 median 的值,然后再用二分法看一下两个数组里有多少个数小于这个二分出来的值。
我们每次二分的是答案,所以复杂度会取决于数组中具体数的大小。如果两数组中最大值和最小值之差是V,那么时间复杂度就是logV x (logm + logn)
算法描述
- 我们需要先确定二分的上下界限,由于两个数组 A, B 均有序,所以下界为
min(A[0], B[0])
,上界为max(A[A.length - 1], B[B.length - 1])
. - 判断当前上下界限下的
mid(mid = (start + end) / 2)
是否为我们需要的答案;这里我们可以分别对两个数组进行二分来找到两个数组中小于等于当前mid
的数的个数cnt1
与cnt2
,sum = cnt1 + cnt2
即为A
跟B
合并后小于等于当前mid的数的个数. - 如果
sum < k
,即中位数肯定不是mid
,应该大于mid
,更新start
为mid
,否则更新end
为mid
,之后再重复第二步 - 当不满足
start + 1 < end
这个条件退出二分循环时,再分别判断一下start
跟end
,最终返回符合要求的那个数即可
算法详解
- 这一题如果用二分法来做,其实就是一个二分答案的过程
- 首先我们已经得到了上下界限,那么答案必定是在这个上下界限中的,需要实现的就是从这个歌上下界限中找出答案
- 我们每次取的
mid
,其实就是我们每次在假设答案为mid
,二分的过程就是不断的推翻这个假设,然后再假设新的答案 - 需要满足的条件为:
- 上面算法描述中的
sum
需要等于k
,这里的k = (A.length + B.length) / 2
. 如果sum < k
,很明显当前的mid
偏小,需要增大,否则就说明当前的mid
偏大,需要缩小.
- 上面算法描述中的
- 最终在
start
与end
相邻的时候退出循环,判断start
跟end
哪个符合条件即可得到最终结果
如何写 Comparator 来对区间进行排序?
转自 https://blog.csdn.net/woxiaohahaa/article/details/53191247
1、定义 operator<():
使用该方法的前提是有默认的比较函数会调用我们的 operator<()。
比如我们有如下类,那么我们可以这样定义 operator<。
struct Edge {
int from, to, weight;
};
bool operator<(Edge a, Edge b) {
//使用大于号实现小于号,表示排序顺序与默认顺序相反。若使用小于号实现小于号,则相同。
return a.weight > b.weight;
}
2、定义一个普通的比较函数:
还是用前面的设定:
struct Edge{
int from, to, weight;
};
bool cmp(Edge a, Edge b){
return a.weight > b.weight;
}
3、定义 operator()():
operator()重载函数需要被定义(声明)在一个新的结构体内。
struct cmp{
bool operator()(const int &a, const int &b){
return a > b;
}
};
1、operator<() 仅适用于自定义结构体(operator()()重载后形参必须要有结构体)。operator<() 函数的添加可以从容修改自带排序功能的容器(set, priority_queue等)的比较规则,在定义该容器时只需set<T>或priority_queue<T>即可,不需要添加其他参数。在使用sort()函数时也不用指定比较函数。
2、定义比较函数,sort()的第三个参数。
3、operator()() 则适用于内建类型与自定义结构体(operator()()形参可以是内建数据类型)以及sort(),但需要类似这样定义容器set<T, cmp>或priority_queue<T, vector<T>, cmp>,其中cmp为仅包含operator()()函数的结构体。
除此之外,我们在使用sort()或定义容器时,还可以使用greater<T>和less<T>,当T为内建类型时,注意在sort使用中需要在<T>后额外加()。
在排好序的区间序列中插入新区间
问题描述
给一个排好序的区间序列,插入一段新区间。求插入之后的区间序列。要求输出的区间序列是没有重叠的。
算法描述
- 将该新区间按照左端值插入原区间中,使得原区间左端值是有序的。
- 遍历原区间列表,并把它复制到一个新的
answer
区间列表当中,answer
是最后要返回的结果。 - 遍历时,要记录上一次访问的区间
last
。若当前区间左端值小于等于last
区间的右端值,说明这两区间有重叠,此时仅更新last
的右端值为这两区间右端值较大者;若当前区间左端值大于last
的右端值,则可以直接加入answer
。 - 返回
answer
。
F.A.Q
Q:第三步有什么意义?
A:插入新区间后的原区间列表,仅能保证左端是有序的。而区间中是否存在重叠,右端是否有序,这些都是未知的。
Q:时空复杂度多少?
A:都是O(N)。
Q:有没有更高效的做法?
A:有!在查找左端新区见待插位置时,可以采用二分查找。原算法的的第三步,实际上是在查找右端的位置,也可以用二分查找,这样两次查找的复杂度都降为了O(logN)。但是,完全没必要,因为这个算法涉及到数组中间位置的移动,所以O(N)的时间复杂度是逃不开的,二分查找的改进对效率提升不明显,而且会增大编码难度。有兴趣的同学可以自己尝试~
外排序与K路归并算法
外排序算法(External Sorting),是指在内存不够的情况下,如何对存储在一个或者多个大文件中的数据进行排序的算法。外排序算法通常是解决一些大数据处理问题的第一个步骤,或者是面试官所会考察的算法基本功。外排序算法是海量数据处理算法中十分重要的一块。
在学习这类大数据算法时,经常要考虑到内存、缓存、准确度等因素,这和我们之前见到的算法都略有差别。
基本步骤
外排序算法分为两个基本步骤:
- 将大文件切分为若干个个小文件,并分别使用内存排好序
- 使用K路归并算法(k-way merge)将若干个排好序的小文件合并到一个大文件中
第一步:文件拆分
根据内存的大小,尽可能多的分批次的将数据 Load 到内存中,并使用系统自带的内存排序函数(或者自己写个快速排序算法),将其排好序,并输出到一个个小文件中。比如一个文件有1T,内存有1G,那么我们就这个大文件中的内容按照 1G 的大小,分批次的导入内存,排序之后输出得到 1024
个 1G 的小文件。
第二步:K路归并算法
K路归并算法使用的是数据结构堆(Heap)来完成的,使用 Java 或者 C++ 的同学可以直接用语言自带的 PriorityQueue(C++中叫priority_queue)来代替。
我们将 K 个文件中的第一个元素加入到堆里,假设数据是从小到大排序的话,那么这个堆是一个最小堆(Min Heap)。每次从堆中选出最小的元素,输出到目标结果文件中,然后如果这个元素来自第 x 个文件,则从第 x 个文件中继续读入一个新的数进来放到堆里,并重复上述操作,直到所有元素都被输出到目标结果文件中。
Follow up: 一个个从文件中读入数据,一个个输出到目标文件中操作很慢,如何优化?
如果我们每个文件只读入1个元素并放入堆里的话,总共只用到了 1024
个元素,这很小,没有充分的利用好内存。另外,单个读入和单个输出的方式也不是磁盘的高效使用方式。因此我们可以为输入和输出都分别加入一个缓冲(Buffer)。假如一个元素有10个字节大小的话,1024
个元素一共 10K,1G的内存可以支持约 100K 组这样的数据,那么我们就为每个文件设置一个 100K 大小的 Buffer,每次需要从某个文件中读数据,都将这个 Buffer 装满。当然 Buffer 中的数据都用完的时候,再批量的从文件中读入。输出同理,设置一个 Buffer 来避免单个输出带来的效率缓慢。