【算法与数据结构】算法与数据结构知识点

文章目录

  • 一、算法和数据结构和LeetCode介绍
  • 二、算法和数据结构入门
    • 2.1 时间复杂度
    • 2.2 空间复杂度
    • 2.3 基础排序算法
      • 2.3.1 选择排序算法
      • 2.3.2 冒泡排序算法
  • 三、数组
    • 3.1 二分法查找法
    • 3.2 双指针法
  • 四、链表理论
  • 五、哈希表理论
  • 五、栈和队列理论
    • 5.1 单调栈
  • 六、二叉树理论
    • 6.1 树的定义
    • 6.2 二叉树的存储方式
    • 6.3 二叉树的遍历方式
    • 6.4 高度和深度
  • 七、回溯算法
  • 八、贪心算法
  • 九、动态规划
    • 9.1 背包问题
    • 9.2 01背包
    • 9.3 完全背包
    • 9.4 多重背包

  最近博主学习了算法与数据结构的一些视频,在这个文章做一些笔记和心得,本篇文章就写了一些基础算法和数据结构的知识点,具体题目解析会放在另外一篇文章。在学习时已经有C, C++的基础。文章附上了学习的代码,仅供大家参考。如果有问题,有错误欢迎大家留言。先前的一些C、C++,Python等文章可以通过 这篇文章找到。

一、算法和数据结构和LeetCode介绍

  当年美国登月时用的算法程序大小不到1M,科学家用他们的算法和数据结构知识将程序缩减到极致,也就是说,KB级别的程序就可以登月了,哈哈!目前很多大厂都要考算法和数据结构,对于在岗的程序员来说,哪怕你现学现卖,面向百度编程、面向Google编程,一些可以查网络,查书的知识反而不是那么重要,而算法和数据结构就比较重要了,以Google搜索引擎来说,每天访问的次数是上亿次,算法上哪怕改进0.001%都能给Google公司省下大量的费用。算法和数据结构需要长期的训练,需要培养算法思维,或者说需要一点悟性。

  力扣(LeetCode)题库为力扣用户进行题目练习的主要入口,支持C++、Java、Python、Rust、Kotlin等十多种编程语言,已上线超过1000道原创编程题,涉及包括贪心、动态规划、链表、二叉树、哈希表等知识点的算法与数据结构,并按难度分为简单、中等、困难三个等级。

  那么我建议大家按照下面这个顺序开始刷,难度就从简单刷起,做了几个类型题目之后,再慢慢做中等题目、困难题目。

数组-> 链表-> 哈希表->字符串->栈与队列->树->回溯->贪心->动态规划->图论->高级数据结构.

二、算法和数据结构入门

2.1 时间复杂度

  一个操作如果和样本的数据量没有关系,每次都是固定时间内完成的操作,叫做常数操作。时间复杂度为一个算法流程中,常数操作数量的一个指标。常用O(读作big O)来表示。大O用来表示上界的,当用它作为算法的最坏情况运行时间的上界,就是对任意数据输入的运行时间的上界。具体来说,先要对一个算法流程非常熟悉,然后去写出这个算法流程中,发生了多少常数操作,进而总结出常数操作数量的表达式。在表达式中,只要高阶项,不要低阶项,也不要高阶项的系数(当 N N N大到一定程度以后,高阶项占据主导,系数也无关紧要),剩下的部分如果为 f ( N ) f(N) f(N),那么时间复杂度为 O ( f ( N ) ) O(f(N)) O(f(N))评价一个算法流程的好坏,先看时间复杂度的指标,然后再分析不同数据样本下的实际运行时间,也就是“常数项时间”。

  例如冒泡排序算法,我们需要找对比 N ∗ ( N − 1 ) 2 \frac {N *(N-1)}{2} 2N(N1)次,即进行 N ∗ ( N − 1 ) 2 \frac {N *(N-1)}{2} 2N(N1)次常数操作,因此时间复杂度为 O ( N 2 ) O(N^2) O(N2)。如果时间复杂度一致,那么就要比较常数操作的实际运行时间,也就是要实际跑代码,对比时间长短,得到算法优劣。

