window使用fdk-aac库编码音频数据

项目背景介绍

本篇基于之前的一直准备工作,做好了fdk-aac编译集成到ffmpeg,以及之前用ffmpeg写好的音频pcm重采样。结合起来,在我的MediaPush项目基础上。编码aac音频数据。后面会结合视频的会h264一起做各种的视频推流技术,比如rtmp推流等。

如果有编译库的问题,或者使用qt采集音频pcm,重采样问题,可以翻看我之前的博客和视频注意,了解相关基础支持

环境

  • VS 版本: Visual Studio Professional 2022 (64 位)
  • QT 版本: 5.12.0
  • c++ 语言
  • ffmpeg3.4 window编译的动态库
  • fdk-aac v0.1.6,编译后集成到ffmpeg
  • 其他 x264库,是本项目的备用,前面有介绍,咱与本篇博客无关

开发过程

编写 AudioCapture.h 的属性

这个类是之前我写的使用qt采集系统音频pcm的类。现在要做编码,所以要做一些修改,在里面,我一个重采样器。还增加一了一段pcm重组的代码

c++ 复制代码
#pragma once
#include <QAudioInput>
#include <QIODevice>
#include <QAudio>
#include <stdio.h>
#include <QDebug>
#include <QWidget>
#include <QPaintEvent>
#include "SwrResample.h"

#define WRITE_RAW_PCM_FILE

class RenderArea : public QWidget
{
    Q_OBJECT

public:
    explicit RenderArea(QWidget* parent = nullptr);

    void setLevel(qreal value);

protected:
    void paintEvent(QPaintEvent* event) override;

private:
    qreal m_level = 0;
    QPixmap m_pixmap;
};

struct TFormat {
    int sample_rate;
    int chanel_layout;
    AVSampleFormat sample_fmt;
};

class AudioCapture : public QIODevice
{
    Q_OBJECT
public:
	AudioCapture()
	{
      
        //QAudioFormat format;
        //format.setSampleRate(44100); // 采样率
        //format.setChannelCount(2);   // 单声道
        //format.setSampleSize(16);    // 采样大小
        //format.setCodec("audio/pcm");
        //format.setByteOrder(QAudioFormat::LittleEndian);
        //format.setSampleType(QAudioFormat::SignedInt); 
	}
    ~AudioCapture();

public:

    inline void OpenWrite() { write_flag = true; }
    inline void CloseWrite() { write_flag = false; }
    void Start(const QAudioDeviceInfo& micInfo);

    void Stop();

    qint64 readData(char* data, qint64 maxlen) override
    {
        Q_UNUSED(data)
            Q_UNUSED(maxlen)
            return 0; // 不实际从设备中读取数据,因为我们处理的是输入数据
    }

    qint64 writeData(const char* data, qint64 len) override;

    void CaculateLevel(const char* data, qint64 len);

    qreal level() const { return m_level; }

    TFormat& format()
    {
        return dst_format;
    }

signals:
    void aframeAvailable(const char* data, qint64 len);
    void updateLevel();

private:
    QAudioInput* audioInput = nullptr;

    QAudioFormat m_pFormat;
    quint32 m_maxAmplitude = 0;
    qreal m_level = 0.0; // 0.0 <= m_level <= 1.0

    SwrResample* m_pSwr = nullptr;

    TFormat dst_format;

    const int nb_sample = 1024;  // 取1024个采样,主要是方便后面的aac编码
    int nb_sample_size = 0; //nb_sample个采样对应的字节数,要计算

    char* src_swr_data =  nullptr;
    int nb_swr_remain = 0;

    char* dst_swr_data = nullptr;

