【熟练+技巧】前缀和、差分数组、快速选择、运算优先级.

做题的几个技法 ——

  1. 前缀和与打表;
  2. 差分数组;

前缀和技巧:解决子数组问题

今天来聊一道简单却十分巧妙的算法问题:算出一共有几个和为 k 的子数组。

图片

思路很简单,我把所有子数组都穷举出来,算它们的和,看看谁的和等于 k 不就行了。

关键是,如何快速得到某个子数组的和呢,比如说给你一个数组nums,让你实现一个接口sum(i, j),这个接口要返回nums[i…j]的和,而且会被多次调用,你怎么实现这个接口呢?

因为接口要被多次调用,显然不能每次都去遍历nums[i…j],有没有一种快速的方法在 O(1) 时间内算出nums[i…j]呢?这就需要前缀和技巧了。

1.1 什么是前缀和

前缀和的思路是这样的,对于一个给定的数组nums,我们额外开辟一个前缀和数组进行预处理:

int n = nums.length;
// 前缀和数组
int[] preSum = new int[n + 1];
preSum[0] = 0;
for (int i = 0; i < n; i++)
    preSum[i + 1] = preSum[i] + nums[i];

图片

这个前缀和数组preSum的含义也很好理解,preSum[i]就是nums[0…i-1]的和。那么如果我们想求nums[i…j]的和,只需要一步操作preSum[j+1]-preSum[i]即可,而不需要重新去遍历数组了。

回到这个子数组问题,我们想求有多少个子数组的和为 k,借助前缀和技巧很容易写出一个解法:

int subarraySum(int[] nums, int k) {
    int n = nums.length;
    // 构造前缀和
    int[] sum = new int[n + 1];
    sum[0] = 0; 
    for (int i = 0; i < n; i++)
        sum[i + 1] = sum[i] + nums[i];

    int ans = 0;
    // 穷举所有子数组
    for (int i = 1; i <= n; i++)
        for (int j = 0; j < i; j++)
            // sum of nums[j..i-1]
            if (sum[i] - sum[j] == k)
                ans++;

    return ans;
}

我的题解:C++

 /**
     * @Description: 前缀和 求子数组和。
     * @param {int} k
     * @return {*}
     * @notes: 暴力穷举  O(N^2)
     */
    int subarraySum(vector<int> &nums, int k)
    {
        int n = nums.size();
        // 构造前缀和
        vector<int> sum(n+1, 0);
        
        for (int i = 0; i < n; i++)
            sum[i+1] = sum[i]+nums[i];

        int ans = 0;
        // 穷举所有子数组
        for (int i = 1; i <= n; i++)
            for (int j = 0; j < i; j++)
                // sum of nums[j..i-1]
                if (sum[i] - sum[j] == k)
                    ans++;

        return ans;
    }

这个解法的时间复杂度图片空间复杂度图片,并不是最优的解法。不过通过这个解法理解了前缀和数组的工作原理之后,可以使用一些巧妙的办法把时间复杂度进一步降低。

1.2 优化解法

前面的解法有嵌套的 for 循环:

for (int i = 1; i <= n; i++)
    for (int j = 0; j < i; j++)
        if (sum[i] - sum[j] == k)
            ans++;

第二层 for 循环在干嘛呢?翻译一下就是,在计算,有几个j能够使得sum[i]和sum[j]的差为 k。毎找到一个这样的j,就把结果加一。

我们可以把 if 语句里的条件判断移项,这样写:

if (sum[j] == sum[i] - k)
    ans++;

优化的思路是:我直接记录下有几个sum[j]和sum[i]-k相等,直接更新结果,就避免了内层的 for 循环。我们可以用哈希表,在记录前缀和的同时记录该前缀和出现的次数。
我的题解: 使用hashmap 打表记录。

/**
     * @Description: 方法二: 计算等式 sum[i] - k == sum[j]. //
     * @param {vector<int>} &nums
     * @param {int} k
     * @return {*}
     * @notes: 关键 使用hashmap 每次的后面j加和过程中,直接count 有几个[前缀和-k] 出现的次数。
     */
    int subarraySum(vector<int> &nums, int k)
    {
        int n = nums.size();
        // 记录前缀和, 以及前缀和出现的次数。
        unordered_map<int, int> preSum;
        preSum[0] = 1;

        int ans = 0, sum0_i = 0;
        for(int i = 0; i<n ;i++){
            sum0_i += nums[i];

            int sum0_j = sum0_i - k;
            if(preSum.count(sum0_j)){
                ans+=preSum[sum0_j];
                // preSum[sum0_j] ++;
            }
            // else{ // 不存在 则加入
            //     preSum.emplace(sum0_i, 1);
            // }
            // 在不在都需要加1
            preSum[sum0_i]++;
        }
        return ans;
    }