2.2 空间复杂度

  空间复杂度(Space Complexity)是对算法运行过程中临时占用空间大小的度量,记作 S ( n ) S(n) S(n)。算法执行所需要的临时空间不随着某个变量N的大小而变化,即此算法空间复杂度为一个常量, 可表示为 O ( 1 ) O(1) O(1)
  那么具体什么代码的空间复杂度为 O ( 1 ) O(1) O(1)呢?

int j = 0;
for (int i = 0; i < n; i++) {
    j++;
}

  第一段代码我们可以看出,随着n的变化,所需开辟的内存空间并不会随着n的变化而变化,即此算法空间复杂度为一个常量,所以表示为大 O(1)。
  那么什么代码的空间复杂度又为 O ( N ) O(N) O(N)呢?

int* a = new int(n);
for (int i = 0; i < n; i++) {
    a[i] = i;
} 

  我们new了一个数组出来,这个数据占用的大小为 N N N虽然有一个for循环,但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可随着 N N N的增大,开辟的内存大小呈线性增长,即 O ( N ) O(N) O(N),其他的 O ( n 2 ) , O ( n 3 ) O(n^2), O(n^3) O(n2)O(n3),我想大家应该都可以以此例举出来了。特别要提到的是一般递归的算法需要的复杂度可能为 O ( l o g ( N ) ) O(log(N)) O(log(N)),可以发现,我们这里的对数是忽略底数的,因为根据对数的换底公式,不同底数之间的的对数只差一个常数。

  对于一个算法来说,它的时间复杂度和空间复杂度往往是相互影响的。那我们熟悉的 Chrome 来说,流畅性方面比其他厂商好了多人,但是占用的内存空间略大。当追求一个较好的时间复杂度时,可能需要消耗更多的储存空间。 反之,如果追求较好的空间复杂 度,算法执行的时间可能就会变长。

  常见的复杂度不多,从低到高排列就这么几个: O ( 1 ) O(1) O(1) O ( l o g ( N ) ) O(log(N)) O(log(N)) O ( N ) O(N) O(N) O ( N 2 ) O(N^2) O(N2)。 除了时间复杂度和空间复杂度的区分以外,还有最好、最坏、平均、均摊时间复杂度的区别。

2.3 基础排序算法

2.3.1 选择排序算法

  先随便选一个元素假设它为最小的元素(默认为序列的第一个元素),然后让这个元素与序列中的每一个元素进行比较,如果遇到比自己小的元素,那更新最小值下标,直到把序列中的元素遍历完,那最后的最小值就是序列中的最小值。

例如: 使用选择排序算法将数组 { 4,2,8,0,5,7,1,3,6,9 } 进行升序排序。
   
步骤:

  • 在一个长度为n的无序数组中,第一次遍历n-1个数找到最小的和第一个数交换。
  • 第二次从下一个数开始遍历n-2个数,找到最小的数和第二个数交换。
  • 重复以上操作直到第n-1次遍历最小的数和第n-1个数交换,排序完成。

时间复杂度:
  对于长度为 N N N的数组,代码执行的时间都花费在内层for循环中的比较语句和外层for循环里的交换语句了,因此时间复杂度为 O ( N 2 ) O(N^2) O(N2)

稳定性:
  由于选择元素之后会发生交换操作,有可能把前面的元素交换到后面,所以选择排序不是稳定的排序。

// 选择排序算法
void SelectSort(int* list, const int n)
{
	for (int i = 0; i < n - 1; i++){
		int min = i;  //min为最小值索引
		for (int j = i + 1; j < n; j++){
			if (list[j] < list[min]){
				min = j;
			}
		}
		swap(list[i], list[min]);
	}
}

2.3.2 冒泡排序算法

三、数组

  数组是存放在连续内存空间上的相同类型数据的集合。数组具有下面的特点:

  • 数组下标从0开始。
  • 数组内存空间的地址都是连续的。

  正是因为数组的在内存空间的地址是连续的,所以我们在删除或者增添元素的时候,就难免要移动其他元素的地址。也就是说,数组不能释放单一元素,如果要释放,就是全释放(程序运行结束,回首内存栈空间)。不同编程语言当中,二维数组的内存管理不一样,例如C++中二维数组是连续内存分布,Java不是连续内存分布。

