常用算法框架

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

二者区别:

数组:连续存储,可以随机访问,通过索引可以快速找到对应元素,而且相对节约存储时间。正因为连续存储,必须一次性分配内存空间,扩容需要重新分配更大空间,把数据复制过去,从中间插入和删除必须移动后面的数据

链表:元素不连续,靠指针指向下一个元素位置。知道某一个节点的前驱和后驱就可以对该指针删除或者插入新元素。由于不连续,无法通过索引找到对应元素,不能随机访问,每个元素保存前后元素位置的指针,增加存储空间

2、数据结构的基本操作

数据结构基本操作无非就是遍历+访问,再具体一点就是增删改查

3、动态规划算法详解

1)动态规划一般形式就是求最值

2)动态规划三要素

  • 重叠子问题
  • 最优子结构
  • 状态转移方程

例子:斐波那契数列

1)暴力解法

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

递归树图:

如果想求出f(20),就需要计算出子问题f(19)和f(18)的值,然后要计算f(19)就需要先计算子问题f(18)和f(17),以此类推。最后遇到f(1)和f(2)的时候,结果已知,直接返回结果,递归式不再向下生成。

但是上面有一个问题,那就是会有重复计算的数据,比如f(18)和f(17)都计算了两遍,下面让我们对上面进行优化

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

 看一下迭代的示例图:

状态转移方程如下:

  4、回溯算法详解

解决一个回溯问题,实际就是一个决策树的遍历过程:

1)路径:已经做出的选择

2)选择列表:可以做的选择

3)结束条件:到达决策树底层,无法在做选择的条件

伪代码如下:

for (auto &selectItem : selectNum)
 {
    // 排除不合法的选择
    if (track.contains(selectItem))
         continue;
    // 做选择
    track.add(selectItem);
    // 进⼊下⼀层决策树
    backtrack(selectNum, track);
    // 取消选择
     track.removeLast();
  }

例子:全排列

比如给三个数[1,2,3],如何全排列?穷举先固定第一位为1,然后第二位可以是2,第三位可以是3;然后第二位变成3,第三位变成2……其实这就是回溯算法

回溯树:

 通过下图在理解一下棱镜、选择列表、结束条件:

示例代码:

List<List<Integer>> res = new LinkedList<>();
/* 主函数,s输入一组不重复的数字,返回它们的全排列 */
List<List<Integer>> permute(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();
    }
}

5、BFS算法详解

BFS核心思想:把问题抽象成图,从一个点开始,向四周开始扩散。一般我们用队列写BFS算法,每次将一个节点周围的所有节点加入队列。

例题:二叉树的最小高度

套用BFS框架,显然root是起点,终点是两个子节点都是null的节点:

if (cur.left == null && cur.right == null)
// 到达叶⼦节点

示例代码:

int minDepth(TreeNode root) {
    if (root == null) return 0;
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    // root 本来就是一层,depth 初始化为 1
    int depth = 1;
    while (!q.isEmpty()) {
        int sz = q.size();
        /* 将当前队列中的所有节点向四周扩散 */
        for (int i = 0; i < sz; i++) {
            TreeNode cur = q.poll();
            /* 判断是否到达终点 */
            if (cur.left == null && cur.right == null)
                return depth;
            /* 将 cur 的相邻节点加入队列 */
            if (cur.left != null)
                q.offer(cur.left);
            if (cur.right != null)
                q.offer(cur.right);
        }
        /* 这里增加步数 */
        depth++;
    }
    return depth;
}

6、双指针技巧框架

1)快慢指针常用算法

a.判断链表是否有环

示例代码:

bool hasCycle(ListNode *node)
{
    if (node == nullptr || node->next == nullptr)
    {
        return false;
    }
    ListNode *fastNode = node;
    ListNode *slowNode = node;
    while(fastNode != nullptr && fastNode->next != nullptr)
    {
        fastNode = fastNode->next->next;
        slowNode = slowNode->next;
        if (fastNode == slowNode)
        {
            return true;
        }
    }
    return false;
}

b.寻找单链表倒数第k个元素

示例代码:

ListNode * Test(ListNode *node,int n)
{
    if (node == nullptr || node->next == nullptr)
    {
        return node;
    }
    ListNode *fastNode = node;
    ListNode *slowNode = node;
    while(n-- > 0)
    {
        fastNode = fastNode->next;//指针先走n步
    }
    while(fastNode != nullptr && fastNode->next != nullptr)
    {
        //快慢指针一起走,fastNode和slowNode间距n,fastNode到链表尾部,slowNode指向倒数n的节点
        fastNode = fastNode->next;
        slowNode = slowNode->next;
    }
    return slowNode;
}

2)左右指针常用算法

a.二分查找

题目:给定一组有序数组,和一个目标值n,进行二分查找,找到返回数组下标,未找到返回-1

示例代码:

int  binarySearch(std::vector<int> &nums,int n)
{
    int left = 0;
    int right = nums.size()-1;
    while(left <= right)
    {
        int mid = left + (right - left)/2;
        if (nums.at(mid) == n)//如果数组中间值等于目标值,返回mid
        {
            return mid;
        }
        if (nums.at(mid) > n) //如果数组中间值大于目标值,右区间前移到中间值前一个位置
        {
            right = mid - 1;
        }
        if (nums.at(mid) < n)//如果数组中间值小于目标值,左区间后移到中间值后一个位置
        {
            left = mid + 1;
        }
    }
    return -1;
}

b.数组反转

示例代码:

void  reverse(std::vector<int> &nums)
{
    int left = 0;
    int right = nums.size()-1;
    while(left < right)
    {
        int nTemp = nums.at(left);
        nums[left] = nums[right];
        nums[right] = nums[left];
        left++;
        right--;
    }
}

7、二分查找框架详解

1)正常二分查找(如果有多个找到一个就返回)

示例代码:见上面例子代码

2)左侧边界的二分查找(如果有多个返回第一个)

思路:找到目标值不返回,而是收缩右边边界(注意越界)

如图:

示例代码:

int left_bound(std::vector<int> &nums, int target) {
    int left = 0, right = nums.size() - 1;
    // 搜索区间为 [left, right]
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            // 搜索区间变为 [mid+1, right]
            left = mid + 1;
        } else if (nums[mid] > target) {
            // 搜索区间变为 [left, mid-1]
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 收缩右侧边界
            right = mid - 1;
        }
    }
    // 检查出界情况
    if (left >= nums.size() || nums[left] != target)
        return -1;
    return left;
}

3)左侧边界的二分查找(如果有多个返回第一个)

思路:找到目标值不返回,而是收缩左边边界(注意越界)

如图:

示例代码: 

int right_bound(std::vector<int> &nums, int target) {
    int left = 0, right = nums.size() - 1;
    while (left <= right) {
        int mid = left + (right - left) / 2;
        if (nums[mid] < target) {
            left = mid + 1;
        } else if (nums[mid] > target) {
            right = mid - 1;
        } else if (nums[mid] == target) {
            // 别返回,锁定右侧边界
            left = mid + 1;
        }
    }
    // 最后要检查 right 越界的情况
    if (right < 0 || nums[right] != target)
        return -1;
    return right;
}

8、滑动窗口框架详解

适用:求解子串问题

滑动窗口算法的思路是这样

  • 我们在字符串S中使用双指针中的左右指针技巧,初始化left = right = 0把索引左闭右开区间[left, right)称为一个「窗口」
  • 我们先不断地增加right指针扩大窗口[left, right),直到窗口中的字符串符合要求(包含了T中的所有字符)。
  • 此时,我们停止增加right,转而不断增加left指针缩小窗口[left, right),直到窗口中的字符串不再符合要求(不包含T中的所有字符了)。同时,每次增加left,我们都要更新一轮结果。
  • 重复第 2 和第 3 步,直到right到达字符串S的尽头。

这个思路其实也不难,第 2 步相当于在寻找一个「可行解」,然后第 3 步在优化这个「可行解」,最终找到最优解,也就是最短的覆盖子串。左右指针轮流前进,窗口大小增增减减,窗口不断向右滑动,这就是「滑动窗口」这个名字的来历

代码框架:

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

我们通过例子了解一下滑动窗口使用

例子:

a.最小覆盖子串

下面画图理解一下,needswindow相当于计数器,分别记录T中字符出现次数和「窗口」中的相应字符的出现次数。

初始状态:

增加right,直到窗口[left, right)包含了T中所有字符:

 

 现在开始增加left,缩小窗口[left, right):

 直到窗口中的字符串不再符合要求,left不再继续移动:

之后重复上述过程,先移动right,再移动left…… 直到right指针到达字符串S的末端,算法结束

现在开始套模板,只需要思考以下四个问题

  • 当移动right扩大窗口,即加入字符时,应该更新哪些数据?
  • 什么条件下,窗口应该暂停扩大,开始移动left缩小窗口?
  • 当移动left缩小窗口,即移出字符时,应该更新哪些数据?
  • 我们要的结果应该在扩大窗口时还是缩小窗口时进行更新?

示例代码:

string minWindow(string s, string t) {
    unordered_map<char, int> need, window;
    for (char c : t) need[c]++;

    int left = 0, right = 0;
    int valid = 0;
    // 记录最小覆盖子串的起始索引及长度
    int start = 0, len = INT_MAX;
    while (right < s.size()) {
        // c 是将移入窗口的字符
        char c = s[right];
        // 右移窗口
        right++;
        // 进行窗口内数据的一系列更新
        if (need.count(c)) {
            window[c]++;
            if (window[c] == need[c])
                valid++;
        }

        // 判断左侧窗口是否要收缩
        while (valid == need.size()) {
            // 在这里更新最小覆盖子串
            if (right - left < len) {
                start = left;
                len = right - left;
            }
            // d 是将移出窗口的字符
            char d = s[left];
            // 左移窗口
            left++;
            // 进行窗口内数据的一系列更新
            if (need.count(d)) {
                if (window[d] == need[d])
                    valid--;
                window[d]--;
            }
        }
    }
    // 返回最小覆盖子串
    return len == INT_MAX ?
                "" : s.substr(start, len);
}

b.找所有字母异位词

示例代码:

vector<int> findAnagrams(string s, string t) {
    unordered_map<char, int> need, window;
    for (char c : t) need[c]++;

    int left = 0, right = 0;
    int valid = 0;
    vector<int> res; // 记录结果
    while (right < s.size()) {
        char c = s[right];
        right++;
        // 进行窗口内数据的一系列更新
        if (need.count(c)) {
            window[c]++;
            if (window[c] == need[c])
                valid++;
        }
        // 判断左侧窗口是否要收缩
        while (right - left >= t.size()) {
            // 当窗口符合条件时,把起始索引加入 res
            if (valid == need.size())
                res.push_back(left);
            char d = s[left];
            left++;
            // 进行窗口内数据的一系列更新
            if (need.count(d)) {
                if (window[d] == need[d])
                    valid--;
                window[d]--;
            }
        }
    }
    return res;
}

  • 3
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值