    bool write_flag = false;
#ifdef WRITE_RAW_PCM_FILE
    FILE* out_raw_pcm_file;
#endif

};
  • SwrResample 类型的对象就是pcm重采样,为什么要重采样,因为编码器是有限制的,很多的fmt格式不支持,我们在做音频的时候,我们一般也是要对数据做一次统一,这样方便后面的处理。当然尽量不要做太大的修改,以免造成音质的损失
  • nb_sample 是一个固定值 1024。 acc编码一般可以采样一次编码1024个采样,所以这里后面输出到编码器的数据,每次都是1024个采样,当然我们设备采集不一定是这个长度,虽有中间有个数据重组的过程
  • nb_sample_size 是nb_sample个采样对象的字节数。因为不同的channel,fmt。1024个采样,需要多少个字节存储是不同的
  • src_swr_data 是个中间数据,存放我把设备采集的数据,做重组后的数据存储
  • nb_swr_remain 是重组过程中,当前剩余数据,后面重组代码逻辑,才能理解这个参数
  • dst_swr_data 是经过SwrResmaple重采样的数据。可以送入编码器的数据
  • TFormat 实例对象 dst_format 是我自定义的结构,存放送入编码器的数据的格式,方便aac编码器的初始化
  • out_raw_pcm_file 是存储设备采集的原始数据,方便验证采集出来的pcm数据是否有问题

编写 AudioCapture.cpp 的重要方法

采集器器启动方法

在原来简单采集的基础上,我们要做一些初始化,比如存储数据的空间分配。一些数据长度的计算。这些都要根据设备最终采集出来的格式去计算

