回溯+记忆搜索——力扣每日一题2024.8.25

news2024/12/22 17:36:52

   

给定一个整数数组  nums 和一个正整数 k,找出是否有可能把这个数组分成 k 个非空子集,其总和都相等。

示例 1:

输入: nums = [4, 3, 2, 3, 5, 2, 1], k = 4
输出: True
说明: 有可能将其分成 4 个子集(5),(1,4),(2,3),(2,3)等于总和。

示例 2:

输入: nums = [1,2,3,4], k = 3
输出: false

刚开始读这道题的时候没有一个好的思路那就暴力呗。​

如何暴力?有两种方式:把k个非空子集看成是k个盒子最终要保证把装有 n 个数字的数组 nums 分成 k 个和相同的集合,这些盒子中存储数值的总和保持一致。然后分成两种情况:​

1. 对于nums中的每个数选择进入到k个盒子中的某一个​

2.对于每个盒子,遍历nums中的n个数字,选择是否将当前遍历的数字放入盒子中​

他们的主要区别在于时间、空间复杂度的不同,我们要分析比较选出复杂度更低的解法。​

好了,让我们先来看一下第一种情况:​

// k 个盒子(集合),记录每个盒子装的数字之和​
int[] bucket = new int[k];​
​
// 穷举 nums 中的每个数字​
for (int index = 0; index < nums.length; index++) {​
    // 穷举每个盒子
    for (int i = 0; i < k; i++) {​
        // nums[index] 选择是否要进入第 i 个盒子​
        // 选择装进第 i 个盒子
        bucket[i] += nums[index];​
        // 递归穷举下一个数字的选择​
        backtrack(nums, index + 1);​
        // 撤销选择​
        bucket[i] -= nums[index];​
    }​
}

这样子提交是TLE的那就得再想想如何优化了——剪枝​

如何剪枝?​

if (bucket[i] + nums[index] > target) {​
    continue;​
}

这样子还是TLE还需要进一步优化​

如果我们让尽可能多的情况命中剪枝的那个 if 分支,就可以减少递归调用的次数,一定程度上减少时间复杂度。​

如何尽可能多的命中这个 if 分支呢?要知道我们的 index 参数是从 0 开始递增的,也就是递归地从 0 开始遍历 nums 数组。​

如果我们提前对 nums 数组排序,把大的数字排在前面,那么大的数字会先被分配到 bucket 中,对于之后的数字,bucket[i] + nums[index] 会更大,更容易触发剪枝的 if 条件。​

boolean canPartitionKSubsets(int[] nums, int k) {​
    // 其他代码不变​
    // ...​
    /* 降序排序 nums 数组 /​
    Arrays.sort(nums);​
    // 翻转数组,得到降序数组​
    for (i = 0, j = nums.length - 1; i < j; i++, j--) {​
        int temp = nums[i];​
        nums[i] = nums[j];​
        nums[j] = temp;​
    }​
*    /*******************/​
    return backtrack(nums, 0, bucket, target);​
}

完整代码:​

class Solution {​
    public boolean canPartitionKSubsets(int[] nums, int k) {​
        if (k > nums.length) return false;​
        int sum = 0;​
        for (int v : nums) sum += v;​
        if (sum % k != 0) return false;​
​
        int[] bucket = new int[k];​
        int target = sum / k;​
        Arrays.sort(nums);​
        for (int i = 0, j = nums.length - 1; i < j; i++ , j--) {​
            int tmp = nums[i];​
            nums[i] = nums[j];​
            nums[j] = tmp;​
        }​
        return backtrack(nums, 0, bucket, target);​
    }​
​
    boolean backtrack(int[] nums, int index, int[] bucket, int target) {​
        if (index == nums.length) {​
            for (int i = 0; i < bucket.length; i++ ) {​
                if (bucket[i] != target) return false;​
            }​
            return true;​
        }​
        for (int i = 0; i < bucket.length; i++ ) {​
            if (bucket[i] + nums[index] > target) continue;​
            bucket[i] += nums[index];​
            if (backtrack(nums, index + 1, bucket, target)) {​
                return true;​
            }​
            bucket[i] -= nums[index];​
​
        }​
        return false;​
    }​
}

但是这样还是TLE,我真崩溃了TAT​

别急啊这只是讨论了一种视角,还有另一种视角呢​

盒子视角:​

以盒子的视角进行穷举,每个盒子需要遍历 nums 中的所有数字,决定是否把当前数字装进盒子中;当装满一个盒子之后,还要装下一个盒子,直到所有盒子都装满为止。​

