【算法筆記】動態規劃基礎(一):dp思想、基礎線性dp

目錄

  • 前言
  • 動態規劃的精髓
    • 什么叫“狀態”
    • 動態規劃的概念
    • 動態規劃的三要素
    • 動態規劃的框架
    • 無后效性
    • dfs -> 記憶化搜索 -> dp
      • 暴力寫法
      • 記憶化搜索寫法
      • 記憶化搜索優化了什么?怎么轉化成dp?
      • dp寫法
    • dp其實也是圖論
      • 首先先說結論:
      • 狀態DAG是怎樣的?
      • dp問題的初始化
      • dp問題的答案
    • 分析dp題的步驟
  • 數字三角形
    • 狀態表示
    • 狀態計算
    • 初始化
    • AC代碼
  • 最長上升子序列(LIS)
    • 狀態表示
    • 狀態計算
    • 初始化
    • AC代碼
    • 優化
  • 最長公共子序列(LCS)
    • 狀態表示
    • 狀態計算
    • 初始化
    • AC代碼
  • 最短編輯距離
    • 狀態表示
    • 狀態計算
    • 初始化
    • AC代碼

前言

計算機歸根結底只會做一件事:窮舉
所有的算法都是在讓計算機【如何聰明地窮舉】而已,動態規劃也是如此。


動態規劃的精髓

什么叫“狀態”

每一個 DP 子問題,都可以用一組變量來唯一標識。把這組變量稱為一個 “狀態”,用 d p [ 狀態 ] dp[狀態] dp[狀態]來表示該子問題的最優解(或方案數、合法性等)。

舉個例子,我在實驗室坐著敲代碼,小王在圖書館站著處對象、小明在水房倒立洗頭…

  • 這里的(我,實驗室,坐著,敲代碼),(小王,圖書館,站著,處對象),(小明,水房,倒立,洗頭)就是三組狀態,可以用一個四維的數組來表示, d p [ i ] [ j ] [ k ] [ l ] dp[i][j][k][l] dp[i][j][k][l]就表示 i i i這個人,在 j j j這個地方,以 k k k這樣的動作,干 l l l這件事時的某種性質。

狀態轉移也好理解,舉個例子:在游戲中,有幾個技能,其中一個技能叫“轉換”,可以讓人物在跑、走、跳之間來回切換,消耗 a a a點體力。其中一個狀態轉移就是消耗 a a a點體力將走切換成跑,如果用 d p dp dp數組表示放 i i i次技能,達到當前狀態消耗的體力的最小值,也就是 d p [ i ] [ 跑 ] = m i n ( d p [ i ] [ 跑 ] , d p [ i ? 1 ] [ 走 ] + a ) dp[i][跑] = min(dp[i][跑], dp[i - 1][走] + a) dp[i][]=min(dp[i][],dp[i?1][]+a) ,這個式子就叫做狀態轉移方程

動態規劃的概念

前置知識:遞歸、遞推

動態規劃(Dynamic programming,簡稱DP) 是一種通過將原問題分解成幾個彼此之間有關聯的、相對簡單的子問題來求解復雜問題的算法。

動態規劃把多階段過程轉化為一系列單階段問題,利用各階段之間的關系,逐個求解。

動態規劃常常適用于有重疊子問題最優子結構性質的問題,動態規劃方法所耗時間往往遠少于樸素解法。

動態規劃背后的基本思想非常簡單。大致上,若要解一個給定問題,我們需要解其不同部分(即子問題),再根據子問題的解以得出原問題的解。

通常許多子問題非常相似,為此動態規劃法試圖僅僅解決每個子問題一次,從而減少計算量:一旦某個給定子問題的解已經算出,則將其記憶化存儲,以便下次需要同一個子問題解之時直接查表。這種做法在重復子問題的數目關于輸入的規模呈指數增長時特別有用。

動態規劃的三要素

動態規劃有很經典的三要素:重疊子問題最優子結構狀態轉移方程

首先,雖然動態規劃的核心思想就是窮舉求最值或方案數,但是問題可以千變萬化,窮舉所有可行解其實并不是一件容易的事,需要你熟練掌握遞歸思維,只有列出正確的 「狀態轉移方程」 ,才能正確地窮舉。而且,你需要判斷算法問題是否具備 「最優子結構」,是否能夠通過子問題的最值得到原問題的最值。另外,動態規劃問題存在 「重疊子問題」,如果暴力窮舉的話效率會很低,所以需要你使用 「記憶化」 來優化窮舉過程,避免不必要的計算。

動態規劃的框架

