抓不住的动态规划

断断续续磕动态规划已经好几周了,怎么讲,如果非要概括一下这两三周的成果的话,大概就是从入门到放弃……动态规划题虽然没有千千万,但也有典型例题两百题吧,磕了一些网上的教程,磕了geeksforgeeks的dynamic programming,刷了大概40题左右的动态规划题,有些刷了两遍的时候还是得抓耳挠腮,状态怎么定义,状态如何转换。

曾经想过用暴力的方式破解,然后观察其中哪些计算时重复的,然后再对重复计算的东西来进行一个记录。还想到了剪枝之类的,但是往往理想很丰满,现实很骨感,我自己意淫出来的大部分DP的状态定义大部分是错的。很多时候明明得用三维来标记状态,却选了二维,用二维标记选了一维,(但是有时候只需要一维的然后选了二维,结果是对的233333)然后打死都弄不来。而且好多题解用暴力的方式做还真做不来。

持续抗战了前前后后快一个月(虽然中间还有出去玩耍的时候),决定动态规划的抗战到此结束,虽然每次做DP很有自虐或者说是高中学习的快感。怎么讲,首先题目难度在你现有能力往上一点点,不至于难到一步登天,只要花点时间花点心思都能做出来,但是……题目不像BFS或者说DFS那样在一个大致的框架内做不算太大的微小的改变,光是大致的框架就得有好多个,有时候题目啰啰嗦嗦一层套一层,光看懂题目就得费老长一段时间,等定义状态的时候更是云里雾里而且老以为是这样的,其实正确答案又是那样的(This that,comme ci comme ca),然后看完答案之后,长吁一口气,原来是这样的。

虽然很有挑战性,刷起来其实有时候还觉得其乐无穷,但是真的得放弃了,以后会再捡起来,但不是现在,不应该在这上面浪费更多的时间了。

总的来说,动态规划虽然比其他类型的题目灵活,但总体还是有很大一部分的题目都在一定的框架之内,最难的难点是定义各种状态,如果状态定义好,那递推公式也想出来了。(很多时候的思路的过程更多的是先想好递推公式,才能确定定义的状态是对的。)

其实主要的步骤还是

  1. State
  2. Function
  3. Initialize

实现方式有Bottom up,从小到大,还有memorized search,从大问题到小问题。 目前遇到的大部分问题都可以用bottom up的方式解决,(能用bottom up就不用top down了,毕竟bottom up写的多,不容易错)。但是有一类,比如区间型的DP或者search in a 2d matrix, 结果和子结果的相对位置关系不好弄的时候,采用memorized search的方式。

后面是一些简陋的总结吧,主要集中在如何定义状态,以及递推公式这两个步骤。实现上会有一些边界考虑的情况,也挺容易错的,要多举例,或者举反例(我举的反例貌似老是不是反的……)

坐标型动态规划

目前来说,坐标型的动态规划,即左上角走到右下角的这种类型的题目都还算简单,最大,最小,方案个数之类的各种题目。一般都选用bottom up的写法。一般当前答案可能是由上一层/左边得到。典型例题,triangle count,unique path,都是比较简单的坐标型动态规划。总的来说,这类题目大多数还在handle的范围内,除了个别比较变态的题目……

跳跃问题

描述:给出一个非负整数数组,你最初定位在数组的第一个位置。

数组中的每个元素代表你在那个位置可以跳跃的最大长度。

判断你是否能到达数组的最后一个位置。

A =[2,3,1,1,4],返回 true.

A =[3,2,1,0,4],返回 false.

思路:

if(dp[i]==true){
   for(int j=1;j<=A[i]&&size;j++){
     dp[i+j]=true;
   }
}

follow up: 给出最少的跳跃次数

稍微变了一下,但还是很简单,只需要从前往后选一个最小的加1.

dp[i] = min{dp[i-j]}+1 if A[i-j]>=j 

骰子求和

扔 n 个骰子,向上面的数字之和为 S。给定 Given n,请列出所有可能的 S值及其相应的概率。坐标类型,走到最后一步。

思路: 开始定义状态的时候只定义了 f[target],只有一维,自以为是对的,然后……其实是错的。 但增加到二维,分别是分值和第几次之后,思路就很简单了。

f[i][j] += f[i-1][j-k] for k in [1,6]

