左神直通BAT演算法(進階篇)-上

zanwensicheng發表於2019-02-19

個人技術部落格:www.zhenganwen.top

經典演算法

Manacher演算法

原始問題

Manacher演算法是由題目“求字串中最長迴文子串的長度”而來。比如abcdcb的最長迴文子串為bcdcb,其長度為5。

我們可以遍歷字串中的每個字元,當遍歷到某個字元時就比較一下其左邊相鄰的字元和其右邊相鄰的字元是否相同,如果相同則繼續比較其右邊的右邊和其左邊的左邊是否相同,如果相同則繼續比較……,我們暫且稱這個過程為向外“擴”。當“擴”不動時,經過的所有字元組成的子串就是以當前遍歷字元為中心的最長迴文子串。

我們每次遍歷都能得到一個最長迴文子串的長度,使用一個全域性變數儲存最大的那個,遍歷完後就能得到此題的解。但分析這種方法的時間複雜度:當來到第一個字元時,只能擴其本身即1個;來到第二個字元時,最多擴兩個;……;來到字串中間那個字元時,最多擴(n-1)/2+1個;因此時間複雜度為1+2+……+(n-1)/2+1O(N^2)。但Manacher演算法卻能做到O(N)

Manacher演算法中定義瞭如下幾個概念:

  • 迴文半徑:串中某個字元最多能向外擴的字元個數稱為該字元的迴文半徑。比如abcdcb中字元d,能擴一個c,還能再擴一個b,再擴就到字串右邊界了,再算上字元本身,字元d的迴文半徑是3。
  • 迴文半徑陣列pArr:長度和字串長度一樣,儲存串中每個字元的迴文半徑。比如charArr="abcdcb",其中charArr[0]='a'一個都擴不了,但算上其本身有pArr[0]=1;而charArr[3]='d'最多擴2個,算上其本身有pArr[3]=3
  • 最右迴文右邊界R:遍歷過程中,“擴”這一操作擴到的最右的字元的下標。比如charArr=“abcdcb”,當遍歷到a時,只能擴a本身,向外擴不動,所以R=0;當遍歷到b時,也只能擴b本身,所以更新R=1;但當遍歷到d時,能向外擴兩個字元到charArr[5]=b,所以R更新為5。
  • 最右迴文右邊界對應的迴文中心CCR是對應的、同時更新的。比如abcdcb遍歷到d時,R=5C就是charArr[3]='d'的下標3

處理迴文子串長度為偶數的問題:上面拿abcdcb來舉例,其中bcdcb屬於一個迴文子串,但如果迴文子串長度為偶數呢?像cabbac,按照上面定義的“擴”的邏輯豈不是每個字元的迴文半徑都是0,但事實上cabbac的最長迴文子串的長度是6。因為我們上面“擴”的邏輯預設是將回文子串當做奇數長度的串來看的,因此我們在使用Manacher演算法之前還需要將字串處理一下,這裡有一個小技巧,那就是將字串的首尾和每個字元之間加上一個特殊符號,這樣就能將輸入的串統一轉為奇數長度的串了。比如abba處理過後為#a#b#b#a,這樣的話就有charArr[4]='#'的迴文半徑為4,也即原串的最大回文子串長度為4。相應程式碼如下:

public static char[] manacherString(String str){
  char[] source = str.toCharArray();
  char chs[] = new char[str.length() * 2 + 1];
  for (int i = 0; i < chs.length; i++) {
    chs[i] = i % 2 == 0 ? '#' : source[i / 2];
  }
  return chs;
}
複製程式碼

接下來分析,BFPRT演算法是如何利用遍歷過程中計算的pArrRC來為後續字元的迴文半徑的求解加速的。

首先,情況1是,遍歷到的字元下標curR的右邊(起初另R=-1),這種情況下該字元的最大回文半徑pArr[cur]的求解無法加速,只能一步步向外擴來求解。

左神直通BAT演算法(進階篇)-上

情況2是,遍歷到的字元下標curR的左邊,這時pArr[cur]的求解過程可以利用之前遍歷的字元迴文半徑資訊來加速。分別做curR關於C的對稱點cur'L

  • 如果從cur'向外擴的最大範圍的左邊界沒有超過L,那麼pArr[cur]=pArr[cur']

    左神直通BAT演算法(進階篇)-上

    證明如下:

    左神直通BAT演算法(進階篇)-上

    由於之前遍歷過cur'位置上的字元,所以該位置上能擴的步數我們是有記錄的(pArr[cur']),也就是說cur'+pArr[cur']處的字元y'是不等於cur'-pArr[cur']處的字元x'的。根據RC的定義,整個LR範圍的字元是關於C對稱的,也就是說cur能擴出的最大回文子串和cur'能擴出的最大回文子串相同,因此可以直接得出pArr[cur]=pArr[cur']

  • 如果從cur'向外擴的最大範圍的左邊界超過了L,那麼pArr[cur]=R-cur+1

    左神直通BAT演算法(進階篇)-上

    證明如下:

    左神直通BAT演算法(進階篇)-上

    R右邊一個字元xx關於cur對稱的字元yx,y關於C對稱的字元x',y'。根據C,R的定義有x!=x';由於x',y'在以cur'為中心的迴文子串內且關於cur'對稱,所以有x'=y',可推出x!=y';又y,y'關於C對稱,且在L,R內,所以有y=y'。綜上所述,有x!=y,因此cur的迴文半徑為R-cur+1

  • cur'為中心向外擴的最大範圍的左邊界正好是L,那麼pArr[cur] >= (R-cur+1)

    左神直通BAT演算法(進階篇)-上

    這種情況下,cur'能擴的範圍是cur'-L,因此對應有cur能擴的範圍是R-cur。但cur能否擴的更大則取決於xy是否相等。而我們所能得到的前提條件只有x!=x'y=y'x'!=y',無法推匯出x,y的關係,只知道cur的迴文半徑最小為R-cur+1(算上其本身),需要繼續嘗試向外擴以求解pArr[cur]

