搜索&动态规划

  • 今天我想讲解一下平常算法用到的搜索和DP问题,也是对现阶段学习的一个总结。之前和同事聊天,有人说DP问题不好解决总是找不到动态规划方程。其实很多DP问题可以通过搜索来解决,很多搜索的问题也可以转换成DP问题去求解。二者又很多相识处。

搜索

  • 从语义上讲,搜索指的是当你不知道答案的时候,不断的向下尝试,直到最后能求得结果。在搜索过程中,我们可能会失败,也就是意味着我们要从失败的地方重新搜索,也就是我们所说的回溯,回溯到失败前的状态去搜索新的情况。但是有时候我们会重复搜索很多情况而导致时间过久,那么这个时候我们要做的就是记录。记录下来搜索的结果,以免再次搜索。
    简单的讲就是DFS+回溯+记录结果集。但是这样的搜索会涉及到大量的函数栈调用,通常情况下函数调用栈的大小是1~2M,如果你在不知道数据量的情况下直接在工程中使用搜索,那么可能会引起栈溢出。(不过我们可以使用BFS用队列去解决这个问题)这里面还需要注意的一点就是回归条件,所谓的回归条件就是指,什么时候停止搜索,就是搜索到的最小情况时如何计算和返回。请看下面的例子。
    (这篇文章中我会引用leetcode中的题目作为参考)

  • 这是一个很典型的搜索问题,我们需要输出所有可能的排列,那么我们就从第一个数字开始向后查找,并把结果记录到临时的结果集当中,当我们查找到回归条件的时候(回归条件为临时结果集的大小的输入数据的大小),我们再把临时结果集copy到最终的大的结果中。之后,我们需要回溯去找到说有可能的情况直到搜索结束。这道题我加了一个标致位用来记录数据是否被搜索过,这个状态也要在回溯计算的时候进行回溯。
    在这里插入图片描述
    code:

void dfs(vector<bool> &table, vector<int> &record, int k, vector<int> &data, vector<vector<int>> &ret){
        if(k == data.size()){  //回归条件
            ret.push_back(record);
            return;
        }
        
        for(int i = 0; i < table.size(); ++i){
            if(table[i]){
                table[i] = false; 	//标志位改变,避免再次被搜索
 record.push_back(data[i]);  //记录到临时变量中
                dfs(table, record, k + 1, data, ret);  //搜索结果
                table[i] = true; record.pop_back(); //回溯
            }
        }
    }
    
    vector<vector<int>> permute(vector<int>& nums) {
        vector<vector<int>> ret;
        if(nums.size() == 0) return ret;
        vector<bool> table(nums.size(), true);
        vector<int> record; 
        
        dfs(table, record, 0, nums, ret);
        
        return ret;
}

上面就是这道题目的代码,并不是很复杂,下面我们看一道稍微复杂一点的题目来理解一下搜索的强大,我记得在高中学习计算机竞赛的时候,经常会有人说,想不起来就搜索。

在这里插入图片描述

  • 这道题我们可以搜索这个字符串,如果发现了某一段字符在字典里面,那么就记录下来,接着搜索,直到找到字符串的最后。其实和刚才那道题目的基本方法时一样的,这么看起来这道题就很简单了。这道题目的字典存储我使用了字典树,字典树时一个很不错的数据结构,在很多时候的性能时优于hash的。
