[关闭]
@smilence 2020-04-12T22:02:58.000000Z 字数 14004 阅读 2458

第七部分:递归 动态规划

算法面试指南


7.1 知识解析

TODO: 递归一般用于解决“无限”(未知)步骤的问题,而循环用于解决“有限”(已知)步骤的问题

1.Build approach from subproblems to the final destination。
这类问题的keyword: compute the nth, visit the first n, return all paths, etc
凡是解决此类问题,无非只有两种方法,Bottom-UpTop-Down, 实现方式上前者对应iteration, 后者对应recursion。

本文将问题节点空间"两端收敛"的问题,归结为收敛结构,通常这类问题的path也为“有限”个,例如: n!, fibnacci(n);将节点空间"发散"的问题,归结为发散结构,例如: 二叉树的遍历。 抽象地说,能够在多项式时间内解决的问题,是收敛问题(P类问题),不能在多项式时间内解决的问题(如阶乘级或指数级),是发散问题。(关于P问题的描述:http://www.matrix67.com/blog/archives/105

特别地,聚合问题(Aggregate),即关于特解(包括判定性问题) 或最优解或总和的问题,具有很强的收敛性质。

2.递推关系是实现递归形式问题的关键
递推关系 = 递推式 + 初始条件;
对divide and conquer来说,就等效于由递推公式到通项公式的数学问题,其证明即为数学归纳法。

3.Recursion的空间与时间成本
对系统来说,一般利用底层stack实现递归。每次操作可视为stack里的一个object。
递归的 time cost 成指数增长,其中 为递归的深度(单路径递归调用的次数); space cost为

4.用stack解决递归形式问题

在使用D&C策略时,如果每个subproblem,处理的是单个同样类型的object,则可以将该object按照Top-down的方向入栈,然后循环pop()来访问。如果subproblem的size不同,则可将他们声明为同种类型的object,按照Top-Down的方向入栈;最后pop()访问时,再将复合的subproblem拆解,push入栈;如此循环直至stack清空。

e.g Solve the Hanoi Tower problem without recursion

  1. enum Tower { Zero,Left, Mid, Right};
  2. class HanoiObj{
  3. public:
  4. Tower src,tmp,dest;
  5. int num,index;
  6. HanoiObj(Tower s,Tower t,Tower d,int n):src(s),tmp(t),dest(d),num(n){
  7. if(n == 1) index = 1;
  8. }
  9. HanoiObj(Tower s,Tower d,int i):src(s),dest(d),index(i){
  10. num = 1; tmp = Zero;
  11. }
  12. };
  13. void move( Tower src, Tower dest,int index){
  14. cout << "Move Disk #" << index <<" from Tower " << src << " to Tower " << dest << endl;
  15. }
  16. void TOH(int N, stack<HanoiObj*>&Hstack){
  17. Tower s,t,d;
  18. int n;
  19. Hstack.push(new HanoiObj(Left,Mid,Right,N));
  20. while( !Hstack.empty()){
  21. HanoiObj *tmpObj = Hstack.top();
  22. Hstack.pop();
  23. s = tmpObj->src;
  24. t = tmpObj->tmp;
  25. d = tmpObj->dest;
  26. n = tmpObj->num;
  27. if(n < 1) continue;
  28. if(n == 1)
  29. move(s,d,tmpObj->index);
  30. else{
  31. Hstack.push(new HanoiObj(t,s,d,n-1));
  32. Hstack.push(new HanoiObj(s,d,n));
  33. Hstack.push(new HanoiObj(s,d,t,n-1));
  34. }
  35. delete tmpObj;//
  36. }
  37. }

5.算法策略
Divide and conquer : 将问题分成几个部分,每一部分问题互相不重叠,假定每个部分都可以得到解决进行递归调用,合并每一部分的结果。例如Merge Sort,Quick Sort。

Dynamic Programming:尽可能不重复计算每个subproblem,将计算结果存储下来,以确定后驱问题的结果。与贪心算法的区别是,在计算后驱问题时,可能会综合考虑多个前驱问题的解。

Greedy Algorithm : 只做出当下最优的判断,从全局来说并不能保证总是最优。如Dijkstra算法。

Backtracking: 一种暴力(穷举)的深度优先搜索法,直至检索到节点空间的尽头。Tree或Graph的DFS,是backtracking的special case。
http://stackoverflow.com/questions/6162465/divide-and-conquer-dynamic-programming-and-greedy-algorithms

Backtracking办法的典型模板:

  1. void backtracking( P point, vector<P> &path, vector<vector<P> >&paths ){
  2. if(!point ) // invalid point
  3. return;
  4. path.push_back(point);
  5. bool success = ; // condition for success
  6. if( success )
  7. paths.push_back( vector<P>(path.begin(),path.end()) ); // don't return here
  8. for( P next: all directions )
  9. backtracking( next, path, paths );
  10. path.pop_back();
  11. return;
  12. }

7.2 由下往上地解决收敛结构问题(1)

具有强收敛性的问题(例如关于特解或最优解或数量总和的问题,所有的判定性问题),问题对应的N维有限空间中的节点可以用整数坐标表示, 且当前节点的解只依赖于前驱节点(无论顺序还是倒序),那么往往可以用dynamic programming解决。解决的关键是建立subproblem的解之间的递推关系,也就是当前节点由前驱节点决定, 然后将当前节点的解(或往往是,以当前节点为末节点的解,抑或是以当前两个节点为头尾的序列的解)array记录下来(如果当前节点只由之前紧接的若干个节点决定,那么也可以只用若干个变量)。

处理时以问题的一端为循环开端,以另一端为循环终止条件( 两端均为收敛),记录部分或全部解(DP Table。如int cache[n] ),DP Table的下标即为递推关系中的函数参数。

如果除到达终点外还需记录具体的path,则可记录前驱节点prev[n]vector<vector<int> > prev,然后用backtracking处理成path。只不过,这时候backtracking经过DP的剪枝,只剩下正确的路径。因此这时候判断非法节点和胜利条件通常是等价的。

e.g.1 Climbing takes n steps to reach to the top. Each time you can either climb 1 or 2 or 3 steps. In how many distinct ways can you climb to the top? ( CtCI 9.1 ,Leetcode: Climbing Stairs)

  1. int climbStairs(int n) {
  2. if(n <= 1 ) return 1;
  3. if(n == 2) return 2;
  4. int p = 1, q = 2, curr;
  5. for(int i = 3; i <= n; ++i ){
  6. curr = p + q;
  7. p = q;
  8. q = curr;
  9. }
  10. return curr;
  11. }

e.g.2 Compute the Nth prime ( Ebay interview )
e.g.3 Given a string s and a dictionary of words dict, determine if s can be segmented into a space-separated sequence of one or more dictionary words. ( Leetcode: Word Break )
e.g.4 Given a string s, partition s such that every substring of the partition is a palindrome.Return the minimum cuts needed for a palindrome partitioning of s. (Leetcode:Palindrome Partitioning II)

  1. int minCut(string s) {
  2. if(s.empty()) return 0;
  3. vector<vector<bool> > palin(
  4. s.size(),
  5. vector<bool>(s.size(),false),
  6. );
  7. vector<int> minCut(s.size()+1,0);
  8. for(int i = 0; i <= s.size(); i++)
  9. minCut[i] = s.size() - i - 1;
  10. for(int i = s.size() - 1; i >= 0; i--){
  11. for(int j = i; j < s.size();++j){
  12. if(s[i] == s[j] && ( j - i <= 1 || palin[i+1][j-1] ) ){
  13. palin[i][j] = true;
  14. minCut[i] = min( minCut[j+1]+1,minCut[i]);
  15. }
  16. }
  17. }
  18. return minCut[0];
  19. }

特别地,具有简单“聚合”性质的问题,如最值或求和问题,往往可以进一步优化DP Table的空间。更确切地说,是那些只在乎紧邻的前一个最优解,而不在乎前几个最优解的问题(这样的话,就可以接受每次替换掉那个最优解,在这个维度上只用O(1)的空间)最简单的例子就是求一个数组内数的最值/求和。

e.g.1 How many paths are there for the robot to go from to ( CtCI 9.2, Leetcode: Unique Path)

  1. int uniquePaths(int m, int n) {
  2. int *ways = new int[n];
  3. for(int j = 0; j < n; j++ )
  4. ways[j] = 0;
  5. ways[n-1] = 1;
  6. for(int i = m - 1; i >= 0; --i)
  7. for(int j = n - 2; j >= 0; --j )
  8. ways[j] += ways[j+1];
  9. return ways[0];
  10. }

e.g.2 Given a value N, if we want to make change for N cents, and we have infinite supply of each of S = { S1, S2, .. , Sm} valued coins, how many ways can we make the change? (GeeksforGeeks: http://www.geeksforgeeks.org/dynamic-programming-set-7-coin-change/ )

  1. int count( int S[], int m, int n )
  2. {
  3. // If n is 0 then there is 1 solution (do not include any coin)
  4. if (n == 0)
  5. return 1;
  6. // If n is less than 0 then no solution exists
  7. if (n < 0)
  8. return 0;
  9. // If there are no coins and n is greater than 0, then no solution exist
  10. if (m <=0 && n >= 1)
  11. return 0;
  12. // count is sum of solutions (i) including S[m-1] (ii) excluding S[m-1]
  13. return count( S, m - 1, n ) + count( S, m, n-S[m-1] );
  14. }
  1. int count( int S[], int m, int n )
  2. {
  3. int i, j, x, y;
  4. // We need n+1 rows as the table is consturcted in bottom up manner using
  5. // the base case 0 value case (n = 0)
  6. int table[n+1][m];
  7. // Fill the enteries for 0 value case (n = 0)
  8. for (i=0; i<m; i++)
  9. table[0][i] = 1;
  10. // Fill rest of the table enteries in bottom up manner
  11. for (i = 1; i < n+1; i++)
  12. {
  13. for (j = 0; j < m; j++)
  14. {
  15. // Count of solutions including S[j]
  16. x = (i-S[j] >= 0)? table[i - S[j]][j]: 0;
  17. // Count of solutions excluding S[j]
  18. y = (j >= 1)? table[i][j-1]: 0;
  19. // total count
  20. table[i][j] = x + y;
  21. }
  22. }
  23. return table[n][m-1];
  24. }
  1. int count( int S[], int m, int n )
  2. {
  3. // table[i] will be storing the number of solutions for
  4. // value i. We need n+1 rows as the table is consturcted
  5. // in bottom up manner using the base case (n = 0)
  6. int table[n+1];
  7. // Initialize all table values as 0
  8. memset(table, 0, sizeof(table));
  9. // Base case (If given value is 0)
  10. table[0] = 1;
  11. // Pick all coins one by one and update the table[] values
  12. // after the index greater than or equal to the value of the
  13. // picked coin
  14. for(int i=0; i<m; i++)
  15. for(int j=S[i]; j<=n; j++)
  16. table[j] += table[j-S[i]];
  17. return table[n];
  18. }

e.g.3 Please implement a function which gets the minimal number of coins, whose value is v1, v2, …, vn, to make change for an amount of money with value t. Any coin with value vi may duplicate for any times to make change. (http://codercareer.blogspot.com/2011/12/no-26-minimal-number-of-coins-for.html)

7.3 由下往上地解决收敛结构问题(2)

对于“最长子序列”问题(即求解有限空间内,满足一定条件的最长顺序节点序列),用DP Table来记录以当前节点为末节点的序列(而不是以当前节点或之前节点为末节点的序列)的解,并根据递推关系,由问题空间的起点到达问题空间的终点。注意,对于"回文"序列,中间节点可以等效为序列的末节点。

e.g.1 The longest Increasing Subsequence (LIS) problem is to find the length of the longest subsequence of a given sequence such that all elements of the subsequence are sorted in increasing order. For example, length of LIS for { 10, 22, 9, 33, 21, 50, 41, 60, 80 } is 6 and LIS is {10, 22, 33, 50, 60, 80}. (http://www.geeksforgeeks.org/dynamic-programming-set-3-longest-increasing-subsequence/)

  1. /* lis() returns the length of the longest increasing
  2. subsequence in arr[] of size n */
  3. int lis( int arr[], int n )
  4. {
  5. int *lis, i, j, max = 0;
  6. lis = (int*) malloc ( sizeof( int ) * n );
  7. /* Initialize LIS values for all indexes */
  8. for (i = 0; i < n; i++ )
  9. lis[i] = 1;
  10. /* Compute optimized LIS values in bottom up manner */
  11. for (i = 1; i < n; i++ )
  12. for (j = 0; j < i; j++ )
  13. if ( arr[i] > arr[j] && lis[i] < lis[j] + 1)
  14. lis[i] = lis[j] + 1;
  15. /* Pick maximum of all LIS values */
  16. for (i = 0; i < n; i++ )
  17. if (max < lis[i])
  18. max = lis[i];
  19. /* Free memory to avoid memory leak */
  20. free(lis);
  21. return max;
  22. }

e.g.2 Given the heights of each person in the circus, compute the largest possible number of people in tower. ( CtCI 11.7)
e.g.3 Find the contiguous subarray within an array (containing at least one number) which has the largest sum. ( Leetcode: Maximum Subarray, CtCI 17.8 )

  1. int maxSubArray(int A[], int n) {
  2. if(!n) return 0;
  3. int max_sum = A[0], sum = A[0];
  4. for(int i = 1; i < n; i++){
  5. sum = max(sum + A[i], A[i]);
  6. if(sum > max_sum)
  7. max_sum = sum;
  8. return max_sum;
  9. }
  10. }

e.g.4 There are N gas stations along a circular route, where the amount of gas at station i is gas[i].You have a car with an unlimited gas tank and it costs cost[i] of gas to travel from station i to its next station (i+1). You begin the journey with an empty tank at one of the gas stations. Return the starting gas station's index if you can travel around the circuit once, otherwise return -1. (Leetcode: Gas Station)

e.g.5 Longest Common Subsequence: LCS for input Sequences “ABCDGH” and “AEDFHR” is “ADH” of length 3. ( http://www.geeksforgeeks.org/dynamic-programming-set-4-longest-common-subsequence/)

特别地,如果当前节点的解既依赖于前驱子问题,又依赖于后驱子问题,,则可考虑先顺序遍历,记录DP Table,再倒序遍历,合并DP Table的解。

e.g.1 Say you have an array for which the ith element is the price of a given stock on day i.Design an algorithm to find the maximum profit. You may complete at most two transactions.(Leetcode: Best Time to Buy and Sell Stock III)
e.g.2 Replace each element with the product of all elements other than that element.(Ebay Interview)
e.g.3 Given n non-negative integers representing an elevation map where the width of each bar is 1, compute how much water it is able to trap after raining.( Leetcode: Trapping Rain Water)

7.4 由上往下地解决收敛结构问题

对于弱收敛性问题(例如“满足特定条件的所有解"),如果胜利条件只取决于当前节点与后驱节点,而与前驱节点无关,并且subproblem有overlap,那么可以用递推公式为参照,利用memoization technique来进行递归, 即记录当前计算的局部解, 并在有可能时进行读取(以当前的函数参数为key,函数的返回值为value)。Memoization是Top-Down形式的Dynamic Programming,并且受到的制约更少,自然也可以用来解决强收敛性问题(但空间上可能效率不及Bottom-Up形式的DP)。

e.g.1 Build the tallest stack of boxes, if each box is larger than the box above it in any dimension

  1. vector<Box> createStackDP( Box boxes[],const int num, Box bottom, map< Box, vector<Box> >& stackCache){
  2. vector<Box> max_stack;
  3. int max_height = 0;
  4. vector<Box> new_stack;
  5. if( stackCache.count( bottom ) > 0 )
  6. return stackCache[ bottom ];
  7. else{
  8. for( int i = 0; i < num; i++ ){
  9. if( Box[i].canBeAbove( bottom ) ){
  10. new_stack = createStackDP( boxes, num, Box[i], stackCache );
  11. }
  12. if( new_stack.size() > max_height ){
  13. max_height = new_stack.size();
  14. max_stack = new_stack;
  15. }
  16. }
  17. }
  18. max_stack.insert( max_stack.begin(), bottom );
  19. stackCache[ bottom ] = max_stack;
  20. return max_stack;
  21. }

e.g.2 Given a string s and a dictionary of words dict, add spaces in s to construct a sentence where each word is a valid dictionary word.

  1. class Solution {
  2. public:
  3. vector<string> wordBreak(string s,unordered_set<string> &dict,unordered_map<string,vector<string> >& cache){
  4. if(cache.count(s))
  5. return cache[s];
  6. vector<string> vs;
  7. if(s.empty()){
  8. vs.push_back(string());
  9. return vs;
  10. }
  11. for(int len = 1; len <= s.size(); ++len ){
  12. string prefix = s.substr(0,len);
  13. if(dict.count(prefix) > 0){
  14. string suffix = s.substr(len);
  15. vector<string> segments = wordBreak(suffix,dict,cache);
  16. for(int i = 0; i < segments.size(); ++i){
  17. if(segments[i].empty()) vs.push_back(prefix);
  18. else vs.push_back(prefix + " " + segments[i]);
  19. }
  20. }
  21. }
  22. cache[s] = vs;
  23. return vs;
  24. }
  25. vector<string> wordBreak(string s, unordered_set<string> &dict) {
  26. // IMPORTANT: Please reset any member data you declared, as
  27. // the same Solution instance will be reused for each test case.
  28. if(s.empty()) return vector<string>();
  29. unordered_map<string,vector<string> > cache;
  30. return wordBreak(s,dict,cache);
  31. }
  32. };

7.5 用回溯解决发散结构问题

对于发散性问题(例如“所有组合","全部解"),可以选取其问题空间"收敛"的一端作为起点,沿着节点发散的方向(或者说,当前决策的多种选择)递归,直到a.当前节点"不合法" 或 b.当前节点发散方向搜索完毕才会return。所谓"剪枝"(pruning),就是指只选择尽可能少的、可能到达"胜利条件"的方向, 例如将幂指数级的复杂度降低到阶乘级。注意如果经过剪枝之后,所有搜索只会沿着"正确"的方向行进,那么当前节点"不合法"往往也就意味着胜利条件。

如果需要记录决策的路径,可以用vector<int> &path沿着搜索的方向记录,在满足胜利情形时记录当前path。注意在return前,删除path中的当前节点。如果搜索的方向有出现环路的可能,那么可以使用bool []unordered_map来记录该节点是否已被使用,只要注意在访问时以及return前维护即可(TODO? 需要在访问下一个节点之前记录)。TODO: 换句话说,如果backtracking可能会回到原点,那么需要用visited table去记录哪些被访问过,否则就会infinite loop或recursion
e.g.1 Graph BFS, DFS
e.g.2 841. Keys and Rooms TODO : A*启发式搜索
e.g.3 https://leetcode.com/problems/sliding-puzzle/

Backtracking办法的典型模板:

  1. void backtracking( P point, vector<P> &path, vector<vector<P> >&paths ){
  2. if(!point ) // invalid point
  3. return;
  4. path.push_back(point);
  5. bool success = ; // condition for success
  6. if( success )
  7. paths.push_back( vector<P>(path.begin(),path.end()) ); // don't return here
  8. for( P next: all directions )
  9. backtracking( next, path, paths );
  10. path.pop_back();
  11. return;
  12. }

e.g.1 Given n pairs of parentheses, generate all combinations of parentheses.

  1. class Solution {
  2. public:
  3. void gP(int leftRem, int rightRem, string &path, vector<string> &paths ){
  4. if(leftRem < 0 || rightRem < 0)
  5. return;
  6. if(leftRem > 0){
  7. path.push_back('(');
  8. gP(leftRem-1,rightRem,path,paths);
  9. path.pop_back();
  10. }
  11. if(leftRem < rightRem){
  12. path.push_back(')');
  13. if(rightRem == 1)
  14. paths.push_back(path);
  15. gP(leftRem, rightRem-1,path,paths);
  16. path.pop_back();
  17. }
  18. }
  19. vector<string> generateParenthesis(int n) {
  20. vector<string> res;
  21. if(n == 0) return res;
  22. string path;
  23. gP(n,n,path,res);
  24. return res;
  25. }
  26. };

e.g.2 Implement the "paint fill" function that fill in the surrounding area until the color changes.

  1. enum Color{
  2. white,yellow,green,blue,black
  3. };
  4. void paintFill( int **screen, int m, int n, int x, int y, Color ncolor){
  5. if( x >= n || x < 0 || y >= m || y < 0 )
  6. return;
  7. if( screen[y][x] != ncolor){
  8. screen[y][x] = ncolor;
  9. paintFill( screen, m, n, x +1,y, ncolor);
  10. paintFill( screen, m, n, x ,y +1, ncolor);
  11. paintFill( screen, m, n, x -1 ,y , ncolor);
  12. paintFill( screen, m, n, x ,y -1 , ncolor);
  13. }
  14. }

e.g.3 Placing n queens on an n×n chessboard such that no two queens attack each other.

  1. bool checkValid( int row1, int col1,int *rowCol ){
  2. for( int row2 = row1 - 1; row2 >= 0; row2--){
  3. if( rowCol[row2] == col1 )
  4. return false;
  5. if( abs(row1 - row2) == abs( rowCol[row2] - col1 ) )
  6. return false;
  7. }
  8. return true;
  9. }
  10. void placeQ( int row, int rowCol[], vector<int*>& res ){
  11. if (row == GRID_SIZE){
  12. int *p = new int[GRID_SIZE];
  13. for( int i =0; i < GRID_SIZE; i++)
  14. p[i] = rowCol[i];
  15. res.push_back(p);
  16. return;
  17. }
  18. int col = 0;
  19. for(col = 0; col < GRID_SIZE; col++){
  20. if( checkValid(row,col,rowCol) ){
  21. rowCol[row] = col;
  22. placeQ( row+1, rowCol,res);
  23. }
  24. }
  25. }

e.g.4 Solve a boggle game, with a dictionary given as unordered_set.

7.6 用Divide and Conquer 解决独立子问题

如果能将问题分成若干个部分解决,且互相孤立,则可以优先选择使用D&C策略。特别地,如果期望将问题的复杂度由进一步降低(),一般总是可以联想到使用D&C策略,将问题分割而治。

e.g. 1 Implement pow(x, n).

  1. double pow(double x, int n) {
  2. if (n == 0) return 1.0;
  3. if( abs(x) < numeric_limits<double>::epsilon() )
  4. return 0.0;
  5. double half = pow(x,n/2);
  6. if(n%2 == 0)
  7. return half*half;
  8. else if(n > 0)
  9. return half*half*x;
  10. else
  11. return half*half/x;
  12. }

e.g.2 Divide two integers without using multiplication, division and mod operator.

  1. class Solution {
  2. public:
  3. int divide(int dividend, int divisor) {
  4. // IMPORTANT: Please reset any member data you declared, as
  5. // the same Solution instance will be reused for each test case.
  6. int sign = 1;
  7. if(divisor == 0) throw overflow_error("Divide by zero exception");
  8. if(dividend < 0) sign = -1;
  9. if(divisor < 0 ) sign = -sign;
  10. long long y = abs((long long)dividend);
  11. long long x = abs((long long)divisor);
  12. int power = 0;
  13. while( (x<<power) <= y )
  14. power++;
  15. long long res = 0;
  16. while( y >= x ){ //divide the problem into dealing with every bit
  17. if( y >= x<<power ){
  18. y -= x<<power;
  19. res |= 1<<power;
  20. }
  21. power--;
  22. }
  23. if(sign == -1) return -res;
  24. else return res;
  25. }
  26. };

e.g. 3 Evaluate an infix notation( without parentheses ). ( Ebay interview question ).

  1. double readNum( char * &str){
  2. double num;
  3. sscanf( str, "%lf", &num );
  4. str++;
  5. while( isdigit(*str) || *str == '.' )
  6. str++;
  7. return num;
  8. }
  9. double evaluate( char *str){
  10. if( *str == '\0' )
  11. return 0.0;
  12. double res = 1.0;
  13. char op = '*';
  14. while( op == '*' || op == '/' ){
  15. if( op == '*' )
  16. res *= readNum(str);
  17. else
  18. res /= readNum(str);
  19. op = *str++;
  20. }
  21. return res + evaluate( --str);
  22. }
添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注