比如说下面这个情况,需要前缀和 8 就能找到和为 k 的子数组了,之前的暴力解法需要遍历数组去数有几个 8,而优化解法借助哈希表可以直接得知有几个前缀和为 8。

图片

这样,就把时间复杂度降到了图片,是最优解法了。

1.3 总结

前缀和不难,却很有用,主要用于处理数组区间的问题。

比如说,让你统计班上同学考试成绩在不同分数段的百分比,也可以利用前缀和技巧:

int[] scores; // 存储着所有同学的分数
// 试卷满分 150 分
int[] count = new int[150 + 1]
// 记录每个分数有几个同学
for (int score : scores)
    count[score]++
// 构造前缀和
for (int i = 1; i < count.length; i++)
    count[i] = count[i] + count[i-1];
这样,给你任何一个分数段,你都能通过前缀和相减快速计算出这个分数段的人数,百分比也就很容易计算了。

但是,稍微复杂一些的算法问题,不止考察简单的前缀和技巧。比如本文探讨的这道题目,就需要借助前缀和的思路做进一步的优化,借助哈希表记录额外的信息。可见对题目的理解和细节的分析能力对于算法的优化是至关重要的。

二、论那些小而美的算法技巧:差分数组/前缀和

2.1 前言

大家好,我是算法老司机 labuladong,本文给大家介绍一个小而美的算法技巧:差分数组。

读完本文,你可以去解决力扣第 1109 题「航班预订统计」,难度 Medium

差分数组技巧是前文 前缀和技巧详解 写过的前缀和技巧的兄弟。

前缀和主要适用的场景是原始数组不会被修改的情况下,频繁查询某个区间的累加和。

重点记忆 下面前缀和框架】没看过前文没关系,这里简单介绍一下前缀和,核心代码就是下面这段:

class PrefixSum {
    // 前缀和数组
    private int[] prefix;

    /* 输入一个数组,构造前缀和 */
    public PrefixSum(int[] nums) {
        prefix = new int[nums.length + 1];
        // 计算 nums 的累加和
        for (int i = 1; i < prefix.length; i++) {
            prefix[i] = prefix[i - 1] + nums[i - 1];
        }
    }

    /* 查询闭区间 [i, j] 的累加和 */
    public int query(int i, int j) {
        return prefix[j + 1] - prefix[i];
    }
}

在这里插入图片描述

prefix[i]就代表着nums[0…i-1]所有元素的累加和,如果我们想求区间nums[i…j]的累加和,只要计算prefix[j+1] - prefix[i]即可,而不需要遍历整个区间求和。

本文讲一个和前缀和思想非常类似的算法技巧「差分数组」,差分数组的主要适用场景是频繁对原始数组的某个区间的元素进行增减。

比如说,我给你输入一个数组nums,然后又要求给区间nums[2…6]全部加 1,再给nums[3…9]全部减 3,再给nums[0…4]全部加 2,再给…

一通操作猛如虎,然后问你,最后nums数组的值是什么?

常规的思路很容易,你让我给区间nums[i…j]加上val,那我就一个 for 循环给它们都加上呗,还能咋样?这种思路的时间复杂度是 O(N),由于这个场景下对nums的修改非常频繁,所以效率会很低下。

这里就需要差分数组的技巧,类似前缀和技巧构造的prefix数组,我们先对nums数组构造一个diff差分数组,diff[i]就是nums[i]和nums[i-1]之差:

int[] diff = new int[nums.length];
// 构造差分数组
diff[0] = nums[0];
for (int i = 1; i < nums.length; i++) {
    diff[i] = nums[i] - nums[i - 1];
}

在这里插入图片描述

通过这个diff差分数组是可以反推出原始数组nums的,代码逻辑如下:

int[] res = new int[diff.length];
// 根据差分数组构造结果数组
res[0] = diff[0];
for (int i = 1; i < diff.length; i++) {
    res[i] = res[i - 1] + diff[i];
}

这样构造差分数组diff,就可以快速进行区间增减的操作,如果你想对区间nums[i…j]的元素全部加 3,那么只需要让diff[i] += 3,然后再让diff[j+1] -= 3即可:
在这里插入图片描述

原理很简单,回想diff数组反推nums数组的过程,diff[i] += 3意味着给nums[i…]所有的元素都加了 3,然后diff[j+1] -= 3又意味着对于nums[j+1…]所有元素再减 3,那综合起来,是不是就是对nums[i…j]中的所有元素都加 3 了?