3.1 二分法查找法

  二分法要求的数组必须是有序数组。二分法需要注意的就是边界条件以及while循环什么时候停止。边界条件是[left,right)还是[left,right]决定了while循环当中的停止条件是left 程序如下:

	int search(vector<int>& nums, int target) {
        int left = 0;
        int right = nums.size() - 1;
        int middle;
        while (left <= right) {
            middle = (left + right) / 2;
            if (nums[middle] < target) {
                left = middle + 1;               
            } else if (nums[middle] > target){
                right = middle - 1;
            } else {
                return middle;
            }
        }
        return -1;
	}

复杂度分析:

  • 时间复杂度: O ( l o g n ) O(log n) O(logn),二分法查找的查找速度是 l o g 2 n log_{2}{n} log2n,可以去掉底数(乘上一个常数),结果就是 O ( l o g n ) O(log n) O(logn)
  • 空间复杂度: O ( 1 ) O(1) O(1), 开辟的内存空间大小是一个常量。

3.2 双指针法

  双指针法也能叫做快慢指针法:通过一个快指针和慢指针在一个for循环下完成两个for循环的工作。

  • 暴力解法时间复杂度: O ( n 2 ) O(n^2) O(n2),两个for循环。
  • 双指针时间复杂度: O ( n ) O(n) O(n),一个for循环。

四、链表理论

  单链表:由一个个节点组成,每个节点由一个数据域和一个指针域组成,数据域放数据,指针域指向下一个节点,链表入口节点叫做head,最后一个节点的next指针指向NULL(空指针)。
【算法与数据结构】算法与数据结构知识点_第1张图片
  双链表:在单链表的基础上增加了一个指针域,这个指针域指向前一个节点,head的prev指针指向NULL。双链表既可以向前查,也可以向后查。
【算法与数据结构】算法与数据结构知识点_第2张图片
  循环链表:链表的首尾相连。循环链表可以解决约瑟夫环问题。
【算法与数据结构】算法与数据结构知识点_第3张图片
  数组在内存中是连续分布的,但是链表不是连续分布的,它通过指针域的指针链接在内存中的各个节点。
链表定义方式:

// 单链表
struct ListNode {
    int val;  // 节点上存储的元素
    ListNode *next;  // 指向下一个节点的指针
    ListNode(int x) : val(x), next(NULL) {}  // 节点的构造函数
};

初始化链表:

// 法一 
ListNode* head = new ListNode(5);
// 法二
ListNode* head = new ListNode();
head->val = 5;

删除和添加节点:
  假如想删除图中D节点,那么我们将C节点指针指向E节点,然后释放D的内存(C++需要手动释放,Java,Python有内存回收机制,不需要手动释放)。
【算法与数据结构】算法与数据结构知识点_第4张图片
  假如要添加F节点,将C节点的指针指向F,F指针指向D即可。
【算法与数据结构】算法与数据结构知识点_第5张图片
查询:
  链表查询是比较费劲的,例如想找第10个节点,那么得从第一个节点开始,按指针域一个一个找,找到第九个节点才能找到第十个节点。因此,链表查询时间复杂度为 O ( n ) O(n) O(n)

项目 插入/删除 查询 使用场景
数组 O ( n ) O(n) O(n) O ( 1 ) O(1) O(1) 数据量固定,频繁查询,较少增删
链表 O ( 1 ) O(1) O(1) O ( n ) O(n) O(n) 数据量不固定,频繁增删, 较少查询

五、哈希表理论

  哈希表可以通过索引直接访问表中的元素哈希表一般用来快速判断一个元素是否出现在集合里,但哈希法是牺牲空间换取时间,因为要使用额外的数组set或map才能实现快速查找。举个例子,班级里是否有小明这个同学,如果要用枚举时间复杂度为 O ( n ) O(n) O(n),但如果哈希表只需要 O ( 1 ) O(1) O(1)就可以做到。

  在初始化时,只需要把全班的名字存在哈希表里,查询的时候通过姓名直接可以知道是否有这位同学。哈希表通过哈希函数(hash funciton)将学生姓名映射到哈希表上

【算法与数据结构】算法与数据结构知识点_第6张图片

