这里写目录标题
1 若n为2的幂次方,则能有n&(n-1)==0成立
2 BST 二叉搜索树
二叉搜索树(Binary Search Tree),又名二叉排序树(Binary Sort Tree)。是具有有以下性质的二叉树:
- 若左子树不为空,则左子树上所有节点的值均小于或等于它的根节点的值。
- 若右子树不为空,则右子树上所有节点的值均大于或等于它的根节点的值。
- 左、右子树也分别为二叉搜索树。
- BST的中序遍历是一个升序序列
- 先序+后序 不能唯一确定一颗BST
- 中序+后序,中序+先序可以完全确定一颗BST,单独任意一个均不能
3 已知两种遍历结果求另一种遍历结果
在进行分析前读者需要知道不同遍历结果的特点:
- 前序遍历的第一元素是整个二叉树的根节点
- 中序遍历中根节点的左边的元素是左子树,根节点右边的元素是右子树
- 后序遍历的最后一个元素是整个二叉树的根节点
- 如果一个二叉树的根节点有左子树,那么这个左子树一定在前序遍历中一定紧跟着根节点
已知前序遍历、中序遍历求后序遍历
- 先观察前序遍历找到根节点
- 观察中序遍历将根节点左边归为左子树元素,右边归为右子树元素(可能会出现只有左子树或者右子树的情况)
- 观察前序遍历中左\右子树几个元素的顺序,最靠前的为左\右子树的根节点
- 重复前面的步骤
已知中序遍历、后序遍历求前序遍历
- 先观察后序遍历找到根节点
- 观察中序遍历将根节点左边归为左子树元素,右边归为右子树元素(可能会出现只有左子树或者右子树的情况)
- 观察后序遍历中左\右子树几个元素的顺序,最靠后的为左\右子树的根节点
- 重复前面的步骤
已知前序遍历、后序遍历无法求出中序遍历(因为由前序后序重构出来的二叉树不止一种)
4 鸡蛋掉落(887)
-
题目描述:
你将获得 K 个鸡蛋,并可以使用一栋从 1 到 N 共有 N 层楼的建筑。
每个蛋的功能都是一样的,如果一个蛋碎了,你就不能再把它掉下去。
你知道存在楼层 F ,满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破。
每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X 扔下(满足 1 <= X <= N)。
你的目标是确切地知道 F 的值是多少。
无论 F 的初始值如何,你确定 F 的值的最小移动次数是多少? -
只有一个鸡蛋的时候:移动N次
-
有无限个鸡蛋:二分法logN
-
有两个鸡蛋:第一个鸡蛋等间隔移动,第二个鸡蛋按只有一个鸡蛋移动
-
有两个鸡蛋:不等间隔,使第一个鸡蛋和第二个鸡蛋的可能移动次数尽量相同。则让第一个鸡蛋的移动间隔变化,每多扔一次间隔减少1.
-
K个鸡蛋,N层楼:
从第x楼扔鸡蛋:- 鸡蛋不碎: 状态(K,N-x)
- 鸡蛋碎了:状态(K-1,x-1)
5 图
-
图中节点的度:图中的度:所谓顶点的度(degree),就是指和该顶点相关联的边数。
- 在有向图中,度又分为入度和出度。
- 入度 (in-degree) :以某顶点为弧头,终止于该顶点的弧的数目称为该顶点的入度。
- 出度 (out-degree) :以某顶点为弧尾,起始于该顶点的弧的数目称为该顶点的出度。
- 在某顶点的入度和出度的和称为该顶点的度
- 在有向图中,度又分为入度和出度。
握手定理:
- 在任何有向图图中,所有顶点的度数之和等于边数的2倍,所有顶点的入度之和等于所有顶点出度之和,等于边数
- 无向简单图,同样所有顶点的度数之和等于边数的2倍
6 会议室(252)
题目:给定一个会议时间安排的数组,每个会议时间都会包括开始和结束的时间 [[s1,e1],[s2,e2],…] (si < ei),请你判断一个人是否能够参加这里面的全部会议。
思路:
先按开始时间排好序,然后看后一个的开始的时间是不是在前一个会议结束之前。
class Solution(object):
def canAttendMeetings(self, intervals):
"""
:type intervals: List[List[int]]
:rtype: bool
"""
intvs = sorted(intervals, key = lambda x: x[0])
for idx, intv in enumerate(intvs):
if idx > 0:
if intv[0] < intvs[idx - 1][1]:
return False
return True
7 会议室2(253)
题目:
给定一个会议时间安排的数组,每个会议时间都会包括开始和结束的时间 [[s1,e1],[s2,e2],…] (si < ei),为避免会议冲突,同时要考虑充分利用会议室资源,请你计算至少需要多少间会议室,才能满足这些会议安排。
解题思路1:
首先按照开始时间进行排序 ,同时把结束时间加入到列表中,遍历所有的开始时间,然后只在结束时间的列表里找比这个开始时间晚结束的,表明其有重叠,同时记录结束时间列表长度。·
class Solution:
def minMeetingRooms(self, intervals: List[List[int]]) -> int:
occupied, res = [], 0
intervals.sort(key = lambda x:x[0])
for i in intervals:
start, end = i
occupied = [t for t in occupied if t > start]
occupied.append(end)
res = max(res, len(occupied))
return res
解题思路二:
- 首先按照结束时间进行排序
- 生成一个列表记录每个时间点开始的时间段的个数和结束的时间段的个数之差,这个差可以表示在这个时间点新增了几个时间段(负数代表结束了几个时间段)
- 求出这个列表的前缀和,表示从开头到这个时间点有几个时间段在进行
- 最后输出最大值,就是最少要几个
class Solution(object):
def minMeetingRooms(self, intervals):
"""
:type intervals: List[List[int]]
:rtype: int
"""
if not intervals:
return 0
if not intervals[0]:
return 1
intervals = sorted(intervals, key = lambda x: x[1])
record = [0 for _ in range(intervals[-1][1] + 1)]
for interval in intervals:
# print record
begin, end = interval[0], interval[1]
record[begin] += 1
record[end] -= 1
for i, x in enumerate(record):
if i > 0:
record[i] += record[i - 1]
return max(record)
8 双向链表
节点类:
class Node:
def __init__(self, data):
self.data = data
self.next = None
self.prev = None
def getData(self):
return self.data
def setData(self, data):
self.data = data
def getNext(self):
return self.next
def getPrev(self):
return self.prev
链表类:
class TwoWayList:
def __init__(self):
self.head = None # 头结点
self.tail = None # 尾结点
self.length = 0 # 链表长度
def isEmpty(self):
# 判断链表是否为空
return self.head == None
def append(self, item):
# 在链表尾部添加节点
if self.length == 0:
node = Node(item)
self.head = node
self.tail = node
self.length = 1
return
node = Node(item)
tail = self.tail
tail.next = node
node.prev = tail
self.tail = node
self.length += 1
def insert(self, index, item):
# 链表中插入节点
length = self.length
if (index<0 and abs(index)>length) or (index>0 and index>=length):
# 判断是否超出下标限制
return False
if index < 0:
# 如果负数索引的话,转成正数索引
index = index + length
if index == 0:
# 最开始插入
node = Node(item)
if self.head != None:
self.head.prev = node
else:
self.tail = node
node.next = self.head
self.head = node
self.length += 1
return True
if index == length - 1:
return self.append(item)
node1 = self.head
for i in range(0, index):
node1 = node1.next
node2 = node1.next
node = Node(item)
node.prex = node1
node.next = node2
node1.next = node
node2.prev = node
self.length += 1
return True
9 队列的实现方式有哪些
- 队列用数组来实现
- 两个栈实现队列
- 链表实现
10 数据结构时间复杂度
skip list:跳表
Cartesian Tree:笛卡尔树
B-Tree:平衡多路查找树
Splay Tree:伸展树
MergeSort:归并排序
Timsort:是一种混合、稳定高效的排序算法,源自合并排序和插入排序
Bubble Sort:冒泡排序
InsertionSort:插入排序
Selection Sort:选择排序
shell sort:希尔排序
Bucket Sort:桶排序
radix sort:基数排序
11 排序算法稳定性
- 稳定性定义
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。 - 稳定性
归并排序、冒泡排序、插入排序、基数排序、折半插入排序是稳定的
选择排序、快速排序、希尔排序、堆排序是不稳定的 - 最基础的四个算法:冒泡、选择、插入、快排中,快排的时间复杂度最小O(n*log2n),其他都是O(n2)
- 排序算法的思想:
- 冒泡排序:
是相邻元素之间的比较和交换,两重循环O(n2);所以,如果两个相邻元素相等,是不会交换的。所以它是一种稳定的排序方法 - 选择排序:
每个元素都与第一个元素相比,产生交换,两重循环O(n2);举个栗子,5 8 5 2 9,第一遍之后,2会与5交换,那么原序列中两个5的顺序就被破坏了。所以不是稳定的排序算法 - 插入排序:
插入排序是在一个已经有序的小序列的基础上,一次插入一个元素。刚开始这个小序列只包含第一个元素,事件复杂度O(n2)。比较是从这个小序列的末尾开始的。想要插入的元素和小序列的最大者开始比起,如果比它大则直接插在其后面,否则一直往前找它该插入的位置。如果遇见了一个和插入元素相等的,则把插入元素放在这个相等元素的后面。所以相等元素间的顺序没有改变,是稳定的。 - 快速排序
快速排序有两个方向,左边的i下标一直往右走,当a[i] <= a[center_index],其中center_index是中枢元素的数组下标,一般取为数组第0个元素。而右边的j下标一直往左走,当a[j] > a[center_index]。如果i和j都走不动了,i <= j, 交换a[i]和a[j],重复上面的过程,直到i>j。 交换a[j]和a[center_index],完成一趟快速排序。在中枢元素和a[j]交换的时候,很有可能把前面的元素的稳定性打乱,比如序列为 5 3 3 4 3 8 9 10 11, 现在中枢元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱,所以快速排序是一个不稳定的排序算法,不稳定发生在中枢元素和a[j]交换的时刻。 - 归并排序
归并排序是把序列递归地分成短序列,递归出口是短序列只有1个元素(认为直接有序)或者2个序列(1次比较和交换),然后把各个有序的段序列合并成一个有序的长序列,不断合并直到原序列全部排好序。可以发现,在1个或2个元素时,1个元素不会交换,2个元素如果大小相等也没有人故意交换,这不会破坏稳定性。那么,在短的有序序列合并的过程中,稳定是是否受到破坏?没有,合并过程中我们可以保证如果两个当前元素相等时,我们把处在前面的序列的元素保存在结果序列的前面,这样就保证了稳定性。所以,归并排序也是稳定的排序算法。 - 基数排序
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序,最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。基数排序基于分别排序,分别收集,所以其是稳定的排序算法。 - 希尔排序(shell)
希尔排序是按照不同步长对元素进行插入排序,当刚开始元素很无序的时候,步长最大,所以插入排序的元素个数很少,速度很快;当元素基本有序了,步长很小,插入排序对于有序的序列效率很高。所以,希尔排序的时间复杂度会比o(n^2)好一些。由于多次插入排序,我们知道一次插入排序是稳定的,不会改变相同元素的相对顺序,但在不同的插入排序过程中,相同的元素可能在各自的插入排序中移动,最后其稳定性就会被打乱,所以shell排序是不稳定的。 - 堆排序
我们知道堆的结构是节点i的孩子为2i和2i+1节点,大顶堆要求父节点大于等于其2个子节点,小顶堆要求父节点小于等于其2个子节点。在一个长为n的序列,堆排序的过程是从第n/2开始和其子节点共3个值选择最大(大顶堆)或者最小(小顶堆),这3个元素之间的选择当然不会破坏稳定性。但当为n/2-1, n/2-2, …1这些个父节点选择元素时,就会破坏稳定性。有可能第n/2个父节点交换把后面一个元素交换过去了,而第n/2-1个父节点把后面一个相同的元素没有交换,那么这2个相同的元素之间的稳定性就被破坏了。所以,堆排序不是稳定的排序算法
- 冒泡排序:
12 有N个节点的二叉树,其高度为多少
二叉树高度最高的情况是每一个层只有一个结点,此时高度为N
最小的情况是完全二叉树,高度是[log2N]+1,以2为底的对数取整后+1
所以高度是[log2N]+1 到 N
13 单链表为空的判定条件
若单链表带头结点,那么判定它为空的条件是head->nextNULL;
若单链表不带头结点,那么判定它为空的条件则是headNULL。
14 向量点积和叉积
-
向量的点乘,也叫向量的内积、数量积,对两个向量执行点乘运算,就是对这两个向量对应位一一相乘之后求和的操作,点乘的结果是一个标量。
-
点乘的几何意义是可以用来表征或计算两个向量之间的夹角,以及在b向量在a向量方向上的投影
-
两个向量的叉乘,又叫向量积、外积、叉积,叉乘的运算结果是一个向量而不是一个标量。并且两个向量的叉积与这两个向量组成的坐标平面垂直。
-
在三维几何中,向量a和向量b的叉乘结果是一个向量,更为熟知的叫法是法向量,该向量垂直于a和b向量构成的平面。
向量积(矢积)与数量积(标积)的区别
名称 | 标积/内积/数量积/点积 | 矢积/外积/向量积/叉积 |
---|---|---|
运算式(a,b和c粗体字,表示向量) | a·b=|a||b|·cosθ | a×b=c,其中 |c|=|a||b|·sinθ,c的方向遵守右手定则 |
几何意义 | 向量a在向量b方向上的投影与向量b的模的乘积 | c是垂直a、b所在平面,且以|b|·sinθ为高、|a|为底的平行四边形的面积 |
运算结果的区别 | 标量(常用于物理)/数量(常用于数学) | 矢量(常用于物理)/向量(常用于数学) |
15 排序算法总结
十种常见排序算法可以分为两大类:
- 比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此也称为非线性时间比较类排序。
- 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。
冒泡排序(Bubble Sort)
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
- 针对所有的元素重复以上的步骤,除了最后一个;
- 重复步骤1~3,直到排序完成。
选择排序(Selection Sort)
- 初始状态:无序区为R[1…n],有序区为空;
- 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1…i-1]和R(i…n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第1个记录R交换,使R[1…i]和R[i+1…n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
- n-1趟结束,数组有序化了。
插入排序(Insertion Sort)
一般来说,插入排序都采用in-place在数组上实现。具体算法描述如下:
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后;
- 重复步骤2~5。
希尔排序(Shell Sort)
第一个突破O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
首先它把较大的数据集合分割成若干个小组(逻辑上分组),然后对每一个小组分别进行插入排序,此时,插入排序所作用的数据量比较小(每一个小组),插入的效率比较高
先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
- 选择一个增量序列t1,t2,…,tk,其中ti>tj,tk=1;
- 按增量序列个数k,对序列进行k 趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
- 希尔排序的核心在于间隔序列的设定。既可以提前设定好间隔序列,也可以动态的定义间隔序列
归并排序(Merge Sort)
该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 对这两个子序列分别采用归并排序;
- 将两个排序好的子序列合并成一个最终的排序序列。
归并排序是一种稳定的排序方法。和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(nlogn)的时间复杂度。代价是需要额外的内存空间。
快速排序(Quick Sort)
快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
实现:
def quicksort(arr):
if not arr:
return []
key = arr[0]
left = quicksort([i for i in arr[1:] if i < key])
right = quicksort([i for i in arr[1:] if i >= key])
return left + [key] + right
改进:
- 快速排序在处理小规模数据时的表现不好.
这个时候可以改用插入排序。当数据规模小于一定程度时,改用插入排序。具体小到何种规模时,采用插入排序,这个理论上还不解,一些文章中说是 5 到 25 之间。SGI STL 中的快速排序采用的值是 10. - 对于一个每个元素都完全相同的一个序列来讲,快速排序也会退化到 O(n^2)。
要将这种情况避免到,可以这样做:在分区的时候,将序列分为 3 堆,一堆小于中轴元素,一堆等于中轴元素,一堆大于中轴元素,下次递归调用快速排序的时候,只需对小于和大于中轴元素的两堆数据进行排序,中间等于中轴元素的一堆已经放好。 - 枢纽元素选择:
选序列的首元素为枢轴元素(或称基准元素)改为选序列的首元素、中间元素和末元素3个元素中中间大的元素为基准元素(简单的就用中间元素为基准),这可大大改善快速排序的性能。
堆排序(Heap Sort)
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
- 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
- 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。
计数排序(Counting Sort)
计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。
- 找出待排序的数组中最大和最小的元素;
- 统计数组中每个值为i的元素出现的次数,存入数组C的第i项;
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
- 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。
桶排序(Bucket Sort)
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排)。
- 设置一个定量的数组当作空桶;
- 遍历输入数据,并且把数据一个一个放到对应的桶里去;
- 对每个不是空的桶进行排序;
- 从不是空的桶里把排好序的数据拼接起来。
桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。
基数排序(Radix Sort)
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。有时候有些属性是有优先级顺序的,先按低优先级排序,再按高优先级排序。最后的次序就是高优先级高的在前,高优先级相同的低优先级高的在前。
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位组成radix数组;
- 对radix进行计数排序(利用计数排序适用于小范围数的特点);
基数排序基于分别排序,分别收集,所以是稳定的。但基数排序的性能比桶排序要略差,每一次关键字的桶分配都需要O(n)的时间复杂度,而且分配之后得到新的关键字序列又需要O(n)的时间复杂度。假如待排数据可以分为d个关键字,则基数排序的时间复杂度将是O(d*2n) ,当然d要远远小于n,因此基本上还是线性级别的。
基数排序的空间复杂度为O(n+k),其中k为桶的数量。一般来说n>>k,因此额外空间需要大概n个左右。
16 背包问题
- 01背包问题:每个物体只有选和不选两个 选项
- 完全背包问题:每个物体可以选任意多个
- 多重背包问题:每个物体有限制,只能选限制内的个数
- 混合背包问题:物品有很多种
- 二维费用的背包问题:
- 分组背包问题:物品分组,然后组内最多选一件
- 背包问题求方案数:
- 求背包问题方案
- 有依赖的背包问题
01背包问题
有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。
第 i 件物品的体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。
二维动态规划:
- 状态定义:f[i][j]表示只看前i个物品,总体积是j的情况下,总价值最大是多少
- res = max(f[n][0~V])
- 状态转义f[i][j]
- 不选第i个物品 f[i][j] = f[i-1][j]
- 选第i个物品 f[i][j] = f[i-1][j-v[i]]+w[i] 只有当j大于V[i]的时候才能选择
- f[i][j] = max(f[i-1][j],f[i-1][j-v[i]]+w[i])
- 状态初始化:初始化f[0][0] = 0
- 时间复杂度O(n^n),空间复杂度O(n^n)
n, v = map(int, input().split())
goods = []
for i in range(n):
goods.append([int(i) for i in input().split()])
# 初始化,先全部赋值为0,这样至少体积为0或者不选任何物品的时候是满足要求
dp = [[0 for i in range(v+1)] for j in range(n+1)]
for i in range(1, n+1):
for j in range(1,v+1):
dp[i][j] = dp[i-1][j] # 第i个物品不选
if j>=goods[i-1][0]:# 判断背包容量是不是大于第i件物品的体积
# 在选和不选的情况中选出最大值
dp[i][j] = max(dp[i][j], dp[i-1][j-goods[i-1][0]]+goods[i-1][1])
print(dp[-1][-1])
- 上个办法的优化
- f[i]只和上一层的f有关,即f[i-1]
- 二维数组变成一维
- 也需要两层循环,然后第二层循环倒着循环
- 这个时候f[m]就是最终的答案 初始化的时候要把所有的都初始化为0
- 状态定义:f[i]表示体积为i的情况下他的最大价值是多少
- 状态转移:跟上边类似,但要考虑到每次计算用到的是上一个状态,所以要倒着运算,这样用到的就是f【i-1】
- 状态初始化:初始化一个长度为V+1一维数组,选取0个物品时,体积为0~V时的最大价值(值全部为0)。
n, v = map(int, input().split())
goods = []
for i in range(n):
goods.append([int(i) for i in input().split()])
dp = [0 for i in range(v+1)]
for i in range(n):
for j in range(v,-1,-1): # 从后往前
if j >= goods[i][0]:
dp[j] = max(dp[j], dp[j-goods[i][0]] + goods[i][1])
print(dp[-1])
- 如果我要求的不是尽可能最大的价值,而是刚好等于背包容量的最大价值,那么该如何去做呢?
f[0] = 0 f[i] = -inf
答:在初始化的时候去处理,只把f[0]初始化成0,其他的f初始化为负无穷,这样就可以确保所有的状态都是从f[0]转移过来,因为从其他地方转移过来的话,值为负无穷。
完全背包问题
有 N 种物品和一个容量是 V 的背包,每种物品都有无限件可用。
第 i 种物品的体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出最大价值。
完全背包问题跟01背包问题最大的区别就是每一个物品可以选无数次,因此当我们考虑到第i个物品时,我们应该考虑的情况是:不选这个物品、选一次这个物品、选两次这个物品…,直到不能再选(选的次数k,k*v[i] > j,j为当前背包容量),然后再从这些情况中选最大的
状态定义:f[i]表示总体积是i的情况下,最大价值是多少
result = max(f[0…m])
状态转移:
一维动态规划:
n, v = map(int, input().split())
goods = []
for i in range(n):
goods.append([int(i) for i in input().split()])
dp = [0 for i in range(v+1)]
for i in range(n):
for j in range(v,-1,-1): # 从后往前
k = j//goods[i][0] # 能选多少次
# 从这些次里面取最大
dp[j] = max([dp[j- x* goods[i][0]] + x * goods[i][1] for x in range(k+1)])
# dp[j- x* goods[i][0]] 这个里边是不包含第i个物品的,因为倒序,所有后边可以加上i物品的价值
print(dp[-1])
一维动态规划(优化):
其实可以通过从前往后递推
一方面我们可以根据前一个状态(i-1)推出此时的状态,另一方面由于当前状态前面的值也是当前问题的子问题,因此我们也可以从前面的值推到后面的值。
n, v = map(int, input().split())
goods = []
for i in range(n):
goods.append([int(i) for i in input().split()])
dp = [0 for i in range(v+1)]
for i in range(n):
for j in range(v+1): # 这边表示从good[i][0]到v
if j >= goods[i][0]:
dp[j] = max(dp[j], dp[j-goods[i][0]] + goods[i][1])
# dp[j-goods[i][0]]这个是从前往后算的,所以已经计算了第i个物品,所以直接更新就可以
print(dp[-1])
多重背包
描述:
有N件物品和一个容量为V的背包。
第i件物品的体积是vi,价值是wi,数量是si。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包流量,且总价值最大。
时间复杂度:NVS
n,v = map(int, input().split())
goods = []
for i in range(n):
goods.append([int(i) for i in input().split()])
dp = [0 for i in range(v+1)]
for i in range(n):
for j in range(v, -1, -1):
# 考虑两种情况的最小值
k = min(j//goods[i][0], goods[i][2])
dp[j] = max([dp[j-x*goods[i][0]] + x*goods[i][1] for x in range(k+1)])
print(dp[-1])
.
一维动态规划(转换01背包)
想法很简单,直接把背包中的物品展开,展成很多数量为1的物品,这样就转换为01背包问题。代码如下:
n,v = map(int, input().split())
goods = []
for i in range(n):
goods.append([int(i) for i in input().split()])
new_goods = []
# 展开
for i in range(n):
for j in range(goods[i][2]):
new_goods.append(goods[i][0:2])
goods = new_goods
n = len(goods)
# 01背包问题
dp = [0 for i in range(v+1)]
for i in range(n):
for j in range(v,-1,-1):
if j>= goods[i][0]:
dp[j] = max(dp[j], dp[j - goods[i][0]] + goods[i][1])
print(dp[-1])
优化方法1(二进制优化):
拆成一个一个的话会大大的增加计算复杂度。
利用二进制的方式进行优化。可以考虑如果给出一个数,最少用几个数可以把0-它之间的数全部表示出来。
eg:7->1 2 4
eg:10->1 2 4 3(10-4-2-1)这样可以保证只到10
每个位置的数是其二进制位的数
这样的话会分成log(s)份
时间复杂度:NVlog(V)
if __name__ == "__main__":
n, v = map(int, input().split())
goods = []
for i in range(n):
vi, wi, si = map(int, input().split())
j = 1
temp = si
while j <= temp:
temp -= j
goods.append([vi*j, wi*j])
j *= 2
if temp > 0:
goods.append([vi*temp, wi*temp])
dp = [0 for i in range(v + 1)]
for i in range(len(goods)):
for j in range(v, -1, -1):
if j >= goods[i][0]:
dp[j] = max(dp[j], dp[j - goods[i][0]] + goods[i][1])
print(dp[-1])
优化方法2:多重背包的单调队列
时间复杂度NV
n, m = map(int, input().split())
dp, q = [0] * 20005, [None] * 20005 # q = [[pos, val], ...]
for _ in range(n):
c, w, s = map(int, input().split())
for j in range(c):
hh = tt = 0
for k in range((m - j)//c + 1):
cur_val = dp[j + k * c] - k * w
while hh < tt and q[tt - 1][1] <= cur_val: tt -= 1
q[tt] = [k, cur_val]; tt += 1
if q[hh][0] < k - s: hh += 1
dp[j + c * k] = q[hh][1] + k * w
print(dp[m])
混合背包问题
有 N 种物品和一个容量是 V 的背包。
物品一共有三类:
第一类物品只能用1次(01背包);
第二类物品可以用无限次(完全背包);
第三类物品最多只能用 si 次(多重背包);
每种体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。
输出最大价值。
# 拆分成不同的背包问题,然后多重背包用二进制改进
if __name__ == "__main__":
n, v = map(int, input().split())
dp = [0]*(v+1)
goods = []
for i in range(n):
vi, wi, si = map(int, input().split())
if si < 0:goods.append([-1,vi,wi]) # 01背包
elif si == 0:goods.append([0,vi,wi]) # 完全背包
else:
j = 1
temp = si
while j <= temp:
temp -= j
goods.append([-1, vi * j, wi * j])
j *= 2
if temp > 0:
goods.append([-1, vi * temp, wi * temp])
for good in goods:
if good[0] < 0:
for j in range(v,good[1]-1,-1):
dp[j] = max(dp[j], dp[j-good[1]] + good[2])
else:
for j in range(good[1],v+1):
dp[j] = max(dp[j], dp[j - good[1]] + good[2])
print(dp[-1])
二维费用的背包问题
有 N 件物品和一个容量是 V 的背包,背包能承受的最大重量是 M。
每件物品只能用一次。体积是 vi,重量是 mi,价值是 wi。
求解将哪些物品装入背包,可使物品总体积不超过背包容量,总重量不超过背包可承受的最大重量,且价值总和最大。
输出最大价值。
状态:dp[i][j]表示体积为i,重量为j时,最大价值是多少
if __name__ == "__main__":
n, v, m = map(int, input().split())
dp = [[0]*(m+1) for _ in range(v+1)]
goods = []
for i in range(n):
vi, mi, wi = map(int, input().split())
for j in range(v, vi - 1, -1):
for k in range(m, mi - 1, -1):
dp[j][k] = max(dp[j][k], dp[j - vi][k-mi] + wi)
print(dp[-1][-1])
分组背包问题
有 N 组物品和一个容量是 V 的背包。
每组物品有若干个,同一组内的物品最多只能选一个。
每件物品的体积是 vij,价值是 wij,其中 i 是组号,j 是组内编号。
求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。
输出最大价值。
接下来有 N 组数据:
每组数据第一行有一个整数 Si,表示第 i 个物品组的物品数量;
每组数据接下来有 Si 行,每行有两个整数 vij,wij,用空格隔开,分别表示第 i 个物品组的第 j 个物品的体积和价值;
if __name__ == "__main__":
n, v = map(int, input().split())
dp = [0]*(v+1)
goods = []
for i in range(n):
s = int(input())
for j in range(s):
vi, wi = map(int, input().split())
if len(goods)>j:
goods[j][0], goods[j][1] = vi, wi
else:
goods.append([vi,wi])
for j in range(v, -1, -1):
for k in range(s):
if j >= goods[k][0]:
dp[j] = max(dp[j], dp[j - goods[k][0]] + goods[k][1])
print(dp[-1])
有依赖的背包问题
有 N 个物品和一个容量是 V 的背包。
物品之间具有依赖关系,且依赖关系组成一棵树的形状。如果选择一个物品,则必须选择它的父节点。
如果选择物品5,则必须选择物品1和2。这是因为2是5的父节点,1是2的父节点。
每件物品的编号是 i,体积是 vi,价值是 wi,依赖的父节点编号是 pi。物品的下标范围是 1…N。
求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。
输出最大价值。
输入格式
第一行有两个整数 N,V,用空格隔开,分别表示物品个数和背包容量。
接下来有 N 行数据,每行数据表示一个物品。
第 i 行有三个整数 vi,wi,pi,用空格隔开,分别表示物品的体积、价值和依赖的物品编号。
如果 pi=−1,表示根节点。 数据保证所有物品构成一棵树。
状态定义:dp[i][j] :选节点i的情况下,体积是j时,最大价值是多少
import sys
import math
########################处理输入##############################
x = sys.stdin.readlines()
n, m = map(int, x.pop(0).rstrip().split()) #物品数量、背包体积
y = [ [] for i in range(len(x))]
for i in range(len(x)):
y[i] = [int(j) for j in x[i].rstrip().split()]
f = [[0 for j in range(m+1)] for i in range(n+1)]
#########################代码主体##############################
h = [-1 for _ in range(n+1)]
e = [0 for _ in range(n+1)]
ne = [0 for _ in range(n+1)]
v = [0 for _ in range(n+1)]
w = [0 for _ in range(n+1)]
idx = 1 #边的序号
def addedge(a, b): #建立链式前向星,a起点指向b终点
if a >= 0 and b >= 0: #起点、终点序号都得合法; 防止-1这种列表序号
global idx
e[idx] = b #终点
ne[idx] = h[a] #同起点下一条边,构成链表效果
h[a] = idx #h的长度代表不同起点的数量, "当前边"就是以点a为起点的最后一条边
idx += 1
def dfs(u): #u代表子树的根节点,利用链式前向星进行dfs,起点代表树的父节点而终点代表子节点,叶节点指向-1
global f
i = h[u] #边的序号
while i != -1:
son = e[i] #子节点
dfs(son)
for j in range(m-v[u], -1, -1): #体积从大到小, 必拿本节点
for k in range(1, j+1): #分组背包问题,因为同个子树容量不同时,决策不同,决策之间互斥可以看作不同的物品
f[u][j] = max(f[u][j], f[u][j-k] + f[son][k]) #从子树更新最大价值情况
i = ne[i]
for i in range(m, v[u]-1, -1): #背包容量足够就加上当前节点
f[u][i] = f[u][i-v[u]] + w[u]
for i in range(0, v[u]): #背包容量不够就不加当前节点,但是也不能放任何子节点
f[u][i] = 0
def solution(n, m, items):
# dp[u,i,j] 代表选了以u为根的子树, 只考虑其前i个子树时, 背包容量为j时, 可获得的最大价值 -> f[u][j]
#每算某个节点,先算它的子节点
root = 0
for i in range(1, len(items)+1): # i代表节点序号
v[i], w[i], p = items[i-1]
if p == -1: root = i
else: addedge(p, i) #else可加可不加
dfs(root)
return f[root][m]
######################################################################################
print(solution(n, m, y))
背包问题求方案数
有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。
第 i 件物品的体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出 最优选法的方案数。注意答案可能很大,请输出答案模 10^9+7 的结果。
解决思路就是要加一个记录方案数的数组
if __name__ == "__main__":
mod = pow(10, 9)+7
n, v = map(int, input().split())
dp = [0] + [float("-inf")] * v # 存储最大价值
nums = [1] + [0] * v # 存储最大方案数
goods = []
for i in range(n):
vi, wi = map(int, input().split())
for j in range(v, vi-1, -1):
t = max(dp[j], dp[j - vi] + wi) # t存储要更新的dp的值
s = 0 # 最大方案数
# 判断dp是从那个方案转化过来的,并进行方案数相加,如果都可以均相加
if t == dp[j]:s += nums[j]
if t == dp[j-vi] + wi:s += nums[j-vi]
if s >= mod: s -= mod # 取模
# 赋值
dp[j] = t
nums[j] = s
max_val = max(dp) # 求最大价值
res = 0
# 找到所有最大价值对应的方案数,进行相加
for i in range(v+1):
if max_val == dp[i]:
res += nums[i]
if res >= mod:res -= mod
print(res)
背包问题求具体方案
有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。
第 i 件物品的体积是 vi,价值是 wi。
求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。
输出 字典序最小的方案。这里的字典序是指:所选物品的编号所构成的序列。物品的编号范围是 1…N。
if __name__ == "__main__":
n, v = map(int, input().split())
goods = [[0,0]] # 因为编号从1开始
for i in range(n):
goods.append([int(i) for i in input().split()])
dp = [[0 for i in range(v+1)] for j in range(n+2)]
for i in range(n,0,-1):
for j in range(0, v + 1):
dp[i][j] = dp[i + 1][j]
if j >= goods[i][0]:
dp[i][j] = max(dp[i][j], dp[i + 1][j - goods[i][0]] + goods[i][1])
vol = v
for i in range(1,n+1):
if dp[i][vol] == dp[i+1][vol-goods[i][0]] + goods[i][1]:
print(i,end=" ")
vol -= goods[i][0]
17 动态规划
动态规划问题的一般形式就是求最值。
既然是要求最值,核心问题是什么呢?求解动态规划的核心问题是穷举。
特别的地方:
- 这类问题存在「重叠子问题」,如果暴力穷举的话效率会极其低下,所以需要「备忘录」或者「DP table」来优化穷举过程
- 动态规划问题一定会具备「最优子结构」,才能通过子问题的最值得到原问题的最值。
- 只有列出正确的「状态转移方程」才能正确地穷举。
- 重叠子问题、最优子结构、状态转移方程就是动态规划三要素。
啥叫「自顶向下」?注意我们刚才画的递归树(或者说图),是从上向下延伸,都是从一个规模较大的原问题比如说f(20),向下逐渐分解规模,直到f(1)和f(2)触底,然后逐层返回答案,这就叫「自顶向下」。
啥叫「自底向上」?反过来,我们直接从最底下,最简单,问题规模最小的f(1)和f(2)开始往上推,直到推到我们想要的答案f(20),这就是动态规划的思路,这也是为什么动态规划一般都脱离了递归,而是由循环迭代完成计算。
具有「最优子结构」是动态规划问题。要符合「最优子结构」,子问题间必须互相独立。
18 回溯算法
解决一个回溯问题,实际上就是一个决策树的遍历过程。你只需要思考 3 个问题:
- 路径:也就是已经做出的选择。
- 选择列表:也就是你当前可以做的选择。
- 结束条件:也就是到达决策树底层,无法再做选择的条件。
# 回溯框架
result = []
def backtrack(路径, 选择列表):
if 满足结束条件:
result.add(路径)
return
for 选择 in 选择列表:
做选择
backtrack(路径, 选择列表)
撤销选择
其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」
各种搜索问题其实都是树的遍历问题,而多叉树的遍历框架就是这样:
void traverse(TreeNode root) {
for (TreeNode child : root.childern)
// 前序遍历需要的操作
traverse(child);
// 后序遍历需要的操作
}
前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历代码在离开某个节点之后的那个时间点执行。
这也是回溯算法的一个特点,不像动态规划存在重叠子问题可以优化,回溯算法就是纯暴力穷举,复杂度一般都很高。
有“通用解题方法”的美称
在包含问题所有解的解空间树中,按照深度优先搜索的策略,从根节点出发深度搜索解空间树:
也可以分成以下几个步骤:
- 确定问题空间解
- 确定节点的扩展搜索规则
- 以深度优先搜索解空间,并在搜索过程中进行剪枝
19 滑动窗口
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
/*** debug 输出的位置 ***/
printf("window: [%d, %d)\n", left, right);
/********************/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
其中两处…表示的更新窗口数据的地方,到时候你直接往里面填就行了。
而且,这两个…处的操作分别是右移和左移窗口更新操作,等会你会发现它们操作是完全对称的。
20 二分查找
二分查找框架
int binarySearch(int[] nums, int target) {
int left = 0, right = ...;
while(...) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}
分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节。
寻找一个数(基本的二分搜索)
搜索一个数,如果存在,返回其索引,否则返回 -1。
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1; // 注意
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1; // 注意
else if (nums[mid] > target)
right = mid - 1; // 注意
}
return -1;
}
1、为什么 while 循环的条件中是 <=,而不是 <?
- 因为初始化right的赋值是nums.length - 1,即最后一个元素的索引,而不是nums.length。
- 区别是:前者相当于两端都闭区间[left, right],后者相当于左闭右开区间[left, right)
- 搜索区间为空的时候应该终止
- while(left <= right)的终止条件是left == right + 1
- while(left < right)的终止条件是left == right
2、为什么left = mid + 1,right = mid - 1?我看有的代码是right = mid或者left = mid,没有这些加加减减,到底怎么回事,怎么判断?
刚才明确了「搜索区间」这个概念,而且本算法的搜索区间是两端都闭的,即[left, right]。那么当我们发现索引mid不是要找的target时,下一步应该去搜索哪里呢?
当然是去搜索[left, mid-1]或者[mid+1, right]对不对?因为mid已经搜索过,应该从搜索区间中去除。
3、此算法有什么缺陷?
比如说给你有序数组nums = [1,2,2,2,3],target为 2,此算法返回的索引是 2,没错。但是如果我想得到target的左侧边界,即索引 1,或者我想得到target的右侧边界,即索引 3,这样的话此算法是无法处理的。
寻找左侧边界的二分搜索
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // 注意
while (left < right) { // 注意
int mid = (left + right) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // 注意
}
}
return left;
}
1、为什么 while 中是<而不是<=?
while(left < right)终止的条件是left == right,此时搜索区间[left, left)为空,所以可以正确终止。
2、为什么没有返回 -1 的操作?如果nums中不存在target这个值,怎么办?
「左侧边界」有什么特殊含义:
nums中小于给定数的元素有几个。
另一种写法:
int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
// 搜索区间为 [left, right]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// 搜索区间变为 [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// 搜索区间变为 [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// 收缩右侧边界
right = mid - 1;
}
}
// 检查出界情况
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}
寻找右侧边界的二分查找
int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
left = mid + 1; // 注意
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
return left - 1; // 注意
}
int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// 这里改成收缩左侧边界即可
left = mid + 1;
}
}
// 这里改为检查 right 越界的情况,见下图
if (right < 0 || nums[right] != target)
return -1;
return right;
}
1、为什么最后返回left - 1而不像左侧边界的函数,返回left?而且我觉得这里既然是搜索右侧边界,应该返回right才对。
首先,while 循环的终止条件是left == right,所以left和right是一样的,你非要体现右侧的特点,返回right - 1好了。
至于为什么要减一,这是搜索右侧边界的一个特殊点,关键在这个条件判断:
if (nums[mid] == target) {
left = mid + 1;
// 这样想: mid = left - 1
因为我们对left的更新必须是left = mid + 1,就是说 while 循环结束时,nums[left]一定不等于target了,而nums[left-1]可能是target。
至于为什么left的更新必须是left = mid + 1,同左侧边界搜索,就不再赘述。
逻辑统一
第一个,最基本的二分查找算法:
- 因为我们初始化 right = nums.length - 1
- 所以决定了我们的「搜索区间」是 [left, right]
- 所以决定了 while (left <= right)
- 同时也决定了 left = mid+1 和 right = mid-1
- 因为我们只需找到一个 target 的索引即可
- 所以当 nums[mid] == target 时可以立即返回
第二个,寻找左侧边界的二分查找:
- 因为我们初始化 right = nums.length
- 所以决定了我们的「搜索区间」是 [left, right)
- 所以决定了 while (left < right)
- 同时也决定了 left = mid + 1 和 right = mid
- 因为我们需找到 target 的最左侧索引
- 所以当 nums[mid] == target 时不要立即返回
- 而要收紧右侧边界以锁定左侧边界
第三个,寻找右侧边界的二分查找:
- 因为我们初始化 right = nums.length
- 所以决定了我们的「搜索区间」是 [left, right)
- 所以决定了 while (left < right)
- 同时也决定了 left = mid + 1 和 right = mid
- 因为我们需找到 target 的最右侧索引
- 所以当 nums[mid] == target 时不要立即返回
- 而要收紧左侧边界以锁定右侧边界
- 又因为收紧左侧边界时必须 left = mid + 1
- 所以最后无论返回 left 还是 right,必须减一
排除法写二分
二分查找法不仅仅可以用在有序数组元素上的查找,只要其具有一定单调性
排除法的理念:把待搜索的目标值留在最后判断,在循环体内不断地把不符合题目的要求的子区间排除掉,在退出循环之后,因为只剩下一个数没看到,进行单独判断。
一般步骤:
- while (left < right) 退出循环的时候有 left == right 成立,因此无需考虑返回 left 还是 right;
- if else 语句:思考nums[mid] 满足什么性质时,nums[mid]不是目标元素,接着判断mid左边是否存在目标元素,然后判断右边
- 所以只有两种写法:
# 1
if check(mid):
right = mid
else:
left = mid + 1
# 2
if check(mid):
right = mid - 1
else:
left = mid
-
根据边界收缩的行为,修改取中间数的行为
- mid = left + (right - left) // 2
- "//"是向下取整,所有永远取不到有边界right
- 所以在面对 left = mid right = mid - 1时,可能产生死循环
- 死循环指的是区间分不开,不能排除掉非目标元素
- 此时修改取中间数的行为,在括号里边加一,改为向上取整即可
- 即当 left = mid right = mid - 1时,mid = left + (right - left+1) // 2
-
退出循环后,看是否需要多nums[left]是否是目标元素,进行一次检查
注意:
- 退出时,left = right [left,right]这个值并没有进行判断
- 初始化时,有事把搜索区间设置大一点没有关系
- 从如何把mid排除掉的角度思考if else怎么写
- 把“什么时候不是目标元素”作为注释写在代码中
- 把下一轮搜索的区间范围作为注释写到代码中