只要花费 O(1) 的时间修改diff数组,就相当于给nums的整个区间做了修改。多次修改diff,然后通过diff数组反推,即可得到nums修改后的结果。

【关键记忆】现在我们把差分数组抽象成一个类,包含increment方法和result方法:

关键记住构造 diff差分数组过程;
与增加diff来 还原回固定区间数值的算法;

以上就能一遍 完成区间数组的累加。

class Difference {
    // 差分数组
    private int[] diff;

    public Difference(int[] nums) {
        assert nums.length > 0;
        diff = new int[nums.length];
        // 构造差分数组
        diff[0] = nums[0];
        for (int i = 1; i < nums.length; i++) {
            diff[i] = nums[i] - nums[i - 1];
        }
    }

    /* 给闭区间 [i,j] 增加 val(可以是负数)*/
    public void increment(int i, int j, int val) {
        diff[i] += val;
        if (j + 1 < diff.length) {
            diff[j + 1] -= val;
        }
    }

    public int[] result() {
        int[] res = new int[diff.length];
        // 根据差分数组构造结果数组
        res[0] = diff[0];
        for (int i = 1; i < diff.length; i++) {
            res[i] = res[i - 1] + diff[i];
        }
        return res;
    }
}

这里注意一下increment方法中的 if 语句:

public void increment(int i, int j, int val) {
    diff[i] += val;
    if (j + 1 < diff.length) {
        diff[j + 1] -= val;
    }
}

当j+1 >= diff.length时,说明是对nums[i]及以后的整个数组都进行修改,那么就不需要再给diff数组减val了。

2.2 算法实践

这里看一下力扣第 1109 题「航班预订统计」:
在这里插入图片描述

函数签名如下:

int[] corpFlightBookings(int[][] bookings, int n)

这个题目就在那绕弯弯,其实它就是个差分数组的题,我给你翻译一下:

给你输入一个长度为n的数组nums,其中所有元素都是 0。再给你输入一个bookings,里面是若干三元组(i,j,k),每个三元组的含义就是要求你给nums数组的闭区间[i-1,j-1]中所有元素都加上k。请你返回最后的nums数组是多少?

PS:因为题目说的n是从 1 开始计数的,而数组索引从 0 开始,所以对于输入的三元组(i,j,k),数组区间应该对应[i-1,j-1]。

这么一看,不就是一道标准的差分数组题嘛?我们可以直接复用刚才写的类:

int[] corpFlightBookings(int[][] bookings, int n) {
    // nums 初始化为全 0
    int[] nums = new int[n];
    // 构造差分解法
    Difference df = new Difference(nums);

    for (int[] booking : bookings) {
        // 注意转成数组索引要减一哦
        int i = booking[0] - 1;
        int j = booking[1] - 1;
        int val = booking[2];
        // 对区间 nums[i..j] 增加 val
        df.increment(i, j, val);
    }
    // 返回最终的结果数组
    return df.result();
}

这道题就解决了。

其实我觉得差分数组和前缀和数组都是比较常见且巧妙的算法技巧,分别适用不同的常见,而且是会者不难,难者不会。所以,关于差分数组的使用,你学会了吗?

三、快速选择算法详解—快排亲兄弟

快速选择算法是一个非常经典的算法,和快速排序算法是亲兄弟。

原始题目很简单,给你输入一个无序的数组nums和一个正整数k,让你计算nums中第k大的元素。

那你肯定说,给nums数组排个序,然后取第k个元素,也就是nums[k-1],不就行了吗?

当然可以,但是排序时间复杂度是O(NlogN),其中N表示数组nums的长度。

我们就想要第k大的元素,却给整个数组排序,有点杀鸡用牛刀的感觉,所以这里就有一些小技巧了,可以把时间复杂度降低到O(NlogK)甚至是O(N),下面我们就来具体讲讲。

力扣第 215 题「数组中的第 K 个最大元素」就是一道类似的题目,函数签名如下:

int findKthLargest(int[] nums, int k);

在这里插入图片描述

只不过题目要求找第k个最大的元素,和我们刚才说的第k大的元素在语义上不太一样,题目的意思相当于是把nums数组降序排列,然后返回第k个元素。

比如输入nums = [2,1,5,4], k = 2,算法应该返回 4,因为 4 是nums中第 2 个最大的元素。

这种问题有两种解法,一种是二叉堆(优先队列)的解法,另一种就是标题说到的快速选择算法(Quick Select),我们分别来看。

