Leetcode 每日一题练习

648. 单词替换

题解

读懂题是第一步, 本题要将句子中所有的单词替换为在词典中对应的它的前缀形式(如果字典中存在这个单词的前缀的话), 最直接的思路就是遍历句子中的所有单词, 将每个单词依次和词典中的所有单词比较, 看词典中是否存在它的前缀, 但这种方法无疑效率极低, 这个场景和我们日常查词典有些相似, 想想我们日常在词典中查一个单词是如何快速定位的, 当然是按照字母顺序在词典中先定位第一个字母的范围, 再定位第二个字母的范围…,最后找到需要的单词. 现在只要把这种方法表示出来就可以大大加快寻找前缀的速度. 这样就可以构造一棵词典树, 树的边表示不同的字母, 节点可以用来标定是否是一个可行单词. 再去查找某个单词的前缀的时候, 只需要去树中执行bfs找到最短的可行前缀即可.

代码

type TreeNodes struct{
    node [26]*TreeNodes
    over bool
}

func construct(dic []string) *TreeNodes{
    root := TreeNodes{}
    var point *TreeNodes
    for _, str := range dic{
        point = &root
        for i,_ := range str{
            if point.node[str[i]-'a']==nil{
                newnode := &TreeNodes{}
                point.node[str[i]-'a'] = newnode
                point = newnode
            }else{
                point = point.node[str[i]-'a']
            }
        }
        point.over = true
    }
    return &root
}

func findWord(root *TreeNodes, word string)(bool, string){
    var point *TreeNodes
    point = root
    prefix := []byte{}
    for i,_ := range word{
        if point.node[word[i]-'a'] == nil{
            return false,""
        }else{
            prefix = append(prefix, word[i])
            point = point.node[word[i]-'a']
            if point.over{
                return true, string(prefix)
            }
        }
    }
    return false,""
}

func replaceWords(dictionary []string, sentence string) string {
    root := construct(dictionary)
    sentenceArray := strings.Split(sentence, " ")
    exist, str := false, ""
    for i, word := range sentenceArray{
        exist, str = findWord(root, word)
        if exist{
            sentenceArray[i] = str
        }else{
            sentenceArray[i] = word
        }
    }
    return strings.Join(sentenceArray, " ")
}


总结

注意直接使用"+“在go中进行字符串连接是非常耗时的, 因此可以将原句子分割后得到字符串数组, 查询是否存在单词的可行前缀并直接替换对应数组中的字符串, 最后再调用strings.Join将数组中的所有字符串使用空格连接, 可以自行测试如果对数组中每个字符串在查找其可行前缀后都使用”+"连接到结果字符串上耗时远远大于这种方案.

523. 连续的子数组和

题解

本题要求连续子数组和为k的倍数, 则实际有影响的为各个位置上的元素对k取模后的余数, 这种求连续子数组和满足某种条件的问题, 常常可以使用前缀和求解. 在这个问题中使用前缀和保存从数组开头到当前位置元素的所有数字对k取模后求和再对k取模的值, 并将对应的值和当前的下标保存到长度为k的数组中, 数组下标表示前缀和(因为最终要对k取模,因此取值范围一定在k以内), 对应的数组位置的值为这个前缀和对应nums数组中的下标. 这种用数组保存的方式比用map来保存要快得多.

代码

func checkSubarraySum(nums []int, k int) bool {
    prefixsum := map[int]int{}
    prefixsum[0] = -1
    sum := 0
    exist := false
    val := 0
    for i,value := range nums{
        sum = (sum + value) % k
        val, exist = prefixsum[sum]
        if !exist{
            prefixsum[sum] = i
        }else if i - val > 1{
                return true
        }
    }
    return false
}

总结

没有注意到k的取值范围, 因为k的取值范围非常大, 使用数组来保存前缀和对应的下标需要开始k大小的数组, 在实践中会导致超出leetcode的内存限制. 因此这里还是要用map来保存前缀和和对应的下标, 但思路仍然是相同的.

974. 和可被 K 整除的子数组

题解

本题和昨天的题目类似, 通过前缀和相同来得到能被k整除的连续子数组, 注意本题中k的范围为 10^4, 因此可以用数组来保存前缀和对k取模后得到某个余数对应的下标位置个数. 后续遇到相同余数在结果中加上之前相同余数的位置个数, 并更新该余数对应的位置个数.

代码

func subarraysDivByK(nums []int, k int) int {
    remain := make([]int, k)
    remain[0] = 1
    sum  := 0
    result := 0
    for _, value := range nums{
        sum = ((sum + value)%k + k) % k
        result += remain[sum]
        remain[sum]++
    }
    return result
}

总结

注意对负数取模的处理, 在这里为了将相同余数的位置个数保存到数组中, 要将负数取模后的数转换为正数(如对5取模得-4转换为1, 在求和过程中这二者的效果相同), 先取模再加一个k再取模(如果是正数通过再取模将加上的k消掉)即可