// 自頂向下遞歸的動態規劃
int dp(狀態1, 狀態2, ...){for(int 選擇 : 所有的選擇){# 此時的狀態已經因為做了選擇而改變res = 求最值(res, dp(狀態1, 狀態2, ...));}return res;
}// 自底向上遞推的動態規劃
# 初始化 base case
dp[0][0][...] = base case;
# 進行狀態轉移
for(狀態1 : 狀態1的所有取值){for(狀態2 : 狀態2的所有取值){for(狀態3 : 狀態3的所有取值){dp[狀態1][狀態2][...] = 求最值(選擇1, 選擇2, ...);}}
}

無后效性

tips:動態規劃的題要求你的轉移無后效性,那什么叫無后效性呢

無后效性,即前面的選擇不會影響后面的游戲規則。

尋路算法中,不會因為前面走了 B 路線而對后面路線產生影響。斐波那契數列因為第 N 項與前面的項是確定關聯,沒有選擇一說,所以也不存在后效性問題。

什么場景存在后效性呢?比如你的人生是否能通過動態規劃求最優解?其實是不行的,因為你今天的選擇可能影響未來人生軌跡,比如你選擇了計算機這個專業,會直接影響到你大學四年學的課程、接觸到的人,四年的大學生活因此就完全變了,所以根本無法與選擇了土木工程的你進行比較。

有同學可能覺得這樣局限是不是很大?其實不然,無后效性的問題仍然很多,比如背包放哪件物品、當前走哪條路線、用了哪些零錢,都不會影響整個背包大小、整張地圖的地形、以及你最重要付款的金額…

dfs -> 記憶化搜索 -> dp

821. 跳臺階

暴力寫法

#include <iostream>
#define endl '\n'
using namespace std;int f(int n){if(n == 0 || n == 1) return 1;return f(n - 1) + f(n - 2);
}int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;cout << f(n) << endl;return 0;
}

可以看出,暴力做法有很多遞歸的分支都是重復的,也就是有很多重復的子問題。這些重復的子問題在下一次遇到時如果每次都重新計算一遍就會很浪費時間,所以可以用一個數組記錄一下,下次再遇到直接查表即可,這也就是記憶化搜索

記憶化搜索寫法

#include <iostream>
#define endl '\n'
using namespace std;int dp[16];int f(int n){if(dp[n]) return dp[n];if(n == 0 || n == 1) return 1;return dp[n] = f(n - 1) + f(n - 2);
}int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;cout << f(n) << endl;return 0;
}

記憶化搜索優化了什么?怎么轉化成dp?

  • 暴力:每個子問題都考慮一次。
  • 記憶化搜索:對于相同的子問題,只考慮一次。

那是不是可以理解為:dp就是在暴力的基礎上,把所有效果相同的狀態都放在了一個集合里?暴力是單一狀態和單一狀態之間某種屬性的轉移,而dp是集合和集合之間某種屬性的轉移?

比如這道題, d p [ i ] dp[i] dp[i]表示的集合就是:所有從第 0 0 0級臺階走到第 i i i級臺階的合法方案。
屬性就是:集合中元素的數量。

所以綜上, d p [ i ] dp[i] dp[i]就表示:所有從第 0 0 0級臺階走到第 i i i級臺階的方案數。

dp寫法

#include <iostream>
#define endl '\n'
using namespace std;int dp[16];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;dp[0] = 1, dp[1] = 1;for(int i = 2; i <= n; i++){dp[i] = dp[i - 1] + dp[i - 2];}cout << dp[n] << endl;return 0;
}

dp其實也是圖論

首先先說結論:

動態規劃 (DP) 很多時候其實就是在一個狀態圖(嚴格來說是一個有向無環圖,DAG)上做一次「拓撲遍歷+松弛」的過程。

在動態規劃(DP)中,我們把“子問題”看作圖中的“節點”把“從一個子問題推導到另一個子問題”的依賴關系看作有向邊,那么所有這些狀態和邊就構成了一張有向無環圖(DAG)。而對這張DAG做一次拓撲排序——恰好保證了:當我們要計算某個狀態的最優值(或計數、最小/最大代價……)時,它依賴的所有前驅狀態都已經計算完畢。

最大值、最小值,是不是相當于是求最長路、最短路,求方案數是不是就相當于是沿著拓撲序列累加?

狀態DAG是怎樣的?

  • 節點(state)
    每一個 DP 子問題 S S S 都對應 DAG 中的一個節點。
    例如,經典“爬樓梯”問題中,讓 d p [ i ] dp[i] dp[i] 表示到達第 i i i 級臺階的方案數,
    那么節點就是 0 , 1 , 2 , … , n 0, 1, 2, \ldots, n 0,1,2,,n

  • 有向邊(依賴)
    如果計算 d p [ v ] dp[v] dp[v] 需要用到 d p [ u ] dp[u] dp[u],就在 DAG 中加一條從 u u u 指向 v v v 的邊。
    爬樓梯里, d p [ i ] dp[i] dp[i] 可由 d p [ i ? 1 ] dp[i-1] dp[i?1] d p [ i ? 2 ] dp[i-2] dp[i?2] 推出,就有兩條邊:
    ( i ? 1 ) → i , ( i ? 2 ) → i (i-1) \rightarrow i,\quad (i-2) \rightarrow i (i?1)i,(i?2)i

  • 無環
    DP 的定義通常是“從小問題推向大問題”,不存在循環依賴,圖上必然是無環的。

dp問題的初始化

