labuladong算法小结

2021/6/30 22:50:37

本文主要是介绍labuladong算法小结,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

1. 数据结构的存储方式

2. 数据结构基本操作——遍历+访问

线性

​ for/while

void traverse(int[] arr) {			
    for (int i = 0; i < arr.length; i++) { 
        // 迭代访问 arr[i] 
    } 
} 

非线性

​ 递归

链表遍历框架

兼具迭代和递归结构:

class ListNode{
		int val;
		ListNode next;	
}

void traverse(ListNode head){
		for(ListNode p=head; p!=null; p=p.next;){
			//迭代访问 p.val
		}
}

void traverse(ListNode head){
		//递归访问
		traverse(head.next);
}

二叉树遍历框架

典型非线性递归遍历结构:

class TreeNode{
    int val;
    TreeNode left, right;
}

void traverse(TreeNode root){
    // 前序遍历
    traverse(root.left);
    //中序遍历
    traverse(root.right);
    //后序遍历
}

N 叉树遍历框架

class TreeNode{
    int val;
    TreeNode[] children;
}

void traverse(TreeNode root){
    for(TreeNode child : root.children){
				traverse(child)
    }	
}

N叉树遍历可以拓展为图的遍历。
用 boolean 数组visited 标记访问过的顶点。

3. 算法刷题指南

数据结构是工具,算法是通过合适的工具解决特定问题的方法。
要了解常用数据结构的特性和缺陷。

先刷二叉树。
因为二叉树最容易培养框架思维,大部分算法技巧,本质都是树的遍历问题。

所有二叉树问题都用这个框架

void traverse(TreeNode root) { 
    // 前序遍历 
    traverse(root.left) 
    // 中序遍历 
    traverse(root.right) 
    // 后序遍历 
} 

LeetCode 124 题,难度 Hard,让你求二叉树中最大路径和,主要代码如下:

  • 本质就是后序遍历
class Solution {
    int ans = Integer.MIN_VALUE;
    public int maxPathSum(TreeNode root) {
        oneSideMax(root);
        return ans;
    }

    private int oneSideMax(TreeNode root) {
        if (root == null) {
            return 0;
        }
        int left = Math.max(oneSideMax(root.left), 0);
        int right = Math.max(oneSideMax(root.right), 0);
        ans = Math.max(root.val + left + right, ans); //在这里获得路径和

        return root.val + Math.max(left, right); //返回最大边即可
    }
}

LeetCode 105 题,重建二叉树

  • 不要看这个函数的参数很多,只是为了控制数组索引而已,本质上该算法也就是一个前序遍历。
TreeNode buildTree(int[] preorder, int preStart, int preEnd, 
                   int[] inorder, int inStart, int inEnd, 
                   Map<Integer, Integer> inMap) {
    if(preStart > preEnd || inStart > inEnd) {
      return null;
    }
		TreeNode root = new TreeNode(preorder[preStart]); 
  	int inRoot = inMap.get(root.val);
  	int numsLeft = inRoot - inStart; 

		root.left = buildTree(preorder, preStart + 1, preStart + numsLeft ,
                          inorder, inStart, inRoot + 1, inMap)
		root.right = buildTree(preorder, preStart + numsLeft + 1, preEnd, 
                           inorder, inRoot + 1, inEnd, inMap);
    return root;
}

[没看懂]
LeetCode 99 题,难度 Hard,恢复一棵 BST(二叉搜索树),主要代码如下:
https://leetcode-cn.com/problems/recover-binary-search-tree/

void traverse(TreeNode* node) { 
	if (!node) return; 
	traverse(node->left);
	if (node->val < prev->val) { 
        	s = (s == NULL) ? prev : s;
		t = node; 
	} 
	prev = node; 
	traverse(node->right); 
} 

–就是中序遍历,对于一个 BST 中序遍历意味着什么?【从小到大输出?】
注:对于 BST 树,左子树所有结点值 < 根节点 < 右子树所有值

先刷树

刷10道有点难受,结合框架做20道就有点自己的理解,刷完整个专题,再去做回溯、动态规划、分治专题,会发现只要涉及递归,都是树的问题。

动态规划详解说过凑零钱问题,暴力解法就是遍历一棵 N 叉树:

def coinChange(coins: List[int], amount: int):
	def dp(n):
		if n == 0: return 0 
		if n < 0: return -1 
		
		res = float('INF')
		for coin in coins:
			subproblem = dp(n - coin) 
			# 子问题无解,跳过
			
			if subproblem == -1: continue 
			res = min(res, 1 + subproblem) 
		return res if res != float('INF') else -1 
	return dp(amount) 

提取框架,看出核心思路

不过是一个 N 叉树的遍历问题而已

def dp(n): 
	for coin in coins: 
		dp(n - coin) 

很多动态规划问题就是在遍历一棵树,你如果对树的遍历操作烂熟于心,起码知道怎么把思路转化成代码,也知道如何提取别人解法的核心思路。

回溯算法就是个 N 叉树的前后序遍历问题,没有例外
比如 N 皇后问题吧,主要代码如下:

void backtrack(int[] nums, LinkedList<Integer> track) { 
	if (track.size() == nums.length) { 
		res.add(new LinkedList(track)); 
		return; 
	} 
for (int i = 0; i < nums.length; i++) { 
	if (track.contains(nums[i])) 
		continue; 
	track.add(nums[i]);	// 进入下一层决策树 
	backtrack(nums, track); 
	track.removeLast(); 
} 

/* 提取出 N 叉树遍历框架 */

void backtrack(int[] nums, LinkedList<Integer> track) { 
	for (int i = 0; i < nums.length; i++) { 
		backtrack(nums, track); 
} 

算法4——扫了一眼,好书,java 描述的,留着对照看,以后要过一遍
二分图——代替反向索引
间隔度数计算——BFS 广度优先搜索寻找最短路径问题
套汇问题——bellman-ford 算法,寻找负权重环

4. 动态规划框架

​ 动规问题一般形式就是求最值
​ 比如求最长递增子序列,最小编辑距离
​ 求解动态规划核心问题是穷举
​ 动规穷举特点——存在重叠子问题
​ 暴力穷举效率低下,需要备忘录或 DP table 优化穷举过程。【剪枝?】
​ 动规问题一定具备最优子结构,才能通过子问题的最值得到原问题最值。
​ 列出正确的「状态转移方程」才能正确地穷举

​ 【差解法】双 for 暴力循环时间 On^2
​ 【好解法】备忘录或 dp,时间 On

​ 动规三要素
​ 重叠子问题
​ 最优子结构
​ 状态转移方程

​ 思考框架:
​ 1.明确「状态」
​ 2.定义dp 数组/函数的含义
​ 3.明确「选择」
​ 4.明确 base case

​ 题目列表
​ 1.斐波那契数列——重叠子问题
​ 2.凑零钱——最优子结构
​ 3.求二叉树最大值
​ 4.编辑距离
​ 5.最长递增子列

斐波那契数列问题和凑零钱问题来详解动态规划的基本原理。
前者主要是让你明白什么是重叠子问题(斐波那契数列严格来说不是动态规划问题),
后者主要举集中于如何列出状态转移方程

但凡遇到需要递归的问题,最好都画出递归树,这对你分析算法的复 杂度,寻找算法低效的原因都有巨大帮助 。

1. 斐波那契数列暴力递归

int fib(int N) {
	if (N == 1 || N == 2) return 1; 
	return fib(N - 1) + fib(N - 2); 
} 

递归算法时间复杂度 = 子问题个数*解决一个子问题需要的时间
子问题个数=递归树中结点总数
二叉树结点总数是指数级别,子问题个数 O2^n
解决一个子问题时间,没有循环,只有 f(n-1)+f(n-2)一个加法操作,时间 O1
所以这个算法时间负责度 O2^n

2. 带备忘录的递归解法

​ 先去备忘录里查一下,有就直接返回,没有再递归解决

int fib(int N) {
	if (N < 1) return 0; //备忘录全初始化为 0 
	vector<int> memo(N + 1, 0); 
	//初始化最简情况
  return helper(memo, N); 
} 
int helper(vector<int>& memo, int n) {
	//base case
	if (n == 1 || n == 2) 
		return 1; 
	//已经计算过
  if(memo[n] != 0) 
		return memo[n]; 
	memo[n] = helper(memo, n - 1) + helper(memo, n - 2); 
	return memo[n]; 
} 

3. dp 数组的迭代解法

int fib(int N) {
	vector<int> dp(N + 1, 0);
	// base case
	dp[1] = dp[2] = 1;
	for (int i = 3; i <= N; i++)
		dp[i] = dp[i - 1] + dp[i - 2]; 
	return dp[N];
}

根据斐波那契数列 的状态转移方程,当前状态只和之前的两个状态有关,其实并不需要那么⻓ 的一个 DP table 来存储所有的状态,只要想办法存储之前的两个状态就行 了。所以,可以进一步优化,把空间复杂度降为 O(1):

int fib(int n) {
    if (n == 2 || n == 1)
        return 1;
    int prev = 1, curr = 1;
    for (int i = 3; i <= n; i++) {
        int sum = prev + curr;
        prev = curr;
        curr = sum;
	} 
    return curr;
}

斐波那契数列——重叠子问题
优化后,空间复杂度降到 O1
当前状态=前两个状态相加
华为机试第5题 《跳台阶1/3》 就是这样做的

322.凑零钱——如何列出动态转移方程——最优子结构
base case
状态转移方程
上面这两个应该是最优子结构了
https://leetcode-cn.com/problems/coin-change/solution/322-ling-qian-dui-huan-by-leetcode-solution/

public class Solution {
    public int coinChange(int[] coins, int amount) {
        int max = amount + 1;
        int[] dp = new int[amount + 1];
        Arrays.fill(dp, max);
        dp[0] = 0;
        for (int i = 1; i <= amount; i++) {
            for (int j = 0; j < coins.length; j++) {
                if (coins[j] <= i) {
                    dp[i] = Math.min(dp[i], dp[i - coins[j]] + 1);
                }
            }
        }
        return dp[amount] > amount ? -1 : dp[amount];
    }
}

转移方程应为:F(S)=F(S−C)+1

4. 自己总结

备忘录 = 记忆化搜索
自顶向下的递归,备忘录存储之前查找过的值,需要 On 的临时存储数组。
dp table = 动态规划
自低向上,也需要一个 dp 数组,On 空间
这两个方法复杂度开支相同,本质一样。

求一棵二叉树的最大值,不难吧(简单起⻅,假设节点中的值都是非负数)

int maxVal(TreeNode root) {
  if (root == null)
		return -1;
  int left = maxVal(root.left);
  int right = maxVal(root.right); 
  return max(root.val, left, right); 
} 

最优子结构并不是动态规划独有的一种性质,能求最值的问题大部分都具有这个性质;但反过来,最优子结构性质作为动态规划问题的必要条件,一定是让你求最值的

72.编辑距离
https://leetcode-cn.com/problems/edit-distance/

遍历方向
主要就是看 base case 和最终结果的存储 位置
1、遍历的过程中,所需的状态必须是已经计算出来的。
2、遍历的终点必须是存储结果的那个位置。

5. 回溯算法

主要是做遍历,列出所有答案,或者只要一个(获得就提前返回)
不算是求最值。

题目列表
1.全排列
2.N 皇后

回溯算法核心框架

for 选择 in 选择列表: 
	#做选择
​	将该选择从选择列表移除 
​	路径.add(选择) 
​	backtrack(路径, 选择列表) 
	#撤销选择
​	路径.remove(选择) 
​	将该选择再加入选择列表 


回溯算法框架
result = [] 
def backtrack(路径, 选择列表): 
	if 满足结束条件: 
		result.add(路径) 
		return 
	for 选择 in 选择列表: 
		做选择 
		backtrack(路径, 选择列表) 
		撤销选择 

回溯算法代表问题
46.全排列问题https://leetcode-cn.com/problems/permutations/solution/quan-pai-lie-by-leetcode-solution-2/

为何要撤销选择?

树形结构的深度优先遍历,就是回溯算法
状态,每个结点表示了求解问题的不同阶段
深度优先遍历在回到上一层结点时需状态重置
状态重置,就是回溯算法中回溯的意思
状态变量
1.递归到了第几层depth,depth==nums.size 是递归终止条件
2.已经选择了那些数,放进列表 path 中(栈)
3.布尔数组 used,是否访问过

我的理解

回溯算法,就是树的深度优先遍历
从根节点,逐渐深入到一个完整的排列
排列数=总数字数,则排列完成,加入 res 列表

向左子树一路压栈,压到底层,访问一次。
然后开始弹栈,弹出后访问右子树,再一路压到底层,访问一次。
再弹栈。
每次压栈时将这个点记录到 used 或者 visited 列表,
下次往下压时就不压这个了。

(used数组,dong 用的是 stack.contains() 更方便,dong 结构更清晰,采用东的。
力扣对照着看)

N 皇后问题
https://leetcode-cn.com/problems/n-queens/
也可以用框架,就是在选择方法上要改一改
更进一步还有八皇后题目https://leetcode-cn.com/problems/eight-queens-lcci/

回溯算法和动态规划的暴力求解阶段类似,
只是动态规划有重叠子问题,回溯算法没有。

6. 二分查找搜索

704经典二分查找https://leetcode-cn.com/problems/binary-search/
34.二分查找左右边界https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array/

左边界二分搜索

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;
}

右侧边界

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; // 注意 
} 