3.1 二叉堆解法

二叉堆的解法比较简单,实际写算法题的时候,推荐大家写这种解法,先直接看代码吧:

int findKthLargest(int[] nums, int k) {
    // 小顶堆,堆顶是最小元素
    PriorityQueue<Integer> 
        pq = new PriorityQueue<>();
    for (int e : nums) {
        // 每个元素都要过一遍二叉堆
        pq.offer(e);
        // 堆中元素多于 k 个时,删除堆顶元素
        if (pq.size() > k) {
            pq.poll();
        }
    }
    // pq 中剩下的是 nums 中 k 个最大元素,
    // 堆顶是最小的那个,即第 k 个最大元素
    return pq.peek();
}

二叉堆(优先队列)是比较常见的数据结构,可以认为它会自动排序,我们前文 手把手实现二叉堆数据结构 实现过这种结构,我就默认大家熟悉它的特性了。

看代码应该不难理解,可以把小顶堆pq理解成一个筛子,较大的元素会沉淀下去,较小的元素会浮上来;当堆大小超过k的时候,我们就删掉堆顶的元素,因为这些元素比较小,而我们想要的是前k个最大元素嘛。当nums中的所有元素都过了一遍之后,筛子里面留下的就是最大的k个元素,而堆顶元素是堆中最小的元素,也就是「第k个最大的元素」。

二叉堆插入和删除的时间复杂度和堆中的元素个数有关,在这里我们堆的大小不会超过k,所以插入和删除元素的复杂度是O(logK),再套一层 for 循环,总的时间复杂度就是O(NlogK)。空间复杂度很显然就是二叉堆的大小,为O(K)。

这个解法算是比较简单的吧,代码少也不容易出错,所以说如果笔试面试中出现类似的问题,建议用这种解法。唯一注意的是,Java 的PriorityQueue默认实现是小顶堆,有的语言的优先队列可能默认是大顶堆,可能需要做一些调整。

3.2 快速选择算法

快速选择算法比较巧妙,时间复杂度更低,是快速排序的简化版,一定要熟悉思路。

我们先从快速排序讲起。

快速排序的逻辑是,若要对nums[lo…hi]进行排序,我们先找一个分界点p,通过交换元素使得nums[lo…p-1]都小于等于nums[p],且nums[p+1…hi]都大于nums[p],然后递归地去nums[lo…p-1]和nums[p+1…hi]中寻找新的分界点,最后整个数组就被排序了。

快速排序的代码如下:

/* 快速排序主函数 */
void sort(int[] nums) {
    // 一般要在这用洗牌算法将 nums 数组打乱,
    // 以保证较高的效率,我们暂时省略这个细节
    sort(nums, 0, nums.length - 1);
}

/* 快速排序核心逻辑 */
void sort(int[] nums, int lo, int hi) {
    if (lo >= hi) return;
    // 通过交换元素构建分界点索引 p
    int p = partition(nums, lo, hi);
    // 现在 nums[lo..p-1] 都小于 nums[p],
    // 且 nums[p+1..hi] 都大于 nums[p]
    sort(nums, lo, p - 1);
    sort(nums, p + 1, hi);
}

关键就在于这个分界点索引p的确定,我们画个图看下partition函数有什么功效:

图片

索引p左侧的元素都比nums[p]小,右侧的元素都比nums[p]大,意味着这个元素已经放到了正确的位置上,回顾快速排序的逻辑,递归调用会把nums[p]之外的元素也都放到正确的位置上,从而实现整个数组排序,这就是快速排序的核心逻辑。

那么这个partition函数如何实现的呢?看下代码:

int partition(int[] nums, int lo, int hi) {
    if (lo == hi) return lo;
    // 将 nums[lo] 作为默认分界点 pivot
    int pivot = nums[lo];
    // j = hi + 1 因为 while 中会先执行 --
    int i = lo, j = hi + 1;
    while (true) {
        // 保证 nums[lo..i] 都小于 pivot
        while (nums[++i] < pivot) {
            if (i == hi) break;
        }
        // 保证 nums[j..hi] 都大于 pivot
        while (nums[--j] > pivot) {
            if (j == lo) break;
        }
        if (i >= j) break;
        // 如果走到这里,一定有:
        // nums[i] > pivot && nums[j] < pivot
        // 所以需要交换 nums[i] 和 nums[j],
        // 保证 nums[lo..i] < pivot < nums[j..hi]
        swap(nums, i, j);
    }
    // 将 pivot 值交换到正确的位置
    swap(nums, j, lo);
    // 现在 nums[lo..j-1] < nums[j] < nums[j+1..hi]
    return j;
}

