结束时发生了什么?即left==right-1时,根据循环不变式始终有array[left]<=key, array[right]>=key(否则就不应该在这里找)。显然我们把两个指针缩小到只有left和right两个情况,只要检查两个位置的值与key相等与否即可得到满足问题的解。因此算法是正确的。
以上两个算法尽管参考别人博客,但是证明以及具体二分写法都不一样,可以仔细对比学习。
查找第一个等于或者大于Key的元素的位置
int searchFirstEqualOrLarger(int *arr, int n, int key)
{
int left=0, right=n-1;
while(left<=right)
{
int mid = (left+right)/2;
if(arr[mid] >= key)
right = mid-1;
else if (arr[mid] < key)
left = mid+1;
}
return left;
}
查找第一个大于key的元素的位置
int searchFirstLarger(int *arr, int n, int key)
{
int left=0, right=n-1;
while(left<=right)
{
int mid = (left+right)/2;
if(arr[mid] > key)
right = mid-1;
else if (arr[mid] <= key)
left = mid+1;
}
return left;
}
查找最后一个等于或者小于key的元素的位置
int searchLastEqualOrSmaller(int *arr, int n, int key)
{
int left=0, right=n-1;
while(left<=right)
{
int m = (left+right)/2;
if(arr[m] > key)
right = m-1;
else if (arr[m] <= key)
left = m+1;
}
return right;
}
查找最后一个小于key的元素的位置
int searchLastSmaller(int *arr, int n, int key)
{
int left=0, right=n-1;
while(left<=right) {
int mid = (left+right)/2;
if(arr[mid] >= key)
right = mid-1;
else if (arr[mid] < key)
left = mid+1;
}
return right;
}
下面是一个测试的例子:
int main(void)
{
int arr[17] = {1,
2, 2, 5, 5, 5,
5, 5, 5, 5, 5,
5, 5, 6, 6, 7};
printf(“First Equal : %2d \n”, searchFirstEqual(arr, 16, 5));
printf(“Last Equal : %2d \n”, searchLastEqual(arr, 16, 5));
printf(“First Equal or Larger : %2d \n”, searchFirstEqualOrLarger(arr, 16, 5));
printf(“First Larger : %2d \n”, searchFirstLarger(arr, 16, 5));
printf(“Last Equal or Smaller : %2d \n”, searchLastEqualOrSmaller(arr, 16, 5));
printf(“Last Smaller : %2d \n”, searchLastSmaller(arr, 16, 5));
system(“pause”);
return 0;
}
最后输出结果是:
First Equal : 3
Last Equal : 12
First Equal or Larger : 3
First Larger : 13
Last Equal or Smaller : 12
Last Smaller : 2
很多的时候,应用二分检索的地方都不是直接的查找和key相等的元素,而是使用上面提到的二分检索的各个变种,熟练掌握了这些变种,当你再次使用二分检索的检索的时候就会感觉的更加的得心应手了。
二分法总结
二分法的代码中是存在非常多的细节的,一不小心,我们写出来的二分法就会存在bug。
下面我们以LeetCode上的一道二分法的题目来看看。
正确解答代码如下
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size() - 1;
while(low <= high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
};
容易出错的地方1:终止条件
while(low <= high) 如果写成了 while(low < high)
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size() - 1;
while(low < high){ //修改
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
};
那执行会报错
int low = 0, high = nums.size() - 1;
while(low < high)
当数组只有一个元素,则 low = 0, high = 1 -1 = 0,又因为while(low < high)。
所以根本就不会进入循环,也就根本没有执行判断。
那我们把high = nums.size() - 1改成high = nums.size() 可以吗?
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size();//修改
while(low < high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
};
low=0,high=6
第一次循环:mid=3 ,nums[mid] = 5 < 9,low = mid + 1 = 4
第二次循环:mid=(4+6)/2=5 ,nums[mid] = 12 > 9,high = mid - 1 = 4
此时 low和mid都是等于4,加上while(low < high),循环终止,没找到目标值。
当然,如果我们同时将
high = nums.size()
while(low <= high)
那代码可以通过测试
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size();//修改
while(low <= high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
};
通过上面我们也可以知道high = nums.size()或者high = nums.size()-1都是可以的。
容易出错的地方2:high的取值判断
将high = mid - 1变成high = mid
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size()-1;
while(low <= high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid;
} else {
low = mid + 1;
}
}
return -1;
}
};
这种显示超出时间限制那就是应该就是一直在进行循环,导致超时。
low=0,high=5
第一次循环:mid=2 ,nums[mid] = 3 > 2,high = mid = 2
第二次循环:mid=(0+2)/2=1 ,nums[mid] = 0 < 2,low = mid + 1 = 2
后面循环:因为low=high=2,而且while(low <= high),因此该循环就变成了死循环就会导致超时。
所以我们可以针对可能会进入死循环设置一个退出条件。
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size()-1;
int count = 0;
while(low <= high){
count++;
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid;
} else {
low = mid + 1;
}
if (count >= nums.size())
{
return -1;
}
}
return -1;
}
};
当然上面这种解决方法不优雅,也不利于我们加深对二分法的使用,因此我们不建议使用该种方法。
我们还是继续分析上面的情况,进入死循环的原因是while(low <= high)中是小于等于,那我们改成小于不就解决了吗?
改成下面的代码之后
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size()-1;
while(low < high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid;
} else {
low = mid + 1;
}
}
return -1;
}
};
当数组只有一个元素,则 low = 0, high = 1 -1 = 0,又因为while(low < high)。
所以根本就不会进入循环,也就根本没有执行判断。
因此我们将high = nums.size()-1改成high = nums.size()。
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size();
while(low < high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid;
} else {
low = mid + 1;
}
}
return -1;
}
};
代码可以成功执行。
容易出错的地方3:low的取值判断
将low = mid-1改成low = mid
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size()-1;
while(low <= high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid-1;
} else {
low = mid;
}
}
return -1;
}
};
low=0,high=6
第一次循环:mid=3 ,nums[mid] = 5 > 2,high = mid - 1 = 2
第二次循环:mid=(0+2)/2=1 ,nums[mid] = 0 < 2,low = mid = 1
第三次循环:mid=(1+2)/2=1 ,nums[mid] = 0 < 2,low = mid = 1
第四次循环:mid=(1+2)/2=1 ,nums[mid] = 0 < 2,low = mid = 1
、、、
可以看出就这样会一直无限循环,这边的无限循环不是因为 low和high相等造成的,而是因为low = mid,而mid=(low+high)/2=low,而high比low和mid大1造成的。
从上面的可以看出,写二分法要非常注意边界条件,一个等号,一个+1都可能让程序产生不同的效果,因为我们要牢牢把握住标准解法,然后在标准解法的基础上进行变通。
双指针法(尺取法)
双指针技巧可以分为两类,一类是「快慢指针」,一类是「左右指针」。前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。
尺取法其实是一个降低复杂度的优化算法,废话不多说,先上一道题。
题目:给定一个数组和一个数s,在这个数组中找一个区间,使得这个区间之和等于s。
例如:给定的数组int x[14] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14};和一个s = 15。那么,可以找到的区间就应该有0到4, 3到5, 6到7.(注意这里的下标从0开始)
对于这样的题,不用任何技巧就可以跑出结果,例如下面这个方法可能是大多数人能够想出来的:
先用一个数组sum[i]存放前i个元素的和,其实现用的是”递推思想“,注意,在编程中”递推“的思想用的特别多,一定要习惯这种思维方式。
sum[0] = x[0];//x为给定的原数组
for(int i = 1; i < n; i++){
sum[i] += sum[i-1];//递推思想
}
然后通过两层循环求解
for(int i = 0; i < n; i++)
for(int j = n-1; j >= 0; j–){
if(sum[j]-sum[i]==s) printf(“%d—%d\n”, i, j);
}
上面的方法当然是可行的,但是复杂度太高,有一个算法可以将其复杂度降为O(n)。这就是”尺取算法“。
尺取法:顾名思义,像尺子一样取一段,借用挑战书上面的话说,尺取法通常是对数组保存一对下标,即所选取的区间的左右端点,然后根据实际情况不断地推进区间左右端点以得出答案。之所以需要掌握这个技巧,是因为尺取法比直接暴力枚举区间效率高很多,尤其是数据量大的。
那么,用”尺取法“做上面这道题思路应该是这样的:
其实,这种方法很类似于蚯蚓的蠕动。
1)用一对脚标i, j。最开始都指向第一个元素。
2)如果区间i到j之和比s小,就让j往后挪一位,并把sum的值加上这个新元素。相当于蚯蚓的头向前伸了一下。
3)如果区间i到j之和比s大,就让sum减掉第一个元素。相当于蚯蚓的尾巴向前缩了一下。
4)如果i到j之和刚好等于s,则输入。
用一张图来表示就是这样的,每一行的黄色部分代表本次循环选中的区间
接下来附上完整源代码:
#include
#include
using namespace std;
void findSUM(int *A, int n, int s){
int i = 0, j = 0;
int sum = A[0];
while(i <= j && j < n){
if(sum >= s){
if(sum == s) printf(“%d—%d\n”, i, j);
sum -= A[i];
i++;
}
else{
j++;
sum += A[j];
}
}
}
int main(){
std::ios::sync_with_stdio(false);
std::cin.tie(0);
int m;
int x[14] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14};
cin >> m;
findSUM(x, 14, m);
return 0;
}
大家可以看到,”尺取法“一般只有O(n)的复杂度,针对大规模数据还是很有效的。另外,”尺取法“有时候也叫“双指针法”,当然,名字并没有那么重要,领会思想就行。
一、快慢指针的常见算法
快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。
1、判定链表中是否含有环
单链表的特点是每个节点只知道下一个节点,所以一个指针的话无法判断链表中是否含有环的。
如果链表中不含环,那么这个指针最终会遇到空指针 null 表示链表到头了,这还好说,可以判断该链表不含环。
public boolean hasCycle(ListNode head) {
while (head != null){
head = head.next;
}
return false;
}
但是如果链表中含有环,那么这个指针就会陷入死循环,因为环形数组中没有 null 指针作为尾部节点。
经典解法就是用两个指针,一个每次前进两步,一个每次前进一步。如果不含有环,跑得快的那个指针最终会遇到 null,说明链表不含环;如果含有环,快指针最终会超慢指针一圈,和慢指针相遇,说明链表含有环。
public boolean hasCycle(ListNode head){
ListNode fast, slow;
fast = slow = head;
while(fast != null && fast.next != null){
fast = fast.next.next;
slow = slow.next;
if(fast == slow){
return true;
}
}
return false;
}
2、已知链表中含有环,返回这个环的起始位置
public static ListNode detectCycle(ListNode head) {
ListNode fast = head;
ListNode slow = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
ListNode index1 = fast;
ListNode index2 = head;
while (index1 != index2) {
index1 = index1.next;
index2 = index2.next;
}
return index2;
}
}
return null;
}
3、寻找链表的中点
类似上面的思路,我们还可以让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。
public static ListNode findMid(ListNode head) {
ListNode slow, fast;
slow = fast = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
return slow;// slow 就在中间位置
}
当链表的长度是奇数时,slow 恰巧停在中点位置;如果长度是偶数,slow 最终的位置是中间偏右。
寻找链表中点的一个重要作用是对链表进行归并排序。
回想数组的归并排序:求中点索引递归地把数组二分,最后合并两个有序数组。对于链表,合并两个有序链表是很简单的,难点就在于二分。
但是现在你学会了找到链表的中点,就能实现链表的二分了。
4、寻找链表的倒数第 k 个元素
我们的思路还是使用快慢指针,让快指针先走 k 步,然后快慢指针开始同速前进。这样当快指针走到链表末尾 null 时,慢指针所在的位置就是倒数第 k 个链表节点(为了简化,假设 k 不会超过链表长度):
public static ListNode findK(ListNode head, int k) {
ListNode slow, fast;
slow = fast = head;
while (k-- > 0){
fast = fast.next;
}
while (fast != null) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
应用比如:力扣第 19 题「删除链表的倒数第n个元素」
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode slow = head;
ListNode fast = head;
for(int i = 0; i < n; i++){
fast = fast.next;
}
if(fast == null){// 如果此时快指针走到头了,说明倒数第 n 个节点就是第一个结点
return head.next;
}
while(fast != null && fast.next != null){// 让慢指针和快指针同步向前
slow = slow.next;
fast = fast.next;
}
slow.next = slow.next.next;// slow.next 就是倒数第 n 个节点,删除它
return head;
}
}
二、左右指针的常用算法
左右指针在数组中实际是指两个索引值,一般初始化为 left = 0, right = nums.length - 1 。
1、二分查找
前文 二分查找算法详解 有详细讲解,这里只写最简单的二分算法,旨在突出它的双指针特性:
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1;
else if (nums[mid] > target)
right = mid - 1;
}
return -1;
}
2、两数之和
直接看一道 LeetCode 题目吧:
只要数组有序,就应该想到双指针技巧。这道题的解法有点类似二分查找,通过调节 left 和 right 可以调整 sum 的大小:
int twoSum(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left < right) {
int sum = nums[left] + nums[right];
if(sum == target)
return new int[]{left + 1, right + 1};
else if (sum < target)
left++;
else if (sum > target)
right–;
}
return -1;
}
3、反转数组
public void reverse(int[] nums) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
// swap(nums[left], nums[right])
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
left++; right–;
}
}
4、滑动窗口算法
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
…
/*** debug 输出的位置 ***/
printf(“window: [%d, %d)\n”, left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
…
}
}
}
其中两处…表示的更新窗口数据的地方,到时候直接往里面填就行了。
而且,这两个…处的操作分别是右移和左移窗口更新操作,等会你会发现它们操作是完全对称的。
注:把索引左闭右开区间[left, right)称为一个窗口。
滑动窗口
滑动窗口实际上也是双指针的应用,滑动窗口的右指针不断扩大窗口的范围,当窗口内的对象符合某个条件时,进行统计或者某种操作;然后左指针收缩窗口来打破条件,以便让右指针右移继续扩大窗口
209 长度最小的子数组
使用左右指针来构建一个滑动窗,
当窗口内的数字的和<s时,右指针右移来扩大窗口;
当窗口内的数字之和>=s时,统计窗口的长度,并将左指针右移来缩小窗口
class Solution:
def minSubArrayLen(self, s: int, nums: List[int]) -> int:
if not nums:
return 0
i, j = 0, 0
windowSum = 0
min_len = float(‘inf’)
while j<len(nums):
windowSum += nums[j]
while i<=j and windowSum>=s:
min_len = min(min_len, j-i+1)
windowSum -= nums[i]
i+=1
j+=1
return 0 if min_len == float(‘inf’) else min_len
贪心算法(贪婪算法)
先来看看维基百科的定义:
贪心算法(英语:greedy algorithm),又称贪婪算法,是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是最好或最优的算法。[1]比如在旅行推销员问题中,如果旅行员每次都选择最近的城市,那这就是一种贪心算法。
贪心算法在有最优子结构的问题中尤为有效。最优子结构的意思是局部最优解能决定全局最优解。简单地说,问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。
贪心算法与动态规划的不同在于它对每个子问题的解决方案都做出选择,不能回退。动态规划则会保存以前的运算结果,并根据以前的结果对当前进行选择,有回退功能。
贪心法可以解决一些最优化问题,如:求图中的最小生成树、求哈夫曼编码……对于其他问题,贪心法一般不能得到我们所要求的答案。一旦一个问题可以通过贪心法来解决,那么贪心法一般是解决这个问题的最好办法。由于贪心法的高效性以及其所求得的答案比较接近最优结果,贪心法也可以用作辅助算法或者直接解决一些要求结果不特别精确的问题。在不同情况,选择最优的解,可能会导致辛普森悖论(Simpson’s Paradox),不一定出现最优的解。
贪心算法在数据科学领域被广泛应用,特别是金融工程。其中一个贪心算法例子就是Ensemble method。
贪心算法,又名贪婪法,是寻找最优解问题的常用方法,这种方法模式一般将求解过程分成若干个步骤,但每个步骤都应用贪心原则,选取当前状态下最好/最优的选择(局部最有利的选择),并以此希望最后堆叠出的结果也是最好/最优的解。{看着这个名字,贪心,贪婪这两字的内在含义最为关键。这就好像一个贪婪的人,他事事都想要眼前看到最好的那个,看不到长远的东西,也不为最终的结果和将来着想,贪图眼前局部的利益最大化,有点走一步看一步的感觉。}
贪婪法的基本步骤:
步骤1:从某个初始解出发;
步骤2:采用迭代的过程,当可以向目标前进一步时,就根据局部最优策略,得到一部分解,缩小问题规模;
步骤3:将所有解综合起来。
事例一:找零钱问题
假设你开了间小店,不能电子支付,钱柜里的货币只有 25 分、10 分、5 分和 1 分四种硬币,如果你是售货员且要找给客户 41 分钱的硬币,如何安排才能找给客人的钱既正确且硬币的个数又最少?
这里需要明确的几个点:
1.货币只有 25 分、10 分、5 分和 1 分四种硬币;
2.找给客户 41 分钱的硬币;
3.硬币最少化
思考,能使用我们今天学到的贪婪算法吗?怎么做?
(回顾一下上文贪婪法的基本步骤,1,2,3)
1.找给顾客sum_money=41分钱,可选择的是25 分、10 分、5 分和 1 分四种硬币。能找25分的,不找10分的原则,初次先找给顾客25分;
2.还差顾客sum_money=41-25=16。然后从25 分、10 分、5 分和 1 分四种硬币选取局部最优的给顾客,也就是选10分的,此时sum_money=16-10=6。重复迭代过程,还需要sum_money=6-5=1,sum_money=1-1=0。至此,顾客收到零钱,交易结束;
3.此时41分,分成了1个25,1个10,1个5,1个1,共四枚硬币。
编程实现
#include
using namespace std;
#define ONEFEN 1
#define FIVEFEN 5
#define TENFEN 10
#define TWENTYFINEFEN 25
int main()
{
int sum_money=41;
int num_25=0,num_10=0,num_5=0,num_1=0;
//不断尝试每一种硬币
while(money>=TWENTYFINEFEN) { num_25++; sum_money -=TWENTYFINEFEN; }
while(money>=TENFEN) { num_10++; sum_money -=TENFEN; }
while(money>=FIVEFEN) { num_5++; sum_money -=FIVEFEN; }
while(money>=ONEFEN) { num_1++; sum_money -=ONEFEN; }
//输出结果
cout<< “25分硬币数:”<<num_25<<endl;
cout<< “10分硬币数:”<<num_10<<endl;
cout<< “5分硬币数:”<<num_5<<endl;
cout<< “1分硬币数:”<<num_1<<endl;
return 0;
}
事例二:背包最大价值问题
有一个背包,最多能承载重量为 C=150的物品,现在有7个物品(物品不能分割成任意大小),编号为 1~7,重量分别是 wi=[35,30,60,50,40,10,25],价值分别是 pi=[10,40,30,50,35,40,30],现在从这 7 个物品中选择一个或多个装入背包,要求在物品总重量不超过 C 的前提下,所装入的物品总价值最高。
这里需要明确的几个点:
1.每个物品都有重量和价值两个属性;
2.每个物品分被选中和不被选中两个状态(后面还有个问题,待讨论);
3.可选物品列表已知,背包总的承重量一定。
所以,构建描述每个物品的数据体结构 OBJECT和背包问题定义为:
//typedef是类型定义的意思
//定义待选物体的结构体类型
typedef struct tagObject
{
int weight;
int price;
int status;
}OBJECT;
//定义背包问题
typedef struct tagKnapsackProblem
{
vectorobjs;
int totalC;
}KNAPSACK_PROBLEM;
这里采用定义结构体的形式,主要是可以减少代码的书写量,可以实现代码的复用性和可扩展性,简化,提高可读性。就是贪图简单方便,规避繁琐。
如下,实例化objects
OBJECT objects[] = { { 35,10,0 },{ 30,40,0 },{ 60,30,0 },{ 50,50,0 },
{ 40,35,0 },{ 10,40,0 },{ 25,30,0 } };
思考:如何选,才使得装进背包的价值最大呢?
策略1:价值主导选择,每次都选价值最高的物品放进背包;
策略2:重量主导选择,每次都选择重量最轻的物品放进背包;
策略3:价值密度主导选择,每次选择都选价值/重量最高的物品放进背包。
(贪心法则:求解过程分成若干个步骤,但每个步骤都应用贪心原则,选取当前状态下最好的或最优的选择(局部最有利的选择),并以此希望最后堆叠出的结果也是最好或最优的解)
策略1:价值主导选择,每次都选价值最高的物品放进背包
根据这个策略最终选择装入背包的物品编号依次是 4、2、6、5,此时包中物品总重量是 130,总价值是 165。
//遍历没有被选的objs,并且选择price最大的物品,返回被选物品的编号
int Choosefunc1(std::vector& objs, int c)
{
int index = -1; //-1表示背包容量已满
int max_price = 0;
//在objs[i].status == 0的物品里,遍历挑选objs[i].price最大的物品
for (int i = 0; i < static_cast(objs.size()); i++)
{
if ((objs[i].status == 0) && (objs[i].price > max_price ))//objs没有被选,并且price> max_price
{
max_price = objs[i].price;
index = i;
}
}
return index;
}
策略2:重量主导选择,每次都选择重量最轻(小)的物品放进背包
根据这个策略最终选择装入背包的物品编号依次是 6、7、2、1、5,此时包中物品总重量是 140,总价值是 155。
int Choosefunc2(std::vector& objs, int c)
{
int index = -1;
int min_weight= 10000;
for (int i = 0; i < static_cast(objs.size()); i++)
{
if ((objs[i].status == 0) && (objs[i].weight < min_weight))
{
min_weight= objs[i].weight;
index = i;
}
}
return index;
}
策略3:价值密度主导选择,每次选择都选价值/重量最高(大)的物品放进背包
物品的价值密度 si 定义为 pi/wi,这 7 件物品的价值密度分别为 si=[0.286,1.333,0.5,1.0,0.875,4.0,1.2]。根据这个策略最终选择装入背包的物品编号依次是 6、2、7、4、1,此时包中物品的总重量是 150,总价值是 170。
int Choosefunc3(std::vector& objs, int c)
{
int index = -1;
double max_s = 0.0;
for (int i = 0; i < static_cast(objs.size()); i++)
{
if (objs[i].status == 0)
{
double si = objs[i].price;
si = si / objs[i].weight;
if (si > max_s)
{
max_s = si;
index = i;
}
}
}
return index;
}
有了物品,有了方法,下面就是将两者结合起来的贪心算法GreedyAlgo
void GreedyAlgo(KNAPSACK_PROBLEM *problem, SELECT_POLICY spFunc)
{
int idx;
int sum_weight_current = 0;
//先选
while ((idx = spFunc(problem->objs, problem->totalC- sum_weight_current)) != -1)
{ //再检查,是否能装进去
if ((sum_weight_current + problem->objs[idx].weight) <= problem->totalC)
{
problem->objs[idx].status = 1;//如果背包没有装满,还可以再装,标记下装进去的物品状态为1
sum_weight_current += problem->objs[idx].weight;//把这个idx的物体的重量装进去,计算当前的重量
}
else
{
//不能选这个物品了,做个标记2后重新选剩下的
problem->objs[idx].status = 2;
}
}
PrintResult(problem->objs);//输出函数的定义,查看源代码
}
注意:这里对objs[idx].status定义了三种状态,分别是待选择为0(初始所有状态均为0),装进包里变为1,判断不符合变为2,这样最后只需要拿去状态为1的即可。
主函数部分
OBJECT objects[] = { { 35,10,0 },{ 30,40,0 },{ 60,30,0 },{ 50,50,0 },
{ 40,35,0 },{ 10,40,0 },{ 25,30,0 } };
int main()
{
KNAPSACK_PROBLEM problem;
problem.objs.assign(objects, objects + 7);//assign赋值,std::vector::assign
problem.totalC = 150;
cout << “Start to find the best way ,NOW” << endl;
GreedyAlgo(&problem, Choosefunc3);
system(“pause”);
return 0;
}
查看策略3的输出结果:
但是,我们再回顾一下第一个事例问题
现在问题变了,还是需要找给顾客41分钱,现在的货币只有 25 分、20分、10 分、5 分和 1 分四种硬币;该怎么办?
按照贪心算法的三个步骤:
1.41分,局部最优化原则,先找给顾客25分;
2.此时,41-25=16分,还需要找给顾客10分,然后5分,然后1分;
3.最终,找给顾客一个25分,一个10分,一个5分,一个1分,共四枚硬币。
是不是觉得哪里不太对,如果给他2个20分,加一个1分,三枚硬币就可以了呢?_;
总结:贪心算法的优缺点
优点:简单,高效,省去了为了找最优解可能需要穷举操作,通常作为其它算法的辅助算法来使用;
缺点:不从总体上考虑其它可能情况,每次选取局部最优解,不再进行回溯处理,所以很少情况下得到最优解。
完整代码:https://github.com/QianLingjun/
动态规划
0. intro
很有意思的问题。以往见过许多教材,对动态规划(DP)的引入属于“奉天承运,皇帝诏曰”式:不给出一点引入,见面即拿出一大堆公式吓人;学生则死啃书本,然后突然顿悟。针对入门者的教材不应该是这样的。恰好我给入门者讲过四次DP入门,迭代出了一套比较靠谱的教学方法,所以今天跑过来献丑。
现在,我们试着自己来一步步“重新发明”DP。
1. 从一个生活问题谈起
先来看看生活中经常遇到的事吧——假设您是个土豪,身上带了足够的1、5、10、20、50、100元面值的钞票。现在您的目标是凑出某个金额w,需要用到尽量少的钞票。
依据生活经验,我们显然可以采取这样的策略:能用100的就尽量用100的,否则尽量用50的……依次类推。在这种策略下,666=6×100+1×50+1×10+1×5+1×1,共使用了10张钞票。
这种策略称为“贪心”:假设我们面对的局面是“需要凑出w”,贪心策略会尽快让w变得更小。能让w少100就尽量让它少100,这样我们接下来面对的局面就是凑出w-100。长期的生活经验表明,贪心策略是正确的。
但是,如果我们换一组钞票的面值,贪心策略就也许不成立了。如果一个奇葩国家的钞票面额分别是1、5、11,那么我们在凑出15的时候,贪心策略会出错:
15=1×11+4×1 (贪心策略使用了5张钞票)
15=3×5 (正确的策略,只用3张钞票)
为什么会这样呢?贪心策略错在了哪里?
鼠目寸光。
刚刚已经说过,贪心策略的纲领是:“尽量使接下来面对的w更小”。这样,贪心策略在w=15的局面时,会优先使用11来把w降到4;但是在这个问题中,凑出4的代价是很高的,必须使用4×1。如果使用了5,w会降为10,虽然没有4那么小,但是凑出10只需要两张5元。
在这里我们发现,贪心是一种只考虑眼前情况的策略。
那么,现在我们怎样才能避免鼠目寸光呢?
如果直接暴力枚举凑出w的方案,明显复杂度过高。太多种方法可以凑出w了,枚举它们的时间是不可承受的。我们现在来尝试找一下性质。
重新分析刚刚的例子。w=15时,我们如果取11,接下来就面对w=4的情况;如果取5,则接下来面对w=10的情况。我们发现这些问题都有相同的形式:“给定w,凑出w所用的最少钞票是多少张?”接下来,我们用f(n)来表示“凑出n所需的最少钞票数量”。
那么,如果我们取了11,最后的代价(用掉的钞票总数)是多少呢?
明显[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TrhwwRny-1631411775840)(https://www.zhihu.com/equation?tex=\text{cost}+%3D+f(4)]+%2B+1+%3D+4+%2B+1+%3D+5) ,它的意义是:利用11来凑出15,付出的代价等于f(4)加上自己这一张钞票。现在我们暂时不管f(4)怎么求出来。
依次类推,马上可以知道:如果我们用5来凑出15,cost就是[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YbfsPkgK-1631411775840)(https://www.zhihu.com/equation?tex=f(10)]+%2B+1+%3D+2+%2B+1+%3D+3) 。
那么,现在w=15的时候,我们该取那种钞票呢?当然是各种方案中,cost值最低的那一个!
- 取11:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uwvhHIIo-1631411775841)(https://www.zhihu.com/equation?tex=\text{cost}%3Df(4)]%2B1%3D4%2B1%3D5)
- 取5: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-N3blu3QT-1631411775841)(https://www.zhihu.com/equation?tex=\text{cost}%3Df(10)]%2B1%3D2%2B1%3D3)
- 取1: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mfEaDjbF-1631411775842)(https://www.zhihu.com/equation?tex=\text{cost}%3Df(14)]%2B1%3D4%2B1%3D5)
显而易见,cost值最低的是取5的方案。我们通过上面三个式子,做出了正确的决策!
这给了我们一个至关重要的启示—— [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cCM5z2fR-1631411775842)(https://www.zhihu.com/equation?tex=f(n)]) 只与 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oUDWBKLV-1631411775843)(https://www.zhihu.com/equation?tex=f(n-1)]%2Cf(n-5)%2Cf(n-11)) 相关;更确切地说:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-62FEFxXM-1631411775843)(https://www.zhihu.com/equation?tex=f(n)]%3D\min{f(n-1)%2Cf(n-5)%2Cf(n-11)}%2B1)
这个式子是非常激动人心的。我们要求出f(n),只需要求出几个更小的f值;既然如此,我们从小到大把所有的f(i)求出来不就好了?注意一下边界情况即可。代码如下:
我们以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-DQ2hC6Hq-1631411775844)(https://www.zhihu.com/equation?tex=O(n)]) 的复杂度解决了这个问题。现在回过头来,我们看看它的原理:
- [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hFEinBIK-1631411775845)(https://www.zhihu.com/equation?tex=f(n)]) 只与[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-YWdj3zKy-1631411775845)(https://www.zhihu.com/equation?tex=f(n-1)]%2Cf(n-5)%2Cf(n-11))的值相关。
- 我们只关心 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7E32uHoT-1631411775846)(https://www.zhihu.com/equation?tex=f(w)]) 的值,不关心是怎么凑出w的。
这两个事实,保证了我们做法的正确性。它比起贪心策略,会分别算出取1、5、11的代价,从而做出一个正确决策,这样就避免掉了“鼠目寸光”!
它与暴力的区别在哪里?我们的暴力枚举了“使用的硬币”,然而这属于冗余信息。我们要的是答案,根本不关心这个答案是怎么凑出来的。譬如,要求出f(15),只需要知道f(14),f(10),f(4)的值。**其他信息并不需要。**我们舍弃了冗余信息。我们只记录了对解决问题有帮助的信息——f(n).
我们能这样干,取决于问题的性质:求出f(n),只需要知道几个更小的f©。我们将求解f©称作求解f(n)的“子问题”。
这就是DP(动态规划,dynamic programming).
将一个问题拆成几个子问题,分别求解这些子问题,即可推断出大问题的解。
思考题:请稍微修改代码,输出我们凑出w的方案。
2. 几个简单的概念
【无后效性】
一旦f(n)确定,“我们如何凑出f(n)”就再也用不着了。
要求出f(15),只需要知道f(14),f(10),f(4)的值,而f(14),f(10),f(4)是如何算出来的,对之后的问题没有影响。
“未来与过去无关”,这就是无后效性。
(严格定义:如果给定某一阶段的状态,则在这一阶段以后过程的发展不受这阶段以前各段状态的影响。)
【最优子结构】
回顾我们对f(n)的定义:我们记“凑出n所需的最少钞票数量”为f(n).
f(n)的定义就已经蕴含了“最优”。利用w=14,10,4的最优解,我们即可算出w=15的最优解。
大问题的最优解可以由小问题的最优解推出,这个性质叫做“最优子结构性质”。
引入这两个概念之后,我们如何判断一个问题能否使用DP解决呢?
能将大问题拆成几个小问题,且满足无后效性、最优子结构性质。
3. DP的典型应用:DAG最短路
问题很简单:给定一个城市的地图,所有的道路都是单行道,而且不会构成环。每条道路都有过路费,问您从S点到T点花费的最少费用。
一张地图。边上的数字表示过路费。
这个问题能用DP解决吗?我们先试着记从S到P的最少费用为f§.
想要到T,要么经过C,要么经过D。从而[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KyAh1X0b-1631411775847)(https://www.zhihu.com/equation?tex=f(T)]%3D\min{f©%2B20%2Cf(D)%2B10})
好像看起来可以DP。现在我们检验刚刚那两个性质:
- 无后效性:对于点P,一旦f§确定,以后就只关心f§的值,不关心怎么去的。
- 最优子结构:对于P,我们当然只关心到P的最小费用,即f§。如果我们从S走到T是 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-vv2xADf0-1631411775848)(https://www.zhihu.com/equation?tex=S+\to+P\to+Q\to+T)] ,那肯定S走到Q的最优路径是 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WFqgkAkH-1631411775848)(https://www.zhihu.com/equation?tex=S\to+P\to+Q)] 。对一条最优的路径而言,从S走到**沿途上所有的点(子问题)**的最优路径,都是这条大路的一部分。这个问题的最优子结构性质是显然的。
既然这两个性质都满足,那么本题可以DP。式子明显为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OexYP1Us-1631411775848)(https://www.zhihu.com/equation?tex=f§]%3D\min{f®%2Bw_{R→P}})
其中R为有路通到P的所有的点, [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ie9SSgUI-1631411775849)(https://www.zhihu.com/equation?tex=w_{R→P})] 为R到P的过路费。
代码实现也很简单,拓扑排序即可。
4. 对DP原理的一点讨论
【DP的核心思想】
DP为什么会快?
无论是DP还是暴力,我们的算法都是在可能解空间内,寻找最优解。
来看钞票问题。暴力做法是枚举所有的可能解,这是最大的可能解空间。
DP是枚举有希望成为答案的解。这个空间比暴力的小得多。
也就是说:DP自带剪枝。
DP舍弃了一大堆不可能成为最优解的答案。譬如:
15 = 5+5+5 被考虑了。
15 = 5+5+1+1+1+1+1 从来没有考虑过,因为这不可能成为最优解。
从而我们可以得到DP的核心思想:尽量缩小可能解空间。
在暴力算法中,可能解空间往往是指数级的大小;如果我们采用DP,那么有可能把解空间的大小降到多项式级。
一般来说,解空间越小,寻找解就越快。这样就完成了优化。
【DP的操作过程】
一言以蔽之:大事化小,小事化了。
将一个大问题转化成几个小问题;
求解小问题;
推出大问题的解。
【如何设计DP算法】
下面介绍比较通用的设计DP算法的步骤。
首先,把我们面对的局面表示为x。这一步称为设计状态。
对于状态x,记我们要求出的答案(e.g. 最小费用)为f(x).我们的目标是求出f(T).
找出f(x)与哪些局面有关(记为p),写出一个式子(称为状态转移方程),通过f§来推出f(x).
【DP三连】
设计DP算法,往往可以遵循DP三连:
我是谁? ——设计状态,表示局面
我从哪里来?
我要到哪里去? ——设计转移
设计状态是DP的基础。接下来的设计转移,有两种方式:一种是考虑我从哪里来(本文之前提到的两个例子,都是在考虑“我从哪里来”);另一种是考虑我到哪里去,这常见于求出f(x)之后,更新能从x走到的一些解。这种DP也是不少的,我们以后会遇到。
总而言之,“我从哪里来”和“我要到哪里去”只需要考虑清楚其中一个,就能设计出状态转移方程,从而写代码求解问题。前者又称pull型的转移,后者又称push型的转移。(这两个词是
思考题:如何把钞票问题的代码改写成“我到哪里去”的形式?
提示:求出f(x)之后,更新f(x+1),f(x+5),f(x+11).
5. 例题:最长上升子序列
扯了这么多形而上的内容,还是做一道例题吧。
最长上升子序列(LIS)问题:给定长度为n的序列a,从a中抽取出一个子序列,这个子序列需要单调递增。问最长的上升子序列(LIS)的长度。
e.g. 1,5,3,4,6,9,7,8的LIS为1,3,4,6,7,8,长度为6。
如何设计状态(我是谁)?
我们记 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LiRJGdTs-1631411775849)(https://www.zhihu.com/equation?tex=f(x)]) 为以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HNksetAg-1631411775849)(https://www.zhihu.com/equation?tex=a_x)] 结尾的LIS长度,那么答案就是 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-4BL6de4p-1631411775850)(https://www.zhihu.com/equation?tex=\max{f(x)]}) .
状态x从哪里推过来(我从哪里来)?
考虑比x小的每一个p:如果 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZWPqQBn1-1631411775850)(https://www.zhihu.com/equation?tex=a_x>a_p)] ,那么f(x)可以取f§+1.
解释:我们把 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-tYvrBJcH-1631411775850)(https://www.zhihu.com/equation?tex=a_x)] 接在 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GXgXHcFn-1631411775851)(https://www.zhihu.com/equation?tex=a_p)] 的后面,肯定能构造一个以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eaQV1hkm-1631411775851)(https://www.zhihu.com/equation?tex=a_x)] 结尾的上升子序列,长度比以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5fuum785-1631411775852)(https://www.zhihu.com/equation?tex=a_p)] 结尾的LIS大1.那么,我们可以写出状态转移方程了:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eJjuxbZd-1631411775852)(https://www.zhihu.com/equation?tex=f(x)]%3D\max_{p<x+%2C+a_p<a_x+}{f§}%2B1)
至此解决问题。两层for循环,复杂度 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1XUYEeuK-1631411775853)(https://www.zhihu.com/equation?tex=O(n^2)]) .
从这三个例题中可以看出,DP是一种思想,一种“大事化小,小事化了”的思想。带着这种思想,DP将会成为我们解决问题的利器。
最后,我们一起念一遍DP三连吧——我是谁?我从哪里来?我要到哪里去?
6. 习题
如果读者有兴趣,可以试着完成下面几个习题:
一、请采取一些优化手段,以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Yztggfu5-1631411775854)(https://www.zhihu.com/equation?tex=O(n\log+n)]) 的复杂度解决LIS问题。
提示:可以参考这篇博客 Junior Dynamic Programming–动态规划初步·各种子序列问题
二、“按顺序递推”和“记忆化搜索”是实现DP的两种方式。请查阅资料,简单描述“记忆化搜索”是什么。并采用记忆化搜索写出钞票问题的代码,然后完成P1541 乌龟棋 - 洛谷 。
三、01背包问题是一种常见的DP模型。请完成P1048 采药 - 洛谷。
下面放一篇比较好的动态规划文章:https://blog.csdn.net/WhereIsHeroFrom/article/details/120107337
分治算法
一、基本概念
在计算机科学中,分治法是一种很重要的算法。字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)……
任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。问题的规模越小,越容易直接求解,解题所需的计算时间也越少。例如,对于n个元素的排序问题,当n=1时,不需任何计算。n=2时,只要作一次比较即可排好序。n=3时只要作3次比较即可,…。而当n较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当困难的。
二、基本思想及策略
分治法的设计思想是:将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。
分治策略是:对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。这种算法设计策略叫做分治法。
如果原问题可分割成k个子问题,1<k≤n,且这些子问题都可解并可利用这些子问题的解求出原问题的解,那么这种分治法就是可行的。由分治法产生的子问题往往是原问题的较小模式,这就为使用递归技术提供了方便。在这种情况下,反复应用分治手段,可以使子问题与原问题类型一致而其规模却不断缩小,最终使子问题缩小到很容易直接求出其解。这自然导致递归过程的产生。分治与递归像一对孪生兄弟,经常同时应用在算法设计之中,并由此产生许多高效算法。
三、分治法适用的情况
分治法所能解决的问题一般具有以下几个特征:
- 该问题的规模缩小到一定的程度就可以容易地解决
- 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。
- 利用该问题分解出的子问题的解可以合并为该问题的解;
- 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题。
第一条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加;
第二条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用;、
第三条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法或动态规划法。
第四条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好。
四、分治法的基本步骤
分治法在每一层递归上都有三个步骤:
step1 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
step2 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题
step3 合并:将各个子问题的解合并为原问题的解。
它的一般的算法设计模式如下:
Divide-and-Conquer§
- if |P|≤n0
- then return(ADHOC§)
- 将P分解为较小的子问题 P1 ,P2 ,…,Pk
- for i←1 to k
- do yi ← Divide-and-Conquer(Pi) △ 递归解决Pi
- T ← MERGE(y1,y2,…,yk) △ 合并子问题
- return(T)
其中|P|表示问题P的规模;n0为一阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。ADHOC§是该分治法中的基本子算法,用于直接解小规模的问题P。因此,当P的规模不超过n0时直接用算法ADHOC§求解。算法MERGE(y1,y2,…,yk)是该分治法中的合并子算法,用于将P的子问题P1 ,P2 ,…,Pk的相应的解y1,y2,…,yk合并为P的解。
五、分治法的复杂性分析
一个分治法将规模为n的问题分成k个规模为n/m的子问题去解。设分解阀值n0=1,且adhoc解规模为1的问题耗费1个单位时间。再设将原问题分解为k个子问题以及用merge将k个子问题的解合并为原问题的解需用f(n)个单位时间。用T(n)表示该分治法解规模为|P|=n的问题所需的计算时间,则有:
T(n)= k T(n/m)+f(n)
通过迭代法求得方程的解:
递归方程及其解只给出n等于m的方幂时T(n)的值,但是如果认为T(n)足够平滑,那么由n等于m的方幂时T(n)的值可以估计T(n)的增长速度。通常假定T(n)是单调上升的,从而当 mi≤n<mi+1时,T(mi)≤T(n)<T(mi+1)。
六、可使用分治法求解的一些经典问题
(1)二分搜索
(2)大整数乘法
(3)Strassen矩阵乘法
(4)棋盘覆盖
(5)合并排序
(6)快速排序
(7)线性时间选择
(8)最接近点对问题
(9)循环赛日程表
(10)汉诺塔
七、依据分治法设计程序时的思维过程
实际上就是类似于数学归纳法,找到解决本问题的求解方程公式,然后根据方程公式设计递归程序。
1、一定是先找到最小问题规模时的求解方法
2、然后考虑随着问题规模增大时的求解方法
3、找到求解的递归函数式后(各种规模或因子),设计递归程序即可。
回溯算法
算法导读
本期算法讲解思路:
白话算法->算法思路->实例:八皇后问题->实例:01背包问题->算法教你玩数独
白话算法
回溯法(back tracking)(探索与回溯法)是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法,而满足回溯条件的某个状态的点称为“回溯点”。
白话:回溯法可以理解为通过选择不同的岔路口寻找目的地,一个岔路口一个岔路口的去尝试找到目的地。如果走错了路,继续返回来找到岔路口的另一条路,直到找到目的地。
实例一:八皇后问题
八皇后问题是一个古老而著名的问题,是回溯算法的典型例题。该问题是十九世纪著名的数学家高斯1850年提出:在8X8格的国际象棋上摆放八个皇后(棋子),使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上。
小白面试经:理解如何解决这个问题,回溯法的精髓已经get。如果只是想了解算法面试知识,知道解决这个问题就能完成你的算法积累了。想快速掌握算法,可以直接查看解题思路的四个步骤。
八皇后问题解题思路:
问题简化:下面我们将八皇后问题转化为四皇后问题,并用回溯法来找到它的解
**目的:**在4x4棋盘上,使得4个皇后不能在同行同列以及同斜线上。
step1
尝试先放置第一枚皇后,被涂黑的地方是不能放皇后
step2
第二行的皇后只能放在第三格或第四格,比方我们放第三格,则:
此时我们也能理解为什么叫皇后问题了,皇后旁边容不下其他皇后。而在同一个房间放下四个皇后确实是个不容易的问题。
step3
可以看到再难以放下第三个皇后,此时我们就要用到回溯算法了。我们把第二个皇后更改位置,此时我们能放下第三枚皇后了。
step4
虽然是能放置第三个皇后,但是第四个皇后又无路可走了。返回上层调用(3号皇后),而3号也别无可去,继续回溯上层调用(2号),2号已然无路可去,继续回溯上层(1号),于是1号皇后改变位置如下,继续回溯。
这就是回溯算法的精髓,虽然没有最终把问题解决,但是可以剧透一波,就是根据这个算法,最终能够把四位皇后放在4x4的棋盘里。也能用同样的方法解决了八皇后问题。下面我们用代码解决八皇后问题。
代码实现八皇后问题
我们将算法也设置成两步,
第一步 我们要判断每次输入的皇后是否在同一行同一列,或者同一斜线上。
bool is_ok(int row){ //判断设置的皇后是否在同一行,同一列,或者同一斜线上
for (int j=0;j<row;j++)
{
if (queen[row]==queen[j]||row-queen[row]==j-queen[j]||row+queen[row]==j+queen[j])
return false;
}
return true;
}
第二步 我们用十行代码来进入我们核心算法
void back_tracking(int row=0) //算法函数,从第0行开始遍历
{
if (row==n)
t ++; //判断若遍历完成,就进行计数
for (int col=0;col<n;col++) //遍历棋盘每一列
{
queen[row] = col; //将皇后的位置记录在数组
if (is_ok(row)) //判断皇后的位置是否有冲突
back_tracking(row+1); //递归,计算下一个皇后的位置
}
}
代码实现算法也是比较简单的,主要还是看是否掌握算法思想。
实例二:01背包问题
有N件物品和一个容量为V的背包。第i件物品的价格(即体积,下同)是w[i],价值是c[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
这是最基础的背包问题,总的来说就是:选还是不选,这是个问题
相当于用f[i][j]表示前i个物品装入容量为v的背包中所可以获得的最大价值。
对于一个物品,只有两种情况
情况一: 第i件不放进去,这时所得价值为:f[i-1][v]
情况二: 第i件放进去,这时所得价值为:f[i-1][v-c[i]]+w[i]
接下来的实例属于算法进阶,可做了解
提两点,
1.与上期贪婪法所解决的背包问题相比,回溯法将能更能顾及寻找全局最优。
2.背包问题与八皇后问题所用的算法虽然都是回溯法,但是他们的目的不一样,八皇后只要求把所有的棋子放在棋盘上(即只需解决深度最优)。而01背包问题不仅需要让物品都放进背包,而且要使得物品质量最大,在八皇后问题上多提出了一个限制。
问题的解空间
用回溯法解问题时,应明确定义问题的解空间。问题的解空间至少包含问题的一个(最优)解。对于 n=3 时的 0/1 背包问题,可用一棵完全二叉树表示解空间,如图所示:
求解步骤
1)针对所给问题,定义问题的解空间;
2)确定易于搜索的解空间结构;
3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。
**常用的剪枝函数:**用约束函数在扩展结点处剪去不满足约束的子树;用限界函数剪去得不到最优解的子树。
回溯法对解空间做深度优先搜索时,有递归回溯和迭代回溯(非递归)两种方法,但一般情况下用递归方法实现回溯法。
算法描述
解 0/1 背包问题的回溯法在搜索解空间树时,只要其左儿子结点是一个可行结点,搜索就进入其左子树。当右子树中有可能包含最优解时才进入右子树搜索。否则将右子树剪去。
我们直接上手代码解决这个问题
算法部分
void dfs(int i,int cv,int cw)
{ //cw当前包内物品重量,cv当前包内物品价值
if(i>n)
{
if(cv>bestval) //是否超过了最大价值
{
bestval=cv; //得到最大价值
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Go语言工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Go语言全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Golang知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip1024b (备注Go)
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
],价值是c[i]。求解将哪些物品装入背包可使这些物品的费用总和不超过背包容量,且价值总和最大。
这是最基础的背包问题,总的来说就是:选还是不选,这是个问题
相当于用f[i][j]表示前i个物品装入容量为v的背包中所可以获得的最大价值。
对于一个物品,只有两种情况
情况一: 第i件不放进去,这时所得价值为:f[i-1][v]
情况二: 第i件放进去,这时所得价值为:f[i-1][v-c[i]]+w[i]
接下来的实例属于算法进阶,可做了解
提两点,
1.与上期贪婪法所解决的背包问题相比,回溯法将能更能顾及寻找全局最优。
2.背包问题与八皇后问题所用的算法虽然都是回溯法,但是他们的目的不一样,八皇后只要求把所有的棋子放在棋盘上(即只需解决深度最优)。而01背包问题不仅需要让物品都放进背包,而且要使得物品质量最大,在八皇后问题上多提出了一个限制。
问题的解空间
用回溯法解问题时,应明确定义问题的解空间。问题的解空间至少包含问题的一个(最优)解。对于 n=3 时的 0/1 背包问题,可用一棵完全二叉树表示解空间,如图所示:
求解步骤
1)针对所给问题,定义问题的解空间;
2)确定易于搜索的解空间结构;
3)以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。
**常用的剪枝函数:**用约束函数在扩展结点处剪去不满足约束的子树;用限界函数剪去得不到最优解的子树。
回溯法对解空间做深度优先搜索时,有递归回溯和迭代回溯(非递归)两种方法,但一般情况下用递归方法实现回溯法。
算法描述
解 0/1 背包问题的回溯法在搜索解空间树时,只要其左儿子结点是一个可行结点,搜索就进入其左子树。当右子树中有可能包含最优解时才进入右子树搜索。否则将右子树剪去。
我们直接上手代码解决这个问题
算法部分
void dfs(int i,int cv,int cw)
{ //cw当前包内物品重量,cv当前包内物品价值
if(i>n)
{
if(cv>bestval) //是否超过了最大价值
{
bestval=cv; //得到最大价值
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。
深知大多数Go语言工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!
因此收集整理了一份《2024年Go语言全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。
[外链图片转存中…(img-AMw1h27D-1712925509954)]
[外链图片转存中…(img-7gYBgoxq-1712925509955)]
[外链图片转存中…(img-Lw1vtjSv-1712925509955)]
[外链图片转存中…(img-ADoPmBQl-1712925509956)]
[外链图片转存中…(img-b5FCwBRm-1712925509956)]
既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Golang知识点,真正体系化!
由于文件比较大,这里只是将部分目录大纲截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且后续会持续更新
如果你觉得这些内容对你有帮助,可以添加V获取:vip1024b (备注Go)
[外链图片转存中…(img-BOXb9Tw9-1712925509957)]
一个人可以走的很快,但一群人才能走的更远。不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎扫码加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!