1051. 高度检查器

题解

本题思路上很简单, 只需将数组复制一份并排序, 再与原数组比较即可. 这里主要意图应该是让你练习一下排序算法, 因此我们手动实现一下归并排序这种比较经典的排序算法. 归并算法是经典的分治法思想, 分治法分为"分"和"治"两部分, 分即将数组一分为二, 治即对于每个子数组进行排序, 最后将排好序的两个子数组合并(归并的"并"). 对于某一类分治法, 其时间复杂度可以使用主定理进行计算(感兴趣可自行了解).

代码

func heightChecker(heights []int) int {
    length := len(heights)
    sorted := mergeSort(heights, length)
    result := 0
    for i,value := range sorted{
        if value != heights[i]{
            result++
        }
    }
    return result
}

func mergeSort(input []int, length int) []int{
    if length == 1{
        return input
    }else{
        mid := length / 2
        left := mergeSort(input[0:mid],mid)
        right := mergeSort(input[mid:length],length-mid)
        result := []int{}
        j := 0
        for _,value := range right{
            for j<mid && left[j]<value{
                result = append(result, left[j])
                j++
            }
            result = append(result, value)
        }
        for j<mid{
                result = append(result, left[j])
                j++
        }
        return result
    }
}

1122. 数组的相对排序

题解

考虑数组中数字的大小范围在1000以内, 因此直接对数组排序可以使用计数排序算法. 本题中要求将数组1中的数字按照数组2中数字的顺序排序, 这部分也可以使用计数排序的思想, 即统计数组1中各个数的出现次数, 并保存在一个计数数组中, 这个数组中下标i处的值为数字i对应的数组1中出现的次数. 这种用下标指示值, 用元素指示个数的思路在前面的题中也多次出现. 得到计数数组后, 先将数组中包含在arr2的数按照arr2中的顺序放置, 并将对应位置次数归零, 再从头遍历数组, 按升序将其余数字放在末尾即可.

代码

func relativeSortArray(arr1 []int, arr2 []int) []int {
    count := make([]int, 1001)
    for _,value := range arr1{
        count[value]++
    }
    result := []int{}
    for _,value := range arr2{
        for count[value]>0{
            result = append(result, value)
            count[value]--
        }
    }
    for index,value := range count{
        for value > 0{
            result = append(result, index)
            value--
        }
    }
    return result
}

75. 颜色分类

题解

本题只有0,1,2三个数, 对只包含这三个数的数组进行排序, 直接在原数组上修改, 充分考虑题目条件, 没有必要使用排序算法来排序, 当题目中只有两个数的时候, 对整个数组排序相当于通过两变量交换将小的数都交换到前面, 大的都交换到后面. 现在有三个数, 则通过只需通过交换位置将所有的2都放到数组末尾, 将所有的0放到数组开头, 1自然就位于中间位置. 交换的思路也很简单, 通过两个变量分别标记被移到数组开头的0的个数和移到数组末尾的2的个数, 遇到0就将其移到之前的0的后面并将开头0的个数加一, 对2同理.

代码

func sortColors(nums []int)  {
    zeros := 0
    twos := len(nums) - 1
    Loop:
    for i, value := range nums{
        if value == 0{
            nums[i],nums[zeros] = nums[zeros],nums[i]
            zeros++
        }else if value == 2{
            for twos >= 0 && nums[i] == 2{
                if i >= twos{
                    break Loop
                }
                fmt.Println()
                nums[i],nums[twos] = nums[twos],nums[i]
                twos--
            }
            if nums[i] == 0{
            nums[i],nums[zeros] = nums[zeros],nums[i]
            zeros++
            }
        }
    }
    return
}

总结

思路是正确的, 但在实现时有一些细节要注意, 如遇到2将其交换到末尾时, 如果被交换过来的数也是2则要继续将其移到末尾, 直到交换过来的数不是2或者已经将当前下标后面的所有数都交换为2为止.

1 个赞

2037. 使每位学生都有座位的最少移动次数

题解

本题总体思路比较直观, 因为找最小的移动次数让每个同学都坐在某个位置的椅子上, 因此只要找到同学位置和椅子位置最小的差值和就可以得到答案. 首先将椅子位置和同学位置均排序, 再从头遍历数组将二者差加和即得最终结果, 是一种贪心算法, 这里分析一下为什么排序后将椅子和同学的位置依次做差就能得到正确答案. 考虑数组中的任意两个位置, 假设椅子对应的位置为i和i+k, 同学对应的位置为j和j+m, 若则无论j,j+m和i的大小关系如何, 这两个同学挪到两个椅子的最小值均为|j-i|+|j+m-i-k|(可以自行考虑i,i+k,j,j+m这四个数的全部不同位置关系, 一一列举即可得出结论). 由两个位置的情况可推得一般情况, 即两个数组长度相同且均从小到大排序的情况下, 两个数组各个元素的差值的最小和就是各个位置元素差的和(还可以思考无论哪个同学坐到哪个位置上, 总要有另一个同学坐在另一个位置上从而将这两个同学的距离补上, 因此按照顺序一一就坐就是最优的).

