├── .DS_Store ├── .gitignore ├── .gitmodules ├── LICENSE ├── README.md └── groking-leetcode ├── .DS_Store ├── 1-sliding-window.ipynb ├── 2-two-pointers.ipynb ├── 3-fast-slow-pointer.ipynb ├── 4-interval-merge.ipynb ├── 5-cyclic-sort.ipynb ├── README.md └── img └── cyclic-sort.png /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsj217/leetcode-solution/243f6dafa824c9ad5b7ad054d1c0bff5f1f6b3b2/.DS_Store -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "leetcode-in-python3"] 2 | path = leetcode-in-python3 3 | url = https://github.com/rsj217/leetcode-in-python3.git 4 | [submodule "leetcode-in-rust"] 5 | path = leetcode-in-rust 6 | url = https://github.com/rsj217/leetcode-in-rust.git 7 | [submodule "leetcode-in-golang"] 8 | path = leetcode-in-golang 9 | url = https://github.com/rsj217/leetcode-in-golang.git 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 rsj217 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Leetcode Pattern 2 | 3 | [groking-leetcode](./groking-leetcode) 4 | 5 | [leetcode-in-python3](https://rsj217.github.io/leetcode-in-python3/) 6 | 7 | [leetcode-in-rust](https://rsj217.github.io/leetcode-in-rust/) 8 | 9 | [leetcode-in-golang](https://pkg.go.dev/github.com/rsj217/leetcode-in-golang) 10 | 11 | 12 | 13 | git clone --recursive git@github.com:rsj217/leetcode-solution.git 14 | 15 | -------------------------------------------------------------------------------- /groking-leetcode/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsj217/leetcode-solution/243f6dafa824c9ad5b7ad054d1c0bff5f1f6b3b2/groking-leetcode/.DS_Store -------------------------------------------------------------------------------- /groking-leetcode/1-sliding-window.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "滑动窗口(Sliding Windows)算法在TCP 流量控制中有着至关重要的作用。滑动窗口的思想在解决一些 Leetcode 上的题目也非常有效。通常在求一些连续的子数组,子串的问题中有着化繁为简的功效。\n", 8 | "\n", 9 | "下面就针对一些 leetcode上的题目来说明一下使用滑动窗口的套路。滑动窗口的题多半是 medium 和 hard 级别\n", 10 | "\n", 11 | "## 子数组最大平均数 I\n", 12 | "\n", 13 | "leetcode的[643. 子数组最大平均数 I](https://leetcode-cn.com/problems/maximum-average-subarray-i/),题目如下:\n", 14 | "## \n", 15 | "\n", 16 | "> 给定 n 个整数,找出平均数最大且长度为 k 的连续子数组,并输出该最大平均数。\n", 17 | "> \n", 18 | "> 示例 1:\n", 19 | "> \n", 20 | "> 输入: [1,12,-5,-6,50,3], k = 4\n", 21 | "> \n", 22 | "> 输出: 12.75\n", 23 | "> \n", 24 | "> 解释: 最大平均数 (12-5-6+50)/4 = 51/4 = 12.75\n", 25 | "\n", 26 | "题意很明确,例如上面的输入。可以把所有连续的子数组都取出来然后相加,最后再求出最大的平均值。如下图:\n", 27 | "\n", 28 | "![image.png](https://s2.ax1x.com/2020/01/09/lWz9oV.jpg)\n", 29 | "\n", 30 | "也就是求 0->3 1->4 2->5 这三个连续子数组的和,然后取平均值" 31 | ] 32 | }, 33 | { 34 | "cell_type": "code", 35 | "execution_count": 2, 36 | "metadata": {}, 37 | "outputs": [], 38 | "source": [ 39 | "from typing import *\n", 40 | "\n", 41 | "class Solution:\n", 42 | " def findMaxAverage(self, nums: List[int], k: int) -> float:\n", 43 | " if not nums:\n", 44 | " return 0\n", 45 | " ret = float('-inf')\n", 46 | " for i in range(len(nums) - k):\n", 47 | " r = 0\n", 48 | " for j in range(i, i + k):\n", 49 | " r += nums[j]\n", 50 | " ret = max(r / k, ret)\n", 51 | " return ret" 52 | ] 53 | }, 54 | { 55 | "cell_type": "markdown", 56 | "metadata": {}, 57 | "source": [ 58 | "上面的代码使用暴力方式,最外层的的变量 i 几乎要遍历整个数组,其复杂度是O(N)。嵌套的循环也要进行 k 次迭代,其复杂度是 O(K)。综合而言,算法的时间复杂度需要 O(N*K)。所幸没有使用额外的空间,空间的复杂度是O(1)。\n", 59 | "\n", 60 | "不妨仔细看一下上面的图,内嵌的循环中,第一次和第二次都对 `12 + (-5) + -6` 进行了计算,也就是内层循环有着大量的重复计算。再次看上面的图,刨去重复计算的部分,后一次循环相对于前一次循环,都向右移动一格。就像一个窗口一样,往右边移动了一格。\n", 61 | "\n", 62 | "使用双指针实现滑动窗口。窗口的范围为左闭右开的区间 `[lo, hi)` 。初始化 lo 和 hi 都为0, 窗口的大小为 hi - lo ,也是0。\n", 63 | "\n", 64 | "由于题意可知,窗口最大值为 k(4),因此只要窗口尚未达到最大值,就右移一格,当窗口大小正好是 4 的时候,就说明原题存在一个解。一旦存在一个解,此时就可以将 lo 也向右移动一格,以实现缩小窗口。然后右边再继续滑动窗口,整个过程就像一个窗口向右滑动。一旦右边不能再滑动了,就直接缩小左边的窗口,直到窗口不合法返回结果。\n", 65 | "\n", 66 | "移动的本质如下图:\n", 67 | "\n", 68 | "![image.png](https://s2.ax1x.com/2020/01/09/lWzVy9.jpg)\n", 69 | "\n", 70 | "\n", 71 | "* 初始窗口大小为 0 \n", 72 | "* hi 向右移动一格,窗口大小为 1\n", 73 | "* hi 再向右移动一格,窗口大小为 2\n", 74 | "* hi 再向右移动一格,窗口大小为 3\n", 75 | "* hi 再向右移动一个,窗口大小为 4,此时存在一个解。求解\n", 76 | "* lo 向右一定一格,缩小窗口,窗口大小为3\n", 77 | "* hi 再向右移动一格,窗口大小为 4, 此时又存在第二个解,求解\n", 78 | "\n", 79 | "如此往复,直到 hi 到达末尾,lo 再缩小窗口就不合法,此时就结束算法。上面的算法兑换代码如下" 80 | ] 81 | }, 82 | { 83 | "cell_type": "code", 84 | "execution_count": 3, 85 | "metadata": {}, 86 | "outputs": [], 87 | "source": [ 88 | "class Solution:\n", 89 | " def findMaxAverage(self, nums: List[int], k: int) -> float:\n", 90 | " if not nums:\n", 91 | " return 0\n", 92 | " \n", 93 | " ret = float('-inf')\n", 94 | " # 初始化窗口计数器,用来判断窗口是否需要缩小\n", 95 | " windows = 0\n", 96 | " \n", 97 | " # 初始化窗口大小为 0\n", 98 | " lo = hi = 0 \n", 99 | " # 窗口未滑到最右 \n", 100 | " while hi < len(nums): \n", 101 | " windows += nums[hi] # 向右扩展一格\n", 102 | " hi += 1\n", 103 | " \n", 104 | " # 判断窗口计数器\n", 105 | " if hi - lo >= k: \n", 106 | " # 求解\n", 107 | " if hi - lo == k: \n", 108 | " # 更新解 \n", 109 | " ret = max(windows / k, ret) \n", 110 | " # 缩小窗口\n", 111 | " windows -= nums[lo] \n", 112 | " lo += 1 \n", 113 | " return ret" 114 | ] 115 | }, 116 | { 117 | "cell_type": "markdown", 118 | "metadata": {}, 119 | "source": [ 120 | "通过窗口的滑动,尽管窗口再不停的变大或缩小,但是 hi 和 lo 都是亦步亦趋的往右边靠拢。整个过程算法的时间复杂度为 O(N)。\n", 121 | "\n", 122 | "## 滑动窗口的模板\n", 123 | "\n", 124 | "借助上面的题目可以一窥滑动窗口套路。滑动窗口的本质是窗口扩大的过程中,寻找一个解,然后窗口缩小的时候寻找一个最优解。如何理解呢?可以借助下面的一题\n", 125 | "\n", 126 | "[209. 长度最小的子数组](https://leetcode-cn.com/problems/minimum-size-subarray-sum/)\n", 127 | "\n", 128 | "> 给定一个含有 n 个正整数的数组和一个正整数 s ,找出该数组中满足其和 ≥ s 的长度最小的连续子数组。如果不存在符合条件的连续子数组,返回 0。\n", 129 | "> \n", 130 | "> 示例: \n", 131 | "> \n", 132 | "> 输入: s = 7, nums = [2,3,1,2,4,3]\n", 133 | "> \n", 134 | "> 输出: 2\n", 135 | "> \n", 136 | "> 解释: 子数组 [4,3] 是该条件下的长度最小的连续子数组。\n", 137 | "\n", 138 | "\n", 139 | "题意要求寻找一个连续的子数组,并且子数组的和要大于等于目标值s。这也是一个数组加和方式。回想上面的滑动窗口,窗口逐步扩大的时候。可以累加窗口的元素的和,如果大于目标s,那么就等于存在一个解。例如 [2, 3, 1, 2] 这个子数组,是hi 不停向右扩展。\n", 140 | "\n", 141 | "有解并不代表最优解,假设上面第3个元素就是 7, [2, 3, 1, 7] 自然是一个解,但是 单纯的 [7] 也是一个解。因此我们需要不断的缩小窗口,来找到最小窗口的解。代码如下:" 142 | ] 143 | }, 144 | { 145 | "cell_type": "code", 146 | "execution_count": 4, 147 | "metadata": {}, 148 | "outputs": [], 149 | "source": [ 150 | "class Solution:\n", 151 | " def minSubArrayLen(self, s: int, nums: List[int]) -> int:\n", 152 | " if not nums:\n", 153 | " return 0\n", 154 | " ret = len(nums) + 1\n", 155 | " lo, hi = 0, 0\n", 156 | " windows = 0\n", 157 | " while hi < len(nums):\n", 158 | " windows += nums[hi]\n", 159 | " hi += 1\n", 160 | " \n", 161 | " while windows >= s:\n", 162 | " ret = min(hi - lo, ret)\n", 163 | " windows -= nums[lo]\n", 164 | " lo += 1\n", 165 | " return ret if ret != len(nums) + 1 else 0" 166 | ] 167 | }, 168 | { 169 | "cell_type": "markdown", 170 | "metadata": {}, 171 | "source": [ 172 | "与前面一题类似,初始化窗口为 0,然后 hi 向右移动。每移动一格窗口后,检测当前的窗口计数器是否有解,有解即记录更新解,并且缩小窗口。\n", 173 | "\n", 174 | "> NOTE 一般求最小子数组,有可能没有解,因此初始化解是数组本身的长度再+1, 方便最后判断是否有解的情况。\n", 175 | "\n", 176 | "从上面的两个题大致可以看出,滑动窗口的套路无怪乎两步:\n", 177 | "\n", 178 | "* 其一 向右滑动一格,\n", 179 | "* 其二 滑动之后检测窗口计数器和题意要求,适当的缩小窗口\n", 180 | "\n", 181 | "其伪代码可以表述如下:" 182 | ] 183 | }, 184 | { 185 | "cell_type": "markdown", 186 | "metadata": {}, 187 | "source": [ 188 | "```\n", 189 | "lo = hi = 0\n", 190 | "while hi < len(nums):\n", 191 | " windows.add(nums[hi])\n", 192 | " hi+ += 1\n", 193 | "\n", 194 | " while has_valid:\n", 195 | " handle(ret)\n", 196 | " windows.remove(s[lo])\n", 197 | " lo += 1\n", 198 | "```" 199 | ] 200 | }, 201 | { 202 | "cell_type": "markdown", 203 | "metadata": {}, 204 | "source": [ 205 | "## 窗口计数器\n", 206 | "\n", 207 | "滑动窗口的解题模板可以解决 leetcode 上大部分此类题目。然而算法之所以有魅力,一个重要原因就是不变中带有万变。有的算法稍微改变一下,其效果大相径庭。或者有的算法性能并不二至,有的写法可读性高,有的则讳莫苦涩。\n", 208 | "\n", 209 | "在第二步骤中,通过窗口计数器与题意要求缩小窗口,也是求解的过程。另外一些题目,在缩小窗口的时候未必有解,而是缩小之后才有解。因此需要灵活的处理窗口计数器,缩小窗口,求解的过程。\n", 210 | "\n", 211 | "geeksforgeeks 上有一题 `Longest Substring with K Distinct Characters`,即找出字串中不同字符数为K的最大子串\n", 212 | "\n", 213 | "\n", 214 | "> 输入: String=\"araaci\", K=2\n", 215 | "> \n", 216 | "> 输出: 4\n", 217 | "> \n", 218 | "> 解释: 不同字符数 K=2, 即有连个不同字符的最大子串 \"araa\".\n", 219 | "> \n", 220 | "> 输入: String=\"araaci\", K=1\n", 221 | "> \n", 222 | "> 输出: 2\n", 223 | "> \n", 224 | "> 解释: 不同字符数 K=1, 即有连个不同字符的最大子串 \"aa\".\n", 225 | "> \n", 226 | "> 输入: String=\"cbbebi\", K=3\n", 227 | "> \n", 228 | "> 输出: 5\n", 229 | "> \n", 230 | "> 解释: 不同字符数 K=3, 即有连个不同字符的最大串\"cbbeb\" & \"bbebi\".\n", 231 | "\n", 232 | "\n", 233 | "由题意可知,求最长字串。使用一个窗口,窗口的不同字符数要等于 K。窗口的计数器可以设置当前窗口内不同字符的个数。然后接助一个hash表,用来记录当前窗口内的字符出现的次数。\n", 234 | "\n", 235 | "窗口滑动的时候检查下一个字符是否出现,并更新不同字符数目。然后跟 k 进行比较,缩小窗口并求解。\n", 236 | "\n", 237 | "滑动窗口对于求解字符出现次数,重复字符数,经常需要借助hash字典。后面会针对这样的case举例。" 238 | ] 239 | }, 240 | { 241 | "cell_type": "code", 242 | "execution_count": 6, 243 | "metadata": {}, 244 | "outputs": [], 245 | "source": [ 246 | "def longest_substring_with_k_distinct(str, k):\n", 247 | " ret = 0\n", 248 | " # 窗口计数器\n", 249 | " windows = 0\n", 250 | " windows_dct = dict()\n", 251 | " lo, hi = 0, 0\n", 252 | "\n", 253 | " while hi < len(str):\n", 254 | " windows_dct[str[hi]] = windows_dct.get(str[hi], 0) + 1\n", 255 | " if windows_dct[str[hi]] == 1:\n", 256 | " windows += 1\n", 257 | " hi += 1\n", 258 | "\n", 259 | " while windows > k:\n", 260 | " windows_dct[str[lo]] -= 1\n", 261 | " if windows_dct[str[lo]] == 0:\n", 262 | " windows -= 1\n", 263 | " lo += 1\n", 264 | " if windows == k:\n", 265 | " ret = max(hi - lo, ret)\n", 266 | " return ret" 267 | ] 268 | }, 269 | { 270 | "cell_type": "markdown", 271 | "metadata": {}, 272 | "source": [ 273 | "上面的代码也符合解题模板。窗口向右移动,然后检查移动之后,窗口扩大之后,当前的窗口计数器是否满足题意,即 窗口内不同的字符数 windows 是否大于 k,一旦大于 k,则缩小窗口。while 也可以写成 if 。因为\n", 274 | "windows只要变更一次就不在合法。如果 windows 不大于 k,那么就是小于等于k。当等于k的时候,正好是题目的一个解,随即更新解即可。\n", 275 | "\n", 276 | "尽管有内循环,最终的时间复杂度还是 O(N)。空间复杂度来自于存储 k 个字符的hash字典,即 O(K) 的空间复杂度\n", 277 | "\n", 278 | "这个问题不同于解题模板之处在于,题解在判断的条件之外。即缩小窗口后才求解,此前一直是先求解再缩小窗口。然而问题殊途同归,谁先谁后并不一定,需要具体题目分析。这也是前文所说的算法变化的魅力。\n", 279 | "\n", 280 | "同类题,leetcode上也有一道:\n", 281 | "\n", 282 | "[904. 水果成篮](https://leetcode-cn.com/problems/fruit-into-baskets/)\n", 283 | "\n", 284 | "\n", 285 | "> 在一排树中,第 i 棵树产生 tree[i] 型的水果。\n", 286 | "> 你可以从你选择的任何树开始,然后重复执行以下步骤:\n", 287 | "> \n", 288 | "> 把这棵树上的水果放进你的篮子里。如果你做不到,就停下来。\n", 289 | "> 移动到当前树右侧的下一棵树。如果右边没有树,就停下来。\n", 290 | "> 请注意,在选择一颗树后,你没有任何选择:你必须执行步骤 1,然后执行步骤 2,然后返回步骤 1,然后执行步骤 2,依此类推,直至停止。\n", 291 | "> \n", 292 | "> 你有两个篮子,每个篮子可以携带任何数量的水果,但你希望每个篮子只携带一种类型的水果。\n", 293 | "> 用这个程序你能收集的水果总量是多少?\n", 294 | "> \n", 295 | "> 示例 1:\n", 296 | "> \n", 297 | "> 输入:[1,2,1]\n", 298 | "> \n", 299 | "> 输出:3\n", 300 | "> \n", 301 | "> 解释:我们可以收集 [1,2,1]。\n", 302 | "> \n", 303 | "> 示例 2:\n", 304 | "> \n", 305 | "> 输入:[0,1,2,2]\n", 306 | "> \n", 307 | "> 输出:3\n", 308 | "> \n", 309 | "> 解释:我们可以收集 [1,2,2].\n", 310 | "> \n", 311 | "> 如果我们从第一棵树开始,我们将只能收集到 [0, 1]。\n", 312 | "> \n", 313 | "> 示例 3:\n", 314 | "> \n", 315 | "> 输入:[1,2,3,2,2]\n", 316 | "> \n", 317 | "> 输出:4\n", 318 | "> \n", 319 | "> 解释:我们可以收集 [2,3,2,2].\n", 320 | "> \n", 321 | "> 如果我们从第一棵树开始,我们将只能收集到 [1, 2]。\n", 322 | "> \n", 323 | "> 示例 4:\n", 324 | "> \n", 325 | "> 输入:[3,3,3,1,2,1,1,2,3,3,4]\n", 326 | "> \n", 327 | "> 输出:5\n", 328 | "> \n", 329 | "> 解释:我们可以收集 [1,2,1,1,2].\n", 330 | "> \n", 331 | "> 如果我们从第一棵树或第八棵树开始,我们将只能收集到 4 个水果。\n", 332 | "\n", 333 | "也许是为了增加解题的趣味性,题目和现实生活相结合。然而解题的过程中要学会挖掘抽象题意。\n", 334 | "\n", 335 | "尽管有两个篮子,但是篮子里只能有同一种水果。如果把两个篮子放在一起。就和前一题类似了。两个篮子组成一个数组。数组中只能出现 2 次不同的水果(k=2)。所以使用滑动窗口解法如下;" 336 | ] 337 | }, 338 | { 339 | "cell_type": "code", 340 | "execution_count": 7, 341 | "metadata": {}, 342 | "outputs": [], 343 | "source": [ 344 | "class Solution:\n", 345 | " def totalFruit(self, tree: List[int]) -> int:\n", 346 | " ret = 0\n", 347 | " lo, hi = 0, 0\n", 348 | " windows = 0\n", 349 | " windows_dct = dict()\n", 350 | " while hi < len(tree):\n", 351 | " windows_dct[tree[hi]] = windows_dct.get(tree[hi], 0) + 1\n", 352 | " if windows_dct[tree[hi]] == 1:\n", 353 | " windows += 1\n", 354 | " hi += 1\n", 355 | " size = hi - lo\n", 356 | "\n", 357 | " while windows > 2:\n", 358 | " windows_dct[tree[lo]] -= 1\n", 359 | " if windows_dct[tree[lo]] == 0:\n", 360 | " windows -= 1\n", 361 | " lo += 1\n", 362 | "\n", 363 | " if windows <= 2:\n", 364 | " ret = max(hi - lo, ret)\n", 365 | " return ret" 366 | ] 367 | }, 368 | { 369 | "cell_type": "markdown", 370 | "metadata": {}, 371 | "source": [ 372 | "## 滑动窗口的解\n", 373 | "\n", 374 | "正确的设置滑动计数器,以便在合适的时候缩小窗口。直接关系到滑动窗口的解。正如 水果成篮 这类题一样,需要花点心思将题目抽象成滑动窗口的类型,然后才能针对性的进行求解。下面再看两个这样的转换思路题目\n", 375 | "\n", 376 | "[1004. 最大连续1的个数 III](https://leetcode-cn.com/problems/max-consecutive-ones-iii/)\n", 377 | "\n", 378 | "> 给定一个由若干 0 和 1 组成的数组 A,我们最多可以将 K 个值从 0 变成 1 。\n", 379 | "> 返回仅包含 1 的最长(连续)子数组的长度。\n", 380 | "> \n", 381 | "> 示例 1:\n", 382 | "> \n", 383 | "> 输入:A = [1,1,1,0,0,0,1,1,1,1,0], K = 2\n", 384 | "> \n", 385 | "> 输出:6\n", 386 | "> \n", 387 | "> 解释: \n", 388 | "> \n", 389 | "> [1,1,1,0,0,1,1,1,1,1,1]\n", 390 | "> \n", 391 | "> 粗体数字从 0 翻转到 1,最长的子数组长度为 6。\n", 392 | "> \n", 393 | "> 示例 2:\n", 394 | "> \n", 395 | "> 输入:A = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], K = 3\n", 396 | "> \n", 397 | "> 输出:10\n", 398 | "> \n", 399 | "> 解释:\n", 400 | "> \n", 401 | "> [0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1]\n", 402 | "> \n", 403 | "> 粗体数字从 0 翻转到 1,最长的子数组长度为 10\n", 404 | "\n", 405 | "题意很明确,将 k 个 0 替换成 1,替换后的字串中连续的 1 最长。由于替换后,再去检查串的连续性,这是一种思路。但是对于继续右移的时候,替换的串该如何处理?\n", 406 | "\n", 407 | "一个转换思路就是,记录当前连续的 1 的个数,窗口的大小 减去 连续的个数,就是剩余可以替换的个数,如果这个个数等于 k ,那么正好是一个解。因此代码如下:" 408 | ] 409 | }, 410 | { 411 | "cell_type": "code", 412 | "execution_count": 8, 413 | "metadata": {}, 414 | "outputs": [], 415 | "source": [ 416 | "class Solution:\n", 417 | " def longestOnes(self, A: List[int], K: int) -> int:\n", 418 | " if not A:\n", 419 | " return 0\n", 420 | " \n", 421 | " ret = 0\n", 422 | " lo = hi = 0\n", 423 | "\n", 424 | " max_1_repeat_size = 0\n", 425 | " \n", 426 | " while hi < len(A):\n", 427 | " if A[hi] == 1:\n", 428 | " max_1_repeat_size += 1\n", 429 | " \n", 430 | " hi += 1 \n", 431 | " if hi - lo - max_1_repeat_size > K:\n", 432 | " if A[lo] == 1:\n", 433 | " max_1_repeat_size -= 1\n", 434 | " lo += 1\n", 435 | " \n", 436 | " ret = max(hi - lo, ret)\n", 437 | " return ret" 438 | ] 439 | }, 440 | { 441 | "cell_type": "markdown", 442 | "metadata": {}, 443 | "source": [ 444 | "这一题的关键是如何找到滑动窗口的解。通常情况下,都会利用连续和匹配这样的转换技巧。解决了这一题,下面的一题也就迎刃而解了\n", 445 | "\n", 446 | "[424. 替换后的最长重复字符](https://leetcode-cn.com/problems/longest-repeating-character-replacement/)\n", 447 | "\n", 448 | "\n", 449 | "> 给你一个仅由大写英文字母组成的字符串,你可以将任意位置上的字符替换成另外的字符,总共可最多替换 k 次。在执行上述操作后,找到包含重复字母的最长子串的长度。\n", 450 | "> \n", 451 | "> 注意:\n", 452 | "> 字符串长度 和 k 不会超过 104。\n", 453 | "> \n", 454 | "> 示例 1:\n", 455 | "> \n", 456 | "> 输入: s = \"ABAB\", k = 2\n", 457 | "> 输出: 4\n", 458 | "> 解释:\n", 459 | "> 用两个'A'替换为两个'B',反之亦然。\n", 460 | "> 示例 2:\n", 461 | "> \n", 462 | "> 输入: s = \"AABABBA\", k = 1\n", 463 | "> 输出:4\n", 464 | "> 解释: 将中间的一个'A'替换为'B',字符串变为 \"AABBBBA\"。子串 \"BBBB\" 有最长重复字母, 答案为 4。\n", 465 | "\n", 466 | "这到题的变化之处在于不再是求某个给定的可以替换的字符,而是串中的所有字符都可以替换。万变不离其中,依然可以找到当前窗口中最长的重复字串,然后将其它字符变更为当前重复的字符即可。如何查找重复字符呢?解法稍后\n", 467 | "\n", 468 | "## 滑动窗口遇到哈希\n", 469 | "\n", 470 | "leetcode上有一类题,所求的字串除了最大最小之外,还有是否重复,模式匹配的题目。尤其是后一种,通常以 hard 题目出现。\n", 471 | "\n", 472 | "对于字符是否重复或者是否出现过,可以借助一个 hash 字典结构存储器出现的次数,而窗口计数器也可以使用另外一个 hash 字典存储所需要对比的数据。\n", 473 | "\n", 474 | "对于 `替换后的最长重复字符` 这题,我们可以创建一个 hash 字典,又来存储字符出现的次数。窗口向右移动的时候,更新窗口字符的计数,求出最长重复的字符和长度。剩下的套路就和之前翻转 1 的类似了。具体代码如下:" 475 | ] 476 | }, 477 | { 478 | "cell_type": "code", 479 | "execution_count": 9, 480 | "metadata": {}, 481 | "outputs": [], 482 | "source": [ 483 | "class Solution:\n", 484 | " def characterReplacement(self, s: str, k: int) -> int:\n", 485 | " ret = 0\n", 486 | " lo = hi = 0\n", 487 | " \n", 488 | " t = dict()\n", 489 | " max_repeat = 0\n", 490 | " \n", 491 | " while hi < len(s):\n", 492 | " t[s[hi]] = t.get(s[hi], 0) + 1\n", 493 | " max_repeat = max(max_repeat, t[s[hi]]) \n", 494 | " hi += 1\n", 495 | " \n", 496 | " if hi - lo - max_repeat > k:\n", 497 | " t[s[lo]] -= 1\n", 498 | " lo += 1\n", 499 | " ret = max(hi - lo, ret)\n", 500 | " return ret" 501 | ] 502 | }, 503 | { 504 | "cell_type": "markdown", 505 | "metadata": {}, 506 | "source": [ 507 | "\n", 508 | "找重复字符,或者不同字符,往往需要借助 hash 字典来存储字符出现的次数。Leetcode 第三题比较经典\n", 509 | "\n", 510 | "[3. 无重复字符的最长子串](https://leetcode-cn.com/problems/longest-substring-without-repeating-characters/)\n", 511 | "\n", 512 | "\n", 513 | "> 给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。\n", 514 | "> \n", 515 | "> 示例 1:\n", 516 | "> \n", 517 | "> 输入: \"abcabcbb\"\n", 518 | "> 输出: 3 \n", 519 | "> 解释: 因为无重复字符的最长子串是 \"abc\",所以其长度为 3。\n", 520 | "> 示例 2:\n", 521 | "> \n", 522 | "> 输入: \"bbbbb\"\n", 523 | "> 输出: 1\n", 524 | "> 解释: 因为无重复字符的最长子串是 \"b\",所以其长度为 1。\n", 525 | "> 示例 3:\n", 526 | "> \n", 527 | "> 输入: \"pwwkew\"\n", 528 | "> 输出: 3\n", 529 | "> 解释: 因为无重复字符的最长子串是 \"wke\",所以其长度为 3。\n", 530 | ">   请注意,你的答案必须是 子串 的长度,\"pwke\" 是一个子序列,不是子串。\n", 531 | "\n", 532 | "题意要求的是寻找最长的无重复的字串。最长可以理解为窗口最大的时候的解。无重复如何判断呢?首先思考如何判断一个字串是否有重复字符。一个简单的方法就是迭代字符串,依次数这些字符出现的次数。如果数到大于 1,那么肯定就有重复的字符。\n", 533 | "\n", 534 | "因此反向思考,如果遇到一个字符,在字符表里没有出现,那么就是没有重复字符。更进一步,使用滑动窗口的时候,hi 向右滑动的时候必然会读到一个字符,如果这个字符没有重复,那么hi可以继续滑动。如果这个字符有重复,那么就说明需要缩小窗口了,直到窗口内没有重复的字符。\n", 535 | "\n", 536 | "对于 字串 `abcabcbb` , 只有当 hi 为第四个字符的时候,才出现重复字符 `a`,此时就需要缩小窗口,即 lo 也向右移动。移动多少次呢?因为是右边字符率先重复的,重复的字符未必是左边当前 lo 的字符,因此lo 需要逐步缩小,直到找到那个重复的 hi 字符。下图可以清晰的说明这种情况。\n", 537 | "\n", 538 | "![图片](https://s2.ax1x.com/2020/01/09/lWzMFK.jpg)\n", 539 | "\n", 540 | "\n", 541 | "当 a 重复的时候,lo为0, lo 需要不断的右移,直到把 a 剔除在外。最后附上代码如下" 542 | ] 543 | }, 544 | { 545 | "cell_type": "code", 546 | "execution_count": 10, 547 | "metadata": {}, 548 | "outputs": [], 549 | "source": [ 550 | "class Solution:\n", 551 | " def lengthOfLongestSubstring(self, s: str) -> int:\n", 552 | " ret = 0\n", 553 | " lo, hi = 0, 0\n", 554 | " windows_dct = dict()\n", 555 | " while hi < len(s):\n", 556 | " windows_dct[s[hi]] = windows_dct.get(s[hi], 0) + 1\n", 557 | " hi += 1\n", 558 | " \n", 559 | " while windows_dct[s[hi - 1]] > 1:\n", 560 | " windows_dct[s[lo]] = windows_dct[s[lo]] - 1\n", 561 | " lo += 1\n", 562 | " ret = max(hi - lo, ret)\n", 563 | " return ret" 564 | ] 565 | }, 566 | { 567 | "cell_type": "markdown", 568 | "metadata": {}, 569 | "source": [ 570 | "使用一个 windows 字典用来记录字符出现的次数。hi 右移的时候,更新 hi 字符的次数。随即判断这个 字符 是否重复,如果重复,则需要缩小窗口。一旦窗口不需要缩小,表面有一个字串肯定不重复,即存在一个解。此时更新这个解即可。经过窗口的滑动,最后可以把所有的解求出,并且在动态求解的过程中只保存最优解。\n", 571 | "\n", 572 | "当然,上面一题的滑动解法还可以写成下面的样子:" 573 | ] 574 | }, 575 | { 576 | "cell_type": "code", 577 | "execution_count": 11, 578 | "metadata": {}, 579 | "outputs": [], 580 | "source": [ 581 | "class Solution:\n", 582 | " def lengthOfLongestSubstring(self, s: str) -> int:\n", 583 | " if not s:\n", 584 | " return 0\n", 585 | " \n", 586 | " ret = 0\n", 587 | " i = j = 0\n", 588 | " t = dict()\n", 589 | " while i < len(s): \n", 590 | " if j < len(s) and t.get(s[j], 0) == 0:\n", 591 | " t[s[j]] = 1\n", 592 | " j += 1\n", 593 | " else:\n", 594 | " t[s[i]] -= 1\n", 595 | " i += 1\n", 596 | " ret = max(j - i, ret)\n", 597 | " return ret" 598 | ] 599 | }, 600 | { 601 | "cell_type": "markdown", 602 | "metadata": {}, 603 | "source": [ 604 | "思路一样,但是和之前介绍的解题模板稍有不同。因此不再详细解释。\n", 605 | "\n", 606 | "最后,上面的两种解法,都使用了一个hash字典来做计数器。使用的额外的空间,其空间复杂度为 O(K),K 为重复字符的个数,最坏的情况下 K <= N 。当然对于 英文字母,可以使用一个固定大小的数组来存储器出现的频次。时间复杂度依然O(N)。\n", 607 | "\n", 608 | "## 滑动窗口与匹配\n", 609 | "\n", 610 | "前面所见的题目,多半是处理某个串或数组在某个条件下的某种特性的解。leetcode还有一类题,给出两个串,其中一个和之前的一样,另外一个则是一个 模式串 pattern。需要 字串在模式串的某些条件约束下求解问题。这类的题目的复杂度初看会比上面的类型要难。但是,只要掌握好模式匹配的技巧,依然可以轻松破解。\n", 611 | "\n", 612 | "[567. 字符串的排列](https://leetcode-cn.com/problems/permutation-in-string/)\n", 613 | "\n", 614 | "\n", 615 | "> 给定两个字符串 s1 和 s2,写一个函数来判断 s2 是否包含 s1 的排列。\n", 616 | "> 换句话说,第一个字符串的排列之一是第二个字符串的子串。\n", 617 | "> \n", 618 | "> 示例1:\n", 619 | "> \n", 620 | "> 输入: s1 = \"ab\" s2 = \"eidbaooo\"\n", 621 | "> 输出: True\n", 622 | "> 解释: s2 包含 s1 的排列之一 (\"ba\").\n", 623 | " > \n", 624 | "> 示例2:\n", 625 | "> \n", 626 | "> 输入: s1= \"ab\" s2 = \"eidboaoo\"\n", 627 | "> 输出: False\n", 628 | "\n", 629 | "题意要求 s1的字串可以任意重新组合,组合之后,可以在 s2 中匹配。假如说A串完全匹配 B串,那么意味着A串和B串一模一样,字母的顺序和次数都应该一致。s1 匹配 s2 的字串,也就是说需要在 s2 中,找到s1出现的字母,并且次数也要一致。同时,s2 的匹配的字串,其长度也要等于 s1。\n", 630 | "\n", 631 | "了解这两个限制之后,使用上述 hash 字典的技巧,可以先求出一个字符表。即 s1 所有字符出现的次数。然后设置一个滑动窗口。窗口右移的过程中,判断右移的字符是否匹配了s1串对应的字符(次数一致),一旦窗口的字符都匹配了s1字符。那么就可以进行窗口的缩小了。此时,窗口很可能存在一个解。正如上面所说,匹配要长度也一致,即窗口的大小如果等于 s1串的长度,那么就存在这一一个解。上面的过程可以用下图所示:\n", 632 | "\n", 633 | "![image.png](https://s2.ax1x.com/2020/01/09/lWzGyd.jpg)\n", 634 | "\n", 635 | "当 窗口右移的时候,记录 hi 的字符出现的次数。同时如果 出现了 s1 中的字符数一致,则表明该字符匹配了s1。对于上图的第二个数组,当窗口的所有字符都匹配了 s1 ,此时 hi - lo ,窗口大小为 5,显然跟 s1 的长度不匹配,因此需要缩小窗口。只有当窗口匹配s1,并且 hi - lo 的长度正好等于 s1 的长度,才是最终的解。\n", 636 | "\n", 637 | "当然,在缩小窗口的时候,如果 a和b 不是相邻的元素,那么最终在缩小窗口的时候,导致了 窗口中的字符不再和 s1 中的字符匹配,此时就是没有解的情况。\n", 638 | "\n", 639 | "需要一个hash字典(need_dct)记录 s1 的字符数,同时另外一个 hash 字典(windows_dct)记录窗口字符数,还需要一个是否匹配的match 变量,用来标记 窗口中字符在 s1中的匹配情况。窗口计数器可以看成是 windows_dct 和 match,因为在窗口变更的时候,都可能需要修改这俩个量。\n", 640 | "\n", 641 | "最终算法兑换为代码如下" 642 | ] 643 | }, 644 | { 645 | "cell_type": "code", 646 | "execution_count": 12, 647 | "metadata": {}, 648 | "outputs": [], 649 | "source": [ 650 | "class Solution:\n", 651 | " def checkInclusion(self, s1: str, s2: str) -> bool:\n", 652 | " need_dct = {}\n", 653 | " for i in s1:\n", 654 | " need_dct[i] = need_dct.get(i, 0) + 1\n", 655 | " \n", 656 | " windows_dct = {}\n", 657 | " ret = False\n", 658 | " lo = hi = 0\n", 659 | " \n", 660 | " match = 0\n", 661 | " while hi < len(s2):\n", 662 | " hi_char = s2[hi]\n", 663 | " windows_dct[hi_char] = windows_dct.get(hi_char, 0) + 1\n", 664 | " if windows_dct[hi_char] == need_dct.get(hi_char, 0):\n", 665 | " match += 1\n", 666 | " hi += 1\n", 667 | "\n", 668 | " while match == len(need_dct):\n", 669 | " if hi - lo == len(s1): # 字符数匹配,长度也要匹配,不能出现别的字符\n", 670 | " return True\n", 671 | " \n", 672 | " lo_char = s2[lo]\n", 673 | " windows_dct[lo_char] -= 1\n", 674 | " if windows_dct[lo_char] < need_dct.get(lo_char, 0):\n", 675 | " match -= 1\n", 676 | " lo += 1\n", 677 | " return ret" 678 | ] 679 | }, 680 | { 681 | "cell_type": "markdown", 682 | "metadata": {}, 683 | "source": [ 684 | "\n", 685 | "对于向右移动的 hi_char ,可以加一层判断,即 只有 hi_char 是 need_dct 的字母,才更新 windows_dct ,这样会节省很多空间。由于为了说明算法思路,上面的写法就不针对这种情况进行优化。\n", 686 | "\n", 687 | "从中可以看出,hash 字典计数的方式前面出现过多次。match 这个变量的设计是另外一个关键,当 s1 中有重复的字符的时候,缩小窗口尤其要注意。增加 match的时候是一个 `=`,减少 match 的时候是一个 `<` 号,具体逻辑想清楚之后也很好理解。\n", 688 | "\n", 689 | "算法的时间复杂度来自两个串的处理,即 O(N+K), 空间复杂度如果只存模式串的字符,那么可以优化成O(K)。\n", 690 | "\n", 691 | "## 匹配与包含\n", 692 | "\n", 693 | "匹配有着严格的定义。而包含就松了很多。匹配的求解是窗口大小要严格等于模式串的长度。而包含就未必,只需要窗口字符的次数跟模式串的次数一致即可。leetcode 有一到 hard的题目,就是一种包含关系。\n", 694 | "\n", 695 | "[76. 最小覆盖子串](https://leetcode-cn.com/problems/minimum-window-substring/)\n", 696 | "\n", 697 | "\n", 698 | "> 给你一个字符串 S、一个字符串 T,请在字符串 S 里面找出:包含 T 所有字母的最小子串。\n", 699 | "> \n", 700 | "> 示例:\n", 701 | "> \n", 702 | "> 输入: S = \"ADOBECODEBANC\", T = \"ABC\"\n", 703 | "> 输出: \"BANC\"\n", 704 | "\n", 705 | "题目的长度很短,难道却不小。当然掌握了滑动技巧,难道就更小了。与上题一样,只要找到match的模式串,然后再缩小窗口即可。" 706 | ] 707 | }, 708 | { 709 | "cell_type": "code", 710 | "execution_count": 13, 711 | "metadata": {}, 712 | "outputs": [], 713 | "source": [ 714 | "class Solution:\n", 715 | " def minWindow(self, s: str, t: str) -> str:\n", 716 | " if not t:\n", 717 | " return ''\n", 718 | "\n", 719 | " need_dct = {}\n", 720 | " for i in t:\n", 721 | " need_dct[i] = need_dct.get(i, 0) + 1\n", 722 | "\n", 723 | " ret_size = len(s) + 1\n", 724 | " ret = None\n", 725 | " lo = hi = 0\n", 726 | " windows_dict = dict()\n", 727 | "\n", 728 | " match = 0\n", 729 | " while hi < len(s):\n", 730 | " if s[hi] in need_dct:\n", 731 | " windows_dict[s[hi]] = windows_dict.get(s[hi], 0) + 1\n", 732 | " if windows_dict[s[hi]] == need_dct[s[hi]]:\n", 733 | " match += 1\n", 734 | " hi += 1\n", 735 | " \n", 736 | " while match == len(need_dct):\n", 737 | " if hi - lo < ret_size:\n", 738 | " ret_size = hi - lo\n", 739 | " ret = s[lo:hi]\n", 740 | " if s[lo] in windows_dict:\n", 741 | " windows_dict[s[lo]] -= 1\n", 742 | " if windows_dict[s[lo]] < need_dct[s[lo]]:\n", 743 | " match -= 1\n", 744 | " lo += 1\n", 745 | " if ret_size != len(s) + 1:\n", 746 | " return ret\n", 747 | " return ''" 748 | ] 749 | }, 750 | { 751 | "cell_type": "markdown", 752 | "metadata": {}, 753 | "source": [ 754 | "由于是求最小子串,求解过程中,需要更新解。上面使用了 字串的切片更新解,可以优化中间存储的是解的 lo 和 hi,最后返回的时候再切片。\n", 755 | "\n", 756 | "输入有两个串,滑动窗口的串为 N,滑动过程中的时间复杂度为 O(N),模式串hash字符表构建的时候至少也需要遍历一次,因此算法最终复杂度是 O(N+K)\n", 757 | "\n", 758 | "空间方面,也是需要 hash 用来进行窗口计数器,其复杂度是O(K),而对于中间结果的存储,上面的方式最坏需要 O(N)的空间来存储解。\n", 759 | "\n", 760 | "\n", 761 | "这类问题还有一道题可以用来练习 [438. 找到字符串中所有字母异位词](https://leetcode-cn.com/problems/find-all-anagrams-in-a-string/)\n", 762 | "\n", 763 | "如果对之前的滑动套路比较了解了,解这题就有点老生常谈了。前面的题目要求求最小或者最大。这道题则是要求所有解。\n", 764 | "\n", 765 | "> 给定一个字符串 s 和一个非空字符串 p,找到 s 中所有是 p 的字母异位词的子串,返回这些子串的起始索引。\n", 766 | "> 字符串只包含小写英文字母,并且字符串 s 和 p 的长度都不超过 20100。\n", 767 | "> \n", 768 | "> 说明:\n", 769 | "> \n", 770 | "> 字母异位词指字母相同,但排列不同的字符串。\n", 771 | "> 不考虑答案输出的顺序。\n", 772 | "> 示例 1:\n", 773 | "> \n", 774 | "> 输入: s: \"cbaebabacd\" p: \"abc\"\n", 775 | "> \n", 776 | "> 输出: [0, 6]\n", 777 | "> 解释:起始索引等于 0 的子串是 \"cba\", 它是 \"abc\" 的字母异位词。起始索引等于 6 的子串是 \"bac\", 它是 \"abc\" 的字母异位词。\n", 778 | "> 示例 2:\n", 779 | "> \n", 780 | "> 输入:s: \"abab\" p: \"ab\"\n", 781 | "> \n", 782 | "> 输出:[0, 1, 2]\n", 783 | "> 解释:起始索引等于 0 的子串是 \"ab\", 它是 \"ab\" 的字母异位词。\n", 784 | "> 起始索引等于 1 的子串是 \"ba\", 它是 \"ab\" 的字母异位词。\n", 785 | "> 起始索引等于 2 的子串是 \"ab\", 它是 \"ab\" 的字母异位词。\n", 786 | "\n", 787 | "\n", 788 | "题目是 medium 类型,但是通过率却很低,大概很多人尝试了一下,然后提交才发现遗漏不少注意点。这道和模式匹配的几乎一样。只是在求解的过程中存储多个解罢了。下面直接给出代码" 789 | ] 790 | }, 791 | { 792 | "cell_type": "code", 793 | "execution_count": 14, 794 | "metadata": {}, 795 | "outputs": [], 796 | "source": [ 797 | "class Solution:\n", 798 | " def findAnagrams(self, s: str, p: str) -> List[int]:\n", 799 | " \n", 800 | " ret = []\n", 801 | " need_dct = {}\n", 802 | " for i in p:\n", 803 | " need_dct[i] = need_dct.get(i, 0) + 1\n", 804 | " \n", 805 | " windows_dct = {}\n", 806 | " lo = hi = 0\n", 807 | " match = 0\n", 808 | " \n", 809 | " while hi < len(s):\n", 810 | " windows_dct[s[hi]] = windows_dct.get(s[hi], 0) + 1\n", 811 | " if windows_dct[s[hi]] == need_dct.get(s[hi], -1):\n", 812 | " match += 1\n", 813 | " hi += 1\n", 814 | " \n", 815 | " while match == len(need_dct):\n", 816 | " if hi - lo == len(p):\n", 817 | " ret.append(lo)\n", 818 | " windows_dct[s[lo]] -= 1\n", 819 | " if windows_dct[s[lo]] < need_dct.get(s[lo], -1):\n", 820 | " match -= 1\n", 821 | " lo += 1\n", 822 | " \n", 823 | " return ret" 824 | ] 825 | }, 826 | { 827 | "cell_type": "markdown", 828 | "metadata": {}, 829 | "source": [ 830 | "\n", 831 | "滑动窗口的解题模板基本使用。老生常谈的两步,第一步右移窗口,更新窗口计数器。第二步 通过窗口计数器缩小窗口,寻找解,更新解,更新窗口计数器。\n", 832 | "\n", 833 | "\n", 834 | "## 字符与单词的滑动\n", 835 | " \n", 836 | "此前的滑动窗口都是字符的滑动,还有一类是单词的滑动。这类题目的难道一下加大了。当然,对于这类问题,单词滑动本身也没有太多可圈可点的技巧。比较麻烦的在单词的起始位置不再是字串的第一个字符了。例如 s[0:len(s)] 可以进行单词滑动,s[1:len(s)] 也可以进行单词滑动。这类题滑动窗口就不算是一个比较好的解法。\n", 837 | "\n", 838 | "[30. 串联所有单词的子串](https://leetcode-cn.com/problems/substring-with-concatenation-of-all-words/)\n", 839 | "\n", 840 | "\n", 841 | "> 注意子串要与 words 中的单词完全匹配,中间不能有其他字符,但不需要考虑 words 中单词串联的顺序。\n", 842 | "> 示例 1:\n", 843 | "> \n", 844 | "> 输入:s = \"barfoothefoobarman\",\n", 845 | "> words = [\"foo\",\"bar\"]\n", 846 | "> 输出:[0,9]\n", 847 | "> 解释:\n", 848 | "> 从索引 0 和 9 开始的子串分别是 \"barfoo\" 和 \"foobar\" 。\n", 849 | "> 输出的顺序不重要, [9,0] 也是有效答案。\n", 850 | "> 示例 2:\n", 851 | "> \n", 852 | "> 输入:\n", 853 | "> s = \"wordgoodgoodgoodbestword\",\n", 854 | "> words = [\"word\",\"good\",\"best\",\"word\"]\n", 855 | "> 输出:[]\n", 856 | "\n", 857 | "题目给出的模式数组,其中元素是一个三个字符的单词,如果问题是 'bftfbm' 中串联 f 和 b,问题就和滑动窗口很似了。如果是 `barfoothefoobarman` 和 `foo bar` ,我们可以把 hi 和 lo 的步长设置为 单词的字符数大小。即一次向右移动三格。\n", 858 | "\n", 859 | "![image.png](https://s2.ax1x.com/2020/01/09/lWzdFf.jpg)" 860 | ] 861 | }, 862 | { 863 | "cell_type": "code", 864 | "execution_count": 15, 865 | "metadata": {}, 866 | "outputs": [], 867 | "source": [ 868 | "# 错误答案\n", 869 | "class Solution:\n", 870 | "\n", 871 | " def findSubstring(self, s: str, words: List[str]) -> List[int]:\n", 872 | " if len(words) == 0 or len(words[0]) == 0:\n", 873 | " return []\n", 874 | "\n", 875 | " need_dct = {}\n", 876 | " for w in words:\n", 877 | " need_dct[w] = need_dct.get(w, 0) + 1\n", 878 | "\n", 879 | " words_count = len(words)\n", 880 | " words_len = len(words[0])\n", 881 | "\n", 882 | " ret = []\n", 883 | " \n", 884 | " lo = hi = 0\n", 885 | " match = 0\n", 886 | " windows_dct = {}\n", 887 | " while hi < len(s):\n", 888 | " windows_dct[s[hi:hi+words_len]] = windows_dct.get(s[hi:hi+words_len], 0) + 1\n", 889 | " if windows_dct[s[hi:hi+words_len]] == need_dct.get(s[hi:hi+words_len], -1):\n", 890 | " match += 1\n", 891 | " hi += words_len\n", 892 | " \n", 893 | " while match == len(need_dct):\n", 894 | " if hi - lo == words_len * words_count:\n", 895 | " ret.append(lo)\n", 896 | " windows_dct[s[lo:lo+words_len]] -= 1\n", 897 | " if windows_dct[s[lo:lo+words_len]] < need_dct.get(s[lo:lo+words_len], -1):\n", 898 | " match -= 1\n", 899 | " lo += words_len\n", 900 | " \n", 901 | " return ret" 902 | ] 903 | }, 904 | { 905 | "cell_type": "markdown", 906 | "metadata": {}, 907 | "source": [ 908 | "很不幸,上面的代码,对于 `barfoothefoobarman` 和 `wordgoodgoodgoodbestword` 都能正确的返回,其实也只是一种巧合罢了。对于 `abarfoothefoobarman` 的输入,则会求解错误。原因也很简单,单词是按照 单词的长度进行跳跃的,对于 后面字串的起始字符是 a,也就是原字符的 起始字符 b 将不会有机会成为起始字符。\n", 909 | "\n", 910 | "解决的思路也很简单,让原始输入的字串的每个字符都成为一次起始字符,再使用上面滑动窗口作为子函数求解即可。\n", 911 | "\n", 912 | "还是不够走运,这样的提交,本身解法没有错,但是运行的时间太长,leetcode会拒绝。怎么办呢?此时的问题恰恰是滑动窗口的问题。每个字符作为一次起始,这个过程看了是无法去除的。剩下可以优化的就是滑动窗口。\n", 913 | "\n", 914 | "由题意已知,起始知道每次需要匹配判断字串长度(words_len * words_count),最外层的循环是可以提前结束的。此外,在滑动窗口内部,一旦不匹配,也是可以提前返回的。因此修改之后的代码如下:" 915 | ] 916 | }, 917 | { 918 | "cell_type": "code", 919 | "execution_count": 16, 920 | "metadata": {}, 921 | "outputs": [], 922 | "source": [ 923 | "class Solution:\n", 924 | "\n", 925 | " def findSubstring(self, s: str, words: List[str]) -> List[int]:\n", 926 | " if len(words) == 0 or len(words[0]) == 0:\n", 927 | " return []\n", 928 | "\n", 929 | " need_dct = {}\n", 930 | " for w in words:\n", 931 | " need_dct[w] = need_dct.get(w, 0) + 1\n", 932 | "\n", 933 | " words_count = len(words)\n", 934 | " words_len = len(words[0])\n", 935 | "\n", 936 | " ret = []\n", 937 | " for i in range((len(s) - words_count * words_len) + 1):\n", 938 | " r = self._find_sub_string(i, s, words_len, words_len * words_count, need_dct)\n", 939 | " if r != -1:\n", 940 | " ret.append(r)\n", 941 | " return ret\n", 942 | "\n", 943 | " def _find_sub_string(self, start, s, words_len, sub_string_len, need_dct):\n", 944 | "\n", 945 | " lo = hi = start\n", 946 | " windows_dct = {}\n", 947 | " match = 0\n", 948 | " while hi < start + sub_string_len:\n", 949 | " if not need_dct.get(s[hi:hi+words_len]):\n", 950 | " return -1\n", 951 | "\n", 952 | " windows_dct[s[hi:hi+words_len]] = windows_dct.get(s[hi:hi+words_len], 0) + 1\n", 953 | " if windows_dct[s[hi:hi+words_len]] > need_dct.get(s[hi:hi+words_len], -1):\n", 954 | " return -1\n", 955 | " hi += words_len\n", 956 | "\n", 957 | " if hi - lo == sub_string_len:\n", 958 | " return lo\n", 959 | " else:\n", 960 | " return -1" 961 | ] 962 | }, 963 | { 964 | "cell_type": "markdown", 965 | "metadata": {}, 966 | "source": [ 967 | "虽然使用了滑动窗口的思路,但是不再是像之前那样亦步亦趋了。这个解法最终可以跑过 leetcode。实际上还是耗时比较长,算不上一个好的解法。\n", 968 | "\n", 969 | "解法的时间复杂度是 O(N * K * Len) ,N 是字串的长度,K 是单词的数目,Len 则是每个单词的长度。由此可见,当 K 和 len 比较小的时候,算法还可以。当 K 和 N 差不多的时候,其复杂度就比较高了。\n", 970 | "\n", 971 | "## 总结\n", 972 | "\n", 973 | "综上所述,滑动窗口套路对于一些求解连续的字串子数组的问题非常有用。滑动窗口的思路就是设置一个再字符串或数组上进行滑动。窗口扩大的时候寻找一个解,窗口缩小的时候寻找最优解。滑动窗口解法的模板就比较死板。先将窗口不断右移,每次移动之后,使用窗口计算器判断解,判断窗口是否可以缩小。然后再循环往复。\n", 974 | "\n", 975 | "这里的关键是如何定义窗口计数器和找出解。或者如何将一个问题抽象成滑动窗口问题。需要不断的进行练习和总结。\n", 976 | "\n", 977 | "另外,在解决一些重复,匹配模式的字串问题,可以借助hash字典记录字符的出现次数。这些hash字典通常也可以成为窗口计数器来求解问题。\n", 978 | "\n", 979 | "## 后记\n", 980 | "\n", 981 | "文中的例题代码,经过了 leetcode 的提交测试并且通过了。为了演示滑动窗口的套路,代码实现尽量写起来像模板一样。实际上,只要思路类似,写法和实现未必需要像模板那么死板。\n", 982 | "\n", 983 | "如很多情况下 for in 迭代在 python中都可以比 while 循环更优雅(至少不会因忘记写 hi += 1这样的条件造成死循环)。总而言之,熟练掌握滑动窗口的思想,可以写出优雅代码,而不是死套模板。\n", 984 | "\n", 985 | "> [643. 子数组最大平均数 I](https://leetcode-cn.com/problems/maximum-average-subarray-i/)\n", 986 | ">\n", 987 | "> [209. 长度最小的子数组](https://leetcode-cn.com/problems/minimum-size-subarray-sum/)\n", 988 | ">\n", 989 | "> [904. 水果成篮](https://leetcode-cn.com/problems/fruit-into-baskets/)\n", 990 | ">\n", 991 | "> [1004. 最大连续1的个数 III](https://leetcode-cn.com/problems/max-consecutive-ones-iii/)\n", 992 | ">\n", 993 | "> [424. 替换后的最长重复字符](https://leetcode-cn.com/problems/longest-repeating-character-replacement/)\n", 994 | ">\n", 995 | "> [3. 无重复字符的最长子串](https://leetcode-cn.com/problems/longest-substring-without-repeating-characters/)\n", 996 | ">\n", 997 | "> [567. 字符串的排列](https://leetcode-cn.com/problems/permutation-in-string/)\n", 998 | ">\n", 999 | "> [76. 最小覆盖子串](https://leetcode-cn.com/problems/minimum-window-substring/)\n", 1000 | ">\n", 1001 | "> [438. 找到字符串中所有字母异位词](https://leetcode-cn.com/problems/find-all-anagrams-in-a-string/)\n", 1002 | ">\n", 1003 | "> [30. 串联所有单词的子串](https://leetcode-cn.com/problems/substring-with-concatenation-of-all-words/)\n", 1004 | ">\n", 1005 | "> 来源:力扣(LeetCode) [https://leetcode-cn.com/problemset/all/](https://leetcode-cn.com/problemset/all/)" 1006 | ] 1007 | } 1008 | ], 1009 | "metadata": { 1010 | "kernelspec": { 1011 | "display_name": "Python 3", 1012 | "language": "python", 1013 | "name": "python3" 1014 | }, 1015 | "language_info": { 1016 | "codemirror_mode": { 1017 | "name": "ipython", 1018 | "version": 3 1019 | }, 1020 | "file_extension": ".py", 1021 | "mimetype": "text/x-python", 1022 | "name": "python", 1023 | "nbconvert_exporter": "python", 1024 | "pygments_lexer": "ipython3", 1025 | "version": "3.7.3" 1026 | } 1027 | }, 1028 | "nbformat": 4, 1029 | "nbformat_minor": 2 1030 | } 1031 | -------------------------------------------------------------------------------- /groking-leetcode/2-two-pointers.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "# 对撞指针\n", 8 | "\n", 9 | "双指针(two pointer)是 Leetcode 中重要的解题技巧,[滑动窗口](https://www.jianshu.com/p/485ce4e0f8a5)也是一种双指针技巧。窗口的滑动是 lo 和 hi 两个指针分别向右滑动。此外,两个指针如果分别从 低位 和 高位相向滑动,那么就是对撞指针。\n", 10 | "\n", 11 | "对撞指针的用法很多种,对于两边夹,单调性的数组(已排序的数组),以及 partition 区分都非常有用。下面就通过一些简单的题目来介绍对撞指针。\n", 12 | "\n", 13 | "## 两边夹\n", 14 | "\n", 15 | "对撞,顾名思义,就是相向而行,直到碰撞。leetcode的 [125 验证回文串](https://leetcode-cn.com/problems/valid-palindrome/) 题要求是验证回文串。\n", 16 | "\n", 17 | "> 给定一个字符串,验证它是否是回文串,只考虑字母和数字字符,可以忽略字母的大小写。\n", 18 | "> \n", 19 | "> 说明:本题中,我们将空字符串定义为有效的回文串。\n", 20 | "> \n", 21 | "> 示例 1:\n", 22 | "> \n", 23 | "> 输入: \"A man, a plan, a canal: Panama\"\n", 24 | "> \n", 25 | "> 输出: true\n", 26 | "> \n", 27 | "> 示例 2:\n", 28 | "> \n", 29 | "> 输入: \"race a car\"\n", 30 | "> \n", 31 | "> 输出: false\n", 32 | "\n", 33 | "所谓回文串就是从左到右扫描的字符,和从右到左扫描的字符一模一样。回文在诗歌中应用也颇为广泛,颇有炫才之技。著名的[璇玑图](https://zh.wikipedia.org/wiki/%E7%92%87%E7%8E%91%E5%9B%BE) 里的回文就很妙。\n", 34 | "\n", 35 | "回到题目本身,既然回文是为了验证从左往右和从右往左都必须一样。那么就可以设置两个指针,一个分别从左往右扫描,另外一个从右往左扫描。扫描的过程中,比对指针所对应的字符。\n", 36 | "\n", 37 | "例如下图 分别从左往右和从右往左\n", 38 | "\n", 39 | "![](https://s2.ax1x.com/2020/01/09/lfP5LQ.png)\n" 40 | ] 41 | }, 42 | { 43 | "cell_type": "code", 44 | "execution_count": 1, 45 | "metadata": {}, 46 | "outputs": [], 47 | "source": [ 48 | "class Solution:\n", 49 | " def isPalindrome(self, s: str) -> bool:\n", 50 | " l = 0\n", 51 | " r = len(s) - 1\n", 52 | " while l < len(s):\n", 53 | " if s[l] != s[r]:\n", 54 | " return False\n", 55 | " l += 1\n", 56 | " r -= 1\n", 57 | " return True" 58 | ] 59 | }, 60 | { 61 | "cell_type": "markdown", 62 | "metadata": {}, 63 | "source": [ 64 | "实际上,对于回文串,它们本身是对称的。因此在比对的时候,不需要完全从左到右。而是从左到右和从右到左一起移动,一旦相撞,则停止。" 65 | ] 66 | }, 67 | { 68 | "cell_type": "code", 69 | "execution_count": 2, 70 | "metadata": {}, 71 | "outputs": [], 72 | "source": [ 73 | "class Solution:\n", 74 | " def isPalindrome(self, s: str) -> bool:\n", 75 | " l = 0\n", 76 | " r = len(s) - 1\n", 77 | " while l < r:\n", 78 | " if s[l] != s[r]:\n", 79 | " return False\n", 80 | " l += 1\n", 81 | " r -= 1\n", 82 | " return True" 83 | ] 84 | }, 85 | { 86 | "cell_type": "markdown", 87 | "metadata": {}, 88 | "source": [ 89 | "\n", 90 | "代码几乎没有改动,只改变了循环条件。当 `l < r` 的时候,说明中间只剩一个字符元素。左右两边完全对称,符合回文的定义。`l <= r` 的时候,表示两个指针重叠,此时字符相对自身也是属于回文,也可以这样写,只是这题没有必要。\n", 91 | "\n", 92 | "在滑动窗口中,通常设置窗口的大小为 [lo, hi) 左闭右开的区间,为了方便计算窗口的大小 hi - lo,而在对撞指针中,两个指针的元素都需要使用,即都是闭合的,可以设置为 [l, r] 的区间内向中间靠拢。\n", 93 | "\n", 94 | "对于两边夹的对撞指针,核心就是在处理两边指针扫描元素的时候进行逻辑判断,以便移动指针。当前题目的指针是同步移动,类似的还有下面一题。\n", 95 | "\n", 96 | "[344 翻转字符串](https://leetcode-cn.com/problems/reverse-string/)\n", 97 | "\n", 98 | "> 编写一个函数,其作用是将输入的字符串反转过来。输入字符串以字符数组 char[] 的形式给出。\n", 99 | "> \n", 100 | "> 不要给另外的数组分配额外的空间,你必须原地修改输入数组、使用 O(1) 的额外空间解决这一问题。\n", 101 | "> \n", 102 | "> 你可以假设数组中的所有字符都是 ASCII 码表中的可打印字符。\n", 103 | "> \n", 104 | "> 示例 1:\n", 105 | "> \n", 106 | "> 输入:[\"h\",\"e\",\"l\",\"l\",\"o\"]\n", 107 | "> \n", 108 | "> 输出:[\"o\",\"l\",\"l\",\"e\",\"h\"]\n", 109 | "> \n", 110 | "> 示例 2:\n", 111 | "> \n", 112 | "> 输入:[\"H\",\"a\",\"n\",\"n\",\"a\",\"h\"]\n", 113 | "> \n", 114 | "> 输出:[\"h\",\"a\",\"n\",\"n\",\"a\",\"H\"]\n", 115 | "\n", 116 | "解题思路类似,初始化连个指针 l 和 r,分别指向数组的第一个元素和最后一个元素,然后同步向中间靠拢,靠拢的过程中交互两个指针的元素。" 117 | ] 118 | }, 119 | { 120 | "cell_type": "code", 121 | "execution_count": 4, 122 | "metadata": {}, 123 | "outputs": [], 124 | "source": [ 125 | "from typing import *\n", 126 | "\n", 127 | "class Solution:\n", 128 | " def reverseString(self, s: List[str]) -> None:\n", 129 | " l = 0\n", 130 | " r = len(s) - 1\n", 131 | " while l < r:\n", 132 | " s[l], s[r] = s[r], s[l]\n", 133 | " l += 1\n", 134 | " r -= 1" 135 | ] 136 | }, 137 | { 138 | "cell_type": "markdown", 139 | "metadata": {}, 140 | "source": [ 141 | "从上面两题可以初步的感受对撞指针的套路。即左右指针相向移动,上面的两题是同步移动的。还有其他的题目,指针的移动未必是同步的,移动前提需要判断一些条件。当条件满足之后适当的移动左边或者右边,最终相撞求解返回。例如\n", 142 | "\n", 143 | "[11.盛水最多的容器](https://leetcode-cn.com/problems/container-with-most-water/)\n", 144 | "\n", 145 | "> 给定 n 个非负整数 a1,a2,...,an,每个数代表坐标中的一个点 (i, ai) 。在坐标内画 n 条垂直线,垂直线 i 的> 两个端点分别为 (i, ai) 和 (i, 0)。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。\n", 146 | "> \n", 147 | "> 说明:你不能倾斜容器,且 n 的值至少为 2。\n", 148 | "> \n", 149 | "![](https://s2.ax1x.com/2020/01/09/lfPbiq.png)\n", 150 | "\n", 151 | "> 示例:\n", 152 | "> \n", 153 | "> 输入: [1,8,6,2,5,4,8,3,7]\n", 154 | ">\n", 155 | "> 输出: 49\n", 156 | "\n", 157 | "由题意可知,盛水的容量来与数组两个数字之间有关,其中数字越小(高度越短)决定了最终的盛水量,类似木桶理论。每两个数字可以组成一个容器,一个数字则无法盛水。与滑动窗口类似,可以找出任意两个数字组成的一个窗口,求解其值存储。当所有窗口都计算完毕之后返回最大的值即可。\n", 158 | "\n", 159 | "与滑动窗口又不一样的地方在于,窗口不是从左往右滑动。而是使用了对撞指针相向缩小。初始化的窗口为数组的最大宽度,然后左右指针相向移动。\n", 160 | "\n", 161 | "什么时候移动呢?由 [l, r] 组成的窗口,其盛水量为 `min(l, r) * (r - l)`。由此可见,r-l 只能更小,想要这个表达式更大,只能找到更高的数。因此想要盛更多的水,只有把最矮的设法变高,即高度更矮的指针进行移动。如果移动更高的指针,那么 r - l 距离变小了,同时 `min(l, r)` 也不会更大,只会更小。最终代码如下:" 162 | ] 163 | }, 164 | { 165 | "cell_type": "code", 166 | "execution_count": 5, 167 | "metadata": {}, 168 | "outputs": [], 169 | "source": [ 170 | "class Solution:\n", 171 | " def maxArea(self, height: List[int]) -> int:\n", 172 | " l = 0\n", 173 | " r = len(height) - 1\n", 174 | " max_area = float('-inf')\n", 175 | " while l < r:\n", 176 | " min_height = min(height[l], height[r])\n", 177 | " max_area = max(min_height * (r - l), max_area)\n", 178 | " if height[l] < height[r]:\n", 179 | " l += 1\n", 180 | " else:\n", 181 | " r -= 1\n", 182 | " return max_area if max_area != float('-inf') else 0 " 183 | ] 184 | }, 185 | { 186 | "cell_type": "markdown", 187 | "metadata": {}, 188 | "source": [ 189 | "从上面的例子可以看出,对撞指针的一般模板是设置 `[l, r]`, 然后再 `l < r` 的条件中缩小窗口,直到指针相撞退出。左右指针向中间靠拢,最多移动单位不过为数组的长度,即时间复杂度为 O(n)。\n", 190 | "\n", 191 | "与 上面一题类似还有一道接雨水的问题。\n", 192 | "\n", 193 | "[42.接雨水](https://leetcode-cn.com/problems/trapping-rain-water/)\n", 194 | "\n", 195 | "> 给定 n 个非负整数表示每个宽度为 1 的柱子的高度图,计算按此排列的柱子,下雨之后能接多少雨水。\n", 196 | "> \n", 197 | "> ![图](https://s2.ax1x.com/2020/01/09/lfPjQU.jpg)\n", 198 | "\n", 199 | ">上面是由数组 [0,1,0,2,1,0,1,3,2,1,2,1] 表示的高度图,在这种情况下,可以接 6 个单位的雨水(蓝色部分表示雨水)。 感谢 Marcos 贡献此图。\n", 200 | "> \n", 201 | "> 示例:\n", 202 | "> \n", 203 | "> 输入: [0,1,0,2,1,0,1,3,2,1,2,1]\n", 204 | "> \n", 205 | "> 输出: 6\n", 206 | "\n", 207 | "这一题与上一题的要求不一样,每个数组的元素自身的高度对于盛水量有关系。上一题是两个元素之间的,元素本身没有关系。这一题恰好相反。当前元素所能盛水的大小,取决于其左边最高的元素和其右边最高元素中比较矮的那个,同时需要减掉自身的高度。如下图\n", 208 | "\n", 209 | " ![图](https://s2.ax1x.com/2020/01/09/lfiSeJ.jpg)\n", 210 | "\n", 211 | "\n", 212 | "因此假设有一个指针重做往右进行移动,那么它当前接水的容量来自它左边最高和它右边最高的数。如何求出左边和右边的最高数呢?\n", 213 | "\n", 214 | "一个简单办法就是使用对撞指针,l 和 r 分别向中间靠拢,移动的过程中可以找出 [0, l] 中间的最高 l_max 和 [r, len]中间的最高 r_max,而当前的指针可以取 min(l_max, r_max) 中的部分。如下图\n", 215 | "\n", 216 | "![图](https://s2.ax1x.com/2020/01/09/lfi9oR.jpg)\n", 217 | "\n", 218 | "当 l , r 分别在上图的过程中,l 所能盛水的容量是 ` height[l_max] - height[l]`,此时是0,然后 l 向右移动。\n", 219 | "此时 l 能盛水 依然是 ` height[l_max] - height[l]` 则是一个单位。依次类推,只要 l_max < r_max ,那么就移动 l 指针,直到 l 等于 r。如果 l_max 小于 r_max,也就是上图的对称过程,就不再赘述。具体可以看代码:" 220 | ] 221 | }, 222 | { 223 | "cell_type": "code", 224 | "execution_count": 8, 225 | "metadata": {}, 226 | "outputs": [], 227 | "source": [ 228 | "class Solution:\n", 229 | " def trap(self, height: List[int]) -> int:\n", 230 | " if len(height) <= 1:\n", 231 | " return 0\n", 232 | " l = 0\n", 233 | " r = len(height) - 1\n", 234 | " l_max = height[l]\n", 235 | " r_max = height[r]\n", 236 | "\n", 237 | " ret = 0\n", 238 | " while l <= r:\n", 239 | " l_max = max(l_max, height[l]) # 找出左边最大的柱子\n", 240 | " r_max = max(r_max, height[r]) # 找出右边最大的柱子\n", 241 | " \n", 242 | " if l_max < r_max: \n", 243 | " ret += l_max - height[l] # 移动左边\n", 244 | " l += 1\n", 245 | " else:\n", 246 | " ret += r_max - height[r]\n", 247 | " r -= 1\n", 248 | " return ret" 249 | ] 250 | }, 251 | { 252 | "cell_type": "markdown", 253 | "metadata": {}, 254 | "source": [ 255 | "上面这题对于对撞指针需要一定的理解,万变不离其宗。找到最初窗口缩小的条件,然后逐步调整缩小窗口,缩小窗口来自左右两边的指针向中间靠拢。其时间复杂度为O(n),只需要线性时间即可。\n", 256 | "\n", 257 | "## twoSum\n", 258 | "\n", 259 | "对撞指针还有一个应用场景就是针对已经排序的数组进行碰撞。leetcode的第一题[1.两数之和](https://leetcode-cn.com/problems/two-sum/)特别经典。使用 哈希字典能很快解题。其中第[167. 两数之和 II - 输入有序数组](https://leetcode-cn.com/problems/two-sum-ii-input-array-is-sorted/) 题,将输入的数组排定为有序数组。\n", 260 | "\n", 261 | "> 给定一个已按照升序排列 的有序数组,找到两个数使得它们相加之和等于目标数。\n", 262 | "> \n", 263 | "> 函数应该返回这两个下标值 index1 和 index2,其中 index1 必须小于 index2。\n", 264 | "> \n", 265 | "> 说明:\n", 266 | "> \n", 267 | "> 返回的下标值(index1 和 index2)不是从零开始的。\n", 268 | "> \n", 269 | "> 你可以假设每个输入只对应唯一的答案,而且你不可以重复使用相同的元素。\n", 270 | "> \n", 271 | "> 示例:\n", 272 | "> \n", 273 | "> 输入: numbers = [2, 7, 11, 15], target = 9\n", 274 | "> \n", 275 | "> 输出: [1,2]\n", 276 | "> \n", 277 | "> 解释: 2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。\n", 278 | "\n", 279 | "有序的数组就比好办了,对于两个数之和,如何大于target,那么就需要减少两数之和,自然就是 r 指针向左移动,如果小于target元素,那么就需要增加两数之和,自然就是移动左边的r指针。\n", 280 | "\n", 281 | "![图](https://s2.ax1x.com/2020/01/09/lfiASK.jpg)\n", 282 | "\n", 283 | "\n", 284 | "代码如下:\n" 285 | ] 286 | }, 287 | { 288 | "cell_type": "code", 289 | "execution_count": 9, 290 | "metadata": {}, 291 | "outputs": [], 292 | "source": [ 293 | "class Solution:\n", 294 | " def twoSum(self, numbers: List[int], target: int) -> List[int]:\n", 295 | " if len(numbers) < 2:\n", 296 | " return [-1, -1]\n", 297 | " l = 0\n", 298 | " r = len(numbers) - 1\n", 299 | " while l < r: \n", 300 | " if numbers[l] + numbers[r] < target:\n", 301 | " l += 1\n", 302 | " elif target < numbers[l] + numbers[r]:\n", 303 | " r -= 1\n", 304 | " else:\n", 305 | " return [l+1, r+1]\n", 306 | " return [-1, -1]" 307 | ] 308 | }, 309 | { 310 | "cell_type": "markdown", 311 | "metadata": {}, 312 | "source": [ 313 | "与两数之和类似的自然就是三数之和,三数之和的注意点就是需要先对数组进行排序,然后可以对数组进行迭代。迭代过程中,就以当前元素i 到末尾的数组为子数组 [i, len],然后转变为两数之和进行处理,即寻找 子数组的 pair 对。\n", 314 | "\n", 315 | "[15.三数之和](https://leetcode-cn.com/problems/3sum/)\n", 316 | "\n", 317 | "> 给定一个包含 n 个整数的数组 nums,判断 nums 中是否存在三个元素 a,b,c ,使得 a + b + c = 0 ?找出所有满足条件且不重复的三元组。\n", 318 | "> \n", 319 | "> 注意:答案中不可以包含重复的三元组。\n", 320 | "> \n", 321 | "> 例如, 给定数组 nums = [-1, 0, 1, 2, -1, -4],\n", 322 | "> \n", 323 | "> 满足要求的三元组集合为:\n", 324 | "> \n", 325 | "> [[-1, 0, 1], [-1, -1, 2]]\n", 326 | "\n", 327 | "由于题目不能有重复的数组,因此对于连续的两个相同的数字,其子数组是一样的,需要先去重。同时一旦找到了两个合法的数字,l 和 r尚 不能停止,因为 [l, r] 之间可能有多个符合题意的pair对,同时在寻找剩余 pair对的时候也需要注意去重。" 328 | ] 329 | }, 330 | { 331 | "cell_type": "code", 332 | "execution_count": 10, 333 | "metadata": {}, 334 | "outputs": [], 335 | "source": [ 336 | "class Solution:\n", 337 | " def threeSum(self, nums: List[int]) -> List[List[int]]:\n", 338 | " nums.sort()\n", 339 | " ret = []\n", 340 | " for i in range(len(nums)-2):\n", 341 | " if i > 0 and nums[i-1] == nums[i]:\n", 342 | " continue\n", 343 | " \n", 344 | " l = i + 1\n", 345 | " r = len(nums) - 1\n", 346 | " \n", 347 | " while l < r:\n", 348 | " if nums[i] + nums[l] + nums[r] < 0:\n", 349 | " l += 1\n", 350 | " elif 0 < nums[i] + nums[l] + nums[r]:\n", 351 | " r -= 1\n", 352 | " else:\n", 353 | " ret.append([nums[i], nums[l], nums[r]])\n", 354 | " l += 1\n", 355 | " r -= 1\n", 356 | " \n", 357 | " while l < r and nums[l-1] == nums[l]:\n", 358 | " l += 1\n", 359 | "\n", 360 | " while l < r and nums[r] == nums[r+1]:\n", 361 | " r -= 1\n", 362 | " continue\n", 363 | " return ret" 364 | ] 365 | }, 366 | { 367 | "cell_type": "markdown", 368 | "metadata": {}, 369 | "source": [ 370 | "三数之和的变种有一题为 [16.最接近的三数之和](https://leetcode-cn.com/problems/3sum-closest/)。初看会觉得比三数之和复杂,其实更简单。所谓最近,即 pair对的和 与 target 的差的绝对值就是距离。初始化一个距离,当距离更短的时候更新距离,同时缩小对撞指针。题目如下\n", 371 | "\n", 372 | "> 给定一个包括 n 个整数的数组 nums 和 一个目标值 target。找出 nums 中的三个整数,使得它们的和与 target 最接近。返回这三个数的和。假定每组输入只存在唯一答案。\n", 373 | "> \n", 374 | "> 例如,给定数组 nums = [-1,2,1,-4], 和 target = 1.\n", 375 | "> \n", 376 | "> 与 target 最接近的三个数的和为 2. (-1 + 2 + 1 = 2).\n", 377 | "\n", 378 | "代码如下" 379 | ] 380 | }, 381 | { 382 | "cell_type": "code", 383 | "execution_count": 12, 384 | "metadata": {}, 385 | "outputs": [], 386 | "source": [ 387 | "class Solution:\n", 388 | " def threeSumClosest(self, nums: List[int], target: int) -> int:\n", 389 | " if len(nums) < 3:\n", 390 | " return None\n", 391 | " \n", 392 | " nums.sort()\n", 393 | " min_distance = float('inf')\n", 394 | " ret = None\n", 395 | " for i in range(len(nums) - 2):\n", 396 | " l = i + 1\n", 397 | " r = len(nums) - 1\n", 398 | " \n", 399 | " while l < r:\n", 400 | " three_sum = nums[i] + nums[l] + nums[r]\n", 401 | " distance = three_sum - target\n", 402 | " \n", 403 | " if abs(distance) < min_distance: # 如果出现更短的距离,即更接近target,则更新解\n", 404 | " min_distance = abs(distance)\n", 405 | " ret = three_sum\n", 406 | " \n", 407 | " if 0 < distance: # 距离大于 0,即表示和更大,可以减小和,即 r 指针左移\n", 408 | " r -= 1\n", 409 | " elif distance < 0 : # 距离小于 0,即表示和更小,可以增加和,即 l 指针右移\n", 410 | " l += 1\n", 411 | " else:\n", 412 | " return three_sum\n", 413 | " return ret" 414 | ] 415 | }, 416 | { 417 | "cell_type": "markdown", 418 | "metadata": {}, 419 | "source": [ 420 | "移动指针的技巧很简单,两数之和与target的距离有大有小。如果这个值大于 0,那么可以减少 两数之和,即r指针向左移动,反之则表示这个和可以增大,即 l 指针右移。正好等于0,由于题目保证了解唯一,那么就可以直接返回了。\n", 421 | "\n", 422 | "此类两数,三数甚至[四数之和](https://leetcode-cn.com/problems/4sum/),都是对撞指针对单调数组的一种处理方式。通过对数组的排序,可以明确的知道 l 和 r 指针的 和 或者 差 应该扩大还是缩小。\n", 423 | "\n", 424 | "\n", 425 | "## Partition\n", 426 | "\n", 427 | "对于已排序的数组可以使用对撞指针,而对撞指针本身也是处理排序的一种重要技巧。快速排序的思想就是选取一个 partition 元素,分别将比其小和比其大的元素区分出来。其中使用对撞指针很容易实现类似的功能。\n", 428 | "\n", 429 | "leetcode 上 题,就是应用对撞指针求解 partition 问题。[75. 颜色分类](https://leetcode-cn.com/problems/sort-colors/)\n", 430 | "\n", 431 | "> 给定一个包含红色、白色和蓝色,一共 n 个元素的数组,原地对它们进行排序,使得相同颜色的元素相邻,并按照红色、白色、蓝色顺序排列。\n", 432 | "> \n", 433 | "> \n", 434 | "> 此题中,我们使用整数 0、 1 和 2 分别表示红色、白色和蓝色。\n", 435 | "> \n", 436 | "> 示例:\n", 437 | "> \n", 438 | "> 输入: [2,0,2,1,1,0]\n", 439 | "> \n", 440 | "> 输出: [0,0,1,1,2,2]\n", 441 | "\n", 442 | "这道题也称之为[荷兰旗帜问题](https://en.wikipedia.org/wiki/Dutch_national_flag_problem),即 三个数字分别表示荷兰国旗🇳🇱三种颜色。之所以是荷兰,最早提出这个问题的就是大名鼎鼎的荷兰计算机科学家 Edsger Dijkstra 提出的.\n", 443 | "\n", 444 | "![图](https://s2.ax1x.com/2020/01/09/lfiEQO.jpg)\n", 445 | "\n", 446 | "\n", 447 | "如上图所示,分别初始化 l 指针和 r 指针。l 所在的部分都是 0, r 所在的部分都是 2,中间的部分是 1,还有 i 处于的部分。\n", 448 | "\n", 449 | "考察 i 这个元素,如果是 1,就等于直接扩展 1 的部分,i ++ 即可。\n", 450 | "\n", 451 | "如果 i 是 0,那么就和 l + 1 的元素进行交换,由于 l + 1 是1,交换之后,1 的部分自然还是合法,因此只需要 i ++ 即可。当然,l 也向右移动一个单位\n", 452 | "\n", 453 | "如果 i 是 2, 那么就需要和 r - 1 的元素进行交换,可是 r - 1 交换后,并不知道它是谁,因此 i 不能 ++ ,需要再对当前的 i 重复上述的逻辑。最终代码如下" 454 | ] 455 | }, 456 | { 457 | "cell_type": "code", 458 | "execution_count": 16, 459 | "metadata": {}, 460 | "outputs": [], 461 | "source": [ 462 | "class Solution:\n", 463 | " def sortColors(self, nums: List[int]) -> None:\n", 464 | " l = -1\n", 465 | " r = len(nums)\n", 466 | " i = 0\n", 467 | " while i < r:\n", 468 | " if nums[i] == 0:\n", 469 | " nums[l+1], nums[i] = nums[i], nums[l+1] # 从左往右扫描,因为交换后的 元素 l+1 已经扫描了,因此需要 i += 1\n", 470 | " l += 1\n", 471 | " i += 1\n", 472 | " elif nums[i] == 1:\n", 473 | " i += 1\n", 474 | " else:\n", 475 | " nums[r-1], nums[i] = nums[i], nums[r-1] # 交换后的元素还没不知道是多少,需要针对这个进行处理\n", 476 | " r -= 1" 477 | ] 478 | }, 479 | { 480 | "cell_type": "markdown", 481 | "metadata": {}, 482 | "source": [ 483 | "通过上图可知,初始化的 l 为 -1,r 为数组长度,即此时0的部分长度为0,2的部分长度也为0。然后逐步扩展指针。最终碰撞的是 i 和 r。由于 r 表示是 2 的部分,i 必须小于 r即可进行处理。同时可以很清楚的了解为什么 r -- 的时候,i 不需要 -- 。\n", 484 | "\n", 485 | "## 滑动窗口\n", 486 | "\n", 487 | "对撞指针和滑动窗口都是双指针的一种,回忆滑动窗口可以知道。往往滑动窗口的过程有多个解,最终问题是需要求最有解。如果需要枚举所有的解,那么使用滑动窗口需要特别注意,如滑动窗口的重复解。请看下面一题:\n", 488 | "\n", 489 | "[713.乘积之和小于k的子数组](https://leetcode-cn.com/problems/subarray-product-less-than-k/)\n", 490 | "\n", 491 | "> 给定一个正整数数组 nums。\n", 492 | "> \n", 493 | "> 找出该数组内乘积小于 k 的连续的子数组的个数。\n", 494 | "> \n", 495 | "> 示例 1:\n", 496 | "> \n", 497 | "> 输入: nums = [10,5,2,6], k = 100\n", 498 | "> \n", 499 | "> 输出: 8\n", 500 | "> \n", 501 | "> 解释: 8个乘积小于100的子数组分别为: [10], [5], [2], [6], [10,5], [5,2], [2,6], [5,2,6]。\n", 502 | "> \n", 503 | "> 需要注意的是 [10,5,2] 并不是乘积小于100的子数组。\n", 504 | "> \n", 505 | "> 说明:\n", 506 | "> \n", 507 | "> 0 < nums.length <= 50000\n", 508 | "> \n", 509 | "> 0 < nums[i] < 1000\n", 510 | "> \n", 511 | "> 0 <= k < 10^6\n", 512 | "\n", 513 | "从题意可知,特别像滑动窗口的问题。通常滑动窗口会问符合要求的最小连续子数组。如果使用滑动窗口过程中。如果使用直接使用滑动窗口,会发现遗漏一些解。如果针对每个元素都使用一次滑动窗口,复杂度暂且不说,重复的解也会出现。\n", 514 | "\n", 515 | "针对这题,既要配合滑动窗口,,也要合理的使用对撞指针。使用滑动窗口确定一个可能解,由于乘积最小,那么窗口最右边的元素必须包含,那么再求解这个窗口包含最有元素的子数组,就是窗口内的所有解。具体代码如下:" 516 | ] 517 | }, 518 | { 519 | "cell_type": "code", 520 | "execution_count": 17, 521 | "metadata": {}, 522 | "outputs": [], 523 | "source": [ 524 | "class Solution:\n", 525 | " def numSubarrayProductLessThanK(self, nums: List[int], k: int) -> int:\n", 526 | " ret = 0\n", 527 | " lo = hi = 0\n", 528 | " product = 1\n", 529 | " while hi < len(nums):\n", 530 | " product *= nums[hi]\n", 531 | " hi += 1\n", 532 | " while product >= k and lo < hi:\n", 533 | " product /= nums[lo]\n", 534 | " lo += 1\n", 535 | " ret += hi - lo\n", 536 | " return ret" 537 | ] 538 | }, 539 | { 540 | "cell_type": "markdown", 541 | "metadata": {}, 542 | "source": [ 543 | "## 总结\n", 544 | "\n", 545 | "双指针是一个非常重要的技巧,指针同方向推进可以抽象出滑动窗口,指针相向靠拢则称之为对撞指针。对撞指针一般应用需要首尾元素一起比对的题目,两个指针同步靠近。也适合对已排序的数组求解任意2个或3个元素组成的解,此时就需要判断指针在什么条件下移动,左右指针如何靠拢。当然,还有一个经典的用法就是在快速排序中,用来寻找 Partition 元素。除此之外,双指针还有一类[快慢指针]()的技巧。\n", 546 | "\n", 547 | "\n", 548 | "文中涉及的leetcode\n", 549 | "\n", 550 | "> [125 验证回文串](https://leetcode-cn.com/problems/valid-palindrome/) \n", 551 | "> \n", 552 | "> [344 翻转字符串](https://leetcode-cn.com/problems/reverse-string/)\n", 553 | "> \n", 554 | "> [11.盛水最多的容器](https://leetcode-cn.com/problems/container-with-most-water/)\n", 555 | "> \n", 556 | "> [42.接雨水](https://leetcode-cn.com/problems/trapping-rain-water/)\n", 557 | "> \n", 558 | "> [1.两数之和](https://leetcode-cn.com/problems/two-sum/)\n", 559 | "> \n", 560 | "> [167. 两数之和 II - 输入有序数组](https://leetcode-cn.com/problems/two-sum-ii-input-array-is-sorted/)\n", 561 | "> \n", 562 | "> [15.三数之和](https://leetcode-cn.com/problems/3sum/)\n", 563 | "> \n", 564 | "> [16.最接近的三数之和](https://leetcode-cn.com/problems/3sum-closest/)\n", 565 | "> \n", 566 | "> [75. 颜色分类](https://leetcode-cn.com/problems/sort-colors/)\n", 567 | "> \n", 568 | "> [713.乘积之和小于k的子数组](https://leetcode-cn.com/problems/subarray-product-less-than-k/)\n", 569 | ">\n", 570 | "> 来源:力扣(LeetCode) [https://leetcode-cn.com/problemset/all/](https://leetcode-cn.com/problemset/all/)" 571 | ] 572 | }, 573 | { 574 | "cell_type": "code", 575 | "execution_count": null, 576 | "metadata": {}, 577 | "outputs": [], 578 | "source": [] 579 | } 580 | ], 581 | "metadata": { 582 | "kernelspec": { 583 | "display_name": "Python 3", 584 | "language": "python", 585 | "name": "python3" 586 | }, 587 | "language_info": { 588 | "codemirror_mode": { 589 | "name": "ipython", 590 | "version": 3 591 | }, 592 | "file_extension": ".py", 593 | "mimetype": "text/x-python", 594 | "name": "python", 595 | "nbconvert_exporter": "python", 596 | "pygments_lexer": "ipython3", 597 | "version": "3.7.3" 598 | } 599 | }, 600 | "nbformat": 4, 601 | "nbformat_minor": 2 602 | } 603 | -------------------------------------------------------------------------------- /groking-leetcode/3-fast-slow-pointer.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "## 龟兔赛跑\n", 8 | "\n", 9 | "相信很多人小时候听得最多的两个故事,莫过于高斯和龟兔赛跑了。高斯的故事让小朋友知道了学好数学的重要性,龟兔赛跑则无时无刻提醒小朋友们不要像兔子骄傲懈怠,同时也要像乌龟一样坚持不懈。\n", 10 | "\n", 11 | "是的,学习算法技巧亦是。需要不断地练习,总结,持之以恒。然而,龟兔赛跑也是一个重要的算法技巧。也称之为 [Floyd判圈算法](https://zh.wikipedia.org/wiki/Floyd%E5%88%A4%E5%9C%88%E7%AE%97%E6%B3%95)。其主要目的是判断有限状态机、迭代函数或者链表是否有环。采用的技巧即使快慢指针,一个快,一个慢,正像兔子和乌龟一样,如果存在环,那么最终慢指针会遇到快指针。\n", 12 | "\n", 13 | "证明就不具体描述,在头脑试想一下。假设一个跑道(链表)有环,那么无论兔子还是乌龟,总有一天它们都得进入跑道。并且进去之后就再也无法出来。那么一个快一个慢,快的就会逐渐超过慢的,最终会相遇。如果没有环,兔子又不懈怠,无论如何,慢的追不上快的,快的也不会遇见慢的。\n", 14 | "\n", 15 | "## 快慢指针\n", 16 | "\n", 17 | "leetcode [141. 环形链表](https://leetcode-cn.com/problems/linked-list-cycle/)即可以使用快慢指针解决。\n", 18 | "\n", 19 | "> 给定一个链表,判断链表中是否有环。\n", 20 | "> \n", 21 | "> 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。\n", 22 | ">\n", 23 | "> 示例 1:\n", 24 | "> \n", 25 | "> 输入:head = [3,2,0,-4], pos = 1\n", 26 | "> \n", 27 | "> 输出:true\n", 28 | "> \n", 29 | "> 解释:链表中有一个环,其尾部连接到第二个节点。\n", 30 | ">\n", 31 | "> ![图](https://assets.leetcode-cn.com/aliyun-lc-upload/uploads/2018/12/07/circularlinkedlist.png)\n", 32 | ">\n", 33 | "\n", 34 | "从题意可知,需要对输入的链表判断其是否成环。初始化一个慢指针 slow,再初始化一个快指针 fast,fast 每次移动的速度比 slow 快一倍。如果快慢指针相遇,则判断有环。如果没有环,快慢指针不会相遇,并且快指针最终会走完链表。具体代码如下:\n" 35 | ] 36 | }, 37 | { 38 | "cell_type": "code", 39 | "execution_count": 1, 40 | "metadata": {}, 41 | "outputs": [], 42 | "source": [ 43 | "class ListNode:\n", 44 | " def __init__(self, x):\n", 45 | " self.val = x\n", 46 | " self.next = None\n", 47 | "\n", 48 | "class Solution:\n", 49 | " def hasCycle(self, head: ListNode) -> bool:\n", 50 | " if not head:\n", 51 | " return False\n", 52 | " \n", 53 | " slow = head\n", 54 | " fast = head\n", 55 | " \n", 56 | " while fast is not None and fast.next is not None:\n", 57 | " slow = slow.next\n", 58 | " fast = fast.next.next\n", 59 | " \n", 60 | " if slow == fast:\n", 61 | " return True\n", 62 | " return False\n", 63 | " " 64 | ] 65 | }, 66 | { 67 | "cell_type": "markdown", 68 | "metadata": {}, 69 | "source": [ 70 | "fast 指针每次比 slow 多快一步。如果链表有环,那么 slow 和 fast 最终都会进入到环。\n", 71 | "\n", 72 | "假设 fast 正好等于 slow,直接返回\n", 73 | "如果 fast 落后 slow 一步,那么下一次循环,slow 移动一步,fast 移动两步,fast 正好追上 slow,可以返回\n", 74 | "如果 fast 落后 slow 两步,那么下一次循环,slow 移动一步,fast 移动两步,fast 落后 slow 一步,可以归结为上一种情况,最终会相遇。\n", 75 | "\n", 76 | "算法的最终时间复杂度为 O(n),毕竟 slow 指针是一步步的迭代整个链表。空间复杂度为常数。\n", 77 | "\n", 78 | "## 环形链表的入口\n", 79 | "\n", 80 | "使用快慢指针,不仅能判断链表是否有环,还能判断链表的环的起始位置。Leetcode 上的 [环形链表 II](https://leetcode-cn.com/problems/linked-list-cycle-ii/) 是上一题的升级版。\n", 81 | "\n", 82 | "> 给定一个链表,返回链表开始入环的第一个节点。 如果链表无环,则返回 null。\n", 83 | "> \n", 84 | "> 为了表示给定链表中的环,我们使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。 如果 pos 是 -1,则在该链表中没有环。\n", 85 | "> \n", 86 | "> 说明:不允许修改给定的链表。\n", 87 | ">\n", 88 | "> 示例 1:\n", 89 | "> \n", 90 | "> 输入:head = [3,2,0,-4], pos = 1\n", 91 | "> \n", 92 | "> 输出:tail connects to node index 1\n", 93 | "> \n", 94 | "> 解释:链表中有一个环,其尾部连接到第二个节点。\n", 95 | ">\n", 96 | "> ![图](https://assets.leetcode-cn.com/aliyun-lc-upload/uploads/2018/12/07/circularlinkedlist.png)\n", 97 | "> \n", 98 | "\n", 99 | "判断环的起始位置,通过简单的数学归纳可以很容易得到算法的验证。起始可以作为结论记住就好。其方法就是,一旦 slow 和 fast 相遇,就让 slow 从新回到起点,fast 起始位置不变,但是步长变成和 slow 一样,当他们再次相遇,就是环的入口点。代码如下:\n" 100 | ] 101 | }, 102 | { 103 | "cell_type": "code", 104 | "execution_count": 2, 105 | "metadata": {}, 106 | "outputs": [], 107 | "source": [ 108 | "class ListNode:\n", 109 | " def __init__(self, x):\n", 110 | " self.val = x\n", 111 | " self.next = None\n", 112 | "\n", 113 | "class Solution:\n", 114 | " def detectCycle(self, head: ListNode) -> ListNode:\n", 115 | " if not head:\n", 116 | " return head\n", 117 | " has_cycle = False\n", 118 | " slow = head\n", 119 | " fast = head\n", 120 | " while fast is not None and fast.next is not None:\n", 121 | " slow = slow.next\n", 122 | " fast = fast.next.next\n", 123 | " if slow == fast:\n", 124 | " has_cycle = True\n", 125 | " break\n", 126 | "\n", 127 | " if not has_cycle:\n", 128 | " return None\n", 129 | " \n", 130 | " slow = head\n", 131 | " while slow != fast:\n", 132 | " slow = slow.next\n", 133 | " fast = fast.next\n", 134 | " \n", 135 | " return slow" 136 | ] 137 | }, 138 | { 139 | "cell_type": "markdown", 140 | "metadata": {}, 141 | "source": [ 142 | "![环入口](https://s2.ax1x.com/2019/12/26/lAhSeS.md.png)\n", 143 | "\n", 144 | "通过上图可以看出,当 slow 和 fast 第一次相遇的时候。slow 走了`ABC`: m 步, fast 走了`ABCBC` 2m 步。假设 slow 和 fast 相遇距离 环入口的距离为`BC` n 步。那么 slow 从头开始,再走 `AB` m - n 步就到了环入口,而 fast 正好也走了`CB` m - n 步,因此两者会相遇。\n", 145 | "\n", 146 | "为了不失一般性,如果环很小,那么当 slow 走到环入口的时候,fast 可能走了好几圈,可是这圈数换成距离,依然是 m - n。与 fast 饶多少圈没有关系。\n", 147 | "\n", 148 | "## 环形链表环的长度\n", 149 | "\n", 150 | "通过快慢指针,可以推到链表是否有环,以及寻找环形链表的入口。另外一个比较有意思的就是环的长度如何求解呢?实际上,有了环的入口,再通过环走一圈记录长度即可。那么可以再仔细想想,只要 slow 进入了环,那么起点不用从环的入口开始,当前位置作为起点,再走回当前位置即可。" 151 | ] 152 | }, 153 | { 154 | "cell_type": "code", 155 | "execution_count": 3, 156 | "metadata": {}, 157 | "outputs": [], 158 | "source": [ 159 | "def cycle_len(head: ListNode) -> int:\n", 160 | " \n", 161 | " slow = head\n", 162 | " fast = head\n", 163 | " has_cycle = False\n", 164 | " while fast is not None and fast.next is not None:\n", 165 | " slow = slow.next\n", 166 | " fast = fast.next.next\n", 167 | " if slow == fast:\n", 168 | " has_cycle = True\n", 169 | " break\n", 170 | " l = 0\n", 171 | " cur = slow\n", 172 | " while True:\n", 173 | " cur = cur.next\n", 174 | " l += 1\n", 175 | " if slow == cur:\n", 176 | " break\n", 177 | " return l" 178 | ] 179 | }, 180 | { 181 | "cell_type": "markdown", 182 | "metadata": {}, 183 | "source": [ 184 | "## 链表的中点\n", 185 | "\n", 186 | "由于快慢指针中,快指针走的速度是慢指针的2倍。因此很容易就可以推出,对于无环的链表,快指针终究会走到尽头,那么此时慢指针的停留的位置,正好是链表的中点。如果链表长度为奇数,中点的左右正好长度正好相等。如果链表长度是偶数,那么中点就偏右一个单位。\n", 187 | "\n", 188 | "中点把链表分为左右两部,需要操作左右部分就得先找到中点。利用这个性质可以解决 Leetcode 的[234. 回文链表](https://leetcode-cn.com/problems/palindrome-linked-list/) \n", 189 | "\n", 190 | "> 请判断一个链表是否为回文链表。\n", 191 | "> \n", 192 | "> 示例 1: \n", 193 | "> \n", 194 | "> 输入: 1->2\n", 195 | "> \n", 196 | "> 输出: false\n", 197 | ">\n", 198 | "> 示例 2:\n", 199 | "> \n", 200 | "> 输入: 1->2->2->1\n", 201 | "> \n", 202 | "> 输出: true\n", 203 | "\n", 204 | "leetcode的回文题有很多,通常判断一个线性字串是否是回文串,可以使用[对撞指针](https://www.jianshu.com/p/f9f2e30c75ae)解决。即初始化 left 和 right 指针,就像 reverse 字串的套路一样,只不过交互 left right 改成比对 left 和 right。\n", 205 | "\n", 206 | "而对于链表,显然不太适合设置一个 right 指针,即使并且通过 right 指针也不能直接向左移动。既然不方便重两边向中间夹,那么可以换一个思路。从中间像两边扩展。\n", 207 | "\n", 208 | "通过快慢指针先找到链表的中点,然后将中点到链表结尾进行 reverse,然后分别对比中点左边和中点右边的部分。如下图所示:\n", 209 | "\n", 210 | "![链表中点](https://s2.ax1x.com/2019/12/26/lAEf8H.png)\n", 211 | "\n", 212 | "具体的实现代码如下:" 213 | ] 214 | }, 215 | { 216 | "cell_type": "code", 217 | "execution_count": 8, 218 | "metadata": {}, 219 | "outputs": [], 220 | "source": [ 221 | "from typing import List\n", 222 | "\n", 223 | "class ListNode:\n", 224 | " def __init__(self, x):\n", 225 | " self.val = x\n", 226 | " self.next = None\n", 227 | "\n", 228 | "class Solution:\n", 229 | " def isPalindrome(self, head: ListNode) -> bool:\n", 230 | " slow = head\n", 231 | " fast = head\n", 232 | "\n", 233 | " while fast is not None and fast.next is not None:\n", 234 | " slow = slow.next\n", 235 | " fast = fast.next.next\n", 236 | "\n", 237 | " mid = slow\n", 238 | " end = self.reverse(mid)\n", 239 | " node1 = head\n", 240 | " node2 = end\n", 241 | "\n", 242 | " ret = True\n", 243 | " while node2 is not None:\n", 244 | " if node1.val != node2.val:\n", 245 | " ret = False\n", 246 | " break\n", 247 | " node1 = node1.next\n", 248 | " node2 = node2.next\n", 249 | " self.reverse(end)\n", 250 | " return ret\n", 251 | "\n", 252 | " def reverse(self, node):\n", 253 | " parent = None\n", 254 | " cur = node\n", 255 | " while cur is not None:\n", 256 | " next_ = cur.next\n", 257 | " cur.next = parent\n", 258 | " parent = cur\n", 259 | " cur = next_\n", 260 | " return parent" 261 | ] 262 | }, 263 | { 264 | "cell_type": "markdown", 265 | "metadata": {}, 266 | "source": [ 267 | "需要注意,翻转是以 中点 开始。图示很明白,最后中点也需要加入比对。可是对于链表长度为偶数,那么中点就偏右,偏右的中点也需要跟其左边的元素进行比对,因此翻转的内容包括中点。当然,输入是一个链表,输出是一个boolean,但是不能改变原来的链表结构。因此最后还需要把 reverse 的右边再 reverse。\n", 268 | "\n", 269 | "通过翻转再进行链表迭代,类似的还可以参考 [143. 重排链表](https://leetcode-cn.com/problems/reorder-list/)。也是通过快慢指针,先找到中点,然后将右变进行 reverse,最后再合左边部分一次相间插入即可。\n", 270 | "\n", 271 | "## 快乐数\n", 272 | "\n", 273 | "快慢指针的初衷是判断链表是否有环。其本质是对于一个序列,出现了重复的元素。因此可以想象把重复元素连接起来,就成为了环。而这种成环的问题,不妨尝试使用快慢指针。leetcode 的 [202. 快乐数](https://leetcode-cn.com/problems/happy-number/)\n", 274 | "\n", 275 | "> 编写一个算法来判断一个数是不是“快乐数”。\n", 276 | "> \n", 277 | "> 一个“快乐数”定义为:对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和,然后重复这个过程直到这个数变为 1,也可能是无限循环但始终变不到 1。如果可以变为 1,那么这个数就是快乐数。\n", 278 | "> \n", 279 | "> 示例: \n", 280 | "> \n", 281 | "> 输入: 19\n", 282 | "> \n", 283 | "> 输出: true\n", 284 | "> \n", 285 | "> 解释: \n", 286 | "> \n", 287 | "> 12 + 92 = 82\n", 288 | "> \n", 289 | "> 82 + 22 = 68\n", 290 | "> \n", 291 | "> 62 + 82 = 100\n", 292 | "> \n", 293 | "> 12 + 02 + 02 = 1\n", 294 | "\n", 295 | "从题意可知,输入是任一个数,然后对其对位进行平方相加。什么样的数不会变成1呢,如果没有特别思路,可以拿几个简单的数字来验证一下,比如 4\n", 296 | "\n", 297 | "\n", 298 | "> 4\t\t\n", 299 | "> \n", 300 | "> 16\t= 4 ^ 2\n", 301 | "> \n", 302 | "> 37 = 1 ^ 2 + 6 ^ 2\n", 303 | "> \n", 304 | "> 58 = 3 ^ 2 + 7 ^ 2\n", 305 | "> \n", 306 | "> ...\n", 307 | "> \n", 308 | "> ...\n", 309 | "> \n", 310 | "> 20 = 4 ^ 2 + 2 ^ 2\n", 311 | "> \n", 312 | "> 4 = 2 ^ 2\n", 313 | "\n", 314 | "可以看到,对于 4 ,通过一些演算,最终又出现了 4, 在出现1之前就有 4 有重复,那么就永远也不可能出现 1。而这种情况,正好可以使用快慢指针。具体代码如下:\n" 315 | ] 316 | }, 317 | { 318 | "cell_type": "code", 319 | "execution_count": 9, 320 | "metadata": {}, 321 | "outputs": [], 322 | "source": [ 323 | "class Solution:\n", 324 | " def isHappy(self, n: int) -> bool:\n", 325 | " slow = fast = n\n", 326 | " while True: \n", 327 | " slow = self.findNext(slow)\n", 328 | " fast = self.findNext(self.findNext(fast))\n", 329 | " if slow == 1:\n", 330 | " return True\n", 331 | " if slow == fast:\n", 332 | " return False\n", 333 | " \n", 334 | " def findNext(self, n):\n", 335 | " next_ = 0\n", 336 | " while n > 0:\n", 337 | " next_ += (n % 10) ** 2\n", 338 | " n //= 10\n", 339 | " return next_" 340 | ] 341 | }, 342 | { 343 | "cell_type": "markdown", 344 | "metadata": {}, 345 | "source": [ 346 | "从代码结构上看,和之前的链表判环十分相像。其关键在于如何设置快指针和慢指针,对于链表可以直接 使用 next。而这个操作其实可以转化成多调一次函数。\n", 347 | "\n", 348 | "类似的还有 [457. 环形数组循环](https://leetcode-cn.com/problems/circular-array-loop)\n", 349 | "\n", 350 | "> 给定一个含有正整数和负整数的环形数组 nums。 如果某个索引中的数 k 为正数,则向前移动 k 个索引。相反,如果是负数 (-k),则向后移动 k 个索引。因为数组是环形的,所以可以假设最后一个元素的下一个元素是第一个元素,而第一个元素的前一个元素是最后一个元素。\n", 351 | "\n", 352 | "> 确定 nums 中是否存在循环(或周期)。循环必须在相同的索引处开始和结束并且循环长度 > 1。此外,一个循环中的所有运动都必须沿着同一方向进行。换句话说,一个循环中不能同时包括向前的运动和向后的运动。\n", 353 | "> \n", 354 | "> 示例 1:\n", 355 | "> \n", 356 | "> 输入:[2,-1,1,2,2]\n", 357 | "> \n", 358 | "> 输出:true\n", 359 | "> \n", 360 | "> 解释:存在循环,按索引 0 -> 2 -> 3 -> 0 。循环长度为 3 。\n", 361 | "\n", 362 | "理解题意非常重要,索引位置的数按照其值进行移动,移动之后新位置又继续移动,再次回到最终起点,那么就是一个循环。比快乐数稍微复杂在于,循环长度的规定,以及不能形成方向震荡。\n", 363 | "\n", 364 | "依然可以使用快慢指针,对数组的每一个元素进行判断。判断每一个元素的时候,需要寻找下一个元素,寻找下一个元素可以封装成一个步骤。这个步骤中,如果发现循环长度和方向不符合要求,就可以提前返回。结束当前元素的判断,具体代码如下:" 365 | ] 366 | }, 367 | { 368 | "cell_type": "code", 369 | "execution_count": 10, 370 | "metadata": {}, 371 | "outputs": [], 372 | "source": [ 373 | "class Solution:\n", 374 | " def circularArrayLoop(self, nums: List[int]) -> bool:\n", 375 | " for i in range(len(nums)):\n", 376 | " is_forward = nums[i] >= 0\n", 377 | " slow = fast = i\n", 378 | " while True:\n", 379 | " slow = self.nextIndex(slow, nums, is_forward)\n", 380 | " fast = self.nextIndex(fast, nums, is_forward)\n", 381 | " if fast != -1:\n", 382 | " fast = self.nextIndex(fast, nums, is_forward)\n", 383 | " \n", 384 | " if slow == -1 or fast == -1 or slow == fast:\n", 385 | " break\n", 386 | " \n", 387 | " if slow != -1 and slow == fast:\n", 388 | " return True\n", 389 | " return False\n", 390 | " \n", 391 | " \n", 392 | " \n", 393 | " def nextIndex(self, cur_index, nums, is_forward):\n", 394 | " derection = nums[cur_index] >= 0 \n", 395 | " if derection != is_forward:\n", 396 | " return -1\n", 397 | " \n", 398 | " next_index = (cur_index + nums[cur_index]) % len(nums)\n", 399 | " if cur_index == next_index:\n", 400 | " return -1\n", 401 | " return next_index" 402 | ] 403 | }, 404 | { 405 | "cell_type": "markdown", 406 | "metadata": {}, 407 | "source": [ 408 | "由于最外层需要对每个数组元素进行迭代判断,判定循环的函数执行复杂度是 O(N),最坏的情况下算法的时间复杂度是 O(N*N)。但是由于判定每个元素的时候可以提前返回,其均摊复杂度依然可以认为是线性 O(N)。\n", 409 | "\n", 410 | "## 快快快\n", 411 | "\n", 412 | "判断成环和寻找中点,快指针都比慢指针快两倍。实际上快多少步可以自由的定义。同时也可以是速度一致,但是起点不一致,此时不能用来判断是否成环,但是像中点那种找到距离为k的点。\n", 413 | "\n", 414 | "leetcode [189. 旋转数组](https://leetcode-cn.com/problems/rotate-array/) 和 [61. 旋转链表](https://leetcode-cn.com/problems/rotate-list/) 都是针对一个序列,向右移动 k 个位置,右边超过边界就从左边接上。不同在于一个旋转是数组,一个是链表\n", 415 | "\n", 416 | "> 给定一个链表,旋转链表,将链表每个节点向右移动 k 个位置,其中 k 是非负数。\n", 417 | "> \n", 418 | "> 示例 1:\n", 419 | "> \n", 420 | "> 输入: 1->2->3->4->5->NULL, k = 2\n", 421 | "> \n", 422 | "> 输出: 4->5->1->2->3->NULL\n", 423 | "> \n", 424 | "> 解释:\n", 425 | "> \n", 426 | "> 向右旋转 1 步: 5->1->2->3->4->NULL\n", 427 | "> \n", 428 | "> 向右旋转 2 步: 4->5->1->2->3->NULL\n", 429 | "> \n", 430 | "> 示例 2:\n", 431 | "> \n", 432 | "> 输入: 0->1->2->NULL, k = 4\n", 433 | "> \n", 434 | "> 输出: 2->0->1->NULL\n", 435 | "> \n", 436 | "> 解释:\n", 437 | "> \n", 438 | "> 向右旋转 1 步: 2->0->1->NULL\n", 439 | "> \n", 440 | "> 向右旋转 2 步: 1->2->0->NULL\n", 441 | "> \n", 442 | "> 向右旋转 3 步: 0->1->2->NULL\n", 443 | "> \n", 444 | "> 向右旋转 4 步: 2->0->1->NULL\n", 445 | "\n", 446 | "如果需要旋转的是 数组,可以对前 len - k 位置的进行reverse,然后对后 k 元素进行 reverse,最后再对整个数组进行reverse。如下图\n", 447 | "\n", 448 | "![旋转数组](https://s2.ax1x.com/2019/12/26/lAlEWV.png)\n", 449 | "\n", 450 | "\n", 451 | "对于链表,显然不是特别方便像数组这样处理。但是可以使用快慢指针,即 fast 比 slow 快 k 步起点,然后以相同的速度前进。找到需要断开的节点,slow 的 next 就是新链表的 head,然后相对于的链表拼接即可图示如下:\n", 452 | "\n", 453 | "![旋转链表](https://s2.ax1x.com/2019/12/26/lAlnL4.md.png)\n", 454 | "\n", 455 | "\n", 456 | "最终对链表的处理代码如下:\n", 457 | "\n" 458 | ] 459 | }, 460 | { 461 | "cell_type": "code", 462 | "execution_count": 12, 463 | "metadata": {}, 464 | "outputs": [], 465 | "source": [ 466 | "class ListNode:\n", 467 | " def __init__(self, x):\n", 468 | " self.val = x\n", 469 | " self.next = None\n", 470 | "\n", 471 | "class Solution:\n", 472 | " def rotateRight(self, head: ListNode, k: int) -> ListNode:\n", 473 | " if not head:\n", 474 | " return head\n", 475 | " \n", 476 | " list_len = 0\n", 477 | " \n", 478 | " node = head\n", 479 | " while node is not None:\n", 480 | " list_len += 1\n", 481 | " node = node.next\n", 482 | " \n", 483 | " n = k % list_len\n", 484 | " \n", 485 | " fast = head\n", 486 | " for i in range(n):\n", 487 | " fast = fast.next\n", 488 | " \n", 489 | " slow = head\n", 490 | " while fast.next is not None:\n", 491 | " slow = slow.next\n", 492 | " fast = fast.next\n", 493 | " \n", 494 | " fast.next = head\n", 495 | " head = slow.next\n", 496 | " slow.next = None\n", 497 | " \n", 498 | " return head" 499 | ] 500 | }, 501 | { 502 | "cell_type": "markdown", 503 | "metadata": {}, 504 | "source": [ 505 | "由此可见,快慢指针的套路也可以变化多端。此时的快慢指针的含义是 fast 比 slow 移动的相对距离恒定。这个性质也非常有用。例如可以解决数组去重的问题。\n", 506 | "\n", 507 | "[26. 删除排序数组的中的重复数]\n", 508 | "\n", 509 | "> 给定一个排序数组,你需要在原地删除重复出现的元素,使得每个元素只出现一次,返回移除后数组的新长度。\n", 510 | "> \n", 511 | "> 不要使用额外的数组空间,你必须在原地修改输入数组并在使用 O(1) 额外空间的条件下完成。\n", 512 | "> \n", 513 | "> 示例 1:\n", 514 | "> \n", 515 | "> 给定数组 nums = [1,1,2], \n", 516 | "> \n", 517 | "> 函数应该返回新的长度 2, 并且原数组 nums 的前两个元素被修改为 1, 2。 \n", 518 | "> \n", 519 | "> 你不需要考虑数组中超出新长度后面的元素。\n", 520 | "\n", 521 | "这个问题是唯一化的经典方式,通常删除数组,需要移动元素。对于重复排序的数组,如果依次再迭代的时候重复元素,那么将会多次移动数组,每次删除的操作都是 O(N) 复杂度。有没有优化的空间呢?\n", 522 | "\n", 523 | "事实上,可以逻辑上删除元素,即被删除的元素的位置放入合法的元素,然后适当的更新数组的长度,那么对外提供api的时候,超过数组长度的访问将会越界。而那些原来的元素就可以不用移动。如图所示:\n", 524 | "\n", 525 | "![唯一化](https://s2.ax1x.com/2019/12/26/lA3Bxf.png)\n", 526 | "\n", 527 | "设置一个 slow 和 fast,fast 比 slow 先走一步。然后依次比对 slow 和 fast, 如果 slow 的值与 fast 相等。那么 fast 继续移动。如果两者不相等,说明slow的前一位需要替换为 fast 当前值。代码如下\n" 528 | ] 529 | }, 530 | { 531 | "cell_type": "code", 532 | "execution_count": 13, 533 | "metadata": {}, 534 | "outputs": [], 535 | "source": [ 536 | "class Solution:\n", 537 | " def removeDuplicates(self, nums: List[int]) -> int:\n", 538 | " slow = fast = 0\n", 539 | " while fast < len(nums):\n", 540 | " if nums[slow] != nums[fast]:\n", 541 | " nums[slow+1] = nums[fast]\n", 542 | " slow += 1\n", 543 | " fast += 1\n", 544 | " return slow + 1" 545 | ] 546 | }, 547 | { 548 | "cell_type": "markdown", 549 | "metadata": {}, 550 | "source": [ 551 | "对于此题的进阶版 [80.删除排序数组的中的重复数](https://leetcode-cn.com/problems/remove-duplicates-from-sorted-array-ii/)\n", 552 | "\n", 553 | "> 给定一个排序数组,你需要在原地删除重复出现的元素,使得每个元素最多出现两次,返回移除后数组的新长度。\n", 554 | "> \n", 555 | "> 不要使用额外的数组空间,你必须在原地修改输入数组并在使用 O(1) 额外空间的条件下完成。\n", 556 | "> \n", 557 | "> 示例 1:\n", 558 | "> \n", 559 | "> 给定 nums = [0,0,1,1,1,1,2,3,3],\n", 560 | "> \n", 561 | "> 函数应返回新长度 length = 7, 并且原数组的前五个元素被修改为 0, 0, 1, 1, 2, 3, 3 。\n", 562 | "> \n", 563 | "> 你不需要考虑数组中超出新长度后面的元素。\n", 564 | "\n", 565 | "要求变成不能有超过 2 个的重复元素,也就是说 fast 和 slow 之间的差距是 2。 同时 fast 每次需要和 slow - 1 的指针值进行对比。可以画图稍微体会一下快慢指针在解此题的本质。具体代码如下" 566 | ] 567 | }, 568 | { 569 | "cell_type": "code", 570 | "execution_count": 14, 571 | "metadata": {}, 572 | "outputs": [], 573 | "source": [ 574 | "class Solution:\n", 575 | " def removeDuplicates(self, nums: List[int]) -> int:\n", 576 | " if len(nums) <= 2:\n", 577 | " return len(nums)\n", 578 | " \n", 579 | " slow, fast = 1, 2\n", 580 | "\n", 581 | " while fast < len(nums):\n", 582 | " if nums[slow - 1] != nums[fast]:\n", 583 | " slow += 1\n", 584 | " nums[slow] = nums[fast]\n", 585 | " fast += 1\n", 586 | " return slow + 1\n", 587 | " " 588 | ] 589 | }, 590 | { 591 | "cell_type": "markdown", 592 | "metadata": {}, 593 | "source": [ 594 | "## 总结\n", 595 | "\n", 596 | "快慢指针本质上也是双指针的一种。通常 fast 比 slow 快一倍方式运行,可以用来判断链表是否成环,这就是著名的龟兔算法。所谓的成环,可以理解为一些线性的序列元素出现了重复。进而可以解决类似快乐数,循环数组的问题。通过快慢指针的特性,也方便的选择链表的中点和计算环的入口。此外,fast 和 slow 指针既可以是速度不一样的指针,也可以说是速度一样,但是双方的起点不一样,数组的归一化就是借助了这个特性。\n", 597 | "\n", 598 | "文中涉及的leetcode\n", 599 | "\n", 600 | "> [141. 环形链表](https://leetcode-cn.com/problems/linked-list-cycle/)\n", 601 | "> \n", 602 | "> [142. 环形链表 II](https://leetcode-cn.com/problems/linked-list-cycle-ii/) \n", 603 | "> \n", 604 | "> [234. 回文链表](https://leetcode-cn.com/problems/palindrome-linked-list/) \n", 605 | "> \n", 606 | "> [202. 快乐数](https://leetcode-cn.com/problems/happy-number/)\n", 607 | "> \n", 608 | "> [457. 环形数组循环](https://leetcode-cn.com/problems/circular-array-loop)\n", 609 | "> \n", 610 | "> [189. 旋转数组](https://leetcode-cn.com/problems/rotate-array/) \n", 611 | "> \n", 612 | "> [61. 旋转链表](https://leetcode-cn.com/problems/rotate-list/)\n", 613 | "> \n", 614 | "> [26. 删除排序数组的中的重复数](https://leetcode-cn.com/problems/remove-duplicates-from-sorted-array/)\n", 615 | "> \n", 616 | "> [80.删除排序数组的中的重复数](https://leetcode-cn.com/problems/remove-duplicates-from-sorted-array-ii/)\n", 617 | ">\n", 618 | "> 来源:力扣(LeetCode) [https://leetcode-cn.com/problemset/all/](https://leetcode-cn.com/problemset/all/)\n" 619 | ] 620 | } 621 | ], 622 | "metadata": { 623 | "kernelspec": { 624 | "display_name": "Python 3", 625 | "language": "python", 626 | "name": "python3" 627 | }, 628 | "language_info": { 629 | "codemirror_mode": { 630 | "name": "ipython", 631 | "version": 3 632 | }, 633 | "file_extension": ".py", 634 | "mimetype": "text/x-python", 635 | "name": "python", 636 | "nbconvert_exporter": "python", 637 | "pygments_lexer": "ipython3", 638 | "version": "3.7.3" 639 | } 640 | }, 641 | "nbformat": 4, 642 | "nbformat_minor": 2 643 | } 644 | -------------------------------------------------------------------------------- /groking-leetcode/4-interval-merge.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "并发(concurrency)一直是计算机里常见的现象。理解并发的关键在于不同的任务在执行的时候,相互之间存在重叠(overlap)。一个任务的**开始时间(start)**和**结束时间(end)** 构成了一个区间(interval)。如何处理这种区间和重叠成为了Leetcode上一类有意思的问题。合并(merge)区间,或是找出不相交的区间等等。既有对逻辑思维的训练,也面向了实际生活面临的问题。下面就这类问题进行简单的梳理。\n", 8 | "\n", 9 | "### 区间关系\n", 10 | "\n", 11 | "给定两个区间(interval),例如 A 和 B,它们的关系无怪乎就下面图示的六种情况\n", 12 | "\n", 13 | "![区间关系图](https://s2.ax1x.com/2020/01/07/lcYwNT.png)\n", 14 | "\n", 15 | "需要特别注意前三种情况,往往很多问题最终都可以简化成处理这三种情况的case。 即 A.start <= B.start 。有两个重要的性质\n", 16 | "\n", 17 | "对于 2, 3 情况下的 overlap 区域,可以表示为\n", 18 | "\n", 19 | "```\n", 20 | "start = max(A.start, B.start) \n", 21 | "end = min(A.end, B.end)\n", 22 | "\n", 23 | "\n", 24 | "```\n", 25 | "\n", 26 | "`[start, end]` 就是重叠的区域。如果 `end <= start`,那么就是图示中第一种情况,A 和 B 并不重叠。\n", 27 | "\n", 28 | "> 注意,对于临界条件,根据具体的问题判断\n", 29 | "\n", 30 | "重叠的部分是为**交集**,在集合的概念里,还有另外一个运算叫**并集**。并集的运算代码如下\n", 31 | "\n", 32 | "```\n", 33 | "start = min(A.start, B.start)\n", 34 | "end = max(A.start, B.end)\n", 35 | "```\n", 36 | "\n", 37 | "由于前提是 A.start <= B.start,处理 start 的时候就比较简单。同时也暗示的一个信息,就是在处理 intervals 的时候,如果没有什么思路,可以把 interval 按照 其 start 进行排序。排序之后颇有柳暗花明的效果。当然,时间复杂度最好也是 O(NlogN)。" 38 | ] 39 | }, 40 | { 41 | "cell_type": "markdown", 42 | "metadata": {}, 43 | "source": [ 44 | "### 区间合并(merge intervals)\n", 45 | "\n", 46 | "上面的提到的 interval 求并集,Leetcode上第[56. 合并区间](https://leetcode-cn.com/problems/merge-intervals)题\n", 47 | "\n", 48 | "\n", 49 | "> 给出一个区间的集合,请合并所有重叠的区间。\n", 50 | "> \n", 51 | "> 示例 1:\n", 52 | ">\n", 53 | "> 输入: [[1,3],[2,6],[8,10],[15,18]]\n", 54 | "> \n", 55 | "> 输出: [[1,6],[8,10],[15,18]]\n", 56 | "> \n", 57 | "> 解释: 区间 [1,3] 和 [2,6] 重叠, 将它们合并为 [1,6].\n", 58 | "> \n", 59 | "> 示例 2:\n", 60 | ">\n", 61 | "> 输入: [[1,4],[4,5]]\n", 62 | ">\n", 63 | "> 输出: [[1,5]]\n", 64 | ">\n", 65 | "> 解释: 区间 [1,4] 和 [4,5] 可被视为重叠区间。\n", 66 | "\n", 67 | "从题意可知,给定的输入是一个二维 list。list 的每个元素是一个 interval,需要的就是求这些 interval的merge并集。处理的过程类似 reduce,过程。假设 第一项是A, 第二项是B,A和B处理之后成为新的 A,那么接下来的一项是新的 B,依次类推,直到处理完list所有元素。\n", 68 | "\n", 69 | "既然是处理 A 和 B 的问题,那么就可以套用上面所说的两个方法,当然前提是需要针对 list 中的 interval的 start 进行排序。\n", 70 | "\n", 71 | "如下图所示\n", 72 | "\n", 73 | "![图](https://s2.ax1x.com/2020/01/07/lc26YQ.md.png)\n", 74 | "\n", 75 | "\n", 76 | "经过排序之后,A B C 的三者中的两者可以归结为前面所述的 1, 2, 3 中类型。\n", 77 | "\n", 78 | "A[1, 4] 和 C[7, 9] 是第一种情况, 两者不相交\n", 79 | "\n", 80 | "A[1, 4] 和 B[2, 5] 是第二种情况,有重叠的部分\n", 81 | "\n", 82 | "对于第二种类型,直接进行 merge 求并集即可。求完并集之后需要创建一个新的 A'[1, 5],但是这个 A' 暂时不能放到结果中。因为 这个 A' 如果 end 很大,它跟 C 也有可能组成 第二种情况,即上图的中的\n", 83 | "\n", 84 | "A [1, 8] merge B[2, 5] 得到的 A'[1, 8],其中 A' 和 C 又有重叠,需要merge。\n", 85 | "\n", 86 | "只有两个 interval的 组合是第一种情况,即无交集的时候,才把当前的 start 和 end 更新到结果中。 具体代码如下:" 87 | ] 88 | }, 89 | { 90 | "cell_type": "code", 91 | "execution_count": 3, 92 | "metadata": {}, 93 | "outputs": [], 94 | "source": [ 95 | "from typing import *\n", 96 | "\n", 97 | "class Solution:\n", 98 | " def merge(self, intervals: List[List[int]]) -> List[List[int]]:\n", 99 | " if len(intervals) < 2:\n", 100 | " return intervals\n", 101 | " \n", 102 | " intervals.sort(key=lambda x: x[0])\n", 103 | " \n", 104 | " ret = []\n", 105 | " start, end = intervals[0][0], intervals[0][1]\n", 106 | " for istart, iend in intervals[1:]:\n", 107 | " if istart <= end:\n", 108 | " end = max(end, iend)\n", 109 | " else:\n", 110 | " ret.append([start, end])\n", 111 | " start, end = istart, iend\n", 112 | " \n", 113 | " ret.append([start, end])\n", 114 | " return ret\n", 115 | " \n", 116 | "intervals = [[1, 3],[2, 6],[8, 10],[15, 18]]\n", 117 | "ret = Solution().merge(intervals)\n", 118 | "assert ret == [[1, 6], [8, 10], [15, 18]], f'{ret} is err'" 119 | ] 120 | }, 121 | { 122 | "cell_type": "markdown", 123 | "metadata": {}, 124 | "source": [ 125 | "解法就是针对排序后的list进行迭代区间merge处理。使用一对 start,end 标记当前 merge 后的 A',使用贪心的思想,假设 A' 能和接下来的 interval 进行merge。处理下一个 interval 的时候,两者的关系无非是重叠和非重叠,判断重叠的方式使用求交集的方式。并更新 end。如果没有重叠,则记录目标的解,同时也要更新 start end,即 A'。\n", 126 | "\n", 127 | "需要注意的是,迭代结束之后,对于当前的 A', 其实也是一个解,不能忘记追加到结果集中。\n", 128 | "\n", 129 | "因为需要先对输入的 list 进行排序,排序的时间复杂度为 O(NlogN)。排序之后,对list 进行迭代 merge 处理,一共所需要的时间也就是迭代的时间。因此最终的时间复杂度是 O(NlogN)。\n", 130 | "\n", 131 | "空间方面主要是结果集的存储,即没有任何交集的情况下,需要返回的就是输入值。因此需要 O(N)的空间。当然,对于某些 排序方法,也需要额外的空间。取决于具体的语言和平台。\n", 132 | "\n", 133 | "### [435. 无重叠区间](https://leetcode-cn.com/problems/non-overlapping-intervals)\n", 134 | "\n", 135 | "区间合并求重叠是一类问题,其对应的有一种处理方式就是去除重叠部分。如 Leetcode的 435 题:\n", 136 | "\n", 137 | "> 给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。\n", 138 | ">\n", 139 | "> 注意:\n", 140 | ">\n", 141 | "> 可以认为区间的终点总是大于它的起点。区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。\n", 142 | ">\n", 143 | "> 示例 1:\n", 144 | "> \n", 145 | "> 输入: [ [1,2], [2,3], [3,4], [1,3] ]\n", 146 | "> \n", 147 | "> 输出: 1\n", 148 | "> \n", 149 | "> 解释: 移除 [1,3] 后,剩下的区间没有重叠。\n", 150 | ">\n", 151 | "> 示例 2:\n", 152 | "> \n", 153 | "> 输入: [ [1,2], [1,2], [1,2] ]\n", 154 | "> \n", 155 | "> 输出: 2\n", 156 | "> \n", 157 | "> 解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。\n", 158 | ">\n", 159 | "> 示例 3:\n", 160 | "> \n", 161 | "> 输入: [ [1,2], [2,3] ]\n", 162 | "> \n", 163 | "> 输出: 0\n", 164 | "> \n", 165 | "> 解释: 你不需要移除任何区间,因为它们已经是无重叠的了。\n", 166 | "\n", 167 | "\n", 168 | "与前面介绍的套路一样,首先对 list 进行排序。然后使用贪心的策略,即取列表的第一项为当前的A'。让其跟下一个区间进行比对,如果有重叠,更新除去的计数器。如果没有重叠,就移动当前的 A'。代码和上面一题非常相似,如下:\n" 169 | ] 170 | }, 171 | { 172 | "cell_type": "code", 173 | "execution_count": 8, 174 | "metadata": {}, 175 | "outputs": [ 176 | { 177 | "name": "stdout", 178 | "output_type": "stream", 179 | "text": [ 180 | "0\n" 181 | ] 182 | } 183 | ], 184 | "source": [ 185 | "from typing import *\n", 186 | "\n", 187 | "class Solution:\n", 188 | " def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int:\n", 189 | " if not intervals:\n", 190 | " return 0\n", 191 | " \n", 192 | " intervals.sort(key=lambda x: x[0])\n", 193 | " \n", 194 | " end = intervals[0][1]\n", 195 | " ret = 0\n", 196 | " for istart, iend in intervals[1:]:\n", 197 | " if istart < end:\n", 198 | " ret += 1\n", 199 | " end = min(end, iend)\n", 200 | " else:\n", 201 | " end = iend\n", 202 | " return ret\n", 203 | "\n", 204 | "intervals = [ [1,2], [2,3], [3,4], [1,3] ] # 1 [1, 3]\n", 205 | "intervals = [ [1,2], [1,2], [1,2] ] # 2, [1, 2], [1, 2]\n", 206 | "intervals = [ [1,2], [2,3] ] # 0\n", 207 | "intervals = []\n", 208 | "ret = Solution().eraseOverlapIntervals(intervals)\n", 209 | "print(ret)" 210 | ] 211 | }, 212 | { 213 | "cell_type": "markdown", 214 | "metadata": {}, 215 | "source": [ 216 | "需要注意的是,56题合并区间使用的求并集的算法,所以更新的 end = max(end, iend) ,而这道题其实要求的是区间的交集(保留重叠少的部分),所以更新的 end = min(end, iend)。其中原委不难,但需细品。\n", 217 | "\n", 218 | "因为也是需要先排序,然后再迭代intervals,最终的算法时间复杂度是 O(NlogN),没有使用额外的空间,空间复杂度为 O(1)。\n", 219 | "\n", 220 | "\n", 221 | "### 插入区间\n", 222 | "\n", 223 | "了解了区间重叠的交集和并集技巧,可以处理不少类似模式(pattern)的题目。即使最开始的问题并不是求解两个 intervals的merge操作,也可以通过迭代逐步的转化。\n", 224 | "\n", 225 | "[57. 插入区间](https://leetcode-cn.com/problems/insert-interval)\n", 226 | "\n", 227 | "> 给出一个无重叠的 ,按照区间起始端点排序的区间列表。\n", 228 | "> \n", 229 | "> 在列表中插入一个新的区间,你需要确保列表中的区间仍然有序且不重叠(如果有必要的话,可以合并区间)。\n", 230 | "> \n", 231 | "> 示例 1:\n", 232 | "> \n", 233 | "> 输入: intervals = [[1,3],[6,9]], newInterval = [2,5]\n", 234 | "> \n", 235 | "> 输出: [[1,5],[6,9]]\n", 236 | "> \n", 237 | "> 示例 2:\n", 238 | "> \n", 239 | "> 输入: intervals = [[1,2],[3,5],[6,7],[8,10],[12,16]], newInterval = [4,8]\n", 240 | "> \n", 241 | "> 输出: [[1,2],[3,10],[12,16]]\n", 242 | "> \n", 243 | "> 解释: 这是因为新的区间 [4,8] 与 [3,5],[6,7],[8,10] 重叠。\n", 244 | "\n", 245 | "\n", 246 | "与56题合并区间类似,上面的题目要求插入一个 interval,同时插入之后整个 intervals 依然保持互相不重叠。前提是输入本身就是一个有序且无重叠的intervals。\n", 247 | "\n", 248 | "如果被插入的 interval 跟现有的intervals列表没有重叠.那么最简单不过,直接找到排序位置插入即可。如下图,在 `[1, 3] [6, 8] [9, 10]` intervals 中插入 interval `[4, 5]`,找到空隙插入即可,空隙就是被插入的interval的start 要大于上一个 interval 的end。\n", 249 | "\n", 250 | "![图片](https://s2.ax1x.com/2020/01/08/lgwy6S.md.png)\n", 251 | "\n", 252 | "如果跟现有的重叠呢?很明显,需要跟现有的合并。使用贪心的策略,假设被插入的interval 很大,那么就有可能 merge 这个输入的intervals。即下图的两种情况\n", 253 | "\n", 254 | "![图片](https://s2.ax1x.com/2020/01/08/lgwL79.md.png)\n", 255 | "\n", 256 | "对于被插入的 interval 是 `[4, 7]`,那么它与 `[6, 8]` 重叠,因此需要 merge 合并成 `[4, 8]` 再插入。因为当前的 end = 8,小于下一个 interval `[9,10]`的start,因此可以直接插入并终止算法。\n", 257 | "\n", 258 | "可是对于 `[4, 10]` 这个区间,即使跟 `[6, 8]`merge之后,它依然和下一个 interval `[9,10]`存在重叠,因此需要继续迭代合并`[9,10]`,直到出现可以插入的位置。\n", 259 | "\n", 260 | "所以求解此题的一个思路就是通过迭代排序的 intervals,找到重叠的 interval,然后进行合并,合并之后再判断是否满足题意,以最终求解返回。\n", 261 | "\n", 262 | "剩下的就是代码实现了:\n", 263 | "\n" 264 | ] 265 | }, 266 | { 267 | "cell_type": "code", 268 | "execution_count": 9, 269 | "metadata": {}, 270 | "outputs": [ 271 | { 272 | "name": "stdout", 273 | "output_type": "stream", 274 | "text": [ 275 | "[[2, 3]]\n" 276 | ] 277 | } 278 | ], 279 | "source": [ 280 | "from typing import *\n", 281 | "\n", 282 | "\n", 283 | "class Solution:\n", 284 | " def insert(self, intervals: List[List[int]], newInterval: List[int]) -> List[List[int]]:\n", 285 | " start, end = newInterval[0], newInterval[1]\n", 286 | " ret = []\n", 287 | " i = 0\n", 288 | " # 寻找可以插入的起始位置,与上一个 interval 不重叠\n", 289 | " while i < len(intervals):\n", 290 | " istart, iend = intervals[i][0], intervals[i][1]\n", 291 | " if iend < start:\n", 292 | " ret.append([istart, iend])\n", 293 | " else:\n", 294 | " break\n", 295 | " i += 1\n", 296 | " \n", 297 | " # 处理当前重叠的 interval,merge 所有\n", 298 | " while i < len(intervals):\n", 299 | " istart, iend = intervals[i][0], intervals[i][1]\n", 300 | " if end < istart:\n", 301 | " break\n", 302 | " \n", 303 | " # 使用了求 并集 的技巧\n", 304 | " start = min(start, istart) \n", 305 | " end = max(end, iend)\n", 306 | " i += 1\n", 307 | "\n", 308 | " # 插入不能合并区间\n", 309 | " ret.append([start, end])\n", 310 | " \n", 311 | " # 将剩余的结果也保持到结果集中\n", 312 | " while i < len(intervals):\n", 313 | " ret.append(intervals[i])\n", 314 | " i += 1\n", 315 | " \n", 316 | " return ret\n", 317 | " \n", 318 | "\n", 319 | "intervals = [[1,3],[6,9]]\n", 320 | "newInterval = [2,5]\n", 321 | "\n", 322 | "intervals = [[1,3], [5,7], [8,12]]\n", 323 | "newInterval = [4, 6]\n", 324 | "\n", 325 | "intervals = [[1,3], [5,7], [8,12]]\n", 326 | "newInterval = [4, 10]\n", 327 | "\n", 328 | "intervals = [[2,3], [5, 7]]\n", 329 | "newInterval = [1, 4]\n", 330 | "\n", 331 | "intervals = [[1,5]]\n", 332 | "newInterval = [2, 3]\n", 333 | "\n", 334 | "intervals = []\n", 335 | "newInterval = [2, 3]\n", 336 | "\n", 337 | "ret = Solution().insert(intervals, newInterval)\n", 338 | "print(ret)" 339 | ] 340 | }, 341 | { 342 | "cell_type": "markdown", 343 | "metadata": {}, 344 | "source": [ 345 | "上述的代码一共分为三步\n", 346 | "\n", 347 | "1. 找到上一个 interval 和 被插入的 interval 不重叠的位置,即`pre_interval.end <= interval.start`,插入之前不重叠的 interval。\n", 348 | "2. 找到待 merge 的interval,依次merge,使用的技巧类似 56 题的 区间合并技术。如果不再重叠,就将结果保存 `ret.append([start, end])`\n", 349 | "3. 合并区间之后,将剩余输入的 interval 保存到结果集中。\n", 350 | "\n", 351 | "输入的intervals已经是有序的,处理的过程是直接迭代。最终的时间复杂度是 O(N),空间复杂度也是 O(N),需要将结果保存输出。\n", 352 | "\n", 353 | "\n", 354 | "### 986. 区间列表的交集\n", 355 | "\n", 356 | "面对一堆 intervals,最终也是转换成最基本的 A B 两个interval进行处理,而处理两者关系的时候,往往又使用到贪心策略。无论多么复杂,都可以按照这个思路先思考。下面一题输入是两个区间列表,其实最终也是一样的策略。\n", 357 | "\n", 358 | "[986. 区间列表的交集](https://leetcode-cn.com/problems/interval-list-intersections/)\n", 359 | "\n", 360 | "> 给定两个由一些闭区间组成的列表,每个区间列表都是成对不相交的,并且已经排序。返回这两个区间列表的交集。(形式上,闭区间 [a, b](其中 a <= b)表示实数 x 的集合,而 a <= x <= b。两个闭区间的交集是一组实数,要么为空集,要么为闭区间。例如,[1, 3] 和 [2, 4] 的交集为 [2, 3]。)\n", 361 | ">\n", 362 | "> 示例:\n", 363 | ">\n", 364 | "> ![图](https://assets.leetcode-cn.com/aliyun-lc-upload/uploads/2019/02/02/interval1.png)\n", 365 | ">\n", 366 | "> 输入:A = [[0,2],[5,10],[13,23],[24,25]], B = [[1,5],[8,12],[15,24],[25,26]]\n", 367 | "> \n", 368 | "> 输出:[[1,2],[5,5],[8,10],[15,23],[24,24],[25,25]]\n", 369 | "> \n", 370 | "> 注意:输入和所需的输出都是区间对象组成的列表,而不是数组或列表。\n", 371 | "\n", 372 | "\n", 373 | "题意给的是两个 intervals列表并且都是已经排序的,一种思路是先合并两个list,将其打平为一个列表,然后再排序,就转变成了类似 56 题的模式。或者直接使用暴力方式,一次迭代处理两个列表元素的重叠merge情况。代码如下:" 374 | ] 375 | }, 376 | { 377 | "cell_type": "code", 378 | "execution_count": 10, 379 | "metadata": {}, 380 | "outputs": [], 381 | "source": [ 382 | "from typing import *\n", 383 | "\n", 384 | "\n", 385 | "class Solution:\n", 386 | " def intervalIntersection(self, A: List[List[int]], B: List[List[int]]) -> List[List[int]]:\n", 387 | " ret = []\n", 388 | " for i in range(len(A)):\n", 389 | " for j in range(len(B)):\n", 390 | " if B[j][1] < A[i][0]: \n", 391 | " continue\n", 392 | " if A[i][1] < B[j][0]:\n", 393 | " continue\n", 394 | " ret.append([max(A[i][0], B[j][0]), min(A[i][1], B[j][1])]) \n", 395 | " return ret" 396 | ] 397 | }, 398 | { 399 | "cell_type": "markdown", 400 | "metadata": {}, 401 | "source": [ 402 | "通常在 leetcode上,暴力破解都不是有效的解法。面对两个列表,可以使用 双指针,即每个列表一个指针,然后处理对应的 interval的重叠交集,然后再移动指针。这些循环结果是线性复杂度。\n", 403 | "\n", 404 | "代码如下:" 405 | ] 406 | }, 407 | { 408 | "cell_type": "code", 409 | "execution_count": 11, 410 | "metadata": {}, 411 | "outputs": [ 412 | { 413 | "name": "stdout", 414 | "output_type": "stream", 415 | "text": [ 416 | "[[10, 15], [60, 70]]\n" 417 | ] 418 | } 419 | ], 420 | "source": [ 421 | "# 双指针 O(N+M) overlap的重要性质 start = max(A.start, B.start) end = min(A.end, B.end) start <= end\n", 422 | " \n", 423 | "\n", 424 | "from typing import *\n", 425 | "\n", 426 | "\n", 427 | "class Solution:\n", 428 | " def intervalIntersection(self, A: List[List[int]], B: List[List[int]]) -> List[List[int]]:\n", 429 | " ret = []\n", 430 | " i = j = 0\n", 431 | " while i < len(A) and j < len(B):\n", 432 | " start = max(A[i][0], B[j][0])\n", 433 | " end = min(A[i][1], B[j][1])\n", 434 | " \n", 435 | " # 对于重叠的interval,求交集\n", 436 | " if start <= end:\n", 437 | " ret.append([start, end]) \n", 438 | " \n", 439 | " # 移动指针,无论重叠还是不重叠,移动指针都是将 end 小的移动\n", 440 | " if A[i][1] < B[j][1]:\n", 441 | " i += 1\n", 442 | " else:\n", 443 | " j += 1 \n", 444 | " return ret\n", 445 | " \n", 446 | "A = [[1, 3], [5, 6], [7, 9]]\n", 447 | "B = [[2, 3], [5, 7]]\n", 448 | "\n", 449 | "A = [[1, 3], [5, 7], [9, 12]]\n", 450 | "B = [[1, 2]]\n", 451 | "\n", 452 | "A = [[0,2],[5,10],[13,23],[24,25]]\n", 453 | "B = [[1,5],[8,12],[15,24],[25,26]]\n", 454 | "\n", 455 | "A = [[10,50],[60,120],[140,210]]\n", 456 | "B = [[0,15],[60,70]]\n", 457 | "ret = Solution().intervalIntersection(A, B)\n", 458 | "print(ret)" 459 | ] 460 | }, 461 | { 462 | "cell_type": "markdown", 463 | "metadata": {}, 464 | "source": [ 465 | "从代码可以看出重叠的重要特性是通过求交集的技巧,找到 A B 的第一种关系的条件。一旦处理了当前指针的区间,就需要移动指针,移动的技巧就是找 end 小的部分。当前 end 小,那么下一个 interval的 start 就有可能小,end的大的就有可能重叠下一个 start 小的,还是贪心的策略。\n", 466 | "\n", 467 | "时间复杂度是 O(N+M),两个列表长度分别是 N 和 M。\n", 468 | "\n", 469 | "与之类似的还有下面一题:\n", 470 | "\n", 471 | "### 1229. 安排会议日程\n", 472 | "\n", 473 | "前面有所提及,区间问题最常见来着并发程序的调度。现实生活中也经常有这样的情形,时间空暇或者工作时间都是一个个区间。休息和工作的关系无非就是区间的重叠问题。1229就是一道贴近日常工作的问题。\n", 474 | "\n", 475 | "[1229. 安排会议日程](https://leetcode-cn.com/problems/meeting-scheduler/)\n", 476 | "\n", 477 | "> 你是一名行政助理,手里有两位客户的空闲时间表:slots1 和 slots2,以及会议的预计持续时间 duration,请你为他们安排合适的会议时间。\n", 478 | "> \n", 479 | "> 「会议时间」是两位客户都有空参加,并且持续时间能够满足预计时间 duration 的 最早的时间间隔。\n", 480 | "> \n", 481 | "> 如果没有满足要求的会议时间,就请返回一个 空数组。\n", 482 | "> \n", 483 | "> 「空闲时间」的格式是 [start, end],由开始时间 start 和结束时间 end 组成,表示从 start 开始,到 end 结束。\n", 484 | "> \n", 485 | "> 题目保证数据有效:同一个人的空闲时间不会出现交叠的情况,也就是说,对于同一个人的两个空闲时间 [start1, end1] 和 [start2, end2],要么 start1 > end2,要么 start2 > end1。\n", 486 | "> \n", 487 | "> 示例 1:\n", 488 | "> \n", 489 | "> 输入:slots1 = [[10,50],[60,120],[140,210]], slots2 = [[0,15],[60,70]], duration = 8\n", 490 | "> \n", 491 | "> 输出:[60,68]\n", 492 | "> \n", 493 | "> 示例 2:\n", 494 | "> \n", 495 | "> 输入:slots1 = [[10,50],[60,120],[140,210]], slots2 = [[0,15],[60,70]], duration = 12\n", 496 | "> \n", 497 | "> 输出:[]\n", 498 | "\n", 499 | "\n", 500 | "与上一题的求解策略一样,也是先找出两个区间列表的交集,然后再判断这个交集长度是否满足第二个参数 duration。\n" 501 | ] 502 | }, 503 | { 504 | "cell_type": "code", 505 | "execution_count": 12, 506 | "metadata": {}, 507 | "outputs": [ 508 | { 509 | "name": "stdout", 510 | "output_type": "stream", 511 | "text": [ 512 | "[]\n" 513 | ] 514 | } 515 | ], 516 | "source": [ 517 | "class Solution:\n", 518 | " def minAvailableDuration(self, A: List[List[int]], B: List[List[int]], duration: int) -> List[int]:\n", 519 | " A.sort(key=lambda x: x[0])\n", 520 | " B.sort(key=lambda x: x[0])\n", 521 | " \n", 522 | " i = j = 0\n", 523 | " while i < len(A) and j < len(B):\n", 524 | " start = max(A[i][0], B[j][0])\n", 525 | " end = min(A[i][1], B[j][1])\n", 526 | " \n", 527 | " if end - start >= duration:\n", 528 | " return [start, start + duration]\n", 529 | " \n", 530 | " if A[i][1] <= B[j][1]:\n", 531 | " i += 1\n", 532 | " else:\n", 533 | " j += 1\n", 534 | " \n", 535 | " return []\n", 536 | " \n", 537 | "\n", 538 | "A = [[10,50],[60,120],[140,210]]\n", 539 | "B = [[0,15],[60,70]]\n", 540 | "duration = 8\n", 541 | "\n", 542 | "A = [[10,50],[60,120],[140,210]]\n", 543 | "B = [[0,15],[60,70]] \n", 544 | "duration = 12\n", 545 | "\n", 546 | "ret = Solution().minAvailableDuration(A, B, duration)\n", 547 | "print(ret)" 548 | ] 549 | }, 550 | { 551 | "cell_type": "markdown", 552 | "metadata": {}, 553 | "source": [ 554 | "时间复杂度也是 O(N+M)。解题的关键还是转换成两个区间 A 和 B 的进行求交集。\n", 555 | "\n", 556 | "看了HR 安排日常也需要一定的算法知识,不然可能就是人工的枚举暴力破解。Leetcode或者 geeksforgeeks 上还有几道贴近生活的区间问题。\n", 557 | "\n", 558 | "### 会议室\n", 559 | "\n", 560 | "[252.会议室](https://leetcode-cn.com/problems/meeting-rooms)\n", 561 | " \n", 562 | "> 给定一个会议时间安排的数组,每个会议时间都会包括开始和结束的时间 [[s1,e1],[s2,e2],...] (si < ei),请你判断一个人是否能够参加这里面的全部会议。\n", 563 | "> \n", 564 | "> 示例 1:\n", 565 | "> \n", 566 | "> 输入: \n", 567 | "> [[0,30],[5,10],[15,20]]\n", 568 | "> \n", 569 | "> 输出: false\n", 570 | "> \n", 571 | "> 示例 2:\n", 572 | "> \n", 573 | "> 输入: [[7,10],[2,4]]\n", 574 | "> \n", 575 | "> 输出: true\n", 576 | "\n", 577 | "经过了上面几个题目的训练,想必这个问题就迎刃而解了。会议时间是区间,场景就是日常的工作情况。能参加所有会议,那么就是区间都没有重叠的即可。\n", 578 | "\n", 579 | "代码如下" 580 | ] 581 | }, 582 | { 583 | "cell_type": "code", 584 | "execution_count": 13, 585 | "metadata": {}, 586 | "outputs": [ 587 | { 588 | "name": "stdout", 589 | "output_type": "stream", 590 | "text": [ 591 | "True\n" 592 | ] 593 | } 594 | ], 595 | "source": [ 596 | "from typing import *\n", 597 | "\n", 598 | "class Solution(object):\n", 599 | " def canAttendMeetings(self, intervals: List[List[int]]) -> bool:\n", 600 | " intervals.sort(key=lambda x: x[0])\n", 601 | " end = intervals[0][1]\n", 602 | " for istart, iend in intervals[1:]:\n", 603 | " if istart < end:\n", 604 | " return False\n", 605 | " else:\n", 606 | " end = iend\n", 607 | " return True\n", 608 | " \n", 609 | " \n", 610 | "intervals = [[0,30], [5,10], [15,20]]\n", 611 | "intervals = [[7,10],[2,4]] \n", 612 | "ret = Solution().canAttendMeetings(intervals)\n", 613 | "print(ret)\n" 614 | ] 615 | }, 616 | { 617 | "cell_type": "markdown", 618 | "metadata": {}, 619 | "source": [ 620 | "这题比较简单,它的升级版 [253.会议室II](https://leetcode-cn.com/problems/meeting-rooms-ii/) 则需要一定的技巧。这是一种新的模式(pattern)\n", 621 | "\n", 622 | "> 题目描述 \n", 623 | "> 给定一个会议时间安排的数组,每个会议时间都会包括开始和结束的时间 [[ s1 , e1 ] ,[ s2 , e2 ],…] (si < ei) ,为避免会议冲突,同时要考虑充分利用会议室资源,请你计算至少需要多少间会议室,才能满足这些会议安排。\n", 624 | "> \n", 625 | "> 示例 1:\n", 626 | "> \n", 627 | "> 输入: [[0, 30],[5, 10],[15, 20]]\n", 628 | "> \n", 629 | "> 输出: 2\n", 630 | "> \n", 631 | "> 示例 2:\n", 632 | "> \n", 633 | "> 输入: [[7,10],[2,4]]\n", 634 | "> \n", 635 | "> 输出: 1\n", 636 | "\n", 637 | "\n", 638 | "日常生活也是这样,往往不同的会议再相同的时间举行,就得需要 HR 安排不同的会议室了。转换成 区间算法问题,无非就是判断这些区间是否有重叠。初看还是老问题套路,可是实际想想,两个列表的交集的前提是我们知道有两个列表的输入,此题虽然也知道一共列表的大小。但是我们总不能初始化这些长度的指针吧。\n", 639 | "\n", 640 | "仔细想想,假设就是一个HR,不管任何算法。常人的思维也是一种贪心策略:\n", 641 | "\n", 642 | "1. 第一个会肯定需要占一个会议室,因此初始化一个会议室放第一个区间。\n", 643 | "2. 然后看第二个会,如果跟第一个会议有冲突,那么就再安排一个会议室\n", 644 | "3. 再看第三个会议,前面已经有两个会议室了,如果有空的,即轮到它的时候,别的会议都结束了,会议室为空,就安排,没有就再初始化一个会议室。\n", 645 | "\n", 646 | "关键是2 3 步骤的时候。如果当前是有空的会议室,安排到那个会议室呢?在代码里,我们可以扫描一遍当前的会议室,有合适的就安排。那么时间复杂度会浪费再扫描会议室上。\n", 647 | "\n", 648 | "一个行之有效的策略就是,当前最早结束的会议如果空了,那么就可以安排,如果没有,就看下一个最早结束的,直到扫描所有会议室。看起来还是需要扫描所有会议室,但是比之前的线性扫描,可以提前结束扫描。\n", 649 | "\n", 650 | "既然扫描的过程中需要最早结束的会议室,那么这个会议室就可以组织成 堆(heap) 这种数据结构。找最大最小只需要常数时间,而且堆的增删过程中,维护堆的性质也不过是O(logN) 。\n", 651 | "\n", 652 | "代码如下:" 653 | ] 654 | }, 655 | { 656 | "cell_type": "code", 657 | "execution_count": 14, 658 | "metadata": {}, 659 | "outputs": [ 660 | { 661 | "name": "stdout", 662 | "output_type": "stream", 663 | "text": [ 664 | "2\n" 665 | ] 666 | } 667 | ], 668 | "source": [ 669 | "# 最小堆 O(NlogN)\n", 670 | "\n", 671 | "from typing import *\n", 672 | "from heapq import heappop, heappush\n", 673 | "\n", 674 | "class Solution:\n", 675 | " def minMeetingRooms(self, intervals: List[List[int]]) -> int:\n", 676 | " if not intervals:\n", 677 | " return 0\n", 678 | " \n", 679 | " rooms = []\n", 680 | " intervals.sort(key=lambda x: x[0])\n", 681 | " \n", 682 | " for i in intervals:\n", 683 | " if len(rooms) > 0 and rooms[0] <= i[0]:\n", 684 | " heappop(rooms)\n", 685 | " heappush(rooms, i[1])\n", 686 | " return len(rooms)\n", 687 | "\n", 688 | "intervals = [[1,4], [2,5], [7,9]]\n", 689 | "intervals = [[6,7], [2,4], [8,12]]\n", 690 | "intervals = [[1,4], [2,3], [3,6]]\n", 691 | "intervals = [[4,5], [2, 3], [2, 4], [3, 5]]\n", 692 | "\n", 693 | "ret = Solution().minMeetingRooms(intervals)\n", 694 | "print(ret)" 695 | ] 696 | }, 697 | { 698 | "cell_type": "markdown", 699 | "metadata": {}, 700 | "source": [ 701 | "依然是老套路\n", 702 | "\n", 703 | "1. 先以 interval.start 排序 intervals\n", 704 | "2. 然后迭代 interval,堆为空,则把当前的 interval 加入堆。表示安排一个会议室给这个 interval。如果堆不为空,且最小堆的end比interval.start 还小,说明当前的 interval可以在这个最小堆后面的会议室。即可以替换这个最小堆的元素。代码只需要弹出这个最小堆堆顶元素。\n", 705 | "3. 再把当前的 interval 加入堆中。\n", 706 | "\n", 707 | "最终堆的大小,就是所需要的最小会议室。\n", 708 | "\n", 709 | "排序的时间复杂度是 O(NlogN), 迭代处理堆的操作是 O(logN),最终的复杂度还是 O(NlogN)。空间复杂度则是建堆的空间,即 O(N)。\n", 710 | "\n", 711 | "\n", 712 | "### 员工空闲时间\n", 713 | "\n", 714 | "引入最小堆,是因为当前需要处理的 interval,需要跟上一个最小的 interval进行比较。下面的一个问题,也是需要接祖 最小堆 进行处理。\n", 715 | "\n", 716 | "> 我们得到了一个雇员的时间表,它代表了每个雇员的工作时间。每个员工工作时间是一个不重叠区间的列表,并且这些区间是按照排序的顺序排列的。\n", 717 | "> 返回有限时间列表,表示所有员工的公共、正长度空闲时间且排序的有限时间列表\n", 718 | "> \n", 719 | "> 示例一:\n", 720 | "> \n", 721 | "> 输入: schedule = [[[1,2],[5,6]],[[1,3]],[[4,10]]]\n", 722 | "> 输出: [[3,4]]> \n", 723 | "> 释: 总共有三个雇员,所有公共的空闲时间间隔都是[-inf,1] ,[3,4] ,[10,inf ]。 我们抛弃任何包含 inf 的区间,因为它们不是有限的。\n", 724 | "> \n", 725 | "> 示例二:\n", 726 | "> \n", 727 | "> 输入: schedule = [[[1,3],[6,7]],[[2,4]],[[2,5],[9,12]]]\n", 728 | "> 输出: [[5,6],[7,9]]\n", 729 | "> \n", 730 | "> (即使我们以[ x,y ]的形式表示时间区间,但其中的对象是时间区间,而不是列表或数组。 例如,schedule [0][0]。 开始1,进度表[0][0]。 结束2,计划[0][0][0]没有定义。)\n", 731 | "> \n", 732 | "> 此外,我们不会在回答中包含像[5,5]这样的区间,因为它们的长度为零。\n", 733 | "\n", 734 | "\n", 735 | "从题意可知,求员工的空闲时间,也就是求员工工作时间的非集之间的交集。可以先求出所有员工的interval的非集,然后再求其交集,问题就转换为 986. 区间列表的交集。\n", 736 | "\n", 737 | "当然,熟悉集合运算的话,这个问题也可以转换成所有员工的 interval的交集,然后再求其非集。如下图所示\n", 738 | "\n", 739 | "![图](https://s2.ax1x.com/2020/01/08/l2Pc7t.md.png)\n", 740 | "\n", 741 | "\n", 742 | "员工一的 时间为 `[1, 3], [9, 12]`\n", 743 | "\n", 744 | "员工二的 时间为 `[2, 4]`\n", 745 | "\n", 746 | "员工三的 时间为 `[6, 8]`\n", 747 | "\n", 748 | "三个员工的工作时间interval的merge 就是 \n", 749 | "\n", 750 | "`[1, 4] [6, 8], [9, 12]` 这个新的intervals的间隙(gap),恰好就是所要求的结果,也就是merge之后的interval的非集合,即 `[4, 6] 和 [8, 9]`。、\n", 751 | "\n", 752 | "代码如下\n" 753 | ] 754 | }, 755 | { 756 | "cell_type": "code", 757 | "execution_count": 15, 758 | "metadata": {}, 759 | "outputs": [ 760 | { 761 | "name": "stdout", 762 | "output_type": "stream", 763 | "text": [ 764 | "[[5, 7]]\n" 765 | ] 766 | } 767 | ], 768 | "source": [ 769 | "from typing import *\n", 770 | "from heapq import heappop, heappush\n", 771 | "\n", 772 | "class Solution:\n", 773 | " \n", 774 | " def employeeFreeTime(self, schedule: List[List[int]]) -> List[List[int]]:\n", 775 | " if len(schedule) == 0:\n", 776 | " return []\n", 777 | " \n", 778 | " ret = []\n", 779 | " s = []\n", 780 | " for i in schedule:\n", 781 | " for j in i:\n", 782 | " heappush(s, j)\n", 783 | " \n", 784 | " start, end = s[0][0], s[0][1]\n", 785 | " while len(s) > 0:\n", 786 | " interval = heappop(s)\n", 787 | " istart, iend = interval[0], interval[1]\n", 788 | " if istart <= end:\n", 789 | " end = max(end, iend)\n", 790 | " else:\n", 791 | " ret.append([end, istart])\n", 792 | " start, end = istart, iend\n", 793 | " return ret\n", 794 | "\n", 795 | "schedule = [[[1,2],[5,6]],[[1,3]],[[4,10]]] # [[3,4]]\n", 796 | "schedule = [[[1,3],[6,7]],[[2,4]],[[2,5],[9,12]]] # [[5,6],[7,9]]\n", 797 | "schedule = [[[1,3], [5,6]], [[2,3], [6,8]]] # [3, 5]\n", 798 | "schedule = [[[1,3], [9,12]], [[2,4]], [[6,8]]] # [[4, 6], [8, 9]]\n", 799 | "schedule = [[[1,3]], [[2,4]], [[3,5], [7,9]]] # [[5, 7]]\n", 800 | "ret = Solution().employeeFreeTime(schedule)\n", 801 | "print(ret)" 802 | ] 803 | }, 804 | { 805 | "cell_type": "markdown", 806 | "metadata": {}, 807 | "source": [ 808 | "考虑到需要将所有员工的 interval 进行排序,输入又是嵌套列表。因此可以借助最小堆。迭代 所有员工的时间,建一个最小堆。\n", 809 | "\n", 810 | "这个堆里的 元素可能存在有重叠的,依次在循环中弹出堆元素,如果当前记录的 start, end 和上一个 interval存在重叠,就进行合并,并更新 start end,这个处理和 56题一样。\n", 811 | "\n", 812 | "如果不重叠,则表示他们之间有 gap,记录这个 gap到结果集中,同时更新 start,end。当堆元素都pop完毕,当前的 start 和 end 是所以merge合并的并集,因此不需要加入结果集中。\n", 813 | "\n", 814 | "上述的算法中,堆排序需要的时间复杂度是 O(NlogM),M是员工的个数,迭代堆的操作是线性,堆pop的操作调整是 O(logM),最终的复杂度也是 O(NlogM),\n", 815 | "\n", 816 | "至此,大部分关于 interval merge 的技巧都介绍了。剩下的就是如何综合的使用这些技巧,例如 geeksforgeeks 有一道题目\n", 817 | "\n", 818 | "\n", 819 | "最大CPU负载,cpu负载对于程序员来说是再熟悉不过了。计算某个时间的最大负载算法就用到了区间相关技术,题目为\n", 820 | "\n", 821 | "> 我们得到了一份乔布斯的名单。 每个作业在运行时都有一个开始时间、一个结束时间和一个 CPU 负载。 我们的目标是,如果所有作业都在同一台机器上运行,那么在任何时候都能找到最大的 CPU 负载。\n", 822 | ">\n", 823 | "> 示例一:\n", 824 | "> 输入: [[1,4,3], [2,5,4], [7,9,6]]\n", 825 | "> 输出: 7\n", 826 | "> 说明: 区间 [1,4,3] 和 [2,5,4] 有重叠, 那么最大 CPU load (3+4=7) ,另外一个区间 [7, 9] 的load仅仅是 6\n", 827 | "> \n", 828 | "> 示例二:\n", 829 | "> 输入: [[6,7,10], [2,4,11], [8,12,15]]\n", 830 | "> 输出: 15\n", 831 | "> 说明: 没有重叠的区间,最大的 load 就是最后一个区间的load,15.\n", 832 | "> \n", 833 | "> 示例三:\n", 834 | "> 输入: [[1,4,2], [2,4,1], [3,6,5]]\n", 835 | "> 输出: 8\n", 836 | "> 说明: 三个区间都有重叠,最大的load就是三个区间的load之和 8\n", 837 | "\n", 838 | "这一题贴近工作,但是初看也会有点迷糊。仔细思考一下,无非还是需要求所有 interval的merge交集,在求merge交集的同时计算 merge 的第三个参数,即 cpu的load。\n", 839 | "\n", 840 | "那么就转换成了[253.会议室II](https://leetcode-cn.com/problems/meeting-rooms-ii/)。\n", 841 | "\n", 842 | "使用一个最小堆存储有重叠的 interval,并且计算 重叠的interval的load值为当前的load值 cur_load。当迭代下一个interval 依次和堆中元素进行比较,如果没有重叠,就说明跟这些区间没有关系。然后依次出堆,同时减掉之前用来计数的 cur_load。然后再把当前的interval 入堆,并且计算更新 cur_load 和结果 max_load。\n", 843 | "\n", 844 | "具体代码如下:\n" 845 | ] 846 | }, 847 | { 848 | "cell_type": "code", 849 | "execution_count": 16, 850 | "metadata": {}, 851 | "outputs": [ 852 | { 853 | "name": "stdout", 854 | "output_type": "stream", 855 | "text": [ 856 | "8\n" 857 | ] 858 | } 859 | ], 860 | "source": [ 861 | "### Maximum CPU Load \n", 862 | "\n", 863 | "from heapq import heappop, heappush\n", 864 | "\n", 865 | "class Solution:\n", 866 | " \n", 867 | " def maximumCPULoad(self, jobs: List[List[int]]) -> int:\n", 868 | " jobs.sort(key=lambda x: x[0])\n", 869 | " \n", 870 | " max_load = 0\n", 871 | " cur_load = 0\n", 872 | " min_heap = []\n", 873 | " for i in jobs:\n", 874 | " while len(min_heap) > 0 and min_heap[0][1] <= i[0]:\n", 875 | " job = heappop(min_heap)\n", 876 | " cur_load -= job[2]\n", 877 | " heappush(min_heap, i)\n", 878 | " cur_load += i[2]\n", 879 | " max_load = max(max_load, cur_load)\n", 880 | " return max_load\n", 881 | " \n", 882 | " \n", 883 | "jobs = [[1,4,3], [2,5,4], [7,9,6]]\n", 884 | "jobs = [[6,7,10], [2,4,11], [8,12,15]]\n", 885 | "jobs = [[1,4,2], [2,4,1], [3,6,5]]\n", 886 | "\n", 887 | "ret = Solution().maximumCPULoad(jobs)\n", 888 | "print(ret)" 889 | ] 890 | }, 891 | { 892 | "cell_type": "markdown", 893 | "metadata": {}, 894 | "source": [ 895 | "### 总结\n", 896 | "\n", 897 | "区间合并(interval merge)相关的问题大抵如此。首先需要熟悉两个 interval 之间的关系。开篇介绍了 6 种关系。归纳一下,实际上就三种,1 不重叠;2. 重叠不包含;3. 重叠包含。另外三种是这三种的镜像。对于不重叠的区间,有时候需要学会如何求他们的 gap。对于重叠的区间,有两个算法,就交集和就并集。通常在解决一个 interval是列表的时候,可以优先按照 interval.start 进行排序。然后迭代的过程中,把问题简化成处理 A 和 B 两个interval的关系,无非是 merge 求交集和并集。在输出最后结果的时候,注意贪心策略,求并集的时候,当前的并集未必是整个 列表的并集,需要依次迭代,直到有 gap的区间。另外,对于合并多个列表的区间,也是通过双指针的方式转换处理 A 和 B 的问题,然后再判断移动哪个指针。\n", 898 | "\n", 899 | "最后就是处理当前的 interval可能与上一个,甚至是前几个 interval 进行比较,通常可以借助 堆 数据结构处理比较,既能保证顺序,又有时间复杂度上的优势。\n", 900 | "\n", 901 | "总而言之,化繁为简,多加练习。\n", 902 | "\n", 903 | "PS:leetcode 上的这类题,有不少都需要会员。。。\n", 904 | "\n", 905 | "> [56. 合并区间](https://leetcode-cn.com/problems/merge-intervals)\n", 906 | "> \n", 907 | "> [435. 无重叠区间](https://leetcode-cn.com/problems/non-overlapping-intervals)\n", 908 | "> \n", 909 | "> [57. 插入区间](https://leetcode-cn.com/problems/insert-interval)\n", 910 | "> \n", 911 | "> [986. 区间列表的交集](https://leetcode-cn.com/problems/interval-list-intersections/)\n", 912 | "> \n", 913 | "> [1229. 安排会议日程](https://leetcode-cn.com/problems/meeting-scheduler/)\n", 914 | "> \n", 915 | "> [252.会议室](https://leetcode-cn.com/problems/meeting-rooms)\n", 916 | "> \n", 917 | "> [253.会议室II](https://leetcode-cn.com/problems/meeting-rooms-ii/) \n", 918 | "> \n", 919 | "> [759. 员工空闲时间](https://leetcode-cn.com/problems/employee-free-time)\n", 920 | ">\n", 921 | "> 来源:力扣(LeetCode) [https://leetcode-cn.com/problemset/all/](https://leetcode-cn.com/problemset/all/)" 922 | ] 923 | } 924 | ], 925 | "metadata": { 926 | "kernelspec": { 927 | "display_name": "Python 3", 928 | "language": "python", 929 | "name": "python3" 930 | }, 931 | "language_info": { 932 | "codemirror_mode": { 933 | "name": "ipython", 934 | "version": 3 935 | }, 936 | "file_extension": ".py", 937 | "mimetype": "text/x-python", 938 | "name": "python", 939 | "nbconvert_exporter": "python", 940 | "pygments_lexer": "ipython3", 941 | "version": "3.7.3" 942 | } 943 | }, 944 | "nbformat": 4, 945 | "nbformat_minor": 2 946 | } 947 | -------------------------------------------------------------------------------- /groking-leetcode/5-cyclic-sort.ipynb: -------------------------------------------------------------------------------- 1 | { 2 | "cells": [ 3 | { 4 | "cell_type": "markdown", 5 | "metadata": {}, 6 | "source": [ 7 | "### 循环排序(Cyclic sort)\n", 8 | "\n", 9 | "循环排序是一项重要的技巧。通常可以用来高效的解决一种未排序,元素值为 1-n 的数组(n为数组长度)。既然数组的元素都是其长度范围内的元素,如果这个数组排序,那么数组元素的值和其索引有着一一对应的关系。\n", 10 | "\n", 11 | "例如数组 `[3, 1, 2, 4, 5]` 排序后是 `[1, 2, 4, 3, 5]`\n", 12 | "\n", 13 | "```\n", 14 | "arr[0] = 1\n", 15 | "arr[1] = 2\n", 16 | "arr[2] = 3\n", 17 | "arr[3] = 4\n", 18 | "arr[4] = 5\n", 19 | "```\n", 20 | "\n", 21 | "从排序后的数组可以看出,元素的的 `值 == 索引 + 1`。即元素都在其**正确**的位置上。如果元素不在其正确的位置上,那么这个元素可能是重复的数字,对应的索引可能就是缺失的数字。\n", 22 | "\n", 23 | "因此通过循环排序可以解决leetcode 上类似寻找数组缺失数字,重复数字等问题。\n", 24 | "\n", 25 | "那么什么是循环排序呢?\n", 26 | "\n", 27 | "循环排序的宗旨就是通过迭代数组,判断当前索引的元素是否在其正确的位置上,如果在旧迭代下一步,如果就把元素跟它应该在的位置的元素交换。总结下来就两个步骤\n", 28 | "\n", 29 | "1. 迭代当前数组\n", 30 | "2. 判断当前元素是否在其正确的索引位置,如果在则继续迭代,如果不在则与其正确索引位置的元素交换\n", 31 | "\n", 32 | "其 伪代码如下\n", 33 | "\n", 34 | "```\n", 35 | "i = 0\n", 36 | "\n", 37 | "while i < len(nums):\n", 38 | " j = get_next_index(nums[i])\n", 39 | " if nums[i] != nums[j]:\n", 40 | " nums[i], nums[j] = nums[j], nums[i]\n", 41 | " else:\n", 42 | " i += 1\n", 43 | " \n", 44 | "```\n", 45 | "\n", 46 | "结果这样处理,数组就\"排序\"好了,所谓排序指大部分元素都在其正确的索引位置,但是有些元素不在,这些元素可能是重复元素。直观上略抽象,下面通过真题解析。\n", 47 | "\n", 48 | "### cylic sort\n", 49 | "\n", 50 | "> 给定一个数组,其元素的值是 1 - n,n为数组的长度。每个元素都是unqiue。使用O(N)的复杂度原地排序\n", 51 | ">\n", 52 | "> 示例 1:\n", 53 | ">\n", 54 | "> 输入: [3, 1, 5, 4, 2]\n", 55 | ">\n", 56 | "> 输出: [1, 2, 3, 4, 5]\n", 57 | ">\n", 58 | "> 示例 2:\n", 59 | ">\n", 60 | "> 输入: [2, 6, 4, 3, 1, 5]\n", 61 | ">\n", 62 | "> 输出: [1, 2, 3, 4, 5, 6]\n", 63 | ">\n", 64 | "> 示例 3:\n", 65 | ">\n", 66 | "> 输入: [1, 5, 6, 4, 3, 2]\n", 67 | ">\n", 68 | "> 输出: [1, 2, 3, 4, 5, 6]\n", 69 | "\n", 70 | "套用上面 cyclic sort 的模板,很容易写入下面的代码:" 71 | ] 72 | }, 73 | { 74 | "cell_type": "code", 75 | "execution_count": 3, 76 | "metadata": {}, 77 | "outputs": [ 78 | { 79 | "name": "stdout", 80 | "output_type": "stream", 81 | "text": [ 82 | "[1, 2, 3, 4, 5, 6]\n" 83 | ] 84 | } 85 | ], 86 | "source": [ 87 | "class Solution:\n", 88 | " def cyclicSort(self, nums):\n", 89 | " i = 0\n", 90 | " while i < len(nums):\n", 91 | " j = nums[i] - 1\n", 92 | " if nums[i] != nums[j]:\n", 93 | " nums[i], nums[j] = nums[j], nums[i]\n", 94 | " else:\n", 95 | " i += 1\n", 96 | " return nums\n", 97 | " \n", 98 | "\n", 99 | "nums = [2, 6, 4, 3, 1, 5]\n", 100 | "ret = Solution().cyclicSort(nums)\n", 101 | "print(ret)\n" 102 | ] 103 | }, 104 | { 105 | "cell_type": "markdown", 106 | "metadata": {}, 107 | "source": [ 108 | "上述过程如图所示:\n", 109 | "\n", 110 | "![cyclic sort](./img/cyclic-sort.png)\n", 111 | "\n", 112 | "\n", 113 | "循环迭代数组的时候,当前元素的正确位置索引为其值 - 1。如果当前值不在正确位置,需要进行交换。直到其索引和值正确。这个过程中,i 并不会变化,即看起来有一个 内部 循环。那么时间复杂度是 `O(N*N)`吗?\n", 114 | "\n", 115 | "实际上不是,因为每次交换,都有一个元素正确了,那么下次迭代到它的时候,自然也不用再交换。而处理当前元素的时候,最坏的情况下需要交互 N-1个元素。而剩下的元素都不用再交互,只有正常的迭代。最终复杂度是 O(N)+O(N−1) ,即线性复杂度。由于是原地排序,空间复杂度是 O(1)。\n", 116 | "\n", 117 | "\n", 118 | "### 重复数\n", 119 | "\n", 120 | "基于循环排序,如果数组的元素不是unique,有重复的元素,有一类题就是找出这些重复的数字。\n", 121 | "\n", 122 | "[287. 寻找重复数](https://leetcode-cn.com/problems/find-the-duplicate-number/)\n", 123 | "\n", 124 | "> 给定一个包含 n + 1 个整数的数组 nums,其数字都在 1 到 n 之间(包括 1 和 n),可知至少存在一个重复的整数。假设只有一个重复的整数,找出这个重复的数。 \n", 125 | "> \n", 126 | "> 示例 1:\n", 127 | "> \n", 128 | "> 输入: [1,3,4,2,2]\n", 129 | "> \n", 130 | "> 输出: 2\n", 131 | "> \n", 132 | "> 示例 2:\n", 133 | "> \n", 134 | "> 输入: [3,1,3,4,2]\n", 135 | "> \n", 136 | "> 输出: 3\n", 137 | "> \n", 138 | "> 说明:\n", 139 | "> \n", 140 | "> 1. 不能更改原数组(假设数组是只读的)。\n", 141 | "> 2. 只能使用额外的 O(1) 的空间。\n", 142 | "> 3. 时间复杂度小于 O(n2) 。\n", 143 | "> 4. 数组中只有一个重复的数字,但它可能不止重复出现一次。\n", 144 | "\n", 145 | "从题意保证输入只有一个重复的元素,并且符合循环排序的条件(元素是 1->n)。使用循环排序的技巧。那么就有元素不在其正确的索引位置上,这个元素就是重复的元素。但是这样做其实不符合题意,因为题目说明要求不能改变原来的数组,cylic sort 会改变数组,这里使用 cylic sort 只是为了说明可以通过循环排序找出重复数。" 146 | ] 147 | }, 148 | { 149 | "cell_type": "code", 150 | "execution_count": 2, 151 | "metadata": {}, 152 | "outputs": [ 153 | { 154 | "name": "stdout", 155 | "output_type": "stream", 156 | "text": [ 157 | "4\n" 158 | ] 159 | } 160 | ], 161 | "source": [ 162 | "class Solution:\n", 163 | " def findDuplicate(self, nums):\n", 164 | " i = 0\n", 165 | " while i < len(nums):\n", 166 | " j = nums[i] - 1\n", 167 | " if nums[i] != nums[j]:\n", 168 | " nums[i], nums[j] = nums[j], nums[i]\n", 169 | " else:\n", 170 | " i += 1\n", 171 | "\n", 172 | " for i in range(len(nums)):\n", 173 | " if nums[i] != i + 1:\n", 174 | " return nums[i]\n", 175 | " return -1\n", 176 | "\n", 177 | "\n", 178 | "nums = [1, 4, 4, 3, 2]\n", 179 | "nums = [2, 1, 3, 3, 5, 4]\n", 180 | "nums = [2, 4, 1, 4, 4]\n", 181 | "ret = Solution().findDuplicate(nums)\n", 182 | "print(ret)\n" 183 | ] 184 | }, 185 | { 186 | "cell_type": "markdown", 187 | "metadata": {}, 188 | "source": [ 189 | "时间复杂度是O(N),空间复杂度是 O(1)。正如前面所说,不能改变数组,正确的解法可以使用快慢指针。" 190 | ] 191 | }, 192 | { 193 | "cell_type": "markdown", 194 | "metadata": {}, 195 | "source": [ 196 | "### 缺失的数字\n", 197 | "\n", 198 | "有元素重复了,那么重复的元素必然占据了其索引本来应该所在的元素。那么那个元素就是确实的数字,另外一类题:\n", 199 | "\n", 200 | "\n", 201 | "[268. 缺失数字](链接:https://leetcode-cn.com/problems/missing-number)\n", 202 | "\n", 203 | "> 给定一个包含 0, 1, 2, ..., n 中 n 个数的序列,找出 0 .. n 中没有出现在序列中的那个数。\n", 204 | "> \n", 205 | "> 示例 1:\n", 206 | "> \n", 207 | "> 输入: [3,0,1]\n", 208 | "> \n", 209 | "> 输出: 2\n", 210 | "> \n", 211 | "> 示例 2:\n", 212 | "> \n", 213 | "> 输入: [9,6,4,2,3,5,7,0,1]\n", 214 | "> \n", 215 | "> 输出: 8\n", 216 | "> \n", 217 | "> 说明: 你的算法应具有线性时间复杂度。你能否仅使用额外常数空间来实现?\n", 218 | "> \n" 219 | ] 220 | }, 221 | { 222 | "cell_type": "code", 223 | "execution_count": 7, 224 | "metadata": {}, 225 | "outputs": [], 226 | "source": [ 227 | "class Solution:\n", 228 | " def missingNumber(self, nums: List[int]) -> int:\n", 229 | " i = 0\n", 230 | " while i < len(nums):\n", 231 | " j = nums[i] - 1\n", 232 | " if nums[i] > 0 and nums[i] != nums[j]:\n", 233 | " nums[i], nums[j] = nums[j], nums[i]\n", 234 | " else:\n", 235 | " i += 1\n", 236 | " for i in range(len(nums)):\n", 237 | " if nums[i] == 0:\n", 238 | " return i + 1\n", 239 | " return 0" 240 | ] 241 | }, 242 | { 243 | "cell_type": "markdown", 244 | "metadata": {}, 245 | "source": [ 246 | "这题的关键是数组的元素包含 0,0 在这样的数组越界。对于越界的元素可以忽略不管。最后再找出这些索引位置不对的元素即可,类似的还有下面一题:\n", 247 | "\n", 248 | "\n", 249 | "[448. 找到所有数组中消失的数字](https://leetcode-cn.com/problems/find-all-numbers-disappeared-in-an-array)\n", 250 | "\n", 251 | "> 给定一个范围在  1 ≤ a[i] ≤ n ( n = 数组大小 ) 的 整型数组,数组中的元素一些出现了两次,另一些只出现一次。\n", 252 | "> 找到所有在 [1, n] 范围之间没有出现在数组中的数字。\n", 253 | "> 您能在不使用额外空间且时间复杂度为O(n)的情况下完成这个任务吗? 你可以假定返回的数组不算在额外空间内。\n", 254 | "> \n", 255 | "> 示例:\n", 256 | "> \n", 257 | "> 输入:\n", 258 | "> \n", 259 | "> [4,3,2,7,8,2,3,1]\n", 260 | "> \n", 261 | "> 输出:\n", 262 | "> \n", 263 | "> [5,6]\n", 264 | "\n", 265 | "\n", 266 | "使用循环排序技巧将数组排序,然后再迭代数组,找出索引不在正确位置上的数。它的 索引 + 1 就是缺失的数字:" 267 | ] 268 | }, 269 | { 270 | "cell_type": "code", 271 | "execution_count": 4, 272 | "metadata": {}, 273 | "outputs": [ 274 | { 275 | "name": "stdout", 276 | "output_type": "stream", 277 | "text": [ 278 | "[5, 6]\n" 279 | ] 280 | } 281 | ], 282 | "source": [ 283 | "from typing import *\n", 284 | "\n", 285 | "# 448 \n", 286 | "class Solution:\n", 287 | " def findDisappearedNumbers(self, nums: List[int]) -> List[int]:\n", 288 | " i = 0 \n", 289 | " while i < len(nums):\n", 290 | " j = nums[i] - 1\n", 291 | " if nums[i] != nums[j]:\n", 292 | " nums[i], nums[j] = nums[j], nums[i]\n", 293 | " else:\n", 294 | " i += 1\n", 295 | " return [i + 1 for i in range(len(nums)) if nums[i] != i + 1]\n", 296 | " \n", 297 | "nums = [4, 3, 2, 7, 8, 2, 3, 1]\n", 298 | "ret = Solution().findDisappearedNumbers(nums)\n", 299 | "print(ret)" 300 | ] 301 | }, 302 | { 303 | "cell_type": "markdown", 304 | "metadata": {}, 305 | "source": [ 306 | "通过上面两题,可以了解循环排序通常找两类数\n", 307 | "\n", 308 | "1. 重复数:不在正确索引位置上的数\n", 309 | "2. 缺失数:不在正确索引位置上的数的索引+1\n", 310 | "\n", 311 | "关键都是找出不在正确位置上的数和其索引\n", 312 | "\n", 313 | "下面一题正是将两者结合\n", 314 | "\n", 315 | "[645. 错误的集合](https://leetcode-cn.com/problems/set-mismatch)\n", 316 | "\n", 317 | "> 集合 S 包含从1到 n 的整数。不幸的是,因为数据错误,导致集合里面某一个元素复制了成了集合里面的另外一个元素的值,导致集合丢失了一个整数并且有一个元素重复。\n", 318 | ">\n", 319 | "> 给定一个数组 nums 代表了集合 S 发生错误后的结果。你的任务是首先寻找到重复出现的整数,再找到丢失的整数,将它们以数组的形式返回。\n", 320 | ">\n", 321 | "> 示例 1:\n", 322 | ">\n", 323 | "> 输入: nums = [1,2,2,4]\n", 324 | ">\n", 325 | "> 输出: [2,3]\n", 326 | ">\n", 327 | "> 注意:\n", 328 | ">\n", 329 | "> 给定数组的长度范围是 [2, 10000]。\n", 330 | ">\n", 331 | "> 给定的数组是无序的。" 332 | ] 333 | }, 334 | { 335 | "cell_type": "code", 336 | "execution_count": 5, 337 | "metadata": {}, 338 | "outputs": [ 339 | { 340 | "name": "stdout", 341 | "output_type": "stream", 342 | "text": [ 343 | "[2, 3]\n" 344 | ] 345 | } 346 | ], 347 | "source": [ 348 | "class Solution:\n", 349 | " def findErrorNums(self, nums: List[int]) -> List[int]:\n", 350 | " i = 0\n", 351 | " while i < len(nums):\n", 352 | " j = nums[i] - 1\n", 353 | " if nums[i] != nums[j]:\n", 354 | " nums[i], nums[j] = nums[j], nums[i]\n", 355 | " else:\n", 356 | " i += 1\n", 357 | " \n", 358 | " for i in range(len(nums)):\n", 359 | " if nums[i] != i + 1:\n", 360 | " return [nums[i], i+1]\n", 361 | " return []\n", 362 | " \n", 363 | "\n", 364 | " \n", 365 | "nums = [1,2,2,4]\n", 366 | "ret = Solution().findErrorNums(nums)\n", 367 | "print(ret)\n", 368 | "\n" 369 | ] 370 | }, 371 | { 372 | "cell_type": "markdown", 373 | "metadata": {}, 374 | "source": [ 375 | "`[nums[i], i+1]` 正好是**重复数**和**缺失数**。时间复杂度依然是常数,也没有使用额外的空间。\n", 376 | "\n", 377 | "### 越界处理\n", 378 | "\n", 379 | "循环排序的重要条件就是数组元素范围在 1 - n内,就像上面第268题一样,有些问题的数组元素其实是会越界。当然,对于这种问题,也可以分析一下是否可以使用循环排序。一个tips就是对于找缺失或者重复数,就可以尝试一下。例如下面这题:\n", 380 | "\n", 381 | "[41. 缺失的第一个正数](https://leetcode-cn.com/problems/first-missing-positive/)\n", 382 | "\n", 383 | "\n", 384 | "> 给定一个未排序的整数数组,找出其中没有出现的最小的正整数。\n", 385 | "> \n", 386 | "> 示例 1:\n", 387 | "> \n", 388 | "> 输入: [1,2,0]\n", 389 | "> \n", 390 | "> 输出: 3\n", 391 | "> \n", 392 | "> 示例 2:\n", 393 | "> \n", 394 | "> 输入: [3,4,-1,1]\n", 395 | "> \n", 396 | "> 输出: 2\n", 397 | "> \n", 398 | "> 示例 3:\n", 399 | "> \n", 400 | "> 输入: [7,8,9,11,12]\n", 401 | "> \n", 402 | "> 输出: 1\n", 403 | "> \n", 404 | "> 说明:\n", 405 | "> \n", 406 | "> 你的算法的时间复杂度应为O(n),并且只能使用常数级别的空间。\n", 407 | "\n", 408 | "找出最小的正整数,按照循环排序的思路,对于越界的元素搁置不管。那么等\"排序\"结束后,错误位置的数字,那么就是目标要求的数。\n", 409 | "\n", 410 | "代码如下:\n", 411 | "\n" 412 | ] 413 | }, 414 | { 415 | "cell_type": "code", 416 | "execution_count": 8, 417 | "metadata": {}, 418 | "outputs": [ 419 | { 420 | "name": "stdout", 421 | "output_type": "stream", 422 | "text": [ 423 | "[7, 8, 9, 11, 12]\n" 424 | ] 425 | } 426 | ], 427 | "source": [ 428 | "# 41\n", 429 | "class Solution:\n", 430 | " def firstMissingPositive(self, nums: List[int]) -> int:\n", 431 | " if not nums:\n", 432 | " return 1\n", 433 | " \n", 434 | " i = 0\n", 435 | " while i < len(nums):\n", 436 | " \n", 437 | " j = nums[i] - 1\n", 438 | " if 0 < nums[i] and j < len(nums) and nums[i] != nums[j]:\n", 439 | " nums[i], nums[j] = nums[j], nums[i]\n", 440 | " else:\n", 441 | " # 越界,处于正确位置\n", 442 | " i += 1\n", 443 | "\n", 444 | " for i in range(len(nums)):\n", 445 | " if i != nums[i] - 1:\n", 446 | " return i + 1\n", 447 | " \n", 448 | " # 全都在正确的位置\n", 449 | " return len(nums) + 1\n", 450 | " \n", 451 | "nums = [7,8,9,11,12]\n", 452 | "print(nums)\n", 453 | "\n" 454 | ] 455 | }, 456 | { 457 | "cell_type": "markdown", 458 | "metadata": {}, 459 | "source": [ 460 | "尽管这是一道 Hard 题目,通过循环排序的技巧,解题思路还是比较清晰。这种问题的还有一个变种,就是不再是找最小的正整数,而是找k个最小数。\n", 461 | "\n", 462 | "需要注意的是,对于应排序好的全是正整数的数组,那么最终返回的值也会越界,即是 数组长度 + 1。\n", 463 | "\n", 464 | "那么代码如下:" 465 | ] 466 | }, 467 | { 468 | "cell_type": "code", 469 | "execution_count": 11, 470 | "metadata": {}, 471 | "outputs": [ 472 | { 473 | "name": "stdout", 474 | "output_type": "stream", 475 | "text": [ 476 | "[1, 5, 6]\n" 477 | ] 478 | } 479 | ], 480 | "source": [ 481 | "class Solution:\n", 482 | " \n", 483 | " def findFirstKMissingPositive(self, nums, k):\n", 484 | " missingNumbers = []\n", 485 | " i = 0 \n", 486 | " while i < len(nums):\n", 487 | " j = nums[i] - 1\n", 488 | " if 0 < nums[i] and j < len(nums) and nums[i] != nums[j]:\n", 489 | " nums[i], nums[j] = nums[j], nums[i]\n", 490 | " else:\n", 491 | " i += 1\n", 492 | "\n", 493 | " extra_nums = set()\n", 494 | " for i in range(len(nums)):\n", 495 | " if nums[i] != i + 1 and len(missingNumbers) < k:\n", 496 | " missingNumbers.append(i+1)\n", 497 | " extra_nums.add(nums[i])\n", 498 | "\n", 499 | " i = 1 \n", 500 | " while len(missingNumbers) < k:\n", 501 | " cur_num = len(nums) + i\n", 502 | " if cur_num not in extra_nums:\n", 503 | " missingNumbers.append(cur_num)\n", 504 | " i += 1\n", 505 | " return missingNumbers\n", 506 | "\n", 507 | "nums = [3, -1, 4, 5, 5]\n", 508 | "k = 3\n", 509 | "\n", 510 | "nums = [2, 3, 4]\n", 511 | "k = 3\n", 512 | "\n", 513 | "ret = Solution().findFirstKMissingPositive(nums, k)\n", 514 | "print(ret)\n", 515 | "\n" 516 | ] 517 | }, 518 | { 519 | "cell_type": "markdown", 520 | "metadata": {}, 521 | "source": [ 522 | "### 情侣牵手\n", 523 | "\n", 524 | "循环排序的重要技巧是**交换**,交换之后能让部分元素的位置正确。通过交换来处理元素在leetcode还有一题,虽然标签循环排序,而是贪心算法。但是借用循环排序的交换元素的技巧,解题非常方便。\n", 525 | "\n", 526 | "[765. 情侣牵手](https://leetcode-cn.com/problems/couples-holding-hands)\n", 527 | "\n", 528 | "> N 对情侣坐在连续排列的 2N 个座位上,想要牵到对方的手。 计算最少交换座位的次数,以便每对情侣可以并肩坐在一起。 一次交换可选择任意两人,让他们站起来交换座位。\n", 529 | "> \n", 530 | "> 人和座位用 0 到 2N-1 的整数表示,情侣们按顺序编号,第一对是 (0, 1),第二对是 (2, 3),以此类推,最后一对是 (2N-2, 2N-1)。\n", 531 | "> \n", 532 | "> 这些情侣的初始座位  row[i] 是由最初始坐在第 i 个座位上的人决定的。\n", 533 | "> \n", 534 | "> 示例 1:\n", 535 | "> \n", 536 | "> 输入: row = [0, 2, 1, 3]\n", 537 | "> \n", 538 | "> 输出: 1\n", 539 | "> \n", 540 | "> 解释: 我们只需要交换row[1]和row[2]的位置即可。\n", 541 | "> \n", 542 | "> 示例 2:\n", 543 | "> \n", 544 | "> 输入: row = [3, 2, 0, 1]\n", 545 | "> \n", 546 | "> 输出: 0\n", 547 | "> \n", 548 | "> 解释: 无需交换座位,所有的情侣都已经可以手牵手了。\n", 549 | "> \n", 550 | "> 说明: len(row) 是偶数且数值在 [4, 60]范围内。 可以保证row 是序列 0...len(row)-1 的一个全排列。" 551 | ] 552 | }, 553 | { 554 | "cell_type": "markdown", 555 | "metadata": {}, 556 | "source": [ 557 | "从题意可知,判断两个元素是否是情侣可以通过定义得出,\n", 558 | "\n", 559 | "```\n", 560 | "2N-2 和 2N-1。N = (index // 2) + 1\n", 561 | "```\n", 562 | "\n", 563 | "但是这种相邻的数字,使用 `^`异或求解更方便。\n", 564 | "\n", 565 | "解题的思路就是使用贪心策略,假设当前的元素是正确的,那么就需要在剩余的元素中找到他的情侣。一旦找到,就处理好一对情侣,接着处理下一对,如果本身已经是正确的情侣,就直接跳过即可。代码如下:\n" 566 | ] 567 | }, 568 | { 569 | "cell_type": "code", 570 | "execution_count": 13, 571 | "metadata": {}, 572 | "outputs": [], 573 | "source": [ 574 | "class Solution:\n", 575 | "\n", 576 | " def minSwapsCouples(self, row: List[int]) -> int:\n", 577 | " ret = 0\n", 578 | " i = 0\n", 579 | " while i < len(row):\n", 580 | " if not self.isCouple(row[i], row[i + 1]):\n", 581 | " j = i + 1\n", 582 | " while j < len(row):\n", 583 | " if self.isCouple(row[i], row[j]):\n", 584 | " row[i + 1], row[j] = row[j], row[i + 1]\n", 585 | " ret += 1\n", 586 | " break\n", 587 | " j += 1\n", 588 | " i += 2\n", 589 | " return ret\n", 590 | "\n", 591 | " def isCouple(self, i, j):\n", 592 | " return i ^ 1 == j" 593 | ] 594 | }, 595 | { 596 | "cell_type": "markdown", 597 | "metadata": {}, 598 | "source": [ 599 | "### 总结\n", 600 | "\n", 601 | "Cyclic Sort 模式的套路也比较清晰。大致2-3步,第一步迭代这个数组进行排序,第二步在处理当前元素的时候,判断其是否在其正确的索引位置,只要不是正确的位置就找到正确位置元素进行交换,最后一步就是再次迭代\"排序\"好的数组,找出不在正确索引位置的那个数和其索引,就是通常需要找的 重复数 和 缺失数。\n", 602 | "\n", 603 | "> [287. 寻找重复数](https://leetcode-cn.com/problems/find-the-duplicate-number/)\n", 604 | "> \n", 605 | "> [268. 缺失数字](https://leetcode-cn.com/problems/missing-number)\n", 606 | "> \n", 607 | "> [448. 找到所有数组中消失的数字](https://leetcode-cn.com/problems/find-all-numbers-disappeared-in-an-array/)\n", 608 | "> \n", 609 | "> [645. 错误的集合](https://leetcode-cn.com/problems/set-mismatch)\n", 610 | "> \n", 611 | "> [41. 缺失的第一个正数](https://leetcode-cn.com/problems/first-missing-positive/)\n", 612 | "> \n", 613 | "> [765. 情侣牵手](https://leetcode-cn.com/problems/couples-holding-hands)\n", 614 | ">\n", 615 | "> 来源:力扣(LeetCode) [https://leetcode-cn.com/problemset/all/](https://leetcode-cn.com/problemset/all/)" 616 | ] 617 | } 618 | ], 619 | "metadata": { 620 | "kernelspec": { 621 | "display_name": "Python 3", 622 | "language": "python", 623 | "name": "python3" 624 | }, 625 | "language_info": { 626 | "codemirror_mode": { 627 | "name": "ipython", 628 | "version": 3 629 | }, 630 | "file_extension": ".py", 631 | "mimetype": "text/x-python", 632 | "name": "python", 633 | "nbconvert_exporter": "python", 634 | "pygments_lexer": "ipython3", 635 | "version": "3.7.3" 636 | } 637 | }, 638 | "nbformat": 4, 639 | "nbformat_minor": 2 640 | } 641 | -------------------------------------------------------------------------------- /groking-leetcode/README.md: -------------------------------------------------------------------------------- 1 | # Leetcode Pattern 2 | 3 | [0. Leetcode Pattern](./README.md) 4 | 5 | [1. 滑动窗口(Sliding Window)](./1-sliding-window.ipynb) 6 | 7 | [2. 对撞指针(Collide Pointer)](./2-two-pointers.ipynb) 8 | 9 | [3. 快慢指针(Fast Slow Pointer)](./3-fast-slow-pointer.ipynb) 10 | 11 | [4. 区间合并(Interval Merge)](./4-interval-merge.ipynb) 12 | 13 | [5. 循环排序(Cylic Sort)](./5-cyclic-sort.ipynb) 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /groking-leetcode/img/cyclic-sort.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rsj217/leetcode-solution/243f6dafa824c9ad5b7ad054d1c0bff5f1f6b3b2/groking-leetcode/img/cyclic-sort.png --------------------------------------------------------------------------------