2023年4月

我们是
袋鼠云数栈 UED 团队
,致力于打造优秀的一站式数据中台产品。我们始终保持工匠精神,探索前端道路,为社区积累并传播经验价值。。

本文作者:木杪

有限状态机(FSM)
是计算机科学中的一种数学模型,可用于表示和控制系统的行为。它由一组状态以及定义在这些状态上的转换函数组成。FSM 被广泛用于计算机程序中的状态机制。

有限状态机(FSM)应用场景

  • 在各种自动化系统的应用:
    例如交通信号灯、地铁站的旋转闸门、银行自动取款机等。通过对状态和转换函数的定义,可以实现对系统行为的精确控制。

    交通信号灯状态流转图

    file

    地铁站的旋转闸门状态流转图

    file

    银行自动取款机状态流转图

    file

  • 在编程领域的应用:
    例如在编写编译器和解释器时,可以使用
    有限状态机(FSM)
    来处理词法分析。例如:
    JSON.Parse

  • 在Notion中应用:
    可以使用
    有限状态机(FSM)
    的相关概念来构建各种工作流程,例如状态转换图、状态转换表等。

  • 在web中应用:
    我们熟悉的
    Promise
    也是一个状态机,具有三个状态:pending、resolved。rejected。

    Promise状态流转图

    file

    登录功能流转图

    file

类似这样的状态机的例子数不胜数,甚至于,人也是一种极其复杂的状态机,给定一种刺激或多种刺激组合,也会触发人从某种状态过渡到另一种状态。只不过复杂程度极高,以至于现代科学完全无法解密这种状态机。

有限状态机(FSM)实现原理

具体来说,FSM由以下几部分组成:

  • 初始状态
    :系统的初始状态。
  • 状态集合
    :表示系统可能处于的各种状态。
  • 转移函数
    :定义系统在不同状态之间的转移条件和结果。
  • 终止状态
    :系统在某个状态下可以停止计算。

有限状态机(FSM)
的实现基于
状态转移图

状态转移图
是一个有向图,它表示
有限状态机(FSM)
中状态之间的转移关系。在
状态转移图
中,每个状态表示系统的某种状态,每个转移表示系统从一个状态转移到另一个状态的条件和结果。

实现简易的有限状态机(FSM)

实现步骤

  • 当状态机开始执行时,它会自动进入初始化状态(initial state)。
  • 每个状态都可以定义,在进入(onEnter)或退出(onExit)该状态时发生的行为事件(actions),通常这些行为事件会携带副作用(side effect)。
  • 每个状态都可以定义触发转换(transition)的事件。
  • 转换定义了在退出一个状态并进入另一个状态时,状态机该如何处理这种事件。
  • 在状态转换发生时,可以定义可以触发的行为事件,从而一般用来表达其副作用。

状态转移图

file

function createMachine(stateMachineDefinition) {
  const machine = {
    value: stateMachineDefinition.initialState,
    performTransition(currentState, event) {
      const currentStateDefinition = stateMachineDefinition[currentState];
      const destinationTransition = currentStateDefinition.transitions[event];
      if (!destinationTransition) {
        return;
      }
      const destinationState = destinationTransition.target;
      const destinationStateDefinition =
        stateMachineDefinition[destinationState];

      destinationTransition.action();
      currentStateDefinition.actions.onExit();
      destinationStateDefinition.actions.onEnter();

      machine.value = destinationState;

      return machine.value;
    },
  };
  return machine;
}

const machine = createMachine({
  initialState: "off",
  off: {
    actions: {
      onEnter() {
        console.log("off: onEnter");
      },
      onExit() {
        console.log("off: onExit");
      },
    },
    transitions: {
      switch: {
        target: "on",
        action() {
          console.log('transition action for "switch" in "off" state');
        },
      },
    },
  },
  on: {
    actions: {
      onEnter() {
        console.log("on: onEnter");
      },
      onExit() {
        console.log("on: onExit");
      },
    },
    transitions: {
      switch: {
        target: "off",
        action() {
          console.log('transition action for "switch" in "on" state');
        },
      },
    },
  },
});

let state = machine.value;
console.log(`current state: ${state}`);
state = machine.performTransition(state, "switch");
console.log(`current state: ${state}`);
state = machine.performTransition(state, "switch");
console.log(`current state: ${state}`);

有限状态机(FSM)的 应用实现

在状态比较多的情况下,把状态、事件及
transitions
集中到一个状态机中,进行统一管理。这样不需要写太多的
if-else
,或者
case
判断,如果增加状态和事件,也便于代码的维护和扩展。

文本解析器

实现思路

  • 确定状态和输入
    在编写 FSM 之前,我们需要确定我们的状态和输入。在这个例子中,我们将定义三个状态:起始状态、数字状态和字符串状态。我们还将定义四个输入:数字、字母、引号和空格。
  • 定义状态机类
    现在,我们可以编写代码来实现我们的 FSM 。我们需要定义一个状态机类,它将接受输入,并根据转移规则转换状态。该类应该包含以下属性:
    • currentState
      :当前状态。
    • states
      :状态列表。
    • transitions
      :转移列表。
      它还应该包含以下方法:
    • transition
      :该方法接受一个输入参数
      input
      ,根据当前状态以及输入参数,执行相应的状态转换。
  • 定义转移规则
    我们还需要定义状态之间的转移规则。为此,我们将使用转移列表,其中包含状态之间的映射和输入。转移规则应该考虑当前状态和输入,并根据它们确定下一个状态。如果当前状态和输入没有匹配的转移规则,则应该抛出一个异常。
  • 解析文本
    现在,我们可以使用状态机解析文本。我们需要将文本拆分为单词,并将每个单词作为输入提供给状态机。在处理完所有输入后,我们可以通过调用
    getInputType
    方法来获取解析的令牌。

示例代码

const STATES = {
  START: "start",
  NUMBER: "number",
  STRING: "string",
};

const INPUTS = {
  NUMBER: "number",
  LETTER: "letter",
  SPACE: "space",
  QUOTE: "quote",
};

