分类 数据结构和算法 下的文章

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/maximum-subarray/

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:

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

进阶:

如果你已经实现复杂度为 O(n) 的解法,尝试使用更为精妙的分治法求解。

分治法并不能算是最优解,但是可以锻炼对该算法的掌握情况。本题不考虑此算法。

二、题解

2.1 贪心算法

算法:

  1. 遍历数组,使用sum变量计算每一个节点时的最大和,用ans表示当前所有区间的最大子序和。
  2. 运行到第i个节点的时候,如果sum > 0,说明对当前节点有增益效果,sum继续加上当前节点。
  3. 如果sum <= 0,说明之前的序列对当前节点来说没有增益效果,之前的和可以舍弃。sum从当前节点开始重新计算。
  4. 然后比较ans和sum的大小,较大的赋值给sum。

图例:

图片来源于解答:画解算法:53. 最大子序和 , © 著作权归作者所有 。

以数组[-2, 3, -1, 1, -3]为例,初始化时设置sum为0,ans为第一个元素的值:

访问第一个元素-2,当前sum为0,更新sum为当前节点的值-2,sum和ans对比ans还是-2:

image2781361f9b1cc7d6.png

访问第二个元素3,因为sum = -2,如果加上当前节点,会使得连续和变成1(还不如不加,不加是3)。因此重新计算sum,设置sum为当前节点值3,继续往前计算:

imagefd03c8e73879b133.png

访问第三个元素-1,此时sum > 0,对-1有增益效果,sum加上-1等于2,ans不变:

image1f06514c3fa63f36.png

第四个元素:

image8742e9aae039ff80.png

到第五个元素时,sum = 3,加上-3之后等于0,ans依旧等于3:

imagedaf7eae811ef6e59.png

然后到此结束,最大连续和是3。

代码:

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int ans, sum, i;
        ans = nums.size() > 0 ? nums[0] : 0;

        for (i = 0, sum = 0; i < nums.size(); i++) {
            if (sum <= 0) {
                sum = nums[i];
            } else {
                sum += nums[i];
            }

            ans = max(ans, sum);
        }
        return ans;
    }
};

image.png

复杂度分析:

  • 时间复杂度:O(n)
  • 空间复杂度:O(1)

2.2 动态规划

算法:

其实动态规划的思路和上面的贪心算法差不多,关键的结果是动态规划的状态方程:假设dp[i]是第i个节点时候的最大连续和,那怎么计算它的值呢?

其实还是和上面的贪心算法一样,只要dp[i - 1]加上当前节点的和大于当前节点,那么dp[i]就等于和值。否则dp[i]就应该设置为当前节点值,所以它的状态转移方程是:

dp[0] = nums[0];
dp[i] = max(dp[i - 1] + nums[i], nums[i])

代码:

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int i, sum;
        vector<int> dp(nums.size());

        if (nums.size() == 0) {
            return 0;
        }

        dp[0] = nums[0];
        sum = dp[0];

        for (i = 1; i < nums.size(); i++) {
            dp[i] = max(dp[i - 1] + nums[i], nums[i]);
            sum = max(dp[i], sum);
        }

        return sum;
    }
};

复杂度分析:

  • 时间复杂度:O(n)
  • 空间复杂度:O(n),整个状态中只用到了dp[i]和dp[i - 1],优化成O(1)。

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/diameter-of-binary-tree

著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

一、题目描述

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

示例 :

给定二叉树

          1
         / \
        2   3
       / \     
      4   5    

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

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

二、题解

本题最重要的一个点是要理清题意,求的是最大直径,是树内任意两点间的距离的最大值,不是求根节点到任意节点间距离的最大值(即深度)。

想法:

任意一条路径可以被写成两个箭头(不同方向),每个箭头代表一条从某些点向下遍历到孩子节点的路径。

假设我们知道对于每个节点最长箭头距离分别为L,R,那么最优路径经过L + R + 1个节点。

算法:

按照常用方法计算一个节点的深度:max(depth of node.left, depth of node.right) + 1。在计算的同时,经过这个节点的路径长度为1 + (depth of node.left) + (depth of node.right)。搜索每个节点并记录这些路径经过的点数最大值,期望长度是结果-1

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    int diameterOfBinaryTree(TreeNode* root) {
        unsigned int diameter = 1;
        depth(root, diameter);
        return diameter - 1;
    }
