C++冲鸭~【算法】

前言

此篇博客是学习笔记,根据Hello算法学习。GitHub链接:https://github.com/krahets/hello-algo

搜索

二分查找

二分查找 是一种基于分治策略的高效搜索算法。它利用数据的有序性,每轮减少一半搜索范围,直到找到目标元素或搜索区间为空为止。

// binary_search.cpp
int  binarySearch(vector<int> &nums, int target) {
	int i=0, j=nums.size()-1;
	while (i<=j) {
		int m = i + (j-i) / 2;
		if (nums[m] < target)
			i = m + 1;
		else if (nums[m] > target)
			j = m - 1;
		else
			return m;
	}
	return -1;
}

根据左开右闭,左闭右开或者都开都闭进行选择。

二分查找插入点

给定一个长度为n的有序数组 nums 和一个元素 target ,数组不存在重复元素。现将 target 插入到数组 nums 中,并保持其有序性。若数组中已存在元素 target ,则插入到其左方。请返回插入后 target 在数组中的索引。

// binary_search_insertion.cpp
int binarySearchInsertion(vector<int> &nums, int target) {
	int i=0, j=nums.size() - 1;
	while (i <= j) {
		int m = i + (j - i) / 2;
		if(nums[m] < target) {
			i = m + 1;
		} else if (nums[m] > target) {
			j = m - 1;
		} else {
			return m;
		}
	}
	return i;
}

数组中有重复元素

/* 二分查找插入点(存在重复元素) */
int binarySearchInsertion(vector<int> &nums, int target) {
    int i = 0, j = nums.size() - 1; // 初始化双闭区间 [0, n-1]
    while (i <= j) {
        int m = i + (j - i) / 2; // 计算中点索引 m
        if (nums[m] < target) {
            i = m + 1; // target 在区间 [m+1, j] 中
        } else if (nums[m] > target) {
            j = m - 1; // target 在区间 [i, m-1] 中
        } else {
            j = m - 1; // 首个小于 target 的元素在区间 [i, m-1] 中
        }
    }
    // 返回插入点 i
    return i;
}

二分查找边界

给定一个长度为n的有序数组nums,数组可能包含重复元素。请返回数组中最左一个元素target的索引。若数组中不包含该元素,则返回-1。

// binary_search_edge.cpp
int binarySearchLeftEdge(vector<int> &nums, int target) {
	// 等价于查找target的插入点
	int i = binarySearchInsertion(nums, target);
	if (i == nums.size() || nums[i] != target) {
		return -1;
	}
	return i;
}

哈希优化策略

我们常常通过将线性查找替换为哈希查找来降低算法的时间复杂度。

线性查找:以时间换空间

给定一个整数数组 nums 和一个目标元素 target ,请在数组中搜索“和”为 target 的两个元素,并返回它们的数组索引。返回任意一个解即可。

// 暴力枚举
vector<int> twoSumBruteForce(vector<int> &nums, int target) {
	int size = num.size();
	// 两层循环,时间复杂度 O(n^2)
	for (int i=0; i<size; i++) {
		for (int j=0; j<size; j++) {
			if (nums[i] + nums[j] == target):
				return {i, j};
		}
	}
	return {};
}

哈希查找:以空间换时间

借助一个哈希表,键值对分别为数组元素和元素索引。

vector<int> twoSumHashTable(vector<int> &nums, int target) {
	int size = nums.size();
	// 空间复杂度 O(n)
	unordered_map<int, int> dic;
	// 时间复杂度 O(n)
	for (int i=0; i<size; i++) {
		if (dic.find(target - nums[i]) != dic.end()) {
			return {dic[target - nums[i]], i};
		}
		dic.emplace(nums[i], i);
	}
	return {};
}

重识搜索算法

暴力搜索

线性搜索
广度优先搜索/深度优先搜索

自适应搜索

二分查找
哈希查找
树查找
在这里插入图片描述

排序

选择排序【selection sort】

开启一个循环,每轮从未排序区间选择最小的元素,将其放在已排序区间的末尾。

void selectionSort(vector<int> &nums) {
	int n = nums.size();
	for (int i=0; i<n; i++) {
		int k = i;
		for (int j=i+1; j<n; j++) {
			if (nums[j] < nums[k])
				k = j;
		}
		swap(nums[i], nums[k]);
	}
}

冒泡排序【bubble sort】

void bubbleSort(vector<int> &nums) {
	for (int i = nums.size() - 1; i > 0; i--) {
		for (int j = 0; j < i; j++) {
			if (nums[j] > nums[j+1]) {
				swap(nums[j], nums[j+1]);
			}
		}
	}
}