工作原理:如上图所示,哈希函数将姓名转换成数值索引(一般通过特定编码方式),然后按索引在哈希表上得到目标数据。同时为了保证哈希函数计算的索引一定落在哈希表中,还做了取模操作。有时候,学生数量会大于哈希表长度,不同学生会得到同一个索引,也就是映射到哈希表上同一个位置,也就出现所谓的哈希碰撞问题。

哈希碰撞解决办法

  • 1、拉链法:在碰撞位置引入链表,链表指向依次指向不同的学生。拉链法要注意适当选择哈希表大小,充分利用哈希表内存,同时不要生成太长的链表
  • 2、线性探测法:当发生碰撞时,就找表的下一个空位方置。因此,一定要保证哈希表大小大于数据大小

常用的哈希表有

  • 数组
  • 集合(set)
  • 映射(map)

  在C++中,set和map提供了下面几种形式,使用集合来解决问题时,优先使用unordered_set,它底层用哈希表实现,查询效率和增删效率最高。只有处理有序数据时用set或者multiset(二者区别在于值能否重复)。

  虽然set、multiset、 map和multimap底层使用红黑树实现的,但是使用方式还是哈希表的key和value方式,同属于映射方法,同样可以归类到哈希法中。此外,红黑树是一种平衡二叉搜索树,key值是有序的,但key值不能修改,改动key值会导致整颗树错乱,所以智能删除和增加。map当中对key有限制,不可修改,value没有限制
【算法与数据结构】算法与数据结构知识点_第7张图片
【算法与数据结构】算法与数据结构知识点_第8张图片

五、栈和队列理论

  首先是关于栈和队列的元素进出关系:栈是先进后出,队列是先进先出栈提供push 和 pop 等等接口,所有元素必须符合先进后出规则,所以栈不提供走访功能,也不提供迭代器(iterator)。 不像是set 或者map 提供迭代器iterator来遍历所有元素。
【算法与数据结构】算法与数据结构知识点_第9张图片
  栈是以底层容器完成其所有的工作,对外提供统一的接口,我们可以控制使用哪种容器来实现栈的功能(栈是可插拔),例如vector,list,deque等等。所以STL中栈往往不被归类为容器,而被归类为container adapter(容器适配器)。目前最常见的SGI STL(STL库的其中一个版本),如果没有指定底层实现,默认以deque(双向队列)缺省为底层容器,只要封住一端,开通另一端就可以实现栈的逻辑。
  也可以指定vector为底层实现:

std::stack<int, std::vector<int> > third;  // 使用vector为底层容器的栈

  队列的情况是一样的,队列中先进先出的数据结构,同样不允许有遍历行为,不提供迭代器, SGI STL中队列一样是以deque为缺省情况下的底部结构。队列也不归为容器,也是容器适配器。

std::queue<int, std::list<int>> third; // 定义以list为底层容器的队列

5.1 单调栈

  单调栈问题长是针对一个一维数组,要寻找任一个元素的右边或者左边第一个比自己大或者小的元素的位置。单调栈可以在 O ( n ) O(n) O(n)的时间复杂度内找到每一个元素的右边第一个比它大的元素位置。单调栈的本质是空间换时间,优点是整个数组只需遍历一次。我们使用一个栈来保存遍历过程中的元素。因为我们遍历数组的时候,我们不知道之前都遍历了哪些元素,以至于遍历一个元素找不到是不是之前遍历过一个更小的,所以我们需要用一个容器(这里用单调栈)来记录我们遍历过的元素。

  单调栈问题需要考虑以下几点:

    1. 单调栈里面存放的元素是什么?
    1. 单调栈是递增还是递减的?
        这里的递增或者递减的顺序指的是从栈底到栈顶(栈头)的顺序,C++中使用STL库可以用st.top()来访问栈顶。

六、二叉树理论

6.1 树的定义

  首先引入树的度的概念:结点拥有的子树个数称为结点的度,比如下图中结点3和结点4的度分别为3和2。对于树而言,树的度是结点最大的度,下面这棵树的度为4(结点1的度)。

【算法与数据结构】算法与数据结构知识点_第10张图片

  二叉树是指树的度最大为2的树。满二叉树:如果一棵树只有度为0和度为2的节点,并且度为0的节点在同一层上,则这棵二叉树为满二叉树。如下图所示,这是一棵满二叉树。这棵二叉树为满二叉树,也可以说深度为k,有2^k-1个节点的二叉树。

