1 KMP演算法
大廠勸退,面試高頻^_^
1.1 KMP演算法分析
查詢字串問題:例如我們有一個字串str="abc1234efd"和match="1234"。我們如何查詢str字串中是否包含match字串的子串?
暴力解思路:迴圈str和match,挨個對比,最差情況為O(NM)。時間複雜度為O(NM)
KMP演算法,在N大於M時,可以在時間複雜度為O(N)解決此類問題
我們對str記錄字元座標前的字首字尾最大匹配長度,例如str="abcabck"
1、對於k位置前的字元,前字尾長度取1時,字首為"a"字尾為"c"不相等
2、對於k位置前的字元,前字尾長度取2時,字首為"ab"字尾為"bc"不相等
3、對於k位置前的字元,前字尾長度取3時,字首為"abc"字尾為"abc"相等
4、對於k位置前的字元,前字尾長度取4時,字首為"abca"字尾為"cabc"不相等
5、對於k位置前的字元,前字尾長度取5時,字首為"abcab"字尾為"bcabc"不相等
注意前字尾長度不可取k位置前的整體長度6。那麼此時k位置前的最大匹配長度為3
所以,例如"aaaaaab","b"的指標為6,那麼"b"座標前的前字尾最大匹配長度為5
我們對match建立座標前字尾最大匹配長度陣列,概念不存在的設定為-1,例如0位置前沒有字串,就為-1,1位置前只有一個字元,前字尾無法取和座標前字串相等,規定為0。例如"aabaabc",nextArr[]為[-1,0,1,0,1,2,3]
暴力方法之所以慢,是因為每次比對,如果match的i位置前都和str匹配上了,但是match的i+1位置沒匹配成功。那麼str會回退到第一次匹配的下一個位置,match直接回退到0位置再次比對。str和match回退的位置太多,之前的資訊全部作廢,沒有記錄
而KMP演算法而言,如果match的i位置前都和str匹配上了,但是match的i+1位置沒匹配成功,那麼str位置不回跳,match回跳到當前i+1位置的最大前字尾長度的位置上,去和當前str位置比對。
原理是如果我們當前match位置i+1比對失敗了,我們跳到最大前字尾長度的下一個位置去和當前位置比對,如果能匹配上,由於i+1位置之前都匹配的上,那麼match的最大字尾長度也比對成功,可以被我們利用起來。替換成match的字首長度上去繼續對比,起到加速的效果
那麼為什麼str和match最後一個不相等的位置,之前的位置無法配出match,可以反證,如果可以配置出來,那麼該串的頭資訊和match的頭資訊相等,得出存在比match當前不等位置最大前字尾還要大的前字尾,矛盾
Code:
public class Code01_KMP {
// O(N)
public static int getIndexOf(String s, String m) {
if (s == null || m == null || m.length() < 1 || s.length() < m.length()) {
return -1;
}
char[] str = s.toCharArray();
char[] match = m.toCharArray();
int x = 0; // str中當前比對到的位置
int y = 0; // match中當前比對到的位置
// match的長度M,M <= N O(M)
int[] next = getNextArray(match); // next[i] match中i之前的字串match[0..i-1],最長前字尾相等的長度
// O(N)
// x在str中不越界,y在match中不越界
while (x < str.length && y < match.length) {
// 如果比對成功,x和y共同往各自的下一個位置移動
if (str[x] == match[y]) {
x++;
y++;
} else if (next[y] == -1) { // 表示y已經來到了0位置 y == 0
// str換下一個位置進行比對
x++;
} else { // y還可以通過最大前字尾長度往前移動
y = next[y];
}
}
// 1、 x越界,y沒有越界,找不到,返回-1
// 2、 x沒越界,y越界,配出
// 3、 x越界,y越界 ,配出,str的末尾,等於match
// 只要y越界,就配出了,配出的位置等於str此時所在的位置x,減去y的長度。就是str存在匹配的字串的開始位置
return y == match.length ? x - y : -1;
}
// M O(M)
public static int[] getNextArray(char[] match) {
// 如果match只有一個字元,人為規定-1
if (match.length == 1) {
return new int[] { -1 };
}
// match不止一個字元,人為規定0位置是-1,1位置是0
int[] next = new int[match.length];
next[0] = -1;
next[1] = 0;
int i = 2;
// cn代表,cn位置的字元,是當前和i-1位置比較的字元
int cn = 0;
while (i < next.length) {
if (match[i - 1] == match[cn]) { // 跳出來的時候
// next[i] = cn+1;
// i++;
// cn++;
// 等同於
next[i++] = ++cn;
// 跳失敗,如果cn>0說明可以繼續跳
} else if (cn > 0) {
cn = next[cn];
// 跳失敗,跳到開頭仍然不等
} else {
next[i++] = 0;
}
}
return next;
}
// for test
public static String getRandomString(int possibilities, int size) {
char[] ans = new char[(int) (Math.random() * size) + 1];
for (int i = 0; i < ans.length; i++) {
ans[i] = (char) ((int) (Math.random() * possibilities) + 'a');
}
return String.valueOf(ans);
}
public static void main(String[] args) {
int possibilities = 5;
int strSize = 20;
int matchSize = 5;
int testTimes = 5000000;
System.out.println("test begin");
for (int i = 0; i < testTimes; i++) {
String str = getRandomString(possibilities, strSize);
String match = getRandomString(possibilities, matchSize);
if (getIndexOf(str, match) != str.indexOf(match)) {
System.out.println("Oops!");
}
}
System.out.println("test finish");
}
}
1.2 KMP演算法應用
題目1:旋轉詞
例如Str1="123456",對於Str1的旋轉詞,字串本身也是其旋轉詞,Str1="123456"的旋轉詞為,"123456","234561","345612","456123","561234","612345"。給定Str1和Str2,那麼判斷這個兩個字串是否互為旋轉詞?是返回true,不是返回false
暴力解法思路:把str1的所有旋轉詞都列出來,看str2是否在這些旋轉詞中。挨個便利str1,迴圈陣列的方式,和str2挨個比對。O(N*N)
KMP解法:str1拼接str1得到str',"123456123456",我們看str2是否是str'的子串
題目2:子樹問題
給定兩顆二叉樹頭結點,node1和node2,判斷node2為頭結點的樹,是不是node1的某個子樹?
2 bfprt演算法
面試常見
情形:在一個無序陣列中,怎麼求第k小的數。如果通過排序,那麼排序的複雜度為O(n*logn)。問,如何O(N)複雜度解決這個問題?
思路1:我們利用快排的思想,對陣列進行荷蘭國旗partion過程,每一次partion可以得到隨機數m小的區域,等於m的區域,大於m的區域。我們看我們m區域是否包含我們要找的第k小的樹,如果沒有根據比較,在m左區間或者m右區間繼續partion,直到第k小的數在我們的的中間區域。
快排是左右區間都會再進行partion,而該問題只會命中大於區域或小於區域,時間複雜度得到優化。T(n)=T(n/2)+O(n),時間複雜度為O(N),由於m隨機選,概率收斂為O(N)
思路2:bfprt演算法,不使用概率求期望,複雜度仍然嚴格收斂到O(N)
2.1 bfprt演算法分析
通過上文,利用荷蘭國旗問題的思路為:
1、隨機選一個數m
2、進行荷蘭國旗,得到小於m區域,等於m區域,大於m區域
3、index命中到等於m區域,返回等於區域的左邊界,否則比較,進入小於區域,或者大於區域,只會進入一個區域
bfprt演算法,再此基礎上唯一的區別是,第一步,如何選擇m。快排的思想是隨機選擇一個
bfprt如何選擇m?
- 1、對arr分組,5個一組,所以0到4為一組,5到9為一組,最後不夠一組的當成最後一組
- 2、對各個小組進行排序。第一步和第二步進行下來,時間複雜度為O(N)
- 3、把每一小組排序後的中間位置的數拿出來。放入一個陣列中m[]。前三步統稱為bfprt方法
- 4、對m陣列,取中位數,這個數就是我們需要的m
T(N) = T(N/5) + T(?) + O(N)
建議畫圖分析:
T(?)在我們隨機選取m的時候,是不確定的,但是在bfprt中,m的左側範圍最多有多少個數,等同於m右側最少有幾個數。
假設我們經過分組拿到的m陣列有5個數,中位數是我們的m,在m[]陣列中,大於m的有2個,小於m的有2個。對於整的資料規模而言,m[]的規模是n/5。大於m[]中位數的規模為m[]的一半,也就是整體資料規模的n/10。
由於m[]中的每個數都是從小組中選出來的,那麼對於整體資料規模而言,大於m的數整體為3n/10(每個n/10規模的數回到自己的小組,大於等於的每小組有3個)
那麼最少有3n/10的規模是大於等於m的,那麼對於整體資料規模而言最多有7n/10的小於m的。同理最多有7n/10的資料是大於m的
可得:
T(N) = T(N/5) + T(7n/10) + O(N)
數學證明,以上公式無法通過master來算複雜度,但是數學證明覆雜度嚴格O(N),證明略(演算法導論第九章第三節)
bfprt演算法在演算法上的地位非常高,它發現只要涉及到我們隨便定義的一個常數分組,得到一個表示式,最後收斂到O(N),那麼就可以通過O(N)的複雜度測試
public class Code01_FindMinKth {
public static class MaxHeapComparator implements Comparator<Integer> {
@Override
public int compare(Integer o1, Integer o2) {
return o2 - o1;
}
}
// 利用大根堆,時間複雜度O(N*logK)
public static int minKth1(int[] arr, int k) {
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(new MaxHeapComparator());
for (int i = 0; i < k; i++) {
maxHeap.add(arr[i]);
}
for (int i = k; i < arr.length; i++) {
if (arr[i] < maxHeap.peek()) {
maxHeap.poll();
maxHeap.add(arr[i]);
}
}
return maxHeap.peek();
}
// 改寫快排,時間複雜度O(N)
public static int minKth2(int[] array, int k) {
int[] arr = copyArray(array);
return process2(arr, 0, arr.length - 1, k - 1);
}
public static int[] copyArray(int[] arr) {
int[] ans = new int[arr.length];
for (int i = 0; i != ans.length; i++) {
ans[i] = arr[i];
}
return ans;
}
// arr 第k小的數: process2(arr, 0, N-1, k-1)
// arr[L..R] 範圍上,如果排序的話(不是真的去排序),找位於index的數
// index [L..R]
// 通過荷蘭國旗的優化,概率期望收斂於O(N)
public static int process2(int[] arr, int L, int R, int index) {
if (L == R) { // L == R ==INDEX
return arr[L];
}
// 不止一個數 L + [0, R -L],隨機選一個數
int pivot = arr[L + (int) (Math.random() * (R - L + 1))];
// 返回以pivot為劃分值的中間區域的左右邊界
// range[0] range[1]
// L ..... R pivot
// 0 1000 70...800
int[] range = partition(arr, L, R, pivot);
// 如果我們第k小的樹正好在這個範圍內,返回區域的左邊界
if (index >= range[0] && index <= range[1]) {
return arr[index];
// index比該區域的左邊界小,遞迴左區間
} else if (index < range[0]) {
return process2(arr, L, range[0] - 1, index);
// index比該區域的右邊界大,遞迴右區間
} else {
return process2(arr, range[1] + 1, R, index);
}
}
public static int[] partition(int[] arr, int L, int R, int pivot) {
int less = L - 1;
int more = R + 1;
int cur = L;
while (cur < more) {
if (arr[cur] < pivot) {
swap(arr, ++less, cur++);
} else if (arr[cur] > pivot) {
swap(arr, cur, --more);
} else {
cur++;
}
}
return new int[] { less + 1, more - 1 };
}
public static void swap(int[] arr, int i1, int i2) {
int tmp = arr[i1];
arr[i1] = arr[i2];
arr[i2] = tmp;
}
// 利用bfprt演算法,時間複雜度O(N)
public static int minKth3(int[] array, int k) {
int[] arr = copyArray(array);
return bfprt(arr, 0, arr.length - 1, k - 1);
}
// arr[L..R] 如果排序的話,位於index位置的數,是什麼,返回
public static int bfprt(int[] arr, int L, int R, int index) {
if (L == R) {
return arr[L];
}
// 通過bfprt分組,最終選出m。不同於隨機選擇m作為劃分值
int pivot = medianOfMedians(arr, L, R);
int[] range = partition(arr, L, R, pivot);
if (index >= range[0] && index <= range[1]) {
return arr[index];
} else if (index < range[0]) {
return bfprt(arr, L, range[0] - 1, index);
} else {
return bfprt(arr, range[1] + 1, R, index);
}
}
// arr[L...R] 五個數一組
// 每個小組內部排序
// 每個小組中位數拿出來,組成marr
// marr中的中位數,返回
public static int medianOfMedians(int[] arr, int L, int R) {
int size = R - L + 1;
// 是否需要補最後一組,例如13,那麼需要補最後一組,最後一組為3個數
int offset = size % 5 == 0 ? 0 : 1;
int[] mArr = new int[size / 5 + offset];
for (int team = 0; team < mArr.length; team++) {
int teamFirst = L + team * 5;
// L ... L + 4
// L +5 ... L +9
// L +10....L+14
mArr[team] = getMedian(arr, teamFirst, Math.min(R, teamFirst + 4));
}
// marr中,找到中位數,原問題是arr拿第k小的數,這裡是中位數陣列拿到中間位置的數(第mArr.length / 2小的數),相同的問題
// 返回值就是我們需要的劃分值m
// marr(0, marr.len - 1, mArr.length / 2 )
return bfprt(mArr, 0, mArr.length - 1, mArr.length / 2);
}
public static int getMedian(int[] arr, int L, int R) {
insertionSort(arr, L, R);
return arr[(L + R) / 2];
}
// 由於確定是5個數排序,我們選擇一個常數項最低的排序-插入排序
public static void insertionSort(int[] arr, int L, int R) {
for (int i = L + 1; i <= R; i++) {
for (int j = i - 1; j >= L && arr[j] > arr[j + 1]; j--) {
swap(arr, j, j + 1);
}
}
}
// for test
public static int[] generateRandomArray(int maxSize, int maxValue) {
int[] arr = new int[(int) (Math.random() * maxSize) + 1];
for (int i = 0; i < arr.length; i++) {
arr[i] = (int) (Math.random() * (maxValue + 1));
}
return arr;
}
public static void main(String[] args) {
int testTime = 1000000;
int maxSize = 100;
int maxValue = 100;
System.out.println("test begin");
for (int i = 0; i < testTime; i++) {
int[] arr = generateRandomArray(maxSize, maxValue);
int k = (int) (Math.random() * arr.length) + 1;
int ans1 = minKth1(arr, k);
int ans2 = minKth2(arr, k);
int ans3 = minKth3(arr, k);
if (ans1 != ans2 || ans2 != ans3) {
System.out.println("Oops!");
}
}
System.out.println("test finish");
}
}
2.2 bfprt演算法應用
題目:求一個陣列中,拿出所有比第k小的數還小的數
可以通過bfprt拿到第k小的數,再對原陣列遍歷一遍,小於該數的拿出來,不足k位的,補上第k小的數
對於這類問題,筆試的時候最好選擇隨機m,進行partion。而不是選擇bfprt。bfprt的常數項高。面試的時候可以選擇bfprt演算法