綜上所述,pArr[cur]的計算有四種情況:暴力擴、等於pArr[cur']、等於R-cur+1、從R-cur+1繼續向外擴。使用此演算法求解原始問題的過程就是遍歷串中的每個字元,每個字元都嘗試向外擴到最大並更新R(只增不減),每次R增加的量就是此次能擴的字元個數,而R到達串尾時問題的解就能確定了,因此時間複雜度就是每次擴操作檢查的次數總和,也就是R的變化範圍(-1~2N,因為處理串時向串中新增了N+1#字元),即O(1+2N)=O(N)

整體程式碼如下:

public static int maxPalindromeLength(String str) {
  char charArr[] = manacherString(str);
  int pArr[] = new int[charArr.length];
  int R = -1, C = -1;
  int max = Integer.MIN_VALUE;
  for (int i = 0; i < charArr.length; i++) {
    pArr[i] = i > R ? 1 : Math.min(pArr[C * 2 - i], R - i);
    while (i + pArr[i] < charArr.length && i - pArr[i] > -1) {
      if (charArr[i + pArr[i]] == charArr[i - pArr[i]]) {
        pArr[i]++;
      } else {
        break;
      }
    }
    if (R < i + pArr[i]) {
      R = i + pArr[i]-1;
      C = i;
    }
    max = Math.max(max, pArr[i]);
  }
  return max-1;
}

public static void main(String[] args) {
  System.out.println(maxPalindromeLength("zxabcdcbayq"));
}
複製程式碼

上述程式碼將四種情況的分支處理濃縮到了7~14行。其中第7行是確定加速資訊:如果當前遍歷字元在R右邊,先算上其本身有pArr[i]=1,後面檢查如果能擴再直接pArr[i]++即可;否則,當前字元的pArr[i]要麼是pArr[i']i關於C對稱的下標i'的推導公式為2*C-i),要麼是R-i+1,要麼是>=R-i+1,可以先將pArr[i]的值置為這三種情況中最小的那一個,後面再檢查如果能擴再直接pArr[i]++即可。

最後得到的max是處理之後的串(length=2N+1)的最長迴文子串的半徑,max-1剛好為原串中最長迴文子串的長度。

進階問題

給你一個字串,要求新增儘可能少的字元使其成為一個迴文字串。

思路:當R第一次到達串尾時,做R關於C的對稱點L,將L之前的字串逆序就是結果。

BFPRT演算法

題目:給你一個整型陣列,返回其中第K小的數。

這道題可以利用荷蘭國旗改進的partition和隨機快排的思想:隨機選出一個數,將陣列以該數作比較劃分為<,=,>三個部分,則=部分的數是陣列中第幾小的數不難得知,接著對<(如果第K小的數在<部分)或>(如果第K小的數在>部分)部分的數遞迴該過程,直到=部分的數正好是整個陣列中第K小的數。這種做法不難求得時間複雜度的數學期望為O(NlogN)(以2為底)。但這畢竟是數學期望,在實際工程中的表現可能會有偏差,而BFPRT演算法能夠做到時間複雜度就是O(NlogN)

BFPRT演算法首先將陣列按5個元素一組劃分成N/5個小部分(最後不足5個元素自成一個部分),再這些小部分的內部進行排序,然後將每個小部分的中位數取出來再排序得到中位數:

左神直通BAT演算法(進階篇)-上

BFPRT求解此題的步驟和開頭所說的步驟大體類似,但是“隨機選出一個的作為比較的那個數”這一步替換為上圖所示最終選出來的那個數。

O(NlogN)的證明,為什麼每一輪partition中的隨機選數改為BFPRT定義的選數邏輯之後,此題的時間複雜度就徹底變為O(NlogN)了呢?下面分析一下這個演算法的步驟:

BFPRT演算法,接收一個陣列和一個K值,返回陣列中的一個數

  1. 陣列被劃分為了N/5個小部分,每個部分的5個數排序需要O(1),所有部分排完需要O(N/5)=O(N)
  2. 取出每個小部分的中位數,一共有N/5個,遞迴呼叫BFPRT演算法得到這些數中第(N/5)/2小的數(即這些數的中位數),記為pivot
  3. pivot作為比較,將整個陣列劃分為<pivot , =pivot , >pivot三個區域
  4. 判斷第K小的數在哪個區域,如果在=區域則直接返回pivot,如果在<>區域,則將這個區域的數遞迴呼叫BFPRT演算法
  5. base case:在某次遞迴呼叫BFPRT演算法時發現這個區域只有一個數,那麼這個數就是我們要找的數。

程式碼示例:

public static int getMinKthNum(int[] arr, int K) {
  if (arr == null || K > arr.length) {
    return Integer.MIN_VALUE;
  }
  int[] copyArr = Arrays.copyOf(arr, arr.length);
  return bfprt(copyArr, 0, arr.length - 1, K - 1);
}

public static int bfprt(int[] arr, int begin, int end, int i) {
  if (begin == end) {
    return arr[begin];
  }
  int pivot = medianOfMedians(arr, begin, end);
  int[] pivotRange = partition(arr, begin, end, pivot);
  if (i >= pivotRange[0] && i <= pivotRange[1]) {
    return arr[i];
  } else if (i < pivotRange[0]) {
    return bfprt(arr, begin, pivotRange[0] - 1, i);
  } else {
    return bfprt(arr, pivotRange[1] + 1, end, i);
  }
}

public static int medianOfMedians(int[] arr, int begin, int end) {
  int num = end - begin + 1;
  int offset = num % 5 == 0 ? 0 : 1;
  int[] medians = new int[num / 5 + offset];
  for (int i = 0; i < medians.length; i++) {
    int beginI = begin + i * 5;
    int endI = beginI + 4;
    medians[i] = getMedian(arr, beginI, Math.min(endI, end));
  }
  return bfprt(medians, 0, medians.length - 1, medians.length / 2);
}

public static int getMedian(int[] arr, int begin, int end) {
  insertionSort(arr, begin, end);
  int sum = end + begin;
  int mid = (sum / 2) + (sum % 2);
  return arr[mid];
}

public static void insertionSort(int[] arr, int begin, int end) {
  if (begin >= end) {
    return;
  }
  for (int i = begin + 1; i <= end; i++) {
    for (int j = i; j > begin; j--) {
      if (arr[j] < arr[j - 1]) {
        swap(arr, j, j - 1);
      } else {
        break;
      }
    }
  }
}

public static int[] partition(int[] arr, int begin, int end, int pivot) {
  int L = begin - 1;
  int R = end + 1;
  int cur = begin;
  while (cur != R) {
    if (arr[cur] > pivot) {
      swap(arr, cur, --R);
    } else if (arr[cur] < pivot) {
      swap(arr, cur++, ++L);
    } else {
      cur++;
    }
  }
  return new int[]{L + 1, R - 1};
}

public static void swap(int[] arr, int i, int j) {
  int tmp = arr[i];
  arr[i] = arr[j];
  arr[j] = tmp;
}

public static void main(String[] args) {
  int[] arr = {6, 9, 1, 3, 1, 2, 2, 5, 6, 1, 3, 5, 9, 7, 2, 5, 6, 1, 9};
  System.out.println(getMinKthNum(arr,13));
}
複製程式碼

時間複雜度為O(NlogN)(底數為2)的證明,分析bfprt的執行步驟(假設bfprt的時間複雜度為T(N)):

  1. 首先陣列5個5個一小組並內部排序,對5個數排序為O(1),所有小組排好序為O(N/5)=O(N)
  2. 由步驟1的每個小組抽出中位陣列成一箇中位數小組,共有N/5個數,遞迴呼叫bfprt求出這N/5個數中第(N/5)/2小的數(即中位數)為T(N/5),記為pivot
  3. 對步驟2求出的pivot作為比較將陣列分為小於、等於、大於三個區域,由於pivot是中位數小組中的中位數,所以中位數小組中有N/5/2=N/10個數比pivot小,這N/10個數分別又是步驟1中某小組的中位數,可推匯出至少有3N/10個數比pivot小,也即最多有7N/10個數比pivot大。也就是說,大於區域(或小於)最大包含7N/10個數、最少包含3N/10個數,那麼如果第i大的數不在等於區域時,無論是遞迴bfprt處理小於區域還是大於區域,最壞情況下子過程的規模最大為7N/10,即T(7N/10)

綜上所述,bfprtT(N)存在推導公式:T(N/5)+T(7N/10)+O(N)。根據 基礎篇 中所介紹的Master公式可以求得bfprt的時間複雜度就是O(NlogN)(以2為底)。

morris遍歷二叉樹

關於二叉樹先序、中序、後序遍歷的遞迴和非遞迴版本在【直通BAT演算法(基礎篇)】中有講到,但這6種遍歷演算法的時間複雜度都需要O(H)(其中H為樹高)的額外空間複雜度,因為二叉樹遍歷過程中只能向下查詢孩子節點而無法回溯父結點,因此這些演算法藉助棧來儲存要回溯的父節點(遞迴的實質是系統幫我們壓棧),並且棧要保證至少能容納下H個元素(比如遍歷到葉子結點時回溯父節點,要保證其所有父節點在棧中)。而morris遍歷則能做到時間複雜度仍為O(N)的情況下額外空間複雜度只需O(1)

遍歷規則

首先在介紹morris遍歷之前,我們先把先序、中序、後序定義的規則拋之腦後,比如先序遍歷在拿到一棵樹之後先遍歷頭結點然後是左子樹最後是右子樹,並且在遍歷過程中對於子樹的遍歷仍是這樣。

忘掉這些遍歷規則之後,我們來看一下morris遍歷定義的標準:

  1. 定義一個遍歷指標cur,該指標首先指向頭結點
  2. 判斷cur的左子樹是否存在
    • 如果cur的左孩子為空,說明cur的左子樹不存在,那麼cur右移來到cur.right
    • 如果cur的左孩子不為空,說明cur的左子樹存在,找出該左子樹的最右結點,記為mostRight
      • 如果,mostRight的右孩子為空,那就讓其指向curmostRight.right=cur),並左移curcur=cur.left
      • 如果mostRight的右孩子不空,那麼讓cur右移(cur=cur.right),並將mostRight的右孩子置空
  3. 經過步驟2之後,如果cur不為空,那麼繼續對cur進行步驟2,否則遍歷結束。

下圖所示舉例演示morris遍歷的整個過程:

左神直通BAT演算法(進階篇)-上

先序、中序序列

遍歷完成後對cur進過的節點序列稍作處理就很容易得到該二叉樹的先序、中序序列:

左神直通BAT演算法(進階篇)-上

示例程式碼:

public static class Node {
    int data;
    Node left;
    Node right;
    public Node(int data) {
        this.data = data;
    }
}

public static void preOrderByMorris(Node root) {
    if (root == null) {
        return;
    }
    Node cur = root;
    while (cur != null) {
        if (cur.left == null) {
            System.out.print(cur.data+" ");
            cur = cur.right;
        } else {
            Node mostRight = cur.left;
            while (mostRight.right != null && mostRight.right != cur) {
                mostRight = mostRight.right;
            }
            if (mostRight.right == null) {
                System.out.print(cur.data+" ");
                mostRight.right = cur;
                cur = cur.left;
            } else {
                cur = cur.right;
                mostRight.right = null;
            }
        }
    }
    System.out.println();
}

public static void mediumOrderByMorris(Node root) {
    if (root == null) {
        return;
    }
    Node cur = root;
    while (cur != null) {
        if (cur.left == null) {
            System.out.print(cur.data+" ");
            cur = cur.right;
        } else {
            Node mostRight = cur.left;
            while (mostRight.right != null && mostRight.right != cur) {
                mostRight = mostRight.right;
            }
            if (mostRight.right == null) {
                mostRight.right = cur;
                cur = cur.left;
            } else {
                System.out.print(cur.data+" ");
                cur = cur.right;
                mostRight.right = null;
            }
        }
    }
    System.out.println();
}

