Hi 大家好,我是张小猪。欢迎来到『宝宝也能看懂』系列之 leetcode 周赛题解。
这里是第 171 期的第 4 题,也是题目列表中的第 1320 题 -- 『二指输入的的最小距离』
二指输入法定制键盘在 XY 平面上的布局如上图所示,其中每个大写英文字母都位于某个坐标处,例如字母 A 位于坐标 (0,0),字母 B 位于坐标 (0,1),字母 P 位于坐标 (2,3) 且字母 Z 位于坐标 (4,1)。
给你一个待输入字符串 word
,请你计算并返回在仅使用两根手指的情况下,键入该字符串需要的最小移动总距离。坐标 (x1,y1) 和 (x2,y2) 之间的距离是 |x1 - x2| + |y1 - y2|。
注意,两根手指的起始位置是零代价的,不计入移动总距离。你的两根手指的起始位置也不必从首字母或者前两个字母开始。
示例 1:
输入:word = "CAKE"
输出:3
解释:
使用两根手指输入 "CAKE" 的最佳方案之一是:
手指 1 在字母 'C' 上 -> 移动距离 = 0
手指 1 在字母 'A' 上 -> 移动距离 = 从字母 'C' 到字母 'A' 的距离 = 2
手指 2 在字母 'K' 上 -> 移动距离 = 0
手指 2 在字母 'E' 上 -> 移动距离 = 从字母 'K' 到字母 'E' 的距离 = 1
总距离 = 3
示例 2:
输入:word = "HAPPY"
输出:6
解释:
使用两根手指输入 "HAPPY" 的最佳方案之一是:
手指 1 在字母 'H' 上 -> 移动距离 = 0
手指 1 在字母 'A' 上 -> 移动距离 = 从字母 'H' 到字母 'A' 的距离 = 2
手指 2 在字母 'P' 上 -> 移动距离 = 0
手指 2 在字母 'P' 上 -> 移动距离 = 从字母 'P' 到字母 'P' 的距离 = 0
手指 1 在字母 'Y' 上 -> 移动距离 = 从字母 'A' 到字母 'Y' 的距离 = 4
总距离 = 6
示例 3:
输入:word = "NEW"
输出:3
示例 4:
输入:word = "YEAR"
输出:7
提示:
2 <= word.length <= 300
- 每个
word[i]
都是一个大写英文字母。
HARD
题目的内容非常简单,就是用两根手指,输入一串给定的字符串,要求返回手指移动的最短距离。其中键盘布局已经通过图片给出来了。
看完题目之后,小猪蹄子一蹬,尾巴一翘,因为通常描述很简单的题目,可能是真的特别简单,又或者可能就是思考起来比较复杂。而这道题的难度是 HARD...是时候再玩几盘吃鸡放松一下了 是时候认真了呢,哼唧 >.<
先不思考题目逻辑,单看需求,第一个非常明显的就是我们需要知道手指在键盘上从一个字母移动到另一个字母的开销。那我们就先来实现这一个 helper 方法吧。
由于英文字母是按照顺序排列的,所以我们可以很容易的想到取 char code 来做计算。结合题目给出的移动距离公式 |x1 - x2| + |y1 - y2|
,我们可以很自然的想到,把距离拆解成横向和纵向两个方向计算,最后再求和。由于每一行的字母数量是固定的,所以对于纵向距离来说,我们可以分别求出两个字母所在的行数,然后相减即可。而对于横向距离,同样因为每一行的字符数量固定,我们可以直接进行取模运算,然后相减即可。
这里需要注意的一点就是别忘了减完之后要取绝对值。具体代码可能类似下面这样:
function distance(a, b) {
const x = word.charCodeAt(a) - 65;
const y = word.charCodeAt(b) - 65;
return Math.abs((x % 6) - (y % 6)) + Math.abs(((x / 6) << 0) - ((y / 6) << 0));
}
接下来就到了核心问题,如何确定解法的逻辑。这里如果一时之间觉得无从下手的话,可以先举几个例子看看:
- 如果有一个字符,那么我们的开销是
distance(a, a)
,也就是0
。 - 如果有两个字符,那么我们的开销是
distance(a, a) + distance(b, b)
,也是0
。 - 如果有三个字符,那么我们的开销有可能是以下几种情况:
distance(a, a) + distance(b, b) + distance(b, c)
。distance(a, a) + distance(b, b) + distance(a, c)
。distance(a, a) + distance(a, b) + distance(c, c)
。
- 如果有四个字符,那么我们的开销有可能是以下几种情况:
distance(a, a) + distance(b, b) + distance(b, c) + distance(c, d)
。distance(a, a) + distance(b, b) + distance(b, c) + distance(a, d)
。distance(a, a) + distance(b, b) + distance(a, c) + distance(c, d)
。distance(a, a) + distance(b, b) + distance(a, c) + distance(b, d)
。distance(a, a) + distance(a, b) + distance(c, c) + distance(c, d)
。distance(a, a) + distance(a, b) + distance(c, c) + distance(b, d)
。distance(a, a) + distance(a, b) + distance(b, c) + distance(d, d)
。
- 如果有五个字符...等等,你想累死小猪吗,虽然猪肉贵了...
小伙伴们可以稍微仔细的看一下上面的几个例子,我把顺序写的挺故意的,就是想更轻易的展示其中的信息。下面罗列一下我们可以发现的一些事情:
- 第一个信息就是,我们的手指一定是先从第一个字符开始的。(啊,不要白眼...
- 第二个信息就是,我们的一根手指最终一定会落在结尾处。(啊,不要打我...
- 第三个信息就是,我们如果已经使用了两根手指(嗯?怎么怪怪的...),那么继续下一步的最优解可能会有两种情况:
- 移动第一根手指到最后。
- 移动第二根手指到最后。
- 第四个信息就是,我们如果还没有使用第二根手指,那么继续下一步的最优解一定是用上第二根手指。
上面四个信息可能看起来非常的不值一提,不过这是我们后续推导的根基。接下来我们进行正式的逻辑部分。
我们上面得到了一些步骤之间手指的基本信息,那么这时候可能会有小伙伴提出这样的设想。我们直接把第一根手指放在开始,第二根手指放在离它最远的地方。然后每次字符移动的时候用离目标最近的一根手指移动,这样是不是就解决了呢?有了这种设想之后,我们可以用题目给的例子来试一下,例如 "CAKE", "HAPPY" 等,我们会发现能得到正确的结果。那么这样看起来,这道题似乎很简单鸭。
别着急,我们再来看另外一个例子 "ZKNBZ"。按照上面的思路,我们会把手指放在 "Z" 和离它最远的 "K",然后根据离目标最近的原则,继续做以下的移动 "Z" => "N", "N" => "B", "B" => "Z"。最终移动的总距离为 8。可是,我们回头看看这个方案,仍旧把手指放在 "Z" 和 "K",然后进行以下的移动 "K" => "N", "N" => B","Z" => "Z"。这样最终的总移动距离为 6。这也就证明了,我们前面的那种设想是有问题的。
那么问题出在哪里呢?这里引入两个概念,局部最优解和全局最优解。前者指的是在当前状况下我们得到的最优解,例如上面设想中的每一次移动都使用距离最近的手指,这样在当前这一步我们确实得到了最优解。而后者指的是在整体流程结束后我们得到的最优解,例如上面例子中最后得到了 6 这个解。那么很显然,我们得到了局部最优解,可是没有得到全局最优解。
相信有的小伙伴这时候已经反应过来了,上面的那种设想不就是一种贪心算法的实现么,也就是企图用局部最优解最终推导出全局最优解。可是这是有条件的,对于我们这里的题目,局部最优解就不能推导出全局最优解。所以,我们应该用动态规划的方式尝试基于步骤之间的关系来推导出全局最优解。
那么接下来我们来看看步骤之间的关系吧。这里先解释后面会用到的几个数据的意思:
- 假设当前两根手指所处的位置分别是
x
和y
,其中x < y
,那么对于我们从位置0
开始一直到y
这一段字符串,当在一根手指放在x
的条件下时,最优解值称为dp[x][y]
。 - 一根手指从位置
x
移动到y
的距离称为distance(x, y)
。 - 一根手指从位置
0
移动到位置1
,并持续移动到位置x
,所需要的距离的总和称为sum(x)
。
那么接下来我们来看看 dp[x][y]
的情况,我们可以先举几个具体的例子方便思考:
- 对于
dp[3][6]
,它的来源只可能是dp[3][5] + distance(5, 6)
。 - 对于
dp[5][6]
,它的来源就比较复杂了,可能来自于dp[0][5] + distance(0, 6)
,也可能是dp[1][5] + distance(1, 6)
,一直到dp[4][5] + distance(4, 6)
,另外千万别忘了还可能是sum(5) + distance(6, 6)
。
看到这里小伙伴们有没有发现我们最开始举的几个例子和得到的那几个信息的意义啦。正所谓古语有云:重要信息,不要钱 4 个,嘿嘿 >.<
那么把上面这个例子再抽象成 dp[x][y]
,我们可以得到以下计算方法:
dp[x][y] = x !== y - 1
? dp[x][y - 1] + distance(y - 1, y)
: Math.min(sum(x), dp[0][x] + distance(0, y), dp[1][x] + distance(1, y), ..., dp[x - 1][x] + distance(x - 1, y))
这时候我们看看题目所需要求的那个结果是什么,如果用上面我们的几个值来表达,其实就是 Math.min(sum(n - 1), dp[0][n - 1], dp[1][n - 1], ..., dp[n - 2][n - 1])
。
现在我们有了递推公式,也有了最终结果的取值,我们只需要用代码实现其中的计算即可。具体流程如下:
- 初始化
dp
数组和sum
数组。 - 根据递推公式,推导出
dp
数组中我们所需要的值。 - 根据结果的取值,计算出结果。
基于这个流程,我们可以实现类似这样的代码:
const minimumDistance = word => {
const LEN = word.length;
const dp = new Array(LEN - 1);
const sum = new Uint16Array(LEN);
for (let i = 1; i < LEN; ++i) {
dp[i - 1] = new Uint16Array(LEN);
sum[i] += sum[i - 1] + distance(i - 1, i);
}
for (let j = 2; j < LEN; ++j) {
let min = sum[j - 1];
for (let i = 0; i < j - 1; ++i) {
const min2 = dp[i][j - 1] + distance(i, j);
if (min2 < min) min = min2;
dp[i][j] = dp[i][j - 1] + distance(j - 1, j);
}
dp[j - 1][j] = min;
}
let min = sum[LEN - 1];
for (let i = 0; i < LEN - 1; ++i) {
if (dp[i][LEN - 1] < min) min = dp[i][LEN - 1];
}
return min;
function distance(a, b) {
const x = word.charCodeAt(a) - 65;
const y = word.charCodeAt(b) - 65;
return Math.abs((x % 6) - (y % 6)) + Math.abs(((x / 6) << 0) - ((y / 6) << 0));
}
};
按照惯例,我们还是尝试把 dp
这个二维数组优化为一个一维数组。顺便再吐槽一下 JS 中申明多维数组真是麻烦。
这里的优化就非常简单了,我们上面的 dp[x][y]
中,y
表示的是当前右侧手指的位置。这个位置其实也就是当前推演进行到了哪一位字符。而这个推演其实是一轮一轮进行的,与我们的循环直接绑定。并且我们后续的计算中不再需要更早期的推演值了。说到这里,相信小伙伴们也发现了,我们其实根本不需要这一维度的值,因为我们只需要记录最新的值即可。
于是顺理成章的,我们可以得到类似下面的代码:
const minimumDistance = word => {
const LEN = word.length;
const dp = new Uint16Array(LEN - 1);
const sum = new Uint16Array(LEN);
for (let i = 1; i < LEN; ++i) {
sum[i] += sum[i - 1] + distance(i - 1, i);
}
for (let j = 2; j < LEN; ++j) {
let min = sum[j - 1];
for (let i = 0; i < j - 1; ++i) {
const min2 = dp[i] + distance(i, j);
dp[i] = dp[i] + distance(j - 1, j);
if (min2 < min) min = min2;
}
dp[j - 1] = min;
}
return Math.min(...dp, sum[LEN - 1]);
function distance(a, b) {
const x = word.charCodeAt(a) - 65;
const y = word.charCodeAt(b) - 65;
return Math.abs((x % 6) - (y % 6)) + Math.abs(((x / 6) << 0) - ((y / 6) << 0));
}
};
这段代码目前跑出了 56ms,暂时 beats 100%。
回看上面的代码的时候,我们会发现其实其中有一部分的开销还能被砍掉。关于 sum
数组,仔细观察逻辑后会发现,其实我们只需要它的最新值而以。所以我们完全没有必要继续去维护这个数组了。去掉这一部分后的代码如下:
const minimumDistance = word => {
const dp = new Uint16Array(word.length - 1);
let sum = 0;
let step = distance(0, 1);
for (let j = 2; j < word.length; ++j) {
sum += step;
step = distance(j - 1, j);
let min = sum;
for (let i = 0; i < j - 1; ++i) {
const min2 = dp[i] + distance(i, j);
dp[i] = dp[i] + step;
if (min2 < min) min = min2;
}
dp[j - 1] = min;
}
return Math.min(...dp, sum);
function distance(a, b) {
const x = word.charCodeAt(a) - 65;
const y = word.charCodeAt(b) - 65;
return Math.abs((x % 6) - (y % 6)) + Math.abs(((x / 6) << 0) - ((y / 6) << 0));
}
};
在上面的分析过程中,我们提到了局部最优解、全局最优解、贪心算法、动态规划。不过这里我们并没有做详细的展开,只是点到为止。待日后具体的专题内容时候,我们再详细的说吧。小伙伴们要是期待的话就多催更哟,催多了小猪才有动力爆肝鸭,哈哈哈哈 >.<
另外,其实动态规划的思路可以有很多种,例如自上而下、自下而上其实都可以。并且递推的内容和具体的逻辑也和我们设置的 dp
数组有关。欢迎小伙伴们积极补充其他的方案哟。Yo~ Put your hands up~