【算法与数据结构】算法与数据结构知识点_第11张图片

  完全二叉树:在完全二叉树中,除了最底层节点可能没有填满以外,其余每层节点数量都达到最大值,并且最下面一层节点都集中在该层的最左边若干位置。若底层为第k层,则该层包含 [ 1 , 2 k − 1 ] [1, 2^{k-1}] [1,2k1]个节点。
  在【算法和数据结构】347、LeetCode前 K 个高频元素中提到的优先级队列。实际上,优先级队列其实是一个堆,堆就是一棵完全二叉树,同时保证父子节点的顺序关系。下图当中第三棵树就不是一棵完全二叉树。

【算法与数据结构】算法与数据结构知识点_第12张图片

  二叉搜索树:又叫二叉排序树。它具有下面三个特点:

  • 若它的左子树不空,则左子树上所有结点的值均小于它的根结点的值;
  • 若它的右子树不空,则右子树上所有结点的值均大于它的根结点的值;
  • 它的左、右子树也分别为二叉排序树

【算法与数据结构】算法与数据结构知识点_第13张图片

  平衡二叉排序树:又被称为AVL(Adelson-Velsky and Landis)树,且具有以下性质:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。下图当中第三棵树就不是一棵平衡二叉树,左右两个子树的高度差绝对值超过了1。

【算法与数据结构】算法与数据结构知识点_第14张图片

  C++中map、set、multimap,multiset的底层实现都是平衡二叉搜索树,所以map、set的增删操作的时间复杂度是 l o g ( n ) log(n) log(n)。unordered_map、unordered_set底层实现是哈希表, 增删操作的时间复杂度为 O ( 1 ) O(1) O(1)。详细内容可以看本文第五节。

6.2 二叉树的存储方式

  二叉树可以用链式存储,也可以顺序存储。那么链式存储方式就用指针, 顺序存储的方式就是用数组。链式存储如下图所示:

【算法与数据结构】算法与数据结构知识点_第15张图片

  顺序存储如下图所示,在遍历时,假设父节点为i那么它的左孩子就是 i ∗ 2 + 1 i*2+1 i2+1,右孩子就是 i ∗ 2 + 2 i*2+2 i2+2相较于链式存储,顺序存储方式比较不容易理解,也不直观,所以一般我们用链式存储二叉树
【算法与数据结构】算法与数据结构知识点_第16张图片

6.3 二叉树的遍历方式

二叉树主要有两种遍历方式,这两种也是图论当中最基本的两种遍历方式。

  • 深度优先遍历:先往深处走,遇到叶子节点再往回走。
  • 广度优先遍历:一层一层的去遍历。

在上面两种方式的基础之上进一步拓展,有如下的分类:

  • 深度优先遍历

    • 前序遍历(递归法、迭代法)
    • 中序遍历(递归法、迭代法)
    • 后序遍历(递归法、迭代法)
  • 广度优先遍历

    • 层次遍历(迭代法)

前中后是指中间节点的遍历顺序,是在前、中或者是后。例如,前序遍历:中左右;中序遍历:左中右;后序遍历:左右后。

【算法与数据结构】算法与数据结构知识点_第17张图片

  递归法和迭代法是这两种遍历的实现方法。深度优先遍历一般是用递归的方式实现,也就是说,用递归来实现前中后遍历比较方便。栈其实就是递归的一种实现结构,前中后遍历的逻辑也可以用栈使用非递归的方式来实现。广度优先遍历的实现一般使用队列来实现,队列是先进先出的结构,这样才能一层层的遍历二叉树。
  链式存储二叉树节点的定义方式如下,相较于链表节点,二叉树节点与其定义差不多,二叉树节点有两个指针分别指向了其左右孩子。
  树节点定义

// 树节点定义
struct TreeNode {
    int val;
    TreeNode* left;
    TreeNode* right;
    TreeNode(int x) : val(x), left(NULL), right(NULL) {}
};

  迭代法实现前中后遍历