public static void main(String[] args) {
    Node root = new Node(1);
    root.left = new Node(2);
    root.right = new Node(3);
    root.left.left = new Node(4);
    root.left.right = new Node(5);
    root.right.left = new Node(6);
    root.right.right = new Node(7);
    preOrderByMorris(root);
    mediumOrderByMorris(root);

}
複製程式碼

這裡值得注意的是:morris遍歷會來到一個左孩子不為空的結點兩次,而其它結點只會經過一次。因此使用morris遍歷列印先序序列時,如果來到的結點無左孩子,那麼直接列印即可(這種結點只會經過一次),否則如果來到的結點的左子樹的最右結點的右孩子為空才列印(這是第一次來到該結點的時機),這樣也就忽略了cur經過的結點序列中第二次出現的結點;而使用morris遍歷列印中序序列時,如果來到的結點無左孩子,那麼直接列印(這種結點只會經過一次,左中右,沒了左,直接列印中),否則如果來到的結點的左子樹的最右結點不為空時才列印(這是第二次來到該結點的時機),這樣也就忽略了cur經過的結點序列中第一次出現的重複結點。

後序序列

使用morris遍歷得到二叉樹的後序序列就沒那麼容易了,因為對於樹種的非葉結點,morris遍歷最多會經過它兩次,而我們後序遍歷實在第三次來到該結點時列印該結點的。因此要想得到後序序列,僅僅改變在morris遍歷時列印結點的時機是無法做到的。

但其實,在morris遍歷過程中,如果在每次遇到第二次經過的結點時,將該結點的左子樹的右邊界上的結點從下到上列印,最後再將整個樹的右邊界從下到上列印,最終就是這個數的後序序列:

左神直通BAT演算法(進階篇)-上

左神直通BAT演算法(進階篇)-上

左神直通BAT演算法(進階篇)-上

左神直通BAT演算法(進階篇)-上

其中無非就是在morris遍歷中在第二次經過的結點的時機執行一下列印操作。而從下到上列印一棵樹的右邊界,可以將該右邊界上的結點看做以right指標為後繼指標的連結串列,將其反轉reverse然後列印,最後恢復成原始結構即可。示例程式碼如下(其中容易犯錯的地方是18行和19行的程式碼不能調換):

public static void posOrderByMorris(Node root) {
    if (root == null) {
        return;
    }
    Node cur = root;
    while (cur != null) {
        if (cur.left == null) {
            cur = cur.right;
        } else {
            Node mostRight = cur.left;
            while (mostRight.right != null && mostRight.right != cur) {
                mostRight = mostRight.right;
            }
            if (mostRight.right == null) {
                mostRight.right = cur;
                cur = cur.left;
            } else {
                mostRight.right = null;
                printRightEdge(cur.left);
                cur = cur.right;
            }
        }
    }
    printRightEdge(root);
}

private static void printRightEdge(Node root) {
    if (root == null) {
        return;
    }
    //reverse the right edge
    Node cur = root;
    Node pre = null;
    while (cur != null) {
        Node next = cur.right;
        cur.right = pre;
        pre = cur;
        cur = next;
    }
    //print 
    cur = pre;
    while (cur != null) {
        System.out.print(cur.data + " ");
        cur = cur.right;
    }
    //recover
    cur = pre;
    pre = null;
    while (cur != null) {
        Node next = cur.right;
        cur.right = pre;
        pre = cur;
        cur = next;
    }
}

public static void main(String[] args) {
    Node root = new Node(1);
    root.left = new Node(2);
    root.right = new Node(3);
    root.left.left = new Node(4);
    root.left.right = new Node(5);
    root.right.left = new Node(6);
    root.right.right = new Node(7);
    posOrderByMorris(root);
}
複製程式碼

時間複雜度分析

因為morris遍歷中,只有左孩子非空的結點才會經過兩次而其它結點只會經過一次,也就是說遍歷的次數小於2N,因此使用morris遍歷得到先序、中序序列的時間複雜度自然也是O(1);但產生後序序列的時間複雜度還要算上printRightEdge的時間複雜度,但是你會發現整個遍歷的過程中,所有的printRightEdge加起來也只是遍歷並列印了N個結點:

左神直通BAT演算法(進階篇)-上

因此時間複雜度仍然為O(N)

morris遍歷結點的順序不是先序、中序、後序,而是按照自己的一套標準來決定接下來要遍歷哪個結點。

morris遍歷的獨特之處就是充分利用了葉子結點的無效引用(引用指向的是空,但該引用變數仍然佔記憶體),從而實現了O(1)的時間複雜度。

求和為aim的最長子陣列長度

舉例:陣列[7,3,2,1,1,7,-6,-1,7]中,和為7的最長子陣列長度為4。(子陣列:陣列中任意個連續的陣列成的陣列)

大前提:如果我們求出以陣列中每個數結尾的所有子陣列中和為aim的子陣列,那麼答案一定就在其中。

規律:對於陣列[i,……,k,k+1,……,j],如果要求aim為800,而我們知道從i累加到j的累加和為2000,那麼從i開始向後累加,如果累加到k時累加和才達到1200,那麼k+1~j就是整個陣列中累加和為800的最長子陣列。

步驟:以[7,3,2,1,1,7,-6,-3,7]aim=7為例,

  • 首先將(0,-1)放入HashMap中,代表0這個累加和在還沒有遍歷時就出現了。->(0,-1)
  • 接著每遍歷一個數就將該位置形成的累加和存入HashMap,比如arr[0]=7,0位置上形成的累加和為前一個位置形成的累加和0加上本位置上的7,因此將(7,0)放入HashMap中表示0位置上第一次形成累加和為7,然後將該位置上的累加和減去aim,即7-7=0,找第一次形成累加和為0的位置,即-1,因此以下標為0結尾的子陣列中和為aim的最長子陣列為0~0,即7一個元素,記最大長度maxLength=1->(7,0)
  • 接著來到arr[1]=3,1位置上形成的累加和為7+3=10HashMap中沒有key10的記錄,因此放入(10,1)表示1位置上最早形成累加和為10,然後將該位置上的累加和減去aim10-7=3,到HashMap中找有沒有key3的記錄(有沒有哪個位置最早形成累加和為3),發現沒有,因此以下標為1結尾的子陣列中沒有累加和為aim的。->(10,1)
  • 接著來到arr[2]=2,2位置上形成的累加和為10+2=12HashMap中沒有key12的記錄,因此放入(12,2)sum-aim=12-7=5,到HashMap中找有沒有key5的記錄,發現沒有,因此以下標為2結尾的子陣列中沒有累加和為aim的。->(12,2)
  • 來到arr[3]=1,放入(13,3)sum-aim=5,以下標為3結尾的子陣列沒有累加和為aim的。->(13,3)
  • 來到arr[4]=1,放入(14,4)sum-aim=7,發現HashMap中有key=7的記錄 (7,0),即在0位置上累加和就能達到7了,因此1~4是以下標為4結尾的子陣列中累積和為7的最長子陣列,更新maxLength=4->(14,4)
  • 來到arr[5]=7,放入(21,5)sum-aim=14HashMap中有(14,4),因此5~5是本輪的最長子陣列,但maxLength=4>1,因此不更新。->(21,5)
  • 來到arr[6]=-6,放入15,6,沒有符合的子陣列。->(15,6)
  • 來到arr[7]=-1,累加和為15+(-1)=14,但 HashMap中有key=14的記錄,因此不放入(14,7)HashMap中儲存的是某累加和第一次出現的位置,而14這個了累加和最早在4下標上就出現了)。sum-aim=7HashMap中有(7,0),因此本輪最長子陣列為1~7,因此更新maxLength=7
  • 來到arr[8]=7,累加和為21,存在key為21的記錄,因此不放入(21,7)。sum-aim=14,本輪最長子陣列為5~8,長度為4,不更新maxLength

示例程式碼:

public static int maxLength(int[] arr,int aim) {
    //key->accumulate sum   value->index
    HashMap<Integer, Integer> hashMap = new HashMap<>();
    hashMap.put(0, -1);
    int curSum = 0;
    int maxLength = 0;
    for (int i = 0; i < arr.length; i++) {
        curSum += arr[i];
        if (!hashMap.containsKey(curSum)) {
            hashMap.put(curSum, i);
        }
        int gap = curSum - aim;
        if (hashMap.containsKey(gap)) {
            int index = hashMap.get(gap);
            maxLength = Math.max(maxLength, i - index);
        }
    }
    return maxLength;
}