// 装满所有盒子为止​
while (k > 0) {​
    // 记录当前盒子中的数字之和​
    int bucket = 0;​
    for (int i = 0; i < nums.length; i++) {​
        // 决定是否将 nums[i] 放入当前盒子中​
        if (canAdd(bucket, num[i])) {​
            bucket += nums[i];​
        }​
        if (bucket == target) {​
            // 装满了一个盒子,装下一个盒子
            k--;​
            break;​
        }​
    }​
}

也可以把这个 while 循环改写成递归函数,不过比刚才略微复杂一些,首先写一个 backtrack 递归函数出来:​

boolean backtrack(int k, int bucket, int[] nums, int start, boolean[] used, int target);

他的含义是:​

现在 k 号盒子正在思考是否应该把 nums[start] 这个元素装进来;目前 k 号盒子里面已经装的数字之和为 bucket;used 标志某一个元素是否已经被装到盒子中;target 是每个盒子需要达成的目标和。​

步骤:​

1.需要遍历 nums 中所有数字,决定哪些数字需要装到当前桶中。​

2.如果当前桶装满了(桶内数字和达到 target),则让下一个桶开始执行第 1 步。​

 boolean backtrack(int k, int bucket, int[] nums, int start, boolean[] used, int target) {​
        // base case​
        if (k == 0) {​
            // 所有盒子都被装满了,而且 nums 一定全部用完了​
            // 因为 target == sum / k​
            return true;​
        }​
        if (bucket == target) {​
​
            // 装满了当前盒子,递归穷举下一个盒子的选择​
            // 让下一个盒子从 nums[0] 开始选数字​
            return backtrack(k - 1, 0 ,nums, 0, used, target);​
        }​
​
        // 从 start 开始向后探查有效的 nums[i] 装入当前盒子​
        for (int i = start; i < nums.length; i++) {​
            // 剪枝​
            if (used[i]) {​
                // nums[i] 已经被装入别的盒子中​
                continue;​
            }​
            if (nums[i] + bucket > target) {​
                // 当前盒子装不下 nums[i]​
                continue;​
            }​
            // 做选择,将 nums[i] 装入当前盒子中​
            used[i] = true;​
            bucket += nums[i];​
            // 递归穷举下一个数字是否装入当前盒子​
            if (backtrack(k, bucket, nums, i + 1, used, target)) {​
                return true;​
            }​
            // 撤销选择​
            used[i] = false;​
            bucket -= nums[i];​
        }​
        // 穷举了所有数字,都无法装满当前盒子
        return false;​
    }

我能想到的也就到这里了,下面是东哥的原文:​

这段代码是可以得出正确答案的,但是效率很低,我们可以思考一下是否还有优化的空间。​

首先,在这个解法中每个桶都可以认为是没有差异的,但是我们的回溯算法却会对它们区别对待,这里就会出现重复计算的情况。​

什么意思呢?我们的回溯算法,说到底就是穷举所有可能的组合,然后看是否能找出和为 target 的 k 个桶(子集)。​

那么,比如下面这种情况,target = 5,算法会在第一个桶里面装 1, 4:​

现在第一个桶装满了,就开始装第二个桶,算法会装入 2, 3:​

然后以此类推,对后面的元素进行穷举,凑出若干个和为 5 的桶(子集)。​

但问题是,如果最后发现无法凑出和为 target 的 k 个子集,算法会怎么做?​

回溯算法会回溯到第一个桶,重新开始穷举,现在它知道第一个桶里装 1, 4 是不可行的,它会尝试把 2, 3 装到第一个桶里:​

现在第一个桶装满了,就开始装第二个桶,算法会装入 1, 4:​

好,到这里你应该看出来问题了,这种情况其实和之前的那种情况是一样的。也就是说,到这里你其实已经知道不需要再穷举了,必然凑不出来和为 target 的 k 个子集。​

但我们的算法还是会傻乎乎地继续穷举,因为在她看来,第一个桶和第二个桶里面装的元素不一样,那这就是两种不一样的情况呀。​

那么我们怎么让算法的智商提高,识别出这种情况,避免冗余计算呢?​

你注意这两种情况的 used 数组肯定长得一样,所以 used 数组可以认为是回溯过程中的「状态」。​

所以,我们可以用一个 memo 备忘录,在装满一个桶时记录当前 used 的状态,如果当前 used 的状态是曾经出现过的,那就不用再继续穷举,从而起到剪枝避免冗余计算的作用。​

有读者肯定会问,used 是一个布尔数组,怎么作为键进行存储呢?这其实是小问题,比如我们可以把数组转化成字符串,这样就可以作为哈希表的键进行存储了。​