效率优化

void bubbleSortWithFlag(vector<int> &nums) {
	for (int i=nums.size() - 1; i > 0; i--) {
		bool flag = false;
		for (int j=0; j<i; j++) {
			if (nums[j] > nums[j+1]) {
				swap(nums[j], nums[j+1]);
				flag = true;
			}
		}
		if (!flag)
			break;
	}
}

插入排序【insertion sort】

// insertionSort.cpp
void insertionSort(vector<int> &nums) {
	for (int i=1; i<nums.size(); i++) {
		int base = nums[i], j=i-1;
		while (j >= 0 && nums[j] > base) {
			nums[j + 1] = nums[j];
			j--;
		}
		nums[j+1] = base;
	}
}

快速排序【quick sort】

void swap(vector<int> &nums, int i, int j){
	int tmp = nums[i];
	nums[i] = nums[j];
	nums[j] = tmp;
}

int partition(vector<int> &nums, int left, int right) {
	int i = left, j = right;
	while(i<j){
		while (i < j && nums[j] >= nums[left])
			j--;
		while (i < j && nums[i] <= nums[left])
			i++;
		swap(nums, i, j);
	}
	swap(nums, i, left);
	return i;
}

void quickSort(vector<int> &nums, int left, int right) {
	if (left >= right)
		return;
	int pivot = partition(nums, left, right);
	quickSort(nums, left, pivot - 1);
	quickSort(nums, pivot + 1, right);
}

归并排序

/* 合并左子数组和右子数组 */
// 左子数组区间 [left, mid]
// 右子数组区间 [mid + 1, right]
void merge(vector<int> &nums, int left, int mid, int right) {
    // 初始化辅助数组
    vector<int> tmp(nums.begin() + left, nums.begin() + right + 1);
    // 左子数组的起始索引和结束索引
    int leftStart = left - left, leftEnd = mid - left;
    // 右子数组的起始索引和结束索引
    int rightStart = mid + 1 - left, rightEnd = right - left;
    // i, j 分别指向左子数组、右子数组的首元素
    int i = leftStart, j = rightStart;
    // 通过覆盖原数组 nums 来合并左子数组和右子数组
    for (int k = left; k <= right; k++) {
        // 若“左子数组已全部合并完”,则选取右子数组元素,并且 j++
        if (i > leftEnd)
            nums[k] = tmp[j++];
        // 否则,若“右子数组已全部合并完”或“左子数组元素 <= 右子数组元素”,则选取左子数组元素,并且 i++
        else if (j > rightEnd || tmp[i] <= tmp[j])
            nums[k] = tmp[i++];
        // 否则,若“左右子数组都未全部合并完”且“左子数组元素 > 右子数组元素”,则选取右子数组元素,并且 j++
        else
            nums[k] = tmp[j++];
    }
}

/* 归并排序 */
void mergeSort(vector<int> &nums, int left, int right) {
    // 终止条件
    if (left >= right)
        return; // 当子数组长度为 1 时终止递归
    // 划分阶段
    int mid = (left + right) / 2;    // 计算中点
    mergeSort(nums, left, mid);      // 递归左子数组
    mergeSort(nums, mid + 1, right); // 递归右子数组
    // 合并阶段
    merge(nums, left, mid, right);
}

堆排序

/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
void siftDown(vector<int> &nums, int n, int i) {
    while (true) {
        // 判断节点 i, l, r 中值最大的节点,记为 ma
        int l = 2 * i + 1;
        int r = 2 * i + 2;
        int ma = i;
        if (l < n && nums[l] > nums[ma])
            ma = l;
        if (r < n && nums[r] > nums[ma])
            ma = r;
        // 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
        if (ma == i) {
            break;
        }
        // 交换两节点
        swap(nums[i], nums[ma]);
        // 循环向下堆化
        i = ma;
    }
}

/* 堆排序 */
void heapSort(vector<int> &nums) {
    // 建堆操作:堆化除叶节点以外的其他所有节点
    for (int i = nums.size() / 2 - 1; i >= 0; --i) {
        siftDown(nums, nums.size(), i);
    }
    // 从堆中提取最大元素,循环 n-1 轮
    for (int i = nums.size() - 1; i > 0; --i) {
        // 交换根节点与最右叶节点(即交换首元素与尾元素)
        swap(nums[0], nums[i]);
        // 以根节点为起点,从顶至底进行堆化
        siftDown(nums, i, 0);
    }
}

桶排序

