小力将 N 个零件的报价存于数组 nums。小力预算为 target,假定小力仅购买两个零件,要求购买零件的花费不超过预算,请问他有多少种采购方案。
注意:答案需要以(1000000007) 为底取余,如:计算初始结果为:1000000008,请返回1
示例 1:
输入:nums = [2,5,3,5], target = 6
输出:1
解释:预算内仅能购买 nums[0] 与 nums[2]。
示例 2:
输入:nums = [2,2,1,9], target = 10
输出:4
解释:符合预算的采购方案如下:
nums[0] + nums[1] = 4
nums[0] + nums[2] = 3
nums[1] + nums[2] = 3
nums[2] + nums[3] = 10
提示:
2 <= nums.length <= 10^5
1 <= nums[i], target <= 10^5
链接:https://leetcode-cn.com/problems/4xy4Wx
暴力法应该是最容易想出来的, 用2层循环遍历,满足条件就把result+1, 2层循环下来, 时间复杂度是O(N^2). 还有注意点就是, 需要考虑重复, 比如[1,3,5], target为5, 计算1,3满足; 计算3,1又满足, 但是这样是一种组合, 所以 (计数结果/2) 才是最终结果.
虽然简单,但是还是写出来, 因为后面的算法写出来之后,都可以先用这个算法判定下对错,不用每次都跑到LeetCode上提交了.
func purchasePlans(_ nums: [Int], _ target: Int) -> Int {
var result = 0
for (i,firstNum) in nums.enumerated() {
for (j,secondNum) in nums.enumerated() {
// 同样的下表不能参与计算
if (i == j) {
continue
}
if firstNum + secondNum <= target {
result += 1
}
}
}
// 去除重复的计算结果,
// 比如[1,3,5], target为5,
//计算1,3满足; 计算3,1又满足, 但是这样是一种组合, 所以 (计数结果/2) 才是最终结果.
result = result / 2
return result % 1000000007
}
第二种算法,还是使用2层循环, 但是进行了一点点优化, 那就是进行了排序,
nums 数组排序后,对于某个数 nums[i],如果存在一个数 nums[j] 使得 j > i 的同时满足 nums[i] + nums[j] < target,则 i 到 j 之间的任意索引 k 都满足 nums[i] + nums[k] < target。
自然而然, 还是从简单的开始
- 先针对数组进行一次排序,O(NlogN),小的在前,大的在后,
- 然后在从小到大进行循环,外循环 ,下表为i
- 在来一层循环, j从 i+1到数组末尾, 查找到一个满足条件的就+1,
- 找到不满足条件的,就结束本次内循环, 继续第2步的外循环
- 循环结束后, result就是结果值了
直接这样提交上去, 还是超时, 但是稍作修改后, 加上了最差情况的处理, 不会出超时了,但是效率很低,需要3292ms
最坏情况就是,数组中的任意组合都满足条件, 那么第4步的break的条件就永远不会触发, 这样的算法就和暴力法一样了, 需要O(N^2)的时间复杂度.
// 排序后最大组合的2个数满足条件,说明任何组合都可以,直接计算结果
if sortArray[count - 1] + sortArray[count - 2] <= target {
let result = count * (count - 1) / 2
return result % 1000000007
}
全部代码如下:
func purchasePlans(_ nums: [Int], _ target: Int) -> Int {
let sortArray = nums.sorted()
let count = nums.count
var result = 0
// 排序后最大组合的2个数满足条件,说明任何组合都可以,直接计算结果,
if sortArray[count - 1] + sortArray[count - 2] <= target {
let result = count * (count - 1) / 2
return result % 1000000007
}
for (i,firstNum) in sortArray.enumerated() {
for j in i+1 ..< sortArray.count {
let secondNum = sortArray[j]
if firstNum + secondNum <= target {
result += 1
} else {
break
}
}
}
return result % 1000000007
}
上面的算法本质还是需要2层O(N)循环, 内层的循环存在一点优化空间, 内层的循环主要目的是找到元素j, 使得j是最后一个满足条件的下标, 上面的做法是遍历寻找, 如果改成2分查找元素j 应该会有不错的提升, 算法的整体时间复杂度会下降到O(N*logN).
查找从O(N) 变成 O(logN), 想法很简单, 但是实现起来还有有坑的. 还是由整体到局部
- 先针对数组进行一次排序,O(NlogN),小的在前,大的在后,
- 然后在从小到大进行循环,外循环 ,下表为i
- 在进行一层循环, 进行二分查找, mid取值在i+1 到 数组count之间
- 找到满足条件的mid,就计算一次结果, 然后继续 i ++, 执行第3步
- 循环结束后, result就是结果值了
关键点在于第4步, 为了寻找到满足条件的情况,需要考虑的情况还挺多,
- 当找到的mid值与nums[i] 相加 < target时,
- 如果mid就是数组最后一个元素了,查找结束, 总共有mid - i 满足条件
- 如果mid+1 与 nums[i]恰好 > target时, 也可以说明mid是最后一个满足条件的元素, 查找结束, 总共有mid - i 满足条件
- 如果上述2个条件不满足, 那说明合适的值在区间右侧, leftIndex = mid+1, 继续向右侧寻找
- 当找到的mid值与num[i]相加 > tatget时,
- 如果mid的前一个值就是i, 那么说明本次已经找不到了,查找结束,0个满足条件
- 如果mid-1 与 nums[i]恰好 <= target时, 说明mid-1就是最后一个满足条件的元素了, 查找结束, 总有有 (mid-1) - i 个满足条件
- 如果上述2个条件都不满足, 那说明合适的值在区间左侧, rightIndex = mid-1, 继续向左寻找
- 当找到的mid值与num[i]相加 == tatget时, 这时候也别着急开心,还有一种情况需要考虑, 比如预算为8,[3,5,5,5,6],需要找出最后一个5出现的位置
- 找到最后一个满足条件的位置,结束查找
-
不要直接用sortArray.lastIndex(of: number),这个时间复杂度为O(n),从mid开始找,会更快点
看了上面的case, 导致这样写的算法还是挺复杂的. 但是时间复杂度也下降到了O(N*logN), 执行时间从 3292ms下降到了636ms.
知道有这种思路就好了, 后面还有一种算法, 更简洁, 更快, 二分法代码如下,
func purchasePlans(_ nums: [Int], _ target: Int) -> Int {
let sortArray = nums.sorted()
let count = nums.count
// 排序后最大组合的2个数满足条件,说明任何组合都可以,直接计算结果
if sortArray[count - 1] + sortArray[count - 2] <= target {
let result = count * (count - 1) / 2
return result % 1000000007
}
var result = 0
// 外层循环,执行N次
for (i,firstNum) in sortArray.enumerated() {
// 后面已经没有数字相加了,结束
if i == count - 1 {
break
}
var leftIndex = i
var rightIndex = count
// 这里不要遍历,使用用2分法进行优化
var mid = (leftIndex + rightIndex) / 2
// 内层循环,2分查找执行log(N)次
while true {
let secondNum = sortArray[mid]
if firstNum + secondNum < target { // 当前值小于预算
// 当前值小于预算 && mid已经是最后一个值了, 那说明i与mid之间的任意组合都满足条件
if mid == count - 1 {
result += mid - i
break
} else {
// 当前值小于预算 && 下一个值超过了预算, 那说明这个下表就是本次的最终结果
let nextNum = sortArray[mid+1]
if firstNum + nextNum > target {
result += mid - i
break
}
}
// 当前值小于预算 && 下一个值还小于等于预算, 那继续向右寻找
leftIndex = mid + 1
mid = (leftIndex + rightIndex) / 2
} else if firstNum + secondNum > target { // 超出预算,
// 超出预算 && 前一个值就已经是i了, 那说明本次的任何结果都不满足
// 这时候可以结束所有遍历了, 因为后续的firstNum一定更找不到合适的mid
if mid == i + 1 {
break
} else {
// 超出预算 && 前一个值小于等于预算, 那说明这个前一个值就是本次的最终结果,共有(mid-1) - i个满足条件
let preNum = sortArray[mid-1]
if firstNum + preNum <= target {
result += mid - 1 - i
break
}
}
//超出预算, 前一个值计算后还是超出预算,继续向左寻找
rightIndex = mid
mid = (leftIndex + rightIndex) / 2
} else { // 和预算相等,找出最后一个和预算相等的数字下表, 比如预算为8,[3,5,5,5,6],需要找出最后一个5出现的位置
let index = findLastIndex(in: sortArray, index: mid)
result += index - i
break
}
}
}
return result % 1000000007
}
// 根据传入的数组,找出最后一个和sortArray[index]相等的下表
func findLastIndex(in sortArray: [Int], index: Int) -> Int {
// 不要直接用这个,这个时间复杂度为O(n),从mid开始找
//sortArray.lastIndex(of: number)
let resultNum = sortArray[index]
let count = sortArray.count
for i in index ..< count {
let num = sortArray[i]
if num != resultNum {
return i-1
}
}
return count - 1
}
终于到了重头戏了, 双指针法, 还是基于排序进行优化, 上面的二分法优化,并没有特别好的利用上次结果, 如果找到一个合适的mid, 计算好了本次结果, 下次再二分查找时, 还是从 i+1 到count进行二分, 但是对于 nums[i + 1],想要找到对应的右区间,该索引一定在 mid 的左边。
因此,二分法解法可以抽象理解为,一个初始为 0 ~ nums.length 的窗口通过不断缩小,直到左右相逢。
- 针对数组进行一次排序,O(NlogN),小的在前,大的在后,
- 从leftIndex从0开始, rightIndex从count-1开始
- 如果sortArray[leftIndex] + sortArray[rightIndex] <= target , 说明leftIndex和rightIndex之间的都满足条件, result += rightIndex - leftIndex, leftIndex继续向右前进
- 如果sortArray[leftIndex] + sortArray[rightIndex] > target , 说明rightIndex太大了, rightIndex --, 继续第3步
- 循环结束后, result就是结果值了
双指针法, 排序的时间复杂度为O(N * logN), 查找的时间复杂度只有O(N), 整体的时间复杂度还是为O(N * logN), 时间也从636ms 略微下降到了 596ms, 但是算法的逻辑更加清晰, 代码也更简洁
func purchasePlans(_ nums: [Int], _ target: Int) -> Int {
let sortArray = nums.sorted()
let count = nums.count
var result = 0
// 双指针法,一个从左,一个从右,中间区间都是满足条件的
var leftIndex = 0
var rightIndex = count - 1
while leftIndex < rightIndex {
let leftValue = sortArray[leftIndex]
let rightValue = sortArray[rightIndex]
if leftValue + rightValue <= target { // 满足条件,计算本次的可选项,leftIndex++
result += rightIndex - leftIndex
leftIndex += 1
} else { // 不满足条件,rightIndex--
rightIndex -= 1
}
}
return result % 1000000007
}
最后总结下, 合理利用2个规律, 优化算法的时间复杂度. 从超时 -> 3292ms -> 636ms -> 596ms
nums 数组排序后,对于某个数 nums[i],如果存在一个数 nums[j] 使得 j > i 的同时满足 nums[i] + nums[j] < target,则 i 到 j 之间的任意索引 k 都满足 nums[i] + nums[k] < target。
与此同时,对于 nums[i + 1],想要找到对应的右区间,该索引一定在 j 的左边。
因此,解法可以抽象理解为,一个初始为 0 ~ nums.length 的窗口通过不断缩小,直到左右相逢。