GithubHelp home page GithubHelp logo

algorithms's People

Contributors

wang-kai avatar

Stargazers

 avatar

Watchers

 avatar  avatar  avatar  avatar

algorithms's Issues

贪心算法总结

题目

无重叠区间

给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。

注意:

可以认为区间的终点总是大于它的起点。
区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。
示例 1:

输入: [ [1,2], [2,3], [3,4], [1,3] ]

输出: 1

解释: 移除 [1,3] 后,剩下的区间没有重叠。
示例 2:

输入: [ [1,2], [1,2], [1,2] ]

输出: 2

解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。
示例 3:

输入: [ [1,2], [2,3] ]

输出: 0

解释: 你不需要移除任何区间,因为它们已经是无重叠的了。

思路

贪心算法通过局部最优选择来构造全局最优。

贪心算法的思路是:

  1. 把所有的活动按照结束时间排序,第一个元素作为第一个要听的演唱会
  2. 接着不断选择开始时间晚于上一场演唱会的结束时间,并且最早结束的演唱会。

针对于该题的上下文,最后再求解下区间总数与选定区间的差即可。

解题

func eraseOverlapIntervals(intervals [][]int) int {
    // 剔除长度为 0 的情况
    if len(intervals) == 0 {
        return 0
    }

    var resIntervals = [][]int{}

    // 按照结束时间排序
    startTimes, endTimes := sortIntervals(intervals)
    // 排在第一个的,是一定要选定的
    resIntervals = append(resIntervals, []int{startTimes[0], endTimes[0]})
    
    currentEndTime := endTimes[0]
    for i := 1; i < len(endTimes); i++ {
        // 在已排序好的结构中,选择开始时间比当前结束时间大的,作为下一个选定对象
        if currentEndTime <= startTimes[i] {
            resIntervals = append(resIntervals, []int{startTimes[i], endTimes[i]})
            currentEndTime = endTimes[i]
        }
    }

    return len(intervals) - len(resIntervals)
}

func sortIntervals(intervals [][]int) (startTimes []int, endTimes[]int) {
    // 这里使用选择排序算法,把 intervals 按照结束时间排序
    for i := 1; i < len(intervals); i++ {
        key := intervals[i]
        var j = i-1

        for j >= 0 && key[1] < intervals[j][1] {
            intervals[j+1] = intervals[j]
            j--
        }
        intervals[j+1] = key
    }

    for i := 0; i < len(intervals); i++ {
        startTimes = append(startTimes, intervals[i][0])
        endTimes = append(endTimes, intervals[i][1])
    }
    
    return
}

反转链表

链表反转题目,大致就是在一个单链表上,全部或局部的反转链表。解题代码很简洁,但理解起来有点困难。

入门题目:

反转链表

输入一个链表,反转链表后,输出新链表的表头。

输入

{1,2,3}

返回值

{3,2,1}

思路

类似于这样的题目,其实是技巧大于思路。我也是从解题代码中反复看,才理解了代码的思路。

解题

func ReverseList( pHead *ListNode ) *ListNode {
    // 排除极端情况
    // 当然,这也是递归调用的终止条件
    if pHead == nil || pHead.Next == nil {
        return pHead
    }
    
    // 递归拿到最后一个元素
    newHead := ReverseList(pHead.Next)
    
    // 执行反转,让其下一个元素,倒指向其本身
    // 此时的 pHead 已经不是原来的 pHead 了,而是倒数第二个元素、倒数第三个元素 ... 
    pHead.Next.Next = pHead
    
    // 置空本身的 next 属性(会有下一个元素帮其重新指向正确的元素)
    pHead.Next = nil
    
    // 新链表的表头在最后一次递归中获取到
    // 该变量一直没变,层层往上抛
    return newHead
}

进阶一步

题目

反转链表前 N 个节点

思路

在之前反转整条链表的思路上,控制两个变量:

  1. 递归深度,因为只反转前 n 个元素
  2. 要记录第 n+1 个元素,以为它将作为所有节点默认的 next 值

解题

// 没用说使用了递归方法就不能使用全局变量
// 能帮助理清思路
var lastNode *ListNode

func reverseKList(head *ListNode, k int) *ListNode {
	// 当 k == 1 的时候,说明递归深度已经到头了
	if k <= 1 {
		lastNode = head.Next
		return head
	}

	// 拿到第 K 个节点
	KthNode := reverseKList(head.Next, k-1)

	// 反转链表
	head.Next.Next = head

	// 设置每轮反转后,元素的默认 next 值
	// 如果它不是最初的 head 的话,会有下一轮来修改它的 next
	// 如果是最初的 head,则它指向 k+1 元素
	head.Next = lastNode

	// 返回第 K 个节点,因为它将作为新的 head
	return KthNode
}

最后的 BOSS

题目

反转从位置 m 到 n 的链表。请使用一趟扫描完成反转。

说明:
1 ≤ m ≤ n ≤ 链表长度。

思路

这道题要基于上一步的基础上求解。

  1. 转换问题,解释这个链表 head 变成了第 m 个元素,那就相当于反转前 n-m 个元素了。
  2. 等价的去变换题目,创造条件,我们不断求 head.Next,直到 head.Next 为第 m 个元素
  3. 求解完反转 n-m 后,会返回新的 head,我们把它接在之前就行了

解题

func reverseBetween(head *ListNode, m int, n int) *ListNode {
	// 直到 m == 1,就开始按照反转前 n 个的思路操作
	if m == 1 {
		reverseKList(head, m-n)
	}

	// 如果匹配不到 m==1,保持原有序列不变
	head.Next = reverseBetween(head.Next, m-1, n-1)

	// 返回最初的 head
	return head
}

var lastNode *ListNode

func reverseKList(head *ListNode, k int) *ListNode {

	if k <= 0 {
		lastNode = head.Next
		return head
	}

	KthNode := reverseKList(head.Next, k-1)
	head.Next.Next = head
	head.Next = lastNode

	return KthNode
}

二叉搜素树常规操作(Binary Search Tree)

什么是二叉搜索树?

一颗二叉搜索树是以二叉树来组织的,其中每个节点就是一个对象,除了 key 和卫星数据之外,每个节点还包含属性 leftrightp,分别指向左孩子、右孩子和双亲。其满足如下性质:

对于二叉搜索树,任何结点 x,其左子树中关键字最大不超过 x.key (x.L.key <= x.key),其右子树中关键字最小不能低于 x.key (x.R.key >= x.key)

二叉搜索树的相关操作执行效率如下:

  1. 在一棵高度为 h 的二叉搜索树上,动态集合上的操作 Search、Minimun、Maximum、Successor、Fredecessor 可以在 O(h) 时间内完成。
  2. 在一棵高度为 h 的二叉搜索树上,实现动态集合操作 INSERT & DELETE 的运行时间均为 O(h)

实现二叉搜索树

定义数据结构

type Node struct {
	Val    int
	Parent *Node
	Left   *Node
	Right  *Node
}

type BST struct {
	Root *Node
}

删除节点

删除二叉搜索树节点,其实考验的是删除一个节点后,如何保证二叉搜索树的性质不变。删除节点的思考点如下:

  1. 如果节点没有孩子,则直接删除
  2. 如果节点有左孩子或者右孩子,孩子补位
  3. 如果节点有两个孩子,则找到节点的后继 n
    1. 如果 n 是 节点的右孩子,则直接替换
    2. 如果 n 不是节点的右孩子,则先让 n 的右孩子替换自己,然后自己替换掉要删除的节点

技巧

因为 leet-code 上给出的节点没有 parent 指针,所以解题中使用了递归,为什么在没有前驱指针的时候递归可以实现响应操作?

  1. 整个 “删除节点” 操作仅和其上一个节点有关系,所以递归上控制好 “上下衔接” 即可完成删除操作。代码中 root.Left = root.Right = 就是做的这个事情
func deleteNode(root *TreeNode, key int) *TreeNode {
    // 排除特殊条件
    if root == nil {
        return nil
    }

    if root.Val == key {
        // 已找到,开始删除
        if root.Left == nil {
            // 没有左孩子,右孩子替上
            return root.Right
        }

        if root.Right == nil {
            // 没有右孩子,左孩子替上
            return root.Left
        }
        
        // 找到后驱节点
        min := root.Right
        for min != nil && min.Left != nil {
            min = min.Left
        }
        
        // 用后驱节点的值修改本节点
        root.Val = min.Val
        // 删除后驱节点
        root.Right = deleteNode(root.Right, min.Val)

    }else if root.Val > key {
        // 比节点值小,转向左子树查找
        root.Left = deleteNode(root.Left, key)
    }else if root.Val < key {
        // 比节点值大,转向右子树查找
        root.Right = deleteNode(root.Right, key)
    }

    return root
}

添加节点

添加节点主要分为两步:

  1. 添加到哪里?节点值比添加的值大,就向左找,节点值比添加的值小,就向右找,直到深入到叶节点
  2. 执行添加,对比要添加到节点的左孩子还是右孩子,然后指针赋值

技巧

  1. 这里的 p = root 是有深意的,当 for 循环条件破裂后,p 真正的记录到了要插入的新元素的父节点
  2. if p == nil return newNode 这里其实是间接判断了 root == nil,但判断 p 可以与下面形成一整串 if else 更规整
func insertIntoBST(root *TreeNode, val int) *TreeNode {
    // 记录原始的根节点
    originHead := root
    var newNode = &TreeNode{Val: val}

    // 找到要插入的位置
    var p *TreeNode
    for root != nil {
        p = root
        if root.Val > val {
            root = root.Left
        }else {
            root = root.Right
        }
    }
    
    if p == nil {
        return newNode
    }else if p.Val > val {
        p.Left = newNode
    }else{
        p.Right = newNode
    }

    return originHead
}

查询节点

根据二叉搜索树固有的性质,查找特定节点相对还是很简单的:

  1. 比对当前节点是否是要找的节点
  2. 如果不是
    1. 大于要找的值,向其左孩子分支找
    2. 小于要找的值,向其右孩子分支找

技巧

  1. 一定要善用迭代,因为迭代效率高
  2. 把最有可能结束的条件放在前面,省得空往下走,比如 root.Val == val

递归解法