/* 桶排序 */
void bucketSort(vector<float> &nums) {
    // 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
    int k = nums.size() / 2;
    vector<vector<float>> buckets(k);
    // 1. 将数组元素分配到各个桶中
    for (float num : nums) {
        // 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
        int i = num * k;
        // 将 num 添加进桶 bucket_idx
        buckets[i].push_back(num);
    }
    // 2. 对各个桶执行排序
    for (vector<float> &bucket : buckets) {
        // 使用内置排序函数,也可以替换成其他排序算法
        sort(bucket.begin(), bucket.end());
    }
    // 3. 遍历桶合并结果
    int i = 0;
    for (vector<float> &bucket : buckets) {
        for (float num : bucket) {
            nums[i++] = num;
        }
    }
}

在这里插入图片描述

计数排序

通过统计元素数量来实现排序,通常应用于整数数组。

void countingSortNaive(vector<int> &nums) {
	int m=0;
	for (int num:nums) {
		m = max(m, max);
	}
	vector<int> counter(m + 1, 0);
	for(int num:nums) {
		counter[num]++;
	}
	int i = 0;
	for(int num = 0; num < m+1; num++) {
		for (int j = 0; j < counter[num]; j++, i++) {
			nums[i] = num;
		}
	}
}

基数排序

「基数排序 radix sort」的核心思想与计数排序一致,也通过统计个数来实现排序。在此基础上,基数排序利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果。

/* 获取元素 num 的第 k 位,其中 exp = 10^(k-1) */
int digit(int num, int exp) {
    // 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
    return (num / exp) % 10;
}

/* 计数排序(根据 nums 第 k 位排序) */
void countingSortDigit(vector<int> &nums, int exp) {
    // 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
    vector<int> counter(10, 0);
    int n = nums.size();
    // 统计 0~9 各数字的出现次数
    for (int i = 0; i < n; i++) {
        int d = digit(nums[i], exp); // 获取 nums[i] 第 k 位,记为 d
        counter[d]++;                // 统计数字 d 的出现次数
    }
    // 求前缀和,将“出现个数”转换为“数组索引”
    for (int i = 1; i < 10; i++) {
        counter[i] += counter[i - 1];
    }
    // 倒序遍历,根据桶内统计结果,将各元素填入 res
    vector<int> res(n, 0);
    for (int i = n - 1; i >= 0; i--) {
        int d = digit(nums[i], exp);
        int j = counter[d] - 1; // 获取 d 在数组中的索引 j
        res[j] = nums[i];       // 将当前元素填入索引 j
        counter[d]--;           // 将 d 的数量减 1
    }
    // 使用结果覆盖原数组 nums
    for (int i = 0; i < n; i++)
        nums[i] = res[i];
}

/* 基数排序 */
void radixSort(vector<int> &nums) {
    // 获取数组的最大元素,用于判断最大位数
    int m = *max_element(nums.begin(), nums.end());
    // 按照从低位到高位的顺序遍历
    for (int exp = 1; exp <= m; exp *= 10)
        // 对数组元素的第 k 位执行计数排序
        // k = 1 -> exp = 1
        // k = 2 -> exp = 10
        // 即 exp = 10^(k-1)
        countingSortDigit(nums, exp);
}

在这里插入图片描述

分治

分而治之,通常基于递归实现。

分治搜索策略

int dfs(vector<int> &nums, int target, int i, int j) {
	if (i > j) {
		return -1;
	}
	int m = (i+j) / 2;
	if (nums[m] < target) {
		return dfs(nums, target, m+1, j);
	} else if (nums[m] > target) {
		return dfs(nums, target, i, m-1);
	} else {
		return m;
	}
}

int binarySearch(vector<int> &nums, int target) {
	int n = nums.size();
	return dfs(nums, target, 0, n-1);
}

构建树问题

TreeNode *dfs(vector<int> &preorder, unordered_map<int, int> &inorderMap, int i, int l, int r) {
	if (r - l < 0)
		return null;
	TreeNode *root = new TreeNode(preorder[i]);
	int m = inorderMap[preorder[i]];
	root->left = dfs(preorder, inorderMap, i+1, l, m-1);
	root->right = dfs(preorder, inorderMap, i+1+m-l, m+1, r);
	return root;
}

TreeNode *buildTree(vector<int> &preorder, vector<int> &inorder) {
	unordered_map<int, int> inorderMap;
	for(int i=0; i<inorder.size();i++) {
		inorderMap[inorder[i]] = i;
	}
	TreeNode *root = dfs(preorder, inorderMap,0, 0, inorder.size()-1);
	return root;
}

汉诺塔问题

void move(vector<int> &src, vector<int> &tar) {
	int pan = src.back();
	src.pop_back(pan);
}