class Solution {
public:
    // 前序遍历
    void traversal_preOrder(TreeNode* cur, vector<int>& vec) {
        if (cur == NULL) return;
        vec.push_back(cur->val);                // 中
        traversal_preOrder(cur->left, vec);     // 左
        traversal_preOrder(cur->right, vec);    // 右
    }
    // 中序遍历
    void traversal_midOrder(TreeNode* cur, vector<int>& vec) {
        if (cur == NULL) return;        
        traversal_midOrder(cur->left, vec);     // 左
        vec.push_back(cur->val);                // 中
        traversal_midOrder(cur->right, vec);    // 右
    }
    // 后序遍历
    void traversal_postOrder(TreeNode* cur, vector<int>& vec) {
        if (cur == NULL) return;
        traversal_postOrder(cur->left, vec);     // 左
        traversal_postOrder(cur->right, vec);    // 右
        vec.push_back(cur->val);                // 中
    }
    vector<int> preorderTraversal(TreeNode* root) {
        vector<int> result;
        traversal_preOrder(root, result);
        return result;
    }
};

6.4 高度和深度

  高度和深度是相反的表示,深度是从上到下数,而高度是从下往上数。深度指从根节点到该节点最长简单路径边数,而高度指从该节点到叶子节点的最长简单路径边数。叶子节点是指没有子节点的节点。假设根节点的深度和叶子节点的高度为1,那么树的深度和高度是相等的,而对其他节点来说高度和深度不一定相等。例如下图当中,8这个节点的深度为2,高度为4。
【算法与数据结构】算法与数据结构知识点_第18张图片

七、回溯算法

  回溯算法也可以叫回溯搜索法,它是一种搜索的方式。回溯是递归的副产品,有递归就有回溯,因此回溯函数就是递归函数。回溯法的本质是穷举,穷举所有可能,然后选出我们想要的答案。如果想要令回溯法更加高效一些,那就加一些剪枝操作。虽然说回溯法并不高效,但是一些问题不得不用回溯法,能用暴力搜索解出来就不错了,在剪枝,除此之外没有更高效的解法。回溯法用来及诶觉以下的几个问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 棋盘问题:N皇后,解数独等等

  回溯法解决的问题可以抽象为树形结构,因为回溯法解决的问题都是在集合中递归查找子集,集合的大小就构成了树的宽度,递归的深度,都构成树的深度。递归就要有终止条件,所以必然是一颗高度有限的树。回溯算法的伪代码:

void backtracking(参数) {
    if (终止条件) {
        存放结果;
        return;
    }

    for (选择:本层集合中元素(树中节点孩子的数量就是集合的大小)) {
        处理节点;
        backtracking(路径,选择列表); // 递归
        回溯,撤销处理结果
    }
}

八、贪心算法

  贪心算法的本质是选择每一阶段的局部最优,从而达到全局最优。贪心算法经典问题有:背包问题,买卖股票的最佳时机。贪心算法没有固定的套路,说白了就是常识性推导加上举反例。贪心算法一般分为以下四个步骤:

  • 1、将问题分解为若干个子问题
  • 2、找出合适的贪心策略
  • 3、求解每一个子问题的最优解
  • 4、将局部最优解堆叠成全局最优解

九、动态规划

  动态规划(Dynamic Programming, DP),如果一个问题有很多重叠的子问题,使用动态规划是最有效的。所有动态规划总每一个状态由上一个状态推导出来,这一点就区别于贪心算法,贪心算法没有状态变量的推导,而是从局部直接选最优的。动态规划问题可以分为下面五个步骤:

  • 1、确定dp数组(dp table)以及下标的含义
  • 2、确定递推公式
  • 3、dp数组如何初始化
  • 4、确定遍历顺序
  • 5、举例推导dp数组
      在很多动态规划题目当中,确定了递推公式,题目就自然的解出来了。同时,在debug动态规划题目是,将dp数组打印出来,观察其变化是否按照自己所预想的那样。

9.1 背包问题

  对于背包问题来说,主要可以分为两个部分:背包和物品。背包的最大容量为 V V V,物品具有价值 W W W,体积 v v v以及每个物品的数量。如果根据物品数量进行分类,可以分为01背包问题,完全背包问题,多重背包问题和分组背包问题

  • 01背包:每种物品的数量只有一个;
  • 完全背包:物品数量有无数个;
  • 多重背包:不同物品的数量可以不同;
  • 分组背包:按组打包,每组最多选一个。
      对于找工作面试来说,掌握01背包,完全背包和多重背包就够了。LeetCode的题库中没有纯01背包问题,需要转化成01背包问题。