const TRANSITIONS = [
  {
    currentState: STATES.START,
    input: INPUTS.NUMBER,
    nextState: STATES.NUMBER,
  },
  {
    currentState: STATES.START,
    input: INPUTS.LETTER,
    nextState: STATES.STRING,
  },
  { currentState: STATES.START, input: INPUTS.SPACE, nextState: STATES.START },
  { currentState: STATES.START, input: INPUTS.QUOTE, nextState: STATES.STRING },
  {
    currentState: STATES.NUMBER,
    input: INPUTS.NUMBER,
    nextState: STATES.NUMBER,
  },
  { currentState: STATES.NUMBER, input: INPUTS.SPACE, nextState: STATES.START },
  {
    currentState: STATES.STRING,
    input: INPUTS.LETTER,
    nextState: STATES.STRING,
  },
  { currentState: STATES.STRING, input: INPUTS.SPACE, nextState: STATES.START },
  { currentState: STATES.STRING, input: INPUTS.QUOTE, nextState: STATES.START },
];

class TextParse {
  constructor() {
    this.currentState = STATES.START;
    this.buffer = "";
    this.type;
  }

  performTransition(input) {
    const transition = TRANSITIONS.find(
      (t) => t.currentState === this.currentState && t.input === input.type
    );
    if (!transition)
      throw new Error(
        `Invalid input "${input.value}" for state "${this.currentState}"`
      );

    this.currentState = transition.nextState;

    if (this.currentState === STATES.START) {
      const token = this.buffer;
      const type = this.type;
      this.buffer = "";
      this.type = "";
      return {
        type,
        value: token,
      };
    } else {
      this.buffer += input.value;
      this.type = input.type;
    }
  }
}

function textParse(input) {
  const textParse = new TextParse();
  const tokens = [];

  for (let i = 0; i < input.length; i++) {
    const char = input[i];

    try {
      const token = textParse.performTransition({
        type: getInputType(char),
        value: char,
      });

      if (token) {
        tokens.push(token);
      }
    } catch (e) {
      console.error(e.message);
      return null;
    }
  }

    const lastToken = textParse.performTransition({ type: INPUTS.SPACE });

  if (lastToken) {
    tokens.push(lastToken);
  }

  return tokens;
}

function getInputType(char) {
  if (/[0-9]/.test(char)) {
    return INPUTS.NUMBER;
  } else if (/[a-zA-Z]/.test(char)) {
    return INPUTS.LETTER;
  } else if (/[\s\n\t\r]/.test(char)) {
    return INPUTS.SPACE;
  } else if (char === '"') {
    return INPUTS.QUOTE;
  } else {
    throw new Error(`Unknown input type for "${char}"`);
  }
}

// Example usage:
console.log(textParse('123 abc "def ghi" 456')); 
// [
//   { type: 'number', value: '123' },
//   { type: 'letter', value: 'abc' },
//   { type: 'letter', value: '"def' },
//   { type: 'letter', value: 'ghi' },
//   { type: '', value: '' },
//   { type: 'number', value: '456' }
// ]

示例代码

web 应用

使用
有限状态机(FSM)
结合
React
构建 web 应用,不局限于身份认证,登录,步骤表单,有蛮多 web 应用在
有限状态机(FSM)的实践
,下面主要描述
从有限状态机(FSM)在服务端拉取数据的状态转移上的应用

  • 状态转移图
    file

  • 状态集(States)
    ,
    转换规则(Transitions)

const states = {
  INITIAL: "idle",
  LOADING: "loading",
  SUCCESS: "success",
  FAILURE: "failure",
};
const transitions = {
  [states.INITIAL]: {
    fetch: () => /* Returns states.LOADING */,
  },

  [states.LOADING]: {},

  [states.SUCCESS]: {
    reload: () => /* Returns states.LOADING */,
    clear: () => /* Returns states.INITIAL */,
  },

  [states.FAILURE]: {
    retry: () => /* Returns states.LOADING */,
    clear: () => /* Returns states.INITIAL */,
  },
}

示例代码

总结

结合前端应用的探索体现的不多,可以再作为第二篇内容去探讨,有兴趣的同学可以尝试一下
有限状态机(FSM)
在 web 上的应用探索,以及
Xstate库(FSM封装的功能性库)
的应用,以及跟
状态管理库
差异化的知识。在这里提醒一点,状态管理库
(Redux)

Xstate
并不是互斥的,
Xstate
关注的是如何设计状态,状态管理库关注的是如何管理状态。事实上,状态机几乎可以与任何无主见的状态管理工具一起使用。我鼓励您探索各种方法,以确定最适合您、您的团队和您的应用程序的方法。

参考资料

在使用ArcGIS进行影像、地形等切片时,往往需要保持一致的切片方案才能够更好的加载地图服务。

本文介绍如何获取已经发布好的ArcGIS服务的切片方案xml文件。

当然切片xml文件还可以通过工具
Generate Tile Cache Tiling Scheme
生成,具体操作可参考相关文档,本文不做说明。

示例服务地址:http://localhost:6080/arcgis/rest/services/test/globaltdt5/MapServer

有两种方式获取切片方案xml文件:

1、如果能够直接登录到发布服务的ArcGIS服务器,可以通过服务器对应的文件夹找到conf.xml文件

文件路径(具体路径根据安装情况确定):D:\arcgisserver\directories\arcgiscache\test_globaltdt5\Layers\Conf.xml

2、如果只是提供了一个服务访问地址,无法登录到指定机器,可通过url获取:

http://localhost:6080/arcgis/rest/directories/arcgiscache/test_globaltdt5/Layers/Conf.xml

说明:rest/directories/arcgiscache对应ArcGIS服务器上的arcgiscache目录:

test_globaltdt5为目录+服务名称:

Layers\Conf.xml为服务目录下的固定名称

注意:不建议使用多种不同切片方案,尤其做三维系统,不论是基于cesium还是基于ArcGIS,建议使用ogc标准的切片方案。

目前大多数都是使用以下切片方案,建议使用该方案(ogc_2000_start_0.xml):