private:
    int depth(TreeNode *node, unsigned int &diameter) {
        unsigned int lDepth, rDepth;
        if (node == NULL) {
            return 0;
        }
        lDepth = depth(node->left, diameter);
        rDepth = depth(node->right, diameter);

        diameter = max(diameter, lDepth + rDepth + 1);

        return max(lDepth, rDepth) + 1;
    }
};

复杂度分析:

  • 时间复杂度:O(N),每个节点只访问一次。
  • 空间复杂度:O(N),深度优先搜索的栈开销。

2020-03-10添加

为了完成leetcode每日1题打卡计划,使用golang重新提交了一次。

计算深度和直径的方式和上面略有不同,原理还是一样的:

func depth(root *TreeNode, diameter *int) int {
    if root == nil {
        return 0
    }

    // 左子节点的深度
    lDepth := depth(root.Left, diameter) + 1
    // 右子节点的深度
    rDepth := depth(root.Right, diameter) + 1

    // 当前节点的直径
    curDiameter := lDepth + rDepth - 1
    // 更新最大的直径
    if *diameter < curDiameter {
        *diameter = curDiameter
    }

    // 返回最大深度
    if lDepth < rDepth {
        return rDepth
    } else {
        return lDepth
    }
}

func diameterOfBinaryTree(root *TreeNode) int {
    var diameter int
    diameter = 0
    depth(root, &diameter)
    return diameter
}

作者:LeetCode

链接:112. 路径总和

来源:力扣(LeetCode)

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

一、题目描述

给定一个二叉树和一个目标和,判断该树中是否存在根节点到叶子节点的路径,这条路径上所有节点值相加等于目标和。

说明:叶子节点是指没有子节点的节点。

示例:

给定如下二叉树,以及目标和sum = 22

              5
             / \
            4   8
           /   / \
          11  13  4
         /  \      \
        7    2      1

返回true, 因为存在目标和为 22 的根节点到叶子节点的路径5->4->11->2

二、题解

2.1 dfs深搜(递归)

算法:

利用递归深度优先搜索每个节点,每经过一个节点,sum值减去当前节点的值,继续搜索子节点。直到叶子节点的时候判断sum,为0返回true,否则返回false。

代码:

class Solution {
public:
    bool hasPathSum(TreeNode* root, int sum) {
        if (root == NULL) {
            return false;
        }
        sum -= root->val;

        // 搜索到叶子节点了
        if (root->left == NULL && root->right == NULL) {
            return sum == 0;
        }

        // 计算左右子节点
        return hasPathSum(root->left, sum) || hasPathSum(root->right, sum);
    }
};

imagef9574f9026222914.png

复杂度分析

  • 时间复杂度:我们访问每个节点一次,时间复杂度为O(N),其中N是节点个数。
  • 空间复杂度:最坏情况下,整棵树是非平衡的,例如每个节点都只有一个孩子,递归会调用N次(树的高度),因此栈的空间开销是O(N) 。但在最好情况下,树是完全平衡的,高度只有 log(N),因此在这种情况下空间复杂度只有O(log(N)) 。

2.2 bfs广搜(迭代)

算法:

我们可以用栈将递归转成迭代的形式,深度优先搜索在除了最坏情况下都比广度优先搜索更快。最坏情况是指满足目标和的 root->leaf 路径是最后被考虑的,这种情况下深度优先搜索和广度优先搜索代价是相通的。

使用迭代的思路是:利用队列,每次保存当前节点以及剩余的sum,如果是叶子节点并且剩余的sum为0则返回true。否则,把节点的左右子节点分别压入队列,更新sum值。

所以我们从包含根节点的栈开始模拟,剩余目标和为 sum - root.val,然后开始迭代。弹出当前元素,如果当前剩余目标和为 0 并且在叶子节点上返回 True;如果剩余和不为零并且还处在非叶子节点上,将当前节点的所有孩子以及对应的剩余和压入栈中。

struct NodeSum {
    TreeNode *node; // 当前节点
    int sum; // 剩余的和
};

