Expliquer en détail le problème de la programmation dynamique dans la série

Après avoir lu cet article, vous pouvez vous rendre sur Likou pour gagner les sujets suivants:

198. Un vol de maison

213. Vol à domicile II

337. Vol de maison III

-----------

Certains lecteurs m'ont demandé en privé comment faire la série de questions LeetCode "House Robber" (la version anglaise s'appelle House Robber). J'ai trouvé que cette série de sujets est très appréciée. Ce sont des sujets de programmation dynamiques plus représentatifs et plus habiles. Venez aujourd'hui. Parlez de ce sujet.

Il y a un total de trois cours dans la série de la famille et du vol. La difficulté de conception est très raisonnable et progressive. Le premier est un problème de programmation dynamique relativement standard, tandis que le second incorpore les conditions d'un tableau circulaire, et le troisième est encore plus absolu. Il combine les solutions ascendantes et descendantes de la programmation dynamique avec des arbres binaires. Je pense que c'est très Éclairant. Si vous ne l'avez jamais fait auparavant, il est recommandé d'étudier.

Ci-dessous, nous partons de la première analyse.

Voleur de maison I

6147bbdf310af02edb4e2e39ee005905.jpg

public int rob (int [] nums);

Le sujet est facile à comprendre et les caractéristiques de la programmation dynamique sont évidentes. Comme nous l'avons résumé dans l'article précédent «Explication détaillée de la programmation dynamique», résoudre le problème de la programmation dynamique, c'est trouver «état» et «choix», c'est tout .

Imaginez que vous êtes le voleur professionnel, traversant cette rangée de maisons de gauche à droite.Il y a deux choix devant chaque maison : saisir ou non.

Si vous prenez cette maison, vous ne pouvez certainement pas prendre la maison voisine, vous ne pouvez choisir que dans la maison voisine.

Si vous n'attrapez pas cette maison, vous pouvez vous rendre dans la maison voisine et continuer à faire des choix.

Après avoir traversé la dernière maison, vous n'êtes pas obligé de l'attraper. L'argent que vous pouvez récupérer est évidemment 0 ( cas de base ).

La logique ci-dessus est très simple, en fait, "l'état" et le "choix" ont été clarifiés: l'index de la maison en face de vous est l'état, et saisir ou non est le choix .

44518b0a94fb3984312ce557966ca429.jpeg

Dans les deux choix, choisissez le résultat le plus élevé à chaque fois et vous obtiendrez le plus d'argent possible:

// La fonction principale 
public int rob (int [] nums) { 
    return dp (nums, 0); 
} 
// Renvoie la valeur maximale que nums [start ..] peut récupérer 
private int dp (int [] nums, int start) { 
    if (start> = nums.length) { 
        return 0; 
    } 

    int res = Math.max ( 
            // Ne pas saisir, aller à la prochaine maison 
            dp (nums, start + 1),  
            // saisir, aller à la prochaine maison 
            nums [début ] + dp (nums, start + 2) 
        ); 
    return res; 
}

Après avoir clarifié la transition d'état, vous pouvez constater qu'il existe start un sous-problème de chevauchement pour le même  emplacement, comme illustré dans la figure suivante:

267f2bb39fbad1ca422faedbe34c3c56.jpeg

Les voleurs ont de nombreuses options pour accéder à cette position. Ne serait-ce pas une perte de temps s'ils entrent en récursivité à chaque fois qu'ils arrivent ici? Il y a donc des sous-problèmes qui se chevauchent, qui peuvent être optimisés avec mémo:

private int [] memo; 
// Fonction principale 
public int rob (int [] nums) { 
    // Initialization memo 
    memo = new int [nums.length]; 
    Arrays.fill (memo, -1); 
    // Le voleur commence à 0 La maison a commencé à voler 
    return dp (nums, 0); 
} 

// Renvoie la valeur maximale que dp [start ..] peut récupérer 
private int dp (int [] nums, int start) { 
    if (start> = nums.length) { 
        return 0; 
    } 
    // Evite le double calcul 
    if (memo [start]! = -1) return memo [start]; 

    int res = Math.max (dp (nums, start + 1),  
                    nums [start] + dp ( nums, start + 2)); 
    // Entrez le mémo 
    mémo [start] = res; 
    return res; 
}