void dfs(int i, vector<int> &src, vector<int> &buf, vector<int> &tar) {
	if (i == 1) {
		move(src, tar);
		return;
	}

	dfs(i - 1, src, tar, buf);
	move(src, tar);
	dfs(i - 1, buf, src, tar);
}
void solveHanota(vector<int> &A, vector<int> &B, vector<int> &C) {
	int n = A.size();
	dfs(n, A, B, C);
}

回溯

回溯算法是一种通过穷举来解决问题的方法,他的核心思想是从一个状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,知道找到解或者尝试了所有可能的选择都无法找到解为止。

给定一个二叉树,搜索并记录所有值为7的节点,请返回节点列表。

// 前序遍历
void preOrder(TreeNode *root) {
	if (root == nullptr) {
		return;
	}
	if (root->val == 7) {
		res.push_back(root);
	}
	preOrder(root->left);
	preOrder(root->right);
}

尝试和回退

// 前序遍历:尝试与回退
void preOrder(TreeNode *root) {
	if (root == nullptr) {
		return;
	}
	path.push_back(root);
	if (root->val == 7) {
		// 记录解
		res.push_back(path);
	}
	preOrder(root->left);
	preOrder(root->right);
	// 回退
	path.pop_back();
}

剪枝

// 前序遍历:剪枝
void preOrder(TreeNode *root) {
	if (root == nullptr || root->val == 3) {
		return;
	}
	path.push_back(root);
	if (root->val == 7) {
		res.push_back(path);
	}
	preOrder(root->left);
	preOrder(root->right);
	path.pop_back();
}

在这里插入图片描述

全排列问题

全排列问题是回溯算法的一些典型应用。它的定义是在给定一个集合的情况下,找出这个集合中元素的所有可能的排列。

无相等元素的情况

void backtrack(vector<int> &state, const vector<int> &choices, vector<bool> &selected, vector<vector<int>> &res) {
	if (state.size() == choices.size()) {
		res.push_back(state);
		return;
	}
	for (int i=0; i<choices.size(); i++) {
		int choice = choices[i];
		if (!selected[i]) {
			selected[i] = true;
			state.push_back(choice);
			backtrack(state, choices, selected, res);
			selected[i] = false;
			state.pop_back();
		}
	}
}

//全排列
vector<vector<int>> permutationI(vector<int> nums) {
	vector<int> state;
	vector<bool> selected(nums.size(), false);
	vector<vector<int>> res;
	backtrack(state, nums, selected, res);
	return res;
}

考虑相等元素的情况

/* 回溯算法:全排列 II */
void backtrack(vector<int> &state, const vector<int> &choices, vector<bool> &selected, vector<vector<int>> &res) {
    // 当状态长度等于元素数量时,记录解
    if (state.size() == choices.size()) {
        res.push_back(state);
        return;
    }
    // 遍历所有选择
    unordered_set<int> duplicated;
    for (int i = 0; i < choices.size(); i++) {
        int choice = choices[i];
        // 剪枝:不允许重复选择元素 且 不允许重复选择相等元素
        if (!selected[i] && duplicated.find(choice) == duplicated.end()) {
            // 尝试:做出选择,更新状态
            duplicated.emplace(choice); // 记录选择过的元素值
            selected[i] = true;
            state.push_back(choice);
            // 进行下一轮选择
            backtrack(state, choices, selected, res);
            // 回退:撤销选择,恢复到之前的状态
            selected[i] = false;
            state.pop_back();
        }
    }
}

/* 全排列 II */
vector<vector<int>> permutationsII(vector<int> nums) {
    vector<int> state;
    vector<bool> selected(nums.size(), false);
    vector<vector<int>> res;
    backtrack(state, nums, selected, res);
    return res;
}

子集和问题

给定一个正整数数组nums和一个目标正整数target,请找出所有可能的组合,使得组合中的元素和等于target。给定数组无重复元素,每个元素可以被选取多次。请以列表的形式返回这些组合,列表中不应包含重复组合。

无重复元素的情况

void backtrack(vector<int> &state, int target, int total, vector<int> &choices, vector<vector<int>> &res) {
	if (total == target) {
		res.push_back(state);
		return;
	}
	for (size_t i = 0; i < choices.size(); i++) {
		if (total + choices[i] > target) {
			continue;
		}
		state.push_back(choices[i]);
		backtrack(state, target, total + choices[i], choices, res);
		state.pop_back();
	}
}

vector<vector<int>> subsetSumINaive(vector<int> &nums, int target) {
	vector<int> state;
	int total = 0;
	vector<vector<int>> res;
	backtrack(state, target, total, nums, res);
	return res;
}

重复子集剪枝

