LCP 28 采购方案

小力将 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。

自然而然, 还是从简单的开始

  1. 先针对数组进行一次排序,O(NlogN),小的在前,大的在后,
  2. 然后在从小到大进行循环,外循环 ,下表为i
  3. 在来一层循环, j从 i+1到数组末尾, 查找到一个满足条件的就+1,
  4. 找到不满足条件的,就结束本次内循环, 继续第2步的外循环
  5. 循环结束后, 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), 想法很简单, 但是实现起来还有有坑的. 还是由整体到局部

  1. 先针对数组进行一次排序,O(NlogN),小的在前,大的在后,
  2. 然后在从小到大进行循环,外循环 ,下表为i
  3. 在进行一层循环, 进行二分查找, mid取值在i+1 到 数组count之间
  4. 找到满足条件的mid,就计算一次结果, 然后继续 i ++, 执行第3步
  5. 循环结束后, 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 的窗口通过不断缩小,直到左右相逢。

  1. 针对数组进行一次排序,O(NlogN),小的在前,大的在后,
  2. 从leftIndex从0开始, rightIndex从count-1开始
  3. 如果sortArray[leftIndex] + sortArray[rightIndex] <= target , 说明leftIndex和rightIndex之间的都满足条件, result += rightIndex - leftIndex, leftIndex继续向右前进
  4. 如果sortArray[leftIndex] + sortArray[rightIndex] > target , 说明rightIndex太大了, rightIndex --, 继续第3步
  5. 循环结束后, 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 的窗口通过不断缩小,直到左右相逢。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值