核心思路

1.1框架思维
1.1.1数据结构的存储方式

数据结构的底层存储方式只有两种:数组(顺序存储)和链表(链式存储)

队列和栈,既可以用链表实现,也可以用数组实现。用数组实现则要考虑扩容问题,用链表实现没有这个问题,但需要更多的内存空间存储节点指针

图的表示方式,邻接表就是链表,邻接矩阵就是二维数组。用邻接矩阵判断连通性很迅速,并可以进行矩阵运算解决一些问题,但如果图比较稀疏则很耗费空间。邻接表比较节省时间,但操作效率不如邻接矩阵

哈希表就是通过哈希函数把键映射到一个大数组里,对于解决哈希冲突的方法常见的有拉链发和线性探查法。拉链法链表特性操作简单,但是需要额外空间存储指针。线性探查法需要数组特性,一边连续寻址,不需要指针的存储空间,但操作复杂些。

树用数组实现就是堆,因为堆是一个完全二叉树,用数组从存储不要结点指针,操作也比较简单。用链表实现,就是很常见的树,因为不一定是完全二叉树,所以不适合用数组存储。为此在这种链表树结构之上,衍生出各种巧妙地设计,比如二叉搜索树,AVL树,红黑树,区间树,B树。

Redis提供列表字符串集合等几种常用数据结构,但是对于每种数据结构,底层存储方式至少有两种给一边根据数据的实际情况使用合适的存储方式

数组紧凑连续存储可以随机访问通过索引快速找到对应元素,而且相对节约存储空间,但是因为连续存储,内存空间必须一次性分配足,所以数组如果要扩容,需要先重新分配一块更大的空间,再把数据全部复制过去,时间复杂度围为O(N),而且如果想在数组中间进行插入和删除,则必须移动位置,时间复杂度为O(N)

链表元素不连续,不存在扩容问题,插入删除操作方便为O(1),但是无法根据索引算出对应元素的地址,所以不能随机访问,而且由于每个元素必须存储指向前后元素位置的指针,因此会消耗相对更多的存储空间

 

1.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){
  //先序遍历head.val
  traverse(head.next);
  //后序遍历head.val
}

中间件的调用链其实就是一个递归遍历链表的过程

二叉树遍历框架,是典型的非线性递归遍历结构

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叉树的遍历可以扩展为图的遍历,因为图就是好几个N叉树的结合体,但是图可能出现环,用bool数组visited做标记

 

1.1.3二叉树刷题框架
void traverse(TreeNode root){
  //前序遍历
  traverse(root.left);
  //中序遍历
  traverse(root.right);
  //后续遍历

只要涉及递归的问题,基本上都是树的问题

很多动态规划的问题就是在遍历一棵树

回溯法就是一个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);
    }
  }
}
1.1.4总结

数据结构基本存储方式就是链式和顺序,基本操作就是增删改查,遍历方式是迭代和递归。

1.2动态规划解体套路框架

动态规划问题的一般形式就是求最值,比如求最长递增子序列,最小编辑距离等

求解动态规划的核心问题是穷举法

存在重叠子问题,如果暴力枚举效率极低,需要方法优化穷举

动态规划问题一定会具备最优子结构

列出状态转移方程

思考点:

①问题最简单状态

②问题有多少状态

③每个状态可以做出什么选择使得状态改变

④如何定义dp数组/函数的含义来表现状态和选择

框架

初始化base
dp[0][0][……]=base case
状态转移
for(状态1 in 状态1的所有取值)
  for(状态2 in 状态2的所有取值)
    for……
      dp[状态1][状态2][……]=求最值(选择1,选择2……)
1.2.1斐波那契数列
①.暴力递归
int fib(int N){
  if(N==0){
    return 0;
  }
  if(N==1||N==2){
    return 1;
  }
  return fib(N-1)+fib(N-2);
}

遇到要递归的问题,多画递归树

递归算法的时间复杂度:就是用子问题个数乘以解决一个子问题需要的时间

②.带备忘录的递归解法

一般使用一个数组或者哈希表充当备忘录

