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

一、链表定义

链表在redis中的使用十分广泛,例如列表的底层实现之一就是链表,包括发布、订阅等等功能都是有用到链表的。redis中链表在adlist.hadlist.c中实现,只用了300+行代码,十分简单。

redis中的链表是一个双向不循环的链表,两个核心的数据结构是listNodelist,其中listNode的定义如下:

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

listNode是链表节点定义,链表节点和平常用到差别不大,由一个数据域和两个分别指向前后节点的指针组成。

list的定义为:

typedef struct list {
    listNode *head;
    listNode *tail;
    void *(*dup)(void *ptr);
    void (*free)(void *ptr);
    int (*match)(void *ptr, void *key);
    unsigned int len;
} list;

它和普通链表定义的不同点在于:除了包含首尾指针以及链表长度以外,链表的结构还包含了3个函数指针结构:

  • dup: 用于拷贝节点的函数。
  • free: 用于释放节点的函数。
  • match: 用于对比节点是否相等的函数。

链表的宏观结构:

二、链表操作

2.1 链表创建和释放

初始化的过程主要是创建链表对象,并初始化值:

list *listCreate(void)
{
    struct list *list;

    // 分配内存空间
    if ((list = zmalloc(sizeof(*list))) == NULL)
        return NULL;

    // 初始化成员
    list->head = list->tail = NULL;
    list->len = 0;
    list->dup = NULL;
    list->free = NULL;
    list->match = NULL;
    return list;
}

释放链表:

void listRelease(list *list)
{
    unsigned int len;
    listNode *current, *next;

    current = list->head;
    len = list->len;

    // 循环遍历链表
    while(len--) {
        next = current->next;
        // 如果有自定义的free函数,先调用函数释放节点内部数据
        if (list->free) list->free(current->value);
        // 释放节点
        zfree(current);
        current = next;
    }

    // 释放链表
    zfree(list);
}

2.2 插入节点

插入节点提供了三个函数,分别是从头部插入、从尾部插入以及从指定位置插入。

头插法:

list *listAddNodeHead(list *list, void *value)
{
    listNode *node;

    // 给新插入的节点分贝内存空间并赋值
    if ((node = zmalloc(sizeof(*node))) == NULL)
        return NULL;
    node->value = value;

    // 插入节点
    if (list->len == 0) {
        // 链表为空的时候要给首尾指针赋值
        list->head = list->tail = node;
        node->prev = node->next = NULL;
    } else {
        // 插入节点到链表
        node->prev = NULL;
        node->next = list->head;
        list->head->prev = node;
        list->head = node;
    }

    // 长度加1
    list->len++;

    return list;
}

尾插法,和头插法基本一致:

ist *listAddNodeTail(list *list, void *value)
{
    listNode *node;

    if ((node = zmalloc(sizeof(*node))) == NULL)
        return NULL;
    node->value = value;
    if (list->len == 0) {
        list->head = list->tail = node;
        node->prev = node->next = NULL;
    } else {
        node->prev = list->tail;
        node->next = NULL;
        list->tail->next = node;
        list->tail = node;
    }
    list->len++;
    return list;
}

插入节点到指定位置:

/*
 * 参数说明
 * @list 链表对象
 * @old_node 被插入的节点
 * @value 新插入节点的数据域
 * @after 是查到节点的后面还是前面
 * @return 插入成功后返回传入的链表对象,否则返回NULL
 */
list *listInsertNode(list *list, listNode *old_node, void *value, int after) {
    listNode *node;

    // 创建新节点并赋值
    if ((node = zmalloc(sizeof(*node))) == NULL)
        return NULL;
    node->value = value;

    // 插入节点
    if (after) {
        // 插入到old_node节点后
        node->prev = old_node;
        node->next = old_node->next;
        if (list->tail == old_node) {
            list->tail = node;
        }
    } else {
        // 插入到old_节点前
        node->next = old_node;
        node->prev = old_node->prev;
        if (list->head == old_node) {
            list->head = node;
        }
    }
    
    // 修改前一个节点的next指向
    if (node->prev != NULL) {
        node->prev->next = node;
    }
    // 修改后一个节点的prev指向
    if (node->next != NULL) {
        node->next->prev = node;
    }
    // 链表长度加1
    list->len++;

    return list;
}

2.3 删除节点

void listDelNode(list *list, listNode *node)
{
    if (node->prev) // 删除的不是头结点,修改前一个节点的next指针
        node->prev->next = node->next;
    else // 删除的是头结点,更新head指向
        list->head = node->next;

    if (node->next)
        node->next->prev = node->prev;
    else // 删除的是尾结点,更新next指向
        list->tail = node->prev;
    // 存在节点的free函数,调用函数释放节点内部资源
    if (list->free) list->free(node->value);
    zfree(node);
    list->len--;
}

三、链表遍历

redis在实现链表的时候,给链表的遍历单独创建了一套结构和方法。其中遍历的结构是listIter,它包含了一个listNode *的指针域和遍历方向direction

typedef struct listIter {
    listNode *next;
    int direction;
} listIter;

direction的作用是标记遍历方向是从头部往尾部遍历还是从尾部往头部遍历,获取一个listIter的方法:

listIter *listGetIterator(list *list, int direction)
{
    listIter *iter;

    if ((iter = zmalloc(sizeof(*iter))) == NULL)
        return NULL;

    if (direction == AL_START_HEAD)
        iter->next = list->head; // 从头往尾遍历,iter指向头结点
    else
        iter->next = list->tail; // 从尾往头遍历,iter指向尾结点
    iter->direction = direction;
    return iter;
}

配合listNode获取下一个节点的函数:

listNode *listNext(listIter *iter)
{
    // 保存当前指针
    listNode *current = iter->next;

    // 
    if (current != NULL) {
        if (iter->direction == AL_START_HEAD)
            iter->next = current->next; // 赋值下一个节点到iter
        else
            iter->next = current->prev; // 赋值前一个节点到iter
    }
    return current;
}

注意listNext函数只是获取当前iter指向的节点,并把iter指向下一个节点。为什么要把遍历操作单独抽离出来呢?

主要有以下几个目的:

  1. 外部遍历时不用重复编写遍历代码。前面已经说过,链表在redis很多场景都用到了,所有的结构共用一套遍历实现就可以了。
  2. 外部遍历的时候无需访问list的内部元素,充分做到了面向对象的理念。

四、其他

4.1 链表拷贝

list *listDup(list *orig)
{
    list *copy;
    listIter *iter;
    listNode *node;

    // 为新链表创建内存空间
    if ((copy = listCreate()) == NULL)
        return NULL;
    // 初始化
    copy->dup = orig->dup;
    copy->free = orig->free;
    copy->match = orig->match;
    // 遍历
    iter = listGetIterator(orig, AL_START_HEAD);
    while((node = listNext(iter)) != NULL) {
        void *value;

        if (copy->dup) {
            // 如果存在复制函数,使用自定的复制函数来复制每个节点
            value = copy->dup(node->value);
            if (value == NULL) {
                // 复制失败,释放掉前面已经拷贝成功的节点,避免内存泄漏
                listRelease(copy);
                listReleaseIterator(iter);
                return NULL;
            }
        } else
            value = node->value;

        // 新拷贝的节点插入到链表结尾
        if (listAddNodeTail(copy, value) == NULL) {
            listRelease(copy);
            listReleaseIterator(iter);
            return NULL;
        }
    }
    listReleaseIterator(iter);
    return copy;
}

4.2 链表查找

listNode *listSearchKey(list *list, void *key)
{
    listIter *iter;
    listNode *node;

    // 遍历链表
    iter = listGetIterator(list, AL_START_HEAD);
    while((node = listNext(iter)) != NULL) {
        if (list->match) {
            // 使用自定义的比较函数来比较每个节点和查找的key
            if (list->match(node->value, key)) {
                listReleaseIterator(iter);
                return node;
            }
        } else {
            // 使用默认的比较函数来比较节点和key
            if (key == node->value) {
                listReleaseIterator(iter);
                return node;
            }
        }
    }
    listReleaseIterator(iter);
    return NULL;
}

4.3 返回指定位置上的元素

传入索引,返回对应位置上的元素,支持从尾部索引:

listNode *listIndex(list *list, int index) {
    listNode *n;

    if (index < 0) {
        // index小于0,从尾部开始索引
        index = (-index)-1;
        n = list->tail;
        while(index-- && n) n = n->prev;
    } else {
        // index大于0,从头部开始索引
        n = list->head;
        while(index-- && n) n = n->next;
    }
    return n;
}

五、总结

收获很大,主要是两个地方感触很深:

  1. 链表的遍历
  2. 从尾部开始返回对应索引位置上面的元素

其实实现并不算难,只是实现的思路和想法很新颖,很值得学习!

一、LRU算法描述

LRU全称是Least Recently Used,最近最少使用的意思,是一种常用的内存置换算法。当内存不够的时候,要把部分内存写入到磁盘中,此时就要用到LRU算法来决定把那部分内存写入到磁盘。

当把内存数据写入到磁盘的时候,肯定是把不常用的内存置换到磁盘这样才是最优的。不然把常用的内存写入磁盘,然后又频繁从磁盘读出来,明显会降低系统性能。LRU的核心思想就是把最近最少使用的内存置换到磁盘中去。

一般来说,实现LRU算法都是使用哈希表+链表来实现,这样可以使得LRU操作的时间复杂度是O(1)。具体的过程描述为:

  1. 传入内存地址,通过哈希表判断内存是否已经存在于链表了。如果内存存在,把节点移到链表开头。
  2. 如果内存不存在于哈希表中,有两种情况:

    • 链表没满,把节点插入链表开头。
    • 链表满了,淘汰掉链表末尾节点,当前节点插入到链表开头。

哈希表中记录的是内存和链表节点的对应关系,这样当一个内存地址传入的时候,在O(1)的时间内就能确定该内存地址是否已经存在于缓存中了。如果没有哈希表,那么每次判断内存是否存在于缓存的时候都要遍历链表查询,需要时间复杂度为O(n)。

LRU图解

假设存在一个缓存容量为5的缓存区,当前已经缓存了1、4、8、9四块内存:

访问内存块3,因为缓存中不含有这块内存,要把3加到缓存中去:

访问内存块5,因为5不存在于缓存中,要把5插入到缓存。但是目前链表已经满了,不能容纳更多的节点,所以必须把最后的节点淘汰掉,也就是去掉9:

访问内存块1,1已经存在于缓存了,把它放到链表最前面来:

二、代码描述

2.1 lru对象设计

实现LRU需要用到双向链表+哈希表:

  • 哈希表保存了每个缓存节点的地址,可以直接使用STL中的map。
  • STL中也有双向链表,可以直接使用这个链表结构。

一个LRU缓存对象需要包含的方法:

  • get():从缓存获取一块内存
  • put():存放一块内存到缓存

LRU对象设计:

// 缓存的数据
typedef pair<int, int> cache_val;

class lru_cache {
private:
    // 链表
    list<cache_val> l;
    // 哈希表
    map<int, list<cache_val>::iterator> m;
    // 链表的容量和长度
    size_t cap, len;
public:
    // 构造函数
    lru_cache(size_t cap) : cap(cap), len(0) {}
}

2.2 get函数实现

get函数的实现:

int get(int key) {
    cache_val cache;
    map<int, list<cache_val>::iterator>::iterator it;

    it = m.find(key);

    // 没有找到,返回-1
    if (it == m.end())
        return -1;

    // 找到了,移到最前面去
    cache = *(it->second);
    // 先删除
    m.erase(key);
    l.erase(it->second);

    // 再插入
    l.push_front(cache);
    m.insert(pair<int, list<cache_val>::iterator>(key, l.begin()));
    return it->second->second;
}

2.3 put函数实现