/* 回溯算法:子集和 I */
void backtrack(vector<int> &state, int target, vector<int> &choices, int start, vector<vector<int>> &res) {
    // 子集和等于 target 时,记录解
    if (target == 0) {
        res.push_back(state);
        return;
    }
    // 遍历所有选择
    // 剪枝二:从 start 开始遍历,避免生成重复子集
    for (int i = start; i < choices.size(); i++) {
        // 剪枝一:若子集和超过 target ,则直接结束循环
        // 这是因为数组已排序,后边元素更大,子集和一定超过 target
        if (target - choices[i] < 0) {
            break;
        }
        // 尝试:做出选择,更新 target, start
        state.push_back(choices[i]);
        // 进行下一轮选择
        backtrack(state, target - choices[i], choices, i, res);
        // 回退:撤销选择,恢复到之前的状态
        state.pop_back();
    }
}

/* 求解子集和 I */
vector<vector<int>> subsetSumI(vector<int> &nums, int target) {
    vector<int> state;              // 状态(子集)
    sort(nums.begin(), nums.end()); // 对 nums 进行排序
    int start = 0;                  // 遍历起始点
    vector<vector<int>> res;        // 结果列表(子集列表)
    backtrack(state, target, nums, start, res);
    return res;
}

考虑重复元素的情况

给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和等于 target 。给定数组可能包含重复元素,每个元素只可被选择一次。请以列表形式返回这些组合,列表中不应包含重复组合。

//相等元素剪枝
/* 回溯算法:子集和 II */
void backtrack(vector<int> &state, int target, vector<int> &choices, int start, vector<vector<int>> &res) {
    // 子集和等于 target 时,记录解
    if (target == 0) {
        res.push_back(state);
        return;
    }
    // 遍历所有选择
    // 剪枝二:从 start 开始遍历,避免生成重复子集
    // 剪枝三:从 start 开始遍历,避免重复选择同一元素
    for (int i = start; i < choices.size(); i++) {
        // 剪枝一:若子集和超过 target ,则直接结束循环
        // 这是因为数组已排序,后边元素更大,子集和一定超过 target
        if (target - choices[i] < 0) {
            break;
        }
        // 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过
        if (i > start && choices[i] == choices[i - 1]) {
            continue;
        }
        // 尝试:做出选择,更新 target, start
        state.push_back(choices[i]);
        // 进行下一轮选择
        backtrack(state, target - choices[i], choices, i + 1, res);
        // 回退:撤销选择,恢复到之前的状态
        state.pop_back();
    }
}

/* 求解子集和 II */
vector<vector<int>> subsetSumII(vector<int> &nums, int target) {
    vector<int> state;              // 状态(子集)
    sort(nums.begin(), nums.end()); // 对 nums 进行排序
    int start = 0;                  // 遍历起始点
    vector<vector<int>> res;        // 结果列表(子集列表)
    backtrack(state, target, nums, start, res);
    return res;
}

N皇后问题

根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定n个皇后和一个n*n大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案。

/* 回溯算法:N 皇后 */
void backtrack(int row, int n, vector<vector<string>> &state, vector<vector<vector<string>>> &res, vector<bool> &cols,
               vector<bool> &diags1, vector<bool> &diags2) {
    // 当放置完所有行时,记录解
    if (row == n) {
        res.push_back(state);
        return;
    }
    // 遍历所有列
    for (int col = 0; col < n; col++) {
        // 计算该格子对应的主对角线和副对角线
        int diag1 = row - col + n - 1;
        int diag2 = row + col;
        // 剪枝:不允许该格子所在列、主对角线、副对角线存在皇后
        if (!cols[col] && !diags1[diag1] && !diags2[diag2]) {
            // 尝试:将皇后放置在该格子
            state[row][col] = "Q";
            cols[col] = diags1[diag1] = diags2[diag2] = true;
            // 放置下一行
            backtrack(row + 1, n, state, res, cols, diags1, diags2);
            // 回退:将该格子恢复为空位
            state[row][col] = "#";
            cols[col] = diags1[diag1] = diags2[diag2] = false;
        }
    }
}

/* 求解 N 皇后 */
vector<vector<vector<string>>> nQueens(int n) {
    // 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
    vector<vector<string>> state(n, vector<string>(n, "#"));
    vector<bool> cols(n, false);           // 记录列是否有皇后
    vector<bool> diags1(2 * n - 1, false); // 记录主对角线是否有皇后
    vector<bool> diags2(2 * n - 1, false); // 记录副对角线是否有皇后
    vector<vector<vector<string>>> res;

    backtrack(0, n, state, res, cols, diags1, diags2);

    return res;
}

动态规划