dp問題的初始化經常不是很好理解,但如果你站在拓撲排序、最短路問題的角度來看,就會相對好理解一些。

回憶一下你學過的所有的最算路問題,代碼是不是都有這樣的兩部分初始化:

memset(dist, 0x3f, sizeof dist); // 所有點dist初始化成正無窮
dist[s] = 0; // 源點dist初始化成0

dp問題的初始化其實也是這樣的,分成兩部分:

  • 對所有的點進行初始化:比如求最小值,memset(dp, 0x3f, sizeof dp)
    對于這種初始化,一般如果是求最小值,就初始化成一個極大值;如果求最大值,就初始化成一個極小值;如果是求數量,就初始化成0。
  • 對“源點”進行初始化:單源最短路的問題源點只有一個,就給源點初始化成0,而拓撲排序就相當于是一個多源的最短路,源點就是所有入度為0的點,在dp中也就是那些最基本的狀態(base case),這種初始化只需將所有的base case都按dp狀態表示的含義,初始化成對應的數即可。比如數字三角形問題中:最頂端的點不能由任何點走過來,于是直接將其初始化成對應的值,或者你可以看做最頂端的點可以由上面下標為0的點走過來,所以也可以將所有下標為0的點初始化成0。

很多問題對“源點”的初始化經常會初始化下標為 0 0 0的位置,像下面這樣:

	for(int i = 1; i <= n; i++){dp[i][0] = i;}for(int i = 1; i <= m; i++){dp[0][i] = i;}

怎么確定base case呢?一般來說,如果你的dp遍歷是for(int i = a; ...),那base case就很有可能是你的i = a的前一個狀態(前驅結點),多維也是同樣的道理。

簡單來講,一個dp問題,你循環遍歷的所有點都是狀態DAG上入度不為0的點,你單拎出來初始化的點都是狀態DAG入度為0的點

dp問題的答案

和初始化相同,初始化是初始化所有的“起點”(入度為0的點),那答案就是所有的“終點”(出度為0的點),如果“終點”有很多個,就要定義一個結果變量,對于求最值,需要遍歷一下所有出度為0的狀態取個最值;對于求方案數,需要逐個判斷、累加一下。

分析dp題的步驟

  • 做dp題,首先就要把dp數組寫出來,首先要想:dp數組要開幾維一般來說,如果不優化狀態表示的話,有幾個狀態dp數組就要開幾維。
  • 然后,搞明白你dp數組的含義,也就是搞明白集合的表示集合的屬性。集合的屬性常見的可能有最大值(max)、最小值(min)、數量(cnt)…
  • 最后,寫出狀態轉移方程,對應的過程就是將問題分成若干子問題的過程,也就是集合的劃分
  • 然后,想怎么初始化,求最大值就初始化成極小值、求最小值就初始化成極大值、求數量就初始化成0…
  • 然后,寫代碼:定義dp數組、初始化、for循環枚舉所有的狀態,輸出結果。

具體各個過程都是什么意思:看下面的幾個線性dp經典模型。

數字三角形

898. 數字三角形

狀態表示

因為有行和列,所以狀態要開二維: d p [ i ] [ j ] dp[i][j] dp[i][j]

  • 集合:所有從 a [ 1 ] [ 1 ] a[1][1] a[1][1]走到 a [ i ] [ j ] a[i][j] a[i][j]的路線
  • 屬性:(路線經過數字和的)最大值(max)

綜上 d p [ i ] [ j ] dp[i][j] dp[i][j]:所有從 a [ 1 ] [ 1 ] a[1][1] a[1][1]走到 a [ i ] [ j ] a[i][j] a[i][j]的路線的數字和的最大值

狀態計算

集合劃分:將這個集合劃分成幾個不同的集合,就要找一個不同的步驟。

可以看出,走到 a [ i ] [ j ] a[i][j] a[i][j]的這最后一步可以是從 a [ i ? 1 ] [ j ] a[i - 1][j] a[i?1][j]走過來的,也可以是從 a [ i ? 1 ] [ j ? 1 ] a[i - 1][j - 1] a[i?1][j?1]走過來的,所以可以從最后一步來劃分這個集合,前一個集合是 d p [ i ? 1 ] [ j ] dp[i - 1][j] dp[i?1][j],后一個集合是 d p [ i ? 1 ] [ j ? 1 ] dp[i - 1][j - 1] dp[i?1][j?1]

顯然: d p [ i ] [ j ] = m a x ( d p [ i ? 1 ] [ j ? 1 ] + a [ i ] [ j ] , d p [ i ? 1 ] [ j ] + a [ i ] [ j ] ) dp[i][j] = max(dp[i - 1][j - 1] + a[i][j], dp[i - 1][j] + a[i][j]) dp[i][j]=max(dp[i?1][j?1]+a[i][j],dp[i?1][j]+a[i][j])

初始化

要求最大值,并且有負數,先將所有位置都初始化成負無窮。

因為和要從0開始加,而不是負無窮,所以要將所有的 d p [ 0 ] [ j ] dp[0][j] dp[0][j] d p [ i ] [ 0 ] dp[i][0] dp[i][0]都初始化成0。