C'est la solution de programmation dynamique descendante, nous pouvons également la modifier légèrement et écrire une solution ascendante :

int rob (int [] nums) { 
    int n = nums.length; 
    // dp [i] = x signifie: 
    // commence le vol depuis la i-ème maison, et le montant maximum d'argent que vous pouvez récupérer est x 
    // cas de base: dp [n] = 0 
    int [] dp = new int [n + 2]; 
    for (int i = n-1; i> = 0; i--) { 
        dp [i] = Math.max (dp [i + 1], nums [i] + dp [i + 2]); 
    } 
    return dp [0]; 
}

Nous avons également constaté que la transition d'état n'est dp[i] liée qu'aux  deux états les plus récents, elle peut donc être optimisée davantage pour réduire la complexité de l'espace à O (1).

int rob (int [] nums) { 
    int n = nums.length; 
    // 记录 dp [i + 1] 和 dp [i + 2] 
    int dp_i_1 = 0, dp_i_2 = 0; 
    // 记录 dp [i] 
    int dp_i = 0; 
    pour (int i = n - 1; i> = 0; i--) { 
        dp_i = Math.max (dp_i_1, nums [i] + dp_i_2); 
        dp_i_2 = dp_i_1; 
        dp_i_1 = dp_i; 
    } 
    return dp_i; 
}

Le processus ci-dessus a été expliqué en détail dans notre "Explication détaillée de la planification dynamique", je crois que tout le monde sera en mesure de le comprendre. Je pense qu'il est très intéressant que le suivi de cette question nécessite quelques adaptations intelligentes basées sur notre réflexion actuelle.

PS: J'ai écrit plus de 100 articles originaux avec soin , et j'ai brossé main dans la main avec 200 questions de boucle, qui sont toutes publiées dans  la feuille de triche de l'algorithme de labuladong , qui est continuellement mise à jour . Il est recommandé de collecter, de brosser les questions dans l'ordre de mes articles , de maîtriser diverses routines d'algorithmes, puis de les jeter dans l'océan de questions.

Voleur de maison II

Cette question est fondamentalement la même que la première description. Les voleurs ne peuvent toujours pas voler les maisons voisines. L'entrée est toujours un tableau, mais cela vous indique que ces maisons ne sont pas dans une rangée, mais dans un cercle .

En d'autres termes, la première maison et la dernière maison sont maintenant adjacentes l'une à l'autre et ne peuvent pas être pillées en même temps. Par exemple, pour un tableau d'entrée  nums=[2,3,2], le résultat renvoyé par l'algorithme doit être 3 au lieu de 4, car le début et la fin ne peuvent pas être volés en même temps.

Cette contrainte ne devrait pas être difficile à résoudre.Nous avons mentionné une solution au tableau circulaire dans le précédent "Monotonic Stack Solving Next Greater Number", alors comment traiter ce problème?

Tout d'abord, la première et la dernière pièce ne peuvent pas être volées en même temps, il n'y a donc que trois situations possibles: soit aucune n'est volée; soit la première maison est volée et la dernière n'est pas volée; soit la dernière maison est volée et la première n'est pas volée.

be9fa34adb48b9b85e67f79719eb5c54.jpeg

C'est simple, dans ces trois cas, le type de résultat est le plus gros, ce qui est la réponse finale! Cependant, en fait, nous n'avons pas besoin de comparer les trois cas, comparez simplement le cas 2 et le cas 3, car ces deux cas ont un plus grand choix de maisons que le cas, et le montant d'argent dans la maison n'est pas négatif, il y a donc beaucoup de choix. , Le résultat optimal de la décision n'est certainement pas petit .

Modifiez donc légèrement la solution précédente:

public int rob (int [] nums) { 
    int n = nums.length; 
    if (n == 1) renvoie nums [0]; 
    return Math.max (robRange (nombres, 0, n - 2),  
                    robRange (nombres, 1, n - 1)); 
} 

// 仅 计算 闭 区间 [début, fin] 的 最优 结果
int robRange (int [] nums, int début, int fin) { 
    int n = nums.length; 
    int dp_i_1 = 0, dp_i_2 = 0; 
    int dp_i = 0; 
    pour (int i = fin; i> = début; i--) { 
        dp_i = Math.max (dp_i_1, nums [i] + dp_i_2); 
        dp_i_2 = dp_i_1; 
        dp_i_1 = dp_i; 
    } 
    return dp_i; 
}