func searchBST(root *Node, val int) *Node {
    if root == nil || root.Val == val {
        return root
    }

    if root.Val > val {
        return searchBST(root.Left, val)
    }else {
        return searchBST(root.Right, val)
    }
}

迭代解法(对于大多数计算机,迭代版本的效率要高很多)

func searchBST(root *TreeNode, val int) *TreeNode {
    for root != nil && root.Val != val {
        if root.Val > val {
            root = root.Left
        }else {
            root = root.Right
        }
    }

    return root
}

找到最小值 & 最大值

出于二叉搜索树的性质,可以在无需任何比较的情况下找到最大值和最小值。

  1. 因为节点的右孩子总比其本身要大,所以最大值只需要不断 “向右” 找就行了
  2. 因为节点的左孩子总比其本身小,所以最小值只需要不断 “向左” 就能找到

技巧

此次是做题配合《算法导论》看书在推进,不得不说书上有很多经典的技巧

  1. 很严谨的每次都判断是否为 nil,这要成为思维定式刻在脑子里
  2. 巧妙的条件 for 循环,满足条件就继续往下走,很优雅
func (b *BST) Minimum() *Node {
	var n = b.Root

	for n != nil && n.Left != nil {
		n = n.Left
	}

	return n
}
func (b *BST) Maximun() *Node {
	var n = b.Root

	for n != nil && n.Right != nil {
		n = n.Right
	}

	return n
}

“笨方法” 也在训练着思维

拿到一道算法题的时候,不要上来就直接看最优解,那些粗鲁的 “笨方法” 中也有一些思维和代码技巧在里面。

1. 循环嵌套,控制游标

题目

求数组的子数组之和的最大值,数组中每个元素是:正整数、负整数 或 0

比如 [-2, 5, 3, -6, 4, -8, 6] 的结果是 8 ([5, 3])

思路

最优解法是动态规划,如果临场想不出来,暴力循环做出来也是一种能力。

依次遍历每个元素,这是第一个游标。然后从每个当前元素依次向后遍历,这是第二个游标。然后计算两个游标区间内的数组和

设定一个全局变量,每次比对所计算出来的数组和与全局变量,如果更大,就替换掉全局变量。

求解

package main

import "fmt"

func main() {
	var arr = []int{-2, 1}
	result := maxSum(arr)

	fmt.Println(result)
}

func maxSum(arr []int) int {
	var sum = arr[0]

	// 遍历每个元素
	for i := 0; i < len(arr); i++ {

		// 从第一层元素(包括它自己,因为可以是一个元素的数组),向后遍历
		for j := i; j < len(arr); j++ {

			// 求 arr[i, j] 子数组元素的和
			var k = i + 1
			var tmpSum = arr[i]
			for k <= j {
				tmpSum += arr[k]
				k++
			}

			if tmpSum > sum {
				sum = tmpSum
			}

		}
	}

	return sum
}

2. 有条件的遍历

题目

求两个字符串的公共子串

例如:A abc123, B bc1234 => bc1

思路

这道题也是有动态规划解法的,这里我们使用暴力求解法训练下思维和代码一致性。

  • 首先开启第一层遍历,依次取出字符串 A 的每个字符
  • 然后开启第二层遍历,依次取出字符串 B 的每个字符。
  • 接着就是重头戏,如果两个字符相等,那就 “携手并进” 直到字符不相等为止,记录下两个字符 “一起走了多远” (这里是重点,使用 for 关键字做有判断条件的遍历)
  • 最重要的是,题目要的是找出公共的子串,所以要在合适的时机埋点,定义变量,最后找到公共子串

解题

package main

import "fmt"

func main() {

	var a string = "abc123"
	var b string = "bc1234"
	res := longestCommonStr(a, b)

	fmt.Println(res)

}

func longestCommonStr(a, b string) string {
	var maxLen int
	var from, to int
	// 遍历 a 的每一个字符
	for i := 0; i < len(a); i++ {

		// 遍历 b 的每一个字符
		for j := 0; j < len(b); j++ {
			if a[i] == b[j] {

				fmt.Printf("Match at %c\n", a[i])

				var matchLen int = 1
                
                // 把两个变量重新赋值(不要影响到两层循环遍历大局),开启 “携手并进”
				var p, q = i, j
                // 带判断条件的 for 循环
				for p+1 < len(a) && q+1 < len(b) && a[p+1] == b[q+1] {
					matchLen++
					p++
					q++
				}
                
                // 记录下每次符合条件的场景
				if matchLen > maxLen {
					maxLen = matchLen
					from = i
					to = p
				}

				fmt.Printf("From %d\tto %d\n", from, to)
			}
		}
	}

	return string([]byte(a)[from : to+1])
}

二叉树的层序遍历(由一道算法题再次推敲 go 指针的使用)

题目

二叉树的层序遍历

给你一个二叉树,请你返回其按 层序遍历 得到的节点值。 (即逐层地,从左到右访问所有节点)。

示例:
二叉树:[3,9,20,null,null,15,7],

    3
   / \
  9  20
    /  \
   15   7
返回其层序遍历结果:

[
  [3],
  [9,20],
  [15,7]
]

思路

  1. 先写一个工具函数,可以打印第 K 层
  2. 然后依次打印第 1、2、3 ... K 层
  3. 因为事先不知道二叉树有几层,所以一直打印,直到某一层打印结果数组长度为 0
  4. 还是基于框架,还是基于前序遍历,递归的实现某一层上的节点从左向右输出。

技巧一:指针传参

这里主要提到一点,也是卡主我的一点:当一个操作需要递归,而这些递归过程要共享(并且都操作)某个对象的时候,就需要在参数上加指针,该指针指向要共享的对象。

levelOrder() 中定义的 tmp Int Slice,然后我们通过 kLevel 函数中的 *[]int 指针把 tmp Slice 的地址传给每一个递归的函数。

每当有 append() 的操作时,都有可能产生新的 Int Slice,因为我们最后加到 resutl 中的是最原始的 tmp 引用的地址。这就需要我们在迭代的过程中去不断的修改 tmp 变量所保存的地址。

# 声明一个 Slice,tmp 的值为 []int 的地址
tmp := []int{}

# 这里相当于先定义了一个指针 *[]int,其值存的是 tmp 的值,也就是最初 []int 的地址
# 然后把指针传给了调用函数
kLevel(root, &tmp, k)

# *items 可以拿到 tmp 的值,然后对其做 append 操作,该操作可能返回一个新的 []int 地址
# 我们修改 *items, 也就是把新的地址再赋予到指针的 value,这个 value 也就是 tmp 所承载的地址
*items = append(*items, root.Val)

# 最后我们把 tmp 指向的 []int 装入到结果数组中
result = append(result, tmp)

技巧二:结束条件不明确时,巧用 for

我们从 0 层开始向下层序遍历是,我们不知道有几层,但我们知道遍历的结束条件,这时可以空缺掉 for 循环的条件项,把条件项通过 break 语句写在 for 函数内

for i := 0; ; i++ {
    if (xxx) {
        break
    }
}

解题

/**
 * Definition for a binary tree node.
 * type TreeNode struct {
 *     Val int
 *     Left *TreeNode
 *     Right *TreeNode
 * }
 */
func levelOrder(root *TreeNode) [][]int {
    // 构建结果数组,如果二叉树为空,则直接返回结果
    var result = [][]int{}
    if root == nil {
        return result
    }

    for k := 0; ; k++ {
        // 把第 k 层的元素撞到 tmp slice 中
        var tmp = []int{}
        kLevel(root, &tmp, k)

        // 当这一次元素数量为空,那就停止遍历,结束
        if len(tmp) == 0 {
            break
        }

        // 如果不为空,把这一层元素加到结果数组中
        result = append(result, tmp)
    }

    return result
}

// 把以 root 为根的二叉树的第 k 层装到  items 里面
func kLevel(root *TreeNode, items *[]int, k int){
    if root == nil {
        return
    }

    if k == 0 {
        // 开始装元素
        *items = append(*items, root.Val)
    } else {
        // 不属于这一层,就往下递归
        k--
        kLevel(root.Left, items, k)
        kLevel(root.Right, items, k)
    }
}

链表两两反转(是思考力也是勇气)

蚂蚁金服第二轮,出了个我做过的类似题目,但我的第一反应是:这道题比较难,我做过类似的,但这道题我不会。于是心里就开始想着把这道题如何哼哼唧唧过去。其实现在回头再做这道题,我凭自己的能力也可以做个七七八八。

有时候就是有太多的思维惯式和不自信,要打破它。还是高中数学老师说的,为什么做过的题还会错?是因为错的太少。

题目

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。
你不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。
例如 [2,1,6,4], 交换后输出应为 [1,2,4,6]

思路

之前做过依次反转一个链表,现在是两两反转,其实技法差不多,都是基于递归:

  1. 找到反转规律,保证不错乱
  2. 找到结束条件
  3. 返回链表头结点

解题

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */
func swapPairs(head *ListNode) *ListNode {
    // 链表个数为偶数个,最后一个为 nil
    // 或者为奇数个的时候,此时已是最后一个,node.Next == nil
    // 此种场景不做操作
    if head == nil ||  head.Next == nil {
        return head
    }


    newHead := head.Next

    // 指向下一轮的头结点
    head.Next = swapPairs(newHead.Next)

    // 反转相邻结点
    newHead.Next = head

    // 每次返回链表头结点,为下一轮递归操作做准备
    return newHead
}

寻找数组中的众数

题目

给定一个数组,里面有一个数字的个数超过了一半,把这个数找出来。

思路

求众数(超过一半)有专门的算法叫摩尔投票

摩尔投票算法是基于这个事实:每次从序列里选择两个不相同的数字删除掉(或称为“抵消”),最后剩下一个数字或几个相同的数字,就是出现次数大于总数一半的那个。

算法本质上就是 “对拼消耗”,设定一个计数变量,记录下每个数字的 “生命值”,谁的人数最多,谁活到最后,时间复杂度 O(n)。

解题

package main

func main() {
	var arr = []int{2, 3, 13, 2, 2, 45, 2, 2}
	result := marjorElement(arr)

	println(result)
}

func marjorElement(arr []int) int {
	var temp = arr[0]
	var mount = 1

	for i := 1; i < len(arr); i++ {
		if temp == arr[i] {
			mount++
		} else {
			mount--
		}

		if mount <= 0 {
			temp = arr[i]
			mount = 1
		}
	}

	return temp
}