但上面這樣比較麻煩,因為所有路線都是從起點開始走的,所以直接將起點初始化成 a [ 1 ] [ 1 ] a[1][1] a[1][1],循環從 2 2 2開始遍歷即可。

AC代碼

#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 505;int a[N][N];
int dp[N][N];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;for(int i = 1; i <= n; i++){for(int j = 1; j <= i; j++){cin >> a[i][j];}}memset(dp, -0x3f, sizeof dp);dp[1][1] = a[1][1];for(int i = 2; i <= n; i++){for(int j = 1; j <= i; j++){dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j]) + a[i][j];}}int res = -0x3f3f3f3f;for(int i = 1; i <= n; i++) res = max(res, dp[n][i]);cout << res << endl;return 0;
}

最長上升子序列(LIS)

895. 最長上升子序列

狀態表示

序列只有一維,dp數組也開一維就夠了: d p [ i ] dp[i] dp[i]

  • 集合:所有以 a [ i ] a[i] a[i]結尾的上升子序列
  • 屬性:(長度的)最大值

綜上 d p [ i ] dp[i] dp[i]:所有以 a [ i ] a[i] a[i]結尾的上升子序列的長度的最大值。

狀態計算

集合劃分:將這個集合劃分成幾個不同的集合,就要找一個不同的步驟。

因為最后一步都是相同的,都是將 a [ i ] a[i] a[i]加到序列中,無法劃分,所以看倒數第二步:也就是加 a [ i ] a[i] a[i]前這個上升子序列是以什么元素結尾的。想一下,如果 a [ i ] a[i] a[i]能加到一個以 a [ j ] a[j] a[j]結尾的上升子序列的后面,是不是一定要滿足 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j] ?

所以這個集合就可以劃分成:所有滿足 a [ i ] > a [ j ] a[i] > a[j] a[i]>a[j]( 0 0 0 ≤ \le j j j < < < i i i)的以 a [ j ] a[j] a[j]結尾的上升子序列接上一個 a [ i ] a[i] a[i]

狀態轉移方程就出來了: d p [ i ] = m a x ( d p [ i ] , d p [ j ] + 1 ) ( a [ j ] < a [ i ] ) dp[i] = max(dp[i], dp[j] + 1) (a[j] < a[i]) dp[i]=max(dp[i],dp[j]+1)(a[j]<a[i])

初始化

求最大值,上升子序列最短都是1,都初始化成1即可。

AC代碼

#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;int a[N];
int dp[N];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n;cin >> n;for(int i = 1; i <= n; i++){cin >> a[i];dp[i] = 1;}for(int i = 1; i <= n; i++){for(int j = 1; j < i; j++){if(a[i] > a[j]) dp[i] = max(dp[i], dp[j] + 1);}}int res = 1; for(int i = 1; i <= n; i++) res = max(res, dp[i]);cout << res << endl;return 0;
}

優化

很顯然,用dp求LIS是 O ( n 2 ) O(n^2) O(n2)的,如果 n n n比較大會超時,但可以基于貪心+二分、單調棧、單調隊列等多種方式進行優化到 O ( n l o g n ) O(nlogn) O(nlogn) O ( n ) O(n) O(n),因為這是dp專題,就不細講了。

最長公共子序列(LCS)

897. 最長公共子序列

狀態表示

有兩個序列,一個序列開一維,兩個序列就開二維: d p [ i ] [ j ] dp[i][j] dp[i][j]

  • 集合:所有 a a a的前 i i i個元素和 b b b的前 j j j個元素的公共子序列
  • 屬性:(長度的)最大值

綜上 d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有 a a a的前 i i i個元素和 b b b的前 j j j個元素的公共子序列的長度的最大值。

狀態計算

集合劃分:將這個集合劃分成幾個不同的集合,就要找一個不同的步驟。

看最后一步, a a a的前 i i i個元素的子序列里,是不是有的帶 a [ i ] a[i] a[i],有的不帶 a [ i ] a[i] a[i]? b b b的前 j j j個元素的子序列里,是不是有的帶 b [ j ] b[j] b[j],有的不帶 b [ j ] b[j] b[j]?那二者的公共子序列是不是也如此?

所以可以以 a [ i ] a[i] a[i] b [ j ] b[j] b[j]是否包含在公共子序列中為依據進行劃分。

然后想這幾個集合的狀態怎么表示,可以分成下面四種情況:

  • 情況1: a [ i ] a[i] a[i], b [ j ] b[j] b[j] 均存在于 最長公共子序列中 (前提 a [ i ] = = b [ j ] a[i]==b[j] a[i]==b[j])
  • 情況2: a [ i ] a[i] a[i] 在, b [ j ] b[j] b[j] 不在 (無前提)
  • 情況3: a [ i ] a[i] a[i], b [ j ] b[j] b[j] 均不在 (無前提)
  • 情況4: a [ i ] a[i] a[i]不在, b [ j ] b[j] b[j]在 (無前提)