class Solution {​
​
    // 备忘录,存储 used 数组的状态​
    HashMap<String, Boolean> memo = new HashMap<>();​
​
    public boolean canPartitionKSubsets(int[] nums, int k) {​
        // 见上文​
    }​
​
    boolean backtrack(int k, int bucket, int[] nums, int start, boolean[] used, int target) {        ​
        // base case​
        if (k == 0) {​
            return true;​
        }​
        // 将 used 的状态转化成形如 [true, false, ...] 的字符串​
        // 便于存入 HashMap​
        String state = Arrays.toString(used);​
​
        if (bucket == target) {​
            // 装满了当前桶,递归穷举下一个桶的选择​
            boolean res = backtrack(k - 1, 0, nums, 0, used, target);​
            // 将当前状态和结果存入备忘录​
            memo.put(state, res);​
            return res;​
        }​
        ​
        if (memo.containsKey(state)) {​
            // 如果当前状态曾今计算过,就直接返回,不要再递归穷举了​
            return memo.get(state);​
        }​
​
        // 其他逻辑不变...​
    }​
}

这不就到了记忆搜索的知识点了嘛!​

可以去看我之前的博客有讲过记忆搜索 

这样提交解法,发现执行效率依然比较低,这次不是因为算法逻辑上的冗余计算,而是代码实现上的问题。​

因为每次递归都要把 used 数组转化成字符串,这对于编程语言来说也是一个不小的消耗,所以我们还可以进一步优化。​

注意题目给的数据规模 nums.length <= 16,也就是说 used 数组最多也不会超过 16,那么我们完全可以用「位图」的技巧,用一个 int 类型的 used 变量来替代 used 数组。​

具体来说,我们可以用整数 used 的第 i 位((used >> i) & 1)的 1/0 来表示 used[i] 的 true/false。​

这样一来,不仅节约了空间,而且整数 used 也可以直接作为键存入 HashMap,省去数组转字符串的消耗。​