// 交换数组中的两个元素
void swap(int[] nums, int i, int j) {
    int temp = nums[i];
    nums[i] = nums[j];
    nums[j] = temp;
}

熟悉快速排序逻辑的读者应该可以理解这段代码的含义了,这个partition函数细节较多,上述代码参考《算法4》,是众多写法中最漂亮简洁的一种,所以建议背住,这里就不展开解释了。

好了,对于快速排序的探讨到此结束,我们回到一开始的问题,寻找第k大的元素,和快速排序有什么关系?

注意这段代码:

int p = partition(nums, lo, hi);

我们刚说了,partition函数会将nums[p]排到正确的位置,使得nums[lo…p-1] < nums[p] < nums[p+1…hi]。

那么我们可以把p和k进行比较,如果p < k说明第k大的元素在nums[p+1…hi]中,如果p > k说明第k大的元素在nums[lo…p-1]中。

所以我们可以复用partition函数来实现这道题目,不过在这之前还是要做一下索引转化:

题目要求的是「第k个最大元素」,这个元素其实就是nums升序排序后「索引」为len(nums) - k的这个元素。

这样就可以写出解法代码:

int findKthLargest(int[] nums, int k) {
    int lo = 0, hi = nums.length - 1;
    // 索引转化
    k = nums.length - k;
    while (lo <= hi) {
        // 在 nums[lo..hi] 中选一个分界点
        int p = partition(nums, lo, hi);
        if (p < k) {
            // 第 k 大的元素在 nums[p+1..hi] 中
            lo = p + 1;
        } else if (p > k) {
            // 第 k 大的元素在 nums[lo..p-1] 中
            hi = p - 1;
        } else {
            // 找到第 k 大元素
            return nums[p];
        }
    }
    return -1;
}

这个代码框架其实非常像我们前文 二分搜索框架 的代码,这也是这个算法高效的原因,但是时间复杂度为什么是O(N)呢?按理说类似二分搜索的逻辑,时间复杂度应该一定会出现对数才对呀?

其实这个O(N)的时间复杂度是个均摊复杂度,因为我们的partition函数中需要利用 双指针技巧 遍历nums[lo…hi],那么总共遍历了多少元素呢?

最好情况下,每次p都恰好是正中间(lo + hi) / 2,那么遍历的元素总数就是:

N + N/2 + N/4 + N/8 + … + 1

这就是等比数列求和公式嘛,求个极限就等于2N,所以遍历元素个数为2N,时间复杂度为O(N)。

但我们其实不能保证每次p都是正中间的索引的,最坏情况下p一直都是lo + 1或者一直都是hi - 1,遍历的元素总数就是:

N + (N - 1) + (N - 2) + … + 1

这就是个等差数列求和,时间复杂度会退化到O(N^2),为了尽可能防止极端情况发生,我们需要在算法开始的时候对nums数组来一次随机打乱:

int findKthLargest(int[] nums, int k) {
    // 首先随机打乱数组
    shuffle(nums);
    // 其他都不变
    int lo = 0, hi = nums.length - 1;
    k = nums.length - k;
    while (lo <= hi) {
        // ...
    }
    return -1;
}

// 对数组元素进行随机打乱
void shuffle(int[] nums) {
    int n = nums.length;
    Random rand = new Random();
    for (int i = 0 ; i < n; i++) {
        // 从 i 到最后随机选一个元素
        int r = i + rand.nextInt(n - i);
        swap(nums, i, r);
    }
}

前文 洗牌算法详解 写过随机乱置算法,这里就不展开了。当你加上这段代码之后,平均时间复杂度就是O(N)了,提交代码后运行速度大幅提升。

总结一下,快速选择算法就是快速排序的简化版,复用了partition函数,快速定位第 k 大的元素。相当于对数组部分排序而不需要完全排序,从而提高算法效率,将平均时间复杂度降到O(N)。

四、分治算法详解:表达式的不同优先级

我们已经写了 动态规划算法,回溯(DFS)算法,BFS 算法,贪心算法,双指针算法,滑动窗口算法,现在就差个分治算法没写了,今天来写一下,集齐七颗龙珠,就能召唤神龙了~

其实,我觉得回溯、分治和动态规划算法可以划为一类,因为它们都会涉及递归。

回溯算法就一种简单粗暴的算法技巧,说白了就是一个暴力穷举算法,比如让你 用回溯算法求子集、全排列、组合,你就穷举呗,就考你会不会漏掉或者多算某些情况。