public static void main(String[] args) {
    int arr[] = {7, 3, 2, 1, 1, 7, -6, -1, 7};
    int aim = 7;
    System.out.println(maxLength(arr, aim));//7
}
複製程式碼

擴充

求奇數個數和偶數個數相同的最長子陣列長度

將奇數置為1,偶數置為-1,就轉化成了求和為0的最長子陣列長度

求數值為1的個數和數值為2的個數相同的最長子陣列(陣列只含0、1、2三種元素)

將2置為-1,就轉化成了求和為0的最長子陣列長度

進階

求任意劃分陣列的方案中,劃分後,異或和為0的子陣列最多有多少個

舉例:給你一個陣列[1,2,3,0,2,3,1,0],你應該劃分為[1,2,3],[0],[2,3,1],[0],答案是4。

大前提:如果我們求出了以陣列中每個數為結尾的所有子陣列中,任意劃分後,異或和為0的子陣列最多有多少個,那麼答案一定就在其中。

規律:異或運算子合交換律和結合律。0^N=NN^N=0

可能性分析:對於一個陣列[i,……,j,m,……,n,k],假設進行符合題意的最優劃分後形成多個子陣列後,k作為整個陣列的末尾元素必定也是最後一個子陣列的末尾元素。最後一個子陣列只會有兩種情況:異或和不為0、異或和為0。

  • 如果是前者,那麼最後一個子陣列即使去掉k這個元素,其異或和也不會為0,否則最優劃分會將最後一個子陣列劃分為兩個子陣列,其中k單獨為一個子陣列。比如最後一個子陣列是indexOf(m)~indexOf(k),其異或和不為0,那麼dp[indexOf(k)]=dp[indexOf(k)-1],表示陣列0~indexOf(k)的解和其子陣列0~(indexOf(k)-1)的解是一樣的。->case 1
  • 如果是後者,那麼最後一個子陣列中不可能存在以k為結尾的更小的異或和為0的子陣列。比如最後一個子陣列是indexOf(m)~indexOf(k),其異或和為0,那麼dp[indexOf(k)]=dp[indexOf(m)-1]+1,表示陣列0~indexOf(k)的解=子陣列0~(indexOf(m)-1)的解+1。->case 2

示例程式碼:

public static int maxSubArrs(int[] arr) {
    if (arr == null) {
        return 0;
    }
    HashMap<Integer, Integer> map = new HashMap();
    map.put(0, -1);
    int curXorSum = 0;
    int res = 0;
    int[] dp = new int[arr.length];
    for (int i = 0; i < arr.length; i++) {
        curXorSum ^= arr[i];
        //case 1,之前沒有出現過這個異或和,那麼該位置上的dp等於前一個位置的dp
        if (!map.containsKey(curXorSum)) {
            dp[i] = i > 0 ? dp[i - 1] : 0;
        } else {
            //case 2,之前出現過這個異或和,那麼之前這個異或和出現的位置到當前位置形成的子陣列異或和為0
            int index = map.get(curXorSum);
            dp[i] = index > 0 ? dp[index] + 1 : 1;
        }
        //把最近出現的異或和都記錄下來,因為要劃分出最多的異或和為0的子陣列
        map.put(curXorSum, i);
    }
    //最後一個位置的dp就是整個問題的解
    return dp[dp.length -1];
}

public static void main(String[] args) {
    int arr[] = {1, 2, 3, 0, 2, 3, 1, 0,4,1,3,2};
    System.out.println(maxSubArrs(arr));
}
複製程式碼

高度套路的二叉樹資訊收集問題

求一棵二叉樹的最大搜尋二叉子樹的結點個數

最大搜尋二叉子樹指該二叉樹的子樹中,是搜尋二叉樹且結點個數最多的。

這類題一般都有一個大前提假設對於以樹中的任意結點為頭結點的子樹,我們都能求得其最大搜尋二叉子樹的結點個數,那麼答案一定就在其中

而對於以任意結點為頭結點的子樹,其最大搜尋二叉子樹的求解分為三種情況(列出可能性):

  • 整棵樹的最大搜尋二叉子樹存在於左子樹中。這要求其左子樹中存在最大搜尋二叉子樹,而其右子樹不存在。
  • 整棵樹的最大搜尋二叉子樹存在於右子樹中。這要求其右子樹中存在最大搜尋二叉子樹,而其左子樹不存在。
  • 最整棵二叉樹的最大搜尋二叉子樹就是其本身。這需要其左子樹就是一棵搜尋二叉子樹且左子樹的最大值結點比頭結點小、其右子樹就是一棵搜尋二叉子樹且右子樹的最小值結點比頭結點大。

要想區分這三種情況,我們需要收集的資訊:

  • 子樹中是否存在最大搜尋二叉樹
  • 子樹的頭結點
  • 子樹的最大值結點
  • 子樹的最小值結點

因此我們就可以開始我們的高度套路了:

  1. 將要從子樹收集的資訊封裝成一個ReturnData,代表處理完這一棵子樹要向上級返回的資訊。
  2. 假設我利用子過程收集到了子樹的資訊,接下來根據子樹的資訊和分析問題時列出的情況加工出當前這棵樹要為上級提供的所有資訊,並返回給上級(整合資訊)。
  3. 確定base case,子過程到子樹為空時,停。

根據上面高度套路的分析,可以寫出解決這類問題高度相似的程式碼:

public static class Node{
    int data;
    Node left;
    Node right;
    public Node(int data) {
        this.data = data;
    }
}

public static class ReturnData {
    int size;
    Node head;
    int max;
    int min;
    public ReturnData(int size, Node head, int max, int min) {
        this.size = size;
        this.head = head;
        this.max = max;
        this.min = min;
    }
}

public static ReturnData process(Node root) {
    if (root == null) {
        return new ReturnData(0, null, Integer.MIN_VALUE, Integer.MAX_VALUE);
    }
    
    ReturnData leftInfo = process(root.left);
    ReturnData rightInfo = process(root.right);
    
    //case 1
    int leftSize = leftInfo.size;
    //case 2
    int rightSize = rightInfo.size;
    int selfSize = 0;
    if (leftInfo.head == root.left && rightInfo.head == root.right
        && leftInfo.max < root.data && rightInfo.min > root.data) {
        //case 3
        selfSize = leftInfo.size + rightInfo.size + 1;
    }
    int maxSize = Math.max(Math.max(leftSize, rightSize), selfSize);
    Node maxHead = leftSize > rightSize ? leftInfo.head : 
    				selfSize > rightSize ? root : rightInfo.head;
    
    return new ReturnData(maxSize, maxHead, 
                          Math.max(Math.max(leftInfo.max, rightInfo.max), root.data), 
                          Math.min(Math.min(leftInfo.min, rightInfo.min), root.data));
}

public static void main(String[] args) {
    Node root = new Node(0);
    root.left = new Node(5);
    root.right = new Node(1);
    root.left.left = new Node(3);
    root.left.left.left = new Node(2);
    root.left.left.right = new Node(4);
    System.out.println(process(root).size);//4
}
複製程式碼

求一棵二叉樹的最遠距離

如果在二叉樹中,小明從結點A出發,既可以往上走到達它的父結點,又可以往下走到達它的子結點,那麼小明從結點A走到結點B最少要經過的結點個數(包括A和B)叫做A到B的距離,任意兩結點所形成的距離中,最大的叫做樹的最大距離。

高度套路化

大前提:如果對於以該樹的任意結點作為頭結點的子樹中,如果我們能夠求得所有這些子樹的最大距離,那麼答案就在其中。

對於該樹的任意子樹,其最大距離的求解分為以下三種情況:

  • 該樹的最大距離是左子樹的最大距離。
  • 該樹的最大距離是右子樹的最大距離。
  • 該樹的最大距離是從左子樹的最深的那個結點經過該樹的頭結點走到右子樹的最深的那個結點。

要從子樹收集的資訊:

  • 子樹的最大距離
  • 子樹的深度

示例程式碼:

public static class Node{
    int data;
    Node left;
    Node right;
    public Node(int data) {
        this.data = data;
    }
}

public static class ReturnData{
    int maxDistance;
    int height;
    public ReturnData(int maxDistance, int height) {
        this.maxDistance = maxDistance;
        this.height = height;
    }
}

public static ReturnData process(Node root){
    if (root == null) {
        return new ReturnData(0, 0);
    }
    ReturnData leftInfo = process(root.left);
    ReturnData rightInfo = process(root.right);

    //case 1
    int leftMaxDistance = leftInfo.maxDistance;
    //case 2
    int rightMaxDistance = rightInfo.maxDistance;
    //case 3
    int includeHeadDistance = leftInfo.height + 1 + rightInfo.height;

    int max = Math.max(Math.max(leftMaxDistance, rightMaxDistance), includeHeadDistance);
    return new ReturnData(max, Math.max(leftInfo.height, rightInfo.height) + 1);
}