打劫房屋

在上次打劫完一条街道之后,窃贼又发现了一个新的可以打劫的地方,但这次所有的房子围成了一个圈,这就意味着第一间房子和最后一间房子是挨着的。每个房子都存放着特定金额的钱。你面临的唯一约束条件是:相邻的房子装着相互联系的防盗系统,且 当相邻的两个房子同一天被打劫时,该系统会自动报警。

给定一个非负整数列表,表示每个房子中存放的钱, 算一算,如果今晚去打劫,你最多可以得到多少钱 在不触动报警装置的情况下 [1,2,3,5]

思路:这题其实很简单,分两种情况,拿第一间房子的时候算一遍,结果为f[n-1],不拿第一间房子的时候算一遍,结果为f[n],然后取最大的一个。

交叉字符串

描述:给出三个字符串:s1、s2、s3,判断s3是否由s1和s2交叉构成。比如 s1 = “aabcc” s2 = “dbbca”

  • 当 s1 = “aadbbcbcac”,返回 true.

  • 当 s3 = “aadbbbaccc”, 返回 false.

思路:看上去像双序列类型的题目,其实是坐标型,因为不能跳过某个字符。一旦不匹配,则为false。

if(s3[i+j-1]==s1[i-1]){向下扩展}
if(s3[i+j-1]==s2[j-1]){向下扩展}

不同的二叉查找树

给出 n,问由 1…n 为节点组成的不同的二叉查找树有多少种?

dp[n] = dp[i]*dp[n-1] for i=0……n-1. 左子树的形态和右子树形态。

最大正方形

在一个二维01矩阵中找到全为1的最大正方形.

1 0 1 0 0
1 0 1 1 1
1 1 1 1 1
1 0 0 1 0

返回 4

思路: f[i][j]记录该位置能找到的最大正方形的边长。那么以i,j为右下角的边长为f[i][j]的区域全为正方形。

//递推公式为 左边1的个数,上面1的个数,以及f[ii-1[j-1]的最小值。
f[i][j] = min{left[i][j],up[i][j],f[i-1][j-1]+1} if(i,j)==1

follow up:找出最大矩形。

思路:正方形只需要记一个边长,但是长方形的话需要记长和宽,分析的思路也差不多。

follow up:01矩阵里面找一个,对角线全为1, 其他为0的正方形

思路:这时候left[i][j]记左边的0的个数

坐标型动态规划,但是不是bottom up 的例题

Give you an integer matrix (with row size n, column size m),find the longest increasing continuous subsequence in this matrix. (The definition of the longest increasing continuous subsequence here can start at any row or column and go up/down/right/left any direction).

Example

Given a matrix:

[

[1 ,2 ,3 ,4 ,5],

[16,17,24,23,6],

[15,18,25,22,7],

[14,19,20,21,8],

[13,12,11,10,9]

]

return 25

思路:坐标型的题目,不是都是采用bottom up的思路,(虽然大部分都是。)但是这题f[i][j] 可以从上下左右更新。是很经典的dfs+memorized search 的例题。在return之前,把值记下。

序列型DP

序列问题,有相对顺序,大体上可以分为单序列的题目和双序列的题目。单序列的题目又被称为接龙型题目。和坐标类型的题目有些相似,但是题目类型变换的比较多一点,状态转移方程有时候也要略难一点。

单序列:

单序列问题为接龙问题,一般状态都是一维的。

最长上升子序列

描述:给出 [5,4,1,2,3],LIS 是 [1,2,3],返回 3

给出 [4,2,4,5,3,7],LIS 是 [2,4,5,7],返回 4

思路: 最经典的题目了,状态转移方程也很简单。

dp[i] = max{dp[i-j]}+1 if nums[i]>nums[i-j]

follow up为时间复杂度为o(nlogn)的解法,此时的做法是用一个数组来保存当前的递增序列,然后可以应用二分查找。

单词拆分

描述:给定字符串 s 和单词字典 dict,确定 s 是否可以分成一个或多个以空格分隔的子串,并且这些子串都在字典中存在。

s = “lintcode”

dict = [“lint”,“code”]

分析:看着挺简单,但容易错的地方为 第二层循环为单词的最长长度,不然就TLE了 如果遇到很长的的字符串。不应该是n^2, 应该降到o(nk).(类比背包问题。)

