├── 1. DP优化-二进制和单调队列.md ├── 2. DP优化 - 快速幂,四边形不等式.md ├── 3. 高级数据结构-线段树.md ├── 4. 算法思维-括号问题.md ├── 5. 字符串算法-从KMP到AC自动机.md └── README.md /1. DP优化-二进制和单调队列.md: -------------------------------------------------------------------------------- 1 | 最近2场LC周赛的最后一道,都运用了一些优化知识。四边形不等式优化和二进制优化,之前不久还有一场用到了单调队列优化。在这里对这5类优化做一个梳理。 2 | 3 | 分别是二进制优化,单调队列(栈)优化,斜率优化, 四边形不等式优化, 快速幂优化. 4 | 5 | ## 所谓优化 6 | 所谓优化,就是在原有算法的基础上提升时间/空间复杂度的方式。这些优化方式都是建立在能把基本的DP转移方程搞定,然后想如何提升效率。所以DP是基本功,这讲讲的都是一些进阶知识,针对DP有一定基础的同学。 7 | 8 | ## 二进制优化 9 | 这类问题一般是这样的,我们有一个数字K,我们需要枚举他的1~K 这里的所有可能。 10 | 11 | 比如经典的背包问题,有一个商品只剩7个了。我们如果把它当做01背包来处理,可以把这个商品看成,1个一种,2个合并拿为另一种,3个合并拿为第三种。。。一直到7个。当然7种可能里只能选1种可能,他们之间是互斥的,所以需要放到枚举体积的循环内。 12 | 13 | 这样子时间复杂度是 背包容量 * 物品种类 * 物品个数。 14 | 15 | 我们来看下代码 16 | ``` 17 | public static int backpack(int[] wei, int[] val, int[] cnt, int W) { 18 | int n = wei.length; 19 | int[] dp = new int[W + 1]; 20 | for (int i = 0; i < n; i++) { // 枚举物品种类 21 | int curWei = wei[i], curVal = val[i]; 22 | for (int j = W; j >= 0; j--) { // 枚举体积 23 | for (int k = 1; k <= cnt[i]; k++) { // 枚举此种物品个数 24 | if (k * curWei > j) break; 25 | dp[j] = Math.max(dp[j], dp[j - k * curWei] + k * curVal); 26 | } 27 | } 28 | 29 | } 30 | return dp[W]; 31 | } 32 | ``` 33 | 34 | 下面就引入了2进制优化的思考,7在二进制表示下是111. 那么我们其实可以用100,010,001.进行组合而表示出所有7种可能性。比如这3个数字全不选是000, 选第一和第三个就能组合出101. 35 | 36 | 所以我们其实可以不用枚举所有的7种可能性,我们只要组合出3种可能性就好。 这里的3其实是7的LOG。 37 | 38 | 所以时间复杂度 那一维的时间复杂度可以优化到LOG N。 39 | 40 | 在继续深入下去之前,我们可以先看一道这个思想的题热个身。 41 | 42 | https://leetcode.com/problems/patching-array/ 43 | 44 | 这道题就差不多是类似的思想,我需要加进去哪些数,就可以组合出所有的1 ~ N的数。这题就是判最小的那个未满足的数是几,把它引入。然后找下一个未满足的。如果下一个满足,就把这个范围拉大。比如[1,3,10] 要组合出11以内的数。先看1有没有,有了加进来,sum=1。发现2没有,所以要补2. sum=3. 3 < sum ,加进来。 sum = 6. 然后就会发现7没有,要补7。依次类推。 45 | 46 | 而我们这里的思想是把一个数拆成尽可能少的数,使得他们的组合出来的全集是1 ~ 这个数的集合。 47 | 48 | 这里和之前不一样的地方是组合。也就是说我们之前把7种物品是看成互斥的(选了里面1个,就不能再选另外6个),而现在我们可以把这3种物品看成不互斥的(为了让他们能组合起来,选了一个还有机会选另一个) 49 | 50 | 所以我们要把这层循环放到W之前。其实暗示的含义是这样我们把7个的A物品,变成了B物品(001),C物品(010),D物品(100)。每种都可以选1次或选0次。 51 | 52 | 如果总数是8个, 我们需要一个B物品(001), C物品(010), D物品(100),但是这样我们只能表示所有0-7的可能,而此时无法引入(1000),因为并没有15 个物品这么多。所以最后一个(8-7=001)为E物品 53 | 54 | 下面是代码 55 | ``` 56 | public static int backpack(int[] wei, int[] val, int[] cnt, int W) { 57 | int n = wei.length; 58 | int[] dp = new int[W + 1]; 59 | for (int i = 0; i < n; i++) { // 枚举物品种类 60 | int curWei = wei[i], curVal = val[i], restCnt = cnt[i]; 61 | for (int k = 1; restCnt > 0; k <<= 1) { // 枚举此种物品个数 62 | if (k > restCnt) k = restCnt; 63 | restCnt -= k; 64 | for (int j = W; j >= k * curWei; j--) { // 枚举体积 65 | dp[j] = Math.max(dp[j], dp[j - k * curWei] + k * curVal); 66 | } 67 | } 68 | } 69 | return dp[W]; 70 | } 71 | ``` 72 | ### 下面我们来看另一个问题,这里会讲2个优化 73 | 74 | https://leetcode.com/problems/sliding-window-maximum/ 75 | 76 | 这道题是非常经典,就是求滑动窗口的最大值。我们知道求滑动窗口的,一般维护2个指针就好。然后左指针和右指针移动的时候看怎么更新状态。比如求窗口中位数。我们维护2个堆,一个大根堆存小的一半的数。一个小根堆存大的一半的数。左右指针滑的时候,我们可以知道这个数应该要从哪个堆移除,和加入。 77 | 78 | 而最大值稍微复杂一些,如果我们只是维护一个目前的最大值,如果新加进来的数比最大值大则更新。但是新离开的数和最大值一样的话就麻烦了。我们没有维护第二个大的值。即使多维护一个第二大的变量,当他成为最大值并离开时,我们又麻烦了。那其实是要维护窗口内的所有数了。 79 | 80 | 这个时候,我们要思考的点是一个数什么时候是无用数,无用数就是他的存在没有任何可能去改变结果。比如【9,8,7,6】这里没有无用数,因为虽然一开始是9是老大,但是窗口右移,之后每个数都有机会去做老大。我们再看一组数[1,2,3,4]. 如果是求最大值,那么前3个是彻底没用的,如果窗口是4的话,因为4的存在他们是绝对不会成为任何一个窗口的最大值。 81 | 82 | 这里其实引入了数据特权的2个维度。这和打德州扑克很像,即使你牌不够大,如果你的位置有优势,总体优势不一定只看牌的大小。而这里的数据其实也是这2个维度,一个是位置,一个是大小。只有当2维都落后时,这时才是`无用数`。 83 | 84 | 我们来重新定义一下`无用数`。就是位置和大小都比当前窗口的某一个数要差,那么就可以直接丢弃这个数。这个背后的思想也就是`单调栈和单调队列优化`的思想。 85 | 86 | 我们在进栈的时候,可以去看,前面的数一般算位置比自己差的,因为我是后进栈(后浪之后还有无限可能)如果前浪除了剩余时间少,另一个维度也输给了后浪,前浪就可以出栈了。 87 | 88 | 按照这个思想,因为我们要的是最大值,我们后浪来的时候,如果是最大的,其实是可以把所有前浪都弹出去,直到遇到一个更大的前浪,他就安静的待在他后面,直到时间流逝(窗口滑动),前浪自然离开,这个后浪就登上了历史舞台,前提是没有更后浪把他带走。 89 | 90 | 这样看单调栈的思想其实还蕴含着一定的社会规律。 91 | 92 | 基于上述思想,队列头部就是最大的数了。每次滑动的时候,需要看看,这个数的寿命是不是到了。如果到了就把他从队头弹出,那么之后就是第二大的了。当一个数新进来时,就看看有没有混得比自己差的前浪,让他们从队尾弹出。 93 | 94 | **所以单调队列的本质是一群年龄从老到小的数据,维持了本事从高到底的排序**这就是单调的来由 95 | ``` 96 | public int[] maxSlidingWindow(int[] nums, int k) { 97 | if (nums.length == 0 || k == 0) return new int[0]; 98 | Deque dq = new ArrayDeque<>(nums.length); 99 | int[] res = new int[nums.length - k + 1]; 100 | int idx = 0; 101 | for (int i = 0; i < nums.length; i++) { 102 | if (i >= k && nums[i - k] == dq.peekFirst()) 103 | dq.pollFirst(); // 前浪自然死亡 104 | while (!dq.isEmpty() && nums[i] > dq.peekLast()) { 105 | dq.pollLast(); // 后浪淘汰前浪 106 | } 107 | dq.offerLast(nums[i]); // 后浪入队等待机会 108 | if (i >= k - 1) 109 | res[idx++] = dq.peekFirst(); // 后浪登上历史舞台 110 | } 111 | return res; 112 | } 113 | ``` 114 | ### 在深入一些 115 | 116 | 我们之前讲到了数据的2个维度。并结合这个来思考如何让后浪淘汰前浪,而引出了单调队列和单调栈。下面又是一个很经典的问题,叫最长(最短)子数组的和满足XXX条件。 117 | 118 | 比如https://leetcode.com/problems/minimum-size-subarray-sum/ 119 | 120 | 这道题。 121 | 122 | 要求的是最短的子数组和要>=S。题目说明数组里全是正数。这是一个经典的可变长度的滑动窗口,窗口为了要尽可能的小,所以一旦满足条件,左侧就开始收缩,直到不再满足条件为止。更新MIN SIZE。然后继续移动右侧使得满足条件。 123 | 124 | 这种算法其实和做企业很像,右指针是不断去开拓新的可能,找到了盈利模式后。移动左指针,不断去压缩成本,保持自己的竞争优势。 125 | 126 | 当然上面的假设很美好,因为所有的数据都是正数的,所以我们可以保证的是,我们的每一次努力,都是正反馈(不可能移动了左指针,成本反而比之前高) 127 | 128 | 但是现实并不一定这么美好,因为有很多未知数。可能辛苦付出的研发投入,最后被证明是毫无价值的。那么在含有负数的时候应该怎么考虑这类问题呢? 129 | 130 | 其实这也对应了一道LEETCODE 题目,这题就变成了HARD了。生活其实就都是HARD这样的。 131 | 132 | https://leetcode.com/problems/shortest-subarray-with-sum-at-least-k/ 133 | 134 | 如果引入了负数,我们其实就没有了滑动窗口永远是正反馈的性质,那么窗口的不变量就变成了变量。这个时候比较容易想到的是构建前缀和数组,然和暴力枚举所有窗口的可能,用前缀和相减O(1)得到是否满足条件,并更新最短。这样窗口的窗口可能是O(N^2)的复杂度。 135 | 136 | ### 只能这样了吗? 137 | 138 | 在现实中我们遇到这样一个问题该如何破局呢? 139 | 140 | 如果是我,我会先尽一切可能找到一个可以满足要求的方案,然后再想怎么去优化。 141 | 142 | 那么我就先忘记最短这个条件,因为无论多短,都要先满足>=k. 143 | 144 | 那么我就从PRESUM 里去找,当我站在这个PRESUM时,我希望做的是之前有没有一个PRESUM 可以使得我们相减能满足条件。如果有,那么我再想优化,他是否是离我距离最短。 145 | 146 | 讲到这里你是不是发现了什么?对的,距离。又是你,出现了。前浪和后浪。 147 | 148 | 这里就像是找能一起完成任务的伙伴,如果有多个,我要选同龄人(共同话题多,代沟少,何乐为不为) 149 | 150 | 那么无用数也就很明显了。因为这里是要减去一个之前的PRESUM。为了使得我能完成任务(>= K)的概率尽可能高就意味着减去的数越小越好。(更有机会使得结果更大,更大就意味着更多可能>=K) 151 | 152 | 那么首先要保证任务完成,所以数值更小的PRESUM不能丢。其次是完成任务前提下,我希望数组长度越短。那么如果一个数又大又远,就是无用数了。 153 | 154 | **单调队列的本质是一群年龄从老到小的数据,维持了本事从高到底的排序** 155 | 156 | 这里的本事是PRESUM 小,所以单调队列里队头是最小值。 157 | 158 | 我们当前这个PRESUM如果能和队头碰出火花了,其实队头的使命就结束了。因为他没必要再和我之后更年轻的人组队,因为代沟只会更大。而我,会不断弹出队头,直到一个人和我完成不了任务。那么我就知道和谁做任务代沟最小了。因为队列里的本事是单调递减的(队头完不成,之后的人虽然年轻但也还是完不成的) 159 | 160 | 说到这里我想你应该直到怎么做这题了吧。 161 | ``` 162 | public int shortestSubarray(int[] A, int K) { 163 | int l = A.length, res = l + 1; 164 | int[] presum = new int[l + 1]; 165 | for (int i = 1; i <= l; i++) { 166 | presum[i] = presum[i - 1] + A[i - 1]; 167 | } 168 | Deque inc = new ArrayDeque<>(); 169 | for (int i = 0; i <= l; i++) { 170 | // 优先确保有解,因为前端是最小的,先试最小的>=K 的概率最大 171 | while (!inc.isEmpty() && presum[i] - presum[inc.peekFirst()] >= K) { 172 | res = Math.min(res, i - inc.pollFirst()); 173 | } 174 | // 保持单调性 175 | while (!inc.isEmpty() && presum[i] <= presum[inc.peekLast()]) { 176 | inc.pollLast(); 177 | } 178 | inc.offerLast(i); 179 | } 180 | return res == l + 1 ? -1 : res; 181 | } 182 | ``` 183 | #### 现在我们再来看看要是目标是要求SUM <= K 该怎么做呢? 184 | 185 | 其实这就是数据的本事换了定义,之前我们认为,数值越小的越有本事。是因为减去小的,我可以有更大的概率满足>=K的条件。现在就是反过来了。为了让相减之后,尽可能小。那么数值大的被认为是本事大。所以只要把单调递增队列 变成递减即可。 186 | 187 | **下面给大家留一道思考题,如果是求最长的SUBARRAY 要求SUM >= K 或 <= K 该怎么做呢?** 188 | 189 | ### 回到滑动窗口最大值 190 | 上面我们已经讲了单调队列的算法是怎么一回事。下面我们再来看看这道题的另一个做法。 191 | 192 | 因为窗口大小是固定的,我们在任一段落决定的他的值是有限的数据。假设我们把所有数据全划分为窗口大小的片段。然后对这个片段求出最大值。那么只要解决跨片段的问题就能把问题解决,一般也只需要考虑2个片段的数据,就可以得出跨片段窗口的最大值了。 193 | 194 | 下面就是思考如何用O(1)的时间去处理2边片段的数据,得到跨片段的最大值呢? 195 | 196 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-3e70ffbaf9d918c8.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 197 | 198 | 不难发现我们只要知道左片段右半部分的最大值,和右片段左半部分的最大值就可以算出窗口的最大值。 199 | 200 | 按照这个思路。我们需要维护2个DP数组,分别存储每个片段的左半部分的最大值和右半部分的最大值。 然后指针滑动的时候取MAX就可以O(1)时间轻松拿到解。 201 | ``` 202 | public int[] maxSlidingWindow(int[] nums, int k) { 203 | int l = nums.length; 204 | if (l * k == 0) return new int[0]; 205 | int[] left = new int[l]; // 右片段的左半部分的最大值 206 | int[] right = new int[l]; // 左片段的右半部分的最大值 207 | for (int i = 0; i < l; i++) { 208 | if (i % k == 0) left[i] = nums[i]; 209 | else left[i] = Math.max(left[i - 1], nums[i]); 210 | int j = l - 1 - i; 211 | if ((j + 1) % k == 0 || j == l - 1) right[j] = nums[j]; 212 | else right[j] = Math.max(right[j + 1], nums[j]); 213 | } 214 | 215 | int[] res = new int[l - k + 1]; 216 | for (int i = 0; i < res.length; i++) 217 | res[i] = Math.max(right[i], left[i + k - 1]); 218 | return res; 219 | } 220 | ``` 221 | 上面的思路也是非常经典。我们下面想想这个问题的更一般形式。一个数组,我们如何在O(1)时间求出任意窗口的最大值呢。 222 | 223 | 继承上面的思路,我们知道如何用窗口大小的K 来在O(1)时间找出任意窗口为K的最大值。那么我们在预处理的时候,只要把K从1枚举到N就可以了。然后查找就是O(1)的了。 224 | 225 | 其实这个思路一点没有毛病,因为在这种做法下,等价于把所有要查询的可能给CACHE住了。其实我们可以先想下有了K值的LEFT数组和RIGHT数组,可以用O(2)的时间解决2 * K的窗口吗? 226 | 227 | 这个应该非常好想。把2个K的窗口的最大值求出来然后对这2个最大值给取MAX。 228 | 229 | 那K~ 2 * K - 1的窗口该如何求最大值呢? 230 | 231 | 其实用K值的LEFT和RIGHT数组也是可以求的, 只不过这个时候2个窗口中间会有重叠的部分,不过对取MAX没有任何影响。 232 | 233 | 如下图,K=3, 求窗口为4(红色)的最大值。可以用这样2个K=3的窗口(绿色和蓝色)最大值给求解出来。 234 | 235 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-81a597f8f59b31bb.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 236 | 237 | 这种做法再处理2 * K 以上的窗口,随着窗口越来越大,性能会越来越慢。所以我们就需要引入更大一些的K。这样在处理大窗口的时候就用大K,小窗口用小K。这个跟做测量是一样的,你要测量一直铅笔需要一把直尺就好了。要是要测量头发丝就需要螺旋测微器。 238 | 239 | 那么我们需要思考的就是如何构建出一套最精简的工具包使得,任意窗口K都可以有对应的工具,立刻量出。 240 | 241 | 这又回到了我们的2进制思想。比如窗口大小的二进制为(101101),我们只需要用最高位是1,剩余位全0(10000)的工具就一定可以覆盖住它. 242 | 243 | `32的工具 可以覆盖掉32-63的所有最大值计算,原理如上图左右2边分别计算出2个32的最大值,取最大` 244 | 245 | 那么这样我们最多只要构建31组工具,就可以实现所有窗口的O(1)测量了。 246 | 247 | 同样是刚才那道题,我们可以直接丢工具包进去做。二进制本质上是LOG(N)的复杂度,所以构建工具包的时间复杂度O(N LOG N) 248 | 249 | 这里构建工具包有一些技巧,我们可以用低长度的工具包,O(1)时间构建出高一层长度的工具包。 250 | ``` 251 | int[][] rangeMaxQuery(int[] nums) { 252 | int l = nums.length; 253 | int[][] dp = new int[l][20]; //dp[i][j] = 从i 开始,长度为2^j 的窗口里最大值是多少 254 | for (int i = 0; i < l; i++) dp[i][0] = nums[i]; 255 | for (int j = 1; (1 << j) <= l; j++) { 256 | for (int i = 0; i+(1 << (j-1)) < l; i++) { 257 | // dp[0][1] = max(dp[0][0], dp[1][0]) 低长度工具包构建高长度工具包 258 | // dp[0][2] = max(dp[0][1], dp[2][1]) ... 259 | dp[i][j] = Math.max(dp[i][j-1], dp[i+(1 << (j-1))][j-1]); 260 | } 261 | } 262 | return dp; 263 | } 264 | ``` 265 | 有了上面这个工具包,我们再求窗口最大值时只需 266 | 267 | `res[i] = Math.max(dp[i][num], dp[i + k - (1 << num)][num]);` 268 | 269 | `num`为最高位1是第几位(0开始计数) 270 | 271 | ### 二进制优化++ 272 | 再回到今天的周赛题来,https://leetcode.com/problems/kth-ancestor-of-a-tree-node 273 | 274 | 题目给了一颗有根树,要求任意节点的第K个祖先,如没有返回-1. 暴力解就是从这个节点向上走K步,然后输出第K个祖先。可是题目的树深可能达到50000,并且有50000次查询,所以这样做很有可能会超时。 275 | 276 | 比如我们要找第15个祖先,有没有什么快速的方式呢?我们可以用二进制的思想。15的最高位1为8.那么我是否可以先跳8步,看那个祖先是谁,然后基于那个祖先再找他的第7个祖先即可。这样子只需要LOG N的时间,就可以定位了。 277 | 278 | 那么我们也是要构造和前面一个情况一样的工具包。这个工具包里需要摆放的是每一个节点,往后1步,2步,4步,。。。2^k步的父亲节点是谁,如果跳过根节点存-1. 279 | 280 | 我们也是和上面一样的构造工具包的思路,先构造一步的,随后用1步的去构造2步的。 281 | ``` 282 | int[][] dp; 283 | public TreeAncestor(int n, int[] parent) { 284 | int highestOneTrailingZero = Integer.numberOfTrailingZeros(Integer.highestOneBit(n)); 285 | dp = new int[highestOneTrailingZero + 1][n]; 286 | dp[0] = parent; 287 | for (int i = 1; i < dp.length; i++) { 288 | for (int j = 0; j < n; j++) { 289 | int par = dp[i - 1][j]; // 用0的J 来构建 1的J 290 | dp[i][j] = (par == -1 ? -1 : dp[i - 1][par]); 291 | } 292 | } 293 | } 294 | ``` 295 | 然后来找的时候,比如找15,我们先要去跳8下的祖先,然后去找该祖先的跳7下(这里再用最高位来2进制跳)一直跳到-1,或第15个祖先退出循环。 296 | ``` 297 | public int getKthAncestor(int node, int k) { 298 | while (k > 0 && node != -1) { 299 | int i = Integer.highestOneBit(k); 300 | int par = dp[Integer.numberOfTrailingZeros(i)][node]; 301 | node = par; 302 | k -= i; 303 | } 304 | return node; 305 | } 306 | ``` 307 | 上述就是二进制思路的基本思想。 308 | 309 | - 本质是一个数字,可以转换到2进制,然后依次处理二进制的每个1,而达到快速攻克某个问题的效果。 310 | 311 | - 构造工具包的时候,一般从最底层的1,依次往上构造。 312 | 313 | 看到这里再回想一下,我们已经讲述的2进制优化的3个问题,分别是多重背包的二进制优化,区间最大值的二进制优化,和第K个祖先的二进制优化。 314 | 315 | ## 单调队列优化 316 | 下面我们来看第二种DP优化方式,单调队列。之前已经讲过了单调队列是怎么一回事。他的核心就是**一群年龄从老到小的数据,维持了本事从高到底的排序。 新数据进来时需要淘汰无用数。** 317 | 318 | 在一类DP问题中,我们可以用单调队列的思想来进行降维打击。我们来看我上次周赛遇到的一道题。 319 | 320 | https://leetcode.com/problems/constrained-subsequence-sum/ 321 | 322 | 这道题的意思是我们要构造一个子序列(顺序要保证,中间可以跳元素)这个子序列中间跳的元素不能超过K。也就是K=2,只能跳1个元素。K=1,子序列的数之间必须相邻。 323 | 324 | 那么很快就可以想到的是根据数组长度来构建DP,DP I,来表示数组的前I项,且最后一项选中的最大值。那么最后一项选。前面K个数是可以有2个情况,选或不选。 325 | 326 | 所以转移公式为 327 | 328 | `dp[i] = (max{j from [i - k, i)} dp[j]) + cur[i]` 329 | 330 | 这里我们可以看到每一个DP需要从前面K个状态转移过来,那么时间复杂度就为O (N * K) 331 | 332 | 你看到这里会发现其实那个MAX的变量是在根据I 指针在滑动的而且是定长的。那么其实直接套上之前的滑动窗口最大值的维护,就可以把这里O(K)的枚举 变成O(1)的取窗口最大了。 333 | 334 | 下面是我用,数组来模拟双端队列的比赛时写的一段代码 335 | ``` 336 | public int constrainedSubsetSum(int[] nums, int k) { 337 | int ans = Integer.MIN_VALUE, l = nums.length; 338 | int[] q = new int[l+1]; 339 | int hh = 0, tt = 0; 340 | int[] dp = new int[l + 1]; 341 | for (int i = 1; i <= l; i++) { 342 | if (hh <= tt && i - q[hh] > k) hh++; // 非空,且寿命到了,前浪退出历史舞台 343 | int cur = nums[i - 1]; 344 | dp[i] = Math.max(cur, dp[q[hh]] + cur); 345 | ans = Math.max(ans, dp[i]); 346 | while (hh <= tt && dp[i] >= dp[q[tt]]) tt--; // 后浪淘汰本事不行的前浪 347 | q[++tt] = i; 348 | } 349 | return ans; 350 | } 351 | ``` 352 | 这道题还可以有一个变形,之前我们的限制是空挡不能大于等于K。现在我们的要求是选出子序列,连续的数不能大于等于K,这里我们限制数组里的数都为正数,又应该怎么做呢? 353 | 354 | 之前我们为了限制空挡,要求最后一个数必须拿。这样我们就可以通过枚举前K个必拿的状态转移到目前MAX的不会有K空挡的状态。 355 | 356 | 而现在为了不能持久连续,我们依赖用前K个状态,此时已经不要求最后一个必拿,只要结果最大即可。破坏连续K个的核心就是枚举,这个不拿是K个里的什么位置。 357 | 358 | 可是自己这个不拿,然后就是DP[I-1],也可以是DP[I-2] + ARR[I],也可以是DP[I-3] + ARR[I] + ARR[I - 1]。。。这里我们发现了一个数组求和,我们可以用PRESUM,来把这个求和的步骤变成O(1) 359 | 360 | 转移公式为 361 | 362 | `dp[i] = (max{j from [i - k, i)} dp[j - 1] - presum[j]) + presum[i]` 363 | 364 | 这里的意思我们确定J 不拿了,J之前的用DP[J-1]取到最大,之后的用PRESUM求出来(因为数都是正数可以贪心只丢一个) 365 | 366 | 而单调队列里存的就是之前K SIZE WINDOW的 (dp[j-1] - presum[j]) 的最大值。 367 | 368 | 依旧是区间窗口最大值的维护。 369 | 370 | 主代码如下 371 | ``` 372 | int[] dp = new int[n + 1]; // DP 从 1 到 N 373 | int h = 0, t = 0; 374 | for (int i = 1; i <= n; i++) { 375 | if (h <= t && i - q[h] > k) h++; 376 | dp[i] = max(dp[i - 1], presum[i] + help(q[h])); 377 | while (h <= t && help(i) >= help(q[t])) t--; 378 | q[++t] = i; 379 | } 380 | return dp[n]; 381 | ``` 382 | HELP函数如下 383 | ``` 384 | int help(int i) { 385 | if (i == 0) return 0; 386 | return dp[i - 1] - presum[i]; 387 | } 388 | ``` 389 | ## 总结 390 | 今天讲述了DP优化中的两类,分别是二进制优化和单调队列优化。我们不妨回顾一下,二进制优化是把对数的遍历拆成对每一个二进制1的遍历而提升效率。效率提升幅度为O(N)->O(LOG N)。而单调队列的优化通常就是维护区间最值属性直接获得最佳状态减少一层状态转移的遍历而提升效率。效率提升幅度为(O(K) -> O(1)) 391 | 392 | 下一讲会带大家一起进入神奇的四边形不等式优化和斜率优化和快速幂优化。看数学是怎么帮助我们提升代码性能的。 393 | 394 | 最后给大家留一道二进制优化的思考题和单调队列优化的思考题。 395 | 396 | >在一棵有根多叉树中,如何使用二进制优化,来找最近公共祖先呢? 397 | 398 | >总共有 n 道题目要抄,编号 0,2,…,n,抄第 i 题要花 ai 分钟。老师要求最多可以连续空的题为K,求消耗时间最少满足老师要求的方案。 399 | -------------------------------------------------------------------------------- /2. DP优化 - 快速幂,四边形不等式.md: -------------------------------------------------------------------------------- 1 | 在上一章中,我们介绍了基于单调队列和二进制DP的优化。 2 | 今天我们来看另外3类,斜率优化,四边形不等式,快速幂优化。 3 | ## 斐波那契数列 4 | 一般大学的DP课,都会从这个有名的数列讲起。通常会给你们演示的递归写法,发现在算接近40的菲波那切项的时候就长时间返回不出值了。这种做法被证明是指数级的复杂度。随后便开始讲解递归过程中,比如F(K) 在很多递归树的分支里都被展开进行了重复计算。如果我们可以保存一个已经算好的结果,之后相同K的计算其实是可以复用之前这个算好的结果的。这就是记忆化搜索,也称自顶向下的DP。这种做法可以把原来的指数级别的时间复杂度给优化到线性的。 5 | 6 | 其实这个数列还可以更快,这里就要用到矩阵乘法的思想了。在讲这个之前,我们先来介绍一下快速幂是什么? 7 | 8 | 我们来看一道LEETCODE的题目 9 | 10 | https://leetcode.com/problems/powx-n/ 11 | 12 | 我们在算X 的 N次方时,最基本的做法是X 乘以N次。其实我们也可以用二进制的思想来用LOG N 的时间把它算出来。 13 | 14 | 比如我们算4次方,我们可以用X^2 的结果 直接 再平方。 同理算8次方的话,我们可以先把x^2 给算好,接下来只要算x^2 的四次方了。 然后我们把x^4算好,只要算它的平方了。 15 | 16 | 那么如果是奇数怎么办,我们可以把当前的值预先乘进答案里来解决。比如三次方我们发现是奇数,我们可以先把X 乘一次到答案,然后再算X^2即可。 17 | 18 | 所以会有如下代码 19 | ``` 20 | public double myPow(double x, int n) { 21 | if (x == 0) return 0; 22 | if (n == Integer.MIN_VALUE) return (1 / x) * myPow(1 / x, Integer.MAX_VALUE); 23 | if (n < 0) return myPow(1 / x, -n); 24 | double res = 1, p = x; 25 | while (n > 0) { 26 | if (n % 2 == 1) res *= p; // 是奇数,把答案先乘进结果 27 | p = p * p; // 把x 的基础 变成 x^2 28 | n >>= 1; // 之后只需要求原来的一半次幂 29 | } 30 | return res; 31 | } 32 | ``` 33 | 上述是快速幂的基本思想。这里假设小伙伴们已经知道了矩阵乘法是如何做的。以及1个 M * N 的矩阵 乘以 一个 N * K的矩阵 结果是 M * K的矩阵。如果不知道的,可以看我的[这篇博客](https://www.jianshu.com/p/a3a0f3b944c1)或上网查阅资料。 34 | 35 | 博客里也介绍了 只有方阵 才有矩阵的幂。 36 | 37 | 那么矩阵乘法快速幂的DP优化的核心思想如下: 38 | 39 | **一组DP状态,其实等价于一个向量。而DP状态的转移方程,可以是对一个向量做变形的矩阵。那么本质上从1个向量到另一个状态的向量,是可以通过一个矩阵来做到。矩阵具有结合律,我们可以先对右半部分矩阵用快速幂得到一个终极的变形矩阵,再乘以向量,就可以把O(N)的计算 优化到 O (LOG (N))** 40 | 41 | 第一次接触这个思想的小伙伴一定会觉得非常陌生,不过我们就拿斐波那契数列来下手。 42 | 43 | 我们可以知道斐波那契的递推公式为 dp[i] = dp[i-1] + dp[i - 2]; 那么每一个新的数的计算依赖于前2个,所以我们结果可以构建这么一个向量为 【dp[n], dp[n-1]】 44 | 45 | 那么怎么转移呢,其实就是找用什么样的矩阵和这个向量做乘法后,可以让N ++ 46 | 47 | dp[n + 1] = dp[n] * 1 + dp[n - 1] *1; dp[n] = dp[n] * 1 + dp[n - 1] * 0; 48 | 49 | 我们可以发现,只需要用【[1,1],[1,0]】这个矩阵对向量【dp[n], dp[n-1]】做乘法即可得到【dp[n+1], dp[n]】。 50 | 51 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-23b0a3927e259b0b.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 52 | 53 | 那么有了上述公式, 如果要从 dp[1], dp[2] 求到 dp[n], dp[n-1] 中间需要有N-2个同样的变形矩阵的乘法。 54 | 55 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-382cf8e00e026464.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 56 | 57 | 综上我们可以实现如下代码 58 | 59 | https://leetcode.com/problems/fibonacci-number 60 | 61 | ``` 62 | public int fib(int N) { 63 | if (N == 0) return 0; 64 | if (N <= 2) return 1; 65 | int[][] dp = {{1, 1}}; // dp[2], dp[1] 66 | int[][] ma = {{1,1},{1,0}}; 67 | N -= 2; 68 | while (N > 1) { 69 | if ((N & 1) == 1) dp = mul(dp, ma); 70 | ma = mul(ma, ma); 71 | N >>= 1; 72 | } 73 | dp = mul(init, ma); 74 | return dp[0][0]; // dp[n] 75 | } 76 | int[][] mul(int[][] a, int[][] b) { // 矩阵乘法 (m * n) X (n * k) = m * k 77 | int m = a.length, n = a[0].length, k = b[0].length; 78 | int[][] c = new int[m][k]; 79 | for (int i = 0; i < m; i++) { 80 | for (int j = 0; j < k; j++) { 81 | for (int p = 0; p < n; p++) { 82 | c[i][j] += a[i][p] * b[p][j]; 83 | } 84 | } 85 | } 86 | return c; 87 | } 88 | ``` 89 | 90 | ### 另一道LEETCODE 91 | https://leetcode.com/problems/knight-dialer/ 92 | 93 | 这道题主要是讲马子跳跃法,然后再一个键盘上可能打出多少不同的数字,在跳N步之后。比如跳1步,那么就是10. 因为第一个键可以按在任何一个位置。跳2步,是20. 94 | 这道题可以直接TOP DOWN去做,比如我的目标是最后在1这个位置,跳完K步。那么能跳到1 的,只有 95 | 96 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-4d0e6fa113863ea6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 97 | 98 | 我们这里定K=2,也就是求跳完2步的数量 99 | 100 | 所以就有 dfs(1, K=2) = dfs (6, K=1) +dfs(8, K=1) 101 | 102 | K = 1 是递归出口,返回1就好了。 103 | 104 | 有了这个思路,我们其实只要把每个点可以由哪些点跳过来的信息保存好。 105 | 106 | 就可以搜索了。 107 | ``` 108 | NEIGHBORS_MAP = { 109 | 0: (4, 6), 110 | 1: (6, 8), 111 | 2: (7, 9), 112 | 3: (4, 8), 113 | 4: (3, 9, 0), 114 | 5: tuple(), # 5 has no neighbors 115 | 6: (1, 7, 0), 116 | 7: (2, 6), 117 | 8: (1, 3), 118 | 9: (2, 4), 119 | } 120 | ``` 121 | 因为到达一个点之后K步,和之前怎么跳过来的是不相关的。所以我们可以人为无论前面怎么跳,你现在跳到了M的数字,并且还有K步,余下到1的可能性都是不变的。那么就可以引入记忆化搜索来避免重复的递归展开计算。这样时间复杂度就是状态数量 * 每次递归函数要做的操作。 状态数量是 10 * K(K为跳的步数) 122 | 123 | 其实这道题就解决了。 124 | 125 | 其实我们可以发现这道题也是当前的状态是通过上一步的状态,根据固定的公式去转移的,最后是去求个数,我们就可以使用矩阵来为向量做变换的思想把它优化到LOG (N)。 126 | 127 | 上面这个邻居信息表的含义其实就是dp[i][1] = dp[i-1][4] + dp[i-1][6]; 128 | 129 | 那么我们把需要的位置给设置成系数1, 不需要的位置设置成系数0,上面的MAP等价于下面的矩阵 130 | 131 | ``` 132 | NEIGHBORS_MAP = { 133 | 0: (0, 0, 0, 0, 1, 0, 1, 0, 0, 0), 134 | 1: (0, 0, 0, 0, 0, 0, 1, 0, 1, 0), 135 | 2: (0, 0, 0, 0, 0, 0, 0, 1, 0, 1), 136 | 3: (0, 0, 0, 1, 0, 0, 0, 0, 1, 0), 137 | 4: (1, 0, 0, 1, 0, 0, 0, 0, 0, 1), 138 | 5: (0, 0, 0, 0, 0, 0, 0, 0, 0, 0), 139 | 6: (1, 1, 0, 0, 0, 0, 0, 1, 0, 0), 140 | 7: (0, 0, 1, 0, 0, 0, 1, 0, 0, 0), 141 | 8: (0, 1, 0, 1, 0, 0, 0, 0, 0 ,0), 142 | 9: (0, 0, 1, 0, 1, 0, 0, 0, 0, 0), 143 | } 144 | ``` 145 | 接下来的事情似乎就是用快速幂,来求终极变换方案。然后把初始向量和终极矩阵方案直接相乘。然后把1~10的值求和即可。 146 | 147 | 如果理解的斐波那契那里的代码,下面其实是一样一样的。 148 | 149 | 注意因为向量是行向量,所以做乘法的时候,是和矩阵的列去乘,所以上面的MAP,再转矩阵的时候,应该从行映射到矩阵的列。比如上面的MAP的第一行其实等价下面矩阵的第一列。当然你定义初始向量为列向量,就可以不用做这个变换。 150 | 151 | ``` 152 | int M = 1000000007; 153 | public int knightDialer(int n) { 154 | long[][] m = {{0,0,0,0,1,0,1,0,0,0}, 155 | {0,0,0,0,0,0,1,0,1,0}, 156 | {0,0,0,0,0,0,0,1,0,1}, 157 | {0,0,0,0,1,0,0,0,1,0}, 158 | {1,0,0,1,0,0,0,0,0,1}, 159 | {0,0,0,0,0,0,0,0,0,0}, 160 | {1,1,0,0,0,0,0,1,0,0}, 161 | {0,0,1,0,0,0,1,0,0,0}, 162 | {0,1,0,1,0,0,0,0,0,0}, 163 | {0,0,1,0,1,0,0,0,0,0}}; 164 | long[][] res = {{1,1,1,1,1,1,1,1,1,1}}; 165 | n--; 166 | while (n > 0) { 167 | if (n % 2 == 1) res = mul(res, m); 168 | m = mul(m , m); 169 | n /= 2; 170 | } 171 | long sum = 0; 172 | for (long i : res[0]) { 173 | sum += i; 174 | } 175 | return (int) (sum % M); 176 | } 177 | // A[m][p] * B[p][n] = C[m][n] 178 | long[][] mul(long[][] a, long[][] b) { 179 | int m = a.length, n = b[0].length, p = a[0].length; 180 | long[][] res = new long[m][n]; 181 | for (int i = 0; i < m; i++) { 182 | for (int j = 0; j < n; j++) { 183 | for (int k = 0; k < p; k++) { 184 | res[i][j] += a[i][k] * b[k][j]; 185 | res[i][j] %= M; 186 | } 187 | } 188 | } 189 | return res; 190 | } 191 | ``` 192 | ## 什么样的问题可以用矩阵快速幂优化 193 | **我们看到这里发现这里有个状态机转化的思想,我们把它写成矩阵和向量的乘法形式,这类DP都可以使用快速幂; 当然这种题目会要求去求个数,而不是MIN/ MAX** 194 | 但并不是只要是状态机变化的计数DP都可以用矩阵乘法快速幂。比如经典的DECODE ways,https://leetcode.com/problems/decode-ways/ 195 | 196 | 虽然他是从DP[N-1] 和 DP[N-2] 过来,但是里面涉及到了条件分支,这种题目无法写成矩阵变换的形式。 197 | 198 | 下面我们再看一道可以用快速幂的思想去解的题,然后看我们怎么定义不同的状态使得可以用不同的矩阵转换来表示的情况。 199 | 200 | https://leetcode.com/problems/student-attendance-record-ii/ 201 | 202 | 题目中要求一个学生最多只能出现1个A, 和2个连续的L(也就是说不要求L的总数,只要没有3个连续的L即可) 203 | 204 | 同时我们发现这道题也是求个数,我们可以之后思考是否可以用快速幂来优化。 205 | 206 | 我们看怎么来定义DP的状态。首先A和L一定会在状态机里的。不可以定dp[n][i][j] 表示第N天时,这个学生已经连续i天是L了,且历史上发生了j次A 207 | 208 | 那么根据这个状态,我们可以知道如果i >0 那么其实只能从dp[n-1][i-1][j]转移过来,因为你希望连续2天是L,必然要从连续1天是L转过来。 209 | 210 | 如果i = 0的时候,可以从其他所有状态转过来,因为只要再结尾加个P或加个A,就可以破坏掉连续i天是L的连续。根据这个思路,我们也可以列出关系MAP。 211 | 212 | ``` 213 | NEIGHBORS_MAP = { 214 | k,0,0: (k-1, 1, 0), (k-1, 2, 0), (k-1, 0, 0) [最后加P] 215 | k,1,0: (k-1, 0, 0)[最后加L] 216 | k,2,0: (k-1, 1, 0)[最后加L] 217 | k,0,1: (k-1, 1, 0), (k-1, 2, 0), (k-1, 0, 0) [最后加A] (k-1, 1, 1), (k-1, 2, 1), (k-1, 0, 1) [最后加P] 218 | k,1,1: (k-1, 0, 1)[最后加L] 219 | k,2,1: (k-1, 1, 1)[最后加L] 220 | } 221 | ``` 222 | 有了上述的MAP,我们就可以很方便的转换成矩阵。这里我们要对I,J 做编码。因为J最多2种取值。所以编码之后的数为 i * 2 + j 223 | 那么矩阵就是 224 | ``` 225 | 0: 1,1,1,0,0,0 226 | 1: 1,0,0,0,0,0 227 | 2: 0,1,0,0,0,0 228 | 3: 1,1,1,1,1,1 229 | 4: 0,0,0,1,0,0 230 | 5: 0,0,0,0,1,0 231 | ``` 232 | 有了这些,下面我们来思考初始向量是什么,根据定义,在最开始只有可能是没有A,没有L,所以只有dp[0][0][0] = 1,其他都为0. 233 | 234 | 最后记得把这6种状态结尾的个数做一个求和即是题目的答案 235 | ``` 236 | int M = 1000000007; 237 | public int checkRecord(int n) { 238 | long[][] m = new long[][]{ 239 | {1,1,0,1,0,0}, 240 | {1,0,1,1,0,0}, 241 | {1,0,0,1,0,0}, 242 | {0,0,0,1,1,0}, 243 | {0,0,0,1,0,1}, 244 | {0,0,0,1,0,0} 245 | }; 246 | long[][] res = new long[][]{{1,0,0,0,0,0}}; 247 | while (n > 1) { 248 | if (n % 2 == 1) res = mul(res, m); 249 | m = mul(m, m); 250 | n >>= 1; 251 | } 252 | res = mul(res, m); 253 | long sum = 0; 254 | for (int i = 0; i < 6; i++) sum += res[0][i]; 255 | return (int) (sum % M); 256 | } 257 | ``` 258 | 259 | 我们再来看一个状态的表示方法,之前我们定义的是结尾有多少个L。这里我们可以定义结尾最多可能有多少个L。这样定义的好处是最后不用作那个求和。因为我们的状态是最多有多2个L,所以也包含了1个L和0个L的情况了。为了把A也给不求和,所以我们把状态也转成历史上最多有了多少个A 260 | 261 | 这样我们最后只要返回dp[n][2][1] 就是所有结果。 262 | 263 | 那么因为都改为最多,所以第一个变化的就是初始向量,原来除了0,0 其他都不合法。现在因为是最多,也就是L和A可有可无。所以求变得全合法了。 264 | 265 | ``` 266 | long[][] res = new long[][]{{1,1,1,1,1,1}}; 267 | ``` 268 | 然后状态转移是如何呢,我们知道0,0 现在只能从前一个2,0过来了,不然就破坏了最多的定义。因为只要加一个P,就可以使得结尾最多又恢复到0个L。 269 | 270 | 1,0 也可以从2,0转过来(通过最后加P) 271 | 272 | 也可以从(0,0)转过来,通过最后加L。 但是不能最后加A,因为当前定义是历史上最多0个A。(注意历史上最多和只看结尾上最多还是有区别的) 273 | 274 | 所以我们发现任何状态都可以从(0,2)转过来,因为最后都可以加P。 275 | 276 | 只有当I >0时,可以从(i-1, x)转过来,通过加L, 注意这里的X和上一个状态要一致,因为这里是历史上最多。 277 | 278 | 当J >0,可以从(2, j -1)转过来, 通过加A。这里前面可以直接取最大值2,因为加了一个A我们就不会让最多2个L的性质不合法,所以可以取2. 279 | 280 | 那么下面就是写转移矩阵了。因为所有通过加P 都可以从(2,0)转移过来,我们可以看到第二列全是1 281 | ``` 282 | 0: 0,0,1,0,0,0 283 | 1: 1,0,1,0,0,0 284 | 2: 0,1,1,0,0,0 285 | 3: 0,0,1,0,0,1 286 | 4: 0,0,1,1,0,1 287 | 5: 0,0,1,0,1,1 288 | ``` 289 | 下面只要改一下初始矩阵,和变换矩阵,其余代码不用动,最后直接返回即可。 290 | ``` 291 | int M = 1000000007; 292 | public int checkRecord(int n) { 293 | long[][] m = new long[][]{ 294 | {0,1,0,0,0,0}, 295 | {0,0,1,0,0,0}, 296 | {1,1,1,1,1,1}, 297 | {0,0,0,0,1,0}, 298 | {0,0,0,0,0,1}, 299 | {0,0,0,1,1,1} 300 | }; 301 | long[][] res = new long[][]{{1,1,1,1,1,1}}; 302 | while (n > 1) { 303 | if (n % 2 == 1) res = mul(res, m); 304 | m = mul(m, m); 305 | n >>= 1; 306 | } 307 | res = mul(res, m); 308 | return (int) res[0][5]; 309 | } 310 | ``` 311 | 讲了这么多,我们最后再来总结一下快速幂优化的思想,`就是把计数的状态机转换的DP,通过把初始状态表示为初始向量,转移方程表示为变换矩阵。通过矩阵快速幂的方式优化时间复杂度从O(n) 到 O(log(n))的一类技巧。` 312 | 313 | 讲到这里矩阵快速幂优化就要告一段落,再开始新的篇章前,我给你们留一道思考题。 314 | 315 | >上题中L和A 都是定值,如果L和A,是可变的(假设2者之和不超过10),你该如何实现LOG N的算法呢? 316 | 317 | ## 四边形不等式优化 318 | 319 | 四边形不等式DP理论非常复杂,编码还是比较简单。 320 | 321 | 先说下他的由来。我们都知道一个东西叫最优树。还记得我们在学编码时的哈夫曼数吗,因为每个字母的出现频率不一样,所以我们希望频率高的编码尽可能短,就有了哈夫曼树的思想。他就是贪心的去合并权值最小的2个树,最后合到一颗为止。该树即为所求的哈夫曼树。 322 | 323 | 随后计算机鼻祖 高纳德 在解决最优二叉搜索树时发明的一个算法,随后姚期智的夫人,做了深入研究,扩展为一般性的DP优化方法。可以把一些时间复杂度O(n^3)的DP问题优化到O(n^2), 所以这个方法又被成为 (Knuth-Yao speedup theorem) 324 | 325 | 最优二叉搜索树问题: 326 | 327 | >现有 n 个排序过的数,记为数组 a。为叙述方便使 a 的下标从 1 开始。已知给定两组概率 P1...PN 和 Q0...QN,Pi 为“每一次搜索的目标正好为 a i的概率, Qi 328 |  为“每一次搜索的目标正好在a (i) 和 a (i+1) 之间”的概率,其中设边界值 a (0) 329 |  为负无穷,边界值 a (n+1)为正无穷。求根据这些概率组成一个高效的二叉搜索树,使得每次搜索的平均时间最小化。只需要返回该树的最优平均时间,不需要表达或者返回树的结构。 330 | 331 | 332 | 我们来思考下因为二叉搜索树需要保持节点本身有序的特性,所以我们不能像哈夫曼树那样贪心的取2个概率最小的子树去合并,因为会破坏搜索树的特性。其实这里等价于只有相邻的子树可以合并。这样我们可以把最小的子问题给求好,然后依据最小求次小。次小的时候我们需要枚举决策点,然后再所有决策点里找最小。这样的做法是O(n^3)的。 333 | 334 | 因为要遍历N层,每一层我们要遍历N个窗口,每个窗口我们又要枚举最优决策点。 335 | 336 | 我们来看下N^3的代码如实写 337 | ``` 338 | double calculateOptimalBST(double[] recordProbability, double[] gapProbability) { 339 | int n = gapProbability.length; 340 | double[][] dp = new double[n+1][n+1]; 341 | double[][] subTreeProbabilitySum = new double[n+1][n+1]; 342 | for (int i = 1;i <= n; i++) { 343 | dp[i][i - 1] = gapProbability[i-1]; 344 | subTreeProbabilitySum[i][i - 1] = gapProbability[i-1]; 345 | } 346 | for (int len = 1; len < n; len++) { // 枚举节点数为LEN的子树的最优解 347 | for (int i = 1; i < n + 1 - len; i++) { // 滑动每一个窗口i~j 348 | int j = i + len - 1; 349 | subTreeProbabilitySum[i][j] = 350 | subTreeProbabilitySum[i][j - 1] + recordProbability[j] + gapProbability[j]; 351 | dp[i][j] = Double.MAX_VALUE; 352 | for (int k = i; k <= j; k++) { // 枚举决策点,K是根节点 353 | if (dp[i][j] > dp[i][k-1] + dp[k+1][j]) { 354 | dp[i][j] = dp[i][k-1] + dp[k+1][j]; // 左右子树的搜索代价各加一层 355 | } 356 | } 357 | dp[i][j] += subTreeProbabilitySum[i][j]; // 有这个树的搜索代价加一层 358 | } 359 | } 360 | return dp[1][n - 1]; 361 | } 362 | ``` 363 | 上面的代码我们可以跑2个简单的例子论证下正确性 364 | 365 | 比如,只含一个节点的树,他的最优解就是本身,但是左右的GAP 因为在遍历的时候是需要搜到NULL节点才能确定这个值不存在,所以搜的层数都为2. 366 | ``` 367 | Assert.assertTrue(0.2 + (0.3 + 0.5) * 2 == 368 | calculateOptimalBST(new double[]{-1, 0.2}, new double[]{0.3, 0.5})); 369 | ``` 370 | 371 | 我们再来验证2个节点的情况,加入第一个GAP区间概率很高,我们应该拿左边的节点为根节点更优。 372 | 373 | ``` 374 | Assert.assertTrue(0.25 + (0.4 + 0.15) * 2 + (0.08 + 0.12) * 3 375 | == calculateOptimalBST(new double[]{-1, 0.25, 0.15}, new double[]{0.4, 0.08, 0.12})); 376 | ``` 377 | 378 | 我们再来验证2个节点的情况,加入最后一个GAP区间概率很高,我们应该拿右边的节点为根节点更优。 379 | 380 | ``` 381 | Assert.assertTrue(0.15 + (0.4 + 0.25) * 2 + (0.08 + 0.12) * 3 382 | == calculateOptimalBST(new double[]{-1, 0.25, 0.15}, new double[]{0.12, 0.08, 0.4})); 383 | ``` 384 | 下面我们来讲四边形不等式优化。 385 | 386 | 这个优化的证明过程非常繁琐,我这里只讲技巧,具体证明我给大家一些不错的资料,有兴趣的朋友大家可以根据资料去学习。比如B站的[这个视频](https://www.bilibili.com/video/BV1W7411T7dq?from=search&seid=16563172725339405871) 387 | 388 | 这类的优化过程通常是这样的,比如原来的O(N^3)的写法是这样 389 | 390 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-fc5dcf1d24c62cea.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 391 | 392 | ### 优化之后的代码会长这样 393 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-445288c12fdbc2f9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 394 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-0adee5a904388d07.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 395 | 396 | 上面我们引入一个关键的S表,他代表我们之前求过的最优决策。 397 | 398 | 这个决策表会有一些初始值我们需要赋值,之后就是用最优决策来锁定第三层循环的范围。被证明可以让第三层在第二层下的总次数是O (N)的。 也就是表面上看是2层循环,但是实际只有O(N)的遍历次数,具体可以通过打CNT,每遍历一次CNT++来直观感受。 399 | 400 | 下面我们来分析什么时候可以用四边形不等式。 401 | 402 | >1、如果上述的w函数同时满足区间包含单调性和四边形不等式性质,那么函数dp也满足四边形不等式性质 403 | 我们再定义s(i,j)表示 dp(i,j) 取得最优值时对应的下标(即 i≤k≤j 时,k 处的 dp 值最大,则 s(i,j)=k此时有如下定理 404 | 405 | >2、假如dp(i,j)满足四边形不等式,那么s(i,j)单调,即 s(i,j)≤s(i,j+1)≤s(i+1,j+1) 406 | 407 | 所以也就是说只要W函数,有2个性质,我们可以知道S[I,J]单调,那么就可以套模板来优化。也就是第三层循环由原来的I~J,变成S[I][J-1] ~S[I+1][J] 408 | 409 | 我们来看下什么是包含单调性 和 四边形不等式性。 410 | 411 | - 区间包含单调性 :如果对于任意 a<=b<=c<=d ,均有w(b,c) <= w(a,d) 成立,则称函数 w 对于区间包含关系具有单调性。 412 | - 四边形不等式 :如果对于任意 a <= b <= c <= d ,均有 w(a,c) + w(b,d) <= w(a,d) + w(b,c) 成立,则称函数 满足四边形不等式(简记为“交叉小于包含”)。若等号永远成立,则称函数 w 满足 四边形恒等式 。 413 | 414 | 我们回过头来看上面最优二叉搜索树的W函数。 415 | 416 | 本质上是subTreeProbabilitySum, 因为加的都是>=0很容易得出大区间一定>=小区间,所以满足区间包含单调性 417 | 418 | 第二个四边形不等式,有时可以直接证明出来是满足的,有些时候不太好想,我们可以直接对W函数打表,然后验证所有的ABCD,如果是满足的。那么大概率可以用四边形不等式优化。 419 | 420 | 我们来写个打表函数。 421 | ``` 422 | for (int i = 1; i < n; i++) { 423 | for (int j = i; j < n; j++) { 424 | for (int k = j; k < n; k++) { 425 | for (int m = k; m < n; m++) { 426 | double contain = subTreeProbabilitySum[i][m] + subTreeProbabilitySum[j][k]; 427 | double cross = subTreeProbabilitySum[i][k] + subTreeProbabilitySum[j][m]; 428 | assert contain >= cross; 429 | } 430 | } 431 | } 432 | } 433 | ``` 434 | 如果没有报错,我们可以尝试用一下四边形不等式优化。 435 | ``` 436 | double calculateOptimalBST(double[] recordProbability, double[] gapProbability) { 437 | int n = gapProbability.length; 438 | double[][] dp = new double[n+1][n+1]; 439 | 440 | double[][] subTreeProbabilitySum = new double[n+1][n+1]; 441 | for (int i = 1;i <= n; i++) { 442 | dp[i][i - 1] = gapProbability[i-1]; 443 | subTreeProbabilitySum[i][i - 1] = gapProbability[i-1]; 444 | } 445 | int[][] s = new int[n+1][n+1]; // step 1. 引入决策表 446 | for (int i = 1; i <= n; i++) // step 4. 给s 赋初始值 447 | s[i][i - 1] = i; 448 | for (int len = 1; len < n; len++) { 449 | for (int i = 1; i < n + 1 - len; i++) { 450 | int j = i + len - 1; 451 | subTreeProbabilitySum[i][j] = 452 | subTreeProbabilitySum[i][j - 1] + recordProbability[j] + gapProbability[j]; 453 | dp[i][j] = Double.MAX_VALUE; 454 | int st = s[i][j-1], ed = Math.min(j, s[i+1][j]); // step 3. 用决策表更新搜索范围 455 | for (int k = st; k <= ed; k++) { 456 | if (dp[i][j] > dp[i][k-1] + dp[k+1][j]) { 457 | dp[i][j] = dp[i][k-1] + dp[k+1][j]; 458 | s[i][j] = k; // step2. 记录最优决策 459 | } 460 | } 461 | dp[i][j] += subTreeProbabilitySum[i][j]; 462 | } 463 | } 464 | return dp[1][n - 1]; 465 | } 466 | ``` 467 | 大致分为4步。 468 | - 第一步,引入决策表。 469 | - 第二步,在更新时更新决策表。 470 | - 第三步,在第三层循环时,用决策表的数据来循环。 471 | - 第四步,是需要思考的,如何赋初始值。这道题因为最开始算的是DP[I][I], 也就是1的单位,那么对S[I][j]来说,他的前驱和后继s[i][j-1] 和 s[i+1][j] 都是0 的单位,所以要在赋初始值时用s[i][i-1],其次就是在第一次循环时ed 这个位置因为只有一个长度好遍历,所以这里要加个MIN 472 | 473 | #### 我们再来研究下这个优化到底在遍历什么? 474 | 首先我们会发现,他是根据长度从小到大在遍历每个窗口。在每层长度中,每个窗口要遍历的范围则是S[i][j-1] 到 S[i+1][j]. 具体一下再LEN=1, I = 1时,其实就是 s[2][1] - s[1][0] 个决策点. 随后I到2了,变成s[3][2] - s[2][1] 个决策点。 那么发现前项和后项有正负号可以抵消。所以最终一个LEN的遍历次数就是 s[n-1][n-len+1] - s[len-1][0]。根据S数组的定义,就是决策点,所有决策定都不会超过N,所以对于一个LEN来说,内部2个循环和为s[n-1][n-len+1] - s[len-1][0] <= n. 475 | 476 | 就证明是O(n ^ 2) 了 477 | 478 | 我们可以看一道类似的题目 479 | https://www.lintcode.com/problem/stone-game 480 | 这道题其实和最优二叉树还是比较像的,区别就是不用考虑GAP区间。其实也就更加简单。 481 | 482 | 因为不考虑GAP区间,我们甚至可以直接证明COST[I,J] 是满足四边形恒等式的。 483 | 484 | `cost[a,d] = cost[a,b] + cost[b,c] + cost[c,d]` 485 | 486 | `cost[a,c] = cost[a,b] + cost[b,c] ` 487 | `cost[b,d] = cost[b,c] + cost[c,d] ` 488 | 489 | 这样变形之后带入原式,就会发现左右两边相等。 490 | 491 | 另外一个最优二叉搜索树的DP[I,J] 其实是包含了gap[i-1] ~gap[j] 的。 492 | 493 | 所以枚举最优决策不让左右最优子树的GAP有重叠需要从dp[i][k-1] 和 dp[k+1][j]来转移。因为他们代表了gap[i-1]~gap[k-1] 和 gap[k]~gap[j]的2颗最优子树。刚好排除了决策节点的全覆盖。 494 | 495 | 而这道题因为不存在GAP,DP[I,J]的定义也发生了变化,指的是合并stone[i] ~ stone[j], 所以枚举K的时候,是dp[i][k] 和 dp[k+1][j]转移过来,意思是最后是由[i,k]这堆石头和[k+1, j]这堆石头合并。 496 | 497 | 我们来看下直接用四边形,因为石子这个问题,循环是从长度为2的情况开始(1是终态不用算的),所以在初始化的时候是初始化1,而不是像上一题初始化0.这些都是要注意的细节。下面上代码 498 | 499 | ``` 500 | public int stoneGame(int[] A) { 501 | int l = A.length; 502 | if (l == 0) return 0; 503 | int[][] dp = new int[l][l]; 504 | int[][] s = new int[l][l]; 505 | int[] presum = new int[l + 1]; 506 | for (int i = 0; i < l; i++) { 507 | presum[i + 1] = presum[i] + A[i]; 508 | s[i][i] = i; 509 | } 510 | for (int len = 2; len <= l; len++) { 511 | for (int i = 0; i < l - len + 1; i++) { 512 | int j = i + len - 1; 513 | dp[i][j] = Integer.MAX_VALUE / 2; 514 | int st = s[i][j-1], ed = Math.min(j - 1, s[i+1][j]); 515 | for (int k = st; k <= ed; k++) { 516 | if (dp[i][k] + dp[k + 1][j] < dp[i][j]) { 517 | dp[i][j] = dp[i][k] + dp[k + 1][j]; 518 | s[i][j] = k; 519 | } 520 | } 521 | dp[i][j] += presum[j + 1] - presum[i]; 522 | } 523 | } 524 | return dp[0][l - 1]; 525 | } 526 | ``` 527 | 通过四边形不等式我们又把一道N^3的区间DP问题给优化到了N ^ 2 528 | 529 | **这道题还有一种n log n的解法,叫GarsiaWachs算法的最优解法, 学有余力的小伙伴可以自行搜索学习** 530 | 531 | ### 我们再来看一道这周周赛的一个问题 532 | https://leetcode.com/problems/allocate-mailboxes/ 533 | 这道题暴力的思路是,我们只分析前K个房子,假设此时要建M个邮局。我们已经知道M-1个邮局,前0~K-1个房子下的最优解。我们只要枚举最后一个邮局建造的位置管辖的屋子,然后不管辖的屋子靠前面算过的最优子问题直接获得答案即可得到当前问题的答案。 534 | 535 | 因为这里最优子问题是由2个维度组成N和K。那么枚举决策的时候还是要根据N来枚举最后一步能管的房子数量。所以这道题DP 是O(N^3)的 536 | 537 | DP方程为 538 | `dp[i][j]=min(dp[i][j],dp[k][j-1]+w[k+1][i]);` 539 | 540 | 其中dp[i][j]表示前i个村庄建j个邮局的最小距离和,k枚举1到i的所有村庄;w[k+1][i]表示第k+1个村庄到第i个村庄建一个邮局的最小距离和,有一个显然的性质:在某一段区间上建一个邮局,最小距离和为在其中点村庄上建 541 | 542 | 那么我先来思考怎么快速的把这个W数组给求出来 543 | 544 | 我们知道如果只有一个房子w[i][i] = 0. 545 | 546 | 如果有2个房子,我们是取中点建是最优的。所以我们新加了一个房子就是加上他到中点的距离。 547 | ``` 548 | int mid=(i+j)/2; 549 | w[i][j] = w[i][j-1]+abs(x[j]-x[mid]); 550 | ``` 551 | 有读者可能会问,除了最后一个点不算,为什么w[i][j] = w[i][j-1],中点不是会随着加了一个屋子,而往后移吗? 552 | 553 | 这个我们可以分类讨论看,如果是从奇数房子增加到偶数房子。中点是不会移动的,所以可以等价。 554 | 555 | 如果是偶数房子增加到奇数房子,中点是需要往后移动一格。但是原来小于等于中点的房子数量是偶数的一半,我把中点往后移,这一半的房子的距离都要加上中点移动的距离。同时剩下一半的房子的距离都会减去中点移动的距离。因为2半的房子数量相同,所以值还是不变的。综上这个等式是成立的。 556 | 557 | 那么我们就可以在O(N ^ 2) 的时间求完W数组 558 | 559 | 接下来主要的时间瓶颈就是DP这个O(n * n * K)的复杂度。 560 | 561 | 这个式子`dp[i][j] = min{ dp[i-1][k] + w(k+1,j) | i-1 <= k < j }`乍一看和我们之前说到可以用四边形不等式的式子`dp[i][j] = min{ dp[i][k] + dp[k+1][j] + w(i,j) | i <= k < j }`似乎不太一样,但有一个相似点是他也要枚举最优的决策,这个时候有一个技巧就是,我们把决策表打印出来,看他是不是每行单调递增(允许>=),同时每列也单调递增(允许>=)。如果满足这个性质,大概率这个式子也是满足决策单调性,就可以用四边形不等式的套路进行优化 562 | 563 | 所以基础代码如下 564 | ``` 565 | int inf = Integer.MAX_VALUE / 2; 566 | private int[][] dis(int[] a) { 567 | int l = a.length; 568 | int[][] dis = new int[l][l]; 569 | for (int i = 0; i < l; i++) { 570 | for (int j = i + 1; j < l; j++) { 571 | dis[i][j] = dis[i][j - 1] + a[j] - a[(j + i)/2]; 572 | } 573 | } 574 | return dis; 575 | } 576 | public int minDistance(int[] houses, int k) { 577 | Arrays.sort(houses); 578 | int[][] dis = dis(houses); 579 | int n = houses.length; 580 | // DP I J 表示前J个屋子用了I个邮局的最小距离和 581 | int[][] dp = new int[k + 1][n]; 582 | int[][] s = new int[n+1][n+1]; 583 | for (int[] i : s) Arrays.fill(i, -1); 584 | for (int i = 0; i < n; i++) { 585 | dp[1][i] = dis[0][i]; 586 | } 587 | for (int l = 2; l <= k; l++) { 588 | for (int i = l; i < n; i++) { 589 | dp[l][i] = inf; 590 | for (int j = l-1; j <= i; j++) { // 枚举最后一个邮局COVER 多少房子 591 | if (dp[l - 1][j - 1] + dis[j][i] < dp[l][i]) { 592 | dp[l][i] = dp[l - 1][j - 1] + dis[j][i]; 593 | s[l][i] = j; 594 | } 595 | } 596 | } 597 | } 598 | // 验证行单调 599 | for (int[] i : s) { 600 | boolean seeingMinusOne = true; 601 | for (int j = 1; j < i.length; j++) { 602 | if (seeingMinusOne && i[j-1] != -1) seeingMinusOne = false; 603 | if (i[j] == -1 && !seeingMinusOne ) i[j] = inf; 604 | assert i[j] >= i[j-1]; 605 | 606 | } 607 | } 608 | // 验证列单调 609 | for (int i = 0; i < n; i++) { 610 | boolean seeingMinusOne = true; 611 | for (int j = 1; j < n; j++) { 612 | if (seeingMinusOne && s[j-1][i] != -1) seeingMinusOne = false; 613 | if (s[j][i] == -1 && !seeingMinusOne ) s[j][i] = inf; 614 | assert s[j][i] >= s[j-1][i]; 615 | } 616 | } 617 | return dp[k][n - 1]; 618 | } 619 | ``` 620 | 用这个代码去跑一下评测系统,如果出现ASSERTION ERROR,那么就代表决策不具备单调性,如果没出问题,那么我们可以去尝试用四边形不等式优化。 621 | 622 | 下面我就是要思考这里我们要算的是DP[L][I], 那么更新的决策点就是S[L][I],这个时候S[L-1][I] 是已经求好了,另外一侧需要S[L][I+1], 那么就需要第二层循环从大到小,再检查一下DP只需要上一层的元素,所以从大到小是没问题的。 623 | 624 | 因为I都是从I-1开始,那么初始的第三层循环的右侧<=值最大应该是N-1,所以S[L][N] = N - 1.同时因为L是从2开始的,我们需要构建好左边的初始值,S[1][i] = 1. (因为L =2 , 然后J从L-1开始,所以=1) 625 | 626 | 用四边形不等式优化的代码如下: 627 | ``` 628 | int inf = Integer.MAX_VALUE / 2; 629 | private int[][] dis(int[] a) { 630 | int l = a.length; 631 | int[][] dis = new int[l][l]; 632 | for (int i = 0; i < l; i++) { 633 | for (int j = i + 1; j < l; j++) { 634 | dis[i][j] = dis[i][j - 1] + a[j] - a[(j + i)/2]; 635 | } 636 | } 637 | return dis; 638 | } 639 | public int minDistance(int[] houses, int k) { 640 | Arrays.sort(houses); 641 | int[][] dis = dis(houses); 642 | int n = houses.length; 643 | int[][] dp = new int[k + 1][n]; 644 | int[][] s = new int[n+1][n+1]; // step.1 645 | for (int[] i : s) Arrays.fill(i, -1); 646 | for (int i = 0; i < n; i++) { 647 | dp[1][i] = dis[0][i]; 648 | s[1][i] = 1; // step 4. 649 | } 650 | 651 | for (int l = 2; l <= k; l++) { 652 | s[l][n] = n - 1; // step 4. 653 | for (int i = n - 1; i >= l - 1; i--) { 654 | dp[l][i] = inf; 655 | int st = s[l-1][i], ed = s[l][i+1]; // step 3. 656 | for (int j = st; j <= ed; j++) { 657 | if (dp[l - 1][j - 1] + dis[j][i] < dp[l][i]) { 658 | dp[l][i] = dp[l - 1][j - 1] + dis[j][i]; 659 | s[l][i] = j; // step 2. 660 | } 661 | } 662 | } 663 | } 664 | 665 | return dp[k][n - 1]; 666 | } 667 | ``` 668 | 到这里我已经把四边形不等式如何优化的思想已经介绍完了,当然四边形不等式的优化还可以运用再一维DP里,不过本文已经相当长了。而且我还有斜率优化也还没写,所以我们1维DP的四边形不等式优化和斜率优化放在下一章讲。因为这2个算法,目前LC还没有对应的题目,所以算是超纲讲授。不过既然都开始写了,就写写完完整整。所以小伙伴们,我们下章见。 669 | 670 | ## 总结 671 | 这次我们主要学习了矩阵快速幂来优化基于状态机转移的DP计数类问题。原理就是把初始状态设计成向量,转移方程设计为矩阵。转移过程就是向量和矩阵的乘法,然后因为矩阵乘法具有结合律,所以我们可以先算矩阵乘法通过快速幂的方式达到优化效果。 672 | 673 | 随后是四边形不等式的优化,原理就是在区间类DP中需要枚举最优决策点时,我们可以通过判断代价函数是否满足区间包含单调性,和四边形不等式来得知决策点是否具备单调性,如果具备单调性就可以用4步法来把O(N^3)的复杂度 优化至 O (N^2)。 674 | 675 | 老惯例,给大家留2道思考题。 676 | >1. 在矩阵快速幂中,那道同学上课缺席迟到的题目,如果L和A是动态传入,应该如何通过代码来构建转移矩阵呢? 677 | 678 | >2. 石子合并那道题,如果是求最大COST,应该怎么做呢? 679 | 680 | >3. 请研究https://leetcode.com/problems/minimum-cost-to-merge-stones/ 怎么做,能否用四边形不等式。 681 | 682 | ## 上期思考题时间 683 | #### 在一棵有根多叉树中,如何使用二进制优化,来找最近公共祖先呢? 684 | 685 | 这道题分为3步。 686 | 687 | 第一步,同样我们对这颗树的每个节点构建工具包,使得每个节点向上的一步,二步,四步。。。的节点编号都直接被存下来。同时把每个节点的深度给存下来。 688 | 689 | 随后深度深的那个节点,开始从大工具(步数大的开始跳)开始使用,使用的前提是用完之后,依然没有比深度浅的节点更浅。那么就用,继续换小工具。这样做的目标是使得2个节点在同一深度。 690 | 691 | 随后如果2个节点是一个点了,那么就直接返回。如果不是,就来到第三步。 692 | 693 | 第三步就是2个节点使用工具一起往上跳,用该工具的前提是,2个节点用完不会使得他们来到了同一个节点(因为可能跳过头了);我们的目标要找到最浅的一个不同的父节点,那么他们上面一个就是最近公共祖先。 694 | 695 | #### 总共有 n 道题目要抄,编号 0,1,…,n,抄第 i 题要花 ai 分钟。老师要求最多可以连续空的题为K,求消耗时间最少满足老师要求的方案。 696 | 697 | 首先我们可以在最后加1个时间为0 的题。然后这样就可以用DP[N+1]来得到答案。 698 | 699 | DP[N+1]表示的是N+1这道题写了的话花的最小时间。 700 | 701 | 随后我们就可以知道转移方程就是,因为最多K道空,那么前K道里面必然需要一个题是写的,那么就从这K个DP转移过来,求最小。那么这里也是维护区间最小值,我们可以用单调队列来解决。 702 | -------------------------------------------------------------------------------- /3. 高级数据结构-线段树.md: -------------------------------------------------------------------------------- 1 | 这应该是系统介绍LC的线段树题目全网截止发文时最全的文章了。 2 | 3 | 从这篇文章里,你可以学到如何用线段树思维和模板解LC的超难题。 4 | 5 | 这篇文章算是进阶文章,不适合完全不知道线段树是什么的,以及最基本的操作为什么可以这么写的小伙伴。 6 | 7 | 关于基础,网上有很多资料。 8 | 9 | 你会收获的知识点如下: 10 | 1. 普通基于数组的线段树 和 动态开点的线段树,及各自的运用场景与优劣势 11 | 2. 线段树可以为我们做什么,如何站在巨人(线段树)的肩膀上思考问题 12 | 3. 如果快速套模板去攻破Leetcode的所有的线段树问题 13 | 14 | ### ---------------------2020.7.5 更新-------------------------------- 15 | 今天又多了一道线段树的题目。第一次看这篇文章的小伙伴建议先跳过这道题 16 | 17 | #### 1505. Minimum Possible Integer After at Most K Adjacent Swaps On Digits 18 | 19 | 这道题按照1353. Maximum Number of Events That Can Be Attended的思路,我们可以开10个桶,存下标。然后就根据K的大小去看, 0里面的最前的下标能否够换。如果不行,后面的0的下标也就不用看了,最小的都不够换。那只能取试试1的。 20 | 21 | 想到这步后面有个棘手的问题就是,我们摆了一个位置后(换过去之后)这个位置前的所有数的原下标都要++。 22 | 23 | 比如[3,1,0] 我们把0 放到最前面。3的下标从0变到1, 1的下标从1变到2. 24 | 25 | 所以等价于我们把0下标之前的所有下标都要+1。这步又是O(N)的。 26 | 27 | 这是一个区间更新,我们就可以想到线段树。我们让线段树每个叶子节点存的是当前这个原始下标需要向右偏移多少位。 28 | 29 | 这样我们下次去查的时候,比如这时最前面的是下标是2,我们要去线段树2这个叶子节点上读取偏移量才是他目前真正的下标。然后再看移动的距离和K比够不够。 30 | 31 | 主函数代码 32 | ``` 33 | public String minInteger(String num, int k) { 34 | // 预处理10个桶的下标 35 | Queue[] m = new Queue[10]; 36 | for (int i = 0; i < 10; i++) m[i] = new LinkedList<>(); 37 | char[] cs = num.toCharArray(); 38 | for (int i = 0; i < cs.length; i++) m[cs[i] - '0'].offer(i); 39 | 40 | // 初始化线段树 41 | char[] res = new char[cs.length]; 42 | int idx = 0; 43 | tr = new Node[4 * cs.length]; 44 | build(1, 0, cs.length); 45 | // 开始构建 res 46 | while (idx < cs.length) { 47 | for (char c = '0'; c <= '9'; c++) { 48 | Queue q = m[c - '0']; 49 | if (q.isEmpty()) continue; 50 | // 找到下标 51 | int cur = q.peek(); 52 | // 搜索CUR这个叶子节点的偏移量 53 | cur += query(1, cur, cur); 54 | if (k < cur - idx) continue; 55 | k -= cur - idx; 56 | res[idx++] = c; 57 | int pos = q.poll(); 58 | // 更新区间[0, POS] 所有的偏移量+1 59 | update(1, 0, pos, 1); 60 | break; 61 | } 62 | } 63 | return new String(res); 64 | } 65 | ``` 66 | 然后就是修改线段树的模板 **模板用法,请先读下面的文章** 67 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-5f1d49f0aaf67159.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 68 | ### ---------------------2020.7.5 更新结束-------------------------------- 69 | 70 | ## 线段树的基本思想 71 | https://oi-wiki.org/ds/seg/ 72 | 73 | 线段树的基本思想可以直接看这个WIKI。 74 | 75 | 然后一般线段树会开一个4倍于要维护区间的数组。比如要维护的区间是[11,20] ;就会用长度为40的数组。然后数组下标是**1 是根节点**,表示的范围是[11, 20], **任何一个节点的左孩子是 2 * i, 右孩子是2 * i + 1.** 76 | 77 | 当然是和` i << 1 和 (i << 1)|1 `等价。 78 | 79 | 数组式线段树,一般会有3个接口函数 80 | 81 | 1. BUILD(根据初始ARRAY,或者直接构建初始线段树,作用就是给NODE 赋予初始值) 82 | 83 | 2. query (从哪个节点下标开始,取[L, R]的范围的值) 84 | 85 | 3. update (从哪个节点下标开始, 用VAL 更新[L, R]的区间) 86 | 87 | 因为区间更新可以兼容单点更新,所以我放模板的时候统一用区间更新,如果遇到一道题只需要单点更新,那么可以无视PUSH DOWN 函数,以及把L, R传成一个数即可。 88 | 89 | 上面3个接口函数,都是递归套路的写法。 90 | 91 | 另外决定每个线段树不一样的性质是VAL的定义。我们都知道每个NODE里会维护一个VAL。 这个VAL可以有不同的解释,那么在对应的区间可加性上就会有不同的算法。 92 | 93 | 所谓区间可加性是线段树使用的一个必要条件。比如我们要维护[1,10]的区间和。我们知道的是[1,5] 和 [6,10]的区间和,我们怎么根据2个子区间的信息 构造全局区间的信息,如果可以做到,就满足区间可加性。同时很重要的一点是,如果我们的题目要用到范围更新,那么懒标记也要满足区间可加。比如我们要求区间的最大公约数,这个是区间可加的,但是更新区间的时候,是在原范围内的所有数都加上一个值,这就会引起最大公约数和这个DELTA的增量不可加的情况,这个时候,我们得优化算法使得其变成区间可加。会比较难。 94 | 95 | 然后线段树在维护节点的时候,和数据结构里的堆非常类似,会需要自定义2个基本操作。一个叫PUSHUP,他的含义就是`根据孩子信息更新父亲信息`。 96 | 97 | 另外一个在涉及到需要用到区间更新,懒标记的时候才需要的函数,叫PUSHDOWN,他的含义是`用父亲的懒标记,更新还在的信息和懒标记` 98 | 99 | 所以在用线段树的模板时,基本只要考虑3个问题, 100 | 101 | 1. 每个节点的VAL定义是什么 102 | 103 | 2. 如何更新VAL(包含如何PUSHUP) 104 | 105 | 3. 是否有区间更新 , 如果有,如何定义懒标记,和PUSHDOWN 106 | 107 | 下面我给出普通线段树的模板,然后会介绍模板如何使用 108 | ``` 109 | class Node { 110 | int rgLeft, rgRight; // 该节点负责的左区间和右区间。前闭后闭 111 | int sign; // 延迟懒标记,用于区间更新 112 | int val; // 节点的VAL值,不同题目下含义不同 113 | public Node(int left, int right) { 114 | this.rgLeft = left; this.rgRight = right; 115 | } 116 | } 117 | Node[] tr; 118 | // pushup 就是利用孩子的信息更新父亲的信息 (u << 1) 为左孩子, (u<<1|1)为右孩子 119 | void pushup(int u) { 120 | Node left = tr[u << 1], right = tr[u << 1 | 1]; 121 | tr[u].val = merge(left.val, right.val); 122 | } 123 | // 把2个子区间的VAL,利用线段树的区间可加性,归并到总区间的VAL 124 | private int merge(int left, int right) { 125 | // TODO 126 | return -1; 127 | } 128 | // 根据父亲的懒标记,去更新孩子的VAL及懒标记 129 | void pushdown(int u) { 130 | if (tr[u].rgLeft != tr[u].rgRight) { 131 | // get sign 132 | // use sign update rgLeft child val and rgLeft sign 133 | // use sign update rgRight child val and rgRight sign 134 | } 135 | // clear sign 136 | } 137 | // 线段树初始化函数, u 代表当前节点的INDEX, L代表负责的左区间,R代表负责的右区间 138 | void build(int u, int l, int r) { 139 | if (l > r) return; 140 | if (l == r) { 141 | tr[u] = new Node(l, l); 142 | } else { 143 | tr[u] = new Node(l, r); 144 | int mid = (l + r) / 2; 145 | build(u << 1, l, mid); 146 | build(u << 1 | 1, mid + 1, r); 147 | pushup(u); 148 | } 149 | } 150 | // 线段树区间查询函数, u 代表当前节点的INDEX, L代表query的左区间,R代表query的右区间 151 | int query(int u, int l, int r) { 152 | if (l > r) return 0; 153 | if (tr[u].rgLeft >= l && tr[u].rgRight <= r) return tr[u].val; 154 | else { 155 | pushdown(u); 156 | int mid = (tr[u].rgLeft + tr[u].rgRight) >> 1; 157 | if (r <= mid) return query(u << 1, l, r); 158 | else if (l > mid) return query(u << 1 | 1, l, r); 159 | return merge(query(u << 1, l, r), query(u << 1 | 1, l, r)); 160 | } 161 | } 162 | // 线段树区间更新函数, u 代表当前节点的INDEX, L代表要更新的左区间,R代表要更新的右区间, VAL是要更新的值 163 | void update(int u, int l, int r, int val) { 164 | if (tr[u].rgLeft >= l && tr[u].rgRight <= r) { 165 | // update val 166 | // TODO : update sign? 167 | } else { 168 | pushdown(u); 169 | int mid = (tr[u].rgLeft + tr[u].rgRight) >> 1; 170 | if (l <= mid) update(u << 1, l, r, val); 171 | if (r >= mid + 1) update(u << 1 | 1, l, r, val); 172 | pushup(u); 173 | } 174 | } 175 | ``` 176 | 我们来看一道入门线段树的题目 177 | 178 | 是LEETCODE 307 题 179 | 180 | #### 307. Range Sum Query - Mutable 181 | 在这道题中,我们首先思考他是在对区间的一个点做变化,同时要快速求得区间和。那么就可以想到线段树。 182 | 183 | 所以第一步就是***想到可以用线段树*** 184 | 185 | 第二步就是想这题中**每个NODE节点里的VAL 的定义是什么** 186 | 187 | 在这题里很好想,这要维护区间和就可以。那么VAL就是这个NODE所表示的区间的区间和。 188 | 189 | 第三步 190 | 191 | 想到要用线段树之后,**如何更新**,题目中说的是把一个值更新到另一个值,那么因为是维护区间和,所以MERGE函数就是LEFT + RIGHT 192 | 193 | 所以我们要写的就这么2行代码 194 | 195 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-207e9ea707e35451.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 196 | 197 | 因为用不到区间更新,所以我们可以直接无视PUSHDOWN函数 198 | 199 | 第四步 200 | 201 | 就是**如何站在线段树这个数据结构上去解决原问题。** 202 | 203 | 那么其实原问题要做的事情都和线段树的接口函数一一对应了。 204 | 205 | 所以直接调用即可。 206 | 207 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-59402043dc18b5e1.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 208 | 209 | ### 离散化和动态开点 210 | 211 | 因为线段树开初始空间的情况完全依赖与最小和最大的区间范围。 212 | 213 | 比如我们有一些操作,里面数的最小值可能会达到`Integer.MIN_VALUE` , 数的最大值可能会达到`Integer.MAX_VALUE` 214 | 215 | 但是数的量大概只有10000~100000左右。那么为了这么一点数去开2^32这么大的空间是不现实的。会直接暴内存。 216 | 217 | 这个时候,就有2种做法。第一种做法就是预处理数据,把原来分散点的集中起来。比如[100, 2000, 9000, 100000000] 这4个点,我们把它变成[1,2,3,4] 同时利用一个MAP我们可以查询100 对应的是线段树里的1. 线段树里的1 可以查出是原数组里的100. 218 | 219 | 那么我们不离散化需要开4 * [100, 100000000] 这么大区间的线段树数组。离散化后,因为只有4个数,其实只要开 4 * [1, 4], 也就16大小的数组即可了。 220 | 221 | 离散化的`优点`就是,可以使得空间更加紧凑,并且用了离散化后可以使用数组来存线段树的节点,效率更加高。 222 | 223 | `缺点`就是要写更多的代码去对原数据做预处理,然后再使用线段树接口函数的时候,也要把输入用预处理的MAP做个转换。有时可能输出也要做个转换回原数据。 224 | 225 | 上面这个缺点只是对程序员来说,需要花更多的力气。(懒人创造时间,LESS IS MORE)但是如果为了追求性能,是可以采用的。但是还有另外一个致命的缺点。 226 | 227 | 就是你这个程序如果要用离散化开线段树,你必须一开始就要知道全量的数据,这样你才能根据这些数据,排出顺序,知道需要多少空间。 228 | 229 | 比如我们现在要提供这样一个数据结构,可以随时INSERT 一个区间。也就是我们常说的流处理,或者在线处理。这个时候我们一开始是没有全量信息的。我们就只能使用动态开点的方式。 230 | 231 | ### 动态开点线段树 232 | 所谓动态开点的含义就是,一开始只有一个根节点,他代表了你这个程序所支持的输入可能的数据范围。然后你需要更新或者查找哪个区间,我按需在帮你把这些区间涉及到的内部节点给创造出来(如果调用的时候没有的话)。 233 | 234 | 这种方式的优点有2个。 235 | 1. 可以处理在线数据 236 | 2. 不用预处理数据了,直接使用即可。 237 | 238 | 缺点是 239 | 1. 空间会是n log n,原来数组法是O(n), 这里的N是多少个数(在之前的例子里是4,在在线的例子里是INSERT的次数),这个多出来的空间一般也不影响解题 240 | 241 | 2. 节点使用指针相连,没法利用数组有的缓存局部性,时间上常数也会比数组的要大。但是这个常数一般不影响做题。 242 | 243 | 下面给出动态开点线段树的模板 244 | ``` 245 | class Node { 246 | long rgLeft, rgRight; 247 | private Node left, right; // 不要直接用左孩子, 右孩子, 用对应方法去拿 248 | int val; 249 | public Node(int start, int end) { 250 | rgLeft = start; 251 | rgRight = end; 252 | } 253 | public int getRangeMid() { 254 | return (int) (rgLeft + (rgRight - rgLeft) / 2); 255 | } 256 | // 返回左孩子,如果不存在就动态创建 257 | public Node left() { 258 | if (left == null) left = new Node((int)rgLeft, getRangeMid()); 259 | return left; 260 | } 261 | // 返回右孩子,如果不存在就动态创建 262 | public Node right() { 263 | if (right == null) right = new Node(getRangeMid() + 1, (int)rgRight); 264 | return right; 265 | } 266 | } 267 | // 线段树初始化函数, cur 是当前节点, L代表查询的左区间,R代表查询的右区间 268 | int query(Node cur, long l, long r) { 269 | if (l > r) return 0; 270 | if (cur.rgLeft >= l && cur.rgRight <= r) return cur.val; 271 | else { 272 | pushdown(cur); 273 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 274 | if (r <= mid) return query(cur.left(), l, r); 275 | else if (l > mid) return query(cur.right(), l, r); 276 | return merge(query(cur.left(), l, r), query(cur.right(), l, r)); 277 | } 278 | } 279 | // 根据父亲的懒标记,去更新孩子的VAL及懒标记 280 | void update(Node cur, int l, int r, int val) { 281 | if (cur.rgLeft >= l && cur.rgRight <= r) { 282 | // update val 283 | // TODO : update sign? 284 | } else { 285 | pushdown(cur); 286 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 287 | if (l <= mid) update(cur.left(), l, r, val); 288 | if (r >= mid + 1) update(cur.right(), l, r, val); 289 | pushup(cur); 290 | } 291 | } 292 | void pushup(Node cur) { 293 | cur.val = merge(cur.left().val, cur.right().val); 294 | } 295 | private int merge(int left, int right) { 296 | return -1; 297 | } 298 | void pushdown(Node cur) { 299 | if (cur.rgLeft != cur.rgRight) { 300 | // get sign 301 | // use sign update rgLeft child val and rgLeft sign 302 | // use sign update rgRight child val and rgRight sign 303 | } 304 | // clear sign 305 | } 306 | ``` 307 | 308 | 到这里线段树的模板基本介绍完了。 309 | ##### 我们就根据每道题的特质,来选取线段树最便捷的策略,开始A题之旅。 310 | 311 | ## 常见线段树CASE1-维护区间个数 312 | 这类线段树,又被称之为权值线段树。或者说桶线段树。比如我们有这样一个数组[1,1,2,2,4,4,8,8,8] 313 | 314 | 如果我们要找第K小的,我们可以先把上面的元素都放进桶里,然后2分。每个桶里存的是数值为1的数的个数有几个,数值为2的数的个数有几个,以此类推。然后对这些桶的值求一个PRESUM(前缀和数组),之后就可以在这个数组上2分找到第K小。 315 | 316 | 那么按照这个思路,其实PRESUM就是在做区间查询,查询的是这个区间所管辖的数值的做个数。 317 | 318 | 那么第一步就可以想到用线段树。 319 | 320 | 而更新操作其实就是给一个叶子区间(桶)做一个CNT++的。所以是单点更新。同时PUSHUP,就是`leftCnt + rightCnt` 就是父亲的`cnt` 321 | 322 | #### 493. Reverse Pairs 323 | 比如leetcode 493题,他是要求逆序对的数量,这里的逆序对`if i < j and nums[i] > 2*nums[j].` 324 | 325 | 下面就是如何站在线段树的肩膀上思考问题。 326 | 327 | 因为我们是从前往后插入线段树,对于每一个当前的数,其实它对应的下标更大所以是J,线段树里的都是比他小的下标所以是I。那么对于J来说,只要在之前的桶里找到比自己的数值2倍还要大的区间的总个数,就是能和自己组成所有逆序对的个数。 328 | 329 | 因为这道题数据范围给的是INT全集,并且是离线的操作,所以我们可以使用离散化,当然也可以动态开点。下面给出动态开点,我们需要填写的代码。 330 | 331 | 注意因为输入就是INT全集了,所以2倍为了防止整型溢出,需要转成long。我的QUERY模板里,是支持LONG的QUERY,但是根节点的范围用的是INT,也就是最多只能覆盖INT全集而不是LONG全集 332 | 333 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-d65249ef84a825c9.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 334 | 335 | 关于线段树本身,模板也只要写2行,因为只涉及单点修改,所以可以无视`pushdown()` 336 | 337 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-6f301a1c0d165203.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 338 | 339 | 340 | #### 315. Count of Smaller Numbers After Self 341 | 315 这个也是一个同样的问题,只不过他要找的是后面比他小的个数。那么我们就可以维护权值线段树,然后从后往前处理。找到从[MIN, 自己VAL-1]区间的CNT,即可 342 | 343 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-75b024b6b548df4e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 344 | 345 | 和上面那道题的代码几乎差不多,而且站在线段树的肩膀上,逻辑也非常好想和好写。 346 | 347 | #### 327. Count of Range Sum 348 | 我们在看327题,这道不是要找简单的大或小的范围了。他要求了一个容错的区间[lower, upper],并且他是在统计区间和的容错区间的个数。 349 | 350 | 这道题因为要求的是个数,还是要基于权值线段树。然后又要维护一个和的范围,那么就可以想到要用前缀和数组。 351 | 352 | 另外这里的输入是INT全集,还要再全集上求前缀和,势必要用到LONG。那么范围过大了(如果开LONG的动态开点,LOG的代价会到64,而且LONG的边界不好处理),这里使用了离散化的方式。 353 | 354 | 所以这道题就是对前缀和从前往后遍历。后面的前缀和,需要找到2个端点[low,up]的区间,使得`presum[i] - low <= upper and presum[i] - up >= lower` 355 | 356 | 然后用这个区间,去在线段树里找有多少个PRESUM 是符合条件的。 357 | 358 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-1f5e9a4b780bc53c.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 359 | 360 | 线段树本身的模板还是只要改那2行。所以不展示出来了。 361 | 362 | ## 406. Queue Reconstruction by Height 363 | 406这一题,一般面试是要求你用O(n^2)的解法去做就可以了。 364 | 365 | 但是这道题最优的做法是O(nlogn)。其实也有多种做法,比如分治就可以解。分治的解法,我会另开专题去讲。这里就讲下如何用权值线段树去解。 366 | 367 | 首先我们要分析出一个性质。把所有元素按第一位从小到大排序,如果第一位相等,第二位大的在前面。这样的数有一个性质。 368 | 369 | 因为第一位是当前最小,那么如果第二位是K个,那么他在放进队伍中的时候,前面只要留K个空位置(因为之后的数都至少>=它,只要后面的数放进他留的K个空位,必定满足题目性质) 370 | 371 | 所以这道题,就是需要动态去找这个数组已经用掉的位置不算,在余下的没用掉的位置里,第K个位置是全数组的第几个下标。 372 | 373 | 然后每个元素就一次用元素的第二位去查这个信息,就知道自己该放到哪了。 374 | 375 | 比如说一开始一共有8个数要排队。那么我们就开一个线段树[0,7]的线段树,VAL 就表示成这个区间可用位置的个数。然后没放进一个元素,我们单点更新VAL=0,这样可用位置就会少1. 376 | 377 | 我们在查找第K大的可用位置,只要先去看左半区间的总的可用位置个数是否>K,如果大,我们就去左半区间找。如果小,我们就去右半区间找第(k-左半区间的总的可用位置个数)大的可用位置即可。 378 | 379 | 因为这题只需要单点query和update,所以我把2个函数合并为一个简化版的函数。其他地方还是原模板的写法 380 | 381 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-f6138ec3d2f55f40.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 382 | 383 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-11f2ad8102f72007.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 384 | 385 | 386 | 387 | ## 常见线段树CASE2-维护区间最值 388 | 线段树另一类常见操作就是维护区间最值,最值也有区间可加性。 389 | 390 | 我们从一道题开始 391 | #### 732. My Calendar III 392 | 这道题要求返回的是加上一个EVENT之后,最多的重叠层数。那么我们就可以维护区间里,最多的重叠层数即可。比如左半区间最多重叠层数是2,右半区间是4,那么总区间就是这2个的最大值。 393 | 394 | 更新的时候,只要更新对应区间里的值使他们val+1即可,表示我们又在这个区间盖上一层。(这里就好比给区间盖被子,找到区间里被子最厚的地方) 395 | 396 | 另外这道题就是我们说的在线操作,一开始无法知道所有的输入,不知道客户端未来会输入什么范围,而且输入是10^9,所以只能用动态开点。 397 | 398 | 如果你们已经走过上面的每一道题,应该对模板已经用的比较熟练了,除了`pushdown`,那这里就带大家练习pushdown 399 | 400 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-6cb53c5a3aa0d02d.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 401 | 402 | SIGN 的含义就是我这层还没给孩子透传的要加的DELTA是多少。若果需要PUSHDOWN了,我就把DELTA加到孩子上,同时加到孩子SIGN上,当他们需要更新他们的孩子时,他们也要拿着自己的SIGN去做这件事(当然是延迟告诉的,只要需要的时候才会用到SIGN去更新) 403 | 404 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-820d92e805a3dce8.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 405 | 406 | 最后就是用线段树接口函数去写题目就非常简单了, 4行代码 407 | 408 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-1be4ca36eb7f30c7.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 409 | 410 | 411 | #### 699. Falling Squares 412 | 这道题本质上和前一道题差不多,也是盖被子,然后被子的厚度是不同的(之前那道题被子厚度全是1)。另外一个问题这个被子是刚性的,也就是说如果中间比较厚两边比较薄,被子不会像现实中可以贴合的覆盖上,而是以最厚的那部分为接触点,钢板被子硬邦邦的叠在上面。 413 | 414 | 所以我们不能直接给区间每个点+上厚度。需要先把要覆盖的区间的最大厚度找出来,然后加上自己的厚度。然后用这个厚度去更新所有要覆盖的区间。这里的更新是直接赋值,而不是累加。 415 | 416 | 我们可以看到用了线段树之后,主代码都非常好写好想。把原来复杂的问题简化了很多。 417 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-3e7fe1fda319acd3.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 418 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-da9f02b4aef917c2.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 419 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-8081783a52b079c6.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 420 | 421 | #### 218. The Skyline Problem 422 | 这道题也是差不多。拿到一个楼,就拿这个楼的天花板去更新他所管辖区间的天花板最高值。所以这里的更新,如果比它高才更新。更新的时候也需要用MAX操作。 423 | 424 | 那么所有楼做了这个操作后,线段树就维护出了天际线的天花板。我们需要用每一个楼可能的横坐标(楼的最左侧和最右侧),去对所有横坐标排序,然后依次遍历这个坐标的高度,是否发生了变化。就能拿把题目的输出给构建出来。 425 | ``` 426 | public List> getSkyline(int[][] buildings) { 427 | TreeSet t = new TreeSet<>(); 428 | // 存储所有楼的可能涉及到的横坐标 429 | for (int[] b : buildings) {t.add(b[0]); t.add(b[1]);} 430 | if (t.isEmpty()) return new ArrayList<>(); 431 | // 利用最小最大横坐标,构建线段树根节点 432 | Node root = new Node(t.first(), t.last()); 433 | // 用天花板高度去更新最大高度 434 | for (int[] b : buildings) update(root, b[0], b[1]-1, b[2]); 435 | int preH = 0, prex = t.first(); 436 | List> res = new ArrayList<>(); 437 | // 依次遍历每个坐标,如果高度发生变化就加到结果集中 438 | for (int x : t) { 439 | int h = query(root, prex, x-1); 440 | if (h != preH) res.add(Arrays.asList(prex, h)); 441 | prex = x; 442 | preH = h; 443 | } 444 | // 结尾归0的点加进结果集里 445 | res.add(Arrays.asList(prex, 0)); 446 | return res; 447 | } 448 | class Node { 449 | long rgLeft, rgRight; 450 | Node left, right; 451 | int val, sign; 452 | public Node(int start, int end) { 453 | rgLeft = start; 454 | rgRight = end; 455 | } 456 | public int getRangeMid() { 457 | return (int) (rgLeft + (rgRight - rgLeft) / 2); 458 | } 459 | public Node left() { 460 | if (left == null) left = new Node((int)rgLeft, getRangeMid()); 461 | return left; 462 | } 463 | public Node right() { 464 | if (right == null) right = new Node(getRangeMid() + 1, (int)rgRight); 465 | return right; 466 | } 467 | } 468 | int query(Node cur, long l, long r) { 469 | if (l > r) return 0; 470 | if (cur.rgLeft >= l && cur.rgRight <= r) return cur.val; 471 | else { 472 | pushdown(cur); 473 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 474 | if (r <= mid) return query(cur.left(), l, r); 475 | else if (l > mid) return query(cur.right(), l, r); 476 | return merge(query(cur.left(), l, r), query(cur.right(), l, r)); 477 | } 478 | } 479 | void update(Node cur, int l, int r, int val) { 480 | if (cur.rgLeft >= l && cur.rgRight <= r) { 481 | // update val 482 | cur.val = Math.max(cur.val, val); 483 | // TODO : update sign? 484 | cur.sign = Math.max(cur.sign, val); 485 | } else { 486 | pushdown(cur); 487 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 488 | if (l <= mid) update(cur.left(), l, r, val); 489 | if (r >= mid + 1) update(cur.right(), l, r, val); 490 | pushup(cur); 491 | } 492 | } 493 | void pushup(Node cur) { 494 | cur.val = merge(cur.left().val, cur.right().val); 495 | } 496 | private int merge(int left, int right) { 497 | return Math.max(left, right); 498 | } 499 | void pushdown(Node cur) { 500 | int sign = cur.sign; 501 | if (cur.rgLeft != cur.rgRight) { 502 | // get sign 503 | // use sign update rgLeft child val and rgLeft sign 504 | cur.left().val = Math.max(cur.left().val, sign); 505 | cur.left().sign = Math.max(cur.left().sign, sign); 506 | // use sign update rgRight child val and rgRight sign 507 | cur.right().val = Math.max(cur.right().val, sign); 508 | cur.right().sign = Math.max(cur.right().sign, sign); 509 | } 510 | cur.sign = 0; 511 | // clear sign 512 | } 513 | ``` 514 | 515 | ## 其他线段树CASE 516 | #### 715. Range Module 517 | 这道题是在维护区间存在性,也就是说这个区间要么存在,要么不在,所以不是1就是0. 如果已经是1了,你再add也还是1. 如果已经是0了,你再remove也还是0 518 | 519 | 所以add时val传1, remove时val传0。 然后线段树更新的时候就直接赋值即可。 520 | 521 | 区间的VAL表示总区间是否全部存在(因为query是看query的区间是否全部存在),所以就是左子树和右子树都为1,才是1. 只要有0,就是0. 那么更新的时候用 max即可。 522 | ``` 523 | class RangeModule { 524 | Node root = new Node(0, (int) 1e9); 525 | public RangeModule() { 526 | 527 | } 528 | public void addRange(int left, int right) { 529 | update(root, left, right-1, 1); 530 | } 531 | 532 | public boolean queryRange(int left, int right) { 533 | return query(root, left, right - 1) == 1; 534 | } 535 | 536 | public void removeRange(int left, int right) { 537 | update(root, left, right-1, 0); 538 | } 539 | 540 | class Node { 541 | long rgLeft, rgRight; 542 | Node left, right; 543 | int val; 544 | int sign; 545 | public Node(int start, int end) { 546 | rgLeft = start; 547 | rgRight = end; 548 | sign = -1; 549 | } 550 | public int getRangeMid() { 551 | return (int) (rgLeft + (rgRight - rgLeft) / 2); 552 | } 553 | public Node left() { 554 | if (left == null) left = new Node((int)rgLeft, getRangeMid()); 555 | return left; 556 | } 557 | public Node right() { 558 | if (right == null) right = new Node(getRangeMid() + 1, (int)rgRight); 559 | return right; 560 | } 561 | } 562 | int query(Node cur, long l, long r) { 563 | if (l > r) return 0; 564 | if (cur.rgLeft >= l && cur.rgRight <= r) return cur.val; 565 | else { 566 | pushdown(cur); 567 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 568 | if (r <= mid) return query(cur.left(), l, r); 569 | else if (l > mid) return query(cur.right(), l, r); 570 | return merge(query(cur.left(), l, r), query(cur.right(), l, r)); 571 | } 572 | } 573 | void update(Node cur, int l, int r, int val) { 574 | if (cur.rgLeft >= l && cur.rgRight <= r) { 575 | cur.val = val; 576 | cur.sign = val; 577 | } else { 578 | pushdown(cur); 579 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 580 | if (l <= mid) update(cur.left(), l, r, val); 581 | if (r >= mid + 1) update(cur.right(), l, r, val); 582 | pushup(cur); 583 | } 584 | } 585 | void pushup(Node cur) { 586 | cur.val = merge(cur.left().val, cur.right().val); 587 | } 588 | private int merge(int left, int right) { 589 | return left & right; 590 | } 591 | void pushdown(Node cur) { 592 | if (cur.rgLeft != cur.rgRight) { 593 | if (cur.sign != -1) { 594 | cur.left().sign = cur.left().val = cur.sign; 595 | cur.right().sign = cur.right().val = cur.sign; 596 | } 597 | } 598 | // clear sign 599 | cur.sign = -1; 600 | } 601 | } 602 | ``` 603 | #### 850. Rectangle Area II 604 | 这道题需要用扫描线结合线段树来解。 605 | 606 | 扫描线在X方向移动,每次利用线段树求得当前被覆盖的区间长度是多少。这道题的区间覆盖和那个盖被子的挺像,就是被子是没有厚度的,所以我们只要求被子盖着的面积。你要拿完被子,才算不被覆盖。有一条两条都算被覆盖。另外你盖了2条,只拿走一条,也算被覆盖。 607 | 608 | 所以我们就用扫描线扫每一个X,基于这个X 会多出来和要移除哪些被子我们更新到线段树里。然后按照现在的X 和 之前的X,就出这段被子覆盖面积(是Y轴长度)* (DELTA X),就能知道这个段里增加的面积。 609 | 610 | 这道题因为用不到QUERY,因为他每次只需要查询根节点的总覆盖面积。所以更新的时候虽然是区间更新,但是其实搜不到子孩子(甚至不会被创建出来),也就没必要下推懒标记。那么就不需要PUSHDOWN了。 611 | 612 | 然后VAL 要表示的是这个区间盖了基层被子。如果这个区间盖被子的数量>1,那么就返回他的全部区间范围。否则的话,就要返回他左孩子的覆盖范围+右孩子的覆盖范围。 613 | 614 | 这个过程需要自底向上更新好。所以我们要维护1个CNT, 和1个VAL。CNT代表盖了多少被子,VAL代表覆盖的范围是多少。如果cnt是0,`val = left.val + right.val` 615 | 616 | 如果>0,`val = rgRight - rgLeft` 617 | 618 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-44048e76a10ef756.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 619 | 620 | 这道题因为他的特殊性,线段树模板改的稍微多一些(相比之前都只要写2行) 621 | 622 | ![](https://upload-images.jianshu.io/upload_images/10803273-0e26397e787335af.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 623 | 624 | #### 1157. Online Majority Element In Subarray 625 | 这道题,其实是基于数组的区间去做QUERY的,所以非常容易想到可以用线段树。 626 | 627 | 但是题目要求返回的是满足THRESHOLD条件的众数。因为1个区间众数只可能有一个(要超过半数),所以我们需要维护区间最有可能的那个众数。但是只是最有可能要判断是不是众数,还需要再CHECK一次。 628 | 629 | 众数候选人 是否满足区间可加性呢? 630 | 631 | 拿到候选人 如何不用O (n)的时间去判断它是否是真的符合THRESHOLD条件呢? 632 | 633 | 这2个点还是需要思考的。 634 | 635 | 后者我们拿到一个候选值,我们可以根据这个值把它所有涉及到的下标都存好。那么就可以根据QUERY的范围去做2次2分查找,分别找到下标的左边界和右边界。然后就能知道这个区间一共有多少个候选数,来判断是否符合THRESHOLD条件。 636 | 637 | 前者是根据moore's voting 算法的特性,知道候选人是有区间可加的性质。如果2个候选人不一样,就拿票数相减,候选人是票数多的那个。如果2个候选人相同,就票数相加。 638 | 639 | 所以每个节点要维护2个数,一个是众数候选人 VAL, 还有一个是他的票数。我们在这里把这2个值,都存进一个int[2]的数组里,这样可以保留很多函数接口 640 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-d0161ab2521e9dee.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 641 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-c21f72ecc506154f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 642 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-0b2ad1a543f09647.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 643 | 644 | 645 | #### 1353. Maximum Number of Events That Can Be Attended 646 | 这道题是每次会有一个活动,活动间可能有重叠,一个活动只要参加一天就算参加过了,问最少可以花几天参加。 647 | 648 | 这道题其实用线段树不是很好想。因为他要的不是重叠的最大高度。而是不重叠的最大高度。 649 | 650 | 那么如果按照贪心的思路,我们可以想到越早结束的,我们越放到前面去参加掉。找自己还能用的空余时间里,去匹配当前这个活动。 651 | 652 | 那么其实线段树维护区间最左可用点。我们要找到这个会议的覆盖时间里,最左边自己没参加任何活动的点。 653 | 654 | 之后选了这个点后,我们就把这个点在线段树中的可用性给抹掉。然后PUSHUP更新父亲节点的最左可用点。 655 | 656 | 我们可以把所有的用掉的点的VAL都设置为INF。 657 | 658 | 然后返回区间最小值,就是最小可用点。 659 | 660 | 查询区间时如果查到这个区间最小可用点是INF,就代表这个活动没有空余时间参加了。 661 | ``` 662 | class Solution { 663 | int inf = 100001; 664 | public int maxEvents(int[][] es) { 665 | Arrays.sort(es, (a,b)->{return a[1] - b[1];}); 666 | int cnt = 0; 667 | Node root = new Node(1, es[es.length - 1][1]); 668 | for (int[] e : es) { 669 | int pos = query(root, e[0], e[1]); 670 | if (pos < inf) { 671 | cnt++; 672 | update(root, pos, pos, inf); 673 | } 674 | } 675 | return cnt; 676 | } 677 | class Node { 678 | long rgLeft, rgRight; 679 | Node left, right; 680 | int val; 681 | public Node(int start, int end) { 682 | rgLeft = start; 683 | rgRight = end; 684 | val = start; 685 | } 686 | public int getRangeMid() { 687 | return (int) (rgLeft + (rgRight - rgLeft) / 2); 688 | } 689 | public Node left() { 690 | if (left == null) left = new Node((int)rgLeft, getRangeMid()); 691 | return left; 692 | } 693 | public Node right() { 694 | if (right == null) right = new Node(getRangeMid() + 1, (int)rgRight); 695 | return right; 696 | } 697 | } 698 | int query(Node cur, long l, long r) { 699 | if (l > r) return 0; 700 | if (cur.rgLeft >= l && cur.rgRight <= r) return cur.val; 701 | else { 702 | pushdown(cur); 703 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 704 | if (r <= mid) return query(cur.left(), l, r); 705 | else if (l > mid) return query(cur.right(), l, r); 706 | return merge(query(cur.left(), l, r), query(cur.right(), l, r)); 707 | } 708 | } 709 | void update(Node cur, int l, int r, int val) { 710 | if (cur.rgLeft >= l && cur.rgRight <= r) { 711 | cur.val = val; 712 | } else { 713 | pushdown(cur); 714 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 715 | if (l <= mid) update(cur.left(), l, r, val); 716 | if (r >= mid + 1) update(cur.right(), l, r, val); 717 | pushup(cur); 718 | } 719 | } 720 | void pushup(Node cur) { 721 | cur.val = merge(cur.left().val, cur.right().val); 722 | } 723 | private int merge(int left, int right) { 724 | return Math.min(left, right); 725 | } 726 | void pushdown(Node cur) { 727 | 728 | } 729 | } 730 | ``` 731 | 732 | 733 | #### 673. Number of Longest Increasing Subsequence 734 | 这道题一般大家也不会想到线段树。最长上升子序列一般都会朝DP方向去想。 735 | 736 | 但是每来一个数,我们其实就是要找比这个数小的区间里最长的上升长度是多少,并且个数是多少。 737 | 738 | 叶子节点表示的就是最左端到目前这个数结尾的,最长上升子序列的长度是多少,以及有多少个。 739 | 740 | 如果7叶子节点是【2,2】, 8 叶子节点是【2,1】 741 | 742 | 那么因为2个最长长度一致,个数就可以相加了。在包含7结尾和8结尾的最长上升子序列长度是2, 个数是3 743 | 744 | 如果2边最大长度不一致,那么就用最大长度更大的那个来表示这个包含更多区间结尾的最长上升子序列的信息。 745 | 746 | 主函数如下 747 | ``` 748 | int inf = Integer.MAX_VALUE; 749 | public int findNumberOfLIS(int[] nums) { 750 | if (nums.length == 0) return 0; 751 | Node root = new Node(-inf-1, inf); 752 | for (int i : nums) { 753 | // 找比自己小的区间的信息 754 | int[] val = query(root, -inf-1, i-1); 755 | // 把最长上升的长度++ 756 | val[0]++; 757 | update(root, i, i, val); 758 | } 759 | return root.val[1]; // 返回最长上升长度的个数 760 | } 761 | ``` 762 | 然后复制动态开点线段树模板,做如下修改。 763 | 764 | 因为VAL是2个数,所以返回VAL的地方都改成数组 765 | ``` 766 | class Node { 767 | long rgLeft, rgRight; 768 | private Node left, right; // 不要直接用左孩子, 右孩子, 用对应方法去拿 769 | int[] val; 770 | public Node(int start, int end) { 771 | rgLeft = start; 772 | rgRight = end; 773 | val = new int[]{0, 1}; 774 | } 775 | public int getRangeMid() { 776 | return (int) (rgLeft + (rgRight - rgLeft) / 2); 777 | } 778 | // 返回左孩子,如果不存在就动态创建 779 | public Node left() { 780 | if (left == null) left = new Node((int)rgLeft, getRangeMid()); 781 | return left; 782 | } 783 | // 返回右孩子,如果不存在就动态创建 784 | public Node right() { 785 | if (right == null) right = new Node(getRangeMid() + 1, (int)rgRight); 786 | return right; 787 | } 788 | } 789 | // 线段树初始化函数, cur 是当前节点, L代表查询的左区间,R代表查询的右区间 790 | int[] query(Node cur, long l, long r) { 791 | if (l > r) return new int[]{0,1}; 792 | if (cur.rgLeft >= l && cur.rgRight <= r) return cur.val.clone(); 793 | else { 794 | pushdown(cur); 795 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 796 | if (r <= mid) return query(cur.left(), l, r); 797 | else if (l > mid) return query(cur.right(), l, r); 798 | return merge(query(cur.left(), l, r), query(cur.right(), l, r)); 799 | } 800 | } 801 | // 根据父亲的懒标记,去更新孩子的VAL及懒标记 802 | void update(Node cur, int l, int r, int[] val) { 803 | if (cur.rgLeft >= l && cur.rgRight <= r) { 804 | cur.val = merge(cur.val, val); // 当前这个点结尾的最长长度 去和新的情况做MERGE 805 | } else { 806 | pushdown(cur); 807 | long mid = (cur.rgLeft + cur.rgRight) >> 1; 808 | if (l <= mid) update(cur.left(), l, r, val); 809 | if (r >= mid + 1) update(cur.right(), l, r, val); 810 | pushup(cur); 811 | } 812 | } 813 | void pushup(Node cur) { 814 | cur.val = merge(cur.left().val, cur.right().val); 815 | } 816 | private int[] merge(int[] left, int[] right) { 817 | if (left[0] > 0 && left[0] == right[0]) { // 如果最长长度>0, 且一样的话 818 | return new int[]{left[0], left[1] + right[1]}; // 个数相加 819 | } else if (left[0] > right[0]) return left.clone(); // 用更长的那个 820 | return right.clone(); 821 | } 822 | void pushdown(Node cur) { 823 | 824 | } 825 | ``` 826 | 827 | ## 总结 828 | 我们来回顾一下,这篇文章可以学到的。首先你掌握了线段树的3个接口函数BUILD, QUERY, UPDATE。2个核心函数PUSHUP和PUSHDOWN。 829 | 830 | 其次掌握了对大数据范围需要做离散化或者动态开点,以及他们各自的优劣。 831 | 832 | 然后掌握了几种线段树的常见用法,如权值线段树(VAL是动态维护每个数值的个数),维护区间最值(最值可以是普通被子,刚性被子,厚度为0的被子) 833 | 834 | 随后是一些其他情况下的线段树的例子,表明什么是区间可加如何运用到线段树的PUSHUP和PUSHDOWN中。 835 | 836 | 最后照例给大家留2道思考题。 837 | 838 | ### 思考题1. 839 | 维护一个数据结构可以动态更新一个数组里的任意下标的数。并且还需要支持LOG N时间返回任意搜索区间里的,MAX SUM SUBARRAY。应该怎么做呢 840 | 841 | 比如一个数组是【1,2,3,-5,1,6】 842 | 843 | 我可以搜索下标2到下标4的区间里最大子数组的SUM。那么显然最大子数组是【3】自己,那么就返回3. 844 | 845 | 如果是搜索下标0到下标4的区间里最大子数组的SUM,那么最大子数组就是【1,2,3】,返回6 846 | 847 | 如果是搜索下标0到下标5的区间里最大子数组的SUM,那么最大子数组就是【1,2,3,-5,1,6】,返回8 848 | 849 | ### 思考题2 850 | 维护一个数据结构,里面有一个输入的数组 851 | 852 | 支持如下三种操作形式: 853 | - 把数组中的一段数全部乘一个值; 854 | - 把数组中的一段数全部加一个值; 855 | - 询问数列中的一段数的和 856 | 857 | 单次操作时间复杂度<=LOG N 858 | 859 | 860 | -------------------------------------------------------------------------------- /4. 算法思维-括号问题.md: -------------------------------------------------------------------------------- 1 | LC上有非常多很括号相关的问题。比如说有一类是纯括号判断判断一个STRING里的括号是否合法,或者要加最少多少个括号可以使得它合法,或者移除最少多少个括号使得它合法等。 2 | 3 | 另外一类是基于括号会有一些运算,比如说`2(XXX) = XXXXXX` 这种decode模式的题目。这个专题我会介绍2个这类问题的本质,帮助你在下次阅读到这类问题,知道如何从本质去思考,然后快速找到问题的突破点。 4 | 5 | >本质1:括号合法匹配的本质,就是在一个字符串任意前缀里,他的左括号数量必须都大于等于右括号数量。然后最终的字符串左括号数量和右括号数量相等。 6 | 7 | >本质2 : 括号求表达式的本质,每一个括号内部是一个子问题,我们可以直接把子问题交给递归假设它已解决,然后只要思考如何汇总子问题的解变成全局的解的方法。 8 | 9 | ## 1.括号合法匹配的本质 10 | 11 | 有了本质1的特性,我们再逆向思考,什么时候括号不合法,就可以解决一大类问题了。不合法无非2种。1. 右括号多余左括号了。 2. 到最后左括号还多出了一些,没有被右括号关掉 12 | 13 | 那么只要维护这2个不合法信息,我就可以知道括号是否合法。 14 | 15 | 我们只要在遇到做左括号时对`L++`,右括号时对`L--`。一旦发现L<0了,就代表右括号多出来了。到最后`L!=0`就代表左括号没被关掉。 16 | 17 | 上面是个基础技能。 18 | 19 | 我们再在基础技能上去衍生一下,看一个不合法的括号情况,如果加上最少的括号使得它合法。这个其实很好想,就是对每一个不合法的括号(分为两类,中间多出来的右括号,和最后没关掉的左括号) 20 | 21 | #### LC. 921 题 就引刃而解 22 | 23 | ``` 24 | public int minAddToMakeValid(String S) { 25 | char[] cs = S.toCharArray(); 26 | int l = 0, r = 0; 27 | for (char c : cs) { 28 | if (c == '(') { 29 | l++; 30 | } else if (l > 0) { 31 | l--; 32 | } else r++; 33 | } 34 | return l + r; 35 | } 36 | 37 | ``` 38 | 39 | 下面我们来看一道对偶题。最少移除括号的问题 40 | 41 | 是LC 1249. Minimum Remove to Make Valid Parentheses 42 | 43 | 如果只是要求数量的话,上面的代码原封不动就可以用。 44 | 45 | 这里面需要求一个具体解。这个时候,就要求我们要移除正确的括号了。 **如果上面那题增加括号也要一个具体解,小伙伴们想想如何改** 46 | 47 | 其实这边也很好想,凡是要R++的地方,我就跳过这个字母。就解决了中间多出来的右括号问题。然后从后往前再扫一次,凡是遇到左括号,我就直接去掉。就一定可以把没关掉的左括号给关掉了。 48 | 49 | 这里有些读者会问为什么不可以从左往右的。为什么一定要从右往左呢? 50 | 51 | 这是因为,你要是从左往右可能是可以,但也可能是移除了一个合法右括号的左半边,引入了新的不合法右括号比如 52 | 53 | `()(`, 你要是从左移除可能会变成`)(` 54 | 55 | 那为什么从右往左没这个问题呢? 56 | 57 | 这里有2个思考角度 58 | 59 | - 第一个角度就是,因为所有右括号前必然已经有一个左括号了。还多出来一些左括号,一定是要么在所有右括号后面。那么从右往左就可以WORK,如果是在合法右括号左边。比如`(()`,那么即使你移走了一个左括号,前面多出来的左括号也可以立刻补位。 60 | 61 | 62 | 63 | - 如果有另一个平行宇宙,括号是这样使用的`)(`, 那么我们就可以在那个平行宇宙 用规则1 去先把多出来的右括号`(` 给删去。这时就是想只要怎么把这个宇宙的括号规则给映射去那个宇宙,其实就是reverse一下就可以 64 | 65 | 举个例子`( () () (` reverse一下 变成`( )( )( (`, 记住这个时候`(`是那个宇宙的右括号了。我们套用规则1,可以发现第一个和最后一个 是多出来的右括号。去掉之后合法括号为`)( )(` 66 | 67 | 那么本质这边就是在原宇宙从右向左扫描。 68 | 69 | 上面那道题代码就如下所示 70 | 71 | ``` 72 | public String minRemoveToMakeValid(String s) { 73 | char[] cs = s.toCharArray(); 74 | int l = 0, idx = 0; 75 | for (int i = 0; i < cs.length; i++) { 76 | if (cs[i] == '(') { 77 | l++; 78 | } else if (cs[i] == ')') { 79 | if (l == 0) continue; 80 | l--; 81 | } 82 | cs[idx++] = cs[i]; 83 | } 84 | int j = cs.length - 1, len = idx - l; 85 | for (int i = idx - 1; i >= 0; i--) { 86 | if (l > 0 && cs[i] == '(') l--; 87 | else cs[j--] = cs[i]; 88 | } 89 | return new String(cs, j + 1, len); 90 | } 91 | 92 | ``` 93 | 94 | #### 如果我要最少移除括号的所有可能解,应该如何求呢 95 | 96 | 我们之前找了数量,然后找了任一解,下面问题如果是所有解,应该怎么求呢? 97 | 98 | 这就是一道LC HARD问题了。 99 | 100 | 是LC 301. Remove Invalid Parentheses 101 | 102 | https://leetcode.com/problems/remove-invalid-parentheses/ 103 | 104 | 这道题其实本质还是找所有可以删除的括号 105 | 106 | 我们一开始先把不合法的左括号数量和 右括号的数量用之前的套路给统计出来。 107 | 108 | 如果有不合法右括号,就一定要先从左到向删右括号。因为在右括号被删干净前,左括号一定是不够的。不然就不会出现不合法的右括号,所以在前面删左括号是没必要的。等右括号删干净了。然后之前跳到最后尝试删左括号。 109 | 110 | 因为答案要去重,所以有连续K个相同格式的括号的情况下只看第一个。这是基本的去重思路了。当然偷懒可以用SET来去重。 111 | 112 | ``` 113 | List res = new ArrayList<>(); 114 | public List removeInvalidParentheses(String s) { 115 | char[] cs = s.toCharArray(); 116 | // 统计左右括号不合法数量, l代表左括号不合法数量,r代表右括号不合法数量 117 | int l = 0, r = 0; 118 | for (int i = 0; i < cs.length; i++) { 119 | char c = cs[i]; 120 | if (c == '(') { 121 | l++; 122 | } else if (c == ')') { 123 | if (l > 0) l--; 124 | else r++; 125 | } 126 | } 127 | dfs(cs, 0, cs.length - 1, l, r, (char)0); 128 | return new ArrayList<>(res); 129 | } 130 | void dfs(char[] cs, int st, int ed, int l, int r, char pre) { 131 | if (l == 0 && r == 0) { 132 | String valid = valid(cs); 133 | if (valid != null) 134 | res.add(valid); 135 | return; 136 | } 137 | if (st > ed) return; 138 | if (r > 0) { 139 | char cur = cs[st]; 140 | if (cur == ')' && pre != ')') { 141 | cs[st] = 0; 142 | dfs(cs, st + 1, ed, l, r - 1, cs[st]); 143 | } 144 | cs[st] = cur; 145 | dfs(cs, st + 1, ed, l, r, cur); 146 | } else { // l > 0 147 | char cur = cs[ed]; 148 | if (cur == '(' && pre != '(') { 149 | cs[ed] = 0; 150 | dfs(cs, st, ed - 1, l - 1, r, cs[ed]); 151 | } 152 | cs[ed] = cur; 153 | dfs(cs, st, ed - 1, l, r, cur); 154 | } 155 | } 156 | // 合法的返回结果字符串,不然返回null 157 | String valid(char[] cs) { 158 | StringBuilder sb = new StringBuilder(); 159 | int l = 0; 160 | for (char c : cs) { 161 | if (c == 0) continue; 162 | sb.append(c); 163 | if (c == '(') l++; 164 | else if (c == ')') { 165 | if (--l < 0) return null; 166 | } 167 | } 168 | return l > 0 ? null : sb.toString(); 169 | } 170 | 171 | ``` 172 | 173 | 当然我们用角度2去写这个题也是可以的 174 | 175 | 思路为先考虑右括号不合法,再反向考虑左括号不合法。当遇到一个右括号不合法,我们需要枚举之前所有的右括号依次去掉都是解。 176 | 177 | 然后考虑左括号,可以用平行世界法。 178 | 179 | ``` 180 | List res = new ArrayList<>(); 181 | public List removeInvalidParentheses(String s) { 182 | dfs(s, 0, '(', ')'); 183 | return res; 184 | } 185 | void dfs(String ans, int lastJ, char L, char R) { 186 | int l = 0; 187 | for (int i = 0; i < ans.length(); i++) { 188 | char c = ans.charAt(i); 189 | if (c == L) l++; 190 | else if (c == R) l--; 191 | if (l >= 0) continue; 192 | // 枚举之前所有的右括号依次去掉 193 | for (int j = lastJ; j <= i; j++) { 194 | if (ans.charAt(j) == R && (j == lastJ || ans.charAt(j - 1) != R)) 195 | dfs(ans.substring(0, j) + ans.substring(j + 1), j, L, R); 196 | } 197 | return; 198 | } 199 | String rev = new StringBuilder(ans).reverse().toString(); 200 | if (L == '(') dfs(rev,0, R, L); // 去平行宇宙 201 | else res.add(rev); 202 | } 203 | 204 | ``` 205 | 206 | 最后我们来看另一道HARD题, 207 | 208 | 是LC. 32. Longest Valid Parentheses 209 | 210 | 这道题,就是要求一个最长的合法括号匹配子串。根据我们的定义知道,只要遇到了不合法的右括号,那么就要和之前的字符串做一个了断了。因为只要包含了这个前缀就不会再合法。那么根据前面的MAX来跟前全局的MAX。之后从头开始。 211 | 212 | 下一个问题是,如果满足了没有任何前缀有多出来的右括号,我怎么知道这个前缀中最长的合法子串是多少长呢。 213 | 214 | 所谓合法就是意味着还要满足第二个条件,左右括号数量相等。那么我们就需要特判一下这个条件。 215 | 216 | 另一个棘手问题是,当左括号>右括号时,我们不知道后面还有没有右括号可以闭起来。这样就会造成一旦之前有个多出来的左括号,第二个条件始终没法满足。但是去掉这个多出来的左括号,是可以造成后面满足条件的。解决这个问题,就是从右向左再找一遍,就可以规避掉最前面的左括号捣乱了。这时就是个平行宇宙的概念。 217 | 218 | 代码如下 219 | 220 | ``` 221 | public int longestValidParentheses(String s) { 222 | String rev = new StringBuilder(s).reverse().toString(); 223 | return Math.max(help(s, '(', ')'), help(rev, ')', '(')); 224 | } 225 | int help(String s, char L, char R) { 226 | int leftCnt = 0, rightCnt = 0, res = 0; 227 | for (char c : s.toCharArray()) { 228 | if (c == L) leftCnt++; 229 | else rightCnt++; 230 | if (leftCnt == rightCnt) res = Math.max(res, 2 * leftCnt); 231 | else if (rightCnt > leftCnt) { 232 | rightCnt = leftCnt = 0; 233 | } 234 | } 235 | return res; 236 | } 237 | 238 | ``` 239 | 240 | LC上还有一道题是带通配符的括号是否匹配 241 | 242 | LC 678. Valid Parenthesis String。 243 | 244 | 其实就是说`*`既可以被当做左括号或者右括号或者空白字符。看这个字符串是否匹配。 245 | 246 | 其实本质就是在维护括号匹配的2大特性。如果当中出现了右括号不合法,我们就看之前的通配符用作左括号。如果最后是左括号多出来了,我们就维护一个变量,尽可能压缩之前的左括号。 247 | 248 | 所以我们就有了一个容错区间,LMIN是防止左括号用不完的情况所以代表,只要左括号多出来了,我来一个通配符我都当它是右括号。 LMAX代表,希望左括号越多越好,这样万一后面来了很多右括号,我也有很多储备尽可能不被击穿。 249 | 250 | ``` 251 | public boolean checkValidString(String s) { 252 | int lmax = 0, lmin = 0; 253 | for (char c : s.toCharArray()) { 254 | if (c == '(') { 255 | lmax++; lmin++; 256 | } else if (c == ')') { 257 | lmax--; 258 | lmin = Math.max(0, lmin - 1); 259 | if (lmax < 0) return false; // 储备也被右括号击穿了,就彻底不合法 260 | } else { // 通配符,提升储备同时防止左括号过多 261 | lmax++; 262 | lmin = Math.max(0, lmin - 1); 263 | } 264 | } 265 | return lmin == 0; // 尽可能的消耗左括号还是消耗不完,不合法 266 | } 267 | 268 | ``` 269 | 270 | ## 2.括号求表达式的本质 271 | 272 | 在讲这个问题之前,我们先来思考一个非常简单的问题,就是求二叉树的深度。一般二叉树的问题,很多都可以用递归来解决。我们在思考的时候,就是让左子树返回一些信息,同时让右子树范围一些信息。父节点就根据左右子树返回的信息做一些处理,再返回。这就是二叉树里递归的思想。 273 | 274 | 其实在解决一些表达式计算的时候,是和二叉树递归有很多类似的地方。基本都可以转换过去。而思维的突破点就是如果定义叶子节点,内部节点如何根据叶子结算得到值返回给上层。这2个思考点。 275 | 276 | 我们来看一道题 277 | 278 | LC. 856. Score of Parentheses 279 | 280 | 这道题,其实`()` 就是叶子节点,他的分数为1分。 281 | 282 | 内部节点其实就是外层的括号,他的函数就是对他的孩子节点返回的值求和 然后再乘以2. 283 | 284 | 所以一个这样的表达式可以画成如下的树`((()())())` 285 | 286 | 这里整个表达式 是个根节点。 287 | 288 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-28e7061862d81746.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 289 | 290 | 大方向是这样,具体的写法可以有很多种。比如这种乘以2的算子是可以下推的,所以我们就可以不用构建这个树。而是在叶子节点的时候,直接把父亲节点们下推的算子全部作用上,然后加到RESULT里即可。这样可以把时间复杂度从O(N^2) 优化到 O(N) 291 | 292 | 因为前者,我们需要找到每一个左括号匹配的对应右括号,然后递归去算。那么递归里每一层都是O(N),最坏情况下是O(N ^2) 293 | 294 | 我们看下算子下推的写法 295 | 296 | ``` 297 | public int scoreOfParentheses(String S) { 298 | int depth = 0, res = 0; 299 | char[] cs = S.toCharArray(); 300 | for (int i = 0; i < cs.length; i++) { 301 | if (cs[i] == '(') depth++; 302 | else { 303 | depth--; 304 | if (cs[i - 1] == '(') 305 | res += 1 << depth; // 叶子节点,把父亲们积累的DEPTH一次作用上 306 | } 307 | } 308 | return res; 309 | } 310 | 311 | ``` 312 | 313 | 有了这个思路,我们再来看另2道题。 314 | 315 | 一道是394. Decode String 316 | 317 | 另一道是1190. Reverse Substrings Between Each Pair of Parentheses 318 | 319 | 在这类题目里,叶子节点就是括号里面不带括号的STRING。而外层的括号,就是内部节点。在第一题中,我们可以构建的树长成下图。 320 | 321 | 我们以`2[b2[c]]3[a]`为例子 322 | 323 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-73cf23eb9ee0ed39.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 324 | 325 | 在第二个问题里 326 | 327 | 我们以`(u(love)i)`为例子 328 | 329 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-727269fc7b7efa98.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 330 | 331 | 在这类问题里,我们可以使用一个stack,然后用后序遍历的方式来计算。因为每个节点最后返回的是一个STRING,所以我们可以在STACK里存的是STRINGBUILDER,那么返回的时候TOSTRING即可。 332 | 333 | 当我们遇到一个左括号,我们推入栈一个STRINGBUILDER,然后之后的操作就是在栈顶的STRINGBUILDER里做,直到遇到右括号,然后把STRINGBUILDER 弹出之后,就是在父节点那层获得了孩子节点做完的返回的信息,然后加入到父节点的STRINGBUILDER(在栈顶了)中计算。 334 | 335 | 所以2道题差不多,代码如下 336 | 337 | 第一题对每个节点,除了要维护STRING,还要维护乘法系数,所以需要2个栈 338 | 339 | ``` 340 | public String decodeString(String s) { 341 | char[] cs = s.toCharArray(); 342 | Deque nums = new ArrayDeque<>(); 343 | Deque sbs = new ArrayDeque<>(); 344 | sbs.push(new StringBuilder()); 345 | int num = 0; 346 | StringBuilder sb = new StringBuilder(); 347 | for (char c : cs) { 348 | if (c == '[') { 349 | nums.push(num); 350 | sbs.push(new StringBuilder()); 351 | num = 0; 352 | } else if (c == ']') { 353 | int cnt = nums.pop(); 354 | String cur = sbs.pop().toString(); 355 | for (int i = 0; i < cnt; i++) { 356 | sbs.peek().append(cur); 357 | } 358 | } else if (Character.isDigit(c)) { 359 | num = num * 10 + (c - '0'); 360 | } else { 361 | sbs.peek().append(c); 362 | } 363 | } 364 | return sbs.peek().toString(); 365 | } 366 | 367 | ``` 368 | 369 | 第二题就是出栈的时候需要做一个REVERSE 370 | 371 | ``` 372 | public String reverseParentheses(String s) { 373 | Deque stk = new ArrayDeque<>(); 374 | stk.push(new StringBuilder()); 375 | for (char c : s.toCharArray()) { 376 | if (c == '(') stk.push(new StringBuilder()); 377 | else if (c == ')') { 378 | String res = stk.pop().reverse().toString(); 379 | stk.peek().append(res); 380 | } else stk.peek().append(c); 381 | } 382 | return stk.peek().toString(); 383 | } 384 | 385 | ``` 386 | 387 | 第二题有一个O(N)的解法,由于解法和本章无关,所以没有介绍,有兴趣的小伙伴可以去DISCUSS里学习。 388 | 389 | 上面的题目,节点的定义和节点的计算都是比较简单直观的。一般LC HARD的题,就会稍微复杂一些。我找到了如下3题。 390 | 391 | 726. Number of Atoms 392 | 393 | 736. Parse Lisp Expression 394 | 395 | 1096. Brace Expansion II 396 | 397 | 对726题来说 398 | 399 | 每一个括号里我们需要统计元素的个数,所以需要返回一个MAP作为信息,返回到上层后,还需要乘以系数,加入父节点的MAP里。因为最后需要按照字母序排序,所以使用TREEMAP即可。 400 | 401 | ``` 402 | public String countOfAtoms(String formula) { 403 | Map m = help(formula.toCharArray()); 404 | StringBuilder sb = new StringBuilder(); 405 | for (Map.Entry e : m.entrySet()) { 406 | sb.append(e.getKey()); 407 | if (e.getValue() > 1) sb.append(e.getValue()); 408 | } 409 | return sb.toString(); 410 | } 411 | int i = 0; 412 | private Map help(char[] cs) { 413 | Map m = new TreeMap<>(); 414 | while (i < cs.length) { 415 | if (cs[i] == '(') { 416 | i++; 417 | // 拿孩子节点的信息 418 | Map chd = help(cs); 419 | // 拿系数 420 | int num = 0; 421 | while (i < cs.length && Character.isDigit(cs[i])) num = num * 10 + cs[i++] - '0'; 422 | for (Map.Entry e : chd.entrySet()) { 423 | String key = e.getKey(); 424 | int val = num * e.getValue(); 425 | m.compute(key, (k,v)->{ 426 | if (v == null) return val; 427 | return v + val; 428 | }); 429 | } 430 | } else if (cs[i] == ')') { 431 | i++; 432 | break; // 本节点MAP处理完毕 433 | } else { 434 | // 处理子叶子节点 435 | StringBuilder sb = new StringBuilder(""+cs[i++]); 436 | while (i < cs.length && cs[i] >= 'a' && cs[i] <= 'z') 437 | sb.append(cs[i++]); 438 | int num = 0; 439 | while (i < cs.length && Character.isDigit(cs[i])) num = num * 10 + cs[i++] - '0'; 440 | int val = Math.max(1, num); 441 | m.compute(sb.toString(), (k,v)->{ 442 | if (v == null) return val; 443 | return v + val; 444 | }); 445 | } 446 | } 447 | return m; 448 | } 449 | 450 | ``` 451 | 452 | 我们再来看736题 453 | 454 | 736题对于一个树节点来说,就是用括号括起来的运算,里面可能包括子运算。最后返回的是一个整数。节点有3种类型分别是ADD, MULT, LET。 455 | 456 | 而叶子节点就是一个纯数字。为什么这么定义呢?因为我们发现5 和 (add 2 3)是可以等价的。所以纯数字是可以定义为叶子节点。 457 | 458 | 然后进一步分析,ADD, MULT是内部节点的时候,会有2个孩子。而LET可能有多个孩子。 459 | 460 | 如果内部节点是LET,其之后的结构必然是一个变量加一个子节点。(子节点可能是叶子节点,可能是需要递归计算的内部节点),最后一个子节点为LET的返回值。同时上层LET的变量表需要透传到其孩子节点中。 461 | 462 | 所以大概的树结构如下 463 | 464 | 以`(let x 2 (mult x (let x 3 y 4 (add x y))))`为例 465 | 466 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-be8c7e80175bffba.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 467 | 468 | ``` 469 | public int evaluate(String exp) { 470 | // hashmap 需要下传 471 | return eval(exp, new HashMap<>()); 472 | } 473 | private int eval(String exp, Map vals) { 474 | // 如果没有以括号开头,那么是叶子节点 475 | if (exp.charAt(0) != '(') { 476 | if (exp.charAt(0) == '-' || Character.isDigit(exp.charAt(0))) { // 纯数字 477 | return Integer.parseInt(exp); 478 | } else { // 变量值 479 | return vals.get(exp); 480 | } 481 | } 482 | // 以括号开头,处理内部节点,后面操作后的参数按照空格切割。 483 | List exps = parse(exp); 484 | // 继承父亲的MAP,构建自己这层的MAP 485 | Map curVals = new HashMap<>(vals); 486 | if (exp.startsWith("(a")) { // 如果是加法,递归2个孩子 487 | return eval(exps.get(0), curVals) + eval(exps.get(1), curVals); 488 | } else if (exp.startsWith("(m")) { // 如果是乘法,递归2个孩子 489 | return eval(exps.get(0), curVals) * eval(exps.get(1), curVals); 490 | } else { // 如果是LET,递归(SIZE / 2) 个孩子 491 | for (int i = 1; i < exps.size() - 1; i += 2) { 492 | curVals.put(exps.get(i - 1), eval(exps.get(i), curVals)); 493 | } 494 | // 最后一个作为返回值 495 | return eval(exps.get(exps.size() - 1), curVals); 496 | } 497 | } 498 | List parse(String curExp) { 499 | int st = curExp.startsWith("(m") ? 6 : 5; 500 | curExp = curExp.substring(st); 501 | char[] cs = curExp.toCharArray(); 502 | int left = 1, i = 0, pre = 0; 503 | List res = new ArrayList<>(); 504 | for (;left > 0; i++) { // 只关心本层的括号 和直接变量; 遇到上层括号,循环退出 505 | if (cs[i] == '(') left++; 506 | else if (cs[i] == ')') { 507 | if (left-- == 1) res.add(curExp.substring(pre, i)); 508 | } else if (left == 1 && cs[i] == ' ') { 509 | res.add(curExp.substring(pre, i)); 510 | pre = i + 1; 511 | } 512 | } 513 | return res; 514 | } 515 | 516 | ``` 517 | 518 | ### 最后一道题1096 519 | 520 | 我们可以把`{}`里的值当做内部节点,纯字母当做叶子节点。 521 | 522 | 然后内部节点返回的是LIST, 那么纯字母返回的其实就是只含一个STRING的LIST。 523 | 524 | 在外层父节点,根据孩子节点的信息做计算。 525 | 526 | 父节点可以做2种运算,distinct union或者对2个集合做笛卡尔乘积。这取决于节点之间是否有逗号。 527 | 528 | 那么构建树,如下图 529 | 530 | 以`"{{a,z},a{b,c},{ab,z}}"`为例 531 | 532 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-d35e23b7a9433dab.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 533 | 534 | 所以这递归函数中,遇到左括号,调用递归,拿到孩子的信息,把这个信息放入,自己的处理队列中。遇到逗号,就之前积攒的东西,放进SET里去做DISTINCT UNION。遇到右括号,代表自己这个节点使命结束,退出来之后,对所有队列的东西做笛卡尔积然后DISTINCT UNION返回给上层。 535 | 536 | ``` 537 | int i = 0; 538 | public List braceExpansionII(String expression) { 539 | String e = "{" + expression + "}"; 540 | return help(e.toCharArray()); 541 | } 542 | List help(char[] cs) { 543 | assert cs[i++] == '{'; 544 | List> pre = new ArrayList<>(); 545 | TreeSet set = new TreeSet<>(); 546 | while (i < cs.length) { 547 | if (cs[i] == ',') { 548 | // 求之前进预备队列的笛卡尔积,然后放进DISTINCT UNION 549 | set.addAll(combine(pre)); 550 | pre = new ArrayList<>(); 551 | i++; 552 | } else if (cs[i] == '{') { 553 | // 把孩子的返回,放进笛卡尔积预备队列里 554 | pre.add(help(cs)); 555 | } else if (cs[i] == '}') break; 556 | else { 557 | // 叶子节点 558 | StringBuilder sb = new StringBuilder(); 559 | while (Character.isLowerCase(cs[i])) { 560 | sb.append(cs[i++]); 561 | } 562 | pre.add(Arrays.asList(sb.toString())); 563 | } 564 | } 565 | assert cs[i++] == '}'; 566 | // 最后把余下的预备队列里的数计算完,放进DISTINCT UNION中 567 | set.addAll(combine(pre)); 568 | return new ArrayList<>(set); 569 | } 570 | private List combine(List> in) { 571 | List res = Arrays.asList(""); 572 | for (List i: in) { 573 | List tmp = res; 574 | res = new ArrayList<>(); 575 | for (String pre : tmp) { 576 | for (String post : i) { 577 | res.add(pre + post); 578 | } 579 | } 580 | } 581 | return res; 582 | } 583 | 584 | ``` 585 | 586 | ## 总结 587 | 588 | 今天我们主要讲解了2种括号的套路和思维方式。 589 | 590 | - 第一种就是依靠括号匹配2个特性来找到突破点去解题。特性1是任意前缀左括号数量大于右括号,特性2最终左括号右括号数量相等。 591 | 592 | - 第二种就是定义出叶子节点和内部节点的计算单元,找出每个单元的返回的数据结构,然后思考如何利用孩子的解取组合出父亲的解。 593 | 594 | 最后给大家留一道思考题。之前我们只讨论了一种括号的合法匹配。如果括号有3种,`{}`,`[]`,`()`。 且前者可以包含后者,后者不能包含前者,如果判断括号是匹配的呢。如果不匹配最少删除多少个使之匹配呢? 595 | 596 | 597 | 598 | 599 | 600 | -------------------------------------------------------------------------------- /5. 字符串算法-从KMP到AC自动机.md: -------------------------------------------------------------------------------- 1 | 现在写文章,也是痛点在哪,就写哪?今天的痛点是老是记不住KMP算法。 2 | 3 | 我曾经3次拿下KMP算法。但令人遗憾的是,我又忘记了。 4 | 5 | 所以决定还是写写,这样下次可以快速捡起来。网上有很多很好的KMP的学习材料。 6 | 7 | 一般都是从头讲起的。我这里推荐出来,给完全没接触过的KMP的小伙伴。 8 | 9 | [KMP超详细讲解](https://blog.csdn.net/v_JULY_v/article/details/7041827) 10 | 11 | 上面这篇文章应该是我看到的最好的讲解了。 12 | 13 | 我下面的讲解,是从另一个角度去思考KMP算法的。 14 | 15 | KMP本身理解就比较复杂。如果我的讲解,你们看不懂,可以去看我上面分享的。 16 | 17 | ### 1. 直觉 18 | 19 | 在计算机的世界里数据都是以01形式表示。 20 | 21 | 比如有一串数据流是`01110111101`. 22 | 23 | 我们想在这串数据流中找到是否含有`01111`这个子串 24 | 25 | 那么在暴力做法时,我们匹配到第5个字符,发现失配了。 26 | 27 | 那么人的直觉就是因为要匹配的串开头是0. 一直匹配到第5个字符才不对,前面都对。那么必然为`01110` 所以我们可以直接把`开头的0`移动到`失配的那个0`上。 28 | 29 | 这样就可以提高匹配速度。 30 | 31 | ### 2. next array 32 | 33 | lc 里有很多题目,都可以通过初始化一个NEXT ARRAY来提高算法的时间复杂度。 34 | 35 | **TODO: 之后会补充例子进来** 36 | 37 | NEXT ARRAY一般会去存,从这个位置开始包括这个位置下一个J字符的INDEX是什么,如果之后没有J字符了,那么就返回-1. 38 | 39 | 因为一般题目会说只有小写字母。所以我们可以用26 * N的时间,构造好这个NEXT ARRAY。之后就可以实现O(1)的跳跃。 40 | 41 | ``` 42 | // next array 构造法 43 | char[] cs = str.toCharArray(); 44 | int l = cs.length; 45 | int[][] next = new int[l + 1][26]; 46 | Arrays.fill(next[l], -1); 47 | for (int i = l - 1; i >= 0; i--) { 48 | next[i] = next[i+1].clone(); 49 | next[i][cs[i]-'a'] = i; 50 | } 51 | 52 | ``` 53 | 54 | #### 为什么可以这么构造? 55 | 56 | 核心就是从后往前,当前这个字符只会改变这层状态机的这个字符的状态点。其余的字符都是继承来自后面的状态机的转移。 57 | 58 | 如果后面有该字符(非本层字符),后一层的状态机已经掌握了最近的这个字符的下标的INDEX。所以我这层就可以直接用。 59 | 60 | 如果是本层字符,显然我自己最近。我就用我自己就好了。 61 | 62 | #### 那么如何使用NEXT ARRAY呢? 63 | 64 | 比如我们要找一个串是不是目标串的子序列。我们已经把目标串的NEXT ARRAY构建出来后。可以用如下代码快速判断。 65 | 66 | ``` 67 | int i = 0; 68 | for (char c : cs) { 69 | i = next[i][c-'a']; 70 | if (i == -1) return false; 71 | } 72 | return true; 73 | 74 | ``` 75 | 76 | ### 3. 有限确定状态机 77 | 78 | 其实上面的NEXT ARRAY 就是一种有限确定状态机。他定义了你在哪个`INDEX i`下状态 为`J`.应该转移去哪个INDEX。 79 | 80 | 任何状态i, j 都对应1个确定性的去处。(只需查1次,就知道) 81 | 82 | 这个就是DFA, Deterministic finite state, 有限确定状态机的思想。 83 | 84 | 我们如果把这个思想引入到字符串匹配中,就是思考如何构造一个DFA,使得每一个状态都有对应的去处。 85 | 86 | 假设我们有了这个DFA数组,我们如何利用它快速匹配字符串呢 87 | 88 | ``` 89 | for (int i = 0, j = 0; j < dfa.length && i < cs.length; i++) { 90 | j = dfa[j][cs[i]-'a']; 91 | } 92 | if (j == dfa.length) return i - j; // dfa走到终态了 93 | return -1; 94 | 95 | ``` 96 | 97 | 那么我们就把找匹配字符串的时间复杂度从暴力的O (N ^ 2) 优化到了O (N) 98 | 99 | 下面就是如何去构造这个DFA。 100 | 101 | 其实思想也是和NEXT ARRAY 非常相似。也是分为2步。 102 | 103 | 大多数状态是继承上一层的,我这层只管正确的那个状态进行更新。 104 | 105 | 要更新的状态,其实就是模式串和查找串字符相等的时候,我们需要推进一格。 106 | 107 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-a1b1577d0946e959.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 108 | 109 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-2b2ac7a21a34f9e3.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 110 | 111 | 上图应该不难理解。 112 | 113 | 下面就是其他不匹配的状态是如何继承的呢? 114 | 115 | 我们可以知道当我们在第一个字符时,凡是不匹配的也只能回退到当前。因为已经退无可退。随后的不匹配是等价于用[1, k]的走状态机的模式的走到的串。 116 | 117 | 为什么这样是对的。举个例子我在匹配`abc` 到c 失败了,要回退的位置等价于用`bc`从头开始走状态机走到的位置。(因为暴力试错下,算法就是这么走的) 118 | 119 | 再比如如上图我用`ABABC`构建了DFA状态机。我查询串是`ABABA....` 会发现走到第5个字符失配了。那么我可以用`BABA`去重走状态机,走到的位置等价于我在第5个位置失配要走到的位置是一样的。 120 | 121 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-8343553c66fdfb0e.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 122 | 123 | 有了这个观察之后,我们就知道我们需要维护2层状态,1层是用0...k,另一层就是用1....k 来表示上图的黑线的状态机。 124 | 125 | 那么我们在更新上图中J的状态机时,第一步继承的时候本质就是继承上一层黑线的A转移和B转移。然后更新自己的C转移 126 | 127 | 就可以写出构造DFA的方法了。 128 | 129 | ``` 130 | char[] cs = str.toCharArray(); 131 | int l = cs.length; 132 | int[][] dfa = new int[l][26]; 133 | dfa[0][cs[0]-'a'] = 1; // 构建初始层 134 | for (int i = 1, pre = 0; i < l; pre = dfa[pre][cs[i] - 'a'], i++) { 135 | dfa[i] = dfa[pre].clone(); // 继承 136 | dfa[i][cs[i]-'a'] = i+1; // 更新自己 137 | } 138 | 139 | ``` 140 | 141 | 综上我们就介绍完了字符串匹配里的DFA的算法。 142 | 143 | **我希望你们可以理解基础的NEXT ARRAY,然后基于NEXT ARRAY的思想和字符串匹配的暴力解的性质,自己推导出DFA的算法。这样就不容易忘记了。即使忘记,也可以从NEXT ARRAY下手来回忆。** 144 | 145 | 小伙伴可能会觉得和传统的KMP算法讲解完全不一样啊。 146 | 147 | 下面我们来回归到其他博客经常会介绍的KMP算法。 148 | 149 | 他的本质其实是不确定有限状态机的解法。又称NFA,Nondeterministic finite state 150 | 151 | 这个算法是实现正则表达式计算引擎的算法。有兴趣的小伙伴可以深入了解,他是怎么被运用在正则表达式解析总的。 152 | 153 | 因为如果想用DFA来构造所有的状态转换,状态数量(指数)过于庞大。是不可行的,所以引入的NFA。 154 | 155 | ### 4. NFA解法 156 | 157 | 在NFA解法里,当一个状态不匹配时,就可能需要多次跳跃。因为不能罗列所有不匹配的情况。所以只能在状态机里存可能是正确的解的情况。 158 | 159 | 那么这个状态机,我们定义为,nfa[i] 当 查询串当前的索引J 和 匹配串的I 不匹配时。 匹配串**可能**和索引J匹配的位置在哪。 160 | 161 | 那么构建初始值必然是`nfa[0] = -1`,因为在初始位置不匹配,下一个无论如何回退都是无法匹配上的,就只能返回-1. 162 | 163 | 之后的NFA数组怎么构建,我们可以用2个视角去看。这样可以看的更清晰。 164 | 165 | #### 自底向上看 166 | 167 | 匹配串为`char[] pat;`, 目标构建`int[] nfa;` 168 | 169 | 我们有了0,我们就要去看`nfa[1]`怎么构建?按照定义我们就需要判断`pat[0] `是否和`pat[1] `相等。如果相等, 那么其实如果发生了不匹配, nfa[1] = nfa[0]是一致的。我们把这个性质称作`匹配可继承`,意思就是如果2个字符一样,我就可以直接使用前一个的NFA转移来作为我自己的NFA转移。 170 | 171 | 如果不相等。因为当前`pat[1] `和一个字符不匹配了,那个字符是有可能和`pat[0] `匹配的。所以nfa[1] = 0即可。 172 | 173 | 当为2的时候,我们意识到要想继续用匹配可继承。 所以之前的J往前回跳的字串,必须得让大号索引I 之前的字串都要有。这样才可以安全的去继承回跳状态。 174 | 175 | 那么我们就必须在处理2的问题前,要保证如果0和1的结尾字符不一样,就必须得把0调整为-1. 来确保,我们上面提到的性质。 176 | 177 | 那么总结出来,就是有2个指针,一个i指针依次取计算`NFA[I]`。 另一个j指针,目标是使得`pat.substring(0, j) == pat.substring(i - j, i)`注意JAVA里SUBSTRING函数是前闭后开的。那么如果`pat[j] == pat[i]` i++, j++这个目标依然可以满足。 178 | 179 | 但是`pat[j] != pat[i]` 我们就必须调整J,让他能够继续满足`pat.substring(0, j) == pat.substring(i - j, i)` 180 | 181 | 在自底向上看的方法里,我们依赖2个串的前缀长度的字符完全一致。好直接继承使用之前算过的NFA来表达自己未知的NFA。其实图简单,只要我们每次把J设置成-1,这个条件就必然成立了。但是也就退化成了暴力解法。所以我们的目标是要找到尽可能大的J,满足`pat.substring(0, j) == pat.substring(i - j, i)` 182 | 183 | 所以在我们前一个定义上我们要加强一下 184 | 185 | 定义1: **nfa[i] 当 查询串当前的索引I 和 匹配串的J 不匹配时。 匹配串可能和索引I匹配的位置在哪。** 186 | 187 | 定义2: **在众多的可能位置中,我们希望J 尽可能大** 188 | 189 | 下面我们自顶向下看, 如果调整J 使得可以找到最大的J 190 | 191 | #### 自顶向下看 192 | 193 | 我们构造一个全局的视角,假设有一个字符串。我们构造好了NFA。 194 | 195 | 当它失配的时候,我们必须要在NFA里找到下一个可能的位置去和当前失配的字符去比。如果成功,则可以继续往前走。 196 | 197 | 所以这就必须要求,这个可能的位置的前面如果有字符,那么它的所有字符,必然是当前这个查询串的后缀。我来画个图。 198 | 199 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-2b20b549f0a72ed3.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 200 | 201 | 就是要求蓝色部分的相等是必须保证的。然后我来适配这个新过来的字符和当前失配的字符是否一致。 202 | 203 | 所以就有了下面这个图。 204 | 205 | ![image.png](https://upload-images.jianshu.io/upload_images/10803273-ba21c38c46432cf0.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240) 206 | 207 | 我们假设上面这个字符串0...j-1的NFA都构造好了。 208 | 209 | 因为在使用的时候如果J 失配了。nfa[j] 要回去的位置必然也是以蓝色区域为前缀的。不然就满足不了我们上面提到的性质。 210 | 211 | 所以我们在构造NFA[J]的时候,如果不相等,我们要安心的把nfa[j] = k的前提是 212 | 213 | pat[nfa[k]] == pat[j] . 214 | 215 | 这样我们就保证了`pat.substring(0, j) == pat.substring(i - j, i)`这个不变量的同时,满足了前缀蓝色和后缀蓝色相同的性质。 216 | 217 | 综上我们可以得出如下代码。 218 | 219 | ``` 220 | char[] pat = pattern.toCharArray(); 221 | int[] nfa = new int[pat.length]; 222 | nfa[0] = -1; 223 | for (int i = 1, j = 0; i < nfa.length; i++, j++) { 224 | // 不变量 assert(pattern.substring(0, j).equals(pattern(i-j, i)); 225 | nfa[i] = (pat[i] == pat[j] ? nfa[j] : j); 226 | while (j >= 0 && pat[j] != pat[i]) j = nfa[j]; 227 | } 228 | 229 | ``` 230 | 231 | 有了NFA之后,在使用这个数组的时候,因为他是不确定的,所以我们可能要跳多次跳到匹配的字符。有如下代码: 232 | 233 | ``` 234 | for (int i = 0, j = 0; j < nfa.length && i < cs.length; i++, j++) { 235 | while (j >= 0 && pat[j] != cs[i]) j = nfa[j]; 236 | } 237 | if (j == nfa.length) return i - j; 238 | return -1; 239 | 240 | ``` 241 | 242 | 这里我们在回望一下其他博客一般会介绍的NEXT数组的定义,其实就是从J失配,那么NEXT[J]存的就是从i开始的最大前缀长度能MATCH上pat[j]的后缀。其实是有共通性的。 243 | 244 | 我们不妨把文章开头推荐的博客最后的那个优化过的KMP NEXT ARRAY求法。换一种方法写出来。 245 | 246 | 有如下代码 247 | 248 | ``` 249 | char[] pat = pattern.toCharArray(); 250 | int[] next= new int[pat.length]; 251 | next[0] = -1; 252 | int i = 0, j = -1; 253 | while (i < next.length - 1) { 254 | while (j >= 0 && pat[i] != pat[j]) j = next[j]; 255 | j++; 256 | i++; 257 | next[i] = (pat[i] == pat[j] ? next[j] : j); 258 | } 259 | 260 | ``` 261 | 262 | 博客里原始的NEXT ARRAY的求法。(就是前缀后缀最长公共元素长度值向右移动一格的数组). 因为有些题目我们是需要知道前缀后缀最长公共元素长度值,那么就可以用下述求法,因为向右移动了一格。所以我们可以补一个无用CHAR在最后。得到全部的前缀后缀最长公共元素长度值 263 | 264 | ``` 265 | char[] pat = pattern.toCharArray(); 266 | int[] next= new int[pat.length]; 267 | next[0] = -1; 268 | int i = 0, j = -1; 269 | while (i < next.length - 1) { 270 | while (j >= 0 && pat[i] != pat[j]) j = next[j]; 271 | next[++i] = ++j; 272 | } 273 | 274 | ``` 275 | 276 | LC 里有很多题,是基于前缀后缀最长公共元素来解的。 277 | 278 | 比如 279 | 280 | 214. Shortest Palindrome 281 | 282 | 459. Repeated Substring Pattern 283 | 284 | 1392. Longest Happy Prefix 285 | 286 | 大多数题解也解释的很清楚了,其实就是算出NEXT数组后,利用最大值可以直接或间接得到题目所求。 287 | 288 | 回到上面的代码,我们不难发现,J其实就是存在I里的。所以我们可以做进一步简化。 289 | 290 | ``` 291 | char[] pat = pattern.toCharArray(); 292 | next[0] = -1; 293 | for (int i = 0; i < next.length - 1;) { 294 | int j = next[i]; 295 | while (j >= 0 && pat[i] != pat[j]) j = next[j]; 296 | next[++i] = j + 1; 297 | } 298 | 299 | ``` 300 | 301 | ## AC自动机 302 | 303 | 做这步简化,为的是引出我们的AC自动机的模板。下面每一行,都是一个等价。 304 | 305 | KMP这个状态机转换,是在一个匹配串上。如果有一组匹配串,我们就会用到AC自动机。 306 | 307 | 我们用一个CHAR[] 来存一个匹配串。我们可以用一颗TRIE树来存一组匹配串。 308 | 309 | 当在TRIE树中搜索时,当一个节点发生了失配。在KMP中,我们用NEXT数组找到下一个可能匹配上的节点。在TRIE树中,我们对每个TRIE节点,都建立一个NEXT指针,表示失配的时候可以跳到哪个节点。 310 | 311 | 在计算NEXT数组时,我们是根据CHAR[]从前往后,后面的NEXT[I] 往往依赖于前面的NEXT[J]。 312 | 313 | 在AC自动机中,我们遍历的是TRIE树,下层的节点的NEXT指针依赖于之前层节点的NEXT指针。所以这里我们需要用BFS来BUILD AC自动机。 314 | 315 | 我们在初始化NEXT数组时,NEXT[0] = -1。 我们假设TRIE树根节点为-1,之后第一层所有孩子的NEXT指针,都指向根节点。 316 | 317 | ### AC自动机模板 318 | 319 | ``` 320 | public class ACTemplate { 321 | class Node { 322 | Node[] chds = new Node[26]; 323 | Node next = null; 324 | // other value... 325 | boolean isWord = false; 326 | } 327 | // trie inser template 328 | void insert(String s) { 329 | Node p = root; 330 | for (char c : s.toCharArray()) { 331 | int idx = c - 'a'; 332 | if (p.chds[idx] == null) p.chds[idx] = new Node(); 333 | p = p.chds[idx]; 334 | } 335 | // update other value 336 | p.isWord = true; 337 | } 338 | Node root = new Node(); 339 | void buildNextPointer() { 340 | Queue q = new LinkedList<>(); 341 | // same as next[0] = -1; 342 | for (int i = 0; i < 26; i++) { 343 | if (root.chds[i] == null) continue; 344 | root.chds[i].next = root; 345 | q.offer(root.chds[i]); 346 | } 347 | while (!q.isEmpty()) { 348 | Node i = q.poll(); 349 | for (int k = 0; k < 26; k++) { 350 | Node iPlusOne = i.chds[k]; // iPlusOne = i + 1 in kmp 351 | if (iPlusOne == null) continue; 352 | Node j = i.next; 353 | // same as while (j >= 0 && pat[i] != pat[j]) j = next[j]; 354 | while (j != root && j.chds[k] == null) j = j.next; 355 | iPlusOne.next = j.chds[k]; // same as next[i + 1] = j + 1; 356 | if (iPlusOne.next == null) iPlusOne.next = root; // avoid NPE 357 | q.offer(iPlusOne); // for BFS 358 | } 359 | } 360 | } 361 | int query(String text) { 362 | char[] cs = text.toCharArray(); 363 | Node j = root; 364 | int wordCnt = 0; 365 | for (int i = 0; i < cs.length; i++) { 366 | int idx = cs[i] - 'a'; 367 | // same as "while (j >= 0 && pat[j] != cs[i]) j = nfa[j];" in kmp 368 | while (j != root && j.chds[idx] == null) j = j.next; 369 | if (j.chds[idx] == null) continue; 370 | j = j.chds[idx]; // same as "j++;" in kmp 371 | if (j.isWord) { 372 | wordCnt++; 373 | j.isWord = false; 374 | } 375 | } 376 | return wordCnt; 377 | } 378 | 379 | public static void main(String[] args) { 380 | String text = "yasherhs"; 381 | String[] words = {"she", "her", "say", "shr","rh"}; 382 | ACTemplate acTemplate = new ACTemplate(); 383 | for (String w : words) acTemplate.insert(w); 384 | acTemplate.buildNextPointer(); 385 | System.out.println(acTemplate.query(text)); 386 | // should be 3 387 | } 388 | } 389 | 390 | ``` 391 | 392 | 鉴于目前LC 还没有出过需要用到AC自动机的题目,所以就只简单给一个模板。模板中我们维护了节点是否为单词的信息。最后用来统计所有单词个数。不同题目要统计的信息不同,可能会改变NODE节点里存的信息不同。等之后有题目进来,我再过来更新。 393 | 394 | ## 总结 395 | 396 | 其实这篇文章主要是要讲KMP,然后怎么从KMP映射到AC自动机的扩展。 397 | 398 | 在 KMP中。我们从NEXT 数组的思想映射到DFA的KMP解法。 399 | 400 | 再扩展到NFA的KMP解法。本质是2点。 401 | 402 | 1. 不变量的维护,每一次I进来,都必须有`str.substring(0, j) == str(i - j, i)`。 403 | 404 | 2. nfa里存的值要尽可能大。 405 | 406 | 有了第一个性质。我们就可以写出`nfa[i] = pat[i] == pat[j] ? nfa[j] : j;` 407 | 408 | 这个代码。 409 | 410 | 我们假设第二个性质存在,(自底向上可以数学归纳,证明0是对的。之后假设K对,K+1必对)所以我们可以假设nfa[k] 能返回尽可能大的值,然后我们贪心的找到J最大的位置。来维护下次循环时的第一个特性 411 | 412 | 就有了如下代码`while (j >= 0 && pat[i] != pat[j]) j = nfa[j];` 413 | 414 | LEETCODE 有很多题目是基于前缀后缀最大公共长度值的,所以我们只要把第一个性质退化成`nfa[i] = j;`, 然后最后补一个无用字母。就可以得到这个原始NEXT数组。 415 | 416 | AC自动机就是建TRIE树, BFS根据KMP的模板改出BUILD_NEXT_POINTER的代码即可。 417 | 418 | 小伙伴可以拿LeetCode 28. Implement strStr() 来练习KMP模板。DFA解法 419 | 420 | ``` 421 | public int strStr(String haystack, String pattern) { 422 | char[] pat = pattern.toCharArray(), cs = haystack.toCharArray(); 423 | if (pat.length == 0) return 0; 424 | int[][] dfa = new int[pat.length][256]; 425 | dfa[0][pat[0]] = 1; 426 | for (int i = 1, pre = 0; i < pat.length; i++) { 427 | dfa[i] = dfa[pre].clone(); 428 | dfa[i][pat[i]] = i + 1; 429 | pre = dfa[pre][pat[i]]; 430 | } 431 | int j = 0, i = 0; 432 | for (; i < cs.length && j < pat.length; i++) { 433 | j = dfa[j][cs[i]]; 434 | } 435 | if (j == pat.length) return i - j; 436 | return -1; 437 | } 438 | 439 | ``` 440 | 441 | 442 | 443 | 444 | 445 | 446 | 447 | 448 | 449 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # how-to-grokking-leetcode-hard 2 | 这个是个中文博客,讲述一些leetcode hard的思维和算法技巧 3 | 4 | 作者没搞过OI竞赛,平时会打打LC周赛,目标全球前100。 5 | 6 | 博客所用语言可能是JAVA 或 C++,看心情,其实我主JAVA,C++还在学习中。 7 | 8 | 写作按比赛有用程度 9 | 10 | ## 计划写作内容 11 | ### DP优化,包括(斜率优化, 单调队列优化,四边形不等式优化, 二进制优化,快速幂优化) 12 | ### 思维,包括(minmax, 贪心,数据结构设计题,如何思考二分) 13 | ### DP高级,包括 (区间DP, 数位DP, 状压DP) 14 | ### 图论,包括(二分图,欧拉回路,Dijstra, spfa, 最小生成树, floyd, 拓扑排序) 15 | ### 搜索高级, 包括( A*, 迭代加深,IDA*, 双端队列广搜,双向DFS) 16 | ### 字符串高级,包括(KMP,后缀树,AC自动机,后缀数组) 17 | ### 数据结构高级,包括(红黑树,B+树,线段树,区间树,树状数组,splay, treap, 并查集,可持久化数据结构, KD树) 18 | 19 | 20 | --------------------------------------------------------------------------------