关键点在于,nums[mid]==target 时,不是返回,而是调整搜索区间
如果搜左边界,就写成 high=mid-1;
如果搜右边界,就写成 low=mid+1

【这个方法太复杂,容易出错,我更喜欢二分法找到中心,然后向两遍扩散】

7. 滑动窗口算法

双指针解决子串、子数组问题

int left = 0, right = 0;
while (right < s.size()) {
    // 增大窗口 
    window.add(s[right]); 
    right++; 

    while (window needs shrink) { 
        // 缩小窗口 
        window.remove(s[left]); 
        left++; 
    } 
} 

这个滑动窗口也可以视情况使用 set 来做
比如不重复最小子串

/* 滑动窗口算法框架 */
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++; 
            // 进行窗口内数据的一系列更新 ... 
        } 
    } 
} 

现在开始套模板,只需要思考以下四个问题:
1、当移动 right 扩大窗口,即加入字符时,应该更新哪些数据?
2、什么条件下,窗口应该暂停扩大,开始移动 left 缩小窗口?
3、当移动 left 缩小窗口,即移出字符时,应该更新哪些数据?
4、我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?

76最小覆盖子串
https://leetcode-cn.com/problems/minimum-window-substring/

可以使用方括号访问键对应的值 map[key] 。需要注意的是,如果该 key 不存在,C++ 会自动创建这个 key,并把 map[key] 赋值为 0。