follow up// 还是第一次遇到,直接用DFS会超时,只能说将中间结果存下来。

给一字串lintcode,字典为[“de”, “ding”, “co”, “code”, “lint”]

则结果为[“lint code”, “lint co de”]。

Combination Sum

描述:给出一个都是正整数的数组 nums,其中没有重复的数。从中找出所有的和为 target 的组合个数。

给出 nums = [1, 2, 4], target = 4

,return 6. 可能的所有组合有:

[1, 1, 1, 1]
[1, 1, 2]
[1, 2, 1]
[2, 1, 1]
[2, 2]
[4]

分析:看着挺难,其实挺简单。虽然这个简单是看了答案之后的简单吧…不要陷入排列组合的怪圈,虽然121 和 211看着挺一样的。实际的做法为拆成前半部分和后一个数。结尾为1的组合数,结尾为2的组合数和结尾为4的组合数之和。

dp[i] = sum(dp[i-A[j]])

俄罗斯套娃信封

给一定数量的信封,带有整数对 (w, h) 分别代表信封宽度和高度。一个信封的宽高均大于另一个信封时可以放下另一个信封。

求最大的信封嵌套层数

给一些信封 [[5,4],[6,4],[6,7],[2,3]] ,最大的信封嵌套层数是 3([2,3] => [5,4] => [6,7])

思路:变态型LIS。先排好序,再根据第二个求上升子序列。但当时这题做了好几遍都没有AC,然后参考了一下答案,其中的lower_bound函数以及sort函数很方便,比自己写的排序要好,贴一下代码。

class Solution {
public:
    /*
     * @param envelopes: a number of envelopes with widths and heights
     * @return: the maximum number of envelopes
     */
    int maxEnvelopes(vector>& envelopes) {
        // write your code here
        vector dp;
        auto cmp=[](pair&a,pair&b){
            return a.firstb.second);
        };
        sort(envelopes.begin(),envelopes.end(),cmp);
        
        for(auto p:envelopes){
            auto it = lower_bound(dp.begin(),dp.end(),p.second);
            if(it==dp.end()) {dp.push_back(p.second);}else{
                *it = p.second;
            }
        }
        
        return dp.size();
    }
};

变态青蛙跳

一只青蛙正要过河,这条河分成了 x 个单位,每个单位可能存在石头,青蛙可以跳到石头上,但它不能跳进水里。

按照顺序给出石头所在的位置,判断青蛙能否到达最后一块石头所在的位置。刚开始时青蛙在第一块石头上,假设青蛙第一次跳只能跳一个单位的长度。

如果青蛙最后一个跳 k 个单位,那么它下一次只能跳 k - 1 ,k 或者 k + 1 个单位。注意青蛙只能向前跳。

给出石头的位置为 [0,1,3,5,6,8,12,17]

总共8块石头。
第一块石头在 0 位置,第二块石头在 1 位置,第三块石头在 3 位置等等......
最后一块石头在 17 位置。

返回 true。青蛙可以通过跳 1 格到第二块石头,跳 2 格到第三块石头,跳 2 格到第四块石头,跳 3 格到第六块石头,跳 4 格到第七块石头,最后跳 5 格到第八块石头。

给出石头的位置为 `[0,1,2,3,4,8,9,11]`
返回 false。青蛙没有办法跳到最后一块石头因为第五块石头跟第六块石头的距离太大了。

分析:

第一遍是用BFS来做的,下面这种是动态规划的方法。这种方法有个问题时当最后一个元素十分大的时候,memory会超出,90%的case都通过了,只有这个没通过,而且用BFS的话,其实也没有什么重复计算的问题……因为只向前跳。

dp[k][stone]=true -> dp[k-1][stone+k-1]=true, dp[k+1][stone+k+1]=true, dp[k][stone+k]=true

initialize dp[0][0]=true;
answer dp[*][laststone];

maximum k = laststone;

双序列DP

双序列DP最典型的题目即为字符串花式匹配的问题。总之就是花式变着来,回文啊,最长公共子串,最长公共子序列,其中关键点就是要写清楚,当s[i]==t[j]时状态怎么变。此类题目一般难,主要的关键点在于弄清楚i,j所代表什么。但是遇到有些变态的题目还是得冥思苦想直到放弃。不追求举一反三,只追求举一背一吧。

  1. 最长的回文序列,翻转 转换为双序列最多共同的元素
  2. 正则表达式匹配问题