9.2 01背包

  有n件物品和一个最多能背重量为w 的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品只能用一次,求解将哪些物品装入背包里物品价值总和最大。题目假设如下,背包最大重量为4。

【算法与数据结构】算法与数据结构知识点_第19张图片

  根据动态规划的五个步骤,我们首先确认dp数组的含义。假设一个二维 d p [ i ] [ j ] dp[i][j] dp[i][j]数组代表了从下标为 [ 0 − i ] [0-i] [0i]的物品里任意取,放进容量为 j j j的背包,最大价值总和。第二步确认递归公式。不放物品:当第 i i i个物品不放进去时,此时的价值和前面的相同,有 d p [ i ] [ j ] = d p [ i − 1 ] [ j ] dp[i][j] = dp[i - 1][j] dp[i][j]=dp[i1][j]放物品:放物品的前提是放入的物品重量不大于背包现有容量,当然这个可以用 i f if if语句控制。假设能放进去,那么放进去之后的价值 d p [ i ] [ j ] dp[i][j] dp[i][j],可以表示为 d p [ i − 1 ] [ j − w e i g h t [ i ] ] + v a l u e [ i ] dp[i - 1][j - weight[i]] + value[i] dp[i1][jweight[i]]+value[i]。其中, d p [ i − 1 ] [ j − w e i g h t [ i ] ] dp[i - 1][j-weight[i]] dp[i1][jweight[i]]为背包容量为 j − w e i g h t [ i ] j - weight[i] jweight[i]的时候不放物品i的最大价值, v a l u e [ i ] value[i] value[i]为物品 i i i的价值。综合以上分析,我们可以得到递归公式: d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − w e i g h t [ i ] ] + v a l u e [ i ] ) dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]) dp[i][j]=max(dp[i1][j],dp[i1][jweight[i]]+value[i])

  第三步我们进行初始化,因为 i i i是由 i − 1 i-1 i1初始化而来的,那么我们将 d p [ i ] [ 0 ] dp[i][0] dp[i][0]初始化为0。实际上我们在构建dp数组的时候可以将二维数组中的所有元素初始化为0,而非零的值将在循环遍历中被覆盖。然后初始化第一行当 j > w e i g h t [ i ] j>weight[i] j>weight[i]时(背包可以放假物品0), d p [ 0 ] [ j ] dp[0][j] dp[0][j]应该是 v a l u e [ 0 ] value[0] value[0]。第四步,确定遍历顺序。遍历的两个维度分别是物品和背包重量,遍历物品相对比遍历背包重量更容易理解:

【算法与数据结构】算法与数据结构知识点_第20张图片

  

// 初始化 dp
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
for (int j = weight[0]; j <= bagweight; j++) {
    dp[0][j] = value[0];
}
// weight数组的大小 就是物品个数
for(int i = 1; i < weight.size(); i++) { // 遍历物品
    for(int j = 0; j <= bagweight; j++) { // 遍历背包容量
        if (j < weight[i]) dp[i][j] = dp[i - 1][j];
        else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
    }
}

  当然,上述的dp数组也可以写成一维滚动数组形式。下面的代码舍去了初始化的代码,舍去下标二维dp数组的下标 i i i从而变成了一维数组。主要原因是 d p [ i − 1 ] [ j ] dp[i - 1][j] dp[i1][j]完全可以用 d p [ j ] dp[j] dp[j]来表示。二维dp遍历的时候,背包容量是从小到大,而一维dp遍历的时候,背包是从大到小。倒序遍历是为了保证物品i只被放入一次!。但如果一旦正序遍历了,那么物品0就会被重复加入多次!

  举一个例子:物品0的重量weight[0] = 1,价值value[0] = 15。如果正序遍历dp[1] = dp[1 - weight[0]] + value[0] = 15,dp[2] = dp[2 - weight[0]] + value[0] = 30。此时dp[2]就已经是30了,意味着物品0,被放入了两次,所以不能正序遍历。

  倒序就是先算dp[2]。dp[2] = dp[2 - weight[0]] + value[0] = 15 (dp数组已经都初始化为0),dp[1] = dp[1 - weight[0]] + value[0] = 15。所以从后往前循环,每次取得状态不会和之前取得状态重合,这样每种物品就只取一次了。

