|
| 1 | +/*! https://leetcode.com/problems/number-of-ways-to-stay-in-the-same-place-after-some-steps/ |
| 2 | +
|
| 3 | +# 题解: 借鉴数据库缓存解决动态规划困难题 |
| 4 | +
|
| 5 | +分享下 leetcode 困难题[停在原地的方案数](https://leetcode-cn.com/problems/number-of-ways-to-stay-in-the-same-place-after-some-steps/) |
| 6 | +不断推敲和优化逐步通过题目的过程 |
| 7 | +
|
| 8 | +看到这种不同路径求方案总数,很容易想到 [unique_path](https://leetcode-cn.com/problems/unique-paths/) 这道动态规划入门题, |
| 9 | +
|
| 10 | +这题跟 unique_path 一样也是「求从起点到终点不同行走路径的方案总数」,自然想到用动态规划去实现 |
| 11 | +
|
| 12 | +## 无记忆化的搜索 |
| 13 | +由于动态规划迭代解法的状态表达较难抽象,于是我先写出更简单动态规划递归的无记忆化搜索版本 |
| 14 | +
|
| 15 | +### 递归的结束条件 |
| 16 | +那么递归的结束条件显然是「剩余步数」为 0 |
| 17 | +
|
| 18 | +### 解答的更新条件 |
| 19 | +方案数的更新条件则是 剩余步数为 0 且 当前位置也是 0,这时候可以将方案数+1 |
| 20 | +
|
| 21 | +### 递归函数的入参 |
| 22 | +首先需要当前位置和当前剩余步数两个"可变"的入参 |
| 23 | +
|
| 24 | +再需要一个"常数"表达最大可前往的位置,一旦移动到数组的右边界,下一步就只能原地走或向左走 |
| 25 | +
|
| 26 | +最后需要一个已发现方案总数的可变指针,用来更新解答集 |
| 27 | +
|
| 28 | +### 递归搜索的决策层 |
| 29 | +
|
| 30 | +只能在数组范围 [0, arr_len] 行走,行走方向 原地不动、向左、向右 三种 |
| 31 | +
|
| 32 | +1. 如果当前坐标是 0, 则只能 原地不动 或 向右 |
| 33 | +2. 如果当前坐标是 arr_len-1,则只能 原地不动 或 向左 |
| 34 | +3. 其余情况的行走方向决策则是 原地不动 或 向左 或 向右 |
| 35 | +
|
| 36 | +### 无记忆化搜索代码 |
| 37 | +
|
| 38 | +```rust |
| 39 | +fn num_ways_dfs(cur_position: i32, remain_steps: i32, max_position: i32, plans_count: &mut u32) { |
| 40 | + if remain_steps == 0 { |
| 41 | + if cur_position == 0 { |
| 42 | + // panicked at 'attempt to add with overflow' |
| 43 | + *plans_count += 1; |
| 44 | + } |
| 45 | + return; |
| 46 | + } |
| 47 | +
|
| 48 | + // 剪枝: 走的太远不可能移动回原点的情况 |
| 49 | + if cur_position > remain_steps { |
| 50 | + return; |
| 51 | + } |
| 52 | +
|
| 53 | + // 做决策 |
| 54 | + // 决策: 原地不动 |
| 55 | + num_ways_dfs(cur_position, remain_steps-1, max_position, plans_count); |
| 56 | + if cur_position == 0 { |
| 57 | + // 只能向右 |
| 58 | + num_ways_dfs(cur_position+1, remain_steps-1, max_position, plans_count); |
| 59 | + } else if cur_position == max_position { |
| 60 | + // 只能向左 |
| 61 | + num_ways_dfs(cur_position-1, remain_steps-1, max_position, plans_count); |
| 62 | + } else { |
| 63 | + num_ways_dfs(cur_position+1, remain_steps-1, max_position, plans_count); |
| 64 | + num_ways_dfs(cur_position-1, remain_steps-1, max_position, plans_count); |
| 65 | + } |
| 66 | +} |
| 67 | +
|
| 68 | +fn num_ways_dfs_entrance(steps: i32, arr_len: i32) -> i32 { |
| 69 | + let mut plans_count = 0; |
| 70 | + num_ways_dfs(0, steps, arr_len-1, &mut plans_count); |
| 71 | + (plans_count % (10_u32.pow(9)+7)) as i32 |
| 72 | +} |
| 73 | +``` |
| 74 | +
|
| 75 | +虽然我加上了递归的剪枝条件,但是 leetcode 上只过了 1/3 的测试用例便在 (27,7) 这个测试用例上超时了 |
| 76 | +
|
| 77 | +不仅如此,更新方案总数时还出现 u32 溢出的问题,我粗略估算下该函数的时间复杂度是 O(3^n) 指数级别的时间复杂度,其中 n 为剩余步数 |
| 78 | +
|
| 79 | +### 非线性递归导致超时? |
| 80 | +
|
| 81 | +所谓线性递归大概指递归的决策层只有一个分支,或者说递归搜索树只有一个分支 |
| 82 | +
|
| 83 | +像我上述代码的决策层有 向左/向右/原地不动 三种决策的就显然是个非线性递归,通常都很慢需要剪枝或记忆化才能提速 |
| 84 | +
|
| 85 | +## 记忆化搜索 |
| 86 | +
|
| 87 | +### 斐波那契递归的记忆化 |
| 88 | +
|
| 89 | +斐波那契递归解法也是个典型的非线性递归 |
| 90 | +
|
| 91 | +假设斐波那契数列的第 n 项为 fib(n),很容易想到斐波那契数列的 fib(3) 的搜索树可以展开为: |
| 92 | +
|
| 93 | +> fib(3)=fib(2)+fib(1)=(fib(1)+fib(0))+fib(1)=2*fib(1)+fib(0) |
| 94 | +
|
| 95 | +我们发现 fib(1) 被重复计算了两次,所以业界有种「记忆化搜索」的优化策略 |
| 96 | +
|
| 97 | +具体实现是定义一个 HashMap,key 为递归函数的入参,value 为该入参情况的计算结果 |
| 98 | +
|
| 99 | +例如计算 fib(3) 的过程中,第一次遇到 fib(1) 这个入参时进行计算,并将计算结果存入 HashMap 中, |
| 100 | +
|
| 101 | +第二次递归调用 fib(1) 时可以直接从 HashMap 中查表取结果而不需要「重复计算」 |
| 102 | +
|
| 103 | +这种优化思路有点像缓存,相信一个无状态的函数同样的入参一定能得到同样的结果,所以第二次遇到同样的入参时直接拿上一次相同入参的计算结果去返回 |
| 104 | +
|
| 105 | +### 记忆化搜索的实现条件 |
| 106 | +
|
| 107 | +我第一版的递归搜索代码中,方案总数作为可变指针参数来传入,这种写法「不能用记忆化搜索优化」 |
| 108 | +
|
| 109 | +因函数 `fn num_ways_dfs(cur_position: i32, remain_steps: i32, max_position: i32, plans_count: &mut u32)` |
| 110 | +
|
| 111 | +**并没有返回值**,我无法实现一个 key 为入参,value 为该入参的上次计算结果返回值这样的记忆化缓存 |
| 112 | +
|
| 113 | +### 逆向思维: 自下而上的递归 |
| 114 | +
|
| 115 | +假设 `f(pos,steps)=plans` 表示从原点出发,当前位置 pos,剩余步数为 steps 的方案总数 plans |
| 116 | +
|
| 117 | +很容易想到 状态转移规律: f(0,0)=f(0,1)+f(1,1) |
| 118 | +
|
| 119 | +也就是终点是原点的前一个状态只能是: 前一个位置是 0 然后选择原地不动 或 前一个位置是 1 然后向左走 |
| 120 | +
|
| 121 | +然后参考「数学归纳法」可以按照相同的规律将 f(0,1) 和 f(1,1) 也展开成子项,直到展开成 f(0, steps) 也就是起点 |
| 122 | +
|
| 123 | +### 记忆化搜索的函数签名 |
| 124 | +
|
| 125 | +```rust |
| 126 | +struct NumWaysHelper { |
| 127 | + max_position: i32, |
| 128 | + steps: i32, |
| 129 | + /// memo |
| 130 | + cache: std::collections::HashMap<(i32, i32), u64> |
| 131 | +} |
| 132 | +
|
| 133 | +impl NumWaysHelper { |
| 134 | + fn dfs(&mut self, cur_pos: i32, remain_steps: i32) -> u64 { |
| 135 | + // TODO 递归结束条件 |
| 136 | +
|
| 137 | + let mut plans_count = 0; |
| 138 | + // 做决策/状态转移 |
| 139 | + // 上一步是: 原地不动 |
| 140 | + // TODO |
| 141 | + if cur_pos == 0 { |
| 142 | + // 上一步是: 向左 |
| 143 | + // TODO |
| 144 | + } else if cur_pos == self.max_position { |
| 145 | + // 上一步是: 向左 |
| 146 | + // TODO |
| 147 | + } else { |
| 148 | + // 上一步是: 向左或向右 |
| 149 | + // TODO |
| 150 | + } |
| 151 | + self.cache.insert((cur_pos, remain_steps), plans_count); |
| 152 | + plans_count |
| 153 | + } |
| 154 | +} |
| 155 | +``` |
| 156 | +
|
| 157 | +### 缓存的写入 |
| 158 | +
|
| 159 | +其中最关键的就是 `self.cache.insert((cur_pos, remain_steps), plans_count);` 这行 |
| 160 | +
|
| 161 | +函数在 return 前先把(当前入参,返回值)这对计算结果「缓存到 HashMap」中 |
| 162 | +
|
| 163 | +### 利用缓存避免重复计算 |
| 164 | +
|
| 165 | +```rust,ignore |
| 166 | +let mut plans_count = 0; |
| 167 | +// 做决策/状态转移 |
| 168 | +// 上一步是: 原地不动 |
| 169 | +if let Some(plans) = self.cache.get(&(cur_pos, remain_steps+1)) { |
| 170 | + plans_count += *plans; |
| 171 | +} else { |
| 172 | + plans_count += self.dfs(cur_pos, remain_steps+1); |
| 173 | +} |
| 174 | +``` |
| 175 | +
|
| 176 | +因为递归调用的开销挺大的,以上上一步是原地不动的决策分支中,一旦发现之前运算过 (cur_pos, remain_steps+1) 的入参情况就直接取缓存中的上次计算结果(因为函数是无状态的,相同的入参一定能得到相同的结果) |
| 177 | +
|
| 178 | +### 记忆化搜索版本的题解 |
| 179 | +
|
| 180 | +```rust |
| 181 | +struct NumWaysHelper { |
| 182 | + max_position: i32, |
| 183 | + steps: i32, |
| 184 | + /// memo |
| 185 | + cache: std::collections::HashMap<(i32, i32), u64> |
| 186 | +} |
| 187 | +
|
| 188 | +impl NumWaysHelper { |
| 189 | + fn dfs(&mut self, cur_pos: i32, remain_steps: i32) -> u64 { |
| 190 | + // TODO 递归结束条件 |
| 191 | + if remain_steps == self.steps { |
| 192 | + if cur_pos == 0 { |
| 193 | + return 1; |
| 194 | + } else { |
| 195 | + // 只有从起点出发的方案才是有效的方案,其余方案都不可取(0) |
| 196 | + return 0; |
| 197 | + } |
| 198 | + } |
| 199 | +
|
| 200 | + let mut plans_count = 0; |
| 201 | + // 做决策/状态转移 |
| 202 | + // 共同的决策分支-上一步是: 原地不动 |
| 203 | + plans_count += self.calc_plans_from_cache(cur_pos, remain_steps+1); |
| 204 | + if cur_pos == 0 { |
| 205 | + // 上一步是: 向左 |
| 206 | + plans_count += self.calc_plans_from_cache(cur_pos+1, remain_steps+1); |
| 207 | + } else if cur_pos == self.max_position { |
| 208 | + // 上一步是: 向右 |
| 209 | + plans_count += self.calc_plans_from_cache(cur_pos-1, remain_steps+1); |
| 210 | + } else { |
| 211 | + // 上一步是: 向左或向右 |
| 212 | + plans_count += self.calc_plans_from_cache(cur_pos+1, remain_steps+1); |
| 213 | + plans_count += self.calc_plans_from_cache(cur_pos-1, remain_steps+1); |
| 214 | + } |
| 215 | + self.cache.insert((cur_pos, remain_steps), plans_count); |
| 216 | + plans_count |
| 217 | + } |
| 218 | +
|
| 219 | + fn calc_plans_from_cache(&mut self, last_pos: i32, last_remain_steps: i32) -> u64 { |
| 220 | + if let Some(plans) = self.cache.get(&(last_pos, last_remain_steps)) { |
| 221 | + *plans |
| 222 | + } else { |
| 223 | + self.dfs(last_pos, last_remain_steps) |
| 224 | + } |
| 225 | + } |
| 226 | +} |
| 227 | +
|
| 228 | +fn num_ways_dfs_entrance(steps: i32, arr_len: i32) -> i32 { |
| 229 | + let mut helper = NumWaysHelper { |
| 230 | + max_position: arr_len-1, |
| 231 | + steps, |
| 232 | + cache: std::collections::HashMap::new() |
| 233 | + }; |
| 234 | + (helper.dfs(0, 0) % (10_u64.pow(9)+7)) as i32 |
| 235 | +} |
| 236 | +``` |
| 237 | +
|
| 238 | +## 本题缓存与数据库缓存的异同 |
| 239 | +
|
| 240 | +MySQL 为了提高短时间相同 Query 的查询速度,会将查询的 SQL 语句计算哈希和对应的查询结果存入 Query Cache |
| 241 | +
|
| 242 | +在缓存的有效期内,遇到第二个相同的 SQL 查询就能直接从缓存中获取上次查询结果进行返回 |
| 243 | +
|
| 244 | +MySQL 将 SQL 语句进行哈希是不是跟我们这题将递归调用的入参元祖作为 key 存入 HashMap 类似? |
| 245 | +
|
| 246 | +除了数据库,graphql 和 dataloader 也是大量用到了缓存,也是将查询计算 hash 作为 key 存入 HashMap 中 |
| 247 | +
|
| 248 | +可以了解下 dataloader 这个 crate 的 [源码](https://docs.rs/dataloader/0.14.0/src/dataloader/cached.rs.html#8) |
| 249 | +是如何进行缓存以及解决 `N+1` 查询的问题的 |
| 250 | +
|
| 251 | +## 解决溢出错误 |
| 252 | +
|
| 253 | +我们记忆化搜索的解法通过了80%的测试用例,但是在输入参数特别大时就出错了 |
| 254 | +
|
| 255 | +``` |
| 256 | +输入: |
| 257 | +93 |
| 258 | +85 |
| 259 | +输出: |
| 260 | +468566822 |
| 261 | +预期结果: |
| 262 | +623333920 |
| 263 | +``` |
| 264 | +
|
| 265 | +看到期待值不对很多人以为「是不是我算法写错了」? |
| 266 | +
|
| 267 | +其实不是,一般这种入参很大的都是整数溢出的问题,leetcode 的 Rust 用的是溢出时自动 `wrapping` 的 release 编译 |
| 268 | +
|
| 269 | +所谓 `wrapping` 值得就例如 `0_u8.wrapping_sub(1)==255`,0_u8 减 1 会下溢成 255 |
| 270 | +
|
| 271 | +由于 leetcode 的题目描述中也提示了 方案总数可能会很大,所以需要取模 |
| 272 | +
|
| 273 | +*/ |
| 274 | +
|
| 275 | +type Plans = i32; |
| 276 | +
|
| 277 | +struct NumWaysHelper { |
| 278 | + max_position: i32, |
| 279 | + steps: i32, |
| 280 | + /// memo |
| 281 | + cache: std::collections::HashMap<(i32, i32), Plans>, |
| 282 | +} |
| 283 | +
|
| 284 | +impl NumWaysHelper { |
| 285 | + const MOD: Plans = (10 as Plans).pow(9) + 7; |
| 286 | + fn dfs(&mut self, cur_pos: i32, remain_steps: i32) -> Plans { |
| 287 | + // TODO 递归结束条件 |
| 288 | + if remain_steps == self.steps { |
| 289 | + if cur_pos == 0 { |
| 290 | + return 1; |
| 291 | + } else { |
| 292 | + // 只有从起点出发的方案才是有效的方案,其余方案都不可取(0) |
| 293 | + return 0; |
| 294 | + } |
| 295 | + } |
| 296 | +
|
| 297 | + // 做决策/状态转移 |
| 298 | + // 共同的决策分支-上一步是: 原地不动 |
| 299 | + let mut plans_count = self.calc_plans_from_cache(cur_pos, remain_steps + 1) % Self::MOD; |
| 300 | + if cur_pos == 0 { |
| 301 | + // 上一步是: 向左 |
| 302 | + plans_count = plans_count % Self::MOD |
| 303 | + + self.calc_plans_from_cache(cur_pos + 1, remain_steps + 1) % Self::MOD; |
| 304 | + } else if cur_pos == self.max_position { |
| 305 | + // 上一步是: 向右 |
| 306 | + plans_count = plans_count % Self::MOD |
| 307 | + + self.calc_plans_from_cache(cur_pos - 1, remain_steps + 1) % Self::MOD % Self::MOD; |
| 308 | + } else { |
| 309 | + // 上一步是: 向左或向右 |
| 310 | + plans_count = plans_count % Self::MOD |
| 311 | + + self.calc_plans_from_cache(cur_pos + 1, remain_steps + 1) % Self::MOD; |
| 312 | + plans_count = plans_count % Self::MOD |
| 313 | + + self.calc_plans_from_cache(cur_pos - 1, remain_steps + 1) % Self::MOD; |
| 314 | + } |
| 315 | + self.cache.insert((cur_pos, remain_steps), plans_count); |
| 316 | + plans_count |
| 317 | + } |
| 318 | +
|
| 319 | + fn calc_plans_from_cache(&mut self, last_pos: i32, last_remain_steps: i32) -> Plans { |
| 320 | + if let Some(plans) = self.cache.get(&(last_pos, last_remain_steps)) { |
| 321 | + *plans |
| 322 | + } else { |
| 323 | + self.dfs(last_pos, last_remain_steps) |
| 324 | + } |
| 325 | + } |
| 326 | +} |
| 327 | +
|
| 328 | +fn num_ways_dfs_entrance(steps: i32, arr_len: i32) -> i32 { |
| 329 | + let mut helper = NumWaysHelper { |
| 330 | + max_position: arr_len - 1, |
| 331 | + steps, |
| 332 | + cache: std::collections::HashMap::new(), |
| 333 | + }; |
| 334 | + helper.dfs(0, 0) % NumWaysHelper::MOD |
| 335 | +} |
| 336 | +
|
| 337 | +#[test] |
| 338 | +fn test_num_ways() { |
| 339 | + const TEST_CASES: [(i32, i32, i32); 4] = [(93, 85, 623333920), (3, 2, 4), (2, 4, 2), (4, 2, 8)]; |
| 340 | + for (steps, arr_len, plans_count) in TEST_CASES { |
| 341 | + assert_eq!(num_ways_dfs_entrance(steps, arr_len), plans_count); |
| 342 | + } |
| 343 | +} |
0 commit comments