分治算法

算法思想

分治,分而治之,将原问题划分成 n 个规模较小而结构与原问题相似的子问题,这些规模小的问题与原问题是同质的,本质上还是同一个问题,递归解决这些子问题,然后合并其结果,就能得到原问题的解。(PS:当然,递归不是必须的)

空间换时间,来实现算法时间复杂度的优化

【分治算法】是很多高效算法的基础,诸如快速排序、归并排序、傅立叶变换、二分搜索

特征

  • 原问题的规模缩小到一定的程度就很容易解决
    -- 绝大多数问题都可以满足,因为问题的计算复杂性通常都是随着问题规模的增加而增加
  • 原问题可以分解为若干个规模较小的相同问题,即该问题具有【最优子结构性质】
    -- 应用分治法的前提,大多数问题也可以满足,它反映了递归思想的应用
  • 分解出的子问题的解可以合并为该问题的解
    -- 能否利用分治法的关键特征。如果只具备第一、第二特征,而不具备第三特征,则可以考虑动态规划或贪心算法
  • 分解出的各个子问题相互独立,即【子问题之间不包括公共的子问题】
    -- 涉及分治算法的效率,如果各个子问题不是相互独立的,分治算法要做很多不必需要的工作,重复地解决公共子问题,此时采用动态规划会比较好

前三个特征是使用分治法的关键,而特征4 涉及到分治法的效率问题。如果不符合3、4特征,可以尝试使用动态规划或胎心算法来解决。

【动态规划】是一种特殊的分治,贪心算法是一种特殊的动态规划

适用范围:

  • 分治算法:最优子结构
  • 动态规划:最优子结构、重叠子问题
  • 贪心算法:最优子结构、重叠子问题、贪心选择性质

分支模式在每一层上都有三个步骤:

  1. 分解,将原问题分解成一系列与原问题同质的子问题
  2. 解决,递归解决各个子问题,若子问题足够小,则直接求解
  3. 合并,将子问题的结果合并成原问题的解

举个例子