public static void main(String[] args) {
    Node root = new Node(0);
    root.left = new Node(5);
    root.right = new Node(1);
    root.right.right = new Node(6);
    root.left.left = new Node(3);
    root.left.left.left = new Node(2);
    root.left.left.right = new Node(4);
    System.out.println(process(root).maxDistance);
}
複製程式碼

高度套路化:列出可能性->從子過程收集的資訊中整合出本過程要返回的資訊->返回

舞會最大活躍度

一個公司的上下級關係是一棵多叉樹,這個公司要舉辦晚會,你作為組織者已經摸清了大家的心理:一個員工的直 接上級如果到場,這個員工肯定不會來。每個員工都有一個活躍度的值(值越大,晚會上越活躍),你可以給某個員工發邀請函以決定誰來,怎麼讓舞會的氣氛最活躍?返回最大的活躍值。

舉例:

左神直通BAT演算法(進階篇)-上

如果邀請A來,那麼其直接下屬BCD一定不會來,你可以邀請EFGHJKL中的任意幾個來,如果都邀請,那麼舞會最大活躍度為A(2)+E(9)+F(11)+G(2)+H(4)+J(7)+K(13)+L(5);但如果選擇不邀請A來,那麼你可以邀請其直接下屬BCD中任意幾個來,比如邀請B而不邀請CD,那麼B的直接下屬E一定不回來,但CD的直接下屬你可以選擇性邀請。

大前提:如果你知道每個員工來舞會或不來舞會對舞會活躍值的影響,那麼舞會最大活躍值就容易得知了。比如是否邀請A來取決於:B來或不來兩種情況中選擇對舞會活躍值增益最大的那個+C來或不來兩種情況中選擇對舞會活躍值增益最大的那個+D來或不來兩種情況中選擇對舞會活躍值增益最大的那個;同理,對於任意一名員工,是否邀請他來都是用此種決策。

列出可能性:來或不來。

子過程要收集的資訊:返回子員工來對舞會活躍值的增益值和不來對舞會的增益值中的較大值。

示例程式碼:

public static class Node{
    int happy;
    List<Node> subs;
    public Node(int happy) {
        this.happy = happy;
        this.subs = new ArrayList<>();
    }
}

public static class ReturnData {
    int maxHappy;
    public ReturnData(int maxHappy) {
        this.maxHappy = maxHappy;
    }
}

public static ReturnData process(Node root) {
    if (root.subs.size() == 0) {
        return new ReturnData(root.happy);
    }
    //case 1:go
    int go_Happy = root.happy;
    //case 2:don't go
    int unGo_Happy = 0;
    for (Node sub : root.subs) {
        unGo_Happy += process(sub).maxHappy;
    }
    return new ReturnData(Math.max(go_Happy, unGo_Happy));
}

public static int maxPartyHappy(Node root) {
    if (root == null) {
        return 0;
    }
    return process(root).maxHappy;
}

public static void main(String[] args) {
    Node A = new Node(2);
    Node B = new Node(8);
    Node C = new Node(5);
    Node D = new Node(24);
    B.subs.add(new Node(9));
    C.subs.addAll(Arrays.asList(new Node(11),new Node(2),new Node(4),new Node(7)));
    D.subs.addAll(Arrays.asList(new Node(13), new Node(5)));
    A.subs.addAll(Arrays.asList(B, C, D));
    System.out.println(maxPartyHappy(A));//57
}
複製程式碼

求一個數學表示式的值

給定一個字串str,str表示一個公式,公式裡可能有整數、加減乘除符號和左右括號,返回公式的計算結果。

舉例:str="48*((70-65)-43)+8*1",返回-1816。str="3+1*4",返回7。 str="3+(1*4)",返回7。

說明:

  1. 可以認為給定的字串一定是正確的公式,即不需要對str做公式有效性檢查。
  2. 如果是負數,就需要用括號括起來,比如"4*(-3)"。但如果負數作為公式的開頭或括號部分的開頭,則可以沒有括號,比如"-3*4"和"(-3*4)"都是合法的。
  3. 不用考慮計算過程中會發生溢位的情況

最優解分析:此題的難度在於如何處理表示式中的括號,可以藉助一個棧。但如果僅僅靠一個棧,程式碼量會顯得紛多繁雜。如果我們將式中包含左右括號的子表示式的計算單獨抽出來作為一個過程(記為process),那麼該過程可以被複用,如果我們將整個表示式中所有包含左右括號的子表示式當做一個數值,那麼原始問題就轉化為計算不含括號的表示式了。

以表示式3+2*5-(7+2)*3為例分析解題步驟:

左神直通BAT演算法(進階篇)-上

示例程式碼:

public static int getValue(String exp){
    return process(exp.toCharArray(), 0)[0];
}

/**
     * @param exp   expression
     * @param index the start index of expression
     * @return int[], include two elements:the result and the endIndex
     */
public static int[] process(char[] exp, int index) {

    LinkedList que = new LinkedList();
    //下一個要往隊尾放的數
    int num = 0;
    //黑盒process返回的結果
    int sub[];

    while (index < exp.length && exp[index] != ')') {

        if (exp[index] >= '0' && exp[index] <= '9') {
            num = num * 10 + exp[index] - '0';
            index++;
        } else if (exp[index] != '(') {
            // +、-、*、/
            addNum(num, que);
            num = 0;
            que.addLast(String.valueOf(exp[index]));
            index++;
        } else {
            // '('
            sub = process(exp, index + 1);
            num = sub[0];
            index = sub[1] + 1;
        }
    }

    addNum(num, que);

    return new int[]{getSum(que), index};
}

private static int getSum(LinkedList<String> que) {
    int res = 0;
    boolean add = true;
    while (!que.isEmpty()) {
        int num = Integer.valueOf(que.pollFirst());
        res += add ? num : -num;
        if (!que.isEmpty()) {
            add = que.pollFirst().equals("+") ? true : false;
        }
    }
    return res;
}

private static void addNum(int num, LinkedList<String> que) {
    if (!que.isEmpty()) {
        String element = que.pollLast();
        if (element.equals("+") || element.equals("-")) {
            que.addLast(element);
        } else{
            // * or /
            Integer preNum = Integer.valueOf(que.pollLast());
            num = element.equals("*") ? (preNum * num) : (preNum / num);
        }
    }
    que.addLast(String.valueOf(num));
}

public static void main(String[] args) {
    String exp = "48*((70-65)-43)+8*1";
    System.out.println(getValue(exp));
    System.out.println(-48*38+8);
}
複製程式碼

求異或和最大的子陣列

給你一個陣列,讓你找出所有子陣列的異或和中,最大的是多少。

暴力解

遍歷陣列中的每個數,求出以該數結尾所有子陣列的異或和。

public static class NumTrie{
    TrieNode root;

    public NumTrie() {
        root = new TrieNode();
    }

    class TrieNode{
        TrieNode[] nexts;
        public TrieNode(){
            nexts = new TrieNode[2];
        }
    }

    public void addNum(int num) {
        TrieNode cur = root;
        for (int i = 31; i >= 0; i--) {
            int path = (num >> i) & 1;
            if (cur.nexts[path] == null) {
                cur.nexts[path] = new TrieNode();
            }
            cur = cur.nexts[path];
        }
    }

    /**
         * find the max value of xor(0,k-1)^xor(0,i)-> the max value of xor(k,i)
         * @param num -> xor(0,i)
         * @return
         */
    public int maxXor(int num) {
        TrieNode cur = root;
        int res = 0;
        for (int i = 31; i >= 0; i--) {
            int path = (num >> i) & 1;
            //如果是符號位,那麼儘量和它相同(這樣異或出來就是正數),如果是數值位那麼儘量和它相反
            int bestPath = i == 31 ? path : (path ^ 1);
            //如果貪心路徑不存在,就只能走另一條路
            bestPath = cur.nexts[bestPath] != null ? bestPath : (bestPath ^ 1);
            //記錄該位上異或的結果
            res |= (bestPath ^ path) << i;

            cur = cur.nexts[bestPath];
        }
        return res;
    }
}

public static int maxXorSubArray(int arr[]) {
    int maxXorSum = Integer.MIN_VALUE;
    NumTrie numTrie = new NumTrie();
    //沒有數時異或和為0,這個也要加到字首數中,否則第一次到字首樹找bestPath會報空指標
    numTrie.addNum(0);
    int xorZeroToI = 0;
    for (int i = 0; i < arr.length; i++) {
        xorZeroToI ^= arr[i];
        maxXorSum = Math.max(maxXorSum, numTrie.maxXor(xorZeroToI));
        numTrie.addNum(xorZeroToI);
    }
    return maxXorSum;
}


public static void main(String[] args) {
    int[] arr = {1, 2, 3, 4, 1, 2, -7};
    System.out.println(maxXorSubArray(arr));
}
複製程式碼

時間複雜度為O(N^3)

