学到现在,动态规划的全套流程我们已经彻底掌握了,最后我再写几道较难、较实用的应用题,喜欢程序设计的朋友们可以看看。
LeetCode514
自由之路(转盘问题)
题目介绍:
给定一个字符串 ring
,表示刻在外环上的编码;给定另一个字符串 key
,表示需要拼写的关键词。您需要算出能够拼写关键词中所有字符的最少步数。
最初,ring 的第一个字符与 12:00
方向对齐。您需要顺时针或逆时针旋转 ring
以使 key 的一个字符在 12:00
方向对齐,然后按下中心按钮,以此逐个拼写完 key
中的所有字符。
旋转 ring
拼出 key 字符 key[i]
的阶段中:
-
您可以将 ring 顺时针或逆时针旋转 一个位置 ,计为1步。旋转的最终目的是将字符串
ring
的一个字符与12:00
方向对齐,并且这个字符必须等于字符key[i]
。 -
如果字符
key[i]
已经对齐到12:00
方向,您需要按下中心按钮进行拼写,这也将算作 1 步。按完之后,您可以开始拼写 key 的下一个字符(下一阶段), 直至完成所有拼写。
题目分析:
先把问题转换一下,转动圆盘,指针不动其实等价于 圆盘不动,转动指针。所以我们可以分析出这道题目有两种状态:指针指向字符串ring
的哪个位置 和 此时需要找key
的第几个字符,也就是到哪一步了,该指向哪个字符才能进行下一步了。
现在考虑dp
函数该怎么定义
dp
函数如何定义呢?我们先考虑从起点到终点的正向转移能否适用:
从起点考虑对应的dp[i][j]
为 当指针指向ring[i]
时,输入字符串key[0...j]
至少需要dp[i][j]
步操作。该怎么进行状态转移呢?要想转第j
个字符,必须先把前j-1
个字符转好,但是怎么从第j-1
个字符递推到第j
个字符呢?
按照往常思路,找到第j-1
个字符的最少操作数再加上从第j-1
个字符的位置转移到第j
个字符的位置所需要的最少操作数就可以了,细致的朋友会发现,这种思路就是贪心算法的思路。但是,贪心的思路可能会让模型陷入局部最优解的困境之中,我们最终得到的结果并不是整体的最小值,待会我会举个例子来说明确实存在陷入局部最优的情况。
通过 选择 来确定怎么进行状态转移
选择 就是 如何拨动指针得到待输入的字符 ,有几种选择形式呢?通过这个选择的内容,我们大致可以推出:
-
是逆时针拨动还是顺时针拨动?
-
待输入的字符可能有相同的好几个,该选择哪一个呢,直接选择离第
j-1
个字符最近的待输入字符吗?
第一个问题好解决,逆时针还是顺时针拨,只需要判断是顺时针需要的步数少,还是逆时针需要的步数少即可。直接用下面这一段代码进行体现:
int delta = std::min(abs(l-k),n-abs(l-k)); //l为待输入字符的下标,k为输入第j-1个字符完毕时指针指向的下标,n为字符串`ring`的字符个数。
第二个问题与状态转移有直接关系,多个字符我该选择哪一个呢?直接选择最近的吗?我们先试用一下贪心算法:
贪心思路:
前j-1
个字符确定下来,直接在指针指向第j-1
个字符的基础上找最近的第j
个字符。先给出代码框架,
int dp(String ring,int i,String key,int j){ int subProblem = dp(ring,i,key, j-1); //找第j-1个字符时 for(int k : 找出所有第j个字符的下标){ int delta = abs(???(j-1) - k); //确认顺时针还是逆时针 delta = min(delta,ring.size()-delta); } return subProblem + delta; }
我们发现,代码框架给不出来,缺少指向第j-1
个字符的指针。这时只要更改一下dp
函数定义,同时返回一个下标来指向第j-1
个字符即可,如下:
int dp(String ring,int i,String key,int j,int &index){ int subProblem = dp(ring,i,key, j-1); //找第j-1个字符时 int pre_index = index; int result = INT_MAX; for(int k : 找出所有第j个字符的下标){ int delta = abs(pre_index - k); //确认顺时针还是逆时针 delta = min(delta,ring.size()-delta); if(result > 1 + delta + subProblem){ result = + delta + subProblem; index = k; } } return result; }
以上是贪心算法的代码,但是我可以告诉你,贪心算法的思路是错的,会陷入局部最优解的困境。请看下面这个例子
动态规划从终点开始分析问题思路:
比如说输入 ring = "gdonidg"
,现在圆盘的状态如下图:
假设现在我想输入的字符为key[j] = 'D'
,圆盘中有两个字符'D'
,可以顺时针找,也可以逆时针找,可以找右边第一个‘D’,也可以找左边第二个’D’,有四种选择方法,我们找次数最少的那个。
刚刚的代码已经告诉了我们,顺时针还是逆时针转取决于哪个值更小,按照贪心算法的思路(刚刚的代码逻辑),我们应该选择右边第一个‘D’,但是选择完D后再选择I时,一共进行了 1+1+3+1,6步操作(确定字符吗,按下button也是一步),但是,如果选择左边第二个‘D’,再选择I时,我们只进行了2+1+1+1,5步操作。所以,从起点开始利用贪心算法分析会陷入局部最优解困境,不可取。
从这个例子可以看出,当我们对dp(i,j)
进行分析时,还需要考虑选择了第j
个字符之后,j+1,j+2,...
这些字符该怎么选取。既然从起点开始分析不行,那我们考虑从终点开始分析,既然选择第j
个字符时,需要考虑j+1,j+2,...
这些字符,那我们直接先把后面的字符考虑清楚之后再考虑第j
个字符。
重新定义dp
函数
dp(i,j)
为当指针指向ring[i]
时,输入key[j,j+1,...]
字符串所需要的最少操作数。根据这个定义,最终我们要求的结果其实就是dp(0,0)
。
重新思考状态转移
根据定义,我们可以得出base case: dp[...][n] = 0
,key
为空时自然不需要任何操作,那如何求dp[i][j]
呢?直接给出代码:
int dp(String ring,int i,String key,int j){ //base case: if(j == key.size()){ return 0; } int result = INT_MAX; for(int k : 找出所有第j个字符的下标){ int delta = abs(i - k); //确认顺时针还是逆时针 delta = min(delta,ring.size()-delta); int subProblem = dp(ring,k,key,j+1); result = min(result,1+delta+subProblem); } return result; }
完整代码:
//建立字符与索引映射 字符->索引列表 unordered_map<char, vector<int>> charToIndex; //map存储键值对,没有这个键则会新建一个键值对 //备忘录 vector <vector<int>> memo; int findRotateSteps(string ring, string key) { int m = ring.size(); int n = key.size(); //备忘录全部初始化为0 memo = vector <vector<int>>(m, vector<int>(n, 0)); //存储映射关系 for (int i = 0; i < m; ++i) { char c = ring[i]; charToIndex[c].push_back(i); } //圆盘最初指向12点钟方向,从第一个字符开始输入key return dp(ring, 0, key, 0); } int dp(string ring, int i, string key, int j) { //base case: if (j == key.size()) { return 0; } if(memo[i][j] != 0) return memo[i][j]; int result = INT_MAX; for (int k: charToIndex[key[j]]) { int delta = abs(i - k); //确认顺时针还是逆时针 delta = min(delta, (int)ring.size() - delta); int subProblem = dp(ring, k, key, j + 1); result = min(result, 1 + delta + subProblem); } memo[i][j] = result; return result; }