map[key]++ 相当于 Java 的 map.put(key, map.getOrDefault(key, 0) + 1) 。

疑问
// 判断左侧窗口是否要收缩

while (valid == need.size()) { ————如果 valid 是3,但是里面2个 c,一个 b并不等于 need{a,b,c} 怎么整?

if (window[c] == need[c])
valid++; ————明白了,判断 window 中 a 个数==need 中 a 个数才valid++

567字符串排列
https://leetcode-cn.com/problems/permutation-in-string/

438字母异位词
https://leetcode-cn.com/problems/find-all-anagrams-in-a-string/

3最长无重复子串
https://leetcode-cn.com/problems/longest-substring-without-repeating-characters/

8. 双指针

快慢指针解决链表中的问题,比如典型的判定链表中是否包含环;
左右指针解决数组(或者字符串)中的问题,比如二分查找。

快慢指针
一般都初始化指向链表的头结点 head,前进时快指针 fast 在前, 慢指针 slow 在后
1.是否有环
2.已知有环,判断环的起点(脑筋急转弯 k-m)
3.链表中点(快慢指针,一个一步,一个两步)
链表归并
4.链表倒数第 k 个元素

ListNode slow, fast; 
slow = fast = head; 
while (k-- > 0) 
	fast = fast.next; 
while (fast != null) { 
	slow = slow.next; 
	fast = fast.next; 
}
return slow;

左右指针
在数组中实际是指两个索引值,一般初始化为 left = 0, right = nums.length - 1
1.二分查找
2.两数之和
167两数之和:输入有序数组
https://leetcode-cn.com/problems/two-sum-ii-input-array-is-sorted/
3.盛水最多的容器
4.反转数组,只需要O1空间复杂度
5.滑动窗口是左右指针最高级玩法

9. BFS 算法

DFS 算法就是回溯算法

BFS 的核心思想应该不难理解的,就是把一些问题抽象成图,从一个点开始,向四周开始扩散。
一般来说,我们写 BFS 算法都是用「队列」这种数 据结构,每次将一个节点周围的所有节点加入队列。
BFS 找到的路径一定是最短的,但代价 就是空间复杂度比 DFS 大很多。

DFS 不能找最短路径吗?
其实也是可以的,但是时间复杂度相对高很多。
你想啊,DFS 实际上是靠递归的堆栈记录走过的路径,你要找到最短路径,肯定得把二叉树中所有树杈都探索完才能对比出最短的路径有多⻓对不对?
而 BFS 借助队列做到一次一步「⻬头并进」,是可以在不遍历完整棵树的条件下找到最短距离的。

BFS 可以找到最短距离,但是空间复杂度高,而 DFS 的空间复杂度较低
BFS 还是有代价的,一般来说在找最短路径的时候使用 BFS, 其他时候还是 DFS 使用得多一些(主要是递归代码好写)

BFS 常见场景:问题的本质就 是让你在一幅「图」中找到从起点 start 到终点 target 的最近距离
1.走迷宫,有的格子是围墙不能走,从起点到终点的最短距离是多少?如果这个迷宫带「传送门」可以瞬间传送呢?
2.两个单词,要求你通过某些替换,把其中一个变成另一个,每次只能替换一个字符,最少要替换几次?
3.连连看游戏,两个方块消除的条件不仅仅是图案相同,还得保证两个方块之间的最短连线不能多于两个拐点。你玩连连看,点击两个坐标,游戏是如何判断它俩的最短连线有几个拐点的?

// 计算从起点 start 到终点 target 的最近距离 
int BFS(Node start, Node target) {
Queue<Node> q; // 核心数据结构 
Set<Node> visited; // 避免走回头路

q.offer(start); // 将起点加入队列 
visited.add(start);
int step = 0; // 记录扩散的步数

while (q not empty) {
    int size = q.size();
    /* 将当前队列中的所有节点向四周扩散 */ 
    for (int i = 0; i < size; i++) {
        Node cur = q.poll();
        /* 划重点:这里判断是否到达终点 */
        if (cur is target)
              return step;
        /* 将 cur 的相邻节点加入队列 */ 
        for (Node x : cur.adj()) 
            if (x not in visited) { 
              q.offer(x); 
              visited.add(x); 
            } 
        }
        /* 划重点:更新步数在这里 */ 
        step++; 
    } 
}

队列 q 就不说了,BFS 的核心数据结构;
cur.adj() 泛指 cur 相邻的节点,比如说二维数组中, cur 上下左右四面的位置就是相邻节点;
visited 的主要作用是防止走回头路,大部分时候都是必须的,但是像一般的二叉树结构,没有子节点到父节点的指针,不会走回头路就不需要visited

111.二叉树最小深度
https://leetcode-cn.com/problems/minimum-depth-of-binary-tree/

752.解开密码锁最少次数
https://leetcode-cn.com/problems/open-the-lock/

10. 专题

(较难排后,对比剑指offer 看)

​ 动态规划,找状态转移方程几步流程——0308看了一些

53.最大子序和E https://leetcode-cn.com/problems/maximum-subarray/submissions/
突破惯性思考方式 -> 以子序列结束点为基准,遍历以某个节点为结束的所有子序列。
可解决如背包问题, 最大公共子串问题,包括本题
详解:https://leetcode-cn.com/problems/maximum-subarray/solution/xiang-xi-jie-du-dong-tai-gui-hua-de-shi-xian-yi-li/

动态规划思想是倒推思想,归纳法

二分专题
简单二分用 while(left<=right) 在循环里面就能找到答案
复杂二分用 while(left<right) 因为退出时left==right,减少很多麻烦,缺点是初次用起来容易在里面死循环
https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array/solution/da-jia-bu-yao-kan-labuladong-de-jie-fa-fei-chang-2/
二分查找多个变种总结
https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array/solution/yi-wen-dai-ni-gao-ding-er-fen-cha-zhao-j-ymwl/
这个人写的二分法统一总结很棒,避免很多麻烦
https://www.cnblogs.com/buaaliang/p/11372738.html



这篇关于labuladong算法小结的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!


扫一扫关注最新编程教程