優化暴力解

觀察暴力解,以 {1, 2, 3, 4, 1, 2, 0}為例,當我計算以4結尾的所有子陣列的異或和時,我會先計運算元陣列{4}的,然後計算{3,4}的,然後計算{2,3,4}的,也就是說每次都是從頭異或到尾,之前的計算的結果並沒有為之後的計算過程加速。於是,我想著,當我計算{3,4}的時候,將3^4的結果臨時儲存一下,在下次的{2,3,4}的計算時複用一下,再儲存一下2^3^4的結果,在下次的{1,2,3,4}的計算又可以複用一下。於是暴力解就被優化成了下面這個樣子:

public static int solution2(int[] arr) {
    int res = 0;
    int temp=0;
    for (int i = 0; i < arr.length; i++) {
        //以i結尾的最大異或和
        int maxXorSum = 0;
        for (int j = i; j >= 0; j--) {
            temp ^= arr[j];
            maxXorSum = Math.max(maxXorSum, temp);
        }
        //整體的最大異或和
        res = Math.max(res, maxXorSum);
    }
    return res;
}

public static void main(String[] args) {
    int[] arr = {1, 2, 3, 4, 1, 2, 0};
    System.out.println(solution2(arr));//7
}
複製程式碼

這時時間複雜度降為了O(N^2)

最優解

然而使用字首樹結構能夠做到時間複雜度O(N)

解題思路:將以i結尾的所有子陣列的最大異或和的求解限制在O(1)

解題技巧:

  1. 對於子陣列0~i(i是合法下標)和0~i之間的下標k(k大於等於0,小於等於i),k~i的異或和xor(k,i)0~i的異或和xor(0,i)0~k-1之間的異或和xor(0,k-1)三者之間存在如下關係:xor(k,i)=xor(0,i) ^ xor(o,k-1)A^B=C -> B=C^A),因此求xor(k,i)的最大值可以轉化成求xor(0,i) ^ xor(o,k-1)的最大值(這個思路很重要,後續步驟就是根據這個來的)。

  2. 遍歷陣列,將以首元素開頭,以當前遍歷元素結尾的子陣列的異或和的32位二進位制數放入字首樹結構中(每一位作為一個字元,且字元非0即1)。遍歷結束後,所有0~i的異或和就存放在字首樹中了。比如:遍歷{1, 2, 3, 4, 1, 2, 0}形成的字首樹如下:

    左神直通BAT演算法(進階篇)-上

  3. 假設遍歷陣列建立字首樹的過程中,遍歷到4這個數來了,將0 100放入其中,由於之前還遍歷過1,2,3,所以xor(0,0)xor(0,1)xor(0,2)也是在字首樹中的。如果此時要求xor(k,3)的最大值(k在下標0和3之間且包括0和3),可以將其轉化為求xor(0,3) ^ xor(0,k-1),而我們已知xor(0,3)=0 100,所以xor(0,k-1)的求解就變成了關鍵。

    xor(0,k-1)的求解:此時遊標cur從字首樹的根結點走向葉子結點,cur沿途經過的二進位制位連在一起就是xor(0,k-1),要求每次選擇要經過哪個二進位制位時,儘可能使之與xor(0,3)的異或結果更大:

    左神直通BAT演算法(進階篇)-上

    這個求解過程就是在貪心(如果是符號位,那麼儘可能讓異或結果為0,如果是數值位,那麼儘可能讓異或結果為1),字首樹裡只放著xor(0,0)、xor(0,1)、xor(0,2)、xor(0,3),而xor(0,k-1)只能從中取值,這個從根節點一步步試探走到葉子結點的過程就是在貪,哪一條路徑對應的xor使得xor ^ xor(0,3)最大。

    示例程式碼:

    public static class NumTrie{
        TrieNode root;
    
        public NumTrie() {
            root = new TrieNode();
        }
    
        class TrieNode{
            TrieNode[] nexts;
            public TrieNode(){
                nexts = new TrieNode[2];
            }
        }
    
        public void addNum(int num) {
            TrieNode cur = root;
            for (int i = 31; i >= 0; i--) {
                int path = (num >> i) & 1;
                if (cur.nexts[path] == null) {
                    cur.nexts[path] = new TrieNode();
                }
                cur = cur.nexts[path];
            }
        }
    
        /**
             * find the max value of xor(0,k-1)^xor(0,i)-> the max value of xor(k,i)
             * @param num -> xor(0,i)
             * @return 
             */
        public int maxXor(int num) {
            TrieNode cur = root;
            int res = 0;
            for (int i = 31; i >= 0; i--) {
                int path = (num >> i) & 1;
                //如果是符號位,那麼儘量和它相同(這樣異或出來就是正數),如果是數值位那麼儘量和它相反
                int bestPath = i == 31 ? path : (path ^ 1);
                //如果貪心路徑不存在,就只能走另一條路
                bestPath = cur.nexts[bestPath] != null ? bestPath : (bestPath ^ 1);
                //記錄該位上異或的結果
                res |= (bestPath ^ path) << i;
    
                cur = cur.nexts[bestPath];
            }
            return res;
        }
    }
    
    public static int maxXorSubArray(int arr[]) {
        int maxXorSum = 0;
        NumTrie numTrie = new NumTrie();
        //一個數自己異或自己異或和為0,這個也要加到字首數中,否則第一次到字首樹找bestPath會報空指標
        numTrie.addNum(0);
        int xorZeroToI = 0;
        for (int i = 0; i < arr.length; i++) {
            xorZeroToI ^= arr[i];
            maxXorSum = Math.max(maxXorSum, numTrie.maxXor(xorZeroToI));
            numTrie.addNum(xorZeroToI);
        }
        return maxXorSum;
    }
    
    
    public static void main(String[] args) {
        int[] arr = {1, 2, 3, 4, 1, 2, -7};
        System.out.println(maxXorSubArray(arr));//7
    }
    複製程式碼

求和為aim的最長子陣列(都大於0)

基礎篇中有過相同的題,只不過這裡的陣列元素值為正數,而基礎篇中的可正可負可0。

基礎篇中的做法是用一個雜湊表記錄子陣列和出現的最早的位置。而此題由於資料特殊性(都是正數)可以在額外空間複雜度O(1),時間複雜度O(N)內完成。

使用一個視窗,用L表示視窗的左邊界、R表示視窗的右邊界,用sum表示視窗內元素之和(初始為0)。起初,L和R都停在-1位置上,接下來每次都要將L向右擴一步或將R向右擴一步,具體擴哪個視情況而定:

  • 如果sum<aim,那麼R往右邊擴
  • 如果sum=aim,那麼記錄視窗內元素個數,L往右邊擴
  • 如果sum>aim,那麼L往右邊擴

直到R擴到arr.length越界,那麼此時視窗內元素之和必定小於aim,整個過程可以結束。答案就是所有sum=aim情況下視窗內元素最多時的個數。

示例程式碼:

/**
     * 陣列元素均為正數,求和為aim的最長子陣列的長度
     * @param arr
     * @return
     */
public static int aimMaxSubArray(int arr[],int aim) {
    int L=-1;
    int R= -1;
    int sum = 0;
    int len=0;
    while (R != arr.length) {
        if (sum < aim) {
            R++;
            if (R < arr.length) {
                sum += arr[R];
            } else {
                break;
            }
        } else if (sum == aim) {
            len = Math.max(len, R - L);
            sum -= arr[++L];
        } else {
            sum -= arr[++L];
        }
    }
    return len;
}

public static void main(String[] args) {
    int arr[] = {1, 2, 3, 5, 1, 1, 1, 1, 1, 1, 9};
    System.out.println(aimMaxSubArray(arr,6));
}
複製程式碼

思考:為什麼這個流程得到的答案是正確的呢?也就是說,為什麼視窗向右滑動的過程中,不會錯過和為aim的最長子陣列?我們可以來證明一下:

左神直通BAT演算法(進階篇)-上

假設,橢圓區域就是和為aim的最長子陣列,如果L來到了橢圓區域的左邊界L2,那麼R的位置有兩種情況:在橢圓區域內比如R1,在橢圓區域外比如R2。如果是前者,由於視窗L2~R1是肯定小於aim的(元素都是正數),因此在R從R1右移到橢圓區域右邊界過程中,L是始終在L2上的,顯然不會錯過正確答案;如果是後者,視窗L2~R2sum明顯超過了aim,因此這種情況是不可能存在的。而L在L2左邊的位置上,比如L1時,R更不可能越過橢圓區域來到了R2,因為視窗是始終保持sum<=aim的。

求和小於等於aim的最長子陣列(有正有負有0)

如果使用暴力列舉,列舉出以每個元素開頭的子陣列,那麼答案一定就在其中(O(N^3))。但這裡介紹一種時間複雜度O(N)的解。