动态规划是一个重要的算法范式,它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。

问题求解步骤

  1. 描述决策
  2. 定义状态
  3. 建立dp表
  4. 推导状态转移方程
  5. 确定边界条件

给定一个n*m的二维网格grid,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或向右移动一步,直到到达右下角单元格。请返回从左上角到右下角的最小路径和。

在这里插入图片描述
第一步:思考每轮的决策,定义状态,得到dp表
从当前格子向下或向右一步。当前状态包含行索引和列索引,记为[i, j];
状态对应的子问题:从起始点[0, 0]走到[i, j]的最小路径和,记为dp[i, j];
至此,我们得到了二维dp矩阵,其尺寸与输入网格grid相同。

在这里插入图片描述
第二步:找出最优子结构,进而推导出状态转移方程
对于状态【i, j】,它只能从上边格子[i-1, j]和左边格子[i, j-1]转移而来。因此最优子结构为:到达[i, j]的最小路径和由[i-1, j]的最小路径和与[i, j-1]的最小路径和,这两者较小的那一个决定。

dp[i, j] = min (dp[i-1, j], dp[i, j-1]) + grid[i, j]

在这里插入图片描述
第三步:确定边界条件和状态转移顺序
首行的状态只能从左边的状态得到,首列的状态只能从上边的状态得到,因此首行i=0和首列j=0是边界条件。

由于每个格子是由其左方格子和上方格子转移而来,因此我们使用循环来遍历矩阵。

在这里插入图片描述
根据分析,我们可以直接写出动态规划代码。然而子问题求解是一种从顶到底的思想,因此按照“暴力搜索-》记忆化搜索-》动态规划”的顺序实现更加符合思维习惯。

暴力搜索

// min_path_sum.cpp
int minPathSumDFS(vector<int> &grid, int i, int j) {
	// 若为左上角单元格,则终止搜索
	if (i == 0 &7 j == 0) {
		return grid[0][0];
	}
	// 若行列索引越界,则返回+∞
	if (i < 0 || j < 0) {
		return INT_MAX;
	}
	// 计算从左上角到 (i-1, j) 和 (i, j-1) 的最小路径代价
	int left = minPathSumDFS(grid, i-1, j);
	int up = minPathSumDFS(grid, i, j-1);

	return min(left, up) != INT_MAX ? min(left, up) : INT_MAX;
}

记忆化搜索

引入一个和网格grid相同尺寸的记忆列表mem,用于记录各个子问题的解,并将重叠子问题进行剪枝。

// min_path_sum.cpp
int minPathDFSMem(vector<vector<int>> &grid, vector<vector<int>> &mem, int i, int j) {
	if (i == 0 && j == 0) {
		return grid[0][0];
	}
	if (i < 0 || j < 0) {
		return INT_MAX;
	}
	if (mem[i][j] != -1) {
		return mem[i][j];
	}

	int left = minPathSumDFSMem(grid, mem, i-1, j);
	int up = minPathSumDFSMem(grid, mem, i, j-1);

	min[i][j] = min(left, up) != INT_MAX ? min(left, up) + grid[i][j] : INT_MAX;
	return mem[i][j];
}

动态规划

// int minPathSumDP(vector<vector<int>> &grid) {
	int n = grid.size(), m = grid[0].size();
	// 初始化dp表
	vector<vector<int>> dp(n, vector<int>(m));
	dp[0][0] == grid[0][0];
	for (int j = 1; j<m; j++) {
		dp[0][j] == dp[0][j-1] + grid[0][j];
	}
	for (int i = 1; i<n; i++) {
		dp[i][0] == dp[i-1][0] + grid[i][0];
	}
	for (int i=0; i<n; i++) {
		for (int j=0; j<m; j++) {
			dp[i][j] = min(dp[i][j-1], dp[i-1][j]) + grid[i][j];
		}
	}
	return dp[n-1][m-1];
}

空间优化

// 空间优化后的动态规划
int minPathSumDPComp(vector<vector<int>> &grid) {
	int n = grid.size(), m = grid[0].size();
	// 初始化dp表
	vector<int> dp(m);
	// 状态转移:首行
	dp[0] = grid[0][0];
	for (int j=1; j<m; j++) {
		dp[j] = dp[j-1] + grid[0][j];
	}
	// 状态转移:其余行
	for (int i=1; i<n; i++){
		dp[0] = dp[0] + grid[i][0];
		for (int j=1; j<m; j++) {
			dp[j] = min(dp[j-1], dp[j]) + grid[i][j];
		}
	}
	return dp[m-1];
}

01背包问题

给定n个物品,第i个物品的重量为wgt[i-1]、价值为val[i-1],和一个容量为cap的背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。