最长公共子序列

描述:给出两个字符串,找到最长公共子序列(LCS),返回LCS的长度。

给出"ABCD" 和 “EDCA”,这个LCS是 “A” (或 D或C),返回1

给出"ABCD" 和 “EACB”,这个LCS是"AC"返回 2

分析:dp[i][j]表示字符串s[0:i-1] t[0:j-1]最长的公共子串。

if(s[i-1]==t[j-1]){
  //匹配和不匹配中的最大值
  dp[i][j] = max{dp[i-1][j],dp[i-1][j-1]+1}
}

编辑距离

描述:给定两个序列 ‘P’和’Q’。你的任务是,我们可以对这对’P’这个序列修改不超过k个元素到任意的值,并要求两个修改后序列的最长公共子序列最长。

给定 P = [8 ,3], Q = [1, 3], K = 1

返回 2

给定 P = [1, 2, 3, 4, 5], Q = [5, 3, 1, 4, 2], K = 1

返回 3

分析:

换汤不换药吧。dp[k][i][j]代表k次修改后最长公共子序列

if(match){
  dp[k][i][j] = dp[k][i-1][j-1]+1;
}else{
  dp[k][i][j] = max{dp[k-1][i-1][j-1],dp[k][i-1][j],dp[k][i][j-1];}
}

不同的子序列

给出字符串S和字符串T,计算S的不同的子序列中T出现的个数。

子序列字符串是原始字符串通过删除一些(或零个)产生的一个新的字符串,并且对剩下的字符的相对位置没有影响。(比如,“ACE”是“ABCDE”的子序列字符串,而“AEC”不是)。

“rabbbit”, T =“rabbit” return 4

// dp[i][j] 子串 s[0:i-1]和T[0:j-1]的出现个数
//初始化,T为空时全为一种方法。 
dp[k][0]=1
//跳过s[i-1]加上不跳过i-1的写法
dp[i][j] = dp[i-1][j];
if(s[i-1]==t[j-1]){
  dp[i][j] += dp[i-1][j-1]
}

背包问题

背包问题如果一眼能看出是背包问题,还是很简单的,难就难在怎么才能一眼看出是背包问题……几个要点是,1. 背包有一定容量,比如只能装k个。2. 给出了一个数组,这个数组不是序列,位置互换也不影响。背包装进去之后可以容量要减小。

一般求最大价值/最小个数/最xxxx。 然后可行的target是数组的组合,尽管定义状态的时候 dp[i][j],其中i是容量,j代表target.

  1. knapsack with repetition
    可以重复拿,则不需要记录i位置,只需要记录容量
    dp[i] = max(dp[i-w[j]]+v[j]). 其中w[j] 为重量,v[j]为价值
  2. knapsack without repetition
    对一个物品,只能选择拿或者不拿
    dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i-1]]+v[i-1]); 取拿或者不拿的最大值。
  3. coin change
    其实就是knapsack with repetition的换汤不换药的题目。

以上三种类型的题都比较简单,还在掌握范围内。

KSum

给定 n 个不同的正整数,整数 k(k <= n)以及一个目标数字 target。

在这 n 个数里面找出 k 个数,使得这 k 个数的和等于目标数字,求问有多少种方案?

分析:与背包问题有点不同的是,一定要取K个,而且求的是方案数。此题与那个骰子的题目有点像(骰子之和为求n个骰子之和的方案个数),但是其中一个要注意的是 怎么迭代。 关于怎么迭代,这个已经TLE了很多次,每次都是无脑的 for(int i=0;i<=target;i++)。 一个个寻找,但是其实只需要看target-A[m]的范围。以及求方案数 很有可能[i][0]都要初始化为1

f[i][j][target] += f[i-1][j][target-A[m]] for m in [0….j]

区间型DP+区间型DP

区间型DP感觉是目前做了两三周动态规划题目的天花板了。有时候举一都不一定能反一,状态就更是难找了,总觉得藏的很隐蔽,目前大概的状态只能做到做过的都会吧。不再强求自己的一类题,不考验代码功力,检验智商和题海里练了多少次游泳的时候到了。没有办法做到很聪明也没有更多的时间和题海抗战,拜拜了区间型DP。