put`函数的实现:

void put(const int &key, const int val) {
    cache_val cache;
    map<int, list<cache_val>::iterator>::iterator it;
    it = m.find(key);

    // 找到了缓存
    if (it != m.end()) {
        // 移到链表开头
        cache = *(it->second);
        l.erase(it->second);
        l.push_front(cache);
        // 重新修改哈希表的指向
        m.erase(key);
        m[key] = l.begin();
        return;
    }

    // 下面是没有找到缓存,要插入新的节点到链表和哈希表
    // 链表的长度已经超过容量了
    if (len >= cap) {
        // 哈希表移除节点
        m.erase(l.back().first);
        // 链表移除末尾节点
        l.pop_back();
    }

    cache = pair<int, int>(key, val);
    l.push_front(cache);
    m.insert(pair<int, list<cache_val>::iterator>(key, l.begin()));

    if (len < cap)
        len++;
}

三、单元测试

测试案例:

TEST(lru_cache, leetcode) {
    lru_cache cache = lru_cache(2);

    cache.put(1, 1);
    cache.put(2, 2);
    EXPECT_EQ(1, cache.get(1));       // 返回  1

    cache.put(3, 3);    // 该操作会使得密钥 2 作废
    EXPECT_EQ(-1, cache.get(2));

    cache.put(4, 4);    // 该操作会使得密钥 1 作废
    EXPECT_EQ(-1, cache.get(1));
    EXPECT_EQ(3, cache.get(3));
    EXPECT_EQ(4, cache.get(4));
}

本篇文章是基于前篇《数据结构之链表(一):单向链表》实现的,和单向链表重复的细节这里不再描述。

一、双向链表

双向链表和单向链表类似,唯一的区别是链表节点中除了有指向下一个节点的指针以外,还有一个指向上一个节点的指针。

节点的定义:

template<typename T>
class list_node {
public:
    friend class doubly_linkedlist<T>;
private:
    T data;
    list_node<T> *prev; // 指向前一个节点
    list_node<T> *next; // 指向后一个节点
};

链表的定义:

template<typename T>
class doubly_linkedlist {
public:
    doubly_linkedlist() : head(nullptr), tail(nullptr), len(0) {}
private:
    size_t len; // 链表长度
    list_node<T> *head; // 头节点
    list_node<T> *tail; // 尾结点
};

二、插入操作

双链表的插入操作和单链表基本没有差别,就只是多了一个修改prev节点的步骤。

以下是添加节点node2的过程:

操作主要有两步:

  1. 修改node2的prev和next指向,分别指向前后节点。
  2. 修改前节点node1的next指针指向node2,修改后节点node2的prev指针指向node2。

插入函数实现:

/*
 * 添加节点操作
 * @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++;
    new_node->next = next;
    new_node->prev = prev;

    if (prev) {
        prev->next = new_node;
    }

    if (next) {
        next->prev = new_node;
    }
}

实现了这个函数之后,单链表中所有的头插、尾插函数都不用修改了,直接调用这个函数就完成了。

其实后面的删除元素也是一样,前面所说的把添加和删除逻辑抽离出来的好处就在这里。

三、删除操作

删除node2节点的操作步骤:

过程描述:

  1. 删除node2的prev和next指针指向。
  2. 修改node1的next指针指向node3,修改node3的prev指针指向node1。

删除代码实现:

void remove_node(list_node<T> *del_node, list_node<T> *prev, list_node<T> *next) {
    len--;
    del_node->next = nullptr;
    del_node->prev = nullptr;

    if (prev) {
        prev->next = next;
    }

    if (next) {
        next->prev = prev;
    }
}

四、双向循环链表

双向循环链表是在双向链表的基础上增加了循环机制,即链表的尾结点要连接上首节点,链表的首节点要连接上尾结点。循环链表的示意图:

《算法导论》中实现双向循环链表的方法是引入一个哨兵节点,这个节点不包含任何数据信息,只作为首尾相连的用途:

实际上也就是一个NULL值,和双向链表没有很大区别。

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/super-egg-drop

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

一、题目描述

你将获得 K 个鸡蛋,并可以使用一栋从 1 到 N 共有 N 层楼的建筑。

每个蛋的功能都是一样的,如果一个蛋碎了,你就不能再把它掉下去。

你知道存在楼层 F ,满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破。

每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X 扔下(满足 1 <= X <= N)。

你的目标是确切地知道 F 的值是多少。无论 F 的初始值如何,你确定 F 的值的最小移动次数是多少?

示例 1:

  • 输入:K = 1, N = 2
  • 输出:2
  • 解释:鸡蛋从 1 楼掉落。如果它碎了,我们肯定知道 F = 0 。否则,鸡蛋从 2 楼掉落。如果它碎了,我们肯定知道 F = 1 。如果它没碎,那么我们肯定知道 F = 2 。因此,在最坏的情况下我们需要移动 2 次以确定 F 是多少。

示例 2:

  • 输入:K = 2, N = 6
  • 输出:3

示例 3:

  • 输入:K = 3, N = 14
  • 输出:4

提示:

  • 1 <= K <= 100
  • 1 <= N <= 10000

二、动态规划+递归

看到题目的第一眼就能想到得用动态规划来解题了,关键是如何来定义动态规划的状态和状态转移方程。

比较容易想到的是以dp(k, n)来表示k个鸡蛋在n层建筑时需要扔鸡蛋的最小次数,它的最优状态肯定是从前面的1-n层楼中取,谁的状态越优,就取谁的。也就是说,在那一层扔鸡蛋需要的次数最少,就是谁。那么:

$$dp(k,n)=min(dp(k,1), dp(k,2), ..., dp(k,n))$$

在第i层扔鸡蛋,有两种结果:

  1. 鸡蛋碎了,这个时候鸡蛋少了一个,楼层也少一个,此时它的最优解是dp(k-1,i-1) + 1。
  2. 鸡蛋没碎,这个时候鸡蛋没少,楼层少了一个,最优解是dp(k,n-i) + 1。

可以参考这张图片:

图片来源:labuladong

后面的dp[k][n]和dp(k, n)是一个意思。

为什么没碎之后是dp[k][n-i],因为没碎的话,鸡蛋还是k个,而楼层只有n-i了,所以只要找出dp[k][n-i]就可以了。

计算出来这两种情况后,要选择最坏情况下的场景,所以要取两者的最大值。总结出来状态转移方程就是:

$$\begin{equation} dp[k][i]= \begin{cases} 1 & k=1 \\ 0 & n=0\\ max(dp[k-1][i-1], dp[k][n-i]) & k>1,n>0 \end{cases}\end{equation}$$

归纳成C++代码:

int dp(int k, int n, vector<vector<int>> &v) {
    int i, f = INT_MAX, dpi;
    
    if (k == 1) return n;
    if (n == 0) return 0;

    // v数组默认全是-1
    if (v[k][n] != -1) {
        return v[k][n];
    }

    // 遍历所有楼层
    for (i = 1; i <= n; i++) {
        // 计算出第i层的最优解
        dpi = max(dp(k - 1, i - 1, v), dp(k, n - i, v)) + 1;
        // 计算所有楼层的最小值
        f = min(f, dpi);
    }

    v[k][n] = f;
    return f;
}

时间复杂度

计算所有的鸡蛋+楼层组合需要时间复杂度O(KN),每个楼层取最优值是O(N),总体的时间复杂度是:

$$O(K*N^2)$$

这个时间复杂度还是很大的,可以说迄今为止我还没见过这么高复杂度的动态规划。提交后果然超时了:

在本地测试这组数据发现需要执行2s+才能计算出来(CPU是I5-8400主频2.8GHz),所以提交肯定会超时:

三、动态规划+二分

超时之后不得不想办法优化,优化前为了确定在哪个楼层扔鸡蛋最优不得不从1遍历到n,这个时间复杂度是O(N)太长了,实际上可以使用二分法来找出最优解,讲O(N)变成O(logN)。

上面已经求出了状态转移方程是max(dp[k-1][i-1], dp[k][n-i]),其中可以知道的是,dp[k-1][i-1]是随着i逐步递增的,因为楼层越高,需要扔的次数肯定也越多。而dp[k][n-i]是跟随i递减的,因子i是负数,i越大,n-i越小,扔的次数也越小。

上面的状态转移方程就是现在所有楼层的最大值中,找到最小值,如图所示:

红色的线表示的是两个子状态中的最大值,两根线的交点正好就是所有最大值中的最小值。那么根据这个规律,就可以把i∈[1, n]的遍历改成二分法来遍历,找到这个最小值。代码如下:

int dp(int k, int n, vector<vector<int>> &v) {
    int i, f = INT_MAX;
    int low, high, mid, broken, not_broken;
    
    if (k == 1) return n;
    if (n == 0) return 0;

    if (v[k][n] != -1) {
        return v[k][n];
    }

    low = 1;
    high = n;
    while (low <= high) {
        mid = (low + high) / 2;
        // 鸡蛋碎了
        broken = dp(k - 1, mid - 1, v);
        // 鸡蛋没碎
        not_broken = dp(k, n - mid, v);
        if (broken > not_broken) {
            high = mid - 1;
            f = min(f, broken + 1);
        } else {
            low = mid + 1;
            f = min(f, not_broken + 1);
        }
    }
    
    v[k][n] = f;
    return f;
}

修改后再测试上面超时的数据,只要4毫秒就可以执行完了,效率提高了几百倍:

再次提交代码,通过了(耗时较高):

把遍历变成二分后,遍历的O(N)变成了O(logN),所以总的时间复杂度是:O(KN*logN)​,空间复杂度是O(KN)。

四、终极动态规划

dp[k][m]表示k个鸡蛋扔m次最多可以确定的多少层的楼,那么第一个大于等于n的下标m就是扔鸡蛋的次数了。到第i层的时候,依旧是有两种策略:

  1. 鸡蛋碎了,此时鸡蛋减1,扔的次数减1,接下来可以检测出来的楼层是dp[k-1][m-1]
  2. 鸡蛋没碎,扔的次数减1,接下来可以检测出来的楼层是dp[k][m-1]

所以第i个鸡蛋能确定的楼层的总数是:dp[k-1][m-1] + dp[k][m-1] + 1,即鸡蛋碎了的时候能确定的楼层数加上鸡蛋没碎的时候能确定的楼层数再加上本层。

代码:

// k是鸡蛋个数,n是楼层,两个都大于等于1
int superEggDrop(int k, int n) {
    int i, j;
    vector<vector<int>> v(k + 1, vector<int>(n + 1, 0));

    for (j = 1; v[k][j - 1] < n; j++) { // 扔鸡蛋的次数
        for (i = 1; i <= k && v[k][j - 1] < n; i++) { // 鸡蛋个数
            v[i][j] = v[i - 1][j - 1] + v[i][j - 1] + 1;
        }
    }
    return j - 1;
}
要注意的地方是循环条件终止条件是v[k][j-1] >= n,为什么是j-1呢,因为第一层for循环的j表示的是第j次扔鸡蛋能确定的层数,此时层数还没有确定,还要在下面的循环中计算得到。如果使用v[k][j]会出现死循环导致数据溢出。

时间复杂度O(K*N),提交后执行用时大大减少:

继续优化

上面已经计算出来状态转移方程是:

$$dp[k][i]=dp[k-1][i-1] + dp[k][i-1] + 1$$

可以看到,当前状态实际上只与前面一列的状态有关,可以只保存前面一列状态的数据,空间复杂度为O(K)。

int superEggDrop(int k, int n) {
    int i, j;
    vector<vector<int>> v(k + 1, vector<int>(2, 0));
    // 第0列作为前一列的备份列,第1列作为当前列
    for (j = 1; v[k][0] < n; j++) {
        for (i = 1; i <= k && v[k][j - 1] < n; i++) {
            v[i][1] = v[i - 1][0] + v[i][0] + 1;
        }

        // 拷贝第1列到第0列
        for (i = 0; i <= k; i++) {
            v[i][0] = v[i][1];
        }
    }
    return j - 1;
}

[leetcode]300-最长上升子序列

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/longest-increasing-subsequence

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

一、题目描述

给定一个无序的整数数组,找到其中最长上升子序列的长度。

示例:

  • 输入: [10,9,2,5,3,7,101,18]
  • 输出: 4
  • 解释: 最长的上升子序列是 [2,3,7,101],它的长度是 4。

说明:

  • 可能会有多种最长上升子序列的组合,你只需要输出对应的长度即可。
  • 你算法的时间复杂度应该为O(n^2)。
进阶:你能将算法的时间复杂度降低到O(nlogn)吗?

二、题解

首先要搞清楚的是题意:求最长上升子序列。子序列不是子串,两者的区别是子序列可以不连续,子串必须连续

例如:[1, 3, 5, 2, 9],最长上升子序列是[1, 3, 5, 9],最长上升子串是[1, 3, 5]。

2.1 暴力法

题目的一个要求是用O(n^2)的时间复杂度来完成这个问题,如果有O(n^2)的时间复杂度,可以直接使用暴力破解的办法:

// 伪代码
for (i = 0; i < size; i++) {
    for (j = i; j < size; j++) {
        // 存在一个上升序列,和加一
        if (nums[j] >= nums[i])
            val[i][j] +=1;
    }
}

算法的时间复杂度和空间复杂度都是O(n^2)。

2.2 动态规划

dp[i]表示到第i个元素时的最大上升子序列长度,那么dp[i]就等于数组中上一个小于5的元素的最大上升子序列长度加1。

以[10,9,2,5,3,7]为例,i=5时如何求dp[i]?此时nums[i]=7,要想构造上升序列,它前面的数字都小于7才行。左边小于7的数字有三个:2、5和3。

  • 在数字1的地方,最大上升子序列的长度是1。
  • 在数字5的地方,最大上升子序列的长度是2;
  • 在数字3的地方,最大上升子序列的长度是2。

因为7大于三个数字,所以到达7的时候最大子序列长度肯定要加1,这个时候只有在上面三个数字中最大的上面加才能保证数字7的子序列最大。因此可以归纳出来的状态转移方程为:

$$dp[i]=max(dp[0], dp[1],..., dp[n]), n<i, nums[n]<=nums[i].$$

使用动态规划的时间复杂度也是O(n^2),空间复杂度O(n)。

2.3 二分法求解

二分法不容易想出来,即使是看题解也是看了几遍才明白。

考虑一个问题:假设存在数组tails,tails[i]表示最大上升子序列长度为i的子序列的末尾元素的最小值,这种情况下tails的长度就是最大子序列的长度了。存不存在这么一个数组呢,有没有办法构造出这样的一个数组?答案是肯定的。

以[10, 9, 2, 5, 7]为例解释tails数组每个元素的意义:

  1. 所有的数字都是长度为1的子序列,其中最小的数字是2,所以tails[1]=2。
  2. 长度为2的子序列有[2, 5], [2, 7]和[5, 7],其中末尾数最小的是5,所以tails[2]=5。
  3. 长度为3的子序列只有[2, 5, 7],末尾数只有一个就是7,所以tails[3]=7。

如果我们能构造出这个数组,那么问题就解决了。那么关键的问题是:如何知道遍历到哪个元素的时候,它的子序列长度是多少?

首先,暂且假设这个数组存在。结合tails数组的特性,可以确定的一个问题是tails[i]肯定大于tails[i-1],tails数组一定严格递增。因为题目求的是上升子序列,而tails数组保存的是每个子序列中的最小值,所以子序列右边的元素肯定大于左边的。

一个要注意的问题是tails[i]表示的是长度为i的子序列的末尾元素的最小值,以[1, 5, 3, 4]为例,最大上升子序列长度为2的有两个:[1, 5]和[1, 3],如果不取最小的末尾元素3,那么tails数组就是[1, 5],当遍历到4的时候,找到第一个大于它的数是5,此时下标等于2,也就是说最大上升子序列长度就是2。而实际上应该是3(子序列[1, 3, 4]),结果不对。

结合这个结论,在判断nums[i]的时候,只要从左到右找到第一个大于它的值的时候(使用二分法查找),这个下标就是它的最大子序列长度了。

这种情况下时间复杂度为O(nlogn)​,空间复杂度为O(n)。

三、代码

3.1 动态规划

int lengthOfLIS_DP(vector<int> &nums) {
    int res = 1;
    size_t i, j;
    vector<int> dp(nums.size(), 1);

    if (nums.empty())
        return 0;

    for (i = 0; i < nums.size(); i++) {
        // 遍历所有比当前数字小的元素
        for (j = 0; j < i; j++) {
            if (nums[j] < nums[i]) {
                // 更新dp数组
                dp[i] = max(dp[i], dp[j] + 1);
                // 更新结果
                res = max(dp[i], res);
            }
        }
    }

    return res;
}

3.2 二分法

通过二分法找到区间内第一个大于目标的元素:

/*
 * 在区间[left, right]找到第一个大于等于目标的元素
 * @v 待查找的数组
 * @left 左边界,包含
 * @right 右边界,包含
 * @return 找到返回元素的下标,否则返回-1
 */
int find_first_ge(vector<int> &v, int left, int right, int target) {
    int mid;
    while (left <= right) {
        mid = (left + right) / 2;
        if (v[mid] >= target) {
            if (mid == left || v[mid - 1] < target)
                return mid;
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return -1;
}

求最长上升子序列:

int lengthOfLIS_BINSEARCH(vector<int> &nums) {
    vector<int> tails(nums.size(), 0);
    int i, idx, max_len;

    if (nums.empty())
        return 0;

    max_len = 0;
    for (i = 0; i < nums.size(); i++) {
        idx = find_first_ge(tails, 0, max_len - 1, nums[i]);
        if (idx < 0) {
            // 没有找到一个比当前元素小的,扩充
            tails[max_len++] = nums[i];
        } else {
            // 找到了,替换掉
            tails[idx] = nums[i];
        }
    }
    return max_len;
}

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/house-robber

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

一、题目描述

你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。

给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。

示例 1:

  • 输入: [1,2,3,1]
  • 输出: 4
  • 解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。

示例 2:

  • 输入: [2,7,9,3,1]
  • 输出: 12
  • 解释: 偷窃 1 号房屋 (金额 = 2), 偷窃 3 号房屋 (金额 = 9),接着偷窃 5 号房屋 (金额 = 1)。偷窃到的最高金额 = 2 + 9 + 1 = 12 。

二、题解

假设当前是第n家房屋,已经偷到了x元。当前有两种选择:偷或者不偷。

  1. 如果偷了,那么说明前面一家房屋是不能偷的,否则会报警。这个时候偷到的金额为x[n - 2] + nums[n]。
  2. 如果不偷,说明前面一家房屋肯定是要偷的,不然少偷了一家。这个时候偷到的金额为x[n - 1]。

两种情况,如何选择?肯定是选择两者中金额更大的,那么就能得到状态转移方程为:

$$dp[i]=max(dp[i - 1], dp[i - 2] + nums[i])$$

因为当前状态只于前面两个状态有关,因此没必要分配dp数组,使用固定的变量来表示就行了(和斐波那契数列一样)。状态转移方程可修改为:

$$amount = max(prev, pprev + nums[n])$$

其中prev是上一个状态的最大值,pprev是上上个状态的最大值,nums[n]是当前第n个房屋的金额。

三、代码

int rob(vector<int> &nums) {
    int max_amount = 0, pprev, prev;
    size_t i;

    if (nums.size() < 1)
        return 0;

    pprev = 0;
    prev = nums[0];
    max_amount = prev;
    for (i = 1; i < nums.size(); i++) {
        max_amount = max(prev, pprev + nums[i]);
        pprev = prev, prev = max_amount;
    }
    return max_amount;
}

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/best-time-to-buy-and-sell-stock

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

一、题目描述

给定一个数组,它的第 i 个元素是一支给定股票第 i 天的价格。

如果你最多只允许完成一笔交易(即买入和卖出一支股票),设计一个算法来计算你所能获取的最大利润。

注意你不能在买入股票前卖出股票。

示例 1:

  • 输入:[7,1,5,3,6,4]
  • 输出:5
  • 解释:在第 2 天(股票价格 = 1)的时候买入,在第 5 天(股票价格 = 6)的时候卖出,最大利润 = 6-1 = 5 。注意利润不能是 7-1 = 6, 因为卖出价格需要大于买入价格。

示例 2

  • 输入:[7,6,4,3,1]
  • 输出:0
  • 解释:这种情况下, 没有交易完成, 所以最大利润为 0。

二、题解

利润最大化的条件:最高的卖出价格 - 最低的买入价格。保存一个截止目前的最低价格,并用当前价格减去这个最低价格,同时保存一份最高的利润,如果当前利润高于最高的利润就更新它。

三、代码

int maxProfit(vector<int> &prices) {
    int min_price = INT_MAX, max_profit = 0;
    for (auto i: prices) {
        // 更新最低价格:当前的最低价格低于之前的最低价格
        if (i < min_price) {
            min_price = i;
        }
        // 更新最大利润:当前的最大利润高于之前的最大利润
        if (i - min_price > max_profit) {
            max_profit = i - min_price;
        }
    }
    return max_profit;
}

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/coin-change

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

一、题目描述

给定不同面额的硬币 coins 和一个总金额 amount。编写一个函数来计算可以凑成总金额所需的最少的硬币个数。如果没有任何一种硬币组合能组成总金额,返回 -1。

示例 1:

  • 输入:coins = [1, 2, 5], amount = 11
  • 输出:3
  • 解释:11 = 5 + 5 + 1

示例 2:

  • 输入:coins = [2], amount = 3
  • 输出:-1

说明:

你可以认为每种硬币的数量是无限的。

二、题解

以示例一种的[1, 2, 5]三种硬币为例,当要凑到11块钱的时候,什么时候硬币个数最小?

只有在凑到11之前的硬币最少,凑够11的硬币才最少。凑到11块一种有三种可能性:

  1. 已经有10块了,再凑1块得到11块。
  2. 已经有9块了,再凑2块得到11块。
  3. 已经有6块了,再凑5块得到11块。

也就是说,手里面当前已经揣了10块、9块或是6块钱,这个时候哪个的硬币数量最少,凑成的11块钱硬币才最少。很明显又是一个动态规划,以dp[i]表示amount=i时候的最优解个数,那么dp[i]的状态转移方程为:

$$\begin{equation} dp(i)= \begin{cases} -1 & amount < 0\\ 0 & amount = 0 \\ min(dp[i-coin1], dp[i-coin2], ...) + 1 & amount > 0\end{cases} \end{equation}$$

三、代码

class Solution {
public:
    int coinChange(vector<int> &coins, int amount) {
        int i, cnt;
        // 初始化为int类型的最大值以方便做特殊处理
        vector<int> dp(amount + 1, INT_MAX);

        if (amount < 0)
            return -1;

        dp[0] = 0;
        for (i = 1; i <= amount; i++) {
            for (auto coin: coins) {
                // dp[i - coin]存在并且有最小值才行
                if (i - coin < 0 || dp[i - coin] == INT_MAX)
                    continue;
                dp[i] = min(dp[i], dp[i - coin] + 1);
            }
        }
        return dp[amount] == INT_MAX ? -1 : dp[amount];
    }
};

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/wildcard-matching

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

一、题目描述

给定一个字符串 (s) 和一个字符模式 (p) ,实现一个支持 '?' 和 '*' 的通配符匹配。

  • '?' 可以匹配任何单个字符。
  • '*' 可以匹配任意字符串(包括空字符串)。

两个字符串完全匹配才算匹配成功。

说明:

  • s 可能为空,且只包含从 a-z 的小写字母。
  • p 可能为空,且只包含从 a-z 的小写字母,以及字符 ? 和 *。

示例 1:

  • 输入:s = "aa", p = "a"
  • 输出:false
  • 解释:"a" 无法匹配 "aa" 整个字符串。

示例 2:

  • 输入:s = "aa", p = "*"
  • 输出: true
  • 解释: '*' 可以匹配任意字符串。

示例 3:

  • 输入:s = "cb", p = "?a"
  • 输出:false
  • 解释:'?' 可以匹配 'c', 但第二个 'a' 无法匹配 'b'。

二、题解

动态规划,使用dp[i][j]表示字符串s的第i个字符模式串p的第j个字符的匹配状况,如果匹配上了设置为true,否则为false。

dp[i][j]何时为true:

  • s[i] == p[j]或者p[j] == '?'的时候,只要两个字符串前面的字符匹配上(也就是dp[i - 1][j - 1] = ture),那么这里的两个字符也匹配上。
  • s[i] == '*'的时候,有两种情况能匹配上:

    1. 字符串s的前面一个字符就已经能匹配上模式串p了,即dp[i - 1][j] = true时,如s=ab, p=ab*
    2. 模式串p的前面一个字符已经匹配上字符串s了,即dp[i][j - 1] = true时,如s=abcd, p=ab*
    3. 注意这里的状态并不是d[i - 1][j - 1]控制的,因为一个*可以匹配多个字符,而?只能匹配一个字符。

初始情况下:

  • dp[0][0] = true:空字符串s和空字符p,匹配上。
  • dp[0][j]:s为空,p不为空,只有p是*时值才为true。
  • dp[i][0]:s不为空,p为空,永远不可能匹配上,都是false。

基于以上几点,就可以写出代码了。

三、代码

bool isMatch(string s, string p) {
    int i, j;
    vector<vector<bool>> dp(s.length() + 1, vector<bool>(p.length() + 1, false));

    // 初始化数组
    dp[0][0] = true;
    for (j = 1; j <= p.length(); j++) {
        if (p[j - 1] == '*')
            dp[0][j] = dp[0][j - 1];
        else
            break;
    }

    for (i = 1; i <= s.length(); i++) {
        for (j = 1; j <= p.length(); j++) {
            if ((s[i - 1] == p[j - 1] || p[j - 1] == '?'))
                dp[i][j] = dp[i - 1][j - 1];
            if (p[j - 1] == '*' && (dp[i - 1][j] || dp[i][j - 1])) {
                dp[i][j] = true;
            }
        }
    }
    return dp[s.length()][p.length()];
}

来源:力扣(LeetCode)

链接:https://leetcode-cn.com/problems/matrix-cells-in-distance-order

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

一、题目描述

给出 R 行 C 列的矩阵,其中的单元格的整数坐标为 (r, c),满足 0 <= r < R 且 0 <= c < C。另外,我们在该矩阵中给出了一个坐标为 (r0, c0) 的单元格。返回矩阵中的所有单元格的坐标,并按到 (r0, c0) 的距离从最小到最大的顺序排。

其中,两单元格(r1, c1) 和 (r2, c2) 之间的距离是曼哈顿距离:|r1 - r2| + |c1 - c2|,你可以按任何满足此条件的顺序返回答案。

示例 1:

  • 输入:R = 1, C = 2, r0 = 0, c0 = 0
  • 输出:[[0,0],[0,1]]
  • 解释:从 (r0, c0) 到其他单元格的距离为:[0,1]

示例 2:

  • 输入:R = 2, C = 2, r0 = 0, c0 = 1
  • 输出:[[0,1],[0,0],[1,1],[1,0]]
  • 解释:从 (r0, c0) 到其他单元格的距离为:[0,1,1,2],[[0,1],[1,1],[0,0],[1,0]] 也会被视作正确答案。

示例 3:

  • 输入:R = 2, C = 3, r0 = 1, c0 = 2
  • 输出:[[1,2],[0,2],[1,1],[0,1],[1,0],[0,0]]
  • 解释:从 (r0, c0) 到其他单元格的距离为:[0,1,1,2,2,3]其他满足题目要求的答案也会被视为正确,例如 [[1,2],[1,1],[0,2],[1,0],[0,1],[0,0]]。

二、题目解析

思路:列出所有的节点并计算和单元格的距离,然后根据距离排序,最后返回所有的节点。

三、代码

不使用map,直接使用vector完成:

题解区看到的一个思路清晰、容易理解的代码。
class Solution {
private:
    static bool sortFunc(vector<int> &a, vector<int> &b) {
        return a[2] < b[2];
    }
public:
    vector<vector<int>> allCellsDistOrder(int R, int C, int r0, int c0) {
        int x, y, i = 0;
        vector<vector<int>> v(R*C, vector<int>(3));
        // 计算所有点的坐标和距离
        for (x = 0; x < R; x++) {
            for (y = 0; y < C; y++) {
                v[i][0] = x;
                v[i][1] = y;
                v[i][2] = abs(r0 - x) + abs(c0 - y); // 计算曼哈顿距离
                i++;
            }
        }
        // 根据曼哈顿距离排序
        sort(v.begin(), v.end(), sortFunc);
        // 弹出曼哈顿距离
        for (i = 0; i < R * C; i++) {
            v[i].pop_back();
        }
        return v;
    }
};