代码

func minMovesToSeat(seats []int, students []int) int {
    sort.Ints(seats)
    sort.Ints(students)
    result := 0
    for i,value := range seats{
        if value > students[i]{
            result += value - students[i]
        }else{
            result += students[i] - value
        }
    }
    return result
}

945. 使数组唯一的最小增量

题解

本题思路上和昨天的题目基本一致, 先排序再从头遍历排好序的数组, 将重复的数字增加到最小不重复为止(如2,2,2,3, 将第二个2增加到3,将第三个2增加到4, 将3增加到5). 使用一个变量记录当前已经遍历过的数字修改后不重复数字中的最大值. 对遍历的每个数字, 判断其与当前最大值的关系, 若相等则将最大值加一并将结果加一, 小于最大值将最大值加一并将结果加上当前数字与最大值的差值, 大于最大值将最大值更新为当前数字.
考虑排序在这里的作用, 排序给每一个数字都提供了隐含的信息, 即前面的数字都比当前数字小, 后面的数字都比当前数字大, 这一信息使得我们可以做出更好的决策. 在未排序前, 对数组中的数我们无法知道是应该保持原样还是增加到某个数字可以满足题目条件, 但在排序后, 只需要增加到最小不重复的数即可, 考虑两个数字2,4, 假如当前不重复需要数字4,5(注意题目中只允许增加, 不允许减小). 无论是将2增加到4,4增加到5. 还是4不变,2增加到5, 增加的差值均为3. 这是因为任何比4大的值, 2都需要先增加到4, 再增加到这个值. 这一过程无论是在同一个数字上完成还是拆分为在两个数字上完成, 过程中需要改变的差值都是一样的.

代码

func minIncrementForUnique(nums []int) int {
    sort.Ints(nums)
    current := -1
    result := 0
    for _,value := range nums{
        if value == current{
            value++
            current++
            result++
        }else if value < current{
            current++
            result += current - value
        }else {
            current = value
        }
    }
    return result
}

502. IPO

题解

本题是一道难题, 思路总体还是比较清晰的, 本题目标是取k个不同的project, 使得最后能得到的利润最大. 每一个project都有对应的利润和成本. 要使最后的整体利润最大, 无疑只要在当前可以选择(即成本在当前已获得的资金范围内)的所有project中选择利润最大的, 再更新可选择的project, 再次选择利润最大的. 如此反复直到选择完全部的k个project即可. 有了这个思路, 我们要解决两个问题. 1. 如何高效的选择利润最大的project, 最大堆显然是一个不错的选择. 2. 如何更新可选择的project的范围, 我们将project的成本排序, 每次获得了更多的本金后就将小于当前本金的所有project的利润加入最大堆, 为了每次只放入新增加的project, 可以对project的成本数组排序, 用一个标记变量标记之前已经选择过的project位置. 获得更多本金后从标记位置开始继续遍历数组, 并将成本小于本金的项的利润放入最大堆中. 代码实现以上解题思路即可.

代码

type ProHeap []int

func (h ProHeap) Len()int {
        return len(h)
    }

func (h ProHeap) Less(i,j int)bool {
        return h[i] > h[j]
    }

func (h *ProHeap) Swap(i, j int) {
	(*h)[i], (*h)[j] = (*h)[j], (*h)[i]
}

func (h *ProHeap) Push(x interface{}) {
	*h = append(*h, x.(int))
}

func (h *ProHeap) Pop() interface{}{
	res := (*h)[len(*h)-1]
	*h = (*h)[:len(*h)-1]
	return res
}

type Project struct {
	capital int
	profit  int
}

func findMaximizedCapital(k int, w int, profits []int, capital []int) int {
    n := len(profits)
	projects := make([]Project, n)

	for i := 0; i < n; i++ {
		projects[i] = Project{capital: capital[i], profit: profits[i]}
	}

    sort.Slice(projects, func(i, j int) bool {
		return projects[i].capital < projects[j].capital
	})



    proheap := make(ProHeap, 0)
    heap.Init(&proheap)

    mark := 0
    for k>0{
        for _,value := range projects[mark:n]{
            if value.capital > w{
                break
            }else{
                heap.Push(&proheap, projects[mark].profit)
                mark++
            }
        }
        if proheap.Len() == 0{
            return w
        }
        w += heap.Pop(&proheap).(int)
        k--
    }
    return w
}

总结

go中最大堆可以使用容器类heap来实现, 只要实现了heap接口的所有方法, 就可以直接使用这个堆了.

1 个赞

330. 按要求补齐数组

题解