很典型的一类题目是 博弈型DP,首先这类题目的决策取决于后面的数,可能也有bottom up的写法(每次分析新加一个输还是赢……emmmm反正我做过的题我从来没有推出来转移方程……),但top down的方式比较容易分析。

但总体来说,此类的题目最重要的是画出搜索树,搜索哪些区间,以及提前稍微做做小小的预处理。

典型例题有

  1. Coins in a line1
  2. Coins in a line2
  3. 石子归并
  4. 吹气球
  5. 攀爬字符串

硬币排成线

I/II为博弈型DP,但并不是区间型DP,第三题则是,从左拿一下,从右一下(区间型的一个特征吧)

题目描述:有 n 个硬币排成一条线。两个参赛者轮流从右边依次拿走 1 或 2 个硬币,直到没有硬币为止。拿到最后一枚硬币的人获胜。

请判定 第一个玩家 是输还是赢?

分析:

搜索树

        f(n)
        /  \
  f(n-1) 	 f(n-2)(重复计算的状态)
 /     \       /  \
f(n-2) f(n-3)

先手赢的条件是后手输。

f[1]=true;
f[2] = true;
f[n] = !f[n-1]||!f[n-2];

Follow up

有 n 个不同价值的硬币排成一条线。两个参赛者轮流从左边依次拿走 1 或 2 个硬币,直到没有硬币为止。计算两个人分别拿到的硬币总价值,价值高的人获胜。

计算能拿到的最大价值,大于一半,即价值高。f[n] 为还剩n个硬币时,能拿到最多的价值。f[n] = sum[n]-min{f[n-1],f[n-2]}。此题用top-down的写法很简单。

  先                f(n)
                /       \
  后           f(n-1) 	 f(n-2)(重复计算的状态)
            /     \       /  \
  先      f(n-2) f(n-3)

Follow up

可以选择从左边拿,也可以选择从右边拿,目的都是要让别人拿的少(点评:送出题人一句话 机关算尽太聪明)

  先                f(0,n)
                /        \
  后           f(1,n) 	  f(0,n-1)(重复计算的状态)
            /     \         /      \
  先      f(2,n) f(1,n-1)  f(0,n-2) f(1,n-1)

区间型DP的经典吧。 f(1,n-1)代表区间(1,n-1)能取得的最大值。从上题的一维变成了现在的二维。

(点评:错误的思路为算一遍从左边先拿,再算一遍右边先拿….然后取一个,这样的思路是错的.)

石子归并

描述:有一个石子归并的游戏。最开始的时候,有n堆石子排成一列,目标是要将所有的石子合并成一堆。合并规则如下:

  1. 每一次可以合并相邻位置的两堆石子
  2. 每次合并的代价为所合并的两堆石子的重量之和

求出最小的合并代价。

对于石子序列:[4, 1, 1, 4](每个数代表这堆石子的重量),最优合并方案下,合并代价为 18:

1. 合并第2堆和第3堆 => [4, 2, 4], 代价 +2
2. 合并前两堆 => [6, 4],代价 +6
3. 合并剩下的两堆 => [10],代价 +10

思路

划分为两堆,先分别和并,再一起合并。

                 f(0,n)
                /        \                            \               \
      f[0,1]+ f(2,n) 	f(0,2)+ f(2,n)(重复计算的状态)  f(0,3)+f(3,n)  ........
           /     \          /      \
         f(1,2)+f(2,n) 

初始化: 如果石子数量为1,则为0