第一步:思考每轮的决策,定义状态,从而得到dp表;

对于每个物品来说,不放入背包,背包容量不变;放入背包,背包容量减小。
状态定义:当前物品编号i和剩余背包容量,记为[i, c]。
前i个物品在剩余容量为c的背包中的最大价值,记为dp[i, c]。
待求解的是dp[n, cap] 需要一个尺寸为(n+1)* (cap+1) 的二维dp表。

第二步:找出最优子结构,进而推导出状态转移方程;

不放入物品i:背包容量不变,状态变化为【i-1, c】;
放入物品i:背包容量减小wgt[i-1], 价值增加val[i-1],状态变化为【i-1, c-wgt[i-1]】。

最优子结构:最大价值dp[i, c]等于不放入物品i和放入物品i两种方案中的价值更大的那一个。

dp[i, c] = max(dp[i-1, c], dp[i-1, c-wgt[i-1]] + val[i-1])

第三步:确定边界条件和状态转移顺序;

当无物品或无剩余背包容量时最大价值为0,即首列dp[i, 0]和首行dp[0, i]都等于0;

暴力搜索

// 01背包
int knapsackDFS(vector<int> &wgt, vector<int> &val, int i, int c) {
	if (i == 0 || c == 0) {
		return 0;
	}
	// 若超过背包容量,则只能不放入背包
		if (wgt[i - 1] > c) {
			knapsackDFS(wgt, val, i - 1, c);
		}
		int no = knapsackDFS(wgt, val, i-1, c);
		int yes = knapsackDFS(wgt, val, i - 1, c - wgt[i-1]) + val[i-1];
		return max(no, yes);
}

记忆化搜索

/* 0-1 背包:记忆化搜索 */
int knapsackDFSMem(vector<int> &wgt, vector<int> &val, vector<vector<int>> &mem, int i, int c) {
    // 若已选完所有物品或背包无容量,则返回价值 0
    if (i == 0 || c == 0) {
        return 0;
    }
    // 若已有记录,则直接返回
    if (mem[i][c] != -1) {
        return mem[i][c];
    }
    // 若超过背包容量,则只能不放入背包
    if (wgt[i - 1] > c) {
        return knapsackDFSMem(wgt, val, mem, i - 1, c);
    }
    // 计算不放入和放入物品 i 的最大价值
    int no = knapsackDFSMem(wgt, val, mem, i - 1, c);
    int yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1];
    // 记录并返回两种方案中价值更大的那一个
    mem[i][c] = max(no, yes);
    return mem[i][c];
}

动态规划

/* 0-1 背包:动态规划 */
int knapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
    int n = wgt.size();
    // 初始化 dp 表
    vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
    // 状态转移
    for (int i = 1; i <= n; i++) {
        for (int c = 1; c <= cap; c++) {
            if (wgt[i - 1] > c) {
                // 若超过背包容量,则不选物品 i
                dp[i][c] = dp[i - 1][c];
            } else {
                // 不选和选物品 i 这两种方案的较大值
                dp[i][c] = max(dp[i - 1][c], dp[i - 1][c - wgt[i - 1]] + val[i - 1]);
            }
        }
    }
    return dp[n][cap];
}

空间优化

/* 0-1 背包:空间优化后的动态规划 */
int knapsackDPComp(vector<int> &wgt, vector<int> &val, int cap) {
    int n = wgt.size();
    // 初始化 dp 表
    vector<int> dp(cap + 1, 0);
    // 状态转移
    for (int i = 1; i <= n; i++) {
        // 倒序遍历
        for (int c = cap; c >= 1; c--) {
            if (wgt[i - 1] <= c) {
                // 不选和选物品 i 这两种方案的较大值
                dp[c] = max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
            }
        }
    }
    return dp[cap];
}

完全背包

每个物品可以重复选择

/* 完全背包:动态规划 */
int unboundedKnapsackDP(vector<int> &wgt, vector<int> &val, int cap) {
    int n = wgt.size();
    // 初始化 dp 表
    vector<vector<int>> dp(n + 1, vector<int>(cap + 1, 0));
    // 状态转移
    for (int i = 1; i <= n; i++) {
        for (int c = 1; c <= cap; c++) {
            if (wgt[i - 1] > c) {
                // 若超过背包容量,则不选物品 i
                dp[i][c] = dp[i - 1][c];
            } else {
                // 不选和选物品 i 这两种方案的较大值
                dp[i][c] = max(dp[i - 1][c], dp[i][c - wgt[i - 1]] + val[i - 1]);
            }
        }
    }
    return dp[n][cap];
}