int fib(int N){
  if(N==0){
    return 0;
  }
  vector<int> memo(N+1,0);
  return helper(memo,N);
}
int helper(vector<int>& memo,int n){
  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];
}

递归算法的时间复杂度,就是用子问题个数乘以解决一个子问题需要的时间

③.dp数组的迭代解法
int fib(int N){
  if(N==0)
    return 0;
  if(N==1||N==2)
    return 1;
  vector<int> dp(N+1,0);
  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==0){
    return 0;
  }
  if(n==1||n==2){
    return 1;
  }
  prev=1,curr=1;
  for(int i=3;i<=n;i++){
    int sum=prev+curr;
    prev=curr;
    curr=sum;
  }
  return curr;
}
1.2.2凑零钱问题

给你k种面值的硬币,面值为c1,c2,c3……ck,每种硬币的数量无线,再给一个总金额amount,为您最少需要几枚硬币凑出这个金额,如果不可能凑出,返回-1

函数名:int coinChange(int[] coins,int amount);

①暴力递归

这个问题是动态规划,具有最优子结构,子问题具有相互独立性

首先确定base case,目标金额amount为0时返回0

其次确定状态,也就是原问题和子问题中的变量,唯一的状态就是目标金额

之后确定选择,也就是导致状态产生变化的行为,每次选择一枚硬币,目标金额就少了,面值就是你的选择

最后明确dp函数,数组的定义,自顶向下揭发,状态只有一个,就是目标金额

dp(n):输入一个目标金额n,返回凑出目标金额n的最少硬币数量

int coinChange(int[] coins,int amount){
  int dp(int n){
    int res=0;
    for(int coin=0;coin<=coins;coin++){
      res=min(res,1+dp(n-coin))
    }
    return res;
  }
  return dp(amount);
}

加上base case即可得到最终答案,目标金额为0时,需要数量为0的硬币,目标金额小于0返回-1

int coinChange(int[] coins,int amount){
  int dp(int n){
    if(n==0)
      return 0;
    else if(n<0)
      return -1;
    res=INT_MAX
    for(int coin=0;coin<=coins;coin++){
      int subproblem=-1;
      subproblem=dp(n-coin);
      if(subproblem==-1)
        continue;
      int res;
      res=min(res,1+subproblem);
    }
    if(res>0){
      return res;
    }
    else {
      return 0;
    }
  }
  return dp(amount);
}

递归算法的时间复杂度=子问题总数时间复杂度*每个子问题的时间复杂度

②dp数组的迭代解法

我们可以自底向上使用DP table来消除重叠子问题

dp数组的定义:当目标金额为i时,至少需要dp[i]枚硬币凑出

int coinChange(vector<int>& coins,int amount){
  //数组大小为amount+1,初始值为amount+1
  vector<int> dp(amount+1,amount+1);
  //base case
  dp[0]=0;
  //外层for循环遍历所有状态的所有取值
  for(int i=0;i<dp.size();i++){
    //内层for循环求所有选择的最小值
    for(int coin:coins){
      //子问题无解跳过
      if(i-coin<0){
        continue;
      }
      dp[i]=min(dp[i],1+dp[i-coins]);
    }
  }
  return (dp[amount]==amount+1)?-1:dp[amount];
}
 
1.3回溯算法解题套路框架

这个算法主要是琼剧,简单直接

解决一个会说问题实际上就是一个解决决策树遍历过程

思考问题:

①路径:已经做出的选择

②选择列表:当前可以做出的选择

③结束条件:到达决策树底层,无法再做选择的条件

框架

result=[];
int backtrack(路径,选择列表){
  if(满足结束条件){
    result.add(路径)
    return
  }
  for(选择 in 选择列表){
    做选择
    backtrack(路径,选择列表)
    撤销选择
  }
}

核心就是for循环里面的递归,在递归调用之前做选择,在递归调用之后撤销选择

 

1.3.1全排列问题

backtrack函数就像一个指针,在这棵树上遍历,同事要正确维护每个节点的属性,每当走到属的底层,路径就是一个全排列