class Solution {
public:
    bool hasPathSum(TreeNode* root, int sum) {
        queue<NodeSum *> q;
        NodeSum* p;

        TreeNode *node;
        int residualSum;

        if (root == NULL) {
            return false;
        }
        q.push(new NodeSum{ root, sum });

        while (q.empty() == false) {
            p = q.front();
            q.pop();

            node = p->node;
            // 计算剩余需要的sum
            residualSum = p->sum - node->val;
            delete p;
            
            // 存在左子节点,压入队列
            if (node->left) {
                q.push(new NodeSum{ node->left, residualSum });
            }
            // 存在右子节点,压入队列
            if (node->right) {
                q.push(new NodeSum{ node->right, residualSum });
            }
            
            // 叶子节点,sum为0
            if (node->left == NULL && node->right == NULL && residualSum == 0) {
                return true;
            }
        }
        return false;
    }
};

复杂度分析:

  • 时间复杂度:和递归方法相同是O(N)。
  • 空间复杂度:当树不平衡的最坏情况下是O(N) 。在最好情况(树是平衡的)下是 O(log(N))。

MTU: Maximum transmission unit,最大传输单元,IP报文段的最大大小。

MSS: Maximum segment size,最大的帧大小,是TCP数据段的最大大小。

其中MTU工作于数据链路层,取决于网络环境。而MSS只是TCP负载部分的大小,它受限于MTU。要注意的是MSS描述的只是TCP负载部分的长度,不包括头部。

计算公式:

$MSS = MTU - IPHeader - TCPHeader $

大部分情况下,MTU的值是1500,IP头部和TCP头部各占20字节,所以MSS的长度最大也就是1460字节。如果TCP负载长度超出这个值,IP数据报将被分段发送。

前几天面试时候遇到的问题:

给定一个十六进制字符串"AB",转换成十六进制的整数0xab输出。

临时接到的面试通知,赶场子过去一坐下就给个题目,说实话面试了一两个星期是第一次做这方面的面试题。没有思想准备,当时脑海里就闪过两个念头,一个是左移,一个是直接进制转换。

- 阅读剩余部分 -

相对于栈和链表等数据结构来说,树有着更复杂的结构。正如我们平常生活中看到的树一样,它有很多分支,而且分支上面还会有分支。

树的用途十分广泛,最常见的树是二叉树,衍生了很多类型的树,红黑树,搜索树等等,被用来查找效率十分高,一个最典型的应用就是mysql中的索引。

树是由很多个节点构成,要实现一个树最最主要的就是实现树的节点。

- 阅读剩余部分 -

一、链表的遍历

链表的遍历算是十分简单了,从头到尾获取next指针的值,如果next不为0,一直打印。

// 遍历链表,结果保存在vector中返回
template <typename T>
std::vector<T> CMyList<T>::traversal() const {
    vector<T> v;
    CListNode<T> *p = root.getNext();
    while (p)
    {
        v.push_back(p->getData());
        p = p->getNext();
    }

    return v;
}

- 阅读剩余部分 -

一、单向链表

1.1 单向链表

链表是一种线性结构,通过一个链把所有的节点都链起来,因此叫做链表。它和数组最大的不同是:数组的内存是连续的,而链表不是。数组支持随机读写,但是插入和删除麻烦,链表不支持随机读写,但是插入和删除很方便。在更多场景下,链表用途更广。

一个链表的图形示例如下,每个链表节点都包含了一个数据域和指向下一个节点指针:

链表是由一系列的节点构成,从面向对象角度来说,链表和节点是两个完全不同的东西。节点是链表中的实体,而链表只是节点的抽象,它包含若干个组成链表的节点。因此,链表和节点这两个对象应该区别开来。

一个基本的单向链表支持的操作:

  • 添加节点
  • 删除节点
  • 查找节点

1.2 节点定义

一个节点的结构至少应该包含必要的数据域和next指针域,next只用用于指向下一个节点的地址。

以下是一个最简单的链表节点示例:

链表节点的C++定义:

template<typename T>
class list_node {
    // 友元类,方便链表类读取节点元素数据
    friend class singly_linklist<T>;
private:
    T data; // 数据域
    list_node<T> *next; // 下一个节点指针
};

1.3 链表定义

链表是由一个个的节点构成,在它的内部只要保存链表头节点的地址,就能找到链表中所有节点地址。

