分治算法
分治算法的思想
分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
分治算法和递归的区别
分治是处理问题的思想,递归是一种编程技巧。分治一般都比较适合用用递归来实现。
分治算法的实现中,每一层地递归都会涉及到下面三个操作:
分解:将原问题分解成一系列子问题;
解决:将子问题的结果合并成原问题。
使用分治算法需要满足的条件
1、原问题与分解成的小问题具有相同的模式;
2、原问题分解成的子问题可以独立求解,子问题之间没有相关性;
3、具有分解终止条件,也就是说,当问题足够小时,可以直接求解;
4、可以将子问题合并成原问题,而这个合并操作的复杂度不能太高,否则就起不到减小算法总体复杂度的效果了。
经典题目
1、二分搜索
给定一个n个元素有序的(升序)整型数组nums 和一个目标值target ,写一个函数搜索nums中的 target,如果目标值存在返回下标,否则返回 -1。
示例 1:
输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4
示例2:
输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1
题目链接:https://leetcode.cn/problems/binary-search
解题思路
1、满足分支算法的思想,将为题分解为规模较小的相同问题时,就比较容易求解;
2、找出中间的值和目标值进行比较,通过判断大小,来不断的缩小问题的求解范围;
3、这样每次的求解,总是能将问题的范围缩小一半,或者找出目标值。
时间复杂度:O(logn)
空间复杂度:O(1)
func search(nums []int, target int) int {
left, right := 0, len(nums)-1
for left <= right {
mid := (right + left) / 2
if nums[mid] == target {
return mid
} else if nums[mid] > target {
right = mid - 1
} else if nums[mid] < target {
left = mid + 1
}
}
return -1
}
2、第一个错误的版本
你是产品经理,目前正在带领一个团队开发新的产品。不幸的是,你的产品的最新版本没有通过质量检测。由于每个版本都是基于之前的版本开发的,所以错误的版本之后的所有版本都是错的。
假设你有 n 个版本 [1, 2, ..., n]
,你想找出导致之后所有版本出错的第一个错误的版本。
你可以通过调用bool isBadVersion(version)接口来判断版本号 version 是否在单元测试中出错。实现一个函数来查找第一个错误的版本。你应该尽量减少对调用 API 的次数。
示例 1:
输入:n = 5, bad = 4
输出:4
解释:
调用 isBadVersion(3) -> false
调用 isBadVersion(5)-> true
调用 isBadVersion(4)-> true
所以,4 是第一个错误的版本。
示例 2:
输入:n = 1, bad = 1
输出:1
链接:https://leetcode.cn/problems/first-bad-version
题解:
这道题目是二分查找的变种题目,找出最近的一个出错的版本,也就是出错版本的左边都是出错的版本;
所以利用二分查找,找出出错的又边界即可
/**
* Forward declaration of isBadVersion API.
* @param version your guess about first bad version
* @return true if current version is bad
* false if current version is good
* func isBadVersion(version int) bool;
*/
func firstBadVersion(n int) int {
left, right := 0, n
for left < right {
mid := (right + left) / 2
if isBadVersion(mid) {
right = mid
} else {
left = mid + 1
}
}
return right
}
// 测试函数
func isBadVersion(n int) bool {
return false
}
3、快速排序
快速排序在我们刚学习算法的时候就遇到了过了,其中它使用到的算法思想就是分治策略。
它的处理思路就是,会选中一个基准数,通过一趟排序,和当前的基准数进行比较,将数据分总成两部分,一部分大于基准数,另一部分小于基准数。
然后对这两部分数据在进行上面的操作,直到所有的数据都有序,整个排序的过程可以使用递归去实现。
快排的时间复杂度:平均情况下快速排序的时间复杂度是Θ(nlgn)
,最坏情况是n2
空间复杂度:最好空间复杂度为 O(logn)
,最坏空间复杂度为 O(n)
上代码
func QuickSort(arr []int) {
quickSort(arr, 0, len(arr)-1)
}
func quickSort(data []int, l, u int) {
if l < u {
m := partition(data, l, u)
quickSort(data, l, m-1)
quickSort(data, m, u)
}
}
func partition(data []int, l, u int) int {
quick := data[l]
left := l
for i := l + 1; i <= u; i++ {
if data[i] <= quick {
left++
data[left], data[i] = data[i], data[left]
}
}
data[l], data[left] = data[left], data[l]
return left + 1
}
4、归并排序
归并排序也是使用分治思想实现的一个比较经典的栗子,这里来分析下
归并排序的处理思路:
1、首先拆分子序列,使得子序列很容易排序,然后对子序列进行排序;
2、然后是合并的过程,将有序的子序列合并;
3、合并子序列的过程;
-
1、申请空间,大小为两个已经排好序的子序列大小之和,该空间用来存放合并后的序列;
-
2、设定两个指针,最初位置分别为两个已经排序序列的起始位置;
-
3、比较两个指针所指向的元素,选择相对小的元素放入到合并空间,并移动指针到下一位置;
-
4、重复步骤3直到某一指针超出序列尾;
-
5、将剩下的元素直接复制到合并序列尾部;
具体的动画细节
上代码
时间复杂度 O(nlogn)
空间复杂度 O(n)
func MergeSort(arr []int) []int {
if len(arr) <= 1 {
return arr
}
mid := len(arr) / 2
leftArr := MergeSort(arr[0:mid])
rightArr := MergeSort(arr[mid:])
return merge(leftArr, rightArr)
}
func merge(left, right []int) []int {
i, j := 0, 0
m, n := len(left), len(right)
var result []int
// 合并子序列
for {
// 任何一个区间遍历完成就退出
if i >= m || j >= n {
break
}
if left[i] > right[j] {
result = append(result, right[j])
j++
} else {
result = append(result, left[i])
i++
}
}
// 左边的子集没有遍历完成,将左侧的放入到结果集
if i < m {
result = append(result, left[i:]...)
}
// 右侧的子集没有遍历完成,将右侧的放入到结果集中
if j < n {
result = append(result, right[j:]...)
}
return result
}
直接在原数组上进行操作
func MergeSort(nums []int) []int {
mergeSort(nums, 0, len(nums)-1)
return nums
}
func mergeSort(nums []int, start, end int) {
if start >= end {
return
}
mid := start + (end-start)/2
mergeSort(nums, start, mid)
mergeSort(nums, mid+1, end)
var tmp []int
i, j := start, mid+1
for i <= mid && j <= end {
if nums[i] > nums[j] {
tmp = append(tmp, nums[j])
j++
} else {
tmp = append(tmp, nums[i])
i++
}
}
if i <= mid {
tmp = append(tmp, nums[i:mid+1]...)
}
if j <= end {
tmp = append(tmp, nums[j:end+1]...)
}
for i := start; i <= end; i++ {
nums[i] = tmp[i-start]
}
}
5、数组中的逆序对
题目地址:https://leetcode.cn/problems/shu-zu-zhong-de-ni-xu-dui-lcof/
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
输入: [7,5,6,4]
输出: 5
解题思路
首先用到的是分治算法的思想
1、什么是逆序对,就是前面的数字大小,小于位于其后面的数字大小,那么这就是一个逆序对;
2、这里主要用到了分治的算法,只是在分治算法的基础之上,在进行数据的合并的时候,因为左边部分和右边部分都是已经排好序了,所以可以使用这种关系,来计算逆序对的个数;
3、具体的合并计算过程;
-
1、在左边和右边数组中,都从第一个下标,开始匹配;
-
2、如果左边当前下标的数大于右边数组当前下标的值,因为这两个数组都是从大到小排序的,所有左边从当前下标开始数,都大于右边当前匹配下标的数,
cnt += mid - i + 1
,同时右边数组下标前移; -
3、如果左边当前下标的数小于右边数组当前下标的值,不满足,左边数组下标前移;
func reversePairs(nums []int) int {
return mergeSort(nums, 0, len(nums)-1)
}
func mergeSort(nums []int, start, end int) int {
if start >= end {
return 0
}
mid := start + (end-start)/2
cnt := mergeSort(nums, start, mid) + mergeSort(nums, mid+1, end)
tmp := []int{}
i, j := start, mid+1
for i <= mid && j <= end {
if nums[i] > nums[j] {
tmp = append(tmp, nums[j])
cnt += mid - i + 1
j++
} else {
tmp = append(tmp, nums[i])
i++
}
}
if i <= mid {
tmp = append(tmp, nums[i:mid+1]...)
}
if j <= end{
tmp = append(tmp, nums[j:end+1]...)
}
for i := start; i <= end; i++ {
nums[i] = tmp[i-start]
}
return cnt
}
6、汉诺塔
汉诺塔问题的描述:
假设有 A、B、C 三根柱子。其中在 A 柱子上,从下往上有 N 个从大到小叠放的盘子。我们的目标是,希望用尽可能少的移动次数,把所有的盘子由 A 柱移动到 C 柱。过程中,每次只能移动一个盘子,且在任何时候,大盘子都不可以在小盘子上面。
解题思路:
尝试把问题分解,使用分治的思想,将问题分解成一个个容易解决的子问题,然后一个个的去解决
我们把一个 N 层汉诺塔从 A 搬到 C,我们假定只有两层,首先把 N-1 层搬到 B,然后把下面的第 N 层搬到 C,然后再把 N-1 层从 B 搬到 C 。
如果存在多层,那我们就假定 N-1 层已经排好序了,只搬第 N 层,这样依次递归下去。
终止条件:
当只剩下最后一个的时候,我们只需要搬动一次就行了
var count int = 0
func main() {
beadNum := 5 // This is the initial number of beads
hanoi(beadNum, "A", "B", "C")
fmt.Println(count)
}
func hanoi(beadNum int, pillarA string, pillarB string, pillarC string) {
if beadNum == 1 {
// 最后一个了,可以结束了
move(beadNum, pillarA, pillarC)
} else {
// Step 2: 将 N-1 层从 A 移动到 B
hanoi(beadNum-1, pillarA, pillarC, pillarB)
// Step 2: 将第 N 层从 A 移动到 C
move(beadNum, pillarA, pillarC)
// Step 3: 将 B 中的 N-1 层移动到 C
hanoi(beadNum-1, pillarB, pillarA, pillarC)
}
}
func move(beadNum int, pillarFrom string, pillarTo string) {
count += 1
}
总结
1、分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成n个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
2、分治是处理问题的思想,递归是一种编程技巧。分治一般都比较适合用用递归来实现。
3、分治算法的实现中,每一层地递归都会涉及到下面三个操作:
-
分解:将原问题分解成一系列子问题;
-
解决:将子问题的结果合并成原问题。
参考
【数据结构与算法之美】https://time.geekbang.org/column/intro/100017301
【经典优化算法之分治法】https://zhuanlan.zhihu.com/p/45986027
【归并排序】https://zh.m.wikipedia.org/zh-hans/归并排序
【归并排序】https://geekr.dev/posts/go-sorting-algorithm-merge
【分治使用技巧】https://boilingfrog.github.io/2022/11/17/分治算法的思想/