多叉树遍历的框架

void traverse(TreeNode root){
  for(TreeNode child:root.children){
    //前序遍历的操作
    traverse(child);
    //后序遍历需要的操作
  }
}

前序遍历的代码在进入某一个节点之前的那个时间点执行,后序遍历的代码在离开某个节点之后的那个时间点执行

for 选择 in 选择列表:
  #做选择
  将该选择从选择列表中移除
  路径.add(选择)
  backtrack(路径,选择列表)
  #撤销选择
  路径.remove(选择)
  将该选择恢复到选择列表

我们只要在递归前做出选择,在递归后撤销刚才的选择,就能正确维护每个系欸但的选择列表和路径

List<List<Integer>> res=new LinkedList<>();
List<List<Integer>> premute(int[] nums){
  //记录路径
  LinkedList<Integer> track=new LinkedList<>();
  backtrack(nums,track);
  return res;
}


/**路径:记录在track中
选择列表:在nums中不存在于track的那些元素
结束条件:nums中的元素全都在track中出现**/
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();
  }
}

回溯算法就是纯暴力穷举,复杂度高

 

1.3.2N皇后问题

棋盘中皇后可以攻击同一行同一列,或者左上左下右上右下四个方向的任意单位。现在给你一公分N*N的期盼,让你放置N个皇后,使得他们不能互相攻击,返回所有合法结果

vector<vector<string>> res;
/*输入棋盘边长n,返回所有合法的放置方法 */
vector<vector<string>> solveNQueens(int n){
  //'.'表示空,'Q'表示皇后,初始化空棋盘
  vector<string> board(n,string(n,'''''.'''''));
  backtrack(board,0);
  return res;
}


/**路径:board中小于row的那些行都已经成功放置了皇后
选择列表:第row行的所有列都是放置皇后的选择
结束条件:row超过board的最后一行,说明棋盘放满了**/
void backtrack(vector<string>& board, int row){
  if(row==board.size()){
    res.push_back(board);
    return;
  }
  int n = board[row].size();
  for(int col=0;col<n;col++){
    //排除不满足选择
    if(!isValid(board,row,col))
      continue;
    //做选择
    board[row][col]='Q';
    //进入下一个决策
    backtrack(board,row+1);
    //撤销选择
    board[row][col]='.';
  }
}

这部分主要代码其实和全排列二差不多,isValid函数的实现:

/*是否可以在board[row][col]放置皇后*/
bool isValid(vector<string>& board,int row,int col){
  int n=board.size();
  //检查列中是否有皇后相互冲突
  for(int i=0;i<row;i++){
    if(board[i][col]=='Q')
      return false;
  }
  //检查右上方是否有皇后互相冲突
  for(int i=row-1;j=col+1;i>=0&&j<n;i--,j++){
    if(board[i][j]=='Q')
      return false;
  }
  //检查左上方是否有皇后冲突
  for(int i=row-1,j=col-1;i>=0&&j>=0;i--,j--){
    if(board[i][j]=='Q')
      return false;
  }
  return true;
}

有时候只想要一个答案

//函数找到一个答案后就返回true
bool backtrack(vector<string>& board,int row){
  if(row==board.size()){
    res.push_back(board);
    return true;
  }
  int n=board[row].size();
  for(int cpl=0;col<n;col++){
    if(!isValid(board,row,col))
      continue;
    //做选择
    board[row][col]='Q';
    //进入下一行决策
    if(backtrack(board,row+1))
      return true;
    //撤销选择
    board[row][col]='.';
  }
  return false;
}
1.3.3总结

回溯算法就是一个多叉树的遍历问题,关键就是在前序遍历和后序遍历的位置做一些操作

int backtrack(……){
  for(选择 in 选择列表){
    做选择
    backtrack(……);
    撤销选择
  }
}

写backtrack函数的时候,需要维护走过的路径和当前可以做的选择列表触发"结束条件"时,将"路径"计入结果集

原文地址:https://www.cnblogs.com/ak918xp/p/14221892.html