因此一个链表中,只要包含头结点的地址就行了。然后根据其他的扩展,我们可能还需要用到:

  • 长度字段:O(1)的时间复杂度返回链表长度。
  • 尾结点指针:保存末尾节点的指针,尾插时O(1)的时间复杂度即可定位到尾结点。

链表的结构示意图:

链表的C++定义:

template<typename T>
class singly_linklist {
public:
    singly_linklist() : len(0), head(nullptr), tail(nullptr) {}
private:
    size_t len; // 链表长度
    list_node<T> *head; // 头结点
    list_node<T> *tail; // 尾结点
};
所有代码使用C++完成,后面的函数无特殊说明都是类成员函数。

二、插入节点

2.1 插入逻辑

以下图为例,不考虑首尾节点状态以及插入位置,一个节点被插入的逻辑是:

  1. 设置新增节点node2的next指向为node3
  2. 修改原节点node1的next指向新节点node2
  3. 插入完成

在链表类中,定义一个私有的添加节点函数add_node来完成这个插入操作:

/*
 * 添加节点操作,新添加的节点需确保不是空值
 * @new_node 新节点,不能为空
 * @prev 前驱节点,可能为空
 * @next 后继节点,可能为空
 */
void add_node(list_node<T> *new_node, list_node<T> *prev, list_node<T> *next) {
    if (new_node == nullptr)
        return;
    
    len++; // 长度加1
    new_node->next = next;
    if (prev) {
        prev->next = new_node;
    }
}

2.2 头插法

头插法的意思是把节点插入到链表头部,这种情况下除了插入节点到链表中,还要注意的是修改链表的head节点指针。

头插法函数实现:

/*
 * 插入数据到链表开头
 * @node 待插入的数据节点
 */
singly_linklist &push_front(list_node<T> *node) {
    if (node == nullptr)
        return *this;

    // 添加节点到头部
    add_node(node, nullptr, head);
    // 修改首尾节点指针
    head = node;
    if (tail == nullptr)
        tail = node;

    return *this;
}

2.3 尾插法

尾插法和头插法相对,尾插法的意思是把节点插入到链表尾部,因此,插入后也要更新尾结点指针。

尾插法代码实现:

/*
 * 插入元素到末尾
 * @node 待插入的数据节点
 */
singly_linklist &push_front(list_node<T> *node) {
    if (node == nullptr)
        return *this;

    // 添加节点到头部
    add_node(node, nullptr, head);
    // 修改首尾节点指针
    head = node;
    if (tail == nullptr)
        tail = node;

    return *this;
}

三、查找元素

3.1 查找指定值的节点

/*
 * 在链表中查找指定值的节点
 * @data 待查找节点
 * @return 找到返回对应的节点,否则返回nullptr
 */
list_node<T> *find(const T &data) {
    list_node<T> *p = head;
    // 遍历链表
    while (p) {
        // 找到了
        if(p->data == data)
            return p;
        // 没找到,继续找下一个
        p = p->next;
    }
    return p;
}

3.2 查找指定节点的前节点

在增加或删除节点之前,都要修改前节点的指针指向,因此,需要实现一个查找指定节点的头结点函数。

/*
 * 查找某个节点的前一个节点
 * @node 当前节点
 * @return 如果存在上一个节点,返回上一个节点的地址,否则返回nullptr
 */
list_node<T> *find_prev(list_node<T> *node) {
    list_node<T> *p = head;
    if (node == nullptr)
        return nullptr;

    while (p != nullptr) {
        if (p->next == node) {
            return p;
        }
        p = p->next;
    }
    return p;
}

四、删除元素

4.1 删除逻辑

以下图为例,删除节点2的操作是:

  1. 修改节点1的next指向node3
  2. 删除node2的next指向,删除完成

核心的删除代码:

/*
 * 删除节点,待删除的节点请确保不是空值
 * @del_node 待删除节点,不为空
 * @prev 前驱节点,可能为空
 * @next 后继节点,可能为空
 */
void remove_node(list_node<T> *del_node, list_node<T> *prev, list_node<T> *next) {
    if (del_node == nullptr)
        return;

    len--; // 链表长度减1
    del_node->next = nullptr; // 被删除节点的next指针置空

    if (prev) { // prev可能为空
        prev->next = next;
    }
}