转移方程: f(0,n) = sum(0,n) + min{f(0,j)+f(j,n)} for (j

下面是一种错误的示例, 简单的认为石子始终是最后要合并一堆的,分为开头一个先合并,开头的后合并的方式。合并的次序影响着cost,如果是下面这种搜索树的话,意味着f(0)要么第一次合并,要么最后一次合并(错)。

                  f(0,n)
                /        \
       f[0,1]+ f(1,n) 	f[0,2]+ f(2,n)(重复计算的状态)
           /     \          /      \
          f(1,2)+f(2,n) 

吹气球

题目描述:有n个气球,编号为0到n-1,每个气球都有一个分数,存在nums数组中。每次吹气球i可以得到的分数为 nums[left] * nums[i] * nums[right],left和right分别表示i气球相邻的两个气球。当i气球被吹爆后,其左右两气球即为相邻。要求吹爆所有气球,得到最多的分数。

给出 [4, 1, 5, 10]

返回 270

nums = [4, 1, 5, 10] burst 1, 得分 4 * 1 * 5 = 20
nums = [4, 5, 10]    burst 5, 得分 4 * 5 * 10 = 200 
nums = [4, 10]       burst 4, 得分 1 * 4 * 10 = 40
nums = [10]          burst 10, 得分 1 * 10 * 1 = 10

题外话:看懂题目都得老长一段时间,看完答案觉得真是妙妙妙,

                f(0,n)
                /                                
       f[0,k-1]+A[k]+f[k+1,n]	

先把k两边的气球全部吹破,然后再吹破k气球。

难点在初始化

如果气球不是在边界位置,两边是一直存在气球的,所以可以光明正大的 A[k]*A[k-1]*A[k+1] .

攀爬字符串

给定一个字符串 S1,将其递归地分割成两个非空子字符串,从而将其表示为二叉树。

下面是s1 = "great"的一个可能表达:

    great
   /    \
  gr    eat
 / \    /  \
g   r  e   at
           / \
          a   t

在攀爬字符串的过程中,我们可以选择其中任意一个非叶节点,然后交换该节点的两个儿子。

例如,我们选择了 “gr” 节点,并将该节点的两个儿子进行交换,从而产生了攀爬字符串 “rgeat”。

    rgeat
   /    \
  rg    eat
 / \    /  \
r   g  e   at
           / \
          a   t

我们认为, “rgeat” 是 “great” 的一个攀爬字符串.

类似地,如果我们继续将其节点 “eat” 和 “at” 进行交换,就会产生新的攀爬字符串 “rgtae”。

    rgtae
   /    \
  rg    tae
 / \    /  \
r   g  ta  e
       / \
      t   a

同样地,“rgtae” 也是 "great"的一个攀爬字符串。

给定两个相同长度的字符串s1 和 s2,判定 s2 是否为 s1 的攀爬字符串。

分析:

写这题的时候,我还没有看答案,评论….这简直不是人做的题。

-----------------------------------------------------------分割符----------------------------------------------------

骑自行车回家的路上,发现好像其实还是人做的,区间型DP真的是区间型DP.首先这题要读懂题意,只能沿当前这棵已经分叉的树变换,而不是所谓新字符串再重新分叉变换(如果真是这样,一个字符可以变为任意一个字符。),然后比较区间是否可以通过翻转变换得到。

                            dp[0,n)
                         /          \
          dp[0,1) dp[1,n)           dp[0,2) dp[2,n)
                    /    \
                  dp[1,2) dp[2,n)

有各种划分的方法。

dp[0,n) = dp[0,i) &&dp[i,n) 划分的两个区间都是scramble string

判断语句
if(isscramble(s1.substr(0,i),s2.substr(0,i))&&isscramble(s1.substr(i,n-1),s2.substr(i,n-i))||isscramble(s1.substr(0,i),s2.substr(n-i,i))&&isscramble(s1.substr(i,n-i),s2.substr(0,n-i))){return true;}

动态规划暂时告一段落吧,虽然内心还是很想征服的,而且其实花一定长的时间肯定至少能马马虎虎的达到还算可以的掌握率的,昨晚睡觉前随意看了一个YouTube博主,里面讲leetcode的题目讲的还算可以,马马虎虎,然后其中有一个视频是《如何两个月刷题进谷歌》

博主还挺实诚的,两个月刷题进google,还是0基础,别想了,必挂。当然视频肯定是有个转折点的,说刷上个四五百题,拿挂的几率能从100%到50%了。当然这两个月能干啥呢,那只能“拼命努力”。“努力”都不行,得拼命努力才有机会。

怎么点评这个视频嘞,首先这个视频看着挺鸡汤的,其实……说的还都是实话,以及现在才知道原来去google要刷完这么多题,搞得我都想刷完这么多题了,不是为了google,而是一种征服的感觉吧(毕竟没有难道一步登天,花时间进去肯定是能刷出一定的成果的),怎么讲,希望今年年末零零散散的时间里能刷完400题吧,感觉已经成了生活的乐趣之一了。

你可能感兴趣的:(菜鸟成长之路)