本题是一道难题, 难点在于根据题目题意, 如何确定某个数组中数的和能够覆盖的数字范围. 按照我们一贯使用的思考方式, 先考虑一下简单情况, 若只有一个数字1, 则能覆盖的数字只有1, 添加上一个数字2, 则能覆盖的数字有1,2,1+2. 如果在1,2基础上再添加一个数字3, 则能覆盖的数字有1,2,1+2,3,1+3,2+3,1+2+3. 则由这几个简单例子可以发现, 如果之前能够覆盖的数字为集合{x}, 则加上一个数字r后能覆盖的数字为集合 x \cup (x + r) . 若之前能够覆盖的为一个连续的区间[x,x+k],则加上数字r后能够覆盖的区间为 [x,x+k] \cup [x+r, x+k+r]. 则此时想到可以用贪心算法, 如果已经覆盖了[x, x+k], 则只需要让x+r = x+k+1, 即新增加的数恰好可以使得新覆盖区间的开头为已经覆盖区间的末尾. 就能得到[x,x+k+r]的新覆盖区间. 如果r比这种情况小, 那么新覆盖的区间没有我们当前得到的区间大. 如果比这种情况大, 则x+r和x+k之间会有几个数字没有覆盖上, 还需要再用一个新的数覆盖一次, 显然很难使得添加数字的个数最小.

将这个思路在代码中实现, 初始化一个数字表示当前覆盖的区间最大值sum, 遍历数组, 设x为nums[i], 判断当前sum是否大于nums[i](即已经覆盖的区间是否包含当前这个数组中的数字). 如果小于, 则添加一个数字sum+1 前面分析中的x+k+1 (因为题目只要求添加的数字的最小个数,因此不需要真的添加到数组中, 只需扩大可行区间的范围即可), 将区间最大值扩展为sum+sum+1 前面分析中的x+k+r, 并将添加数字的个数加一. 再次判断与x的关系. 如此反复直到覆盖的区间包含x为止, 继续向下遍历. 遍历完成后, 判断当前区间是否已经可以覆盖n, 如果不能, 则按照之前的方法继续扩展区间直到能覆盖n为止.

代码

func minPatches(nums []int, n int) int {
    midsum := 0
    result := 0
    for _,value := range nums{
        for midsum < value-1 && midsum < n{
            result++
            midsum += midsum + 1
        }
        midsum += value
    }
    for midsum < n{
        result++
        midsum += midsum + 1
    }
    return result
}

633. 平方数之和

题解

本题计算出c的平方根, 用两个变量low和high分别标记0和c的平方根(取下整), 计算两变量的平方和, 若比c大则减小high, 否则增大low, 直到得到平方和为c或者low=high为止.

代码

func judgeSquareSum(c int) bool {
    high := int(math.Sqrt(float64(c)))
    low := 0
    for low <= high{
        if low*low + high*high == c{
            return true
        }else if low*low + high*high > c{
            high--
        }else{
            low++
        }
    }
    return false
}

总结

本题还有一些有趣的相关数学知识, 费马两平方和定理和两数平方和定理, 前者说明了一个奇素数何时能被写为两平方数之和. 满足条件的奇素数也被称为毕达哥拉斯质数, 后者给出了所有大于1的整数在什么情况下能被写为两平方数之和. 是前者的推广. 可参考

Fermat’s theorem on sums of two squares

Sum of two squares theorem

826. 安排工作以达到最大收益

题解

本题仍然使用贪心算法, 首先给worker排序, 在排好序后遍历worker数组, 并把当前worker可以做的工作中profit最大的分配给他, 即给结果加上可以做的工作中的最大profit, 这个最大profit可以使用一个变量来保存, 当遍历的下一个worker时, 根据他的工作能力更新可以做的工作中的最大值. 如此反复, 最终得到结果.

代码

func maxProfitAssignment(difficulty []int, profit []int, worker []int) int {
    sort.Ints(worker)
    length := len(profit)
    type w_p struct{
        diff int
        profit int
    }
    d_p := make([]w_p, length)
    for i,value := range profit{
        d_p[i].diff = difficulty[i]
        d_p[i].profit = value
    }
    sort.Slice(d_p, func(i, j int) bool {return d_p[i].diff < d_p[j].diff})

    max_pro := 0
    index := 0
    result := 0
    for _, value := range worker{
        for index < length && d_p[index].diff <= value{
            max_pro = max(max_pro, d_p[index].profit)
            index++
        }
        result += max_pro
    }
    return result
}

总结