引申

如果没有元素超过一半,则返回 -1

func majorityElement(nums []int) int {
    tmp := nums[0]
    var amount = 1

    for i := 1; i < len(nums); i++ {
        if tmp == nums[i] {
            amount++
        }else{
            amount--
        }

        if amount == 0 {
            tmp = nums[i]
            amount = 1
        }
    }

    var elAmount = 0

    for i := 0; i < len(nums); i++ {
        if nums[i] == tmp {
            elAmount++
        }

        if elAmount > len(nums) / 2 {
            return tmp
        }
    }

    return -1
}

二叉树题多数都是递归

题目

二叉树的直径

给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。

示例 :
给定二叉树

          1
         / \
        2   3
       / \     
      4   5    
返回 3, 它的长度是路径 [4,2,1,3] 或者 [5,2,1,3]。

注意:两结点之间的路径长度是以它们之间边的数目表示。

思路

确定思路前要通篇考虑一些极端情况:

  1. “直径线路” 不一定过根节点,可能右子树只有一个节点,但左子树开叉很长

那么我们如何找到 “直径线路”?“直径线路” 所经过的根节点,该根节点左子树方向的深度 + 右子树方向的深度,一定是最大的。所以我们就遍历每颗子树的左右子树深度之和即可。

我们不能直接知道一个节点的深度,但我们可以根据 “后续遍历” 知道左右节点的高度后,向上累加,最后知道节点深度。

题解

/**
 * Definition for a binary tree node.
 * type TreeNode struct {
 *     Val int
 *     Left *TreeNode
 *     Right *TreeNode
 * }
 */
func diameterOfBinaryTree(root *TreeNode) int {
    var max int

    var depth func(*TreeNode) int
    depth = func(n *TreeNode) int {
        // 空节点的深度一定为 0
        if n == nil {
            return 0
        }

        ld := depth(n.Left)
        rd := depth(n.Right)

        // 直径就是左右子树的深度之和
        // 辗转求出最大值
        distance := ld + rd
        if distance > max {
            max = distance
        }

        // 子树的深度,一定是左右子树最大的那个基础上再加 1
        if ld > rd {
            return ld + 1
        }

        return rd + 1
    }

    depth(root)

    return max
}

题目

二叉树的最近公共祖先

给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。

百度百科中最近公共祖先的定义为:
“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”

提示:

树中节点数目在范围 [2, 10^5] 内。
-109 <= Node.val <= 109
所有 Node.val 互不相同 。
p != q
p 和 q 均存在于给定的二叉树中。

lowest-common-ancestor-of-a-binary-tree-2

lowest-common-ancestor-of-a-binary-tree-1

思路

解对一道题,最重要的是什么?答曰:思路,对于一道题获取正确思路最重要的是什么?答曰:审题。获取的信息量越多,越对解题有益。

编程题不像普通的数学题,当你开始实现思路的时候,就开始陷于代码实现。如果思路不对,在规定的时间内基本上不可能有重做的机会了。

“通过一层一层向上找父节点,然后比较” 这种思路是行不通的。审好示例二,这两个节点之间,可能 A 本身 就是 B 的父节点,最近公共祖先可以为节点本身

二叉树的题,多数都是要通过递归来解决。递归三要素:

  1. 一个问题的解可以分解为几个子问题的解
  2. 这个问题与分解后的子问题,除了数据规模不同,求解思路完全相同
  3. 存在递归终止条件

二话不说,先写递归框架,看着框架容易有思路

func lowestCommonAncestor(root, p, q *TreeNode) *TreeNode {
      lowestCommonAncestor(root.Left, p, q)
      lowestCommonAncestor(root.Right, p, q)
}

p, q 一定是不变的,因为找的就是它俩。变的就是不断的去递归左、右节点。两个节点的最近公共祖先,那这两个节点一定是某个节点的左右节点,或者一个在节点的左子树中,一个在节点的右子树中。所以,求两个节点在一颗树上的最近公共祖先问题,可以转化为:求两个节点在左右子树的公共祖先问题,如果左右子树都没找到公共祖先,那公共祖先就是根节点。

上面的分析就解决了 1、2 要素。那么递归的终结条件是什么?

这里要理清楚,我的终止条件是判断节点,还是判断节点的左右子节点?,也就是说要判断直接的后续条件,还是判断当前元素本身。回答这个问题的依据是,直接判断当前元素是否可以直接解决此规模中的子问题,并且产生的结果能为更大规模的问题提供判断依据

  1. 因为 “公共祖先也可以是元素本身”,则如果 root == q || root == p 那么该问题(至少此轮所面对的数据规模来讲)已经求解成功,公共祖先就是 root,所以直接判断本轮元素,是完全可以求解出该规模子问题的。
  2. 该递归规模产生的结果,是否可以直接为求解上层(更大规模)问题提供基础?完全可以,因为如果判断出 root == q || root == p 则无需在向下递归了,此路已有明确结果。上层可根据左、右子树返回值是否为空,来判断 p、q是在左子树还是在右子树,如果都在,那最近公共祖先就是 root。

代码逻辑:

  1. 如果 root 为 nil,则返回 nil ,这个没啥好说的
  2. 如果 root == p || root == q ,则返回 root

如何拼装递归出来的子问题来还原原问题的解?

  1. 如果 Left & Right 递归结果都不为空,则说明 p、q 分别在左右子树上,返回 root,root 就是最近公共祖先
  2. 如果 Left OR Right 有一个为空,则返回不为 nil 的那个,说明两个节点都在不为空的那个子树上
  3. 因为 “提示” 中已经说了, p、q 都在子树上,所以就不要考虑 Left & Right 都不为空的情况了**(审题很重要)**

解题

/**
 * Definition for TreeNode.
 * type TreeNode struct {
 *     Val int
 *     Left *ListNode
 *     Right *ListNode
 * }
 */
func lowestCommonAncestor(root, p, q *TreeNode) *TreeNode {
    // 递归结束条件
    if root == nil {
        return nil
    }
    if p == root || q == root {
		return root
	}

    // 求解子问题
    lRes := lowestCommonAncestor(root.Left, p, q)
    rRes := lowestCommonAncestor(root.Right, p, q)

    // 用子问题来还原原问题的解
    if lRes == nil && rRes == nil {
        return nil
    }

    if lRes != nil && rRes != nil {
        return root
    }

    if lRes == nil {
        return rRes
    }

    return lRes
}

分治**实践与总结

很多有用的算法在结构上是递归的:为了解决一个给定的问题,算法一次或多次递归的调用其自身以解决紧密相关的若干子问题。这些算法典型的遵循 分治**:将原问题分解为几个规模较小但类似于原问题的子问题,递归的求解这些子问题,然后合并这些子问题的解来建立原问题的解。

分治模式在每层递归时都有 3 个步骤:

  1. 分解原问题为若干子问题,这些子问题是原问题的规模较小的实例
  2. 解决这些子问题,递归的求解各子问题。然而,如果子问题规模足够小,则直接求解
  3. 合并这些子问题的解组成原问题的解

这里练习两道题,深入理解下 “分治” **的精髓。

题目一:归并排序

归并排序的思路大致如下:

  1. 不断的递归分拆数组,直到拆解的子数组内只有一个元素(已经触底,无需排序)
  2. 递归回溯的过程中,两两合并子数组,使得合并后的数组有序
  3. 最终整个数组自然就是有序的了
/*
	归并排序思路:
	1. 把原数组递归拆分成仅有一个元素的小数组
	2. 不断两两合并
*/
func mergeSort(nums []int, p, r int) {
	if r > p {
		q := (p + r) / 2

		// 把原数组拆分为 [p, q] & [q+1, r]
		mergeSort(nums, p, q)
		mergeSort(nums, q+1, r)
		merge(nums, p, q, r)
	}
}

/*
	merge 操作
	1. 把数组 [p, q] [q+1, r] 合并成一个有序数组
*/
func merge(nums []int, p, q, r int) {
	// 声明左右两个数组,分别存放 [p, q] & [q+1, r]
	// 长度比需要的长度加一,因为要存入哨兵元素
	var leftArr = make([]int, q-p+1+1)
	var rightArr = make([]int, r-q+1)

	// 填充两个数组
	for i := 0; i < len(leftArr)-1; i++ {
		leftArr[i] = nums[i+p]
	}
	leftArr[q-p+1] = math.MaxInt64
	for i := 0; i < len(rightArr)-1; i++ {
		rightArr[i] = nums[i+1+q]
	}
	rightArr[r-q] = math.MaxInt64

	//	有序合并两个数组,并放入到 nums 数组的指定序列内
	var a, b int
	for i := p; i <= r; i++ {
		if leftArr[a] < rightArr[b] {
			nums[i] = leftArr[a]
			a++
		} else {
			nums[i] = rightArr[b]
			b++
		}
	}
}

题目二:最大子数组问题

“最大子数组问题” 是由另外一个问题引申过来的间接问题,直接求解引申问题是重要的,理解原问题是如何引申过来的也是十分重要的,因为这是第一步。

max-sub-arrry

如图是一张股票的波动图,我们要求出如何 “低买高卖” 才能获取最大收益?图下方有一列 “变化” 的统计,原问题可以转化为:寻找 “变化” 数组中和最大的非空连续子数组。

使用分治思路来求解这个问题:

  1. 所谓的 “和最大子数组”,如果把数组拆成两半,那这个数组无非出现在 3 个场景
    1. 左边的那个数组
    2. 右边的那个数组
    3. 这个数组跨中间节点
  2. 我们先把数组不断的 2 分拆解,触底到每个子数组只有一个元素
  3. 开始回溯,判断最大值出现在 “左数组”、“右数组” 还是 “跨中间节点”
  4. 回溯到最后,找到和最大的子数组
func findMaxSubArray(nums []int, low, high int) (from, to, sum int) {
	// base case
	if low == high {
		return low, high, nums[low]
	}

	mid := (low + high) / 2

	lLow, lHigh, lSum := findMaxSubArray(nums, low, mid)
	rLow, rHigh, rSum := findMaxSubArray(nums, mid+1, high)
	cLow, cHigh, cSum := findCrossMaxArray(nums, low, mid, high)

	if lSum >= rSum && lSum >= cSum {
		return lLow, lHigh, lSum
	}

	if rSum >= lSum && rSum >= cSum {
		return rLow, rHigh, rSum
	}

	return cLow, cHigh, cSum
}