动态规划是一类算法问题,肯定是让你求最值的。因为动态规划问题拥有 最优子结构,可以通过状态转移方程从小规模的子问题最优解推导出大规模问题的最优解。

分治算法呢,可以认为是一种算法思想,通过将原问题分解成小规模的子问题,然后根据子问题的结果构造出原问题的答案。这里有点类似动态规划,所以说运用分治算法也需要满足一些条件,你的原问题结果应该可以通过合并子问题结果来计算。

其实这几个算法之间界定并没有那么清晰,有时候回溯算法加个备忘录似乎就成动态规划了,而分治算法有时候也可以加备忘录进行剪枝。

我觉得吧,没必要过分纠结每个算法的定义,定义这东西无非文学词汇而已,反正能把题做出来你说这是啥算法都行,所以大家还是得多刷题,刷出感觉,各种算法都手到擒来。

最典型的分治算法就是归并排序了,核心逻辑如下:

void sort(int[] nums, int lo, int hi) {
    int mid = (lo + hi) / 2;
    /****** 分 ******/
    // 对数组的两部分分别排序
    sort(nums, lo, mid);
    sort(nums, mid + 1, hi);
    /****** 治 ******/
    // 合并两个排好序的子数组
    merge(nums, lo, mid, hi);
}

「对数组排序」是一个可以运用分治思想的算法问题,只要我先把数组的左半部分排序,再把右半部分排序,最后把两部分合并,不就是对整个数组排序了吗?

下面来看一道具体的算法题。

4.1 添加括号的所有方式

我来借力扣第 241 题讲讲什么是分治算法,先看看题目:

在这里插入图片描述
简单说,就是给你输入一个算式,你可以给它随意加括号,请你穷举出所有可能的加括号方式,并计算出对应的结果。

函数签名如下:

// 计算所有加括号的结果
List<Integer> diffWaysToCompute(String input);

看到这道题的第一感觉肯定是复杂,我要穷举出所有可能的加括号方式,是不是还要考虑括号的合法性?是不是还要考虑计算的优先级?

是的,这些都要考虑,但是不需要我们来考虑。利用分治思想和递归函数,算法会帮我们考虑一切细节,也许这就是算法的魅力吧,哈哈哈。

废话不多说,解决本题的关键有两点:

1、不要思考整体,而是把目光聚焦局部,只看一个运算符。

这一点我们前文经常提及,比如 手把手刷二叉树第一期 就告诉你解决二叉树系列问题只要思考每个节点需要做什么,而不要思考整棵树需要做什么。

说白了,解决递归相关的算法问题,就是一个化整为零的过程,你必须瞄准一个小的突破口,然后把问题拆解,大而化小,利用递归函数来解决。

2、明确递归函数的定义是什么,相信并且利用好函数的定义。

这也是前文经常提到的一个点,因为递归函数要自己调用自己,你必须搞清楚函数到底能干嘛,才能正确进行递归调用。

下面来具体解释下这两个关键点怎么理解。

我们先举个例子,比如我给你输入这样一个算式:

1 + 2 * 3 - 4 * 5

请问,这个算式有几种加括号的方式?请在一秒之内回答我。

估计你回答不出来,因为括号可以嵌套,要穷举出来肯定得费点功夫。

不过呢,嵌套这个事情吧,我们人类来看是很头疼的,但对于算法来说嵌套括号不要太简单,一次递归就可以嵌套一层,一次搞不定大不了多递归几次。

所以,作为写算法的人类,我们只需要思考,如果不让括号嵌套(即只加一层括号),有几种加括号的方式?

还是上面的例子,显然我们有四种加括号方式:

(1) + (2 * 3 - 4 * 5)

(1 + 2) * (3 - 4 * 5)

(1 + 2 * 3) - (4 * 5)

(1 + 2 * 3 - 4) * (5)

发现规律了么?其实就是按照运算符进行分割,给每个运算符的左右两部分加括号,这就是之前说的第一个关键点,不要考虑整体,而是聚焦每个运算符。

现在单独说上面的第三种情况:

(1 + 2 * 3) - (4 * 5)

我们用减号-作为分隔,把原算式分解成两个算式1 + 2 * 3和4 * 5。

分治分治,分而治之,这一步就是把原问题进行了「分」,我们现在要开始「治」了。

1 + 2 * 3可以有两种加括号的方式,分别是:

(1) + (2 * 3) = 7

(1 + 2) * (3) = 9

或者我们可以写成这种形式:

1 + 2 * 3 = [9, 7]

而4 * 5当然只有一种加括号方式,就是4 * 5 = [20]。