为什么排序如此重要, 正如之前的题解中多次提到的, 一个排好序的数组在遍历的时候天然包含了更多信息, 即前面的数字比当前的小, 后面的比当前的大. 使用贪心的重要思想就是, 在当前能获得的结果中取最好的, 那么对于排好序的数组, 前面的必然比当前小, 我只需要依次遍历找到我能拿到的最大值即可. 如果没有排序, 那么我们每次都要将数组整体遍历一遍才能确定我当前能拿到的最大值是多少(后面可能有难度更小但利润更大的任务, 显然我们都想做这种任务). 每次都遍历一遍数组整体是 n^2 的复杂度, 排好序后可以用下标标记之前遍历到哪里了, 只需要遍历一遍数组即可. 是 nlogn(排序)+n(遍历数组) = O(nlogn) 的复杂度. 只有在排好序的数组里标记之前遍历到哪里才有意义, 未排序的数组标记之前遍历到哪里没什么意义(后面既可能有更大的, 也可能有更小的)

1482. 制作 m 束花所需的最少天数

题解

不能做出足够的花束的条件是很容易确定的, 只要花园里花的总数比需要的数量小, 则直接返回-1. 否则只要等待足够长的时间, 总能做出足够的花束. 如果等待时间为花盛开时间的最大值, 显然一定满足条件, 若为最小值, 不一定满足条件, 即实际上本题转化为在所有盛开时间中搜索能满足条件的最小值. 对于给定一个时间, 只需要遍历一遍bloomDay数组即可得知其是否满足条件. 这种搜索其实与在一个数组中搜索指定的数没有本质上的区别, 将数组排序后使用二分查找, 对于搜索某一固定数字, 我们采取的方式是如果中间值比目标大, 则将右边界设为中间值, 否则将左边界设为中间值. 如此反复. 而在本题中, 虽然不能直接比较目标值与中间值的相对大小, 但通过使用中间值对bloomDay数组遍历判断是否能做成足够数量的花束(设一个标记变量保存当前连续可使用的花的数量,大于等于k时将当前花束数量加1,最后判断花束数量是否大于等于m)我们可以了解到目标值与中间值的相对大小. 如果此中间值满足题目条件, 那么目标值应该小于等于这个中间值, 否则大于等于这个中间值, 因此回到了最熟悉的二分搜索部分.

代码

func minDays(bloomDay []int, m int, k int) int {
    if len(bloomDay) < m*k{
        return -1
    }
    
    left := 1
    right := 0
    for _,value := range bloomDay{
        if value > right{
            right = value
        }
    }
    mid := 0
    begin := 0
    bouquet := 0
    for left < right{
        mid = (left + right) / 2
        begin = 0
        bouquet = 0
        for _,value := range bloomDay{
            if value <= mid{
                begin++
                if begin >= k{
                    begin = 0
                    bouquet++
                    if bouquet >= m{
                        break
                    }
                }
            }else{
                begin = 0
            }
        }
        if bouquet >= m{
            right = mid
        }else{
            left = mid+1
        }
    }
    return left
}

1552. 两球之间的磁力

题解

竟然是一道和<瑞克和莫蒂>的联动题(瑞克和莫蒂真的很好看, 强推!!), 要求我们分配这些球, 使得两个球的最小间隔最大. 典型的max-min问题. 解答这种问题因为题目中是要求分配球来获得最终的结果, 使得我们的思路往往起初就会停留在如何分配球这个问题上, 试图通过比较各种分配方式的最小间隔来找到最大的值, 但显然是低效的, 原因在于可能有很多种分配组合都对应着同一个最小间隔. 因此应该转换思路, 给定一个最小间隔, 判断是否有分配方式可以符合这个最小间隔, 只要有一个符合的分配方式, 那么这个最小间隔就是可以取得的, 相当于用这个分配方式代表了所有相同最小间隔的分配方式(代表元思想). 如何判断? 只要在排序后的数组从头到尾遍历, 并采用贪心算法, 和昨天的花束问题类似, 每当间隔大于等于给定的最小间隔就分配一个球, 最后看能分配的球的数量和给定的球的数量之间的相对大小. 大于给定的球则给定最小间隔可以取得. 如何得到最大值? 一样通过二分法, 通过上述判断方法判定中间数值与目标最大值的相对大小, 中间值能取得则将左边界设为中间值, 否则右边界设为中间值. 最终即可得到所求的最大值.

代码

func maxDistance(position []int, m int) int {
    sort.Ints(position)
    right := position[len(position)-1]-position[0]
    left := 1

    for left <= right{
        mid := (left + right) / 2
        last := position[0]
        ballnum := 1
        dis := 0
        for _,value := range position[1:]{
            dis += value - last
            last = value
            if dis >= mid{
                ballnum++
                if ballnum >= m{
                    break
                }
                dis = 0
            }
        }
        if ballnum >= m{
            left = mid + 1
        }else{
            right = mid - 1
        }
    }
    return right
}

总结