À ce stade, la deuxième question a également été résolue.

Voleur de maison III

La troisième question a réussi à changer d'avis à nouveau: le voleur a constaté que la maison à laquelle il faisait face n'était pas une rangée, pas un cercle, mais un arbre binaire! La maison est au nœud de l'arbre binaire, et les deux maisons connectées ne peuvent pas être volées en même temps. C'est en effet un crime légendaire de haute intelligence:

9a1b4661c90d296a60d61921628717c2.jpg

La réflexion générale n'a pas du tout changé, c'est toujours le choix de saisir ou de ne pas saisir, et d'aller au choix avec plus de profit. On peut même écrire le code directement selon cette routine:

Map <TreeNode, Integer> memo = new HashMap <> (); 
public int rob (TreeNode root) { 
    if (root == null) return 0; 
    // Utilisez memo pour éliminer les sous-problèmes qui se chevauchent 
    if (memo.containsKey (root))  
        return memo.get (root); 
    // saisir, puis aller à la maison suivante 
    int do_it = root.val 
        + (root.left == null?  
            0: rob (root.left.left) + rob (root.left.right )) 
        + (root.right == null?  
            0: rob (root.right.left) + rob (root.right.right)); 
    // Ne pas saisir, puis aller à la maison suivante 
    int not_do = rob (root.left) + rob (root.right); 

    int res = Math.max (do_it, not_do); 
    memo.put (root, res); 
    return res; 
}

Ce problème est résolu, la complexité temporelle est O (N), le N nombre de nœuds.

PS: J'ai écrit plus de 100 articles originaux avec soin , et j'ai brossé main dans la main avec 200 questions de boucle, qui sont toutes publiées dans  la feuille de triche de l'algorithme de labuladong , qui est continuellement mise à jour . Il est recommandé de collecter, de brosser les questions dans l'ordre de mes articles , de maîtriser diverses routines d'algorithmes, puis de les jeter dans l'océan de questions.

Mais le point intelligent de cette question est qu'il existe de plus belles solutions. Par exemple, voici une solution que j'ai vue dans la zone de commentaire:

int rob (TreeNode root) { 
    int [] res = dp (root); 
    return Math.max (res [0], res [1]); 
} 

/ * renvoie un tableau de taille 2 arr 
arr [0] signifie non Si vous récupérez root, le montant maximum d'argent que vous pouvez obtenir 
arr [1] représente le montant maximum d'argent que vous obtenez si vous récupérez root * / 
int [] dp (TreeNode root) { 
    if (root == null) 
        return new int [] {0, 0 }; 
    int [] left = dp (root.left); 
    int [] right = dp (root.right); 
    // Si vous saisissez, vous ne pouvez pas saisir la prochaine maison 
    int rob = root.val + left [0] + right [ 0]; 
    // Ne pas saisir, la maison suivante peut être saisie ou non, selon la taille du revenu 
    int not_rob = Math.max (gauche [0], gauche [1]) 
                + Math.max (droite [0], droite [1] ); 

    return new int [] {not_rob, rob}; 
}

La complexité temporelle est O (N), et la complexité spatiale n'est que l'espace requis par la pile de fonctions récursives, sans l'espace supplémentaire du mémo.

Vous voyez que sa pensée est différente de la nôtre. La définition de la fonction récursive est modifiée, et la pensée est légèrement modifiée pour rendre la logique auto-cohérente, et obtenir toujours la bonne réponse, et le code est plus beau. C'est une caractéristique du problème de programmation dynamique que nous avons évoqué dans l'article précédent "Différentes définitions produisent des solutions différentes".

En fait, cette solution s'exécute beaucoup plus rapidement que notre solution, bien que la complexité temporelle du niveau d'analyse de l'algorithme soit la même. La raison en est que cette solution n'utilise pas de mémos supplémentaires, ce qui réduit la complexité des opérations de données, de sorte que l'efficacité opérationnelle réelle sera plus rapide.


Je suppose que tu aimes

Origine blog.51cto.com/15064450/2570830
conseillé
Classement