然后呢,你能不能通过上述结果推导出(1 + 2 * 3) - (4 * 5)有几种加括号方式,或者说有几种不同的结果?

显然,可以推导出来(1 + 2 * 3) - (4 * 5)有两种结果,分别是:

9 - 20 = -11

7 - 20 = -13

那你可能要问了,1 + 2 * 3 = [9, 7]的结果是我们自己看出来的,如何让算法计算出来这个结果呢?

这个简单啊,再回头看下题目给出的函数签名:

// 定义:计算算式 input 所有可能的运算结果
List<Integer> diffWaysToCompute(String input);

这个函数不就是干这个事儿的吗?这就是我们之前说的第二个关键点,明确函数的定义,相信并且利用这个函数定义。

你甭管这个函数怎么做到的,你相信它能做到,然后用就行了,最后它就真的能做到了。

那么,对于(1 + 2 * 3) - (4 * 5)这个例子,我们的计算逻辑其实就是这段代码:

List<Integer> diffWaysToCompute("(1 + 2 * 3) - (4 * 5)") {
    List<Integer> res = new LinkedList<>();
    /****** 分 ******/
    List<Integer> left = diffWaysToCompute("1 + 2 * 3");
    List<Integer> right = diffWaysToCompute("4 * 5");
    /****** 治 ******/
    for (int a : left)
        for (int b : right)
            res.add(a - b);

    return res;
}

好,现在(1 + 2 * 3) - (4 * 5)这个例子是如何计算的,你应该完全理解了吧,那么回来看我们的原始问题。

原问题1 + 2 * 3 - 4 * 5是不是只有(1 + 2 * 3) - (4 * 5)这一种情况?是不是只能从减号-进行分割?

不是,每个运算符都可以把原问题分割成两个子问题,刚才已经列出了所有可能的分割方式:

(1) + (2 * 3 - 4 * 5)

(1 + 2) * (3 - 4 * 5)

(1 + 2 * 3) - (4 * 5)

(1 + 2 * 3 - 4) * (5)

所以,我们需要穷举上述的每一种情况,可以进一步细化一下解法代码:

List<Integer> diffWaysToCompute(String input) {
    List<Integer> res = new LinkedList<>();
    for (int i = 0; i < input.length(); i++) {
        char c = input.charAt(i);
        // 扫描算式 input 中的运算符
        if (c == '-' || c == '*' || c == '+') {
            /****** 分 ******/
            // 以运算符为中心,分割成两个字符串,分别递归计算
            List<Integer> 
                left = diffWaysToCompute(input.substring(0, i));
            List<Integer> 
                right = diffWaysToCompute(input.substring(i + 1));
            /****** 治 ******/
            // 通过子问题的结果,合成原问题的结果
            for (int a : left)
                for (int b : right)
                    if (c == '+')
                        res.add(a + b);
                    else if (c == '-')
                        res.add(a - b);
                    else if (c == '*')
                        res.add(a * b);
        }
    }
    // base case
    // 如果 res 为空,说明算式是一个数字,没有运算符
    if (res.isEmpty()) {
        res.add(Integer.parseInt(input));
    }
    return res;
}

有了刚才的铺垫,这段代码应该很好理解了吧,就是扫描输入的算式input,每当遇到运算符就进行分割,递归计算出结果后,根据运算符来合并结果。

这就是典型的分治思路,先「分」后「治」,先按照运算符将原问题拆解成多个子问题,然后通过子问题的结果来合成原问题的结果。

当然,一个重点在这段代码:

// base case
// 如果 res 为空,说明算式是一个数字,没有运算符
if (res.isEmpty()) {
    res.add(Integer.parseInt(input));
}

递归函数必须有个 base case 用来结束递归,其实这段代码就是我们分治算法的 base case,代表着你「分」到什么时候可以开始「治」。

我们是按照运算符进行「分」的,一直这么分下去,什么时候是个头?显然,当算式中不存在运算符的时候就可以结束了。

那为什么以res.isEmpty()作为判断条件?因为当算式中不存在运算符的时候,就不会触发 if 语句,也就不会给res中添加任何元素。

至此,这道题的解法代码就写出来了,但是时间复杂度是多少呢?

如果单看代码,真的很难通过 for 循环的次数看出复杂度是多少,所以我们需要改变思路,本题在求所有可能的计算结果,不就相当于在求算式input的所有合法括号组合吗?

那么,对于一个算式,有多少种合法的括号组合呢?这就是著名的「卡特兰数」了,最终结果是一个组合数,推导过程稍有些复杂,我这里就不写了,有兴趣的读者可以自行搜索了解一下。

