十三、前言
这里继续前面的算法介绍,由于字数限制,只能分两次了...
十四、排序
(1)排序算法
作用: 将一组数据按照特定的顺序进行排列
==> 理想排序 <==
说明: 需要满足下面这五个才是理想的,不过很难.
运行效率:
期望排序算法的时间复杂度尽量低,且总体操作数量较少
就地性:
只在原数组上直接操作实现排序,而不需要借助额外的辅助数组,从而节省内存
稳定性:
在完成排序后,相等元素在数组中的相对顺序不发生改变。
自适应性:
自适应排序的时间复杂度会受输入数据的影响,即最佳、最差、平均时间复杂度并不完全相等。 自适应性需要根据具体情况来评估。如果最差时间复杂度差于平均时间复杂度,说明排序算法在某些数据下性能可能劣化,因此被视为负面属性;而如果最佳时间复杂度优于平均时间复杂度,则被视为正面属性。
是否基于比较:
通过比较运算符来判断元素的相对顺序,从而排序整个数组
(2)选择排序
原理: 开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾
流程:
初始状态下,所有元素未排序,即未排序索引区间为
[0, 𝑛 − 1]
选取区间
[0, 𝑛 − 1]
中的最小元素,将其与索引 0 处元素交换。完成后,数组前 1 个元素已排序选取区间
[1, 𝑛 − 1]
中的最小元素,将其与索引 1 处元素交换。完成后,数组前 2 个元素已排序以此类推。经过 𝑛 − 1 轮选择与交换后,数组前 𝑛 − 1 个元素已排序
仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。
function selectionSort(nums) {
let n = nums.length;
// 外循环:未排序区间为 [i, n-1],所以这里一共进行 n - 1 轮
for (let i = 0; i < n - 1; i++) {
// 内循环:找到未排序区间内的最小元素,随着 i 的增加,
// 内循环每次都比前一次减少1轮,第一次是n轮
// 最后一次是两轮
let k = i;
for (let j = i + 1; j < n; j++) {
if (nums[j] < nums[k]) {
// 记录最小元素的索引
k = j;
}
}
// 将该最小元素与未排序区间的首个元素交换
[nums[i], nums[k]] = [nums[k], nums[i]];
}
}
==> 算法特点 <==
满足特性: 非自适应排序、原地排序、非稳定排序
时间复杂度:
O(n²)空间复杂度:
𝑂(1)
(3)冒泡排序
说明: 通过连续地比较与交换相邻元素实现排序,因为整个过程就像气泡从底部升到顶部一样,因此得名冒泡排序
==> 流程 <==
比如: 假设数组的长度为n
首先,对 𝑛 个元素执行“冒泡”,将数组的最大元素交换至正确位置
接下来,对剩余 𝑛 − 1 个元素执行“冒泡”,将第二大元素交换至正确位置
以此类推,经过 𝑛 − 1 轮“冒泡”后,前 𝑛 − 1 大的元素都被交换至正确位置
仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。
function bubbleSort(nums) {
// 外循环:未排序区间为 [0, i]
for (let i = nums.length - 1; i > 0; i--) {
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (let j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
let tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
}
}
}
}
==> 优化 <==
说明: 如果某轮冒泡中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果,此时可以用一个变量flag来检测这种状态
function bubbleSortWithFlag(nums) {
// 外循环:未排序区间为 [0, i]
for (let i = nums.length - 1; i > 0; i--) {
// 初始化标志位
let flag = false;
// 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
for (let j = 0; j < i; j++) {
if (nums[j] > nums[j + 1]) {
// 交换 nums[j] 与 nums[j + 1]
let tmp = nums[j];
nums[j] = nums[j + 1];
nums[j + 1] = tmp;
// 记录交换元素
flag = true;
}
}
// 此轮冒泡未交换任何元素,直接跳出
if (!flag) {
break;
}
}
}
==> 算法特点 <==
满足特性: 自适应排序、原地排序、稳定排序
时间复杂度:
O(n²)空间复杂度:
𝑂(1)
注意: 在优化后其最优时间复杂度可以达到O(n),也就是一个有序的数组进行冒泡排列的时候
(4)插入排序
操作: 在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置
==> 流程 <==
初始状态下,数组的第 1 个元素已完成排序。
选取数组的第 2 个元素作为 base ,将其插入到正确位置后,数组的前 2 个元素已排序
选取第 3 个元素作为 base ,将其插入到正确位置后,数组的前 3 个元素已排序
以此类推,在最后一轮中,选取最后一个元素作为 base ,将其插入到正确位置后,所有元素均已排序。
function insertionSort(nums) {
// 外循环:已排序元素数量为 1, 2, ..., n
for (let i = 1; i < nums.length; i++) {
let base = nums[i],
j = i - 1;
// 内循环:将 base 插入到已排序部分的正确位置
while (j >= 0 && nums[j] > base) {
// 将 nums[j] 向右移动一位
nums[j + 1] = nums[j];
j--;
}
// 将 base 赋值到正确位置
nums[j + 1] = base;
}
}
==> 算法特点 <==
满足特性: 自适应排序、原地排序、稳定排序
时间复杂度:
O(n²)空间复杂度:
𝑂(1)
注意: 在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度𝑂(𝑛)。
==> 优势 <==
说明: 插入排序的使用频率显著高于冒泡排序和选择排序,原因如下
冒泡排序需要借助一个临时变量,而插入排序不需要,因此其计算开销通常会更高
选择排序在任何情况下的时间复杂度都为 𝑂(𝑛²) 。如果给定一组部分有序的数据,插入排序通常比选择排序效率更高,其次选择排序不稳定,无法应用于多级排序。
(5)快速排序
==> 哨兵划分 <==
说明: 这是快速排序的核心,其目标是选择数组中的某个元素作为基准数,将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧
过程:
选取数组最左端元素作为基准数,然后初始化两个变量 i 和 j 分别指向数组的两端。
设置一个循环,每次循环从两头开始分别寻找第一个比基准数大和第一个比基数小的元素,如果找到这样的一对元素,就交换,如果只找到一个或者没有就不交换
循环执行步骤 2,直到 i 和 j 相遇时停止,最后将基准数与 i 和 j 相遇的位置交换
// 交换元素
swap(nums, i, j) {
let tmp = nums[i];
nums[i] = nums[j];
nums[j] = tmp;
}
// 哨兵划分
partition(nums, left, right) {
// 以 nums[left] 作为基准数
let i = left,
j = right;
while (i < j) {
while (i < j && nums[j] >= nums[left]) {
// 从右向左找首个小于基准数的元素
j -= 1;
}
while (i < j && nums[i] <= nums[left]) {
// 从左向右找首个大于基准数的元素
i += 1;
}
// 元素交换
this.swap(nums, i, j); // 交换这两个元素
}
// 将基准数交换至两子数组的分界线
this.swap(nums, i, left);
// 返回基准数的索引
return i;
}
说明: 哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足左子数组任意元素 ≤ 基准数 ≤ 右子数组任意元素
。所以,现在排序就变成对这两个数组进行排序了
==> 流程 <==
首先,对原数组执行一次哨兵划分,得到未排序的左子数组和右子数组。
然后,对左子数组和右子数组分别递归执行哨兵划分,在划分时如果碰到偶数个元素,遵循大于或者等于基准数的放右侧,小于的则放左侧
持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序。
quickSort(nums, left, right) {
// 子数组长度为 1 时终止递归
if (left >= right) {
return;
}
// 哨兵划分
const pivot = this.partition(nums, left, right);
// 递归左子数组、右子数组
this.quickSort(nums, left, pivot - 1);
this.quickSort(nums, pivot + 1, right);
}
==> 算法特点 <==
满足特性: 自适应排序、原地排序、非稳定排序
时间复杂度:
O(𝑛 log 𝑛)空间复杂度:
𝑂(𝑛)
注意: 在最差情况下,每轮哨兵划分操作都将长度为 𝑛 的数组划分为长度为 0 和 𝑛−1 的两个子数组,此时递归层数达到 𝑛 层,每层中的循环数为 𝑛 ,总体使用 𝑂(𝑛²) 时间。
==> 为何快 <==
最差情况概率低:
一般情况下,快速排序能在 𝑂(𝑛 log 𝑛) 的时间复杂度下运行。
缓存使用效率高:
在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。
复杂度常数系数低:
快速排序的比较、赋值、交换等操作的总数量最少
==> 基准数的选取 <==
说明: 快速排序在某些输入下的时间效率可能降低,比如一个完全倒叙的数组,在进行哨兵划分的时候右子数组的长度总是0,导致其退化成冒泡排序,通常可以选取数组的首、尾、中点元素的中位数作为基准数,也可以选择其它或者更多的数来提高算法的稳定性,这样出现时间复杂度为𝑂(𝑛²)的概率大大降低
// 选取三个元素的中位数
medianThree(nums, left, mid, right) {
// 此处使用异或运算来简化代码
// 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
if ((nums[left] < nums[mid]) ^ (nums[left] < nums[right])) {
return left;
} else if ((nums[mid] < nums[left]) ^ (nums[mid] < nums[right])) {
return mid;
} else {
return right;
}
}
// 哨兵划分(三数取中值)
partition(nums, left, right) {
// 选取三个候选元素的中位数
let med = this.medianThree(
nums,
left,
Math.floor((left + right) / 2),
right
);
// 将中位数交换至数组最左端
this.swap(nums, left, med);
// 以 nums[left] 作为基准数
let i = left,
j = right;
while (i < j) {
// 从右向左找首个小于基准数的元素
while (i < j && nums[j] >= nums[left]) {
j--;
}
// 从左向右找首个大于基准数的元素
while (i < j && nums[i] <= nums[left]) {
i++;
}
// 交换这两个元素
this.swap(nums, i, j);
}
// 将基准数交换至两子数组的分界线
this.swap(nums, i, left);
// 返回基准数的索引
return i;
}
==> 尾递归优化 <==
说明: 在某些输入下,快速排序可能占用空间较多。比如完全倒序的数组,可能就会占用 𝑂(𝑛) 大小的栈帧空间。 为了防止栈帧空间的累积,我们可以在每轮哨兵排序完成后,比较两个子数组的长度,仅对较短的子数组进行递归。由于较短子数组的长度不会超过 𝑛/2 ,因此这种方法能确保递归深度不超过 log 𝑛 ,从而将最差空间复杂度优化至 𝑂(log 𝑛)
quickSort(nums, left, right) {
// 子数组长度为 1 时终止
while (left < right) {
// 哨兵划分操作
let pivot = this.partition(nums, left, right);
// 对两个子数组中较短的那个执行快排
if (pivot - left < right - pivot) {
// 递归排序左子数组
this.quickSort(nums, left, pivot - 1);
// 剩余未排序区间为 [pivot + 1, right]
left = pivot + 1;
} else {
// 递归排序右子数组
this.quickSort(nums, pivot + 1, right);
// 剩余未排序区间为 [left, pivot - 1]
right = pivot - 1;
}
}
}
(6)归并排序
说明: 它分为划分和合并两个阶段
划分阶段:
通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题。
合并阶段:
当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束。
==> 流程 <==
计算数组中点 mid ,递归划分左子数组
[left, mid]
和右子数组[mid + 1, right]
递归执行步骤 1. ,直至子数组区间长度为 1 时,终止递归划分。
从底至顶地先将左子数组合并,合并完毕之后在去合并右子数组,最后将两部分合并成一个有序数组
// 合并左子数组和右子数组
function merge(nums, left, mid, right) {
// 左子数组区间 [left, mid], 右子数组区间 [mid+1, right]
// 创建一个临时数组 tmp ,用于存放合并后的结果
const tmp = new Array(right - left + 1);
// 初始化左子数组和右子数组的起始索引
let i = left,
j = mid + 1,
k = 0;
// 当左右子数组都还有元素时,比较并将较小的元素复制到临时数组中
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
tmp[k++] = nums[i++];
} else {
tmp[k++] = nums[j++];
}
}
// 将左子数组和右子数组的剩余元素复制到临时数组中
while (i <= mid) {
tmp[k++] = nums[i++];
}
while (j <= right) {
tmp[k++] = nums[j++];
}
// 将临时数组 tmp 中的元素复制回原数组 nums 的对应区间
for (k = 0; k < tmp.length; k++) {
nums[left + k] = tmp[k];
}
}
/* 归并排序 */
function mergeSort(nums, left, right) {
// 终止条件
if (left >= right) {
// 当子数组长度为 1 时终止递归
return;
}
// 划分阶段
// 计算中点
let mid = Math.floor((left + right) / 2);
// 递归左子数组
mergeSort(nums, left, mid);
// 递归右子数组
mergeSort(nums, mid + 1, right);
// 合并阶段
merge(nums, left, mid, right);
}
==> 算法特点 <==
满足特性: 非自适应排序、非原地排序、稳定排序
时间复杂度:
O(𝑛 log 𝑛)空间复杂度:
𝑂(𝑛)
(7)堆排序
说明: 这是一种基于堆数据结构实现的算法,假设数组长度为n
==> 流程 <==
输入数组并建立大顶堆。完成后,最大元素位于堆顶。
将堆顶元素与堆底元素交换。完成交换后,堆的长度减 1 ,已排序元素数量加 1 。
从堆顶元素开始,从顶到底执行堆化操作。完成堆化后,堆的性质得到修复。
循环执行2 和 3步。循环 𝑛 − 1 轮后,即可完成数组排序。
// 假设堆的长度为 n ,从节点 i 开始,从顶至底堆化
function siftDown(nums, n, i) {
while (true) {
// 判断节点 i, l, r 中值最大的节点,记为 ma
let l = 2 * i + 1;
let r = 2 * i + 2;
let 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;
}
// 交换两节点
[nums[i], nums[ma]] = [nums[ma], nums[i]];
// 循环向下堆化
i = ma;
}
}
// 堆排序
function heapSort(nums) {
// 建堆操作:堆化除叶节点以外的其他所有节点
for (let i = Math.floor(nums.length / 2) - 1; i >= 0; i--) {
siftDown(nums, nums.length, i);
}
// 从堆中提取最大元素,循环 n-1 轮
for (let i = nums.length - 1; i > 0; i--) {
// 交换根节点与最右叶节点(即交换首元素与尾元素)
[nums[0], nums[i]] = [nums[i], nums[0]];
// 以根节点为起点,从顶至底进行堆化
siftDown(nums, i, 0);
}
}
==> 算法特点 <==
满足特性: 非自适应排序、原地排序、非稳定排序
时间复杂度:
O(𝑛 log 𝑛)空间复杂度:
𝑂(1)
(8)桶排序
说明: 通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中;然后,在每个桶内部分别执行排序;最终按照桶的顺序将所有数据合并,假设数组的长度为n
==> 流程 <==
- 初始化 𝑘 个桶,将 𝑛 个元素分配到 𝑘 个桶中。
- 对每个桶分别执行排序
- 按照桶的从小到大的顺序,合并结果。
function bucketSort(nums) {
// 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
const k = nums.length / 2;
const buckets = [];
for (let i = 0; i < k; i++) {
buckets.push([]);
}
// 1. 将数组元素分配到各个桶中
for (const num of nums) {
// 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
const i = Math.floor(num * k);
// 将 num 添加进桶 i
buckets[i].push(num);
}
// 2. 对各个桶执行排序
for (const bucket of buckets) {
// 使用内置排序函数,也可以替换成其他排序算法
bucket.sort((a, b) => a - b);
}
// 3. 遍历桶合并结果
let i = 0;
for (const bucket of buckets) {
for (const num of bucket) {
nums[i++] = num;
}
}
}
==> 算法特点 <==
满足特性: 自适应排序、原地排序
时间复杂度:
O(𝑛 + k)空间复杂度:
O(𝑛 + k)
(9)计数排序
说明: 通过统计元素数量来实现排序,通常应用于整数数组
==> 简单实现 <==
举例: 给定一个长度为 𝑛 的数组 nums,其中的元素都是非负整数,
步骤:
遍历数组,找出数组中的最大数字,记为 𝑚 ,然后创建一个长度为 𝑚 + 1 的辅助数组 counter 。
借助 counter 统计 nums 中各数字的出现次数,其中 counter[num] 对应数字 num 的出现次数。统计方法 很简单,只需遍历 nums(设当前数字为 num),每轮将 counter[num] 增加 1 即可。
由于 counter 的各个索引天然有序,因此相当于所有数字已经被排序好了。接下来,我们遍历 counter ,根据各数字的出现次数,将它们按从小到大的顺序填入 nums 即可。
function countingSortNaive(nums) {
// 1. 统计数组最大元素 m
let m = 0;
for (const num of nums) {
m = Math.max(m, num);
}
// 2. 统计各数字的出现次数
// counter[num] 代表 num 的出现次数
const counter = new Array(m + 1).fill(0);
for (const num of nums) {
counter[num]++;
}
// 3. 遍历 counter ,将各元素填入原数组 nums
let i = 0;
for (let num = 0; num < m + 1; num++) {
for (let j = 0; j < counter[num]; j++, i++) {
nums[i] = num;
}
}
}
==> 完整实现 <==
说明: 如果输入的数据是一个商品对象,根据商品的价格对商品进行排序的时候,上面的算法就失效了,它只能给出价格排序的结果,如果非要实现呢,需要先计算 counter 的前缀和,也就是索引 i 处的前缀和 prefix[i] 等于数组前 i 个元素之和,前缀和具有明确的意义,prefix[num] - 1 代表元素 num 在结果数组 res 中最后一次出现的索引。这个信息非常关键,因为它告诉我们各个元素应该出现在结果数组的哪个位置。接下来,我们倒序遍历原数组 nums 的 每个元素 num ,在每轮迭代中执行两步。 首先
将 num 填入数组 res 的索引 prefix[num] - 1 处。最后
令前缀和 prefix[num] 减小 1 ,从而得到下次放置 num 的索引。 遍历完成后,数组 res 中就是排序好的结果,最后使用 res 覆盖原数组 nums 即可
function countingSort(nums) {
// 1. 统计数组最大元素 m
let m = 0;
for (const num of nums) {
m = Math.max(m, num);
}
// 2. 统计各数字的出现次数
// counter[num] 代表 num 的出现次数
const counter = new Array(m + 1).fill(0);
for (const num of nums) {
counter[num]++;
}
// 3. 求 counter 的前缀和,将“出现次数”转换为“尾索引”
// 即 counter[num]-1 是 num 在 res 中最后一次出现的索引
for (let i = 0; i < m; i++) {
counter[i + 1] += counter[i];
}
// 4. 倒序遍历 nums ,将各元素填入结果数组 res
// 初始化数组 res 用于记录结果
const n = nums.length;
const res = new Array(n);
for (let i = n - 1; i >= 0; i--) {
const num = nums[i];
// 将 num 放置到对应索引处
res[counter[num] - 1] = num;
// 令前缀和自减 1 ,得到下次放置 num 的索引
counter[num]--;
}
// 使用结果数组 res 覆盖原数组 nums
for (let i = 0; i < n; i++) {
nums[i] = res[i];
}
}
==> 算法特点 <==
满足特性: 稳定排序、非原地排序
时间复杂度:
O(𝑛 + m)空间复杂度:
O(𝑛 + m)
注意: 只适用于非负整数且数据量大但数据范围较小的情况
(10)基数排序
说明: 在计数排序的基础上,利用数字各位之间的递进关系,依次对每一位进行排序,从而得到最终的排序结果
==> 流程 <==
举例: 以学号数据为例,假设数字的最低位是第 1 位,最高位是第 8 位
步骤:
初始化位数 𝑘 = 1
对学号的第 𝑘 位执行“计数排序”。完成后,数据会根据第 𝑘 位从小到大排序
将 𝑘 增加 1 ,然后返回步骤 2. 继续迭代,直到所有位都排序完成后结束
说明: 对于一个 𝑑 进制的数字 𝑥 ,要获取其第 𝑘 位 𝑥𝑘 ,可以使用下面的计算公式,其中 ⌊𝑎⌋ 表示对浮点数 𝑎 向下取整,而 mod 𝑑 表示对 𝑑 取余。对于学号数据,𝑑 = 10 且 𝑘 ∈ [1, 8] 。 此外,我们需要小幅改动计数排序代码,使之可以根据数字的第 𝑘 位进行排序。
// 获取元素 num 的第 k 位,其中 exp = 10^(k-1)
function digit(num, exp) {
// 传入 exp 而非 k 可以避免在此重复执行昂贵的次方计算
return Math.floor(num / exp) % 10;
}
// 计数排序(根据 nums 第 k 位排序)
function countingSortDigit(nums, exp) {
// 十进制的位范围为 0~9 ,因此需要长度为 10 的桶
const counter = new Array(10).fill(0);
const n = nums.length;
// 统计 0~9 各数字的出现次数
for (let i = 0; i < n; i++) {
// 获取 nums[i] 第 k 位,记为 d
const d = digit(nums[i], exp);
// 统计数字 d 的出现次数
counter[d]++;
}
// 求前缀和,将“出现个数”转换为“数组索引”
for (let i = 1; i < 10; i++) {
counter[i] += counter[i - 1];
}
// 倒序遍历,根据桶内统计结果,将各元素填入 res
const res = new Array(n).fill(0);
for (let i = n - 1; i >= 0; i--) {
const d = digit(nums[i], exp);
// 获取 d 在数组中的索引 j
const j = counter[d] - 1;
// 将当前元素填入索引 j
res[j] = nums[i];
// 将 d 的数量减 1
counter[d]--;
}
// 使用结果覆盖原数组 nums
for (let i = 0; i < n; i++) {
nums[i] = res[i];
}
}
/* 基数排序 */
function radixSort(nums) {
// 获取数组的最大元素,用于判断最大位数
let m = Number.MIN_VALUE;
for (const num of nums) {
if (num > m) {
m = num;
}
}
// 按照从低位到高位的顺序遍历
for (let exp = 1; exp <= m; exp *= 10) {
// 对数组元素的第 k 位执行计数排序
// k = 1 -> exp = 1
// k = 2 -> exp = 10
// 即 exp = 10^(k-1)
countingSortDigit(nums, exp);
}
}
==> 算法特点 <==
满足特性: 稳定排序、非原地排序
时间复杂度:
O(𝑛k)空间复杂度:
O(𝑛 + d)
注意: 基数排序适用于数值范围较大的情况,但前提是数据必须可以表示为固定位数的格式,且位数不能过大
十五、分治
(1)分治算法
说明: 通常基于递归来实现,其包括分
和治
两个步骤
分:
递归地将原问题分解为两个或多个子问题,直至到达最小子问题时终止。
治:
从已知解的最小子问题开始,从底至顶地将子问题的解进行合并,从而构建出原问题的解。
==> 判断分治问题依据 <==
问题可以被分解:
原问题可以被分解成规模更小、类似的子问题,以及能够以相同方式递归地进行划分。
子问题是独立的:
子问题之间是没有重叠的,互相没有依赖,可以被独立解决。
子问题的解可以被合并:
原问题的解通过合并子问题的解得来。
==> 使用分治提升效率 <==
举例: 以冒泡排序为例
说明: 虽然这两个排序的时间复杂度都是平方阶,但是计算这两个不等式,可以看到当n > 4
的时候,右边的操作次数就开始比左边的少了,如果
把子数组不断地再从中点划分为两个子数组,直至子数组只剩一个元素时停止划分,这就是并归排序,如果
多设置几个划分点,将原数组平均划分为 𝑘 个子数组,那这就和桶排序类似了
注意: 由于分治生成的子问题是相互独立的,那么通常可以并行解决,在多核或多处理器的环境中,由于系统可以同时处理多个子问题,更加充分地利用计算资源,从而显著减少总体的运行时间,也就是有利于操作系统的并行优化
(2)分治搜索策略
说明: 搜索算法分为下面这两类,实际上,时间复杂度为 𝑂(log 𝑛) 的搜索算法通常都是基于分治策略实现的,比如二分查找,分治能够提升搜索效率,本质上是因为暴力搜索每轮只能排除一个选项,而分治搜索每轮可以排除一半选项。
暴力搜索:
它通过遍历数据结构实现,时间复杂度为 𝑂(𝑛)
自适应搜索:
它利用特有的数据组织形式或先验信息,可达到 𝑂(log 𝑛) 甚至 𝑂(1) 的时间复杂度。
举例: 给定一个长度为 𝑛 的有序数组 nums ,数组中所有元素都是唯一的,请查找元素 target,使用递归来实现
思路: 将搜索区间 [𝑖, 𝑗] 对应的子问题记为 𝑓(𝑖, 𝑗)。将原问题 𝑓(0, 𝑛 − 1) 为起始点,通过以下步骤进行二分查找
- 计算搜索区间 [𝑖, 𝑗] 的中点 𝑚 ,根据它排除一半搜索区间。
- 递归求解规模减小一半的子问题,可能为 𝑓(𝑖, 𝑚 − 1) 或 𝑓(𝑚 + 1, 𝑗) 。
- 循环第 1和 2步,直至找到 target 或区间为空时返回。
function dfs(nums, target, i, j) {
// 若区间为空,代表无目标元素,则返回 -1
if (i > j) {
return -1;
}
// 计算中点索引 m
const m = i + ((j - i) >> 1);
if (nums[m] < target) {
// 递归子问题 f(m+1, j)
return dfs(nums, target, m + 1, j);
} else if (nums[m] > target) {
// 递归子问题 f(i, m-1)
return dfs(nums, target, i, m - 1);
} else {
// 找到目标元素,返回其索引
return m;
}
}
// 二分查找
function binarySearch(nums, target) {
const n = nums.length;
// 求解问题 f(0, n-1)
return dfs(nums, target, 0, n - 1);
}
(3)构建二叉树问题
举例: 给定一个二叉树的前序遍历 preorder 和中序遍历 inorder ,请从中构建二叉树,返回二叉树的根节点。假设二叉树中没有值重复的节点。
==> 是否可以分治解决 <==
问题可以被分解:
原问题可以划分成构建左子树、构建右子树、初始化根节点这三步。而对于每个子树仍然可以复用以上划分方法,将其划分为更小的子树,直至达到空子树时终止
子问题是独立的:
左子树和右子树是相互独立的,它们之间没有交集。在构建左子树时,我们只需要关注中序遍历和前序遍历中与左子树对应的部分。右子树同理。
子问题的解可以合并:
一旦得到了左子树和右子树,我们就可以将它们链接到根节点上,得到原问题的解。
==> 划分子树 <==
步骤:
前序遍历的首元素 3 是根节点的值
查找根节点 3 在 inorder 中的索引,利用该索引可将 inorder 划分为
9 | 3 | 1 2 7
根据 inorder 划分结果,易得左子树和右子树的节点数量分别为 1 和 3 ,从而可将 preorder 划分为
3 | 9 | 2 1 7
==> 描述子树 <==
说明: 已经得到根节点、左子树、右子树在 preorder 和 inorder 中的索引区间。而为了描述这些索引区间,我们需要借助几个变量
- 将当前树的根节点在 preorder 中的索引记为 𝑖
- 将当前树的根节点在 inorder 中的索引记为 𝑚
- 将当前树在 inorder 中的索引区间记为 [𝑙, 𝑟]
- | 根节点在 preorder 中的索引 | 子树在 inorder 中的索引区间 |
---|---|---|
当前树 | i | [𝑙, 𝑟] |
左子树 | 𝑖 + 1 | [𝑙, 𝑚 − 1] |
右子树 | 𝑖 + 1 + (𝑚 − 𝑙) | 𝑚 + 1, 𝑟] |
注意: 右子树根节点索引中的 (𝑚 − 𝑙) 的含义是左子树的节点数量
==> 实现 <==
function dfs(preorder, inorderMap, i, l, r) {
// 子树区间为空时终止
if (r - l < 0) return null;
// 初始化根节点
const root = new TreeNode(preorder[i]);
// 查询 m ,从而划分左右子树
const m = inorderMap.get(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;
}
// 构建二叉树
function buildTree(preorder, inorder) {
// 初始化哈希表,存储 inorder 元素到索引的映射
let inorderMap = new Map();
for (let i = 0; i < inorder.length; i++) {
inorderMap.set(inorder[i], i);
}
const root = dfs(preorder, inorderMap, 0, 0, inorder.length - 1);
return root;
}
==> 特点 <==
时间复杂度:
O(𝑛)空间复杂度:
O(𝑛)
(4)汉诺塔问题
问题: 给定三根柱子,记为 A、B 和 C 。起始状态下,柱子 A 上套着 𝑛 个圆盘,它们从上到下按照从小到大的顺序排列。我们的任务是要把这 𝑛 个圆盘移到柱子 C 上,并保持它们的原有顺序不变。在移动圆盘的过程中,需要遵守以下规则:
- 圆盘只能从一个柱子顶部拿出,从另一个柱子顶部放入。
- 每次只能移动一个圆盘。
- 小圆盘必须时刻位于大圆盘之上
说明: 将规模为 𝑖 的汉诺塔问题记做 𝑓(𝑖),比如𝑓(3) 代表将 3 个圆盘从 A 移动至 C 的汉诺塔问题
==> 基本情况 <==
情况1: 对于f(1),即当只有一个圆盘时,我们将它直接从 A 移动至 C 即可
情况2: 对于𝑓(2) ,即当有两个圆盘时,由于要时刻满足小圆盘在大圆盘之上,因此需要借助 B 来完成移动,也就是将两个圆盘借助 B 从 A 移至 C
==> 问题f(3) <==
说明: 当有三个圆盘时,可以将将 A 顶部的两个圆盘看做一个整体 D,这样问题就变成将 D 从 A 移动到 B,待底部盘子到 C 之后,将 D 移动到 C,也就是类似的两个f(2)问题和一个f(1)问题,也就是可以将原问题 𝑓(𝑛) 划分为两个子问题 𝑓(𝑛 − 1) 和一个子问题 𝑓(1)
==> 实现 <==
/* 移动一个圆盘 */
function move(src, tar) {
// 从 src 顶部拿出一个圆盘
const pan = src.pop();
// 将圆盘放入 tar 顶部
tar.push(pan);
}
/* 求解汉诺塔:问题 f(i) */
function dfs(i, src, buf, tar) {
// 若 src 只剩下一个圆盘,则直接将其移到 tar
if (i === 1) {
move(src, tar);
return;
}
// 子问题 f(i-1) :将 src 顶部 i-1 个圆盘借助 tar 移到 buf
dfs(i - 1, src, tar, buf);
// 子问题 f(1) :将 src 剩余一个圆盘移到 tar
move(src, tar);
// 子问题 f(i-1) :将 buf 顶部 i-1 个圆盘借助 src 移到 tar
dfs(i - 1, buf, src, tar);
}
/* 求解汉诺塔 */
function solveHanota(A, B, C) {
const n = A.length;
// 将 A 顶部 n 个圆盘借助 B 移到 C
dfs(n, A, B, C);
}
==> 特点 <==
时间复杂度:
O(2ⁿ)空间复杂度:
O(𝑛)
十六、回溯
(1)回溯算法
说明: 回溯算法是一种通过穷举来解决问题的方法,它的核心思想是从一个初始状态出发,暴力搜索所有可能的解决方案,当遇到正确的解则将其记录,直到找到解或者尝试了所有可能的选择都无法找到解为止。通常采用深度优先搜索
来遍历解空间,其实前序、中序和后序遍历都属于深度优先搜索。
举例: 给定一个二叉树,搜索并记录所有值为 7 的节点,然后返回节点列表。
理解: 可以前序遍历这颗树,并判断当前节点的值是否为 7 ,若是则将该节点的值加入到结果列表 res 之中
function preOrder(root, res) {
if (root === null) {
return;
}
if (root.val === 7) {
// 记录解
res.push(root);
}
preOrder(root.left, res);
preOrder(root.right, res);
}
==> 尝试与回退 <==
说明: 之所以称之为回溯算法,是因为该算法在搜索解空间时会采用尝试与回退的策略。当算法在搜索过程中遇到某个状态无法继续前进或无法得到满足条件的解时,它会撤销上一步的选择,退回到之前的状态,并尝试其他可能的选择。对于上面的例子,访问每个节点都代表一次尝试,而越过叶节点或返回父节点的 return 则表示回退,值得注意的是,回退并不仅仅包括函数返回。
举例: 在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径。
理解: 需要借助一个列表 path 记录访问过的节点路径。当访问到值为 7 的节点时,则复制 path 并添加进结果列表 res 。遍历完成后,res 中保存的就是所有的解,这里每次尝试
,都通过将当前节点添加进 path 来记录路径;而每次回退
,就是将该节点从 path 中弹出,以恢复本次尝试之前的状态。也就是这两个操作是互为逆向的
function preOrder(root, path, res) {
if (root === null) {
return;
}
// 尝试
path.push(root);
if (root.val === 7) {
// 记录解
res.push([...path]);
}
preOrder(root.left, path, res);
preOrder(root.right, path, res);
// 回退
path.pop();
}
==> 剪枝 <==
说明: 复杂的回溯问题通常包含一个或多个约束条件,约束条件通常可用于剪枝。
举例: 在二叉树中搜索所有值为 7 的节点,请返回根节点到这些节点的路径,并要求路径中不包含值为 3 的节点。
理解: 也就是在上面的基础上,若遇到值为 3 的节点,则提前返回,停止继续搜索,也就是剪掉不满足约束条件的搜索分支,从而避免许多无意义的尝试,以此提高搜索效率
function preOrder(root, path, res) {
// 剪枝
if (root === null || root.val === 3) {
return;
}
// 尝试
path.push(root);
if (root.val === 7) {
// 记录解
res.push([...path]);
}
preOrder(root.left, path, res);
preOrder(root.right, path, res);
// 回退
path.pop();
}
==> 回溯的主体代码 <==
// state:节点遍历路径,
// choices:当前节点的左子节点和右子节点,
// res:路径列表。
function backtrack(state, choices, res) {
// 判断是否为解
if (isSolution(state)) {
// 记录解
recordSolution(state, res);
// 停止继续搜索
return;
}
// 遍历所有选择
for (let choice of choices) {
// 剪枝:判断选择是否合法
if (isValid(state, choice)) {
// 尝试:做出选择,更新状态
makeChoice(state, choice);
// 进行下一轮选择
backtrack(state, choices, res);
// 回退:撤销选择,恢复到之前的状态
undoChoice(state, choice);
}
}
}
// 判断当前状态是否为解
function isSolution(state) {
return state && state[state.length - 1]?.val === 7;
}
// 记录解
function recordSolution(state, res) {
res.push([...state]);
}
// 判断在当前状态下,该选择是否合法
function isValid(state, choice) {
return choice !== null && choice.val !== 3;
}
// 更新状态
function makeChoice(state, choice) {
state.push(choice);
}
// 恢复状态
function undoChoice(state) {
state.pop();
}
==> 常用术语 <==
名词 | 定义 | 举例 |
---|---|---|
解 | 解是满足问题特定条件的答案,可能有一个或多个 | 根节点到节点 7 的满足约束条件的所有路径 |
约束条件 | 约束条件是问题中限制解的可行性的条件,通常用于剪枝 | 路径中不包含节点 3 |
状态 | 状态表示问题在某一时刻的情况,包括已经做出的选择 | 当前已访问的节点路径 |
尝试 | 尝试是根据可用选择来探索解空间的过程,包括做出选择,更新状态,检查是否为解 | 递归访问左(右)子节点,将节点添加进 path ,判断节点的值是否为 7 |
回退 | 回退指遇到不满足约束条件的状态时,撤销前面做出的选择,回到上一个状态 | 当越过叶节点、结束节点访问、遇到值为 3 的节点时终止搜索,函数返回 |
剪枝 | 剪枝是根据问题特性和约束条件避免无意义的搜索路径的方法,可提高搜索效率 | 当遇到值为 3 的节点时,则终止继续搜索 |
==> 局限性 <==
说明: 在处理大规模或者复杂问题时,回溯算法的运行效率可能难以接受,此时可以通过枝剪或者在搜索过程中引入一些策略或者估计值,从而优先搜索最有可能产生有效解的路径的方法来提升运行效率
(2)全排列问题
说明: 在给定一个集合的情况下,找出这个集合中元素的所有可能的排列
==> 无相等元素的情况 <==
举例: 输入一个整数数组,数组中不包含重复元素,返回所有可能的排列
理解: 从回溯代码的角度看,候选集合 choices 是输入数组中的所有元素,状态 state 是直至目前已被选择的元素。由于每个元素只允许被选择一次,因此 state 中的所有元素都应该是唯一的,因此可以将搜索过程展开成一个递归树,树中的每个节点代表当前状态 state 。从根节点开始,经过三轮选择后到达叶节点,每个叶节点都对应一个排列。
剪枝: 为了实现每个元素只被选择一次,我们考虑引入一个布尔型数组 selected ,其中 selected[i] 表示 choices[i] 是否已被选择,并基于它实现以下剪枝操作
在做出选择 choice[i] 后,我们就将 selected[i] 赋值为 True ,代表它已被选择。
遍历选择列表 choices 时,跳过所有已被选择过的节点,即剪枝。
function backtrack(state, choices, selected, res) {
// 当状态长度等于元素数量时,记录解
if (state.length === choices.length) {
res.push([...state]);
return;
}
// 遍历所有选择
choices.forEach((choice, i) => {
// 剪枝:不允许重复选择元素
if (!selected[i]) {
// 尝试:做出选择,更新状态
selected[i] = true;
state.push(choice);
// 进行下一轮选择
backtrack(state, choices, selected, res);
// 回退:撤销选择,恢复到之前的状态
selected[i] = false;
state.pop();
}
});
}
function permutationsI(nums) {
const res = [];
backtrack([], nums, Array(nums.length).fill(false), res);
return res;
}
==> 有相等元素的情况 <==
举例: 输入一个整数数组,数组中可能包含重复元素,返回所有不重复的排列,假设输入数组为 [1, 1, 2] 。为了方便区分两个重复元素 1 ,我们将第二个 1 记为 1̂
剪枝: 在第一轮中,选择 1 或选择 1̂是等价的,在这两个选择之下生成的所有排列都是重复的。因此应该把 1̂剪枝掉。 同理,在第一轮选择 2 之后,第二轮选择中的 1 和 1̂也会产生重复分支,因此也应将第二轮的 1̂剪枝,也就是在某一轮选择中,保证多个相等的元素仅被选择一次
function backtrack(state, choices, selected, res) {
// 当状态长度等于元素数量时,记录解
if (state.length === choices.length) {
res.push([...state]);
return;
}
// 遍历所有选择
const duplicated = new Set();
choices.forEach((choice, i) => {
// 剪枝:不允许重复选择元素 且 不允许重复选择相等元素
if (!selected[i] && !duplicated.has(choice)) {
// 尝试:做出选择,更新状态
// 记录选择过的元素值
duplicated.add(choice);
selected[i] = true;
state.push(choice);
// 进行下一轮选择
backtrack(state, choices, selected, res);
// 回退:撤销选择,恢复到之前的状态
selected[i] = false;
state.pop();
}
});
}
function permutationsII(nums) {
const res = [];
backtrack([], nums, Array(nums.length).fill(false), res);
return res;
}
(3)子集和问题
==> 无相等元素的情况 <==
举例: 给定一个正整数数组 nums 和一个目标正整数 target ,请找出所有可能的组合,使得组合中的元素和等于 target。给定数组无重复元素,每个元素可以被选取多次。请以列表形式返回这些组合,列表中不应包含重复组合。
注意:
- 输入集合中的元素可以被无限次重复选取。
- 子集是不区分元素顺序的
参考全排列解法: 可以把子集的生成过程想象成一系列选择的结果,并在选择过程中实时更新元素和,当元素和等于 target 时,就将子集记录至结果列表,由于满足上面注意的两点,因此会存在重复的元素,就需要去重,但是不能直接对结果去重,原因是:
当数组元素较多,尤其是当 target 较大时,搜索过程会产生大量的重复子集。
比较数组的异同非常耗时,需要先排序数组,再比较数组中每个元素的异同。
剪枝: 给定输入数组 [𝑥1 , 𝑥2 , … , 𝑥𝑛] ,设搜索过程中的选择序列为 [𝑥𝑖1 , 𝑥𝑖2 , … , 𝑥𝑖𝑚 ] ,则该选择序列需要满足 𝑖1 ≤ 𝑖2 ≤ ⋯ ≤ 𝑖𝑚 ,不满足该条件的选择序列都会造成重复,应当剪枝。
实现: 初始化变量 start ,用于指示遍历起点。当做出选择 𝑥𝑖 后,设定下一轮从索引 𝑖 开始遍历。这样做就可以让选择序列满足 𝑖1 ≤ 𝑖2 ≤ ⋯ ≤ 𝑖𝑚 ,从而保证子集唯一,此外还可以做以下优化
- 在开启搜索前,先将数组 nums 排序。在遍历所有选择时,当子集和超过 target 时直接结束循环,因为 后边的元素更大,其子集和都一定会超过 target
- 省去元素和变量 total ,通过在 target 上执行减法来统计元素和,当 target 等于 0 时记录解。
function backtrack(state, target, choices, start, res) {
// 子集和等于 target 时,记录解
if (target === 0) {
res.push([...state]);
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
for (let i = start; i < choices.length; i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (target - choices[i] < 0) {
break;
}
// 尝试:做出选择,更新 target, start
state.push(choices[i]);
// 进行下一轮选择
backtrack(state, target - choices[i], choices, i, res);
// 回退:撤销选择,恢复到之前的状态
state.pop();
}
}
// 求解子集和
function subsetSumI(nums, target) {
// 状态(子集)
const state = [];
// 对 nums 进行排序
nums.sort((a, b) => a - b);
// 遍历起始点
const start = 0;
// 结果列表(子集列表)
const res = [];
backtrack(state, target, nums, start, res);
return res;
}
==> 有相等元素的情况 <==
举例: 在上面的条件上,给定数组可能包含重复元素,每个元素只可被选择一次,其它不变
说明: 由于存在重复的元素,那么相等的元素在某一轮里面就会被重复的选取,这样得到的结果也就存在重复的了
剪枝: 由于数组是已排序的, 因此相等元素都是相邻的。这意味着在某轮选择中,若当前元素与其左边元素相等,则说明它已经被选择过,因此直接跳过当前元素,同时每个元素只能被选择一次,所以同样可以利用变量 start 来满足该约束:当做出选择 𝑥𝑖 后,设定下一轮从索引 𝑖 + 1 开始向后遍历。这样即能去除重复子集,也能避免重复选择元素。
function backtrack(state, target, choices, start, res) {
// 子集和等于 target 时,记录解
if (target === 0) {
res.push([...state]);
return;
}
// 遍历所有选择
// 剪枝二:从 start 开始遍历,避免生成重复子集
// 剪枝三:从 start 开始遍历,避免重复选择同一元素
for (let i = start; i < choices.length; i++) {
// 剪枝一:若子集和超过 target ,则直接结束循环
// 这是因为数组已排序,后边元素更大,子集和一定超过 target
if (target - choices[i] < 0) {
break;
}
// 剪枝四:如果该元素与左边元素相等,说明该搜索分支重复,直接跳过
if (i > start && choices[i] === choices[i - 1]) {
continue;
}
// 尝试:做出选择,更新 target, start
state.push(choices[i]);
// 进行下一轮选择
backtrack(state, target - choices[i], choices, i + 1, res);
// 回退:撤销选择,恢复到之前的状态
state.pop();
}
}
function subsetSumII(nums, target) {
// 状态(子集)
const state = [];
// 对 nums 进行排序
nums.sort((a, b) => a - b);
// 遍历起始点
const start = 0;
// 结果列表(子集列表)
const res = [];
backtrack(state, target, nums, start, res);
return res;
}
(4)N 皇后问题
举例: 根据国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。给定 𝑛 个皇后和一个 𝑛 × 𝑛 大小的棋盘,寻找使得所有皇后之间无法相互攻击的摆放方案
约束条件: 多个皇后不能在同一行、同一列、同一对角线上
==> 逐行放置策略 <==
说明: 皇后的数量和棋盘的行数都为 𝑛 ,因此我们容易得到一个推论:棋盘每行都允许且只允许放置一个皇后。也就是说,我们可以采取逐行放置策略:从第一行开始,在每行放置一个皇后,直至最后一行结束,本质上看,逐行放置策略起到了剪枝的作用,它避免了同一行出现多个皇后的所有搜索分支。
==> 列与对角线剪枝 <==
说明: 为了满足列约束,我们可以利用一个长度为 𝑛 的布尔型数组 cols 记录每一列是否有皇后。在每次决定放置前,我们通过 cols 将已有皇后的列进行剪枝,并在回溯中动态更新 cols 的状态。 设棋盘中某个格子的行列索引为 (𝑟𝑜𝑤, 𝑐𝑜𝑙) ,选定矩阵中的某条主对角线,我们发现该对角线上所有格子的行索引减列索引都相等,即对角线上所有格子的 𝑟𝑜𝑤 − 𝑐𝑜𝑙 为恒定值。也就是说,如果两个格子满足 𝑟𝑜𝑤1 − 𝑐𝑜𝑙1 = 𝑟𝑜𝑤2 − 𝑐𝑜𝑙2 ,则它们一定处在同一条主对角线上。
==> 代码实现 <==
说明: 𝑛 维方阵中 𝑟𝑜𝑤 − 𝑐𝑜𝑙 的范围是 [−𝑛 + 1, 𝑛 − 1] ,𝑟𝑜𝑤 + 𝑐𝑜𝑙 的范围是 [0, 2𝑛 − 2] ,所以主对角线和次对角线的数量都为 2𝑛 − 1 ,即数组 diag1 和 diag2 的长度都为 2𝑛 − 1 。
function backtrack(row, n, state, res, cols, diags1, diags2) {
// 当放置完所有行时,记录解
if (row === n) {
res.push(state.map((row) => row.slice()));
return;
}
// 遍历所有列
for (let col = 0; col < n; col++) {
// 计算该格子对应的主对角线和副对角线
const diag1 = row - col + n - 1;
const 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 皇后
function nQueens(n) {
// 初始化 n*n 大小的棋盘,其中 'Q' 代表皇后,'#' 代表空位
const state = Array.from({ length: n }, () => Array(n).fill("#"));
// 记录列是否有皇后
const cols = Array(n).fill(false);
// 记录主对角线是否有皇后
const diags1 = Array(2 * n - 1).fill(false);
// 记录副对角线是否有皇后
const diags2 = Array(2 * n - 1).fill(false);
const res = [];
backtrack(0, n, state, res, cols, diags1, diags2);
return res;
}
十七、动态规划
(1)初始动态规划
说明: 它将一个问题分解为一系列更小的子问题,并通过存储子问题的解来避免重复计算,从而大幅提升时间效率。
举例: 给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,请问有多少种方案可以爬到楼顶。
说明: 可以考虑通过回溯来穷举所有可能性。具体来说,将爬楼梯想象为一个多轮选择的过程:从地面出发,每轮选择上 1 阶或 2 阶,每当到达楼梯顶部时就将方案数量加 1 ,当越过楼梯顶部时就将其剪枝。
function backtrack(choices, state, n, res) {
// 当爬到第 n 阶时,方案数量加 1
if (state === n) {
res.set(0, res.get(0) + 1);
}
// 遍历所有选择
for (const choice of choices) {
// 剪枝:不允许越过第 n 阶
if (state + choice > n) {
break;
}
// 尝试:做出选择,更新状态
// 回退
backtrack(choices, state + choice, n, res);
}
}
// 爬楼梯:回溯
function climbingStairsBacktrack(n) {
// 可选择向上爬 1 或 2 阶
const choices = [1, 2];
// 从第 0 阶开始爬
const state = 0;
const res = new Map();
// 使用 res[0] 记录方案数量
res.set(0, 0);
backtrack(choices, state, n, res);
return res.get(0);
}
==> 暴力搜索 <==
思路: 回溯通常不对原问题进行拆解,这里可以从问题分解的角度触发,假设爬到第 𝑖 阶共有 𝑑𝑝[𝑖] 种方案,那么 𝑑𝑝[𝑖] 就是原问题,那么它包括的子问题就有𝑑𝑝[𝑖 − 1], 𝑑𝑝[𝑖 − 2], … , 𝑑𝑝[2], 𝑑𝑝[1]这些,由于每轮只能上 1 阶或 2 阶,因此当我们站在第 𝑖 阶楼梯上时,上一轮只可能站在第 𝑖 − 1 阶或第 𝑖 − 2 阶 上。换句话说,我们只能从第 𝑖 − 1 阶或第 𝑖 − 2 阶前往第 𝑖 阶,也就是说爬到第 𝑖 − 1 阶的方案数加上爬到第 𝑖 − 2 阶的方案数就等于爬到第 𝑖 阶的方案数,也就是原问题的解可以由子问题的解构建得来
说明: 以 𝑑𝑝[𝑛] 为起始点,递归地将一个较大问题拆解为两个较小问题的和,直至到达最小子问题 𝑑𝑝[1] 和 𝑑𝑝[2] 时返回。其中,最小子问题的解是已知的,即 𝑑𝑝[1] = 1、 𝑑𝑝[2] = 2 ,表示爬到第 1、2 阶分别有 1、2 种方案
function dfs(i) {
// 已知 dp[1] 和 dp[2] ,返回之
if (i === 1 || i === 2) {
return i;
}
// dp[i] = dp[i-1] + dp[i-2]
const count = dfs(i - 1) + dfs(i - 2);
return count;
}
function climbingStairsDFS(n) {
return dfs(n);
}
注意: 这个类似斐波那契数列,那么其时间复杂度为O(2ⁿ),如果n很大,那么等待的时间将会很久,导致其时间复杂度为指数阶是由于重叠子问题
导致的,因为绝大部分计算资源都浪费在这些重叠的问题上。
==> 记忆化搜索 <==
说明: 为了提升算法效率,那么所有的重叠子问题都只能被计算一次,因此可以声明一个数组 mem 来记录每个子问题的解,并在搜索过程中将重叠子问题剪枝,其步骤如下,这样所有重叠子问题都只需被计算一次,时间复杂度为𝑂(𝑛)
当首次计算 𝑑𝑝[𝑖] 时,我们将其记录至 mem[i] ,以便之后使用。
当再次需要计算 𝑑𝑝[𝑖] 时,我们便可直接从 mem[i] 中获取结果,从而避免重复计算该子问题。
function dfs(i, mem) {
// 已知 dp[1] 和 dp[2] ,返回之
if (i === 1 || i === 2) {
return i;
}
// 若存在记录 dp[i] ,则直接返回之
if (mem[i] != -1) {
return mem[i];
}
// dp[i] = dp[i-1] + dp[i-2]
const count = dfs(i - 1, mem) + dfs(i - 2, mem);
// 记录 dp[i]
mem[i] = count;
return count;
}
function climbingStairsDFSMem(n) {
// mem[i] 记录爬到第 i 阶的方案总数,-1 代表无记录
const mem = new Array(n + 1).fill(-1);
return dfs(n, mem);
}
==> 动态规划 <==
说明: 动态规划是从最小子问题的解开始,迭代地构建更大子问题的解,直至得到原问题的解,比如上面的例子也可以初始化一个数组 dp 来存储子问题的解,它起到了记忆化搜索中数组 mem 相同的记录作用
function climbingStairsDP(n) {
if (n === 1 || n === 2) {
return n;
}
// 初始化 dp 表,用于存储子问题的解
const dp = new Array(n + 1).fill(-1);
// 初始状态:预设最小子问题的解
dp[1] = 1;
dp[2] = 2;
// 状态转移:从较小子问题逐步求解较大子问题
for (let i = 3; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2];
}
return dp[n];
}
注意: 与回溯算法一样,动态规划也使用状态概念来表示问题求解的某个特定阶段,每个状态都对应一个子问题以及相应的局部最优解
常用术语:
将数组 dp 称为
𝑑𝑝表
,𝑑𝑝[𝑖] 表示状态 𝑖 对应子问题的解将最小子问题对应的状态(即第 1 和 2 阶楼梯)称为
初始状态
将递推公式 𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2] 称为
状态转移方程
==> 空间优化 <==
说明: 由于 𝑑𝑝[𝑖] 只与 𝑑𝑝[𝑖 − 1] 和 𝑑𝑝[𝑖 − 2] 有关,因此我们无须使用一个数组 dp 来存储所有子问题的解,而只需两个变量滚动前进即可,优化后,空间复杂度就变成了O(1)
function climbingStairsDPComp(n) {
if (n === 1 || n === 2) {
return n;
}
let a = 1;
let b = 2;
for (let i = 3; i <= n; i++) {
const tmp = b;
b = a + b;
a = tmp;
}
return b;
}
注意: 在动态规划问题中,当前状态往往仅与前面有限个状态有关,这时我们可以只保留必要的状态,通过降维来节省内存空间。这种空间优化技巧被称为滚动变量
或滚动数组
(2)动态规划问题特性
==> 最优子结构 <==
举例: 给定一个楼梯,你每步可以上 1 阶或者 2 阶,每一阶楼梯上都贴有一个非负整数,表示你在该台阶所需要付出的代价。给定一个非负整数数组 𝑐𝑜𝑠𝑡 ,其中 𝑐𝑜𝑠𝑡[𝑖] 表示在第 𝑖 个台阶需要付出的代价,𝑐𝑜𝑠𝑡[0] 为地面起始点。请计算最少需要付出多少代价才能到达顶部
条件: 第 1、2、3 阶的代价分别为 1、10、1
思路: 设 𝑑𝑝[𝑖] 为爬到第 𝑖 阶累计付出的代价,由于第 𝑖 阶只可能从 𝑖 − 1 阶或 𝑖 − 2 阶走来,因此 𝑑𝑝[𝑖] 只可能等于 𝑑𝑝[𝑖−1]+𝑐𝑜𝑠𝑡[𝑖] 或 𝑑𝑝[𝑖−2]+𝑐𝑜𝑠𝑡[𝑖] 。为了尽可能减少代价,我们应该选择两者中较小的那一个,这就将原问题的最优解转变成子问题的最优解了,
function minCostClimbingStairsDP(cost) {
const n = cost.length - 1;
if (n === 1 || n === 2) {
return cost[n];
}
// 初始化 dp 表,用于存储子问题的解
const dp = new Array(n + 1);
// 初始状态:预设最小子问题的解
dp[1] = cost[1];
dp[2] = cost[2];
// 状态转移:从较小子问题逐步求解较大子问题
for (let i = 3; i <= n; i++) {
dp[i] = Math.min(dp[i - 1], dp[i - 2]) + cost[i];
}
return dp[n];
}
// 优化后的空间复杂度为O(1)
function minCostClimbingStairsDPComp(cost) {
const n = cost.length - 1;
if (n === 1 || n === 2) {
return cost[n];
}
let a = cost[1];
let b = cost[2];
for (let i = 3; i <= n; i++) {
const tmp = b;
b = Math.min(a, tmp) + cost[i];
a = tmp;
}
return b;
}
==> 无后效性 <==
定义: 给定一个确定的状态,它的未来发展只与当前状态有关,而与当前状态过去所经历过的所有状态无关
解释: 以爬楼梯问题为例,给定状态 𝑖 ,它会发展出状态 𝑖 + 1 和状态 𝑖 + 2 ,分别对应跳 1 步和跳 2 步。在做出这两种选择时,不需要考虑之前我是怎么跳的,因为我每次都可以选择跳 1 步还是 2 步
举例: 给定一个共有 𝑛 阶的楼梯,你每步可以上 1 阶或者 2 阶,但不能连续两轮跳 1 阶
,请问有多少种方案可以爬到楼顶。
说明: 如果上一轮是跳 1 阶上来的,那么下一轮就必须跳 2 阶。这意味着,下一步选择不能由当前状态(当前楼梯阶数)独立决定,还和前一个状态(上轮楼梯阶数)有关,此时状态转移方程 𝑑𝑝[𝑖] = 𝑑𝑝[𝑖 − 1] + 𝑑𝑝[𝑖 − 2] 也失效了,因为 𝑑𝑝[𝑖 − 1] 代表本轮跳 1 阶,但其中包含了许多上一轮跳 1 阶上来的方案,而为了满足约束,不能将 𝑑𝑝[𝑖 − 1] 直接计入 𝑑𝑝[𝑖] 中
思路: 为此,我们需要扩展状态定义:状态 [𝑖, 𝑗] 表示处在第 𝑖 阶、并且上一轮跳了 𝑗 阶,其中 𝑗 ∈ {1, 2} 。此状 态定义有效地区分了上一轮跳了 1 阶还是 2 阶,我们可以据此来判断当前状态是从何而来的,如下:
当上一轮跳了 1 阶时,上上一轮只能选择跳 2 阶,即 𝑑𝑝[𝑖, 1] 只能从 𝑑𝑝[𝑖 − 1, 2] 转移过来。
当上一轮跳了 2 阶时,上上一轮可选择跳 1 阶或跳 2 阶,即 𝑑𝑝[𝑖, 2] 可以从 𝑑𝑝[𝑖−2, 1] 或 𝑑𝑝[𝑖−2, 2] 转移过来。
说明: 通过上面可以得到如下状态转移方程
// 最终,返回 𝑑𝑝[𝑛, 1] + 𝑑𝑝[𝑛, 2] 即可,两者之和代表爬到第 𝑛 阶的方案总数。
function climbingStairsConstraintDP(n) {
if (n === 1 || n === 2) {
return 1;
}
// 初始化 dp 表,用于存储子问题的解
const dp = Array.from(new Array(n + 1), () => new Array(3));
// 初始状态:预设最小子问题的解
dp[1][1] = 1;
dp[1][2] = 0;
dp[2][1] = 0;
dp[2][2] = 1;
// 状态转移:从较小子问题逐步求解较大子问题
for (let i = 3; i <= n; i++) {
dp[i][1] = dp[i - 1][2];
dp[i][2] = dp[i - 2][1] + dp[i - 2][2];
}
return dp[n][1] + dp[n][2];
}
(3)解题思路
==> 问题判断 <==
说明: 先观察问题是否适合使用穷举来解决,一般都可以使用树形结构来描述,其中每一个节点代表一个决策,每一条路径代表一个决策序列,在此基础上,如果存在下面中的某个特点,就可以先假设是动态规划问题去解决
特点:
问题包含最大(小)或最多(少)等最优化描述。
问题的状态能够使用一个列表、多维矩阵或树来表示,并且一个状态与其周围的状态存在递推关系。
==> 解题步骤 <==
举例: 给定一个 𝑛 × 𝑚 的二维网格 grid ,网格中的每个单元格包含一个非负整数,表示该单元格的代价。机器人以左上角单元格为起始点,每次只能向下或者向右移动一步,直至到达右下角单元格。请返回从左上角到右下角的最小路径和。
第一步:
思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
说明: 每一轮的决策就是从当前格子向下或向右一步。设当前格子的行列索引为 [𝑖, 𝑗] ,则向下或向右走一步后,索引变为 [𝑖 + 1, 𝑗] 或 [𝑖, 𝑗 + 1] 。因此,状态应包含行索引和列索引两个变量,记为 [𝑖, 𝑗]
。状态 [𝑖, 𝑗] 对应的子问题为:从起始点 [0, 0] 走到 [𝑖, 𝑗] 的最小路径和,记为 𝑑𝑝[𝑖, 𝑗]
。
注意: 动态规划和回溯过程可以被描述为一个决策序列,而状态由所有决策变量构成。它应当包含描述解题进度的所有变量,其包含了足够的信息,能够用来推导出下一个状态。 每个状态都对应一个子问题,我们会定义一个 𝑑𝑝 表来存储所有子问题的解,状态的每个独立变量都是 𝑑𝑝 表的一个维度。本质上看,𝑑𝑝 表是状态和子问题的解之间的映射。
第二步:
找出最优子结构,进而推导出状态转移方程
说明: 对于状态 [𝑖, 𝑗] ,它只能从上边格子 [𝑖 − 1, 𝑗] 和左边格子 [𝑖, 𝑗 − 1] 转移而来。因此最优子结构为:到达 [𝑖, 𝑗] 的最小路径和由 [𝑖, 𝑗 − 1] 的最小路径和与 [𝑖 − 1, 𝑗] 的最小路径和,这两者较小的那一个决定,由此得到下面这样的状态转移方程,𝑔𝑟𝑖𝑑[𝑖, 𝑗]表示当前格子所需要的代价,也就是最后的一个代价
注意: 根据定义好的 𝑑𝑝 表,思考原问题和子问题的关系,找出通过子问题的最优解来构造原问题的最优解的方法,即最优子结构。一旦我们找到了最优子结构,就可以使用它来构建出状态转移方程。
第三步:
确定边界条件和状态转移顺序
思路: 首行的状态只能从其左边的状态得来,首列的状态只能从其上边的状态得来,因此首行 𝑖 = 0 和 首列 𝑗 = 0 是边界条件。由于每个格子是由其左方格子和上方格子转移而来,因此我们使用采用循环来遍历矩阵,外循环遍历各行、内循环遍历各列。
注意: 边界条件在动态规划中用于初始化 𝑑𝑝 表,在搜索中用于剪枝。 状态转移顺序的核心是要保证在计算当前问题的解时,所有它依赖的更小子问题的解都已经 被正确地计算出来。
==> 使用方法 <==
方法一:
暴力搜索
说明: 从状态 [𝑖, 𝑗] 开始搜索,不断分解为更小的状态 [𝑖 − 1, 𝑗] 和 [𝑖, 𝑗 − 1] ,递归函数包括以下要素。
递归参数:
状态 [𝑖, 𝑗]返回值:
从 [0, 0] 到 [𝑖, 𝑗] 的最小路径和 𝑑𝑝[𝑖, 𝑗]终止条件:
当 𝑖 = 0 且 𝑗 = 0 时,返回代价 𝑔𝑟𝑖𝑑[0, 0]剪枝:
当 𝑖 < 0 时或 𝑗 < 0 时索引越界,此时返回代价 +∞ ,代表不可行。
function minPathSumDFS(grid, i, j) {
// 若为左上角单元格,则终止搜索
if (i === 0 && j === 0) {
return grid[0][0];
}
// 若行列索引越界,则返回 +∞ 代价
if (i < 0 || j < 0) {
return Infinity;
}
// 计算从左上角到 (i-1, j) 和 (i, j-1) 的最小路径代价
const up = minPathSumDFS(grid, i - 1, j);
const left = minPathSumDFS(grid, i, j - 1);
// 返回从左上角到 (i, j) 的最小路径代价
return Math.min(left, up) + grid[i][j];
}
注意: 由于存在多条路径可以从左上角到达某一单元格,因此会存在重叠的子问题,其数量会随着网格尺寸的变大而剧增,由于每个状态都有向下和向右两种选择,从左上角走到右下角总共需要 𝑚 + 𝑛 − 2 步,所以最差时间复杂度为 𝑂(2^(𝑚+𝑛))
方法二:
记忆化搜索
说明: 引入一个和网格 grid 相同尺寸的记忆列表 mem ,用于记录各个子问题的解,并将重叠子问题进行剪枝
function minPathSumDFSMem(grid, mem, i, j) {
// 若为左上角单元格,则终止搜索
if (i === 0 && j === 0) {
return grid[0][0];
}
// 若行列索引越界,则返回 +∞ 代价
if (i < 0 || j < 0) {
return Infinity;
}
// 若已有记录,则直接返回
if (mem[i][j] !== -1) {
return mem[i][j];
}
// 左边和上边单元格的最小路径代价
const up = minPathSumDFSMem(grid, mem, i - 1, j);
const left = minPathSumDFSMem(grid, mem, i, j - 1);
// 记录并返回左上角到 (i, j) 的最小路径代价
mem[i][j] = Math.min(left, up) + grid[i][j];
return mem[i][j];
}
注意: 所有子问题的解只需计算一次,因此时间复杂度取决于状态总数,即网格尺寸 𝑂(𝑛𝑚) 。
方法三:
动态规划
function minPathSumDP(grid) {
const n = grid.length,
m = grid[0].length;
// 初始化 dp 表
const dp = Array.from({ length: n }, () =>
Array.from({ length: m }, () => 0)
);
dp[0][0] = grid[0][0];
// 状态转移:首行
for (let j = 1; j < m; j++) {
dp[0][j] = dp[0][j - 1] + grid[0][j];
}
// 状态转移:首列
for (let i = 1; i < n; i++) {
dp[i][0] = dp[i - 1][0] + grid[i][0];
}
// 状态转移:其余行列
for (let i = 1; i < n; i++) {
for (let j = 1; j < m; j++) {
dp[i][j] = Math.min(dp[i][j - 1], dp[i - 1][j]) + grid[i][j];
}
}
return dp[n - 1][m - 1];
}
==> 空间优化 <==
说明: 由于每个格子只与其左边和上边的格子有关,因此我们可以只用一个单行数组来实现 𝑑𝑝 表。注意,因为数组 dp 只能表示一行的状态,所以无法提前初始化首列状态,而是在遍历每行中更新它。
function minPathSumDPComp(grid) {
const n = grid.length,
m = grid[0].length;
// 初始化 dp 表
const dp = new Array(m);
// 状态转移:首行
dp[0] = grid[0][0];
for (let j = 1; j < m; j++) {
dp[j] = dp[j - 1] + grid[0][j];
}
// 状态转移:其余行
for (let i = 1; i < n; i++) {
// 状态转移:首列
dp[0] = dp[0] + grid[i][0];
// 状态转移:其余列
for (let j = 1; j < m; j++) {
dp[j] = Math.min(dp[j - 1], dp[j]) + grid[i][j];
}
}
return dp[m - 1];
}
(4)0-1背包问题
举例: 给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1]、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝 的 背包。每个物品只能选择一次,问在不超过背包容量下能放入物品的最大价值。
理解: 由于物品编号 𝑖 从 1 开始计数,数组索引从 0 开始计数,因此物品 𝑖 对应重量 𝑤𝑔𝑡[𝑖 − 1] 和 价值 𝑣𝑎𝑙[𝑖 − 1],可以将 0‑1 背包问题看作是一个由 𝑛 轮决策组成的过程,每个物体都有不放入和放入两种决策,因此该问题是可以用动态规划解决的
==> 分析 <==
第一步:
思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
说明: 对于每个物品来说,不放入背包,背包容量不变;放入背包,背包容量减小。由此可得状态定义:当前物品编号 𝑖 和剩余背包容量 𝑐 ,记为[𝑖, 𝑐]
,状态 [𝑖, 𝑐] 对应的子问题为:前 𝑖 个物品在剩余容量为 𝑐 的背包中的最大价值,记为 𝑑𝑝[𝑖, 𝑐]
,由此得到待求解的是 𝑑𝑝[𝑛, 𝑐𝑎𝑝]
,因此需要一个尺寸为 (𝑛 + 1) × (𝑐𝑎𝑝 + 1) 的二维 𝑑𝑝 表,+1的原因是容量价值什么的都从0开始
第二步:
找出最优子结构,进而推导出状态转移方程
说明: 当我们做出物品 𝑖 的决策后,剩余的是前 𝑖 − 1 个物品的决策,可分为以下两种情况,也就是最大价值 𝑑𝑝[𝑖, 𝑐] 等于不放入物品 𝑖 和放入物品 𝑖 两种方案中的价值更大的那一个,因此可以得到状态转移方程
不放入物品 𝑖:
背包容量不变,状态变化为 [𝑖 − 1, 𝑐]
放入物品 𝑖:
背包容量减小 𝑤𝑔𝑡[𝑖 − 1] ,价值增加 𝑣𝑎𝑙[𝑖 − 1] ,状态变化为 [𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]]
注意: 若当前物品重量 𝑤𝑔𝑡[𝑖 − 1] 超出剩余背包容量 𝑐 ,则只能选择不放入背包。
第三步:
确定边界条件和状态转移顺序
说明: 当无物品或无剩余背包容量时最大价值为 0 ,即首列 𝑑𝑝[𝑖, 0] 和首行 𝑑𝑝[0, 𝑐] 都等于 0 。 当前状态 [𝑖, 𝑐] 从上方的状态 [𝑖 − 1, 𝑐] 和左上方的状态 [𝑖 − 1, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]] 转移而来,因此通过两层循环正序遍历整个 𝑑𝑝 表即可
==> 使用方法 <==
方法一:
暴力搜索
递归参数:
状态 [𝑖, 𝑐]
返回值:
子问题的解 𝑑𝑝[𝑖, 𝑐]
终止条件:
当物品编号越界 𝑖 = 0 或背包剩余容量为 0 时,终止递归并返回价值 0
剪枝:
若当前物品重量超出背包剩余容量,则只能不放入背包。
function knapsackDFS(wgt, val, i, c) {
// 若已选完所有物品或背包无容量,则返回价值 0
if (i === 0 || c === 0) {
return 0;
}
// 若超过背包容量,则只能不放入背包
if (wgt[i - 1] > c) {
return knapsackDFS(wgt, val, i - 1, c);
}
// 计算不放入和放入物品 i 的最大价值
const no = knapsackDFS(wgt, val, i - 1, c);
const yes = knapsackDFS(wgt, val, i - 1, c - wgt[i - 1]) + val[i - 1];
// 返回两种方案中价值更大的那一个
return Math.max(no, yes);
}
注意: 当物品较多、背包容量较大,尤其是相同 重量的物品较多时,重叠子问题的数量将会大幅增多
方法二:
记忆化搜索
说明: 保证重叠子问题只被计算一次,我们借助记忆列表 mem 来记录子问题的解,其中 mem[i][c] 对应 𝑑𝑝[𝑖, 𝑐] 。
function knapsackDFSMem(wgt, val, mem, i, 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 的最大价值
const no = knapsackDFSMem(wgt, val, mem, i - 1, c);
const yes = knapsackDFSMem(wgt, val, mem, i - 1, c - wgt[i - 1]) + val[i - 1];
// 记录并返回两种方案中价值更大的那一个
mem[i][c] = Math.max(no, yes);
return mem[i][c];
}
方法三:
动态规划
function knapsackDP(wgt, val, cap) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array(n + 1)
.fill(0)
.map(() => Array(cap + 1).fill(0));
// 状态转移
for (let i = 1; i <= n; i++) {
for (let c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包容量,则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = Math.max(
dp[i - 1][c],
dp[i - 1][c - wgt[i - 1]] + val[i - 1]
);
}
}
}
return dp[n][cap];
}
==> 空间优化 <==
说明: 由于每个状态都是由正上方或左上方的格 子转移过来的。假设只有一个数组,当开始遍历第 𝑖 行时,该数组存储的仍然是第 𝑖 − 1 行的状态。如果采取正序
遍历,那么遍历到 𝑑𝑝[𝑖, 𝑗] 时,左上方 𝑑𝑝[𝑖 − 1, 1] ~ 𝑑𝑝[𝑖 − 1, 𝑗 − 1] 值可能已经被覆盖,此时就无法得到正确的状态转移结果。如果采取倒序
遍历,则不会发生覆盖问题,状态转移可以正确进行。
举例: 下面的图是单个数组下从第 𝑖 = 1 行转换至第 𝑖 = 2 行的正序和倒叙遍历,以此看它们的区别
// 将数组 dp 的第一维 𝑖 直接删除,并且把内循环更改为倒序遍历
function knapsackDPComp(wgt, val, cap) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array(cap + 1).fill(0);
// 状态转移
for (let i = 1; i <= n; i++) {
// 倒序遍历
for (let c = cap; c >= 1; c--) {
if (wgt[i - 1] <= c) {
// 不选和选物品 i 这两种方案的较大值
dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}
(5)完全背包问题
举例: 给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1]、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝 的背包。每个物品可以重复选取,问在不超过背包容量下能放入物品的最大价值。
==> 思路 <==
说明: 与上一个问题的区别在于不限制物品的选择次数,从而状态 [𝑖, 𝑐] 的变化分为两种情况,从而线性方程变为
0‑1 背包:
每个物品只有一个,因此将物品 𝑖 放入背包后,只能从前 𝑖 − 1 个物品中选择完全背包:
每个物品有无数个,因此将物品 𝑖 放入背包后,仍可以从前 𝑖 个物品中选择
不放入物品 𝑖 :
与 0‑1 背包相同,转移至 [𝑖 − 1, 𝑐]放入物品 𝑖 :
与 0‑1 背包不同,转移至 [𝑖, 𝑐 − 𝑤𝑔𝑡[𝑖 − 1]]
==> 实现 <==
function unboundedKnapsackDP(wgt, val, cap) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array.from({ length: n + 1 }, () =>
Array.from({ length: cap + 1 }, () => 0)
);
// 状态转移
for (let i = 1; i <= n; i++) {
for (let c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包容量,则不选物品 i
dp[i][c] = dp[i - 1][c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[i][c] = Math.max(
dp[i - 1][c],
dp[i][c - wgt[i - 1]] + val[i - 1]
);
}
}
}
return dp[n][cap];
}
==> 空间优化 <==
说明: 由于当前状态是从左边和上边的状态转移而来,因此空间优化后应该对 𝑑𝑝 表中的每一行采取正序遍历。这个遍历顺序与 0‑1 背包正好相反
// 状态压缩后的动态规划
function unboundedKnapsackDPComp(wgt, val, cap) {
const n = wgt.length;
// 初始化 dp 表
const dp = Array.from({ length: cap + 1 }, () => 0);
// 状态转移
for (let i = 1; i <= n; i++) {
for (let c = 1; c <= cap; c++) {
if (wgt[i - 1] > c) {
// 若超过背包容量,则不选物品 i
dp[c] = dp[c];
} else {
// 不选和选物品 i 这两种方案的较大值
dp[c] = Math.max(dp[c], dp[c - wgt[i - 1]] + val[i - 1]);
}
}
}
return dp[cap];
}
(6)编辑距离问题
说明: 编辑距离,也被称为 Levenshtein 距离,指两个字符串之间互相转换的最小修改次数,通常用于在信息检索和自然语言处理中度量两个序列的相似度
举例: 输入两个字符串 𝑠 和 𝑡 ,返回将 𝑠 转换为 𝑡 所需的最少编辑步数。你可以在一个字符串中进行三种编辑操作:插入一个字符、删除一个字符、替换字符为任意一个字符,比如将 kitten 转换为 sitting 需要编辑 3 步,包括 2 次替换操作与 1 次添加操作;将 hello 转 换为 algo 需要 3 步,包括 2 次替换操作和 1 次删除操作。
理解: 编辑距离问题可以很自然地用决策树模型来解释。字符串对应树节点,一轮决策对应树的一条边。在不限制操作的情况下,每个节点都可以派生出许多条边,每条边对应一种操作,这意味着从 hello 转换到 algo 有许多种可能的路径。从决策树的角度看,本题的目标是求解节点 hello 和节点 algo 之间的最短路径。
==> 思路 <==
第一步:
思考每轮的决策,定义状态,从而得到 𝑑𝑝 表
理解: 每一轮的决策是对字符串 𝑠 进行一次编辑操作。如果希望在编辑操作的过程中,问题的规模逐渐缩小,这样才能构建子问题。设字符串 𝑠 和 𝑡 的长度分别为 𝑛 和 𝑚 ,我们先考虑两字符串尾部的字符 𝑠[𝑛 − 1] 和 𝑡[𝑚 − 1]
若 𝑠[𝑛 − 1] 和 𝑡[𝑚 − 1] 相同,我们可以跳过它们,直接考虑 𝑠[𝑛 − 2] 和 𝑡[𝑚 − 2]
若 𝑠[𝑛 − 1] 和 𝑡[𝑚 − 1] 不同,我们需要对 𝑠 进行一次编辑,使得两字符串尾部 的字符相同,从而可以跳过它们,考虑规模更小的问题
说明: 也就是说,我们在字符串 𝑠 中进行的每一轮决策(编辑操作),都会使得 𝑠 和 𝑡 中剩余的待匹配字符发生变化。因此,状态为当前在 𝑠 和 𝑡 中考虑的第 𝑖 和 𝑗 个字符,记为 [𝑖, 𝑗] 。 状态 [𝑖, 𝑗] 对应的子问题:将 𝑠 的前 𝑖 个字符更改为 𝑡 的前 𝑗 个字符所需的最少编辑步数。 至此,得到一个尺寸为 (𝑖 + 1) × (𝑗 + 1) 的二维 𝑑𝑝 表。
第二步:
找出最优子结构,进而推导出状态转移方程
理解: 考虑子问题 𝑑𝑝[𝑖, 𝑗] ,其对应的两个字符串的尾部字符为 𝑠[𝑖 − 1] 和 𝑡[𝑗 − 1] ,可根据不同编辑操作分为三种情况:
- 在 𝑠[𝑖 − 1] 之后添加 𝑡[𝑗 − 1] ,则剩余子问题 𝑑𝑝[𝑖, 𝑗 − 1]
- 删除 𝑠[𝑖 − 1] ,则剩余子问题 𝑑𝑝[𝑖 − 1, 𝑗]
- 将 𝑠[𝑖 − 1] 替换为 𝑡[𝑗 − 1] ,则剩余子问题 𝑑𝑝[𝑖 − 1, 𝑗 − 1]
说明: 所以𝑑𝑝[𝑖, 𝑗] 的最少编辑步数等于 𝑑𝑝[𝑖, 𝑗 − 1]、𝑑𝑝[𝑖 − 1, 𝑗]、𝑑𝑝[𝑖 − 1, 𝑗 − 1] 三者中的最少编辑步数,再加上本次的编辑步数 1,那么对应的状态转移方程为
注意: ,当 𝑠[𝑖 − 1] 和 𝑡[𝑗 − 1] 相同时,无须编辑当前字符,这种情况下的状态转移方程为
第三步:
确定边界条件和状态转移顺序
说明: 当两字符串都为空时,编辑步数为 0 ,即 𝑑𝑝[0, 0] = 0 。当 𝑠 为空但 𝑡 不为空时,最少编辑步数等于 𝑡 的长度,即首行 𝑑𝑝[0, 𝑗] = 𝑗 。当 𝑠 不为空但 𝑡 为空时,等于 𝑠 的长度,即首列 𝑑𝑝[𝑖, 0] = 𝑖 。 观察状态转移方程,解 𝑑𝑝[𝑖, 𝑗] 依赖左方、上方、左上方的解,因此通过两层循环正序遍历整个 𝑑𝑝 表即可。
==> 实现 <==
function editDistanceDP(s, t) {
const n = s.length,
m = t.length;
const dp = Array.from({ length: n + 1 }, () =>
new Array(m + 1).fill(0)
);
// 状态转移:首行首列
for (let i = 1; i <= n; i++) {
dp[i][0] = i;
}
for (let j = 1; j <= m; j++) {
dp[0][j] = j;
}
// 状态转移:其余行列
for (let i = 1; i <= n; i++) {
for (let j = 1; j <= m; j++) {
if (s.charAt(i - 1) === t.charAt(j - 1)) {
// 若两字符相等,则直接跳过此两字符
dp[i][j] = dp[i - 1][j - 1];
} else {
// 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
dp[i][j] =
Math.min(dp[i][j - 1], dp[i - 1][j], dp[i - 1][j - 1]) + 1;
}
}
}
return dp[n][m];
}
==> 空间优化 <==
说明: 由于 𝑑𝑝[𝑖, 𝑗] 是由上方 𝑑𝑝[𝑖 − 1, 𝑗]、左方 𝑑𝑝[𝑖, 𝑗 − 1]、左上方状态 𝑑𝑝[𝑖 − 1, 𝑗 − 1] 转移而来,而正序遍 历会丢失左上方 𝑑𝑝[𝑖 − 1, 𝑗 − 1] ,倒序遍历无法提前构建 𝑑𝑝[𝑖, 𝑗 − 1] ,因此两种遍历顺序都不可取。 为此,我们可以使用一个变量 leftup 来暂存左上方的解 𝑑𝑝[𝑖 − 1, 𝑗 − 1] ,从而只需考虑左方和上方的解。 此时的情况与完全背包问题相同,可使用正序遍历。
function editDistanceDPComp(s, t) {
const n = s.length,
m = t.length;
const dp = new Array(m + 1).fill(0);
// 状态转移:首行
for (let j = 1; j <= m; j++) {
dp[j] = j;
}
// 状态转移:其余行
for (let i = 1; i <= n; i++) {
// 状态转移:首列
let leftup = dp[0]; // 暂存 dp[i-1, j-1]
dp[0] = i;
// 状态转移:其余列
for (let j = 1; j <= m; j++) {
const temp = dp[j];
if (s.charAt(i - 1) === t.charAt(j - 1)) {
// 若两字符相等,则直接跳过此两字符
dp[j] = leftup;
} else {
// 最少编辑步数 = 插入、删除、替换这三种操作的最少编辑步数 + 1
dp[j] = Math.min(dp[j - 1], dp[j], leftup) + 1;
}
// 更新为下一轮的 dp[i-1, j-1]
leftup = temp;
}
}
return dp[m];
}
十八、贪心算法
(1)了解贪心算法
说明: 它在问题的每个决策阶段,都选择当前看起来最优的选择,即贪心地做出局部最优的决策,以期望获得全局最优解,也就是贪心算法不会重新考虑过去的决策,而是一路向前地进行贪心选择,不断缩小问题范围,直至问题被解决
举例: 给定 𝑛 种硬币,第 𝑖 种硬币的面值为 𝑐𝑜𝑖𝑛𝑠[𝑖 − 1] ,目标金额为 𝑎𝑚𝑡 ,每种硬币可以重复选取,问能够凑出目标金额的最少硬币个数。如果无法凑出目标金额则返回 −1
理解: 给定目标金额,我们贪心地选择不大于且最接近它的硬币,不断循环该步 骤,直至凑出目标金额为止。
function coinChangeGreedy(coins, amt) {
// 假设 coins 数组有序
let i = coins.length - 1;
let count = 0;
// 循环进行贪心选择,直到无剩余金额
while (amt > 0) {
// 找到小于且最接近剩余金额的硬币
while (i > 0 && coins[i] > amt) {
i--;
}
// 选择 coins[i]
amt -= coins[i];
count++;
}
// 若未找到可行方案,则返回 -1
return amt === 0 ? count : -1;
}
==> 优点和局限性 <==
说明: 贪心算法不仅操作直接、实现简单,而且通常效率也很高。在上面的例子中,记硬币最小面值为 min(𝑐𝑜𝑖𝑛𝑠),则贪心选择最多循环 𝑎𝑚𝑡/ min(𝑐𝑜𝑖𝑛𝑠) 次,时间复杂度为 𝑂(𝑎𝑚𝑡/ min(𝑐𝑜𝑖𝑛𝑠))。这比动态规划解法的时间复杂度 𝑂(𝑛 × 𝑎𝑚𝑡) 提升了一个数量级,然而,对于某些硬币面值组合,贪心算法并不能找到最优解,比如下面这个例子
正例 𝑐𝑜𝑖𝑛𝑠 = [1, 5, 10, 20, 50, 100]:
在该硬币组合下,给定任意 𝑎𝑚𝑡 ,贪心算法都可以找出最优解。
反例 𝑐𝑜𝑖𝑛𝑠 = [1, 20, 50]:
假设 𝑎𝑚𝑡 = 60 ,贪心算法只能找到 50 + 1 × 10 的兑换组合,共计 11 枚硬币,但动态规划可以找到最优解 20 + 20 + 20 ,仅需 3 枚硬币
反例 𝑐𝑜𝑖𝑛𝑠 = [1, 49, 50]:
假设 𝑎𝑚𝑡 = 98 ,贪心算法只能找到 50 + 1 × 48 的兑换组合,共计 49 枚硬币,但动态规划可以找到最优解 49 + 49 ,仅需 2 枚硬币。
注意: 一般情况,贪心算法适合处理保证找到最优解和近似找到最优解这两种情况
==> 解题步骤 <==
问题分析:
梳理与理解问题特性,包括状态定义、优化目标和约束条件等
确定贪心策略:
确定如何在每一步中做出贪心选择。这个策略能够在每一步减小问题的规模,并最终能解决整个问题。
正确性证明:
通常需要证明问题具有贪心选择性质和最优子结构。这个步骤可能需要使用到数学证明,例如归纳法或反证法等。
(2)分数背包问题
举例: 给定 𝑛 个物品,第 𝑖 个物品的重量为 𝑤𝑔𝑡[𝑖 − 1]、价值为 𝑣𝑎𝑙[𝑖 − 1] ,和一个容量为 𝑐𝑎𝑝 的 背包。每个物品只能选择一次,但可以选择物品的一部分,价值根据选择的重量比例计算,问在不超过背包容量下背包中物品的最大价值。
说明: 分数背包和 0‑1 背包整体上非常相似,不过这里区别在于可以对物品任意地进行切分,并按照重量比例来计算物品价值,那么就有下面两种情况:
对于物品 𝑖 ,它在单位重量下的价值为 𝑣𝑎𝑙[𝑖 − 1]/𝑤𝑔𝑡[𝑖 − 1],简称为单位价值
假设放入一部分物品 𝑖 ,重量为 𝑤 ,则背包增加的价值为 𝑤 × 𝑣𝑎𝑙[𝑖 − 1]/𝑤𝑔𝑡[𝑖 − 1]
==> 贪心策略 <==
说明: 其本质在于要最大化单位重量下的物品价值,因此策略如下
- 将物品按照单位价值从高到低进行排序
- 遍历所有物品,每轮贪心地选择单位价值最高的物品
- 若剩余背包容量不足,则使用当前物品的一部分填满背包即可
==> 实现 <==
// 物品
class Item {
constructor(w, v) {
this.w = w; // 物品重量
this.v = v; // 物品价值
}
}
function fractionalKnapsack(wgt, val, cap) {
// 创建物品列表,包含两个属性:重量、价值
const items = wgt.map((w, i) => new Item(w, val[i]));
// 按照单位价值 item.v / item.w 从高到低进行排序
items.sort((a, b) => b.v / b.w - a.v / a.w);
// 循环贪心选择
let res = 0;
for (const item of items) {
if (item.w <= cap) {
// 若剩余容量充足,则将当前物品整个装进背包
res += item.v;
cap -= item.w;
} else {
// 若剩余容量不足,则将当前物品的一部分装进背包
res += (item.v / item.w) * cap;
// 已无剩余容量,因此跳出循环
break;
}
}
return res;
}
==> 证明 <==
说明: 采用反证法。假设物品 𝑥 是单位价值最高的物品,使用某算法求得最大价值为 res,但该解中不包含物品 𝑥。现在从背包中拿出单位重量的任意物品,并替换为单位重量的物品 𝑥 。由于物品 𝑥 的单位价值最高,因此替 换后的总价值一定大于 res 。这与 res 是最优解矛盾,说明最优解中必须包含物品 𝑥,也就是说单位价值更大的物品总是更优选择,因此贪心策略是成立的