4.2 删除指定节点

删除节点的一种操作是找到前节点指针,同时要注意的是,删除节点可能导致首尾指针失效,要注意更新首尾指针。

/*
 * 删除节点
 * @node 待删除节点
 */
singly_linklist<T> &remove(list_node<T> *node) {
    list_node<T> *prev;
    if (node == nullptr)
        return *this;

    // 找到前节点
    prev = find_prev(node);

    // 修改首尾节点指向
    if (head == node)
        head = node->next;
    if (tail == node)
        tail = prev;

    // 删除节点
    remove_node(node, prev, node->next);
    delete node;

    return *this;
}

4.3 删除首尾元素

有了上面的删除指定节点函数之后,删除首尾元素的操作就变得非常简单了:

// 弹出首元素
singly_linklist &pop_front() {
    return remove(head);
}
// 弹出尾元素
singly_linklist &pop_back() {
    return remove(tail);
}

五、单元测试

5.1 头插法测试

TEST(singly_linklist, push_front) {
    int i;
    singly_linklist<int> list;
    const list_node<int> *p;
    vector<int> v{1, 2, 3};

    list.push_front(3).push_front(2).push_front(1);
    EXPECT_EQ(3, list.get_len());

    p = list.get_head();
    for (i = 0; i < v.size(); i++) {
        EXPECT_EQ(p->get_data(), v[i]);
        p = p->get_next();
    }
    EXPECT_EQ(p, nullptr);
}

5.2 尾插法测试

TEST(singly_linklist, push_back) {
    int i;
    singly_linklist<int> list;
    const list_node<int> *p;
    vector<int> v{1, 2, 3};

    list.push_back(1).push_back(2).push_back(3);
    EXPECT_EQ(3, list.get_len());

    p = list.get_head();
    for (i = 0; i < v.size(); i++) {
        EXPECT_EQ(p->get_data(), v[i]);
        p = p->get_next();
    }
    EXPECT_EQ(p, nullptr);
}

5.3 查找前一个节点测试

TEST(singly_linklist, get_prev) {
    int i;
    singly_linklist<int> list;
    const list_node<int> *p;
    list_node<int> *node1, *node2, *node3;

    // 插入3个节点
    node1 = new list_node<int>(1);
    node2 = new list_node<int>(2);
    node3 = new list_node<int>(3);
    list.push_back(node1).push_back(node2).push_back(node3);
    EXPECT_EQ(3, list.get_len());

    p = list.find_prev(node1);
    EXPECT_EQ(p, nullptr);
    p = list.find_prev(node2);
    EXPECT_EQ(p, node1);
    p = list.find_prev(node3);
    EXPECT_EQ(p, node2);
}

5.4 删除节点测试

// 测试删除节点
TEST(singly_linklist, remove) {
    int i;
    singly_linklist<int> list;
    const list_node<int> *p;
    list_node<int> *node1, *node2, *node3;

    node1 = new list_node<int>(1);
    node2 = new list_node<int>(2);
    node3 = new list_node<int>(3);

    list.push_front(node1);
    list.push_back(node2);
    list.push_back(node3);

    // 删除中间节点
    list.remove(node2);
    EXPECT_EQ(node1, list.get_head());
    EXPECT_EQ(node3, list.get_tail());
    EXPECT_EQ(node1->get_next(), node3);

    // 删除头节点
    list.remove(node1);
    EXPECT_EQ(node3, list.get_head());
    EXPECT_EQ(node3, list.get_tail());
    EXPECT_EQ(node3->get_next(), nullptr);

    // 删除尾节点
    list.remove(node3);
    EXPECT_EQ( list.get_head(), nullptr);
    EXPECT_EQ( list.get_tail(), nullptr);
}

队列是一种先进先出的数据结构,因和平常生活中的排队流程一样因此被称为队列。操作逻辑和栈刚好相反。

常用操作:

  • enqueue: 元素入队
  • dequeue: 首元素出队
  • size: 返回队列中元素的个数
  • empty: 判断队列是否为空
  • front: 返回队首元素

它有两个指针分别指向队列开头和结尾,出队和入队的流程为:

- 阅读剩余部分 -