class Solution {​
    public boolean canPartitionKSubsets(int[] nums, int k) {​
        // 排除一些基本情况​
        if (k > nums.length) return false;​
        int sum = 0;​
        for (int v : nums) sum += v;​
        if (sum % k != 0) return false;​
        ​
        int used = 0; // 使用位图技巧​
        int target = sum / k;​
        // k 号桶初始什么都没装,从 nums[0] 开始做选择​
        return backtrack(k, 0, nums, 0, used, target);​
    }​
​
    HashMap<Integer, Boolean> memo = new HashMap<>();​
​
    boolean backtrack(int k, int bucket,​
                    int[] nums, int start, int used, int target) {        ​
        // base case​
        if (k == 0) {​
            // 所有桶都被装满了,而且 nums 一定全部用完了​
            return true;​
        }​
        if (bucket == target) {​
            // 装满了当前桶,递归穷举下一个桶的选择​
            // 让下一个桶从 nums[0] 开始选数字​
            boolean res = backtrack(k - 1, 0, nums, 0, used, target);​
            // 缓存结果​
            memo.put(used, res);​
            return res;​
        }​
        ​
        if (memo.containsKey(used)) {​
            // 避免冗余计算​
            return memo.get(used);​
        }​
​
        for (int i = start; i < nums.length; i++) {​
            // 剪枝​
            if (((used >> i) & 1) == 1) { // 判断第 i 位是否是 1​
                // nums[i] 已经被装入别的桶中​
                continue;​
            }​
            if (nums[i] + bucket > target) {​
                continue;​
            }​
            // 做选择​
            used |= 1 << i; // 将第 i 位置为 1​
            bucket += nums[i];​
            // 递归穷举下一个数字是否装入当前桶​
            if (backtrack(k, bucket, nums, i + 1, used, target)) {​
                return true;​
            }​
            // 撤销选择​
            used ^= 1 << i; // 使用异或运算将第 i 位恢复 0​
    }​
}

为什么第一种解法即便经过了排序优化,也明显比第二种解法慢很多,这是为什么呢?​

我们来分析一下这两个算法的时间复杂度,假设 nums 中的元素个数为 n。​

先说第一个解法,也就是从数字的角度进行穷举,n 个数字,每个数字有 k 个桶可供选择,所以组合出的结果个数为 k^n,时间复杂度也就是 O(k^n)。​

第二个解法,每个桶要遍历 n 个数字,对每个数字有「装入」或「不装入」两种选择,所以组合的结果有 2^n 种;而我们有 k 个桶,所以总的时间复杂度为 O(k*2^n)。​

当然,这是对最坏复杂度上界的粗略估算,实际的复杂度肯定要好很多,毕竟我们添加了这么多剪枝逻辑。不过,从复杂度的上界已经可以看出第一种思路要慢很多了。​

所以,谁说回溯算法没有技巧性的?虽然回溯算法就是暴力穷举,但穷举也分聪明的穷举方式和低效的穷举方式,关键看你以谁的「视角」进行穷举。​

通俗来说,我们应该尽量「少量多次」,就是说宁可多做几次选择(乘法关系),也不要给太大的选择空间(指数关系);做 n 次「k 选一」仅重复一次(O(k^n)),比 n 次「二选一」重复 k 次(O(k*2^n))效率低很多。​

​师承我东哥

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2082794.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

复制会话与复制SSH渠道的区别

文章目录 新建会话时干了什么复制会话的时候干了什么复制SSH渠道的时候干了什么复制会话与复制SSH渠道的区别TIPS&#xff1a;1. /dev/pts/n2. 守护进程 新建会话时干了什么 在第一次启动xshell时&#xff0c;提供了新建会话选项&#xff0c;创建过程如下&#xff1a; 创建SSH…

前端速通面经八股系列(五)—— Vue(上)

Vue系列目录 一、Vue 基础1. Vue的基本原理2. 双向数据绑定的原理3. 使用 Object.defineProperty() 来进行数据劫持有什么缺点&#xff1f;4. MVVM、MVC、MVP的区别5. Computed 和 Watch 的区别6. Computed 和 Methods 的区别7. slot是什么&#xff1f;有什么作用&#xff1f;原…

【Qt】多元素控件QTreeWidget

多元素控件QTreeWidget 使用QTreeWidget表示一个树型结构&#xff0c;里面的每一个元素都是QTreeWidgetItem&#xff0c;每个QTreeWidgetItem可以包含多个文本和图标&#xff0c;每个文本/图标表示一列。 可以给QTreeWidget设置顶层结构&#xff08;顶层节点可以有多个&#…

解放双手全自动铲屎!CEWEY、霍曼、小佩哪款智能猫砂盆更值得?

这马上要到节假日了&#xff0c;回家的话&#xff0c;自己养的猫咪该怎么办呢&#xff1f;如果猫咪的猫砂盆无人照料的话&#xff0c;那么就会导致我们的猫砂盆里全是猫咪的粪便&#xff0c;这些粪便放久了无人清理就会开始散发恶臭&#xff0c;导致家里臭气熏天&#xff0c;最…

【C++对于C语言的扩充】:auto关键字、范围for以及nullptr

文章目录 &#x1f680;auto关键字&#xff08;C11&#xff09;✈️auto介绍✈️auto的使用细则✈️auto不能使用的场景 &#x1f680;范围for&#xff08;C11&#xff09;✈️范围for介绍✈️范围for的使用条件 &#x1f680;指针空值nullptr&#xff08;C11&#xff09; &…

医药行业智能客服机器人的未来发展趋势探析

Hi~这里是ProXiao 文章参考&#xff1a;晓观点 智能客服机器人在医药行业的发展趋势是什么&#xff1f;https://insight.xiaoduoai.com/intelligent-tools/intelligent-customer-service-robot/what-is-the-development-trend-of-intelligent-customer-service-robots-in-the…

极光推送(JPush)赋能登虹科技,打造智慧视觉云平台新体验

近日&#xff0c;中国领先的客户互动和营销科技服务商极光&#xff08;Aurora Mobile&#xff0c;纳斯达克股票代码&#xff1a;JG&#xff09;与杭州登虹科技有限公司&#xff08;以下简称“登虹科技&#xff08;Closeli&#xff09;”&#xff09;达成合作&#xff0c;借助极…

【Threejs学习】Dat.GUI 辅助调参工具

一、介绍 dat.GUI 辅助调参工具&#xff0c;是一个轻量级的图形用户界面库&#xff08;GUI 组件&#xff09;&#xff0c;使用这个库可以很容易地创建出能够改变代码变量的界面组件。 github地址&#xff1a; https://github.com/dataarts/dat.gui 二、使用 1.使用cdn引入 …

基于 SpringBoot+Vue的——陪玩平台

文章目录 前言1.项目类型2.技术栈介绍3.功能介绍4.项目亮点5.适用场景6.项目展示7.诚邀参与 前言 大家好&#xff0c;我是执手天涯&#xff0c;今天非常荣幸地向大家介绍一款备受瞩目的创新项目基于 SpringBootVue的——陪玩平台。这不仅仅是一个游戏社交平台&#xff0c;更是…

算法设计:实验三动态规划法

【实验目的】 应用动态规划算法思想求解矩阵连乘的顺序问题。 【实验要求】 应用动态规划算法的最优子结构性质和子问题重叠性质求解此问题。分析动态规划算法的基本思想&#xff0c;应用动态规划策略写出算法及相应的程序&#xff0c;求解此题。要读懂读透A[i,j]&…

stm32-SD卡实验

1. SD简介 SD卡&#xff0c;Secure Digital Card&#xff0c;称为安全数字卡&#xff08;安全数码卡&#xff09;。 SD卡系列主要有三种&#xff1a;SD卡(full size)、MiniSD卡和MicroSD卡&#xff08;原名 TF卡&#xff09;。 特点&#xff1a;容量大、高安全性、体积小、传…

threejs绘制带箭头的坐标系

效果图&#xff1a; ![在这里插入图片描述](https://i-blog.csdnimg.cn/direct/9319baf1e01946b8919490704e97532a.pn 实现思路&#xff1a; AxesHelper实现坐标系&#xff0c;但是是没有箭头的&#xff1b;这个对象会显示三个彩色的箭头&#xff0c;这些箭头分别表示x, y, z轴…

Android Studio Koala下载并安装,测试helloworld.

1、下载&#xff1a; 下载 Android Studio 和应用工具 - Android 开发者 | Android Developers 2、滚动条拉到近最后&#xff0c;各个系统的下载地址&#xff1a; 3、下载完成以后&#xff0c;我们双击运行安装&#xff1a; 如果有路径要修改&#xff0c;则修改下就可以了&a…

直播平台直播API集成之twitch篇

前言&#xff1a;     本篇我们来介绍如何使用twitch的直播API创建直播。 准备工作&#xff1a; 1、你首先得有个twitch账号&#xff1b; 2、创建twitch应用&#xff0c;主要是给自己的应用取名并配置授权回调地址&#xff08;可配多个&#xff09;&#xff0c;如下图所示&…

Ae基础概念与界面讲解

目录 Ae软件的用途 Ae界面介绍 预设 界面介绍 首选项概念 导入与导出 Ae软件的用途 Ae是一款专业特效合成软件&#xff0c;通过对收集到的素材进行数字化的编辑组合到一起&#xff0c;进行艺术性的再加工后得到的最终作品。 Ae界面介绍 画面中最显眼的图标是新建合成&am…

RoboCat: A Self-Improving Generalist Agent for Robotic Manipulation

发表时间&#xff1a;22 Dec 2023 论文链接&#xff1a;https://readpaper.com/pdf-annotate/note?pdfId4836882796542689281&noteId2413286807916664832 作者单位&#xff1a;Google DeepMind Motivation&#xff1a;受视觉和语言基础模型的最新进展的启发&#xff0c…

第三十九篇-TeslaP40+CosyVoice-安装

环境 系统&#xff1a;CentOS-7 CPU: 14C28T 内存&#xff1a;32G 显卡&#xff1a;Tesla P40 24G 驱动: 535 CUDA: 12.2克隆 git clone --recursive https://github.com/FunAudioLLM/CosyVoice.git cd CosyVoicegit submodule update --init --recursive下载 Matcha-TTS cd…

DWF 支持的 TON 链 Telegram 免费宠物游戏 Gatto_game,推出 “Paws Up! 世界锦标赛”

TON 链在这轮牛市里无疑是一匹脱缰的黑马&#xff0c;创造了一个又一个爆款&#xff0c;为持有者带来了不菲的收益。 Gatto_game 是一款 TON链 Tamagotchi 电子宠物风格的 P2E web3 游戏。可以通过喂养升级&#xff0c;参加比赛赚取 $TON 或者 $GTON &#xff0c;或许就是下一个…

四大名著改编的ip大作,一个巨亏2亿,一个狂赚20亿!选择决定成败!

最近讨论热度比较高的当属《红楼梦》和《西游记》了 胡玫导演的《红楼梦之金玉良缘》耗费了18年的心血&#xff0c;投资了2个多亿 却仅仅只有600万票房&#xff0c;还被网友调侃称“一黛不如一黛” 而由《西游记》改编的游戏《黑神话悟空》&#xff0c;研发10年投资6亿&…

如祺出行发布首份中期业绩,总收入增长13.6%

8月28日&#xff0c;如祺出行发布2024年中期业绩公告。这是如祺出行于7月10日在港交所主板上市后发布的首份业绩公告。 业绩公告显示&#xff0c;2024年上半年如祺出行收入实现10.37亿元&#xff08;单位&#xff1a;人民币&#xff0c;下同&#xff09;、同比增长13.6%&#x…