初步想一下,是不是可以表示成下面這樣:

  • 情況1:暫用 d p [ i ? 1 ] [ j ? 1 ] + 1 dp[i-1][j-1]+1 dp[i?1][j?1]+1表示
  • 情況2:暫用 d p [ i ] [ j ? 1 ] dp[i][j-1] dp[i][j?1]表示
  • 情況3:暫用 d p [ i ? 1 ] [ j ? 1 ] dp[i-1][j-1] dp[i?1][j?1]表示
  • 情況4:暫用 d p [ i ? 1 ] [ j ] dp[i-1][j] dp[i?1][j]表示

但這樣表示真的對嗎?事實上是不嚴謹的,舉個例子: d p [ i ] [ j ? 1 ] dp[i][j- 1] dp[i][j?1]的集合表示的是所有 a a a的前 i i i個元素和 b b b的前 j ? 1 j - 1 j?1個元素的公共子序列, b b b的前 j j j個元素一定是不包含 b [ j ] b[j] b[j]的,但 a a a的前 i i i個元素是不是可能包含 a [ i ] a[i] a[i],也可能不包含 a [ i ] a[i] a[i]? d p [ i ? 1 ] [ j ] dp[i - 1][j] dp[i?1][j]也是一樣的道理

所以實際上 d p [ i ? 1 ] [ j ] dp[i - 1][j] dp[i?1][j]表示的是情況2+情況3, d p [ i ] [ j ? 1 ] dp[i][j - 1] dp[i][j?1]表示的是情況3+情況4

因為這個模型dp的屬性是求最大值,集合之間有交集不會影響答案,保證不漏就行(就好像你要求10個數的最大值,你知道前7個數的最大值和后7個數的最大值,只需要這兩個集合取max就可以了),所以狀態計算直接取 d p [ i ? 1 ] [ j ? 1 ] + 1 dp[i-1][j-1]+1 dp[i?1][j?1]+1 d p [ i ] [ j ? 1 ] dp[i][j-1] dp[i][j?1] d p [ i ? 1 ] [ j ] dp[i-1][j] dp[i?1][j]三者的最大值就可以了。

狀態轉移方程也就出來了:
d p [ i ] [ j ] = m a x ( d p [ i ? 1 ] [ j ] , d p [ i ] [ j ? 1 ] ) dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]) dp[i][j]=max(dp[i?1][j],dp[i][j?1])
d p [ i ] [ j ] = m a x ( d p [ i ] [ j ] , d p [ i ? 1 ] [ j ? 1 ] + 1 ) ( a [ i ] = = b [ j ] ) dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + 1)(a[i] == b[j]) dp[i][j]=max(dp[i][j],dp[i?1][j?1]+1)(a[i]==b[j])

也可以換種方式理解:對于字符串 a a a b b b中的每個字符都有且只有兩種狀態:在公共子序列中不在公共子序列中。那是不是可以從后往前遍歷每一個字符:如果 a [ i ] = = b [ j ] a[i] == b[j] a[i]==b[j],這個字符就一定在公共子序列中,也就是情況1,而如果 a [ i ] ≠ a [ j ] a[i] \neq a[j] a[i]=a[j],這兩個字符就至少有一個不在公共子序列中,需要丟棄一個,也就是情況2、3、4。

初始化

求最大值,公共子序列最小都是0,都初始化成0即可。

AC代碼

#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;int dp[N][N];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n, m;cin >> n >> m;string a, b;cin >> a >> b;a = " " + a, b = " " + b;for(int i = 1; i <= n; i++) cin >> a[i];for(int i = 1; i <= m; i++) cin >> b[i];for(int i = 1; i <= n; i++){for(int j = 1; j <= m; j++){dp[i][j] = max(dp[i - 1][j], dp[i][j - 1]);if(a[i] == b[j]) dp[i][j] = max(dp[i][j], dp[i - 1][j - 1] + 1);}}cout << dp[n][m] << endl;return 0;
}

最短編輯距離

902. 最短編輯距離

狀態表示

有兩個字符串,開兩維, d p [ i ] [ j ] dp[i][j] dp[i][j]

  • 集合:所有讓 a a a的前 i i i個字符和 b b b的前 j j j個字符變得相同的操作方式
  • 屬性:(操作次數的)最小值

綜上 d p [ i ] [ j ] dp[i][j] dp[i][j]表示所有讓 a a a的前 i i i個字符和 b b b的前 j j j個字符變得相同的操作次數的最小值。

狀態計算

集合劃分:將這個集合劃分成幾個不同的集合,就要找一個不同的步驟。

還是看最后一步,最后一步肯定是改變 a a a的一個字符后 a [ 1 a[1 a[1 ~ i ] i] i] 變得和 b [ 1 b[1 b[1 ~ j ] j] j]相同,而改變字符有三種方式:增加、刪除和替換

首先要弄清楚一點,插入操作實際上是在末尾添加一個字符,刪除操作一定是刪掉最后一個字符