本题和昨天的花束问题有几分相似之处, 体现了一种很重要的思想, 当通过某种组合寻找一由组合产生的属性值, 而多种组合对应同一个属性值时, 可以先假设一个属性值, 再去验证某个组合是否符合. 这样就把问题从求解问题转换为了验证问题. 这里其实和我们平常常用的证明思路正好相反, 平常很多问题证伪只需要举一个反例, 证实则要严格证明,而在今天的问题中, 我们只要找到一个能证实假设的距离成立的组合就足够了, 即证实只要举一个正例即可. 这种题要结合题目的要求, 很多实际生活问题只要能找到一个可行解即可. 这是我们能够用这种方法解题的基础.

很多问题在原问题不容易解决的时候都可以通过一些方式转换成等价的更容易解决的问题, 如计数问题中的自归约问题可将近似求解算法转换成某种采样算法, 通过采样即可得到原问题的近似解(蒙特卡罗)

1052. 爱生气的书店老板

题解

本题中minutes是固定的, 相当于固定长度的minutes可以放置在grumpy数组的任意位置, 将这些位置的数全部变为0, 显然我们需要遍历所有可以放置的位置从而找出能满足的顾客最大值, 因此可以采用滑动窗口从头开始遍历grumpy窗口, 并随着窗口滑动计算当前能满足的顾客数量. 考虑到窗口之外的能满足顾客数量是固定的, 且每次滑动实际上只需要增加窗口后一个的顾客数量且减去窗口最前面的顾客数量, 即可得到每次滑动后能满足的顾客总数. 则初始化先计算出窗口外能满足的顾客总数, 再单独计算窗口内满足的顾客数量. 再将二者加和即得当前能满足的顾客总数. 持续遍历, 按照之前的算法更新能满足的顾客总数并更新最大值.

代码

func maxSatisfied(customers []int, grumpy []int, minutes int) int {
    outwindow := 0
    for i,value := range grumpy[minutes:]{
        if value == 0{
            outwindow += customers[i+minutes]
        }
    }
    innerwindow := 0
    for _,value := range customers[:minutes]{
        innerwindow += value
    }


    maxcus := innerwindow + outwindow
    temp := maxcus
    for i,value := range grumpy[:len(grumpy)-minutes]{
        if value == 1{
            temp -= customers[i]
        }
        if grumpy[i+minutes] == 1{
            temp += customers[i+minutes]
        }

        maxcus = max(maxcus, temp)
    }
    return maxcus
}

软件开发算法

1248. 统计「优美子数组」

题解

这种连续子数组问题是老朋友了, 尤其是这种问符合某个条件的连续子数组个数, 这种题目都采用类似前缀和的思想. 计算从数组开始到当前下标的子数组中奇数的个数, 以奇数个数作为下标, 将从头开始包含这个奇数个数的子数组数目作为数组中的值. 只需要将j下标和j-k下标的数字相乘即可得到从j-k到j包含k个奇数的子数组个数. 从下标k开始遍历"前缀和"数组. 并按照前述方法计算包含k个奇数的子数组个数并加和即可得到最终结果.

代码

func numberOfSubarrays(nums []int, k int) int {
    state := []int{1}
    length := 0
    for _,value := range nums{
        if value % 2 == 1{
            length++
            state = append(state, 1)
        }else{
            state[length]++
        }
    }

    if length < k{
        return 0
    }


    result := 0
    for i, value := range state[k:]{
        result += value * state[i]
    }
    return result
}

总结

其实这种类型的问题核心也是一种转化问题的思路, 即对于求解某一性质为一定值的问题, 可以转化为求解两个区间再做差. 如求解某属性等于k的问题, 可以转化为求解某属性小于等于k和小于等于k-1两个区间后做差的问题. 这种转化适用求解一个区间比求解精准值要容易的多的场景(求解小于等于某个值 往往比求解等于某个值容易得多).

1438. 绝对差不超过限制的最长连续子数组

题解