类似的:零钱兑换问题

编辑距离问题

输入两个字符串 s 和 t,返回将 s 转换为 t 所需的最少编辑步数。
你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、替换字符为任意一个字符。

/* 编辑距离:动态规划 */
int editDistanceDP(string s, string t) {
    int n = s.length(), m = t.length();
    vector<vector<int>> dp(n + 1, vector<int>(m + 1, 0));
    // 状态转移:首行首列
    for (int i = 1; i <= n; i++) {
        dp[i][0] = i;
    }
    for (int j = 1; j <= m; j++) {
        dp[0][j] = j;
    }
    // 状态转移:其余行列
    for (int i = 1; i <= n; i++) {
        for (int j = 1; j <= m; j++) {
            if (s[i - 1] == t[j - 1]) {
                // 若两字符相等,则直接跳过此两字符
                dp[i][j] = dp[i - 1][j - 1];
            } else {
                // 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
                dp[i][j] = min(min(dp[i][j - 1], dp[i - 1][j]), dp[i - 1][j - 1]) + 1;
            }
        }
    }
    return dp[n][m];
}

贪心【greedy algorithm】

基本思想是在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期望获得全局最优解。
动态规划会根据当前阶段的所有决策来考虑当前决策,并使用过去子问题的解来构建当前子问题的解。
贪心算法不会重新考虑过去的决策,二十一路向前的进行贪心选择,不断缩小问题范围,直至问题被解决。

零钱兑换

给定 n 种硬币,第 i 种硬币的面值为 coins[i-1] ,目标金额为 amt ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 -1 。
给定目标金额,我们贪心地选择不大于且最接近它的硬币,不断循环该步骤,直至凑出目标金额为止。

//coin_change_greedy.py
int coinChangeGreedy(vector<int> &coins, int amt) {
	int i = coins.size() - 1;
	int count = 0;
	while (amt > 0) {
		while (i > 0 && coins[i] > amt) {
			i--;
		}
		amt -= coins[i];
		count++;
	}
	return amt == 0? count : -1;
}

贪心算法不仅操作直接,实现简单,而且通常效率也很高;但是对于某些硬币面值组合,贪心算法并不能找到最优解。

可以保证找到最优解,可以找到近似最优解。

贪心典型例题

  • 硬币找零问题
  • 区间调度问题
  • 分数背包问题
  • 股票买卖问题
  • 霍夫曼编码
  • dijkstra算法

分数背包问题

给定n个物品,第i个物品的重量为wgt[i-1]、价值为val[i-1],和一个容量为cap的背包。每个物品只能选择一次,单可以选择物品的一部分,价值根据选择的重量比例计算,问在不超过背包容量下背包中物品的最大价值。

class Item {
	public:
		int w;
		int v;
		Item(int w, int v) : w(w), v(v) {}
};

double fractionalKnapsack(vector<int> &wgt, vector<int> &val, int cap) {
	vector<Item> items;
	for (int i=0; i<wgt.size(); i++) {
		items.push_back(Item(wgt[i], val[i]));
	}
	sort(items.begin(), items.end(), [](Item &a, Item &b) { return (double)a.v / a.w > (double)b.v / b.w});

	double res = 0;
	for (auto &item : items) {
		if (item.w <= cap) {
			res += item.v;
			cap -= item.w;
		} else {
			res += (double)item.v / item.w * cap;
			break;
		}
	}
	return res;
}

最大容量问题

输入一个数组 ht ,数组中的每个元素代表一个垂直隔板的高度。数组中的任意两个隔板,以及它们之间的空间可以组成一个容器。
容器的容量等于高度和宽度的乘积(即面积),其中高度由较短的隔板决定,宽度是两个隔板的数组索引之差。
请在数组中选择两个隔板,使得组成的容器的容量最大,返回最大容量。

int maxCapacity(vector<int> &ht) {
	int i=0, j = ht.size()-1;
	int res = 0;
	while (i < j) {
		int cap = min(ht[i], ht[j]) * (j-i);
		res = max(res, cap);
		if (ht[i] < ht[j]) {
			i++;
		} else {
			j--;
		}
	}
	return res;
}

最大切分乘积问题

给定一个正整数,将其切分为至少两个正整数的和,求切分后所有整数的乘积最大是多少。

// 最大切分乘积
int maxProductCutting(int n) {
	if (n <= 3) {
		return 1 * (n-1);
	}
	int a = n/3;
	int b = n%3;
	if (b == 1) {
		return (int)pow(3, a-1) * 2 * 2;
	}
	if (b == 2) {
		return (int)pow(3, a) * 2;
	}
	return (int)pow(3, a);
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值