為什么呢?拿刪除舉例子,首先,如果你某一步的操作是刪除中間的字符,刪除位置后面的整個序列都會改變,這就不滿足無后效性。其次,如果某一步刪除操作刪除的是中間的字符,那么這個操作在之前一定可以通過刪除最后的字符來實現,也就相當于是之前漏刪了,后面來補,并且末尾的 b [ j ] b[j] b[j]也沒動到,這一定不是最優的子結構,并且如果你某一步的操作是刪除中間的字符,每次刪除的位置都難以決定,這也就不是重疊的子問題

而如果你每次都刪最后的字符,每次操作都是相同的,也不會影響后面的決策,并且是最優的。

任何最優路徑的最后一步,絕不會故意留一段未對上的后綴不管,再去中間做事然后回來補尾巴。

  • 如果最后一步是增加一個字符:為了讓 a [ 1 a[1 a[1 ~ i ] i] i] 變得和 b [ 1 b[1 b[1 ~ j ] j] j]相同,最后加的字符就一定是 b [ j ] b[j] b[j],而 a [ 1 a[1 a[1 ~ i ] + b [ j ] i] + b[j] i]+b[j] b [ 1 b[1 b[1 ~ j ] j] j]相同,也就說明 a [ 1 a[1 a[1 ~ i ] + b [ j ] i] + b[j] i]+b[j] b [ 1 b[1 b[1 ~ j ? 1 ] + b [ j ] j - 1] + b[j] j?1]+b[j]相同,也就說明原來的 a [ 1 a[1 a[1 ~ i ] i] i] b [ 1 b[1 b[1 ~ j ? 1 ] j - 1] j?1]是相同的,也就是 d p [ i ] [ j ? 1 ] + 1 dp[i][j - 1] + 1 dp[i][j?1]+1

  • 如果最后一步是刪除某個字符:刪掉的字符一定是 a [ i ] a[i] a[i],而如果刪 a [ i ] a[i] a[i] a [ 1 a[1 a[1 ~ i ] i] i] 變得和 b [ 1 b[1 b[1 ~ j ] j] j]相同,就說明 a [ 1 a[1 a[1 ~ i ] ? a [ i ] i] - a[i] i]?a[i] b [ 1 b[1 b[1 ~ j ] j] j]相同,也就是 a [ 1 a[1 a[1 ~ i ? 1 ] + a [ i ] ? a [ i ] i - 1] + a[i] - a[i] i?1]+a[i]?a[i] b [ 1 b[1 b[1 ~ j ] j] j]相同,也就是 d p [ i ? 1 ] [ j ] + 1 dp[i - 1][j] + 1 dp[i?1][j]+1

  • 如果最后一步是改變某個字符
    如果 a [ i ] ≠ b [ j ] a[i] \neq b[j] a[i]=b[j],就一定要把 a [ i ] a[i] a[i]改成 b [ j ] b[j] b[j],如果改之后相同了,就說明 a [ 1 a[1 a[1 ~ i ? 1 ] + b [ j ] i - 1] + b[j] i?1]+b[j] b [ 1 b[1 b[1 ~ j ] j] j]相同,也就是 a [ 1 a[1 a[1 ~ i ? 1 ] + b [ j ] i - 1] + b[j] i?1]+b[j] b [ 1 b[1 b[1 ~ j ? 1 ] + b [ j ] j - 1] + b[j] j?1]+b[j]相同,也就是 a [ 1 a[1 a[1 ~ i ? 1 ] i - 1] i?1] b [ 1 b[1 b[1 ~ j ? 1 ] j - 1] j?1]相同,也就是 d p [ i ? 1 ] [ j ? 1 ] + 1 dp[i - 1][j - 1] + 1 dp[i?1][j?1]+1

    如果 a [ i ] = b [ j ] a[i] = b[j] a[i]=b[j],最后的字符就不用管,只需將 a [ 1 a[1 a[1 ~ i ? 1 ] i - 1] i?1] b [ 1 b[1 b[1 ~ j ? 1 ] j - 1] j?1]變得相同,也就是 d p [ i ? 1 ] [ j ? 1 ] dp[i - 1][j - 1] dp[i?1][j?1]

然后可以得出狀態轉移方程:
d p [ i ] [ j ] = m i n ( d p [ i ? 1 ] [ j ] + 1 , d p [ i ] [ j ? 1 ] + 1 ) dp[i][j] = min(dp[i - 1][j] + 1, dp[i][j - 1] + 1) dp[i][j]=min(dp[i?1][j]+1,dp[i][j?1]+1)
d p [ i ] [ j ] = m i n ( d p [ i ] [ j ] , d p [ i ? 1 ] [ j ? 1 ] + ( a [ i ] ≠ b [ j ] ) ) dp[i][j] = min(dp[i][j], dp[i - 1][j - 1] + (a[i] \neq b[j])) dp[i][j]=min(dp[i][j],dp[i?1][j?1]+(a[i]=b[j]))

初始化

求最小值,最大的編輯距離也就是全改一遍的情況,狀態轉移的起點也就是空串的時候,這時候恰巧是全改,只需給所有的 d p [ i ] [ 0 ] 、 d p [ 0 ] [ i ] dp[i][0]、dp[0][i] dp[i][0]dp[0][i]都初始化成 i i i即可。