有一个很经典的问题:有100枚硬币,其中有1枚略轻一些的假币,如果用天平秤,请问至少称几次一定能找到这枚假币?

  • 如果用传统的逐枚比较法,显然至少需要比较50次
    比较第 i 个与第 i+1 个的重量,若相等,则i++,继续比较,直到重量不相等,并输出较轻的硬币编号。

  • 采用分治算法

    1. 100枚硬币分成3份:33、33、34
    2. 称重 1、2 份,若天枰平衡,则假币必在另外 34 枚中;若不平衡,则在较轻的那份 33 枚中
    3. 再将 34 枚分成3份:11、11、12(或将 33 枚分成 11、11、11
    4. 称重两组 11 枚的硬币,若平衡,则假币在 12 枚里(或第三份 11 枚);若不平衡,则在较轻的那份 11 枚中
    5. 12 枚分成3份:4、4、4(或将 11 枚分成 4、4、3),称重方法同上
    6. 4 枚分成 3 份:1、1、2,称重1/1,若平衡,则称重剩下的 2 枚,较轻的 1 枚是假币;若不平衡,较轻的 1 枚是假币。(或将 3 枚分成1、1、1,称重1/1,若平衡,则剩下的 1 枚是假币;若不平衡,则较轻的 1 枚是假币)

    综上所述,最多只需要 5 次就能解决这个问题!

通过观察 1-2、3-4、5-6 发现,除了硬币数量变化了其他步骤完全一样,即 这是一个子问题的分解过程100-33-11-3,将一个大问题分解成了容易解决的小问题,且 这些小问题相互独立,每一个 33 枚硬币和其他硬币互不影响。
实际上类似于数学归纳法,先找到最小问题规模的求解方程,然后考虑随着问题规模增大时的求解方程,然后根据方程式设计递归程序(当然,递归不是必须的),自顶而下的解决问题。

归并排序

归并排序是【分治算法】的典型应用:多次分解数列,直至子数列中只有一个元素(【分】)。然后对子数列排序,合并相邻的有序子数列,最终形成一个完整的有序数列(【治】)。

  • 【分】阶段:递归拆分子序列的过程,递归深度为 log2n
归并排序的分治算法.png
  • 【合】阶段:将两个已经有序的、相邻的子序列合并成一个有序的序列,效率可以达到 O(n)
    以最后一次合并为例:[4,5,7,8]、[1,2,3,6]
合并.png
  • JavaScript 版的实现
    function sort(arr, i, j) {
        // 直到拆分成只有一个元素的子序列
        if(i >= j) return
        // 取中间值,分区
        let mid = Math.floor((i + j) / 2)
        // 1. 拆分左侧一半
        sort(arr, i, mid)
        // 2. 拆分右侧一半
        sort(arr, mid+1, j)
        // 比较, 合并左侧和右侧的结果
        merge(arr, i, mid, j)
    }
    function merge(arr, left, mid, last) {
        // 一个临时数组 及其角标指针, 存储排序后的元素。即:归并排序 需要申请额外的空间
        let temp = [], k = 0
        // 左侧的起始角标 i, 右侧的起始角标 j
        let i = left, j = mid + 1
        while(i <= mid && j <= last) {
            // 左侧和右侧都是有序的, 一次比较,把较小的先放进临时数组
            // 即:相等的元素不会交换顺序,故归并排序是稳定的
            if(arr[i] < arr[j]) {
                temp[k++] = arr[i++]
            } else {
                temp[k++] = arr[j++]
            }
        }
        // 左侧子序列 和 右侧子序列 的长度不一定是相等的,但都是有序的,
        // 所以多出来的可以直接放进临时数组中
        while(i <= mid) {
            temp[k++] = arr[i++]
        }
        while(j <= last) {
            temp[k++] = arr[j++]
        }
        // 把排好序的数组temp, 添加进原数组中
        for(let n = 0; n < k; n++) {
            arr[left+n] = temp[n]
        }
    }
    let arr = [53, 16, 88, 79, 93, 19, 47, 20]
    //          0   1   2   3   4   5   6  7
    let i = 0, j = arr.length - 1
    sort(arr, i, j)  // [16, 19, 20, 47, 53, 79, 88, 93]
    

【归并排序】的效率是比较高的,设数列长为N,将数列分开成小数列一共要 logN 步,每步都是一个合并有序数列的过程,时间复杂度可以记为O(N),故一共为 O(N*logN)
因为【归并排序】每次都是在 相邻 的数据中进行操作,所以【归并排序】在 O(N*logN) 的几种排序方法(快速排序,归并排序,希尔排序,堆排序)也是效率比较高的。

快速排序

【快速排序】也是采用【分治算法】实现的,是对【冒泡算法】的改进。与【归并排序】不同的是,它需要从数列中挑出一个元素作为 基准

  • 【分区】操作: 所有元素比 基准 小的摆放在 基准 前面,比 基准值 大的摆在 基准 的后面(相同的数可以到任一边)。在这个分区退出之后,基准值 就处于数列的中间位置。
  • 递归地重复【分区操作】,直到分区内只有一个元素。

【快速排序】的难点在于 如何选取【基准点】,并按照【基准点】排序?
为了简单起见,以第一个元素作为 基准值

  • 填坑法
    思路:

    1. i=begin, j=last,将基准挖出,形成第一个坑arr[i]
    2. 从右向左(j--)找比基准值小的数,填入arr[i]的坑中,并把基准值填入arr[j]
    3. 从左向右(i++)找比基准值大的数,填入arr[j]的坑中,并把基准值填入arr[i]
    4. 重复步骤2-3,直至i >= j,则第一个分区完成,基准值左侧是比基准小的数,基准值右侧是比基准大的数,分别对左分区和有分区进行排序。
    function sort(arr, begin, last) {
        if(begin >= last) {
            return
        }
        let i = begin, j = last, mid
        let base = arr[i]
        while(true) {
            // j 从后向前找比 base 小的数, 并放到基准值左侧
            while(i !== j) {
                if(arr[j] < base) {
                    // 找到了, 与基准值交换位置
                    arr[i] = arr[j]
                    arr[j] = base  // j 指向基准值
                    break
                } else {
                    // 没找到, 则向后移动角标, 继续查找
                    j--
                }
            }
            if(i >= j) {
                // 结束了, 记录基准值的角标 j
                mid = j
                break
            }
            // i 从前向后找比 base 大的数, 并放到基准值右侧
            while(i !== j) {
                if(arr[i] > base) {
                    // 找到了, 与基准值交换位置
                    arr[j] = arr[i]
                    arr[i] = base  // i 总是指向基准值
                    break
                } else {
                    // 没找到, 则向后移动角标, 继续查找
                    i++
                }
            }
            if(i >= j) {
                // 结束了, 记录基准值的角标 i
                mid = i
                break
            }
        }
        // 左分区
        sort(arr, begin, mid-1)
        // 右分区
        sort(arr, mid + 1, last)
    }
    let arr = [53, 16, 88, 79, 93, 19, 47, 20]
    //          0   1   2   3   4   5   6  7
    let i = 0, j = arr.length - 1
    sort(arr, i, j)
    
  • 交换法
    思路:

    1. 分别设置左右两个指针:i=begin, j=last
    2. 以左侧第一个元素为基准时,先移动右侧指针j(稍后解释为什么)
    3. j遇到比基准小的数时,停止扫描;开始扫描左侧指针i
    4. i遇到比基准大的数时,停止扫描,并交换ij处的值,此时i处的元素值一定比基准小
    5. 继续移动指针j,重复步骤3-4,直到ji相遇,则交换基准与i/j处的元素值;
    6. 至此,第一个分区完成。基准值左侧一定是比基准小的数,基准值右侧是比基准大的数,然后分别对左分区和有分区进行排序。

    注:为什么先移动右指针j?当然,这不是绝对的,这取决于基准的位置,因为当两个指针相遇时,需要与基准交换元素值。当基准值在左边时,必须确保指针相遇的值一定比基准小,而左指针i 始终指向小于基准值的元素,所以让右指针j先移动,让ji靠拢,最终相遇。

    function sort(arr, begin, last) {
        if(begin >= last) {  // 结束
            return
        }
        let base = arr[begin]
        let i = begin, j = last
        while(i < j) {
            // 先移动右侧指针j, 找比基准值小的数
            while(i < j && arr[j] >= base) {
                j--
            }
            // 再移动左侧指针i, 找比基准值大的数
            while(i < j && arr[i] <= base) {
                i++
            }
            if(i < j) {
                // i 记录比基准值大的值, j 记录比基准值小的值, 交换元素值
                let temp = arr[i]
                arr[i] = arr[j]
                arr[j] = temp
            }
        }
        // 此时 i === j, 且 i 处的元素一定比基准值小, 交换元素值
        arr[begin] = arr[i]
        arr[i] = base
        // 第一次分区结束, 以基准值的位置i 为分区线, 递归分区比较
        // 1. 左侧分区
        sort(arr, begin, i - 1)
        // 2. 右侧分区
        sort(arr, i + 1, last)
    }
    

【快速排序】是不稳定的,它的速度与【基准点】有关,【基准点】的好坏大大影响速度。

  • 最差情况下,划分由 n 个元素构成的数组需要进行 n 次比较和 n 次移动,因此划分所需时间为 O(n)。最差时间复杂度 (n-1)+(n-2)+…+2+1= O(n^2)
  • 在最佳情况下,每次将数组划分为规模大致相等的两部分。和【归并排序】的分析相似,快速排序的T(n) = O(nlogn)

求数组中第K个最大(小)元素

思路:这也是分治思想的应用,与【快速排序】类似。但不同的是,【快速排序】每次都要处理基准两侧的分区,而【求第K个最大(小)元素】只需要处理一侧分区即可。

求最接近原点的 K 个点

找出最大子序列

求 x 的 n 次幂

棋盘覆盖问题

整数划分问题

全排列问题

汉诺塔问题

大整数乘法

快速傅里叶变换

循环比赛日程表

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,544评论 6 501
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,430评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,764评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,193评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,216评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,182评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,063评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,917评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,329评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,543评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,722评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,425评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,019评论 3 326
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,671评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,825评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,729评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,614评论 2 353

推荐阅读更多精彩内容

  • 分治法学习总结 分治法是我们经常用到的算法,合理利用分治算法可以使我们更好的解决问题。我们在使用二分查找、归并排序...
    鱼鱼鱼三条鱼ii阅读 5,084评论 0 5
  • Divide-and-Conquer算法的设计 设计过程分为三个阶段: Divide:整个问题划分为多个子问题 C...
    三三de酒阅读 3,267评论 0 4
  • 分治算法简介 在计算机科学中,分治法是一种很重要的算法。字面上的解释是“分而治之”,简单来说就是把一个问题分解为很...
    呼噜噜11阅读 514评论 0 0
  • # 基本思想: 字面上的解释是“分而治之”,就是将一个规模为N的问题分解为K个规模较小的子问题( 反复分解直到问题...
    Tenloy阅读 491评论 0 3
  • 5月以来,哪怕对市场风向再不敏感的人,也感觉到阵阵凉意。二级市场连续下挫,一级市场融资环境恶化,不论企业融资数量还...
    钱皓频道阅读 6,048评论 1 6