演算法班筆記 第九章 資料結構:區間、陣列、矩陣和樹狀陣列
第九章 資料結構:區間、陣列、矩陣和樹狀陣列
子陣列與字首和
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 來對區間進行排序?
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.QQ:第三步有什麼意義? 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 來避免單個輸出帶來的效率緩慢。
簡單位運算操作
什麼是位運算
程式中所有數在記憶體中都以二進位制形式儲存。位運算(bit operation)就是直接對整數在記憶體中的二進位制位進行操作。使用的主要目的是節約記憶體,加速執行,以及對記憶體要求苛刻時使用。
按位與操作
主要講解“按位與”(and)操作,操作符為&
。
將A和B的二進位制表示的每一位進行與操作,只有兩個對應的二進位制位都為1時,結果位才為1,否則為0。
1 & 1 = 1
1 & 0 = 0
0 & 1 = 0
0 & 0 = 0
例如:
下面 (x)y
表示 x
是 y
進位制。
A = (10)10
= (001010)2
(注意高位全是0)
B = (44)10
= (101100)2
A & B = 10 & 44
= 001010 & 101100
= (001000)2
= (8)10
int a = 10 & 44; // a的值是8
按位與相關題目
計算一個32位整數的二進位制表示中有多少個1
不斷用num和num-1做按位與,結果直接賦給num。只要num不為0,就重複該過程。最後返回以上過程的次數即可。程式碼如下:
public class Solution {
public int countOnes(int num) {
int count = 0;
while (num != 0) {
num &= num - 1;
count++;
}
return count;
}
}
Q:這為啥可以?A:其實原理很簡單,先說結論:每一次num &= num - 1
會使得num
最低位1
變為0
。
例如12,二進位制表示為1100
,減1後的二進位制表示為1011
。注意到了嗎,減1後,最低位1變成了0,而最低位1後面的0全變成了1,高位不變。這樣和原數按位與後,就只有最低位1發生了變化。所以該過程迴圈了多少次,就說明抹掉了多少個1。這對於其餘正整數也是適用的。