//存放字典的数据结构
struct Node{
        bool isEnd;
        Node* ptr[60];
        Node():isEnd(false){
            for(auto i = 0; i < 60; ++i) ptr[i] = NULL;
        }
    };
    /*
**插入到字典中,构建字典
*/
    void insertToTree(Node* p, string& word){
        
        for(auto i = 0; i < word.size(); ++i){
            if (p->ptr[word[i] - 'A'] == NULL){
                Node* s = new Node();
                p->ptr[word[i] - 'A'] = s;
            }
            p = p->ptr[word[i] - 'A'];
        }
        
        p->isEnd = true;
    }
    
     /*
	 ** 搜索函数 
	 * s :搜索字符串 
	 * root :字典的根
	 * index: 搜索索引用于回归
	 * status : 用于标记是否被搜索,避免二次搜索
	 * ret : 临时结果,会动态修改
	 * retSet : 最终结果集
	 */
    bool search(string& s, Node* root, int index, vector<int>& status, string& ret, vector<string>& retSet){
        if(index == s.size()) //搜索完成回归 
        {
            retSet.push_back(ret);
            return true;
        }
        
        if(status[index] == 2) return false; //已经搜索过切不能成功需要返回
        
        Node* p = root;
        bool flag = false;
        int index_back = index;
        
        while(p){
            if(p->isEnd){//找到某个单词放入结果中 继续搜索
                string back = ret;
                if(ret.size() == 0)
                    ret = ret + s.substr(index_back, index - index_back);
                else
                    ret = ret + " " + s.substr(index_back, index - index_back);
                
                flag = search(s, root, index, status, ret, retSet);
                ret = back; //回溯到之前的状态
            }
            
            if(index == s.size()) break; //处理找不到的特殊情况
            
            p = p->ptr[ s[index] - 'A' ];

            index++;
        }  
        
        status[index_back] = flag ? 1 : 2; //设置标记
        
        return flag;
    }
    
    vector<string> wordBreak(string s, vector<string>& wordDict) {
        vector<string> retSet;
        string ret;
        
        if(wordDict.size() == 0) return retSet;
        Node* root = new Node();
        
        for(auto i = 0; i < wordDict.size(); ++i){
             insertToTree(root, wordDict[i]);
        }
        
        //0 -> unknown  1-> ok 2 -> failed
        vector<int> status(s.size() + 1, 0);
        
        search(s, root, 0, status, ret, retSet);
        
        return retSet; 
    }

  • 这里面比较关键的点就是设置了标记数组,用来标记每一个位置的搜索情况,以免再次搜索,这时候我们会想起来DP问题的DP数组,其实我们用这个标记数组的目的和DP数组时类似的也是为了避免多次搜索,只不过两者看问题的方式不一样以及处理逻辑不一样,搜索是不管你有木有DP公式,我就是求解当前问题,把当前问题分成小的问题,然后再解决小的问题直到问题小到不能再小,也就是回归。DP则是从小到大看问题,如果我求出来某一个小的问题,我就会知道大的问题的答案。直到求解出当前问题的答案结束。两者的共同就是都要把问题拆分成小问题,且小问题可求且相互独立。

  • 这道题目也又DP求解的思路,定义dp数组,每一个表示再某一个位置是否可以构成合法单词(这里可以理解位一个bool数组),且存储当前位置构成的单词;

  • dp[k] = dp[i] && check( s.sub_str(i, k -i)); (i 属于 0 ~k)

也就是说再第k个位置的元素的状态,由前面所有元素的状态决定,如果存在合法的单词且dp[i]的位置也合法,就可以继续向下推导。

那么下面我们看一下DP问题。

动态规划

DP问题我想很多人都很了解,可以用一句话概括,将带求问题分解为子问题来求解。其实这个划分很重要,划分必须保证划分的新的问题之间保持相互独立 如果因为划分的问题而影响了子问题,则是不对的。所以如何去划分问题才是动态规划的关键所在。相应的DP公式以及DP数组则会迎刃而解。

  • 题目很简单,也是很经典的一道DP问题,我们要求解当前的最大乘积的问题就可以划分为,一个子问题,前一个的最大值和最小值是多少,有了前一个的这两个值,就可以求出来当前的最大值最大值出来。如是我们以此类推。
    DP公式,DP数组当然是保存最大最小值即可,
    DP(i) = MAX ( DP(i-1).max * DP(i), DP(i-1).min * DP(i), DP(i) )