首先從尾到頭遍歷一遍陣列,生成兩個輔助陣列min_summin_sum_index作為求解時的輔助資訊。min_sum表示以某個元素開頭的所有子陣列中和最小為多少,min_sum_index則對應儲存該最小和子陣列的結束下標。

舉例:對於[100,200,7,-6]

  1. 首先遍歷3位置上的-6,以-6開頭的子陣列只有[-6],因此min_sum[3] = -6, min_sum_index[3] = 3[-6]的尾元素-6在原陣列中的下標是3)。
  2. 接著遍歷到2位置上的7,以7開頭的最小和子陣列是[7,-6],因此min_sum[2] = 7-6 = 1, min_sum_index[2]=3。([7,-6]的尾元素-6在原陣列中的下標是3)。
  3. 接著遍歷到1位置上的200,有min_sum[1] = 200, min_sum_index[1] = 1
  4. 接著遍歷到0位置上的100,有min_sum[0] = 100, min_sum_index[0] = 0

那麼遍歷完陣列,生成兩個輔助陣列之後,就可以開始正式的求解流程了:

使用一個視窗,L表示視窗的左邊界,R表示視窗的右邊界,sum表示視窗內元素之和。

  • L從頭到尾依次來到陣列中的每個元素,每次L來到其中一個元素上時,都嘗試向右擴R,R擴到不能擴時,視窗大小R-L即為以該元素開頭的、和小於等於aim的最長子陣列的長度。
  • L起初來到首元素,R起初也停在首元素,sum=0
  • R向右擴一次的邏輯是:如果sum + min_sum[L] <= aim,那麼R就擴到min_sum_index[L] + 1的位置,並更新sum
  • R擴到不能擴時,記錄R-L,L去往下一個元素,並更新sum
  • 如果L來到一個元素後,sum > aim,說明以該元素開頭的、和小於等於aim的最長子陣列的長度,比當前的視窗大小R-L還要小,那麼以該元素開頭的子陣列不在正確答案的考慮範圍之內(因為上一個元素形成的最大視窗大於當前元素能形成的最大視窗,並且前者已經被記錄過了),L直接去往一下個元素並更新sum

左神直通BAT演算法(進階篇)-上

示例程式碼:

public static int lessOrEqualAim(int arr[], int aim) {
    int min_sum[] = new int[arr.length];
    int min_sum_index[] = new int[arr.length];
    min_sum[arr.length-1] = arr[arr.length - 1];
    min_sum_index[arr.length-1] = arr.length - 1;
    for (int i = arr.length - 2; i >= 0; i--) {
        if (min_sum[i + 1] < 0) {
            min_sum[i] = arr[i] + min_sum[i + 1];
            min_sum_index[i] = min_sum_index[i + 1];
        } else {
            min_sum[i] = arr[i];
            min_sum_index[i] = i;
        }
    }

    int R = 0;
    int sum = 0;
    int maxLen = 0;
    for (int L = 0; L < arr.length; L++) {
        while (R < arr.length && sum + min_sum[R] <= aim) {
            sum += min_sum[R];
            R = min_sum_index[R] + 1;
        }
        maxLen = Math.max(maxLen, R - L);
        sum -= R == L ? 0 : arr[L];
        R = Math.max(R, L + 1);
    }
    return maxLen;
}

public static void main(String[] args) {
    int arr[] = {1, 2, 3, 2, -1, -1, 1, 1, -1, -1, 9};
    System.out.println(lessOrEqualAim(arr,3));//8
}
複製程式碼

19-27行是實現的難點,首先19行是L從頭到尾來到陣列中的每個元素,然後20-23while是嘗試讓R擴直到R擴不動為止,24行當R擴不動時就可以記錄以當前L位置上的元素開頭的、和小於等於aim的最長子陣列長度,最後在進入下一次for迴圈、L右移一步之前,sum的更新有兩種情況:

  1. 29行的while執行了,R擴出去了,因此sum直接減去當前L上的元素即可。
  2. 29行的while壓根就沒執行,R一步都沒擴出去且和L在同一位置上,也就是說此刻視窗內沒有元素(只有當R>L時,視窗才包含從L開始到R之前的元素),sum=0,L和R應該同時來到下一個元素,sum仍為0,所以sum不必減去arr[L](只有當L右移導致一個元素從視窗出去時才需要減arr[L])。

最後26行也是為了保證如果L在右移的過程中,R一直都擴不出去,那麼在L右移到R上R仍舊擴不出去時,接下來R應該和L同時右移一個位置。

此方法能夠做到O(N)時間複雜度的關鍵點是:捨去無效情況。比如L在右移一步更新sum之後,如果發現sum > aim,顯然以當前L開頭的、和小於等於aim的最長子陣列肯定小於當前的R-L,而在上一步就記錄了R-(L-1),以當前L開頭的滿足條件的子陣列可以忽略掉(因為一定小於R-(L-1)),而不必讓R回退到當前L重新來擴R。

這樣L和R都只右移而不回退,所以時間複雜度就是遍歷了一遍陣列。

環形單連結串列的約瑟夫問題

據說著名猶太曆史學家Josephus有過以下故事:在羅馬人佔領喬塔帕特後,39個猶太人與Josephus及他的朋友躲到一個洞中,39個猶太人決定寧願死也不要被敵人抓到,於是決定了一個自殺方式,41個人排成一個圓圈,由第1個人開始報數,報數到3的人就自殺,然後再由下一個人重新報1,報數到3的人再自殺,這樣依次下去,直到剩下最後一個人時,那個人可以自由選擇自己的命運。這就是著名的約瑟夫問題。現在請用單向環形連結串列描述該結構並呈現整個自殺過程。

輸入:一個環形單向連結串列的頭節點head和報數的值m。

返回:最後生存下來的節點,且這個節點自己組成環形單向連結串列,其他節點都刪掉。

進階:如果連結串列節點數為N,想在時間複雜度為O(N)時完成原問題的要求,該怎麼實現?

暴力方法:從頭結點開始數,從1數到m,數到m時刪除結點,再從下一個結點開始數……如此要刪除(n-1)個結點,並且每次刪除之前要數m個數,因此時間複雜度為O(NxM)

這裡介紹一種O(N)的方法。

首先介紹一個函式:

左神直通BAT演算法(進階篇)-上

如果從頭結點開始,為每個結點依次編號1、2、3、……,比如環形連結串列有3個結點,每次報數到7時殺人:

結點編號 報數
1 1
2 2
3 3
1 4
2 5
3 6
1 殺人

那麼在殺人之前,結點編號和報數有如下對應關係(x軸代表此刻報數報到哪兒了,y軸則對應是幾號結點報的,n是結點數量):

左神直通BAT演算法(進階篇)-上

假設每次殺人後,都從下一結點重新編號、重新報數,比如環形連結串列有9個結點,報數到7就殺人,那麼殺人之前結點的舊編號和殺人重新編號後結點的新編號有如下關係:

舊編號 新編號
1 3
2 4
3 5
4 6
5 7
6 8
7 被殺,從下一結點開始重新編號
8 1
9 2

如果連結串列結點數為n,報數到m殺人,那麼結點的新舊編號對應關係如下(其中s為報數為m的結點編號):

左神直通BAT演算法(進階篇)-上

這個圖也可以由基本函式y = (x - 1) % n + 1向左平移s個單位長度變換而來:

左神直通BAT演算法(進階篇)-上

y = (x - 1 + s) % n + 1

現在我們有了如下兩個公式:

  1. 結點編號 = (報數 - 1) % n + 1
  2. 舊編號 = (新編號 - 1 + s) % n +1,其中s為報數為m的結點編號

由1式可得s = (m - 1) % n + 1,帶入2式可得

  1. 舊編號 = (新編號 - 1 + (m - 1) % n + 1) % n + 1 = (新編號 + m - 1) % n + 1,其中mn由輸入引數決定。

現在我們有了等式3,就可以在已知一個結點在另一個結點被殺之後的新編號的情況下,求出該結點的舊編號。也就是說,假設現在殺到了第n-1個結點,殺完之後只剩下最後一個結點了(天選結點),重新編號後天選結點肯定是1號,那麼第n-1個被殺結點被殺之前天選結點的編號我們就可以通過等式3求出來,通過這個結果我們又能求得天選結點在第n-2個被殺結點被殺之前的編號,……,依次往回推就能還原一個結點都沒死時天選結點的編號,這樣我們就能從輸入的連結串列中找到該結點,直接將其後繼指標指向自己然後返回即可。

示例程式碼:

static class Node {
    char data;
    Node next;

    public Node(char data) {
        this.data = data;
    }
}