**其实本题还有一个小的优化,可以进行递归剪枝,**减少一些重复计算,比如说输入的算式如下:

1 + 1 + 1 + 1 + 1

那么按照算法逻辑,按照运算符进行分割,一定存在下面两种分割情况:

(1 + 1) + (1 + 1 + 1)

(1 + 1 + 1) + (1 + 1)

算法会依次递归每一种情况,其实就是冗余计算嘛,所以我们可以对解法代码稍作修改,加一个备忘录来避免这种重复计算:

// 备忘录
HashMap<String, List<Integer>> memo = new HashMap<>();

List<Integer> diffWaysToCompute(String input) {
    // 避免重复计算
    if (memo.containsKey(input)) {
        return memo.get(input);
    }
    /****** 其他都不变 ******/
    List<Integer> res = new LinkedList<>();
    for (int i = 0; i < input.length(); i++) {
        // ...
    }
    if (res.isEmpty()) {
        res.add(Integer.parseInt(input));
    }
    /***********************/

    // 将结果添加进备忘录
    memo.put(input, res);
    return res;
}

当然,这个优化没有改变原始的复杂度,只是对一些特殊情况做了剪枝,提升了效率
我的题解:C++


class Solution {
public:
    /**
     * @Description: 递归、回溯、动规、   分治类似—— 先分 后治(merge)
     * @param {string} expression
     * @return {*}
     * @notes: 
     */
    vector<int> diffWaysToCompute(string expression) {
        // 先分开, 到底计算出有几个数据 返回得到的数组; 然后合并起来 
        vector<int> res;
        // 分而治之 —— 分开
        for(int i = 0; i< expression.size() ;i++){
            if(expression[i] == '+' || expression[i] == '-' || expression[i] == '*'){
                // 遇到运算符号之时开始分开运算
                vector<int> left = diffWaysToCompute(expression.substr(0, i));
                vector<int> right = diffWaysToCompute(expression.substr(i+1, expression.size()-i-1));

                //之后 进行merge
                for(int le : left){
                    for(int ri : right){
                        if(expression[i] == '+'){
                            res.push_back(le+ri);
                        }else if(expression[i] == '-'){
                            res.push_back(le - ri);
                        }else if(expression[i] == '*'){
                            res.push_back(le*ri);
                        }
                    }
                }
            }
            // 结束此间递归的分治
        }
        // 返回得到的res
            // 注意当当前存在 数字进行返回时要 —— 返回对应的数字
        if(res.empty()){
            stringstream sstream;
            int temp;
            sstream << expression;
            sstream >> temp;
            res.push_back(temp);
        }
        return res;
    }
    /**
     * @Description: 基于上述分治,新增备忘录  加快程序运算
     * @param {string} expression
     * @return {*}
     * @notes: 【这样做】极大地减少了内存的消耗。
     */
    unordered_map<string, vector<int>> memo;
    vector<int> diffWaysToCompute(string expression) {
        // 先分开, 到底计算出有几个数据 返回得到的数组; 然后合并起来 
        vector<int> res;
        if(memo.count(expression) > 0) {
            return memo[expression];
        }
        // 分而治之 —— 分开
        for(int i = 0; i< expression.size() ;i++){
            if(expression[i] == '+' || expression[i] == '-' || expression[i] == '*'){
                // 遇到运算符号之时开始分开运算
                vector<int> left = diffWaysToCompute(expression.substr(0, i));
                vector<int> right = diffWaysToCompute(expression.substr(i+1, expression.size()-i-1));

                //之后 进行merge
                for(int le : left){
                    for(int ri : right){
                        if(expression[i] == '+'){
                            res.push_back(le+ri);
                        }else if(expression[i] == '-'){
                            res.push_back(le - ri);
                        }else if(expression[i] == '*'){
                            res.push_back(le*ri);
                        }
                    }
                }
            }
            // 结束此间递归的分治
        }
        // 返回得到的res
            // 注意当当前存在 数字进行返回时要 —— 返回对应的数字
        if(res.empty()){
            stringstream sstream;
            int temp;
            sstream << expression;
            sstream >> temp;
            res.push_back(temp);
        }
        memo.emplace(expression, res);
        return res;
    }
};

4.2 最后总结

解决上述算法题利用了分治思想,以每个运算符作为分割点,把复杂问题分解成小的子问题,递归求解子问题,然后再通过子问题的结果计算出原问题的结果。

把大规模的问题分解成小规模的问题递归求解,应该是计算机思维的精髓了吧,建议大家多练,

  • 2
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值