code


 int maxProduct(vector<int>& nums) {
        vector<int> maxSet, minSet;
        
        int max = nums[0]; maxSet.push_back(nums[0]);  minSet.push_back(nums[0]); 
        
        for(int i = 1; i < nums.size(); ++i){
            int a = nums[i] * maxSet[i - 1], b = nums[i] * minSet[i - 1], c = nums[i];
            
            int _max = a > b ? (a > c ? a : c) : (b > c ? b : c);
            int _min = a < b ? (a < c ? a : c) : (b < c ? b : c) ;
            
            maxSet.push_back(_max);  minSet.push_back(_min); 
            
            max = maxSet[i] > max ? maxSet[i] : max;
        }
        
        return max;
}

对比

那么接下来我用一道题目对比一下搜索和DP,可以发现DP和搜索其实是相通的,都是为了把问题缩小求解的一个过程。只是一个从前,一个从后求解。
在这里插入图片描述

  • 观察这个问题,当我们搓破一个气球的时候,数组的相邻顺序会被改变,我们搓不同的顺序会造成不同的结果。我一开始曾想过把不同结果记录下来,然后依次向上推(使用了字符串来记录)导致了效率很低。然后发现其实可以不关注边界值,也就是是说我们求解0 ~ n的时候我们可以不用去管边界值,只去管里面的气球,即dp[0][n]的结果为只搓破1~n-1气球活得的硬币数量。

  • 那么 dp[i][j] = dp[i][k] + dp[k][j] + nums[i] * nums[k] * nums[j]

  • 这里面我们要特殊处理,即扩大边界,前后各加1,以得到最终结果。
    我分别使用了DP和搜索来实现了这道题目

/**
* 动态规划
*/
int maxCoins(vector<int>& nums) {
        int n = nums.size();

        vector<int> l(n + 2, 0);
        vector< vector<int> > dp(n + 2, l);

        for(int i = 2; i <= n + 1; ++i){
            for(int j = i - 2; j >= 0; --j){
                int max = 0, temp = 0;

                for(int k = j + 1; k < i; ++k){
                    int left = 1, right = 1; //因为扩充了数组,所以nums的下表要比dp的小1,且不能越界
                    if(j - 1 >= 0) left = nums[j - 1];
                    if(i - 1 < n) right = nums[i - 1];
                    temp = nums[k -1] * left * right;

                    temp = temp + dp[j][k] + dp[k][i];
                    max = temp > max ? temp : max;
                }
                dp[j][i] = max;
            }
        }

        return dp[0][n + 1];
    }
	 //搜索函数
	//这里我没有写回归的地方,因为我再初始化的时候做了特殊处理
    int search(int begin, int end, vector<vector<int>>& dp, vector<int>& nums){
        
        int ret = 0, temp = 0, left_part = 0, right_part = 0;
        
        for(int k = begin + 1; k < end; k++){
            if(k - begin > 1) 
                left_part = dp[begin][k] == 0 ? search(begin, k, dp, nums) : dp[begin][k];
            else left_part = 0;
            
            if(end - k > 1) 
                right_part = dp[k][end] == 0 ? search(k, end, dp, nums) : dp[k][end];
            else right_part = 0;
            
            int left_num = begin - 1 >= 0 ? nums[begin - 1] : 1;
            int right_num = end - 1 < nums.size() ? nums[end - 1] : 1;
            
            temp = left_part + right_part + left_num * nums[k - 1] * right_num;
            
            if(temp > ret) ret = temp;
        }
        
        dp[begin][end] = ret;
        
        return ret;
    }
    
    int maxCoins1(vector<int>& nums) {
        int n = nums.size();
        
        vector<int> l(n + 2, 0);
        vector<vector<int>> dp(n + 2, l);
        
        return search(0, n + 1, dp, nums);

    }

总结,综上可见搜索和DP的关键就是如何去拆分问题(拆分的问题需相互独立),当你可以合理的拆分问题的时候不管你用哪一种方法都可以求出来最终结果。如果你不能确定你拆分的对不对,就从最小的情况开始测试,如果没问题那么就可以用上面两种方法求解得出。

  • 4
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值