后缀数组的应用:在哪个位置插入字符串使得字典序最大
题目描述
给定两个字符串 str1
和 str2
,想把 str2
整体插入到 str1
中某个位置,形成最大的字典序,返回字典序最大的结果。其中 str1
长度为 NNN, str2
长度为 MMM,且 N>>MN >> MN>>M。
思路分析
暴力解:尝试将 str2
插入到 str1
的每个位置,每次都会拼出一个 (N+M)(N+M)(N+M) 长度的字符串,然后和之前的字符串比较字典序,记录较大的字典序,所以时间复杂度为 O(N∗(N+M))O(N * (N + M))O(N∗(N+M)),而因为 N>>MN >> MN>>M,所以时间复杂度就是O(N2)O(N^2)O(N2)。
而使用DC3算法最终能将复杂度优化成 O(N+M)+O(M2)O(N + M) +O(M^2)O(N+M)+O(M2)。
DC3算法:如果 「str1
从0出发的后缀串的字典序」 ≥\\ge≥ 「str2
从0出发的后缀串的字典序」,则str2
没有必要插在 str1
的0位置之前。同理,如果 「str1
从 iii 出发的后缀串的字典序」 ≥\\ge≥ 「str2
从 iii 出发的后缀串的字典序」,则str2
没有必要插在 str1
的 iii 位置之前。
如果 str1
的每个位置开头的后缀串的字典序都是大于 str2
的0位置开头的后缀串的字典序,则str2
直接插入到 str1
后面即可,整个字符串就是 str1 + str2
。
如果 str1
的 iii 位置开头的后缀串的字典序是小于str2
的 0 位置开头的后缀串的字典序的,那么只能说str2
可以插在 iii 位置之前,也可以插在iii位置之后,但 iii 之前的位置这个插入点是不可忽略的。
举两个例子:
- 例1
str1:......993.... i
str2:994
此时 iii 位置开头的后缀串小于 str2 的字典序,但是str2应该插入到 (i+2)(i+2)(i+2) 位置处,整体变成 …999943… 才是最优,这种情况下str2并不是插入到 iii 位置之前。
- 例2
str1: ......431......
str2: 994
此时 iii 位置开头的后缀串小于 str2 的字典序,str2应该插入到 iii 位置前面,整体变成 …994431… 才是最优,这种情况下str2就是插入到 iii 位置之前。
也就是说,对于 iii 位置来说,找到了最左的可能性位置,即 (i−1)(i-1)(i−1) 和 iii 之间,而它的最右的可能性位置是从 iii 位置开头的情况下,到哪个位置的时候才能和 str2
分出大小。
举个例子:
str1: ...... 9 9 9 3 ...i i+1 i+2 i+3
str2:9994
从 iii 位置开始,到 (i+3)(i+3)(i+3)位置才和 str2
分出大小,对于 iii 位置来说,最左的可能性位置就是 (i−1)(i-1)(i−1) 和 iii 之间,最右的可能性位置就是 (i+2)(i+2)(i+2) 和 (i+3)(i+3)(i+3) 之间,所以 str2
要插入(i+2)(i+2)(i+2) 位置后面才最优。
假设str2
长度为 MMM,则 str1
从最左到最右的可能性的长度最多为 MMM,所以每次插入之后需要关心的字符串长度最多是 2M2M2M,比较该长度的字符串的字典序,时间复杂度为 O(M2)O(M^2)O(M2)。
流程梳理:
- 建立
str1
从 iii 位置出发的后缀串与str2
字符串的字典序比较的机制;【DC3算法】 - 依次尝试
str1
从0位置开始的后缀串与str2
大小的比较,直到 iii 位置与str2
的大小区分出来,此时知道了最左的可能性;【 O(N)O(N)O(N) 复杂度】 - 从
str1
的 iii 位置开始,找到是在哪个位置和str2
分出胜负的,此时找到了最右的可能性;【O(M)O(M)O(M) 复杂度】 - 截取最左可能性到最右可能性的局部串,找到最大的字典序,就能知道
str2
应该插入到哪个位置了。【O(M2)O(M^2)O(M2) 复杂度】
将str1
和 str2
字符串之间加一个小的ASCII码字符,使其拼成一个长的字符串即str1 + 小ASCII码字符 + str2
,对这个字符串使用DC3算法。
在DC3算法生成后缀数组详解一文中DC3算法的模板要求最小值>=1,假设str1 = [3, 1, 2],str2 = [1, 2],先给每个数值+2,就变成了 [5, 3, 4] 和 [3, 4],新增一个1将两个数组进行区分,即合成一个数组变成[5, 3, 4, 1(新增的值), 3, 4]。之所以用1来区分,是因为模板要求最小值>=1,而区分两个字符串的时候要新增一个超小的ASCII码字符,为了符合模板要求,它不能为0,所以用1来作区分,此时其他的值就要从2开始。也就是说,如果数组中的最小值是10,要将其对应成2,那么所有数都要-8;如果数组中的最小值是7,要将其对应成2,则所有数都要-5,这样一来,就可以用1来隔开两个数组。
代码实现
public class InsertS2MakeMostAlphabeticalOrder {// 暴力方法:尝试在每一个位置插入 O(N^2)public static String right(String s1, String s2) {if (s1 == null || s1.length() == 0) {return s2;}if (s2 == null || s2.length() == 0) {return s1;}String p1 = s1 + s2;String p2 = s2 + s1;String ans = p1.compareTo(p2) > 0 ? p1 : p2;for (int end = 1; end < s1.length(); end++) {String cur = s1.substring(0, end) + s2 + s1.substring(end);if (cur.compareTo(ans) > 0) {ans = cur;}}return ans;}// 正式方法 O(N+M) + O(M^2)// N : s1长度// M : s2长度public static String maxCombine(String s1, String s2) {if (s1 == null || s1.length() == 0) {return s2;}if (s2 == null || s2.length() == 0) {return s1;}char[] str1 = s1.toCharArray();char[] str2 = s2.toCharArray();int N = str1.length;int M = str2.length;//找到两个数组的最小值和最大值int min = str1[0];int max = str1[0];for (int i = 1; i < N; i++) {min = Math.min(min, str1[i]);max = Math.max(max, str1[i]);}for (int i = 0; i < M; i++) {min = Math.min(min, str2[i]);max = Math.max(max, str2[i]);}int[] all = new int[N + M + 1]; //新增了1个位置做两个数组的隔断int index = 0;for (int i = 0; i < N; i++) { //arr数组左边放str1数组的值,按照前文描述,要将最小数对应成2,其他数值依次进行调整all[index++] = str1[i] - min + 2; }all[index++] = 1; //arr数组中间,人为增加了一个1for (int i = 0; i < M; i++) { //arr数组右边放str2数组的值,依然要按照要求对数值进行调整all[index++] = str2[i] - min + 2;}//调用DC3算法DC3 dc3 = new DC3(all, max - min + 2);int[] rank = dc3.rank;int comp = N + 1; //arr数组中str2数组开始的位置for (int i = 0; i < N; i++) {if (rank[i] < rank[comp]) { //如果在str1中找到了某个位置出发的后缀串的字典序 < str2的字典序int best = bestSplit(s1, s2, i); //选择最好的插入位置return s1.substring(0, best) + s2 + s1.substring(best);}}//如果str1中一直没有找到字典序 < str2的,直接插入到str1最后return s1 + s2;}public static int bestSplit(String s1, String s2, int first) {int N = s1.length();int M = s2.length();int end = N;for (int i = first, j = 0; i < N && j < M; i++, j++) {if (s1.charAt(i) < s2.charAt(j)) {end = i;break;}}String bestPrefix = s2;int bestSplit = first;for (int i = first + 1, j = M - 1; i <= end; i++, j--) {String curPrefix = s1.substring(first, i) + s2.substring(0, j);if (curPrefix.compareTo(bestPrefix) >= 0) {bestPrefix = curPrefix;bestSplit = i;}}return bestSplit;}public static class DC3 {public int[] sa;public int[] rank;public DC3(int[] nums, int max) {sa = sa(nums, max);rank = rank();}private int[] sa(int[] nums, int max) {int n = nums.length;int[] arr = new int[n + 3];for (int i = 0; i < n; i++) {arr[i] = nums[i];}return skew(arr, n, max);}private int[] skew(int[] nums, int n, int K) {int n0 = (n + 2) / 3, n1 = (n + 1) / 3, n2 = n / 3, n02 = n0 + n2;int[] s12 = new int[n02 + 3], sa12 = new int[n02 + 3];for (int i = 0, j = 0; i < n + (n0 - n1); ++i) {if (0 != i % 3) {s12[j++] = i;}}radixPass(nums, s12, sa12, 2, n02, K);radixPass(nums, sa12, s12, 1, n02, K);radixPass(nums, s12, sa12, 0, n02, K);int name = 0, c0 = -1, c1 = -1, c2 = -1;for (int i = 0; i < n02; ++i) {if (c0 != nums[sa12[i]] || c1 != nums[sa12[i] + 1] || c2 != nums[sa12[i] + 2]) {name++;c0 = nums[sa12[i]];c1 = nums[sa12[i] + 1];c2 = nums[sa12[i] + 2];}if (1 == sa12[i] % 3) {s12[sa12[i] / 3] = name;} else {s12[sa12[i] / 3 + n0] = name;}}if (name < n02) {sa12 = skew(s12, n02, name);for (int i = 0; i < n02; i++) {s12[sa12[i]] = i + 1;}} else {for (int i = 0; i < n02; i++) {sa12[s12[i] - 1] = i;}}int[] s0 = new int[n0], sa0 = new int[n0];for (int i = 0, j = 0; i < n02; i++) {if (sa12[i] < n0) {s0[j++] = 3 * sa12[i];}}radixPass(nums, s0, sa0, 0, n0, K);int[] sa = new int[n];for (int p = 0, t = n0 - n1, k = 0; k < n; k++) {int i = sa12[t] < n0 ? sa12[t] * 3 + 1 : (sa12[t] - n0) * 3 + 2;int j = sa0[p];if (sa12[t] < n0 ? leq(nums[i], s12[sa12[t] + n0], nums[j], s12[j / 3]): leq(nums[i], nums[i + 1], s12[sa12[t] - n0 + 1], nums[j], nums[j + 1], s12[j / 3 + n0])) {sa[k] = i;t++;if (t == n02) {for (k++; p < n0; p++, k++) {sa[k] = sa0[p];}}} else {sa[k] = j;p++;if (p == n0) {for (k++; t < n02; t++, k++) {sa[k] = sa12[t] < n0 ? sa12[t] * 3 + 1 : (sa12[t] - n0) * 3 + 2;}}}}return sa;}private void radixPass(int[] nums, int[] input, int[] output, int offset, int n, int k) {int[] cnt = new int[k + 1];for (int i = 0; i < n; ++i) {cnt[nums[input[i] + offset]]++;}for (int i = 0, sum = 0; i < cnt.length; ++i) {int t = cnt[i];cnt[i] = sum;sum += t;}for (int i = 0; i < n; ++i) {output[cnt[nums[input[i] + offset]]++] = input[i];}}private boolean leq(int a1, int a2, int b1, int b2) {return a1 < b1 || (a1 == b1 && a2 <= b2);}private boolean leq(int a1, int a2, int a3, int b1, int b2, int b3) {return a1 < b1 || (a1 == b1 && leq(a2, a3, b2, b3));}private int[] rank() {int n = sa.length;int[] ans = new int[n];for (int i = 0; i < n; i++) {ans[sa[i]] = i;}return ans;}}// for testpublic static String randomNumberString(int len, int range) {char[] str = new char[len];for (int i = 0; i < len; i++) {str[i] = (char) ((int) (Math.random() * range) + '0');}return String.valueOf(str);}// for testpublic static void main(String[] args) {int range = 10;int len = 50;int testTime = 100000;System.out.println("功能测试开始");for (int i = 0; i < testTime; i++) {int s1Len = (int) (Math.random() * len);int s2Len = (int) (Math.random() * len);String s1 = randomNumberString(s1Len, range);String s2 = randomNumberString(s2Len, range);String ans1 = right(s1, s2);String ans2 = maxCombine(s1, s2);if (!ans1.equals(ans2)) {System.out.println("Oops!");System.out.println(s1);System.out.println(s2);System.out.println(ans1);System.out.println(ans2);break;}}System.out.println("功能测试结束");System.out.println("==========");System.out.println("性能测试开始");int s1Len = 1000000;int s2Len = 500;String s1 = randomNumberString(s1Len, range);String s2 = randomNumberString(s2Len, range);long start = System.currentTimeMillis();maxCombine(s1, s2);long end = System.currentTimeMillis();System.out.println("运行时间 : " + (end - start) + " ms");System.out.println("性能测试结束");}
}
扩展
例如数组 [100万,5, 90万,10亿],这个数组依然可以求解后缀数组,也可以用DC3算法做,但是因为最大值太大,需要的桶就太多,会导致DC3算法的常数时间变得很大。
可以怎么做呢?进行离散化——将5对应成1,90万对应成2,100万对应成3,10亿对应成4,那么 [100万,5, 90万,10亿] 的后缀数组等同于 [3, 1, 2, 4] 的后缀数组。
所以如果一个数组中的数千差万别,可以将其对应成一个较窄的域,然后调用DC3算法,可以减少常数时间。