Files
knowledge-kit/Chapter8 - Algorithm/8.2.md
2025-12-30 21:07:15 +08:00

162 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 《剑指 Offer》字符串“左旋”、“右旋”里的数学秘密
> 为什么要写本篇文章?看上去这是 easy 级别的题目。但“点是面的缩影,面是点的抽象”,单独一道题似乎很简单,我们可以比较轻松做出来。但是这一类题目的本质是什么?不要处于混沌的状态解决了题目,但下次遇到类似的,还是要迟疑思考一会儿。本篇文章带你吃透问题的本质和背后的数学推导。
## 题目描述
字符串的左旋转操作是把字符串前面的若干个字符转移到字符串的尾部。
请定义一个函数实现字符串左旋转操作的功能。比如:
- 输入字符串 "abcdefg" 和数字 2
- 该函数将返回左旋转 2 位后的结果 "cdefgab"
请实现该函数
## 结论
只要是字符串的左旋、右旋,都用整体逆序 + 部分逆序的方法,也可以是部分逆序 + 整体逆序。
## 分析
关于反转也就是逆序有2个 feature
- 反转的可逆性:反转(反转(x)) = x
- 类似负负得正。比如:'123' 经过一次反转后为 '321', '321' 再经过一次反转为 '123'
- 反转的可组合性:反转 (A + B) = 反转(B) + 反转(A)
- '123456' 按照长度为3进行拆分为2部分s1 + s2。s1 = '123', s2 = '456
- 先对后面的 s2也就是'456' 反转得到 '654',即 s2' = '654'
- 再对前面的 s1也就是'123' 反转得到 '321',即 s1' = '321'
- 再对 s1' 和 s2' 进行拼接, s1' + s2' = '654321'
- 观察发现 s1' + s2' 就等于对整体 s1 + s2 逆序后的结果。
再来观察看看:左旋、右旋题目要求的是什么?
前提:假设一个逆序函数,可以将 x 作为输入,输出是 x'。这个共识、前提成立,我们再进行后续的推导:
1. 原始字符串通过 k 为分割,可以拆分为: A + B。题目求的是什么 B + A
2. 思考根据上面的2个特性通过什么变化可以从 A + B得到 B + A 呢?
不难得出结论有2个方案
- 先整体逆序,`逆序 (A + B) = 逆序(B) + 逆序(A) = B' + A'`
- 再局部逆序,`逆序(B') + 逆序(A') = B + A`
结论:我们发现这时候的结果刚好满足题目要求。所以这些方法都是有迹可循的,符合数学群论中的 **“逆运算”** 和 **“运算律”** 的思想
## 方法1先整体逆序再局部逆序
1. 原始字符串通过 k 拆分为: A + B 的结构,左旋后变为 B + A
2. 先整体逆序。`逆序(A) = A' 逆序(B) = B'。大的结构还是逆序(A + B) = B' + A'`
3. 再局部逆序:`逆序(B') + 逆序(A') = B + A`
结论:我们发现这时候的结果刚好满足题目要求,不管是先局部再整体,还是先整体再局部,效果是等价的。
```javascript
// 方法1: 先整体,再部分
const rotateLeft = (message, k) => {
const length = message.length
let datasource = Array.from(message)
const reverse = (datasource, fromIndex, toIndex) => {
for (; fromIndex < toIndex; fromIndex++, toIndex--) {
let temp = datasource[fromIndex]
datasource[fromIndex] = datasource[toIndex]
datasource[toIndex] = temp
}
}
// 1. 先整体逆序
reverse(datasource, 0, length - 1)
// 2. 再局部逆序
// 先对左半部分逆序
/*
已知leftTo = kleftLength = fullLength - k求 leftTo
注意:此时的 length 不等于 k因为左旋的前半段为 k剩余的后半段 length 为完整的 length - k
leftTo - leftFrom + 1 = leftLength
leftTo = leftLength + leftFrom - 1
代入得到:
leftTo = (fullLength - k) + 0 - 1 = length - k - 1
*/
reverse(datasource, 0, length - k - 1)
// 再对右半部分逆序
/*
已知rightTo = fullLength - 1rightLength = k求 rightFrom
rightTo - rightFrom + 1 = rightLength
rightFrom = rightTo - rightLength + 1
代入得到:
rightFrom = (length - 1) - k + 1 = length - k
*/
reverse(datasource, length - k, length - 1)
// 3. 字符串数组拼接为结果
return datasource.join('')
}
```
## 方法2先部分逆序再整体逆序
思考:能不能先局部逆序,再整体逆序?
分析:继续用上面的思路推导下
1. 原始字符串通过 k 拆分为: A + B 的结构,左旋后变为 B + A
2. 先局部逆序:`逆序(A) + 逆序(B) = A' + B'`
3. 再整体逆序。`逆序(A + B) = 逆序(B) + 逆序(A)`。但是此刻我们的输入为: A' + B'
所以等价于:`逆序(A' + B') = 逆序(B') + 逆序(A') = B + A`
结论:我们发现这时候的结果刚好满足题目要求,不管是先局部再整体,还是先整体再局部,效果是等价的。
```javascript
// 方法2: 先部分,再整体
const rotateLeft1 = (message, k) => {
const length = message.length
let datasource = Array.from(message)
const reverse = (datasource, fromIndex, toIndex) => {
for (; fromIndex < toIndex; fromIndex++, toIndex--) {
let temp = datasource[fromIndex]
datasource[fromIndex] = datasource[toIndex]
datasource[toIndex] = temp
}
}
// 1. 再局部逆序
// abcdefg -> gfedc ba ->
// 先对左半部分逆序
/*
已知leftTo = kleftLength = fullLength - k求 leftTo
注意:此时的 length 不等于 k因为左旋的前半段为 k剩余的后半段 length 为完整的 length - k
leftTo - leftFrom + 1 = leftLength
leftTo = leftLength + leftFrom - 1
代入得到:
leftTo = (fullLength - k) + 0 - 1 = length - k - 1
*/
reverse(datasource, 0, k - 1)
// 再对右半部分逆序
/*
已知rightTo = fullLength - 1rightLength = k求 rightFrom
rightTo - rightFrom + 1 = rightLength
rightFrom = rightTo - rightLength + 1
代入得到:
rightFrom = (length - 1) - k + 1 = length - k
*/
reverse(datasource, k, length - 1)
// 2. 先整体逆序
reverse(datasource, 0, length - 1)
// 3. 字符串数组拼接为结果
return datasource.join('')
}
```