class Solution {	// 一维dp数组(滚动数组形式)
public:
	int bag01(const vector<int> weight, const vector<int> value, const int bagweight) {
		vector<int> dp(vector<int>(bagweight + 1, 0));
		for (int i = 0; i < weight.size(); i++) {			// 遍历物品
			for (int j = bagweight; j >= weight[i]; j--) {			// 遍历背包容量
				dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
			}
		}
		return dp[bagweight];
	}
};

  简言之,一维dp数组和二维dp数组的区别在于一维的空间复杂度低,二维的更容易理解(初学者用二维即可)。以上的完整代码如下:

# include 
# include 
using namespace std;

class Solution {
public:
	int bag01(const vector<int> weight, const vector<int> value, const int bagweight) {
		vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));
		for (int j = weight[0]; j <= bagweight; j++) {		// 初始化
			dp[0][j] = value[0];
		}
		for (int i = 1; i < weight.size(); i++) {			// 遍历物品
			for (int j = 0; j <= bagweight; j++) {			// 遍历背包容量
				if (j < weight[i]) dp[i][j] = dp[i - 1][j];
				else dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
			}
		}
		return dp[weight.size() - 1][bagweight];
	}
};

int main() {
	Solution s1;
	vector<int> weight = { 1, 3, 4 };
	vector<int> value = { 15, 20, 30 };
	int bagweight = 4;
	int result = s1.bag01(weight, value, bagweight);
	cout << result << endl;
	system("pause");
	return 0;
}

9.3 完全背包

  完全背包问题可以描述为:有N件物品和一个最多能背重量为W的背包。第i件物品的重量是weight[i],得到的价值是value[i] 。每件物品都有无限个(也就是可以放入背包多次),求解将哪些物品装入背包里物品价值总和最大。依然假设背包最大重量为4。

【算法与数据结构】算法与数据结构知识点_第21张图片

  为了保证每个物品仅被添加一次,01背包内嵌的循环是从大到小遍历。而完全背包的物品是可以添加多次的,所以要从小到大去遍历。

// 先遍历物品,再遍历背包
for(int i = 0; i < weight.size(); i++) { // 遍历物品
    for(int j = weight[i]; j <= bagWeight ; j++) { // 遍历背包容量
        dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);

    }
}

9.4 多重背包

  有N种物品和一个容量为V的背包。第 i i i种物品最多有 M i M_i Mi件可用,每件耗费的空间是 C i C_i Ci ,价值是 W i W_i Wi。求解将哪些物品装入背包可使这些物品的耗费的空间,总和不超过背包容量,且价值总和最大。

  我们将物品数量摊开,其实可以将多重背包问题转换成01背包。例如:背包最大重量为10。物品的重量、价值和数量如下。那么可以转化成7个物品,每个物品只能用一次。这样就是一个01背包问题。因此我们在01背包的基础之上加上遍历个数即可。

【算法与数据结构】算法与数据结构知识点_第22张图片

#include
#include
using namespace std;

class Solution {
public:
    int Multip_Bag(int bagWeight, int nItem, vector<int> weight, vector<int> value, vector<int> nums) {
        vector<int> dp(bagWeight + 1, 0);
        for (int i = 0; i < nItem; i++) { // 遍历物品
            for (int j = bagWeight; j >= weight[i]; j--) { // 遍历背包容量
                // 以上为01背包,然后加一个遍历个数
                for (int k = 1; k <= nums[i] && (j - k * weight[i]) >= 0; k++) { // 遍历个数
                    dp[j] = max(dp[j], dp[j - k * weight[i]] + k * value[i]);
                }
            }
        }
        return dp[bagWeight];
    }
};

int main() {
    int bagWeight = 10, nItem = 3;
    vector<int> weight = {1, 3, 4}, value = {15, 20, 30}, nums = {2, 3, 2};
    Solution s1;
    int result = s1.Multip_Bag(bagWeight, nItem, weight, value, nums);
    cout << result << endl;
    system("pause");
    return 0;
}

持续更新~

end

你可能感兴趣的:(算法,算法)