func findCrossMaxArray(nums []int, low, mid, high int) (l, r, sum int) {
	var lSum int = math.MinInt64
	l = mid
	// mid 元素一定要包含在内
	for i := mid; i >= low; i-- {
		// 这里的 sum 是每次都要加新元素的,因为要是连续子数组
		sum += nums[i]
		if sum > lSum {
			lSum = sum
			l = i
		}
	}

	var rSum int = math.MinInt64
	sum = 0
	for i := mid + 1; i <= high; i++ {
		sum += nums[i]
		if sum > rSum {
			rSum = sum
			r = i
		}
	}

	return l, r, lSum + rSum
}

动态规划的门路

什么是动态规划?

动态规划(dynamic programming应用于子问题重叠的情况,即不同的子问题具有公共的子子问题,动态规划算法对每个子子问题只求解一次,将其解保存在一个表格中。从而无需每次求解一个子子问题时都重新计算,避免了这种不必要的计算工作。

动态规划方法通通常用来求解最优化问题,这类问题可以有很多可能解,每个解都有一个值,我们希望寻找具有最优值(最大值或最小值)的解。

动态规划的一般解法

  1. 刻画一个最优解的结构特征
  2. 递归地定义最优解的值
  3. 计算最优解的值,通常采用自底向上的方法
  4. 利用计算出的信息构造一个最优解

步骤 1~3 是动态规划算法求解的基础。如果我们仅仅需要一个最优解的值,而非解本身,可以忽略第 4 步。如果确实要做第四步,就需要在第三步的过程中维护一些额外信息,以便来构造一个最优解。

牛刀小试

1. 求数组中子数组之和的最大值

题目

输入一个整型数组,数组中的一个或连续多个整数组成一个子数组。求所有子数组的和的最大值。

输入: nums = [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

思路

  1. 首先,从题目中可以看出,我们仅需求 “最大值”,所以做到第三步就可以了,相对来讲难度降低了。
  2. 注意点一:数组是连续的,如果一个元素要不要加入到最大和数组中,一定与其相邻的元素有直接关系
  3. 把从结论到原始数的逻辑理清楚之后,然后在从原始数值,一小步一小步的推到结论
  4. 一个数在不在最大和数组中,就要比较 max(arr[i], endwith[i-1]+arr[i]) i 从小到大增长
  5. 仅仅 endwith[i] 是不够的,因为all[i] (子数组最大和) 可能就不包括边上的那个数,所以要比较 max(all[i-1], endwith[i])
  6. 初始的 all[0], endwith[0] 都是有值的,所以可以不断扩充,找到 all[i],其就是子数组之和的最大值

解题

func maxSubArray(nums []int) int {
	// 数组长度为 0 时直接返回
	if len(nums) == 0 {
		return 0
	}

	// 因为是连续的,所以要有一个 endWithArr[i] 表示以 arr[i] 结尾的最大子数组和
	// 因为已特定数结尾并不一定最大,可能是中间的一段,还需要有一个全局的 all[i] 表示 arr[0:i] 最大子数组和
	var endWith = make([]int, len(nums))
	var all = make([]int, len(nums))
	endWith[0] = nums[0]
	all[0] = nums[0]

	// 依次找出每个子问题(子数组)的最优解
	// 从 nums[0:i] 0<i<len
	for i := 1; i < len(nums); i++ {
		endWith[i] = max(nums[i], nums[i]+endWith[i-1])
		all[i] = max(endWith[i], all[i-1])
	}

	return all[len(nums)-1]
}

func max(a, b int) int {
	if a > b {
		return a
	}
	return b
}

2. 数组中最长递增子序列

题目

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

思路

  1. “最长严格递增子序列的长度”,显然这里求长度,我们只需要进项到第 3 步
  2. 注意,这里是子序列,所以不要求严格递增
  3. 假使截止到第 i 个元素的最长递增子序列的长度为 LIS[i],则 LIS[i] 是怎么来的呢?arr[i] 要对比所有 0 < n < i 的元素,如果比其大,就在其 LIS[n] 上加一,最后看插入到哪个 LIS[n] 队列是最大的,最终得出 LIS[i]

解题

func lengthOfLIS(nums []int) int {
	// 排除数组长度为 0 & 1 的情况
	if len(nums) == 0 {
		return 0
	}
	if len(nums) == 1 {
		return 1
	}

	// 最大长度,辗转比较,得出最大值
	var maxLen int = 1

	// 最长递增子序列数据记录, LIS[i] 表示截止到 i 元素,最长自增子序列的长度
	var LIS = make([]int, len(nums))
	// 初始化第 0 个元素
	LIS[0] = 1

	// 从第 1 个元素开始
	for i := 1; i < len(nums); i++ {
		// 和 i 之前的每个数都比较一下,如果比它大,就考虑加入它的 LIS
		LIS[i] = 1
		for j := 0; j < i; j++ {
			// 比该数字大,并且加入后 LIS 比以前更长,就加入进入
			if nums[i] > nums[j] && LIS[j]+1 > LIS[i] {
				LIS[i] = LIS[j] + 1
			}
		}

		// 辗转比较出最大值
		if LIS[i] > maxLen {
			maxLen = LIS[i]
		}
	}

	return maxLen
}

寻找最大的 K 个数

问题

数组中有若干个无序的数,选出其中最大的 K 个数。

思路

首先想到的思路就是排序,排序之后取出最大的前 K 个数。排序的时间复杂度是 n*lgn

但是其实我们只要最大的 K 个数字,并不需要整体都是有序的。

我们可以通过 K * N 次遍历和比较,来把前 K 大的数字找出来。具体使用哪种方法就需要根据 K & N 的大小来确定使用 K * N 复杂度,还是 n * lgn

如上的办法还是不够最优,二分法是常用的可以改进效率的办法。如果我们把数组分成两部分 setA、setB,setA 集合内的数字全大于 SetB,然后根据 K 的大小决定是否继续拆分。这样子,理论上我们只需要 O(n * log2k) 复杂度。

解题

package main

import (
	"fmt"
)

func main() {
	var arr = []int{12, 3, 1, 2, 3, 12, 31, 4, 34}
	result := kbig(arr, 2)

	fmt.Println(result)
}

func kbig(arr []int, k int) []int {
	// 此时说明 arr 数组被弃用,不在结果之列
	if k <= 0 {
		return []int{}
	}

	// arr 数组被全部采用,不用再做计算了
	if len(arr) <= k {
		return arr
	}

	setA, setB := partition(arr)

	fmt.Println(setA, setB)

	return append(kbig(setA, k), kbig(setB, k-len(setA))...)
}

func partition(arr []int) (a, b []int) {
	var key = arr[len(arr)-1]

	for i := 0; i < len(arr)-1; i++ {
		if arr[i] > key {
			a = append(a, arr[i])
		} else {
			b = append(b, arr[i])
		}
	}

	/*
		二分法如果分组失败,就会陷入无限递归分组,需要避免这种情形
                例如对 [1, 2, 3, 99] 做拆分,如果不把 99 放入到长度较小的子数组中,则分组将无限循环进行下去
	*/
	if len(a) < len(b) {
		a = append(a, key)
	} else {
		b = append(b, key)
	}
	return
}

从输出结果上看,partition 总共做了 5 次,略高于 log2k

[34] [12 3 1 2 3 12 31 4]
[12 12 31 4] [3 1 2 3]
[12 12 31] [4]
[31] [12 12]
[34 31]

链表上的双指针之旅

整理这个文章的本意是 “找链表倒数第 n 个节点” 这个思路我竟然需要画图辅助思索了。先上两道「简单」题:

  1. 剑指 Offer 22. 链表中倒数第k个节点
  2. 面试题 02.02. 返回倒数第 k 个节点

这两道题基本一样,无非是返回节点的值,还是直接返回节点,解题代码如下:

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */
func kthToLast(head *ListNode, k int) int {
    var slowP = head

    for k > 1 && head.Next != nil {
        head = head.Next
        k--
    }

    for head.Next != nil {
        head = head.Next
        slowP = slowP.Next
    }

    return slowP.Val
}

这里有两个注意点:

1. 为什么是 k > 1 ?

根据 “快慢指针” 的套路,快指针最终会移动到尾节点(也就是倒数第 1 个节点),如果要找倒数第 2 个节点,那么它仅需比慢指针先走 1 步即可。所以这里让它先走 k-1 步。

2. 消除 var slowP = head 的使用疑虑

go 语言的指针 & 引用是有点搞,var slowP = head 背后是这样的:

  1. 声明一个 *ListNode 变量
  2. 将 head 指针的值 COPY 一份 存入到 slowP 变量中

这里对 slowP 的改动完全不会影响到 head,反之同理。预热完成之后,开始上题目。

题目

19. 删除链表的倒数第 N 个结点

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

进阶:你能尝试使用一趟扫描实现吗?

示例 1:
输入:head = [1,2,3,4,5], n = 2
输出:[1,2,3,5]
示例 2:

输入:head = [1], n = 1
输出:[]
示例 3:

输入:head = [1,2], n = 1
输出:[1]

思路

  1. 首先,在单链表上删除一个节点最方便的方式是,修改其上一个节点的 next 指针。
  2. 如果不这样做,还有其他办法嘛?那就是 “狸猫换太子”,更改该节点的值为其下一个节点的值,并将 next 指针指向下下个节点。这样方式的场景是,该节点不是最后一个节点
  3. 因为要删的节点可能会是最后一个节点,所以 “狸猫换太子” 法不可取
  4. 这里有一个技巧,就是创建 dummy 节点,其 next 指针指向 head 节点,这样有几个方便之处:
    1. 方便的记忆 head 节点,便于最后按题目要求返回头节点
    2. 产出 head 节点也是很方便的

假使要删除倒数第 n 个节点,那么快指针仅需先跑 n-1 步即可,因为慢指针已经在第一个节点上了。

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */
func removeNthFromEnd(head *ListNode, n int) *ListNode {
    var dummyNode = &ListNode{Next: head}
    var slowP = dummyNode

    for n > 1 {
        head = head.Next
        n--
    }
    
    // for 循环后,slowP 的下一个节点就是倒数第 n 个节点
    for head != nil && head.Next != nil{
        head = head.Next
        slowP = slowP.Next
    }

    // delete nth node
    slowP.Next = slowP.Next.Next

    return dummyNode.Next
}

二叉树的序列化与反序列化(刷题,走量,扩思路,提速速)

题目

二叉树的序列化与反序列化

请设计一个算法来实现二叉树的序列化与反序列化。这里不限定你的序列 / 反序列化算法执行逻辑,你只需要保证一个二叉树可以被序列化为一个字符串并且将这个字符串反序列化为原始的树结构。

思路

原则上来讲前序、后序、中序出的列表是无法恢复成二叉树的,因为没有记录空值。之前做过题目 重建二叉树 也是基于两个条件:

  1. 有前序/后序结果配合中序结果
  2. 假设输入的前序遍历和中序遍历的结果中都不含重复的数字

才能完成。

这里要恢复二叉树,就需要我们把空指针也记录下来(以前序为例)。

  1. 前序遍历结果中,第一个元素即为根节点
  2. 遍历结果去除第 0 个元素后,后面一部分是归属左子树的元素,一部分归属右子树的元素
  3. 执行递归

题解

type Codec struct {
    
}

func Constructor() Codec {
    return Codec{}
}

// Serializes a tree to a single string.
func (this *Codec) serialize(root *TreeNode) string {
    if root == nil {
        return "#"
    }

    lStr := this.serialize(root.Left)
    rStr := this.serialize(root.Right)
    
    // 返回前序遍历字符串
    return fmt.Sprintf("%d,%s,%s", root.Val, lStr, rStr)
}

// Deserializes your encoded data to tree.
func (this *Codec) deserialize(data string) *TreeNode {    
    valStrArr := strings.Split(data, ",")

    return doDeserialize(&valStrArr)
}

func doDeserialize(valsArr *[]string) *TreeNode {
    // 第一个元素即为根元素
    vals := *valsArr
    val := vals[0]
    if val == "#" {
        newArr := vals[1:]
        *valsArr = newArr
        return nil
    }

    valInt, _ := strconv.Atoi(val)

    node := &TreeNode{Val: valInt}
    
    // 去除已经被使用的元素
    newArr := vals[1:]
    *valsArr = newArr
    // 前一部分属于左子树
    node.Left = doDeserialize(valsArr)
    
    // 后一部分属于右子树
    node.Right = doDeserialize(valsArr)

    return node
}

寻找重复的子树(不要放过任何一个问题)

题目

寻找重复的子树

给定一棵二叉树,返回所有重复的子树。对于同一类的重复子树,你只需要返回其中任意一棵的根结点即可。

两棵树重复是指它们具有相同的结构以及相同的结点值。

示例 1:

        1
       / \
      2   3
     /   / \
    4   2   4
       /
      4
下面是两个重复的子树:

      2
     /
    4
和

    4

思路

找重复子树,那就要回答两个问题:

  1. 我长啥样?
  2. 别人长啥样?

此题目的主要思路是:

  1. 使用一个 Map 来记录下以每个节点为根的树的样子(把这棵树序列化为字符串)
  2. 每个子树加入到 Map 的时候检查下有没和自己一样的,如果一样就把自己加到结果数组中(切勿重复加入结果数组)

需要的注意点

  1. 使用后续遍历的方式做序列化,因为只有知道自己的子树长啥样,才能知道自己长啥样
  2. 拼接子树序列化字符串的时候,使用 [根左右]、[左右根],不能使用 [左根右],因为:
0
 \
  0   
  
&

  0
 /
0

如果使用 [左根右] 的话,序列化结果是一样的。

解题

/**
 * Definition for a binary tree node.
 * type TreeNode struct {
 *     Val int
 *     Left *TreeNode
 *     Right *TreeNode
 * }
 */
func findDuplicateSubtrees(root *TreeNode) []*TreeNode {
    var res = []*TreeNode{}
    if root == nil {
        return res
    }

    var memo = make(map[string]int)
    recordSubtree(root, memo, &res)

    return res
}


func recordSubtree(n *TreeNode, memo map[string]int, res *[]*TreeNode) string{
    if n == nil {
        return "#"
    }

    lStr := recordSubtree(n.Left, memo, res)
    rStr := recordSubtree(n.Right, memo, res)
    treeStr := fmt.Sprintf("%s,%s,%d", lStr, rStr, n.Val)

    if  _, ok := memo[treeStr]; !ok {
        memo[treeStr] = 1
    }else {
        if memo[treeStr] == 1 {
            *res = append(*res, n)
            memo[treeStr]++
        }
    }

    return treeStr
}

最大堆求解滑动窗口最大值(debug 了一天)

题目

滑动窗口最大值

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

示例 1:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

思路

另一篇文章中有提到过这道题目的解法,当时用的是有序队列,这次解题我们使用最大堆。一道题做出来就做出来了嘛,干嘛要找另一种方法?思考的执念吧。

说来 “最大堆” 方法也很简单,就是使用最大堆构建一个优先队列,每次从队列中取出最大值和插入新的值,和这个题目很贴切(哎,所谓的面试算法考试,都是考从没学过算法,或者临时抱佛脚的人的,真正的翻翻算法导论,做做课后题,那些都是小儿科)。

具体步骤:

  1. 创建 windowArr 变量来存放当前窗口内的元素索引,只存索引,涉及到元素比较,就通过索引去找具体的值
  2. 对 windowArr 内的元素构建最大堆
  3. 从 k 元素向后遍历,不断的插入新值,取出最大值,直到最后

解题

func maxHeapify(originData, indexArr []int, i int) {
	var largest = i
	l, r := left(i), right(i)
	heapsize := len(indexArr)

	if l < heapsize && originData[indexArr[l]] > originData[indexArr[largest]] {
		largest = l
	}
	if r < heapsize && originData[indexArr[r]] > originData[indexArr[largest]] {
		largest = r
	}

	if largest != i {
		indexArr[i], indexArr[largest] = indexArr[largest], indexArr[i]
		maxHeapify(originData, indexArr, largest)
	}
}

func buildMaxHeap(originData, indexArr []int) {
	for i := len(indexArr)/2 - 1; i >= 0; i-- {
		maxHeapify(originData, indexArr, i)
	}
}

func maxSlidingWindow(nums []int, k int) []int {
	var res = []int{}

	// 填入窗口初始值
	var windowArr = []int{}
	for i := 0; i < k; i++ {
		windowArr = append(windowArr, i)
	}

	// 构建最大堆
	buildMaxHeap(nums, windowArr)

	// 放入第一个元素
	res = append(res, nums[windowArr[0]])

	for i := k; i < len(nums); i++ {

		// 向堆内插入新元素
		windowArr = append(windowArr, i)
		elIndex := len(windowArr) - 1

		// 如果比其父节点值大,那就替换其父节点,保持最大堆性质
		for elIndex >= 0 && nums[windowArr[parent(elIndex)]] < nums[windowArr[elIndex]] {
			windowArr[parent(elIndex)], windowArr[elIndex] = windowArr[elIndex], windowArr[parent(elIndex)]
			elIndex = parent(elIndex)
		}

		// 从第 0 个元素开始,剔除不在窗口内的元素,直到最大值属于当前窗口
		for windowArr[0] <= i-k {
			windowArr[0], windowArr[len(windowArr)-1] = windowArr[len(windowArr)-1], windowArr[0]
			windowArr = windowArr[0 : len(windowArr)-1]

			// maxHeapfiy 保持最大堆的性质
			maxHeapify(nums, windowArr, 0)
		}

		res = append(res, nums[windowArr[0]])
	}

	return res
}

func parent(i int) int {
	return (i - 1) / 2
}
func left(i int) int {
	return (i+1)*2 - 1
}
func right(i int) int {
	return (i + 1) * 2
}

判断回文链表(代码要跟得上思维)

题目

请判断一个链表是否为回文链表。

示例 1:

输入: 1->2
输出: false

示例 2:

输入: 1->2->2->1
输出: true

思路

其实,把单个点各个击破之后,剩下的就是技巧点的结合了。这道题思路不难,需要的是代码准确,不要因为一个点卡主(毕竟做题是有时间限制的)。

  1. 快慢指针找到链表的中间点(考虑链表长度为奇、偶的请款)
  2. 从慢指针开始向后反转,然后新的 head (这个反转代码要熟练,此题作为工具函数)
  3. 最左,最右两个指针向中间走,比对回文是否成立

解题

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */
func isPalindrome(head *ListNode) bool {
    // 处理极端空链表情况
    if head == nil {
        return true
    }
    
    // 记录下 head 指针
    var firstNode = head
    
    // 快慢指针找中点
    var fast, slow *ListNode = head, head
	for fast.Next != nil {
		if fast.Next.Next != nil {
			fast = fast.Next.Next
			slow = slow.Next
		} else {
			// 链表长度为偶数
			break
		}
	}
    
    // 慢指针之后开始反转
    tailNode := reverse(slow.Next)
    
    
    // 比对是否是回文
    var allSame bool = true
	for firstNode != nil && tailNode != nil {
		if firstNode.Val != tailNode.Val {
			allSame = false
			break
		}

		firstNode = firstNode.Next
		tailNode = tailNode.Next
	}

	return allSame
}

func reverse(head *ListNode) *ListNode {
	if head == nil || head.Next == nil {
		return head
	}

	newHead := reverse(head.Next)
	head.Next.Next = head
	head.Next = nil

	return newHead
}

判断 BST 的合法性

题目

验证二叉搜索树

给定一个二叉树,判断其是否是一个有效的二叉搜索树。

假设一个二叉搜索树具有如下特征:

1. 节点的左子树只包含小于当前节点的数。
2. 节点的右子树只包含大于当前节点的数。
3. 所有左子树和右子树自身必须也是二叉搜索树。

思路

判断二叉搜索树的合法性,其实是有一个坑的,那就是:不能仅满足 Left < root && Right > root。二叉搜索树的性质是:

  1. 节点右子树的值都大于或等于它
  2. 节点左子树的值都小于或等于它

这就要求这个合法性的校验,其实是带有上下文的,是一个区段,而不是单纯的和 root 比较大小。

这个上下文通过走 Left 还是 Right 路径向下透传

解题

/**
 * Definition for a binary tree node.
 * type TreeNode struct {
 *     Val int
 *     Left *TreeNode
 *     Right *TreeNode
 * }
 */
func isValidBST(root *TreeNode) bool {
    return isValidWithContext(root, nil, nil)
}


func isValidWithContext(root, min, max *TreeNode) bool {
    if root == nil {
        return true
    }

    if min != nil && root.Val <= min.Val{
        return false
    }

    if max != nil && root.Val >= max.Val{
        return false
    }

    return isValidWithContext(root.Left, min, root) && isValidWithContext(root.Right, root, max)
}

最大公约数问题

问题

写一个程序,求两个正整数的最大公约数(Greatest Common Divisor, GCD)。

思路

解法来自欧几里得《几何原本》中的辗转相除法,假使要求 x, y 的最大公约数,则设 d 是 x, y 的公约数,r 是 x, y 的余数,则有:

r = x - yk (k 为正整数)
  • 因为 d 是 x & y 的约数,所以 d 也是 r 的约数。
  • 因为 d 是 x & y 的公约数,所以 d 也是 y & r 的公约数
  • x & y 的最大公约数,也等于 y & r 的最大公约数

求解

package main

func main() {
	maxDivisor := gcd(10000004, 400000)

	println(maxDivisor)
}

// 求两个数的最大公约数
func gcd(a, b int) int {
	a, b = order(a, b)
	if a%b == 0 {
		return b
	}

	var r = a % b
	return gcd(b, r)
}

func order(a, b int) (int, int) {
	if a > b {
		return a, b
	}

	return b, a
}

寻找数组中的最大值和最小值

问题

数组是最简单的一种数据结构。我们经常碰到的一个基本问题,就是寻找整个数组中最大的数,或者最小的数。我们都会扫描一遍数组,把最大(最小)的数找出来。如果同时找出最大 & 最小的数呢?

对于一个由 N 个整数组成的数组,需要对比多少次才能把最大 & 最小的数找出来呢?

思路

排序需要 n * lgn 复杂度,这里仅找出最大 & 最小值,排序不是一个最优办法。遍历数组,分别比较找出最大 & 最小值,要做 2n 次比较。

这里,我们把两个两个划成一组,第一组做比较后确定 maxmin 的值,接下来每一组的两个值先自己比较,然后相对大的和 max 比较,相对小的和 min 比较,这样比较次数为 1.5n 次。

代码

package main

/*
	寻找数组中的最大值和最小值
*/

var arr = []int{90, 23, 4, 23, 5, 4, 234, 6, 65, 47, 89, 89, 6, 56, 0, 8}

func main() {
	max, min := findMaxAndMin(arr)

	println(max, min)
}

func findMaxAndMin(arr []int) (max, min int) {
	// empty input
	if len(arr) == 0 {
		return 0, 0
	}

	// only has one element
	if len(arr) <= 1 {
		return arr[0], arr[0]
	}

	max, min = compare(arr[0], arr[1])

	for i := 2; i < len(arr); i += 2 {
		if i+1 < len(arr) {
			relativeMax, relativeMin := compare(arr[i], arr[i+1])
			max, _ = compare(max, relativeMax)
			_, min = compare(min, relativeMin)
		} else {
			lastEl := arr[i]
			max, _ = compare(max, lastEl)
			_, min = compare(min, lastEl)
		}
	}

	return
}

func compare(a, b int) (max, min int) {
	if a > b {
		return a, b
	} else {
		return b, a
	}
}

滑动窗口 Know How

什么是滑动窗口算法?

所谓的滑动窗口算法,通常是用来解决字符串、一维数组相关问题本质上就是定义两个游标,一左一右,根据条件向右移动,左右游标之间的那一段,我们形象的称之为 “窗口”,整个过程好像推着一个窗户往右走。

滑动窗口算法为什么能穷举出所有情况?

“滑动窗口算法” 通常要解决的问题都有好多解,只不过要找的是最优解。在滑动窗口的整个算法过程中,右指针负责 “张”,左指针负责 “收”,以是否不满足条件为左指针的移动条件,右指针会直接干到尽头。确实以最低成本穷举出了所有可能解。

在整个求解过程中,我们辗转比较出最优解。

牛刀小试

题目

给定一个字符串,请你找出其中不含有重复字符的 最长子串 的长度。

输入: s = "abcabcbb"
输出: 3 
解释: 因为无重复字符的最长子串是 "abc",所以其长度为 3。
输入: s = ""
输出: 0

思路

  1. 用一个 map 来承载是否有重复字符串的能力,map[byte]int,如果哪个字符的 value 大于了 1,则说明出现了重复字符
  2. 右指针一直干到最后,每次移动都把新占领的字符加入到 map 中
  3. 左指针如遇到有重复的情况,就向右移动,并且把离开的字符 value 减一,保证 map 中记录的真的是 [left:right] 之间的
  4. 在这个过程中,不断记录 left to right 的距离,也就是最长子串的长度,辗转比较出最大值

解题

func lengthOfLongestSubstring(s string) int {
    if len(s) == 0 {
        return 0
    }
    var maxLen int = 1

    // 定义左、右两个指针
    var left, right int

    // 记录当前窗口内的内容
    var window = make(map[byte]int)
    window[s[0]] = 1


    for right < len(s)-1 {
        // 右游标一直走到尽头
        right++
        window[s[right]]++

        // 不再满足条件
        for window[s[right]] > 1 {
            // 左游标向右移动
            window[s[left]]--
            left++
        }

        // 辗转求出符合条件的最大长度
        if right - left + 1 > maxLen {
            maxLen = right - left + 1
        }
    }

    return maxLen
}

二维数组与公共子序列

这篇文章总结一下如何借助二维数组来求解公共子序列问题。公共子序列问题(LCS),无论是连续的公共部分,还是不连续的公共部分,都是基于二维数组来求解。

类似的问题可以引申为:

  • 字符串的公共子串
  • 字符串的公共子序列
  • 数组的公共子序列
  • 数组的公共子数组

题目

公共子序列(不保证连续)

求两个字符串的 LCS 长度

str1 = "Java2blog", str2 = "CoreJava" , 输出: 4

公共子串(一定要是连续的)

String 1: Java2blog

String 2: CoreJava

Longest common subString is: Java

思路

两个题的思路大致都是一样的,首先要把两个字符串标记在二维数组中,这个数组一般称之为 dp (dynamic programming) 数组。dp[i][j] 就表示 str1[1 ... i] & str2[1 ... j] 的公共子串 OR 子序列长度。

· 0 J a v a 2 b l o g
0 0 0 0 0 0 0 0 0 0 0
C 0 0 0 0 0 0 0 0 0 0
o 0 0 0 0 0 0 0 0 1 0
r 0 0 0 0 0 0 0 0 0 0
e 0 0 0 0 0 0 0 0 0 0
J 0 1 0 0 0 0 0 0 0 0
a 0 0 1 0 1 0 0 0 0 0
v 0 0 0 1 0 0 0 0 0 0
a 0 0 1 0 1 0 0 0 0 0

这里以 “Java2blog” & “CoreJava” 做举例

公共子串 的方法是找到二维数组中的最长的对角线

公共子序列 就是求从横轴 OR 纵轴标记为 1 的格子个数。这个场景下我们通过另一种思路会更好理解:

  1. a[i] == b[i] 的时候,该数字一定在 LCS 中
  2. 当 a[i] != b[i] 的时候,LCS 一定出现在 a[...:i-1] 和 b[...:i] 或者 a[...:i] 和 b[...:i-1] 的 LCS 中(二者的最大 LCS)
  3. 并记录 Memo 信息,基于小规模问题,求解大规模问题。本文中使用两次 for 循环,规模由小到大,查找所有可能性

解题

1. 求公共子串

func longestCommonSubStr(a, b string) string {
	var maxLen int

	// 初始化 dp 二维数组
	// 确定了二维数组的长度和每个元素的长度,基础值为 0
	var dp = make([][]int, len(a)+1)
	for i := range dp {
		dp[i] = make([]int, len(b)+1)
	}

	var endAt int

	// 遍历 a 的每一个字符(纵坐标)
	for i := 0; i < len(a); i++ {
		// 遍历 b 的每一个字符(横坐标)
		for j := 0; j < len(b); j++ {
            
			if a[i] == b[j] {
			    // 公共子串在二维数组上的表现为连续的并且
			    // 斜率为 1 的对角线
				dp[i+1][j+1] = dp[i][j] + 1

				if dp[i+1][j+1] > maxLen {
					endAt = i
					maxLen = dp[i+1][j+1]
				}
			}

		}
	}

	return a[endAt-maxLen+1 : endAt+1]
}

2. 求最长公共子序列的长度

func longestCommonSubsequence(str1, str2 string) int {
	var maxLen int

	m, n := len(str1), len(str2)

	// 构建二维数组
	var dp = make([][]int, m+1)
	for i := range dp {
		dp[i] = make([]int, n+1)
	}

	// str1 作为纵坐标
	for i := 0; i < m; i++ {
		// str2 作为横坐标
		for j := 0; j < n; j++ {
			if str1[i] == str2[j] {
				dp[i+1][j+1] = dp[i][j] + 1
			} else {
				dp[i+1][j+1] = max(dp[i+1][j], dp[i][j+1])
			}

			if dp[i+1][j+1] > maxLen {
				maxLen = dp[i+1][j+1]
			}
		}
	}

	return maxLen
}

func max(a, b int) int {
	if a > b {
		return a
	}

	return b
}

打印排序数组中相加和为 K 的所有二元组

问题

给定排序数组 arr 和整数 k ,不重复打印 arr 中所有相加和为 k 的不降序二元组

例如, arr = [-8, -4, -3, 0, 1, 2, 4, 5, 8, 9], k = 10,打印结果为:
1, 9
2, 8

要求: 时间复杂度为 O(n),空间复杂度为 O(1)

思路

这个前提很好,数组已经是排序好的了。这道题的解法是声明两个下标,一个指向数组的最前,一个指向数组的最后,比较对应数字的和,如果等于 K,则加入到结果 slice,前置下标继续向后移动。如果小于 K,则前置下标向后移动一位,如果大于 K,则后置下标向前移动一位,直到两个下标相遇。

最终可以在 O(n) 时间复杂度上找到满足条件的元素。

求解

package main

import "fmt"

func main() {
	var arr = []int{-8, -4, -3, 0, 1, 2, 4, 5, 8, 9}
	result := twoSum(arr, 10)

	fmt.Println(result)
}

func twoSum(arr []int, k int) [][]int {
	var result = [][]int{}

	var prePos, endPos = 0, len(arr) - 1

	for prePos < endPos {
		sum := arr[prePos] + arr[endPos]

		if sum == k {
			item := []int{arr[prePos], arr[endPos]}
			result = append(result, item)
			// 当找到满足条件的值后,前置下标加一
			// 查询继续向下走
			prePos++
		} else {
			if sum < k {
			    // 和小于 k,则前置下标向后移动
				prePos++
			} else {
			    // 和大于 k, 则后置下标向前移动
				endPos--
			}
		}
	}

	return result
}

链表 K 个一组反转(世上无难事)

所谓的面试算法,其实就是基础知识加上题海战术后的做题技巧和熟练度。

题目

给你一个链表,每 k 个节点一组进行翻转,请你返回翻转后的链表。

k 是一个正整数,它的值小于或等于链表的长度。

如果节点总数不是 k 的整数倍,那么请将最后剩余的节点保持原有顺序。


示例:

给你这个链表:1->2->3->4->5

当 k = 2 时,应当返回: 2->1->4->3->5

当 k = 3 时,应当返回: 3->2->1->4->5

思路

思路很简单,就是你的代码要能跟上你的思路走。

  1. 每 K 个一组执行反转
  2. 把整条链表,每 K 个一组操作一遍

具化递归执行思路

  1. 要有一个 reverse 工具函数,执行 startNode ~ endNode 的反转,该函数要返回这一小段链表数据的新 head,以便为下一组反转提供便利
  2. 判断结束条件,尤其是题目中要求的不满足 K 个的情况
  3. 合理规划递归步骤,最后返回新链表的 head

解题

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */


func reverseKGroup(head *ListNode, k int) *ListNode {
    if head == nil {
		return head
	}

	// 找出区间段
	startNode := head
	var endNode *ListNode = head
	
	// 因为 k 接下来还要用它,所以要定义新的变量
	var i = k
	for i > 0 {
		i--
		if endNode == nil {
			return head
		}
		endNode = endNode.Next
	}

	// 递归执行反转,从 startNode 开始,到 endNode 结束,左开右闭
	newHead := reverse(startNode, endNode)

	// 反转下一轮
	startNode.Next = reverseKGroup(endNode, k)
	return newHead

}

// 反转从 a 到 b 的元素
func reverse(a, b *ListNode) *ListNode {
	var pre, cur, next *ListNode

	pre = nil
	cur = a
	next = a

	for cur != b {
		next = cur.Next
		cur.Next = pre
		pre = cur
		cur = next
	}

	return pre
}

和为K的子数组(思考力是第一位的)

题目

和为 K 的子数组

给定一个整数数组和一个整数 k,你需要找到该数组中和为 k 的连续的子数组的个数。

示例 1 :

输入:nums = [1,1,1], k = 2
输出: 2 , [1,1] 与 [1,1] 为两种不同的情况。

思路

要求和为 k 的连续子数据,这里要用到一个技巧:前缀数组。前缀数组第 i 位记录前 i 个元素的和。[1, 2, 3] 的前缀数组为:[1, 3, 6]。通过前缀数组,我们可以非常方便的计算出数组中第 i 个元素到第 j 个元素的和为多少。

借力前缀数组,这个问题就变成了:在数组中找到合为 k 的两个元素。和 TwoSum 问题很类似,可以用暴力解法,也可以用上 Map 。

这里有一个 go 语言的特性要记住,从 map 中取一个值 val, ok := hash[key],如果这个 key 不存在,则会返回其类型的零值。

解题

1. 暴力解法

  1. 首先构建前缀数组,第一个元素设置为 0 ,这是方便匹配 “从第一个元素到目标元素之和” 的场景
  2. 接着开始遍历前缀数组,依次和其之前的元素求差,看是否差为 k,如果为 k ,则符合条件的子数组个数加一
  3. 整个遍历下来,相当于暴力的求和了所有子数组情形
func subarraySum(nums []int, k int) int {
    var res int

    // 构建前缀数组
    var preArr = make([]int, len(nums)+1)
    preArr[0] = 0
    for i := 0; i < len(nums); i++ {
        preArr[i+1] = preArr[i]+nums[i]
    }

    for i := 1; i < len(preArr); i++ {
        for j := 0; j < i; j++ {
            if preArr[i] - preArr[j] == k{
                res++
            }
        }
    }

    return res
}

2. 空间换时间 O(n) 方法

暴力求解的过程中,要让前缀数组中的每一个元素和之前的元素求差,这个是否可以用 Map 来填充,检查遍历次数?

func subarraySum(nums []int, k int) int {
    var res int

    var sumi int
    var hash = make(map[int]int)
    
    // 便于求解从第 0 位到元素本身的和
    hash[0] = 1
    for i := 0; i < len(nums); i++ {
        sumi += nums[i]
        // 要找的元素
        sumj := sumi-k
        if count, ok := hash[sumj]; ok {
            res += count
        }
        
        // 元素本身加入到 map
        hash[sumi] = hash[sumi]+1
    }


    return res
}

学习堆,运用堆

堆是一个很有趣的数据结构,其保证父节点都大于等于子节点(最大堆),或者父节点都小于等于子节点(最小堆)。常用于优先队列的场景,堆排序也是一个 n*logN 的排序方法。

堆的操作基于其结构的基本性质:

  1. 当数组存储 n 个元素的堆时,叶结点下标分别为$\lfloor n/2 \rfloor+1$$\lfloor n/2 \rfloor+2$,... ,n
  2. 元素 i 的父节点索引为 (i-1)/2 (从 0 开始计数)
  3. 元素 i 的左孩子节点索引为 (i+1)*2 - 1 (从 0 开始计数)
  4. 元素 i 的右孩子节点索引为 (i + 1) * 2 (从 0 开始计数)

题目

最小K个数

设计一个算法,找出数组中最小的k个数。以任意顺序返回这k个数均可。

示例:

输入: arr = [1,3,5,7,2,4,6,8], k = 4
输出: [1,2,3,4]

思路

这是一个最小堆的典型场景(哎,这么基本的课本示例也算中等难度,可见整体平均水平也不高)。具体步骤如下:

  1. 先将整个数组构建成最小堆
  2. 执行 k 次操作
    1. 把第一个元素放到结果数组
    2. 第 0 个元素和最后一个元素做交换
    3. 堆范围减一
    4. 针对第一个元素做 minHeapify,保持其最小堆属性

解题

func smallestK(arr []int, k int) []int {
    // 本质上是一个减缩版本的 heap sort
	var res = make([]int, k)
	
	// 针对整个数组构建最大堆
	buildMinHeap(arr)

	var heapSize = len(arr)
	for i := 0; i < k; i++ {
		res[i] = arr[0]
        
        // 把最大值与最后一个元素交换
		arr[0], arr[heapSize-1] = arr[heapSize-1], arr[0]
		// 缩减堆的大小
		heapSize--
		// 重新保持最小堆性质
		minHeapify(arr, heapSize, 0)
	}

	return res
}

func buildMinHeap(nums []int) {
    // 因为从数组的 n/2+1 后都是叶节点,所以仅需对 [0, n/2] 做 minHeapify 操作,并且是倒序来的
	for i := len(nums) / 2; i >= 0; i-- {
		minHeapify(nums, len(nums), i)
	}
}

// minHeapify 保持最小堆性质
func minHeapify(nums []int, heapSize, i int) {
	var min int = i

	l, r := left(i), right(i)
	if l < heapSize && nums[min] > nums[l] {
		min = l
	}

	if r < heapSize && nums[min] > nums[r] {
		min = r
	}
	if i != min {
	    // 如果左孩子 OR 右孩子比父节点小,交换之
		nums[i], nums[min] = nums[min], nums[i]
		
		// 继续下沉,保持最小堆性质
		minHeapify(nums, heapSize, min)
	}
}

// 工具函数,快速定位到父节点、左孩子、右孩子(index from 0)
func parent(i int) int {
	return (i - 1) / 2
}
func left(i int) int {
	return (i+1)*2 - 1
}
func right(i int) int {
	return (i + 1) * 2
}

链表两两反转 & 两个链表求和(阻挡我的原来是对 go 语言指针 & 引用的理解缺乏)

在今天的两道题中,卡主我的是代码实现。我既要最终返回一个变量最开始的值,又要不断的在过程中修改这个变量,导致做题超时,最后做不出来。

题目

两两交换链表中的节点

两两交换链表中的节点

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。

思路

之前已经用递归的方法做过,但往往递归的效率很低,这次用迭代法做一遍。

整体思路如下:

  1. 使用 for 循环,一次跳两格
  2. for 循环内部做链表反转
  3. 每次 for 循环把前面的条件准备好,以便 “前后衔接”

技巧

  1. 因为涉及到 “前后衔接”(本轮和上一轮的衔接),所以 for 循环中一定要有变量是上一轮传过来的(通常的做法是创建一个空对象,其后驱指向真正的 head

代码

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */
func swapPairs(head *ListNode) *ListNode {
    var result = &ListNode{0, head}
    tmp := result

    for tmp.Next != nil && tmp.Next.Next != nil {
        // 把两个节点声明出来,不然思维跟不上,都是 next next next
        node1 := tmp.Next
        node2 := tmp.Next.Next
        
        // 和上一轮串起来
        tmp.Next = node2

        // 在链表反转之前,为下轮做准备
        // 下一轮会用到 node1, node2,以及上一轮的尾节点
        // 如果下一轮为 nil 或 奇数个值,也没有关系,正好满足了 “如不足两个则不作操作”
        node1.Next = node2.Next
        
        // 链表反转
        node2.Next = node1

        // 把这轮的尾元素给到下一轮
        tmp = node1
    }

    return result.Next
}

题目

链表求和

给定两个用链表表示的整数,每个节点包含一个数位。

这些数位是反向存放的,也就是个位排在链表首部。

编写函数对这两个整数求和,并用链表形式返回结果。

输入:(7 -> 1 -> 6) + (5 -> 9 -> 2),即617 + 295

输出:2 -> 1 -> 9,即912

思路

这道题没有太难的思路问题,卡住我的问题是:

  1. 我要返回一个原始变量,但我又需要在过程中不断的使用和迭代这个变量,要如何操作?

这里就涉及到对指针 & 引用的深刻理解,先定义一个指针,然后用引用去记住这个指针。然后在过程中迭代指针,最后返回引用。

技巧点

  1. 这里为什么要声明一个 lastLoopNode?第一是记录头指针用,第二是当我们不知道 head 在哪里时,就自己先声明一个,然后在迭代过程中找到头指针(多应用于多链表操作情形,例如合并两个有序链表

代码

/**
 * Definition for singly-linked list.
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 */
func addTwoNumbers(l1 *ListNode, l2 *ListNode) *ListNode {
    if l1 == nil {
        return l2
    }

    if l2 == nil {
        return l1
    }

    var lastLoopAddVal int
    var lastLoopNode = &ListNode{}

    var head = lastLoopNode
    for l1 != nil || l2 != nil || lastLoopAddVal != 0 {
        if l1 != nil {
            lastLoopAddVal += l1.Val
            l1 = l1.Next
        }

        if l2 != nil {
            lastLoopAddVal += l2.Val
            l2 = l2.Next
        }

        newNode := &ListNode{Val: lastLoopAddVal%10}
        lastLoopAddVal = lastLoopAddVal/10


        lastLoopNode.Next = newNode
        lastLoopNode = newNode
    }

    return head.Next
}

LRU 算法

什么是 LRU 算法?

LRU Cache(Least Recently Used)的淘汰策略是:删除那些最近没有被用到的数据。意即:

  1. 最新使用的数据,要被提前
  2. 最新插入的数据,要被提前

题目审题

LRU 缓存机制

功能要求:

  1. cache 有指定的容量大小
  2. GET 操作,如果关键字存在,则返回其值,不存在则返回 -1
  3. PUT 操作,值存在则做更新,不存在则插入。如果当前缓存数据已满,则要在写入新数据之前,以 LRU 策略来淘汰旧数据

算法要求:

  1. 在 O(1) 时间复杂度内完成操作

思考

  1. 既然数据有前后之分,那就肯定需要有序,就要用到链表
  2. 既然要 O(1) 复杂度查找操作,那就需要用到 HashMap

最终,LRUCache 是一个链表 & Map 的结合体,Map 中存储 Key & 节点指针的映射关系,可以在 O(1) 时间内定位到链表节点。然后通过双向链表,在 O(1) 时间内完成添加、删除节点。

代码实现

type LRUCache struct {
	Cap   int
	Cache map[int]*Item
	Link  DLink
}

// 定义带哨兵的双向链表
type DLink struct {
	Nil *Item
}

func newDLink() DLink {
	nilItem := &Item{}
	nilItem.Pre = nilItem
	nilItem.Next = nilItem
	return DLink{Nil: nilItem}
}

func (l DLink) addItem(n *Item) {
	n.Next = l.Nil.Next
	l.Nil.Next.Pre = n

	l.Nil.Next = n
	n.Pre = l.Nil
}
func (l DLink) delItem(n *Item) {
	n.Pre.Next = n.Next
	n.Next.Pre = n.Pre
}

// 定义元素结构
type Item struct {
	Key       int
	Val       int
	Pre, Next *Item
}

func Constructor(capacity int) LRUCache {
	lru := LRUCache{
		Cap:   capacity,
		Link:  newDLink(),
		Cache: make(map[int]*Item),
	}
	return lru
}

func (this *LRUCache) Get(key int) int {
	if item, ok := this.Cache[key]; !ok {
		return -1
	} else {
		this.Link.delItem(item)
		this.Link.addItem(item)
		return item.Val
	}
}

func (this *LRUCache) Put(key int, value int) {
	if item, ok := this.Cache[key]; !ok {
		// 该节点未存在
		newItem := &Item{Key: key, Val: value}
		if this.Cap == len(this.Cache) {
			// 删除过时元素(一定要先删 Map 再删节点,因为要删哪个 cache 是由链表最后一个元素决定的)
			delete(this.Cache, this.Link.Nil.Pre.Key)
			this.Link.delItem(this.Link.Nil.Pre)
		}
		this.Cache[key] = newItem
		this.Link.addItem(newItem)
	} else {
		// 已经存在
		item.Val = value
		this.Link.delItem(item)
		this.Link.addItem(item)
	}
}

滑动窗口最大值(思考、抽象、求最优)

题目

滑动窗口最大值

给你一个整数数组 nums,有一个大小为 k 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 k 个数字。滑动窗口每次只向右移动一位。

返回滑动窗口中的最大值。

示例 1:

输入:nums = [1,3,-1,-3,5,3,6,7], k = 3
输出:[3,3,5,5,6,7]
解释:
滑动窗口的位置                最大值
---------------               -----
[1  3  -1] -3  5  3  6  7       3
 1 [3  -1  -3] 5  3  6  7       3
 1  3 [-1  -3  5] 3  6  7       5
 1  3  -1 [-3  5  3] 6  7       5
 1  3  -1  -3 [5  3  6] 7       6
 1  3  -1  -3  5 [3  6  7]      7

思路

这道题思考起来并不难,我最初的想法是这样的:

  1. 因为随着窗口向右推,要频繁的操作容器添加 & 删除元素,所以我想到了 map。因为要找最大值,所以需要一个有序列表,并且便于频繁增加 & 删除,所以我想到了双向链表。
  2. 类似于 LinkedHashMap,把每个元素变成双向链表上的节点,链表上仅存放窗口内的值,Map 中也仅存放窗口内的值。空间复杂度为 2k。
  3. 每次窗口挪动,就通过 map 在 O(1) 时间内定位到节点,执行添加 & 删除。并且每次链表上 head 节点对应的值就是窗口内的最大值。

我这个思路有个问题:

  1. 链表的插入效率是 O(n),为了保证严格有序,整个算法操作下来复杂度为 O(n * k)
  2. 看起来由精巧的数据结构支撑,这个复杂度还是最差劲的情况,还不如一边推着窗口走,一边每次 O(k) 次复杂度找出最大值

经过看了题解,有以下两种改进方式:

  1. 更换数据结构,因为这里仅求窗口内的最大值,所以可以使用 最大堆 数据结构,可以轻易的拿出最大值,没必要保持有序,这时处理窗口元素的复杂对就从 O(k) 变成了 O(logK),整体算法的复杂度从 O(n * k) 变成了 O(n * logK)
  2. 还是更换数据结构,维护一个有序队列。队列长度不会超过窗口大小,从 0 到 k 依次递减。每次添加元素时,从后向前比较,如果比插入的元素小就剔除(我们关注的是窗口内元素的最大值)。问题来了:一个元素刚开始看起来是个不起眼的小值,但随着窗口的推移,兴许在接下来阶段,它就能成为窗口内的最大值,这种情况如何解决? 在整个有序队列的操作中,我们删除的仅仅是窗口内的最小值,随着窗口向右推,小于 新值 的值其实是没有用的。

解题

func maxSlidingWindowPro(nums []int, k int) []int {
	var res = make([]int, len(nums)-k+1)

	// 装的是元素的 index
	var stack = []int{}
	push := func(i int) {
	    // 移除当前窗口内比 nums[i] 小的数值,在新窗口内,比其小的值,已没有用武之地了
		for len(stack) > 0 && nums[i] > nums[stack[len(stack)-1]] {
			// 移除 stack 上最后一个元素
			stack = stack[0 : len(stack)-1]
		}
		
		// 最后把元素添加到队列的最后一位
		stack = append(stack, i)
	}

	// 前 k 个元素放入 stack 内
	for i := 0; i < k; i++ {
		push(i)
	}

	res[0] = nums[stack[0]]

	for i := k; i < len(nums); i++ {
		push(i)

		// 如果最大元素不在窗口内,则删除掉
		for stack[0] <= i-k {
			stack = stack[1:]
		}

		// 将本轮窗口内的最大值,放入到结果数组中
		res[i-k+1] = nums[stack[0]]
	}

	return res
}

doubly linked list with a sentinel

双向链表的优点在于可以非常方便的删除、添加结点,添加了哨兵(sentinel)的链表可以使得代码更加干净整洁。

添加哨兵之后,双向链表将变为一个有哨兵的双向循环链表L.nil 将位于表头 & 表尾之间。属性 L.nil.next 指向表头,L.nil.pre 指向表尾。类似的,表头的 pre 属性 & 表尾的 next 属性均指向 L.nil。这样子在操作的时候有几个好处:

  1. 我们可以快速定位到表头(L.nil.next) & 表尾(L.nil.pre),不需要额外的指针
  2. 在操作链表的过程中无需判断指针为空的情形,整个链表构成了一个环,随便前转、后转。

代码实现

type Node struct {
	Key  int
	Val  int
	Pre  *Node
	Next *Node
}

type LinkedList struct {
	Nil *Node
}

func NewLinkList() LinkedList {
	nilNode := &Node{}
	// 空链表情形,L.nil.next & L.nil.pre 均指向 L.nil
	nilNode.Pre = nilNode
	nilNode.Next = nilNode

	l := LinkedList{
		Nil: nilNode,
	}

	return l
}

func (l LinkedList) DelNode(n *Node) {
        // 代码更加清晰、简洁,无需关心空指针
	n.Pre.Next = n.Next
	n.Next.Pre = n.Pre
}

func (l LinkedList) AddNode(n *Node) {
        // 和头部元素连接
	n.Next = l.Nil.Next
	l.Nil.Next.Pre = n
	
	// 和 Nil 元素连接
	l.Nil.Next = n
	n.Pre = l.Nil
}

func (l LinkedList) Search(key int) *Node {
	var n *Node = l.Nil.Next

	for n != nil && n != l.Nil {
		if n.Key == key {
			break
		}

		n = n.Next
	}

	return n
}

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.