<?xml version="1.0" encoding="UTF-8"?>-<TileCacheInfoxmlns:typens="http://www.esri.com/schemas/ArcGIS/10.5"xmlns:xs="http://www.w3.org/2001/XMLSchema"xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:type="typens:TileCacheInfo">-<SpatialReferencexsi:type="typens:GeographicCoordinateSystem">

<WKT>GEOGCS["GCS_China_Geodetic_Coordinate_System_2000",DATUM["D_China_2000",SPHEROID["CGCS2000",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433],AUTHORITY["EPSG",4490]]</WKT>

<XOrigin>-400</XOrigin>

<YOrigin>-400</YOrigin>

<XYScale>11258999068426.24</XYScale>

<ZOrigin>-100000</ZOrigin>

<ZScale>10000</ZScale>

<MOrigin>-100000</MOrigin>

<MScale>10000</MScale>

<XYTolerance>8.9831528411952133e-009</XYTolerance>

<ZTolerance>0.001</ZTolerance>

<MTolerance>0.001</MTolerance>

<HighPrecision>true</HighPrecision>

<LeftLongitude>-180</LeftLongitude>

<WKID>4490</WKID>

<LatestWKID>4490</LatestWKID>

</SpatialReference>-<TileOriginxsi:type="typens:PointN">

<X>-180</X>

<Y>90</Y>

</TileOrigin>

<TileCols>256</TileCols>

<TileRows>256</TileRows>

<DPI>96</DPI>-<LODInfosxsi:type="typens:ArrayOfLODInfo">-<LODInfoxsi:type="typens:LODInfo">

<LevelID>0</LevelID>

<Scale>295497593.05874997</Scale>

<Resolution>0.70312500000000011</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>1</LevelID>

<Scale>147748796.52937579</Scale>

<Resolution>0.35156250000000194</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>2</LevelID>

<Scale>73874398.264687896</Scale>

<Resolution>0.17578125000000097</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>3</LevelID>

<Scale>36937199.132343948</Scale>

<Resolution>0.087890625000000486</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>4</LevelID>

<Scale>18468599.566171981</Scale>

<Resolution>0.043945312500000264</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>5</LevelID>

<Scale>9234299.7830859888</Scale>

<Resolution>0.021972656250000125</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>6</LevelID>

<Scale>4617149.8915429944</Scale>

<Resolution>0.010986328125000062</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>7</LevelID>

<Scale>2308574.9457714972</Scale>

<Resolution>0.0054931640625000312</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>8</LevelID>

<Scale>1154287.4728857479</Scale>

<Resolution>0.0027465820312500139</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>9</LevelID>

<Scale>577143.73644287419</Scale>

<Resolution>0.0013732910156250076</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>10</LevelID>

<Scale>288571.86822143709</Scale>

<Resolution>0.00068664550781250379</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>11</LevelID>

<Scale>144285.9341107186</Scale>

<Resolution>0.00034332275390625206</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>12</LevelID>

<Scale>72142.967055359273</Scale>

<Resolution>0.00017166137695312595</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>13</LevelID>

<Scale>36071.483527679637</Scale>

<Resolution>8.5830688476562974e-005</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>14</LevelID>

<Scale>18035.741763839818</Scale>

<Resolution>4.2915344238281487e-005</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>15</LevelID>

<Scale>9017.8708819199092</Scale>

<Resolution>2.1457672119140744e-005</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>16</LevelID>

<Scale>4508.9354409599546</Scale>

<Resolution>1.0728836059570372e-005</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>17</LevelID>

<Scale>2254.4677204799768</Scale>

<Resolution>5.364418029785185e-006</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>18</LevelID>

<Scale>1127.2338602399891</Scale>

<Resolution>2.6822090148925942e-006</Resolution>

</LODInfo>-<LODInfoxsi:type="typens:LODInfo">

<LevelID>19</LevelID>

<Scale>563.61693011999444</Scale>

<Resolution>1.3411045074462967e-006</Resolution>

</LODInfo>

</LODInfos>

<PreciseDPI>96</PreciseDPI>

</TileCacheInfo>

1. 水平条纹背景

当给背景设置渐变效果时,默认的渐变方向是垂直由上到下的,效果如下:

{
  background: linear-gradient(#aaa, #ddd);
}

image

尝试拉近色标的距离,会发现渐变区域变小了:

{
  background: linear-gradient(#aaa 40%, #ddd 60%);
}

image

当渐变色的色标设置为相同位置时,过渡区域就会变成无限小,看起来的效果就会如下图所示:

{
  background: linear-gradient(#aaa 50%, #ddd 50%);
}

image

然后通过
background-size
来调整他的尺寸,由于默认情况下背景是重复平铺的,所以得到的效果就是填满水平条纹:

{
  background: linear-gradient(#aaa 50%, #ddd 50%);
  background-size: 100% 30px;
}

image

如果某个色标的位置值比整个列表中在它之前的色标的位置值都要小,则该色标的位置值会被设置为它前面所有色标位置值的最大值。因此后面色标的位置可以写成0:

{
  background: linear-gradient(#aaa 50%, #ddd 0);
  background-size: 100% 30px;
}

可以通过修改色标的位置来生成不等宽的条纹:

{
  background: linear-gradient(#aaa 30%, #ddd 0);
  background-size: 100% 30px;
}

{
  background: linear-gradient(#aaa 70%, #ddd 0);
  background-size: 100% 30px;
}

image
image

如果需要多种颜色的条纹,设置多种颜色渐变即可:

{
  background: linear-gradient(#aaa 33.33%, #ddd 0, #ddd 66.66%, #fff 0);
}

image

2. 垂直条纹背景

想要生成垂直方向的条纹,只需修改渐变的方向即可(别忘了把
background-size
颠倒一下):

{
  background: linear-gradient(to right, #aaa 50%, #ddd 0);
  background-size: 30px 100%;
}
/* 或 */
{
  background: linear-gradient(90deg, #aaa 50%, #ddd 0);
  background-size: 30px 100%;
}

image

3. 斜向条纹背景

如果直接修改渐变方向,使其倾斜45°,并且修改
background-size
,会得到下面的效果:

{
  background: linear-gradient(45deg, #aaa 50%, #ddd 0);
  background-size: 30px 30px;
}

image

虽然效果也很好看,但是我们需要的效果是把整个背景旋转45°,而不是把每个小切片旋转45°。仔细观察会发现,想要通过小切片拼接成完整的斜向条纹,只需将每个切片分割为四份。因此需要新增两个色标:

{
  background: linear-gradient(45deg, #aaa 25%, #ddd 0, #ddd 50%, #aaa 0, #aaa 75%, #ddd 0);
  background-size: 30px 30px;
}

image
image

效果实现了,再调整
background-size
,增加条纹宽度:

{
  background: linear-gradient(45deg, #aaa 25%, #ddd 0, #ddd 50%, #aaa 0, #aaa 75%, #ddd 0);
  background-size: 60px 60px;
}

image
image

效果虽然实现,但是条纹的宽度如果想和上面的同样设置为15px,那
background-size
就需要根据勾股定理求出准确的值,此处的结果约为42,因为这个结果不能完全整除,所以只能根据想要的精确度四舍五入取值,因此这种方法不够灵活。如果想要其他倾斜角度的条纹便很难计算
background-size

想要灵活地实现不同角度的条纹,这时候就需要用到
repeating-linear-gradient()
,重复线性渐变。重复线性渐变会循环色标,知道填满整个背景:

{
  background: repeating-linear-gradient(45deg, #aaa, #ddd 30px);
}

image

改写成上面的效果就是:

{
  background: repeating-linear-gradient(45deg, #aaa 0, #aaa 15px, #ddd 0, #ddd 30px);
}

image

只需修改角度便可以得到不同角度的条纹:

{
  background: repeating-linear-gradient(60deg, #aaa 0 15px, #ddd 0 30px);
}

{
  background: repeating-linear-gradient(30deg, #aaa 0 15px, #ddd 0 30px);
}

image
image

4. 附录

MDN linear-gradient
MDN repeating-linear-gradient

本文已收录到
AndroidFamily
,技术和职场问题,请关注公众号 [彭旭锐] 提问。

大家好,我是小彭。

上周末有单双周赛,双周赛我们讲过了,单周赛那天早上有事没参加,后面做了虚拟竞赛,然后整个人就不好了。前 3 题非常简单,但第 4 题有点东西啊,差点就放弃了。最后,被折磨了一个下午和一个大夜总算把第 4 题做出来了,除了新学的 Tarjon 离线算法,这道题还涉及到树上差分、前缀和、DFS、图论等基础知识,几度被折磨得想要放弃。这种感觉,似乎和当年在 LeetCode 上做前 10 题的时候差不多哈哈。

加油吧,没有什么经验是随随便便能够获得的,默默努力,愿君共勉。


周赛大纲

2643. 一最多的行(Easy)

简单模拟题,无需解释。

  • 模拟:$O(nm)$

2644. 找出可整除性得分最大的整数(Easy)

简单模拟题,和 Q1 几乎相同,这场周赛出的不好。

  • 模拟:$O(nm)$

2645. 构造有效字符串的最少插入数(Medium)

中等模拟题,不难。

  • 模拟:$O(n)$

2646. 最小化旅行的价格总和(Hard)

这道题的考点非常多,难度也非常高。先掌握暴力 DFS 的解法,再分析暴力解法中重复计算的环节,最后推出树上差分和离线 Tarjan 算法。这道题非常非常复杂,


2643. 一最多的行(Easy)

题目地址

https://leetcode.cn/problems/row-with-maximum-ones/

题目描述

给你一个大小为
m x n
的二进制矩阵
mat
,请你找出包含最多
1
的行的下标(从
0
开始)以及这一行中
1
的数目。

如果有多行包含最多的 1 ,只需要选择
行下标最小
的那一行。

返回一个由行下标和该行中 1 的数量组成的数组。

题解(模拟)

简单模拟题。

class Solution {
    fun rowAndMaximumOnes(mat: Array<IntArray>): IntArray {
        var maxIndex = 0
        var maxCount = 0
        for (i in 0 until mat.size) {
            var count = 0
            for (j in 0 until mat[0].size) {
                count += mat[i][j]
            }
            if (count > maxCount) {
                maxCount = count
                maxIndex = i
            }
        }
        return intArrayOf(maxIndex, maxCount)
    }
}

复杂度分析:

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


2644. 找出可整除性得分最大的整数(Easy)

题目地址

https://leetcode.cn/problems/find-the-maximum-divisibility-score/

题目描述

给你两个下标从
0
开始的整数数组
nums

divisors

divisors[i]

可整除性得分
等于满足
nums[j]
能被
divisors[i]
整除的下标
j
的数量。

返回
可整除性得分
最大的整数
divisors[i]
。如果有多个整数具有最大得分,则返回数值最小的一个。

题解(模拟)

简单模拟题。

class Solution {
    fun maxDivScore(nums: IntArray, divisors: IntArray): Int {
        var maxDivisor = 0
        var maxCount = -1
        for (divisor in divisors) {
            var count = 0
            for (num in nums) {
                if (num % divisor == 0) count++
            }
            if (count > maxCount || count == maxCount && divisor < maxDivisor) {
                maxDivisor = divisor
                maxCount = count
            }
        }
        return maxDivisor
    }
}

复杂度分析:

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


2645. 构造有效字符串的最少插入数(Medium)

题目地址

https://leetcode.cn/problems/minimum-additions-to-make-valid-string/

题目描述

给你一个字符串
word
,你可以向其中任何位置插入 "a"、"b" 或 "c" 任意次,返回使
word
有效
需要插入的最少字母数。

如果字符串可以由 "abc" 串联多次得到,则认为该字符串
有效

题解(模拟)

维护当前状态与目标状态,当两个状态存在偏差时,插入偏差的字符数。

class Solution {
    fun addMinimum(word: String): Int {
        val n = word.length
        var targetStatus = 0
        var index = 0
        var ret = 0
        while (index < n) {
            // 当前状态
            val curStatus = word[index] - 'a'
            // 插入
            ret += (curStatus + 3 - targetStatus) % 3
            // 目标状态
            targetStatus = (curStatus + 1) % 3
            index++
        }
        ret += when (targetStatus) {
            0 -> 0
            1 -> 2
            2 -> 1
            else -> 0
        }
        return ret
    }
}

复杂度分析:

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


2646. 最小化旅行的价格总和(Hard)

题目地址

https://leetcode.cn/problems/minimize-the-total-price-of-the-trips/

题目描述

现有一棵无向、无根的树,树中有
n
个节点,按从
0

n - 1
编号。给你一个整数
n
和一个长度为
n - 1
的二维整数数组
edges
,其中
edges[i] = [ai, bi]
表示树中节点
ai

bi
之间存在一条边。

每个节点都关联一个价格。给你一个整数数组
price
,其中
price[i]
是第
i
个节点的价格。

给定路径的
价格总和
是该路径上所有节点的价格之和。

另给你一个二维整数数组
trips
,其中
trips[i] = [starti, endi]
表示您从节点
starti
开始第
i
次旅行,并通过任何你喜欢的路径前往节点
endi

在执行第一次旅行之前,你可以选择一些
非相邻节点
并将价格减半。

返回执行所有旅行的最小价格总和。

问题分析

分析 1:题目的数据结构是树而不是图,所以节点之间的最短路是唯一的,不需要使用最短路算法。从节点 start 到节点 end 的最优路径是 start 到最近公共祖先(LCA)+ 最近公共祖先(LCA)到 end;

分析 2:题目可以选择将一些节点的价格减半,显然价格越高的节点越应该减半,或者访问次数越多的节点越应该减半。所以我们可以先对每个 trips[i] 跑一次 DFS,并统计每个节点的访问次数 cnts[i],将每个节点的价格更新为 prices[i] * cnts[i]

分析 3:类似于
337. 打家劫舍 III
,如果我们选择将节点 x 减半(偷窃),那么与 x 相邻的节点便不能减半(偷窃):

  • 如果 prices[x] 减半,那么 x 的最近子节点不能减半;
  • 如果 prices[x] 不变,那么 x 的最近子节点可以减半,也可以不减半,选择两种情况的更优解。

题解一(暴力 DFS)

根据问题分析,我们的算法是:

  • 1、先枚举每种旅途,统计每个节点的访问次数(总共跑 m 次 DFS);
  • 2、更新每个节点的价格权重为 prices[i] * cnts[i];
  • 3、任意选择一个节点为根节点跑一次 DFS,在每一层递归中通过子问题的解得出原问题的解,每个子问题的解有「减半」和「不减半」两种结果;
  • 4、最终,根据根节点的问题求出最终解。
class Solution {
    fun minimumTotalPrice(n: Int, edges: Array<IntArray>, price: IntArray, trips: Array<IntArray>): Int {
        // 建树
        val graph = Array(n) { LinkedList<Int>() }
        for (edge in edges) {
            graph[edge[0]].add(edge[1])
            graph[edge[1]].add(edge[0])
        }
        // 统计节点访问次数
        val cnts = IntArray(n)
        for (trip in trips) {
            cntDfs(graph, cnts, trip[0], trip[1], -1)
        }
        // 更新价格
        for (i in 0 until n) {
            price[i] *= cnts[i]
        }
        // DFS(打家劫舍)
        val ret = priceDfs(graph, price, 0, -1)
        return Math.min(ret[0], ret[1])
    }

    // return:是否找到目标节点
    private fun cntDfs(graph: Array<LinkedList<Int>>, cnts: IntArray, cur: Int, target: Int, parent: Int): Boolean {
        // 终止条件(目标节点)
        if (cur == target) {
            cnts[cur]++
            return true
        }
        // 枚举子节点(树的特性:每个方向最多只会访问一次,不需要使用 visit 数组)
        for (to in graph[cur]) {
            // 避免回环
            if (to == parent) continue
            // 未找到
            if (!cntDfs(graph, cnts, to, target, cur)) continue
            // 找到目标路径,不需要再检查其他方向
            cnts[cur]++
            return true
        }
        return false
    }

    // return:以 cur 为根节点的子树的最大价格 <cur 不变, cur 减半>
    private fun priceDfs(graph: Array<LinkedList<Int>>, price: IntArray, cur: Int, parent: Int): IntArray {
        val ret = intArrayOf(
            price[cur],     // x 不变
            price[cur] / 2 // x 减半
        )
        // 枚举子节点(树的特性:每个方向最多只会访问一次,不需要使用 visit 数组)
        for (to in graph[cur]) {
            // 避免回环
            if (to == parent) continue
            // 子树结果
            val childRet = priceDfs(graph, price, to, cur)
            ret[0] += Math.min(childRet[0], childRet[1])
            ret[1] += childRet[0]
        }
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(nm)$ 其中 m 为 trips 数组的长度,每轮 DFS 的时间是 $O(n)$,计数时间为 $O(nm)$,打家劫舍 DFS 的时间为 $O(n)$;
  • 空间复杂度:$O(n + m)$ 树空间 + DFS 递归栈空间,递归深度最大为 n。

题解一的瓶颈在于 cntDfs 中的 m 次 DFS 搜索,如何优化?

预备知识:差分数组

在 cntDfs 中的每一次 DFS 搜索中,我们需要将 [start, end] 路径上的节点访问次数 +1,这正好类似于在数组上将 [start, end] 区间的位置 + 1,符合 “差分数组” 的应用场景。我们可以在树上做差分,再通过一次 DFS 搜索中计算节点的访问次数。

例如在示例 1 中,我们的路径是 (0, 3),那么路径相当于 [0] + [1,3],针对这两条路径的差分为:

  • [0]:diff[0]++,diff[father[0]] —,即 diff[1] —
  • [1, 3]:diff[3]++,diff[father[1]] —,即 diff[2]—

那怎么计算访问次数呢?跟差分数组一样,对差分数组计算前缀和就可以获得节点的访问次数,我们在归的过程中累加差分值,例如 节点 1 的访问次数就是 +1 + 1 - 1 等于 1 次。

题解二(树上差分 + Tarjan 离线 LCA + DFS)

考虑到旅行路径列表是固定的,我们可以使用 Tarjan 离线算法,预先求出所有旅行路径端点的最近公共祖先。反之,如果旅行路径列表是动态的, 那么离线算法就力不从心了,需要使用复杂度更高的在线算法。

参考资料:

在题解一中,我们需要花费 m 次 DFS 搜索来解决 m 个 LCA 问题,Tarjan 算法的核心思路是在一次 DFS 搜索的过程中解决所有 LCA 查询问题:

  • 1、任选一个点为根节点,从根节点开始。
  • 2、「递」的过程(分解子问题):遍历该点 u 所有子节点 v,并标记这些子节点 v 已被访问过,若是 v 还有子节点,返回 2 继续「递」;
  • 3、「归」的过程:寻找与 u 有查询关系的点 k。如果 k 节点已经被访问过,那么 u 和 k 的最近公共祖先就是当前 u 和 k 所在的分组根节点;
  • 4、
    节点 u 的问题结束后,将 节点 u 合并到父节点的集合上。

细节说明:Tarjan 算法递的过程是寻找查询关系,当路径的两个端点都访问过,那么这两个端点必然处在同一个分组中,而它们的分组根节点正好就是最近公共组件;

细节说明:为什么分组根节点正好就是最近公共组件?因为归是按照 DFS 的搜索顺序回归的;

细节说明:如何合并 v 到 u 的集合上?这是并查集的操作,我们定义 parent[x] 表示 x 节点的所处的分组,初始状态 parent[x] = x;

细节说明:如何查询与 u 有查询关系的点 k?预处理准备映射表;

细节说明:为了区分阶段状态,我们定义 color[x] 表示节点 x 的状态,0 表示未访问、1 表示处于递归栈中,2 表示结束。

更多细节,看代码吧。

class Solution {
    fun minimumTotalPrice(n: Int, edges: Array<IntArray>, price: IntArray, trips: Array<IntArray>): Int {
        // 建树
        val graph = Array(n) { LinkedList<Int>() }
        for (edge in edges) {
            graph[edge[0]].add(edge[1])
            graph[edge[1]].add(edge[0])
        }
        // 查询关系
        val search = Array(n) { LinkedList<Int>() }
        for (trip in trips) {
            search[trip[0]].add(trip[1])
            // 当路径两端相同时,避免重复
            if (trip[0] != trip[1]) search[trip[1]].add(trip[0])
        }
        val unionFind = UnionFind(n, graph, search)
        unionFind.tarjan(0, -1/* 无父节点 */)

        // DFS(打家劫舍)
        val ret = priceDfs(graph, price, unionFind.diff, 0, -1)
        return Math.min(ret[0], ret[1])
    }

    // 并查集
    private class UnionFind(val n: Int, val graph: Array<LinkedList<Int>>, val search: Array<LinkedList<Int>>) {
        // 并查集数据结构
        private val parent = IntArray(n) { it }

        // 树上的父节点
        private val father = IntArray(n)

        // Tarjan 状态
        private val colors = IntArray(n) //  表示未访问、1 表示处于递归栈中,2 表示结束

        // 树上差分
        val diff = IntArray(n)

        private fun find(x: Int): Int {
            // 路径压缩
            if (x != parent[x]) parent[x] = find(parent[x])
            return parent[x]
        }

        // 这道题的合并不能使用按秩合并,必须将子节点 x 合并到 y 的集合中
        private fun merge(x: Int, y: Int) {
            // 按秩合并
            val rootX = find(x)
            val rootY = find(y)
            if (rootX != rootY) parent[rootX] = rootY
        }

        fun tarjan(u: Int, fa: Int) {
            // 记录父节点
            father[u] = fa
            // 标记已访问
            colors[u] = 1
            // 递的过程:遍历 u 的所有子节点 v
            for (v in graph[u]) {
                if (0 != colors[v]) continue // 访问过
                // 继续递的过程
                tarjan(v, u)
            }
            // 枚举查询关系
            for (k in search[u]) {
                if (k == u || colors[k] == 2) {
                    // 找到 u 和 k 的查询关系,更新树上差分
                    val lca = find(k)
                    diff[u]++
                    diff[lca]--
                    diff[k]++
                    val lcaParent = father[lca]
                    if (lcaParent >= 0) diff[lcaParent]--
                }
            }
            // 结束
            colors[u] = 2
            if(fa != -1) merge(u, fa) // 将子节点 u 合并到 fa 的集合中
        }
    }

    // return:以 cur 为根节点的子树的最大价格 <cur 不变, cur 减半>
    private fun priceDfs(graph: Array<LinkedList<Int>>, price: IntArray, diff: IntArray, cur: Int, parent: Int): IntArray {
        val ret = intArrayOf(0, 0, diff[cur])
        // 枚举子节点(树的特性:每个方向最多只会访问一次,不需要使用 visit 数组)
        for (to in graph[cur]) {
            // 避免回环
            if (to == parent) continue
            // 子树结果
            val childRet = priceDfs(graph, price, diff, to, cur)
            ret[0] += Math.min(childRet[0], childRet[1])
            ret[1] += childRet[0]
            ret[2] += childRet[2] // 累加前缀和
        }
        ret[0] += price[cur] * ret[2]
        ret[1] += price[cur] * ret[2] / 2
        return ret
    }
}

复杂度分析:

  • 时间复杂度:$O(n + \alpha m)$ 其中 m 为 trips 数组的长度,$\alpha$ 是并查集的反阿克曼函数,近似于线性函数;
  • 空间复杂度:$O(n + m)$ 树空间 + DFS 递归栈空间,递归深度最大为 n。


前言:

随着4G/5G的发展,无线带宽不断的扩大,数据流量费用不断的降低,使得现在的实时网络视频和视频监控逐渐的普及。

传统的安防项目和车载监控系统都离不开音视频的录制,保存,回放,再加上现在的远程实时视频和远程视频文件调取下载,使得车载终端以及DVR这类设备得以继续的发展。

这里介绍一种使用QT来设计的,适用于安防或是DVR等嵌入式终端使用的控制界面。

说明:

  • QT版本:qt-everywhere-opensource-src-5.9.0
  • qt-creator版本:qt-creator-opensource-linux-x86_64-4.7.0.run
  • 运行设备:HI3520DV300,ARM Cortex A7 @Max. 800MHz,nand flash 128M
  • 编译环境:Ubuntu16.04
  • 交叉编译工具:arm-hisiv300-linux-
  • 测试平台:海思HI3520DV300,Ubuntu16.04

功能介绍:

主要涉及到音视频应用中的:
实时视频预览;录像设置;录像查询;设备状态查询;触发设置;系统设置
六大功能模块。

设计思路:

  1. 由于系统资源的有限性,并且为了软件的更加稳定可靠,将QT界面程序与海思音视频数据实际操作的模块区分开来,分开到两个独立的进程中去实现,海思程序主要处理硬件和海思平台相关的内容,QT程序主要处理界面显示和参数查询设置等内容。它们之间可以通过进程间通信(IPC)来交换信息。
  2. 视频预览和视频回放功能:
    由于在嵌入式设备中,系统资源有限,设备运行能力有限,所以将视频显示类的放到嵌入式设备中去实现。这海思平台,可以直接使用海思的硬件编解码来处理视频图像的显示。
  3. 参数设置和保存:
    视频编码和视频预览存储等等的这些参数,在海思程序中进行保存,QT界面需要显示的时候再去向海思程序查询这些参数的值,这样可以确保查询到的参数与实际使用的参数相同,如果要修改某些参数,也是通过QT界面进行设置,然后再将参数传回海思程序中保存。

详细设计:

  1. 海思程序,在设备上电之后,根据各种参数自动开始检测设备状态,状态正常后自动开始视频录制,存储设备存满之后,根据设置的条件,自动按条件进行覆盖操作。
  2. QT程序,主界面按功能模块分成实时视频预览,录像设置,录像查询,设备状态,触发设置,系统设置这6大模块,实际也就是6个按钮,用户通过鼠标或是触摸屏进行选择操作。
  3. QT程序主界面程序启动后,在后台建立一个线程,用来监听和接收海思模块发送过来的数据和命令(该项目使用的是在IPC队列基础上封装了一层协议的IPCP消息队列)。
  4. QT程序中,每个Qt功能模块就是一个独立的线程,也就是一个子菜单就是一个线程,只有在选中的时候才运行,子菜单关闭线程退出。
  5. QT程序里,各线程各对象之间的通信使用QT的信号和槽函数来实现。

QT界面设计:

主界面菜单参考了刘典武的界面设计,子菜单的界面则全是自己弄的,没有美工,只能说是凑合的将这些功能实现。






代码实现:

完整工程代码目录

biao@ubuntu:~/QT/qt_pro/HST_DVR_GUI$ 
biao@ubuntu:~/QT/qt_pro/HST_DVR_GUI$ tree
.
├── dvrGUI.pro
├── dvrGUI.pro.user
├── GUI_IPCPManager
│   ├── guiIPCManager.cpp
│   └── guiIPCManager.h
├── GUI_UI
│   ├── appinit.cpp
│   ├── appinit.h
│   ├── camaraview.cpp
│   ├── camaraview.h
│   ├── camaraview.ui
│   ├── dvrgui.cpp
│   ├── dvrgui.h
│   ├── dvrgui.ui
│   ├── head.h
│   ├── iconhelper.cpp
│   ├── iconhelper.h
│   ├── keyBoard.cpp
│   ├── keyBoard.h
│   ├── main.cpp
│   ├── main.qrc
│   ├── search.cpp
│   ├── search.h
│   ├── search.ui
│   ├── storage.cpp
│   ├── storage.h
│   ├── storage.ui
│   ├── SysInclude.h
│   ├── system.cpp
│   ├── system.h
│   ├── system.ui
│   ├── trigger.cpp
│   ├── trigger.h
│   ├── trigger.ui
│   ├── users.cpp
│   ├── users.h
│   ├── users.ui
│   ├── video.cpp
│   ├── video.h
│   └── video.ui
├── image
│   ├── DroidSansFallback.ttf
│   ├── fontawesome-webfont.ttf
│   └── main.bmp
├── IPCP_LIB
│   ├── AppDefine.h
│   ├── AppInclude.h
│   ├── HstlibIpcpCommon.h
│   ├── HstlibIpcpInterface.cpp
│   ├── HstlibIpcpInterface.h
│   ├── HstlibIPCPMsgStuct.h
│   ├── SysDebug.h
│   ├── SysDefine.h
│   └── SysInclude.h
└── main.qrc

4 directories, 51 files
biao@ubuntu:~/QT/qt_pro/HST_DVR_GUI$ 

注意事项:

(一)QT运行慢问题

我在海思HI3520DV300设备上运行,当拖动QT界面的时候,CPU使用率会非常的高,但是正常运行的时候基本上不占用CPU,初步定位是当界面刷新的瞬间占用CPU高,不确定是HI3520DV300的处理能力不行还是移植的QT哪里设置不对。

(二)QT图层隐藏问题

在Ubuntu中我们要隐藏QT界面方法有很多,但是有些在海思HI3520中不起作用。在海思中它是按图层来出来,需要隐藏QT图层,其实不需要去设置海思的fb参数,而是直接在QT程序中使用下面的方法三来实现:

/************************************************* 
Function:	 on_btnMsg_pressed  
Description: 右上导航按键
Return: 
Others: 点击关闭不是实际关闭,而是隐藏
	1.注意鼠标的隐藏
	2.鼠标离开了菜单之后失效
	3.setVisible 在海思开发板上不生效
Author: Caibiao Lee
Date:	2019-06-05
*************************************************/
void DVRgui::on_btnMsg_pressed()
{
	  /**方法1**/
//    this->setWindowOpacity(0);
//    this->setAttribute( Qt::WA_TranslucentBackground,true );
//    this->setWindowFlags( Qt::WindowMinimizeButtonHint );
//    exit(0);GuiIPCPSendHeartBeat

	  /**方法2**/
//    this->setVisible(true);
//    this->setHidden(true);

     /**方法3**/
	this->setHidden(true);
	this->setCursor(Qt::BlankCursor);	//隐藏鼠标
}

(三)鼠标问题

要想在海思平台中运行QT,并且支持鼠标控制界面,这个需要配置内核,使内核支持你所使用的鼠标类型。在我使用的HI3520SDK中,内核默认并没有配置鼠标使用的工程。
QT鼠标不支持热拔插,所以在需要在设备启动之前插入鼠标
鼠标正常配置,正常运行的时候,在/dev/input 下可以看到下面设备:

/dev # cd input/
/dev/input # ls
event0  mice    mouse0
/dev/input # 

event0  mice    mouse0 这三个设备,少一个都会出现异常。

(四)字体问题

在QT5中,我们不需要单独的将QT的字体库文件拷贝到ARM设备上,也不需要单独的设置环境变量。将字体库当做QT的资源文件添加到QT工程中去,在程序中加载库文件,这样QTcreater在编译程序的时候就会将QT的字符库文件一起打包到执行文件中去

。我这里使用的字体库是DroidSansFallback.ttf,它支持英文,中文,还有一些特殊图案和符号的显示。

/************************************************************
*Copyright (C), 2017-2027,lcb0281at163.com lcb0281atgmail.com
*FileName: main.cpp
*Date:     2019-06-05
*Author:   Caibiao Lee
*Version:  V1.0
*Description:
*Others:
*History:
***********************************************************/
#include "qapplication.h"
#include "appinit.h"
#include "dvrgui.h"
#include "video.h"
#include "search.h"
#include "storage.h"
#include "users.h"
#include "system.h"
#include "trigger.h"
#include "keyBoard.h"

#include <QFontDatabase>
#include <QDebug>

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);

    QFont iconFont;
    int fontId = QFontDatabase::addApplicationFont(":/image/DroidSansFallback.ttf");
    QStringList fontName = QFontDatabase::applicationFontFamilies(fontId);

    if (fontName.count() > 0) {
        iconFont = QFont(fontName.at(0));
    } else {
       qDebug() << "load DroidSansFallback.ttf error";
    }

    a.setFont(iconFont);

	/**添加虚拟键盘**/
    keyBoard keyBoard;
    keyBoard.hide();


    /**move the windows**/
    AppInit::Instance()->start();

    /**the main UI**/
    DVRgui w;
    w.show();

    return a.exec();
}

(五)主界面图案

主界面上的六个图案并不是手动画上去的,其实它也是字体库中的一个符号,在DroidSansFallback.ttf库中已经实现,它通过数字码找到具体的图案,数字码可以去下面这两个网中查询:

http://fontawesome.dashgame.com
https://fontawesome.com/cheatsheet?from=io

主界面框的设置代码如下:

/************************************************* 
Function:	 initNav  
Description: 初始化选择菜单
Return: none
Others: none
Author: Caibiao Lee
Date:	2019-06-05
*************************************************/
void DVRgui::initNav()
{
    QList<QString> listColorBg;
    listColorBg << "#1570A5" << "#16A085" << "#C0392B" << "#047058" << "#9B59BB" << "#34495E";
    QList<QString> listColorText;
    listColorText << "#FEFEFE" << "#FEFEFE" << "#FEFEFE" << "#FEFEFE" << "#FEFEFE" << "#FEFEFE";

    /**Define QChar Variable**/
    QList<QChar> listChar;
    listChar << 0xf03d << 0xf1c8 << 0xf002 << 0xf030 << 0xf083 << 0xf085;
    QList<QString> listText;
    listText << "视频预览" << "录像设置" << "录像查询" << "设备状态"  << "触发设置" << "系统设置";

    /**Add QToolButton To QList**/
    btns << ui->btnViewMap << ui->btnViewPanel << ui->btnData << ui->btnMap << ui->btnDevice << ui->btnConfig;

    /**set Style Sheet of the QToolButton**/
    for (int i = 0; i < btns.count(); i++) {

        /**Returns the item at index position i in the list.**/
        QToolButton *btn = btns.at(i);
        btn->setToolButtonStyle(Qt::ToolButtonTextUnderIcon);
        btn->setIconSize(QSize(iconWidth, iconHeight));

        QPixmap pix = IconHelper::Instance()->getPixmap(listColorText.at(i), listChar.at(i), iconSize, iconWidth, iconHeight);
        btn->setIcon(QIcon(pix));
        btn->setText(listText.at(i));

        QStringList list;
        list.append(QString("QToolButton{font:%1px;background:%2;}").arg(iconSize / 2.5).arg(listColorBg.at(i)));
        list.append(QString("QToolButton{border:none;border-radius:8px;padding:30px;}"));
        list.append(QString("QToolButton:pressed{background:%1;}").arg("#737A97"));
        btn->setStyleSheet(list.join(""));

        connect(btn, SIGNAL(clicked(bool)), this, SLOT(buttonClicked()));
    }
}

(六)鼠标作用域问题:

在海思平台运行QT程序与在PC及上还是有差异的,在海思平台,QT的鼠标只能在QT窗口范围内有效,如果窗口进行了拖动,或者是人为的移动了窗口位置,但是鼠标的位置没有做相应的移动,当鼠标处于QT窗口外时,鼠标的任何操作QT程序都会检测不到,

同样,鼠标也不能移动。在进行主界面隐藏之后,QT程序是不能够通过鼠标进行唤醒QT程序的,这时只能是通过外部发送一个信号,比如海思设备端的按键信号,触摸屏的信号,当QT接收到信号之后,再在其绑定的槽函数中将主界面拉回画面中。

工程获取


liwen01
公众号中回复
QT
获取工程代码,本章代码工程名为:
hisi_dvr_gui.rar

---------------------------End---------------------------
长按识别二维码
关注 liwen01 公众号