c++ 复制代码
void AudioCapture::Start(const QAudioDeviceInfo& micInfo)
{

#ifdef WRITE_RAW_PCM_FILE
    out_raw_pcm_file = fopen("capture_raw.pcm", "wb");
    if (!out_raw_pcm_file) {
        std::cout << "open out put ra file failed";
    }
#endif // WRITE_RESAMPLE_PCM_FILE

    m_pFormat = micInfo.preferredFormat();

    AVSampleFormat sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_S16;

    switch (m_pFormat.sampleSize()) {
    case 8:
        switch (m_pFormat.sampleType()) {
        case QAudioFormat::UnSignedInt:
            m_maxAmplitude = 255;
            sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_U8;
            break;
        case QAudioFormat::SignedInt:
            m_maxAmplitude = 127;
            sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_U8;
            break;
        default:
            break;
        }
        break;
    case 16:
        switch (m_pFormat.sampleType()) {
        case QAudioFormat::UnSignedInt:
            m_maxAmplitude = 65535;
            break;
        case QAudioFormat::SignedInt:
            m_maxAmplitude = 32767;
            sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_S16;
            break;
        default:
            break;
        }
        break;

    case 32:
        switch (m_pFormat.sampleType()) {
        case QAudioFormat::UnSignedInt:
            m_maxAmplitude = 0xffffffff;
            break;
        case QAudioFormat::SignedInt:
            m_maxAmplitude = 0x7fffffff;
            sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_S32;
            break;
        case QAudioFormat::Float:
            m_maxAmplitude = 0x7fffffff; // Kind of
            sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_FLT;
        default:
            break;
        }
        break;

    default:
        break;
    }

    if (!micInfo.isFormatSupported(m_pFormat)) {
        QMessageBox::warning(nullptr, tr("Audio Capture Error"), "format is not support");
        return;
    }

    qDebug() << "default devicdName: " << micInfo.deviceName() << " : ";
    qDebug() << "sameple: " << m_pFormat.sampleRate() << " : ";
    qDebug() << "channel:  " << m_pFormat.channelCount() << " : ";
    qDebug() << "fmt: " << m_pFormat.sampleSize() << " : ";
    qDebug() << "bytesPerFrame" << m_pFormat.bytesPerFrame();

    

    m_pSwr = new SwrResample();

    //目标和源采样数据格式
    int channel_count= m_pFormat.channelCount();
    int64_t src_ch_layout = (channel_count == 1 ? AV_CH_LAYOUT_MONO : AV_CH_LAYOUT_STEREO);
    int64_t dst_ch_layout = src_ch_layout;

    int src_rate = m_pFormat.sampleRate();
    int dst_rate = src_rate;

    AVSampleFormat src_sample_fmt = sample_fmt;
    AVSampleFormat dst_sample_fmt = AV_SAMPLE_FMT_S16;

    dst_format.chanel_layout = dst_ch_layout;
    dst_format.sample_fmt = dst_sample_fmt;
    dst_format.sample_rate = dst_rate;

    nb_sample_size = m_pFormat.channelCount() * (m_pFormat.sampleSize() / 8) * nb_sample;
    src_swr_data = new char[nb_sample_size];

    m_pSwr->Init(src_ch_layout, dst_ch_layout, src_rate, dst_rate, src_sample_fmt, dst_sample_fmt, nb_sample);

    int dst_nb_sample_size = m_pSwr->GetDstNbSample() * (m_pFormat.sampleSize() / 8) * channel_count;
    dst_swr_data = new char[dst_nb_sample_size];

    audioInput = new QAudioInput(micInfo, m_pFormat);
    //int64_t cbuffSize = audioInput->bufferSize();
    //int64_t bufferSize = m_pFormat.bytesForDuration(1000*1000); // 将 10 毫秒转换为字节数
    //audioInput->setBufferSize(bufferSize);
    
    audioInput->start(this);

    this->open(QIODevice::WriteOnly);

}
  • 首先上面的switch (m_pFormat.sampleSize()) { 里面通过qt采集器的一些格式,我们计算得到采集出的pcm会使用的格式 sample_fmt 的值。这里其实不太准确,我查过资料,好像没有很好的办法严格计算,我觉得这是qt做得不够精细的地方,自己如果对pcm理解能力够的,可以通过一些播放测试,和采集出来长度推测出采集的smaple fmt。也就是一个采样数据是多少bit来存储。在别的平台,这个可能会简单一些。

    在下面有些qDebug()的打印,方便去分析采集出来的原始pcm的格式

  • 然后创建封装好的 SwrResample 。对里面的源格式,目标格式进行赋值

nb_sample_size的计算就是 声道数*(bit/8)* 采样数,这里指定采样数位1024 dst_nb_sample_size的计算是一样的,也是1024个采样的数据长度

采集过程方法

这里就是告诉执行采集的地方,有qt里面调用上来,会很频繁,data就是采集的数据,len就是长度

c++ 复制代码
qint64 AudioCapture::writeData(const char* data, qint64 len)
{
    // 在这里处理音频数据,例如保存到文件、进行处理等
    //qDebug() << "Received audio data. Size:" << len;
    CaculateLevel(data,len);


    
    if (write_flag)
    {
        if (nb_swr_remain + len < nb_sample_size)
        {
            memcpy(src_swr_data + nb_swr_remain, data, len);
            nb_swr_remain += len;
        }
        else
        {
            int out_size = nb_swr_remain + len - nb_sample_size;
            memcpy(src_swr_data+ nb_swr_remain, data, len - out_size);

#ifdef WRITE_RAW_PCM_FILE
            fwrite(src_swr_data, 1, nb_sample_size, out_raw_pcm_file);
#endif

            //消费数据
            m_pSwr->WriteInput(src_swr_data, nb_sample_size);
            int rlen = m_pSwr->SwrConvert(dst_swr_data);
            emit aframeAvailable(dst_swr_data, rlen);

            //重新取一个开始
            nb_swr_remain = out_size;
            if (out_size > 0)
            {
                  memcpy(src_swr_data, data + (len- out_size), out_size);
            }
        }
        
    }

    return len;
}
  • CaculateLevel 延续上次讲过的,就是音频活动量检测程序

  • write_flag是个标志,启动采集数据的处理标志

  • 接下来就是数据重组,我们采集出来的原始数据长度不重要,甚至是可变的都没有关系,我们把它转成nb_sample_size长度一段一段的。

    这是怎么计算的呢,就是创建一个nb_sample_size长度的字节数组 src_swr_data。往里面放数据,当放的数据没有达到nb_sample_size长度的时候,就继续放。一直到超过或者等于nb_sample_size长度,那么这个时候,我们就消费src_swr_data数据 :

    scss 复制代码
     m_pSwr->WriteInput(src_swr_data, nb_sample_size);

    当然这个时候,可能还有多余的数据,那么这个数据就继续存到src_swr_data里面

    c++ 复制代码
       //重新取一个开始
       nb_swr_remain = out_size;
       if (out_size > 0)
       {
             memcpy(src_swr_data, data + (len- out_size), out_size);
       }

    等待下一次的调用,nb_swr_remain就是一直记录的当前src_swr_data有的数据。

过程入上图解释。

  • 然后就是重采样
c++ 复制代码
 m_pSwr->WriteInput(src_swr_data, nb_sample_size);
 int rlen = m_pSwr->SwrConvert(dst_swr_data);
 emit aframeAvailable(dst_swr_data, rlen);

dst_swr_data 通过信号槽发送出去

  • 后面就是把未消费的数据,继续存储,下次整合使用

m_pSwr是我封装好了,之前有讲过,只是做了少许调整。这里不在做解释,可以自己下载代码了解

编写 aac编码过程

初始化编码器

在 AudioCapture采集器创建好后,就初始化编码器

c++ 复制代码
void MediaPushWindow::start()
{
	ui.actionStart->setEnabled(false);
	ui.actionStop->setEnabled(true);
	ui.actionSettings->setEnabled(false);

	start_flag = true;

	m_mic->OpenWrite();
	aacEncoder.reset(new AacEncoder());
	InitAudioEncode();
	
c++ 复制代码
void MediaPushWindow::InitAudioEncode()
{
	int sample_rate = m_mic->format().sample_rate;
	int channel_layout = m_mic->format().chanel_layout;
	AVSampleFormat smaple_fmt = m_mic->format().sample_fmt;
	aacEncoder->InitEncode(sample_rate, 96000, smaple_fmt, channel_layout);
}

aacEncoder编码器使用的参数,采样率,采样格式,通道数,来自于 m_mic 里面的dst_format,这个之前说过,自己定义的格式,存储的就是重采样的目标格式。

编码器的初始化实现过程

AacEncoder.h AacEncoder.cpp里面就是aac编码的实现过程

由于是使用ffmpeg集成的方式,所以基本流程和视频编码过程差不多。下面贴一下头文件的代码

c++ 复制代码
#pragma once
extern "C" {
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libswresample/swresample.h>
#include <libavutil/avutil.h>
#include <libavutil/opt.h>
}
#define WRITE_CAPTURE_AAC

class AacEncoder
{
public:
	AacEncoder();
	~AacEncoder();

	int InitEncode(int sample_rate, int bit_rate, AVSampleFormat sample_fmt,int chanel_layout);
	int Encode(const char* src_buf, int src_len, unsigned char* dst_buf);
	int StopEncode();

	/* check that a given sample format is supported by the encoder */
	static int check_sample_fmt(const AVCodec* codec, enum AVSampleFormat sample_fmt)
	{
		const enum AVSampleFormat* p = codec->sample_fmts;

		while (*p != AV_SAMPLE_FMT_NONE) {
			if (*p == sample_fmt)
				return 1;
			p++;
		}
		return 0;
	}

	/* just pick the highest supported samplerate */
	static int check_sample_rate(const AVCodec* codec,int sample_rate)
	{
		const int* p;

		if (!codec->supported_samplerates)
			return 0;

		p = codec->supported_samplerates;
		while (*p) {
			if (*p == sample_rate)
				return 1;
			p++;
		}
		return 0;
	}

	/* select layout with the highest channel count */
	static int select_channel_layout(const AVCodec* codec)
	{
		const uint64_t* p;
		uint64_t best_ch_layout = 0;
		int best_nb_channels = 0;

		if (!codec->channel_layouts)
			return AV_CH_LAYOUT_STEREO;

		p = codec->channel_layouts;
		while (*p) {
			int nb_channels = av_get_channel_layout_nb_channels(*p);

			if (nb_channels > best_nb_channels) {
				best_ch_layout = *p;
				best_nb_channels = nb_channels;
			}
			p++;
		}
		return best_ch_layout;
	}

private:
	AVPacket* pkt = nullptr;
	AVFrame* frame = nullptr;
	AVCodecContext* audioCodecCtx = nullptr;
#ifdef WRITE_CAPTURE_AAC
	FILE* aac_out_file = nullptr;
#endif // WRITE_CAPTURE_YUV
};

依然是那个配方,只是多了两个方法check_sample_fmt,check_sample_rate。这是检查指定的参数ffmpeg是否支持。select_channel_layout暂时没有,备用把

  • 继续看 InitEncode 的实现
ini 复制代码
int AacEncoder::InitEncode(int sample_rate, int bit_rate, AVSampleFormat sample_fmt, int chanel_layout)
{
	//avcodec_register_all();
	av_register_all();

	const AVCodec* codec = nullptr;
	while ((codec = av_codec_next(codec))) {
		if (codec->encode2 && codec->type == AVMediaType::AVMEDIA_TYPE_AUDIO) {
			qDebug() << "Codec Name :" << codec->name;
			qDebug() << "Type: " << av_get_media_type_string(codec->type);
			qDebug() << "Description: " << (codec->long_name ? codec->long_name : codec->name);
			qDebug() << "---";
		}
	}

	
	/* find the MP2 encoder */
	codec = avcodec_find_encoder_by_name("libfdk_aac");
	//codec = avcodec_find_encoder(AV_CODEC_ID_AAC);
	if (!codec) {
		fprintf(stderr, "Codec not found\n");
		exit(1);
	}

	qDebug() << "codec name: " << codec->name;
	qDebug() << "codec long name: " << codec->long_name;



	const enum AVSampleFormat* p = codec->sample_fmts;
	while (*p != AV_SAMPLE_FMT_NONE) {
		qDebug() << "supoort codec fmt : " << av_get_sample_fmt_name(*p);
		p++;
	}
	

	audioCodecCtx = avcodec_alloc_context3(codec);
	if (!audioCodecCtx) {
		fprintf(stderr, "Could not allocate audio codec context\n");
		exit(1);
	}


	//打印看到只支持 AV_SAMPLE_FMT_S16,所以这里写死
	


	audioCodecCtx->sample_rate = sample_rate;
	audioCodecCtx->channel_layout = chanel_layout;
	audioCodecCtx->channels = av_get_channel_layout_nb_channels(audioCodecCtx->channel_layout);
	audioCodecCtx->sample_fmt = sample_fmt;
	audioCodecCtx->bit_rate = bit_rate;

	//检查是否支持fmt
	if (!check_sample_fmt(codec, audioCodecCtx->sample_fmt)) {
		//fprintf(stderr, "Encoder does not support sample format %s",av_get_sample_fmt_name(audioCodecCtx->sample_fmt));
		qDebug() << "Encoder does not support sample format " << av_get_sample_fmt_name(audioCodecCtx->sample_fmt);
		exit(1);
	}

	if (!check_sample_rate(codec, audioCodecCtx->sample_rate)) {
		//fprintf(stderr, "Encoder does not support sample format %s",av_get_sample_fmt_name(audioCodecCtx->sample_fmt));
		qDebug() << "Encoder does not support sample rate " << audioCodecCtx->sample_rate;
		exit(1);
	}



	/* open it */
	if (avcodec_open2(audioCodecCtx, codec, NULL) < 0) {
		fprintf(stderr, "Could not open codec\n");
		exit(1);
	}

	pkt = av_packet_alloc();
	if (!pkt) {
		fprintf(stderr, "could not allocate the packet\n");
		exit(1);
	}

	/* frame containing input raw audio */
	frame = av_frame_alloc();
	if (!frame) {
		fprintf(stderr, "Could not allocate audio frame\n");
		exit(1);
	}

	frame->nb_samples = audioCodecCtx->frame_size;
	frame->format = audioCodecCtx->sample_fmt;
	frame->channel_layout = audioCodecCtx->channel_layout;

	/* allocate the data buffers */
	int ret = av_frame_get_buffer(frame, 0);
	if (ret < 0) {
		fprintf(stderr, "Could not allocate audio data buffers\n");
		exit(1);
	}

#ifdef WRITE_CAPTURE_AAC
	if (!aac_out_file) {
		aac_out_file = fopen("ouput.aac", "wb");
		if (aac_out_file == nullptr)
		{

		}
	}
#endif // WRITE_CAPTURE_AAC

	return 0;
}

这里使用 avcodec_find_encoder_by_name 直接根据编码器名称来找到编码器,也可以通过id,但是ffmpeg一个编码,可能有多个编码器支持。我这里直接指定 了fdk-aac。这个名称,上面遍历的时候可以打印,在ffmpeg编译的是 ,里面也会支持的编码器名称。

frame 是编码前数据,也就是pcm帧。赋给对应的参数,就可以自动通过 av_frame_get_buffer分配空间。注意里面的nb_samples就是我说的1024个采样。可以理解为音频每帧就是这么多个采样。虽然音频的帧其实就是个长度的区别。长度打了,那么编码时间就长了,延迟就大了,长度小了,编码效率低了。1024刚刚好。音视频,永远是平衡。

音频编码过程

采集重组的数据通过信号槽,发送到这里,然后送入编码器Encode方法

arduino 复制代码
void MediaPushWindow::recvAFrame(const char* data, qint64 len)
{
	if (aacEncoder)
	{
	   aacEncoder->Encode(data, len, nullptr);
	}
}

音频编码实现过程

c++ 复制代码
int AacEncoder::Encode(const char* src_buf, int src_len, unsigned char* dst_buf)
{
	int planar = av_sample_fmt_is_planar(audioCodecCtx->sample_fmt);
	if (planar)
	{
		// 我编码用的非planer结构
	}
	else
	{
		memcpy(frame->data[0], src_buf, src_len);
	}

	

	int ret;

	/* send the frame for encoding */
	ret = avcodec_send_frame(audioCodecCtx, frame);
	if (ret < 0) {
		fprintf(stderr, "Error sending the frame to the encoder\n");
		exit(1);
	}

	/* read all the available output packets (in general there may be any
	 * number of them */
	while (ret >= 0) {
		ret = avcodec_receive_packet(audioCodecCtx, pkt);
		if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF)
			return 0;
		else if (ret < 0) {
			fprintf(stderr, "Error encoding audio frame\n");
			exit(1);
		}
#ifdef WRITE_CAPTURE_AAC
		fwrite(pkt->data, 1, pkt->size, aac_out_file);
#endif
		av_packet_unref(pkt);
	}
	return 0;
}

这里事情就简单,还是我们熟悉的avcodec_send_frame avcodec_receive_packet 过程。 这里有点区别的地方,就是判断了一下 planar格式 。由于我们指定的fmt。所以我们是知道目标的planar格式的。

objectivec 复制代码
enum AVSampleFormat {
    AV_SAMPLE_FMT_NONE = -1,
    AV_SAMPLE_FMT_U8,          ///< unsigned 8 bits
    AV_SAMPLE_FMT_S16,         ///< signed 16 bits
    AV_SAMPLE_FMT_S32,         ///< signed 32 bits
    AV_SAMPLE_FMT_FLT,         ///< float
    AV_SAMPLE_FMT_DBL,         ///< double

    AV_SAMPLE_FMT_U8P,         ///< unsigned 8 bits, planar
    AV_SAMPLE_FMT_S16P,        ///< signed 16 bits, planar
    AV_SAMPLE_FMT_S32P,        ///< signed 32 bits, planar
    AV_SAMPLE_FMT_FLTP,        ///< float, planar
    AV_SAMPLE_FMT_DBLP,        ///< double, planar
    AV_SAMPLE_FMT_S64,         ///< signed 64 bits
    AV_SAMPLE_FMT_S64P,        ///< signed 64 bits, planar

    AV_SAMPLE_FMT_NB           ///< Number of sample formats. DO NOT USE if linking dynamically
};

这是ffmpeg的源码的注释,一眼就可以看出来那些是palnar的结构。这里都用非planar就够,也有个好处,就是memcpy直接就可以拷贝 所有数据。不用交错的去处理。

编码出来的aac数据,目前先直接写文件

arduino 复制代码
#ifdef WRITE_CAPTURE_AAC
		fwrite(pkt->data, 1, pkt->size, aac_out_file);
#endif

vlc播放aac了。

总结

音频的处理,难点就是pcm的转换和重组,注意一帧的长度。还有就是palnar格式注意。其他的和视频一致

7. 其他:

相关推荐
Java资深爱好者3 小时前
如何在std::map中查找元素
开发语言·c++
安步当歌6 小时前
【FFmpeg】av_write_trailer函数
c语言·c++·ffmpeg·视频编解码·video-codec
shuguang258007 小时前
C++ 函数高级——函数重载——基本语法
开发语言·c++·visualstudio
抽风侠7 小时前
C++左值右值
开发语言·c++
DogDaoDao8 小时前
LeetCode 算法:二叉树中的最大路径和 c++
c++·算法·leetcode·二叉树·二叉树路径
添砖JAVA的小墨8 小时前
C++ 如何解决回调地狱问题
c++
且行且知8 小时前
C++课程期末复习全集
开发语言·c++·算法
誰能久伴不乏8 小时前
Qt 绘图详解
开发语言·c++·qt
K-Liberty9 小时前
VTK- 面绘制&体绘制
c++·图像处理·数据可视化
我们的五年9 小时前
【算法:贪心】:贪心算法介绍+基础题(四个步骤);柠檬水找零(交换论证法)
c语言·数据结构·c++·算法·贪心算法