本题虽是一道中等难度的题, 但实际比较复杂. 题目很好理解, 需要找到满足子数组内任意两数字差的绝对值小于等于k的所有子数组中最长子数组的长度. 将问题分解开, 首先解决如何确定某个子数组中任意两数字差绝对值是否小于等于k, 只需要知道这个子数组中的最大值和最小值, 二者的差小于等于k则子数组中任意两数字差绝对值都小于等于k. 那么解决这个问题可以使用滑动窗口, 记录当前窗口中的最大值和最小值, 如果满足小于等于k的条件, 则扩大窗口, 判断新加入的数字是否在最小值和最大值的范围内, 在则继续扩大窗口. 否则判断最值更新后当前窗口还能否继续满足小于等于k的条件, 如果不满足则缩小窗口直到满足为止. 满足则继续扩大窗口. 对于某一个特定的子数组, 获知其最小值和最大值比较容易, 遍历一遍数组即可得到. 但使用滑动窗口解题时, 如果每次扩大或者缩小窗口都对窗口内的数字进行遍历显然时间复杂度极高. 我们只需要根据扩大窗口时新增加的数字和缩小窗口时减少的数字来更新最值. 需要解决的问题是, 缩小窗口直到满足条件这一步骤中, 如何判断缩小窗口已经满足条件了呢. 以最小值被更新为例, 如果最小值更新和与当前最大值的差大于k, 则应该缩小窗口直到窗口内的最大值和最小值的差小于等于k. 这意味着, 我们缩小窗口时可能不仅要将窗口缩小到不包含当前最大值, 还可能继续缩小, 直到满足条件为止. 如当前最小值为2, k为3, 而当前窗口中最大值为8, 则我们应缩小窗口直到窗口内最大值小于等于5为止, 这期间我们可能要缩小到不包含8, 再缩小到不包含7, 再缩小到不包含6… 因此我们需要知道数字中大于5的数字都有什么. 这可以用单调队列来解决, 单调减队列保证了后面的数字一定比前面的数字小, 不满足条件的数字都被舍弃. 用一个哈希表保存当前窗口中所有数字和其对应的个数, 缩小窗口时, 减少哈希表中窗口左端数字对应的个数, 直到遇到当前单调队列的队首数字, 同样减少哈希表中的个数, 并判断减少后是否为0, 为0则将其从单调队列中弹出, 否则继续缩小窗口. 直到单调队列队首数字为满足条件的数字. 对于最大值被更新则做类似处理.

因此解答本题我们需要: 1. 哈希表 : 保存当前窗口中各个数字的个数 2. 单调减队列: 保存当前窗口中从最大值开始以及最大值右侧比最大值小且满足单调减顺序的数字(如对于7,5,6 队列中为7,6). 3. 单调增队列 : 保存窗口中从最小值开始及最小值右侧比最小值大且满足单调增顺序的数字(如对于5,7,6 队列中为5,6). 4. 滑动窗口: 解决核心问题

代码

func longestSubarray(nums []int, limit int) int {
	maxDeque := []int{} // 单调减队列,用来维护最大值
	minDeque := []int{} // 单调增队列,用来维护最小值
	left := 0
	result := 0
    number := map[int]int{}
    maxlen := 0
    minlen := 0

	for right := 0; right < len(nums); right++ {
        number[nums[right]]++
		// 维护 maxDeque 为单调减
		for maxlen != 0 && maxDeque[maxlen-1] <= nums[right] {
			maxDeque = maxDeque[0:maxlen-1]
            maxlen--
		}
		maxDeque = append(maxDeque, nums[right])
        maxlen++

		// 维护 minDeque 为单调增
		for minlen != 0 && minDeque[minlen-1] >= nums[right] {
			minDeque = minDeque[0:minlen-1]
            minlen--
		}
		minDeque = append(minDeque, nums[right])
        minlen++

		// 检查当前窗口中的最大值和最小值之差是否超过 limit
		if maxDeque[0] - minDeque[0] > limit {
			if nums[right] == minDeque[0]{
                for maxDeque[0] -  minDeque[0] > limit{
                    number[nums[left]]--
                    left++
                    if number[maxDeque[0]] == 0{
                        // 最大值出队列
                        maxDeque = maxDeque[1:]
                        maxlen--
                    }
                }
            }else if nums[right] == maxDeque[0]{
                for maxDeque[0] -  minDeque[0] > limit{
                    number[nums[left]]--
                    left++
                    if number[minDeque[0]] == 0{
                        // 最小值出队列
                        minDeque = minDeque[1:]
                        minlen--
                    }
                }
            }
		}

		// 更新结果
		if right-left+1 > result {
			result = right - left + 1
		}
	}

	return result
}


总结

最大值单调队列中的在最大值右侧这一含义为什么如此重要, 因为通过单调队列隐含了一个信息, 即位于当前窗口中单调队列中队列首的数字左侧的数字都比队首数字小, 这一信息使我们可以丢弃一些不必要保存的数字, 因为如果需要缩小窗口, 那么在缩小到这个最大值之前, 窗口中都包含这个最大值, 那么一定不满足要求, 只有在缩小到不包括这个最大值, 才可能满足要求. 这时候再根据单调队列中的数字继续依次判断. 这就是"单调"带来的隐含信息, 这一信息对于简化计算极为重要.

995. K 连续位的最小翻转次数

题解

本题为一道难题, 要解决最小的翻转次数之前先思考如何判断一个数组能否通过几次翻转最终使得数组内数字全为1, 再考虑如何求得翻转次数的最小值.

如何解决能否通过翻转使得数组内数字全为1的问题呢. 看上去数组中的0可能在任何一个位置出现, 我们也不知道101翻转后的010末尾的0能否和后面的数字组合到一起成为新的一串0. 想让计算机解题也不可能像我们看这个数组一样扫视过去大概估计哪些部分可以翻转, 再通过尝试修正一小部分错误就能得出判断. 计算机不会"估计". 但计算机会"硬算". 因此我们不必如此贪心想一步到位得出答案, 大可以从头开始, 每次只将一个0翻转为1, 遍历到下一个0, 翻转为1, 如此重复每次只将数组中最前面的0翻转为1. 最终如果整个数组都能变成1则该数组可以翻转为全1. 否则不能. 而翻转要相邻的k个位同时翻转, 可用一个固定窗口来表示相邻的k位. 遍历数组, 让窗口内窗口首部的数字为0, 翻转窗口, 将窗口移动到下一个0, 如此重复最终即可判断能否使数组内为全1.

