记忆化搜索

目录

引入

优化

与递推的区别、联系

再战经典

题外解法


引入

[NOIP2005 普及组] 采药


抛开效率来讲,我们的“枚举法”堪称无敌,无视一切防御。所以我们很自然的可以设计出下述代码。

int n, t;
int tcost[103], mget[103];
int ans = 0;

//当前位置pos 剩余时间tleft, 临时答案tans
//解决在T时间内能获取的最大值。
void dfs(int pos, int tleft, int tans) {
  if (tleft < 0) return;//基本情况
  if (pos == n + 1) {//更新答案
    ans = max(ans, tans);
    return;
  }
  dfs(pos + 1, tleft, tans);//不采取
  dfs(pos + 1, tleft - tcost[pos], tans + mget[pos]);//采取
}

int main() {
  cin >> t >> n;
  for (int i = 1; i <= n; i++) cin >> tcost[i] >> mget[i];
  dfs(1, t, 0);
  cout << ans << endl;
  return 0;
}

我们可以列出上述递归函数方程T(n) = 2 * T(n - 1)。 

我们很容易就能得知,上述代码的时间复杂度为O(2^{n})。这显然是不能通过我们的所有案例的。

优化

上面的做法为什么效率低下呢?因为同一个状态会被访问多次。

如果我们每查询完一个状态后将该状态的信息存储下来,再次需要访问这个状态就可以直接使用之前计算得到的信息,从而避免重复计算。这充分利用了动态规划中很多问题具有大量重叠子问题的特点,属于用空间换时间的「记忆化」思想

具体到本题上,我们在朴素的 DFS 的基础上,增加一个数组 mem 来记录每个 dfs(pos,tleft) 的返回值。刚开始把 mem 中每个值都设成 -1(代表没求解过,因为最大价值不可能为负值)。每次需要访问一个状态时,如果相应状态的值在 mem 中为 -1,则递归访问该状态。否则我们直接使用 mem 中已经存储过的值即可。

通过这样的处理,我们确保了每个状态只会被访问一次,因此该算法的的时间复杂度为O(TM)

int n, t;
int tcost[103], mget[103];
int mem[103][1003];

int dfs(int pos, int tleft) {
  if (mem[pos][tleft] != -1) //此前访问过,用记忆数组访问该状态
    return mem[pos][tleft];  // 已经访问过的状态,直接返回之前记录的值

  if (pos == n + 1) return mem[pos][tleft] = 0;//标记访问过了pos == n + 1的情况

  int res1, res2 = -INF;//因为res2可能未被负值所以需要初始化。
  res1 = dfs(pos + 1, tleft);//赋值res1--不采取

  if (tleft >= tcost[pos])//如果可以采取
    res2 = dfs(pos + 1, tleft - tcost[pos]) + mget[pos];  // 状态转移
  return mem[pos][tleft] = max(res1, res2);  // 最后将当前状态的值存下来
}

int main() {
  memset(mem, -1, sizeof(mem));
  cin >> t >> n;
  for (int i = 1; i <= n; i++) cin >> tcost[i] >> mget[i];
  cout << dfs(1, t) << endl;
  return 0;
}

与递推的区别、联系

在求解动态规划的问题时,记忆化搜索与递推的代码,在形式上是高度类似的。这是由于它们使用了相同的状态表示方式和类似的状态转移。也正因为如此,一般来说两种实现的时间复杂度是一样的。

确切地说,此时我们的递推应该被称之为动态规划,再细致一下是背包DP中的一般的背包问题。在逻辑上,「记忆化」思想也是我们动态规划的底层思想。

这里有一个简单的例子:

在我们求解 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 时,我们很容易获取到了答案是8,但是如果我们说在上述计算式上再 + 1,那么我们也可以很快回答出答案是9。因为没有人会傻到再从头一个个去加,而是直接利用先前的式子答案8。这也正是我们动态规划的底层思想--记忆化

记忆化搜索和动态规划的联系就在于处理的底层思维相同,至于区别则在于前者利用函数,后者利用循环式

下面给出的是递推实现的代码(为了方便对比,没有添加滚动数组优化),通过对比可以发现二者在形式上的类似性。

const int maxn = 1010;
int n, t, w[105], v[105], f[105][1005];

int main() {
  cin >> n >> t;
  for (int i = 1; i <= n; i++) cin >> w[i] >> v[i];
  for (int i = 1; i <= n; i++)
    for (int j = 0; j <= t; j++) {
      f[i][j] = f[i - 1][j];//不采取
      if (j >= w[i])//如果可以采取
        f[i][j] = max(f[i][j], f[i - 1][j - w[i]] + v[i]);  // 状态转移方程--不采取VS采取
    }
  cout << f[n][t];
  return 0;
}

实际上,上述的记忆数组f可以优化为一维。但由于今天的主角并非是背包DP,所以我们不给出优化的思路过程。(挖一个坑,悲)

再战经典

买股票的最佳时机

 

这道题的解题思路有许多,这里我们主要介绍记忆化搜索和DP的做法,其余的做法稍作解释。

闲言少叙,书归正传。我们将视线重新拉回正题。

对于这道题我们需要求解的是利润,而利润代表着一种“(净)变值”。所以我们可以从一个稍微不同的角度来看待这个问题。我需要求取的是某两天之间最大的净变值。继而我们将问题转置成为非空连续子序列的最大和

既然问题转置成为一个很经典的问题--最大连续子序列和,那么如果我们想利用函数去解决的话,我们很清晰的知道我们可以采用分治策略+函数来实现。以下为一般代码:

class Solution {
    int MaxSum (vector<int>& arr, int left, int right) {
        if (left == right) return arr[left];//基本情况--一个元素

        int mid = ( left + right ) / 2;
        int left_sum = MaxSum(arr, left, mid);//答案可行方案1左区域得到最大和
        int right_sum = MaxSum(arr, mid + 1, right);//答案可行方案2右区域获取最大和

        //合并问题产生的答案可行方案--跨界
        int cross_left = 0, cross_left_max = 0;
        for (int i = mid; i >= 0; --i) {
            cross_left += arr[i];
            if (cross_left > cross_left_max) cross_left_max = cross_left;
        }
        int cross_right = 0, cross_right_max = 0;
        for (int i = mid + 1; i <= right; ++i) {
            cross_right += arr[i];
            if (cross_right > cross_right_max) cross_right_max = cross_right;
        }

        return 
        max(max(right_sum, left_sum), max(cross_left_max + cross_right_max, 0));
    }
public:
    int maxProfit(vector<int>& prices) {
        int n = prices.size();
        vector<int> diff(n);
        diff[0] = 0;
        for (int i = 1; i < n; ++i) {
            diff[i] = prices[i] - prices[i-1];
        }

        return MaxSum(diff, 0, n - 1);
    }
};

上述代码的时间复杂度O(n * logn),虽然在理论上我们可以处理 10^5 的数据量。但是这道题比较恶心,卡了一下算法过程。我们能够通过的案例大概是202/212。这也就是说,我们在大体上是对的。

其实这个算法并非很棒,他还是有在重复计算的。读者可以自行展开观察。这是直观地方式去看,如果是从时间复杂的表达式上去看呢?我们先给出时间复杂度的表达式。

 事实上在 n > 1 时,我们的合并操作 O(N) 的时间可以通过记忆化去消除成为 O(1) 的时间。

代码如下:

class Solution {
//记录区间[l,r]需要记录的数据,标注如下
struct Status {
        int lSum;//记录以左端l为起点的最大区间和
        int rSum;//记录以右端点r为终点的最大区间和
        int mSum;//记录最大区间和
        int iSum;//记录区间合
};
 
Status get(vector<int> &a, int l, int r) {
    if (l == r) {
        return (Status) {a[l], a[l], a[l], a[l]};//一个元素时的状态
    }
 
    int m = (l + r) >> 1;
    Status lSub = get(a, l, m);//获得左区间数据
    Status rSub = get(a, m + 1, r);//获得右区间数据
 
    //合并成新区间,也可以封装函数
    //维护状态
    int iSum = lSub.iSum + rSub.iSum;
    int lSum = max(lSub.lSum, lSub.iSum + rSub.lSum);
    int rSum = max(rSub.rSum, rSub.iSum + lSub.rSum);
    
    //维护区间答案--3中答案可行方案
    int mSum = max(max(lSub.mSum, rSub.mSum), lSub.rSum + rSub.lSum);
    return (Status) {lSum, rSum, mSum, iSum};
}
 
Status MaxSum(vector<int>& arr, int left, int right){
    return get(arr, left, right);
}
public:
    int maxProfit(vector<int>& prices) {
        int n = prices.size();
        vector<int> diff(n);
        diff[0] = 0;
        for (int i = 1; i < n; ++i) {
            diff[i] = prices[i] - prices[i-1];
        }

        Status ans = MaxSum(diff, 0, n - 1);
        return max(0, ans.mSum);
    }
};

至此,我们通过记忆化的手段将复杂度降低为 O(N) 。即见记忆化搜索,何不探求动态规划。没错对于最大子序列和我们也是有动态规划算法的。我们通过压缩区间的方式实现。

详见我的另一篇博客经典题:最大子序列和

int MaxSum(const vector<int>& arr) {
	int max;
	int n = arr.size();
	vector<int> sum(n);
    max = sum[0] = arr[0];//初始化
	
    //动态规划,因为压缩的是列信息所以从前往后
    for(int i = 1; i < n; ++i)
    {
        sum[i] = max(sum[i-1] + arr[i], arr[i]);
        max = max(max, sum[i]);
    }
 
	return max;
}

当然,我们从时候的角度上来说。净变化之和其实就是 末位元素 - 首位元素 而已。所以,你可能会见到另一份DP代码,那也是正确的。那也仅仅是做了一些数学公式变形。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int inf = 1e9;
        int minprice = inf, maxprofit = 0;
        for (int price: prices) {
            maxprofit = max(maxprofit, price - minprice);//答案产生于净变值和即 末位元素 - 起始元素
            minprice = min(price, minprice);//标记历史最低--标记起始元素
        }
        return maxprofit;
    }
};

题外解法

这道题我们从人类的贪婪角度,我们自然希望是"低价买入,高价卖出"了。所以我们需要维护一个过程、区域的有序性,那么我们自然想到使用“栈”。利用“栈”来完成优先级匹配关系

在这里,我们需要标记处一段时间内的增值情况,所以在栈内应该维护一个升序关系。

class Solution {
public:
    int maxProfit(vector<int>& prices) {
        int ans = 0;
        vector<int> Stk;//使用数组模拟栈
        prices.emplace_back(-1); //为了计算prices[n-1]的情况,使用-1标记
        for (int i = 0; i < prices.size(); ++ i){
            while (!Stk.empty() && Stk.back() > prices[i]){ // 维护单调栈升序,如果压入元素小需要抛出栈顶并计算答案
                ans = std::max(ans, Stk.back() - Stk.front()); // 维护最大值
                Stk.pop_back();
            }
            Stk.emplace_back(prices[i]);
        }

        return ans;
    }
};

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值