public static Node aliveNode(Node head, int m) {
    if (head == null) {
        return null;
    }
    int tmp = 1;
    Node cur = head.next;
    while (cur != head) {
        tmp++;
        cur = cur.next;
    }

    //第n-1次殺人前還有兩個結點,殺完之後天選結點的新編號為1
    //通過遞迴呼叫getAlive推出所有結點存活時,天選結點的編號
    int nodeNumber = getAlive(1, m, 2, tmp);

    cur = head;
    tmp = 1;
    while (tmp != nodeNumber) {
        cur = cur.next;
        tmp++;
    }
    cur.next = cur;
    return cur;
}

/**
     * 舊編號 = (新編號 + m - 1) % n + 1
     *
     * @param newNumber 新編號
     * @param m
     * @param n         舊編號對應的存活的結點個數
     * @param len       結點總個數
     * @return
     */
public static int getAlive(int newNumber, int m, int n, int len) {
    if (n == len) {
        return (newNumber + m - 1) % n + 1;
    }
    //計算出新編號對應的舊編號,將該舊編號作為下一次計算的新編號
    return getAlive((newNumber + m - 1) % n + 1, m, n + 1, len);
}

public static void main(String[] args) {
    Node head = new Node('a');
    head.next = new Node('b');
    head.next.next = new Node('c');
    head.next.next.next = new Node('d');
    head.next.next.next.next = new Node('e');
    head.next.next.next.next.next = head;

    System.out.println(aliveNode(head, 3).data);//d
}
複製程式碼

經典結構

視窗最大值更新結構

最大值更新結構

左神直通BAT演算法(進階篇)-上

當向此結構放資料時會檢查一下結構中的已有資料,從時間戳最大的開始檢查,如果檢查過程中發現該資料小於即將放入的資料則將其彈出並檢查下一個,直到即將放入的資料小於正在檢查的資料或者結構中的資料都被彈出了為止,再將要放入的資料放入結構中並蓋上時間戳。如此每次從該結構取資料時,都會返回結構中時間戳最小的資料,也是目前為止進入過此結構的所有資料中最大的那一個。

此結構可以使用一個雙端佇列來實現,一端只用來放資料(放資料之前的檢查過程可能會彈出其他資料),另一端用來獲取目前為止出現過的最大值。

示例如下:

package top.zhenganwen.structure;

import java.util.LinkedList;

public class MaxValueWindow {

  private LinkedList<Integer> queue;
  public MaxValueWindow() {
    this.queue = new LinkedList();
  }

  //更新視窗最大值
  public void add(int i){
    while (!queue.isEmpty() && queue.getLast() <= i) {
      queue.pollLast();
    }
    queue.add(i);
  }

  //獲取視窗最大值
  public int getMax() {
    if (!queue.isEmpty()) {
      return queue.peek();
    }
    return Integer.MIN_VALUE;
  }

  //使視窗最大值過期
  public void expireMaxValue() {
    if (!queue.isEmpty()) {
      queue.poll();
    }
  }

  public static void main(String[] args) {
    MaxValueWindow window = new MaxValueWindow();
    window.add(6);
    window.add(4);
    window.add(9);
    window.add(8);
    System.out.println(window.getMax());//9
    window.expireMaxValue();
    System.out.println(window.getMax());//8
  }
}
複製程式碼

例題

視窗移動

給你一個長度為N的整型陣列和大小為W的視窗,用一個長度為N-W+1的陣列記錄視窗從陣列由左向右移動過程中視窗內最大值。

對於陣列[1,2,3,4,5,6,7]和視窗大小為3,視窗由左向右移動時有:

  • [1,2,3],4,5,6,7,視窗起始下標為0時,框住的數是1,2,3,最大值是3
  • 1,[2,3,4],5,6,7,最大值是4
  • 1,2,[3,4,5],6,7,最大值是5
  • ……

因此所求陣列是[3,4,5,6,7]

思路:前面介紹的視窗最大值更新結構的特性是,先前放入的數如果還存在於結構中,那麼該數一定比後放入的數都大。此題視窗移動的過程就是從視窗中減一個數和增一個數的過程。拿[1,2,3],41,[2,3,4]這一過程分析:首先[1,2,3],4狀態下的視窗應該只有一個值3(因為先加了1,加2之前彈出了1,加3之前彈出了2);轉變為1,[2,3,4]的過程就是向視窗先減一個數1再加一個數4的過程,因為視窗中不含1所以直接加一個數4(彈出視窗中的3,加一個數4)。

程式碼示例:

public static void add(int arr[], int index, LinkedList<Integer> queue) {
  if (queue == null) {
    return;
  }
  while (!queue.isEmpty() && arr[queue.getLast()] < arr[index]) {
    queue.pollLast();
  }
  queue.add(index);
}

public static void expireIndex(int index, LinkedList<Integer> queue) {
  if (queue == null) {
    return;
  }
  if (!queue.isEmpty() && queue.peek() == index) {
    queue.pollFirst();
  }
}

public static int[] maxValues(int[] arr, int w) {
  int[] res = new int[arr.length - w + 1];
  LinkedList<Integer> queue = new LinkedList();
  for (int i = 0; i < w; i++) {
    add(arr, i, queue);
  }
  for (int i = 0; i < res.length; i++) {
    res[i] = queue.peek();
    if (i + w <= arr.length - 1) {
      expireIndex(i, queue);
      add(arr, i + w, queue);
    }
  }
  for (int i = 0; i < res.length; i++) {
    res[i] = arr[res[i]];
  }
  return res;
}

public static void main(String[] args) {
  int[] arr = {3, 2, 1, 5, 6, 2, 7, 8, 10, 6};
  System.out.println(Arrays.toString(maxValues(arr,3)));//[3, 5, 6, 6, 7, 8, 10, 10]
}
複製程式碼

這裡需要的注意的是,針對這道題將視窗最大值更新結構的addexpire方法做了改進(結構中存的是值對應的下標)。例如[2,1,2],-1->2,[1,2,-1],應當翻譯為[2,1,2],-1狀態下的視窗最大值為2下標上的數2,變為2,[1,2,-1]時應當翻譯為下標為0的數從視窗過期了,而不應該是資料2從視窗過期了(這樣會誤刪視窗中下標為2的最大值2)。

求達標的子陣列個數

給你一個整型陣列,判斷其所有子陣列中最大值和最小值的差值不超過num(如果滿足則稱該陣列達標)的個數。(子陣列指原陣列中任意個連續下標上的元素組成的陣列)

暴力解:遍歷每個元素,再遍歷以當前元素為首的所有子陣列,再遍歷子陣列找到其中的最大值和最小值以判斷其是否達標。很顯然這種方法的時間複雜度為o(N^3),但如果使用最大值更新結構,則能實現O(N)級別的解。

如果使用LR兩個指標指向陣列的兩個下標,且LR的左邊。當L~R這一子陣列達標時,可以推匯出以L開頭的長度不超過R-L+1的所有子陣列都達標;當L~R這一子陣列不達標時,無論L向左擴多少個位置或者R向右擴多少個位置,L~R還是不達標。

O(N)的解對應的演算法是:LR都從0開始,R先向右移動,R每右移一個位置就使用最大值更新結構和最小值更新結構記錄一下L~R之間的最大值和最小值的下標,當R移動到如果再右移一個位置L~R就不達標了時停止,這時以當前L開頭的長度不超過R-L+1的子陣列都達標;然後L右移一個位置,同時更新一下最大值、最小值更新結構(L-1下標過期了),再右移RR如果右移一個位置L~R就不達標了停止(每右移R一次也更新最大、小值更新結構)……;直到L到達陣列尾元素為止。將每次R停止時,R-L+1的數量累加起來就是O(N)的解,因為LR都只向右移動,並且每次R停止時,以L開頭的達標子串的數量直接通過R-L+1計算,所以時間複雜度就是將陣列遍歷了一遍即O(N)

示例程式碼:

public static int getComplianceChildArr(int arr[], int num) {
  //最大值、最小值更新結構
  LinkedList<Integer> maxq = new LinkedList();
  LinkedList<Integer> minq = new LinkedList<>();
  int L = 0;
  int R = 0;
  maxq.add(0);
  minq.add(0);
  int res = 0;
  while (L < arr.length) {
    while (R < arr.length - 1) {
      while (!maxq.isEmpty() && arr[maxq.getLast()] <= arr[R + 1]) {
        maxq.pollLast();
      }
      maxq.add(R + 1);
      while (!minq.isEmpty() && arr[minq.getLast()] >= arr[R + 1]) {
        minq.pollLast();
      }
      minq.add(R + 1);
      if (arr[maxq.peekFirst()] - arr[minq.peekFirst()] > num) {
        break;
      }
      R++;
    }
    res += (R - L + 1);
    if (maxq.peekFirst() == L) {
      maxq.pollFirst();
    }
    if (minq.peekFirst() == L) {
      minq.pollFirst();
    }
    L++;
  }
  return res;
}

public static void main(String[] args) {
  int[] arr = {1, 2, 3, 5};
  System.out.println(getComplianceChildArr(arr, 3));//9
}
複製程式碼

相關文章