巧合的是, 这种算法同时解决了最小的问题, 即通过这种方式移动窗口并翻转到最后翻转的次数就是最小次数. 原因是最终目标是将数组全部置为1, 因此想实现这个目标, 任何一个0都需要通过翻转变成1, 那么无论哪个位置的0迟早都要被翻转, 意味着任意位置的0对最终结果数量的贡献是均等的, 即时先翻转了后面的0, 前面遗留的0最后还是需要一次翻转, 因此直接从前到后依次翻转就能得到正确答案.

但到这里还没有结束, 这种算法每次都要将整个窗口内的全部数字翻转一遍, 最坏情况下, 窗口每次移动一位, 则总共需要翻转n*k次. 在k接近n时, 时间复杂度相当于 n^2 级别. 这显然是不可取的, 考虑我们翻转的过程, 如果窗口只向后移动一位, 那么窗口后面的大部分数字经过两次翻转相当于没变. 这提示我们, 没必要真的每次都将每个数字翻转, 只需要记录这个数字被翻转了多少次, 在遍历到这个数字的时候根据翻转次数确定当前数字的值即可. 如何记录某个位置的数字被翻转了多少次? 因为每次翻转都是以一个窗口为单位的, 因此只需记录每次翻转时的窗口尾部到哪里就相当于记录了整个窗口内的数字都被翻转了一次. 用一个单调增数组保存每次翻转窗口尾部的位置, 每次新翻转都将新的尾部放入数组末尾. 对于遍历的每个数字, 将其当前位置和数组首的最小翻转窗口尾部比较, 小于等于这个最小尾部则单调数组的长度就是这个数字被翻转的次数.

代码

func minKBitFlips(nums []int, k int) int {
    window := 0
    numlen := len(nums)
    result := 0
    fliptail := []int{}
    for window < numlen{
        fliplen := len(fliptail)
        if fliplen != 0{
            if window <= fliptail[0]{
                if nums[window] ^ (fliplen % 2) == 0{
                    fliptail = append(fliptail, window+k-1)
                    result++
                }
            }else{
                fliptail = fliptail[1:]
                fliplen--
                if nums[window] ^ (fliplen % 2) == 0{
                    fliptail = append(fliptail, window+k-1)
                    result++
                }
            }
        }else{
            if nums[window]  == 0{
                    fliptail = append(fliptail, window+k-1)
                    result++
                }
        }
        window++
    }
    if len(fliptail) == 0 || (len(fliptail) == 1 && fliptail[0] == numlen-1){
        return result
    }
    return -1
}
1 个赞

1038. 从二叉搜索树到更大和树

题解

考虑到树为一棵二叉搜索树, 因此对于任何一个节点, 比它大的节点有其右子树的节点, 若其父节点为祖父节点的左子节点, 则还有其祖父节点以及祖父节点的右子树的节点. 以此类推. 考虑最简单的仅有三个节点的情况: 一个父节点和左右两个子节点. 对于左子节点而言, 需要加父节点和右子节点的值. 对于父节点要加右子节点的值. 右子节点不用处理. 因此通过对树遍历将问题转化为类似三个节点的情况. 左右子树均可视为一个节点. 从根节点开始, 对节点进行如下操作, 递归遍历右子树, 计算右子树所有节点的和返回. 将当前节点的值加上右子树的和, 将和传递给左子树并递归遍历左子树, 返回左子树所有节点的和. 最终返回根节点和左右两个子树的和. 注意更新节点值和计算子树和是分开的, 更新节点值要将传递的值和当前值以及右子树的和相加. 但返回时返回的是原来的节点值对应的子树和.

代码

/**
 * Definition for a binary tree node.
 * type TreeNode struct {
 *     Val int
 *     Left *TreeNode
 *     Right *TreeNode
 * }
 */
func bstToGst(root *TreeNode) *TreeNode {
    rightFirst(root, 0)
    return root
}

func rightFirst(root *TreeNode, pass int)int{
    temp := root.Val
    if root.Left == nil && root.Right == nil{
        root.Val += pass
        return temp
    }
    rightSum := 0
    if root.Right != nil{
        rightSum = rightFirst(root.Right, pass)
    }
    leftSum := 0
    if root.Left != nil{
        leftSum = rightFirst(root.Left, rightSum+temp+pass)
    }
    root.Val = root.Val + pass + rightSum
    return temp + rightSum + leftSum
}

1 个赞