AC代碼

#include <iostream>
#include <cstring>
#define endl '\n'
using namespace std;
const int N = 1010;int dp[N][N];int main(){ios::sync_with_stdio(false); cin.tie(0); cout.tie(0);int n, m;string a, b;cin >> n >> a >> m >> b;a = " " + a;b = " " + b;for(int i = 1; i <= n; i++){dp[i][0] = i;}for(int i = 1; i <= m; i++){dp[0][i] = i;}for(int i = 1; i <= n; i++){for(int j = 1; j <= m; j++){dp[i][j] = min(dp[i - 1][j], dp[i][j - 1]) + 1; // 增和刪if(a[i] == b[j]) dp[i][j] = min(dp[i][j], dp[i - 1][j - 1]); // 不用改else dp[i][j] = min(dp[i][j], dp[i - 1][j - 1] + 1); // 改}}cout << dp[n][m] << endl;return 0;
}

本文來自互聯網用戶投稿,該文觀點僅代表作者本人,不代表本站立場。本站僅提供信息存儲空間服務,不擁有所有權,不承擔相關法律責任。
如若轉載,請注明出處:http://www.pswp.cn/pingmian/77909.shtml
繁體地址,請注明出處:http://hk.pswp.cn/pingmian/77909.shtml
英文地址,請注明出處:http://en.pswp.cn/pingmian/77909.shtml

如若內容造成侵權/違法違規/事實不符,請聯系多彩編程網進行投訴反饋email:809451989@qq.com,一經查實,立即刪除!

相關文章

pytorch 51 GroundingDINO模型導出tensorrt并使用c++進行部署,53ms一張圖

本專欄博客第49篇文章分享了將 GroundingDINO模型導出onnx并使用c++進行部署,并嘗試將onnx模型轉換為trt模型,fp16進行推理,可以發現推理速度提升了一倍。為此對GroundingDINO的trt推理進行調研,發現 在GroundingDINO-TensorRT-and-ONNX-Inference項目中分享了模型導出onnx…

一個關于相對速度的假想的故事-6

既然已經知道了速度是不能疊加的&#xff0c;同時也知道這個疊加是怎么做到的&#xff0c;那么&#xff0c;我們實際上就知道了光速的來源&#xff0c;也就是這里的虛數單位的來源&#xff1a; 而它的來源則是&#xff0c; 但這是兩個速度的比率&#xff0c;而光速則是一個速度…

深度學習激活函數與損失函數全解析:從Sigmoid到交叉熵的數學原理與實踐應用

目錄 前言一、sigmoid 及導數求導二、tanh 三、ReLU 四、Leaky Relu五、 Prelu六、Softmax七、ELU八、極大似然估計與交叉熵損失函數8.1 極大似然估計與交叉熵損失函數算法理論8.1.1 伯努利分布8.1.2 二項分布8.1.3 極大似然估計總結 前言 書接上文 PaddlePaddle線性回歸詳解…

Python內置函數---breakpoint()

用于在代碼執行過程中動態設置斷點&#xff0c;暫停程序并進入調試模式。 1. 基本語法與功能 breakpoint(*args, kwargs) - 參數&#xff1a;接受任意數量的位置參數和關鍵字參數&#xff0c;但通常無需傳遞&#xff08;默認調用pdb.set_trace()&#xff09;。 - 功能&#x…

從零手寫 RPC-version1

一、 前置知識 1. 反射 獲取字節碼的三種方式 Class.forName("全類名") &#xff08;全類名&#xff0c;即包名類名&#xff09;類名.class對象.getClass() (任意對象都可調用&#xff0c;因為該方法來自Object類&#xff09; 獲取成員方法 Method getMethod(St…

ARINC818協議(六)

上圖中&#xff0c;紅色虛線上面為我們常用的simple mode簡單模式&#xff0c;下面和上面的結合在一起&#xff0c;就形成了extended mode擴展模式。 ARINC818協議 container header容器頭 ancillary data輔助數據 視頻流 ADVB幀映射 FHCP傳輸協議 R_CTRL:路由控制routing ctr…

PyCharm 鏈接 Podman Desktop 的 podman-machine-default Linux 虛擬環境

#工作記錄 PyCharm Community 連接到Podman Desktop 的 podman-machine-default Linux 虛擬環境詳細步驟 1. 準備工作 確保我們已在 Windows 系統中正確安裝并啟動了 Podman Desktop。 我們將通過 Podman Desktop 提供的名為 podman-machine-default 的 Fedora Linux 41 WSL…

小白自學python第一天

學習python的第一天 一、常用的值類型&#xff08;先來粗略認識一下~&#xff09; 類型說明數字&#xff08;number&#xff09;包含整型&#xff08;int&#xff09;、浮點型&#xff08;float&#xff09;、復數&#xff08;complex&#xff09;、布爾&#xff08;boolean&…

初階數據結構--排序算法(全解析!!!)

排序 1. 排序的概念 排序&#xff1a;所謂排序,就是使一串記錄&#xff0c;按照其中的某個或某些些關鍵字的大小&#xff0c;遞增或遞減的排列起來的操作。 2. 常見的排序算法 3. 實現常見的排序算法 以下排序算法均是以排升序為示例。 3.1 插入排序 基本思想&#xff1a;…

Android studio開發——room功能實現用戶之間消息的發送

文章目錄 1. Flask-SocketIO 后端代碼后端代碼 2. Android Studio Java 客戶端代碼客戶端代碼 3. 代碼說明 SocketIO基礎 1. Flask-SocketIO 后端代碼 后端代碼 from flask import Flask, request from flask_socketio import SocketIO, emit import uuidapp Flask(__name_…

4.LinkedList的模擬實現:

LinkedList的底層是一個不帶頭的雙向鏈表。 不帶頭雙向鏈表中的每一個節點有三個域&#xff1a;值域&#xff0c;上一個節點的域&#xff0c;下一個節點的域。 不帶頭雙向鏈表的實現&#xff1a; public class Mylinkdelist{//定義一個內部類&#xff08;節點&#xff09;stat…

Sentinel數據S2_SR_HARMONIZED連續云掩膜+中位數合成

在GEE中實現時&#xff0c;發現簡單的QA60是無法去云的&#xff0c;最近S2地表反射率數據集又進行了更新&#xff0c;原有的屬性集也進行了變化&#xff0c;現在的SR數據集名稱是“S2_SR_HARMONIZED”。那么&#xff1a; 要想得到研究區無云的圖像&#xff0c;可以參考執行以下…

理解計算機系統_網絡編程(1)

前言 以<深入理解計算機系統>(以下稱“本書”)內容為基礎&#xff0c;對程序的整個過程進行梳理。本書內容對整個計算機系統做了系統性導引,每部分內容都是單獨的一門課.學習深度根據自己需要來定 引入 網絡是計算機科學中非常重要的部分,筆者過去看過相關的內…

【2025】Datawhale AI春訓營-RNA結構預測(AI+創新藥)-Task2筆記

【2025】Datawhale AI春訓營-RNA結構預測&#xff08;AI創新藥&#xff09;-Task2筆記 本文對Task2提供的進階代碼進行理解。 任務描述 Task2的任務仍然是基于給定的RNA三維骨架結構&#xff0c;生成一個或多個RNA序列&#xff0c;使得這些序列能夠折疊并盡可能接近給定的目…

vim 命令復習

命令模式下的命令及快捷鍵 # dd刪除光所在行的內容 # ndd從光標所在行開始向下刪除n行 # yy復制光標所在行的內容 # nyy復制光標所在行向下n行的內容 # p將復制的內容粘貼到光標所在行以下&#xff08;小寫&#xff09; # P將復制的內容粘貼到光標所在行以上&#xff08;大寫&…

哪些心電圖表現無緣事業編體檢呢?

根據《公務員錄用體檢通用標準》心血管系統條款及事業單位體檢實施細則&#xff0c;心電圖不合格主要涉及以下類型及處置方案&#xff1a; 一、心律失常類 早搏&#xff1a;包括房性早搏、室性早搏和交界性早搏。如果每分鐘早搏次數較多&#xff08;如超過5次&#xff09;&…

Linux學習——UDP

編程的整體框架 bind&#xff1a;綁定服務器&#xff1a;TCP地址和端口號 receivefrom()&#xff1a;阻塞等待客戶端數據 sendto():指定服務器的IP地址和端口號&#xff0c;要發送的數據 無連接盡力傳輸&#xff0c;UDP:是不可靠傳輸 實時的音視頻傳輸&#x…

ReAct Agent 實戰:基于DeepSeek從0到1實現大模型Agent的探索模式

寫在前面:動態思考,邊想邊做 大型語言模型(LLM)的崛起開啟了通用人工智能(AGI)的無限遐想。但要讓 LLM 從一個被動的“文本生成器”轉變為能夠主動解決問題、與環境交互的智能體(Agent),我們需要賦予它思考、行動和學習的能力。ReAct (Reason + Act) 框架正是實現這一…

從物理到預測:數據驅動的深度學習的結構化探索及AI推理

在當今科學探索的時代&#xff0c;理解的前沿不再僅僅存在于我們書寫的方程式中&#xff0c;也存在于我們收集的數據和構建的模型中。在物理學和機器學習的交匯處&#xff0c;一個快速發展的領域正在興起&#xff0c;它不僅觀察宇宙&#xff0c;更是在學習宇宙。 AI推理 我們…

結合地理數據處理

CSV 文件不僅可以存儲表格數據&#xff0c;還可以與地理空間數據結合&#xff0c;實現更強大的地理處理功能。例如&#xff0c;你可以將 CSV 文件中的坐標數據轉換為點要素類&#xff0c;然后進行空間分析。 示例&#xff1a;將 CSV 文件中的坐標數據轉換為點要素類 假設我們有…